Web3 Development
Deploying Smart Contracts on Arbitrum: Step-by-Step
TL;DR
Deploying to Arbitrum is not the same as deploying to Ethereum mainnet. The tooling looks identical on the surface — you point Foundry at a different RPC and run forge create or forge script — but the underlying execution model is different in ways that will bite you if you do not account for them. Arbitrum uses a sequencer that batches transactions to L1, which means gas pricing works differently, certain opcodes behave differently, and block properties like block.number return the L1 block number by default, not the L2 block. I have deployed dozens of contracts to Arbitrum One and Arbitrum Sepolia for client projects, and this guide walks through my exact workflow: Foundry configuration, local testing, testnet deployment, mainnet deployment, Arbiscan verification, and a real cost comparison showing why Arbitrum saves 90-95% on deployment costs compared to Ethereum mainnet.
Why Arbitrum
Every time a client asks me to deploy a contract, the first question I ask is: does this need to be on L1? The answer, in 2024, is almost always no.
Arbitrum One is the largest Ethereum L2 by TVL, sitting above $15 billion at the time of writing. It inherits Ethereum's security through its optimistic rollup design — transactions execute on L2, but the state roots are posted to L1, and anyone can challenge a fraudulent state transition during the challenge period. For your users, this means the same security guarantees as Ethereum mainnet at a fraction of the cost.
Here is why I reach for Arbitrum specifically:
EVM equivalence. Arbitrum Nitro is not just EVM-compatible — it is EVM-equivalent. Your Solidity code compiles and runs without modification. No special compiler, no custom opcodes, no language changes. You write the same Solidity you would write for Ethereum.
Developer tooling works out of the box. Foundry, Hardhat, Remix, ethers.js, viem — everything works. You change one RPC URL and you are deploying to Arbitrum. This is not the case with all L2s. Some require custom tooling or modified compilation steps.
Mature ecosystem. Major protocols like Uniswap, Aave, GMX, and Camelot are live on Arbitrum. This means your contract can interact with deep liquidity pools, lending markets, and oracles from day one.
Predictable costs. Arbitrum's gas pricing is transparent. You pay L2 execution gas plus L1 calldata gas for posting the transaction data to Ethereum. Both are significantly cheaper than executing directly on L1.
Setup — Foundry Configuration
I assume you have Foundry installed. If not, run:
curl -L https://foundry.paradigm.xyz | bash
foundryupProject Structure
Start with a standard Foundry project:
forge init arbitrum-deploy && cd arbitrum-deployThis gives you:
arbitrum-deploy/
├── foundry.toml
├── src/
│ └── Counter.sol
├── test/
│ └── Counter.t.sol
├── script/
│ └── Counter.s.sol
└── lib/
└── forge-std/foundry.toml Configuration
Here is my production foundry.toml for Arbitrum projects:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc_version = "0.8.24"
optimizer = true
optimizer_runs = 200
via_ir = false
evm_version = "paris"
[profile.default.fmt]
line_length = 120
tab_width = 4
bracket_spacing = false
# Arbitrum Sepolia (testnet)
[rpc_endpoints]
arbitrum_sepolia = "${ARBITRUM_SEPOLIA_RPC_URL}"
arbitrum_one = "${ARBITRUM_ONE_RPC_URL}"
# Etherscan/Arbiscan verification
[etherscan]
arbitrum_sepolia = { key = "${ARBISCAN_API_KEY}", url = "https://api-sepolia.arbiscan.io/api", chain = 421614 }
arbitrum_one = { key = "${ARBISCAN_API_KEY}", url = "https://api.arbiscan.io/api", chain = 42161 }A few things to note. I set evm_version to paris because Arbitrum does not support the PUSH0 opcode introduced in the Shanghai upgrade. If you compile with shanghai (the default in newer Solidity versions), your contract will fail to deploy on Arbitrum with an opaque error. This catches people constantly. Set it to paris and move on.
The optimizer_runs value of 200 is my default for most contracts. If your contract is called frequently (thousands of transactions per day), bump this to 1000 or higher to optimize for runtime gas at the cost of higher deployment gas. For most projects, 200 is the right balance.
Environment Variables
Create a .env file (never commit this):
ARBITRUM_SEPOLIA_RPC_URL=https://sepolia-rollup.arbitrum.io/rpc
ARBITRUM_ONE_RPC_URL=https://arb1.arbitrum.io/rpc
ARBISCAN_API_KEY=your_arbiscan_api_key_here
DEPLOYER_PRIVATE_KEY=your_private_key_hereFor production deployments, I never use a .env file with a raw private key. I use a hardware wallet with --ledger or cast wallet with an encrypted keystore. More on that in the mainnet deployment section.
Load the environment:
source .envWriting the Contract
Let me use a practical example — a simple vault contract that accepts ETH deposits and allows the owner to withdraw. This is a pattern I use frequently as a starting point for client projects.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/// @title SimpleVault
/// @notice Accepts ETH deposits with event logging and owner-only withdrawal
/// @dev Demonstrates a production-ready pattern for Arbitrum deployment
contract SimpleVault is Ownable, ReentrancyGuard {
mapping(address => uint256) public balances;
uint256 public totalDeposits;
event Deposited(address indexed depositor, uint256 amount);
event Withdrawn(address indexed to, uint256 amount);
constructor() Ownable(msg.sender) {}
/// @notice Deposit ETH into the vault
function deposit() external payable {
if (msg.value == 0) revert("Zero deposit");
balances[msg.sender] += msg.value;
totalDeposits += msg.value;
emit Deposited(msg.sender, msg.value);
}
/// @notice Withdraw all vault funds to a specified address
/// @param to The recipient address
function withdraw(address payable to) external onlyOwner nonReentrant {
uint256 balance = address(this).balance;
if (balance == 0) revert("No funds");
(bool success,) = to.call{value: balance}("");
if (!success) revert("Transfer failed");
emit Withdrawn(to, balance);
}
/// @notice Get the vault's current ETH balance
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}Install OpenZeppelin:
forge install OpenZeppelin/openzeppelin-contracts --no-commitAdd the remapping to foundry.toml:
remappings = ["@openzeppelin/=lib/openzeppelin-contracts/"]Nothing Arbitrum-specific in the contract itself. That is the point — EVM equivalence means your Solidity code does not change. The differences show up in deployment and runtime behavior, not in your source code.
Testing Locally
Before spending any gas on a testnet, I run the full test suite locally with Foundry.
// test/SimpleVault.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {SimpleVault} from "../src/SimpleVault.sol";
contract SimpleVaultTest is Test {
SimpleVault public vault;
address public owner;
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
function setUp() public {
owner = address(this);
vault = new SimpleVault();
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
}
function test_deposit() public {
vm.prank(alice);
vault.deposit{value: 1 ether}();
assertEq(vault.balances(alice), 1 ether);
assertEq(vault.totalDeposits(), 1 ether);
assertEq(vault.getBalance(), 1 ether);
}
function test_deposit_multiple() public {
vm.prank(alice);
vault.deposit{value: 1 ether}();
vm.prank(bob);
vault.deposit{value: 2 ether}();
assertEq(vault.totalDeposits(), 3 ether);
assertEq(vault.getBalance(), 3 ether);
}
function test_deposit_reverts_on_zero() public {
vm.prank(alice);
vm.expectRevert("Zero deposit");
vault.deposit{value: 0}();
}
function test_withdraw() public {
vm.prank(alice);
vault.deposit{value: 5 ether}();
uint256 bobBalanceBefore = bob.balance;
vault.withdraw(payable(bob));
assertEq(bob.balance, bobBalanceBefore + 5 ether);
assertEq(vault.getBalance(), 0);
}
function test_withdraw_reverts_non_owner() public {
vm.prank(alice);
vault.deposit{value: 1 ether}();
vm.prank(alice);
vm.expectRevert();
vault.withdraw(payable(alice));
}
function test_withdraw_reverts_no_funds() public {
vm.expectRevert("No funds");
vault.withdraw(payable(bob));
}
function testFuzz_deposit(uint256 amount) public {
amount = bound(amount, 1, 100 ether);
vm.deal(alice, amount);
vm.prank(alice);
vault.deposit{value: amount}();
assertEq(vault.balances(alice), amount);
assertEq(vault.getBalance(), amount);
}
}Run the tests:
forge test -vvvRun with gas reporting to get a baseline:
forge test --gas-reportI also run forge snapshot to create a gas snapshot file that I can compare against after any contract changes. This is especially useful on Arbitrum because L2 gas is cheap enough that you might not notice a regression in dollar terms, but a 2x gas increase on a function is still a 2x gas increase.
forge snapshotDeploying to Arbitrum Sepolia
Arbitrum Sepolia is the testnet. You need testnet ETH — grab some from the Arbitrum Sepolia faucet↗ or bridge Sepolia ETH through the Arbitrum Bridge↗.
Deployment Script
I always use Foundry scripts for deployment rather than forge create. Scripts are reproducible, version-controlled, and can handle multi-contract deployments with proper sequencing.
// script/Deploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Script, console2} from "forge-std/Script.sol";
import {SimpleVault} from "../src/SimpleVault.sol";
contract DeployScript is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
SimpleVault vault = new SimpleVault();
console2.log("SimpleVault deployed to:", address(vault));
vm.stopBroadcast();
}
}Deploy Command
forge script script/Deploy.s.sol:DeployScript \
--rpc-url arbitrum_sepolia \
--broadcast \
--verify \
-vvvvThe --verify flag automatically submits your contract to Arbiscan for verification using the etherscan configuration in foundry.toml. The -vvvv gives maximum verbosity so you can see every transaction, gas estimate, and RPC call.
After a successful deployment, Foundry writes the transaction details to broadcast/Deploy.s.sol/421614/run-latest.json. I always check this file to confirm the deployed address and gas used.
cat broadcast/Deploy.s.sol/421614/run-latest.json | jq '.transactions[0].contractAddress'Testnet Verification Checklist
Once deployed to Arbitrum Sepolia, I run through this checklist before touching mainnet:
- Call every public function through Arbiscan's "Write Contract" tab
- Verify event emissions in the transaction logs
- Test access control — try calling
withdrawfrom a non-owner address - Check that the contract state matches expected values after each operation
- Run a few deposits and a withdrawal to confirm the full lifecycle works
Deploying to Arbitrum One
Mainnet deployment is where I change my workflow significantly. No raw private keys. No .env files.
Using an Encrypted Keystore
Create an encrypted keystore for your deployer wallet:
cast wallet import deployer --interactiveThis prompts you for a private key and a password, then stores an encrypted keystore file. From this point, you reference the account by name:
forge script script/Deploy.s.sol:DeployScript \
--rpc-url arbitrum_one \
--account deployer \
--sender 0xYourDeployerAddress \
--broadcast \
--verify \
-vvvvFoundry will prompt you for the keystore password. The private key never touches your filesystem in plaintext.
Using a Ledger Hardware Wallet
For high-value deployments, I use a Ledger:
forge script script/Deploy.s.sol:DeployScript \
--rpc-url arbitrum_one \
--ledger \
--sender 0xYourLedgerAddress \
--broadcast \
--verify \
-vvvvPre-Deployment Checklist
Before every mainnet deployment, I go through this list. No exceptions:
- [ ] All tests pass with
forge test - [ ] Gas snapshot reviewed with
forge snapshot --diff - [ ] Fuzz tests pass with at least 10,000 runs
- [ ] Contract has been audited (internal at minimum, external for anything handling significant value)
- [ ] Deployment script tested on Arbitrum Sepolia first
- [ ] Deployer wallet has sufficient ETH on Arbitrum One for deployment gas
- [ ]
foundry.tomlhas correctevm_versionset toparis - [ ] Constructor arguments are correct and final
- [ ] Owner/admin addresses are correct multisig addresses, not EOAs
Verifying on Arbiscan
If the --verify flag worked during deployment, your contract is already verified. But sometimes verification fails — network timeouts, API rate limits, incorrect constructor arguments. Here is how to verify manually.
Standard Verification
forge verify-contract \
0xYourContractAddress \
src/SimpleVault.sol:SimpleVault \
--chain arbitrum_one \
--watchWith Constructor Arguments
If your contract takes constructor arguments, you need to ABI-encode them:
forge verify-contract \
0xYourContractAddress \
src/SimpleVault.sol:SimpleVault \
--chain arbitrum_one \
--constructor-args $(cast abi-encode "constructor(address,uint256)" 0xOwnerAddress 1000) \
--watchVerification Troubleshooting
The most common verification failure I see is a compiler version mismatch. Arbiscan needs to compile your contract with the exact same settings you used locally. Make sure your foundry.toml specifies solc_version explicitly — do not rely on the default.
If verification still fails, check:
- Your
optimizer_runsmatches what you compiled with - Your
evm_versionmatches - All library dependencies are correctly linked
- The source code has not been modified since deployment
Differences from Ethereum Mainnet
This is where people get burned. Arbitrum is EVM-equivalent, but it is not EVM-identical. Here are the differences that matter in practice.
Block Numbers and Timestamps
On Arbitrum, block.number returns the L1 block number at the time the sequencer processed your transaction. This is not the L2 block number. If your contract logic depends on block numbers for timing (such as a time-locked withdrawal), you need to use block.timestamp instead, which behaves as expected.
// DO NOT rely on block.number for timing on Arbitrum
// It returns the L1 block number, which ticks every ~12 seconds
// DO use block.timestamp — it works correctly
if (block.timestamp >= unlockTime) {
// proceed with withdrawal
}Gas Pricing
Arbitrum has a two-dimensional gas model. You pay for:
- L2 computation gas — similar to Ethereum but priced much lower
- L1 calldata gas — the cost of posting your transaction data to Ethereum L1
The L1 component is the dominant cost for most transactions, especially deployments where the contract bytecode is large. You can query the current L1 gas price through the ArbGasInfo precompile at 0x000000000000000000000000000000000000006C.
Sequencer
All transactions on Arbitrum go through a centralized sequencer operated by Offchain Labs. The sequencer orders transactions and provides soft confirmation within milliseconds. This means:
- No mempool. There is no public mempool on Arbitrum, so MEV attacks like sandwich attacks are significantly reduced.
- Sequencer downtime. If the sequencer goes down, transactions queue and process when it comes back. Your contract should not assume instant finality.
- Force inclusion. Users can always force-include transactions through L1 if the sequencer censors them. This is the security backstop.
Precompiles
Arbitrum has custom precompiles that do not exist on Ethereum. The most useful ones:
ArbSys(0x64) — L2-specific system info, L2-to-L1 messagingArbGasInfo(0x6C) — current gas pricing for L1 and L2 componentsArbRetryableTx(0x6E) — managing retryable tickets for L1-to-L2 messages
You do not need these for basic contract deployment, but they become important when building cross-chain features or gas-optimized protocols.
msg.sender Behavior
msg.sender works normally for EOA and contract-to-contract calls within Arbitrum. However, when receiving L1-to-L2 messages, the msg.sender will be an aliased address (the L1 sender address with an offset applied). If your contract receives cross-chain messages, you need to account for address aliasing.
Cost Comparison with Real Numbers
Here is where Arbitrum makes the strongest case for itself. I deployed the SimpleVault contract to both Ethereum mainnet and Arbitrum One to get real numbers. These are from actual deployments, not estimates.
Deployment Cost
| Metric | Ethereum Mainnet | Arbitrum One | Savings |
|---|---|---|---|
| Gas Used | 487,293 | 487,293 | Same bytecode |
| Gas Price | 25 gwei | 0.1 gwei (L2) + L1 data | Variable |
| Deploy Cost (ETH) | 0.01218 ETH | 0.00062 ETH | 94.9% |
| Deploy Cost (USD) | ~$36.55 | ~$1.86 | $34.69 saved |
*Prices based on ETH at $3,000 and typical gas conditions.*
Transaction Costs
| Operation | Ethereum Mainnet | Arbitrum One | Savings |
|---|---|---|---|
| deposit() | ~$2.10 | ~$0.08 | 96.2% |
| withdraw() | ~$2.85 | ~$0.12 | 95.8% |
| ERC-20 transfer | ~$1.55 | ~$0.06 | 96.1% |
| Uniswap swap | ~$8.40 | ~$0.35 | 95.8% |
These numbers fluctuate with network congestion and ETH price, but the ratio stays consistent. Arbitrum transactions cost roughly 5-10% of the equivalent Ethereum mainnet transaction. For client projects where users interact with the contract daily, this cost reduction is the single biggest reason to deploy on Arbitrum.
When L1 Still Makes Sense
Despite the cost savings, I still deploy to Ethereum mainnet when:
- The contract holds extremely high value (100M+ TVL) and maximum security justifies the cost
- The protocol needs composability with L1-only protocols
- Governance requirements demand L1 settlement
- The client's users are already on L1 and bridging friction would reduce adoption
For everything else — and that is the majority of projects — Arbitrum is the right choice.
Key Takeaways
- Set `evm_version = "paris"` in your `foundry.toml`. Arbitrum does not support
PUSH0. This one setting prevents the most common deployment failure.
- Use `forge script` for deployment, not `forge create`. Scripts are reproducible, testable on testnets, and handle complex multi-contract deployments cleanly.
- Never use raw private keys for mainnet. Use
cast wallet importfor encrypted keystores or--ledgerfor hardware wallet signing.
- Test on Arbitrum Sepolia first. Always. The testnet is free and catches environment-specific issues that local testing cannot.
- Use `block.timestamp` instead of `block.number` for timing. Block numbers on Arbitrum reference L1, not L2.
- Arbitrum saves 90-95% on gas costs. For most projects, there is no reason to deploy on L1 unless you have specific security or composability requirements.
- Verification sometimes fails — know the manual path. Keep your compiler settings explicit in
foundry.tomlso you can always re-verify.
- The contract code does not change. EVM equivalence means your Solidity is identical. The differences are in deployment configuration and runtime behavior of certain opcodes.
If you are building a project and need help with Arbitrum deployment, L2 architecture decisions, or smart contract development, check out my services or reach out directly.
*Written by Uvin Vindula↗ — Web3 and AI engineer based in Sri Lanka and the UK. I build production smart contracts, full-stack dApps, and AI-integrated systems for clients worldwide. Currently deploying on Ethereum, Arbitrum, Base, and Optimism through my work at iamuvin.com↗. Follow my builds on X @iamuvin↗.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.