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 / README.md
cre/README.md191 lines · 16523 bytes

Upgrade Airlock: the CRE clearance workflow

A Chainlink CRE workflow that is AirlockAdmin's clearance oracle. Every proposal (an upgrade or a change to the airlock itself) needs a clearance before it can execute. The workflow judges each proposal and always ends in an attested on-chain report: Clear when every check passes, Veto when one fails. A proposal no verdict has reached has no clearance and cannot execute, so a missed log or a failed run only delays an upgrade. A clearance the approver gives by hand does not count as a verdict of this workflow (AirlockAdmin.oracleJudged stays false), so the workflow still judges that proposal, and its Veto wins over the approver's clearance.

AirlockAdmin.Proposed ── handler 0: EVM log trigger (LATEST) ──┐
cron, default every 60 s ── handler 1: sweeper (newest 20 ids) ─┤
                                                                ▼
 (the sweeper takes every Pending proposal with oracleJudged false, approver-cleared ones included, oldest first)
 EVM reads, batched through Multicall3: getProposal + clearanceOf + oracleJudged at LATEST;
   approvals, holder balances, delay at the proposal block; the proposal block's hash (header by number)
 kinds 1-3 (guardian / delay / paused impl): R7 from those reads
 kind 0 (upgrade): R5 strict codehash, R8 init data, a funded holder; then node mode: every node (each DON node
   when deployed to a DON; the CLI simulator today)
   attack-simulates the upgrade on each RPC provider (eth_getCode, then 1 to simRequests eth_simulateV1 requests)
   -> R1-R4, R3B, R9; quorum: at least minAgree answers and all equal, otherwise R6 (fail closed)
 identical consensus on {verdict, rule} (across the DON's nodes in a DON deployment)
 evidence JSON -> evidenceHash; EIP-712 attestation (CRE secret); runtime.report -> writeReport
   -> (Mock)KeystoneForwarder -> AirlockReceiver.onReport
        pass -> AirlockAdmin.clear(id, evidenceHash)
        veto -> AirlockAdmin.veto(id, ruleHash) + attachReason(id, evidenceHash)
 EVM read of the proposal state (the forwarder swallows a reverting onReport: tx success is not proof)
File What it is
veto/workflow.ts Triggers and the CRE wiring: EVM reads with a per-run budget, node-mode simulation, secret, report
veto/airlock.ts What happens to a proposal: reads, verdict, report, confirmation; the sweeper's selection (SDK-free)
veto/judge.ts R7, the static upgrade rules, per-provider judging and the quorum
veto/simulate.ts The eth_simulateV1 attack simulation (probes split over requests) and its evaluation (R1-R4, R3B)
veto/bytecode.ts Selector extraction, the R9 opcode list and the solc metadata check
veto/report.ts Report encoding, EIP-712 attestation, proposalDigest, canonical evidence
veto/chain.ts Multicall3 batching under the ChainRead limits
veto/tools/check-evidence.ts Recomputes every on-chain verdict's evidenceHash from getProposal alone
scripts/fork-e2e.sh The full loop on an anvil fork of Sepolia
../src/cre/AirlockReceiver.sol The receiver: clearance oracle and one guardian of AirlockAdmin

Rules

Rule Vetoes when
R1 TOTAL_SUPPLY a stranger's call changes totalSupply
R2 OWNER a stranger's call changes the owner
R3 TRANSFER_CONSERVATION a holder's transfer does not move exactly the amount, or moves anyone else's balance
R3B THIRD_PARTY_FUNDS a stranger (funded first with tokens and ETH, so caller-gated drains are reached) can pull a holder's funds without allowance; or a stranger probe mints or burns, moves anyone else's tokens or ownership (from its own logs, even if a later call undoes it), or drops the holder's balance with no event
R4 TRANSFER_LIVENESS a plain holder transfer fails
R5 STRICT_CODEHASH strict mode and the codehash is not approved
R6 SIM_FAILURE the simulation cannot decide: providers disagree, too few answer, a view reverts, code mismatch, or the probes do not fit in simRequests requests (excused when the codehash is approved)
R7 CONFIG_GUARD a guardian change, a shorter delay or a new paused impl without the approver's pre-approval
R8 INIT_DATA an upgrade carries init data without a codehash+data approval
R9 NEW_OPCODES the new code contains an opcode from this list that the config's allowedOpcodes does not allow: block and fee context (TIMESTAMP, NUMBER, PREVRANDAO, COINBASE, BLOCKHASH, BASEFEE, GASPRICE, BLOBBASEFEE, BLOBHASH, GASLIMIT, GAS, ORIGIN) or calls/creates/selfdestruct, unless that codehash is approved

The stranger is funded first. Before the probes (and before the R3B transferFrom), the simulation makes the derived stranger look like a real user: a token balance (a transfer to it from a second holder, so the test holder's R3 accounting stays exactly -amount) and an ETH balance (a stateOverrides balance). That defeats drains gated on the caller holding tokens (require(balanceOf(msg.sender) != 0)), the caller holding ETH (require(msg.sender.balance != 0)), and a transferFrom that waives its allowance check for a token-holding caller. The funding transfer must succeed or the run fails closed (R6).

Probes. Every selector the dispatcher compares against is called by the funded stranger with five base shapes (the stranger; the stranger and a large number; a large number; none; a wide mix), plus empty and unknown-selector calldata, so a drain that shows itself through a Transfer/OwnershipTransferred event is caught however it is reached. Every selector that is new relative to the standard token / ownable / proxy interface also gets placement probes: the funded holder in each of the first four argument slots, the test amount (1% of the holder's balance) in each other slot, and the remaining slots filled once with the stranger and once with the proxy address. That covers a drain whatever order its arguments take (rescue(from, amount, to), sweep(amount, from, to), rescueTokens(token, from, to, amount) with require(token == address(this)) — the proxy-filled probe passes the guard). Right after every placement probe the holder's and the stranger's balances are read, so a drain that moves the holder's tokens out and a later probe moves them back in the same request — with no Transfer event — is still seen by the drop between the reads. EvilDrainImpl (an open rescue(from, to, amount), nothing else changed) passes R1, R2, R3, R4 and R9; the holder-first probe of rescue vetoes it R3B.

R9 is why simulation evasion does not work: an eth_simulateV1 call without validation runs with basefee 0, so EvilBaseFeeImpl (owner-only mint at basefee 0, open mint on a real block) passes every simulated invariant, and its BASEFEE opcode vetoes it. The allowlist is fixed in the config (default empty) and never depends on the code the proxy runs today, so approving one implementation's codehash exempts that codehash only, not every later upgrade.

The opcode walker skips the solc metadata tail only when it decodes as an exact solc CBOR map (keys ipfs, bzzr0, bzzr1, solc, experimental with their exact value types and lengths, ending exactly at the declared length) and no code can reach it: the instruction before it halts, and any JUMPDEST the EVM accepts inside it is followed until execution halts. Anything else is scanned as code. About 2% of honest solc builds have a reachable JUMPDEST in their metadata hash followed by an R9 opcode byte; approving that codehash covers it.

Approvals are read at the proposal block, so a verdict is the same whether the log trigger or the sweeper reaches it, and an approval registered after the proposal does not count. Keys (veto/keys.ts): keccak256(abi.encode(kind, target, value)) for config changes, keccak256(abi.encode(codehash, dataHash)) for init data, the codehash itself for R5/R9.

Report and evidence

report         = abi.encode(uint8 action, uint256 proposalId, bytes32 proposalDigest, bytes32 ruleHash,
                            bytes32 evidenceHash, uint64 validUntilBlock, bytes signature)   action 0 Veto, 1 Clear
signature      = EIP-712, domain ("AirlockReceiver", "1", chainId, receiver), type
                 Verdict(address airlockAdmin,uint8 action,uint256 proposalId,bytes32 proposalDigest,bytes32 ruleHash,
                         bytes32 evidenceHash,uint64 validUntilBlock)
proposalDigest = AirlockAdmin.proposalDigest(id) = keccak256(abi.encode(kind, target, value, codehash, dataHash,
                                                                         proposedBlock))
evidenceHash   = keccak256 of the canonical JSON (sorted keys, schema upgrade-airlock/evidence-v2): chainId,
                 airlockAdmin, proposalId and the getProposal fields, proposalDigest, verdict, rule
validUntilBlock = max(readyBlock, current block) + reportGraceBlocks   (blocks, never a clock)

The consensus step (the DON, in a DON deployment) agrees only on {verdict, rule}. Per-provider answers, failed checks, culprit selectors and new opcodes are in the node logs. Everything in the evidence is on chain, so veto/tools/check-evidence.ts recomputes each hash: bun veto/tools/check-evidence.ts <rpc> ../deployments/sepolia.json (it reads the logs in 10,000-block windows; on live Sepolia give it an RPC that keeps old logs, such as https://sepolia.gateway.tenderly.co).

Staying inside CRE's per-run limits

cre workflow limits export lists them; the simulator applies the defaults.

Limit How the workflow stays inside it
ChainRead 15 calls (headerByNumber counts too) Multicall3 batches. Sweeper: latest header 1, nextId 1, scan of 20 ids 4 (getProposal, clearanceOf, oracleJudged), then 3 per upgrade (reads at the proposal block, block hash, confirmation) and 2 per config change. Every judgement checks the remaining budget first; the rest waits for the next run. sweepWindow is capped at 30 (scan 6), which leaves room for an upgrade and two config changes; a budget that still runs out ends the run with a log line, never an error. Log path: 5.
ChainRead payload 5 kb Each batch's request and response stay under 4096 bytes. Init data is never read: R8 needs only dataHash.
HTTP 15 calls One simulation is eth_getCode, then 1 to simRequests (default 2) eth_simulateV1 requests per provider: at most 12 calls with four providers, 15 with five. The config refuses rpcs x (1 + simRequests) over 15. At most one simulation per run; static and config verdicts need no HTTP.
HTTP request 120 kb, response 250 kb Each eth_simulateV1 request holds at most maxCalls calls and at most 88,000 bytes of JSON (117,336 once base64-encoded). Probes = 2 fallback + 5 base shapes per selector + 24 placement probes per new selector (each placement probe carries one holder and one stranger balance read). DemoBTCV2 and EvilDrain (16 extracted selectors, including 0x0000001f, a length-check constant from the solc dispatcher that the selector walker over-includes and probes harmlessly; v1 has 15) are 130 probes; at maxCalls 150 it goes out as two requests (148 calls / 40.0 kB JSON / 53.3 kB base64 and 127 calls / 33.0 kB / 44.0 kB) — well inside 120 kb. A drain variant (even the 4-argument rescueTokens) adds one drain selector, so it measures the same. bun tools/measure-requests.ts prints these.
Log event 5 kb A Proposed log with large init data is not delivered; the sweeper judges it from getProposal.
Log line 1 kb, secrets 5 calls Lines are clipped; the attester key is fetched once per run.
Cron at least 30 s Default 0 * * * * * (every minute).

Run

./setup.sh                                  # CRE CLI v1.37.0 (sha256-pinned), bun 1.4.2, npm deps
export PATH="$PWD/.tools/bin:$PWD/.tools/node_modules/.bin:$PATH"
(cd veto && bun test && bun run typecheck)  # unit tests: rules, report bytes, budgets, both handlers on an in-memory
                                            # chain, the simulation against an in-memory token, crafted bytecode
scripts/fork-e2e.sh                         # full loop on an anvil fork of Sepolia (port 19611)

scripts/fork-e2e.sh deploys the stack with script/DeployEth.s.sol on the fork, then runs the workflow in the CRE simulator with --broadcast and checks every result on chain:

  • log trigger: EvilMint vetoed R1, EvilOwner R2, EvilFee R3, EvilBaseFee R9 (BASEFEE), EvilDrain R3B (the culprit rescue named, every other rule passing); seven drain variants A-G (test/fixtures/DrainVariants.sol: reordered args, a four-argument rescueTokens behind require(token == address(this)), drains gated on the caller holding tokens or ETH, a drain with no Transfer event, and a transferFrom that waives its allowance check) each vetoed R3B with every other rule passing; honest v2 cleared and executed after the cooldown; removing the receiver as guardian vetoed R7; a shorter delay pre-approved by the approver (its own key) cleared;
  • sweeper: an upgrade nobody judged reverts NotCleared after the cooldown, one sweeper run clears it and it executes; two proposals the approver cleared by hand with their logs missed are both judged by the next run: EvilDrain is vetoed R3B (the veto wins over the clearance), a longer delay gets a CRE Clear while clearanceOf keeps the approver's evidence; an upgrade with 6 KB of init data is vetoed R8 by the run after that;
  • the probes go out as two eth_simulateV1 requests per provider (the fork config uses the live maxCalls 150), and tools/measure-requests.ts prints each request's call count and size against the HTTP limits;
  • every vetoed proposal reverts ProposalVetoed, and every verdict's evidenceHash is recomputed from chain data.

CRE_SIM selects the simulator binary (default cre, which needs cre login).

Simulator behaviour

  • --trigger-index 0 --evm-tx-hash <tx> --evm-event-index 0 replays one Proposed log.
  • --trigger-index 1 --non-interactive runs the cron sweeper once, immediately; it does not wait for the schedule.
  • --listen does not drive cron: with the sweeper trigger the CLI prints --listen is not supported for this trigger type; ignoring, runs it once and exits. Periodic sweeps in simulation need an outer loop, e.g. while sleep 60; do cre workflow simulate ./veto -T sepolia --non-interactive --trigger-index 1 --broadcast; done.
  • It enforces ChainRead's 15 calls but not its 5 kb payload (a 64 KB call and a 64 KB reply both pass) and not the 5 kb log event size; the workflow respects both anyway.
  • cre workflow build ./veto -T sepolia-fork -o airlock.wasm once, then --wasm airlock.wasm, skips the per-run compile (about 45 s each).

Notes found while building:

  • Sepolia runs the Amsterdam rules: creating a contract costs about 7x what Foundry's Osaka simulation estimates. Deploy scripts use --skip-simulation so the node estimates gas.
  • The CRE runtime has no URL global, so the config validates RPC URLs with a regex.
  • runtime.now() is not reliable in the simulator; every validity window is in blocks.

Config (veto/config.ts)

Field Meaning
airlockAdmin, receiver, proxy Deployment addresses (deployments/*.json)
holders Token holders; all are watched, the first with a balance makes the test transfer
watch Extra addresses whose balances must not move (owner, AirlockAdmin)
rpcs, minAgree Simulation providers (1-5, each with eth_simulateV1 and batch requests) and the quorum
strict R5: every upgrade codehash must be approved
callGas, readGas Gas per simulated call and per simulated read
maxCalls, simRequests Simulated calls per eth_simulateV1 request (80-400, default 250) and requests per provider (1-4, default 2)
allowedOpcodes R9 opcodes that do not veto, by name (default none)
gasLimit Gas for forwarder -> receiver -> AirlockAdmin (default 2,000,000; a Veto report used up to about 524k in the fork end-to-end run, a Clear about 397k)
reportGraceBlocks Report validity after max(readyBlock, current block)
logConfidence LATEST only
sweepSchedule, sweepWindow Sweeper cron (default every 60 s) and how many of the newest ids it scans (default 20, at most 30)
multicall Multicall3 address (default the canonical one)
Source
1# Upgrade Airlock: the CRE clearance workflow
2
3A Chainlink CRE workflow that is `AirlockAdmin`'s clearance oracle. Every proposal (an upgrade or a change to the
4airlock itself) needs a clearance before it can execute. The workflow judges each proposal and always ends in an
5attested on-chain report: **Clear** when every check passes, **Veto** when one fails. A proposal no verdict has
6reached has no clearance and cannot execute, so a missed log or a failed run only delays an upgrade. A clearance the
7approver gives by hand does not count as a verdict of this workflow (`AirlockAdmin.oracleJudged` stays false), so the
8workflow still judges that proposal, and its Veto wins over the approver's clearance.
9
10```
11AirlockAdmin.Proposed ── handler 0: EVM log trigger (LATEST) ──┐
12cron, default every 60 s ── handler 1: sweeper (newest 20 ids) ─┤
13 ▼
14 (the sweeper takes every Pending proposal with oracleJudged false, approver-cleared ones included, oldest first)
15 EVM reads, batched through Multicall3: getProposal + clearanceOf + oracleJudged at LATEST;
16 approvals, holder balances, delay at the proposal block; the proposal block's hash (header by number)
17 kinds 1-3 (guardian / delay / paused impl): R7 from those reads
18 kind 0 (upgrade): R5 strict codehash, R8 init data, a funded holder; then node mode: every node (each DON node
19 when deployed to a DON; the CLI simulator today)
20 attack-simulates the upgrade on each RPC provider (eth_getCode, then 1 to simRequests eth_simulateV1 requests)
21 -> R1-R4, R3B, R9; quorum: at least minAgree answers and all equal, otherwise R6 (fail closed)
22 identical consensus on {verdict, rule} (across the DON's nodes in a DON deployment)
23 evidence JSON -> evidenceHash; EIP-712 attestation (CRE secret); runtime.report -> writeReport
24 -> (Mock)KeystoneForwarder -> AirlockReceiver.onReport
25 pass -> AirlockAdmin.clear(id, evidenceHash)
26 veto -> AirlockAdmin.veto(id, ruleHash) + attachReason(id, evidenceHash)
27 EVM read of the proposal state (the forwarder swallows a reverting onReport: tx success is not proof)
28```
29
30| File | What it is |
31|---|---|
32| `veto/workflow.ts` | Triggers and the CRE wiring: EVM reads with a per-run budget, node-mode simulation, secret, report |
33| `veto/airlock.ts` | What happens to a proposal: reads, verdict, report, confirmation; the sweeper's selection (SDK-free) |
34| `veto/judge.ts` | R7, the static upgrade rules, per-provider judging and the quorum |
35| `veto/simulate.ts` | The `eth_simulateV1` attack simulation (probes split over requests) and its evaluation (R1-R4, R3B) |
36| `veto/bytecode.ts` | Selector extraction, the R9 opcode list and the solc metadata check |
37| `veto/report.ts` | Report encoding, EIP-712 attestation, `proposalDigest`, canonical evidence |
38| `veto/chain.ts` | Multicall3 batching under the ChainRead limits |
39| `veto/tools/check-evidence.ts` | Recomputes every on-chain verdict's evidenceHash from `getProposal` alone |
40| `scripts/fork-e2e.sh` | The full loop on an anvil fork of Sepolia |
41| `../src/cre/AirlockReceiver.sol` | The receiver: clearance oracle and one guardian of `AirlockAdmin` |
42
43## Rules
44
45| Rule | Vetoes when |
46|---|---|
47| R1 `TOTAL_SUPPLY` | a stranger's call changes totalSupply |
48| R2 `OWNER` | a stranger's call changes the owner |
49| R3 `TRANSFER_CONSERVATION` | a holder's transfer does not move exactly the amount, or moves anyone else's balance |
50| R3B `THIRD_PARTY_FUNDS` | a stranger (funded first with tokens and ETH, so caller-gated drains are reached) can pull a holder's funds without allowance; or a stranger probe mints or burns, moves anyone else's tokens or ownership (from its own logs, even if a later call undoes it), or drops the holder's balance with no event |
51| R4 `TRANSFER_LIVENESS` | a plain holder transfer fails |
52| R5 `STRICT_CODEHASH` | strict mode and the codehash is not approved |
53| R6 `SIM_FAILURE` | the simulation cannot decide: providers disagree, too few answer, a view reverts, code mismatch, or the probes do not fit in `simRequests` requests (excused when the codehash is approved) |
54| R7 `CONFIG_GUARD` | a guardian change, a shorter delay or a new paused impl without the approver's pre-approval |
55| R8 `INIT_DATA` | an upgrade carries init data without a codehash+data approval |
56| R9 `NEW_OPCODES` | the new code contains an opcode from this list that the config's `allowedOpcodes` does not allow: block and fee context (TIMESTAMP, NUMBER, PREVRANDAO, COINBASE, BLOCKHASH, BASEFEE, GASPRICE, BLOBBASEFEE, BLOBHASH, GASLIMIT, GAS, ORIGIN) or calls/creates/selfdestruct, unless that codehash is approved |
57
58The stranger is funded first. Before the probes (and before the R3B `transferFrom`), the simulation makes the
59derived stranger look like a real user: a token balance (a transfer to it from a *second* holder, so the test
60holder's R3 accounting stays exactly `-amount`) and an ETH balance (a `stateOverrides` balance). That defeats drains
61gated on the caller holding tokens (`require(balanceOf(msg.sender) != 0)`), the caller holding ETH
62(`require(msg.sender.balance != 0)`), and a `transferFrom` that waives its allowance check for a token-holding caller.
63The funding transfer must succeed or the run fails closed (R6).
64
65Probes. Every selector the dispatcher compares against is called by the funded stranger with five base shapes (the
66stranger; the stranger and a large number; a large number; none; a wide mix), plus empty and unknown-selector
67calldata, so a drain that shows itself through a `Transfer`/`OwnershipTransferred` event is caught however it is
68reached. Every selector that is **new** relative to the standard token / ownable / proxy interface also gets
69*placement* probes: the funded holder in each of the first four argument slots, the test `amount` (1% of the
70holder's balance) in each other slot, and the remaining slots filled once with the stranger and once with the proxy
71address. That covers a drain whatever order its arguments take (`rescue(from, amount, to)`, `sweep(amount, from, to)`,
72`rescueTokens(token, from, to, amount)` with `require(token == address(this))` — the proxy-filled probe passes the
73guard). Right after every placement probe the holder's and the stranger's balances are read, so a drain that moves
74the holder's tokens out and a later probe moves them back in the same request — with no `Transfer` event — is still
75seen by the drop between the reads. `EvilDrainImpl` (an open `rescue(from, to, amount)`, nothing else changed) passes
76R1, R2, R3, R4 and R9; the holder-first probe of `rescue` vetoes it R3B.
77
78R9 is why simulation evasion does not work: an `eth_simulateV1` call without validation runs with basefee 0, so
79`EvilBaseFeeImpl` (owner-only mint at basefee 0, open mint on a real block) passes every simulated invariant, and its
80BASEFEE opcode vetoes it. The allowlist is fixed in the config (default empty) and never depends on the code the
81proxy runs today, so approving one implementation's codehash exempts that codehash only, not every later upgrade.
82
83The opcode walker skips the solc metadata tail only when it decodes as an exact solc CBOR map (keys ipfs, bzzr0,
84bzzr1, solc, experimental with their exact value types and lengths, ending exactly at the declared length) and no
85code can reach it: the instruction before it halts, and any JUMPDEST the EVM accepts inside it is followed until
86execution halts. Anything else is scanned as code. About 2% of honest solc builds have a reachable JUMPDEST in their
87metadata hash followed by an R9 opcode byte; approving that codehash covers it.
88
89Approvals are read at the proposal block, so a verdict is the same whether the log trigger or the sweeper reaches
90it, and an approval registered after the proposal does not count. Keys (`veto/keys.ts`):
91`keccak256(abi.encode(kind, target, value))` for config changes, `keccak256(abi.encode(codehash, dataHash))` for
92init data, the codehash itself for R5/R9.
93
94## Report and evidence
95
96```
97report = abi.encode(uint8 action, uint256 proposalId, bytes32 proposalDigest, bytes32 ruleHash,
98 bytes32 evidenceHash, uint64 validUntilBlock, bytes signature) action 0 Veto, 1 Clear
99signature = EIP-712, domain ("AirlockReceiver", "1", chainId, receiver), type
100 Verdict(address airlockAdmin,uint8 action,uint256 proposalId,bytes32 proposalDigest,bytes32 ruleHash,
101 bytes32 evidenceHash,uint64 validUntilBlock)
102proposalDigest = AirlockAdmin.proposalDigest(id) = keccak256(abi.encode(kind, target, value, codehash, dataHash,
103 proposedBlock))
104evidenceHash = keccak256 of the canonical JSON (sorted keys, schema upgrade-airlock/evidence-v2): chainId,
105 airlockAdmin, proposalId and the getProposal fields, proposalDigest, verdict, rule
106validUntilBlock = max(readyBlock, current block) + reportGraceBlocks (blocks, never a clock)
107```
108
109The consensus step (the DON, in a DON deployment) agrees only on `{verdict, rule}`. Per-provider answers, failed checks, culprit selectors and new opcodes are
110in the node logs. Everything in the evidence is on chain, so `veto/tools/check-evidence.ts` recomputes each hash:
111`bun veto/tools/check-evidence.ts <rpc> ../deployments/sepolia.json` (it reads the logs in 10,000-block windows; on
112live Sepolia give it an RPC that keeps old logs, such as https://sepolia.gateway.tenderly.co).
113
114## Staying inside CRE's per-run limits
115
116`cre workflow limits export` lists them; the simulator applies the defaults.
117
118| Limit | How the workflow stays inside it |
119|---|---|
120| ChainRead 15 calls (headerByNumber counts too) | Multicall3 batches. Sweeper: latest header 1, nextId 1, scan of 20 ids 4 (getProposal, clearanceOf, oracleJudged), then 3 per upgrade (reads at the proposal block, block hash, confirmation) and 2 per config change. Every judgement checks the remaining budget first; the rest waits for the next run. `sweepWindow` is capped at 30 (scan 6), which leaves room for an upgrade and two config changes; a budget that still runs out ends the run with a log line, never an error. Log path: 5. |
121| ChainRead payload 5 kb | Each batch's request and response stay under 4096 bytes. Init data is never read: R8 needs only `dataHash`. |
122| HTTP 15 calls | One simulation is `eth_getCode`, then 1 to `simRequests` (default 2) `eth_simulateV1` requests per provider: at most 12 calls with four providers, 15 with five. The config refuses `rpcs x (1 + simRequests)` over 15. At most one simulation per run; static and config verdicts need no HTTP. |
123| HTTP request 120 kb, response 250 kb | Each `eth_simulateV1` request holds at most `maxCalls` calls and at most 88,000 bytes of JSON (117,336 once base64-encoded). Probes = 2 fallback + 5 base shapes per selector + 24 placement probes per **new** selector (each placement probe carries one holder and one stranger balance read). DemoBTCV2 and EvilDrain (16 extracted selectors, including `0x0000001f`, a length-check constant from the solc dispatcher that the selector walker over-includes and probes harmlessly; v1 has 15) are 130 probes; at `maxCalls` 150 it goes out as two requests (148 calls / 40.0 kB JSON / 53.3 kB base64 and 127 calls / 33.0 kB / 44.0 kB) — well inside 120 kb. A drain variant (even the 4-argument `rescueTokens`) adds one drain selector, so it measures the same. `bun tools/measure-requests.ts` prints these. |
124| Log event 5 kb | A Proposed log with large init data is not delivered; the sweeper judges it from `getProposal`. |
125| Log line 1 kb, secrets 5 calls | Lines are clipped; the attester key is fetched once per run. |
126| Cron at least 30 s | Default `0 * * * * *` (every minute). |
127
128## Run
129
130```bash
131./setup.sh # CRE CLI v1.37.0 (sha256-pinned), bun 1.4.2, npm deps
132export PATH="$PWD/.tools/bin:$PWD/.tools/node_modules/.bin:$PATH"
133(cd veto && bun test && bun run typecheck) # unit tests: rules, report bytes, budgets, both handlers on an in-memory
134 # chain, the simulation against an in-memory token, crafted bytecode
135scripts/fork-e2e.sh # full loop on an anvil fork of Sepolia (port 19611)
136```
137
138`scripts/fork-e2e.sh` deploys the stack with `script/DeployEth.s.sol` on the fork, then runs the workflow in the
139CRE simulator with `--broadcast` and checks every result on chain:
140
141- log trigger: EvilMint vetoed R1, EvilOwner R2, EvilFee R3, EvilBaseFee R9 (BASEFEE), EvilDrain R3B (the culprit
142 `rescue` named, every other rule passing); seven drain variants A-G (`test/fixtures/DrainVariants.sol`: reordered
143 args, a four-argument `rescueTokens` behind `require(token == address(this))`, drains gated on the caller holding
144 tokens or ETH, a drain with no `Transfer` event, and a `transferFrom` that waives its allowance check) each vetoed
145 R3B with every other rule passing; honest v2 cleared and executed after the cooldown; removing the receiver as
146 guardian vetoed R7; a shorter delay pre-approved by the approver (its own key) cleared;
147- sweeper: an upgrade nobody judged reverts `NotCleared` after the cooldown, one sweeper run clears it and it
148 executes; two proposals the approver cleared by hand with their logs missed are both judged by the next run:
149 EvilDrain is vetoed R3B (the veto wins over the clearance), a longer delay gets a CRE Clear while `clearanceOf`
150 keeps the approver's evidence; an upgrade with 6 KB of init data is vetoed R8 by the run after that;
151- the probes go out as two `eth_simulateV1` requests per provider (the fork config uses the live `maxCalls` 150),
152 and `tools/measure-requests.ts` prints each request's call count and size against the HTTP limits;
153- every vetoed proposal reverts `ProposalVetoed`, and every verdict's evidenceHash is recomputed from chain data.
154
155`CRE_SIM` selects the simulator binary (default `cre`, which needs `cre login`).
156
157### Simulator behaviour
158
159- `--trigger-index 0 --evm-tx-hash <tx> --evm-event-index 0` replays one Proposed log.
160- `--trigger-index 1 --non-interactive` runs the cron sweeper once, immediately; it does not wait for the schedule.
161- `--listen` does not drive cron: with the sweeper trigger the CLI prints `--listen is not supported for this
162 trigger type; ignoring`, runs it once and exits. Periodic sweeps in simulation need an outer loop, e.g.
163 `while sleep 60; do cre workflow simulate ./veto -T sepolia --non-interactive --trigger-index 1 --broadcast; done`.
164- It enforces ChainRead's 15 calls but not its 5 kb payload (a 64 KB call and a 64 KB reply both pass) and not
165 the 5 kb log event size; the workflow respects both anyway.
166- `cre workflow build ./veto -T sepolia-fork -o airlock.wasm` once, then `--wasm airlock.wasm`, skips the
167 per-run compile (about 45 s each).
168
169Notes found while building:
170- Sepolia runs the Amsterdam rules: creating a contract costs about 7x what Foundry's Osaka simulation estimates.
171 Deploy scripts use `--skip-simulation` so the node estimates gas.
172- The CRE runtime has no `URL` global, so the config validates RPC URLs with a regex.
173- `runtime.now()` is not reliable in the simulator; every validity window is in blocks.
174
175## Config (`veto/config.ts`)
176
177| Field | Meaning |
178|---|---|
179| `airlockAdmin`, `receiver`, `proxy` | Deployment addresses (`deployments/*.json`) |
180| `holders` | Token holders; all are watched, the first with a balance makes the test transfer |
181| `watch` | Extra addresses whose balances must not move (owner, AirlockAdmin) |
182| `rpcs`, `minAgree` | Simulation providers (1-5, each with `eth_simulateV1` and batch requests) and the quorum |
183| `strict` | R5: every upgrade codehash must be approved |
184| `callGas`, `readGas` | Gas per simulated call and per simulated read |
185| `maxCalls`, `simRequests` | Simulated calls per `eth_simulateV1` request (80-400, default 250) and requests per provider (1-4, default 2) |
186| `allowedOpcodes` | R9 opcodes that do not veto, by name (default none) |
187| `gasLimit` | Gas for forwarder -> receiver -> AirlockAdmin (default 2,000,000; a Veto report used up to about 524k in the fork end-to-end run, a Clear about 397k) |
188| `reportGraceBlocks` | Report validity after max(readyBlock, current block) |
189| `logConfidence` | `LATEST` only |
190| `sweepSchedule`, `sweepWindow` | Sweeper cron (default every 60 s) and how many of the newest ids it scans (default 20, at most 30) |
191| `multicall` | Multicall3 address (default the canonical one) |