@aztec/validator-client 0.0.1-commit.f2ce05ee → 0.0.1-commit.f5d02921e
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 +51 -10
- package/dest/checkpoint_builder.d.ts +21 -8
- package/dest/checkpoint_builder.d.ts.map +1 -1
- package/dest/checkpoint_builder.js +124 -46
- package/dest/config.d.ts +1 -1
- package/dest/config.d.ts.map +1 -1
- package/dest/config.js +26 -6
- 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 +7 -4
- package/dest/factory.d.ts.map +1 -1
- package/dest/factory.js +6 -5
- package/dest/index.d.ts +2 -3
- package/dest/index.d.ts.map +1 -1
- package/dest/index.js +1 -2
- package/dest/key_store/ha_key_store.js +1 -1
- package/dest/metrics.d.ts +10 -2
- package/dest/metrics.d.ts.map +1 -1
- package/dest/metrics.js +12 -0
- package/dest/proposal_handler.d.ts +107 -0
- package/dest/proposal_handler.d.ts.map +1 -0
- package/dest/proposal_handler.js +963 -0
- package/dest/validator.d.ts +34 -17
- package/dest/validator.d.ts.map +1 -1
- package/dest/validator.js +157 -191
- package/package.json +19 -19
- package/src/checkpoint_builder.ts +142 -39
- package/src/config.ts +26 -6
- package/src/duties/validation_service.ts +12 -11
- package/src/factory.ts +9 -3
- package/src/index.ts +1 -2
- package/src/key_store/ha_key_store.ts +1 -1
- package/src/metrics.ts +19 -1
- package/src/proposal_handler.ts +1027 -0
- package/src/validator.ts +214 -212
- package/dest/block_proposal_handler.d.ts +0 -63
- package/dest/block_proposal_handler.d.ts.map +0 -1
- package/dest/block_proposal_handler.js +0 -546
- 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 -19
- package/dest/tx_validator/tx_validator_factory.d.ts.map +0 -1
- package/dest/tx_validator/tx_validator_factory.js +0 -54
- package/src/block_proposal_handler.ts +0 -555
- 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 -154
package/src/validator.ts
CHANGED
|
@@ -9,20 +9,18 @@ import {
|
|
|
9
9
|
SlotNumber,
|
|
10
10
|
} from '@aztec/foundation/branded-types';
|
|
11
11
|
import { Fr } from '@aztec/foundation/curves/bn254';
|
|
12
|
-
import { TimeoutError } from '@aztec/foundation/error';
|
|
13
12
|
import type { EthAddress } from '@aztec/foundation/eth-address';
|
|
14
13
|
import type { Signature } from '@aztec/foundation/eth-signature';
|
|
15
14
|
import { type LogData, type Logger, createLogger } from '@aztec/foundation/log';
|
|
16
|
-
import { retryUntil } from '@aztec/foundation/retry';
|
|
17
15
|
import { RunningPromise } from '@aztec/foundation/running-promise';
|
|
18
16
|
import { sleep } from '@aztec/foundation/sleep';
|
|
19
17
|
import { DateProvider } from '@aztec/foundation/timer';
|
|
20
18
|
import type { KeystoreManager } from '@aztec/node-keystore';
|
|
21
|
-
import type { DuplicateProposalInfo, P2P, PeerId } from '@aztec/p2p';
|
|
19
|
+
import type { DuplicateAttestationInfo, DuplicateProposalInfo, P2P, PeerId } from '@aztec/p2p';
|
|
22
20
|
import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
|
|
23
21
|
import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher';
|
|
24
22
|
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
|
|
25
|
-
import type { CommitteeAttestationsAndSigners,
|
|
23
|
+
import type { CommitteeAttestationsAndSigners, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
|
|
26
24
|
import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
|
|
27
25
|
import type {
|
|
28
26
|
CreateCheckpointProposalLastBlockData,
|
|
@@ -31,32 +29,37 @@ import type {
|
|
|
31
29
|
ValidatorClientFullConfig,
|
|
32
30
|
WorldStateSynchronizer,
|
|
33
31
|
} from '@aztec/stdlib/interfaces/server';
|
|
34
|
-
import {
|
|
35
|
-
import
|
|
36
|
-
BlockProposal,
|
|
37
|
-
BlockProposalOptions,
|
|
38
|
-
CheckpointAttestation,
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
|
|
33
|
+
import {
|
|
34
|
+
type BlockProposal,
|
|
35
|
+
type BlockProposalOptions,
|
|
36
|
+
type CheckpointAttestation,
|
|
37
|
+
CheckpointProposal,
|
|
38
|
+
type CheckpointProposalCore,
|
|
39
|
+
type CheckpointProposalOptions,
|
|
41
40
|
} from '@aztec/stdlib/p2p';
|
|
42
|
-
import { CheckpointProposal } from '@aztec/stdlib/p2p';
|
|
43
41
|
import type { CheckpointHeader } from '@aztec/stdlib/rollup';
|
|
44
|
-
import type { BlockHeader,
|
|
42
|
+
import type { BlockHeader, Tx } from '@aztec/stdlib/tx';
|
|
45
43
|
import { AttestationTimeoutError } from '@aztec/stdlib/validators';
|
|
46
44
|
import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client';
|
|
47
|
-
import {
|
|
48
|
-
|
|
45
|
+
import {
|
|
46
|
+
createHASigner,
|
|
47
|
+
createLocalSignerWithProtection,
|
|
48
|
+
createSignerFromSharedDb,
|
|
49
|
+
} from '@aztec/validator-ha-signer/factory';
|
|
50
|
+
import { DutyType, type SigningContext, type SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types';
|
|
51
|
+
import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
|
|
49
52
|
|
|
50
53
|
import { EventEmitter } from 'events';
|
|
51
54
|
import type { TypedDataDefinition } from 'viem';
|
|
52
55
|
|
|
53
|
-
import { BlockProposalHandler, type BlockProposalValidationFailureReason } from './block_proposal_handler.js';
|
|
54
56
|
import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
|
|
55
57
|
import { ValidationService } from './duties/validation_service.js';
|
|
56
58
|
import { HAKeyStore } from './key_store/ha_key_store.js';
|
|
57
59
|
import type { ExtendedValidatorKeyStore } from './key_store/interface.js';
|
|
58
60
|
import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
|
|
59
61
|
import { ValidatorMetrics } from './metrics.js';
|
|
62
|
+
import { type BlockProposalValidationFailureReason, ProposalHandler } from './proposal_handler.js';
|
|
60
63
|
|
|
61
64
|
// We maintain a set of proposers who have proposed invalid blocks.
|
|
62
65
|
// Just cap the set to avoid unbounded growth.
|
|
@@ -76,29 +79,37 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
76
79
|
private validationService: ValidationService;
|
|
77
80
|
private metrics: ValidatorMetrics;
|
|
78
81
|
private log: Logger;
|
|
79
|
-
|
|
80
82
|
// Whether it has already registered handlers on the p2p client
|
|
81
83
|
private hasRegisteredHandlers = false;
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
private
|
|
85
|
+
/** Tracks the last block proposal we created, to detect duplicate proposal attempts. */
|
|
86
|
+
private lastProposedBlock?: BlockProposal;
|
|
87
|
+
|
|
88
|
+
/** Tracks the last checkpoint proposal we created. */
|
|
89
|
+
private lastProposedCheckpoint?: CheckpointProposal;
|
|
85
90
|
|
|
86
91
|
private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined;
|
|
87
92
|
private epochCacheUpdateLoop: RunningPromise;
|
|
93
|
+
/** Tracks the last epoch in which each attester successfully submitted at least one attestation. */
|
|
94
|
+
private lastAttestedEpochByAttester: Map<string, EpochNumber> = new Map();
|
|
88
95
|
|
|
89
96
|
private proposersOfInvalidBlocks: Set<string> = new Set();
|
|
90
97
|
|
|
98
|
+
/** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */
|
|
99
|
+
private lastAttestedProposal?: CheckpointProposalCore;
|
|
100
|
+
|
|
91
101
|
protected constructor(
|
|
92
102
|
private keyStore: ExtendedValidatorKeyStore,
|
|
93
103
|
private epochCache: EpochCache,
|
|
94
104
|
private p2pClient: P2P,
|
|
95
|
-
private
|
|
105
|
+
private proposalHandler: ProposalHandler,
|
|
96
106
|
private blockSource: L2BlockSource,
|
|
97
107
|
private checkpointsBuilder: FullNodeCheckpointsBuilder,
|
|
98
108
|
private worldState: WorldStateSynchronizer,
|
|
99
109
|
private l1ToL2MessageSource: L1ToL2MessageSource,
|
|
100
110
|
private config: ValidatorClientFullConfig,
|
|
101
111
|
private blobClient: BlobClientInterface,
|
|
112
|
+
private slashingProtectionSigner: ValidatorHASigner,
|
|
102
113
|
private dateProvider: DateProvider = new DateProvider(),
|
|
103
114
|
telemetry: TelemetryClient = getTelemetryClient(),
|
|
104
115
|
log = createLogger('validator'),
|
|
@@ -152,6 +163,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
152
163
|
this.log.trace(`No committee found for slot`);
|
|
153
164
|
return;
|
|
154
165
|
}
|
|
166
|
+
this.metrics.setCurrentEpoch(epoch);
|
|
155
167
|
if (epoch !== this.lastEpochForCommitteeUpdateLoop) {
|
|
156
168
|
const me = this.getValidatorAddresses();
|
|
157
169
|
const committeeSet = new Set(committee.map(v => v.toString()));
|
|
@@ -185,12 +197,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
185
197
|
blobClient: BlobClientInterface,
|
|
186
198
|
dateProvider: DateProvider = new DateProvider(),
|
|
187
199
|
telemetry: TelemetryClient = getTelemetryClient(),
|
|
200
|
+
slashingProtectionDb?: SlashingProtectionDatabase,
|
|
188
201
|
) {
|
|
189
202
|
const metrics = new ValidatorMetrics(telemetry);
|
|
190
203
|
const blockProposalValidator = new BlockProposalValidator(epochCache, {
|
|
191
204
|
txsPermitted: !config.disableTransactions,
|
|
205
|
+
maxTxsPerBlock: config.validateMaxTxsPerBlock,
|
|
192
206
|
});
|
|
193
|
-
const
|
|
207
|
+
const proposalHandler = new ProposalHandler(
|
|
194
208
|
checkpointsBuilder,
|
|
195
209
|
worldState,
|
|
196
210
|
blockSource,
|
|
@@ -199,33 +213,53 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
199
213
|
blockProposalValidator,
|
|
200
214
|
epochCache,
|
|
201
215
|
config,
|
|
216
|
+
blobClient,
|
|
202
217
|
metrics,
|
|
203
218
|
dateProvider,
|
|
204
219
|
telemetry,
|
|
205
220
|
);
|
|
206
221
|
|
|
207
|
-
|
|
208
|
-
|
|
222
|
+
const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
|
|
223
|
+
let slashingProtectionSigner: ValidatorHASigner;
|
|
224
|
+
if (slashingProtectionDb) {
|
|
225
|
+
// Shared database mode: use a pre-existing database (e.g. for testing HA setups).
|
|
226
|
+
({ signer: slashingProtectionSigner } = createSignerFromSharedDb(slashingProtectionDb, config, {
|
|
227
|
+
telemetryClient: telemetry,
|
|
228
|
+
dateProvider,
|
|
229
|
+
}));
|
|
230
|
+
} else if (config.haSigningEnabled) {
|
|
231
|
+
// Multi-node HA mode: use PostgreSQL-backed distributed locking.
|
|
209
232
|
// If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
|
|
210
233
|
const haConfig = {
|
|
211
234
|
...config,
|
|
212
235
|
maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
|
|
213
236
|
};
|
|
214
|
-
|
|
215
|
-
|
|
237
|
+
({ signer: slashingProtectionSigner } = await createHASigner(haConfig, {
|
|
238
|
+
telemetryClient: telemetry,
|
|
239
|
+
dateProvider,
|
|
240
|
+
}));
|
|
241
|
+
} else {
|
|
242
|
+
// Single-node mode: use LMDB-backed local signing protection.
|
|
243
|
+
// This prevents double-signing if the node crashes and restarts mid-proposal.
|
|
244
|
+
({ signer: slashingProtectionSigner } = await createLocalSignerWithProtection(config, {
|
|
245
|
+
telemetryClient: telemetry,
|
|
246
|
+
dateProvider,
|
|
247
|
+
}));
|
|
216
248
|
}
|
|
249
|
+
const validatorKeyStore: ExtendedValidatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
|
|
217
250
|
|
|
218
251
|
const validator = new ValidatorClient(
|
|
219
252
|
validatorKeyStore,
|
|
220
253
|
epochCache,
|
|
221
254
|
p2pClient,
|
|
222
|
-
|
|
255
|
+
proposalHandler,
|
|
223
256
|
blockSource,
|
|
224
257
|
checkpointsBuilder,
|
|
225
258
|
worldState,
|
|
226
259
|
l1ToL2MessageSource,
|
|
227
260
|
config,
|
|
228
261
|
blobClient,
|
|
262
|
+
slashingProtectionSigner,
|
|
229
263
|
dateProvider,
|
|
230
264
|
telemetry,
|
|
231
265
|
);
|
|
@@ -239,8 +273,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
239
273
|
.filter(addr => !this.config.disabledValidators.some(disabled => disabled.equals(addr)));
|
|
240
274
|
}
|
|
241
275
|
|
|
242
|
-
public
|
|
243
|
-
return this.
|
|
276
|
+
public getProposalHandler() {
|
|
277
|
+
return this.proposalHandler;
|
|
244
278
|
}
|
|
245
279
|
|
|
246
280
|
public signWithAddress(addr: EthAddress, msg: TypedDataDefinition, context: SigningContext) {
|
|
@@ -263,6 +297,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
263
297
|
this.config = { ...this.config, ...config };
|
|
264
298
|
}
|
|
265
299
|
|
|
300
|
+
public reloadKeystore(newManager: KeystoreManager): void {
|
|
301
|
+
const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
|
|
302
|
+
this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
|
|
303
|
+
this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
|
|
304
|
+
}
|
|
305
|
+
|
|
266
306
|
public async start() {
|
|
267
307
|
if (this.epochCacheUpdateLoop.isRunning()) {
|
|
268
308
|
this.log.warn(`Validator client already started`);
|
|
@@ -307,13 +347,18 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
307
347
|
checkpoint: CheckpointProposalCore,
|
|
308
348
|
proposalSender: PeerId,
|
|
309
349
|
): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
|
|
310
|
-
this.p2pClient.
|
|
350
|
+
this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler);
|
|
311
351
|
|
|
312
352
|
// Duplicate proposal handler - triggers slashing for equivocation
|
|
313
353
|
this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
|
|
314
354
|
this.handleDuplicateProposal(info);
|
|
315
355
|
});
|
|
316
356
|
|
|
357
|
+
// Duplicate attestation handler - triggers slashing for attestation equivocation
|
|
358
|
+
this.p2pClient.registerDuplicateAttestationCallback((info: DuplicateAttestationInfo) => {
|
|
359
|
+
this.handleDuplicateAttestation(info);
|
|
360
|
+
});
|
|
361
|
+
|
|
317
362
|
const myAddresses = this.getValidatorAddresses();
|
|
318
363
|
this.p2pClient.registerThisValidatorAddresses(myAddresses);
|
|
319
364
|
|
|
@@ -341,6 +386,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
341
386
|
return false;
|
|
342
387
|
}
|
|
343
388
|
|
|
389
|
+
// Log self-proposals from HA peers (same validator key on different nodes)
|
|
390
|
+
if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
391
|
+
this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
|
|
392
|
+
proposer: proposer.toString(),
|
|
393
|
+
slotNumber,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
344
397
|
// Check if we're in the committee (for metrics purposes)
|
|
345
398
|
const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
|
|
346
399
|
const partOfCommittee = inCommittee.length > 0;
|
|
@@ -354,25 +407,25 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
354
407
|
|
|
355
408
|
// Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute.
|
|
356
409
|
// In fisherman mode, we always reexecute to validate proposals.
|
|
357
|
-
const {
|
|
358
|
-
this.config;
|
|
410
|
+
const { slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } = this.config;
|
|
359
411
|
const shouldReexecute =
|
|
360
412
|
fishermanMode ||
|
|
361
|
-
|
|
362
|
-
|
|
413
|
+
slashBroadcastedInvalidBlockPenalty > 0n ||
|
|
414
|
+
partOfCommittee ||
|
|
363
415
|
alwaysReexecuteBlockProposals ||
|
|
364
416
|
this.blobClient.canUpload();
|
|
365
417
|
|
|
366
|
-
const validationResult = await this.
|
|
418
|
+
const validationResult = await this.proposalHandler.handleBlockProposal(
|
|
367
419
|
proposal,
|
|
368
420
|
proposalSender,
|
|
369
421
|
!!shouldReexecute && !escapeHatchOpen,
|
|
370
422
|
);
|
|
371
423
|
|
|
372
424
|
if (!validationResult.isValid) {
|
|
373
|
-
this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
374
|
-
|
|
375
425
|
const reason = validationResult.reason || 'unknown';
|
|
426
|
+
|
|
427
|
+
this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
|
|
428
|
+
|
|
376
429
|
// Classify failure reason: bad proposal vs node issue
|
|
377
430
|
const badProposalReasons: BlockProposalValidationFailureReason[] = [
|
|
378
431
|
'invalid_proposal',
|
|
@@ -427,53 +480,50 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
427
480
|
proposal: CheckpointProposalCore,
|
|
428
481
|
_proposalSender: PeerId,
|
|
429
482
|
): Promise<CheckpointAttestation[] | undefined> {
|
|
430
|
-
const
|
|
483
|
+
const proposalSlotNumber = proposal.slotNumber;
|
|
431
484
|
const proposer = proposal.getSender();
|
|
432
485
|
|
|
433
486
|
// If escape hatch is open for this slot's epoch, do not attest.
|
|
434
|
-
if (await this.epochCache.isEscapeHatchOpenAtSlot(
|
|
435
|
-
this.log.warn(`Escape hatch open for slot ${
|
|
487
|
+
if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
|
|
488
|
+
this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
|
|
436
489
|
return undefined;
|
|
437
490
|
}
|
|
438
491
|
|
|
439
|
-
//
|
|
440
|
-
if (
|
|
441
|
-
this.log.
|
|
492
|
+
// Ignore proposals from ourselves (may happen in HA setups)
|
|
493
|
+
if (proposer && this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
494
|
+
this.log.debug(`Ignoring block proposal from self for slot ${proposalSlotNumber}`, {
|
|
495
|
+
proposer: proposer.toString(),
|
|
496
|
+
proposalSlotNumber,
|
|
497
|
+
});
|
|
442
498
|
return undefined;
|
|
443
499
|
}
|
|
444
500
|
|
|
445
|
-
// Check that I have any address in
|
|
446
|
-
const inCommittee = await this.epochCache.filterInCommittee(
|
|
501
|
+
// Check that I have any address in the committee where this checkpoint will land before attesting
|
|
502
|
+
const inCommittee = await this.epochCache.filterInCommittee(proposalSlotNumber, this.getValidatorAddresses());
|
|
447
503
|
const partOfCommittee = inCommittee.length > 0;
|
|
448
504
|
|
|
449
505
|
const proposalInfo = {
|
|
450
|
-
|
|
506
|
+
proposalSlotNumber,
|
|
451
507
|
archive: proposal.archive.toString(),
|
|
452
|
-
proposer: proposer
|
|
453
|
-
txCount: proposal.txHashes.length,
|
|
508
|
+
proposer: proposer?.toString(),
|
|
454
509
|
};
|
|
455
|
-
this.log.info(`Received checkpoint proposal for slot ${
|
|
510
|
+
this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
|
|
456
511
|
...proposalInfo,
|
|
457
|
-
txHashes: proposal.txHashes.map(t => t.toString()),
|
|
458
512
|
fishermanMode: this.config.fishermanMode || false,
|
|
459
513
|
});
|
|
460
514
|
|
|
461
|
-
// Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
|
|
515
|
+
// Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set).
|
|
516
|
+
// Uses the cached result from the all-nodes callback if available (avoids double validation).
|
|
462
517
|
if (this.config.skipCheckpointProposalValidation) {
|
|
463
|
-
this.log.warn(`Skipping checkpoint proposal validation for slot ${
|
|
518
|
+
this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
|
|
464
519
|
} else {
|
|
465
|
-
const validationResult = await this.
|
|
520
|
+
const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
|
|
466
521
|
if (!validationResult.isValid) {
|
|
467
522
|
this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
468
523
|
return undefined;
|
|
469
524
|
}
|
|
470
525
|
}
|
|
471
526
|
|
|
472
|
-
// Upload blobs to filestore if we can (fire and forget)
|
|
473
|
-
if (this.blobClient.canUpload()) {
|
|
474
|
-
void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
527
|
// Check that I have any address in current committee before attesting
|
|
478
528
|
// In fisherman mode, we still create attestations for validation even if not in committee
|
|
479
529
|
if (!partOfCommittee && !this.config.fishermanMode) {
|
|
@@ -482,14 +532,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
482
532
|
}
|
|
483
533
|
|
|
484
534
|
// Provided all of the above checks pass, we can attest to the proposal
|
|
485
|
-
this.log.info(
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
535
|
+
this.log.info(
|
|
536
|
+
`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${proposalSlotNumber}`,
|
|
537
|
+
{
|
|
538
|
+
...proposalInfo,
|
|
539
|
+
inCommittee: partOfCommittee,
|
|
540
|
+
fishermanMode: this.config.fishermanMode || false,
|
|
541
|
+
},
|
|
542
|
+
);
|
|
490
543
|
|
|
491
544
|
this.metrics.incSuccessfulAttestations(inCommittee.length);
|
|
492
545
|
|
|
546
|
+
// Track epoch participation per attester: count each (attester, epoch) pair at most once
|
|
547
|
+
const proposalEpoch = getEpochAtSlot(proposalSlotNumber, this.epochCache.getL1Constants());
|
|
548
|
+
for (const attester of inCommittee) {
|
|
549
|
+
const key = attester.toString();
|
|
550
|
+
const lastEpoch = this.lastAttestedEpochByAttester.get(key);
|
|
551
|
+
if (lastEpoch === undefined || proposalEpoch > lastEpoch) {
|
|
552
|
+
this.lastAttestedEpochByAttester.set(key, proposalEpoch);
|
|
553
|
+
this.metrics.incAttestedEpochCount(attester);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
493
557
|
// Determine which validators should attest
|
|
494
558
|
let attestors: EthAddress[];
|
|
495
559
|
if (partOfCommittee) {
|
|
@@ -508,172 +572,59 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
508
572
|
|
|
509
573
|
if (this.config.fishermanMode) {
|
|
510
574
|
// bail out early and don't save attestations to the pool in fisherman mode
|
|
511
|
-
this.log.info(`Creating checkpoint attestations for slot ${
|
|
575
|
+
this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
|
|
512
576
|
...proposalInfo,
|
|
513
577
|
attestors: attestors.map(a => a.toString()),
|
|
514
578
|
});
|
|
515
579
|
return undefined;
|
|
516
580
|
}
|
|
517
581
|
|
|
518
|
-
return this.createCheckpointAttestationsFromProposal(proposal, attestors);
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
private async createCheckpointAttestationsFromProposal(
|
|
522
|
-
proposal: CheckpointProposalCore,
|
|
523
|
-
attestors: EthAddress[] = [],
|
|
524
|
-
): Promise<CheckpointAttestation[]> {
|
|
525
|
-
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
|
|
526
|
-
await this.p2pClient.addOwnCheckpointAttestations(attestations);
|
|
527
|
-
return attestations;
|
|
582
|
+
return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
|
|
528
583
|
}
|
|
529
584
|
|
|
530
585
|
/**
|
|
531
|
-
*
|
|
532
|
-
* @returns
|
|
586
|
+
* Checks if we should attest to a slot based on equivocation prevention rules.
|
|
587
|
+
* @returns true if we should attest, false if we should skip
|
|
533
588
|
*/
|
|
534
|
-
private
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
const timeoutSeconds = 10; // TODO(palla/mbps): This should map to the timetable settings
|
|
589
|
+
private shouldAttestToSlot(slotNumber: SlotNumber): boolean {
|
|
590
|
+
// If attestToEquivocatedProposals is true, always allow
|
|
591
|
+
if (this.config.attestToEquivocatedProposals) {
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
540
594
|
|
|
541
|
-
//
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
async () => {
|
|
546
|
-
await this.blockSource.syncImmediate();
|
|
547
|
-
return this.blockSource.getBlockHeaderByArchive(proposal.archive);
|
|
548
|
-
},
|
|
549
|
-
`waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
|
|
550
|
-
timeoutSeconds,
|
|
551
|
-
0.5,
|
|
595
|
+
// Check if incoming slot is strictly greater than last attested
|
|
596
|
+
if (this.lastAttestedProposal && slotNumber <= this.lastAttestedProposal.slotNumber) {
|
|
597
|
+
this.log.warn(
|
|
598
|
+
`Refusing to process a proposal for slot ${slotNumber} given we already attested to a proposal for slot ${this.lastAttestedProposal.slotNumber}`,
|
|
552
599
|
);
|
|
553
|
-
|
|
554
|
-
if (err instanceof TimeoutError) {
|
|
555
|
-
this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
|
|
556
|
-
return { isValid: false, reason: 'last_block_not_found' };
|
|
557
|
-
}
|
|
558
|
-
this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
|
|
559
|
-
return { isValid: false, reason: 'block_fetch_error' };
|
|
600
|
+
return false;
|
|
560
601
|
}
|
|
561
602
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
return { isValid: false, reason: 'last_block_not_found' };
|
|
565
|
-
}
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
566
605
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
606
|
+
private async createCheckpointAttestationsFromProposal(
|
|
607
|
+
proposal: CheckpointProposalCore,
|
|
608
|
+
attestors: EthAddress[] = [],
|
|
609
|
+
): Promise<CheckpointAttestation[] | undefined> {
|
|
610
|
+
// Equivocation check: must happen right before signing to minimize the race window
|
|
611
|
+
if (!this.shouldAttestToSlot(proposal.slotNumber)) {
|
|
612
|
+
return undefined;
|
|
572
613
|
}
|
|
573
614
|
|
|
574
|
-
this.
|
|
575
|
-
...proposalInfo,
|
|
576
|
-
blockNumbers: blocks.map(b => b.number),
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
// Get checkpoint constants from first block
|
|
580
|
-
const firstBlock = blocks[0];
|
|
581
|
-
const constants = this.extractCheckpointConstants(firstBlock);
|
|
582
|
-
const checkpointNumber = firstBlock.checkpointNumber;
|
|
583
|
-
|
|
584
|
-
// Get L1-to-L2 messages for this checkpoint
|
|
585
|
-
const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
|
|
586
|
-
|
|
587
|
-
// Compute the previous checkpoint out hashes for the epoch.
|
|
588
|
-
// TODO: There can be a more efficient way to get the previous checkpoint out hashes without having to fetch the
|
|
589
|
-
// actual checkpoints and the blocks/txs in them.
|
|
590
|
-
const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
|
|
591
|
-
const previousCheckpoints = (await this.blockSource.getCheckpointsForEpoch(epoch))
|
|
592
|
-
.filter(b => b.number < checkpointNumber)
|
|
593
|
-
.sort((a, b) => a.number - b.number);
|
|
594
|
-
const previousCheckpointOutHashes = previousCheckpoints.map(c => c.getCheckpointOutHash());
|
|
595
|
-
|
|
596
|
-
// Fork world state at the block before the first block
|
|
597
|
-
const parentBlockNumber = BlockNumber(firstBlock.number - 1);
|
|
598
|
-
const fork = await this.worldState.fork(parentBlockNumber);
|
|
599
|
-
|
|
600
|
-
try {
|
|
601
|
-
// Create checkpoint builder with all existing blocks
|
|
602
|
-
const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
|
|
603
|
-
checkpointNumber,
|
|
604
|
-
constants,
|
|
605
|
-
l1ToL2Messages,
|
|
606
|
-
previousCheckpointOutHashes,
|
|
607
|
-
fork,
|
|
608
|
-
blocks,
|
|
609
|
-
this.log.getBindings(),
|
|
610
|
-
);
|
|
611
|
-
|
|
612
|
-
// Complete the checkpoint to get computed values
|
|
613
|
-
const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
|
|
614
|
-
|
|
615
|
-
// Compare checkpoint header with proposal
|
|
616
|
-
if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
|
|
617
|
-
this.log.warn(`Checkpoint header mismatch`, {
|
|
618
|
-
...proposalInfo,
|
|
619
|
-
computed: computedCheckpoint.header.toInspect(),
|
|
620
|
-
proposal: proposal.checkpointHeader.toInspect(),
|
|
621
|
-
});
|
|
622
|
-
return { isValid: false, reason: 'checkpoint_header_mismatch' };
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// Compare archive root with proposal
|
|
626
|
-
if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
|
|
627
|
-
this.log.warn(`Archive root mismatch`, {
|
|
628
|
-
...proposalInfo,
|
|
629
|
-
computed: computedCheckpoint.archive.root.toString(),
|
|
630
|
-
proposal: proposal.archive.toString(),
|
|
631
|
-
});
|
|
632
|
-
return { isValid: false, reason: 'archive_mismatch' };
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
// Check that the accumulated epoch out hash matches the value in the proposal.
|
|
636
|
-
// The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
|
|
637
|
-
const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
|
|
638
|
-
const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
|
|
639
|
-
const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
|
|
640
|
-
if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
|
|
641
|
-
this.log.warn(`Epoch out hash mismatch`, {
|
|
642
|
-
proposalEpochOutHash: proposalEpochOutHash.toString(),
|
|
643
|
-
computedEpochOutHash: computedEpochOutHash.toString(),
|
|
644
|
-
checkpointOutHash: checkpointOutHash.toString(),
|
|
645
|
-
previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
|
|
646
|
-
...proposalInfo,
|
|
647
|
-
});
|
|
648
|
-
return { isValid: false, reason: 'out_hash_mismatch' };
|
|
649
|
-
}
|
|
615
|
+
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
|
|
650
616
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
} finally {
|
|
654
|
-
await fork.close();
|
|
655
|
-
}
|
|
656
|
-
}
|
|
617
|
+
// Track the proposal we attested to (to prevent equivocation)
|
|
618
|
+
this.lastAttestedProposal = proposal;
|
|
657
619
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
*/
|
|
661
|
-
private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
|
|
662
|
-
const gv = block.header.globalVariables;
|
|
663
|
-
return {
|
|
664
|
-
chainId: gv.chainId,
|
|
665
|
-
version: gv.version,
|
|
666
|
-
slotNumber: gv.slotNumber,
|
|
667
|
-
coinbase: gv.coinbase,
|
|
668
|
-
feeRecipient: gv.feeRecipient,
|
|
669
|
-
gasFees: gv.gasFees,
|
|
670
|
-
};
|
|
620
|
+
await this.p2pClient.addOwnCheckpointAttestations(attestations);
|
|
621
|
+
return attestations;
|
|
671
622
|
}
|
|
672
623
|
|
|
673
624
|
/**
|
|
674
625
|
* Uploads blobs for a checkpoint to the filestore (fire and forget).
|
|
675
626
|
*/
|
|
676
|
-
|
|
627
|
+
protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
|
|
677
628
|
try {
|
|
678
629
|
const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
|
|
679
630
|
if (!lastBlockHeader) {
|
|
@@ -688,7 +639,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
688
639
|
}
|
|
689
640
|
|
|
690
641
|
const blobFields = blocks.flatMap(b => b.toBlobFields());
|
|
691
|
-
const blobs: Blob[] = getBlobsPerL1Block(blobFields);
|
|
642
|
+
const blobs: Blob[] = await getBlobsPerL1Block(blobFields);
|
|
692
643
|
await this.blobClient.sendBlobsToFilestore(blobs);
|
|
693
644
|
this.log.debug(`Uploaded ${blobs.length} blobs to filestore for checkpoint at slot ${proposal.slotNumber}`, {
|
|
694
645
|
...proposalInfo,
|
|
@@ -750,6 +701,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
750
701
|
]);
|
|
751
702
|
}
|
|
752
703
|
|
|
704
|
+
/**
|
|
705
|
+
* Handle detection of a duplicate attestation (equivocation).
|
|
706
|
+
* Emits a slash event when an attester signs attestations for different proposals at the same slot.
|
|
707
|
+
*/
|
|
708
|
+
private handleDuplicateAttestation(info: DuplicateAttestationInfo): void {
|
|
709
|
+
const { slot, attester } = info;
|
|
710
|
+
|
|
711
|
+
this.log.warn(`Triggering slash event for duplicate attestation from ${attester.toString()} at slot ${slot}`, {
|
|
712
|
+
attester: attester.toString(),
|
|
713
|
+
slot,
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
this.emit(WANT_TO_SLASH_EVENT, [
|
|
717
|
+
{
|
|
718
|
+
validator: attester,
|
|
719
|
+
amount: this.config.slashDuplicateAttestationPenalty,
|
|
720
|
+
offenseType: OffenseType.DUPLICATE_ATTESTATION,
|
|
721
|
+
epochOrSlot: BigInt(slot),
|
|
722
|
+
},
|
|
723
|
+
]);
|
|
724
|
+
}
|
|
725
|
+
|
|
753
726
|
async createBlockProposal(
|
|
754
727
|
blockHeader: BlockHeader,
|
|
755
728
|
indexWithinCheckpoint: IndexWithinCheckpoint,
|
|
@@ -759,11 +732,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
759
732
|
proposerAddress: EthAddress | undefined,
|
|
760
733
|
options: BlockProposalOptions = {},
|
|
761
734
|
): Promise<BlockProposal> {
|
|
762
|
-
//
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
735
|
+
// Validate that we're not creating a proposal for an older or equal position
|
|
736
|
+
if (this.lastProposedBlock) {
|
|
737
|
+
const lastSlot = this.lastProposedBlock.slotNumber;
|
|
738
|
+
const lastIndex = this.lastProposedBlock.indexWithinCheckpoint;
|
|
739
|
+
const newSlot = blockHeader.globalVariables.slotNumber;
|
|
740
|
+
|
|
741
|
+
if (newSlot < lastSlot || (newSlot === lastSlot && indexWithinCheckpoint <= lastIndex)) {
|
|
742
|
+
throw new Error(
|
|
743
|
+
`Cannot create block proposal for slot ${newSlot} index ${indexWithinCheckpoint}: ` +
|
|
744
|
+
`already proposed block for slot ${lastSlot} index ${lastIndex}`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
767
748
|
|
|
768
749
|
this.log.info(
|
|
769
750
|
`Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`,
|
|
@@ -780,25 +761,42 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
780
761
|
broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
|
|
781
762
|
},
|
|
782
763
|
);
|
|
783
|
-
this.
|
|
764
|
+
this.lastProposedBlock = newProposal;
|
|
784
765
|
return newProposal;
|
|
785
766
|
}
|
|
786
767
|
|
|
787
768
|
async createCheckpointProposal(
|
|
788
769
|
checkpointHeader: CheckpointHeader,
|
|
789
770
|
archive: Fr,
|
|
771
|
+
feeAssetPriceModifier: bigint,
|
|
790
772
|
lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
|
|
791
773
|
proposerAddress: EthAddress | undefined,
|
|
792
774
|
options: CheckpointProposalOptions = {},
|
|
793
775
|
): Promise<CheckpointProposal> {
|
|
776
|
+
// Validate that we're not creating a proposal for an older or equal slot
|
|
777
|
+
if (this.lastProposedCheckpoint) {
|
|
778
|
+
const lastSlot = this.lastProposedCheckpoint.slotNumber;
|
|
779
|
+
const newSlot = checkpointHeader.slotNumber;
|
|
780
|
+
|
|
781
|
+
if (newSlot <= lastSlot) {
|
|
782
|
+
throw new Error(
|
|
783
|
+
`Cannot create checkpoint proposal for slot ${newSlot}: ` +
|
|
784
|
+
`already proposed checkpoint for slot ${lastSlot}`,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
794
789
|
this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
|
|
795
|
-
|
|
790
|
+
const newProposal = await this.validationService.createCheckpointProposal(
|
|
796
791
|
checkpointHeader,
|
|
797
792
|
archive,
|
|
793
|
+
feeAssetPriceModifier,
|
|
798
794
|
lastBlockInfo,
|
|
799
795
|
proposerAddress,
|
|
800
796
|
options,
|
|
801
797
|
);
|
|
798
|
+
this.lastProposedCheckpoint = newProposal;
|
|
799
|
+
return newProposal;
|
|
802
800
|
}
|
|
803
801
|
|
|
804
802
|
async broadcastBlockProposal(proposal: BlockProposal): Promise<void> {
|
|
@@ -820,6 +818,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
820
818
|
this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
|
|
821
819
|
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
|
|
822
820
|
|
|
821
|
+
if (!attestations) {
|
|
822
|
+
return [];
|
|
823
|
+
}
|
|
824
|
+
|
|
823
825
|
// We broadcast our own attestations to our peers so, in case our block does not get mined on L1,
|
|
824
826
|
// other nodes can see that our validators did attest to this block proposal, and do not slash us
|
|
825
827
|
// due to inactivity for missed attestations.
|