Upgrade Airlock: design
This document explains why Upgrade Airlock is built the way it is. The README covers what it does and how to run it; cre/README.md covers the workflow's files and configuration.
Threat model
Roles
| Role | Held by | Can |
|---|---|---|
| owner | one key | propose (upgrade, guardian change, delay change, break-glass target change), breakGlass |
| guardians | AirlockReceiver (the CRE workflow) and an emergency guardian key |
veto, attachReason, breakGlass |
| clearance oracle | AirlockReceiver |
clear |
| approver | one key, kept apart from the owner | clear by hand, approveCodehash (registers codehashes, init-data keys and config-change keys) |
| attester | a CRE secret, its own key | signs the EIP-712 verdicts AirlockReceiver accepts |
| anyone | execute a proposal that has passed both doors |
AirlockAdmin owns the OpenZeppelin ProxyAdmin and the token's Ownable, and it has no generic call path. The
only way to change the implementation is execute of an upgrade proposal (or breakGlass to the pre-registered
paused implementation). The constructor refuses overlapping roles (RoleOverlap): the owner is never the approver,
a guardian or the clearance oracle, and proposeSetGuardian(owner, true) reverts at propose time. No single key
can both propose and let its own proposal through. DeployEth checks the rest before it sends anything: owner,
approver, emergency guardian and attester must be four different addresses, and the permissionless
MockKeystoneForwarder is accepted only with ALLOW_MOCK_FORWARDER=1 or with a workflow id and owner to check.
The attacker
The attacker holds the owner key, deploys any bytecode without publishing source, chooses the moment and the
content of every proposal, and calls execute the moment a proposal could run. The attacker does not hold the
approver key, the emergency guardian key or the attester secret, and does not control the CRE workflow.
| Attack | What stops it |
|---|---|
| Propose a malicious implementation (open mint, ownership takeover, a skimming transfer, pulling holders' funds) | The attack simulation finds the broken invariant (R1, R2, R3, R3B, R4); the Veto report lands during the cooldown |
Hide a drain behind one new, innocent-looking function and keep every v1 invariant (EvilDrainImpl's rescue(from, to, amount)) |
Every selector outside the standard token interface is called by the stranger with the funded holder in each of the first four argument slots and the test amount in another, and the holder's balance is read right after each call; every probe's own Transfer and OwnershipTransferred events are checked too. One call that moves a holder's tokens vetoes it (R3B), whatever the argument order, with or without an event, even if a later call puts them back |
| Open the drain only to callers that look like real users (holding tokens or ETH), or waive the allowance for callers that hold tokens | The stranger is funded before it calls anything: tokens from a second holder and 1 ETH by state override. Its transferFrom without allowance and every probe run funded |
Execute right at readyBlock, before anyone looks |
execute needs a clearance (NotCleared); nobody has to be watching |
| Propose an EIP-7702 delegated account: its code is a 23-byte designator, and its key can re-point it after the verdict | proposeUpgrade and proposeSetPausedImpl refuse it (DelegatedAccount), as the constructor does for the paused implementation and the clearance oracle |
Swap the code at the judged address before execute (selfdestruct and redeploy, a metamorphic factory) |
execute compares the target's codehash with the one recorded at propose time and reverts CodehashChanged; the proposal stays pending |
| Code that behaves honestly inside a simulation and maliciously on a real block (basefee, timestamp, block number, origin, gas) | R9: block and transaction context opcodes, external calls, create and selfdestruct veto the upgrade unless the config's fixed allowlist covers them or the approver approved that codehash. The compiler's metadata tail is skipped only when no instruction can reach it, so opcodes cannot hide there |
| Talk the approver into a hand clearance before CRE looks | A hand clearance is not a CRE verdict (oracleJudged stays false): the log trigger or the sweeper still judges the proposal, and a CRE veto ejects it. Only a proposal CRE never reaches runs on the approver's word alone (the sweep window) |
| Recognize the simulation's stranger and behave honestly toward it | The stranger and receiver addresses are derived from the proposal block's hash, which does not exist yet when the implementation is deployed |
| Hide behind initialization data (a reinitializer that hands over ownership) | R8: init data needs the approver's approval of keccak256(abi.encode(codehash, dataHash)) |
Make the Proposed log too large to deliver (CRE drops log events over 5 kb) |
The sweeper judges every pending proposal among the newest sweepWindow ids that CRE has not judged, from getProposal, which never includes the data |
| Weaken the airlock first: remove the CRE receiver as a guardian, shorten the delay, swap the break-glass target | Every change to the airlock is itself a proposal and passes both doors; R7 vetoes it without the approver's pre-approval |
| Forge a verdict through the forwarder used in simulation (the MockKeystoneForwarder is permissionless) | Every report carries an EIP-712 signature by the attester; the receiver recovers it |
| Reuse a Clear for other content: another proposal, another chain history, another implementation | The signed proposalDigest must equal AirlockAdmin.proposalDigest(id) |
| Keep a Clear for later | validUntilBlock bounds when a signed report can be delivered; a Clear only applies to a pending proposal and the first evidence hash is kept. A clearance that has been recorded does not expire (known limitations) |
| Run or bribe an RPC provider | Every answering provider must agree and at least two must answer, so a single bad provider can cause a veto (R6, a delay), never a pass |
Two doors and the clearance state machine
A proposal has a status (None, Pending, Vetoed, Executed) and, while it is pending, a clearance
(clearanceOf[id], the evidence hash of the first accepted clearance, zero while there is none).
propose (owner)
None ─────────────────────────► Pending, not cleared ── execute ──► reverts TooEarly / NotCleared
│ │
clear (oracle or │ │ veto (any guardian)
approver, evidence) ▼ ▼
Pending, cleared ─────► Vetoed ── execute ──► reverts ProposalVetoed
│ veto (terminal: cannot be cleared or executed)
│
execute (anyone), block.number >= readyBlock
▼
Executed (terminal)
- Outer door.
readyBlock = proposedBlock + delayBlocks. Time is counted in blocks only, never timestamps.delayBlocksis 25 on Sepolia (about 5 minutes) and bounded to 10 to 50,400 blocks. - Inner door.
clear(id, evidenceHash)is accepted from the clearance oracle or the approver, for a pending proposal, with a non-zero evidence hash. The first hash is kept; every accepted call still emitsClearedwith the caller's own evidence, so a second opinion is visible on chain. - A veto always wins. A guardian may veto a pending proposal whether it is cleared or not. Once vetoed, it can be neither cleared nor executed.
- The oracle's own verdict is recorded apart.
oracleJudged[id]turns true when the clearance oracle clears or vetoes, never on the approver's clearance. A hand clearance therefore does not end the CRE's job: the sweeper still judges that proposal while it is in the sweep window, and a CRE veto ejects it althoughclearanceOfis set. executecheck order:UnknownProposal/ProposalVetoed/AlreadyExecuted, thenTooEarly, thenNotCleared, then for an upgrade or break-glass targetCodehashChanged(the target's code must still hash to the proposal's codehash). Status and theExecutedevent are written before the external call.- Every kind needs a clearance, not only upgrades: guardian, delay and break-glass target changes pass both doors too.
- Break glass.
breakGlass()(owner or any guardian) switches the proxy to the pre-registered paused implementation at once, without a proposal. Leaving the paused state is an upgrade through both doors. A cleared proposal never expires, so an earlier cleared upgrade can also end the pause (known limitations).
The CRE workflow answers every proposal it judges: a pass becomes a Clear, a failed check a Veto. A proposal no verdict has reached simply has no clearance. That is the property the whole design rests on:
| What goes wrong | Result |
|---|---|
| The log trigger misses a proposal, or the workflow run fails | No clearance: the proposal waits; the sweeper judges it on a later run while its id is among the newest sweepWindow |
| A provider is down or rate-limited | Errors are left out of the quorum; with fewer than minAgree answers the verdict is R6 (veto) |
| Providers disagree | R6 (veto) |
The report transaction fails, or onReport reverts (the forwarder does not propagate the revert) |
No clearance; the workflow reads the proposal back and logs unconfirmed, and the sweeper tries again |
| CRE cannot answer at all | The approver clears by hand with a published evidence JSON (scripts/sepolia/clear.sh); the sweeper still judges the proposal once CRE answers again, and a CRE or guardian veto wins. The approver's clearance is an override: a proposal CRE never reaches runs on it alone |
A wrong veto costs a new proposal. A wrong clearance could let an attacker in, so every uncertain case resolves to a veto or to no clearance.
The sweep window
The sweeper reads only the newest sweepWindow ids (20 by default, at most 30, so that the scan fits the ChainRead
budget). A proposal no log-trigger run has judged (a missed log, a failed run) is caught as long as fewer than
sweepWindow newer proposals exist when a sweep reaches it; the owner can push it out sooner by proposing more.
Once it is below the window, CRE never judges it:
- Not cleared: it stays pending and cannot execute. It costs a new proposal, nothing more.
- Cleared by the approver: it executes on the approver's clearance alone once its cooldown is over. This is the
approver's manual clearance working as designed, an override for when CRE gives no verdict.
oracleJudgedis the sweeper's work list, not a third door;test_knownIssue_approverClearanceRunsWithoutOracleVerdictintest/known-issues/KnownIssues.t.solrecords this.
The workflow
Two handlers share one code path (cre/veto/airlock.ts):
- Handler 0, EVM log trigger on
AirlockAdmin.ProposedatLATESTconfidence. A proposal is judged as soon as it appears, a whole cooldown before it could run. Only the indexed topics are decoded (id, kind, target), never the data. - Handler 1, cron sweeper (default every 60 seconds). It reads
nextId, scans the newestsweepWindow(20) ids, and judges the pending proposals the oracle has not judged (oracleJudgedfalse, approver-cleared ones included), oldest first, within the run's budgets. Whatever is left waits for the next run; older ids are not scanned (the sweep window).
Judging one proposal:
Reads at the proposal block. Approvals, holder balances and the current delay are read at
proposedBlock, so the verdict is the same whether the log trigger or the sweeper reaches it, and an approval registered after the proposal does not count.Config changes (kinds 1 to 3): R7 from those reads alone. Adding or removing a guardian and changing the break-glass target always need the approver's pre-approval of
keccak256(abi.encode(kind, target, value)); a delay change needs it only when it shortens the delay.Upgrades (kind 0): static rules. R5 in strict mode (the codehash must be approved, see the two modes), R8 for init data, and a funded holder must exist.
Upgrades: the attack simulation, in node mode. Every node (every DON node in a DON deployment; the CLI simulator today) sends every configured provider
eth_getCode(newImpl)pinned to the proposal block (the code must hash to the proposed codehash, else R6), then the simulation in at mostsimRequests(default 2)eth_simulateV1requests of at mostmaxCallscalls and 88,000 bytes each, all with the proxy's implementation slot overridden and the stranger given 1 ETH. Every request starts from the proposal block with a transfer of the test amount (1% of the test holder's balance, capped at the sender's balance) from a second configured holder with a balance, when there is one, to the stranger, so the stranger looks like a real user; a funding transfer that reverts is R6. The first request then holds baseline reads, the holder's transfer to the receiver, reads, atransferFrom(holder, stranger)by the stranger without allowance, the stranger's probes and final reads oftotalSupply,owner()and every watched balance, the stranger's included. A further request holds reads, more probes and reads.Every selector found in the new bytecode is probed with five argument shapes (the stranger, the stranger and a large number, a large number, none, a wide mix), plus empty and unknown-selector calldata. Every selector outside the standard token, ownable, proxy and initializer interface also gets 24 placement probes: the funded holder in each of the first four argument slots, the test amount in each other slot, the remaining slots filled once with the stranger and once with the proxy. The holder's and the stranger's balances are read right after each placement probe. A probe's own
TransferandOwnershipTransferredevents count, and so does a holder balance read after a placement probe that is below its value before the probes, so a drain that emits no event, or that a later call undoes, still vetoes. If the probes do not fit, the verdict is R6, unless the approver approved the codehash: that excuses the truncation only (the placement probes of the last selectors are the ones left out), and every other rule still runs on the probes that fit.R9. The opcode walker that extracts selectors also lists dangerous opcodes; any in the new code that the config's fixed
allowedOpcodes(empty by default) does not list vetoes the upgrade unless its codehash is approved. The list never depends on the current implementation, so an approval covers one codehash and no later upgrade. The walker skips the solc metadata tail only when it decodes as an exact solc CBOR map, the instruction before it halts, and every JUMPDEST inside it is followed until execution halts; otherwise the whole code is scanned (fail closed). Aneth_simulateV1call without validation runs with basefee and gas price 0, so this is what catches code that branches on them.Quorum. At least
minAgreeproviders must answer and every answer must be the same verdict and rule.Report. Evidence, attestation,
writeReport, then a read of the proposal to confirm the receiver's effect.
Two modes: automatic and strict
The config's strict flag sets how an upgrade earns its clearance. In both modes the workflow clears nothing that
has not passed the attack simulation.
Automatic mode (strict: false, the default and the demo setting). CRE clears or vetoes every upgrade on its
own, with no human in the loop, through a bounded attack search on the proposed code at the proposal block:
| It probes | How |
|---|---|
| A caller that looks like a real user | The stranger, an EOA derived from the proposal block's hash, holds tokens from a second holder (sent at the start of every request, when a second holder has a balance) and 1 ETH by state override. It makes every call below except the plain transfer |
| A plain transfer | The test holder sends the test amount to a fresh receiver; it must succeed (R4) and move exactly that amount and nothing else (R3) |
| Allowances | Before any probe, the funded stranger calls transferFrom(holder, stranger) with no allowance; it must fail (R3B). That also catches a transferFrom that waives the allowance for callers holding tokens |
| Every selector in the new bytecode | Five base shapes: the stranger, the stranger and a large number, a large number, none, a wide mix of eight words; plus empty and unknown-selector calldata |
| Every new selector (outside the standard token, ownable, proxy and initializer interface) | 24 placement probes: the funded holder in each of the first four argument slots, the test amount in each other slot, the remaining slots filled once with the stranger and once with the proxy. That covers functions of up to four arguments in any order, and a require(token == address(this)) guard |
| Balances, probe by probe | The holder's and the stranger's balances read right after each placement probe; every probe's own Transfer and OwnershipTransferred events; totalSupply, owner() and every watched balance before and after the probes of each request |
What that means: it catches every backdoor a funded outside caller reaches with those shapes. An open mint, an
ownership grab, a skimming transfer and a drain of a holder's tokens are all vetoed, the drain whatever its argument
order, with or without an event, gated on the caller holding tokens or ETH, and even when a later call puts the
tokens back. EvilDrainImpl and the seven drain variants of the fork end-to-end run are each caught by R3B alone.
Strict mode (strict: true, R5). Every new implementation codehash must be approved by the approver key
(approveCodehash) after a review of that exact code: an upgrade whose codehash is not approved by the proposal
block is vetoed R5. An approved codehash still runs the attack simulation and meets every other rule; the approval
exempts it from R9 and excuses probe truncation (R6), nothing more. A reviewed upgrade therefore still has to
survive the attack, and an attacker who holds the owner key also needs the approver's review.
What each mode is for. A bounded search catches what its callers and shapes reach. A backdoor behind a magic
value (a drain that opens for one exact argument), a call counter (one that opens on the hundredth call), a
dynamically encoded argument (bytes, an array or a struct the probes do not build), more arguments than the
placement probes fill, or a specific caller (a contract, a hard-coded address) is outside a bounded search. That
is what strict mode is for: a person reviews the code once, the approval binds that review to one codehash, and the
simulation still runs on every proposal. Automatic mode fits a token that wants routine upgrades cleared in
minutes; strict mode fits one where every upgrade is reviewed anyway, and makes that review part of the clearance.
Why consensus carries only the verdict and the rule
The node-mode function returns stableStringify({verdict, rule}) to consensusIdenticalAggregation, and nothing
else.
- Identical aggregation needs identical bytes. Nodes reach the same providers at different moments. One node
may see a timeout or a rate limit where another gets an answer, and error strings, timings and which provider's
detail gets reported can differ. If any of that were in the observation, honest nodes would disagree and the
run would end without a verdict.
{verdict, rule}is deterministic for honest providers: the simulation is pinned to the proposal block, the actors are derived from chain data, and a provider error removes an answer instead of changing it. A run in which the nodes still cannot agree ends without a report, which leaves the proposal without a clearance. - The rest is detail, not decision. Per-provider answers, failed checks with before and after values, culprit selectors and the flagged R9 opcodes go to the node logs. None of them changes what happens on chain.
- The evidence is rebuilt from the chain. After consensus every node builds the same canonical evidence JSON
from
getProposalfields,proposalDigestand the agreed verdict, hashes it, and signs it with a deterministic (RFC 6979) signature. Every node therefore produces a byte-identical report payload, and anyone can recompute every evidence hash later from chain data alone (cre/veto/tools/check-evidence.ts). - It is small. The largest consensus value is 52 bytes, against a 25 kb observation limit.
Report binding
report = abi.encode(uint8 action, uint256 proposalId, bytes32 proposalDigest, bytes32 ruleHash,
bytes32 evidenceHash, uint64 validUntilBlock, bytes signature)
action 0 = Veto (ruleHash = keccak256("GUARD/<rule>")), 1 = Clear (ruleHash = 0)
signature = 65 bytes r||s||v by the attester over the EIP-712 digest of
Verdict(address airlockAdmin,uint8 action,uint256 proposalId,bytes32 proposalDigest,
bytes32 ruleHash,bytes32 evidenceHash,uint64 validUntilBlock)
domain ("AirlockReceiver", "1", chainId, receiver address)
proposalDigest = keccak256(abi.encode(uint8 kind, address target, uint256 value, bytes32 codehash,
bytes32 dataHash, uint64 proposedBlock))
evidenceHash = keccak256(canonical evidence JSON, sorted keys, schema upgrade-airlock/evidence-v2)
validUntilBlock = max(readyBlock, current block) + reportGraceBlocks (50)
Each field closes one hole:
| Bound to | So that |
|---|---|
| chain id and receiver (EIP-712 domain) | a verdict for another chain or another deployment does not verify |
airlockAdmin (in the struct) |
a verdict for another airlock does not verify |
action and ruleHash (signed) |
a Veto cannot be turned into a Clear; a Clear with a rule is rejected (RuleOnClear) |
proposalDigest |
a verdict about one proposal's content cannot clear another proposal, or the same id after a reorg with other content |
validUntilBlock |
an old report cannot be held back and delivered much later; validity is in blocks, so it needs no clock |
evidenceHash |
the verdict is tied to a document anyone can rebuild and check |
AirlockReceiver.onReport checks, in order: the sender is the configured forwarder; the workflow metadata names
the expected workflow id and owner (when configured, as in a DON deployment, where the KeystoneForwarder backs the
metadata with DON signatures); the report is fresh; the signer is the attester; the action is 0 or 1; a Clear has
no rule; the digest matches AirlockAdmin.proposalDigest(id). Only then does it emit Verdict and call clear, or
veto and attachReason. A replayed Veto is harmless (veto is idempotent), and a replayed Clear keeps the first
evidence hash and is refused once the proposal has executed.
Approval keys, all registered with approveCodehash and read at the proposal block
(cre/veto/keys.ts):
| Key | For |
|---|---|
| the codehash | R5 in strict mode, the R9 exemption, and probes that do not fit (R6) |
keccak256(abi.encode(codehash, dataHash)) |
R8, an upgrade with init data |
keccak256(abi.encode(uint8 kind, address target, uint256 value)) |
R7, a change to the airlock |
Budgets
CRE limits every workflow run (cre workflow limits export). The workflow plans against them instead of hoping:
| Limit | Value | How the workflow stays inside it |
|---|---|---|
| ChainRead calls | 15 per run | Reads go through Multicall3 in batches. Log path: 5 calls for an upgrade (latest header, proposal state, reads at the proposal block, the proposal block's header, the confirmation), 4 for a config change. Sweeper: 1 header, 1 nextId, 4 for the 20-id scan (getProposal, clearanceOf, oracleJudged; sweepWindow is capped at 30), then 3 per upgrade and 2 per config change. Each judgement checks the remaining budget first and defers when it does not fit; a budget that still runs out ends the run with a log line |
| ChainRead payload | 5 kb | Each batch's request and response stay under 4,096 bytes. Init data is never read: R8 needs only dataHash |
| HTTP calls | 15 per run | 1 + simRequests per provider: 12 with the four Sepolia providers; the config refuses more than 15. One attack simulation per run; static and config verdicts need no HTTP |
| HTTP request size | 120 kb | Each eth_simulateV1 request holds at most maxCalls calls and 88,000 bytes of JSON (117,336 base64-encoded). A placement probe takes three calls (the probe and two balance reads). At the live maxCalls of 150, EvilDrain's 130 probes (16 extracted selectors, 2 outside the standard interface: rescue() 0x20ff430b and 0x0000001f, a length-check constant from the solc dispatcher that the selector walker over-includes and probes harmlessly) go out as two requests |
| Consensus observation | 25 kb | {verdict, rule}, at most 52 bytes |
| Log trigger event size | 5 kb | A larger Proposed event is left to the sweeper |
| Cron interval | at least 30 s | every 60 s |
| Execution time | 5 min | One simulation per run; the server's sweep unit stops a run after 6 minutes |
| Secrets calls | 5 per run | The attester key is fetched once per run |
| Log line | 1 kb | Lines are clipped at 900 characters |
On chain, a report runs under a 2,000,000 gas limit. In the fork end-to-end run (an anvil fork of Sepolia, which
charges what live Sepolia charges), a Veto report (onReport, veto, attachReason) used 426,000 to 524,000 gas,
a Clear report 191,000 to 397,000, and execute of the v2 upgrade, codehash recheck included, 70,000.
Known limitations
These are testnet demo contracts. Each limitation below is pinned by a passing test in
test/known-issues/KnownIssues.t.sol that asserts today's behaviour; a
version that removes one replaces its test with a test of the protection.
| Limitation | Test |
|---|---|
A matured, cleared, never-executed upgrade can end a breakGlass in the same block: anyone may execute it |
test_knownIssue_staleProposalUndoesBreakGlassInstantly |
| Clearances never expire and execution order is free, so an older cleared proposal can roll the code back at any later time | test_knownIssue_staleProposalRollback |
owner, approver and clearanceOracle are immutable, and ProxyAdmin cannot leave AirlockAdmin: there is no key rotation or migration |
test_knownIssue_noMigrationPath |
| A stolen owner key can re-pause after every unpause. It can freeze the token, but it cannot steal | test_knownIssue_stolenOwnerRepausesForever |
The same key can raise the delay to 50,400 blocks, which R7 treats as not weakening (judgeConfig), so every later unpause waits that long while the key re-pauses at will |
test_knownIssue_raiseDelayThenPause |
| A single rogue guardian can keep the token paused forever by vetoing every exit, including the proposal that removes it: a veto always wins | test_knownIssue_singleGuardianPermanentFreeze |
| An unpause is an upgrade, so it needs a clearer: while the oracle is down and the approver is unreachable, a paused token stays paused | test_knownIssue_unpauseNeedsAClearer |
A mistaken manual clearance of a reinitializer runs as proposed: execute checks the codehash, never the approval registry or the init data |
test_knownIssue_clearedReinitializerHandsOwnership |
| The approver's clearance lets a proposal run after the cooldown when no CRE verdict has landed, and for good once the proposal is below the sweep window | test_knownIssue_approverClearanceRunsWithoutOracleVerdict |
A production version would add key rotation or a migration path and an expiry for clearances.
Source
| 1 | # Upgrade Airlock: design |
| 2 | |
| 3 | This document explains why Upgrade Airlock is built the way it is. The [README](../README.md) covers what it does |
| 4 | and how to run it; [cre/README.md](../cre/README.md) covers the workflow's files and configuration. |
| 5 | |
| 6 | ## Threat model |
| 7 | |
| 8 | ### Roles |
| 9 | |
| 10 | | Role | Held by | Can | |
| 11 | |---|---|---| |
| 12 | | owner | one key | propose (upgrade, guardian change, delay change, break-glass target change), `breakGlass` | |
| 13 | | guardians | `AirlockReceiver` (the CRE workflow) and an emergency guardian key | `veto`, `attachReason`, `breakGlass` | |
| 14 | | clearance oracle | `AirlockReceiver` | `clear` | |
| 15 | | approver | one key, kept apart from the owner | `clear` by hand, `approveCodehash` (registers codehashes, init-data keys and config-change keys) | |
| 16 | | attester | a CRE secret, its own key | signs the EIP-712 verdicts `AirlockReceiver` accepts | |
| 17 | | anyone | | `execute` a proposal that has passed both doors | |
| 18 | |
| 19 | `AirlockAdmin` owns the OpenZeppelin `ProxyAdmin` and the token's `Ownable`, and it has no generic call path. The |
| 20 | only way to change the implementation is `execute` of an upgrade proposal (or `breakGlass` to the pre-registered |
| 21 | paused implementation). The constructor refuses overlapping roles (`RoleOverlap`): the owner is never the approver, |
| 22 | a guardian or the clearance oracle, and `proposeSetGuardian(owner, true)` reverts at propose time. No single key |
| 23 | can both propose and let its own proposal through. `DeployEth` checks the rest before it sends anything: owner, |
| 24 | approver, emergency guardian and attester must be four different addresses, and the permissionless |
| 25 | MockKeystoneForwarder is accepted only with `ALLOW_MOCK_FORWARDER=1` or with a workflow id and owner to check. |
| 26 | |
| 27 | ### The attacker |
| 28 | |
| 29 | The attacker holds the owner key, deploys any bytecode without publishing source, chooses the moment and the |
| 30 | content of every proposal, and calls `execute` the moment a proposal could run. The attacker does not hold the |
| 31 | approver key, the emergency guardian key or the attester secret, and does not control the CRE workflow. |
| 32 | |
| 33 | | Attack | What stops it | |
| 34 | |---|---| |
| 35 | | Propose a malicious implementation (open mint, ownership takeover, a skimming transfer, pulling holders' funds) | The attack simulation finds the broken invariant (R1, R2, R3, R3B, R4); the Veto report lands during the cooldown | |
| 36 | | Hide a drain behind one new, innocent-looking function and keep every v1 invariant (`EvilDrainImpl`'s `rescue(from, to, amount)`) | Every selector outside the standard token interface is called by the stranger with the funded holder in each of the first four argument slots and the test amount in another, and the holder's balance is read right after each call; every probe's own `Transfer` and `OwnershipTransferred` events are checked too. One call that moves a holder's tokens vetoes it (R3B), whatever the argument order, with or without an event, even if a later call puts them back | |
| 37 | | Open the drain only to callers that look like real users (holding tokens or ETH), or waive the allowance for callers that hold tokens | The stranger is funded before it calls anything: tokens from a second holder and 1 ETH by state override. Its `transferFrom` without allowance and every probe run funded | |
| 38 | | Execute right at `readyBlock`, before anyone looks | `execute` needs a clearance (`NotCleared`); nobody has to be watching | |
| 39 | | Propose an EIP-7702 delegated account: its code is a 23-byte designator, and its key can re-point it after the verdict | `proposeUpgrade` and `proposeSetPausedImpl` refuse it (`DelegatedAccount`), as the constructor does for the paused implementation and the clearance oracle | |
| 40 | | Swap the code at the judged address before `execute` (selfdestruct and redeploy, a metamorphic factory) | `execute` compares the target's codehash with the one recorded at propose time and reverts `CodehashChanged`; the proposal stays pending | |
| 41 | | Code that behaves honestly inside a simulation and maliciously on a real block (basefee, timestamp, block number, origin, gas) | R9: block and transaction context opcodes, external calls, create and selfdestruct veto the upgrade unless the config's fixed allowlist covers them or the approver approved that codehash. The compiler's metadata tail is skipped only when no instruction can reach it, so opcodes cannot hide there | |
| 42 | | Talk the approver into a hand clearance before CRE looks | A hand clearance is not a CRE verdict (`oracleJudged` stays false): the log trigger or the sweeper still judges the proposal, and a CRE veto ejects it. Only a proposal CRE never reaches runs on the approver's word alone ([the sweep window](#the-sweep-window)) | |
| 43 | | Recognize the simulation's stranger and behave honestly toward it | The stranger and receiver addresses are derived from the proposal block's hash, which does not exist yet when the implementation is deployed | |
| 44 | | Hide behind initialization data (a reinitializer that hands over ownership) | R8: init data needs the approver's approval of `keccak256(abi.encode(codehash, dataHash))` | |
| 45 | | Make the `Proposed` log too large to deliver (CRE drops log events over 5 kb) | The sweeper judges every pending proposal among the newest `sweepWindow` ids that CRE has not judged, from `getProposal`, which never includes the data | |
| 46 | | Weaken the airlock first: remove the CRE receiver as a guardian, shorten the delay, swap the break-glass target | Every change to the airlock is itself a proposal and passes both doors; R7 vetoes it without the approver's pre-approval | |
| 47 | | Forge a verdict through the forwarder used in simulation (the MockKeystoneForwarder is permissionless) | Every report carries an EIP-712 signature by the attester; the receiver recovers it | |
| 48 | | Reuse a Clear for other content: another proposal, another chain history, another implementation | The signed `proposalDigest` must equal `AirlockAdmin.proposalDigest(id)` | |
| 49 | | Keep a Clear for later | `validUntilBlock` bounds when a signed report can be delivered; a Clear only applies to a pending proposal and the first evidence hash is kept. A clearance that has been recorded does not expire ([known limitations](#known-limitations)) | |
| 50 | | Run or bribe an RPC provider | Every answering provider must agree and at least two must answer, so a single bad provider can cause a veto (R6, a delay), never a pass | |
| 51 | |
| 52 | ## Two doors and the clearance state machine |
| 53 | |
| 54 | A proposal has a status (`None`, `Pending`, `Vetoed`, `Executed`) and, while it is pending, a clearance |
| 55 | (`clearanceOf[id]`, the evidence hash of the first accepted clearance, zero while there is none). |
| 56 | |
| 57 | ``` |
| 58 | propose (owner) |
| 59 | None ─────────────────────────► Pending, not cleared ── execute ──► reverts TooEarly / NotCleared |
| 60 | │ │ |
| 61 | clear (oracle or │ │ veto (any guardian) |
| 62 | approver, evidence) ▼ ▼ |
| 63 | Pending, cleared ─────► Vetoed ── execute ──► reverts ProposalVetoed |
| 64 | │ veto (terminal: cannot be cleared or executed) |
| 65 | │ |
| 66 | execute (anyone), block.number >= readyBlock |
| 67 | ▼ |
| 68 | Executed (terminal) |
| 69 | ``` |
| 70 | |
| 71 | - **Outer door.** `readyBlock = proposedBlock + delayBlocks`. Time is counted in blocks only, never timestamps. |
| 72 | `delayBlocks` is 25 on Sepolia (about 5 minutes) and bounded to 10 to 50,400 blocks. |
| 73 | - **Inner door.** `clear(id, evidenceHash)` is accepted from the clearance oracle or the approver, for a pending |
| 74 | proposal, with a non-zero evidence hash. The first hash is kept; every accepted call still emits `Cleared` with |
| 75 | the caller's own evidence, so a second opinion is visible on chain. |
| 76 | - **A veto always wins.** A guardian may veto a pending proposal whether it is cleared or not. Once vetoed, it can |
| 77 | be neither cleared nor executed. |
| 78 | - **The oracle's own verdict is recorded apart.** `oracleJudged[id]` turns true when the clearance oracle clears or |
| 79 | vetoes, never on the approver's clearance. A hand clearance therefore does not end the CRE's job: the sweeper |
| 80 | still judges that proposal while it is in the sweep window, and a CRE veto ejects it although `clearanceOf` is |
| 81 | set. |
| 82 | - **`execute` check order:** `UnknownProposal` / `ProposalVetoed` / `AlreadyExecuted`, then `TooEarly`, then |
| 83 | `NotCleared`, then for an upgrade or break-glass target `CodehashChanged` (the target's code must still hash to |
| 84 | the proposal's codehash). Status and the `Executed` event are written before the external call. |
| 85 | - **Every kind needs a clearance**, not only upgrades: guardian, delay and break-glass target changes pass both |
| 86 | doors too. |
| 87 | - **Break glass.** `breakGlass()` (owner or any guardian) switches the proxy to the pre-registered paused |
| 88 | implementation at once, without a proposal. Leaving the paused state is an upgrade through both doors. A cleared |
| 89 | proposal never expires, so an earlier cleared upgrade can also end the pause ([known limitations](#known-limitations)). |
| 90 | |
| 91 | The CRE workflow answers every proposal it judges: a pass becomes a Clear, a failed check a Veto. A proposal no |
| 92 | verdict has reached simply has no clearance. That is the property the whole design rests on: |
| 93 | |
| 94 | | What goes wrong | Result | |
| 95 | |---|---| |
| 96 | | The log trigger misses a proposal, or the workflow run fails | No clearance: the proposal waits; the sweeper judges it on a later run while its id is among the newest `sweepWindow` | |
| 97 | | A provider is down or rate-limited | Errors are left out of the quorum; with fewer than `minAgree` answers the verdict is R6 (veto) | |
| 98 | | Providers disagree | R6 (veto) | |
| 99 | | The report transaction fails, or `onReport` reverts (the forwarder does not propagate the revert) | No clearance; the workflow reads the proposal back and logs `unconfirmed`, and the sweeper tries again | |
| 100 | | CRE cannot answer at all | The approver clears by hand with a published evidence JSON (`scripts/sepolia/clear.sh`); the sweeper still judges the proposal once CRE answers again, and a CRE or guardian veto wins. The approver's clearance is an override: a proposal CRE never reaches runs on it alone | |
| 101 | |
| 102 | A wrong veto costs a new proposal. A wrong clearance could let an attacker in, so every uncertain case resolves to |
| 103 | a veto or to no clearance. |
| 104 | |
| 105 | ### The sweep window |
| 106 | |
| 107 | The sweeper reads only the newest `sweepWindow` ids (20 by default, at most 30, so that the scan fits the ChainRead |
| 108 | budget). A proposal no log-trigger run has judged (a missed log, a failed run) is caught as long as fewer than |
| 109 | `sweepWindow` newer proposals exist when a sweep reaches it; the owner can push it out sooner by proposing more. |
| 110 | Once it is below the window, CRE never judges it: |
| 111 | |
| 112 | - **Not cleared:** it stays pending and cannot execute. It costs a new proposal, nothing more. |
| 113 | - **Cleared by the approver:** it executes on the approver's clearance alone once its cooldown is over. This is the |
| 114 | approver's manual clearance working as designed, an override for when CRE gives no verdict. `oracleJudged` is the |
| 115 | sweeper's work list, not a third door; `test_knownIssue_approverClearanceRunsWithoutOracleVerdict` in |
| 116 | [`test/known-issues/KnownIssues.t.sol`](../test/known-issues/KnownIssues.t.sol) records this. |
| 117 | |
| 118 | ## The workflow |
| 119 | |
| 120 | Two handlers share one code path ([`cre/veto/airlock.ts`](../cre/veto/airlock.ts)): |
| 121 | |
| 122 | - **Handler 0, EVM log trigger** on `AirlockAdmin.Proposed` at `LATEST` confidence. A proposal is judged as soon as |
| 123 | it appears, a whole cooldown before it could run. Only the indexed topics are decoded (id, kind, target), never |
| 124 | the data. |
| 125 | - **Handler 1, cron sweeper** (default every 60 seconds). It reads `nextId`, scans the newest `sweepWindow` (20) |
| 126 | ids, and judges the pending proposals the oracle has not judged (`oracleJudged` false, approver-cleared ones |
| 127 | included), oldest first, within the run's budgets. Whatever is left waits for the next run; older ids are not |
| 128 | scanned ([the sweep window](#the-sweep-window)). |
| 129 | |
| 130 | Judging one proposal: |
| 131 | |
| 132 | 1. **Reads at the proposal block.** Approvals, holder balances and the current delay are read at |
| 133 | `proposedBlock`, so the verdict is the same whether the log trigger or the sweeper reaches it, and an approval |
| 134 | registered after the proposal does not count. |
| 135 | 2. **Config changes (kinds 1 to 3): R7** from those reads alone. Adding or removing a guardian and changing the |
| 136 | break-glass target always need the approver's pre-approval of `keccak256(abi.encode(kind, target, value))`; a |
| 137 | delay change needs it only when it shortens the delay. |
| 138 | 3. **Upgrades (kind 0): static rules.** R5 in strict mode (the codehash must be approved, see |
| 139 | [the two modes](#two-modes-automatic-and-strict)), R8 for init data, and a funded holder must exist. |
| 140 | 4. **Upgrades: the attack simulation, in node mode.** Every node (every DON node in a DON deployment; the CLI |
| 141 | simulator today) sends every configured provider |
| 142 | `eth_getCode(newImpl)` pinned to the proposal block (the code must hash to the proposed codehash, else R6), then |
| 143 | the simulation in at most `simRequests` (default 2) `eth_simulateV1` requests of at most `maxCalls` calls and |
| 144 | 88,000 bytes each, all with the proxy's implementation slot overridden and the stranger given 1 ETH. Every |
| 145 | request starts from the proposal block with a transfer of the test amount (1% of the test holder's balance, |
| 146 | capped at the sender's balance) from a second configured holder with a balance, when there is one, to the |
| 147 | stranger, so the stranger looks like a real user; a funding transfer that reverts is R6. The first request then |
| 148 | holds baseline reads, the holder's transfer to the receiver, reads, a `transferFrom(holder, stranger)` by the |
| 149 | stranger without allowance, the stranger's probes and final reads of `totalSupply`, `owner()` and every watched |
| 150 | balance, the stranger's included. A further request holds reads, more probes and reads. |
| 151 | |
| 152 | Every selector found in the new bytecode is probed with five argument shapes (the stranger, the stranger and a |
| 153 | large number, a large number, none, a wide mix), plus empty and unknown-selector calldata. Every selector outside |
| 154 | the standard token, ownable, proxy and initializer interface also gets 24 placement probes: the funded holder in |
| 155 | each of the first four argument slots, the test amount in each other slot, the remaining slots filled once with |
| 156 | the stranger and once with the proxy. The holder's and the stranger's balances are read right after each |
| 157 | placement probe. A probe's own `Transfer` and `OwnershipTransferred` events count, and so does a holder balance |
| 158 | read after a placement probe that is below its value before the probes, so a drain that emits no event, or that a |
| 159 | later call undoes, still vetoes. If the probes do not fit, the verdict is R6, unless the approver approved the |
| 160 | codehash: that excuses the truncation only (the placement probes of the last selectors are the ones left out), |
| 161 | and every other rule still runs on the probes that fit. |
| 162 | 5. **R9.** The opcode walker that extracts selectors also lists dangerous opcodes; any in the new code that the |
| 163 | config's fixed `allowedOpcodes` (empty by default) does not list vetoes the upgrade unless its codehash is |
| 164 | approved. The list never depends on the current implementation, so an approval covers one codehash and no later |
| 165 | upgrade. The walker skips the solc metadata tail only when it decodes as an exact solc CBOR map, the instruction |
| 166 | before it halts, and every JUMPDEST inside it is followed until execution halts; otherwise the whole code is |
| 167 | scanned (fail closed). An `eth_simulateV1` call without validation runs with basefee and gas price 0, so this is |
| 168 | what catches code that branches on them. |
| 169 | 6. **Quorum.** At least `minAgree` providers must answer and every answer must be the same verdict and rule. |
| 170 | 7. **Report.** Evidence, attestation, `writeReport`, then a read of the proposal to confirm the receiver's effect. |
| 171 | |
| 172 | ## Two modes: automatic and strict |
| 173 | |
| 174 | The config's `strict` flag sets how an upgrade earns its clearance. In both modes the workflow clears nothing that |
| 175 | has not passed the attack simulation. |
| 176 | |
| 177 | **Automatic mode** (`strict: false`, the default and the demo setting). CRE clears or vetoes every upgrade on its |
| 178 | own, with no human in the loop, through a bounded attack search on the proposed code at the proposal block: |
| 179 | |
| 180 | | It probes | How | |
| 181 | |---|---| |
| 182 | | A caller that looks like a real user | The stranger, an EOA derived from the proposal block's hash, holds tokens from a second holder (sent at the start of every request, when a second holder has a balance) and 1 ETH by state override. It makes every call below except the plain transfer | |
| 183 | | A plain transfer | The test holder sends the test amount to a fresh receiver; it must succeed (R4) and move exactly that amount and nothing else (R3) | |
| 184 | | Allowances | Before any probe, the funded stranger calls `transferFrom(holder, stranger)` with no allowance; it must fail (R3B). That also catches a `transferFrom` that waives the allowance for callers holding tokens | |
| 185 | | Every selector in the new bytecode | Five base shapes: the stranger, the stranger and a large number, a large number, none, a wide mix of eight words; plus empty and unknown-selector calldata | |
| 186 | | Every new selector (outside the standard token, ownable, proxy and initializer interface) | 24 placement probes: the funded holder in each of the first four argument slots, the test amount in each other slot, the remaining slots filled once with the stranger and once with the proxy. That covers functions of up to four arguments in any order, and a `require(token == address(this))` guard | |
| 187 | | Balances, probe by probe | The holder's and the stranger's balances read right after each placement probe; every probe's own `Transfer` and `OwnershipTransferred` events; `totalSupply`, `owner()` and every watched balance before and after the probes of each request | |
| 188 | |
| 189 | What that means: it catches every backdoor a funded outside caller reaches with those shapes. An open mint, an |
| 190 | ownership grab, a skimming transfer and a drain of a holder's tokens are all vetoed, the drain whatever its argument |
| 191 | order, with or without an event, gated on the caller holding tokens or ETH, and even when a later call puts the |
| 192 | tokens back. `EvilDrainImpl` and the seven drain variants of the fork end-to-end run are each caught by R3B alone. |
| 193 | |
| 194 | **Strict mode** (`strict: true`, R5). Every new implementation codehash must be approved by the approver key |
| 195 | (`approveCodehash`) after a review of that exact code: an upgrade whose codehash is not approved by the proposal |
| 196 | block is vetoed R5. An approved codehash still runs the attack simulation and meets every other rule; the approval |
| 197 | exempts it from R9 and excuses probe truncation (R6), nothing more. A reviewed upgrade therefore still has to |
| 198 | survive the attack, and an attacker who holds the owner key also needs the approver's review. |
| 199 | |
| 200 | **What each mode is for.** A bounded search catches what its callers and shapes reach. A backdoor behind a magic |
| 201 | value (a drain that opens for one exact argument), a call counter (one that opens on the hundredth call), a |
| 202 | dynamically encoded argument (`bytes`, an array or a struct the probes do not build), more arguments than the |
| 203 | placement probes fill, or a specific caller (a contract, a hard-coded address) is outside a bounded search. That |
| 204 | is what strict mode is for: a person reviews the code once, the approval binds that review to one codehash, and the |
| 205 | simulation still runs on every proposal. Automatic mode fits a token that wants routine upgrades cleared in |
| 206 | minutes; strict mode fits one where every upgrade is reviewed anyway, and makes that review part of the clearance. |
| 207 | |
| 208 | ## Why consensus carries only the verdict and the rule |
| 209 | |
| 210 | The node-mode function returns `stableStringify({verdict, rule})` to `consensusIdenticalAggregation`, and nothing |
| 211 | else. |
| 212 | |
| 213 | - **Identical aggregation needs identical bytes.** Nodes reach the same providers at different moments. One node |
| 214 | may see a timeout or a rate limit where another gets an answer, and error strings, timings and which provider's |
| 215 | detail gets reported can differ. If any of that were in the observation, honest nodes would disagree and the |
| 216 | run would end without a verdict. `{verdict, rule}` is deterministic for honest providers: the simulation is |
| 217 | pinned to the proposal block, the actors are derived from chain data, and a provider error removes an answer |
| 218 | instead of changing it. A run in which the nodes still cannot agree ends without a report, which leaves the |
| 219 | proposal without a clearance. |
| 220 | - **The rest is detail, not decision.** Per-provider answers, failed checks with before and after values, culprit |
| 221 | selectors and the flagged R9 opcodes go to the node logs. None of them changes what happens on chain. |
| 222 | - **The evidence is rebuilt from the chain.** After consensus every node builds the same canonical evidence JSON |
| 223 | from `getProposal` fields, `proposalDigest` and the agreed verdict, hashes it, and signs it with a deterministic |
| 224 | (RFC 6979) signature. Every node therefore produces a byte-identical report payload, and anyone can recompute |
| 225 | every evidence hash later from chain data alone |
| 226 | ([`cre/veto/tools/check-evidence.ts`](../cre/veto/tools/check-evidence.ts)). |
| 227 | - **It is small.** The largest consensus value is 52 bytes, against a 25 kb observation limit. |
| 228 | |
| 229 | ## Report binding |
| 230 | |
| 231 | ``` |
| 232 | report = abi.encode(uint8 action, uint256 proposalId, bytes32 proposalDigest, bytes32 ruleHash, |
| 233 | bytes32 evidenceHash, uint64 validUntilBlock, bytes signature) |
| 234 | action 0 = Veto (ruleHash = keccak256("GUARD/<rule>")), 1 = Clear (ruleHash = 0) |
| 235 | signature = 65 bytes r||s||v by the attester over the EIP-712 digest of |
| 236 | Verdict(address airlockAdmin,uint8 action,uint256 proposalId,bytes32 proposalDigest, |
| 237 | bytes32 ruleHash,bytes32 evidenceHash,uint64 validUntilBlock) |
| 238 | domain ("AirlockReceiver", "1", chainId, receiver address) |
| 239 | proposalDigest = keccak256(abi.encode(uint8 kind, address target, uint256 value, bytes32 codehash, |
| 240 | bytes32 dataHash, uint64 proposedBlock)) |
| 241 | evidenceHash = keccak256(canonical evidence JSON, sorted keys, schema upgrade-airlock/evidence-v2) |
| 242 | validUntilBlock = max(readyBlock, current block) + reportGraceBlocks (50) |
| 243 | ``` |
| 244 | |
| 245 | Each field closes one hole: |
| 246 | |
| 247 | | Bound to | So that | |
| 248 | |---|---| |
| 249 | | chain id and receiver (EIP-712 domain) | a verdict for another chain or another deployment does not verify | |
| 250 | | `airlockAdmin` (in the struct) | a verdict for another airlock does not verify | |
| 251 | | `action` and `ruleHash` (signed) | a Veto cannot be turned into a Clear; a Clear with a rule is rejected (`RuleOnClear`) | |
| 252 | | `proposalDigest` | a verdict about one proposal's content cannot clear another proposal, or the same id after a reorg with other content | |
| 253 | | `validUntilBlock` | an old report cannot be held back and delivered much later; validity is in blocks, so it needs no clock | |
| 254 | | `evidenceHash` | the verdict is tied to a document anyone can rebuild and check | |
| 255 | |
| 256 | `AirlockReceiver.onReport` checks, in order: the sender is the configured forwarder; the workflow metadata names |
| 257 | the expected workflow id and owner (when configured, as in a DON deployment, where the KeystoneForwarder backs the |
| 258 | metadata with DON signatures); the report is fresh; the signer is the attester; the action is 0 or 1; a Clear has |
| 259 | no rule; the digest matches `AirlockAdmin.proposalDigest(id)`. Only then does it emit `Verdict` and call `clear`, or |
| 260 | `veto` and `attachReason`. A replayed Veto is harmless (`veto` is idempotent), and a replayed Clear keeps the first |
| 261 | evidence hash and is refused once the proposal has executed. |
| 262 | |
| 263 | Approval keys, all registered with `approveCodehash` and read at the proposal block |
| 264 | ([`cre/veto/keys.ts`](../cre/veto/keys.ts)): |
| 265 | |
| 266 | | Key | For | |
| 267 | |---|---| |
| 268 | | the codehash | R5 in strict mode, the R9 exemption, and probes that do not fit (R6) | |
| 269 | | `keccak256(abi.encode(codehash, dataHash))` | R8, an upgrade with init data | |
| 270 | | `keccak256(abi.encode(uint8 kind, address target, uint256 value))` | R7, a change to the airlock | |
| 271 | |
| 272 | ## Budgets |
| 273 | |
| 274 | CRE limits every workflow run (`cre workflow limits export`). The workflow plans against them instead of hoping: |
| 275 | |
| 276 | | Limit | Value | How the workflow stays inside it | |
| 277 | |---|---|---| |
| 278 | | ChainRead calls | 15 per run | Reads go through Multicall3 in batches. Log path: 5 calls for an upgrade (latest header, proposal state, reads at the proposal block, the proposal block's header, the confirmation), 4 for a config change. Sweeper: 1 header, 1 `nextId`, 4 for the 20-id scan (`getProposal`, `clearanceOf`, `oracleJudged`; `sweepWindow` is capped at 30), then 3 per upgrade and 2 per config change. Each judgement checks the remaining budget first and defers when it does not fit; a budget that still runs out ends the run with a log line | |
| 279 | | ChainRead payload | 5 kb | Each batch's request and response stay under 4,096 bytes. Init data is never read: R8 needs only `dataHash` | |
| 280 | | HTTP calls | 15 per run | 1 + `simRequests` per provider: 12 with the four Sepolia providers; the config refuses more than 15. One attack simulation per run; static and config verdicts need no HTTP | |
| 281 | | HTTP request size | 120 kb | Each `eth_simulateV1` request holds at most `maxCalls` calls and 88,000 bytes of JSON (117,336 base64-encoded). A placement probe takes three calls (the probe and two balance reads). At the live `maxCalls` of 150, EvilDrain's 130 probes (16 extracted selectors, 2 outside the standard interface: `rescue()` `0x20ff430b` and `0x0000001f`, a length-check constant from the solc dispatcher that the selector walker over-includes and probes harmlessly) go out as two requests | |
| 282 | | Consensus observation | 25 kb | `{verdict, rule}`, at most 52 bytes | |
| 283 | | Log trigger event size | 5 kb | A larger `Proposed` event is left to the sweeper | |
| 284 | | Cron interval | at least 30 s | every 60 s | |
| 285 | | Execution time | 5 min | One simulation per run; the server's sweep unit stops a run after 6 minutes | |
| 286 | | Secrets calls | 5 per run | The attester key is fetched once per run | |
| 287 | | Log line | 1 kb | Lines are clipped at 900 characters | |
| 288 | |
| 289 | On chain, a report runs under a 2,000,000 gas limit. In the fork end-to-end run (an anvil fork of Sepolia, which |
| 290 | charges what live Sepolia charges), a Veto report (`onReport`, `veto`, `attachReason`) used 426,000 to 524,000 gas, |
| 291 | a Clear report 191,000 to 397,000, and `execute` of the v2 upgrade, codehash recheck included, 70,000. |
| 292 | |
| 293 | ## Known limitations |
| 294 | |
| 295 | These are testnet demo contracts. Each limitation below is pinned by a passing test in |
| 296 | [`test/known-issues/KnownIssues.t.sol`](../test/known-issues/KnownIssues.t.sol) that asserts today's behaviour; a |
| 297 | version that removes one replaces its test with a test of the protection. |
| 298 | |
| 299 | | Limitation | Test | |
| 300 | |---|---| |
| 301 | | A matured, cleared, never-executed upgrade can end a `breakGlass` in the same block: anyone may `execute` it | `test_knownIssue_staleProposalUndoesBreakGlassInstantly` | |
| 302 | | Clearances never expire and execution order is free, so an older cleared proposal can roll the code back at any later time | `test_knownIssue_staleProposalRollback` | |
| 303 | | `owner`, `approver` and `clearanceOracle` are immutable, and ProxyAdmin cannot leave AirlockAdmin: there is no key rotation or migration | `test_knownIssue_noMigrationPath` | |
| 304 | | A stolen owner key can re-pause after every unpause. It can freeze the token, but it cannot steal | `test_knownIssue_stolenOwnerRepausesForever` | |
| 305 | | The same key can raise the delay to 50,400 blocks, which R7 treats as not weakening ([`judgeConfig`](../cre/veto/judge.ts#L122-L129)), so every later unpause waits that long while the key re-pauses at will | `test_knownIssue_raiseDelayThenPause` | |
| 306 | | A single rogue guardian can keep the token paused forever by vetoing every exit, including the proposal that removes it: a veto always wins | `test_knownIssue_singleGuardianPermanentFreeze` | |
| 307 | | An unpause is an upgrade, so it needs a clearer: while the oracle is down and the approver is unreachable, a paused token stays paused | `test_knownIssue_unpauseNeedsAClearer` | |
| 308 | | A mistaken manual clearance of a reinitializer runs as proposed: `execute` checks the codehash, never the approval registry or the init data | `test_knownIssue_clearedReinitializerHandsOwnership` | |
| 309 | | The approver's clearance lets a proposal run after the cooldown when no CRE verdict has landed, and for good once the proposal is below [the sweep window](#the-sweep-window) | `test_knownIssue_approverClearanceRunsWithoutOracleVerdict` | |
| 310 | |
| 311 | A production version would add key rotation or a migration path and an expiry for clearances. |