@aztec/validator-client 0.0.1-commit.e3c1de76 → 0.0.1-commit.e588bc7e5
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 +60 -18
- 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.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 +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 +39 -17
- package/dest/validator.d.ts.map +1 -1
- package/dest/validator.js +181 -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 +3 -3
- package/src/metrics.ts +19 -1
- package/src/proposal_handler.ts +1027 -0
- package/src/validator.ts +243 -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 { 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,7 +347,17 @@ 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);
|
|
351
|
+
|
|
352
|
+
// Duplicate proposal handler - triggers slashing for equivocation
|
|
353
|
+
this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
|
|
354
|
+
this.handleDuplicateProposal(info);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Duplicate attestation handler - triggers slashing for attestation equivocation
|
|
358
|
+
this.p2pClient.registerDuplicateAttestationCallback((info: DuplicateAttestationInfo) => {
|
|
359
|
+
this.handleDuplicateAttestation(info);
|
|
360
|
+
});
|
|
311
361
|
|
|
312
362
|
const myAddresses = this.getValidatorAddresses();
|
|
313
363
|
this.p2pClient.registerThisValidatorAddresses(myAddresses);
|
|
@@ -336,6 +386,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
336
386
|
return false;
|
|
337
387
|
}
|
|
338
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
|
+
|
|
339
397
|
// Check if we're in the committee (for metrics purposes)
|
|
340
398
|
const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
|
|
341
399
|
const partOfCommittee = inCommittee.length > 0;
|
|
@@ -349,25 +407,25 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
349
407
|
|
|
350
408
|
// Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute.
|
|
351
409
|
// In fisherman mode, we always reexecute to validate proposals.
|
|
352
|
-
const {
|
|
353
|
-
this.config;
|
|
410
|
+
const { slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } = this.config;
|
|
354
411
|
const shouldReexecute =
|
|
355
412
|
fishermanMode ||
|
|
356
|
-
|
|
357
|
-
|
|
413
|
+
slashBroadcastedInvalidBlockPenalty > 0n ||
|
|
414
|
+
partOfCommittee ||
|
|
358
415
|
alwaysReexecuteBlockProposals ||
|
|
359
416
|
this.blobClient.canUpload();
|
|
360
417
|
|
|
361
|
-
const validationResult = await this.
|
|
418
|
+
const validationResult = await this.proposalHandler.handleBlockProposal(
|
|
362
419
|
proposal,
|
|
363
420
|
proposalSender,
|
|
364
421
|
!!shouldReexecute && !escapeHatchOpen,
|
|
365
422
|
);
|
|
366
423
|
|
|
367
424
|
if (!validationResult.isValid) {
|
|
368
|
-
this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
369
|
-
|
|
370
425
|
const reason = validationResult.reason || 'unknown';
|
|
426
|
+
|
|
427
|
+
this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
|
|
428
|
+
|
|
371
429
|
// Classify failure reason: bad proposal vs node issue
|
|
372
430
|
const badProposalReasons: BlockProposalValidationFailureReason[] = [
|
|
373
431
|
'invalid_proposal',
|
|
@@ -422,53 +480,50 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
422
480
|
proposal: CheckpointProposalCore,
|
|
423
481
|
_proposalSender: PeerId,
|
|
424
482
|
): Promise<CheckpointAttestation[] | undefined> {
|
|
425
|
-
const
|
|
483
|
+
const proposalSlotNumber = proposal.slotNumber;
|
|
426
484
|
const proposer = proposal.getSender();
|
|
427
485
|
|
|
428
486
|
// If escape hatch is open for this slot's epoch, do not attest.
|
|
429
|
-
if (await this.epochCache.isEscapeHatchOpenAtSlot(
|
|
430
|
-
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`);
|
|
431
489
|
return undefined;
|
|
432
490
|
}
|
|
433
491
|
|
|
434
|
-
//
|
|
435
|
-
if (
|
|
436
|
-
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
|
+
});
|
|
437
498
|
return undefined;
|
|
438
499
|
}
|
|
439
500
|
|
|
440
|
-
// Check that I have any address in
|
|
441
|
-
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());
|
|
442
503
|
const partOfCommittee = inCommittee.length > 0;
|
|
443
504
|
|
|
444
505
|
const proposalInfo = {
|
|
445
|
-
|
|
506
|
+
proposalSlotNumber,
|
|
446
507
|
archive: proposal.archive.toString(),
|
|
447
|
-
proposer: proposer
|
|
448
|
-
txCount: proposal.txHashes.length,
|
|
508
|
+
proposer: proposer?.toString(),
|
|
449
509
|
};
|
|
450
|
-
this.log.info(`Received checkpoint proposal for slot ${
|
|
510
|
+
this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
|
|
451
511
|
...proposalInfo,
|
|
452
|
-
txHashes: proposal.txHashes.map(t => t.toString()),
|
|
453
512
|
fishermanMode: this.config.fishermanMode || false,
|
|
454
513
|
});
|
|
455
514
|
|
|
456
|
-
// 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).
|
|
457
517
|
if (this.config.skipCheckpointProposalValidation) {
|
|
458
|
-
this.log.warn(`Skipping checkpoint proposal validation for slot ${
|
|
518
|
+
this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
|
|
459
519
|
} else {
|
|
460
|
-
const validationResult = await this.
|
|
520
|
+
const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
|
|
461
521
|
if (!validationResult.isValid) {
|
|
462
522
|
this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
463
523
|
return undefined;
|
|
464
524
|
}
|
|
465
525
|
}
|
|
466
526
|
|
|
467
|
-
// Upload blobs to filestore if we can (fire and forget)
|
|
468
|
-
if (this.blobClient.canUpload()) {
|
|
469
|
-
void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
527
|
// Check that I have any address in current committee before attesting
|
|
473
528
|
// In fisherman mode, we still create attestations for validation even if not in committee
|
|
474
529
|
if (!partOfCommittee && !this.config.fishermanMode) {
|
|
@@ -477,14 +532,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
477
532
|
}
|
|
478
533
|
|
|
479
534
|
// Provided all of the above checks pass, we can attest to the proposal
|
|
480
|
-
this.log.info(
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
+
);
|
|
485
543
|
|
|
486
544
|
this.metrics.incSuccessfulAttestations(inCommittee.length);
|
|
487
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
|
+
|
|
488
557
|
// Determine which validators should attest
|
|
489
558
|
let attestors: EthAddress[];
|
|
490
559
|
if (partOfCommittee) {
|
|
@@ -503,172 +572,59 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
503
572
|
|
|
504
573
|
if (this.config.fishermanMode) {
|
|
505
574
|
// bail out early and don't save attestations to the pool in fisherman mode
|
|
506
|
-
this.log.info(`Creating checkpoint attestations for slot ${
|
|
575
|
+
this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
|
|
507
576
|
...proposalInfo,
|
|
508
577
|
attestors: attestors.map(a => a.toString()),
|
|
509
578
|
});
|
|
510
579
|
return undefined;
|
|
511
580
|
}
|
|
512
581
|
|
|
513
|
-
return this.createCheckpointAttestationsFromProposal(proposal, attestors);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
private async createCheckpointAttestationsFromProposal(
|
|
517
|
-
proposal: CheckpointProposalCore,
|
|
518
|
-
attestors: EthAddress[] = [],
|
|
519
|
-
): Promise<CheckpointAttestation[]> {
|
|
520
|
-
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
|
|
521
|
-
await this.p2pClient.addCheckpointAttestations(attestations);
|
|
522
|
-
return attestations;
|
|
582
|
+
return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
|
|
523
583
|
}
|
|
524
584
|
|
|
525
585
|
/**
|
|
526
|
-
*
|
|
527
|
-
* @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
|
|
528
588
|
*/
|
|
529
|
-
private
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
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
|
+
}
|
|
535
594
|
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
async () => {
|
|
541
|
-
await this.blockSource.syncImmediate();
|
|
542
|
-
return this.blockSource.getBlockHeaderByArchive(proposal.archive);
|
|
543
|
-
},
|
|
544
|
-
`waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
|
|
545
|
-
timeoutSeconds,
|
|
546
|
-
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}`,
|
|
547
599
|
);
|
|
548
|
-
|
|
549
|
-
if (err instanceof TimeoutError) {
|
|
550
|
-
this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
|
|
551
|
-
return { isValid: false, reason: 'last_block_not_found' };
|
|
552
|
-
}
|
|
553
|
-
this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
|
|
554
|
-
return { isValid: false, reason: 'block_fetch_error' };
|
|
600
|
+
return false;
|
|
555
601
|
}
|
|
556
602
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
return { isValid: false, reason: 'last_block_not_found' };
|
|
560
|
-
}
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
561
605
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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;
|
|
567
613
|
}
|
|
568
614
|
|
|
569
|
-
this.
|
|
570
|
-
...proposalInfo,
|
|
571
|
-
blockNumbers: blocks.map(b => b.number),
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
// Get checkpoint constants from first block
|
|
575
|
-
const firstBlock = blocks[0];
|
|
576
|
-
const constants = this.extractCheckpointConstants(firstBlock);
|
|
577
|
-
const checkpointNumber = firstBlock.checkpointNumber;
|
|
578
|
-
|
|
579
|
-
// Get L1-to-L2 messages for this checkpoint
|
|
580
|
-
const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
|
|
581
|
-
|
|
582
|
-
// Compute the previous checkpoint out hashes for the epoch.
|
|
583
|
-
// TODO: There can be a more efficient way to get the previous checkpoint out hashes without having to fetch the
|
|
584
|
-
// actual checkpoints and the blocks/txs in them.
|
|
585
|
-
const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
|
|
586
|
-
const previousCheckpoints = (await this.blockSource.getCheckpointsForEpoch(epoch))
|
|
587
|
-
.filter(b => b.number < checkpointNumber)
|
|
588
|
-
.sort((a, b) => a.number - b.number);
|
|
589
|
-
const previousCheckpointOutHashes = previousCheckpoints.map(c => c.getCheckpointOutHash());
|
|
590
|
-
|
|
591
|
-
// Fork world state at the block before the first block
|
|
592
|
-
const parentBlockNumber = BlockNumber(firstBlock.number - 1);
|
|
593
|
-
const fork = await this.worldState.fork(parentBlockNumber);
|
|
594
|
-
|
|
595
|
-
try {
|
|
596
|
-
// Create checkpoint builder with all existing blocks
|
|
597
|
-
const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
|
|
598
|
-
checkpointNumber,
|
|
599
|
-
constants,
|
|
600
|
-
l1ToL2Messages,
|
|
601
|
-
previousCheckpointOutHashes,
|
|
602
|
-
fork,
|
|
603
|
-
blocks,
|
|
604
|
-
this.log.getBindings(),
|
|
605
|
-
);
|
|
606
|
-
|
|
607
|
-
// Complete the checkpoint to get computed values
|
|
608
|
-
const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
|
|
609
|
-
|
|
610
|
-
// Compare checkpoint header with proposal
|
|
611
|
-
if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
|
|
612
|
-
this.log.warn(`Checkpoint header mismatch`, {
|
|
613
|
-
...proposalInfo,
|
|
614
|
-
computed: computedCheckpoint.header.toInspect(),
|
|
615
|
-
proposal: proposal.checkpointHeader.toInspect(),
|
|
616
|
-
});
|
|
617
|
-
return { isValid: false, reason: 'checkpoint_header_mismatch' };
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
// Compare archive root with proposal
|
|
621
|
-
if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
|
|
622
|
-
this.log.warn(`Archive root mismatch`, {
|
|
623
|
-
...proposalInfo,
|
|
624
|
-
computed: computedCheckpoint.archive.root.toString(),
|
|
625
|
-
proposal: proposal.archive.toString(),
|
|
626
|
-
});
|
|
627
|
-
return { isValid: false, reason: 'archive_mismatch' };
|
|
628
|
-
}
|
|
615
|
+
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
|
|
629
616
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
|
|
633
|
-
const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
|
|
634
|
-
const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
|
|
635
|
-
if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
|
|
636
|
-
this.log.warn(`Epoch out hash mismatch`, {
|
|
637
|
-
proposalEpochOutHash: proposalEpochOutHash.toString(),
|
|
638
|
-
computedEpochOutHash: computedEpochOutHash.toString(),
|
|
639
|
-
checkpointOutHash: checkpointOutHash.toString(),
|
|
640
|
-
previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
|
|
641
|
-
...proposalInfo,
|
|
642
|
-
});
|
|
643
|
-
return { isValid: false, reason: 'out_hash_mismatch' };
|
|
644
|
-
}
|
|
617
|
+
// Track the proposal we attested to (to prevent equivocation)
|
|
618
|
+
this.lastAttestedProposal = proposal;
|
|
645
619
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
} finally {
|
|
649
|
-
await fork.close();
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* Extract checkpoint global variables from a block.
|
|
655
|
-
*/
|
|
656
|
-
private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
|
|
657
|
-
const gv = block.header.globalVariables;
|
|
658
|
-
return {
|
|
659
|
-
chainId: gv.chainId,
|
|
660
|
-
version: gv.version,
|
|
661
|
-
slotNumber: gv.slotNumber,
|
|
662
|
-
coinbase: gv.coinbase,
|
|
663
|
-
feeRecipient: gv.feeRecipient,
|
|
664
|
-
gasFees: gv.gasFees,
|
|
665
|
-
};
|
|
620
|
+
await this.p2pClient.addOwnCheckpointAttestations(attestations);
|
|
621
|
+
return attestations;
|
|
666
622
|
}
|
|
667
623
|
|
|
668
624
|
/**
|
|
669
625
|
* Uploads blobs for a checkpoint to the filestore (fire and forget).
|
|
670
626
|
*/
|
|
671
|
-
|
|
627
|
+
protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
|
|
672
628
|
try {
|
|
673
629
|
const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
|
|
674
630
|
if (!lastBlockHeader) {
|
|
@@ -683,7 +639,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
683
639
|
}
|
|
684
640
|
|
|
685
641
|
const blobFields = blocks.flatMap(b => b.toBlobFields());
|
|
686
|
-
const blobs: Blob[] = getBlobsPerL1Block(blobFields);
|
|
642
|
+
const blobs: Blob[] = await getBlobsPerL1Block(blobFields);
|
|
687
643
|
await this.blobClient.sendBlobsToFilestore(blobs);
|
|
688
644
|
this.log.debug(`Uploaded ${blobs.length} blobs to filestore for checkpoint at slot ${proposal.slotNumber}`, {
|
|
689
645
|
...proposalInfo,
|
|
@@ -721,6 +677,52 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
721
677
|
]);
|
|
722
678
|
}
|
|
723
679
|
|
|
680
|
+
/**
|
|
681
|
+
* Handle detection of a duplicate proposal (equivocation).
|
|
682
|
+
* Emits a slash event when a proposer sends multiple proposals for the same position.
|
|
683
|
+
*/
|
|
684
|
+
private handleDuplicateProposal(info: DuplicateProposalInfo): void {
|
|
685
|
+
const { slot, proposer, type } = info;
|
|
686
|
+
|
|
687
|
+
this.log.warn(`Triggering slash event for duplicate ${type} proposal from ${proposer.toString()} at slot ${slot}`, {
|
|
688
|
+
proposer: proposer.toString(),
|
|
689
|
+
slot,
|
|
690
|
+
type,
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// Emit slash event
|
|
694
|
+
this.emit(WANT_TO_SLASH_EVENT, [
|
|
695
|
+
{
|
|
696
|
+
validator: proposer,
|
|
697
|
+
amount: this.config.slashDuplicateProposalPenalty,
|
|
698
|
+
offenseType: OffenseType.DUPLICATE_PROPOSAL,
|
|
699
|
+
epochOrSlot: BigInt(slot),
|
|
700
|
+
},
|
|
701
|
+
]);
|
|
702
|
+
}
|
|
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
|
+
|
|
724
726
|
async createBlockProposal(
|
|
725
727
|
blockHeader: BlockHeader,
|
|
726
728
|
indexWithinCheckpoint: IndexWithinCheckpoint,
|
|
@@ -730,11 +732,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
730
732
|
proposerAddress: EthAddress | undefined,
|
|
731
733
|
options: BlockProposalOptions = {},
|
|
732
734
|
): Promise<BlockProposal> {
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
+
}
|
|
738
748
|
|
|
739
749
|
this.log.info(
|
|
740
750
|
`Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`,
|
|
@@ -751,25 +761,42 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
751
761
|
broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
|
|
752
762
|
},
|
|
753
763
|
);
|
|
754
|
-
this.
|
|
764
|
+
this.lastProposedBlock = newProposal;
|
|
755
765
|
return newProposal;
|
|
756
766
|
}
|
|
757
767
|
|
|
758
768
|
async createCheckpointProposal(
|
|
759
769
|
checkpointHeader: CheckpointHeader,
|
|
760
770
|
archive: Fr,
|
|
771
|
+
feeAssetPriceModifier: bigint,
|
|
761
772
|
lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined,
|
|
762
773
|
proposerAddress: EthAddress | undefined,
|
|
763
774
|
options: CheckpointProposalOptions = {},
|
|
764
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
|
+
|
|
765
789
|
this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
|
|
766
|
-
|
|
790
|
+
const newProposal = await this.validationService.createCheckpointProposal(
|
|
767
791
|
checkpointHeader,
|
|
768
792
|
archive,
|
|
793
|
+
feeAssetPriceModifier,
|
|
769
794
|
lastBlockInfo,
|
|
770
795
|
proposerAddress,
|
|
771
796
|
options,
|
|
772
797
|
);
|
|
798
|
+
this.lastProposedCheckpoint = newProposal;
|
|
799
|
+
return newProposal;
|
|
773
800
|
}
|
|
774
801
|
|
|
775
802
|
async broadcastBlockProposal(proposal: BlockProposal): Promise<void> {
|
|
@@ -791,6 +818,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
791
818
|
this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
|
|
792
819
|
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
|
|
793
820
|
|
|
821
|
+
if (!attestations) {
|
|
822
|
+
return [];
|
|
823
|
+
}
|
|
824
|
+
|
|
794
825
|
// We broadcast our own attestations to our peers so, in case our block does not get mined on L1,
|
|
795
826
|
// other nodes can see that our validators did attest to this block proposal, and do not slash us
|
|
796
827
|
// due to inactivity for missed attestations.
|