@aztec/sequencer-client
Sequencer Client
The sequencer client is the proposer-side counterpart to the validator client: it builds checkpoints, broadcasts the block and checkpoint proposals that validators attest to, and publishes the resulting checkpoint to L1. It runs on any node whose configured validator address has been selected as proposer for the target slot.
A single instance owns the proposer flow for one slot: deciding whether to propose, building several L2 blocks one after another, signing them, gossiping them, collecting attestations from the committee, and submitting the final checkpoint to L1 in one Multicall3 transaction together with governance and slashing votes.
The sequencer does not decide what is in the next block on its own. It composes the work of several subsystems: the tx pool supplies transactions, the validator client owns operator keys and contains the CheckpointBuilder that actually executes them, the archiver provides the L2 chain state needed to anchor each block, the epoch cache answers proposer/committee lookups, and the slasher supplies offenses to vote on.
Key Concepts
Slots, Blocks, and Checkpoints
The Aztec consensus design splits each Aztec slot into multiple L2 blocks. This is the design originally called building in chunks.
- Slot — a fixed time window (e.g. 72 s) during which one proposer is allowed to build.
- Block — a single batch of transactions, executed and validated as a unit, with its own header.
- Checkpoint — the collection of all blocks built for one target slot. The proposer commits to a checkpoint on L1 by submitting a
proposetransaction containing the aggregated header, the blocks' tx effects (as blobs), and the committee attestations. - Sub-slot — a fixed-duration window during which one block is built. Sub-slots are equal in length and have deadlines fixed relative to the build frame.
Several blocks per slot is intentional: it amortizes the fixed L1 cost of a checkpoint over more transactions, and it lets the rest of the network reach a usable state-root much sooner than the full L1 confirmation latency.
Proposed vs Checkpointed Chain
There are two tips the sequencer cares about:
- The proposed chain is the set of blocks that have been broadcast over p2p but not yet committed to L1. Both the sequencer and validators push these blocks into the archiver so the rest of the node can serve them.
- The checkpointed chain is the set of checkpoints that have landed on L1, recovered from
CheckpointProposedevents.
Within a slot, the proposer adds blocks to the proposed chain as it goes. Only the last block is bundled with a CheckpointProposal that committee members attest to; intermediate blocks are accepted onto the proposed chain by virtue of the proposer's signature alone, and every node that wants to follow the proposed chain re-executes them. See the validator client README for the consumer side.
Proposer Pipelining
Pipelining (proposed in this discussion) is the only mode the production sequencer runs in. The proposer for slot N builds blocks and broadcasts the checkpoint proposal during slot N - 1; the committee re-executes the final block, sends attestations back, and the proposer targets L1 submission for the start of slot N. This removes the long idle window a naive flow would have inside slot N, where the proposer would otherwise spend most of the slot collecting attestations and waiting for the L1 transaction to mine.
Each phase lands in this slot:
| Phase | Slot |
|---|---|
| Block building | slot N - 1 |
| Checkpoint proposal | slot N - 1 |
| Committee validation | slot N - 1 |
| Attestation collection | slot N - 1 * |
| L1 submission | slot N |
* The ideal timing path reserves enough budget for attestations to be in hand before the ideal L1 send time. The hard attestation_deadline is also the latest useful L1 publish time; validators must finish validation and sign by then for inactivity/slashing purposes.
EpochCache always looks up the pipelined target proposer, so the sequencer asks the cache for the proposer of slot + 1 (PROPOSER_PIPELINING_SLOT_OFFSET = 1) rather than slot. Building runs during the wall-clock slot; the checkpoint commits to the target slot.
This flow introduces two failure modes that block building has to handle:
- Pipeline depth is bounded to 2 (
checkpointNumber ≤ confirmedCheckpoint + 2). Building further ahead would require trusting more in-flight parent proposals than the design allows. - Pipelined parent invalidation: if the parent checkpoint we built on top of fails to land cleanly on L1, the next proposer's work is discarded (
pipelined-checkpoint-discardedevent) and aninvalidaterequest is enqueued for the parent.
Architecture
┌──────────────────────────────────────────────────────────────┐
│ Sequencer │
│ (state machine, one slot at a time) │
│ │
│ work() ──► prepareCheckpointProposal() ──► proposal job │
└─────┬────────────────┬──────────────────┬──────────────────┬─┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────────┐ ┌──────────┐ ┌──────────────────┐ ┌────────────────┐
│ ValidatorClient │ │ Epoch │ │ CheckpointBuilder│ │ Sequencer │
│ (owns keys, │ │ Cache │ │ (forked world │ │ Publisher │
│ HA signer, │ │ (proposer│ │ state, per-block│ │ (Multicall3 │
│ signs the │ │ + │ │ execution via │ │ L1 tx, with │
│ proposals) │ │ comm.) │ │ PublicProcessor)│ │ pre-checks) │
└──────────────────┘ └──────────┘ └──────────────────┘ └────────────────┘
│ │ │
│ block + checkpoint │ pull txs │
│ proposals over p2p ▼ ▼
│ ┌──────────┐ ┌────────────┐
├────────────────────────► │ Tx │ │ L1 Rollup │
│ │ Provider │ │ Contract │
│ push blocks to └──────────┘ └────────────┘
▼ proposed chain
┌──────────────────┐
│ Archiver │
│ (l2 tips, │
│ addBlock) │
└──────────────────┘
SequencerClient.new(config, deps) is the entrypoint and is constructed by the full node. It reads L1 constants (l1GenesisTime, slotDuration, rollupManaLimit) from the rollup contract, builds the publisher factory, validator client wiring, and timetable, then instantiates the Sequencer. See src/client/sequencer-client.ts.
Sequencer (work loop)
Sequencer (src/sequencer/sequencer.ts) drives one slot at a time using a RunningPromise that ticks every sequencerPollingIntervalMS (default 500 ms). On each tick it:
- Asks the epoch cache for the slot at the next L1 block and for the target slot (
slot + 1). Building runs during the wall-clock slot; the checkpoint commits to the target slot. - Calls
prepareCheckpointProposal, which:- Dedupes against the last slot we tried to propose for.
- Runs
checkSync()— verifies world-state, l2 block source, p2p, and l1-to-l2 message source agree on the parent tip. - Checks the escape hatch (governance-controlled freeze) for the target epoch.
- Calls
checkCanPropose(targetSlot)to verify one of our configured validator addresses is the elected proposer. - Falls back to
considerInvalidatingCheckpoint()if we are not the proposer but a previous checkpoint is stuck with bad attestations and the slot is far enough along to invalidate (secondsBeforeInvalidatingBlockAsCommitteeMember/…AsNonCommitteeMember). - Refuses to build further than two checkpoints ahead of the confirmed tip.
- Builds L1
eth_callsimulation overrides (soRollup.canProposeAtsees the expected pending tip when we are building on a pipelined parent or invalidating). - Calls
publisher.canProposeAt(...)— if L1 rejects the simulated propose, abort early.
- Constructs a
CheckpointProposalJoband calls.execute(), parking the returnedpendingL1Submissionon the sequencer sostop()can await it without re-entering the loop.
Each state transition flows through setState(), which records the state, emits state-changed, and updates metrics — nothing else. Timing is not enforced through states: the work loop and the job query explicit deadlines from the ProposerTimetable (the build-entry gate via getBuildStartDeadline, sub-slot selection via selectNextSubslot, attestation collection and L1 publishing bounded by getAttestationDeadline). When a deadline is hit, the slot is abandoned and marked as attempted so it is not retried.
The sequencer is a TypedEventEmitter<SequencerEvents>. The most useful events are:
| Event | When emitted |
|---|---|
state-changed |
Every setState() call. |
preparing-checkpoint |
After the canPropose check decides to build, before L1 simulation. |
block-proposed |
After buildSingleBlock succeeds, before sign + archiver push. |
checkpoint-published |
After the L1 submission lands. |
checkpoint-publish-failed |
Multicall3 tx failed or expired. |
pipelined-checkpoint-discarded |
Pipelined parent failed to land; this slot's work is thrown away. |
checkpoint-error |
Catch-all: an exception escaped work(). |
State enum (src/sequencer/utils.ts):
STOPPED → STOPPING → IDLE → SYNCHRONIZING → PROPOSER_CHECK
→ INITIALIZING_CHECKPOINT
→ (WAITING_FOR_TXS ↔ CREATING_BLOCK ↔ WAITING_UNTIL_NEXT_BLOCK)*
→ ASSEMBLING_CHECKPOINT
→ COLLECTING_ATTESTATIONS
→ PUBLISHING_CHECKPOINT
→ IDLE
CheckpointProposalJob
CheckpointProposalJob (src/sequencer/checkpoint_proposal_job.ts) is the per-slot unit of work. It owns the lifecycle from "we have decided to propose" through "the L1 transaction has been submitted". The contract is:
execute()returns once the checkpoint has been broadcast over p2p (or has failed before that). The L1 submission runs in the background aspendingL1Submission.- All work scheduled on the publisher (governance vote, slashing actions, propose, invalidate) is enqueued early and then flushed together in a single Multicall3 transaction.
Inside execute():
- Vote enqueueing.
CheckpointVoter.enqueueVotes()signs and enqueues the governance and slashing vote requests up front. Even if block-building fails, these still go out in the final Multicall3 — they are the proposer's duty for the slot. proposeCheckpoint()(blocking, returns aCheckpointProposal):- Transitions to
INITIALIZING_CHECKPOINT. If there is a pending invalidation, enqueue it. - Builds pipelined-parent simulation overrides: when building on top of a parent that hasn't landed on L1 yet, the fee-asset price modifier must be computed against the parent fee header we predicted (not the L1 one), so all in-flight checkpoints in the pipeline agree on the same modifier.
- Asks the global variables builder for the slot's
CheckpointGlobalVariables(coinbase,feeRecipient,timestamp,gasFees,chainId,version,slotNumber). These are shared across every block within the checkpoint — onlyblockNumberincrements. - Computes
inHashfrom the L1→L2 messages for the checkpoint, and collectspreviousCheckpointOutHashesfor prior checkpoints in the same epoch. - Forks world state at the parent (
closeDelayMs: 12 s) and asksFullNodeCheckpointsBuilderfor aCheckpointBuilderbound to that fork. - Runs
buildBlocksForCheckpoint()— the per-block loop, described below. - Transitions to
ASSEMBLING_CHECKPOINT, asks the builder tocompleteCheckpoint(), validates it against the configured caps, and asks the validator client to sign theCheckpointProposal(which bundles the final block proposal so the two travel together). - Broadcasts the
CheckpointProposalvia p2p.
- Transitions to
- Attestation collection (
waitForAttestationsAndEnqueueSubmissionAsync) runs in the background:- Transitions to
COLLECTING_ATTESTATIONS. Reads the committee fromEpochCache, computes the quorum (2/3 + 1), and waits for that many attestations on p2p. The validator client adds the proposer's own signature. waitForValidParentCheckpointOnL1()waits for the archiver to confirm the parent we built on top of has landed on L1 with matching hash and valid attestations. If not, the work is dropped (pipelined-checkpoint-discarded) and we enqueue an invalidation for the parent so the next proposer doesn't repeat the same mistake.- Computes
submitAfterasgetTimestampForSlot(targetSlot)so the Multicall3 mines inside the target slot — EIP-712 signatures are bound to a slot and would silently fail if mined outside it. - Calls
publisher.sendRequestsAt(submitAfter), which targets the ideal L1 send time, re-runs each request'spreCheckagainst fresh L1 state, and submits the bundled Multicall3.
- Transitions to
Per-block loop (buildBlocksForCheckpoint)
The per-block loop is the heart of the building flow:
loop:
timing = timetable.selectNextSubslot(targetSlot, now)
if !timing.canStart: break
if blocksBuilt >= maxBlocksPerCheckpoint: break
state = WAITING_FOR_TXS
result = checkpointBuilder.buildBlock(
pendingTxs, blockNumber, timestamp,
{ maxTransactions, maxBlockGas, deadline,
minValidTxs, maxBlocksPerCheckpoint, perBlockAllocationMultiplier })
if result is 'insufficient-txs' or 'insufficient-valid-txs':
if isLastBlock or no deadline: break
else: wait until this sub-slot deadline, then continue (try next sub-slot)
if result is error: halt
blockProposal = validatorClient.createBlockProposal(block)
archiver.addBlock(block) // push to proposed chain
if not timing.isLastBlock:
p2pClient.broadcastProposal(blockProposal)
else:
blockPendingBroadcast = blockProposal // shipped with the CheckpointProposal
state = WAITING_UNTIL_NEXT_BLOCK
waitUntilNextSubSlot(timing.deadline)
The deadlines passed to CheckpointBuilder.buildBlock are absolute timestamps. The builder uses these as hard caps on tx execution, so a slow block cannot eat into the next sub-slot.
maxBlocksPerCheckpoint (bounded by the timetable's getMaxBlocksPerCheckpoint() and the configured cap) and perBlockAllocationMultiplier (default 1.2) are passed in opts so that the builder can redistribute the remaining checkpoint budget (L2 gas, DA gas, blob fields, tx count) across the remaining blocks. See validator-client/README.md § Block Building Limits for the redistribution math; the sequencer only sets the inputs.
Timetable
The sequencer builds a ProposerTimetable (from @aztec/stdlib/timetable) directly from its config and the L1 constants. Key getters:
- Sub-slot scheduling:
selectNextSubslot(slot, now)finds the next sub-slot with at leastminBlockDurationremaining and returns its absolute deadline and whether it is the last sub-slot. If we are running late, sub-slots are skipped; we never start a block we cannot finish. - Build-entry gate:
getBuildStartDeadline(slot)is the latest useful time to start building for a target slot; past it, the work loop abandons the slot without building. - Consensus bounds: the inherited
ConsensusTimetablegetters (getAttestationDeadline, the proposal/attestation receive windows) bound attestation collection, L1 publishing, and p2p ingress.
See the Block Building Timetable Spec for the full timing model, including the ideal/deadline split for checkpoint proposals, the consensus-only receive deadline, and the failure-mode walkthroughs.
SequencerPublisher
SequencerPublisher (src/publisher/sequencer-publisher.ts) translates "I want to propose a checkpoint" into "submit this Multicall3 transaction to L1". It maintains an ordered queue of RequestWithExpiry entries — each one has a label (propose, invalidate-by-*, governance-signal, vote-offenses, execute-slash), a preCheck callback, and an ABI-encoded call. The queue is sorted so invalidations go first and proposes precede votes.
Key entry points:
canProposeAt(archive, msgSender, simulationOverridesPlan?)— eth_call simulation ofRollup.canProposeAt. The sequencer runs this before deciding to build.enqueueProposeCheckpoint(checkpoint, attestations, attestationsSignature, opts)— adds the propose call, with apreCheckthat re-validates the proposal against real L1 state when it is finally sent (catches drift between build time and submit time).enqueueInvalidateCheckpoint,enqueueGovernanceCastSignal,enqueueSlashingActions— the rest of the actions a proposer may bundle.sendRequests(targetSlot?)— immediately flushes the queue as one Multicall3 transaction. Used by theAutomineSequencerfor synchronous in-slot publishing.sendRequestsAt(targetSlot)— the production (pipelined) path: sleeps (cancellable) until the ideal L1 send time, runs each request'spreCheck(dropping those that fail), and then callssendRequests(targetSlot), which filters expired requests, sorts the remainder, and submits one Multicall3 that mines inside the target slot.
SequencerPublisherFactory produces one publisher per attempt, wrapping an L1 publisher (EOA + L1TxUtils) leased from PublisherManager. Leases free the publisher when the job completes so multiple validator addresses on the same node can take turns without conflicts.
Other components
| Component | File | Responsibility |
|---|---|---|
CheckpointVoter |
src/sequencer/checkpoint_voter.ts |
Signs and enqueues governance/slashing votes. Used by both the job and the "vote even though we can't propose" fallbacks. |
GlobalVariableBuilder |
src/global_variable_builder/ |
Computes CheckpointGlobalVariables and predicts the per-slot fee asset price modifier. See its README. |
L1TxFailedStore |
src/publisher/l1_tx_failed_store/ |
Persists actions that returned a revert reason so they aren't retried in the same form on the next slot. |
ChainStateOverrides |
src/sequencer/chain_state_overrides.ts |
Builds the L1 eth_call overrides used during the pipelined parent + invalidation simulations. |
AutomineSequencer |
src/sequencer/automine/ |
Minimal queue-driven sequencer for single-sequencer e2e tests. Bypasses consensus, pipelining, and timetable enforcement. See src/sequencer/automine/README.md. |
Configuration
The configuration object is SequencerConfig (src/sequencer/config.ts + src/publisher/config.ts). The options that most directly affect block building are:
Block budgets
| Option / env var | Default | Purpose |
|---|---|---|
minTxsPerBlock / SEQ_MIN_TX_PER_BLOCK |
1 | Wait for at least this many txs before starting a block. |
minValidTxsPerBlock |
falls back to minTxsPerBlock |
After execution, discard the block if fewer txs validated. |
maxTxsPerBlock / SEQ_MAX_TX_PER_BLOCK |
unset | Hard per-block tx cap (capped at maxTxsPerCheckpoint at startup). |
maxTxsPerCheckpoint / SEQ_MAX_TX_PER_CHECKPOINT |
unset | Total tx cap across the checkpoint. Enables redistribution when set. |
maxBlocksPerCheckpoint / MAX_BLOCKS_PER_CHECKPOINT |
24 | Hard ceiling beyond what the timetable allows. Also caps the indexWithinCheckpoint accepted on inbound block proposals. |
maxL2BlockGas / SEQ_MAX_L2_BLOCK_GAS |
unset | Per-block mana cap, capped at rollupManaLimit. |
maxDABlockGas / SEQ_MAX_DA_BLOCK_GAS |
unset | Per-block DA gas cap, capped at MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT. |
perBlockAllocationMultiplier / SEQ_PER_BLOCK_ALLOCATION_MULTIPLIER |
1.2 | Multiplier passed to the checkpoint builder so early blocks can use slightly more than their even share. |
redistributeCheckpointBudget / SEQ_REDISTRIBUTE_CHECKPOINT_BUDGET |
true | Legacy flag. Redistribution is always on during proposal building. |
Timing
| Option / env var | Default | Purpose |
|---|---|---|
blockDurationMs / SEQ_BLOCK_DURATION_MS |
3000 ms | Length of one sub-slot in ms. Required: the sequencer always runs the enforced timetable. The derived maxBlocksPerCheckpoint = floor((aztecSlotDuration − checkpointInitializationTime − (checkpointAssembleTime + 2·p2pPropagationTime + blockDuration)) / blockDuration); a slot may legitimately fit a single block when that floor is 1. |
attestationPropagationTime / SEQ_ATTESTATION_PROPAGATION_TIME |
2 s | One-way p2p estimate fed to the timetable. |
sequencerPollingIntervalMS / SEQ_POLLING_INTERVAL_MS |
500 | Work-loop tick rate. |
Behavior
| Option / env var | Default | Purpose |
|---|---|---|
buildCheckpointIfEmpty / SEQ_BUILD_CHECKPOINT_IF_EMPTY |
false | Build and submit even when no txs are available. |
publishTxsWithProposals / SEQ_PUBLISH_TXS_WITH_PROPOSALS |
false | Embed full transactions in p2p block proposals (DA fallback for validators). |
fishermanMode / FISHERMAN_MODE |
false | Build internally for monitoring; never publish. |
coinbase / COINBASE |
proposer addr | Recipient of block rewards. |
feeRecipient / FEE_RECIPIENT |
proposer addr | Recipient of tx fees. |
governanceProposerPayload / GOVERNANCE_PROPOSER_PAYLOAD_ADDRESS |
unset | Payload signaled in the governance vote each slot. |
secondsBeforeInvalidatingBlockAsCommitteeMember |
144 | When not the proposer, committee members may invalidate a stuck checkpoint after this many seconds into the slot. |
secondsBeforeInvalidatingBlockAsNonCommitteeMember |
432 | Same for any node — last resort. |
minPeersToPropose / SEQ_MIN_PEERS_TO_PROPOSE |
1 | Minimum connected p2p peers required to build a checkpoint. Zero disables the check; ignored when p2p is disabled by config. |
The full list (including test/fault-injection hooks like pauseProposingForSlots and skipPublishingCheckpointsPercent) lives in src/config.ts.
Failure modes
- Not the proposer:
checkCanProposereturns false → no work done. If a previous checkpoint is stuck with invalid attestations and we are past the configured invalidation threshold, we fall through toconsiderInvalidatingCheckpointand may still submit an invalidation tx. - Out of sync:
checkSyncfails → governance/slashing votes still go out viatryVoteWhenSyncFails, but no block is built. - Insufficient txs in a sub-slot:
CheckpointBuilder.buildBlockreturnsinsufficient-txs. The sub-slot is skipped without committing state; the next sub-slot retries. On the last sub-slot, ifbuildCheckpointIfEmptyis true, the block is still built with whatever is available (possibly zero txs). - Sub-slot deadline exceeded:
CheckpointBuilderenforces the deadline and stops executing further txs. The block is finalized with whatever fit. - Build start deadline exceeded: the work loop abandons the slot before building and marks it as attempted so the same checkpoint is not retried. Inside the job, sub-slot and attestation deadlines bound their own phases.
- No connected peers: with p2p enabled and fewer than
minPeersToProposepeers, the proposer skips building entirely (the proposal could never reach the committee, and a node with enough committee seats to self-attest would publish a checkpoint whose data was never gossiped). Governance/slashing votes and prune still go out, and the slot is marked as attempted. - Pipelined parent fails on L1:
waitForValidParentCheckpointOnL1returns false. The whole proposal is discarded (pipelined-checkpoint-discarded), the parent is enqueued for invalidation, and the L1 submission for this checkpoint is not sent. - L1 submission reverts or expires:
checkpoint-publish-failedis emitted with the individual action results so observability can break down which actions in the Multicall3 went through and which didn't.
Where to look first
| Question | File |
|---|---|
| How does the work loop decide whether to propose? | src/sequencer/sequencer.ts → prepareCheckpointProposal, checkCanPropose |
| How does a checkpoint get built block-by-block? | src/sequencer/checkpoint_proposal_job.ts → proposeCheckpoint, buildBlocksForCheckpoint |
| How do sub-slot deadlines work? | @aztec/stdlib ProposerTimetable + stdlib/src/timetable/README.md |
| How does an L1 transaction get scheduled and submitted? | src/publisher/sequencer-publisher.ts → sendRequestsAt |
| How does pipelining wait for the parent to land? | src/sequencer/checkpoint_proposal_job.ts → waitForValidParentCheckpointOnL1 |
| How do governance and slashing votes get into the L1 tx? | src/sequencer/checkpoint_voter.ts |
| How are fees predicted for the slot? | src/global_variable_builder/README.md |
| How are full-node services wired together? | src/client/sequencer-client.ts |
Development
Build, format, lint, and test from yarn-project/:
yarn workspace @aztec/sequencer-client build
yarn workspace @aztec/sequencer-client test
The integration tests of interest are:
src/sequencer/sequencer.test.ts— work-loop behavior, escape-hatch and invalidation fallbacks.src/sequencer/checkpoint_proposal_job.test.ts— full per-slot job, including pipelined parent validation and discard paths.src/sequencer/checkpoint_proposal_job.timing.test.ts— sub-slot timing and skip behavior under simulated clock drift.stdlib/src/timetable/*.test.ts— pure math againstConsensusTimetable/ProposerTimetable.src/publisher/sequencer-publisher.test.ts— request ordering,sendRequestsAt, preCheck re-runs.