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 / cre / scripts / fork-e2e.sh
cre/scripts/fork-e2e.sh275 lines · 19219 bytes
1#!/usr/bin/env bash
2# End to end on a local anvil fork of Ethereum Sepolia: nothing is sent to a live network and no workflow is
3# deployed. The stack is deployed on the fork, then the CRE workflow runs in the CRE CLI simulator with --broadcast
4# (its reports go through Sepolia's MockKeystoneForwarder on the fork) and every outcome is checked on-chain.
5#
6# handler 0, log trigger (the Proposed log replayed by tx hash):
7# EvilMint -> Vetoed R1, EvilOwner -> Vetoed R2, EvilFee -> Vetoed R3, EvilBaseFee -> Vetoed R9 (BASEFEE)
8# EvilDrain (an open rescue(from, to, amount); supply, owner and transfers intact) -> Vetoed R3B, caught by
9# the probe that puts the funded holder first
10# drain variants A-G (test/fixtures/DrainVariants.sol, each inheriting DemoBTC so only R3B can catch them):
11# A reordered args rescue(from, amount, to); B sweep(amount, from, to); C rescueTokens(token, from, to, amount)
12# with require(token == this); D rescue gated on the caller holding tokens; E rescue with no Transfer event
13# (out-and-back, net zero); F rescue gated on the caller holding ETH; G transferFrom that waives its allowance
14# check for a token-holding caller -> every one Vetoed R3B (the simulation funds the stranger with tokens and
15# ETH, probes new selectors with the holder in each argument slot, and reads balances after each placement probe)
16# honest v2 -> Cleared by CRE, executes after the cooldown
17# owner proposes removing the receiver as guardian -> Vetoed R7 (the approver is a separate key)
18# the approver pre-approves a shorter delay, the owner proposes it -> Cleared
19# handler 1, cron sweeper (the simulator runs a cron handler once per invocation):
20# an honest upgrade nobody judged cannot execute after the cooldown (NotCleared); one sweeper run clears it
21# the approver clears two proposals by hand and their logs are missed: the sweeper still judges both (an
22# approver clearance is not a CRE verdict, AirlockAdmin.oracleJudged): EvilDrain -> Vetoed R3B (the veto wins
23# over the clearance), a longer delay -> CRE Clear, clearanceOf keeps the approver's evidence
24# an upgrade with ~6 KB of init data -> one sweeper run vetoes it R8 (its Proposed log is over CRE's 5 kb log
25# event limit, so in a deployment only the sweeper sees it; the simulator does not enforce that limit)
26# every verdict's evidenceHash is recomputed from getProposal alone (veto/tools/check-evidence.ts)
27# maxCalls 150 (the live Sepolia value) splits the demo token's probes over two eth_simulateV1 requests per provider.
28#
29# Env: CRE_SIM simulator binary (default `cre`, which needs `cre login`; a login-free build of the same CLI works)
30# SEPOLIA_RPC fork source (default publicnode)
31# VETO_ANVIL_PORT (default 19611), KEEP_FORK=1 leaves the anvil fork running at the end
32# Keys: anvil's public test mnemonic only: 0 owner/deployer, 2 attester, 3 report sender, 4 approver,
33# 5 emergency guardian. Nothing secret.
34set -euo pipefail
35cd "$(dirname "$0")/.." # cre/
36ROOT=$(cd .. && pwd)
37export PATH="$PWD/.tools/bin:$PWD/.tools/node_modules/.bin:$HOME/.foundry/bin:$PATH"
38SIM=${CRE_SIM:-cre}
39PORT=${VETO_ANVIL_PORT:-19611}
40RPC=http://127.0.0.1:$PORT
41SRC=${SEPOLIA_RPC:-https://ethereum-sepolia-rpc.publicnode.com}
42M="test test test test test test test test test test test junk"
43key() { cast wallet private-key --mnemonic "$M" --mnemonic-index "$1"; }
44addr() { cast wallet address --private-key "$(key "$1")"; }
45DPK=$(key 0)
46OWNER=$(addr 0)
47export VETO_ATTESTER_KEY_ALL=$(key 2)
48export CRE_ETH_PRIVATE_KEY=$(key 3)
49APPROVER=$(addr 4)
50EMERGENCY=$(addr 5)
51export VETO_ANVIL_PORT=$PORT
52OUT=$(mktemp -d "${TMPDIR:-/tmp}/airlock-fork-e2e.XXXXXX")
53echo "evidence dir: $OUT"
54
55anvil --fork-url "$SRC" --port "$PORT" --silent >"$OUT/anvil.log" 2>&1 &
56ANVIL=$!
57echo "$ANVIL" >"$OUT/anvil.pid"
58if [ "${KEEP_FORK:-0}" = 1 ]; then echo "anvil fork pid $ANVIL on $RPC (KEEP_FORK=1: stop it with kill $ANVIL)"
59else trap 'kill $ANVIL 2>/dev/null || true' EXIT; fi
60for _ in $(seq 1 60); do cast chain-id --rpc-url "$RPC" >/dev/null 2>&1 && break; sleep 1; done
61[ "$(cast chain-id --rpc-url "$RPC")" = 11155111 ] || { echo "fork is not Sepolia" >&2; exit 1; }
62
63# Deploy. Sepolia runs the Amsterdam rules (contract creation costs ~7x Osaka): the node estimates gas
64# (--skip-simulation). Build output and broadcast logs stay in $OUT; the JSON goes through cache/ (foundry
65# fs_permissions) under a per-run name and is moved out.
66DJSON="cache/airlock-fork-e2e.$$.json"
67# The simulator writes through the permissionless MockKeystoneForwarder (the attester alone authenticates a verdict):
68# DeployEth refuses it without ALLOW_MOCK_FORWARDER=1, and refuses an attester shared with another role.
69( cd "$ROOT" && FOUNDRY_PROFILE=eth FOUNDRY_BROADCAST="$OUT/broadcast" DEPLOYER_PK=$DPK ATTESTER_ADDR=$(addr 2) \
70 APPROVER_ADDR=$APPROVER EMERGENCY_GUARDIAN_ADDR=$EMERGENCY ALLOW_MOCK_FORWARDER=1 OUT_JSON=$DJSON \
71 forge script script/DeployEth.s.sol --rpc-url "$RPC" --broadcast --slow --skip-simulation \
72 --out "$OUT/forge-out" --cache-path "$OUT/forge-cache" >"$OUT/deploy.log" 2>&1 ) \
73 || { tail -30 "$OUT/deploy.log"; exit 1; }
74mv "$ROOT/$DJSON" "$OUT/deployment.json"
75D="$OUT/deployment.json"
76GA=$(jq -r .airlockAdmin "$D"); RX=$(jq -r .receiver "$D"); PROXY=$(jq -r .proxy "$D"); DELAY=$(jq -r .delayBlocks "$D")
77echo "AirlockAdmin $GA AirlockReceiver $RX proxy $PROXY delay $DELAY blocks"
78jq --arg rpc "$RPC" '{chainSelectorName:"ethereum-testnet-sepolia", chainId, airlockAdmin, receiver, proxy, holders,
79 watch:[.owner,.airlockAdmin], rpcs:[{url:$rpc,label:"fork-a"},{url:$rpc,label:"fork-b"},{url:$rpc,label:"fork-c"}],
80 minAgree:2, strict:false, callGas:1000000, readGas:200000, maxCalls:150, simRequests:2, allowedOpcodes:[],
81 gasLimit:"2000000", reportGraceBlocks:50, logConfidence:"LATEST", sweepSchedule:"0 * * * * *", sweepWindow:20}' \
82 "$D" > veto/config.fork.json
83cp veto/config.fork.json "$OUT/config.fork.json"
84# Compile the workflow once (TypeScript typecheck + WASM); every simulator run below reuses the binary.
85"$SIM" workflow build ./veto -T sepolia-fork -o "$OUT/airlock.wasm" >"$OUT/build.log" 2>&1 || { tail -30 "$OUT/build.log"; exit 1; }
86
87ZERO=0x0000000000000000000000000000000000000000000000000000000000000000
88IMPL_SLOT=0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
89fail=0
90ok() { echo "ok $*"; }
91bad() { echo "FAIL $*"; fail=1; }
92rule_hash() { cast keccak "GUARD/$1"; }
93status_of() { cast call "$GA" "getProposal(uint256)((uint8,uint8,address,address,uint256,bytes32,bytes32,uint64,uint64,uint256))" "$1" --rpc-url "$RPC" | sed -E 's/^\(([0-9]+), ([0-9]+),.*/\2/'; }
94clearance_of() { cast call "$GA" "clearanceOf(uint256)(bytes32)" "$1" --rpc-url "$RPC"; }
95judged_of() { cast call "$GA" "oracleJudged(uint256)(bool)" "$1" --rpc-url "$RPC"; }
96last_id() { echo $(( $(cast call "$GA" "nextId()(uint256)" --rpc-url "$RPC") - 1 )); }
97# "action ruleHash evidenceHash" of the newest AirlockReceiver.Verdict for proposal $1 (empty if none)
98verdict_of() {
99 cast logs --from-block "$(jq -r .startBlock "$D")" --to-block latest --address "$RX" \
100 "Verdict(uint256 indexed,uint8 indexed,bytes32,bytes32,bytes32)" "$(cast to-uint256 "$1")" --rpc-url "$RPC" --json \
101 | jq -r 'last // empty | "\(.topics[2] | ltrimstr("0x") | sub("^0+";"") | if . == "" then "0" else . end) 0x\(.data[2:66]) 0x\(.data[66:130])"'
102}
103user_logs() { grep -E '\[USER LOG\]' "$1" | sed 's/^.*\[USER LOG\] / | /' | cut -c1-220; }
104simulate() { # name trigger-index [tx-hash]
105 local name=$1 idx=$2 tx=${3:-}
106 local args=(workflow simulate ./veto -T sepolia-fork --non-interactive --trigger-index "$idx" --broadcast --wasm "$OUT/airlock.wasm")
107 [ -n "$tx" ] && args+=(--evm-tx-hash "$tx" --evm-event-index 0)
108 if ! "$SIM" "${args[@]}" >"$OUT/sim-$name.log" 2>&1; then
109 bad "$name: simulator exited non-zero"; tail -25 "$OUT/sim-$name.log"; return 1
110 fi
111 user_logs "$OUT/sim-$name.log"
112}
113propose() { # name, then the cast send signature and args; prints "id tx"
114 local name=$1; shift
115 local tx; tx=$(cast send "$GA" "$@" --private-key "$DPK" --rpc-url "$RPC" --json | jq -r .transactionHash)
116 echo "$name $(last_id) $tx" >>"$OUT/cases.txt"
117 echo "$(last_id) $tx"
118}
119expect_veto() { # name id rule
120 local v; v=$(verdict_of "$2"); local st; st=$(status_of "$2")
121 read -r act rh ev <<<"${v:-x x x}"
122 if [ "$st" = 2 ] && [ "$act" = 0 ] && [ "$rh" = "$(rule_hash "$3")" ]; then ok "$1: #$2 Vetoed by CRE with $3 (evidence $ev)"
123 else bad "$1: #$2 status $st verdict '${v:-none}', expected Vetoed $3"; fi
124 echo "$2" >>"$OUT/vetoed.txt"
125}
126expect_clear() { # name id
127 local v; v=$(verdict_of "$2"); local st; st=$(status_of "$2"); local c; c=$(clearance_of "$2")
128 read -r act rh ev <<<"${v:-x x x}"
129 if [ "$st" = 1 ] && [ "$act" = 1 ] && [ "$rh" = "$ZERO" ] && [ "$c" = "$ev" ] && [ "$c" != "$ZERO" ]; then ok "$1: #$2 Cleared by CRE (clearanceOf = evidence $c)"
130 else bad "$1: #$2 status $st clearance $c verdict '${v:-none}', expected Cleared"; fi
131}
132log_case() { # name impl rule
133 local r; r=$(propose "$1" "proposeUpgrade(address,bytes)" "$2" 0x); read -r id tx <<<"$r"
134 echo "-- $1: proposal #$id (log trigger)"
135 simulate "$1" 0 "$tx" || return 0
136 expect_veto "$1" "$id" "$3"
137}
138# forge create one of the test-only drain fixtures on the fork and print its address.
139create_fixture() { # Contract -> deployed address
140 ( cd "$ROOT" && FOUNDRY_PROFILE=eth forge create "test/fixtures/DrainVariants.sol:$1" \
141 --rpc-url "$RPC" --private-key "$DPK" --broadcast \
142 --out "$OUT/forge-out" --cache-path "$OUT/forge-cache" --json 2>>"$OUT/create.log" | jq -r .deployedTo )
143}
144# Deploy a drain variant, propose an upgrade to it, and expect it Vetoed R3B with every other rule passing.
145variant_case() { # label Contract
146 local label=$1 contract=$2 impl
147 impl=$(create_fixture "$contract")
148 if [ -z "$impl" ] || [ "$impl" = null ]; then bad "$label: forge create $contract failed"; tail -5 "$OUT/create.log"; return 0; fi
149 echo "$label $impl" >>"$OUT/variants.txt"
150 echo "-- $label: $contract at $impl (log trigger)"
151 local r id tx; r=$(propose "$label" "proposeUpgrade(address,bytes)" "$impl" 0x); read -r id tx <<<"$r"
152 simulate "$label" 0 "$tx" || return 0
153 expect_veto "$label" "$id" R3B_THIRD_PARTY_FUNDS
154 if grep -qE 'R1_TOTAL_SUPPLY|R2_OWNER|R3_TRANSFER_CONSERVATION|R4_TRANSFER_LIVENESS|R9_NEW_OPCODES' <(grep 'simulation veto' "$OUT/sim-$label.log"); then
155 bad "$label: another rule failed too (expected R3B alone)"
156 else ok "$label: supply, owner, transfers and opcodes all pass; only R3B fails"; fi
157}
158
159# ---- handler 0: log trigger
160log_case evil-mint "$(jq -r .evilMint "$D")" R1_TOTAL_SUPPLY
161log_case evil-owner "$(jq -r .evilOwner "$D")" R2_OWNER
162log_case evil-fee "$(jq -r .evilFee "$D")" R3_TRANSFER_CONSERVATION
163log_case evil-basefee "$(jq -r .evilBaseFee "$D")" R9_NEW_OPCODES
164if grep -q 'R9_NEW_OPCODES:.*found: BASEFEE' "$OUT/sim-evil-basefee.log"; then ok "evil-basefee: R9 names the BASEFEE opcode"
165else bad "evil-basefee: the simulation log does not name BASEFEE"; fi
166RESCUE=$(cast sig "rescue(address,address,uint256)")
167log_case evil-drain "$(jq -r .evilDrain "$D")" R3B_THIRD_PARTY_FUNDS
168# a placement probe with the holder in slot 0 (from) drains rescue; its effects include moved-others-funds
169if grep -qE "$RESCUE\(h0,[^)]*\):[a-z+-]*moved-others-funds" "$OUT/sim-evil-drain.log"; then
170 ok "evil-drain: the culprit is rescue $RESCUE, caught by a holder-in-slot-0 placement probe"
171else bad "evil-drain: the simulation log does not name a rescue $RESCUE(h0,...):moved-others-funds culprit"; fi
172if grep -qE 'R1_TOTAL_SUPPLY|R2_OWNER|R3_TRANSFER_CONSERVATION|R4_TRANSFER_LIVENESS|R9_NEW_OPCODES' <(grep 'simulation veto' "$OUT/sim-evil-drain.log"); then
173 bad "evil-drain: another rule failed too (expected R3B alone)"
174else ok "evil-drain: supply, owner, transfers and opcodes all pass; only R3B fails"; fi
175if grep -q 'in 2 request(s)' "$OUT/sim-evil-drain.log"; then ok "simulation: the probes went out as two eth_simulateV1 requests per provider"
176else bad "simulation: expected two eth_simulateV1 requests per provider at maxCalls 150"; fi
177
178# ---- handler 0: the reviewer's R3B evasions. Each must still end Vetoed R3B, with every other rule passing.
179variant_case variant-a VariantA_RescueReorder
180variant_case variant-b VariantB_SweepReorder
181variant_case variant-c VariantC_RescueTokensGuard
182variant_case variant-d VariantD_RescueRequireTokens
183variant_case variant-e VariantE_RescueNoLog
184variant_case variant-f VariantF_RescueRequireEth
185variant_case variant-g VariantG_TransferFromAllowanceSkip
186
187# ---- request sizes against the CRE HTTP limits: DemoBTCV2 (honest) and the largest variant (4-arg rescueTokens)
188echo "-- request sizes (DemoBTCV2 and the largest variant) vs the 120 kb request / 15 HTTP-call limits"
189VC=$(awk '$1=="variant-c"{print $2}' "$OUT/variants.txt" 2>/dev/null)
190if [ -n "$VC" ]; then
191 ( cd veto && bun tools/measure-requests.ts "$RPC" "$D" "$OUT/config.fork.json" "DemoBTCV2=$(jq -r .v2 "$D")" "variant-c=$VC" ) | sed 's/^/ /' \
192 || bad "measure: a request exceeds a CRE HTTP size limit"
193else bad "measure: variant-c was not deployed"; fi
194
195read -r V2ID tx <<<"$(propose honest-v2 "proposeUpgrade(address,bytes)" "$(jq -r .v2 "$D")" 0x)"
196echo "-- honest-v2: proposal #$V2ID (log trigger)"
197simulate honest-v2 0 "$tx" && expect_clear honest-v2 "$V2ID"
198
199# Nobody judges this one: no log-trigger run.
200read -r LATEID _ <<<"$(propose unjudged-v2 "proposeUpgrade(address,bytes)" "$(jq -r .v2 "$D")" 0x)"
201echo "-- unjudged-v2: proposal #$LATEID (no workflow run)"
202
203read -r GID tx <<<"$(propose drop-receiver "proposeSetGuardian(address,bool)" "$RX" false)"
204echo "-- drop-receiver: proposal #$GID removes the receiver as guardian (log trigger)"
205simulate drop-receiver 0 "$tx" && expect_veto drop-receiver "$GID" R7_CONFIG_GUARD
206
207# The approver (its own key) pre-approves keccak256(abi.encode(uint8 kind=2, address 0, uint256 20)).
208cast send "$GA" "approveCodehash(bytes32)" "$(cast keccak "$(cast abi-encode "f(uint8,address,uint256)" 2 0x0000000000000000000000000000000000000000 20)")" \
209 --private-key "$(key 4)" --rpc-url "$RPC" >/dev/null
210read -r DID tx <<<"$(propose approved-delay "proposeSetDelay(uint64)" 20)"
211echo "-- approved-delay: proposal #$DID shortens the delay to 20 blocks, pre-approved by the approver (log trigger)"
212simulate approved-delay 0 "$tx" && expect_clear approved-delay "$DID"
213
214# ---- the cooldown passes
215cast rpc anvil_mine "$(printf '0x%x' "$DELAY")" --rpc-url "$RPC" >/dev/null
216if cast send "$GA" "execute(uint256)" "$V2ID" --private-key "$DPK" --rpc-url "$RPC" >/dev/null 2>&1 \
217 && [ "$(cast storage "$PROXY" "$IMPL_SLOT" --rpc-url "$RPC" | cast parse-bytes32-address)" = "$(cast to-check-sum-address "$(jq -r .v2 "$D")")" ]; then
218 ok "honest-v2: #$V2ID executed after $DELAY blocks, the proxy now runs v2"
219else bad "honest-v2: #$V2ID did not execute"; fi
220NOT_CLEARED=$(cast sig "NotCleared(uint256)")
221if err=$(cast call "$GA" "execute(uint256)" "$LATEID" --from "$OWNER" --rpc-url "$RPC" 2>&1); then bad "unjudged-v2: #$LATEID would execute without a clearance"
222elif grep -q "$NOT_CLEARED" <<<"$err"; then ok "unjudged-v2: #$LATEID cannot execute after the cooldown (NotCleared $NOT_CLEARED)"
223else bad "unjudged-v2: #$LATEID execute reverted for another reason: $(tail -1 <<<"$err")"; fi
224
225# ---- handler 1: the sweeper
226echo "-- sweeper run 1"
227if simulate sweep-1 1; then
228 expect_clear unjudged-v2 "$LATEID"
229 if cast send "$GA" "execute(uint256)" "$LATEID" --private-key "$DPK" --rpc-url "$RPC" >/dev/null 2>&1; then ok "unjudged-v2: #$LATEID executes once the sweeper cleared it"
230 else bad "unjudged-v2: #$LATEID still cannot execute"; fi
231fi
232
233# The approver clears two proposals by hand, and the log trigger never runs for them.
234MANUAL=$(cast keccak "approver: manual review")
235read -r ADID _ <<<"$(propose approver-drain "proposeUpgrade(address,bytes)" "$(jq -r .evilDrain "$D")" 0x)"
236read -r ACID _ <<<"$(propose approver-delay "proposeSetDelay(uint64)" 30)"
237for id in "$ADID" "$ACID"; do cast send "$GA" "clear(uint256,bytes32)" "$id" "$MANUAL" --private-key "$(key 4)" --rpc-url "$RPC" >/dev/null; done
238echo "-- approver-drain #$ADID (EvilDrain) and approver-delay #$ACID (delay 30) cleared by the approver, logs missed"
239if [ "$(clearance_of "$ADID")" = "$MANUAL" ] && [ "$(judged_of "$ADID")" = false ] && [ "$(judged_of "$ACID")" = false ]; then
240 ok "approver: #$ADID and #$ACID carry the approver's clearance, oracleJudged false"
241else bad "approver: unexpected state before the sweep (clearance $(clearance_of "$ADID"), oracleJudged $(judged_of "$ADID")/$(judged_of "$ACID"))"; fi
242echo "-- sweeper run 2"
243if simulate sweep-2 1; then
244 if grep -q "pending without a workflow verdict: #$ADID(approver-cleared) #$ACID(approver-cleared)\$" "$OUT/sim-sweep-2.log"; then
245 ok "sweeper: run 2 picked up exactly the two approver-cleared proposals"
246 else bad "sweeper: run 2 did not list exactly #$ADID and #$ACID as approver-cleared candidates"; fi
247 expect_veto approver-drain "$ADID" R3B_THIRD_PARTY_FUNDS
248 [ "$(judged_of "$ADID")" = true ] && ok "approver-drain: #$ADID oracleJudged after the CRE veto" || bad "approver-drain: #$ADID oracleJudged still false"
249 v=$(verdict_of "$ACID"); read -r act rh ev <<<"${v:-x x x}"
250 if [ "$(status_of "$ACID")" = 1 ] && [ "$act" = 1 ] && [ "$(judged_of "$ACID")" = true ] && [ "$(clearance_of "$ACID")" = "$MANUAL" ] && [ "$ev" != "$MANUAL" ]; then
251 ok "approver-delay: #$ACID CRE Clear (evidence $ev) on top of the approver's; clearanceOf keeps the first, oracleJudged true"
252 else bad "approver-delay: #$ACID status $(status_of "$ACID") verdict '${v:-none}' oracleJudged $(judged_of "$ACID") clearance $(clearance_of "$ACID")"; fi
253fi
254
255BIG=0x$(printf '%*s' 12288 '' | tr ' ' 'a') # 6144 bytes of 0xaa
256read -r BIGID _ <<<"$(propose big-init "proposeUpgrade(address,bytes)" "$(jq -r .v2 "$D")" "$BIG")"
257echo "-- big-init: proposal #$BIGID with $(( (${#BIG} - 2) / 2 )) bytes of init data (no log-trigger run)"
258echo "-- sweeper run 3"
259simulate sweep-3 1 && expect_veto big-init "$BIGID" R8_INIT_DATA
260if grep -q "pending without a workflow verdict: #$BIGID\$" "$OUT/sim-sweep-3.log"; then ok "sweeper: run 3 found only #$BIGID to judge"
261else bad "sweeper: run 3 judged more than #$BIGID"; fi
262
263# ---- a vetoed proposal never executes
264cast rpc anvil_mine "$(printf '0x%x' "$DELAY")" --rpc-url "$RPC" >/dev/null
265VETOED=$(cast sig "ProposalVetoed(uint256)")
266for id in $(cat "$OUT/vetoed.txt" 2>/dev/null); do
267 if err=$(cast call "$GA" "execute(uint256)" "$id" --from "$OWNER" --rpc-url "$RPC" 2>&1); then bad "#$id would execute although vetoed"
268 elif grep -q "$VETOED" <<<"$err"; then ok "#$id execute() reverts ProposalVetoed after the cooldown"
269 else bad "#$id execute reverted for another reason: $(tail -1 <<<"$err")"; fi
270done
271
272echo "-- evidence"
273( cd veto && bun tools/check-evidence.ts "$RPC" "$D" ) | sed 's/^/ /' || bad "evidence: a verdict's evidenceHash does not match its proposal"
274
275[ $fail = 0 ] && echo "FORK E2E: ALL PASS (logs in $OUT)" || { echo "FORK E2E: FAILURES (logs in $OUT)"; exit 1; }