@aztec/prover-node 0.0.1-commit.b655e406 → 0.0.1-commit.b8a057fa

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 (84) hide show
  1. package/README.md +518 -0
  2. package/dest/actions/download-epoch-proving-job.d.ts +4 -4
  3. package/dest/actions/download-epoch-proving-job.js +1 -1
  4. package/dest/actions/index.d.ts +1 -1
  5. package/dest/actions/rerun-epoch-proving-job.d.ts +6 -4
  6. package/dest/actions/rerun-epoch-proving-job.d.ts.map +1 -1
  7. package/dest/actions/rerun-epoch-proving-job.js +191 -28
  8. package/dest/actions/upload-epoch-proof-failure.d.ts +2 -2
  9. package/dest/actions/upload-epoch-proof-failure.d.ts.map +1 -1
  10. package/dest/bin/run-failed-epoch.d.ts +1 -1
  11. package/dest/bin/run-failed-epoch.js +6 -5
  12. package/dest/checkpoint-store.d.ts +87 -0
  13. package/dest/checkpoint-store.d.ts.map +1 -0
  14. package/dest/checkpoint-store.js +186 -0
  15. package/dest/config.d.ts +10 -10
  16. package/dest/config.d.ts.map +1 -1
  17. package/dest/config.js +27 -22
  18. package/dest/factory.d.ts +23 -16
  19. package/dest/factory.d.ts.map +1 -1
  20. package/dest/factory.js +49 -68
  21. package/dest/index.d.ts +3 -1
  22. package/dest/index.d.ts.map +1 -1
  23. package/dest/index.js +2 -0
  24. package/dest/job/checkpoint-prover.d.ts +134 -0
  25. package/dest/job/checkpoint-prover.d.ts.map +1 -0
  26. package/dest/job/checkpoint-prover.js +353 -0
  27. package/dest/job/epoch-proving-job-data.d.ts +8 -6
  28. package/dest/job/epoch-proving-job-data.d.ts.map +1 -1
  29. package/dest/job/epoch-proving-job-data.js +25 -18
  30. package/dest/job/epoch-session.d.ts +146 -0
  31. package/dest/job/epoch-session.d.ts.map +1 -0
  32. package/dest/job/epoch-session.js +720 -0
  33. package/dest/job/top-tree-job.d.ts +82 -0
  34. package/dest/job/top-tree-job.d.ts.map +1 -0
  35. package/dest/job/top-tree-job.js +152 -0
  36. package/dest/metrics.d.ts +42 -4
  37. package/dest/metrics.d.ts.map +1 -1
  38. package/dest/metrics.js +128 -99
  39. package/dest/monitors/epoch-monitor.d.ts +3 -2
  40. package/dest/monitors/epoch-monitor.d.ts.map +1 -1
  41. package/dest/monitors/epoch-monitor.js +14 -20
  42. package/dest/monitors/index.d.ts +1 -1
  43. package/dest/proof-publishing-service.d.ts +161 -0
  44. package/dest/proof-publishing-service.d.ts.map +1 -0
  45. package/dest/proof-publishing-service.js +335 -0
  46. package/dest/prover-node-publisher.d.ts +36 -23
  47. package/dest/prover-node-publisher.d.ts.map +1 -1
  48. package/dest/prover-node-publisher.js +244 -98
  49. package/dest/prover-node.d.ts +122 -71
  50. package/dest/prover-node.d.ts.map +1 -1
  51. package/dest/prover-node.js +872 -226
  52. package/dest/prover-publisher-factory.d.ts +12 -6
  53. package/dest/prover-publisher-factory.d.ts.map +1 -1
  54. package/dest/prover-publisher-factory.js +8 -5
  55. package/dest/session-manager.d.ts +158 -0
  56. package/dest/session-manager.d.ts.map +1 -0
  57. package/dest/session-manager.js +452 -0
  58. package/dest/test/index.d.ts +7 -6
  59. package/dest/test/index.d.ts.map +1 -1
  60. package/package.json +28 -25
  61. package/src/actions/download-epoch-proving-job.ts +1 -1
  62. package/src/actions/rerun-epoch-proving-job.ts +120 -30
  63. package/src/actions/upload-epoch-proof-failure.ts +1 -1
  64. package/src/bin/run-failed-epoch.ts +5 -3
  65. package/src/checkpoint-store.ts +218 -0
  66. package/src/config.ts +39 -34
  67. package/src/factory.ts +86 -111
  68. package/src/index.ts +2 -0
  69. package/src/job/checkpoint-prover.ts +468 -0
  70. package/src/job/epoch-proving-job-data.ts +31 -25
  71. package/src/job/epoch-session.ts +436 -0
  72. package/src/job/top-tree-job.ts +227 -0
  73. package/src/metrics.ts +146 -84
  74. package/src/monitors/epoch-monitor.ts +10 -17
  75. package/src/proof-publishing-service.ts +427 -0
  76. package/src/prover-node-publisher.ts +300 -129
  77. package/src/prover-node.ts +569 -254
  78. package/src/prover-publisher-factory.ts +22 -11
  79. package/src/session-manager.ts +552 -0
  80. package/src/test/index.ts +6 -6
  81. package/dest/job/epoch-proving-job.d.ts +0 -69
  82. package/dest/job/epoch-proving-job.d.ts.map +0 -1
  83. package/dest/job/epoch-proving-job.js +0 -354
  84. package/src/job/epoch-proving-job.ts +0 -420
@@ -1,87 +1,136 @@
1
1
  import type { Archiver } from '@aztec/archiver';
2
- import type { RollupContract } from '@aztec/ethereum';
3
- import { assertRequired, compact, pick, sum } from '@aztec/foundation/collection';
2
+ import type { RollupContract } from '@aztec/ethereum/contracts';
3
+ import type { Delayer } from '@aztec/ethereum/l1-tx-utils';
4
+ import { BlockNumber, CheckpointNumber, EpochNumber } from '@aztec/foundation/branded-types';
5
+ import { assertRequired, compact, pick } from '@aztec/foundation/collection';
4
6
  import { memoize } from '@aztec/foundation/decorators';
5
- import type { Fr } from '@aztec/foundation/fields';
6
7
  import { createLogger } from '@aztec/foundation/log';
7
- import { DateProvider } from '@aztec/foundation/timer';
8
- import type { DataStoreConfig } from '@aztec/kv-store/config';
9
- import type { P2PClient } from '@aztec/p2p';
10
- import { PublicProcessorFactory } from '@aztec/simulator/server';
11
- import type { L2Block, L2BlockSource } from '@aztec/stdlib/block';
8
+ import { RunningPromise } from '@aztec/foundation/running-promise';
9
+ import { DateProvider, executeTimeout } from '@aztec/foundation/timer';
10
+ import type { EpochProverFactory } from '@aztec/prover-client';
11
+ import { getLastSiblingPath } from '@aztec/prover-client/helpers';
12
+ import { ChonkCache } from '@aztec/prover-client/orchestrator';
13
+ import { type AvmSimulator, PublicProcessorFactory } from '@aztec/simulator/server';
14
+ import {
15
+ EventDrivenL2BlockStream,
16
+ type L2BlockId,
17
+ type L2BlockSource,
18
+ type L2BlockStreamEvent,
19
+ type L2BlockStreamEventHandler,
20
+ L2TipsMemoryStore,
21
+ } from '@aztec/stdlib/block';
22
+ import type { Checkpoint, PublishedCheckpoint } from '@aztec/stdlib/checkpoint';
12
23
  import type { ChainConfig } from '@aztec/stdlib/config';
13
24
  import type { ContractDataSource } from '@aztec/stdlib/contract';
14
- import { getProofSubmissionDeadlineTimestamp } from '@aztec/stdlib/epoch-helpers';
25
+ import { type L1RollupConstants, getEpochAtSlot, getProofSubmissionDeadlineEpoch } from '@aztec/stdlib/epoch-helpers';
15
26
  import {
16
27
  type EpochProverManager,
28
+ type EpochProvingJobState,
17
29
  EpochProvingJobTerminalState,
30
+ type ITxProvider,
18
31
  type ProverNodeApi,
19
32
  type Service,
20
- type WorldStateSyncStatus,
21
33
  type WorldStateSynchronizer,
22
34
  tryStop,
23
35
  } from '@aztec/stdlib/interfaces/server';
36
+ import type { DataStoreConfig } from '@aztec/stdlib/kv-store';
24
37
  import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
25
- import type { P2PClientType } from '@aztec/stdlib/p2p';
26
- import type { Tx } from '@aztec/stdlib/tx';
38
+ import { MerkleTreeId } from '@aztec/stdlib/trees';
27
39
  import {
28
- Attributes,
29
40
  L1Metrics,
30
41
  type TelemetryClient,
31
42
  type Traceable,
32
43
  type Tracer,
33
44
  getTelemetryClient,
34
- trackSpan,
35
45
  } from '@aztec/telemetry-client';
36
46
 
37
47
  import { uploadEpochProofFailure } from './actions/upload-epoch-proof-failure.js';
48
+ import { CheckpointStore, type RegisterCheckpointData } from './checkpoint-store.js';
38
49
  import type { SpecificProverNodeConfig } from './config.js';
39
- import type { EpochProvingJobData } from './job/epoch-proving-job-data.js';
40
- import { EpochProvingJob, type EpochProvingJobState } from './job/epoch-proving-job.js';
50
+ import type { EpochSession, EpochSessionHooks } from './job/epoch-session.js';
41
51
  import { ProverNodeJobMetrics, ProverNodeRewardsMetrics } from './metrics.js';
42
- import type { EpochMonitor, EpochMonitorHandler } from './monitors/epoch-monitor.js';
43
- import type { ProverNodePublisher } from './prover-node-publisher.js';
52
+ import { ProofPublishingService } from './proof-publishing-service.js';
44
53
  import type { ProverPublisherFactory } from './prover-publisher-factory.js';
54
+ import { SessionManager } from './session-manager.js';
45
55
 
46
56
  type ProverNodeOptions = SpecificProverNodeConfig & Partial<DataStoreOptions>;
47
57
  type DataStoreOptions = Pick<DataStoreConfig, 'dataDirectory'> & Pick<ChainConfig, 'l1ChainId' | 'rollupVersion'>;
48
58
 
49
59
  /**
50
- * An Aztec Prover Node is a standalone process that monitors the unfinalized chain on L1 for unproven epochs,
51
- * fetches their txs from the p2p network or external nodes, re-executes their public functions, creates a rollup
52
- * proof for the epoch, and submits it to L1.
60
+ * Grace period for the proof-publishing service to settle during shutdown. The service waits for
61
+ * any in-flight L1 proof-submission tx to finish; that tx can take a long time to mine, so we cap
62
+ * the wait rather than letting `stop()` hang indefinitely.
53
63
  */
54
- export class ProverNode implements EpochMonitorHandler, ProverNodeApi, Traceable {
64
+ const PUBLISHING_SERVICE_STOP_TIMEOUT_MS = 30_000;
65
+
66
+ /**
67
+ * An Aztec Prover Node is a standalone process that monitors the chain for new checkpoints,
68
+ * starts proving them optimistically as they arrive, and submits epoch proofs to L1 once
69
+ * complete.
70
+ *
71
+ * The class is intentionally thin: it owns the long-lived collections (`CheckpointStore`,
72
+ * `ChonkCache`, `SessionManager`), the L2BlockStream, and a periodic ticker that nudges the
73
+ * manager to pick up newly-complete epochs. Every session lifecycle decision is delegated to
74
+ * the `SessionManager`. Each chain event is translated here into a single method call on it.
75
+ */
76
+ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Traceable {
55
77
  private log = createLogger('prover-node');
56
- private dateProvider = new DateProvider();
57
78
 
58
- private jobs: Map<string, EpochProvingJob> = new Map();
59
- private config: ProverNodeOptions;
60
- private jobMetrics: ProverNodeJobMetrics;
61
- private rewardsMetrics: ProverNodeRewardsMetrics;
79
+ protected readonly checkpointStore: CheckpointStore;
80
+ protected readonly chonkCache: ChonkCache;
81
+ protected sessionManager: SessionManager | undefined;
82
+
83
+ private readonly config: ProverNodeOptions;
84
+ private readonly jobMetrics: ProverNodeJobMetrics;
85
+ private readonly rewardsMetrics: ProverNodeRewardsMetrics;
86
+
87
+ /** In-memory store for the L2BlockStream's local data provider. */
88
+ private tipsStore: L2TipsMemoryStore;
89
+ /** Block stream for checkpoint and reorg detection. */
90
+ private blockStream: EventDrivenL2BlockStream | undefined;
91
+ /**
92
+ * Highest epoch whose proof-submission window has passed. Monotonic high-water mark.
93
+ * Seeded from the last fully-proven epoch at start(); advanced on every block-stream
94
+ * event by comparing the archiver's latest synced L2 slot against each epoch's
95
+ * submission deadline. Protected so tests can verify the start() seeding.
96
+ */
97
+ protected lastExpiredEpoch: EpochNumber | undefined;
98
+
99
+ /**
100
+ * Highest checkpoint number whose proving-side handling has completed (or that was legitimately skipped).
101
+ * The catch-up loop walks from here to each `chain-checkpointed` tip event. Seeded at start() from the last
102
+ * checkpoint of the last fully-proven epoch (or 0), so a restart reprocesses the partially-proven epoch rather
103
+ * than trusting a checkpointed tip that may sit ahead of unproven checkpoints. Clamped down on a prune.
104
+ */
105
+ protected lastProcessedCheckpoint: CheckpointNumber = CheckpointNumber.ZERO;
106
+
107
+ /** Periodic tick that runs the epoch-expiry sweep during idle periods when no block-stream events arrive. */
108
+ private expiryTicker: RunningPromise | undefined;
62
109
 
63
110
  public readonly tracer: Tracer;
64
111
 
65
- protected publisher: ProverNodePublisher | undefined;
112
+ protected publishingService: ProofPublishingService | undefined;
66
113
 
67
114
  constructor(
68
- protected readonly prover: EpochProverManager,
115
+ protected readonly prover: EpochProverManager & EpochProverFactory,
69
116
  protected readonly publisherFactory: ProverPublisherFactory,
70
117
  protected readonly l2BlockSource: L2BlockSource & Partial<Service>,
71
118
  protected readonly l1ToL2MessageSource: L1ToL2MessageSource,
72
119
  protected readonly contractDataSource: ContractDataSource,
73
120
  protected readonly worldState: WorldStateSynchronizer,
74
- protected readonly p2pClient: Pick<P2PClient<P2PClientType.Prover>, 'getTxProvider'> & Partial<Service>,
75
- protected readonly epochsMonitor: EpochMonitor,
121
+ protected readonly p2pClient: { getTxProvider(): ITxProvider } & Partial<Service>,
76
122
  protected readonly rollupContract: RollupContract,
77
123
  protected readonly l1Metrics: L1Metrics,
124
+ private readonly avmSimulator: AvmSimulator,
78
125
  config: Partial<ProverNodeOptions> = {},
79
126
  protected readonly telemetryClient: TelemetryClient = getTelemetryClient(),
127
+ private delayer?: Delayer,
128
+ private readonly dateProvider: DateProvider = new DateProvider(),
80
129
  ) {
81
130
  this.config = {
82
131
  proverNodePollingIntervalMs: 1_000,
83
132
  proverNodeMaxPendingJobs: 100,
84
- proverNodeMaxParallelBlocksPerEpoch: 32,
133
+ proverNodeMaxParallelBlocksPerEpoch: 0,
85
134
  txGatheringIntervalMs: 1_000,
86
135
  txGatheringBatchSize: 10,
87
136
  txGatheringMaxParallelRequestsPerNode: 100,
@@ -97,8 +146,34 @@ export class ProverNode implements EpochMonitorHandler, ProverNodeApi, Traceable
97
146
  this.tracer = telemetryClient.getTracer('ProverNode');
98
147
 
99
148
  this.jobMetrics = new ProverNodeJobMetrics(meter, telemetryClient.getTracer('EpochProvingJob'));
100
-
101
149
  this.rewardsMetrics = new ProverNodeRewardsMetrics(meter, this.prover.getProverId(), rollupContract);
150
+
151
+ this.tipsStore = new L2TipsMemoryStore(this.l2BlockSource.getGenesisBlockHash());
152
+
153
+ this.chonkCache = new ChonkCache(this.log.getBindings());
154
+ this.checkpointStore = new CheckpointStore(
155
+ this.l2BlockSource,
156
+ {
157
+ proverFactory: this.prover,
158
+ chonkCache: this.chonkCache,
159
+ publicProcessorFactory: new PublicProcessorFactory(
160
+ this.contractDataSource,
161
+ this.avmSimulator,
162
+ this.dateProvider,
163
+ this.telemetryClient,
164
+ this.log.getBindings(),
165
+ ),
166
+ dbProvider: this.worldState,
167
+ txProvider: this.p2pClient.getTxProvider(),
168
+ dateProvider: this.dateProvider,
169
+ proverId: this.prover.getProverId(),
170
+ metrics: this.jobMetrics,
171
+ txGatheringTimeoutMs: this.config.txGatheringTimeoutMs,
172
+ deadline: undefined,
173
+ },
174
+ { slotWatcherPollIntervalMs: this.config.proverNodePollingIntervalMs },
175
+ this.log.getBindings(),
176
+ );
102
177
  }
103
178
 
104
179
  public getProverId() {
@@ -109,289 +184,533 @@ export class ProverNode implements EpochMonitorHandler, ProverNodeApi, Traceable
109
184
  return this.p2pClient;
110
185
  }
111
186
 
112
- /**
113
- * Handles an epoch being completed by starting a proof for it if there are no active jobs for it.
114
- * @param epochNumber - The epoch number that was just completed.
115
- * @returns false if there is an error, true otherwise
116
- */
117
- async handleEpochReadyToProve(epochNumber: bigint): Promise<boolean> {
118
- try {
119
- this.log.debug(`Running jobs as ${epochNumber} is ready to prove`, {
120
- jobs: Array.from(this.jobs.values()).map(job => `${job.getEpochNumber()}:${job.getId()}`),
121
- });
122
- const activeJobs = await this.getActiveJobsForEpoch(epochNumber);
123
- if (activeJobs.length > 0) {
124
- this.log.warn(`Not starting proof for ${epochNumber} since there are active jobs for the epoch`, {
125
- activeJobs: activeJobs.map(job => job.uuid),
126
- });
127
- return true;
128
- }
129
- await this.startProof(epochNumber);
130
- return true;
131
- } catch (err) {
132
- if (err instanceof EmptyEpochError) {
133
- this.log.info(`Not starting proof for ${epochNumber} since no blocks were found`);
134
- } else {
135
- this.log.error(`Error handling epoch completed`, err);
136
- }
137
- return false;
138
- }
187
+ /** Test-only: the shared L1 tx delayer, if enabled. */
188
+ public getDelayer(): Delayer | undefined {
189
+ return this.delayer;
139
190
  }
140
191
 
141
- /**
142
- * Starts the prover node so it periodically checks for unproven epochs in the unfinalized chain from L1 and
143
- * starts proving jobs for them.
144
- */
145
- async start() {
146
- this.epochsMonitor.start(this);
147
- await this.publisherFactory.start();
148
- this.publisher = await this.publisherFactory.create();
149
- await this.rewardsMetrics.start();
150
- this.l1Metrics.start();
151
- this.log.info(`Started Prover Node with prover id ${this.prover.getProverId().toString()}`, this.config);
192
+ /** Observability summary for the ProverNodeApi. */
193
+ public getJobs(): Promise<{ uuid: string; status: EpochProvingJobState; epochNumber: EpochNumber }[]> {
194
+ return Promise.resolve(this.sessionManager?.getJobs() ?? []);
152
195
  }
153
196
 
154
- /**
155
- * Stops the prover node and all its dependencies.
156
- */
157
- async stop() {
158
- this.log.info('Stopping ProverNode');
159
- await this.epochsMonitor.stop();
160
- await this.prover.stop();
161
- await tryStop(this.p2pClient);
162
- await tryStop(this.l2BlockSource);
163
- await tryStop(this.publisherFactory);
164
- this.publisher?.interrupt();
165
- await Promise.all(Array.from(this.jobs.values()).map(job => job.stop()));
166
- await this.worldState.stop();
167
- this.rewardsMetrics.stop();
168
- this.l1Metrics.stop();
169
- await this.telemetryClient.stop();
170
- this.log.info('Stopped ProverNode');
197
+ /** Tests inspect this when validating reconcile behaviour. */
198
+ public getCheckpointStore(): CheckpointStore {
199
+ return this.checkpointStore;
171
200
  }
172
201
 
173
- /** Returns world state status. */
174
- public async getWorldStateSyncStatus(): Promise<WorldStateSyncStatus> {
175
- const { syncSummary } = await this.worldState.status();
176
- return syncSummary;
202
+ /** Tests inspect this to verify chonk-cache release semantics. */
203
+ public getChonkCache(): ChonkCache {
204
+ return this.chonkCache;
177
205
  }
178
206
 
179
- /** Returns archiver status. */
180
- public getL2Tips() {
181
- return this.l2BlockSource.getL2Tips();
207
+ /** Tests inspect this when looking up live sessions. */
208
+ public getSessionManager(): SessionManager {
209
+ if (!this.sessionManager) {
210
+ throw new Error('SessionManager not yet constructed — start() must be called first.');
211
+ }
212
+ return this.sessionManager;
182
213
  }
183
214
 
184
- /**
185
- * Starts a proving process and returns immediately.
186
- */
187
- public async startProof(epochNumber: number | bigint) {
188
- const job = await this.createProvingJob(BigInt(epochNumber), { skipEpochCheck: true });
189
- void this.runJob(job);
215
+ /** Returns the underlying prover instance. */
216
+ public getProver() {
217
+ return this.prover;
190
218
  }
191
219
 
192
- private async runJob(job: EpochProvingJob) {
193
- const epochNumber = job.getEpochNumber();
194
- const ctx = { id: job.getId(), epochNumber, state: undefined as EpochProvingJobState | undefined };
220
+ // ---------------- L2BlockStream handler ----------------
221
+
222
+ public async handleBlockStreamEvent(event: L2BlockStreamEvent): Promise<void> {
223
+ switch (event.type) {
224
+ case 'chain-checkpointed':
225
+ await this.processCheckpointJump(event.checkpoint.number);
226
+ break;
227
+ case 'chain-pruned':
228
+ await this.handlePruneEvent(event.block);
229
+ break;
230
+ case 'chain-proven':
231
+ this.publishingService?.onChainProven(BlockNumber(event.block.number));
232
+ break;
233
+ // The proposed tip drives only the tips store's walk-back history (recorded below); the prover-node
234
+ // tracks checkpoints, not proposed blocks. `blocks-added` is never emitted in tips-only mode, and
235
+ // `chain-finalized` carries nothing the prover-node acts on.
236
+ case 'chain-proposed':
237
+ case 'chain-finalized':
238
+ case 'blocks-added':
239
+ break;
240
+ default: {
241
+ const _: never = event;
242
+ break;
243
+ }
244
+ }
245
+ // Expiry is driven by the archiver's latest synced L2 slot
246
+ await this.checkEpochExpiry();
247
+ // Advance the local tips store only after the proving-side handling has succeeded. Any
248
+ // failure above propagates to the L2BlockStream (which logs and stops this poll pass) and
249
+ // skips this update, so the event is re-emitted on the next poll rather than skipped (A-1041).
250
+ await this.tipsStore.handleBlockStreamEvent(event);
251
+ }
195
252
 
196
- try {
197
- await job.run();
198
- const state = job.getState();
199
- ctx.state = state;
200
-
201
- if (state === 'reorg') {
202
- this.log.warn(`Running new job for epoch ${epochNumber} due to reorg`, ctx);
203
- await this.createProvingJob(epochNumber);
204
- } else if (state === 'failed') {
205
- this.log.error(`Job for ${epochNumber} exited with state ${state}`, ctx);
206
- await this.tryUploadEpochFailure(job);
253
+ /**
254
+ * Walks every checkpoint between the local cursor and the newly-reported checkpointed tip, registering
255
+ * each one that belongs to an epoch that can still be proven. The block stream now delivers a single thin
256
+ * `chain-checkpointed` tip event per pass rather than one fat event per checkpoint, so this drives the
257
+ * catch-up itself: light metadata first (`getCheckpointsData`) to decide relevance per epoch, then a heavy
258
+ * `getCheckpoints` fetch only for checkpoints in provable epochs.
259
+ *
260
+ * The cursor advances one checkpoint at a time and only after that checkpoint's proving-side handling has
261
+ * fully succeeded, preserving the A-1041 at-least-once semantics: a mid-jump failure leaves the cursor
262
+ * behind so the next pass retries from the first checkpoint that did not complete.
263
+ */
264
+ private async processCheckpointJump(targetCheckpoint: CheckpointNumber): Promise<void> {
265
+ if (targetCheckpoint <= this.lastProcessedCheckpoint) {
266
+ return;
267
+ }
268
+ const l1Constants = await this.getL1Constants();
269
+
270
+ // Cap the catch-up at the `(proofSubmissionEpochs + 1) * epochDuration` most recent checkpoints.
271
+ // When the cursor is much further behind (e.g. resyncing after a long time offline), fetching the whole gap could
272
+ // load thousands of checkpoints we cannot act on: anything older than the last two epochs is already past
273
+ // its proof-submission window, so we skip it and jump the cursor forward to the start of the capped range.
274
+ const maxCheckpoints = (l1Constants.proofSubmissionEpochs + 1) * l1Constants.epochDuration;
275
+ let from = CheckpointNumber(this.lastProcessedCheckpoint + 1);
276
+ if (Number(targetCheckpoint - from) + 1 > maxCheckpoints) {
277
+ const cappedFrom = CheckpointNumber(targetCheckpoint - maxCheckpoints + 1);
278
+ this.log.warn(`Skipping unprovable checkpoints during catch-up; the prover node is far behind`, {
279
+ from,
280
+ cappedFrom,
281
+ targetCheckpoint,
282
+ maxCheckpoints,
283
+ });
284
+ // Advance the cursor past the skipped checkpoints so they are never retried.
285
+ this.lastProcessedCheckpoint = CheckpointNumber(cappedFrom - 1);
286
+ from = cappedFrom;
287
+ }
288
+ const limit = Number(targetCheckpoint - from) + 1;
289
+ const metadatas = await this.l2BlockSource.getCheckpointsData({ from, limit });
290
+
291
+ // Per-epoch relevance is cached so a multi-checkpoint epoch resolves it once. Skipping is whole-epoch
292
+ // only: the SessionManager requires an epoch's checkpoints fully covered before it opens a session, so we
293
+ // never drop an individual checkpoint inside an epoch we will prove.
294
+ const epochSkippable = new Map<EpochNumber, boolean>();
295
+ for (const metadata of metadatas) {
296
+ const epochNumber = getEpochAtSlot(metadata.header.slotNumber, l1Constants);
297
+ let skippable = epochSkippable.get(epochNumber);
298
+ if (skippable === undefined) {
299
+ skippable =
300
+ (await this.isEpochFullyProven(epochNumber, l1Constants)) ||
301
+ (await this.isEpochPastProofSubmissionWindow(epochNumber, l1Constants));
302
+ epochSkippable.set(epochNumber, skippable);
303
+ }
304
+ if (skippable) {
305
+ this.log.debug(`Skipping checkpoint ${metadata.checkpointNumber} for unprovable epoch ${epochNumber}`);
207
306
  } else {
208
- this.log.verbose(`Job for ${epochNumber} exited with state ${state}`, ctx);
307
+ await this.registerCheckpoint(metadata.checkpointNumber, epochNumber);
209
308
  }
210
- } catch (err) {
211
- this.log.error(`Error proving epoch ${epochNumber}`, err, ctx);
212
- } finally {
213
- this.jobs.delete(job.getId());
309
+ // Advance only after the checkpoint's handling succeeded (or it was legitimately skipped). registerCheckpoint
310
+ // throws on failure, which leaves the cursor here for the next pass to retry (A-1041).
311
+ this.lastProcessedCheckpoint = metadata.checkpointNumber;
214
312
  }
215
313
  }
216
314
 
217
- protected async tryUploadEpochFailure(job: EpochProvingJob) {
218
- if (this.config.proverNodeFailedEpochStore) {
219
- return await uploadEpochProofFailure(
220
- this.config.proverNodeFailedEpochStore,
221
- job.getId(),
222
- job.getProvingData(),
223
- this.l2BlockSource as Archiver,
224
- this.worldState,
225
- assertRequired(pick(this.config, 'l1ChainId', 'rollupVersion', 'dataDirectory')),
226
- this.log,
227
- );
315
+ /** Heavy-fetch a single checkpoint, register it with the store, and notify the session manager. */
316
+ private async registerCheckpoint(checkpointNumber: CheckpointNumber, epochNumber: EpochNumber): Promise<void> {
317
+ const published = await this.l2BlockSource.getCheckpoint({ number: checkpointNumber });
318
+ if (!published) {
319
+ throw new Error(`Checkpoint ${checkpointNumber} not found in block source during catch-up`);
228
320
  }
321
+ const checkpoint = published.checkpoint;
322
+ this.log.info(`New checkpoint ${checkpoint.number} for epoch ${epochNumber}`, {
323
+ checkpointNumber: checkpoint.number,
324
+ epochNumber,
325
+ slotNumber: checkpoint.header.slotNumber,
326
+ });
327
+
328
+ const registerData = await this.collectRegisterData(checkpoint, published.attestations);
329
+ await this.checkpointStore.addOrUpdate(checkpoint, registerData);
330
+ await this.sessionManager?.onCheckpointAdded(epochNumber);
331
+
332
+ // Tips-only mode delivers no blocks, so record one witness per checkpointed block: a reorg into the checkpoint's
333
+ // range then prunes at the true divergence instead of the nearest sparse tip anchor.
334
+ await this.tipsStore.recordBlockHashes(
335
+ await Promise.all(
336
+ checkpoint.blocks.map(async block => ({ number: block.number, hash: (await block.header.hash()).toString() })),
337
+ ),
338
+ );
229
339
  }
230
340
 
231
341
  /**
232
- * Returns the prover instance.
342
+ * Gathers register-time data for a checkpoint: previous block header, L1-to-L2 messages,
343
+ * and the archive sibling path.
233
344
  */
234
- public getProver() {
235
- return this.prover;
345
+ private async collectRegisterData(
346
+ checkpoint: Checkpoint,
347
+ attestations: PublishedCheckpoint['attestations'],
348
+ ): Promise<RegisterCheckpointData> {
349
+ const previousBlockNumber = BlockNumber(checkpoint.blocks[0].number - 1);
350
+ const previousBlockHeader = await this.gatherPreviousBlockHeader(previousBlockNumber);
351
+ const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpoint.number);
352
+ const lastBlock = checkpoint.blocks.at(-1)!;
353
+ const lastBlockHash = await lastBlock.header.hash();
354
+ await this.worldState.syncImmediate(lastBlock.number, lastBlockHash);
355
+ const previousArchiveSiblingPath = await getLastSiblingPath(
356
+ MerkleTreeId.ARCHIVE,
357
+ this.worldState.getSnapshot(previousBlockNumber),
358
+ );
359
+ return {
360
+ attestations,
361
+ previousBlockHeader,
362
+ l1ToL2Messages,
363
+ previousArchiveSiblingPath,
364
+ };
236
365
  }
237
366
 
238
367
  /**
239
- * Returns an array of jobs being processed.
368
+ * Marks every prover orphaned by the prune as pruned, clamps the catch-up cursor below the prune target's
369
+ * checkpoint, and notifies the session manager. Keyed off the prune target block (the highest surviving block)
370
+ * rather than the source's checkpointed tip, which can sit above the target after a re-checkpoint and would leave
371
+ * orphaned provers canonical. Throws (rather than warning) if the cursor floor cannot be resolved, so the pass
372
+ * fails and the prune is retried next iteration.
240
373
  */
241
- public getJobs(): Promise<{ uuid: string; status: EpochProvingJobState; epochNumber: number }[]> {
242
- return Promise.resolve(
243
- Array.from(this.jobs.entries()).map(([uuid, job]) => ({
244
- uuid,
245
- status: job.getState(),
246
- epochNumber: Number(job.getEpochNumber()),
247
- })),
248
- );
374
+ private async handlePruneEvent(prunedToBlock: L2BlockId) {
375
+ this.log.warn(`Chain pruned to block ${prunedToBlock.number}`, { prunedToBlock });
376
+
377
+ // Resolve the cursor floor BEFORE marking provers: markPrunedAboveBlock returns only newly-marked provers, so a
378
+ // throw after marking would leave a retry pass with nothing to act on. Resolving first means a throw leaves
379
+ // everything untouched and the next pass retries the whole handler (the tips cursor only advances on success).
380
+ let cursorFloor: CheckpointNumber;
381
+ if (prunedToBlock.number === 0) {
382
+ cursorFloor = CheckpointNumber.ZERO;
383
+ } else {
384
+ const targetData = await this.l2BlockSource.getBlockData({ number: prunedToBlock.number });
385
+ if (targetData === undefined) {
386
+ throw new Error(
387
+ `No block data found for prune target block ${prunedToBlock.number}; cannot clamp checkpoint cursor`,
388
+ );
389
+ }
390
+ // Clamp to `cpAtTarget - 1`: a mid-checkpoint target leaves that checkpoint partially orphaned and it must be
391
+ // reprocessed. Over-clamping merely re-registers a checkpoint (at-least-once by design — A-1041); under-clamping
392
+ // would permanently skip a rebuilt same-number checkpoint.
393
+ cursorFloor = CheckpointNumber(Math.max(0, Number(targetData.checkpointNumber) - 1));
394
+ }
395
+
396
+ const affected = this.checkpointStore.markPrunedAboveBlock(prunedToBlock.number);
397
+
398
+ if (this.lastProcessedCheckpoint > cursorFloor) {
399
+ this.lastProcessedCheckpoint = cursorFloor;
400
+ }
401
+
402
+ if (affected.length === 0) {
403
+ return;
404
+ }
405
+ const l1Constants = await this.getL1Constants();
406
+ const affectedEpochs = Array.from(
407
+ new Set(affected.map(p => Number(getEpochAtSlot(p.slotNumber, l1Constants)))),
408
+ ).map(n => EpochNumber(n));
409
+ // The session manager cancels every affected session, which in turn calls
410
+ // publishingService.withdraw(uuid) for each candidate; no separate notification to the
411
+ // publishing service is needed.
412
+ await this.sessionManager?.onPrune(affectedEpochs);
249
413
  }
250
414
 
251
- protected async getActiveJobsForEpoch(
252
- epochBigInt: bigint,
253
- ): Promise<{ uuid: string; status: EpochProvingJobState }[]> {
254
- const jobs = await this.getJobs();
255
- const epochNumber = Number(epochBigInt);
256
- return jobs.filter(job => job.epochNumber === epochNumber && !EpochProvingJobTerminalState.includes(job.status));
415
+ /**
416
+ * Returns true once the chain has advanced past the given epoch's proof-submission window.
417
+ * Used to ignore checkpoints whose epoch can no longer be proven in time — chiefly while the
418
+ * archiver replays old blocks after a restart. Compares the archiver's latest synced L2 slot
419
+ * against the epoch's submission-deadline epoch; conservatively returns false if the slot can't
420
+ * be read yet.
421
+ */
422
+ private async isEpochPastProofSubmissionWindow(
423
+ epochNumber: EpochNumber,
424
+ l1Constants: L1RollupConstants,
425
+ ): Promise<boolean> {
426
+ const latestSlot = await this.l2BlockSource.getSyncedL2SlotNumber();
427
+ if (latestSlot === undefined) {
428
+ return false;
429
+ }
430
+ const latestEpoch = getEpochAtSlot(latestSlot, l1Constants);
431
+ return latestEpoch >= getProofSubmissionDeadlineEpoch(epochNumber, l1Constants);
257
432
  }
258
433
 
259
- private checkMaximumPendingJobs() {
260
- const { proverNodeMaxPendingJobs: maxPendingJobs } = this.config;
261
- if (maxPendingJobs > 0 && this.jobs.size >= maxPendingJobs) {
262
- throw new Error(`Maximum pending proving jobs ${maxPendingJobs} reached. Cannot create new job.`);
434
+ /**
435
+ * Compares the archiver's latest synced L2 slot against `lastExpiredEpoch` and, for each
436
+ * newly-expired epoch, releases the chonk-cache entries for its blocks and reaps any
437
+ * CheckpointProvers in the store. An epoch E is expired once the chain reaches the start
438
+ * of epoch `E + proofSubmissionEpochs + 1`. Silently no-ops if nothing has expired since
439
+ * the last check or the archiver's slot can't be read.
440
+ */
441
+ private async checkEpochExpiry(): Promise<void> {
442
+ const latestSlot = await this.l2BlockSource.getSyncedL2SlotNumber();
443
+ if (latestSlot === undefined) {
444
+ return;
263
445
  }
446
+ const l1Constants = await this.getL1Constants();
447
+ const latestEpoch = getEpochAtSlot(latestSlot, l1Constants);
448
+ const offset = l1Constants.proofSubmissionEpochs + 1;
449
+ if (latestEpoch < offset) {
450
+ return;
451
+ }
452
+ const newlyExpiredUpTo = EpochNumber(latestEpoch - offset);
453
+ const from = this.lastExpiredEpoch === undefined ? EpochNumber(0) : EpochNumber(this.lastExpiredEpoch + 1);
454
+ if (newlyExpiredUpTo < from) {
455
+ return;
456
+ }
457
+ for (let e = from; e <= newlyExpiredUpTo; e = EpochNumber(e + 1)) {
458
+ await this.expireEpoch(e);
459
+ }
460
+ this.lastExpiredEpoch = newlyExpiredUpTo;
264
461
  }
265
462
 
266
- @trackSpan('ProverNode.createProvingJob', epochNumber => ({ [Attributes.EPOCH_NUMBER]: Number(epochNumber) }))
267
- private async createProvingJob(epochNumber: bigint, opts: { skipEpochCheck?: boolean } = {}) {
268
- this.checkMaximumPendingJobs();
463
+ /**
464
+ * Releases chonk-cache entries for every block in the supplied epoch (best-effort) and
465
+ * reaps every CheckpointProver in the store whose epoch number matches.
466
+ */
467
+ private async expireEpoch(epoch: EpochNumber): Promise<void> {
468
+ try {
469
+ const blocks = await this.l2BlockSource.getBlocks({ epoch, onlyCheckpointed: true });
470
+ if (blocks.length > 0) {
471
+ this.chonkCache.releaseForBlocks(blocks);
472
+ }
473
+ } catch (err) {
474
+ this.log.warn(`Could not release chonk-cache entries for expired epoch ${epoch}`, err);
475
+ }
476
+ this.checkpointStore.reapExpired(epoch);
477
+ }
269
478
 
270
- this.publisher = await this.publisherFactory.create();
479
+ // ---------------- public API ----------------
271
480
 
272
- // Gather all data for this epoch
273
- const epochData = await this.gatherEpochData(epochNumber);
481
+ /**
482
+ * Schedules proving for the given epoch and returns the job id without waiting for completion.
483
+ */
484
+ public async startProof(epochNumber: EpochNumber): Promise<string> {
485
+ if (!this.sessionManager) {
486
+ throw new Error('ProverNode not started');
487
+ }
488
+ return await this.sessionManager.startProof(epochNumber);
489
+ }
274
490
 
275
- const fromBlock = epochData.blocks[0].number;
276
- const toBlock = epochData.blocks.at(-1)!.number;
277
- this.log.verbose(`Creating proving job for epoch ${epochNumber} for block range ${fromBlock} to ${toBlock}`);
491
+ // ---------------- Service lifecycle ----------------
278
492
 
279
- // Fast forward world state to right before the target block and get a fork
280
- await this.worldState.syncImmediate(toBlock);
493
+ async start() {
494
+ await this.checkpointStore.start();
281
495
 
282
- // Create a processor factory
283
- const publicProcessorFactory = new PublicProcessorFactory(
284
- this.contractDataSource,
285
- this.dateProvider,
286
- this.telemetryClient,
496
+ await this.publisherFactory.start();
497
+ this.publishingService = new ProofPublishingService({
498
+ publisherFactory: this.publisherFactory,
499
+ l2BlockSource: this.l2BlockSource,
500
+ dateProvider: this.dateProvider,
501
+ config: { skipSubmitProof: !!this.config.proverNodeDisableProofPublish },
502
+ bindings: this.log.getBindings(),
503
+ });
504
+ this.sessionManager = this.createSessionManager(this.publishingService);
505
+ // SessionManager owns its own periodic tick; start it here so it begins picking up
506
+ // epochs that become complete by time (no fresh checkpoint event) and advances once
507
+ // the previous epoch is proven on L1.
508
+ this.sessionManager.start();
509
+ // Now that the store + manager exist, arm the live-state observable gauges.
510
+ this.jobMetrics.observeState(this.checkpointStore, this.sessionManager);
511
+
512
+ const { lastFullyProvenEpoch } = await this.resolveLastFullyProvenEpoch();
513
+ this.lastExpiredEpoch = lastFullyProvenEpoch;
514
+ this.lastProcessedCheckpoint = await this.computeStartingCheckpoint(lastFullyProvenEpoch);
515
+ this.blockStream = new EventDrivenL2BlockStream(this.l2BlockSource, this.tipsStore, this, this.log, {
516
+ pollIntervalMS: this.config.proverNodePollingIntervalMs,
517
+ tipsOnly: true,
518
+ });
519
+ this.blockStream.start();
520
+
521
+ // With thin once-per-pass tip events, the expiry sweep no longer fires once per checkpoint; drive it
522
+ // from a periodic tick so epochs still expire during idle/no-event periods.
523
+ this.expiryTicker = new RunningPromise(
524
+ () => this.checkEpochExpiry(),
525
+ this.log,
526
+ this.config.proverNodePollingIntervalMs,
287
527
  );
528
+ this.expiryTicker.start();
288
529
 
289
- // Set deadline for this job to run. It will abort if it takes too long.
290
- const deadlineTs = getProofSubmissionDeadlineTimestamp(epochNumber, await this.getL1Constants());
291
- const deadline = new Date(Number(deadlineTs) * 1000);
292
-
293
- const job = this.doCreateEpochProvingJob(epochData, deadline, publicProcessorFactory, this.publisher, opts);
294
- this.jobs.set(job.getId(), job);
295
- return job;
530
+ await this.rewardsMetrics.start();
531
+ this.l1Metrics.start();
532
+ this.log.info(`Started Prover Node with prover id ${this.prover.getProverId().toString()}`, this.config);
296
533
  }
297
534
 
298
- @memoize
299
- private getL1Constants() {
300
- return this.l2BlockSource.getL1Constants();
535
+ async stop() {
536
+ this.log.info('Stopping ProverNode');
537
+ this.jobMetrics.stopObservingState();
538
+ await this.blockStream?.stop();
539
+ await this.expiryTicker?.stop();
540
+ if (this.sessionManager) {
541
+ await this.sessionManager.stop();
542
+ }
543
+ if (this.publishingService) {
544
+ // Bound the wait: the publishing service blocks until any in-flight L1 proof-submission tx
545
+ // settles, which can outlast a reasonable shutdown window. On timeout we log and move on —
546
+ // the tx may still mine, but shutdown must not hang on it.
547
+ const publishingService = this.publishingService;
548
+ await executeTimeout(
549
+ () => publishingService.stop(),
550
+ PUBLISHING_SERVICE_STOP_TIMEOUT_MS,
551
+ 'prover-node publishing-service stop',
552
+ ).catch(err => this.log.warn(`Timed out stopping proof publishing service`, err));
553
+ }
554
+ await this.checkpointStore.stop();
555
+ this.chonkCache.stop();
556
+ await this.prover.stop();
557
+ await tryStop(this.publisherFactory);
558
+ this.rewardsMetrics.stop();
559
+ this.l1Metrics.stop();
560
+ await this.telemetryClient.stop();
561
+ this.log.info('Stopped ProverNode');
301
562
  }
302
563
 
303
- @trackSpan('ProverNode.gatherEpochData', epochNumber => ({ [Attributes.EPOCH_NUMBER]: Number(epochNumber) }))
304
- private async gatherEpochData(epochNumber: bigint): Promise<EpochProvingJobData> {
305
- const blocks = await this.gatherBlocks(epochNumber);
306
- const txArray = await this.gatherTxs(epochNumber, blocks);
307
- const txs = new Map<string, Tx>(txArray.map(tx => [tx.getTxHash().toString(), tx]));
308
- const l1ToL2Messages = await this.gatherMessages(epochNumber, blocks);
309
- const previousBlockHeader = await this.gatherPreviousBlockHeader(epochNumber, blocks[0]);
310
- const [lastBlock] = await this.l2BlockSource.getPublishedBlocks(blocks.at(-1)!.number, 1);
311
- const attestations = lastBlock?.attestations ?? [];
312
-
313
- return { blocks, txs, l1ToL2Messages, epochNumber, previousBlockHeader, attestations };
564
+ /**
565
+ * Constructs the session manager. Extracted so subclasses (test harness) can swap
566
+ * the implementation. Wired to `tryUploadSessionFailure` so failed sessions get
567
+ * their proving data uploaded.
568
+ */
569
+ protected createSessionManager(publishingService: ProofPublishingService): SessionManager {
570
+ return new SessionManager({
571
+ checkpointStore: this.checkpointStore,
572
+ l2BlockSource: this.l2BlockSource,
573
+ proverFactory: this.prover,
574
+ proverId: this.prover.getProverId(),
575
+ publishingService,
576
+ metrics: this.jobMetrics,
577
+ dateProvider: this.dateProvider,
578
+ config: {
579
+ maxPendingJobs: this.config.proverNodeMaxPendingJobs,
580
+ tickIntervalMs: this.config.proverNodePollingIntervalMs,
581
+ finalizationDelayMs: this.config.proverNodeEpochProvingDelayMs,
582
+ },
583
+ onSessionFailed: async session => {
584
+ await this.tryUploadSessionFailure(session);
585
+ },
586
+ bindings: this.log.getBindings(),
587
+ });
314
588
  }
315
589
 
316
- private async gatherBlocks(epochNumber: bigint) {
317
- const blocks = await this.l2BlockSource.getBlocksForEpoch(epochNumber);
318
- if (blocks.length === 0) {
319
- throw new EmptyEpochError(epochNumber);
590
+ /**
591
+ * Installs session hooks for the e2e harness to interpose around top-tree proving
592
+ * (gate, override, or observe it) without monkey-patching the orchestrator factory.
593
+ * Applies to every session constructed after this call.
594
+ */
595
+ public setSessionHooks(hooks: EpochSessionHooks): void {
596
+ if (!this.sessionManager) {
597
+ throw new Error('ProverNode not started; call start() before setting session hooks.');
320
598
  }
321
- return blocks;
599
+ this.sessionManager.setSessionHooks(hooks);
322
600
  }
323
601
 
324
- private async gatherTxs(epochNumber: bigint, blocks: L2Block[]) {
325
- const deadline = new Date(this.dateProvider.now() + this.config.txGatheringTimeoutMs);
326
- const txProvider = this.p2pClient.getTxProvider();
327
- const txsByBlock = await Promise.all(blocks.map(block => txProvider.getTxsForBlock(block, { deadline })));
328
- const txs = txsByBlock.map(({ txs }) => txs).flat();
329
- const missingTxs = txsByBlock.map(({ missingTxs }) => missingTxs).flat();
330
-
331
- if (missingTxs.length === 0) {
332
- this.log.verbose(`Gathered all ${txs.length} txs for epoch ${epochNumber}`, { epochNumber });
333
- return txs;
602
+ /** Uploads failure snapshots when sessions exit with `failed`. Exposed as a method so tests can spy on it. */
603
+ public async tryUploadSessionFailure(session: EpochSession): Promise<string | undefined> {
604
+ if (!this.config.proverNodeFailedEpochStore) {
605
+ return undefined;
334
606
  }
607
+ const data = SessionManager.buildSessionProvingData(session);
608
+ return await uploadEpochProofFailure(
609
+ this.config.proverNodeFailedEpochStore,
610
+ session.getId(),
611
+ data,
612
+ this.l2BlockSource as Archiver,
613
+ this.worldState,
614
+ assertRequired(pick(this.config, 'l1ChainId', 'rollupVersion', 'dataDirectory')),
615
+ this.log,
616
+ );
617
+ }
335
618
 
336
- throw new Error(`Txs not found for epoch ${epochNumber}: ${missingTxs.map(hash => hash.toString()).join(', ')}`);
619
+ // ---------------- helpers ----------------
620
+
621
+ @memoize
622
+ private getL1Constants(): Promise<L1RollupConstants> {
623
+ return this.l2BlockSource.getL1Constants();
337
624
  }
338
625
 
339
- private async gatherMessages(epochNumber: bigint, blocks: L2Block[]) {
340
- const messages = await Promise.all(blocks.map(b => this.l1ToL2MessageSource.getL1ToL2Messages(b.number)));
341
- const messageCount = sum(messages.map(m => m.length));
342
- this.log.verbose(`Gathered all ${messageCount} messages for epoch ${epochNumber}`, { epochNumber });
343
- const messagesByBlock: Record<number, Fr[]> = {};
344
- for (let i = 0; i < blocks.length; i++) {
345
- messagesByBlock[blocks[i].number] = messages[i];
626
+ /**
627
+ * Returns true if every block in the given epoch is proven on L1. An epoch is only
628
+ * fully proven when its *last* block is proven. Protected for direct unit-test access.
629
+ */
630
+ protected async isEpochFullyProven(
631
+ epochNumber: EpochNumber,
632
+ l1Constants: Pick<L1RollupConstants, 'epochDuration'>,
633
+ ): Promise<boolean> {
634
+ const provenBlockNumber = await this.l2BlockSource.getBlockNumber({ tag: 'proven' });
635
+ if (!provenBlockNumber || provenBlockNumber <= 0) {
636
+ return false;
637
+ }
638
+ const provenHeader = (await this.l2BlockSource.getBlockData({ number: BlockNumber(provenBlockNumber) }))?.header;
639
+ if (!provenHeader) {
640
+ return false;
641
+ }
642
+ const provenEpoch = getEpochAtSlot(provenHeader.getSlot(), l1Constants);
643
+ if (epochNumber < provenEpoch) {
644
+ return true;
645
+ }
646
+ if (epochNumber > provenEpoch) {
647
+ return false;
346
648
  }
347
- return messagesByBlock;
649
+ return this.isProvenBlockLastOfItsEpoch(BlockNumber(provenBlockNumber), provenEpoch, l1Constants);
348
650
  }
349
651
 
350
- private async gatherPreviousBlockHeader(epochNumber: bigint, initialBlock: L2Block) {
351
- const previousBlockNumber = initialBlock.number - 1;
352
- const header = await (previousBlockNumber === 0
353
- ? this.worldState.getCommitted().getInitialHeader()
354
- : this.l2BlockSource.getBlockHeader(previousBlockNumber));
355
-
356
- if (!header) {
357
- throw new Error(`Previous block header ${initialBlock.number} not found for proving epoch ${epochNumber}`);
652
+ /** Protected for direct unit-test access. */
653
+ protected async isProvenBlockLastOfItsEpoch(
654
+ provenBlockNumber: BlockNumber,
655
+ provenEpoch: EpochNumber,
656
+ l1Constants: Pick<L1RollupConstants, 'epochDuration'>,
657
+ ): Promise<boolean> {
658
+ const nextHeader = (await this.l2BlockSource.getBlockData({ number: BlockNumber(provenBlockNumber + 1) }))?.header;
659
+ if (nextHeader) {
660
+ return getEpochAtSlot(nextHeader.getSlot(), l1Constants) > provenEpoch;
358
661
  }
662
+ return this.l2BlockSource.isEpochComplete(provenEpoch);
663
+ }
359
664
 
360
- this.log.verbose(`Gathered previous block header ${header.getBlockNumber()} for epoch ${epochNumber}`);
361
- return header;
665
+ /**
666
+ * Resolves the last fully-proven epoch from L1 proven state, used to seed the catch-up cursor (via
667
+ * `computeStartingCheckpoint`) and `lastExpiredEpoch`. The fully-proven epoch is `provenEpoch` when the
668
+ * proven tip is the last block of its epoch, otherwise `provenEpoch - 1`, or `undefined` if no block is
669
+ * proven yet (so a restart reprocesses the partially-proven epoch rather than trusting a stale tip).
670
+ */
671
+ protected async resolveLastFullyProvenEpoch(): Promise<{ lastFullyProvenEpoch: EpochNumber | undefined }> {
672
+ const provenBlockNumber = await this.l2BlockSource.getBlockNumber({ tag: 'proven' });
673
+ if (!provenBlockNumber || provenBlockNumber <= 0) {
674
+ return { lastFullyProvenEpoch: undefined };
675
+ }
676
+ const l1Constants = await this.getL1Constants();
677
+ const provenHeader = (await this.l2BlockSource.getBlockData({ number: BlockNumber(provenBlockNumber) }))?.header;
678
+ if (!provenHeader) {
679
+ return { lastFullyProvenEpoch: undefined };
680
+ }
681
+ const provenEpoch = getEpochAtSlot(provenHeader.getSlot(), l1Constants);
682
+ if (await this.isProvenBlockLastOfItsEpoch(BlockNumber(provenBlockNumber), provenEpoch, l1Constants)) {
683
+ return { lastFullyProvenEpoch: provenEpoch };
684
+ }
685
+ const lastFullyProvenEpoch = provenEpoch > 0 ? EpochNumber(provenEpoch - 1) : undefined;
686
+ return { lastFullyProvenEpoch };
362
687
  }
363
688
 
364
- /** Extracted for testing purposes. */
365
- protected doCreateEpochProvingJob(
366
- data: EpochProvingJobData,
367
- deadline: Date | undefined,
368
- publicProcessorFactory: PublicProcessorFactory,
369
- publisher: ProverNodePublisher,
370
- opts: { skipEpochCheck?: boolean } = {},
371
- ) {
372
- const { proverNodeMaxParallelBlocksPerEpoch: parallelBlockLimit, proverNodeDisableProofPublish } = this.config;
373
- return new EpochProvingJob(
374
- data,
375
- this.worldState,
376
- this.prover.createEpochProver(),
377
- publicProcessorFactory,
378
- publisher,
379
- this.l2BlockSource,
380
- this.jobMetrics,
381
- deadline,
382
- { parallelBlockLimit, skipSubmitProof: proverNodeDisableProofPublish, ...opts },
383
- );
689
+ /**
690
+ * Resolves the catch-up cursor seed: the last checkpoint of the last fully-proven epoch, or 0 if none. Seeding
691
+ * from a checkpoint (rather than a checkpointed tip) guarantees a restart reprocesses every checkpoint of the
692
+ * partially-proven epoch, since the checkpointed tip can sit ahead of the last fully-proven checkpoint.
693
+ */
694
+ protected async computeStartingCheckpoint(lastFullyProvenEpoch: EpochNumber | undefined): Promise<CheckpointNumber> {
695
+ if (lastFullyProvenEpoch === undefined) {
696
+ return CheckpointNumber.ZERO;
697
+ }
698
+ const checkpoints = await this.l2BlockSource.getCheckpointsData({ epoch: lastFullyProvenEpoch });
699
+ return checkpoints.at(-1)?.checkpointNumber ?? CheckpointNumber.ZERO;
384
700
  }
385
701
 
386
- /** Extracted for testing purposes. */
387
- protected async triggerMonitors() {
388
- await this.epochsMonitor.work();
702
+ private async gatherPreviousBlockHeader(previousBlockNumber: number) {
703
+ const data = await this.l2BlockSource.getBlockData({ number: BlockNumber(previousBlockNumber) });
704
+ if (!data?.header) {
705
+ throw new Error(`Previous block header ${previousBlockNumber} not found`);
706
+ }
707
+ return data.header;
389
708
  }
390
709
 
391
710
  private validateConfig() {
392
711
  if (
393
712
  this.config.proverNodeFailedEpochStore &&
394
- (!this.config.dataDirectory || !this.config.l1ChainId || !this.config.rollupVersion)
713
+ (!this.config.dataDirectory || !this.config.l1ChainId || this.config.rollupVersion === undefined)
395
714
  ) {
396
715
  this.log.warn(
397
716
  `Invalid prover-node config (missing dataDirectory, l1ChainId, or rollupVersion)`,
@@ -404,9 +723,5 @@ export class ProverNode implements EpochMonitorHandler, ProverNodeApi, Traceable
404
723
  }
405
724
  }
406
725
 
407
- class EmptyEpochError extends Error {
408
- constructor(epochNumber: bigint) {
409
- super(`No blocks found for epoch ${epochNumber}`);
410
- this.name = 'EmptyEpochError';
411
- }
412
- }
726
+ // Re-export so handlers can compare states externally.
727
+ export { EpochProvingJobTerminalState };