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
--verifywith 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. Infoundry.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 = falseDiff-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:
- T-3 days: Final code freeze. Run full Foundry + Echidna + Halmos suite. Audit reports incorporated. Bytecode hash recorded.
- T-2 days: Dry-run the deploy script on a mainnet fork at current block. Verify all addresses, ownership, configs.
- T-1 day: Rehearsal — full deploy on testnet (Sepolia / Holesky). Smoke tests pass.
- Morning of: Sync with the team. Confirm gas prices are reasonable. Confirm deployer wallet has enough ETH.
- Execute: Run the deploy script.
--slowfor each step. - Verify: Each contract source on Etherscan. Bytecode diff against expected.
- Smoke test: A first end-to-end transaction. Tiny size.
- Configure: Set initial caps. Transfer ownership to multisig / timelock.
- Publish: Update front-ends, subgraph, documentation. Announce.
- 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.
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.