Development of Multichain Automatic Deployment System
The problem emerges on the third or fourth deployment of same protocol to different networks: someone deployed to Arbitrum with different constant value, Base used different OpenZeppelin version, proxy addresses weren't saved properly, and now it's unclear what's deployed where. Multichain auto-deploy solves exactly this — reproducibility and deployment tracking.
Deterministic Addresses via CREATE2
Key requirement for multichain deployment: same addresses across all networks. This simplifies user experience, documentation, and cross-chain integration.
CREATE2 allows computing contract address before deployment:
address = keccak256(0xff ++ deployerAddress ++ salt ++ keccak256(bytecode))[12:]
If deployer has same address on all EVM networks (via Nick's Factory or own deployer via deterministicDeploy), bytecode is same, salt is same — address will be same everywhere.
Foundry supports this natively:
// deploy script
function run() external {
bytes32 salt = keccak256("MyProtocol_v1.0.0");
vm.broadcast();
address proxy = factory.deployProxy(
address(implementation),
salt,
abi.encodeCall(MyContract.initialize, (owner, params))
);
console.log("Deployed at:", proxy);
console.log("Chain:", block.chainid);
}
# Deploy to multiple networks
forge script script/Deploy.s.sol --rpc-url arbitrum --broadcast
forge script script/Deploy.s.sol --rpc-url base --broadcast
forge script script/Deploy.s.sol --rpc-url optimism --broadcast
Problem: if bytecode differs between networks (e.g., hardcoded chainId or address), CREATE2 address will be different. Must avoid compile-time constant addresses in bytecode.
Deployment System Structure
Network Configuration
Single source of truth for all networks in deploy.config.ts:
export interface NetworkConfig {
chainId: number;
rpcUrl: string;
deployer: string; // deployer address
gasPrice?: bigint; // override for unstable gas networks
confirmations: number; // blocks to wait
verifier?: "etherscan" | "blockscout" | "none";
verifierUrl?: string;
nativeCurrency: string;
contracts: {
// addresses of dependent contracts in this network
usdc?: string;
weth?: string;
uniswapRouter?: string;
};
}
export const networks: Record<string, NetworkConfig> = {
arbitrum: {
chainId: 42161,
rpcUrl: process.env.ARBITRUM_RPC!,
deployer: DEPLOYER_ADDRESS,
confirmations: 1,
verifier: "etherscan",
nativeCurrency: "ETH",
contracts: {
usdc: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
weth: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
},
},
base: {
chainId: 8453,
rpcUrl: process.env.BASE_RPC!,
deployer: DEPLOYER_ADDRESS,
confirmations: 1,
verifier: "blockscout",
verifierUrl: "https://base.blockscout.com/api",
nativeCurrency: "ETH",
contracts: {
usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
weth: "0x4200000000000000000000000000000000000006",
},
},
};
Artifacts and State Management
After each deployment need to save contract addresses. Foundry saves to broadcast/ directory, but inconvenient for multichain tracking.
Better approach — generate deployments.json:
interface DeploymentRecord {
network: string;
chainId: number;
contracts: Record<string, {
address: string;
implementationAddress?: string;
abi: string; // IPFS hash or path
deployedAt: number; // block number
txHash: string;
version: string; // semver
}>;
deployedBy: string;
timestamp: string;
}
This file is committed to repository — single source of truth for addresses. CI/CD updates it after each deployment.
Deployment Script with Retry and Verification
import { createPublicClient, createWalletClient, http } from "viem";
async function deployWithRetry(
network: NetworkConfig,
contractName: string,
deployFn: () => Promise<`0x${string}`>,
maxRetries = 3
): Promise<`0x${string}`> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const address = await deployFn();
// Wait for confirmations
const client = createPublicClient({ transport: http(network.rpcUrl) });
await client.waitForTransactionReceipt({
hash: address,
confirmations: network.confirmations,
});
console.log(`✓ ${contractName} on ${network.chainId}: ${address}`);
return address;
} catch (err) {
if (attempt === maxRetries - 1) throw err;
console.log(`Retry ${attempt + 1}/${maxRetries}: ${err.message}`);
await sleep(2000 * (attempt + 1));
}
}
throw new Error("unreachable");
}
Automatic Contract Verification
After deployment, contracts must be verified on block explorer — mandatory for protocols wanting user trust.
Foundry:
forge verify-contract \
--chain-id 42161 \
--num-of-optimizations 200 \
--compiler-version v0.8.24 \
$CONTRACT_ADDRESS \
src/MyContract.sol:MyContract \
--etherscan-api-key $ARBITRUM_ETHERSCAN_KEY
For Blockscout (Base, some L2):
forge verify-contract \
--verifier blockscout \
--verifier-url https://base.blockscout.com/api \
$CONTRACT_ADDRESS \
src/MyContract.sol:MyContract
Automation in script: verify immediately after deployment, don't wait separate step. Save verification status in deployments.json.
CI/CD Pipeline
GitHub Actions Workflow
name: Deploy Protocol
on:
push:
tags:
- "v*" # deploy on release tagging
jobs:
deploy:
runs-on: ubuntu-latest
strategy:
matrix:
network: [arbitrum, base, optimism]
max-parallel: 1 # sequentially, avoid race conditions in deployments.json
steps:
- uses: actions/checkout@v4
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Run tests
run: forge test --fork-url ${{ secrets.MAINNET_RPC }}
- name: Deploy to ${{ matrix.network }}
env:
DEPLOYER_PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }}
RPC_URL: ${{ secrets[format('{0}_RPC', matrix.network)] }}
run: |
forge script script/Deploy.s.sol \
--rpc-url $RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY \
--broadcast \
--verify
- name: Update deployments.json
run: node scripts/update-deployments.js ${{ matrix.network }}
- name: Commit deployments
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "chore: update deployments for ${{ matrix.network }} @ ${{ github.ref_name }}"
file_pattern: deployments.json
Private Key Management in CI
Never store deployer private key in CI secrets as hex string without additional protection. Better:
- AWS KMS + custom signer: key in KMS, signing via API. CI secrets leak doesn't compromise key.
- Hardware wallet via Frame + WalletConnect: deployment with manual confirmation for mainnet.
- Separate deploy wallet with minimal balance (gas only), not connected to treasury.
Multichain Addressing in Protocol
If contracts interact cross-chain (cross-chain calls), need to manage peer contract addresses:
contract CrossChainRegistry {
mapping(uint256 => address) public peers; // chainId => peer address
function setPeer(uint256 chainId, address peer) external onlyOwner {
peers[chainId] = peer;
emit PeerSet(chainId, peer);
}
function _validateCrossChainSender(uint256 srcChainId, address sender) internal view {
require(peers[srcChainId] != address(0), "Unknown chain");
require(peers[srcChainId] == sender, "Invalid peer");
}
}
If using LayerZero or Wormhole — they provide own registry mechanisms, but basic logic same.
Monitoring Deployed Contracts
After deployment to 5+ networks need to monitor all instances:
// Aggregated monitoring via events
const deployments = require("./deployments.json");
for (const [network, data] of Object.entries(deployments)) {
const client = createPublicClient({ transport: http(data.rpcUrl) });
// Watch critical events across all networks
client.watchContractEvent({
address: data.contracts.core.address,
abi: CORE_ABI,
eventName: "EmergencyPause",
onLogs: (logs) => alertOps(`PAUSE on ${network}`, logs),
});
}
Stack and Tools
| Component | Tool |
|---|---|
| Compile & test | Foundry (forge, cast, anvil) |
| Deploy scripts | TypeScript + viem or Foundry scripts |
| State tracking | deployments.json in git |
| CI/CD | GitHub Actions / GitLab CI |
| Key management | AWS KMS or Ledger for mainnet |
| Verification | Etherscan API / Blockscout API |
| Monitoring | Tenderly + own alerts |
Development timeline for deployment system: 1–2 weeks for EVM-compatible networks. Adding non-EVM networks (Solana, TON) requires separate toolchain and significantly more complex.







