@aztec/sequencer-client 0.0.1-commit.3100065 → 0.0.1-commit.330febf

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dest/config.d.ts +2 -1
  2. package/dest/config.d.ts.map +1 -1
  3. package/dest/config.js +6 -0
  4. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts +48 -8
  5. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts.map +1 -1
  6. package/dest/publisher/l1_tx_failed_store/failed_tx_store.js +68 -1
  7. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.d.ts +2 -2
  8. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.d.ts.map +1 -1
  9. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.js +4 -2
  10. package/dest/publisher/l1_tx_failed_store/index.d.ts +2 -2
  11. package/dest/publisher/l1_tx_failed_store/index.d.ts.map +1 -1
  12. package/dest/publisher/l1_tx_failed_store/index.js +1 -0
  13. package/dest/publisher/sequencer-publisher.d.ts +20 -4
  14. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  15. package/dest/publisher/sequencer-publisher.js +278 -49
  16. package/dest/sequencer/automine/automine_sequencer.d.ts +1 -1
  17. package/dest/sequencer/automine/automine_sequencer.d.ts.map +1 -1
  18. package/dest/sequencer/automine/automine_sequencer.js +7 -1
  19. package/dest/sequencer/checkpoint_proposal_job.d.ts +1 -1
  20. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  21. package/dest/sequencer/checkpoint_proposal_job.js +9 -1
  22. package/dest/sequencer/errors.d.ts +8 -1
  23. package/dest/sequencer/errors.d.ts.map +1 -1
  24. package/dest/sequencer/errors.js +9 -0
  25. package/dest/sequencer/missing_committee.d.ts +72 -0
  26. package/dest/sequencer/missing_committee.d.ts.map +1 -0
  27. package/dest/sequencer/missing_committee.js +139 -0
  28. package/dest/sequencer/sequencer.d.ts +4 -3
  29. package/dest/sequencer/sequencer.d.ts.map +1 -1
  30. package/dest/sequencer/sequencer.js +13 -4
  31. package/package.json +28 -28
  32. package/src/config.ts +7 -0
  33. package/src/publisher/l1_tx_failed_store/failed_tx_store.ts +114 -7
  34. package/src/publisher/l1_tx_failed_store/file_store_failed_tx_store.ts +4 -3
  35. package/src/publisher/l1_tx_failed_store/index.ts +1 -1
  36. package/src/publisher/sequencer-publisher.ts +308 -59
  37. package/src/sequencer/README.md +243 -0
  38. package/src/sequencer/automine/automine_sequencer.ts +5 -1
  39. package/src/sequencer/checkpoint_proposal_job.ts +5 -1
  40. package/src/sequencer/errors.ts +15 -0
  41. package/src/sequencer/missing_committee.ts +192 -0
  42. package/src/sequencer/sequencer.ts +14 -5
@@ -0,0 +1,243 @@
1
+ # Sequencer Timing Model
2
+
3
+ This document covers how the sequencer schedules its work within a slot. See the [package README](../../README.md) for the high-level architecture; this one focuses on the timing math and the state-machine deadlines.
4
+
5
+ The model described here is for **proposer pipelining**, the only mode the production sequencer runs in (the proposer always builds for `slot + 1`). The deterministic single-sequencer `AutomineSequencer` used in some e2e tests publishes synchronously in-slot and does not use this timing model.
6
+
7
+ ## Overview
8
+
9
+ Block production runs on three nested clocks:
10
+
11
+ - A **slot** is a fixed window (e.g. 72 s) during which one elected proposer is allowed to build.
12
+ - A slot contains several equal-length **sub-slots** (e.g. 8 s). Each sub-slot owns the budget for one L2 block and has a deadline fixed relative to the slot start.
13
+ - All blocks built within one slot make up one **checkpoint**, which is what eventually goes on L1.
14
+
15
+ Under pipelining, the proposer for slot `N` does its building inside slot `N - 1` ("build slot"). Slot `N` ("target slot") is only used to mine the L1 transaction. This shifts work like this:
16
+
17
+ | Phase | When |
18
+ | ------------------------------ | -------------- |
19
+ | Initialization | slot `N - 1` |
20
+ | Block building | slot `N - 1` |
21
+ | Checkpoint proposal broadcast | slot `N - 1` |
22
+ | Last-block re-execution | slot `N - 1` |
23
+ | Attestation collection | slot `N - 1` |
24
+ | L1 submission | slot `N` |
25
+
26
+ The wall-clock slot the sequencer is reasoning about ("build slot") and the slot the checkpoint commits to ("target slot") are always `N - 1` and `N` respectively.
27
+
28
+ ## Sub-slots
29
+
30
+ Each sub-slot has a fixed start time and a fixed deadline, both relative to the slot start:
31
+
32
+ ```
33
+ subSlotStart[k] = initializationOffset + (k - 1) * blockDuration
34
+ subSlotDeadline[k] = initializationOffset + k * blockDuration
35
+ ```
36
+
37
+ with `k = 1, 2, ..., maxNumberOfBlocks`. Deadlines do **not** shift based on when the previous block finished. If a block finishes early, the sequencer waits for the next sub-slot to begin (so validators see a regular cadence). If it finishes late, the next block has correspondingly less time.
38
+
39
+ `canStartNextBlock(secondsIntoSlot)` walks the sub-slot list and returns the first one with at least `minExecutionTime` left before its deadline. Sub-slots that no longer have enough headroom are skipped entirely.
40
+
41
+ ### Number of sub-slots
42
+
43
+ The maximum number of buildable blocks per slot is:
44
+
45
+ ```
46
+ timeReservedAtEnd = checkpointAssembleTime
47
+ + 2 * p2pPropagationTime // proposal out + attestations back
48
+ + blockDuration // last-block re-execution
49
+
50
+ timeAvailableForBlocks = aztecSlotDuration
51
+ - checkpointInitializationTime
52
+ - timeReservedAtEnd
53
+
54
+ maxNumberOfBlocks = floor(timeAvailableForBlocks / blockDuration)
55
+ ```
56
+
57
+ The reservation at the end of the slot is sized so that, on the happy path, attestations are in hand by the time the target slot starts. The enforced `COLLECTING_ATTESTATIONS` and `PUBLISHING_CHECKPOINT` deadlines are softer (see the deadline table below) and let a late attestation spill into the target slot. L1 publishing is **not** included in `timeReservedAtEnd` — that is paid for by the target slot.
58
+
59
+ ### Cooldown after the last sub-slot
60
+
61
+ All `maxNumberOfBlocks` sub-slots build a block. The cooldown lives in the `timeReservedAtEnd` window that follows the last sub-slot:
62
+
63
+ - 1 × `checkpointAssembleTime` to assemble and sign the checkpoint,
64
+ - 1 × `p2pPropagationTime` for the `CheckpointProposal` to reach the committee,
65
+ - 1 × `blockDuration` for the committee to re-execute the last block,
66
+ - 1 × `p2pPropagationTime` for attestations to come back.
67
+
68
+ These four windows total `checkpointAssembleTime + blockDuration + 2 * p2pPropagationTime`, exactly the `timeReservedAtEnd` formula above. The block built in the last sub-slot is *not* broadcast as a regular `BlockProposal`; the proposer holds it as `blockPendingBroadcast` so it travels bundled inside the `CheckpointProposal`.
69
+
70
+ ## Timing constants
71
+
72
+ These constants come from `@aztec/stdlib/timetable` (see `stdlib/src/timetable/index.ts`). Some are fixed across the network, some are inputs from configuration.
73
+
74
+ | Constant | Source | Typical value | Purpose |
75
+ | --------------------------------- | --------------------------------------- | ------------- | ---------------------------------------------------- |
76
+ | `aztecSlotDuration` | L1 rollup contract | 72 s | Length of one Aztec slot. |
77
+ | `ethereumSlotDuration` | L1 rollup contract | 12 s | Length of one Ethereum slot. |
78
+ | `blockDuration` | `blockDurationMs` config | 6–8 s | Sub-slot length. |
79
+ | `checkpointInitializationTime` | constant (`CHECKPOINT_INITIALIZATION_TIME`) | 1 s | Estimated sync + proposer check time. |
80
+ | `checkpointAssembleTime` | constant (`CHECKPOINT_ASSEMBLE_TIME`) | 1 s | Time to assemble and sign the checkpoint after the last block. |
81
+ | `p2pPropagationTime` | `attestationPropagationTime` config | 2 s | One-way p2p estimate (proposals, attestations). |
82
+ | `l1PublishingTime` | `l1PublishingTime` config | 12 s | Time reserved for the L1 tx to land. Used by the target slot, not the build slot. |
83
+ | `minExecutionTime` | constant (`MIN_EXECUTION_TIME`) | 2 s | Minimum headroom to start a block. |
84
+ | `initializationOffset` | `=checkpointInitializationTime` | 1 s | Where sub-slot 1 starts. |
85
+
86
+ ## Deadlines
87
+
88
+ `SequencerTimetable.getMaxAllowedTime(state)` returns the latest second-into-slot a given state is allowed to be entered. `assertTimeLeft()` throws `SequencerTooSlowError` if the slot has already advanced past that deadline. Sub-slot scheduling is measured against the build slot (`slotNow`); state assertions, however, are measured against whichever slot `setState` was called with — for the publishing path that is the target slot, which is why the publishing deadline is allowed to exceed `aztecSlotDuration`.
89
+
90
+ | State | Max allowed time (seconds into build slot) |
91
+ | --------------------------- | --------------------------------------------------------------------------- |
92
+ | `PROPOSER_CHECK` | `initializeDeadline = aztecSlotDuration - (checkpointInitializationTime + 2*minExecutionTime)` |
93
+ | `INITIALIZING_CHECKPOINT` | same as `PROPOSER_CHECK` |
94
+ | `WAITING_FOR_TXS` | `initializeDeadline + checkpointInitializationTime` |
95
+ | `CREATING_BLOCK` | same as `WAITING_FOR_TXS` |
96
+ | `WAITING_UNTIL_NEXT_BLOCK` | same as `WAITING_FOR_TXS` |
97
+ | `ASSEMBLING_CHECKPOINT` | `aztecSlotDuration + pipeliningAttestationGracePeriod` |
98
+ | `COLLECTING_ATTESTATIONS` | same as `ASSEMBLING_CHECKPOINT` |
99
+ | `PUBLISHING_CHECKPOINT` | `2 * aztecSlotDuration - ethereumSlotDuration` (extends into the target slot) |
100
+
101
+ In production-like timing, `pipeliningAttestationGracePeriod` is zero, so `ASSEMBLING_CHECKPOINT` and
102
+ `COLLECTING_ATTESTATIONS` must be *entered* before the build-slot boundary. Local networks with
103
+ `l1PublishingTime < ethereumSlotDuration` can use the target-slot attestation window as grace while preserving the
104
+ L1-geometry publishing cutoff. Once entered, attestation collection itself has its own
105
+ `checkpointAttestationDeadline = 2 * aztecSlotDuration - ethereumSlotDuration`, so a late attestation arriving after
106
+ the boundary is still accepted. The publishing deadline extends into the target slot because that is when the L1 tx is
107
+ actually submitted.
108
+
109
+ ## Example: 72 s slot, 8 s sub-slots
110
+
111
+ With typical pipelining values:
112
+
113
+ ```
114
+ checkpointInitializationTime = 1s
115
+ blockDuration = 8s
116
+ checkpointAssembleTime = 1s
117
+ p2pPropagationTime = 2s
118
+ l1PublishingTime = 12s
119
+
120
+ timeReservedAtEnd = 1 + 2*2 + 8 = 13s
121
+ timeAvailableForBlocks = 72 - 1 - 13 = 58s
122
+ maxNumberOfBlocks = floor(58 / 8) = 7
123
+ ```
124
+
125
+ Seven sub-slots, all of which build a block:
126
+
127
+ ```
128
+ Sub-slot 1: starts 1s, deadline 9s (Block 1)
129
+ Sub-slot 2: starts 9s, deadline 17s (Block 2)
130
+ Sub-slot 3: starts 17s, deadline 25s (Block 3)
131
+ Sub-slot 4: starts 25s, deadline 33s (Block 4)
132
+ Sub-slot 5: starts 33s, deadline 41s (Block 5)
133
+ Sub-slot 6: starts 41s, deadline 49s (Block 6)
134
+ Sub-slot 7: starts 49s, deadline 57s (Block 7 — held for the checkpoint proposal)
135
+
136
+ 57s: Block 7 done, ASSEMBLING_CHECKPOINT (1s)
137
+ 58s: CheckpointProposal broadcast
138
+ 60s: Committee receives proposal (+2s p2p)
139
+ 60-68s: Committee re-executes Block 7
140
+ 68s: Committee sends attestations
141
+ 70s: Proposer has the quorum (+2s p2p)
142
+
143
+ 70-72s: Slack
144
+ 72s: Build slot ends → L1 submission starts (target slot begins)
145
+ 84s: L1 tx mined inside the target slot (+12s)
146
+ ```
147
+
148
+ ## Parallel execution: proposer vs committee
149
+
150
+ While the proposer builds block `k+1`, the committee is re-executing block `k`. The pipeline keeps both sides busy except for the cooldown sub-slot.
151
+
152
+ ```
153
+ Time | Proposer | Committee
154
+ -----|------------------------------|--------------------------------------
155
+ 1s | Start Block 1 | (idle)
156
+ 9s | Finish Block 1, broadcast |
157
+ 9s | Start Block 2 |
158
+ 11s | | Receive Block 1 (9s + 2s)
159
+ | | Re-execute Block 1
160
+ 17s | Finish Block 2, broadcast |
161
+ 17s | Start Block 3 |
162
+ 19s | | Finish Block 1 (11s + 8s)
163
+ | | Receive Block 2 (17s + 2s)
164
+ | | Re-execute Block 2
165
+ ...
166
+ 49s | Finish Block 6, broadcast |
167
+ 49s | Start Block 7 (last) |
168
+ 51s | | Receive Block 6 (49s + 2s)
169
+ | | Re-execute Block 6
170
+ 57s | Finish Block 7 (held) |
171
+ | ASSEMBLING_CHECKPOINT (1s) |
172
+ 58s | Broadcast CheckpointProposal |
173
+ 59s | | Finish Block 6 (51s + 8s)
174
+ 60s | | Receive Block 7 + Checkpoint (58s + 2s)
175
+ | | Re-execute Block 7
176
+ 68s | | Send attestations (60s + 8s)
177
+ 70s | Receive attestations |
178
+ 70-72s| Slack |
179
+ 72s | L1 tx submitted |
180
+ 84s | L1 tx mined |
181
+ ```
182
+
183
+ **Observations**:
184
+
185
+ - Validators always lag the proposer by ~2 s (one p2p hop).
186
+ - For the last block there is no `k+1` to build alongside; once the proposer broadcasts the `CheckpointProposal`, it just waits while the committee re-executes.
187
+ - L1 publishing happens entirely inside the next slot and does not steal time from block building.
188
+
189
+ ## Handling timing variations
190
+
191
+ ### Fast initialization (0.5 s instead of 1 s)
192
+
193
+ Sub-slot 1's deadline is still 9 s, so Block 1 gets a 0.5 s bonus before hitting its deadline. No structural change.
194
+
195
+ ### Slow initialization (2 s instead of 1 s)
196
+
197
+ Block 1 has 7 s of build time instead of 8 s. Still well above `minExecutionTime`, so the block still gets built. No sub-slots are skipped.
198
+
199
+ ### Very slow initialization (8 s)
200
+
201
+ Sub-slot 1's deadline (9 s) is closer than `minExecutionTime` (2 s), so it is skipped entirely. The first attempted block runs in sub-slot 2 with the usual budget. The checkpoint will have one fewer block.
202
+
203
+ ### Block takes longer than its budget
204
+
205
+ `CheckpointBuilder` enforces the deadline by stopping public-tx execution; in practice a block can only overrun by the time it takes to finalize the block (typically < 1 s). The next sub-slot starts as scheduled but with proportionally less headroom. If that headroom drops below `minExecutionTime`, the next sub-slot is skipped.
206
+
207
+ ### Block finishes early
208
+
209
+ The sequencer transitions to `WAITING_UNTIL_NEXT_BLOCK` and sleeps until the next sub-slot start. This keeps the cadence regular and gives validators predictable arrival times for re-execution.
210
+
211
+ ### Block proposal returns insufficient txs
212
+
213
+ The current sub-slot is dropped without committing anything. The loop retries on the next sub-slot. If `buildCheckpointIfEmpty` is true, the last sub-slot is forced through with whatever is available, including zero txs.
214
+
215
+ ### Build slot ends before attestations arrive
216
+
217
+ `assertTimeLeft` will reject `PUBLISHING_CHECKPOINT` if the attestation deadline has passed; the slot is abandoned, and
218
+ `checkpoint-publish-failed` is emitted. The `PUBLISHING_CHECKPOINT` deadline allows spillover into the target slot
219
+ (`2 * aztecSlotDuration - ethereumSlotDuration`) precisely to absorb a small overrun.
220
+
221
+ ### Pipelined parent fails on L1
222
+
223
+ Before submitting, the job calls `waitForValidParentCheckpointOnL1`. If the parent we built on top of did not land cleanly (wrong archive, missing attestations, etc.) the job discards its checkpoint, emits `pipelined-checkpoint-discarded`, and enqueues an invalidation for the parent so the next proposer doesn't get stuck on the same bad ancestor.
224
+
225
+ ## Configuration constraints
226
+
227
+ `initializeDeadline` must be positive, so `aztecSlotDuration > checkpointInitializationTime + 2 * minExecutionTime`. With defaults that lower bound is 5 s, far below any realistic slot length.
228
+
229
+ For multi-block production to make sense, `maxNumberOfBlocks ≥ 2`:
230
+
231
+ ```
232
+ aztecSlotDuration ≥ checkpointInitializationTime
233
+ + 2 * blockDuration // two blocks
234
+ + checkpointAssembleTime
235
+ + 2 * p2pPropagationTime
236
+ + blockDuration // last-block re-execution window
237
+ ```
238
+
239
+ Block duration should be ≥ `minExecutionTime` (otherwise no sub-slot ever has enough headroom). `p2pPropagationTime` should be measured against the deployment's actual p2p latency: it directly determines how much of each slot is spent on the cooldown.
240
+
241
+ `l1PublishingTime` should fit inside the Ethereum slot the target slot maps to. The default of 12 s lines up with one
242
+ Ethereum slot; fast local networks may reduce it to use the target-slot attestation window as assembly and attestation
243
+ grace.
@@ -790,7 +790,11 @@ export class AutomineSequencer {
790
790
  return;
791
791
  }
792
792
  const failedTxHashes = failedTxs.map(fail => fail.tx.getTxHash());
793
- this.log.verbose(`Dropping failed txs ${failedTxHashes.join(', ')}`);
793
+ const failures = failedTxs.map(fail => ({ txHash: fail.tx.getTxHash().toString(), reason: fail.error.message }));
794
+ this.log.warn(
795
+ `Dropping ${failedTxs.length} failed txs: ${failures.map(f => `${f.txHash} (${f.reason})`).join(', ')}`,
796
+ { failures },
797
+ );
794
798
  await this.deps.p2pClient.handleFailedExecution(failedTxHashes);
795
799
  }
796
800
  }
@@ -1531,7 +1531,11 @@ export class CheckpointProposalJob implements Traceable {
1531
1531
  }
1532
1532
  const failedTxData = failedTxs.map(fail => fail.tx);
1533
1533
  const failedTxHashes = failedTxData.map(tx => tx.getTxHash());
1534
- this.log.verbose(`Dropping failed txs ${failedTxHashes.join(', ')}`);
1534
+ const failures = failedTxs.map(fail => ({ txHash: fail.tx.getTxHash().toString(), reason: fail.error.message }));
1535
+ this.log.warn(
1536
+ `Dropping ${failedTxs.length} txs from mempool due to failures during block building for slot ${this.targetSlot}`,
1537
+ { slot: this.targetSlot, checkpointNumber: this.checkpointNumber, failures },
1538
+ );
1535
1539
  await this.p2pClient.handleFailedExecution(failedTxHashes);
1536
1540
  }
1537
1541
 
@@ -1,3 +1,18 @@
1
+ import type { SequencerState } from './utils.js';
2
+
3
+ export class SequencerTooSlowError extends Error {
4
+ constructor(
5
+ public readonly proposedState: SequencerState,
6
+ public readonly maxAllowedTime: number,
7
+ public readonly currentTime: number,
8
+ ) {
9
+ super(
10
+ `Too far into slot for ${proposedState} (time into slot ${currentTime}s greater than ${maxAllowedTime}s allowance)`,
11
+ );
12
+ this.name = 'SequencerTooSlowError';
13
+ }
14
+ }
15
+
1
16
  export class SequencerInterruptedError extends Error {
2
17
  constructor() {
3
18
  super(`Sequencer was interrupted`);
@@ -0,0 +1,192 @@
1
+ import type { EpochCache } from '@aztec/epoch-cache';
2
+ import type { RollupContract } from '@aztec/ethereum/contracts';
3
+ import { EpochNumber, type SlotNumber } from '@aztec/foundation/branded-types';
4
+ import { timesParallel } from '@aztec/foundation/collection';
5
+ import type { Logger } from '@aztec/foundation/log';
6
+ import { formatSeconds } from '@aztec/foundation/string';
7
+ import type { DateProvider } from '@aztec/foundation/timer';
8
+ import type { L2BlockSource } from '@aztec/stdlib/block';
9
+ import { type L1RollupConstants, getStartTimestampForEpoch } from '@aztec/stdlib/epoch-helpers';
10
+
11
+ /** Collaborators {@link logMissingCommittee} needs to diagnose and report why no committee exists. */
12
+ export interface MissingCommitteeContext {
13
+ epochCache: Pick<EpochCache, 'getL1Constants' | 'getLagInEpochsForValidatorSet'>;
14
+ rollupContract: Pick<RollupContract, 'getActiveAttesterCount' | 'getAttesterCountAtTime'>;
15
+ l2BlockSource: Pick<L2BlockSource, 'getBlockNumber'>;
16
+ l1Constants: Pick<L1RollupConstants, 'l1GenesisTime' | 'slotDuration' | 'epochDuration'>;
17
+ dateProvider: Pick<DateProvider, 'nowInSeconds'>;
18
+ logger: Pick<Logger, 'info' | 'warn' | 'debug'>;
19
+ }
20
+
21
+ /**
22
+ * Why the sequencer cannot find a validator committee for an upcoming slot. A committee for an epoch is
23
+ * sampled from the validator set as of `lagInEpochsForValidatorSet` epochs earlier, and L1 refuses to
24
+ * produce one until at least `targetCommitteeSize` validators were staked at that sampling time.
25
+ */
26
+ export type MissingCommitteeCause =
27
+ /** Enough validators are staked now; the committee is just waiting for the sampling window to advance. */
28
+ | 'awaiting-sampling-lag'
29
+ /** The chain has not produced any block yet and too few validators are staked: normal bootstrap state. */
30
+ | 'awaiting-first-validators'
31
+ /** The chain has produced blocks before but the validator set has since dropped below the required size. */
32
+ | 'validator-set-shrank';
33
+
34
+ export interface MissingCommitteeDiagnosis {
35
+ cause: MissingCommitteeCause;
36
+ /** Log severity: bootstrap and lag are expected (`info`); a shrunken set on a live chain is not (`warn`). */
37
+ severity: 'info' | 'warn';
38
+ }
39
+
40
+ /**
41
+ * Logs why no validator committee exists for the given slot. Rather than the cryptic "committee does not
42
+ * exist on L1", it diagnoses the cause from the live attester count and whether the chain has ever produced
43
+ * a block: a bootstrapping chain waiting for validators to stake, a full set still waiting for the sampling
44
+ * lag to elapse, or a validator set that has shrunk below the required size on a live chain. Only the last is
45
+ * a genuine problem, so only it is logged at `warn`.
46
+ *
47
+ * Best-effort: if the diagnostic L1 reads fail we fall back to a neutral message so this never throws on the
48
+ * propose path.
49
+ */
50
+ export async function logMissingCommittee(
51
+ targetSlot: SlotNumber,
52
+ targetEpoch: EpochNumber,
53
+ ctx: MissingCommitteeContext,
54
+ ): Promise<void> {
55
+ const targetCommitteeSize = ctx.epochCache.getL1Constants().targetCommitteeSize;
56
+ const lag = ctx.epochCache.getLagInEpochsForValidatorSet();
57
+
58
+ let attesterCount: number;
59
+ let hasProducedBlocks: boolean;
60
+ try {
61
+ [attesterCount, hasProducedBlocks] = await Promise.all([
62
+ ctx.rollupContract.getActiveAttesterCount(),
63
+ ctx.l2BlockSource.getBlockNumber().then(n => n > 0),
64
+ ]);
65
+ } catch (err) {
66
+ ctx.logger.warn(`No committee found for slot ${targetSlot}; could not determine validator set size`, {
67
+ targetSlot,
68
+ targetEpoch,
69
+ targetCommitteeSize,
70
+ err,
71
+ });
72
+ return;
73
+ }
74
+
75
+ const diagnosis = classifyMissingCommittee({ attesterCount, targetCommitteeSize, hasProducedBlocks });
76
+ const logCtx = { targetSlot, targetEpoch, attesterCount, targetCommitteeSize, cause: diagnosis.cause };
77
+
78
+ switch (diagnosis.cause) {
79
+ case 'awaiting-sampling-lag': {
80
+ const firstCommitteeEpoch = await estimateFirstCommitteeEpoch(targetEpoch, lag, targetCommitteeSize, ctx);
81
+ const staked = `${attesterCount} validators are staked (>= ${targetCommitteeSize} required)`;
82
+ const forming = `the committee is still forming as the ${lag}-epoch sampling window advances`;
83
+ // When we could not pin the exact epoch, report the safe upper bound rather than a misleading ETA.
84
+ const epoch = firstCommitteeEpoch ?? EpochNumber(targetEpoch + lag + 1);
85
+ const expectation =
86
+ firstCommitteeEpoch === undefined
87
+ ? `A committee should exist no later than epoch ${epoch}.`
88
+ : `The first committee is expected at epoch ${epoch}${formatEpochEta(epoch, ctx)}.`;
89
+ ctx.logger.info(`No committee for slot ${targetSlot} yet: ${staked}, ${forming}. ${expectation}`, {
90
+ ...logCtx,
91
+ firstCommitteeEpoch: epoch,
92
+ });
93
+ break;
94
+ }
95
+ case 'awaiting-first-validators':
96
+ ctx.logger.info(
97
+ `No committee for slot ${targetSlot}: the chain has not started producing blocks and only ${attesterCount} ` +
98
+ `of the ${targetCommitteeSize} required validators are staked. Block production begins once at least ` +
99
+ `${targetCommitteeSize} validators stake and the ${lag}-epoch sampling lag elapses.`,
100
+ logCtx,
101
+ );
102
+ break;
103
+ case 'validator-set-shrank':
104
+ ctx.logger.warn(
105
+ `No committee for slot ${targetSlot}: the validator set has dropped to ${attesterCount}, below the ` +
106
+ `${targetCommitteeSize} required to form a committee. The chain cannot progress until enough validators ` +
107
+ `are staked again — check for validators exiting or being slashed.`,
108
+ logCtx,
109
+ );
110
+ break;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Classifies why no committee exists for a slot, using only cheap live signals: the current attester count
116
+ * and whether the chain has ever produced a block. See {@link MissingCommitteeCause} for the reasoning.
117
+ */
118
+ export function classifyMissingCommittee(args: {
119
+ attesterCount: number;
120
+ targetCommitteeSize: number;
121
+ hasProducedBlocks: boolean;
122
+ }): MissingCommitteeDiagnosis {
123
+ const { attesterCount, targetCommitteeSize, hasProducedBlocks } = args;
124
+
125
+ if (attesterCount >= targetCommitteeSize) {
126
+ return { cause: 'awaiting-sampling-lag', severity: 'info' };
127
+ }
128
+
129
+ if (hasProducedBlocks) {
130
+ return { cause: 'validator-set-shrank', severity: 'warn' };
131
+ }
132
+
133
+ return { cause: 'awaiting-first-validators', severity: 'info' };
134
+ }
135
+
136
+ /**
137
+ * Given each candidate epoch after the one we failed to propose at, paired with the attester count staked at
138
+ * that epoch's validator-set sample time (`undefined` when the sample time is still in the future), returns
139
+ * the earliest epoch that will have a committee, or `undefined` if none qualifies.
140
+ *
141
+ * Candidates must be ordered ascending by epoch. A committee for epoch `E` exists iff at least
142
+ * `targetCommitteeSize` validators were staked at `E`'s sample time, so the first candidate whose sampled
143
+ * count meets the target is the answer; a candidate whose sample time is still in the future is assumed to
144
+ * have a committee if the set holds and wins as soon as it is reached. The predicate is not monotone in `E`
145
+ * (the set can dip below target between sample times), so this must scan ascending rather than binary-search.
146
+ */
147
+ export function findFirstEpochWithCommittee(args: {
148
+ candidates: { epoch: EpochNumber; sampledAttesterCount: number | undefined }[];
149
+ targetCommitteeSize: number;
150
+ }): EpochNumber | undefined {
151
+ return args.candidates.find(
152
+ c => c.sampledAttesterCount === undefined || c.sampledAttesterCount >= args.targetCommitteeSize,
153
+ )?.epoch;
154
+ }
155
+
156
+ /**
157
+ * Earliest epoch expected to have a committee, replaying L1's validator-set sampling rule: a committee for
158
+ * epoch `E` exists iff at least `targetCommitteeSize` attesters were staked at `E`'s sample time
159
+ * (`epochStart(E) - lag * epochDuration`). Only epochs after the one we failed at can qualify, and the set
160
+ * must have crossed the target within the last `lag` epochs' sample window, so the answer lies in
161
+ * `(targetEpoch, targetEpoch + lag + 1]`. Reads the historical attester count at each candidate's sample time
162
+ * (at most `lag` on-chain reads; future sample times are assumed to hold). Returns `undefined` if no candidate
163
+ * qualifies or the reads fail, leaving the caller to report a bound instead of an ETA.
164
+ */
165
+ async function estimateFirstCommitteeEpoch(
166
+ targetEpoch: EpochNumber,
167
+ lag: number,
168
+ targetCommitteeSize: number,
169
+ ctx: MissingCommitteeContext,
170
+ ): Promise<EpochNumber | undefined> {
171
+ try {
172
+ const epochDurationSeconds = BigInt(ctx.l1Constants.epochDuration * ctx.l1Constants.slotDuration);
173
+ const nowSeconds = BigInt(ctx.dateProvider.nowInSeconds());
174
+ const candidates = await timesParallel(lag + 1, async i => {
175
+ const epoch = EpochNumber(targetEpoch + 1 + i);
176
+ const sampleTime = getStartTimestampForEpoch(epoch, ctx.l1Constants) - epochDurationSeconds * BigInt(lag);
177
+ const sampledAttesterCount =
178
+ sampleTime > nowSeconds ? undefined : await ctx.rollupContract.getAttesterCountAtTime(sampleTime);
179
+ return { epoch, sampledAttesterCount };
180
+ });
181
+ return findFirstEpochWithCommittee({ candidates, targetCommitteeSize });
182
+ } catch (err) {
183
+ ctx.logger.debug(`Could not estimate first committee epoch after epoch ${targetEpoch}`, { targetEpoch, err });
184
+ return undefined;
185
+ }
186
+ }
187
+
188
+ /** Formats the ETA to the start of the given epoch as ` (~12m from now)`, or empty if it is in the past. */
189
+ function formatEpochEta(epoch: EpochNumber, ctx: MissingCommitteeContext): string {
190
+ const secondsUntil = Number(getStartTimestampForEpoch(epoch, ctx.l1Constants)) - ctx.dateProvider.nowInSeconds();
191
+ return secondsUntil > 0 ? ` (~${formatSeconds(secondsUntil)} from now)` : '';
192
+ }
@@ -55,6 +55,7 @@ import { CheckpointVoter } from './checkpoint_voter.js';
55
55
  import { SequencerInterruptedError } from './errors.js';
56
56
  import type { SequencerEvents } from './events.js';
57
57
  import { SequencerMetrics } from './metrics.js';
58
+ import { logMissingCommittee } from './missing_committee.js';
58
59
  import { RequestsTracker } from './requests_tracker.js';
59
60
  import type { SequencerRollupConstants } from './types.js';
60
61
  import { SequencerState } from './utils.js';
@@ -96,8 +97,8 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
96
97
  * re-simulating and re-submitting the same invalidation across the many ticks within a single slot. */
97
98
  private lastInvalidationAttempt: { slot: SlotNumber; checkpointNumber: CheckpointNumber } | undefined;
98
99
 
99
- /** The last slot for which we logged "no committee" warning, to avoid spam */
100
- private lastSlotForNoCommitteeWarning: SlotNumber | undefined;
100
+ /** The last epoch for which we logged a "no committee" diagnostic, to avoid per-slot log spam. */
101
+ private lastEpochForNoCommitteeLog: EpochNumber | undefined;
101
102
 
102
103
  /** The last slot for which we triggered a checkpoint proposal job, to prevent duplicate attempts. */
103
104
  protected lastSlotForCheckpointProposalJob: SlotNumber | undefined;
@@ -970,9 +971,17 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
970
971
  proposer = await this.epochCache.getProposerAttesterAddressInSlot(targetSlot);
971
972
  } catch (e) {
972
973
  if (e instanceof NoCommitteeError) {
973
- if (this.lastSlotForNoCommitteeWarning !== targetSlot) {
974
- this.lastSlotForNoCommitteeWarning = targetSlot;
975
- this.log.warn(`Cannot propose at target slot ${targetSlot} since the committee does not exist on L1`);
974
+ const targetEpoch = getEpochAtSlot(targetSlot, this.l1Constants);
975
+ if (this.lastEpochForNoCommitteeLog !== targetEpoch) {
976
+ this.lastEpochForNoCommitteeLog = targetEpoch;
977
+ await logMissingCommittee(targetSlot, targetEpoch, {
978
+ epochCache: this.epochCache,
979
+ rollupContract: this.rollupContract,
980
+ l2BlockSource: this.l2BlockSource,
981
+ l1Constants: this.l1Constants,
982
+ dateProvider: this.dateProvider,
983
+ logger: this.log,
984
+ });
976
985
  }
977
986
  return [false, undefined];
978
987
  }