@aztec/validator-client 4.0.0-nightly.20250907 → 4.0.0-nightly.20260108

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 (43) hide show
  1. package/dest/block_proposal_handler.d.ts +53 -0
  2. package/dest/block_proposal_handler.d.ts.map +1 -0
  3. package/dest/block_proposal_handler.js +290 -0
  4. package/dest/config.d.ts +1 -1
  5. package/dest/config.d.ts.map +1 -1
  6. package/dest/config.js +10 -0
  7. package/dest/duties/validation_service.d.ts +11 -6
  8. package/dest/duties/validation_service.d.ts.map +1 -1
  9. package/dest/duties/validation_service.js +19 -7
  10. package/dest/factory.d.ts +16 -5
  11. package/dest/factory.d.ts.map +1 -1
  12. package/dest/factory.js +11 -1
  13. package/dest/index.d.ts +2 -1
  14. package/dest/index.d.ts.map +1 -1
  15. package/dest/index.js +1 -0
  16. package/dest/key_store/index.d.ts +1 -1
  17. package/dest/key_store/interface.d.ts +1 -1
  18. package/dest/key_store/local_key_store.d.ts +1 -1
  19. package/dest/key_store/local_key_store.d.ts.map +1 -1
  20. package/dest/key_store/local_key_store.js +1 -1
  21. package/dest/key_store/node_keystore_adapter.d.ts +1 -1
  22. package/dest/key_store/node_keystore_adapter.d.ts.map +1 -1
  23. package/dest/key_store/node_keystore_adapter.js +2 -4
  24. package/dest/key_store/web3signer_key_store.d.ts +1 -7
  25. package/dest/key_store/web3signer_key_store.d.ts.map +1 -1
  26. package/dest/key_store/web3signer_key_store.js +7 -8
  27. package/dest/metrics.d.ts +7 -5
  28. package/dest/metrics.d.ts.map +1 -1
  29. package/dest/metrics.js +25 -12
  30. package/dest/validator.d.ts +28 -27
  31. package/dest/validator.d.ts.map +1 -1
  32. package/dest/validator.js +579 -198
  33. package/package.json +17 -15
  34. package/src/block_proposal_handler.ts +346 -0
  35. package/src/config.ts +12 -0
  36. package/src/duties/validation_service.ts +30 -12
  37. package/src/factory.ts +37 -4
  38. package/src/index.ts +1 -0
  39. package/src/key_store/local_key_store.ts +1 -1
  40. package/src/key_store/node_keystore_adapter.ts +3 -4
  41. package/src/key_store/web3signer_key_store.ts +7 -10
  42. package/src/metrics.ts +34 -13
  43. package/src/validator.ts +281 -271
package/src/validator.ts CHANGED
@@ -1,44 +1,32 @@
1
- import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants';
1
+ import type { BlobClientInterface } from '@aztec/blob-client/client';
2
+ import { getBlobsPerL1Block } from '@aztec/blob-lib';
2
3
  import type { EpochCache } from '@aztec/epoch-cache';
4
+ import { BlockNumber, EpochNumber } from '@aztec/foundation/branded-types';
5
+ import { Fr } from '@aztec/foundation/curves/bn254';
3
6
  import type { EthAddress } from '@aztec/foundation/eth-address';
4
- import { Fr } from '@aztec/foundation/fields';
5
- import { createLogger } from '@aztec/foundation/log';
6
- import { retryUntil } from '@aztec/foundation/retry';
7
+ import type { Signature } from '@aztec/foundation/eth-signature';
8
+ import { type Logger, createLogger } from '@aztec/foundation/log';
7
9
  import { RunningPromise } from '@aztec/foundation/running-promise';
8
10
  import { sleep } from '@aztec/foundation/sleep';
9
- import { DateProvider, Timer } from '@aztec/foundation/timer';
11
+ import { DateProvider } from '@aztec/foundation/timer';
10
12
  import type { KeystoreManager } from '@aztec/node-keystore';
11
- import type { P2P, PeerId } from '@aztec/p2p';
12
- import { AuthRequest, AuthResponse, ReqRespSubProtocol, TxProvider } from '@aztec/p2p';
13
- import { BlockProposalValidator } from '@aztec/p2p/msg_validators';
14
- import { computeInHashFromL1ToL2Messages } from '@aztec/prover-client/helpers';
15
- import {
16
- OffenseType,
17
- type SlasherConfig,
18
- WANT_TO_SLASH_EVENT,
19
- type Watcher,
20
- type WatcherEmitter,
21
- } from '@aztec/slasher';
13
+ import type { P2P, PeerId, TxProvider } from '@aztec/p2p';
14
+ import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
15
+ import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher';
22
16
  import type { AztecAddress } from '@aztec/stdlib/aztec-address';
23
- import type { L2BlockSource } from '@aztec/stdlib/block';
24
- import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
17
+ import type { CommitteeAttestationsAndSigners, L2BlockSource } from '@aztec/stdlib/block';
25
18
  import type { IFullNodeBlockBuilder, Validator, ValidatorClientFullConfig } from '@aztec/stdlib/interfaces/server';
26
19
  import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
27
20
  import type { BlockAttestation, BlockProposal, BlockProposalOptions } from '@aztec/stdlib/p2p';
28
- import { GlobalVariables, type ProposedBlockHeader, type StateReference, type Tx } from '@aztec/stdlib/tx';
29
- import {
30
- AttestationTimeoutError,
31
- ReExFailedTxsError,
32
- ReExStateMismatchError,
33
- ReExTimeoutError,
34
- TransactionsNotAvailableError,
35
- } from '@aztec/stdlib/validators';
36
- import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client';
21
+ import type { CheckpointHeader } from '@aztec/stdlib/rollup';
22
+ import type { Tx } from '@aztec/stdlib/tx';
23
+ import { AttestationTimeoutError } from '@aztec/stdlib/validators';
24
+ import { Attributes, type TelemetryClient, type Tracer, getTelemetryClient, trackSpan } from '@aztec/telemetry-client';
37
25
 
38
26
  import { EventEmitter } from 'events';
39
27
  import type { TypedDataDefinition } from 'viem';
40
28
 
41
- import type { ValidatorClientConfig } from './config.js';
29
+ import { BlockProposalHandler, type BlockProposalValidationFailureReason } from './block_proposal_handler.js';
42
30
  import { ValidationService } from './duties/validation_service.js';
43
31
  import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
44
32
  import { ValidatorMetrics } from './metrics.js';
@@ -47,6 +35,12 @@ import { ValidatorMetrics } from './metrics.js';
47
35
  // Just cap the set to avoid unbounded growth.
48
36
  const MAX_PROPOSERS_OF_INVALID_BLOCKS = 1000;
49
37
 
38
+ // What errors from the block proposal handler result in slashing
39
+ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidationFailureReason[] = [
40
+ 'state_mismatch',
41
+ 'failed_txs',
42
+ ];
43
+
50
44
  /**
51
45
  * Validator Client
52
46
  */
@@ -54,54 +48,58 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
54
48
  public readonly tracer: Tracer;
55
49
  private validationService: ValidationService;
56
50
  private metrics: ValidatorMetrics;
51
+ private log: Logger;
52
+
53
+ // Whether it has already registered handlers on the p2p client
54
+ private hasRegisteredHandlers = false;
57
55
 
58
56
  // Used to check if we are sending the same proposal twice
59
57
  private previousProposal?: BlockProposal;
60
58
 
61
- private lastEpochForCommitteeUpdateLoop: bigint | undefined;
59
+ private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined;
62
60
  private epochCacheUpdateLoop: RunningPromise;
63
61
 
64
- private blockProposalValidator: BlockProposalValidator;
65
-
66
62
  private proposersOfInvalidBlocks: Set<string> = new Set();
67
63
 
68
64
  protected constructor(
69
- private blockBuilder: IFullNodeBlockBuilder,
70
65
  private keyStore: NodeKeystoreAdapter,
71
66
  private epochCache: EpochCache,
72
67
  private p2pClient: P2P,
73
- private blockSource: L2BlockSource,
74
- private l1ToL2MessageSource: L1ToL2MessageSource,
75
- private txProvider: TxProvider,
68
+ private blockProposalHandler: BlockProposalHandler,
76
69
  private config: ValidatorClientFullConfig,
70
+ private blobClient: BlobClientInterface,
77
71
  private dateProvider: DateProvider = new DateProvider(),
78
72
  telemetry: TelemetryClient = getTelemetryClient(),
79
- private log = createLogger('validator'),
73
+ log = createLogger('validator'),
80
74
  ) {
81
75
  super();
76
+
77
+ // Create child logger with fisherman prefix if in fisherman mode
78
+ this.log = config.fishermanMode ? log.createChild('[FISHERMAN]') : log;
79
+
82
80
  this.tracer = telemetry.getTracer('Validator');
83
81
  this.metrics = new ValidatorMetrics(telemetry);
84
82
 
85
- this.validationService = new ValidationService(keyStore);
86
-
87
- this.blockProposalValidator = new BlockProposalValidator(epochCache);
83
+ this.validationService = new ValidationService(keyStore, this.log.createChild('validation-service'));
88
84
 
89
85
  // Refresh epoch cache every second to trigger alert if participation in committee changes
90
- this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), log, 1000);
86
+ this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), this.log, 1000);
91
87
 
92
88
  const myAddresses = this.getValidatorAddresses();
93
89
  this.log.verbose(`Initialized validator with addresses: ${myAddresses.map(a => a.toString()).join(', ')}`);
94
90
  }
95
91
 
96
- public static validateKeyStoreConfiguration(keyStoreManager: KeystoreManager) {
92
+ public static validateKeyStoreConfiguration(keyStoreManager: KeystoreManager, logger?: Logger) {
97
93
  const validatorKeyStore = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
98
94
  const validatorAddresses = validatorKeyStore.getAddresses();
99
95
  // Verify that we can retrieve all required data from the key store
100
96
  for (const address of validatorAddresses) {
101
97
  // Functions throw if required data is not available
98
+ let coinbase: EthAddress;
99
+ let feeRecipient: AztecAddress;
102
100
  try {
103
- validatorKeyStore.getCoinbaseAddress(address);
104
- validatorKeyStore.getFeeRecipient(address);
101
+ coinbase = validatorKeyStore.getCoinbaseAddress(address);
102
+ feeRecipient = validatorKeyStore.getFeeRecipient(address);
105
103
  } catch (error) {
106
104
  throw new Error(`Failed to retrieve required data for validator address ${address}, error: ${error}`);
107
105
  }
@@ -110,6 +108,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
110
108
  if (!publisherAddresses.length) {
111
109
  throw new Error(`No publisher addresses found for validator address ${address}`);
112
110
  }
111
+ logger?.debug(
112
+ `Validator ${address.toString()} configured with coinbase ${coinbase.toString()}, feeRecipient ${feeRecipient.toString()} and publishers ${publisherAddresses.map(x => x.toString()).join()}`,
113
+ );
113
114
  }
114
115
  }
115
116
 
@@ -141,7 +142,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
141
142
  }
142
143
 
143
144
  static new(
144
- config: ValidatorClientConfig & Pick<SlasherConfig, 'slashBroadcastedInvalidBlockPenalty'>,
145
+ config: ValidatorClientFullConfig,
145
146
  blockBuilder: IFullNodeBlockBuilder,
146
147
  epochCache: EpochCache,
147
148
  p2pClient: P2P,
@@ -149,24 +150,37 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
149
150
  l1ToL2MessageSource: L1ToL2MessageSource,
150
151
  txProvider: TxProvider,
151
152
  keyStoreManager: KeystoreManager,
153
+ blobClient: BlobClientInterface,
152
154
  dateProvider: DateProvider = new DateProvider(),
153
155
  telemetry: TelemetryClient = getTelemetryClient(),
154
156
  ) {
155
- const validator = new ValidatorClient(
157
+ const metrics = new ValidatorMetrics(telemetry);
158
+ const blockProposalValidator = new BlockProposalValidator(epochCache, {
159
+ txsPermitted: !config.disableTransactions,
160
+ });
161
+ const blockProposalHandler = new BlockProposalHandler(
156
162
  blockBuilder,
157
- NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager),
158
- epochCache,
159
- p2pClient,
160
163
  blockSource,
161
164
  l1ToL2MessageSource,
162
165
  txProvider,
166
+ blockProposalValidator,
167
+ config,
168
+ metrics,
169
+ dateProvider,
170
+ telemetry,
171
+ );
172
+
173
+ const validator = new ValidatorClient(
174
+ NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager),
175
+ epochCache,
176
+ p2pClient,
177
+ blockProposalHandler,
163
178
  config,
179
+ blobClient,
164
180
  dateProvider,
165
181
  telemetry,
166
182
  );
167
183
 
168
- // TODO(PhilWindle): This seems like it could/should be done inside start()
169
- validator.registerBlockProposalHandler();
170
184
  return validator;
171
185
  }
172
186
 
@@ -176,6 +190,20 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
176
190
  .filter(addr => !this.config.disabledValidators.some(disabled => disabled.equals(addr)));
177
191
  }
178
192
 
193
+ public getBlockProposalHandler() {
194
+ return this.blockProposalHandler;
195
+ }
196
+
197
+ // Proxy method for backwards compatibility with tests
198
+ public reExecuteTransactions(
199
+ proposal: BlockProposal,
200
+ blockNumber: BlockNumber,
201
+ txs: any[],
202
+ l1ToL2Messages: Fr[],
203
+ ): Promise<any> {
204
+ return this.blockProposalHandler.reexecuteTransactions(proposal, blockNumber, txs, l1ToL2Messages);
205
+ }
206
+
179
207
  public signWithAddress(addr: EthAddress, msg: TypedDataDefinition) {
180
208
  return this.keyStore.signTypedDataWithAddress(addr, msg);
181
209
  }
@@ -197,26 +225,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
197
225
  }
198
226
 
199
227
  public async start() {
200
- // Sync the committee from the smart contract
201
- // https://github.com/AztecProtocol/aztec-packages/issues/7962
228
+ if (this.epochCacheUpdateLoop.isRunning()) {
229
+ this.log.warn(`Validator client already started`);
230
+ return;
231
+ }
202
232
 
203
- const myAddresses = this.getValidatorAddresses();
233
+ await this.registerHandlers();
204
234
 
235
+ const myAddresses = this.getValidatorAddresses();
205
236
  const inCommittee = await this.epochCache.filterInCommittee('now', myAddresses);
237
+ this.log.info(`Started validator with addresses: ${myAddresses.map(a => a.toString()).join(', ')}`);
206
238
  if (inCommittee.length > 0) {
207
- this.log.info(
208
- `Started validator with addresses in current validator committee: ${inCommittee
209
- .map(a => a.toString())
210
- .join(', ')}`,
211
- );
212
- } else {
213
- this.log.info(`Started validator with addresses: ${myAddresses.map(a => a.toString()).join(', ')}`);
239
+ this.log.info(`Addresses in current validator committee: ${inCommittee.map(a => a.toString()).join(', ')}`);
214
240
  }
215
241
  this.epochCacheUpdateLoop.start();
216
242
 
217
- this.p2pClient.registerThisValidatorAddresses(myAddresses);
218
- await this.p2pClient.addReqRespSubProtocol(ReqRespSubProtocol.AUTH, this.handleAuthRequest.bind(this));
219
-
220
243
  return Promise.resolve();
221
244
  }
222
245
 
@@ -224,223 +247,165 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
224
247
  await this.epochCacheUpdateLoop.stop();
225
248
  }
226
249
 
227
- public registerBlockProposalHandler() {
228
- const handler = (block: BlockProposal, proposalSender: PeerId): Promise<BlockAttestation[] | undefined> =>
229
- this.attestToProposal(block, proposalSender);
230
- this.p2pClient.registerBlockProposalHandler(handler);
250
+ /** Register handlers on the p2p client */
251
+ public async registerHandlers() {
252
+ if (!this.hasRegisteredHandlers) {
253
+ this.hasRegisteredHandlers = true;
254
+ this.log.debug(`Registering validator handlers for p2p client`);
255
+
256
+ const handler = (block: BlockProposal, proposalSender: PeerId): Promise<BlockAttestation[] | undefined> =>
257
+ this.attestToProposal(block, proposalSender);
258
+ this.p2pClient.registerBlockProposalHandler(handler);
259
+
260
+ const myAddresses = this.getValidatorAddresses();
261
+ this.p2pClient.registerThisValidatorAddresses(myAddresses);
262
+
263
+ await this.p2pClient.addReqRespSubProtocol(ReqRespSubProtocol.AUTH, this.handleAuthRequest.bind(this));
264
+ }
231
265
  }
232
266
 
267
+ @trackSpan('validator.attestToProposal', (proposal, proposalSender) => ({
268
+ [Attributes.BLOCK_HASH]: proposal.payload.header.hash.toString(),
269
+ [Attributes.PEER_ID]: proposalSender.toString(),
270
+ }))
233
271
  async attestToProposal(proposal: BlockProposal, proposalSender: PeerId): Promise<BlockAttestation[] | undefined> {
234
- const slotNumber = proposal.slotNumber.toBigInt();
235
- const blockNumber = proposal.blockNumber;
272
+ const slotNumber = proposal.slotNumber;
236
273
  const proposer = proposal.getSender();
237
274
 
275
+ // Reject proposals with invalid signatures
276
+ if (!proposer) {
277
+ this.log.warn(`Received proposal with invalid signature for slot ${slotNumber}`);
278
+ return undefined;
279
+ }
280
+
238
281
  // Check that I have any address in current committee before attesting
239
282
  const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
240
283
  const partOfCommittee = inCommittee.length > 0;
241
284
 
242
- const proposalInfo = {
243
- ...proposal.toBlockInfo(),
244
- proposer: proposer.toString(),
245
- };
246
-
285
+ const proposalInfo = { ...proposal.toBlockInfo(), proposer: proposer.toString() };
247
286
  this.log.info(`Received proposal for slot ${slotNumber}`, {
248
287
  ...proposalInfo,
249
- txHashes: proposal.txHashes.map(txHash => txHash.toString()),
250
- });
251
-
252
- // Collect txs from the proposal. Note that we do this before checking if we have an address in the
253
- // current committee, since we want to collect txs anyway to facilitate propagation.
254
- const { txs, missingTxs } = await this.txProvider.getTxsForBlockProposal(proposal, {
255
- pinnedPeer: proposalSender,
256
- deadline: this.getReexecutionDeadline(proposal, this.blockBuilder.getConfig()),
288
+ txHashes: proposal.txHashes.map(t => t.toString()),
289
+ fishermanMode: this.config.fishermanMode || false,
257
290
  });
258
291
 
259
- // Check that I have any address in current committee before attesting
260
- if (!partOfCommittee) {
261
- this.log.verbose(`No validator in the current committee, skipping attestation`, proposalInfo);
262
- return undefined;
263
- }
264
-
265
- // Check that the proposal is from the current proposer, or the next proposer.
266
- // Q: Should this be moved to the block proposal validator, so we disregard proposals from anyone?
267
- const invalidProposal = await this.blockProposalValidator.validate(proposal);
268
- if (invalidProposal) {
269
- this.log.warn(`Proposal is not valid, skipping attestation`, proposalInfo);
270
- if (partOfCommittee) {
271
- this.metrics.incFailedAttestations(1, 'invalid_proposal');
272
- }
273
- return undefined;
274
- }
275
-
276
- // Check that the parent proposal is a block we know, otherwise reexecution would fail.
277
- // Q: Should we move this to the block proposal validator? If there, then p2p would check it
278
- // before re-broadcasting it. This means that proposals built on top of an L1-reorg'ed-out block
279
- // would not be rebroadcasted. But it also means that nodes that have not fully synced would
280
- // not rebroadcast the proposal.
281
- if (blockNumber > INITIAL_L2_BLOCK_NUM) {
282
- const config = this.blockBuilder.getConfig();
283
- const deadline = this.getReexecutionDeadline(proposal, config);
284
- const currentTime = this.dateProvider.now();
285
- const timeoutDurationMs = deadline.getTime() - currentTime;
286
- const parentBlock =
287
- timeoutDurationMs <= 0
288
- ? undefined
289
- : await retryUntil(
290
- async () => {
291
- const block = await this.blockSource.getBlock(blockNumber - 1);
292
- if (block) {
293
- return block;
294
- }
295
- await this.blockSource.syncImmediate();
296
- return await this.blockSource.getBlock(blockNumber - 1);
297
- },
298
- 'Force Archiver Sync',
299
- timeoutDurationMs / 1000, // Continue retrying until the deadline
300
- 0.5, // Retry every 500ms
301
- );
302
-
303
- if (parentBlock === undefined) {
304
- this.log.warn(`Parent block for ${blockNumber} not found, skipping attestation`, proposalInfo);
305
- if (partOfCommittee) {
306
- this.metrics.incFailedAttestations(1, 'parent_block_not_found');
307
- }
308
- return undefined;
309
- }
310
-
311
- if (!proposal.payload.header.lastArchiveRoot.equals(parentBlock.archive.root)) {
312
- this.log.warn(`Parent block archive root for proposal does not match, skipping attestation`, {
313
- proposalLastArchiveRoot: proposal.payload.header.lastArchiveRoot.toString(),
314
- parentBlockArchiveRoot: parentBlock.archive.root.toString(),
315
- ...proposalInfo,
316
- });
317
- if (partOfCommittee) {
318
- this.metrics.incFailedAttestations(1, 'parent_block_does_not_match');
319
- }
320
- return undefined;
321
- }
322
- }
323
-
324
- // Check that I have the same set of l1ToL2Messages as the proposal
325
- // Q: Same as above, should this be part of p2p validation?
326
- const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(blockNumber);
327
- const computedInHash = await computeInHashFromL1ToL2Messages(l1ToL2Messages);
328
- const proposalInHash = proposal.payload.header.contentCommitment.inHash;
329
- if (!computedInHash.equals(proposalInHash)) {
330
- this.log.warn(`L1 to L2 messages in hash mismatch, skipping attestation`, {
331
- proposalInHash: proposalInHash.toString(),
332
- computedInHash: computedInHash.toString(),
333
- ...proposalInfo,
334
- });
335
- if (partOfCommittee) {
336
- this.metrics.incFailedAttestations(1, 'in_hash_mismatch');
337
- }
338
- return undefined;
339
- }
292
+ // Reexecute txs if we are part of the committee so we can attest, or if slashing is enabled so we can slash
293
+ // invalid proposals even when not in the committee, or if we are configured to always reexecute for monitoring purposes.
294
+ // In fisherman mode, we always reexecute to validate proposals.
295
+ const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } =
296
+ this.config;
297
+ const shouldReexecute =
298
+ fishermanMode ||
299
+ (slashBroadcastedInvalidBlockPenalty > 0n && validatorReexecute) ||
300
+ (partOfCommittee && validatorReexecute) ||
301
+ alwaysReexecuteBlockProposals ||
302
+ this.blobClient.canUpload();
303
+
304
+ const validationResult = await this.blockProposalHandler.handleBlockProposal(
305
+ proposal,
306
+ proposalSender,
307
+ !!shouldReexecute,
308
+ );
340
309
 
341
- // Check that all of the transactions in the proposal are available in the tx pool before attesting
342
- if (missingTxs.length > 0) {
343
- this.log.warn(`Missing ${missingTxs.length} txs to attest to proposal`, { ...proposalInfo, missingTxs });
344
- if (partOfCommittee) {
345
- this.metrics.incFailedAttestations(1, 'TransactionsNotAvailableError');
310
+ if (!validationResult.isValid) {
311
+ this.log.warn(`Proposal validation failed: ${validationResult.reason}`, proposalInfo);
312
+
313
+ const reason = validationResult.reason || 'unknown';
314
+ // Classify failure reason: bad proposal vs node issue
315
+ const badProposalReasons: BlockProposalValidationFailureReason[] = [
316
+ 'invalid_proposal',
317
+ 'state_mismatch',
318
+ 'failed_txs',
319
+ 'in_hash_mismatch',
320
+ 'parent_block_wrong_slot',
321
+ ];
322
+
323
+ if (badProposalReasons.includes(reason as BlockProposalValidationFailureReason)) {
324
+ this.metrics.incFailedAttestationsBadProposal(1, reason, partOfCommittee);
325
+ } else {
326
+ // Node issues so we can't attest
327
+ this.metrics.incFailedAttestationsNodeIssue(1, reason, partOfCommittee);
346
328
  }
347
- return undefined;
348
- }
349
329
 
350
- // Try re-executing the transactions in the proposal
351
- try {
352
- this.log.verbose(`Processing attestation for slot ${slotNumber}`, proposalInfo);
353
- if (this.config.validatorReexecute) {
354
- this.log.verbose(`Re-executing transactions in the proposal before attesting`);
355
- await this.reExecuteTransactions(proposal, txs, l1ToL2Messages);
356
- }
357
- } catch (error: any) {
358
- this.metrics.incFailedAttestations(1, error instanceof Error ? error.name : 'unknown');
359
- this.log.error(`Error reexecuting txs while processing block proposal`, error, proposalInfo);
360
- if (error instanceof ReExStateMismatchError && this.config.slashBroadcastedInvalidBlockPenalty > 0n) {
330
+ // Slash invalid block proposals (can happen even when not in committee)
331
+ if (
332
+ validationResult.reason &&
333
+ SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason) &&
334
+ slashBroadcastedInvalidBlockPenalty > 0n
335
+ ) {
361
336
  this.log.warn(`Slashing proposer for invalid block proposal`, proposalInfo);
362
337
  this.slashInvalidBlock(proposal);
363
338
  }
364
339
  return undefined;
365
340
  }
366
341
 
367
- // Provided all of the above checks pass, we can attest to the proposal
368
- this.log.info(`Attesting to proposal for slot ${slotNumber}`, proposalInfo);
369
- this.metrics.incAttestations(inCommittee.length);
370
-
371
- // If the above function does not throw an error, then we can attest to the proposal
372
- return this.doAttestToProposal(proposal, inCommittee);
373
- }
374
-
375
- private getReexecutionDeadline(
376
- proposal: BlockProposal,
377
- config: { l1GenesisTime: bigint; slotDuration: number },
378
- ): Date {
379
- const nextSlotTimestampSeconds = Number(getTimestampForSlot(proposal.slotNumber.toBigInt() + 1n, config));
380
- const msNeededForPropagationAndPublishing = this.config.validatorReexecuteDeadlineMs;
381
- return new Date(nextSlotTimestampSeconds * 1000 - msNeededForPropagationAndPublishing);
382
- }
383
-
384
- /**
385
- * Re-execute the transactions in the proposal and check that the state updates match the header state
386
- * @param proposal - The proposal to re-execute
387
- */
388
- async reExecuteTransactions(proposal: BlockProposal, txs: Tx[], l1ToL2Messages: Fr[]): Promise<void> {
389
- const { header } = proposal.payload;
390
- const { txHashes } = proposal;
391
-
392
- // If we do not have all of the transactions, then we should fail
393
- if (txs.length !== txHashes.length) {
394
- const foundTxHashes = txs.map(tx => tx.getTxHash());
395
- const missingTxHashes = txHashes.filter(txHash => !foundTxHashes.includes(txHash));
396
- throw new TransactionsNotAvailableError(missingTxHashes);
342
+ // Check that I have any address in current committee before attesting
343
+ // In fisherman mode, we still create attestations for validation even if not in committee
344
+ if (!partOfCommittee && !this.config.fishermanMode) {
345
+ this.log.verbose(`No validator in the current committee, skipping attestation`, proposalInfo);
346
+ return undefined;
397
347
  }
398
348
 
399
- // Use the sequencer's block building logic to re-execute the transactions
400
- const timer = new Timer();
401
- const config = this.blockBuilder.getConfig();
402
- const globalVariables = GlobalVariables.from({
403
- ...proposal.payload.header,
404
- blockNumber: proposal.blockNumber,
405
- timestamp: header.timestamp,
406
- chainId: new Fr(config.l1ChainId),
407
- version: new Fr(config.rollupVersion),
408
- });
409
-
410
- const { block, failedTxs } = await this.blockBuilder.buildBlock(txs, l1ToL2Messages, globalVariables, {
411
- deadline: this.getReexecutionDeadline(proposal, config),
349
+ // Provided all of the above checks pass, we can attest to the proposal
350
+ this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} proposal for slot ${slotNumber}`, {
351
+ ...proposalInfo,
352
+ inCommittee: partOfCommittee,
353
+ fishermanMode: this.config.fishermanMode || false,
412
354
  });
413
355
 
414
- this.log.verbose(`Transaction re-execution complete`);
415
- const numFailedTxs = failedTxs.length;
416
-
417
- if (numFailedTxs > 0) {
418
- this.metrics.recordFailedReexecution(proposal);
419
- throw new ReExFailedTxsError(numFailedTxs);
356
+ this.metrics.incSuccessfulAttestations(inCommittee.length);
357
+
358
+ // Upload blobs to filestore after successful re-execution (fire-and-forget)
359
+ if (validationResult.reexecutionResult?.block && this.blobClient.canUpload()) {
360
+ void Promise.resolve().then(async () => {
361
+ try {
362
+ const blobFields = validationResult.reexecutionResult!.block.getCheckpointBlobFields();
363
+ const blobs = getBlobsPerL1Block(blobFields);
364
+ await this.blobClient.sendBlobsToFilestore(blobs);
365
+ this.log.debug(`Uploaded ${blobs.length} blobs to filestore from re-execution`, proposalInfo);
366
+ } catch (err) {
367
+ this.log.warn(`Failed to upload blobs from re-execution`, err);
368
+ }
369
+ });
420
370
  }
421
371
 
422
- if (block.body.txEffects.length !== txHashes.length) {
423
- this.metrics.recordFailedReexecution(proposal);
424
- throw new ReExTimeoutError();
372
+ // If the above function does not throw an error, then we can attest to the proposal
373
+ // Determine which validators should attest
374
+ let attestors: EthAddress[];
375
+ if (partOfCommittee) {
376
+ attestors = inCommittee;
377
+ } else if (this.config.fishermanMode) {
378
+ // In fisherman mode, create attestations for validation purposes even if not in committee. These won't be broadcast.
379
+ attestors = this.getValidatorAddresses();
380
+ } else {
381
+ attestors = [];
425
382
  }
426
383
 
427
- // This function will throw an error if state updates do not match
428
- if (!block.archive.root.equals(proposal.archive)) {
429
- this.metrics.recordFailedReexecution(proposal);
430
- throw new ReExStateMismatchError(
431
- proposal.archive,
432
- block.archive.root,
433
- proposal.payload.stateReference,
434
- block.header.state,
435
- );
384
+ // Only create attestations if we have attestors
385
+ if (attestors.length === 0) {
386
+ return undefined;
436
387
  }
437
388
 
438
- this.metrics.recordReex(timer.ms(), txs.length, block.header.totalManaUsed.toNumber() / 1e6);
389
+ if (this.config.fishermanMode) {
390
+ // bail out early and don't save attestations to the pool in fisherman mode
391
+ this.log.info(`Creating attestations for proposal for slot ${slotNumber}`, {
392
+ ...proposalInfo,
393
+ attestors: attestors.map(a => a.toString()),
394
+ });
395
+ return undefined;
396
+ }
397
+ return this.createBlockAttestationsFromProposal(proposal, attestors);
439
398
  }
440
399
 
441
400
  private slashInvalidBlock(proposal: BlockProposal) {
442
401
  const proposer = proposal.getSender();
443
402
 
403
+ // Skip if signature is invalid (shouldn't happen since we validate earlier)
404
+ if (!proposer) {
405
+ this.log.warn(`Cannot slash proposal with invalid signature`);
406
+ return;
407
+ }
408
+
444
409
  // Trim the set if it's too big.
445
410
  if (this.proposersOfInvalidBlocks.size > MAX_PROPOSERS_OF_INVALID_BLOCKS) {
446
411
  // remove oldest proposer. `values` is guaranteed to be in insertion order.
@@ -454,52 +419,76 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
454
419
  validator: proposer,
455
420
  amount: this.config.slashBroadcastedInvalidBlockPenalty,
456
421
  offenseType: OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL,
457
- epochOrSlot: proposal.slotNumber.toBigInt(),
422
+ epochOrSlot: BigInt(proposal.slotNumber),
458
423
  },
459
424
  ]);
460
425
  }
461
426
 
427
+ // TODO(palla/mbps): Block proposal should not require a checkpoint proposal
462
428
  async createBlockProposal(
463
- blockNumber: number,
464
- header: ProposedBlockHeader,
429
+ blockNumber: BlockNumber,
430
+ header: CheckpointHeader,
465
431
  archive: Fr,
466
- stateReference: StateReference,
467
432
  txs: Tx[],
468
433
  proposerAddress: EthAddress | undefined,
469
434
  options: BlockProposalOptions,
470
- ): Promise<BlockProposal | undefined> {
471
- if (this.previousProposal?.slotNumber.equals(header.slotNumber)) {
472
- this.log.verbose(`Already made a proposal for the same slot, skipping proposal`);
473
- return Promise.resolve(undefined);
474
- }
475
-
476
- const newProposal = await this.validationService.createBlockProposal(
477
- blockNumber,
478
- header,
479
- archive,
480
- stateReference,
481
- txs,
482
- proposerAddress,
483
- options,
484
- );
435
+ ): Promise<BlockProposal> {
436
+ // TODO(palla/mbps): Prevent double proposals properly
437
+ // if (this.previousProposal?.slotNumber === header.slotNumber) {
438
+ // this.log.verbose(`Already made a proposal for the same slot, skipping proposal`);
439
+ // return Promise.resolve(undefined);
440
+ // }
441
+
442
+ this.log.info(`Assembling block proposal for block ${blockNumber} slot ${header.slotNumber}`);
443
+ const newProposal = await this.validationService.createBlockProposal(header, archive, txs, proposerAddress, {
444
+ ...options,
445
+ broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
446
+ });
485
447
  this.previousProposal = newProposal;
486
448
  return newProposal;
487
449
  }
488
450
 
451
+ // TODO(palla/mbps): Effectively create a checkpoint proposal different from a block proposal
452
+ createCheckpointProposal(
453
+ header: CheckpointHeader,
454
+ archive: Fr,
455
+ txs: Tx[],
456
+ proposerAddress: EthAddress | undefined,
457
+ options: BlockProposalOptions,
458
+ ): Promise<BlockProposal> {
459
+ this.log.info(`Assembling checkpoint proposal for slot ${header.slotNumber}`);
460
+ return this.createBlockProposal(0 as BlockNumber, header, archive, txs, proposerAddress, options);
461
+ }
462
+
489
463
  async broadcastBlockProposal(proposal: BlockProposal): Promise<void> {
490
464
  await this.p2pClient.broadcastProposal(proposal);
491
465
  }
492
466
 
467
+ async signAttestationsAndSigners(
468
+ attestationsAndSigners: CommitteeAttestationsAndSigners,
469
+ proposer: EthAddress,
470
+ ): Promise<Signature> {
471
+ return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer);
472
+ }
473
+
493
474
  async collectOwnAttestations(proposal: BlockProposal): Promise<BlockAttestation[]> {
494
- const slot = proposal.payload.header.slotNumber.toBigInt();
475
+ const slot = proposal.payload.header.slotNumber;
495
476
  const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
496
477
  this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
497
- return this.doAttestToProposal(proposal, inCommittee);
478
+ const attestations = await this.createBlockAttestationsFromProposal(proposal, inCommittee);
479
+
480
+ // We broadcast our own attestations to our peers so, in case our block does not get mined on L1,
481
+ // other nodes can see that our validators did attest to this block proposal, and do not slash us
482
+ // due to inactivity for missed attestations.
483
+ void this.p2pClient.broadcastAttestations(attestations).catch(err => {
484
+ this.log.error(`Failed to broadcast self-attestations for slot ${slot}`, err);
485
+ });
486
+ return attestations;
498
487
  }
499
488
 
500
489
  async collectAttestations(proposal: BlockProposal, required: number, deadline: Date): Promise<BlockAttestation[]> {
501
490
  // Wait and poll the p2pClient's attestation pool for this block until we have enough attestations
502
- const slot = proposal.payload.header.slotNumber.toBigInt();
491
+ const slot = proposal.payload.header.slotNumber;
503
492
  this.log.debug(`Collecting ${required} attestations for slot ${slot} with deadline ${deadline.toISOString()}`);
504
493
 
505
494
  if (+deadline < this.dateProvider.now()) {
@@ -516,13 +505,33 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
516
505
 
517
506
  let attestations: BlockAttestation[] = [];
518
507
  while (true) {
519
- const collectedAttestations = await this.p2pClient.getAttestationsForSlot(slot, proposalId);
508
+ // Filter out attestations with a mismatching payload. This should NOT happen since we have verified
509
+ // the proposer signature (ie our own) before accepting the attestation into the pool via the p2p client.
510
+ const collectedAttestations = (await this.p2pClient.getAttestationsForSlot(slot, proposalId)).filter(
511
+ attestation => {
512
+ if (!attestation.payload.equals(proposal.payload)) {
513
+ this.log.warn(
514
+ `Received attestation for slot ${slot} with mismatched payload from ${attestation.getSender()?.toString()}`,
515
+ { attestationPayload: attestation.payload, proposalPayload: proposal.payload },
516
+ );
517
+ return false;
518
+ }
519
+ return true;
520
+ },
521
+ );
522
+
523
+ // Log new attestations we collected
520
524
  const oldSenders = attestations.map(attestation => attestation.getSender());
521
525
  for (const collected of collectedAttestations) {
522
526
  const collectedSender = collected.getSender();
527
+ // Skip attestations with invalid signatures
528
+ if (!collectedSender) {
529
+ this.log.warn(`Skipping attestation with invalid signature for slot ${slot}`);
530
+ continue;
531
+ }
523
532
  if (
524
533
  !myAddresses.some(address => address.equals(collectedSender)) &&
525
- !oldSenders.some(sender => sender.equals(collectedSender))
534
+ !oldSenders.some(sender => sender?.equals(collectedSender))
526
535
  ) {
527
536
  this.log.debug(`Received attestation for slot ${slot} from ${collectedSender.toString()}`);
528
537
  }
@@ -539,12 +548,15 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
539
548
  throw new AttestationTimeoutError(attestations.length, required, slot);
540
549
  }
541
550
 
542
- this.log.debug(`Collected ${attestations.length} attestations so far`);
551
+ this.log.debug(`Collected ${attestations.length} of ${required} attestations so far`);
543
552
  await sleep(this.config.attestationPollingIntervalMs);
544
553
  }
545
554
  }
546
555
 
547
- private async doAttestToProposal(proposal: BlockProposal, attestors: EthAddress[] = []): Promise<BlockAttestation[]> {
556
+ private async createBlockAttestationsFromProposal(
557
+ proposal: BlockProposal,
558
+ attestors: EthAddress[] = [],
559
+ ): Promise<BlockAttestation[]> {
548
560
  const attestations = await this.validationService.attestToProposal(proposal, attestors);
549
561
  await this.p2pClient.addAttestations(attestations);
550
562
  return attestations;
@@ -573,5 +585,3 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
573
585
  return authResponse.toBuffer();
574
586
  }
575
587
  }
576
-
577
- // Conversion helpers moved into NodeKeystoreAdapter.