Skip to content

feat(protocol): Preconfer Auction - permissionless standing-bid proposer auction (design + implementation) - #22019

Open
dantaik wants to merge 20 commits into
mainfrom
feat/proposer-auction
Open

feat(protocol): Preconfer Auction - permissionless standing-bid proposer auction (design + implementation)#22019
dantaik wants to merge 20 commits into
mainfrom
feat/proposer-auction

Conversation

@dantaik

@dantaik dantaik commented Aug 14, 2026

Copy link
Copy Markdown
Member

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 rounds
  • packages/protocol/docs/urc_research_report.md — companion deep-dive on eth-fabric/urc

Implementation:

  • contracts/layer1/preconf/iface/IProposerAuction.sol — interface (extends IProposerChecker)
  • contracts/layer1/preconf/impl/ProposerAuction.sol — implementation
  • contracts/layer1/preconf/impl/ProposerAuction_Layout.sol — generated storage layout (gen-layouts.sh registered)
  • test/layer1/preconf/auction/ProposerAuction.t.sol + test/layer1/preconf/mocks/MockInbox.sol — 73-test suite

Design (summary)

  • Perpetual standing-bid list (≤16, all TAIKO-bonded): top ETH bid is the winner, second is the designated backup. Tenure lasts until outbid, quit, expiry, or slashing/ejection.
  • Every transition takes effect 2 epochs after placement (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 in checkProposer, no keeper, no loops on the hot path, ≥1-epoch handover notice.
  • Bids are a per-epoch ETH rate, charged lazily from a prepaid balance at epoch assignment; renew() is a cheap expiry extension; an empty list falls back to permissionless proposing (bonded operators first, then anyone).
  • Liveness Redesign the rollup protocol with new zkEVM proof assumptions #1: the ladder (winner → backup → any bonded operator → anyone) gates on winner absence, so backups propose at full cadence during an outage and the winner reclaims exclusivity by proposing once. A stall escrows the winner's liveness bond and records the fault gap [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 decoupled ESCROW_GRACE ≥ STALL_GRACE (accident-safe), settles 50/50 challenger/locked after a 1-epoch refute window, and ejects below-threshold winners.
  • S1 slashing: EIP-712 operator-signed blocks enable invalid-block and equivocation disputes (one-shot digests, rewardBps ≤ 50%, NoBondToSlash guard); a permissionless snapshot() 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.

forge test --match-path test/layer1/preconf/auction/*.t.sol --via-ir
Suite result: ok. 73 passed; 0 failed

Inbox wiring (included)

  • Inbox._buildProposal now calls the proposer checker gas-isolated (bounded gas + try/catch, config field proposerCheckerGasLimit): a buggy or reverting checker can no longer halt the rollup (PR docs(protocol): URC production-readiness review for permissionless preconf #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 — 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.isPermissionlessInclusionAllowed helper; new PermissionlessProposingNotAllowed error; mainnet/devnet configs set proposerCheckerGasLimit: 3_000_000.
  • Tests: an unauthorized caller proposes successfully when the hatch is open (consuming the overdue inclusion) and reverts with PermissionlessProposingNotAllowed when it is not (not overdue / no inclusions).

Notes

  • No deployment-script wiring yet — the auction contract itself is not yet registered in deploy scripts.
  • Promise slashing (P1/P2) is intentionally out of scope (separate PreconfCommitments contract per the design).
  • Compiled with 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

  • Q1 — the reserve floor was a one-way ratchet (EMA only rose, ~2× incumbent entry bar, floor blocked self re-bids). Now: EMA decays on unassigned epochs, the floor is bounded by the 5% increment bar when an incumbent exists, and same-or-lower self re-bids are floor-exempt (cheap signer rotation).
  • Q19 — unassigned-mode free-riding is now priced: the first bonded operator to use the instant unassigned rung pays the (decaying) floor into proceeds once per epoch; the anyone rung stays free as the liveness floor. Combined with the Q1 decay, vacancy is no longer an absorbing state.
  • Q14 — a full list now has an evict-the-lowest path (new bid exceeding rank 16 by 5% lapses it), so renewing operators can't close the auction at any price.
  • Q15 — the "bonded operator" rung is no longer a flash-bond: passing checkProposer via the bond defers that bond's withdrawal (ProverAuction.checkBondDeferWithdrawal precedent).
  • Q16 — rung 3 stays free as the liveness floor (documented rationale; DoS bounded by 1-proposal/block + 3-day ring buffer + proving drain).
  • Q18 — contract-wallet bidder pattern documented for v1; native cold/hot split deferred.

Liveness / slashing correctness

  • Q2 — the ladder no longer re-closes at epoch boundaries: the winner-absence clock carries across a boundary when the winner is unchanged.
  • Q4 / Q5 / Q17 — catch-up is now resumable (never skips epochs), runs bounded in checkProposer, and purge is re-anchored to _assignedEpoch and runs after backfill (no more wrong winner/charge from the present purged list).
  • Q6 — expiry no longer returns a full bond with zero delay: every exit routes through the withdrawal clock (_everListed + purge-via-lapse).
  • Q7 — promote and renew() now check the bond, so a self-drained winner can't become an unslashable franchise.
  • Q8settleStallSlash pays a settle bounty to msg.sender (carved from the locked share).
  • Q9 — the escrow records whether the challenger was the designated backup; if so the reward is burned (the backup can't profit from causing stalls it can't be blamed for).
  • Q11withdrawEth/bid force a bounded catch-up snapshot first (the pre-snapshot drain is closed).
  • Q12 — an inherited backup's absence clock starts at assignment (no same-tx escrow), and _computeAssignment charges/lapses only the winner (no mass-lapse of the backup pool).
  • Q13escrowGrace cadence budget documented (liveness threshold, constructor-tunable).

S1 evidence

  • Q3 — added a handover margin (default 96 s = the client's 8 handoverSkipSlots) to the timestamp predicate, so an honest incoming operator's handover blocks aren't slashable.
  • Q10 — added a per-epoch seqNo to SignedBlockData; equivocation is now "same (epoch, seqNo), different content" (fabricated-parentHash double-spends become slashable); the half-promised gasLimit predicate 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.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +315 to +317
// Safe: _tenureMaxEpochs <= type(uint32).max - TRANSITION_LEAD_EPOCHS.
info.effectiveEpoch = currentEpoch + TRANSITION_LEAD_EPOCHS;
info.expiresAtEpoch = info.effectiveEpoch + _tenureMaxEpochs;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

🐋 DeepSeek Code Review

🔴 Critical Issues

1. slashEquivocation one-shot digest is order-sensitive — the same equivocation can be slashed twice
In slashEquivocation, the evidence digest is built as:

bytes32 evidence = keccak256(abi.encode(_hashBlockData(_a), _hashBlockData(_b)));

Then _slashForFault marks:

_slashedBefore[keccak256(abi.encode(_epoch, _FAULT_EQUIVOCATION, _winner, _evidence))] = true;

Because the order of _a and _b is part of the hash, swapping the same two signed blocks produces a completely different digest. A challenger can call slashEquivocation(epoch, a, b) and then slashEquivocation(epoch, b, a) and slash the winner twice for the same equivocation fault. With rewardBps ≤ 50%, this can drain a winner’s bond and eject them unfairly.

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));

🟡 Warnings

1. _deferBondWithdrawal does not re-arm for accounts with an already-expired withdrawal clock

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), withdrawableAt is in the past. They can then:

  1. depositBond(...)
  2. call Inbox.propose(...) and pass rung 2 / unassigned bonded rung via checkProposer
  3. withdrawBond(...) in the same transaction

because withdrawableAt is already past. This recreates the exact same-block flash-bond that the design says it fixes. The deferral should set a new future timestamp on every bonded-rung use, not only when the current value is zero.

2. bid never calls _catchUpSnapshots despite the stated resolution
The PR summary says withdrawEth/bid force a bounded catch-up snapshot first. withdrawEth does:

_catchUpSnapshots(MAX_BACKFILL_EPOCHS);

but bid does not. getReserveFloor() and _purgeInactiveInternal() therefore operate on possibly stale state:

  • The reserve floor may not have decayed during unassigned epochs, so the auction can remain stuck above entrants’ willingness to pay.
  • _purgeInactiveInternal is anchored to _assignedEpoch, so a stale cache can leave already-expired entries in the list and trigger a full-list eviction path incorrectly.

A bidder can manually call snapshot() first, but bid should do what the design says.

3. checkProposer can backfill up to 32 epochs inside a 3,000,000-gas-isolated call

_catchUpSnapshots(MAX_BACKFILL_EPOCHS);

with MAX_BACKFILL_EPOCHS = 32. In the worst case this loop can exceed proposerCheckerGasLimit: 3_000_000. If it does, authorized proposals revert and the Inbox catch path is triggered. Liveness then depends on someone calling snapshot() separately to reduce the backlog, or on the permissionless hatch being open. This is not necessarily exploitable, but it is a liveness risk for long no-proposal gaps. Consider a smaller checker backfill cap, or require checkProposer to backfill at most 1–2 epochs while keeping the larger cap only for external snapshot().

4. slashInvalidBlock evidence omits seqNo
The evidence for invalid blocks is:

keccak256(abi.encode(
    _block.epoch,
    _block.blockNumber,
    _block.parentHash,
    _block.timestamp,
    _block.coinbase,
    _block.gasLimit,
    _block.txRoot
));

seqNo is included in the signed struct and the EIP-712 hash but not in this one-shot digest. Two genuinely different invalid blocks that differ only in seqNo and have identical other fields will map to the same digest, so only the first one can be slashed. This is under-slashing, not over-slashing, but it is inconsistent with the per-fault/digest design.

5. bid allows _amountInGwei == 0 for an existing self bid and silently acts as a renewal without updating the signer
There is no require(_amountInGwei > 0, ...). For an existing entry with topHolder == msg.sender, zero passes the floor exemption and becomes a PendingUpdate with amountInGwei = 0. _materializePending then treats it as an expiry-only renewal and ignores the supplied _signer. A signer rotation with zero amount therefore “succeeds” but does not rotate the signer. An explicit amount > 0 check would prevent this confusing state.


🔵 Suggestions

  • Add a regression test for slashEquivocation with (a, b) then (b, a) to ensure it reverts AlreadySlashed.
  • Add a regression test for the seasoned-account depositBond -> checkProposer -> withdrawBond same-tx path to enforce the new deferral behavior.
  • Consider making _deferBondWithdrawal always set:
    _bonds[_account].withdrawableAt = uint48(block.timestamp) + _bondWithdrawalDelay;
    instead of only when zero.
  • Consider renaming or documenting MAX_BACKFILL_EPOCHS separately for checkProposer and external snapshot() to avoid the gas-limit mismatch.

🟢 What Looks Good

  • The Inbox checker call is correctly gas-isolated with try/catch, and authorization is moved before forced-inclusion consumption so the escape hatch can observe the stale inclusion it is gated on.
  • Broad nonReentrant coverage on all state-changing external functions except the simple depositEth.
  • The two-epoch pending-transition model is implemented consistently, and the tests cover the finality regressions well.
  • The stall refutation design uses the Inbox ring-buffer hash directly, which is reorg-safe and prevents simple strategic stalling.
  • The generated storage layout is registered and imported, which should help catch storage-collision mistakes in future upgrades.

Automatically triggered on PR update • model: deepseek-v4-pro

dantaik commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Design + implementation review of ProposerAuction @ 65bf1d4, focused on (a) how this enables permissionless proposing, (b) the liveness guarantee, (c) slashing. All findings below were verified by reading the contract and test suite line-by-line; positives from the #22018 review round were re-checked first: the gap-disproof refutation is correctly implemented (canonical Proposal preimage against IInbox.getProposalHash, timestamp ∈ (gapStart, escrowedAt], locked in by test_refuteStall_RevertWhen_strategicStallerWakeUpProposal), the stall clock is tx-atomic, escrow is one-per-epoch, S1 uses proper EIP-712 domains with chainid + proxy address, and the lazy per-epoch cache needs no keeper. The skeleton matches the agreed design.

A. The permissionless-proposal story

The contract delivers permissionlessness in three layers, and the layering is right:

  1. Permissionless entry to the privileged role — anyone with requiredBond TAIKO and a bid ≥ the reserve floor joins the list; no owner gate on bid().
  2. Permissionless-with-stake fallback — any account holding ejectionThreshold bond (deposit only, no bid, no listing) may propose at rung 2 and immediately in unassigned epochs.
  3. Fully permissionless floor — anyone at rung 3 (~stallGrace+backupGrace+fallbackGrace of silence) and in unassigned epochs after stallGrace; with an empty list the system degrades to based proposing rather than halting.

Two contingencies keep this from being a guarantee yet, and both should be treated as blockers for any deployment claim:

  • The Inbox side is not in this PR, and checkProposer is still the single unconditional gate (verified on main: Inbox._buildProposal calls it with no try/catch, and _permissionlessInclusionMultiplier is stored but never read). This checker is ~10× the whitelist's logic surface; any revert-path bug in it halts proposing until an upgrade. The design doc made the bounded-gas try/catch + forced-inclusion escape hatch a hard prerequisite — it needs to land with the wiring, not after.
  • Finding L1 below throttles the permissionless floor to 1 proposal per stallGrace — so in exactly the scenarios where the floor matters, the chain runs at ~25% cadence.

B. Liveness findings

L1 (High) — Fallback throughput is throttled to one proposal per stallGrace. The rung gate measures elapsed from _lastProposalAt, which every accepted proposal (any rung) resets. After the backup's first proposal, elapsed restarts at 0, so rungs 1–3 close again for another 48 s — during a winner outage the backup can propose once per stallGrace, not once per block. L2 throughput drops ~4×, and forced-inclusion draining (≤10/proposal) throttles with it. Same applies to unbonded proposers in unassigned mode. Telling detail: _lastWinnerProposalAt is written on every rung-0 proposal and never read — the winner-absence clock the ladder should be using already exists as dead state. Fix: gate rungs 1–3 on max(epochStart, _lastWinnerProposalAt) so the ladder stays open while the winner is absent (backup proposes every block; winner reclaims exclusivity by proposing once), and keep _lastProposalAt only where a chain-level clock is wanted (unassigned mode, if at all).

L2 (High) — The "current and next assignments are always final" invariant has four holes. The cached next assignment is advisory: _snapshot recomputes from live state, so any live-state mutation silently overrides finality.

  • (a) Re-bid resets effectiveEpoch. bid() unconditionally sets effectiveEpoch = current + 2 for existing bidders, so an active winner who updates their bid — e.g. counter-bidding to defend against a displacer — is filtered out of the current and next epoch's candidate sets (effectiveEpoch > _epoch → skip) and forfeits epochs they had already finally won. test_bid_updatesOwnBidAndSigner checks the stored fields but not the assignment consequence. This makes bid defense self-destructive.
  • (b) renew() resurrects with no lead. An entry expiring at E+1 is excluded from the cached next assignment at E's snapshot; renewing during E puts it back, displacing the bidder who was told they'd won E+1. Renewal must not extend expiry into epochs earlier than current + TRANSITION_LEAD_EPOCHS.
  • (c) ETH drain is a fast exit that bypasses quit notice. withdrawEth() is unrestricted, so a winner can drain prepaid ETH during E and lapse at E+1's snapshot — out one epoch earlier than quit() (which enforces serving E and E+1), with the backup inheriting at zero notice. Fix: while a bid is active, withdrawEth must leave ≥ TRANSITION_LEAD_EPOCHS × amountInGwei prepaid, making the notice horizon hard.
  • (d) Mid-epoch ejection isn't reflected in the cache. An S1-slashed, ejected winner keeps _currentWinner status (rung-0 exclusivity) for the rest of the epoch with a bond near zero, and stays the advertised _nextWinner until the boundary recompute. The design doc said "slash/eject → immediate rung-1 takeover"; either implement that (ejection promotes _currentBackup in the cache) or document the epoch-granular tradeoff.

The structural fix for (a)/(b) — and for S2 below — is pending fields: (pendingAmount, pendingSigner, pendingFrom) applied at pendingFrom = current + 2, with old values authoritative for already-assigned epochs; and have _snapshot promote the cached next assignment to current rather than recomputing both, so finality is structural instead of coincidental.

L3 (Medium) — Epochs with zero proposals are never recorded, and the hole is permanent. _snapshot runs only inside checkProposer; an epoch in which nobody proposes (precisely the malicious-stall case) never sets _epochWinners/_epochSigners, and the winner's fee for it is never charged. Consequences: S1 faults for that epoch revert forever with NoWinnerForEpoch (see S1 below), and the franchise was free. Compounding: bid()/purgeInactive() delete BidInfo for lapsed entries, so the assignment can't even be reconstructed later. Fix: cache the next signer alongside the next winner and backfill _assignedEpoch + 1 on snapshot (covers single-epoch gaps for free with the promote-don't-recompute change); add a permissionless snapshot() so a challenger can force recording during the epoch they intend to dispute.

L4 (Low) — Dead window when there is no backup: with _currentBackup == address(0), nobody can propose during (stallGrace, stallGrace + backupGrace] — skip the backup rung when unset.

L5 (Low, wiring) — endOfSubmissionWindowTimestamp = epochEnd is returned for every rung, including rung-3 "anyone" proposals the contract will re-gate 48 s later. Decide the fallback-window semantics (e.g. 0 or now + stallGrace for rungs 1–3) before Inbox/derivation wiring; the whitelist precedent returns 0.

C. Slashing findings

S1 (High) — Signer rotation + the L3 hole let an equivocator escape. _signers[bidder] updates instantly on bid(), and _epochSigners[E] is only fixed at E's first proposal. A winner can sign equivocating blocks early in E, then re-bid with a fresh signer before any proposal lands: either the new signer gets recorded for E (evidence no longer matches), or — because the re-bid also vacates their E assignment via L2(a), or nobody proposes at all (L3) — _epochWinners[E] never points at them. Every path ends with the equivocation unslashable. Fix: signer changes take effect with the same 2-epoch lead (pending fields), plus the L3 backfill/poke so assignment recording can't be dodged by not proposing.

S2 (Medium) — Self-challenge dampens the stall slash, and rewardBps is unbounded. The constructor allows rewardBps == 10_000; at that setting a staller's sybil (bonded at rung 2, or anyone at rung 3) who lands the first fallback proposal becomes the challenger and recovers the entire slash — stalling becomes free. Even at 50% it halves the sting, and since only the first fallback proposal escrows, a sybil front-running the honest backup also captures the reward. Bound rewardBps (≤ 5000) in the constructor and accept the residual — with the burn half intact, self-challenge still costs livenessBond/2 per stalled epoch.

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 stallGrace — a ≥4-slot inclusion delay under fee spikes, or a ≥4-deep reorg — is slashed with no defense; the refute window only covers post-escrow reorg oddities. The grace is now the entire accident buffer, which argues for splitting it: open the ladder at stallGrace (fast failover, 48 s) but escrow the slash only when the gap exceeds a larger slashGrace (e.g. 2–3× stallGrace). One extra comparison in checkProposer; liveness stays fast while punishment reserves for unambiguous stalls.

S4 (Medium) — The S1 evidence window is implicitly the bond-withdrawal delay. slashInvalidBlock/slashEquivocation have no deadline, but slash min(livenessBond, balance) — after quit + delay + full withdrawal, late evidence slashes zero. That's a reasonable design (URC used explicit windows) only if stated and sized: document that S1 evidence must be submitted within bondWithdrawalDelay of the fault epoch, and size the delay accordingly (1 week is fine; just make it normative).

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 livenessBond (harsher; arguably better — but state it); slashed funds accumulate in _totalSlashedAmount with no burn/treasury path (doc says 50/50 challenger/burn); settleStallSlash writes a _slashedBefore digest nothing reads; _lastWinnerProposalAt (see L1) and _contractCreationTime are dead state.

S6 (Low) — refuteStall is winner-only. The evidence is objective; make refutation permissionless so a winner operating from a cold key or contract can be defended by anyone.

S7 (Low) — No proof-of-possession for _signer at bid time. The per-epoch tag in the signed payload prevents cross-slashing today, but PoP (a signature over the bidder address) is cheap and removes the registration-fraud class outright (URC lesson).

D. Auction integrity (affects who you're trusting for liveness)

D1 (Medium) — The 5% increment protects the wrong epoch. bid() checks against _activeTop(currentEpoch), which excludes pending bids (effectiveEpoch > current). So after Alice places a displacing bid (effective E+2), Bob can top her by 1 gwei — the anti-sniping increment never applies to the actual contest. Conversely a quitting incumbent still counts as the current top, forcing newcomers to over-increment against a departing bid. Compare against the top as of the new bid's effectiveEpoch, pending entries included.

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 × requiredBond in sybil bonds crowds honest backups off the list (rung 2 remaining open to unlisted bonded operators caps the damage). Requiring prepaid ETH ≥ k epochs at bid time (which L2(c) wants anyway) makes squatting strictly costlier and reduces lapse churn.

Verdict

The 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 snapshot() (L3/S1); (4) minimum prepaid ETH for active bidders (L2c/D3). S2's rewardBps bound and S3's threshold split are small and worth taking at the same time. The wiring PR then needs the Inbox try/catch + escape hatch and the L5 window-semantics decision before this can be called a permissionless-proposal guarantee end to end.


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.
@dantaik dantaik changed the title feat(protocol): add ProposerAuction - perpetual standing-bid preconfer auction feat(protocol): Preconfer Auction - permissionless standing-bid proposer auction (design + implementation) Aug 14, 2026
- _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 dantaik left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. No downward path. _updateMovingAverage runs 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() reverts BidBelowReserve for 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 template ProverAuction adapts price in the entry-attracting direction on vacancy (fee doubling); this contract has no downward analogue anywhere.
  2. 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%)".
  3. 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. Meanwhile renew() 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. checkProposer runs a single-epoch _snapshot(currentEpoch), 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

Generated by Claude Code

require(msg.sender == inbox, NotInbox());

uint32 currentEpoch = _currentEpochIndex();
if (_assignedEpoch != currentEpoch) _snapshot(currentEpoch);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

dantaik and others added 9 commits August 14, 2026 20:45
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).

dantaik commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Round 5 — adversarial attacker review of the round-4 fixes (head 3f24685)

The round-4 commit resolved all 19+3 prior questions. This round attacks the new code as an adversary — because fixes create fresh surface and can fight each other. Six lenses (economics / liveness / slashing / integration / state-machine / comparative), each candidate independently re-verified against the source; I list severity and whether the mechanism is CONFIRMED (fully traced in code) or PLAUSIBLE (real, gated on a config value or an off-path precondition). The headline: several round-4 fixes introduced regressions, and three pairs of fixes now work against each other.

# 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 settleBountyBps of 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 settleBountyBps of 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) = 1below 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 reverts BidBelowReserve until someone separately pokes snapshot(). Doc/code mismatch; add _catchUpSnapshots at bid() top (also fixes Implement verifyMKProof #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 at absence > stallGrace. Charge best-effort instead of reverting.
  • withdrawEth reservation 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 reach currentEpoch.
  • Q19 prices only the within-grace rung (:556); a bonded operator can wait out stallGrace and 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 == 0 can'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 in init.
  • 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant