@aztec/validator-client 0.0.1-commit.3469e52 → 0.0.1-commit.35158ae7e
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.
- package/README.md +63 -19
- package/dest/block_proposal_handler.d.ts +9 -10
- package/dest/block_proposal_handler.d.ts.map +1 -1
- package/dest/block_proposal_handler.js +135 -82
- package/dest/checkpoint_builder.d.ts +27 -15
- package/dest/checkpoint_builder.d.ts.map +1 -1
- package/dest/checkpoint_builder.js +136 -45
- package/dest/config.d.ts +1 -1
- package/dest/config.d.ts.map +1 -1
- package/dest/config.js +30 -7
- package/dest/duties/validation_service.d.ts +2 -2
- package/dest/duties/validation_service.d.ts.map +1 -1
- package/dest/duties/validation_service.js +6 -12
- package/dest/factory.d.ts +3 -1
- package/dest/factory.d.ts.map +1 -1
- package/dest/factory.js +3 -2
- package/dest/index.d.ts +1 -2
- package/dest/index.d.ts.map +1 -1
- package/dest/index.js +0 -1
- package/dest/key_store/ha_key_store.d.ts +1 -1
- package/dest/key_store/ha_key_store.d.ts.map +1 -1
- package/dest/key_store/ha_key_store.js +3 -3
- package/dest/metrics.d.ts +12 -3
- package/dest/metrics.d.ts.map +1 -1
- package/dest/metrics.js +46 -5
- package/dest/validator.d.ts +41 -15
- package/dest/validator.d.ts.map +1 -1
- package/dest/validator.js +230 -69
- package/package.json +19 -17
- package/src/block_proposal_handler.ts +165 -109
- package/src/checkpoint_builder.ts +185 -52
- package/src/config.ts +30 -7
- package/src/duties/validation_service.ts +12 -11
- package/src/factory.ts +4 -0
- package/src/index.ts +0 -1
- package/src/key_store/ha_key_store.ts +3 -3
- package/src/metrics.ts +63 -6
- package/src/validator.ts +294 -86
- package/dest/tx_validator/index.d.ts +0 -3
- package/dest/tx_validator/index.d.ts.map +0 -1
- package/dest/tx_validator/index.js +0 -2
- package/dest/tx_validator/nullifier_cache.d.ts +0 -14
- package/dest/tx_validator/nullifier_cache.d.ts.map +0 -1
- package/dest/tx_validator/nullifier_cache.js +0 -24
- package/dest/tx_validator/tx_validator_factory.d.ts +0 -18
- package/dest/tx_validator/tx_validator_factory.d.ts.map +0 -1
- package/dest/tx_validator/tx_validator_factory.js +0 -54
- package/src/tx_validator/index.ts +0 -2
- package/src/tx_validator/nullifier_cache.ts +0 -30
- package/src/tx_validator/tx_validator_factory.ts +0 -135
package/src/validator.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { BlobClientInterface } from '@aztec/blob-client/client';
|
|
2
2
|
import { type Blob, getBlobsPerL1Block } from '@aztec/blob-lib';
|
|
3
3
|
import type { EpochCache } from '@aztec/epoch-cache';
|
|
4
|
+
import { validateFeeAssetPriceModifier } from '@aztec/ethereum/contracts';
|
|
4
5
|
import {
|
|
5
6
|
BlockNumber,
|
|
6
7
|
CheckpointNumber,
|
|
@@ -18,33 +19,40 @@ import { RunningPromise } from '@aztec/foundation/running-promise';
|
|
|
18
19
|
import { sleep } from '@aztec/foundation/sleep';
|
|
19
20
|
import { DateProvider } from '@aztec/foundation/timer';
|
|
20
21
|
import type { KeystoreManager } from '@aztec/node-keystore';
|
|
21
|
-
import type { P2P, PeerId
|
|
22
|
+
import type { DuplicateAttestationInfo, DuplicateProposalInfo, P2P, PeerId } from '@aztec/p2p';
|
|
22
23
|
import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
|
|
23
24
|
import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher';
|
|
24
25
|
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
|
|
25
|
-
import type { CommitteeAttestationsAndSigners,
|
|
26
|
-
import {
|
|
26
|
+
import type { CommitteeAttestationsAndSigners, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
|
|
27
|
+
import { validateCheckpoint } from '@aztec/stdlib/checkpoint';
|
|
28
|
+
import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
|
|
27
29
|
import type {
|
|
28
30
|
CreateCheckpointProposalLastBlockData,
|
|
31
|
+
ITxProvider,
|
|
29
32
|
Validator,
|
|
30
33
|
ValidatorClientFullConfig,
|
|
31
34
|
WorldStateSynchronizer,
|
|
32
35
|
} from '@aztec/stdlib/interfaces/server';
|
|
33
|
-
import type
|
|
34
|
-
import
|
|
35
|
-
BlockProposal,
|
|
36
|
-
BlockProposalOptions,
|
|
37
|
-
CheckpointAttestation,
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
import { type L1ToL2MessageSource, accumulateCheckpointOutHashes } from '@aztec/stdlib/messaging';
|
|
37
|
+
import {
|
|
38
|
+
type BlockProposal,
|
|
39
|
+
type BlockProposalOptions,
|
|
40
|
+
type CheckpointAttestation,
|
|
41
|
+
CheckpointProposal,
|
|
42
|
+
type CheckpointProposalCore,
|
|
43
|
+
type CheckpointProposalOptions,
|
|
40
44
|
} from '@aztec/stdlib/p2p';
|
|
41
|
-
import { CheckpointProposal } from '@aztec/stdlib/p2p';
|
|
42
45
|
import type { CheckpointHeader } from '@aztec/stdlib/rollup';
|
|
43
46
|
import type { BlockHeader, CheckpointGlobalVariables, Tx } from '@aztec/stdlib/tx';
|
|
44
47
|
import { AttestationTimeoutError } from '@aztec/stdlib/validators';
|
|
45
48
|
import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client';
|
|
46
|
-
import {
|
|
47
|
-
|
|
49
|
+
import {
|
|
50
|
+
createHASigner,
|
|
51
|
+
createLocalSignerWithProtection,
|
|
52
|
+
createSignerFromSharedDb,
|
|
53
|
+
} from '@aztec/validator-ha-signer/factory';
|
|
54
|
+
import { DutyType, type SigningContext, type SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types';
|
|
55
|
+
import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
|
|
48
56
|
|
|
49
57
|
import { EventEmitter } from 'events';
|
|
50
58
|
import type { TypedDataDefinition } from 'viem';
|
|
@@ -75,22 +83,24 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
75
83
|
private validationService: ValidationService;
|
|
76
84
|
private metrics: ValidatorMetrics;
|
|
77
85
|
private log: Logger;
|
|
78
|
-
|
|
79
86
|
// Whether it has already registered handlers on the p2p client
|
|
80
87
|
private hasRegisteredHandlers = false;
|
|
81
88
|
|
|
82
|
-
|
|
83
|
-
private
|
|
89
|
+
/** Tracks the last block proposal we created, to detect duplicate proposal attempts. */
|
|
90
|
+
private lastProposedBlock?: BlockProposal;
|
|
91
|
+
|
|
92
|
+
/** Tracks the last checkpoint proposal we created. */
|
|
93
|
+
private lastProposedCheckpoint?: CheckpointProposal;
|
|
84
94
|
|
|
85
95
|
private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined;
|
|
86
96
|
private epochCacheUpdateLoop: RunningPromise;
|
|
97
|
+
/** Tracks the last epoch in which each attester successfully submitted at least one attestation. */
|
|
98
|
+
private lastAttestedEpochByAttester: Map<string, EpochNumber> = new Map();
|
|
87
99
|
|
|
88
100
|
private proposersOfInvalidBlocks: Set<string> = new Set();
|
|
89
101
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// eslint-disable-next-line aztec-custom/no-non-primitive-in-collections
|
|
93
|
-
private validatedBlockSlots: Set<SlotNumber> = new Set();
|
|
102
|
+
/** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */
|
|
103
|
+
private lastAttestedProposal?: CheckpointProposalCore;
|
|
94
104
|
|
|
95
105
|
protected constructor(
|
|
96
106
|
private keyStore: ExtendedValidatorKeyStore,
|
|
@@ -103,6 +113,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
103
113
|
private l1ToL2MessageSource: L1ToL2MessageSource,
|
|
104
114
|
private config: ValidatorClientFullConfig,
|
|
105
115
|
private blobClient: BlobClientInterface,
|
|
116
|
+
private slashingProtectionSigner: ValidatorHASigner,
|
|
106
117
|
private dateProvider: DateProvider = new DateProvider(),
|
|
107
118
|
telemetry: TelemetryClient = getTelemetryClient(),
|
|
108
119
|
log = createLogger('validator'),
|
|
@@ -156,6 +167,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
156
167
|
this.log.trace(`No committee found for slot`);
|
|
157
168
|
return;
|
|
158
169
|
}
|
|
170
|
+
this.metrics.setCurrentEpoch(epoch);
|
|
159
171
|
if (epoch !== this.lastEpochForCommitteeUpdateLoop) {
|
|
160
172
|
const me = this.getValidatorAddresses();
|
|
161
173
|
const committeeSet = new Set(committee.map(v => v.toString()));
|
|
@@ -184,15 +196,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
184
196
|
p2pClient: P2P,
|
|
185
197
|
blockSource: L2BlockSource & L2BlockSink,
|
|
186
198
|
l1ToL2MessageSource: L1ToL2MessageSource,
|
|
187
|
-
txProvider:
|
|
199
|
+
txProvider: ITxProvider,
|
|
188
200
|
keyStoreManager: KeystoreManager,
|
|
189
201
|
blobClient: BlobClientInterface,
|
|
190
202
|
dateProvider: DateProvider = new DateProvider(),
|
|
191
203
|
telemetry: TelemetryClient = getTelemetryClient(),
|
|
204
|
+
slashingProtectionDb?: SlashingProtectionDatabase,
|
|
192
205
|
) {
|
|
193
206
|
const metrics = new ValidatorMetrics(telemetry);
|
|
194
207
|
const blockProposalValidator = new BlockProposalValidator(epochCache, {
|
|
195
208
|
txsPermitted: !config.disableTransactions,
|
|
209
|
+
maxTxsPerBlock: config.validateMaxTxsPerBlock,
|
|
196
210
|
});
|
|
197
211
|
const blockProposalHandler = new BlockProposalHandler(
|
|
198
212
|
checkpointsBuilder,
|
|
@@ -208,16 +222,34 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
208
222
|
telemetry,
|
|
209
223
|
);
|
|
210
224
|
|
|
211
|
-
|
|
212
|
-
|
|
225
|
+
const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
|
|
226
|
+
let slashingProtectionSigner: ValidatorHASigner;
|
|
227
|
+
if (slashingProtectionDb) {
|
|
228
|
+
// Shared database mode: use a pre-existing database (e.g. for testing HA setups).
|
|
229
|
+
({ signer: slashingProtectionSigner } = createSignerFromSharedDb(slashingProtectionDb, config, {
|
|
230
|
+
telemetryClient: telemetry,
|
|
231
|
+
dateProvider,
|
|
232
|
+
}));
|
|
233
|
+
} else if (config.haSigningEnabled) {
|
|
234
|
+
// Multi-node HA mode: use PostgreSQL-backed distributed locking.
|
|
213
235
|
// If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
|
|
214
236
|
const haConfig = {
|
|
215
237
|
...config,
|
|
216
238
|
maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
|
|
217
239
|
};
|
|
218
|
-
|
|
219
|
-
|
|
240
|
+
({ signer: slashingProtectionSigner } = await createHASigner(haConfig, {
|
|
241
|
+
telemetryClient: telemetry,
|
|
242
|
+
dateProvider,
|
|
243
|
+
}));
|
|
244
|
+
} else {
|
|
245
|
+
// Single-node mode: use LMDB-backed local signing protection.
|
|
246
|
+
// This prevents double-signing if the node crashes and restarts mid-proposal.
|
|
247
|
+
({ signer: slashingProtectionSigner } = await createLocalSignerWithProtection(config, {
|
|
248
|
+
telemetryClient: telemetry,
|
|
249
|
+
dateProvider,
|
|
250
|
+
}));
|
|
220
251
|
}
|
|
252
|
+
const validatorKeyStore: ExtendedValidatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
|
|
221
253
|
|
|
222
254
|
const validator = new ValidatorClient(
|
|
223
255
|
validatorKeyStore,
|
|
@@ -230,6 +262,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
230
262
|
l1ToL2MessageSource,
|
|
231
263
|
config,
|
|
232
264
|
blobClient,
|
|
265
|
+
slashingProtectionSigner,
|
|
233
266
|
dateProvider,
|
|
234
267
|
telemetry,
|
|
235
268
|
);
|
|
@@ -267,6 +300,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
267
300
|
this.config = { ...this.config, ...config };
|
|
268
301
|
}
|
|
269
302
|
|
|
303
|
+
public reloadKeystore(newManager: KeystoreManager): void {
|
|
304
|
+
const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
|
|
305
|
+
this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
|
|
306
|
+
this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
|
|
307
|
+
}
|
|
308
|
+
|
|
270
309
|
public async start() {
|
|
271
310
|
if (this.epochCacheUpdateLoop.isRunning()) {
|
|
272
311
|
this.log.warn(`Validator client already started`);
|
|
@@ -313,6 +352,16 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
313
352
|
): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
|
|
314
353
|
this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
|
|
315
354
|
|
|
355
|
+
// Duplicate proposal handler - triggers slashing for equivocation
|
|
356
|
+
this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
|
|
357
|
+
this.handleDuplicateProposal(info);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Duplicate attestation handler - triggers slashing for attestation equivocation
|
|
361
|
+
this.p2pClient.registerDuplicateAttestationCallback((info: DuplicateAttestationInfo) => {
|
|
362
|
+
this.handleDuplicateAttestation(info);
|
|
363
|
+
});
|
|
364
|
+
|
|
316
365
|
const myAddresses = this.getValidatorAddresses();
|
|
317
366
|
this.p2pClient.registerThisValidatorAddresses(myAddresses);
|
|
318
367
|
|
|
@@ -340,6 +389,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
340
389
|
return false;
|
|
341
390
|
}
|
|
342
391
|
|
|
392
|
+
// Log self-proposals from HA peers (same validator key on different nodes)
|
|
393
|
+
if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
394
|
+
this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
|
|
395
|
+
proposer: proposer.toString(),
|
|
396
|
+
slotNumber,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
343
400
|
// Check if we're in the committee (for metrics purposes)
|
|
344
401
|
const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
|
|
345
402
|
const partOfCommittee = inCommittee.length > 0;
|
|
@@ -369,9 +426,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
369
426
|
);
|
|
370
427
|
|
|
371
428
|
if (!validationResult.isValid) {
|
|
372
|
-
this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
373
|
-
|
|
374
429
|
const reason = validationResult.reason || 'unknown';
|
|
430
|
+
|
|
431
|
+
this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
|
|
432
|
+
|
|
375
433
|
// Classify failure reason: bad proposal vs node issue
|
|
376
434
|
const badProposalReasons: BlockProposalValidationFailureReason[] = [
|
|
377
435
|
'invalid_proposal',
|
|
@@ -413,10 +471,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
413
471
|
return false;
|
|
414
472
|
}
|
|
415
473
|
|
|
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
474
|
return true;
|
|
421
475
|
}
|
|
422
476
|
|
|
@@ -430,48 +484,55 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
430
484
|
proposal: CheckpointProposalCore,
|
|
431
485
|
_proposalSender: PeerId,
|
|
432
486
|
): Promise<CheckpointAttestation[] | undefined> {
|
|
433
|
-
const
|
|
487
|
+
const proposalSlotNumber = proposal.slotNumber;
|
|
434
488
|
const proposer = proposal.getSender();
|
|
435
489
|
|
|
436
490
|
// If escape hatch is open for this slot's epoch, do not attest.
|
|
437
|
-
if (await this.epochCache.isEscapeHatchOpenAtSlot(
|
|
438
|
-
this.log.warn(`Escape hatch open for slot ${
|
|
491
|
+
if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
|
|
492
|
+
this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
|
|
439
493
|
return undefined;
|
|
440
494
|
}
|
|
441
495
|
|
|
442
496
|
// Reject proposals with invalid signatures
|
|
443
497
|
if (!proposer) {
|
|
444
|
-
this.log.warn(`Received checkpoint proposal with invalid signature for slot ${
|
|
498
|
+
this.log.warn(`Received checkpoint proposal with invalid signature for proposal slot ${proposalSlotNumber}`);
|
|
445
499
|
return undefined;
|
|
446
500
|
}
|
|
447
501
|
|
|
448
|
-
//
|
|
449
|
-
|
|
502
|
+
// Ignore proposals from ourselves (may happen in HA setups)
|
|
503
|
+
if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
504
|
+
this.log.debug(`Ignoring block proposal from self for slot ${proposalSlotNumber}`, {
|
|
505
|
+
proposer: proposer.toString(),
|
|
506
|
+
proposalSlotNumber,
|
|
507
|
+
});
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Validate fee asset price modifier is within allowed range
|
|
512
|
+
if (!validateFeeAssetPriceModifier(proposal.feeAssetPriceModifier)) {
|
|
513
|
+
this.log.warn(
|
|
514
|
+
`Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${proposalSlotNumber}`,
|
|
515
|
+
);
|
|
516
|
+
return undefined;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Check that I have any address in the committee where this checkpoint will land before attesting
|
|
520
|
+
const inCommittee = await this.epochCache.filterInCommittee(proposalSlotNumber, this.getValidatorAddresses());
|
|
450
521
|
const partOfCommittee = inCommittee.length > 0;
|
|
451
522
|
|
|
452
523
|
const proposalInfo = {
|
|
453
|
-
|
|
524
|
+
proposalSlotNumber,
|
|
454
525
|
archive: proposal.archive.toString(),
|
|
455
526
|
proposer: proposer.toString(),
|
|
456
|
-
txCount: proposal.txHashes.length,
|
|
457
527
|
};
|
|
458
|
-
this.log.info(`Received checkpoint proposal for slot ${
|
|
528
|
+
this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
|
|
459
529
|
...proposalInfo,
|
|
460
|
-
txHashes: proposal.txHashes.map(t => t.toString()),
|
|
461
530
|
fishermanMode: this.config.fishermanMode || false,
|
|
462
531
|
});
|
|
463
532
|
|
|
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
533
|
// Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
this.log.verbose(`Skipping checkpoint proposal validation for slot ${slotNumber}`, proposalInfo);
|
|
534
|
+
if (this.config.skipCheckpointProposalValidation) {
|
|
535
|
+
this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
|
|
475
536
|
} else {
|
|
476
537
|
const validationResult = await this.validateCheckpointProposal(proposal, proposalInfo);
|
|
477
538
|
if (!validationResult.isValid) {
|
|
@@ -493,14 +554,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
493
554
|
}
|
|
494
555
|
|
|
495
556
|
// Provided all of the above checks pass, we can attest to the proposal
|
|
496
|
-
this.log.info(
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
557
|
+
this.log.info(
|
|
558
|
+
`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${proposalSlotNumber}`,
|
|
559
|
+
{
|
|
560
|
+
...proposalInfo,
|
|
561
|
+
inCommittee: partOfCommittee,
|
|
562
|
+
fishermanMode: this.config.fishermanMode || false,
|
|
563
|
+
},
|
|
564
|
+
);
|
|
501
565
|
|
|
502
566
|
this.metrics.incSuccessfulAttestations(inCommittee.length);
|
|
503
567
|
|
|
568
|
+
// Track epoch participation per attester: count each (attester, epoch) pair at most once
|
|
569
|
+
const proposalEpoch = getEpochAtSlot(proposalSlotNumber, this.epochCache.getL1Constants());
|
|
570
|
+
for (const attester of inCommittee) {
|
|
571
|
+
const key = attester.toString();
|
|
572
|
+
const lastEpoch = this.lastAttestedEpochByAttester.get(key);
|
|
573
|
+
if (lastEpoch === undefined || proposalEpoch > lastEpoch) {
|
|
574
|
+
this.lastAttestedEpochByAttester.set(key, proposalEpoch);
|
|
575
|
+
this.metrics.incAttestedEpochCount(attester);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
504
579
|
// Determine which validators should attest
|
|
505
580
|
let attestors: EthAddress[];
|
|
506
581
|
if (partOfCommittee) {
|
|
@@ -519,22 +594,52 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
519
594
|
|
|
520
595
|
if (this.config.fishermanMode) {
|
|
521
596
|
// bail out early and don't save attestations to the pool in fisherman mode
|
|
522
|
-
this.log.info(`Creating checkpoint attestations for slot ${
|
|
597
|
+
this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
|
|
523
598
|
...proposalInfo,
|
|
524
599
|
attestors: attestors.map(a => a.toString()),
|
|
525
600
|
});
|
|
526
601
|
return undefined;
|
|
527
602
|
}
|
|
528
603
|
|
|
529
|
-
return this.createCheckpointAttestationsFromProposal(proposal, attestors);
|
|
604
|
+
return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Checks if we should attest to a slot based on equivocation prevention rules.
|
|
609
|
+
* @returns true if we should attest, false if we should skip
|
|
610
|
+
*/
|
|
611
|
+
private shouldAttestToSlot(slotNumber: SlotNumber): boolean {
|
|
612
|
+
// If attestToEquivocatedProposals is true, always allow
|
|
613
|
+
if (this.config.attestToEquivocatedProposals) {
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Check if incoming slot is strictly greater than last attested
|
|
618
|
+
if (this.lastAttestedProposal && slotNumber <= this.lastAttestedProposal.slotNumber) {
|
|
619
|
+
this.log.warn(
|
|
620
|
+
`Refusing to process a proposal for slot ${slotNumber} given we already attested to a proposal for slot ${this.lastAttestedProposal.slotNumber}`,
|
|
621
|
+
);
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return true;
|
|
530
626
|
}
|
|
531
627
|
|
|
532
628
|
private async createCheckpointAttestationsFromProposal(
|
|
533
629
|
proposal: CheckpointProposalCore,
|
|
534
630
|
attestors: EthAddress[] = [],
|
|
535
|
-
): Promise<CheckpointAttestation[]> {
|
|
631
|
+
): Promise<CheckpointAttestation[] | undefined> {
|
|
632
|
+
// Equivocation check: must happen right before signing to minimize the race window
|
|
633
|
+
if (!this.shouldAttestToSlot(proposal.slotNumber)) {
|
|
634
|
+
return undefined;
|
|
635
|
+
}
|
|
636
|
+
|
|
536
637
|
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
|
|
537
|
-
|
|
638
|
+
|
|
639
|
+
// Track the proposal we attested to (to prevent equivocation)
|
|
640
|
+
this.lastAttestedProposal = proposal;
|
|
641
|
+
|
|
642
|
+
await this.p2pClient.addOwnCheckpointAttestations(attestations);
|
|
538
643
|
return attestations;
|
|
539
644
|
}
|
|
540
645
|
|
|
@@ -547,7 +652,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
547
652
|
proposalInfo: LogData,
|
|
548
653
|
): Promise<{ isValid: true } | { isValid: false; reason: string }> {
|
|
549
654
|
const slot = proposal.slotNumber;
|
|
550
|
-
|
|
655
|
+
|
|
656
|
+
// Timeout block syncing at the start of the next slot
|
|
657
|
+
const config = this.checkpointsBuilder.getConfig();
|
|
658
|
+
const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config));
|
|
659
|
+
const timeoutSeconds = Math.max(1, nextSlotTimestampSeconds - Math.floor(this.dateProvider.now() / 1000));
|
|
551
660
|
|
|
552
661
|
// Wait for last block to sync by archive
|
|
553
662
|
let lastBlockHeader: BlockHeader | undefined;
|
|
@@ -582,6 +691,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
582
691
|
return { isValid: false, reason: 'no_blocks_for_slot' };
|
|
583
692
|
}
|
|
584
693
|
|
|
694
|
+
// Ensure the last block for this slot matches the archive in the checkpoint proposal
|
|
695
|
+
if (!blocks.at(-1)?.archive.root.equals(proposal.archive)) {
|
|
696
|
+
this.log.warn(`Last block archive mismatch for checkpoint proposal`, proposalInfo);
|
|
697
|
+
return { isValid: false, reason: 'last_block_archive_mismatch' };
|
|
698
|
+
}
|
|
699
|
+
|
|
585
700
|
this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
|
|
586
701
|
...proposalInfo,
|
|
587
702
|
blockNumbers: blocks.map(b => b.number),
|
|
@@ -595,14 +710,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
595
710
|
// Get L1-to-L2 messages for this checkpoint
|
|
596
711
|
const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
|
|
597
712
|
|
|
598
|
-
//
|
|
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.
|
|
713
|
+
// Collect the out hashes of all the checkpoints before this one in the same epoch
|
|
601
714
|
const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
|
|
602
|
-
const
|
|
603
|
-
.filter(
|
|
604
|
-
.
|
|
605
|
-
const previousCheckpointOutHashes = previousCheckpoints.map(c => c.getCheckpointOutHash());
|
|
715
|
+
const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsDataForEpoch(epoch))
|
|
716
|
+
.filter(c => c.checkpointNumber < checkpointNumber)
|
|
717
|
+
.map(c => c.checkpointOutHash);
|
|
606
718
|
|
|
607
719
|
// Fork world state at the block before the first block
|
|
608
720
|
const parentBlockNumber = BlockNumber(firstBlock.number - 1);
|
|
@@ -613,10 +725,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
613
725
|
const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
|
|
614
726
|
checkpointNumber,
|
|
615
727
|
constants,
|
|
728
|
+
proposal.feeAssetPriceModifier,
|
|
616
729
|
l1ToL2Messages,
|
|
617
730
|
previousCheckpointOutHashes,
|
|
618
731
|
fork,
|
|
619
732
|
blocks,
|
|
733
|
+
this.log.getBindings(),
|
|
620
734
|
);
|
|
621
735
|
|
|
622
736
|
// Complete the checkpoint to get computed values
|
|
@@ -642,18 +756,36 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
642
756
|
return { isValid: false, reason: 'archive_mismatch' };
|
|
643
757
|
}
|
|
644
758
|
|
|
645
|
-
// Check that the accumulated out hash matches the value in the proposal.
|
|
646
|
-
|
|
647
|
-
const
|
|
648
|
-
|
|
759
|
+
// Check that the accumulated epoch out hash matches the value in the proposal.
|
|
760
|
+
// The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
|
|
761
|
+
const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
|
|
762
|
+
const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
|
|
763
|
+
const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
|
|
764
|
+
if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
|
|
649
765
|
this.log.warn(`Epoch out hash mismatch`, {
|
|
650
|
-
|
|
651
|
-
|
|
766
|
+
proposalEpochOutHash: proposalEpochOutHash.toString(),
|
|
767
|
+
computedEpochOutHash: computedEpochOutHash.toString(),
|
|
768
|
+
checkpointOutHash: checkpointOutHash.toString(),
|
|
769
|
+
previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
|
|
652
770
|
...proposalInfo,
|
|
653
771
|
});
|
|
654
772
|
return { isValid: false, reason: 'out_hash_mismatch' };
|
|
655
773
|
}
|
|
656
774
|
|
|
775
|
+
// Final round of validations on the checkpoint, just in case.
|
|
776
|
+
try {
|
|
777
|
+
validateCheckpoint(computedCheckpoint, {
|
|
778
|
+
rollupManaLimit: this.checkpointsBuilder.getConfig().rollupManaLimit,
|
|
779
|
+
maxDABlockGas: this.config.validateMaxDABlockGas,
|
|
780
|
+
maxL2BlockGas: this.config.validateMaxL2BlockGas,
|
|
781
|
+
maxTxsPerBlock: this.config.validateMaxTxsPerBlock,
|
|
782
|
+
maxTxsPerCheckpoint: this.config.validateMaxTxsPerCheckpoint,
|
|
783
|
+
});
|
|
784
|
+
} catch (err) {
|
|
785
|
+
this.log.warn(`Checkpoint validation failed: ${err}`, proposalInfo);
|
|
786
|
+
return { isValid: false, reason: 'checkpoint_validation_failed' };
|
|
787
|
+
}
|
|
788
|
+
|
|
657
789
|
this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
|
|
658
790
|
return { isValid: true };
|
|
659
791
|
} finally {
|
|
@@ -664,12 +796,13 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
664
796
|
/**
|
|
665
797
|
* Extract checkpoint global variables from a block.
|
|
666
798
|
*/
|
|
667
|
-
private extractCheckpointConstants(block:
|
|
799
|
+
private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
|
|
668
800
|
const gv = block.header.globalVariables;
|
|
669
801
|
return {
|
|
670
802
|
chainId: gv.chainId,
|
|
671
803
|
version: gv.version,
|
|
672
804
|
slotNumber: gv.slotNumber,
|
|
805
|
+
timestamp: gv.timestamp,
|
|
673
806
|
coinbase: gv.coinbase,
|
|
674
807
|
feeRecipient: gv.feeRecipient,
|
|
675
808
|
gasFees: gv.gasFees,
|
|
@@ -679,7 +812,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
679
812
|
/**
|
|
680
813
|
* Uploads blobs for a checkpoint to the filestore (fire and forget).
|
|
681
814
|
*/
|
|
682
|
-
|
|
815
|
+
protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
|
|
683
816
|
try {
|
|
684
817
|
const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
|
|
685
818
|
if (!lastBlockHeader) {
|
|
@@ -694,7 +827,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
694
827
|
}
|
|
695
828
|
|
|
696
829
|
const blobFields = blocks.flatMap(b => b.toBlobFields());
|
|
697
|
-
const blobs: Blob[] = getBlobsPerL1Block(blobFields);
|
|
830
|
+
const blobs: Blob[] = await getBlobsPerL1Block(blobFields);
|
|
698
831
|
await this.blobClient.sendBlobsToFilestore(blobs);
|
|
699
832
|
this.log.debug(`Uploaded ${blobs.length} blobs to filestore for checkpoint at slot ${proposal.slotNumber}`, {
|
|
700
833
|
...proposalInfo,
|
|
@@ -732,6 +865,52 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
732
865
|
]);
|
|
733
866
|
}
|
|
734
867
|
|
|
868
|
+
/**
|
|
869
|
+
* Handle detection of a duplicate proposal (equivocation).
|
|
870
|
+
* Emits a slash event when a proposer sends multiple proposals for the same position.
|
|
871
|
+
*/
|
|
872
|
+
private handleDuplicateProposal(info: DuplicateProposalInfo): void {
|
|
873
|
+
const { slot, proposer, type } = info;
|
|
874
|
+
|
|
875
|
+
this.log.warn(`Triggering slash event for duplicate ${type} proposal from ${proposer.toString()} at slot ${slot}`, {
|
|
876
|
+
proposer: proposer.toString(),
|
|
877
|
+
slot,
|
|
878
|
+
type,
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
// Emit slash event
|
|
882
|
+
this.emit(WANT_TO_SLASH_EVENT, [
|
|
883
|
+
{
|
|
884
|
+
validator: proposer,
|
|
885
|
+
amount: this.config.slashDuplicateProposalPenalty,
|
|
886
|
+
offenseType: OffenseType.DUPLICATE_PROPOSAL,
|
|
887
|
+
epochOrSlot: BigInt(slot),
|
|
888
|
+
},
|
|
889
|
+
]);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Handle detection of a duplicate attestation (equivocation).
|
|
894
|
+
* Emits a slash event when an attester signs attestations for different proposals at the same slot.
|
|
895
|
+
*/
|
|
896
|
+
private handleDuplicateAttestation(info: DuplicateAttestationInfo): void {
|
|
897
|
+
const { slot, attester } = info;
|
|
898
|
+
|
|
899
|
+
this.log.warn(`Triggering slash event for duplicate attestation from ${attester.toString()} at slot ${slot}`, {
|
|
900
|
+
attester: attester.toString(),
|
|
901
|
+
slot,
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
this.emit(WANT_TO_SLASH_EVENT, [
|
|
905
|
+
{
|
|
906
|
+
validator: attester,
|
|
907
|
+
amount: this.config.slashDuplicateAttestationPenalty,
|
|
908
|
+
offenseType: OffenseType.DUPLICATE_ATTESTATION,
|
|
909
|
+
epochOrSlot: BigInt(slot),
|
|
910
|
+
},
|
|
911
|
+
]);
|
|
912
|
+
}
|
|
913
|
+
|
|
735
914
|
async createBlockProposal(
|
|
736
915
|
blockHeader: BlockHeader,
|
|
737
916
|
indexWithinCheckpoint: IndexWithinCheckpoint,
|
|
@@ -739,13 +918,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
739
918
|
archive: Fr,
|
|
740
919
|
txs: Tx[],
|
|
741
920
|
proposerAddress: EthAddress | undefined,
|
|
742
|
-
options: BlockProposalOptions,
|
|
921
|
+
options: BlockProposalOptions = {},
|
|
743
922
|
): Promise<BlockProposal> {
|
|
744
|
-
//
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
923
|
+
// Validate that we're not creating a proposal for an older or equal position
|
|
924
|
+
if (this.lastProposedBlock) {
|
|
925
|
+
const lastSlot = this.lastProposedBlock.slotNumber;
|
|
926
|
+
const lastIndex = this.lastProposedBlock.indexWithinCheckpoint;
|
|
927
|
+
const newSlot = blockHeader.globalVariables.slotNumber;
|
|
928
|
+
|
|
929
|
+
if (newSlot < lastSlot || (newSlot === lastSlot && indexWithinCheckpoint <= lastIndex)) {
|
|
930
|
+
throw new Error(
|
|
931
|
+
`Cannot create block proposal for slot ${newSlot} index ${indexWithinCheckpoint}: ` +
|
|
932
|
+
`already proposed block for slot ${lastSlot} index ${lastIndex}`,
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
749
936
|
|
|
750
937
|
this.log.info(
|
|
751
938
|
`Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`,
|
|
@@ -762,25 +949,42 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
762
949
|
broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
|
|
763
950
|
},
|
|
764
951
|
);
|
|
765
|
-
this.
|
|
952
|
+
this.lastProposedBlock = newProposal;
|
|
766
953
|
return newProposal;
|
|
767
954
|
}
|
|
768
955
|
|
|
769
956
|
async createCheckpointProposal(
|
|
770
957
|
checkpointHeader: CheckpointHeader,
|
|
771
958
|
archive: Fr,
|
|
959
|
+
feeAssetPriceModifier: bigint,
|
|
772
960
|
lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
|
|
773
961
|
proposerAddress: EthAddress | undefined,
|
|
774
|
-
options: CheckpointProposalOptions,
|
|
962
|
+
options: CheckpointProposalOptions = {},
|
|
775
963
|
): Promise<CheckpointProposal> {
|
|
964
|
+
// Validate that we're not creating a proposal for an older or equal slot
|
|
965
|
+
if (this.lastProposedCheckpoint) {
|
|
966
|
+
const lastSlot = this.lastProposedCheckpoint.slotNumber;
|
|
967
|
+
const newSlot = checkpointHeader.slotNumber;
|
|
968
|
+
|
|
969
|
+
if (newSlot <= lastSlot) {
|
|
970
|
+
throw new Error(
|
|
971
|
+
`Cannot create checkpoint proposal for slot ${newSlot}: ` +
|
|
972
|
+
`already proposed checkpoint for slot ${lastSlot}`,
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
776
977
|
this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
|
|
777
|
-
|
|
978
|
+
const newProposal = await this.validationService.createCheckpointProposal(
|
|
778
979
|
checkpointHeader,
|
|
779
980
|
archive,
|
|
981
|
+
feeAssetPriceModifier,
|
|
780
982
|
lastBlockInfo,
|
|
781
983
|
proposerAddress,
|
|
782
984
|
options,
|
|
783
985
|
);
|
|
986
|
+
this.lastProposedCheckpoint = newProposal;
|
|
987
|
+
return newProposal;
|
|
784
988
|
}
|
|
785
989
|
|
|
786
990
|
async broadcastBlockProposal(proposal: BlockProposal): Promise<void> {
|
|
@@ -802,6 +1006,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
802
1006
|
this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
|
|
803
1007
|
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
|
|
804
1008
|
|
|
1009
|
+
if (!attestations) {
|
|
1010
|
+
return [];
|
|
1011
|
+
}
|
|
1012
|
+
|
|
805
1013
|
// We broadcast our own attestations to our peers so, in case our block does not get mined on L1,
|
|
806
1014
|
// other nodes can see that our validators did attest to this block proposal, and do not slash us
|
|
807
1015
|
// due to inactivity for missed attestations.
|