@aztec/validator-client 0.0.1-commit.b655e406 → 0.0.1-commit.d1f2d6c

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 (69) hide show
  1. package/README.md +282 -0
  2. package/dest/block_proposal_handler.d.ts +24 -12
  3. package/dest/block_proposal_handler.d.ts.map +1 -1
  4. package/dest/block_proposal_handler.js +345 -86
  5. package/dest/checkpoint_builder.d.ts +67 -0
  6. package/dest/checkpoint_builder.d.ts.map +1 -0
  7. package/dest/checkpoint_builder.js +160 -0
  8. package/dest/config.d.ts +1 -1
  9. package/dest/config.d.ts.map +1 -1
  10. package/dest/config.js +17 -7
  11. package/dest/duties/validation_service.d.ts +42 -13
  12. package/dest/duties/validation_service.d.ts.map +1 -1
  13. package/dest/duties/validation_service.js +113 -31
  14. package/dest/factory.d.ts +13 -8
  15. package/dest/factory.d.ts.map +1 -1
  16. package/dest/factory.js +2 -2
  17. package/dest/index.d.ts +3 -1
  18. package/dest/index.d.ts.map +1 -1
  19. package/dest/index.js +2 -0
  20. package/dest/key_store/ha_key_store.d.ts +99 -0
  21. package/dest/key_store/ha_key_store.d.ts.map +1 -0
  22. package/dest/key_store/ha_key_store.js +208 -0
  23. package/dest/key_store/index.d.ts +2 -1
  24. package/dest/key_store/index.d.ts.map +1 -1
  25. package/dest/key_store/index.js +1 -0
  26. package/dest/key_store/interface.d.ts +36 -6
  27. package/dest/key_store/interface.d.ts.map +1 -1
  28. package/dest/key_store/local_key_store.d.ts +10 -5
  29. package/dest/key_store/local_key_store.d.ts.map +1 -1
  30. package/dest/key_store/local_key_store.js +9 -5
  31. package/dest/key_store/node_keystore_adapter.d.ts +18 -5
  32. package/dest/key_store/node_keystore_adapter.d.ts.map +1 -1
  33. package/dest/key_store/node_keystore_adapter.js +18 -4
  34. package/dest/key_store/web3signer_key_store.d.ts +10 -11
  35. package/dest/key_store/web3signer_key_store.d.ts.map +1 -1
  36. package/dest/key_store/web3signer_key_store.js +9 -5
  37. package/dest/metrics.d.ts +1 -1
  38. package/dest/metrics.d.ts.map +1 -1
  39. package/dest/metrics.js +8 -33
  40. package/dest/tx_validator/index.d.ts +3 -0
  41. package/dest/tx_validator/index.d.ts.map +1 -0
  42. package/dest/tx_validator/index.js +2 -0
  43. package/dest/tx_validator/nullifier_cache.d.ts +14 -0
  44. package/dest/tx_validator/nullifier_cache.d.ts.map +1 -0
  45. package/dest/tx_validator/nullifier_cache.js +24 -0
  46. package/dest/tx_validator/tx_validator_factory.d.ts +18 -0
  47. package/dest/tx_validator/tx_validator_factory.d.ts.map +1 -0
  48. package/dest/tx_validator/tx_validator_factory.js +54 -0
  49. package/dest/validator.d.ts +47 -18
  50. package/dest/validator.d.ts.map +1 -1
  51. package/dest/validator.js +365 -63
  52. package/package.json +22 -14
  53. package/src/block_proposal_handler.ts +272 -61
  54. package/src/checkpoint_builder.ts +284 -0
  55. package/src/config.ts +17 -6
  56. package/src/duties/validation_service.ts +157 -38
  57. package/src/factory.ts +17 -8
  58. package/src/index.ts +2 -0
  59. package/src/key_store/ha_key_store.ts +269 -0
  60. package/src/key_store/index.ts +1 -0
  61. package/src/key_store/interface.ts +44 -5
  62. package/src/key_store/local_key_store.ts +14 -5
  63. package/src/key_store/node_keystore_adapter.ts +28 -5
  64. package/src/key_store/web3signer_key_store.ts +18 -5
  65. package/src/metrics.ts +7 -34
  66. package/src/tx_validator/index.ts +2 -0
  67. package/src/tx_validator/nullifier_cache.ts +30 -0
  68. package/src/tx_validator/tx_validator_factory.ts +135 -0
  69. package/src/validator.ts +490 -91
package/src/validator.ts CHANGED
@@ -1,8 +1,19 @@
1
+ import type { BlobClientInterface } from '@aztec/blob-client/client';
2
+ import { type Blob, getBlobsPerL1Block } from '@aztec/blob-lib';
1
3
  import type { EpochCache } from '@aztec/epoch-cache';
4
+ import {
5
+ BlockNumber,
6
+ CheckpointNumber,
7
+ EpochNumber,
8
+ IndexWithinCheckpoint,
9
+ SlotNumber,
10
+ } from '@aztec/foundation/branded-types';
11
+ import { Fr } from '@aztec/foundation/curves/bn254';
12
+ import { TimeoutError } from '@aztec/foundation/error';
2
13
  import type { EthAddress } from '@aztec/foundation/eth-address';
3
14
  import type { Signature } from '@aztec/foundation/eth-signature';
4
- import { Fr } from '@aztec/foundation/fields';
5
- import { type Logger, createLogger } from '@aztec/foundation/log';
15
+ import { type LogData, type Logger, createLogger } from '@aztec/foundation/log';
16
+ import { retryUntil } from '@aztec/foundation/retry';
6
17
  import { RunningPromise } from '@aztec/foundation/running-promise';
7
18
  import { sleep } from '@aztec/foundation/sleep';
8
19
  import { DateProvider } from '@aztec/foundation/timer';
@@ -11,20 +22,38 @@ import type { P2P, PeerId, TxProvider } from '@aztec/p2p';
11
22
  import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
12
23
  import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher';
13
24
  import type { AztecAddress } from '@aztec/stdlib/aztec-address';
14
- import type { CommitteeAttestationsAndSigners, L2BlockSource } from '@aztec/stdlib/block';
15
- import type { IFullNodeBlockBuilder, Validator, ValidatorClientFullConfig } from '@aztec/stdlib/interfaces/server';
25
+ import type { CommitteeAttestationsAndSigners, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
26
+ import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
27
+ import type {
28
+ CreateCheckpointProposalLastBlockData,
29
+ Validator,
30
+ ValidatorClientFullConfig,
31
+ WorldStateSynchronizer,
32
+ } from '@aztec/stdlib/interfaces/server';
16
33
  import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
17
- import type { BlockAttestation, BlockProposal, BlockProposalOptions } from '@aztec/stdlib/p2p';
34
+ import type {
35
+ BlockProposal,
36
+ BlockProposalOptions,
37
+ CheckpointAttestation,
38
+ CheckpointProposalCore,
39
+ CheckpointProposalOptions,
40
+ } from '@aztec/stdlib/p2p';
41
+ import { CheckpointProposal } from '@aztec/stdlib/p2p';
18
42
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
19
- import type { StateReference, Tx } from '@aztec/stdlib/tx';
43
+ import type { BlockHeader, CheckpointGlobalVariables, Tx } from '@aztec/stdlib/tx';
20
44
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
21
45
  import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client';
46
+ import { createHASigner } from '@aztec/validator-ha-signer/factory';
47
+ import { DutyType, type SigningContext } from '@aztec/validator-ha-signer/types';
22
48
 
23
49
  import { EventEmitter } from 'events';
24
50
  import type { TypedDataDefinition } from 'viem';
25
51
 
26
52
  import { BlockProposalHandler, type BlockProposalValidationFailureReason } from './block_proposal_handler.js';
53
+ import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
27
54
  import { ValidationService } from './duties/validation_service.js';
55
+ import { HAKeyStore } from './key_store/ha_key_store.js';
56
+ import type { ExtendedValidatorKeyStore } from './key_store/interface.js';
28
57
  import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
29
58
  import { ValidatorMetrics } from './metrics.js';
30
59
 
@@ -45,6 +74,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
45
74
  public readonly tracer: Tracer;
46
75
  private validationService: ValidationService;
47
76
  private metrics: ValidatorMetrics;
77
+ private log: Logger;
48
78
 
49
79
  // Whether it has already registered handlers on the p2p client
50
80
  private hasRegisteredHandlers = false;
@@ -52,29 +82,43 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
52
82
  // Used to check if we are sending the same proposal twice
53
83
  private previousProposal?: BlockProposal;
54
84
 
55
- private lastEpochForCommitteeUpdateLoop: bigint | undefined;
85
+ private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined;
56
86
  private epochCacheUpdateLoop: RunningPromise;
57
87
 
58
88
  private proposersOfInvalidBlocks: Set<string> = new Set();
59
89
 
90
+ // TODO(palla/mbps): Remove this once checkpoint validation is stable and we can validate all blocks properly.
91
+ // Tracks slots for which we have successfully validated a block proposal, so we can attest to checkpoint proposals for those slots.
92
+ // eslint-disable-next-line aztec-custom/no-non-primitive-in-collections
93
+ private validatedBlockSlots: Set<SlotNumber> = new Set();
94
+
60
95
  protected constructor(
61
- private keyStore: NodeKeystoreAdapter,
96
+ private keyStore: ExtendedValidatorKeyStore,
62
97
  private epochCache: EpochCache,
63
98
  private p2pClient: P2P,
64
99
  private blockProposalHandler: BlockProposalHandler,
100
+ private blockSource: L2BlockSource,
101
+ private checkpointsBuilder: FullNodeCheckpointsBuilder,
102
+ private worldState: WorldStateSynchronizer,
103
+ private l1ToL2MessageSource: L1ToL2MessageSource,
65
104
  private config: ValidatorClientFullConfig,
105
+ private blobClient: BlobClientInterface,
66
106
  private dateProvider: DateProvider = new DateProvider(),
67
107
  telemetry: TelemetryClient = getTelemetryClient(),
68
- private log = createLogger('validator'),
108
+ log = createLogger('validator'),
69
109
  ) {
70
110
  super();
111
+
112
+ // Create child logger with fisherman prefix if in fisherman mode
113
+ this.log = config.fishermanMode ? log.createChild('[FISHERMAN]') : log;
114
+
71
115
  this.tracer = telemetry.getTracer('Validator');
72
116
  this.metrics = new ValidatorMetrics(telemetry);
73
117
 
74
- this.validationService = new ValidationService(keyStore, log.createChild('validation-service'));
118
+ this.validationService = new ValidationService(keyStore, this.log.createChild('validation-service'));
75
119
 
76
120
  // Refresh epoch cache every second to trigger alert if participation in committee changes
77
- this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), log, 1000);
121
+ this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), this.log, 1000);
78
122
 
79
123
  const myAddresses = this.getValidatorAddresses();
80
124
  this.log.verbose(`Initialized validator with addresses: ${myAddresses.map(a => a.toString()).join(', ')}`);
@@ -132,15 +176,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
132
176
  }
133
177
  }
134
178
 
135
- static new(
179
+ static async new(
136
180
  config: ValidatorClientFullConfig,
137
- blockBuilder: IFullNodeBlockBuilder,
181
+ checkpointsBuilder: FullNodeCheckpointsBuilder,
182
+ worldState: WorldStateSynchronizer,
138
183
  epochCache: EpochCache,
139
184
  p2pClient: P2P,
140
- blockSource: L2BlockSource,
185
+ blockSource: L2BlockSource & L2BlockSink,
141
186
  l1ToL2MessageSource: L1ToL2MessageSource,
142
187
  txProvider: TxProvider,
143
188
  keyStoreManager: KeystoreManager,
189
+ blobClient: BlobClientInterface,
144
190
  dateProvider: DateProvider = new DateProvider(),
145
191
  telemetry: TelemetryClient = getTelemetryClient(),
146
192
  ) {
@@ -149,23 +195,41 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
149
195
  txsPermitted: !config.disableTransactions,
150
196
  });
151
197
  const blockProposalHandler = new BlockProposalHandler(
152
- blockBuilder,
198
+ checkpointsBuilder,
199
+ worldState,
153
200
  blockSource,
154
201
  l1ToL2MessageSource,
155
202
  txProvider,
156
203
  blockProposalValidator,
204
+ epochCache,
157
205
  config,
158
206
  metrics,
159
207
  dateProvider,
160
208
  telemetry,
161
209
  );
162
210
 
211
+ let validatorKeyStore: ExtendedValidatorKeyStore = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
212
+ if (config.haSigningEnabled) {
213
+ // If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
214
+ const haConfig = {
215
+ ...config,
216
+ maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
217
+ };
218
+ const { signer } = await createHASigner(haConfig);
219
+ validatorKeyStore = new HAKeyStore(validatorKeyStore, signer);
220
+ }
221
+
163
222
  const validator = new ValidatorClient(
164
- NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager),
223
+ validatorKeyStore,
165
224
  epochCache,
166
225
  p2pClient,
167
226
  blockProposalHandler,
227
+ blockSource,
228
+ checkpointsBuilder,
229
+ worldState,
230
+ l1ToL2MessageSource,
168
231
  config,
232
+ blobClient,
169
233
  dateProvider,
170
234
  telemetry,
171
235
  );
@@ -183,18 +247,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
183
247
  return this.blockProposalHandler;
184
248
  }
185
249
 
186
- // Proxy method for backwards compatibility with tests
187
- public reExecuteTransactions(
188
- proposal: BlockProposal,
189
- blockNumber: number,
190
- txs: any[],
191
- l1ToL2Messages: Fr[],
192
- ): Promise<any> {
193
- return this.blockProposalHandler.reexecuteTransactions(proposal, blockNumber, txs, l1ToL2Messages);
194
- }
195
-
196
- public signWithAddress(addr: EthAddress, msg: TypedDataDefinition) {
197
- return this.keyStore.signTypedDataWithAddress(addr, msg);
250
+ public signWithAddress(addr: EthAddress, msg: TypedDataDefinition, context: SigningContext) {
251
+ return this.keyStore.signTypedDataWithAddress(addr, msg, context);
198
252
  }
199
253
 
200
254
  public getCoinbaseForAttestor(attestor: EthAddress): EthAddress {
@@ -219,18 +273,15 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
219
273
  return;
220
274
  }
221
275
 
276
+ await this.keyStore.start();
277
+
222
278
  await this.registerHandlers();
223
279
 
224
280
  const myAddresses = this.getValidatorAddresses();
225
281
  const inCommittee = await this.epochCache.filterInCommittee('now', myAddresses);
282
+ this.log.info(`Started validator with addresses: ${myAddresses.map(a => a.toString()).join(', ')}`);
226
283
  if (inCommittee.length > 0) {
227
- this.log.info(
228
- `Started validator with addresses in current validator committee: ${inCommittee
229
- .map(a => a.toString())
230
- .join(', ')}`,
231
- );
232
- } else {
233
- this.log.info(`Started validator with addresses: ${myAddresses.map(a => a.toString()).join(', ')}`);
284
+ this.log.info(`Addresses in current validator committee: ${inCommittee.map(a => a.toString()).join(', ')}`);
234
285
  }
235
286
  this.epochCacheUpdateLoop.start();
236
287
 
@@ -239,6 +290,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
239
290
 
240
291
  public async stop() {
241
292
  await this.epochCacheUpdateLoop.stop();
293
+ await this.keyStore.stop();
242
294
  }
243
295
 
244
296
  /** Register handlers on the p2p client */
@@ -247,9 +299,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
247
299
  this.hasRegisteredHandlers = true;
248
300
  this.log.debug(`Registering validator handlers for p2p client`);
249
301
 
250
- const handler = (block: BlockProposal, proposalSender: PeerId): Promise<BlockAttestation[] | undefined> =>
251
- this.attestToProposal(block, proposalSender);
252
- this.p2pClient.registerBlockProposalHandler(handler);
302
+ // Block proposal handler - validates but does NOT attest (validators only attest to checkpoints)
303
+ const blockHandler = (block: BlockProposal, proposalSender: PeerId): Promise<boolean> =>
304
+ this.validateBlockProposal(block, proposalSender);
305
+ this.p2pClient.registerBlockProposalHandler(blockHandler);
306
+
307
+ // Checkpoint proposal handler - validates and creates attestations
308
+ // The checkpoint is received as CheckpointProposalCore since the lastBlock is extracted
309
+ // and processed separately via the block handler above.
310
+ const checkpointHandler = (
311
+ checkpoint: CheckpointProposalCore,
312
+ proposalSender: PeerId,
313
+ ): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
314
+ this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
253
315
 
254
316
  const myAddresses = this.getValidatorAddresses();
255
317
  this.p2pClient.registerThisValidatorAddresses(myAddresses);
@@ -258,42 +320,56 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
258
320
  }
259
321
  }
260
322
 
261
- async attestToProposal(proposal: BlockProposal, proposalSender: PeerId): Promise<BlockAttestation[] | undefined> {
262
- const slotNumber = proposal.slotNumber.toBigInt();
323
+ /**
324
+ * Validate a block proposal from a peer.
325
+ * Note: Validators do NOT attest to individual blocks - attestations are only for checkpoint proposals.
326
+ * @returns true if the proposal is valid, false otherwise
327
+ */
328
+ async validateBlockProposal(proposal: BlockProposal, proposalSender: PeerId): Promise<boolean> {
329
+ const slotNumber = proposal.slotNumber;
330
+
331
+ // Note: During escape hatch, we still want to "validate" proposals for observability,
332
+ // but we intentionally reject them and disable slashing invalid block and attestation flow.
333
+ const escapeHatchOpen = await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber);
334
+
263
335
  const proposer = proposal.getSender();
264
336
 
265
337
  // Reject proposals with invalid signatures
266
338
  if (!proposer) {
267
- this.log.warn(`Received proposal with invalid signature for slot ${slotNumber}`);
268
- return undefined;
339
+ this.log.warn(`Received block proposal with invalid signature for slot ${slotNumber}`);
340
+ return false;
269
341
  }
270
342
 
271
- // Check that I have any address in current committee before attesting
343
+ // Check if we're in the committee (for metrics purposes)
272
344
  const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
273
345
  const partOfCommittee = inCommittee.length > 0;
274
346
 
275
347
  const proposalInfo = { ...proposal.toBlockInfo(), proposer: proposer.toString() };
276
- this.log.info(`Received proposal for slot ${slotNumber}`, {
348
+ this.log.info(`Received block proposal for slot ${slotNumber}`, {
277
349
  ...proposalInfo,
278
350
  txHashes: proposal.txHashes.map(t => t.toString()),
351
+ fishermanMode: this.config.fishermanMode || false,
279
352
  });
280
353
 
281
- // Reexecute txs if we are part of the committee so we can attest, or if slashing is enabled so we can slash
282
- // invalid proposals even when not in the committee, or if we are configured to always reexecute for monitoring purposes.
283
- const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals } = this.config;
354
+ // Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute.
355
+ // In fisherman mode, we always reexecute to validate proposals.
356
+ const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } =
357
+ this.config;
284
358
  const shouldReexecute =
359
+ fishermanMode ||
285
360
  (slashBroadcastedInvalidBlockPenalty > 0n && validatorReexecute) ||
286
361
  (partOfCommittee && validatorReexecute) ||
287
- alwaysReexecuteBlockProposals;
362
+ alwaysReexecuteBlockProposals ||
363
+ this.blobClient.canUpload();
288
364
 
289
365
  const validationResult = await this.blockProposalHandler.handleBlockProposal(
290
366
  proposal,
291
367
  proposalSender,
292
- !!shouldReexecute,
368
+ !!shouldReexecute && !escapeHatchOpen,
293
369
  );
294
370
 
295
371
  if (!validationResult.isValid) {
296
- this.log.warn(`Proposal validation failed: ${validationResult.reason}`, proposalInfo);
372
+ this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
297
373
 
298
374
  const reason = validationResult.reason || 'unknown';
299
375
  // Classify failure reason: bad proposal vs node issue
@@ -308,12 +384,13 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
308
384
  if (badProposalReasons.includes(reason as BlockProposalValidationFailureReason)) {
309
385
  this.metrics.incFailedAttestationsBadProposal(1, reason, partOfCommittee);
310
386
  } else {
311
- // Node issues so we can't attest
387
+ // Node issues so we can't validate
312
388
  this.metrics.incFailedAttestationsNodeIssue(1, reason, partOfCommittee);
313
389
  }
314
390
 
315
391
  // Slash invalid block proposals (can happen even when not in committee)
316
392
  if (
393
+ !escapeHatchOpen &&
317
394
  validationResult.reason &&
318
395
  SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason) &&
319
396
  slashBroadcastedInvalidBlockPenalty > 0n
@@ -321,21 +398,311 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
321
398
  this.log.warn(`Slashing proposer for invalid block proposal`, proposalInfo);
322
399
  this.slashInvalidBlock(proposal);
323
400
  }
401
+ return false;
402
+ }
403
+
404
+ this.log.info(`Validated block proposal for slot ${slotNumber}`, {
405
+ ...proposalInfo,
406
+ inCommittee: partOfCommittee,
407
+ fishermanMode: this.config.fishermanMode || false,
408
+ escapeHatchOpen,
409
+ });
410
+
411
+ if (escapeHatchOpen) {
412
+ this.log.warn(`Escape hatch open for slot ${slotNumber}, rejecting block proposal`, proposalInfo);
413
+ return false;
414
+ }
415
+
416
+ // TODO(palla/mbps): Remove this once checkpoint validation is stable.
417
+ // Track that we successfully validated a block for this slot, so we can attest to checkpoint proposals for it.
418
+ this.validatedBlockSlots.add(slotNumber);
419
+
420
+ return true;
421
+ }
422
+
423
+ /**
424
+ * Validate and attest to a checkpoint proposal from a peer.
425
+ * The proposal is received as CheckpointProposalCore (without lastBlock) since
426
+ * the lastBlock is extracted and processed separately via the block handler.
427
+ * @returns Checkpoint attestations if valid, undefined otherwise
428
+ */
429
+ async attestToCheckpointProposal(
430
+ proposal: CheckpointProposalCore,
431
+ _proposalSender: PeerId,
432
+ ): Promise<CheckpointAttestation[] | undefined> {
433
+ const slotNumber = proposal.slotNumber;
434
+ const proposer = proposal.getSender();
435
+
436
+ // If escape hatch is open for this slot's epoch, do not attest.
437
+ if (await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber)) {
438
+ this.log.warn(`Escape hatch open for slot ${slotNumber}, skipping checkpoint attestation handling`);
324
439
  return undefined;
325
440
  }
326
441
 
442
+ // Reject proposals with invalid signatures
443
+ if (!proposer) {
444
+ this.log.warn(`Received checkpoint proposal with invalid signature for slot ${slotNumber}`);
445
+ return undefined;
446
+ }
447
+
448
+ // Check that I have any address in current committee before attesting
449
+ const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
450
+ const partOfCommittee = inCommittee.length > 0;
451
+
452
+ const proposalInfo = {
453
+ slotNumber,
454
+ archive: proposal.archive.toString(),
455
+ proposer: proposer.toString(),
456
+ txCount: proposal.txHashes.length,
457
+ };
458
+ this.log.info(`Received checkpoint proposal for slot ${slotNumber}`, {
459
+ ...proposalInfo,
460
+ txHashes: proposal.txHashes.map(t => t.toString()),
461
+ fishermanMode: this.config.fishermanMode || false,
462
+ });
463
+
464
+ // TODO(palla/mbps): Remove this once checkpoint validation is stable.
465
+ // Check that we have successfully validated a block for this slot before attesting to the checkpoint.
466
+ if (!this.validatedBlockSlots.has(slotNumber)) {
467
+ this.log.warn(`No validated block found for slot ${slotNumber}, refusing to attest to checkpoint`, proposalInfo);
468
+ return undefined;
469
+ }
470
+
471
+ // Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
472
+ // TODO(palla/mbps): Change default to false once checkpoint validation is stable.
473
+ if (this.config.skipCheckpointProposalValidation !== false) {
474
+ this.log.verbose(`Skipping checkpoint proposal validation for slot ${slotNumber}`, proposalInfo);
475
+ } else {
476
+ const validationResult = await this.validateCheckpointProposal(proposal, proposalInfo);
477
+ if (!validationResult.isValid) {
478
+ this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
479
+ return undefined;
480
+ }
481
+ }
482
+
483
+ // Upload blobs to filestore if we can (fire and forget)
484
+ if (this.blobClient.canUpload()) {
485
+ void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
486
+ }
487
+
327
488
  // Check that I have any address in current committee before attesting
328
- if (!partOfCommittee) {
489
+ // In fisherman mode, we still create attestations for validation even if not in committee
490
+ if (!partOfCommittee && !this.config.fishermanMode) {
329
491
  this.log.verbose(`No validator in the current committee, skipping attestation`, proposalInfo);
330
492
  return undefined;
331
493
  }
332
494
 
333
495
  // Provided all of the above checks pass, we can attest to the proposal
334
- this.log.info(`Attesting to proposal for slot ${slotNumber}`, proposalInfo);
496
+ this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${slotNumber}`, {
497
+ ...proposalInfo,
498
+ inCommittee: partOfCommittee,
499
+ fishermanMode: this.config.fishermanMode || false,
500
+ });
501
+
335
502
  this.metrics.incSuccessfulAttestations(inCommittee.length);
336
503
 
337
- // If the above function does not throw an error, then we can attest to the proposal
338
- return this.createBlockAttestationsFromProposal(proposal, inCommittee);
504
+ // Determine which validators should attest
505
+ let attestors: EthAddress[];
506
+ if (partOfCommittee) {
507
+ attestors = inCommittee;
508
+ } else if (this.config.fishermanMode) {
509
+ // In fisherman mode, create attestations for validation purposes even if not in committee. These won't be broadcast.
510
+ attestors = this.getValidatorAddresses();
511
+ } else {
512
+ attestors = [];
513
+ }
514
+
515
+ // Only create attestations if we have attestors
516
+ if (attestors.length === 0) {
517
+ return undefined;
518
+ }
519
+
520
+ if (this.config.fishermanMode) {
521
+ // bail out early and don't save attestations to the pool in fisherman mode
522
+ this.log.info(`Creating checkpoint attestations for slot ${slotNumber}`, {
523
+ ...proposalInfo,
524
+ attestors: attestors.map(a => a.toString()),
525
+ });
526
+ return undefined;
527
+ }
528
+
529
+ return this.createCheckpointAttestationsFromProposal(proposal, attestors);
530
+ }
531
+
532
+ private async createCheckpointAttestationsFromProposal(
533
+ proposal: CheckpointProposalCore,
534
+ attestors: EthAddress[] = [],
535
+ ): Promise<CheckpointAttestation[]> {
536
+ const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
537
+ await this.p2pClient.addCheckpointAttestations(attestations);
538
+ return attestations;
539
+ }
540
+
541
+ /**
542
+ * Validates a checkpoint proposal by building the full checkpoint and comparing it with the proposal.
543
+ * @returns Validation result with isValid flag and reason if invalid.
544
+ */
545
+ private async validateCheckpointProposal(
546
+ proposal: CheckpointProposalCore,
547
+ proposalInfo: LogData,
548
+ ): Promise<{ isValid: true } | { isValid: false; reason: string }> {
549
+ const slot = proposal.slotNumber;
550
+ const timeoutSeconds = 10;
551
+
552
+ // Wait for last block to sync by archive
553
+ let lastBlockHeader: BlockHeader | undefined;
554
+ try {
555
+ lastBlockHeader = await retryUntil(
556
+ async () => {
557
+ await this.blockSource.syncImmediate();
558
+ return this.blockSource.getBlockHeaderByArchive(proposal.archive);
559
+ },
560
+ `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
561
+ timeoutSeconds,
562
+ 0.5,
563
+ );
564
+ } catch (err) {
565
+ if (err instanceof TimeoutError) {
566
+ this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
567
+ return { isValid: false, reason: 'last_block_not_found' };
568
+ }
569
+ this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
570
+ return { isValid: false, reason: 'block_fetch_error' };
571
+ }
572
+
573
+ if (!lastBlockHeader) {
574
+ this.log.warn(`Last block not found for checkpoint proposal`, proposalInfo);
575
+ return { isValid: false, reason: 'last_block_not_found' };
576
+ }
577
+
578
+ // Get all full blocks for the slot and checkpoint
579
+ const blocks = await this.blockSource.getBlocksForSlot(slot);
580
+ if (blocks.length === 0) {
581
+ this.log.warn(`No blocks found for slot ${slot}`, proposalInfo);
582
+ return { isValid: false, reason: 'no_blocks_for_slot' };
583
+ }
584
+
585
+ this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
586
+ ...proposalInfo,
587
+ blockNumbers: blocks.map(b => b.number),
588
+ });
589
+
590
+ // Get checkpoint constants from first block
591
+ const firstBlock = blocks[0];
592
+ const constants = this.extractCheckpointConstants(firstBlock);
593
+ const checkpointNumber = firstBlock.checkpointNumber;
594
+
595
+ // Get L1-to-L2 messages for this checkpoint
596
+ const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
597
+
598
+ // Compute the previous checkpoint out hashes for the epoch.
599
+ // TODO: There can be a more efficient way to get the previous checkpoint out hashes without having to fetch the
600
+ // actual checkpoints and the blocks/txs in them.
601
+ const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
602
+ const previousCheckpoints = (await this.blockSource.getCheckpointsForEpoch(epoch))
603
+ .filter(b => b.number < checkpointNumber)
604
+ .sort((a, b) => a.number - b.number);
605
+ const previousCheckpointOutHashes = previousCheckpoints.map(c => c.getCheckpointOutHash());
606
+
607
+ // Fork world state at the block before the first block
608
+ const parentBlockNumber = BlockNumber(firstBlock.number - 1);
609
+ const fork = await this.worldState.fork(parentBlockNumber);
610
+
611
+ try {
612
+ // Create checkpoint builder with all existing blocks
613
+ const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
614
+ checkpointNumber,
615
+ constants,
616
+ l1ToL2Messages,
617
+ previousCheckpointOutHashes,
618
+ fork,
619
+ blocks,
620
+ );
621
+
622
+ // Complete the checkpoint to get computed values
623
+ const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
624
+
625
+ // Compare checkpoint header with proposal
626
+ if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
627
+ this.log.warn(`Checkpoint header mismatch`, {
628
+ ...proposalInfo,
629
+ computed: computedCheckpoint.header.toInspect(),
630
+ proposal: proposal.checkpointHeader.toInspect(),
631
+ });
632
+ return { isValid: false, reason: 'checkpoint_header_mismatch' };
633
+ }
634
+
635
+ // Compare archive root with proposal
636
+ if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
637
+ this.log.warn(`Archive root mismatch`, {
638
+ ...proposalInfo,
639
+ computed: computedCheckpoint.archive.root.toString(),
640
+ proposal: proposal.archive.toString(),
641
+ });
642
+ return { isValid: false, reason: 'archive_mismatch' };
643
+ }
644
+
645
+ // Check that the accumulated out hash matches the value in the proposal.
646
+ const computedOutHash = computedCheckpoint.getCheckpointOutHash();
647
+ const proposalOutHash = proposal.checkpointHeader.epochOutHash;
648
+ if (!computedOutHash.equals(proposalOutHash)) {
649
+ this.log.warn(`Epoch out hash mismatch`, {
650
+ proposalOutHash: proposalOutHash.toString(),
651
+ computedOutHash: computedOutHash.toString(),
652
+ ...proposalInfo,
653
+ });
654
+ return { isValid: false, reason: 'out_hash_mismatch' };
655
+ }
656
+
657
+ this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
658
+ return { isValid: true };
659
+ } finally {
660
+ await fork.close();
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Extract checkpoint global variables from a block.
666
+ */
667
+ private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
668
+ const gv = block.header.globalVariables;
669
+ return {
670
+ chainId: gv.chainId,
671
+ version: gv.version,
672
+ slotNumber: gv.slotNumber,
673
+ coinbase: gv.coinbase,
674
+ feeRecipient: gv.feeRecipient,
675
+ gasFees: gv.gasFees,
676
+ };
677
+ }
678
+
679
+ /**
680
+ * Uploads blobs for a checkpoint to the filestore (fire and forget).
681
+ */
682
+ private async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
683
+ try {
684
+ const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
685
+ if (!lastBlockHeader) {
686
+ this.log.warn(`Failed to get last block header for blob upload`, proposalInfo);
687
+ return;
688
+ }
689
+
690
+ const blocks = await this.blockSource.getBlocksForSlot(proposal.slotNumber);
691
+ if (blocks.length === 0) {
692
+ this.log.warn(`No blocks found for blob upload`, proposalInfo);
693
+ return;
694
+ }
695
+
696
+ const blobFields = blocks.flatMap(b => b.toBlobFields());
697
+ const blobs: Blob[] = getBlobsPerL1Block(blobFields);
698
+ await this.blobClient.sendBlobsToFilestore(blobs);
699
+ this.log.debug(`Uploaded ${blobs.length} blobs to filestore for checkpoint at slot ${proposal.slotNumber}`, {
700
+ ...proposalInfo,
701
+ numBlobs: blobs.length,
702
+ });
703
+ } catch (err) {
704
+ this.log.warn(`Failed to upload blobs for checkpoint: ${err}`, proposalInfo);
705
+ }
339
706
  }
340
707
 
341
708
  private slashInvalidBlock(proposal: BlockProposal) {
@@ -360,37 +727,62 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
360
727
  validator: proposer,
361
728
  amount: this.config.slashBroadcastedInvalidBlockPenalty,
362
729
  offenseType: OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL,
363
- epochOrSlot: proposal.slotNumber.toBigInt(),
730
+ epochOrSlot: BigInt(proposal.slotNumber),
364
731
  },
365
732
  ]);
366
733
  }
367
734
 
368
735
  async createBlockProposal(
369
- blockNumber: number,
370
- header: CheckpointHeader,
736
+ blockHeader: BlockHeader,
737
+ indexWithinCheckpoint: IndexWithinCheckpoint,
738
+ inHash: Fr,
371
739
  archive: Fr,
372
- stateReference: StateReference,
373
740
  txs: Tx[],
374
741
  proposerAddress: EthAddress | undefined,
375
742
  options: BlockProposalOptions,
376
- ): Promise<BlockProposal | undefined> {
377
- if (this.previousProposal?.slotNumber.equals(header.slotNumber)) {
378
- this.log.verbose(`Already made a proposal for the same slot, skipping proposal`);
379
- return Promise.resolve(undefined);
380
- }
381
-
743
+ ): Promise<BlockProposal> {
744
+ // TODO(palla/mbps): Prevent double proposals properly
745
+ // if (this.previousProposal?.slotNumber === blockHeader.globalVariables.slotNumber) {
746
+ // this.log.verbose(`Already made a proposal for the same slot, skipping proposal`);
747
+ // return Promise.resolve(undefined);
748
+ // }
749
+
750
+ this.log.info(
751
+ `Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`,
752
+ );
382
753
  const newProposal = await this.validationService.createBlockProposal(
383
- header,
754
+ blockHeader,
755
+ indexWithinCheckpoint,
756
+ inHash,
384
757
  archive,
385
- stateReference,
386
758
  txs,
387
759
  proposerAddress,
388
- { ...options, broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal },
760
+ {
761
+ ...options,
762
+ broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
763
+ },
389
764
  );
390
765
  this.previousProposal = newProposal;
391
766
  return newProposal;
392
767
  }
393
768
 
769
+ async createCheckpointProposal(
770
+ checkpointHeader: CheckpointHeader,
771
+ archive: Fr,
772
+ lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
773
+ proposerAddress: EthAddress | undefined,
774
+ options: CheckpointProposalOptions,
775
+ ): Promise<CheckpointProposal> {
776
+ this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
777
+ return await this.validationService.createCheckpointProposal(
778
+ checkpointHeader,
779
+ archive,
780
+ lastBlockInfo,
781
+ proposerAddress,
782
+ options,
783
+ );
784
+ }
785
+
394
786
  async broadcastBlockProposal(proposal: BlockProposal): Promise<void> {
395
787
  await this.p2pClient.broadcastProposal(proposal);
396
788
  }
@@ -398,20 +790,34 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
398
790
  async signAttestationsAndSigners(
399
791
  attestationsAndSigners: CommitteeAttestationsAndSigners,
400
792
  proposer: EthAddress,
793
+ slot: SlotNumber,
794
+ blockNumber: BlockNumber | CheckpointNumber,
401
795
  ): Promise<Signature> {
402
- return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer);
796
+ return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer, slot, blockNumber);
403
797
  }
404
798
 
405
- async collectOwnAttestations(proposal: BlockProposal): Promise<BlockAttestation[]> {
406
- const slot = proposal.payload.header.slotNumber.toBigInt();
799
+ async collectOwnAttestations(proposal: CheckpointProposal): Promise<CheckpointAttestation[]> {
800
+ const slot = proposal.slotNumber;
407
801
  const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
408
802
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
409
- return this.createBlockAttestationsFromProposal(proposal, inCommittee);
803
+ const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
804
+
805
+ // We broadcast our own attestations to our peers so, in case our block does not get mined on L1,
806
+ // other nodes can see that our validators did attest to this block proposal, and do not slash us
807
+ // due to inactivity for missed attestations.
808
+ void this.p2pClient.broadcastCheckpointAttestations(attestations).catch(err => {
809
+ this.log.error(`Failed to broadcast self-attestations for slot ${slot}`, err);
810
+ });
811
+ return attestations;
410
812
  }
411
813
 
412
- async collectAttestations(proposal: BlockProposal, required: number, deadline: Date): Promise<BlockAttestation[]> {
413
- // Wait and poll the p2pClient's attestation pool for this block until we have enough attestations
414
- const slot = proposal.payload.header.slotNumber.toBigInt();
814
+ async collectAttestations(
815
+ proposal: CheckpointProposal,
816
+ required: number,
817
+ deadline: Date,
818
+ ): Promise<CheckpointAttestation[]> {
819
+ // Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
820
+ const slot = proposal.slotNumber;
415
821
  this.log.debug(`Collecting ${required} attestations for slot ${slot} with deadline ${deadline.toISOString()}`);
416
822
 
417
823
  if (+deadline < this.dateProvider.now()) {
@@ -426,16 +832,16 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
426
832
  const proposalId = proposal.archive.toString();
427
833
  const myAddresses = this.getValidatorAddresses();
428
834
 
429
- let attestations: BlockAttestation[] = [];
835
+ let attestations: CheckpointAttestation[] = [];
430
836
  while (true) {
431
- // Filter out attestations with a mismatching payload. This should NOT happen since we have verified
837
+ // Filter out attestations with a mismatching archive. This should NOT happen since we have verified
432
838
  // the proposer signature (ie our own) before accepting the attestation into the pool via the p2p client.
433
- const collectedAttestations = (await this.p2pClient.getAttestationsForSlot(slot, proposalId)).filter(
839
+ const collectedAttestations = (await this.p2pClient.getCheckpointAttestationsForSlot(slot, proposalId)).filter(
434
840
  attestation => {
435
- if (!attestation.payload.equals(proposal.payload)) {
841
+ if (!attestation.archive.equals(proposal.archive)) {
436
842
  this.log.warn(
437
- `Received attestation for slot ${slot} with mismatched payload from ${attestation.getSender()?.toString()}`,
438
- { attestationPayload: attestation.payload, proposalPayload: proposal.payload },
843
+ `Received attestation for slot ${slot} with mismatched archive from ${attestation.getSender()?.toString()}`,
844
+ { attestationArchive: attestation.archive.toString(), proposalArchive: proposal.archive.toString() },
439
845
  );
440
846
  return false;
441
847
  }
@@ -476,15 +882,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
476
882
  }
477
883
  }
478
884
 
479
- private async createBlockAttestationsFromProposal(
480
- proposal: BlockProposal,
481
- attestors: EthAddress[] = [],
482
- ): Promise<BlockAttestation[]> {
483
- const attestations = await this.validationService.attestToProposal(proposal, attestors);
484
- await this.p2pClient.addAttestations(attestations);
485
- return attestations;
486
- }
487
-
488
885
  private async handleAuthRequest(peer: PeerId, msg: Buffer): Promise<Buffer> {
489
886
  const authRequest = AuthRequest.fromBuffer(msg);
490
887
  const statusMessage = await this.p2pClient.handleAuthRequestFromPeer(authRequest, peer).catch(_ => undefined);
@@ -503,7 +900,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
503
900
  }
504
901
 
505
902
  const payloadToSign = authRequest.getPayloadToSign();
506
- const signature = await this.keyStore.signMessageWithAddress(addressToUse, payloadToSign);
903
+ // AUTH_REQUEST doesn't require HA protection - multiple signatures are safe
904
+ const context: SigningContext = { dutyType: DutyType.AUTH_REQUEST };
905
+ const signature = await this.keyStore.signMessageWithAddress(addressToUse, payloadToSign, context);
507
906
  const authResponse = new AuthResponse(statusMessage, signature);
508
907
  return authResponse.toBuffer();
509
908
  }