TL;DR
- Ethereum L2s matured in 2024–2025. Fees dropped sharply after EIP‑4844, tooling is excellent, and main stacks (Arbitrum Nitro/Orbit, OP Stack, ZK Stack, Starknet Cairo) are stable for production.
- You can deploy to Arbitrum with the exact same Solidity you use on L1. It’s still EVM, just cheaper and faster.
- Optimistic vs ZK rollups isn’t theology. Optimistic gives instant UX and a 7‑day exit window, ZK gives provable finality but more complex proving infra.
- If you only read one section, read the Step‑by‑step deploy to Arbitrum and the L2 comparison table.
Why this post
I started pushing more of my side projects off L1 when blobs landed and my wallet stopped crying. Between mentoring teams at hackathons and maintaining a slightly feral homelab, I’ve deployed the same set of contracts across Arbitrum, Base, OP Mainnet, zkSync Era, Starknet and Scroll to gauge real DX. This post is the distilled version: practical steps, a plain‑English mental model of rollups, and a map of the L2 landscape as it stands today.
Audience: engineers who’ve shipped at least a couple Solidity contracts, comfy with Node and CLI. If you’re brand new to smart contracts, you can still follow along, but I won’t re‑explain uint256
.
Part 1 — Deploying a contract to Arbitrum (mainnet or Sepolia)
Prerequisites
- Node.js 18+ and pnpm/npm
- A wallet private key (funded on Arbitrum One for mainnet, or Arbitrum Sepolia for testnet)
- An RPC endpoint. Public RPCs work, but for serious work grab a provider key.
- Hardhat or Foundry. I show both.
Tip: public Arbitrum RPCs are HTTP only. If your app expects WebSockets, use a provider that offers them or switch to polling.
Option A — Hardhat quick deploy
- Scaffold
mkdir l2-hello && cd l2-hello
npm init -y
npm i -D hardhat @nomicfoundation/hardhat-toolbox @nomicfoundation/hardhat-ethers ethers dotenv
npx hardhat init --typescript
- Add a simple contract
contracts/Counter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Counter {
uint256 public value;
event Bump(uint256 newValue);
function inc(uint256 by) external {
value += by;
emit Bump(value);
}
}
- Configure networks
hardhat.config.ts
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import * as dotenv from "dotenv";
dotenv.config();
const config: HardhatUserConfig = {
solidity: "0.8.24",
networks: {
// Arbitrum One (mainnet)
arbitrum: {
url: process.env.ARBITRUM_RPC || "https://arb1.arbitrum.io/rpc",
chainId: 42161,
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : []
},
// Arbitrum Sepolia (testnet)
arbitrumSepolia: {
url: process.env.ARBITRUM_SEPOLIA_RPC || "https://sepolia-rollup.arbitrum.io/rpc",
chainId: 421614,
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : []
}
},
etherscan: {
// Arbiscan supports verification via Etherscan API keys
apiKey: process.env.ARBISCAN_API_KEY || process.env.ETHERSCAN_API_KEY
}
};
export default config;
- Deploy script
scripts/deploy.ts
import { ethers } from "hardhat";
async function main() {
const Counter = await ethers.getContractFactory("Counter");
const counter = await Counter.deploy();
await counter.waitForDeployment();
console.log("Counter deployed to:", await counter.getAddress());
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
- Run it
# Testnet first
ARBITRUM_SEPOLIA_RPC=<your_rpc> PRIVATE_KEY=<0x...> npx hardhat run --network arbitrumSepolia scripts/deploy.ts
# Then mainnet when ready
ARBITRUM_RPC=<your_rpc> PRIVATE_KEY=<0x...> npx hardhat run --network arbitrum scripts/deploy.ts
- Verify on Arbiscan
ETHERSCAN_API_KEY=<your_key> npx hardhat verify --network arbitrumSepolia <DEPLOYED_ADDRESS>
# or on mainnet
ETHERSCAN_API_KEY=<your_key> npx hardhat verify --network arbitrum <DEPLOYED_ADDRESS>
Gotcha: If verification fails, re‑compile with the exact same compiler version and settings used at deploy, then try again. For multi‑file projects ensure the full metadata is available. For Stylus contracts there’s a dedicated flow.
Option B — Foundry deploy
- Install & init
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge init l2-counter && cd l2-counter
- Contract
src/Counter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Counter {
uint256 public value;
event Bump(uint256 newValue);
function inc(uint256 by) external {
value += by;
emit Bump(value);
}
}
- Broadcast script
script/Deploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Script, console} from "forge-std/Script.sol";
import {Counter} from "src/Counter.sol";
contract Deploy is Script {
function run() external {
uint256 pk = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(pk);
Counter c = new Counter();
vm.stopBroadcast();
console.log("Counter deployed:", address(c));
}
}
- Deploy
# Testnet
forge script script/Deploy.s.sol \
--rpc-url $ARBITRUM_SEPOLIA_RPC \
--broadcast --verify \
--verifier etherscan --etherscan-api-key $ETHERSCAN_API_KEY
# Mainnet
forge script script/Deploy.s.sol \
--rpc-url $ARBITRUM_RPC \
--broadcast --verify \
--verifier etherscan --etherscan-api-key $ETHERSCAN_API_KEY
Tip: On public RPCs you may hit rate limits. For CI/CD I use provider endpoints with retries +
--with-gas-price <gwei>
if base fee spikes.
Test ETH and bridging
- Arbitrum Sepolia ETH: grab from any reputable faucet, or bridge from Ethereum Sepolia via the official bridge.
- Withdrawals from Arbitrum One or Nova to L1 have a roughly one‑week challenge window. Use third‑party bridges if you need instant liquidity and can tolerate extra trust/cost.
Part 2 — Rollups in plain English
The model
A rollup splits work in two:
-
Execution happens on the L2. You bundle lots of user transactions cheaply.
-
Data availability (DA) and dispute/validity happen on L1. You post the transaction data so anyone can reconstruct state. Then either:
- Optimistic: assume it’s valid unless someone proves otherwise within a window (fraud/fault proofs).
- ZK: provide a succinct proof that the batch is valid; L1 verifies the math.
EIP‑4844 added blob space with its own fee market so L2s can post data way cheaper than calldata. That’s why your swaps feel nearly free these days.
Finality and exits
- On optimistic L2s, you usually wait about a week to finalize withdrawals to L1. Apps often use liquidity bridges to make this instant at the UX layer.
- On ZK L2s, once the validity proof is verified on L1, the state is final. Prover latency can vary but keeps improving.
Sequencers and decentralisation
- Today most L2s run a single active sequencer per chain for UX and MEV control. That’s changing. Roadmaps across stacks include permissionless validation and multi‑operator sequencing. You should architect apps to be neutral to who orders your txs.
Part 3 — Arbitrum in 2025: what you actually get
- Nitro execution with full EVM equivalence. Your Solidity just works.
- Stylus lets you write contracts in Rust, C and C++ compiled to WASM, interoperable with EVM. Great for crypto‑heavy code or when gas micro‑optimisations matter.
- AnyTrust (Nova) for ultra‑low fees via a DA committee; Rollup mode (One) for pure L1 DA.
- BoLD upgrades the dispute game to permissionless validation across Arbitrum chains.
- Orbit to launch your own L2/L3 with pick‑your‑own gas token, DA, and throughput.
If you’re building general‑purpose dApps, deploy on Arbitrum One first. If you’re doing high‑volume socials/games with thin margins, evaluate Nova. If you’re an enterprise team and want custom levers, look at Orbit.
Part 4 — L2 comparison in a page
Snapshot as of September 2025. Things move. Always check docs before you ship.
Chain | Type | EVM level | Proofs/Validation | Exit to L1 | Notables |
---|---|---|---|---|---|
Arbitrum One | Optimistic (Rollup) | EVM‑equivalent | Interactive fault proofs; BoLD enables permissionless validation | ~7 days | Stylus (Rust/C/C++ to WASM), mature DeFi/liquidity, Orbit chains |
Arbitrum Nova | Optimistic (AnyTrust) | EVM‑equivalent | Fault proofs; DA via committee | ~7 days | Ultra‑cheap txs, great for gaming/social |
OP Mainnet | Optimistic (OP Stack) | EVM‑equivalent | Permissionless Fault Proofs live | ~7 days | Superchain vision; easy app portability across OP chains |
Base | Optimistic (OP Stack) | EVM‑equivalent | Fault proof infra via OP Stack; rapidly decentralising ops | ~7 days | Big retail funnel, excellent infra, OP‑compatible tooling |
zkSync Era | ZK rollup | EVM‑compatible (Era VM) | Validity proofs; native account abstraction | Minutes to hours (proof) | ZK Stack chains with native interop |
Starknet | ZK rollup | Cairo 1 (not EVM) | Validity proofs | Minutes to hours (proof) | Powerful proving, fast pre‑confirms, decentralising sequencer |
Scroll / Linea | ZK rollups | EVM‑equivalent | Validity proofs | Minutes to hours | Strong EVM parity; good for porting L1 code |
How I choose:
- Need drop‑in EVM, big liquidity, and sane fees? Arbitrum One or Base.
- Need instant UX across multiple OP chains? OP Stack gets you there with shared standards.
- Need protocol‑level AA and ZK proofs? zkSync Era.
- Need Cairo performance primitives? Starknet.
Part 5 — Production checklist (hard‑won notes)
- RPCs: don’t ship on free RPCs. Rate limits will bite at the worst time.
- Gas configs: let providers suggest fees but cap the maximum so a blob price spike doesn’t nuke your budget during airdrops.
- Bridging UX: if you support L1 exits on optimistic rollups, integrate a reputable fast bridge or at least communicate the delay clearly in UI copy.
- Monitoring: track both L2 tx status and L1 posting/proofs. Users care about “done‑done.”
- Verifiers: wire contract verification into CI so your addresses and commit SHAs are recorded and public minutes after deploy.
- Key management: same as L1. Use deployer roles and delayed timelocks. Don’t YOLO admin keys because it’s “just L2.”
- Cross‑chain: if you’re on multiple L2s, design around message passing latencies and reorg boundaries. Don’t assume synchronous miracles.
A — Minimal Hardhat + Foundry repo layout
.
├── contracts/Counter.sol
├── scripts/deploy.ts
├── hardhat.config.ts
├── .env (PRIVATE_KEY, ARBITRUM_RPC, ARBITRUM_SEPOLIA_RPC, ETHERSCAN_API_KEY)
└── foundry (optional)
├── src/Counter.sol
└── script/Deploy.s.sol
B — Common errors and fixes
insufficient funds for gas * price + value
on testnet: you’re on L1 Sepolia not Arbitrum Sepolia, or vice versa. Check chain ID 421614.- Verification fails: wrong compiler version or missing constructor args. Rebuild with the exact compiler and settings.
- “replacement fee too low”: you tried to speed up with a small tip. On L2, either wait or bump more aggressively.
- Event not showing in subgraph: confirm your indexer supports that L2 and the correct chain ID.
- WebSocket connect errors: public Arbitrum RPCs are HTTP only; switch provider or poll.