@aztec/validator-client 0.0.1-commit.fffb133c → 0.0.1-dev
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 +62 -18
- package/dest/checkpoint_builder.d.ts +26 -14
- 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 +26 -3
- package/dest/duties/validation_service.d.ts +3 -4
- package/dest/duties/validation_service.d.ts.map +1 -1
- package/dest/duties/validation_service.js +11 -22
- 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 +12 -3
- package/dest/metrics.d.ts.map +1 -1
- package/dest/metrics.js +46 -5
- package/dest/proposal_handler.d.ts +94 -0
- package/dest/proposal_handler.d.ts.map +1 -0
- package/dest/{block_proposal_handler.js → proposal_handler.js} +376 -69
- package/dest/validator.d.ts +37 -24
- package/dest/validator.d.ts.map +1 -1
- package/dest/validator.js +167 -215
- package/package.json +19 -17
- package/src/checkpoint_builder.ts +183 -50
- package/src/config.ts +26 -3
- package/src/duties/validation_service.ts +18 -24
- 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 +63 -6
- package/src/proposal_handler.ts +903 -0
- package/src/validator.ts +228 -248
- package/dest/block_proposal_handler.d.ts +0 -64
- package/dest/block_proposal_handler.d.ts.map +0 -1
- 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/block_proposal_handler.ts +0 -554
- 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,5 +1,4 @@
|
|
|
1
1
|
import type { BlobClientInterface } from '@aztec/blob-client/client';
|
|
2
|
-
import { type Blob, getBlobsPerL1Block } from '@aztec/blob-lib';
|
|
3
2
|
import type { EpochCache } from '@aztec/epoch-cache';
|
|
4
3
|
import {
|
|
5
4
|
BlockNumber,
|
|
@@ -9,53 +8,52 @@ import {
|
|
|
9
8
|
SlotNumber,
|
|
10
9
|
} from '@aztec/foundation/branded-types';
|
|
11
10
|
import { Fr } from '@aztec/foundation/curves/bn254';
|
|
12
|
-
import { TimeoutError } from '@aztec/foundation/error';
|
|
13
11
|
import type { EthAddress } from '@aztec/foundation/eth-address';
|
|
14
12
|
import type { Signature } from '@aztec/foundation/eth-signature';
|
|
15
|
-
import { type
|
|
16
|
-
import { retryUntil } from '@aztec/foundation/retry';
|
|
13
|
+
import { type Logger, createLogger } from '@aztec/foundation/log';
|
|
17
14
|
import { RunningPromise } from '@aztec/foundation/running-promise';
|
|
18
15
|
import { sleep } from '@aztec/foundation/sleep';
|
|
19
16
|
import { DateProvider } from '@aztec/foundation/timer';
|
|
20
17
|
import type { KeystoreManager } from '@aztec/node-keystore';
|
|
21
|
-
import type { P2P, PeerId
|
|
18
|
+
import type { DuplicateAttestationInfo, DuplicateProposalInfo, P2P, PeerId } from '@aztec/p2p';
|
|
22
19
|
import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
|
|
23
20
|
import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher';
|
|
24
21
|
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
|
|
25
|
-
import type { CommitteeAttestationsAndSigners,
|
|
22
|
+
import type { CommitteeAttestationsAndSigners, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
|
|
26
23
|
import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
|
|
27
24
|
import type {
|
|
28
|
-
|
|
25
|
+
ITxProvider,
|
|
29
26
|
Validator,
|
|
30
27
|
ValidatorClientFullConfig,
|
|
31
28
|
WorldStateSynchronizer,
|
|
32
29
|
} from '@aztec/stdlib/interfaces/server';
|
|
33
30
|
import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
|
|
34
|
-
import
|
|
35
|
-
BlockProposal,
|
|
36
|
-
BlockProposalOptions,
|
|
37
|
-
CheckpointAttestation,
|
|
38
|
-
|
|
39
|
-
|
|
31
|
+
import {
|
|
32
|
+
type BlockProposal,
|
|
33
|
+
type BlockProposalOptions,
|
|
34
|
+
type CheckpointAttestation,
|
|
35
|
+
CheckpointProposal,
|
|
36
|
+
type CheckpointProposalCore,
|
|
37
|
+
type CheckpointProposalOptions,
|
|
40
38
|
} from '@aztec/stdlib/p2p';
|
|
41
|
-
import { CheckpointProposal } from '@aztec/stdlib/p2p';
|
|
42
39
|
import type { CheckpointHeader } from '@aztec/stdlib/rollup';
|
|
43
|
-
import type { BlockHeader,
|
|
40
|
+
import type { BlockHeader, Tx } from '@aztec/stdlib/tx';
|
|
44
41
|
import { AttestationTimeoutError } from '@aztec/stdlib/validators';
|
|
45
42
|
import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client';
|
|
46
|
-
import { createHASigner } from '@aztec/validator-ha-signer/factory';
|
|
47
|
-
import { DutyType, type SigningContext } from '@aztec/validator-ha-signer/types';
|
|
43
|
+
import { createHASigner, createSignerFromSharedDb } from '@aztec/validator-ha-signer/factory';
|
|
44
|
+
import { DutyType, type SigningContext, type SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types';
|
|
45
|
+
import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
|
|
48
46
|
|
|
49
47
|
import { EventEmitter } from 'events';
|
|
50
48
|
import type { TypedDataDefinition } from 'viem';
|
|
51
49
|
|
|
52
|
-
import { BlockProposalHandler, type BlockProposalValidationFailureReason } from './block_proposal_handler.js';
|
|
53
50
|
import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
|
|
54
51
|
import { ValidationService } from './duties/validation_service.js';
|
|
55
52
|
import { HAKeyStore } from './key_store/ha_key_store.js';
|
|
56
53
|
import type { ExtendedValidatorKeyStore } from './key_store/interface.js';
|
|
57
54
|
import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
|
|
58
55
|
import { ValidatorMetrics } from './metrics.js';
|
|
56
|
+
import { type BlockProposalValidationFailureReason, ProposalHandler } from './proposal_handler.js';
|
|
59
57
|
|
|
60
58
|
// We maintain a set of proposers who have proposed invalid blocks.
|
|
61
59
|
// Just cap the set to avoid unbounded growth.
|
|
@@ -75,34 +73,33 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
75
73
|
private validationService: ValidationService;
|
|
76
74
|
private metrics: ValidatorMetrics;
|
|
77
75
|
private log: Logger;
|
|
78
|
-
|
|
79
76
|
// Whether it has already registered handlers on the p2p client
|
|
80
77
|
private hasRegisteredHandlers = false;
|
|
81
78
|
|
|
82
|
-
|
|
83
|
-
private
|
|
79
|
+
/** Tracks the last block proposal we created, to detect duplicate proposal attempts. */
|
|
80
|
+
private lastProposedBlock?: BlockProposal;
|
|
81
|
+
|
|
82
|
+
/** Tracks the last checkpoint proposal we created. */
|
|
83
|
+
private lastProposedCheckpoint?: CheckpointProposal;
|
|
84
84
|
|
|
85
85
|
private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined;
|
|
86
86
|
private epochCacheUpdateLoop: RunningPromise;
|
|
87
|
+
/** Tracks the last epoch in which each attester successfully submitted at least one attestation. */
|
|
88
|
+
private lastAttestedEpochByAttester: Map<string, EpochNumber> = new Map();
|
|
87
89
|
|
|
88
90
|
private proposersOfInvalidBlocks: Set<string> = new Set();
|
|
89
91
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// eslint-disable-next-line aztec-custom/no-non-primitive-in-collections
|
|
93
|
-
private validatedBlockSlots: Set<SlotNumber> = new Set();
|
|
92
|
+
/** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */
|
|
93
|
+
private lastAttestedProposal?: CheckpointProposalCore;
|
|
94
94
|
|
|
95
95
|
protected constructor(
|
|
96
96
|
private keyStore: ExtendedValidatorKeyStore,
|
|
97
97
|
private epochCache: EpochCache,
|
|
98
98
|
private p2pClient: P2P,
|
|
99
|
-
private
|
|
100
|
-
private blockSource: L2BlockSource,
|
|
101
|
-
private checkpointsBuilder: FullNodeCheckpointsBuilder,
|
|
102
|
-
private worldState: WorldStateSynchronizer,
|
|
103
|
-
private l1ToL2MessageSource: L1ToL2MessageSource,
|
|
99
|
+
private proposalHandler: ProposalHandler,
|
|
104
100
|
private config: ValidatorClientFullConfig,
|
|
105
101
|
private blobClient: BlobClientInterface,
|
|
102
|
+
private haSigner: ValidatorHASigner | undefined,
|
|
106
103
|
private dateProvider: DateProvider = new DateProvider(),
|
|
107
104
|
telemetry: TelemetryClient = getTelemetryClient(),
|
|
108
105
|
log = createLogger('validator'),
|
|
@@ -156,6 +153,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
156
153
|
this.log.trace(`No committee found for slot`);
|
|
157
154
|
return;
|
|
158
155
|
}
|
|
156
|
+
this.metrics.setCurrentEpoch(epoch);
|
|
159
157
|
if (epoch !== this.lastEpochForCommitteeUpdateLoop) {
|
|
160
158
|
const me = this.getValidatorAddresses();
|
|
161
159
|
const committeeSet = new Set(committee.map(v => v.toString()));
|
|
@@ -184,17 +182,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
184
182
|
p2pClient: P2P,
|
|
185
183
|
blockSource: L2BlockSource & L2BlockSink,
|
|
186
184
|
l1ToL2MessageSource: L1ToL2MessageSource,
|
|
187
|
-
txProvider:
|
|
185
|
+
txProvider: ITxProvider,
|
|
188
186
|
keyStoreManager: KeystoreManager,
|
|
189
187
|
blobClient: BlobClientInterface,
|
|
190
188
|
dateProvider: DateProvider = new DateProvider(),
|
|
191
189
|
telemetry: TelemetryClient = getTelemetryClient(),
|
|
190
|
+
slashingProtectionDb?: SlashingProtectionDatabase,
|
|
192
191
|
) {
|
|
193
192
|
const metrics = new ValidatorMetrics(telemetry);
|
|
194
193
|
const blockProposalValidator = new BlockProposalValidator(epochCache, {
|
|
195
194
|
txsPermitted: !config.disableTransactions,
|
|
195
|
+
maxTxsPerBlock: config.validateMaxTxsPerBlock,
|
|
196
196
|
});
|
|
197
|
-
const
|
|
197
|
+
const proposalHandler = new ProposalHandler(
|
|
198
198
|
checkpointsBuilder,
|
|
199
199
|
worldState,
|
|
200
200
|
blockSource,
|
|
@@ -203,33 +203,39 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
203
203
|
blockProposalValidator,
|
|
204
204
|
epochCache,
|
|
205
205
|
config,
|
|
206
|
+
blobClient,
|
|
206
207
|
metrics,
|
|
207
208
|
dateProvider,
|
|
208
209
|
telemetry,
|
|
209
210
|
);
|
|
210
211
|
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
|
|
213
|
+
let validatorKeyStore: ExtendedValidatorKeyStore = nodeKeystoreAdapter;
|
|
214
|
+
let haSigner: ValidatorHASigner | undefined;
|
|
215
|
+
if (slashingProtectionDb) {
|
|
216
|
+
// Shared database mode: use a pre-existing database (e.g. for testing HA setups).
|
|
217
|
+
const { signer } = createSignerFromSharedDb(slashingProtectionDb, config);
|
|
218
|
+
haSigner = signer;
|
|
219
|
+
validatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, signer);
|
|
220
|
+
} else if (config.haSigningEnabled) {
|
|
213
221
|
// If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
|
|
214
222
|
const haConfig = {
|
|
215
223
|
...config,
|
|
216
224
|
maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
|
|
217
225
|
};
|
|
218
226
|
const { signer } = await createHASigner(haConfig);
|
|
219
|
-
|
|
227
|
+
haSigner = signer;
|
|
228
|
+
validatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, signer);
|
|
220
229
|
}
|
|
221
230
|
|
|
222
231
|
const validator = new ValidatorClient(
|
|
223
232
|
validatorKeyStore,
|
|
224
233
|
epochCache,
|
|
225
234
|
p2pClient,
|
|
226
|
-
|
|
227
|
-
blockSource,
|
|
228
|
-
checkpointsBuilder,
|
|
229
|
-
worldState,
|
|
230
|
-
l1ToL2MessageSource,
|
|
235
|
+
proposalHandler,
|
|
231
236
|
config,
|
|
232
237
|
blobClient,
|
|
238
|
+
haSigner,
|
|
233
239
|
dateProvider,
|
|
234
240
|
telemetry,
|
|
235
241
|
);
|
|
@@ -243,8 +249,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
243
249
|
.filter(addr => !this.config.disabledValidators.some(disabled => disabled.equals(addr)));
|
|
244
250
|
}
|
|
245
251
|
|
|
246
|
-
public
|
|
247
|
-
return this.
|
|
252
|
+
public getProposalHandler() {
|
|
253
|
+
return this.proposalHandler;
|
|
248
254
|
}
|
|
249
255
|
|
|
250
256
|
public signWithAddress(addr: EthAddress, msg: TypedDataDefinition, context: SigningContext) {
|
|
@@ -267,6 +273,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
267
273
|
this.config = { ...this.config, ...config };
|
|
268
274
|
}
|
|
269
275
|
|
|
276
|
+
public reloadKeystore(newManager: KeystoreManager): void {
|
|
277
|
+
if (this.config.haSigningEnabled && !this.haSigner) {
|
|
278
|
+
this.log.warn(
|
|
279
|
+
'HA signing is enabled in config but was not initialized at startup. ' +
|
|
280
|
+
'Restart the node to enable HA signing.',
|
|
281
|
+
);
|
|
282
|
+
} else if (!this.config.haSigningEnabled && this.haSigner) {
|
|
283
|
+
this.log.warn(
|
|
284
|
+
'HA signing was disabled via config update but the HA signer is still active. ' +
|
|
285
|
+
'Restart the node to fully disable HA signing.',
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
|
|
290
|
+
if (this.haSigner) {
|
|
291
|
+
this.keyStore = new HAKeyStore(newAdapter, this.haSigner);
|
|
292
|
+
} else {
|
|
293
|
+
this.keyStore = newAdapter;
|
|
294
|
+
}
|
|
295
|
+
this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
|
|
296
|
+
}
|
|
297
|
+
|
|
270
298
|
public async start() {
|
|
271
299
|
if (this.epochCacheUpdateLoop.isRunning()) {
|
|
272
300
|
this.log.warn(`Validator client already started`);
|
|
@@ -313,6 +341,16 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
313
341
|
): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
|
|
314
342
|
this.p2pClient.registerCheckpointProposalHandler(checkpointHandler);
|
|
315
343
|
|
|
344
|
+
// Duplicate proposal handler - triggers slashing for equivocation
|
|
345
|
+
this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
|
|
346
|
+
this.handleDuplicateProposal(info);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Duplicate attestation handler - triggers slashing for attestation equivocation
|
|
350
|
+
this.p2pClient.registerDuplicateAttestationCallback((info: DuplicateAttestationInfo) => {
|
|
351
|
+
this.handleDuplicateAttestation(info);
|
|
352
|
+
});
|
|
353
|
+
|
|
316
354
|
const myAddresses = this.getValidatorAddresses();
|
|
317
355
|
this.p2pClient.registerThisValidatorAddresses(myAddresses);
|
|
318
356
|
|
|
@@ -340,6 +378,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
340
378
|
return false;
|
|
341
379
|
}
|
|
342
380
|
|
|
381
|
+
// Log self-proposals from HA peers (same validator key on different nodes)
|
|
382
|
+
if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
383
|
+
this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
|
|
384
|
+
proposer: proposer.toString(),
|
|
385
|
+
slotNumber,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
343
389
|
// Check if we're in the committee (for metrics purposes)
|
|
344
390
|
const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
|
|
345
391
|
const partOfCommittee = inCommittee.length > 0;
|
|
@@ -362,16 +408,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
362
408
|
alwaysReexecuteBlockProposals ||
|
|
363
409
|
this.blobClient.canUpload();
|
|
364
410
|
|
|
365
|
-
const validationResult = await this.
|
|
411
|
+
const validationResult = await this.proposalHandler.handleBlockProposal(
|
|
366
412
|
proposal,
|
|
367
413
|
proposalSender,
|
|
368
414
|
!!shouldReexecute && !escapeHatchOpen,
|
|
369
415
|
);
|
|
370
416
|
|
|
371
417
|
if (!validationResult.isValid) {
|
|
372
|
-
this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
373
|
-
|
|
374
418
|
const reason = validationResult.reason || 'unknown';
|
|
419
|
+
|
|
420
|
+
this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
|
|
421
|
+
|
|
375
422
|
// Classify failure reason: bad proposal vs node issue
|
|
376
423
|
const badProposalReasons: BlockProposalValidationFailureReason[] = [
|
|
377
424
|
'invalid_proposal',
|
|
@@ -413,10 +460,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
413
460
|
return false;
|
|
414
461
|
}
|
|
415
462
|
|
|
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
463
|
return true;
|
|
421
464
|
}
|
|
422
465
|
|
|
@@ -439,52 +482,40 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
439
482
|
return undefined;
|
|
440
483
|
}
|
|
441
484
|
|
|
442
|
-
//
|
|
443
|
-
if (
|
|
444
|
-
this.log.
|
|
485
|
+
// Ignore proposals from ourselves (may happen in HA setups)
|
|
486
|
+
if (proposer && this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
487
|
+
this.log.debug(`Ignoring block proposal from self for slot ${slotNumber}`, {
|
|
488
|
+
proposer: proposer.toString(),
|
|
489
|
+
slotNumber,
|
|
490
|
+
});
|
|
445
491
|
return undefined;
|
|
446
492
|
}
|
|
447
493
|
|
|
448
|
-
// Check that I have any address in
|
|
494
|
+
// Check that I have any address in the committee where this checkpoint will land before attesting
|
|
449
495
|
const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
|
|
450
496
|
const partOfCommittee = inCommittee.length > 0;
|
|
451
497
|
|
|
452
498
|
const proposalInfo = {
|
|
453
499
|
slotNumber,
|
|
454
500
|
archive: proposal.archive.toString(),
|
|
455
|
-
proposer: proposer
|
|
456
|
-
txCount: proposal.txHashes.length,
|
|
501
|
+
proposer: proposer?.toString(),
|
|
457
502
|
};
|
|
458
503
|
this.log.info(`Received checkpoint proposal for slot ${slotNumber}`, {
|
|
459
504
|
...proposalInfo,
|
|
460
|
-
txHashes: proposal.txHashes.map(t => t.toString()),
|
|
461
505
|
fishermanMode: this.config.fishermanMode || false,
|
|
462
506
|
});
|
|
463
507
|
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
this.log.warn(`No validated block found for slot ${slotNumber}, refusing to attest to checkpoint`, proposalInfo);
|
|
468
|
-
return undefined;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
|
|
472
|
-
// TODO(palla/mbps): Change default to false once checkpoint validation is stable.
|
|
473
|
-
if (this.config.skipCheckpointProposalValidation !== false) {
|
|
474
|
-
this.log.verbose(`Skipping checkpoint proposal validation for slot ${slotNumber}`, proposalInfo);
|
|
508
|
+
// Validate the checkpoint proposal and upload blobs (unless skipCheckpointProposalValidation is set)
|
|
509
|
+
if (this.config.skipCheckpointProposalValidation) {
|
|
510
|
+
this.log.warn(`Skipping checkpoint proposal validation for slot ${slotNumber}`, proposalInfo);
|
|
475
511
|
} else {
|
|
476
|
-
const validationResult = await this.
|
|
512
|
+
const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
|
|
477
513
|
if (!validationResult.isValid) {
|
|
478
514
|
this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
479
515
|
return undefined;
|
|
480
516
|
}
|
|
481
517
|
}
|
|
482
518
|
|
|
483
|
-
// Upload blobs to filestore if we can (fire and forget)
|
|
484
|
-
if (this.blobClient.canUpload()) {
|
|
485
|
-
void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
519
|
// Check that I have any address in current committee before attesting
|
|
489
520
|
// In fisherman mode, we still create attestations for validation even if not in committee
|
|
490
521
|
if (!partOfCommittee && !this.config.fishermanMode) {
|
|
@@ -501,6 +532,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
501
532
|
|
|
502
533
|
this.metrics.incSuccessfulAttestations(inCommittee.length);
|
|
503
534
|
|
|
535
|
+
// Track epoch participation per attester: count each (attester, epoch) pair at most once
|
|
536
|
+
const proposalEpoch = getEpochAtSlot(slotNumber, this.epochCache.getL1Constants());
|
|
537
|
+
for (const attester of inCommittee) {
|
|
538
|
+
const key = attester.toString();
|
|
539
|
+
const lastEpoch = this.lastAttestedEpochByAttester.get(key);
|
|
540
|
+
if (lastEpoch === undefined || proposalEpoch > lastEpoch) {
|
|
541
|
+
this.lastAttestedEpochByAttester.set(key, proposalEpoch);
|
|
542
|
+
this.metrics.incAttestedEpochCount(attester);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
504
546
|
// Determine which validators should attest
|
|
505
547
|
let attestors: EthAddress[];
|
|
506
548
|
if (partOfCommittee) {
|
|
@@ -526,183 +568,46 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
526
568
|
return undefined;
|
|
527
569
|
}
|
|
528
570
|
|
|
529
|
-
return this.createCheckpointAttestationsFromProposal(proposal, attestors);
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
private async createCheckpointAttestationsFromProposal(
|
|
533
|
-
proposal: CheckpointProposalCore,
|
|
534
|
-
attestors: EthAddress[] = [],
|
|
535
|
-
): Promise<CheckpointAttestation[]> {
|
|
536
|
-
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
|
|
537
|
-
await this.p2pClient.addCheckpointAttestations(attestations);
|
|
538
|
-
return attestations;
|
|
571
|
+
return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
|
|
539
572
|
}
|
|
540
573
|
|
|
541
574
|
/**
|
|
542
|
-
*
|
|
543
|
-
* @returns
|
|
575
|
+
* Checks if we should attest to a slot based on equivocation prevention rules.
|
|
576
|
+
* @returns true if we should attest, false if we should skip
|
|
544
577
|
*/
|
|
545
|
-
private
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const slot = proposal.slotNumber;
|
|
550
|
-
const timeoutSeconds = 10;
|
|
551
|
-
|
|
552
|
-
// Wait for last block to sync by archive
|
|
553
|
-
let lastBlockHeader: BlockHeader | undefined;
|
|
554
|
-
try {
|
|
555
|
-
lastBlockHeader = await retryUntil(
|
|
556
|
-
async () => {
|
|
557
|
-
await this.blockSource.syncImmediate();
|
|
558
|
-
return this.blockSource.getBlockHeaderByArchive(proposal.archive);
|
|
559
|
-
},
|
|
560
|
-
`waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
|
|
561
|
-
timeoutSeconds,
|
|
562
|
-
0.5,
|
|
563
|
-
);
|
|
564
|
-
} catch (err) {
|
|
565
|
-
if (err instanceof TimeoutError) {
|
|
566
|
-
this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
|
|
567
|
-
return { isValid: false, reason: 'last_block_not_found' };
|
|
568
|
-
}
|
|
569
|
-
this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
|
|
570
|
-
return { isValid: false, reason: 'block_fetch_error' };
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
if (!lastBlockHeader) {
|
|
574
|
-
this.log.warn(`Last block not found for checkpoint proposal`, proposalInfo);
|
|
575
|
-
return { isValid: false, reason: 'last_block_not_found' };
|
|
578
|
+
private shouldAttestToSlot(slotNumber: SlotNumber): boolean {
|
|
579
|
+
// If attestToEquivocatedProposals is true, always allow
|
|
580
|
+
if (this.config.attestToEquivocatedProposals) {
|
|
581
|
+
return true;
|
|
576
582
|
}
|
|
577
583
|
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
return { isValid: false, reason: 'no_blocks_for_slot' };
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
|
|
586
|
-
...proposalInfo,
|
|
587
|
-
blockNumbers: blocks.map(b => b.number),
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
// Get checkpoint constants from first block
|
|
591
|
-
const firstBlock = blocks[0];
|
|
592
|
-
const constants = this.extractCheckpointConstants(firstBlock);
|
|
593
|
-
const checkpointNumber = firstBlock.checkpointNumber;
|
|
594
|
-
|
|
595
|
-
// Get L1-to-L2 messages for this checkpoint
|
|
596
|
-
const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
|
|
597
|
-
|
|
598
|
-
// Compute the previous checkpoint out hashes for the epoch.
|
|
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.
|
|
601
|
-
const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
|
|
602
|
-
const previousCheckpoints = (await this.blockSource.getCheckpointsForEpoch(epoch))
|
|
603
|
-
.filter(b => b.number < checkpointNumber)
|
|
604
|
-
.sort((a, b) => a.number - b.number);
|
|
605
|
-
const previousCheckpointOutHashes = previousCheckpoints.map(c => c.getCheckpointOutHash());
|
|
606
|
-
|
|
607
|
-
// Fork world state at the block before the first block
|
|
608
|
-
const parentBlockNumber = BlockNumber(firstBlock.number - 1);
|
|
609
|
-
const fork = await this.worldState.fork(parentBlockNumber);
|
|
610
|
-
|
|
611
|
-
try {
|
|
612
|
-
// Create checkpoint builder with all existing blocks
|
|
613
|
-
const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
|
|
614
|
-
checkpointNumber,
|
|
615
|
-
constants,
|
|
616
|
-
l1ToL2Messages,
|
|
617
|
-
previousCheckpointOutHashes,
|
|
618
|
-
fork,
|
|
619
|
-
blocks,
|
|
584
|
+
// Check if incoming slot is strictly greater than last attested
|
|
585
|
+
if (this.lastAttestedProposal && slotNumber <= this.lastAttestedProposal.slotNumber) {
|
|
586
|
+
this.log.warn(
|
|
587
|
+
`Refusing to process a proposal for slot ${slotNumber} given we already attested to a proposal for slot ${this.lastAttestedProposal.slotNumber}`,
|
|
620
588
|
);
|
|
621
|
-
|
|
622
|
-
// Complete the checkpoint to get computed values
|
|
623
|
-
const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
|
|
624
|
-
|
|
625
|
-
// Compare checkpoint header with proposal
|
|
626
|
-
if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
|
|
627
|
-
this.log.warn(`Checkpoint header mismatch`, {
|
|
628
|
-
...proposalInfo,
|
|
629
|
-
computed: computedCheckpoint.header.toInspect(),
|
|
630
|
-
proposal: proposal.checkpointHeader.toInspect(),
|
|
631
|
-
});
|
|
632
|
-
return { isValid: false, reason: 'checkpoint_header_mismatch' };
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
// Compare archive root with proposal
|
|
636
|
-
if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
|
|
637
|
-
this.log.warn(`Archive root mismatch`, {
|
|
638
|
-
...proposalInfo,
|
|
639
|
-
computed: computedCheckpoint.archive.root.toString(),
|
|
640
|
-
proposal: proposal.archive.toString(),
|
|
641
|
-
});
|
|
642
|
-
return { isValid: false, reason: 'archive_mismatch' };
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// Check that the accumulated out hash matches the value in the proposal.
|
|
646
|
-
const computedOutHash = computedCheckpoint.getCheckpointOutHash();
|
|
647
|
-
const proposalOutHash = proposal.checkpointHeader.epochOutHash;
|
|
648
|
-
if (!computedOutHash.equals(proposalOutHash)) {
|
|
649
|
-
this.log.warn(`Epoch out hash mismatch`, {
|
|
650
|
-
proposalOutHash: proposalOutHash.toString(),
|
|
651
|
-
computedOutHash: computedOutHash.toString(),
|
|
652
|
-
...proposalInfo,
|
|
653
|
-
});
|
|
654
|
-
return { isValid: false, reason: 'out_hash_mismatch' };
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
|
|
658
|
-
return { isValid: true };
|
|
659
|
-
} finally {
|
|
660
|
-
await fork.close();
|
|
589
|
+
return false;
|
|
661
590
|
}
|
|
662
|
-
}
|
|
663
591
|
|
|
664
|
-
|
|
665
|
-
* Extract checkpoint global variables from a block.
|
|
666
|
-
*/
|
|
667
|
-
private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
|
|
668
|
-
const gv = block.header.globalVariables;
|
|
669
|
-
return {
|
|
670
|
-
chainId: gv.chainId,
|
|
671
|
-
version: gv.version,
|
|
672
|
-
slotNumber: gv.slotNumber,
|
|
673
|
-
coinbase: gv.coinbase,
|
|
674
|
-
feeRecipient: gv.feeRecipient,
|
|
675
|
-
gasFees: gv.gasFees,
|
|
676
|
-
};
|
|
592
|
+
return true;
|
|
677
593
|
}
|
|
678
594
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
595
|
+
private async createCheckpointAttestationsFromProposal(
|
|
596
|
+
proposal: CheckpointProposalCore,
|
|
597
|
+
attestors: EthAddress[] = [],
|
|
598
|
+
): Promise<CheckpointAttestation[] | undefined> {
|
|
599
|
+
// Equivocation check: must happen right before signing to minimize the race window
|
|
600
|
+
if (!this.shouldAttestToSlot(proposal.slotNumber)) {
|
|
601
|
+
return undefined;
|
|
602
|
+
}
|
|
689
603
|
|
|
690
|
-
|
|
691
|
-
if (blocks.length === 0) {
|
|
692
|
-
this.log.warn(`No blocks found for blob upload`, proposalInfo);
|
|
693
|
-
return;
|
|
694
|
-
}
|
|
604
|
+
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
|
|
695
605
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
numBlobs: blobs.length,
|
|
702
|
-
});
|
|
703
|
-
} catch (err) {
|
|
704
|
-
this.log.warn(`Failed to upload blobs for checkpoint: ${err}`, proposalInfo);
|
|
705
|
-
}
|
|
606
|
+
// Track the proposal we attested to (to prevent equivocation)
|
|
607
|
+
this.lastAttestedProposal = proposal;
|
|
608
|
+
|
|
609
|
+
await this.p2pClient.addOwnCheckpointAttestations(attestations);
|
|
610
|
+
return attestations;
|
|
706
611
|
}
|
|
707
612
|
|
|
708
613
|
private slashInvalidBlock(proposal: BlockProposal) {
|
|
@@ -732,6 +637,52 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
732
637
|
]);
|
|
733
638
|
}
|
|
734
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Handle detection of a duplicate proposal (equivocation).
|
|
642
|
+
* Emits a slash event when a proposer sends multiple proposals for the same position.
|
|
643
|
+
*/
|
|
644
|
+
private handleDuplicateProposal(info: DuplicateProposalInfo): void {
|
|
645
|
+
const { slot, proposer, type } = info;
|
|
646
|
+
|
|
647
|
+
this.log.warn(`Triggering slash event for duplicate ${type} proposal from ${proposer.toString()} at slot ${slot}`, {
|
|
648
|
+
proposer: proposer.toString(),
|
|
649
|
+
slot,
|
|
650
|
+
type,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// Emit slash event
|
|
654
|
+
this.emit(WANT_TO_SLASH_EVENT, [
|
|
655
|
+
{
|
|
656
|
+
validator: proposer,
|
|
657
|
+
amount: this.config.slashDuplicateProposalPenalty,
|
|
658
|
+
offenseType: OffenseType.DUPLICATE_PROPOSAL,
|
|
659
|
+
epochOrSlot: BigInt(slot),
|
|
660
|
+
},
|
|
661
|
+
]);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Handle detection of a duplicate attestation (equivocation).
|
|
666
|
+
* Emits a slash event when an attester signs attestations for different proposals at the same slot.
|
|
667
|
+
*/
|
|
668
|
+
private handleDuplicateAttestation(info: DuplicateAttestationInfo): void {
|
|
669
|
+
const { slot, attester } = info;
|
|
670
|
+
|
|
671
|
+
this.log.warn(`Triggering slash event for duplicate attestation from ${attester.toString()} at slot ${slot}`, {
|
|
672
|
+
attester: attester.toString(),
|
|
673
|
+
slot,
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
this.emit(WANT_TO_SLASH_EVENT, [
|
|
677
|
+
{
|
|
678
|
+
validator: attester,
|
|
679
|
+
amount: this.config.slashDuplicateAttestationPenalty,
|
|
680
|
+
offenseType: OffenseType.DUPLICATE_ATTESTATION,
|
|
681
|
+
epochOrSlot: BigInt(slot),
|
|
682
|
+
},
|
|
683
|
+
]);
|
|
684
|
+
}
|
|
685
|
+
|
|
735
686
|
async createBlockProposal(
|
|
736
687
|
blockHeader: BlockHeader,
|
|
737
688
|
indexWithinCheckpoint: IndexWithinCheckpoint,
|
|
@@ -739,13 +690,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
739
690
|
archive: Fr,
|
|
740
691
|
txs: Tx[],
|
|
741
692
|
proposerAddress: EthAddress | undefined,
|
|
742
|
-
options: BlockProposalOptions,
|
|
693
|
+
options: BlockProposalOptions = {},
|
|
743
694
|
): Promise<BlockProposal> {
|
|
744
|
-
//
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
695
|
+
// Validate that we're not creating a proposal for an older or equal position
|
|
696
|
+
if (this.lastProposedBlock) {
|
|
697
|
+
const lastSlot = this.lastProposedBlock.slotNumber;
|
|
698
|
+
const lastIndex = this.lastProposedBlock.indexWithinCheckpoint;
|
|
699
|
+
const newSlot = blockHeader.globalVariables.slotNumber;
|
|
700
|
+
|
|
701
|
+
if (newSlot < lastSlot || (newSlot === lastSlot && indexWithinCheckpoint <= lastIndex)) {
|
|
702
|
+
throw new Error(
|
|
703
|
+
`Cannot create block proposal for slot ${newSlot} index ${indexWithinCheckpoint}: ` +
|
|
704
|
+
`already proposed block for slot ${lastSlot} index ${lastIndex}`,
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
749
708
|
|
|
750
709
|
this.log.info(
|
|
751
710
|
`Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`,
|
|
@@ -762,25 +721,42 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
762
721
|
broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
|
|
763
722
|
},
|
|
764
723
|
);
|
|
765
|
-
this.
|
|
724
|
+
this.lastProposedBlock = newProposal;
|
|
766
725
|
return newProposal;
|
|
767
726
|
}
|
|
768
727
|
|
|
769
728
|
async createCheckpointProposal(
|
|
770
729
|
checkpointHeader: CheckpointHeader,
|
|
771
730
|
archive: Fr,
|
|
772
|
-
|
|
731
|
+
feeAssetPriceModifier: bigint,
|
|
732
|
+
lastBlockProposal: BlockProposal | undefined,
|
|
773
733
|
proposerAddress: EthAddress | undefined,
|
|
774
|
-
options: CheckpointProposalOptions,
|
|
734
|
+
options: CheckpointProposalOptions = {},
|
|
775
735
|
): Promise<CheckpointProposal> {
|
|
736
|
+
// Validate that we're not creating a proposal for an older or equal slot
|
|
737
|
+
if (this.lastProposedCheckpoint) {
|
|
738
|
+
const lastSlot = this.lastProposedCheckpoint.slotNumber;
|
|
739
|
+
const newSlot = checkpointHeader.slotNumber;
|
|
740
|
+
|
|
741
|
+
if (newSlot <= lastSlot) {
|
|
742
|
+
throw new Error(
|
|
743
|
+
`Cannot create checkpoint proposal for slot ${newSlot}: ` +
|
|
744
|
+
`already proposed checkpoint for slot ${lastSlot}`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
776
749
|
this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
|
|
777
|
-
|
|
750
|
+
const newProposal = await this.validationService.createCheckpointProposal(
|
|
778
751
|
checkpointHeader,
|
|
779
752
|
archive,
|
|
780
|
-
|
|
753
|
+
feeAssetPriceModifier,
|
|
754
|
+
lastBlockProposal,
|
|
781
755
|
proposerAddress,
|
|
782
756
|
options,
|
|
783
757
|
);
|
|
758
|
+
this.lastProposedCheckpoint = newProposal;
|
|
759
|
+
return newProposal;
|
|
784
760
|
}
|
|
785
761
|
|
|
786
762
|
async broadcastBlockProposal(proposal: BlockProposal): Promise<void> {
|
|
@@ -802,6 +778,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
802
778
|
this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
|
|
803
779
|
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
|
|
804
780
|
|
|
781
|
+
if (!attestations) {
|
|
782
|
+
return [];
|
|
783
|
+
}
|
|
784
|
+
|
|
805
785
|
// We broadcast our own attestations to our peers so, in case our block does not get mined on L1,
|
|
806
786
|
// other nodes can see that our validators did attest to this block proposal, and do not slash us
|
|
807
787
|
// due to inactivity for missed attestations.
|