Skip to content
BaseHub by wbnns Updated

Proposer

The proposer is the offchain service that promotes a canonical L2 checkpoint range into an AggregateVerifier game on L1. Each cycle it reads the latest onchain parent state, picks the next checkpoint, asks a TEE prover for a proof over that range, revalidates the proof against canonical L2 state, and opens a new dispute game through DisputeGameFactory.

The production proposer is gated by the L1 signer it is configured with. Its output remains self-validating end to end: a game is uniquely keyed by game type, claimed output root, parent, L2 block number, and the ordered intermediate output roots, and the proof itself can be re-checked by the onchain verifier and by independent challengers.

A conforming proposer is expected to:

  1. Load the active AggregateVerifier implementation and proposal parameters from L1.
  2. Recover the latest onchain parent state from AnchorStateRegistry and DisputeGameFactory.
  3. Pick the next checkpoint block that is at or before the configured safe head.
  4. Build a prover_prove request covering that checkpoint range.
  5. Accept only TEE proof results for proposal creation.
  6. Re-check the aggregate output root and every intermediate root against canonical L2 state right before L1 submission.
  7. Optionally pre-validate the TEE signer against TEEProverRegistry.
  8. Submit DisputeGameFactory.createWithInitData() with the required bond.
  9. Retry transient proof, RPC, and transaction failures without ever creating games out of order.

The proposer does not challenge games, resolve them, claim bonds, or decide withdrawal finality — those duties live with the challenger and the proof contracts.

At startup the proposer connects to:

  • an L1 execution RPC for contract reads and transaction submission
  • an L2 execution RPC for agreed L2 block headers
  • a rollup RPC for sync status and output roots
  • a prover RPC that implements prover_prove
  • AnchorStateRegistry
  • DisputeGameFactory
  • an optional TEEProverRegistry

The game implementation address comes from:

DisputeGameFactory.gameImpls(gameType)

That address must be non-zero. From there the proposer reads:

AggregateVerifier.BLOCK_INTERVAL()
AggregateVerifier.INTERMEDIATE_BLOCK_INTERVAL()
DisputeGameFactory.initBonds(gameType)

BLOCK_INTERVAL must be at least 2, INTERMEDIATE_BLOCK_INTERVAL must be non-zero, and BLOCK_INTERVAL % INTERMEDIATE_BLOCK_INTERVAL must be 0. The number of intermediate roots a proposal carries is:

BLOCK_INTERVAL / INTERMEDIATE_BLOCK_INTERVAL

By default the proposer targets finalized L2 state. Operators may opt into safe-but-not-finalized proposals, in which case the rollup node’s safe L2 head is used instead.

Before planning new work, the proposer walks forward from L1 to find the current tip of the proposal chain. Parent state is captured as:

parentAddress
parentOutputRoot
parentL2BlockNumber

If no games have been created yet, the parent is the anchor root from AnchorStateRegistry:

parentAddress = AnchorStateRegistry address
parentOutputRoot = AnchorStateRegistry.getAnchorRoot().root
parentL2BlockNumber = AnchorStateRegistry.getAnchorRoot().l2BlockNumber

Otherwise the proposer performs a deterministic forward walk from the anchor root (or from a cached recovered tip when the cache is still fresh). At each step it:

  1. Computes:

    expectedBlock = parentL2BlockNumber + BLOCK_INTERVAL
  2. Fetches the canonical output root for every intermediate checkpoint at:

    parentL2BlockNumber + INTERMEDIATE_BLOCK_INTERVAL * i

    for i in 1..=BLOCK_INTERVAL / INTERMEDIATE_BLOCK_INTERVAL.

  3. Takes the final intermediate root as the canonical root claim for expectedBlock.

  4. Builds extraData from expectedBlock, parentAddress, and the ordered intermediate roots.

  5. Looks the expected game up:

    DisputeGameFactory.games(gameType, rootClaim, extraData)
  6. If the lookup returns address(0), walk stops and the current parent is the recovered tip.

  7. Otherwise it advances the parent to the returned game proxy and repeats.

This recovery does not scan the factory for a “best” game. It uses each game’s unique factory key, so only the canonical next game for the recovered parent can advance the parent chain. Any game that disagrees on root, parent, L2 block number, or intermediate roots has a different key and is invisible to recovery.

After recovery, the next proposal target is:

targetBlock = parentL2BlockNumber + BLOCK_INTERVAL

The proposer must not request or submit a proof for targetBlock unless:

targetBlock <= safeHead

where safeHead is either:

  • finalized_l2.number, by default, or
  • safe_l2.number, only when non-finalized proposals are explicitly enabled.

When parallel proving is on, multiple proof requests can be in flight for future checkpoints, but L1 submissions are still strictly sequential — at most one proposal transaction is pending at a time, and the next one waits until earlier checkpoint games are recovered or confirmed.

For a checkpoint range the proposer builds a ProofRequest with:

FieldValue
l1_headHash of the latest L1 block at request construction time
l1_head_numberNumber of the latest L1 block at request construction time
agreed_l2_head_hashL2 block hash at parentL2BlockNumber
agreed_l2_output_rootParent output root recovered from L1
claimed_l2_output_rootRollup RPC output root at targetBlock
claimed_l2_block_numbertargetBlock
proposerL1 address that will submit the proposal transaction
intermediate_block_intervalINTERMEDIATE_BLOCK_INTERVAL
image_hashExpected TEE image hash

The prover RPC method is:

prover_prove(ProofRequest) -> ProofResult

Only ProofResult::Tee is accepted by the proposer. ZK proof results are not valid input on this path.

The TEE prover returns:

  • one aggregate proposal that covers the entire checkpoint range
  • per-block proposals for each block inside that range

The aggregate proposal carries:

outputRoot
signature
l1OriginHash
l1OriginNumber
l2BlockNumber
prevOutputRoot
configHash

The TEE signature is taken over:

keccak256(journal)

where journal is packed as:

proposer(20)
|| l1OriginHash(32)
|| prevOutputRoot(32)
|| startingL2Block(8)
|| outputRoot(32)
|| endingL2Block(8)
|| intermediateRoots(32 * N)
|| configHash(32)
|| teeImageHash(32)

For aggregate proposals:

startingL2Block = parentL2BlockNumber
endingL2Block = targetBlock
prevOutputRoot = parentOutputRoot
outputRoot = claimed root at targetBlock

intermediateRoots are sampled every INTERMEDIATE_BLOCK_INTERVAL blocks and include the final target block root.

Right before sending to L1, the proposer re-validates the proof against canonical L2 state:

  1. Fetch the rollup output root at targetBlock.
  2. Require it to equal the aggregate proposal’s outputRoot.
  3. Extract the intermediate roots from the per-block proposals.
  4. Fetch the canonical output root for every intermediate checkpoint.
  5. Require every proposed intermediate root to equal its canonical counterpart.

If the aggregate root or any intermediate root no longer matches canonical state, the proposer drops the pending work and restarts recovery. This is the guard against stale proof results after L1 or L2 reorgs.

When a TEEProverRegistry is configured, the proposer should recover the TEE signer from the aggregate proposal signature and call:

TEEProverRegistry.isValidSigner(signer)

A false return means the proof must not be submitted; discard it and request a new one. If the registry call itself fails for RPC or deployment reasons, the proposer may continue to submission and let the onchain verifier enforce signer validity.

The proposer creates the game with:

DisputeGameFactory.createWithInitData{value: initBond}(
gameType,
rootClaim,
extraData,
initData
)

where:

rootClaim = aggregateProposal.outputRoot

extraData is packed, not ABI-encoded:

l2BlockNumber(32) || parentAddress(20) || intermediateRoots(32 * N)

l2BlockNumber is a 32-byte big-endian integer. parentAddress is the recovered parent game proxy, or the AnchorStateRegistry address for the first game after the anchor.

initData is the TEE proof bytes for AggregateVerifier.initializeWithInitData():

proofType(1) || l1OriginHash(32) || l1OriginNumber(32) || signature(65)

For TEE proofs:

proofType = 0

The ECDSA v value must be normalized to 27 or 28 before submission. initBond is read from DisputeGameFactory.initBonds(gameType) at startup and is sent as the transaction value. The L1 transaction manager owns nonce management, fee bumping, signing, and resubmission.

A game’s factory key is:

gameType || rootClaim || extraData

If createWithInitData() reverts with GameAlreadyExists, the proposer treats the target as already submitted, refreshes recovery from L1, and continues from the new tip. This covers both the case where a prior transaction succeeded but the receipt was missed, and the case where another valid proposer beat us to the same game.

Transient failures are retried on later ticks:

FailureRequired behavior
Recovery RPC or contract read failureSkip the current tick and retry recovery on the next tick
Proof request failureRetry the target on a later tick
Repeated proof failureReset pipeline state and recover from L1
L1 submission failureKeep the proved result and retry submission on a later tick
L1 submission timeoutTreat as a submission failure and retry after recovery
GameAlreadyExistsTreat as success, refresh recovery, and continue
Canonical root mismatchReset pipeline state and re-prove from recovered L1 state
Invalid TEE signerDiscard the proof and request a new one

The current implementation retries a single proof target up to three times before resetting pipeline state, and bounds proposal submission with a ten-minute timeout.

The proposer may expose an optional JSON-RPC admin interface. When enabled, it provides:

MethodResult
admin_startProposerStarts the proving pipeline
admin_stopProposerStops the proving pipeline
admin_proposerRunningReturns whether the pipeline is running

Starting a proposer that is already running, or stopping one that is already stopped, are both errors.

In dry run mode the proposer still performs recovery, checkpoint selection, proof sourcing, and pre-submission validation, but it does not send L1 transactions — it just logs the game that would have been created. Useful for validating prover and RPC behavior, but it does not advance the onchain proposal chain.