Section D · Production

Deployment & Ops

Shipping immutable code to mainnet is a specialized discipline. Reproducibility, deterministic addresses, deploy-day playbooks, and the cold reality that there is mostly no rollback.

Foundry scripts at scale

Anything serious uses forge script. Patterns that hold up:

// script/Deploy.s.sol
contract Deploy is Script {
    function run() external {
        // 1. Load deployer with env-only credentials — never commit
        uint256 pk = vm.envUint("DEPLOYER_PK");
        vm.startBroadcast(pk);

        // 2. Pre-flight checks before any state-changing call
        require(block.chainid == 1, "wrong chain");
        require(_ethRpcSanity(), "rpc smells wrong");

        // 3. Deploy with deterministic salt
        bytes32 salt = bytes32(uint256(0xdeadbeef));
        PoolManager pm = new PoolManager{salt: salt}(governance);
        require(address(pm) == _predicted(), "address mismatch — refusing to continue");

        // 4. Initial config
        pm.setProtocolFee(1000);
        pm.setOwner(governance);

        // 5. Smoke tests inside the script
        require(pm.owner() == governance, "owner not set");

        vm.stopBroadcast();

        // 6. Log everything
        console.log("PoolManager:", address(pm));
        _writeDeploymentJson(address(pm));
    }
}

Run shape:

# Dry-run on local fork
forge script script/Deploy.s.sol --rpc-url $RPC --fork-url $RPC

# Simulate on mainnet — no broadcast yet
forge script script/Deploy.s.sol --rpc-url $RPC

# Broadcast
forge script script/Deploy.s.sol --rpc-url $RPC --broadcast --verify \
    --etherscan-api-key $ETHERSCAN_KEY --slow --legacy

--slow waits for each transaction to be mined before sending the next — critical for deploys that depend on prior addresses.

CREATE2 with vanity addresses

The address of a CREATE2-deployed contract is:

address = keccak256(0xff || deployer || salt || keccak256(initcode))[12:]

By searching over salts (and sometimes initcode constructor args), you can mine addresses that:

  • Start with zero bytes — saves calldata gas when the contract is referenced (zero bytes cost 4 gas; nonzero 16).
  • Encode v4 hook permissions — required, not optional, in v4.
  • Are recognizable / branded — vanity for hash visibility.

For 8 leading zero nibbles: ~4B keccak operations expected. A modern GPU does it in <1 minute. Tools: create2crunch, profanity-style miners.

For the same contract bytecode deployed across multiple chains, the same CREATE2 inputs give the same address — assuming the deployer contract is also at the same address. Use a chain-agnostic SingletonFactory or CreateX to guarantee this.

Multi-chain deploys

Today's DEXes deploy across 10-25 EVM chains. The challenges:

  • Same address on every chain — accomplished via CREATE2 + same deployer + same salt + same initcode.
  • Chain-id-dependent immutables — DOMAIN_SEPARATOR for EIP-712 must vary; everything else must not.
  • Different gas prices, different opcodes available — some L2s lag Cancun. Compile per-chain or pick a lowest-common-denominator.
  • Per-chain governance addresses. Same code, but the owner differs.
  • Verification on every block explorer. Etherscan, Arbiscan, OptimisticEtherscan, Basescan, Lineascan, Polygonscan, Snowtrace, etc. Use Foundry's --verify with the right API key per chain.
# Multi-chain deploy with config file
forge script script/Deploy.s.sol \
    --chain-id 1 \
    --rpc-url $ETH_RPC --etherscan-api-key $ETHERSCAN \
    --broadcast --verify --slow

forge script script/Deploy.s.sol \
    --chain-id 8453 \
    --rpc-url $BASE_RPC --etherscan-api-key $BASESCAN \
    --broadcast --verify --slow

# ...repeat per chain or driven by a deployer shell script

Reproducible builds

The bytecode you deploy must be reproducible from the source. If a verifier (Etherscan, Sourcify, or an auditor) can't reproduce your bytecode from your repo, your verification is suspect.

Lockdown checklist:

  • Locked compiler version. 0.8.26, not ^0.8.0. In foundry.toml.
  • Locked optimizer settings. Runs count and via-ir setting committed.
  • Locked EVM version. evm_version = "cancun".
  • Locked dependencies. Git submodules pinned to commits, not branches. forge install foo/bar@v1.2.3.
  • Deterministic metadata. Same source paths, same imports, same compiler flags → same bytecode hash.
# foundry.toml — reproducible-build settings
[profile.default]
solc_version  = "0.8.26"
evm_version   = "cancun"
via_ir        = true
optimizer     = true
optimizer_runs = 200_000
bytecode_hash = "none"     # remove timestamp-like metadata
cbor_metadata = false

Diff-against-deployed verification

Before flipping the activation switch on a deploy, verify that the on-chain bytecode matches what you intended:

# Fetch deployed runtime bytecode
cast code 0xPoolManagerAddress --rpc-url $RPC > deployed.bytecode

# Get expected from local build
forge inspect PoolManager deployedBytecode > expected.bytecode

# Diff
diff <(xxd deployed.bytecode) <(xxd expected.bytecode)

Common false positives:

  • Immutables get inlined into the deployed bytecode. Diff will show them.
  • Metadata hash at the end differs if compiler versions differ.
  • Library link references show up as placeholders.

Tools that automate this: contractwatcher, custom forge verify-check wrappers.

Bytecode auditing

Sometimes you need to read deployed bytecode for which you don't have source. Tools:

  • Dedaub Decompile — best-in-class decompiler.
  • Etherscan's auto-decompile — adequate.
  • heimdall-rs — open-source decompiler / disassembler.
  • cast disassemble — opcode-level walk.
  • cast 4byte — selector → known signature lookup.

You should be able to look at a 4-byte selector in a transaction, identify the function name, and reason about what the call did — without access to source.

Deploy-day playbook

What a senior engineer actually executes the day of a mainnet deployment:

  1. T-3 days: Final code freeze. Run full Foundry + Echidna + Halmos suite. Audit reports incorporated. Bytecode hash recorded.
  2. T-2 days: Dry-run the deploy script on a mainnet fork at current block. Verify all addresses, ownership, configs.
  3. T-1 day: Rehearsal — full deploy on testnet (Sepolia / Holesky). Smoke tests pass.
  4. Morning of: Sync with the team. Confirm gas prices are reasonable. Confirm deployer wallet has enough ETH.
  5. Execute: Run the deploy script. --slow for each step.
  6. Verify: Each contract source on Etherscan. Bytecode diff against expected.
  7. Smoke test: A first end-to-end transaction. Tiny size.
  8. Configure: Set initial caps. Transfer ownership to multisig / timelock.
  9. Publish: Update front-ends, subgraph, documentation. Announce.
  10. Watch: First 24h on-call. Monitor every alert.

One missed step costs you a redeploy or worse.

Rollback — you mostly can't

The hard truth: for immutable core contracts, there is no rollback. If you ship a bug:

  • If the core is paused-capable: pause and forfeit operation.
  • If the core has a kill switch: trigger and let funds drain to a recovery contract.
  • If neither: hope the bug is non-economic, or coordinate a whitehat rescue.
  • If the bug is economic and being exploited: scream loud, coordinate with auditors and security partners, and accept the loss.

The design implication: design for immutability from day one. No "we'll add an upgrade hatch just in case." That hatch IS the bug surface.

The senior reflex

When asked "how would you handle a deploy-day bug?" — the correct first answer is "don't ship one." The rollout playbook (caps, gradual release, multi-audit) exists precisely because rollback doesn't.