IAMUVIN

Web3 Development

Deploying Smart Contracts on Arbitrum: Step-by-Step

Uvin Vindula·March 11, 2024·9 min read
Share

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:

bash
curl -L https://foundry.paradigm.xyz | bash
foundryup

Project Structure

Start with a standard Foundry project:

bash
forge init arbitrum-deploy && cd arbitrum-deploy

This 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:

toml
[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):

bash
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_here

For 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:

bash
source .env

Writing 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.

solidity
// 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:

bash
forge install OpenZeppelin/openzeppelin-contracts --no-commit

Add the remapping to foundry.toml:

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.

solidity
// 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:

bash
forge test -vvv

Run with gas reporting to get a baseline:

bash
forge test --gas-report

I 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.

bash
forge snapshot

Deploying 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.

solidity
// 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

bash
forge script script/Deploy.s.sol:DeployScript \
    --rpc-url arbitrum_sepolia \
    --broadcast \
    --verify \
    -vvvv

The --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.

bash
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:

  1. Call every public function through Arbiscan's "Write Contract" tab
  2. Verify event emissions in the transaction logs
  3. Test access control — try calling withdraw from a non-owner address
  4. Check that the contract state matches expected values after each operation
  5. 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:

bash
cast wallet import deployer --interactive

This prompts you for a private key and a password, then stores an encrypted keystore file. From this point, you reference the account by name:

bash
forge script script/Deploy.s.sol:DeployScript \
    --rpc-url arbitrum_one \
    --account deployer \
    --sender 0xYourDeployerAddress \
    --broadcast \
    --verify \
    -vvvv

Foundry 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:

bash
forge script script/Deploy.s.sol:DeployScript \
    --rpc-url arbitrum_one \
    --ledger \
    --sender 0xYourLedgerAddress \
    --broadcast \
    --verify \
    -vvvv

Pre-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.toml has correct evm_version set to paris
  • [ ] 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

bash
forge verify-contract \
    0xYourContractAddress \
    src/SimpleVault.sol:SimpleVault \
    --chain arbitrum_one \
    --watch

With Constructor Arguments

If your contract takes constructor arguments, you need to ABI-encode them:

bash
forge verify-contract \
    0xYourContractAddress \
    src/SimpleVault.sol:SimpleVault \
    --chain arbitrum_one \
    --constructor-args $(cast abi-encode "constructor(address,uint256)" 0xOwnerAddress 1000) \
    --watch

Verification 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:

  1. Your optimizer_runs matches what you compiled with
  2. Your evm_version matches
  3. All library dependencies are correctly linked
  4. 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.

solidity
// 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:

  1. L2 computation gas — similar to Ethereum but priced much lower
  2. 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 messaging
  • ArbGasInfo (0x6C) — current gas pricing for L1 and L2 components
  • ArbRetryableTx (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

MetricEthereum MainnetArbitrum OneSavings
Gas Used487,293487,293Same bytecode
Gas Price25 gwei0.1 gwei (L2) + L1 dataVariable
Deploy Cost (ETH)0.01218 ETH0.00062 ETH94.9%
Deploy Cost (USD)~$36.55~$1.86$34.69 saved

*Prices based on ETH at $3,000 and typical gas conditions.*

Transaction Costs

OperationEthereum MainnetArbitrum OneSavings
deposit()~$2.10~$0.0896.2%
withdraw()~$2.85~$0.1295.8%
ERC-20 transfer~$1.55~$0.0696.1%
Uniswap swap~$8.40~$0.3595.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

  1. Set `evm_version = "paris"` in your `foundry.toml`. Arbitrum does not support PUSH0. This one setting prevents the most common deployment failure.
  1. Use `forge script` for deployment, not `forge create`. Scripts are reproducible, testable on testnets, and handle complex multi-contract deployments cleanly.
  1. Never use raw private keys for mainnet. Use cast wallet import for encrypted keystores or --ledger for hardware wallet signing.
  1. Test on Arbitrum Sepolia first. Always. The testnet is free and catches environment-specific issues that local testing cannot.
  1. Use `block.timestamp` instead of `block.number` for timing. Block numbers on Arbitrum reference L1, not L2.
  1. 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.
  1. Verification sometimes fails — know the manual path. Keep your compiler settings explicit in foundry.toml so you can always re-verify.
  1. 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?

Share
Uvin Vindula

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.