Source mirror · commit 369512f · 2026-10-26 · MIT · canonical repo: gitlab.com/jiapunk/upgrade-airlock
git clone https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
upgrade-airlock / scripts / sepolia / deploy.sh
scripts/sepolia/deploy.sh165 lines · 10064 bytes
1#!/usr/bin/env bash
2# Deploy the Upgrade Airlock stack (script/DeployEth.s.sol) to Ethereum Sepolia with the demo keys.
3#
4# scripts/sepolia/deploy.sh # live Sepolia -> deployments/sepolia.json, broadcast/
5# FORK=19631 scripts/sepolia/deploy.sh # identical flow on a local anvil fork -> $TMPDIR only
6#
7# Keys (KEY_DIR, default $HOME/.config/ethsz-2026):
8# owner.key deploys and becomes the demo owner (it can only propose)
9# attester.key its address is baked into AirlockReceiver (the EIP-712 verdict signer)
10# approver.key its address becomes AirlockAdmin.approver (manual clearance, codehash registry)
11# emergency.key its address becomes the second guardian (a human veto, breakGlass)
12# sender.key pays the CRE writeReport transactions (only its balance is checked here)
13# The run refuses when approver.key or emergency.key is missing, or when owner, approver, emergency guardian and
14# attester are not four different addresses (DeployEth refuses the same again before it sends anything).
15# The CRE simulator writes through the permissionless MockKeystoneForwarder, where the attester's signature alone
16# authenticates a verdict, so the run passes ALLOW_MOCK_FORWARDER=1 (DeployEth refuses the mock without it).
17# Env: OUT_JSON (live default deployments/sepolia.json; must be under deployments/ or cache/), DELAY_BLOCKS (25),
18# SEPOLIA_RPC, FORCE=1 (deploy again although OUT_JSON exists), YES=1 (no confirmation prompt),
19# DEPLOY_GAS (gas budget for the balance check, default 110000000; the 13 transactions took about 96M gas on a
20# Sepolia fork).
21# Sepolia runs the Amsterdam rules, under which contract creation costs far more than Foundry's local simulation
22# predicts, so the script lets the node estimate every transaction (--skip-simulation). An anvil fork of Sepolia
23# charges the same gas as live Sepolia (gas-replay.ts checks this).
24source "$(dirname "$0")/lib.sh"
25NTX=13
26
27# Roles first: nothing is read from the chain until the key directory is complete and the roles are distinct.
28for r in owner attester sender approver emergency; do
29 [ -r "$KEY_DIR/$r.key" ] || die "KEY_DIR has no readable $r.key (the five demo keys are owner, attester, sender, approver, emergency)"
30done
31OWNER_PK=$(read_key owner)
32OWNER=$(cast wallet address --private-key "$OWNER_PK")
33ATTESTER=$(addr_of attester)
34SENDER=$(addr_of sender)
35APPROVER=$(addr_of approver)
36EMERGENCY=$(addr_of emergency)
37[ "$(lc "$APPROVER")" != "$(lc "$OWNER")" ] || die "approver.key is the owner's key; the owner must not clear its own proposals"
38[ "$(lc "$EMERGENCY")" != "$(lc "$OWNER")" ] || die "emergency.key is the owner's key; the owner must not be a guardian"
39[ "$(lc "$APPROVER")" != "$(lc "$EMERGENCY")" ] || die "approver.key and emergency.key are the same key; use two different keys"
40[ "$(lc "$ATTESTER")" != "$(lc "$OWNER")" ] || die "attester.key is the owner's key; the attester must be its own key"
41[ "$(lc "$ATTESTER")" != "$(lc "$APPROVER")" ] || die "attester.key and approver.key are the same key; use two different keys"
42[ "$(lc "$ATTESTER")" != "$(lc "$EMERGENCY")" ] || die "attester.key and emergency.key are the same key; use two different keys"
43
44check_rpc
45say "network $NET ($RPC)"
46say "owner $OWNER deploys; demo owner (proposes upgrades)"
47say "attester $ATTESTER signs the EIP-712 verdicts (CRE secret)"
48say "approver $APPROVER manual clearance and codehash registry"
49say "emergency $EMERGENCY second guardian (human veto, breakGlass)"
50say "sender $SENDER pays the CRE writeReport transactions"
51
52if [ "$MODE" = fork ]; then
53 OUT_REL=cache/deployment-eth.fork-$FORK.$$.json
54 export FOUNDRY_BROADCAST=$STATE/broadcast
55 FUNDED=("$OWNER" "$SENDER" "$APPROVER" "$EMERGENCY")
56 [ -r "$KEY_DIR/sweeper.key" ] && FUNDED+=("$(addr_of sweeper)")
57 for a in "${FUNDED[@]}"; do cast rpc anvil_setBalance "$a" 0x56bc75e2d63100000 --rpc-url "$RPC" >/dev/null; done
58 say "fork: owner, sender, approver and emergency guardian$([ ${#FUNDED[@]} = 5 ] && echo ', sweeper') funded with 100 ETH (anvil_setBalance)"
59else
60 OUT_REL=${OUT_JSON:-deployments/sepolia.json}
61fi
62case "$OUT_REL" in deployments/*.json | cache/*.json) ;; *) die "OUT_JSON must be a .json under deployments/ or cache/ (forge fs_permissions)" ;; esac
63if [ "$MODE" = live ] && [ -f "$ROOT/$OUT_REL" ] && [ -z "${FORCE:-}" ]; then
64 die "$OUT_REL already exists (a deployment is live). FORCE=1 replaces it with a new stack (git keeps the old file),
65 or deploy with OUT_JSON=deployments/<name>.json and run the other scripts with DEPLOY_JSON=<that file>."
66fi
67
68# Balance check: DEPLOY_GAS at twice the current gas price (Sepolia's base fee is low, but it can spike).
69BAL=$(cast balance "$OWNER" --rpc-url "$RPC")
70GP=$(cast gas-price --rpc-url "$RPC")
71GAS=${DEPLOY_GAS:-110000000}
72NEED=$(bc <<<"$GAS * $GP * 2")
73say "owner balance $(cast from-wei "$BAL") ETH; gas price $GP wei; budget ${GAS} gas -> need about $(cast from-wei "$NEED") ETH"
74if [ "$(bc <<<"$BAL < $NEED")" = 1 ]; then
75 die "owner $OWNER needs at least $(cast from-wei "$NEED") ETH on Sepolia (faucet), has $(cast from-wei "$BAL")"
76fi
77NONCE=$(cast nonce "$OWNER" --rpc-url "$RPC")
78[ "$NONCE" = 0 ] || say "note: owner nonce is $NONCE; contract addresses follow from it (a fork rehearsal predicts the live ones only at the same nonce)"
79
80if [ "$MODE" = live ] && [ -z "${YES:-}" ]; then
81 [ -t 0 ] || die "a live deployment from a non-interactive shell needs YES=1"
82 read -r -p "Deploy $NTX transactions to Ethereum Sepolia from $OWNER? Type 'deploy': " ans
83 [ "$ans" = deploy ] || die "aborted"
84fi
85
86LOG=$STATE/deploy-$(date +%Y%m%d-%H%M%S).log
87say "deploying $NTX transactions, one per block (forge log: $LOG) ..."
88# A fork run builds into the state dir and keeps forge's run cache there too (a fork shares the chain id with live
89# Sepolia, so the live run's cache/DeployEth.s.sol/11155111/, which `forge script --resume` needs, stays untouched).
90FORGE_DIRS=()
91[ "$MODE" = fork ] && FORGE_DIRS=(--out "$STATE/forge-out" --cache-path "$STATE/forge-cache")
92cleanup() { if [ -n "${FPID:-}" ]; then kill "$FPID" 2>/dev/null || true; fi; }
93trap cleanup EXIT
94trap 'exit 130' INT TERM
95T0=$(now_s)
96(
97 # the key goes to forge's environment through the builtin export; env's own arguments hold addresses only
98 cd "$ROOT" && export DEPLOYER_PK="$OWNER_PK" && exec env ATTESTER_ADDR="$ATTESTER" APPROVER_ADDR="$APPROVER" \
99 EMERGENCY_GUARDIAN_ADDR="$EMERGENCY" ALLOW_MOCK_FORWARDER=1 OUT_JSON="$OUT_REL" FOUNDRY_PROFILE=eth \
100 forge script script/DeployEth.s.sol --rpc-url "$RPC" --broadcast --slow --skip-simulation ${FORGE_DIRS[@]+"${FORGE_DIRS[@]}"}
101) >"$LOG" 2>&1 &
102FPID=$!
103unset OWNER_PK
104# progress: forge prints nothing per transaction, so follow the owner's nonce
105LAST=$NONCE
106while kill -0 "$FPID" 2>/dev/null; do
107 N=$(cast nonce "$OWNER" --rpc-url "$RPC" 2>/dev/null || echo "$LAST")
108 if [ "$N" != "$LAST" ]; then say " $((N - NONCE))/$NTX confirmed ($(fmt_secs $(($(now_s) - T0))))"; LAST=$N; fi
109 sleep 3
110done
111set +e
112wait "$FPID"
113RC=$?
114set -e
115FPID=
116N=$(cast nonce "$OWNER" --rpc-url "$RPC" 2>/dev/null || echo "$LAST")
117[ "$N" = "$LAST" ] || say " $((N - NONCE))/$NTX confirmed ($(fmt_secs $(($(now_s) - T0))))"
118[ "$RC" = 0 ] || { tail -n 25 "$LOG" >&2; die "forge script failed (exit $RC); log $LOG"; }
119SECS=$(($(now_s) - T0))
120
121if [ "$MODE" = fork ]; then
122 mv "$ROOT/$OUT_REL" "$DEPLOY_JSON"
123else
124 DEPLOY_JSON=$ROOT/$OUT_REL
125fi
126
127# Post-deploy checks, read from the chain.
128GA=$(dep airlockAdmin)
129RX=$(dep receiver)
130chk() { [ "$(lc "$(cast call "$1" "$2" ${3:+"$3"} --rpc-url "$RPC")")" = "$(lc "$4")" ] || die "post-deploy check failed: $5"; }
131chk "$GA" 'isWired()(bool)' "" true "ProxyAdmin is not owned by AirlockAdmin"
132chk "$GA" 'isGuardian(address)(bool)' "$RX" true "the receiver is not a guardian"
133chk "$GA" 'isGuardian(address)(bool)' "$EMERGENCY" true "the emergency key is not a guardian"
134chk "$GA" 'isGuardian(address)(bool)' "$OWNER" false "the owner is a guardian"
135chk "$GA" 'clearanceOracle()(address)' "" "$RX" "the receiver is not the clearance oracle"
136chk "$GA" 'approver()(address)' "" "$APPROVER" "approver mismatch"
137chk "$GA" 'owner()(address)' "" "$OWNER" "owner mismatch"
138chk "$RX" 'attester()(address)' "" "$ATTESTER" "receiver attester mismatch"
139chk "$RX" 'airlockAdmin()(address)' "" "$GA" "the receiver points at another AirlockAdmin"
140chk "$(dep proxy)" 'owner()(address)' "" "$GA" "the token's owner is not AirlockAdmin"
141say "post-deploy checks: AirlockAdmin owns ProxyAdmin and the token; guardians = receiver + emergency; clearance oracle = receiver; roles distinct"
142
143RUN=${FOUNDRY_BROADCAST:-$ROOT/broadcast}/DeployEth.s.sol/$CHAIN_ID/run-latest.json
144if [ -f "$RUN" ]; then
145 read -r NSENT GUSED WEI < <(node -e 'const r = require(process.argv[1]).receipts; let g = 0n, w = 0n
146 for (const x of r) { g += BigInt(x.gasUsed); w += BigInt(x.gasUsed) * BigInt(x.effectiveGasPrice) }
147 console.log(r.length, String(g), String(w))' "$RUN")
148 say "deployed in $(fmt_secs "$SECS"): $NSENT transactions, $GUSED gas, $(cast from-wei "$WEI") ETH"
149fi
150say ""
151say "deployment $DEPLOY_JSON"
152for k in airlockAdmin receiver proxy proxyAdmin v1 v2 evilMint evilOwner evilFee evilBaseFee evilDrain paused; do
153 printf ' %-13s %s\n' "$k" "$(addr_ref "$(dep "$k")")"
154done
155printf ' %-13s %s\n' forwarder "$(addr_ref "$(dep forwarder)")" "delayBlocks" "$(dep delayBlocks) blocks" startBlock "$(dep startBlock)"
156say ""
157F=${FORK:+FORK=$FORK }
158say "next: ${F}bun scripts/sepolia/make-config.mjs${FORK:+ (optional on a fork: airlock.sh and sweep.sh generate the fork config)}"
159say " ${F}scripts/sepolia/fund-sender.sh then ${F}scripts/sepolia/airlock.sh"
160if [ "$MODE" = live ]; then
161 say ""
162 say "optional source verification (Sourcify, public):"
163 say " FOUNDRY_PROFILE=eth forge verify-contract $GA src/AirlockAdmin.sol:AirlockAdmin --chain $CHAIN_ID --verifier sourcify"
164 say " FOUNDRY_PROFILE=eth forge verify-contract $RX src/cre/AirlockReceiver.sol:AirlockReceiver --chain $CHAIN_ID --verifier sourcify"
165fi