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 / lib.sh
scripts/sepolia/lib.sh354 lines · 17267 bytes
1# Shared helpers for the Sepolia operator kit. Sourced by the scripts next to it, never run directly.
2#
3# Two modes, picked by the FORK variable:
4# live (default) Ethereum Sepolia through SEPOLIA_RPC (default publicnode); deployment in deployments/sepolia.json
5# FORK=<port> a local anvil fork of Sepolia on 127.0.0.1:<port> (start one with fork.sh); everything the kit
6# writes goes to $TMPDIR/upgrade-airlock-fork-<port>/, nothing under the repo changes
7# AIRLOCK_STATE overrides the live mode's state directory (logs, PID files, the compiled workflow); the systemd
8# units set it to a persistent directory so the operator's shell and the services share one place. Fork mode
9# ignores it, so a rehearsal never touches the live services' files.
10#
11# Keys: KEY_DIR (default $HOME/.config/ethsz-2026) holds one hex private key per file:
12# owner.key deploys the stack, proposes, executes (the owner can only propose; it is never a guardian)
13# attester.key signs the EIP-712 verdicts the AirlockReceiver accepts (a CRE secret)
14# sender.key pays the CRE writeReport transactions
15# approver.key the human approver: manual clearance (clear.sh) and the codehash registry
16# emergency.key the emergency guardian: a human veto and breakGlass
17# They are read only at run time, into shell variables, and handed to forge / cast / the CRE simulator.
18# Nothing here prints a key; only the derived addresses are shown.
19set -euo pipefail
20
21KIT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
22ROOT=$(cd "$KIT/../.." && pwd)
23export PATH="$ROOT/cre/.tools/bin:$ROOT/cre/.tools/node_modules/.bin:$HOME/.foundry/bin:$HOME/.bun/bin:$PATH"
24
25KEY_DIR=${KEY_DIR:-$HOME/.config/ethsz-2026}
26CHAIN_ID=11155111
27EXPLORER=https://sepolia.etherscan.io
28SEPOLIA_BLOCK_SECS=12
29TMP_BASE=${TMPDIR:-/tmp}
30TMP_BASE=${TMP_BASE%/}
31
32die() { echo "error: $*" >&2; exit 1; }
33say() { printf '%s\n' "$*"; }
34now_s() { date +%s; }
35lc() { tr 'A-F' 'a-f' <<<"$1"; }
36for _t in cast jq bc; do command -v "$_t" >/dev/null 2>&1 || die "$_t is not on the PATH (cast comes with Foundry: foundryup)"; done
37unset _t
38
39if [ -n "${FORK:-}" ]; then
40 [[ "$FORK" =~ ^[0-9]{2,5}$ ]] || die "FORK must be a local port number (got '$FORK')"
41 MODE=fork
42 RPC=http://127.0.0.1:$FORK
43 STATE=$TMP_BASE/upgrade-airlock-fork-$FORK
44 DEPLOY_JSON=${DEPLOY_JSON:-$STATE/deployment.json}
45 NET="anvil fork :$FORK"
46else
47 MODE=live
48 RPC=${SEPOLIA_RPC:-https://ethereum-sepolia-rpc.publicnode.com}
49 STATE=${AIRLOCK_STATE:-$TMP_BASE/upgrade-airlock-sepolia}
50 DEPLOY_JSON=${DEPLOY_JSON:-$ROOT/deployments/sepolia.json}
51 NET="Ethereum Sepolia"
52fi
53mkdir -p "$STATE"
54
55# ---------------------------------------------------------------- keys (never printed)
56
57# read_key <owner|attester|sender|approver|emergency>: the 0x-prefixed key on stdout, for capture into a variable only.
58read_key() {
59 local f="$KEY_DIR/$1.key" k
60 [ -r "$f" ] || die "missing or unreadable key file $1.key in KEY_DIR"
61 k=$(tr -d ' \t\r\n' <"$f")
62 [[ "$k" =~ ^(0x)?[0-9a-fA-F]{64}$ ]] || die "$1.key does not hold a 32-byte hex private key"
63 [[ "$k" == 0x* ]] || k=0x$k
64 printf '%s' "$k"
65}
66
67# addr_of <role>
68addr_of() {
69 local k
70 k=$(read_key "$1")
71 cast wallet address --private-key "$k"
72}
73
74# ---------------------------------------------------------------- chain
75
76# rpc_retry <command...>: runs a read-only chain command up to RPC_TRIES times (default 3), 2 s apart, so a transient
77# RPC error (a rate limit, a reset connection, a load-balanced node without the data) does not stop a stage command.
78# Only the last attempt's error is shown. Never wrap anything that sends a transaction (send_as): a retry could send
79# it twice.
80rpc_retry() {
81 local n=${RPC_TRIES:-3} i=1 out
82 while :; do
83 if [ "$i" -lt "$n" ]; then
84 if out=$("$@" 2>/dev/null); then printf '%s\n' "$out"; return 0; fi
85 else
86 if out=$("$@"); then printf '%s\n' "$out"; return 0; fi
87 return 1
88 fi
89 i=$((i + 1))
90 sleep 2
91 done
92}
93
94# The RPC must be Sepolia (chain id 11155111); in FORK mode it must also be an anvil node on loopback, so a FORK run
95# can never send anything to a public network.
96check_rpc() {
97 local cid cv
98 cid=$(rpc_retry cast chain-id --rpc-url "$RPC" 2>/dev/null) || die "no RPC answering at $RPC${FORK:+ (start the fork: FORK=$FORK $KIT/fork.sh start)}"
99 [ "$cid" = "$CHAIN_ID" ] || die "chain id $cid at $RPC, expected $CHAIN_ID (Sepolia)"
100 if [ "$MODE" = fork ]; then
101 cv=$(cast rpc web3_clientVersion --rpc-url "$RPC" 2>/dev/null | tr -d '"') || cv=unknown
102 [[ "$cv" == anvil* ]] || die "FORK=$FORK: $RPC is not an anvil node ($cv); refusing"
103 fi
104}
105
106block_now() { rpc_retry cast block-number --rpc-url "$RPC"; }
107block_ts() {
108 local b
109 b=$(rpc_retry cast rpc eth_getBlockByNumber "$(printf '0x%x' "$1")" false --rpc-url "$RPC") || return 1
110 cast to-dec "$(jq -r .timestamp <<<"$b")"
111}
112
113# dep <key>: a field of the deployment JSON (airlockAdmin, receiver, evilMint, ...).
114dep() {
115 [ -f "$DEPLOY_JSON" ] || die "no deployment at $DEPLOY_JSON (run deploy.sh${FORK:+ with FORK=$FORK} first)"
116 local v
117 v=$(jq -r --arg k "$1" '.[$k] // empty' "$DEPLOY_JSON")
118 if [ -z "$v" ]; then
119 [ "$1" = airlockAdmin ] && die "$DEPLOY_JSON has no airlockAdmin, so it is not a deployment of this stack; deploy it with deploy.sh"
120 die "deployment JSON has no '$1'"
121 fi
122 printf '%s' "$v"
123}
124
125tx_ref() { if [ "$MODE" = live ]; then say "$EXPLORER/tx/$1"; else say "$1"; fi; }
126addr_ref() { if [ "$MODE" = live ]; then say "$EXPLORER/address/$1"; else say "$1"; fi; }
127
128# send_as <role> <cast send args...>: sends with that key and prints the receipt JSON.
129send_as() {
130 local who=$1 k
131 shift
132 k=$(read_key "$who")
133 cast send "$@" --private-key "$k" --rpc-url "$RPC" --json
134}
135
136# ---------------------------------------------------------------- AirlockAdmin
137
138PROPOSAL_SIG='getProposal(uint256)((uint8,uint8,address,address,uint256,bytes32,bytes32,uint64,uint64,uint256))'
139STATUS_NAMES=(None Pending Vetoed Executed)
140KIND_NAMES=(upgrade set-guardian set-delay set-paused-impl)
141ZERO32=0x0000000000000000000000000000000000000000000000000000000000000000
142IMPL_SLOT=0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
143
144# Rule ids: read from the workflow (cre/veto/rules.ts) so a new rule shows up here without an edit; this list is
145# the fallback. ruleHash = keccak256("GUARD/<ID>").
146RULE_IDS=(R1_TOTAL_SUPPLY R2_OWNER R3_TRANSFER_CONSERVATION R4_TRANSFER_LIVENESS R5_STRICT_CODEHASH R6_SIM_FAILURE
147 R7_CONFIG_GUARD R8_INIT_DATA R3B_THIRD_PARTY_FUNDS R9_NEW_OPCODES)
148if [ -f "$ROOT/cre/veto/rules.ts" ]; then
149 _ids=$(grep -oE "'R[0-9][0-9A-Z_]*'" "$ROOT/cre/veto/rules.ts" | tr -d "'" | awk '!s[$0]++' || true)
150 [ -n "$_ids" ] && read -r -a RULE_IDS <<<"$(tr '\n' ' ' <<<"$_ids")"
151 unset _ids
152fi
153
154# proposal <id>: "kind status target readyBlock vetoCount proposedBlock" (numbers as decimals). Fails (status 1, no
155# output) when the RPC does not answer after the retries.
156proposal() {
157 local ga raw
158 ga=$(dep airlockAdmin)
159 raw=$(rpc_retry cast call "$ga" "$PROPOSAL_SIG" "$1" --rpc-url "$RPC") || return 1
160 # (kind, status, proposer, target, value, codehash, dataHash, proposedBlock, readyBlock, vetoCount)
161 sed -E 's/^\((.*)\)$/\1/; s/ \[[^]]*\]//g' <<<"$raw" | awk -F', ' '{print $1, $2, $4, $9, $10, $8}'
162}
163
164# clearance <id>: the evidence hash AirlockAdmin recorded for the proposal, or 0x00..00 when it is not cleared.
165clearance() { rpc_retry cast call "$(dep airlockAdmin)" 'clearanceOf(uint256)(bytes32)' "$1" --rpc-url "$RPC"; }
166
167# oracle_judged <id>: true once the clearance oracle (the CRE receiver) has answered the proposal with a Clear or a
168# Veto, false while it has not (also after an approver's clearance: the sweeper still judges it, and a CRE veto
169# wins), "?" when the deployment has no AirlockAdmin.oracleJudged.
170oracle_judged() { cast call "$(dep airlockAdmin)" 'oracleJudged(uint256)(bool)' "$1" --rpc-url "$RPC" 2>/dev/null || say '?'; }
171
172# state_name <status> <clearance>: Pending (not judged yet) / Cleared (pending, may execute after readyBlock) /
173# Vetoed / Executed.
174state_name() {
175 if [ "$1" = 1 ] && [ "$2" != "$ZERO32" ]; then say Cleared; else say "${STATUS_NAMES[$1]}"; fi
176}
177
178# rule_name <ruleHash>: R1_TOTAL_SUPPLY etc.
179rule_name() {
180 local want r
181 want=$(lc "$1")
182 [ "$want" = "$ZERO32" ] && { say "-"; return; }
183 for r in "${RULE_IDS[@]}"; do
184 [ "$(cast keccak "GUARD/$r")" = "$want" ] && { say "$r"; return; }
185 done
186 say "unknown($1)"
187}
188
189# impl_name <address>: the deployment key that has this address (v2, evilMint, receiver, emergencyGuardian, ...),
190# or the address.
191impl_name() {
192 local a k
193 a=$(lc "$1")
194 k=$(jq -r --arg a "$a" '[to_entries[] | select((.value|type)=="string" and (.value|ascii_downcase)==$a) | .key][0] // empty' "$DEPLOY_JSON")
195 if [ -n "$k" ]; then say "$k"; else say "$1"; fi
196}
197
198# who_name <address>: receiver (CRE), approver, emergencyGuardian, owner, or the address.
199who_name() {
200 local n
201 n=$(impl_name "$1")
202 case "$n" in receiver) say "CRE receiver" ;; *) say "$n" ;; esac
203}
204
205# events_since <fromBlock>: one line per AirlockAdmin judgement event, oldest first:
206# "vetoed <id> <ruleHash> <guardian> <tx> <block>"
207# "cleared <id> <evidenceHash> <clearer> <tx> <block>"
208# "reason <id> <reportHash> <guardian> <tx> <block>"
209# "executed <id> - <executor> <tx> <block>"
210# One eth_getLogs from the deployment block is refused by most public RPCs (publicnode answers some requests with
211# 4444 "pruned history unavailable", drpc's free plan caps the range), so the scan runs in windows of EVENTS_CHUNK
212# blocks (default 5000). Each window is asked of every history RPC in turn, for up to RPC_TRIES rounds 2 s apart:
213# live, HISTORY_RPC (comma-separated) or else SEPOLIA_RPC and then the Tenderly public gateway, which keeps the full
214# history; a fork run reads only its anvil (HISTORY_RPC is ignored there). A window no RPC serves is skipped with a
215# warning on stderr (rows in it show '-' for the rule, evidence and verdict tx), and the function never fails, so
216# `set -e` callers keep going.
217# EVENTS_FROM (a block number) starts the scan later.
218HISTORY_FALLBACK=https://sepolia.gateway.tenderly.co
219events_since() {
220 local ga tv tc tr te from to lo hi chunk url got round all='' skipped=0
221 local -a urls
222 ga=$(dep airlockAdmin)
223 from=${EVENTS_FROM:-$1}
224 chunk=${EVENTS_CHUNK:-5000}
225 [[ "$from" =~ ^[0-9]+$ ]] || { say "warning: event scan start '$from' is not a block number; no events read" >&2; return 0; }
226 [[ "$chunk" =~ ^[1-9][0-9]*$ ]] || chunk=5000
227 if [ "$MODE" = fork ]; then
228 urls=("$RPC") # a fork run talks to the loopback anvil only, whatever HISTORY_RPC says
229 elif [ -n "${HISTORY_RPC:-}" ]; then
230 IFS=',' read -r -a urls <<<"$HISTORY_RPC"
231 else
232 urls=("$RPC" "$HISTORY_FALLBACK")
233 fi
234 [ "${#urls[@]}" -gt 0 ] || urls=("$RPC")
235 to=$(block_now 2>/dev/null) || { say "warning: no block number from $RPC; no events read" >&2; return 0; }
236 tv=$(cast keccak 'Vetoed(uint256,address,bytes32,uint256)')
237 tc=$(cast keccak 'Cleared(uint256,address,bytes32)')
238 tr=$(cast keccak 'ReasonAttached(uint256,address,bytes32)')
239 te=$(cast keccak 'Executed(uint256,address)')
240 for ((lo = from; lo <= to; lo += chunk)); do
241 hi=$((lo + chunk - 1))
242 [ "$hi" -le "$to" ] || hi=$to
243 got=
244 for ((round = 1; round <= ${RPC_TRIES:-3}; round++)); do
245 for url in "${urls[@]}"; do
246 if got=$(logs_window "$url" "$lo" "$hi" "$ga"); then break 2; fi
247 got=
248 done
249 [ "$round" -lt "${RPC_TRIES:-3}" ] && sleep 2
250 done
251 if [ -n "$got" ]; then all+="$got"$'\n'; else skipped=$((skipped + 1)); say "warning: no RPC served AirlockAdmin events in blocks $lo-$hi" >&2; fi
252 done
253 [ "$skipped" = 0 ] || say "warning: $skipped event window(s) skipped: rows there show '-' for the rule, evidence and verdict tx (set HISTORY_RPC to an RPC that keeps the history, or EVENTS_FROM to a recent block)" >&2
254 [ -n "$all" ] || return 0
255 jq -rs --arg tv "$tv" --arg tc "$tc" --arg tr "$tr" --arg te "$te" '
256 def w(i): .data[2 + 64 * i: 66 + 64 * i];
257 def addr(x): "0x" + x[24:64];
258 def num: ltrimstr("0x") | ascii_downcase | explode
259 | reduce .[] as $c (0; . * 16 + (if $c >= 97 then $c - 87 else $c - 48 end));
260 add | .[] | . as $l
261 | (if .topics[0] == $tv then "vetoed \(.topics[1] | num) 0x\(w(0)) \(addr(.topics[2][2:]))"
262 elif .topics[0] == $tc then "cleared \(.topics[1] | num) 0x\(w(0)) \(addr(.topics[2][2:]))"
263 elif .topics[0] == $tr then "reason \(.topics[1] | num) 0x\(w(1)) \(addr(w(0)))"
264 elif .topics[0] == $te then "executed \(.topics[1] | num) - \(addr(w(0)))"
265 else empty end) + " \($l.transactionHash) \($l.blockNumber | num)"' <<<"$all" \
266 || { say "warning: could not parse the event logs; no events read" >&2; return 0; }
267}
268
269# logs_window <rpc> <from> <to> <address>: one eth_getLogs; the JSON array on stdout, or status 1 (cast prints an
270# error object instead of an array when the RPC refuses).
271logs_window() {
272 local out
273 out=$(cast logs --from-block "$2" --to-block "$3" --address "$4" --rpc-url "$1" --json 2>/dev/null) || return 1
274 jq -e 'type == "array"' >/dev/null 2>&1 <<<"$out" || return 1
275 printf '%s\n' "$out"
276}
277
278# first_event <kind> <id> <events text>: the first matching line ("kind id value who tx block") or nothing.
279first_event() { awk -v k="$1" -v id="$2" '$1==k && $2==id && !f {print; f=1}' <<<"$3"; }
280
281fmt_secs() { local s=$1; if [ "$s" -ge 60 ]; then printf '%dm%02ds' $((s / 60)) $((s % 60)); else printf '%ds' "$s"; fi; }
282short() { local h=$1; if [ "${#h}" -gt 14 ]; then printf '%s..%s' "${h:0:8}" "${h: -4}"; else printf '%s' "$h"; fi; }
283
284# ---------------------------------------------------------------- CRE simulator
285
286# The workflow config for this mode: live -> cre/veto/config.sepolia.json (make-config.mjs writes it);
287# fork -> generated into $STATE from the fork deployment, every provider pointing at the fork.
288workflow_config() {
289 if [ "$MODE" = fork ]; then
290 CONFIG=$STATE/config.fork.json
291 if [ ! -f "$CONFIG" ] || [ "$DEPLOY_JSON" -nt "$CONFIG" ] || [ "$KIT/make-config.mjs" -nt "$CONFIG" ]; then
292 # bun checks the result against the workflow's own schema (cre/veto/config.ts); node writes it unchecked
293 "$(command -v bun || echo node)" "$KIT/make-config.mjs" "$DEPLOY_JSON" --fork-rpc "$RPC" --out "$CONFIG" >/dev/null
294 fi
295 TARGET=sepolia-fork
296 export VETO_ANVIL_PORT=$FORK # cre/project.yaml: sepolia-fork -> http://127.0.0.1:${VETO_ANVIL_PORT}
297 else
298 CONFIG=${WORKFLOW_CONFIG:-$ROOT/cre/veto/config.sepolia.json}
299 [ -f "$CONFIG" ] || die "no $(basename "$CONFIG") (run: node scripts/sepolia/make-config.mjs)"
300 TARGET=sepolia
301 fi
302 local want have
303 want=$(lc "$(dep airlockAdmin)")
304 have=$(lc "$(jq -r '.airlockAdmin // empty' "$CONFIG")")
305 [ "$have" = "$want" ] || die "$(basename "$CONFIG") watches '${have:-nothing}', the deployment is $want (re-run make-config.mjs)"
306}
307
308# attester_env: the environment variable the simulator reads the attester secret from (cre/secrets.yaml).
309attester_env() {
310 local n
311 n=$(awk '/^secretsNames:/ {s=1; next} s && /^[[:space:]]+-[[:space:]]+[A-Z0-9_]+/ {print $2; exit}' "$ROOT/cre/secrets.yaml" 2>/dev/null || true)
312 say "${n:-VETO_ATTESTER_KEY_ALL}"
313}
314
315# sim_bin: the CRE CLI to run (CRE_SIM, default `cre`).
316sim_bin() {
317 SIM=${CRE_SIM:-cre}
318 command -v "$SIM" >/dev/null 2>&1 || die "CRE CLI '$SIM' not found (cre/setup.sh installs it; or set CRE_SIM)"
319}
320
321# wasm_build: compiles the workflow once per source version (cre workflow build) and sets WASM_ARGS, so the
322# listener and the sweeper run the same binary and a sweep does not recompile every minute. The cache is
323# <state dir>/wasm/<hash>.wasm, or $TMPDIR/airlock-wasm-<uid>/ when that path is longer than the CLI's 97-character
324# limit for --wasm (the directory must belong to this user). WASM_CACHE=0 skips the cache (the simulator then
325# compiles on every run).
326wasm_build() {
327 WASM_ARGS=()
328 [ "${WASM_CACHE:-1}" = 1 ] || return 0
329 local h dir out tmp log
330 h=$(cd "$ROOT/cre/veto" && cat ./*.ts package.json bun.lock 2>/dev/null | shasum -a 256 | cut -c1-16)
331 dir=$STATE/wasm
332 [ $((${#dir} + 22)) -le 97 ] || dir=$TMP_BASE/airlock-wasm-$(id -u)
333 out=$dir/$h.wasm
334 if [ ${#out} -gt 97 ]; then
335 say "note: no workflow cache (the path $out is longer than the CLI accepts); the simulator compiles on each run" >&2
336 return 0
337 fi
338 mkdir -p "$dir" && chmod 700 "$dir"
339 [ -O "$dir" ] || { say "note: $dir is not owned by this user; the simulator compiles on each run" >&2; return 0; }
340 if [ ! -s "$out" ]; then
341 log=$dir/build-$h.log
342 tmp=$dir/building-$$.wasm # the CLI appends .wasm to an output path without it
343 say "compiling the workflow once for this source version (cre workflow build, about a minute) ..." >&2
344 if (cd "$ROOT/cre" && "$SIM" workflow build ./veto -o "$tmp" >"$log" 2>&1) && [ -s "$tmp" ]; then
345 mv "$tmp" "$out"
346 find "$dir" -name '*.wasm' ! -name "$h.wasm" ! -name 'building-*' -delete 2>/dev/null || true
347 else
348 rm -f "$tmp" "$tmp.wasm"
349 say "note: workflow build failed (log $log); the simulator compiles it on each run instead" >&2
350 return 0
351 fi
352 fi
353 WASM_ARGS=(--wasm "$out")
354}