@aztec/validator-client 3.0.0-canary.a9708bd → 3.0.0-devnet.2-patch.1

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