feat(protocol): Preconfer Auction - permissionless standing-bid proposer auction (design + implementation) - #22019
feat(protocol): Preconfer Auction - permissionless standing-bid proposer auction (design + implementation)#22019dantaik wants to merge 20 commits into
Conversation
Research report and design proposal for an auction-based, permissionless preconfer/proposer mechanism (Preconfer Auction) as an alternative to the URC-based permissionless-preconf stack: ETH auction of per-epoch preconfer rights, TAIKO liveness bonds with slashing, and an automatic backup/fallback ladder, with no dependency on Ethereum consensus-layer changes. Includes a companion deep-dive on eth-fabric/urc. Context: #22012 (URC production-readiness review).
Adds section 4.11: the winning preconfer signs each block it builds (secp256k1, EIP-712 over header fields). The signature is not a validity or consensus input; it enables S1 slashing (equivocation / provably malformed blocks), p2p authenticity, failover verification, and binds the winner to promised ordering.
…-quantized handover Replaces the per-epoch auction (13,500/yr, forced rebidding) with a perpetual auction: a bonded ranked list of standing ETH bids where the top bid is the winner until outbid, voluntary quit, or tenure expiry, and the runner-up inherits automatically. Control changes are quantized to epoch boundaries behind a BID_FREEZE window. An empty list falls back to total permissionless proposing.
- Refute rule: stall slashes require refutation to disprove the recorded fault gap (canonical proposal preimage vs the Inbox ring-buffer hash), closing the stall-then-wake slash-evasion loophole - Handover: transitions take effect 2 epochs after placement (effectiveEpoch), current+next assignments always final, >=1 epoch clean-handover notice - Payment model: per-epoch ETH rate charged lazily from a prepaid balance, zero keeper/boundary transactions; expiry becomes a cheap renew() - Fault catalogue: add P2 promise equivocation; heartbeat censorship documented as priced-not-proved; P1 refutation evidence specced - New section 4.12: O(1) read model, determinism horizon, and getters - Fix stale gas/cost and parameter sections
…r auction Implements the Preconfer Auction design (auction_based_permissionless_preconf.md): - Perpetual standing-bid list (max 16, all TAIKO-bonded); top bid is the winner, second is the designated backup; every transition (bid/quit/expiry) takes effect TRANSITION_LEAD_EPOCHS (2) epochs after placement so current and next epoch assignments are always final (O(1) lazy per-epoch cache). - Bids are a per-epoch ETH rate charged lazily from a prepaid balance at epoch assignment - zero keeper/boundary transactions in steady state; renew() is a cheap expiry extension with no increment. - IProposerChecker integration: fallback ladder (winner -> backup -> any bonded operator -> anyone); stall faults escrow the winner's liveness bond with the fault gap recorded; refutation must disprove the recorded gap via a canonical proposal preimage against the Inbox ring-buffer hash (reorg-safe, immune to stall-then-wake evasion); settlement rewards the challenger. - S1 slashing: EIP-712 operator-signed blocks enable invalid-block and equivocation disputes with one-shot digests, ejection below the bond threshold, and withdrawal delays. - Registered in script/gen-layouts.sh; layout file generated. Compiles with forge (--via-ir); ProposerAuction runtime size 20,952 bytes.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 65bf1d4628
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| InvalidSignature() | ||
| ); | ||
|
|
||
| bytes32 evidence = keccak256(abi.encode(_hashBlockData(_a), _hashBlockData(_b))); |
There was a problem hiding this comment.
Canonicalize equivocation evidence before marking it used
Because this evidence hash preserves argument order, submitting the same signed blocks as (a, b) and then (b, a) produces different _slashedBefore keys in _slashForFault. A challenger can therefore slash the winner and collect a reward twice for one equivocation; sort the two block hashes before constructing the evidence digest.
Useful? React with 👍 / 👎.
|
|
||
| unchecked { | ||
| // Safe: _tenureMaxEpochs <= type(uint32).max - TRANSITION_LEAD_EPOCHS. | ||
| info.effectiveEpoch = currentEpoch + TRANSITION_LEAD_EPOCHS; |
There was a problem hiding this comment.
Keep the old bid active until its update takes effect
When an already-active bidder updates their bid, this overwrites the only effectiveEpoch for that bidder with currentEpoch + 2; _computeAssignment consequently filters the bidder out before then. If the current epoch has not been snapshotted, the winner disappears immediately, and even with a current snapshot they disappear in the next epoch, violating the promised two-epoch transition lead and opening an unintended unassigned/fallback window. Preserve the previous bid state until the update activates.
Useful? React with 👍 / 👎.
| // Safe: _tenureMaxEpochs <= type(uint32).max - TRANSITION_LEAD_EPOCHS. | ||
| info.effectiveEpoch = currentEpoch + TRANSITION_LEAD_EPOCHS; | ||
| info.expiresAtEpoch = info.effectiveEpoch + _tenureMaxEpochs; |
There was a problem hiding this comment.
Enforce the documented tenure bound before unchecked addition
The constructor accepts any nonzero uint32 tenure, so the stated safety condition is not enforced: for example, type(uint32).max makes effectiveEpoch + _tenureMaxEpochs wrap to the epoch immediately before activation. Such a deployment accepts bids that are already expired and purges them without ever assigning them; reject tenures above type(uint32).max - TRANSITION_LEAD_EPOCHS or perform checked arithmetic.
AGENTS.md reference: packages/protocol/AGENTS.md:L224-L229
Useful? React with 👍 / 👎.
🐋 DeepSeek Code Review🔴 Critical Issues1. bytes32 evidence = keccak256(abi.encode(_hashBlockData(_a), _hashBlockData(_b)));Then _slashedBefore[keccak256(abi.encode(_epoch, _FAULT_EQUIVOCATION, _winner, _evidence))] = true;Because the order of Fix by normalizing the evidence before hashing, e.g. sorting the two block hashes or hashing a canonical ordering: (bytes32 h1, bytes32 h2) = (_hashBlockData(_a), _hashBlockData(_b));
if (h1 > h2) (h1, h2) = (h2, h1);
bytes32 evidence = keccak256(abi.encode(h1, h2));🟡 Warnings1. function _deferBondWithdrawal(address _account) internal {
if (_bonds[_account].withdrawableAt == 0) {
_bonds[_account].withdrawableAt = uint48(block.timestamp) + _bondWithdrawalDelay;
}
}If an account has previously gone through the withdrawal delay (or was listed and the delay expired),
because 2. _catchUpSnapshots(MAX_BACKFILL_EPOCHS);but
A bidder can manually call 3. _catchUpSnapshots(MAX_BACKFILL_EPOCHS);with 4. keccak256(abi.encode(
_block.epoch,
_block.blockNumber,
_block.parentHash,
_block.timestamp,
_block.coinbase,
_block.gasLimit,
_block.txRoot
));
5. 🔵 Suggestions
🟢 What Looks Good
Automatically triggered on PR update • model: |
|
Design + implementation review of A. The permissionless-proposal storyThe contract delivers permissionlessness in three layers, and the layering is right:
Two contingencies keep this from being a guarantee yet, and both should be treated as blockers for any deployment claim:
B. Liveness findingsL1 (High) — Fallback throughput is throttled to one proposal per L2 (High) — The "current and next assignments are always final" invariant has four holes. The cached next assignment is advisory:
The structural fix for (a)/(b) — and for S2 below — is pending fields: L3 (Medium) — Epochs with zero proposals are never recorded, and the hole is permanent. L4 (Low) — Dead window when there is no backup: with L5 (Low, wiring) — C. Slashing findingsS1 (High) — Signer rotation + the L3 hole let an equivocator escape. S2 (Medium) — Self-challenge dampens the stall slash, and S3 (Medium) — Accident slashes are now unrefutable by construction; decouple the two thresholds. Because refutation (correctly) requires disproving the recorded gap, a winner whose propose tx simply couldn't land for S4 (Medium) — The S1 evidence window is implicitly the bond-withdrawal delay. S5 (Low) — Doc/impl divergences and dead code to reconcile: the design doc's per-epoch total slash cap (§4.11) is not implemented — each distinct signed block slashes a full S6 (Low) — S7 (Low) — No proof-of-possession for D. Auction integrity (affects who you're trusting for liveness)D1 (Medium) — The 5% increment protects the wrong epoch. D2 (Low) — Active bidders can re-price downward freely (only floor-checked). Combined with the L2(a) fix this becomes a deliberate feature question: unrestricted decreases drift the auction from first-price toward second-price (winner shades down to just above the runner-up). Possibly fine — but decide it, and if allowed, apply the same 2-epoch lead. D3 (Low) — List squatting: 16 slots × VerdictThe architecture is the right one and the hardest part of the previous round — reorg-safe, evasion-proof stall evidence — is implemented correctly. Four fixes are load-bearing before Inbox wiring: (1) switch the ladder to the winner-absence clock (L1); (2) pending fields + promote-cached-next snapshotting, which fixes L2(a)/(b), S1-rotation, and makes finality structural in one change; (3) epoch backfill + permissionless Generated by Claude Code |
Review-round-2 fixes: - Ladder gates on WINNER ABSENCE instead of time since the last proposal: backups propose at full cadence during an outage and the winner reclaims exclusivity by proposing once; slash escrow fires only after a decoupled ESCROW_GRACE (>= stall grace) so transient accidents are never slashed. - Pending transitions: re-bids, renewals, and signer rotations take effect with the 2-epoch lead and can no longer alter finalized current/next assignments; quit clears pending changes; a pending-aware purge keeps renewals alive. - Promote-don't-recompute snapshots: the cached next assignment is promoted, so re-bids cannot forfeit won epochs, renewals cannot resurrect expired entries into finalized epochs, and mid-epoch ejections lose rung-0. - withdrawEth keeps the fixed next winner's epoch fee reserved (no fast exit past the quit notice); rewardBps capped at 50%; permissionless snapshot() poke with bounded 32-epoch backfill records zero-proposal epochs for S1 disputes and fee charges; epoch signer recorded before next-epoch materialization (rotation-escape closed); NoBondToSlash guard. Tests: 73 passing (13 new regressions).
- Ladder gates on winner absence (backup full cadence; winner reclaims by proposing once); slash escrow decoupled behind ESCROW_GRACE >= STALL_GRACE so transient accidents are never slashed. - Pending transitions (re-bid/renew/signer rotation) with the 2-epoch lead + promote-don't-recompute snapshots preserve assignment finality; next winner's ETH fee is reserved against draining. - rewardBps capped at 50%; permissionless snapshot() poke with bounded backfill covers zero-proposal epochs; S1 evidence window documented. - Parameter table and state machine updated; implemented + tested in #22019.
- _buildProposal now calls the proposer checker gas-isolated (bounded gas + try/catch): a buggy or reverting checker can no longer halt the rollup (PR #22012 E-3). - On checker revert, permissionless proposing is allowed while the escape hatch is open: the oldest queued forced inclusion overdue beyond forcedInclusionDelay * permissionlessInclusionMultiplier (wires the previously-unenforced knob, kimi-k3 I-01 / PR #22012 E-4). The authorization runs BEFORE forced inclusions are consumed so the hatch can observe the stale inclusion it is gated on. - New Config field proposerCheckerGasLimit (validated; 3M on mainnet/devnet configs); LibForcedInclusion.isPermissionlessInclusionAllowed helper. - Tests: permissionless proposing succeeds for an unauthorized caller when the hatch is open (and consumes the overdue inclusion); reverts with PermissionlessProposingNotAllowed otherwise (not overdue / no inclusions).
dantaik
left a comment
There was a problem hiding this comment.
Round 4 — design challenge (19 concrete questions, inline)
A fresh design-challenge pass on the standing-bid auction as a permissionless-proposal mechanism, focused on liveness and slashing. These are questions, not bug reports: each needs an answer that is either a design change or a documented rationale. Every one was verified against the code at b589528/b1b4efb (the checkProposer skeleton is unchanged from the last review; b1b4efb adds the Inbox try/catch + escape hatch, which I've folded into Q16) and de-duplicated against the three prior rounds and the DeepSeek bot comment — where a question sharpens a still-open prior item, it says so and adds a new angle. Reviewers who fixed the round-3 findings: the skeleton is genuinely improved; these probe what the pending-update + promote-cached-next redesign opened up.
The 19 are posted inline at their anchor lines. The through-line: the design is now robust against a stalling winner, but not yet against a rational one — most of these are places where an economically motivated participant (incumbent, backup, sybil challenger, or the winner itself) profits from an edge the "honest vs. offline" framing didn't consider.
Grouped by theme:
Reserve floor is a one-way ratchet (Q1, Q19). The EMA floor never decays while the list is vacant, so a demand collapse — or a whale who overbids then quits — freezes the auction closed until a proxy upgrade; and in unassigned mode non-participation strictly dominates bidding, an absorbing state the frozen floor can never self-correct. This is the single most structural issue: the auction can enter a state it cannot leave.
Assignment finality has residual holes after the redesign (Q5, Q11, Q12). Catch-up backfill computes past epochs from the present purged list (recording the wrong winner, charging the wrong party, and un-slashing the real one); the withdrawEth reservation is defeated in the pre-snapshot window at every epoch start; and an inheriting backup can be stall-escrowed in the very transaction that promotes them.
Liveness degrades across epoch boundaries and lets a drained winner persist (Q2, Q7, Q8). The ladder re-closes for a full stallGrace at every boundary during a continuous outage; the promote path never checks the winner's bond, so a self-drained winner becomes an unslashable preconf-signing franchise; and nobody is paid to call settleStallSlash, so ejection may never happen.
Slashing punishes the wrong faults and rewards the wrong party (Q3, Q6, Q9, Q10, Q13). The S1 epoch-window rule outlaws the deployed client's handover convention; expiry is a zero-delay bond exit that guts the S1 evidence window; the backup profits from causing stalls it can't be blamed for; the S1 predicates miss fabricated-parentHash fraud and the promised gasLimit check; and escrowGrace is a de-facto ≤96 s heartbeat mandate with no refutation for honest batching.
Entry economics contradict the permissionless claim (Q4, Q14, Q15, Q18). A quiet epoch's first proposal permanently strands its S1 evidence; a full list has no eviction path so 16 renewing operators close the auction at any price; the "bonded operator" rung is a same-block flash-bond; and the bidder address is a hot proposing key that also custodies all bond/ETH/exit rights.
Integration & migration (Q16, Q17). The rung-3 fee/bond the doc says "must not be skipped" exists in neither the ladder nor the new escape hatch; and _catchUpSnapshots loses epochs beyond 32 permanently instead of resuming.
Three lower-priority comparative questions I did not post inline, worth a design-doc paragraph each rather than a code change:
- tenureMax + renew(): once per-epoch prepaid charging lapses non-payers and slashing ejects the dead, what does tenure expiry still protect against for the winner — and is the griefable expiry-2 renewal deadline (a sniper catching a missed renewal enters increment-free at the floor) worth it?
- First vs. second price: since the top-holder increment exemption already lets proceeds drift toward the runner-up via downward re-bids, why not charge
max(floor, runnerUp × 1.05)capped at the winner's own bid — making bids truthful and deleting the re-pricing dance (and most of the increment-anchor surface) structurally? - Winner-take-all vs. pooled rotation: §4.3 never weighs the fourth structure your own cited template (
ProverAuction) implements — rotating epochs across the top-K standing bids, which has none of the per-epoch-auction churn yet keeps every member warm, paid, and slashable-when-serving. Winner-take-all shrinks warm operators from 4 (today's whitelist) to 1; add the row to §5 or parameterize as top-K with K=1 recovering current behavior.
None of these blocks the direction — the standing-bid + epoch-quantized-handover architecture is sound. They're the questions I'd want answered before an audit engagement, because each one is a place where the incentive analysis (§4.8) and the code diverge.
Generated by Claude Code
|
|
||
| /// @inheritdoc IProposerAuction | ||
| function getReserveFloor() public view returns (uint128 floorInGwei_) { | ||
| uint256 scaled = uint256(_movingAverageBid) * _movingAverageMultiplier; |
There was a problem hiding this comment.
Q1 (must-answer): What is the reserve floor's recovery path from a demand collapse — and do you realize it prices entry at 2× the incumbent, not +5%?
Three compounding properties of this formula:
- No downward path.
_updateMovingAverageruns only in the winner-exists branch of_snapshot, and there is no time decay — so the moment the list empties because nobody can meet the floor,bid()revertsBidBelowReservefor every entrant and the EMA never updates again. Permanent vacancy, zero proceeds, no preconf franchise, until a proxy upgrade. The collapse can even be bought: a whale overbids at 50 ETH/epoch for ~half an EMA window (~112 epochs ≈ 5,600 ETH in fees at a 1-day window), pushing the floor to ~39 ETH, then quits — no rational successor can ever enter. Your own cited templateProverAuctionadapts price in the entry-attracting direction on vacancy (fee doubling); this contract has no downward analogue anywhere. - The floor, not the increment, is the real entry bar. In steady state the EMA converges to the incumbent's own charged rate B, so with the doc's recommended multiplier 2 the floor sits at 2B — a challenger must pay +100% while §4.8 claims cartels are "broken by any outsider bidding +1 increment (5%)".
- It blocks the incumbent's own re-bids and signer rotations. Every
bid()passes the floor check — and re-bidding is the only signer-rotation path — so an incumbent at rate B with a leaked signing key must re-bid ≥ 2B (permanently doubling their rent and ratcheting the floor toward 4B) or keep the compromised signer. Meanwhilerenew()extends tenure below-floor forever with no check at all.
Suggested directions: decay the floor during winnerless epochs (feed initialFloor samples per unassigned epoch, or halve the EMA per N vacant epochs); bound the effective entry bar by min(EMA × multiplier, activeTop × 1.05); exempt same-or-lower self re-bids from the floor and add a floor-independent rotateSigner().
Generated by Claude Code
There was a problem hiding this comment.
Resolved. The floor now decays on unassigned epochs (floorDecayBps), is bounded by the 5% increment bar when an incumbent exists, and same-or-lower self re-bids are floor-exempt (cheap signer rotation). Rationale: design doc §4.13 Q1.
| uint48 epochStart = LibPreconfUtils.getEpochTimestamp(); | ||
| uint48 epochEnd = uint48(uint256(epochStart) + LibPreconfConstants.SECONDS_IN_EPOCH); | ||
|
|
||
| uint48 absenceBase = _lastWinnerProposalAt < epochStart ? epochStart : _lastWinnerProposalAt; |
There was a problem hiding this comment.
Q2 (must-answer): Why does the ladder re-close at every epoch boundary when the winner hasn't changed?
This line re-anchors the absence clock to epochStart at every boundary, even when _snapshot re-promotes the same dead winner (the promote path checks ETH solvency but never liveness). During a multi-epoch outage the backup must therefore re-wait stallGrace (48 s) at the start of every epoch, bonded operators 96 s, rung 3 192 s — an 87.5% / 75% / 50% duty cycle respectively, with a preconf gap at every boundary. With bondMultiplier = 2 this persists for ≥ 3–4 epochs minimum (three 1-livenessBond escrows to cross the ejection threshold, each gated behind a full refute window plus an unincentivized settleStallSlash call) and indefinitely if nobody settles.
The design doc's claim that the backup serves "at full cadence" during an outage is only true intra-epoch, and test_checkProposer_backupProposesAtFullCadenceDuringOutage never crosses a boundary — the regression is invisible to the suite.
Suggested direction: carry _lastWinnerProposalAt across the boundary when _currentWinner is unchanged from the previous epoch (a genuinely new winner still gets the full grace from epoch start), and add a boundary-crossing outage test.
Generated by Claude Code
There was a problem hiding this comment.
Resolved. The winner-absence clock now carries across epoch boundaries when the winner is unchanged (reset only on winner change), so a continuous outage doesn't re-close the ladder. See §4.13 Q2.
|
|
||
| uint256 epochStart = _epochStartTimestamp(_epoch); | ||
| require( | ||
| uint256(_block.timestamp) < epochStart |
There was a problem hiding this comment.
Q3 (must-answer): The S1 epoch-window rule outlaws the handover convention the deployed clients implement. How does an honest incoming winner hand over?
The shipped preconf driver reserves the last handoverSkipSlots (default 8 slots = 96 s; taiko-client cmd/flags/driver.go) of every epoch for the incoming operator, whose L2 blocks carry timestamps still inside the outgoing epoch (lookahead.go SequencingWindowSplit gives the next operator [slotsPerEpoch-8, slotsPerEpoch)). Under this predicate, the incoming winner has no legal move: tag those blocks epoch E+1 and timestamp < epochStart(E+1) makes each one slashable evidence; tag them epoch E and they recover to a non-winner signer, failing the very authenticity checks the signature exists to provide.
Concretely, a rotation produces up to ~8 signed blocks in the handover window; each is a distinct evidence digest worth min(livenessBond, balance), so 3 handover blocks wipe 3L and eject the incoming winner via _maybeEject before they serve a single official slot. The alternative — incoming winner idles the tail — silently deletes the p2p leadership-transfer choreography the shipped client implements, and §2.4 claims the client service is reused "unchanged".
Suggested direction: add an explicit handover margin to the predicate (blocks tagged E+1 legal from epochStart(E+1) − HANDOVER_MARGIN), or spec that the outgoing winner serves to the last slot and rewrite the client convention — either way, decide it here, not at incident time.
Generated by Claude Code
There was a problem hiding this comment.
Resolved. Added a HANDOVER_MARGIN (default 96s = the client's 8 handoverSkipSlots) to the S1 timestamp predicate, so the incoming operator's handover blocks aren't slashable. See §4.13 Q3.
| require(msg.sender == inbox, NotInbox()); | ||
|
|
||
| uint32 currentEpoch = _currentEpochIndex(); | ||
| if (_assignedEpoch != currentEpoch) _snapshot(currentEpoch); |
There was a problem hiding this comment.
Q4 (must-answer): The first proposal after a quiet epoch seals that epoch forever — why the single-epoch snapshot path, given the sealing party controls that proposal?
Sharpening the round-3 zero-proposal item with two new angles the snapshot() fix doesn't cover:
checkProposerruns a single-epoch_snapshot(currentEpoch), and_catchUpSnapshotsrefuses to run once_assignedEpoch >= currentEpoch— so the moment the first proposal of E+1 lands after a quiet epoch E, the cursor jumps E−1
Generated by Claude Code
| require(msg.sender == inbox, NotInbox()); | ||
|
|
||
| uint32 currentEpoch = _currentEpochIndex(); | ||
| if (_assignedEpoch != currentEpoch) _snapshot(currentEpoch); |
There was a problem hiding this comment.
Q4 (must-answer): Why the single-epoch snapshot path, when the party who benefits most from stranding a quiet epoch controls the first proposal that seals it?
(Sharpening the round-3 zero-proposal item with a new angle.) checkProposer runs _snapshot(currentEpoch) — a single step — and _catchUpSnapshots refuses to run once _assignedEpoch >= currentEpoch. So the moment the first proposal of E+1 lands after a quiet epoch E, the cursor jumps E-1 → E+1 and epoch E's winner/signer record and fee are unrecoverable: slashEquivocation(E,…) / slashInvalidBlock(E,…) revert NoWinnerForEpoch forever.
Adversarial timeline: winner W double-signs conflicting preconfs during a low-activity epoch E, proposes nothing on L1 in E, then proposes at slot 0 of E+1 — 12 s in. Preserving S1 evidence for E now requires a third party to land snapshot() ahead of W's own transaction in a 12-second race. W's cost is one epoch of foregone revenue, and even E's fee is forgiven by the same stranding — so in any quiet epoch, S1 deterrence is free to void.
Suggested direction: have checkProposer call a bounded _catchUpSnapshots(k) instead of the single-epoch _snapshot (the boundary proposal already amortizes the gas), and/or decouple gap-epoch record backfill from the monotonic _assignedEpoch cursor so a later snapshot() can still record a skipped epoch.
Generated by Claude Code
There was a problem hiding this comment.
Resolved. checkProposer now runs the bounded, resumable catch-up instead of a single-epoch snapshot, so a quiet epoch's winner/signer/fee are recorded by the boundary proposal rather than sealed away. See §4.13 Q4/Q17.
| } else if (absence <= graceSum2) { | ||
| // Rung 2: the backup or any bonded operator. | ||
| allowed = (_currentBackup != address(0) && _proposer == _currentBackup) | ||
| || _hasBondAtLeast(_proposer, _ejectionThreshold); |
There was a problem hiding this comment.
Q15 (should-answer): What does the "bonded operator" rung buy beyond a same-block TAIKO round-trip?
Rung 2 and the unassigned-mode instant rung gate on _hasBondAtLeast — an instantaneous balance read — while withdrawBond for a non-bidder has no delay at all (codified by test_bond_depositAndWithdraw_unlistedAccount). A contract can therefore depositBond → Inbox.propose → withdrawBond atomically in one transaction. Since non-winner proposers also face no fault class (neither stall nor S1 applies to them), the "bonded operator" gate is a gas-cost formality: rung 2 becomes "anyone with transient TAIKO" at 96 s, and unassigned-mode bonded priority becomes "anyone" at t=0 instead of 48 s — and the flash-bonder even collects the escrow-challenger reward stream while committing nothing.
Suggested direction: require minimum bond age (record depositedAt, require e.g. one epoch of seasoning before _hasBondAtLeast counts it), or start a short withdrawal delay whenever a bond is used to pass checkProposer, so rung-2 eligibility represents standing collateral rather than a same-block round-trip.
Generated by Claude Code
There was a problem hiding this comment.
Resolved. Passing checkProposer via the bond defers that bond's withdrawal (checkBondDeferWithdrawal precedent), breaking the same-block depositBond -> propose -> withdrawBond flash-bond. See §4.13 Q15.
| || _hasBondAtLeast(_proposer, _ejectionThreshold); | ||
| } else { | ||
| // Rung 3: anyone. | ||
| allowed = true; |
There was a problem hiding this comment.
Q16 (should-answer): Which contract enforces the rung-3 fee/bond the design doc says "must not be skipped" — or has it been silently dropped?
Design doc §4.5 says a rung-3 bond or permissionless-inclusion fee "must not be skipped", yet this rung is allowed = true — zero bond, zero fee — and the rung-3 proposer is even recorded as the escrow challenger earning up to 50% of livenessBond. In the empty-list launch state, anyone proposes free 48 s into every epoch.
Now that the Inbox wiring has landed (b1b4efb), the escape-hatch path (catch → _isPermissionlessInclusionAllowed) is a separate door from this ladder's rung 3, and neither charges anything. The mainnet Inbox accepts 1 proposal/L1 block into a 21,600-slot ring buffer and halts all proposing with NotEnoughCapacity when unfinalized proposals fill it; with mainnet livenessBond currently 0, a griefer spamming junk proposals at 1/block during a proving slowdown consumes ring capacity and crowds out honest fallback proposers at pure gas cost — the liveness floor becomes the DoS surface the doc warned about.
Suggested direction: charge rung-3/unassigned proposers a small refundable deposit or fee inside checkProposer (the checker can hold it — no Inbox change needed), or gate rung 3 on a reduced bond threshold with a real withdrawal delay, and document explicitly which contract enforces the §4.5 requirement before mainnet.
Generated by Claude Code
There was a problem hiding this comment.
Documented. Rung 3 stays free as the liveness floor (taxing it taxes the exact fallback the design exists to guarantee); the DoS is bounded by 1-proposal/block + the 3-day ring buffer + the proving drain. The unassigned-mode fee (Q19) prices the privileged early rung. See §4.13 Q16.
| if (_assignedEpoch >= currentEpoch) return; | ||
| uint32 start = _assignedEpoch + 1; | ||
| if (currentEpoch - start >= _maxSteps) { | ||
| start = currentEpoch - _maxSteps + 1; |
There was a problem hiding this comment.
Q17 (should-answer): Why a lossy skip instead of a resumable catch-up — when the epochs lost are precisely those inside the outages this design exists to survive?
(Sharpening the round-3 zero-proposal partial fix.) _catchUpSnapshots jumps start to currentEpoch - 31, and once the loop sets _assignedEpoch = currentEpoch, the early-return makes the older epochs permanently unrecoverable — no repeated snapshot() call can ever record them. During a NotEnoughCapacity halt (up to ~675 epochs at the 3-day ring buffer), an L1 outage, or any quiet stretch, someone must call the unincentivized snapshot() at least every 32 epochs (~3.4 h) or _epochWinners/_epochSigners are never written and S1 slashing reverts NoWinnerForEpoch forever for those epochs — exactly when a winner can sign equivocating preconf blocks off-chain with impunity, since clients still resolve the assignment from live state.
Suggested direction: make catch-up resumable — each snapshot() processes up to 32 epochs starting from _assignedEpoch + 1, never skipping, so repeated permissionless pokes eventually record everything — and pay the caller a sliver of the charged fees per backfilled epoch so the poke is incentivized during exactly the halts that need it.
Generated by Claude Code
There was a problem hiding this comment.
Resolved. Catch-up is now resumable (up to 32 epochs from _assignedEpoch + 1, never skipping), so repeated permissionless pokes eventually record every epoch — no permanently lost outage epochs. See §4.13 Q17.
| address winner = _currentWinner; | ||
|
|
||
| // Rung 0: the winner may always propose during their epoch. | ||
| if (winner != address(0) && _proposer == winner) { |
There was a problem hiding this comment.
Q18 (should-answer): The bidder address must be a hot key signing every ~12 s, yet it also custodies the bond, prepaid ETH, and all exit rights — why no cold/hot split?
checkProposer authenticates the winner by L1 msg.sender, so the bidder address is a hot key proposing every slot — and that same key controls withdrawBond, withdrawEth, quit, and signer rotation (via re-bid). §4.11 insists the block signer is "a hot, rotatable key — never the bond key", yet the proposing key — equally hot — is the bond key. An operator bidding 0.03 ETH/epoch prepays ~47 ETH for a week plus 4× livenessBond in TAIKO, all controlled by the key on an internet-facing proposer box. One compromise yields full theft (quit + delay + withdrawals) and faster harvesting: the attacker re-bids at the same amount to rotate the registered signer to their own key, then farms rewardBps = 50% of repeated self-inflicted S1 slashes via a sybil challenger.
If the intended answer is "use a smart-contract wallet as the bidder", is proposing through a contract every 12 s (extra hot-path gas) plus operator-built access control really the design — and where is that documented?
Suggested direction: split identities — a cold custodian owns the bond, prepaid ETH, and quit/withdraw rights and registers a rotatable hot proposer address using the same PendingUpdate + 2-epoch-lead machinery that already exists for signer rotation — or explicitly document the contract-wallet pattern with its hot-path cost.
Generated by Claude Code
There was a problem hiding this comment.
Documented. Operators bid through a smart-contract wallet (cold custody authorizes a rotatable hot proposer); a native cold/hot split is deferred as an operator-architecture concern, not a protocol-invariant hole. See §4.13 Q18.
| bool allowed; | ||
| if (winner == address(0)) { | ||
| // Unassigned: any bonded operator may propose first-come; anyone after the grace. | ||
| allowed = absence > _stallGrace || _hasBondAtLeast(_proposer, _ejectionThreshold); |
There was a problem hiding this comment.
Q19 (should-answer): In unassigned mode, non-participation strictly dominates bidding — what mechanism guarantees the auction ever clears, or re-clears after it first empties?
When the list is empty, this rung lets any operator holding just ejectionThreshold (half the requiredBond needed to bid) propose first-come with zero fee, and anyone at all after 48 s. So every operator's outside option is: hold a half-bond, never bid, and race in permissionless mode — a tacit-collusion equilibrium needing zero coordination. And it's absorbing: _updateMovingAverage runs only when a winner exists, so with no winner the EMA and floor never decay, and "floor > every operator's private exclusivity premium" can never self-correct.
Numbers: epoch revenue R = 0.6 ETH shared among 3 bonded racers ≈ 0.2 each (~0.15 after L1 PGA leakage). A defector bidding at a 0.5 ETH floor nets 0.1 < 0.15 — non-participation strictly dominates, the treasury books zero, preconf UX (the product the auction funds) is off, and the racing dissipation leaks to L1 validators. One over-shot floor plus one winner exit ends the assigned regime for good.
Suggested direction: price the free-rider option — charge bonded first-come proposers in unassigned epochs a per-proposal fee derived from the floor (credited to proceeds) — and make the floor decay during unassigned epochs so the assigned equilibrium is re-enterable (this is the same decay fix Q1 needs).
Generated by Claude Code
There was a problem hiding this comment.
Resolved. The instant unassigned rung is priced at the (decaying) reserve floor once per epoch, so bidding (same price, guaranteed exclusivity) weakly dominates free-riding; combined with the Q1 floor decay, vacancy is no longer an absorbing state. See §4.13 Q19.
Self-contained single-file HTML presentation walking through the auction and slashing design step by step: the perpetual standing-bid auction, the 2-epoch transition lead, assignment promotion and per-epoch ETH charging, the winner-absence fallback ladder, the gap-disprovable stall slash, S1 signed-block slashing, the Inbox escape hatch, and the parameter table.
Reflects the gas-isolated checker call (try/catch) added to Inbox.propose: propose_single 71083 -> 72431, propose_forced_inclusion 58948 -> 60997, propose_after_ring_buffer_wrap 44783 -> 46131.
- Split the SignedBlockData typehash string to satisfy solhint max-line-length (120). - Fix 'quitted' -> 'quitting' in IProposerAuction NatSpec. - Add 'BALs'/'BAL' to the typos allowlist (EIP-7928 acronym).
Documents the resolution for each of the 19 inline review questions and 3 comparative questions (research-grounded: ProverAuction vacancy fee-doubling / checkBondDeferWithdrawal precedents, the client's 96s handover convention, execution-tickets / based-preconf economics). Covers: reserve-floor decay + increment-bounded entry bar (Q1/Q19), boundary-crossing ladder clock (Q2), S1 handover margin (Q3), resumable non-lossy backfill (Q4/Q5/Q17), zero-delay expiry exit (Q6), under-bonded promote/renew (Q7), settle bounty (Q8), backup challenger-reward burn (Q9), S1 seqNo (Q10), pre-snapshot drain (Q11), inherited-backup clock + winner-only charge (Q12), escrowGrace budget (Q13), evict-lowest (Q14), bond defer-on-use (Q15), free rung-3 (Q16), contract-wallet bidder (Q18).
Resolves the 19 round-4 review questions (see design doc §4.13): Reserve floor & entry economics: - Q1/Q19: EMA decays on unassigned epochs (floorDecayBps); floor bounded by the 5% increment bar when an incumbent exists; self re-bids floor-exempt; the instant unassigned rung is priced at the floor once per epoch. - Q14: a full list evicts the lowest-ranked entry when cleared by 5%. - Q15: passing checkProposer via the bond defers that bond's withdrawal. - Q16/Q18: documented (rung-3 free floor; contract-wallet bidder). Liveness / slashing: - Q2: winner-absence clock carries across epoch boundaries when the winner is unchanged (no ladder re-close); Q12: inherited backup's clock starts at assignment (no same-tx escrow). - Q4/Q5/Q17: resumable non-lossy catch-up in checkProposer; purge re-anchored to _assignedEpoch and run after backfill; bootstrap fast-forwards pre-deploy gap. - Q6: expiry is no longer a delay-free bond exit (_everListed + purge-via-lapse). - Q7: promote and renew() require bond >= ejectionThreshold. - Q8: settleStallSlash pays a settle bounty to the caller. - Q9: the designated backup's challenger reward is burned. - Q11: withdrawEth forces a bounded catch-up snapshot first. - Q12: _computeAssignment charges/lapses only the winner (no mass-lapse). S1 evidence: - Q3: handover margin (default 96s) in the timestamp predicate. - Q10: per-epoch seqNo in SignedBlockData; equivocation keyed on (epoch, seqNo). Also bundles the constructor into a Config struct, which resolves the non-via-IR stack-too-deep codegen failure (genesis-docker CI). Test suite extended to 81 tests, all passing with and without --via-ir.
…on slides Eight new slides that explain, for each of the 19 review questions and the 3 comparative forks, what could go wrong (the adversarial scenario), what was changed, and the economic/game-theory reasoning behind the choice — organized by theme: floor ratchet + free-riding, boundary liveness + the drained winner, finality/backfill state-machine traps, punishing the right party, S1 evidence matching reality, entry economics, and the design forks.
Gas + storage optimization: - Remove dead state _lastProposalAt (written every proposal but never read) and _contractCreationTime (set in init but never read). The winner-proposes hot path now performs exactly one SSTORE (_lastWinnerProposalAt), reusing its packed slot; no new storage is allocated in the steady state. - Reorder the scalar/address cluster so fields pack into fewer slots: the two uint128s (_totalSlashedAmount + _movingAverageBid) now share one slot, and _bidSeq/_lastAvgUpdate/_lastUnassignedFeeEpoch fill the address slots' gaps. Active state shrinks 18 -> 17 slots; __gap grows 29 -> 30 to preserve the total storage footprint. - Regenerated ProposerAuction_Layout.sol via gen-layouts.sh (proxy-deployed contract; layout now correctly tracked by the layout-generation script).
Round 5 — adversarial attacker review of the round-4 fixes (head
|
| # | Sev | Status | Finding | From fix | Anchor |
|---|---|---|---|---|---|
| 1 | 🔴 High | CONFIRMED | Honest winner unrefutably slashed + ejected on outage recovery | Q2 | ProposerAuction.sol:539 |
| 2 | 🔴 High | CONFIRMED | Full-list eviction lapses the active current/next winner | Q14 | :336,:1348 |
| 3 | 🔴 High | PLAUSIBLE | Gap > 32 epochs → authorize & stall-slash on a stale cache | Q4/Q17 | :531,:542 |
| 4 | 🟠 Med | CONFIRMED | Settle bounty undoes the Q9 reward-burn and the Q7 ejection | Q8×Q9×Q7 | :619 |
| 5 | 🟠 Med | CONFIRMED | Reserve floor collapses below initialFloor (revenue backstop gone) |
Q1 | :831 |
| 6 | 🟠 Med | CONFIRMED | One equivocation slashed twice via (a,b)/(b,a) |
Q10 | :726,:1220 |
| 7 | 🟠 Med | CONFIRMED | Handover margin ↔ seqNo: cross-epoch equivocation unslashable | Q3×Q10 | :676 |
| 8 | 🟠 Med | CONFIRMED | Attacker-chosen seqNo makes fabricated-parentHash fraud S1-unslashable |
Q10 | :713 |
| 9 | 🟠 Med | CONFIRMED | Orphaned PendingUpdate clobbers a later re-entry bid |
Q12 | :1271 |
| 10 | 🟠 Med | PLAUSIBLE | 32-epoch hot-path catch-up may OOG the 3M checker cap → brick propose | Q4/Q17 | :531 |
Plus 9 low/informational items summarized at the end.
🔴 High
1. Q2's clock-carry makes outage recovery hostile to the honest winner — unrefutable slash + ejection (ProposerAuction.sol:539, CONFIRMED)
Q2 stopped resetting _lastWinnerProposalAt when the winner is unchanged across a boundary (_snapshot lines 952–958). Line 539 then measures absenceBase = _lastWinnerProposalAt with no epochStart floor — the exact max(epochStart, …) re-anchor Q2 removed.
Attack. Winner W proposes near the end of epoch E (clock = T). A chain-side outage (proving halt / NotEnoughCapacity, L1 alive) blocks all Taiko proposals for ~12 epochs. On recovery a competitor front-runs W's first proposal: absence = now − T ≫ escrowGrace, so _escrowStallSlash fires with gapStart = T. refuteStall (640–648) requires a canonical W proposal with timestamp in (T, escrowedAt] — W has none (the whole chain was down), so the slash is unrefutable. _catchUpSnapshots also charged W one epoch fee per promoted outage epoch. Repeat over ~3 recovery epochs → W's bond crosses the ejection threshold → ejected, competitor inherits fee-free.
Impact. An operator punished for an outage outside their control loses up to one livenessBond per recovery epoch plus N epoch fees, and loses the seat. Pre-Q2 the per-epoch re-anchor gave a fresh grace each epoch to reclaim rung 0 first; Q2 deleted it. test_checkProposer_ladderDoesNotRecloseAtBoundary actually triggers this escrow against Alice but never asserts on it; no test covers multi-epoch recovery.
Fix. On a multi-epoch catch-up (gap > 1) clamp absenceBase = max(epochStart(currentEpoch), _lastWinnerProposalAt), and/or suppress escrow for epochs recorded via backfill rather than a live fallback proposal.
2. Q14 full-list eviction ranks by raw amount and can lapse the active winner, breaking finality (ProposerAuction.sol:336 → _lowestBidder :1348, CONFIRMED)
_lowestBidder (1348–1357) skips only amountInGwei == 0 and ranks by raw _outranks — it ignores the effectiveEpoch/expiresAtEpoch/withdrawEffectiveEpoch filters that _computeAssignment uses to decide who is assignment-active. The eviction comment (333–335) asserts "the evictee is rank 16, never winner/backup" — that is simply not what _lowestBidder computes.
Attack. Full list (16). Incumbent W is the sole assignment-active entry at 100 gwei (so the cache holds _currentWinner = _nextWinner = W); the other 15 are fresh higher bids with effectiveEpoch = current+2 (not yet active, but present in _ranked with high amounts). Attacker calls bid(106,…): _lowestBidder() returns W (raw 100 is the global min), 106 ≥ 100×1.05 passes, _lapseBid(W) deletes W and starts W's bond-withdrawal clock. bid() never runs _catchUpSnapshots, so at the next _snapshot the promote of _nextWinner = W fails (921) and fresh-compute finds only effectiveEpoch = current+2 entries → address(0). The finalized next epoch is retroactively un-assigned and the reigning winner is force-lapsed mid-tenure, no slash, no consent.
Impact. Direct break of the "current and next epoch assignments are always final" invariant (§4.3/§4.12) — the crown-jewel property Q12 was meant to guarantee. Targeted, cheap eviction of a specific competitor; degraded preconf liveness for the un-assigned epoch.
Fix. Rank _lowestBidder with the same effective/expiry/withdraw filters as _computeAssignment (only entries inactive for both current and next epoch are eviction candidates), and/or forbid evicting any address equal to the cached _currentWinner/_currentBackup/_nextWinner/_nextBackup. Also run _catchUpSnapshots at bid() entry so the guard reads a current cache.
3. Gap > 32 epochs: checkProposer authorizes and stall-slashes against a stale assignment cache (ProposerAuction.sol:542, PLAUSIBLE)
_catchUpSnapshots caps the loop at MAX_BACKFILL_EPOCHS = 32 (1039–1040), but checkProposer has no _assignedEpoch == currentEpoch guard after it. currentEpoch/epochStart come from the live epoch (528, 533) while winner = _currentWinner (542) and absenceBase = _lastWinnerProposalAt (539) are whatever the cache holds for epoch ≈ old+32.
Attack. Alice wins epoch 1000. A > 32-epoch total outage passes (nobody advances _assignedEpoch). At epoch 1040 the first propose catches up only to _assignedEpoch = 1032 and then, using Alice's stale cached entry + stale clock, escrows Alice for epoch 1040 with gapStart = t1000 — unrefutable (chain-wide outage), settles for up to rewardBps of her bond, _maybeEject removes her. Simultaneously the true current winner is mis-authorized.
Impact. One-shot loss of ≤ 50% of an innocent operator's bond + wrongful ejection + mis-authorization on the first propose after any > ~3.4 h outage — precisely the case Q4/Q17 were meant to make safe. (Couples with #10.)
Fix. After _catchUpSnapshots, require _assignedEpoch == currentEpoch before trusting _currentWinner/_lastWinnerProposalAt; if still behind, authorize from a fresh _viewAssignment(currentEpoch) and never _escrowStallSlash while _assignedEpoch < currentEpoch.
🟠 Medium
4. The Q8 settle bounty undoes the Q9 reward-burn and the Q7 ejection (ProposerAuction.sol:619, CONFIRMED)
settleStallSlash is permissionless and unrestricted on msg.sender. When challengerIsBackup, Q9 burns the reward (reward = 0, 612–616) — but line 619 then computes bounty = (amount − reward) × settleBountyBps / 10_000 and line 620 credits it to msg.sender with no exclusion of the backup or the winner. So:
- The backup that engineered the stall self-settles and pockets
settleBountyBpsof the full slash (5% tested, up to 50% at the cap) — recovering the reward Q9 just burned. - The slashed winner self-settles to claw back
settleBountyBpsof their own penalty; and because_maybeEject(623) reads the balance after the bounty credit, a winner sitting just under the ejection threshold is lifted back over it and not ejected — partially defeating Q7 too.
test_settleStallSlash_backupChallengerRewardBurnedAndSettlerBountied even shows the settler receiving the 5% while the backup reward is "burned" — nothing stops that settler from being the backup.
Fix. Require the bounty recipient to be neither escrow.winner nor (when challengerIsBackup) escrow.challenger; otherwise route the bounty to _totalSlashedAmount. Compute _maybeEject on the balance excluding any bounty self-credited to the winner.
5. Q1's floor cap can push the reserve floor below initialFloor, defeating the revenue backstop (ProposerAuction.sol:831, CONFIRMED)
getReserveFloor = max(initialFloor, EMA×mult) then min(…, activeTop×1.05) (831). The Q1 self-rebid exemption (317, 320) lets the incumbent lower their own live bid with no floor and no increment.
Attack. Uncontested incumbent Alice at 1000 gwei re-bids 1 gwei (topHolder → floor- and increment-exempt); it materializes in 2 epochs. Now activeTop = 1, so incrementBar = 1 and getReserveFloor() = min(max(10, EMA), 1) = 1 — below initialFloor = 10. Alice pays 1 gwei/epoch and any challenger clears at ~2 gwei. The §4.13 Q1 resolution explicitly promises the floor relaxes "toward initialFloor, never below it"; the cap violates that.
Impact. The initialFloor revenue backstop for an uncontested franchise is nullified — clearing rate collapses to ~1 gwei/epoch, scaling to any mainnet initialFloor.
Fix. Re-assert the hard minimum after the cap: floor = max(initialFloor, min(scaled, incrementBar)). (Displacing a sub-initialFloor incumbent then costs initialFloor rather than incumbent×1.05 — the correct backstop.)
6. One equivocation can be slashed twice — non-canonical evidence hash (ProposerAuction.sol:726 + :1220, CONFIRMED)
evidence = keccak256(abi.encode(hashBlockData(a), hashBlockData(b))) (726) with no canonical ordering, and _slashForFault keys the one-shot guard on keccak256(epoch, faultType, winner, evidence) (1220). Since the two blocks differ in content, keccak(a,b) ≠ keccak(b,a) → two distinct digests, both pass every (order-insensitive) check.
Attack. Call slashEquivocation(E, a, b) then slashEquivocation(E, b, a). Each burns min(livenessBond, balance) and pays rewardBps. Test config: winner 400 → 300 → 200 (2× burned), challenger +50+50. With k blocks sharing one seqNo, k(k−1) ordered pairs drain to ejection while the challenger farms reward.
Impact. Defeats the documented one-shot / per-epoch-cap guarantee (§4.13: "replaying one fault cannot burn more than once"). Griefer over-slashes and double-collects.
Fix. Canonicalize the pair before hashing (sort a,b by hashBlockData), or key the digest on (epoch, _FAULT_EQUIVOCATION, winner, seqNo) so one seqNo-equivocation is slashable exactly once.
7. Q3 ↔ Q10 conflict: cross-epoch equivocation in the handover overlap is unslashable (ProposerAuction.sol:676, CONFIRMED)
Q3 widened slashInvalidBlock's legal window to [epochStart(E) − handoverMargin, epochEnd(E)). Epoch E−1's window ends at epochStart(E) and epoch E's starts at epochStart(E) − 96s, so they overlap on [epochStart(E) − 96s, epochStart(E)).
Attack. Operator W wins E−1 and E with one registered signer. In the overlap W signs two conflicting blocks for the same height: one tagged (E−1, seqNo=k) to victim A, one tagged (E, seqNo=0) to victim B. Both timestamps are legal → slashInvalidBlock says NoViolation for each; slashEquivocation requires the same epoch tag (709) and same seqNo (713) → reverts. Conflicting preconfs to two users, unpunishable.
Fix. For a winner-unchanged handover, require E-tagged blocks in the overlap to continue E−1's monotonic seqNo (single sequence across the boundary), or extend slashEquivocation to catch two signatures by the same signer over the overlap that map to the same height under different (epoch, seqNo) tags.
8. Attacker-chosen seqNo leaves the fabricated-parentHash fraud S1-unslashable (ProposerAuction.sol:713, CONFIRMED)
seqNo is a free uint64 the winner signs, bound to nothing on-chain. slashEquivocation fires only on a seqNo collision (713); slashInvalidBlock checks only the timestamp window. A winner signs the victim's tx into a fabricated-parentHash block with a legal in-epoch timestamp and a seqNo the canonical chain never emits → no collision, legal timestamp → both predicates say NoViolation. The exact phantom-block double-spend Q10 was sold as closing remains S1-unslashable unless the attacker volunteers a seqNo reuse.
Fix. Don't key the fault on an attacker-asserted seqNo; bind equivocation/invalid-block to an on-chain-verifiable position (the Inbox proposal/derivation record) so a single self-fielded signature can't dodge the predicate. (P1 promise-dispute still applies, but that's out of scope in this PR.)
9. Orphaned PendingUpdate clobbers a later re-entry bid, bypassing the 2-epoch lead, floor, and signer (ProposerAuction.sol:1271, CONFIRMED)
_removeBidder (1263–1275) deletes _bids[bidder] but never _pendingUpdates[bidder]; _lapseBid, _maybeEject, and _purgeInactiveInternal all route through it without clearing pending; only quit() clears it (415). bid()'s new-entry branch doesn't clear a residual either.
Attack. W holds a non-imminent pending re-bid, then is ejected (slash) or lapsed/purged. _pendingUpdates[W] survives. W re-deposits and bid(Z, sigZ) as a fresh entry (effectiveEpoch = R+2). The next _materializePending applies the stale orphan (effectiveEpoch in the past) over the fresh bid → W becomes active immediately with the old amount Y, old signer sigY, withdrawEffectiveEpoch = 0, and neither the reserve floor nor the increment is re-checked.
Impact. W is injected into the supposedly-final next-epoch assignment a full epoch early (finality break) at a stale, possibly-below-floor amount with a stale/leaked signer. Bounded to W's own re-entry, no direct theft — but a real break of the finality/floor/signer guarantees.
Fix. delete _pendingUpdates[_bidder] inside _removeBidder (covers lapse/eject/purge) and clear any residual in bid()'s new-entry branch; or ignore a pending whose effectiveEpoch <= placedEpoch in _materializePending.
10. 32-epoch hot-path catch-up may exceed the 3M checker gas cap and brick proposing (ProposerAuction.sol:531, PLAUSIBLE)
checkProposer's first act is _catchUpSnapshots(32) (531), run under the Inbox's {gas: proposerCheckerGasLimit = 3_000_000} try/catch (Inbox.sol 607–616); the catch only lets the proposal through if _isPermissionlessInclusionAllowed() (forced inclusion overdue by delay×160 ≈ 25.6 h). After a ≥ 32-epoch gap with 16 standing bids, the first proposer runs 32 _snapshot calls (each with multiple 16-entry loops + cold epochWinners/epochSigners SSTOREs). If that exceeds 3M it OOG-reverts into a closed escape hatch → propose reverts for everyone, until someone volunteers the unincentivized snapshot() (full block gas, resumable) to grind the backlog down. The 3M budget has near-zero modeled margin against the fix's own worst case and no gas-regression test exercises it.
Fix. Chunk the hot-path catch-up by remaining gas (or lower the hot-path step cap well below 32) so checkProposer cannot OOG; add a gas test that drives it through the Inbox {gas: 3M} isolation with 16 entries and a 32-epoch gap. (Same root cause as #3 — both want a bounded, guard-checked catch-up on the hot path.)
🔵 Low / informational (verified, lower impact)
bid()omits the Q11 catch-up (:331). The §4.13 Q11 resolution claims "bid… force a bounded catch-up snapshot at entry" — the code doesn't. So the Q1 floor decay never runs on the entry path, and after a quiet unassigned gap an honest entrant sees a stale-high floor and revertsBidBelowReserveuntil someone separately pokessnapshot(). Doc/code mismatch; add_catchUpSnapshotsatbid()top (also fixes ImplementverifyMKProof#2's stale-cache angle).- Q19 fee is a new revert path in
checkProposer(:1340). A bonded operator without prepaid ETH ≥ the (decaying) floor can't take the instant unassigned rung —InsufficientEth→ Inbox catch → hatch closed → propose reverts; self-heals atabsence > stallGrace. Charge best-effort instead of reverting. withdrawEthreservation skipped when the cache lags > 32 epochs (:486) — the fixed next winner drains one epoch's fee and bypasses handover notice. Revert (or loop) when one catch-up can't reachcurrentEpoch.- Q19 prices only the within-grace rung (
:556); a bonded operator can wait outstallGraceand propose free — the "hold a half-bond and race" equilibrium survives. Price the post-grace bonded rung too, or document it as the liveness floor. - Never-proposed winner (
:539) — the sentinel_lastWinnerProposalAt == 0can't distinguish "fresh" from "assigned-but-silent", so the ladder still re-closes each epoch for a winner who never proposed (self-limiting via the per-epoch escrow). - Bootstrap sentinel
_assignedEpoch == 0(:1026) collides with real epoch 0 on a fresh-genesis (shadow/testnet) chain; cosmetic (epochs 0–1 predate any effective bid). Seed a distinct never-snapshotted sentinel ininit. - Escape-hatch proposals bypass auction accounting (Inbox
:613) — a hatched proposal leaves_lastProposalAt/epoch records unwritten; mostly self-healing via lazy catch-up, but a hatched winner can accrue a (refutable) spurious escrow. Consider an Inbox-only notify. - Doc inconsistency (
:677): §4.4/§4.11 still list "gasLimit > max" as S1-slashable, which Q10 deliberately removed (delegated to prover V1). Update the tables. - S1 upper bound stays
end(E)(:679) — E's winner's last-8-slot blocks are non-slashable as invalid-block; likely intentional (failed-handover coverage) but untested; add an outgoing-late regression test and document the cross-operator boundary case as out of S1 scope.
Bottom line
The architecture remains sound and the round-4 fixes closed the prior round cleanly — but three of them regressed the exact properties they targeted (Q2 → #1, Q14 → #2, Q1 → #5), and three fix pairs now conflict (Q8×Q9×Q7 → #4, Q3×Q10 → #7, Q4/Q17 hot-path × 3M cap → #3/#10). The two I'd gate an audit on are #2 (finality break via eviction — a pure-code invariant violation) and #1 (honest operator slashed for a chain-wide outage). #4, #5, and #6 are small, high-confidence diffs worth taking immediately. All line numbers are against head 3f24685; findings are marked CONFIRMED (traced in code) vs PLAUSIBLE (real but config/precondition-gated).
Generated by Claude Code
What
The Preconfer Auction — a perpetual standing-bid auction for Taiko's preconfer/proposer rights, the path to permissionless preconfirmations. This PR contains the design research AND the implementation, replacing the separate docs PR #22018 (now closed; superseded by this PR).
Design (included in this PR):
packages/protocol/docs/auction_based_permissionless_preconf.md— the design proposal, incorporating three review roundspackages/protocol/docs/urc_research_report.md— companion deep-dive on eth-fabric/urcImplementation:
contracts/layer1/preconf/iface/IProposerAuction.sol— interface (extendsIProposerChecker)contracts/layer1/preconf/impl/ProposerAuction.sol— implementationcontracts/layer1/preconf/impl/ProposerAuction_Layout.sol— generated storage layout (gen-layouts.shregistered)test/layer1/preconf/auction/ProposerAuction.t.sol+test/layer1/preconf/mocks/MockInbox.sol— 73-test suiteDesign (summary)
effectiveEpoch = placedEpoch + TRANSITION_LEAD_EPOCHS); existing-entry changes (re-bid, renewal, signer rotation) are pending updates with the same lead, and snapshots promote the cached next assignment — current and next epoch assignments are always final. O(1) lazy cache incheckProposer, no keeper, no loops on the hot path, ≥1-epoch handover notice.renew()is a cheap expiry extension; an empty list falls back to permissionless proposing (bonded operators first, then anyone).[gapStart, escrowedAt]; refutation must disprove the recorded gap via a canonical proposal preimage against the Inbox ring-buffer hash (reorg-safe, immune to stall-then-wake evasion); the escrow fires only after a decoupledESCROW_GRACE ≥ STALL_GRACE(accident-safe), settles 50/50 challenger/locked after a 1-epoch refute window, and ejects below-threshold winners.rewardBps ≤ 50%,NoBondToSlashguard); a permissionlesssnapshot()poke with bounded backfill covers zero-proposal epochs.Tests
73 tests, all passing — bidding/renewal/quit, epoch-quantized assignment + lazy charging, pending-transition finality regressions, the full ladder, stall settle/refute (incl. the strategic-staller regression), S1 slashing (incl. signer-rotation and ejection), bonds/ETH/proceeds.
Inbox wiring (included)
Inbox._buildProposalnow calls the proposer checker gas-isolated (bounded gas +try/catch, config fieldproposerCheckerGasLimit): a buggy or reverting checker can no longer halt the rollup (PR docs(protocol): URC production-readiness review for permissionless preconf #22012 E-3).forcedInclusionDelay * permissionlessInclusionMultiplier— finally wiring the previously-unenforced knob (kimi-k3 I-01 / PR docs(protocol): URC production-readiness review for permissionless preconf #22012 E-4). The authorization runs before forced inclusions are consumed, so the hatch can observe the stale inclusion it is gated on.LibForcedInclusion.isPermissionlessInclusionAllowedhelper; newPermissionlessProposingNotAllowederror; mainnet/devnet configs setproposerCheckerGasLimit: 3_000_000.PermissionlessProposingNotAllowedwhen it is not (not overdue / no inclusions).Notes
PreconfCommitmentscontract per the design).forge build --via-ir(ProposerAuction runtime size 20,952 bytes); the local non-IR pipeline hits a pre-existing stack-too-deep in third-party assembly under solc 0.8.30, unrelated to this change.🤖 Designed and implemented with AI assistance; verified against the repository.
Round 4 design review — resolutions (applied)
A fresh 6-lens review (economics / liveness / slashing / integration / state-machine / comparative) raised 19 inline questions + 3 comparative. All verified against
b589528/b1b4efb; every one is a real issue. Full rationale lives in the design doc §4.13. Summary of the resolutions (implemented in the contract + tests):Reserve floor & entry economics
anyonerung stays free as the liveness floor. Combined with the Q1 decay, vacancy is no longer an absorbing state.checkProposervia the bond defers that bond's withdrawal (ProverAuction.checkBondDeferWithdrawalprecedent).Liveness / slashing correctness
checkProposer, and purge is re-anchored to_assignedEpochand runs after backfill (no more wrong winner/charge from the present purged list)._everListed+ purge-via-lapse).renew()now check the bond, so a self-drained winner can't become an unslashable franchise.settleStallSlashpays a settle bounty tomsg.sender(carved from the locked share).withdrawEth/bidforce a bounded catch-up snapshot first (the pre-snapshot drain is closed)._computeAssignmentcharges/lapses only the winner (no mass-lapse of the backup pool).escrowGracecadence budget documented (liveness threshold, constructor-tunable).S1 evidence
handoverSkipSlots) to the timestamp predicate, so an honest incoming operator's handover blocks aren't slashable.seqNotoSignedBlockData; equivocation is now "same(epoch, seqNo), different content" (fabricated-parentHashdouble-spends become slashable); the half-promisedgasLimitpredicate is removed from the doc (prover/V1 scope). (Breaking signature change — pre-deployment.)Comparative (documented, no code): tenure+
renew()kept as dead-bidder cleanup; first-price kept (drift bounded by the increment); winner-take-all (K=1) shipped, top-K rotation documented as an upgrade path.Deployment scripts still not wired (unchanged). Test suite extended for the new behavior.