@aztec/validator-client 0.0.1-commit.343b43af6 → 0.0.1-commit.350e0a4d
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 +21 -21
- package/dest/checkpoint_builder.d.ts +10 -7
- package/dest/checkpoint_builder.d.ts.map +1 -1
- package/dest/checkpoint_builder.js +66 -41
- package/dest/config.d.ts +9 -3
- package/dest/config.d.ts.map +1 -1
- package/dest/config.js +23 -10
- package/dest/duties/validation_service.d.ts +12 -13
- package/dest/duties/validation_service.d.ts.map +1 -1
- package/dest/duties/validation_service.js +33 -39
- package/dest/factory.d.ts +10 -4
- package/dest/factory.d.ts.map +1 -1
- package/dest/factory.js +18 -6
- package/dest/index.d.ts +2 -2
- package/dest/index.d.ts.map +1 -1
- package/dest/index.js +1 -1
- package/dest/key_store/web3signer_key_store.d.ts +10 -2
- package/dest/key_store/web3signer_key_store.d.ts.map +1 -1
- package/dest/key_store/web3signer_key_store.js +32 -41
- package/dest/metrics.d.ts +6 -2
- package/dest/metrics.d.ts.map +1 -1
- package/dest/metrics.js +12 -0
- package/dest/proposal_handler.d.ts +165 -0
- package/dest/proposal_handler.d.ts.map +1 -0
- package/dest/proposal_handler.js +1207 -0
- package/dest/validator.d.ts +36 -22
- package/dest/validator.d.ts.map +1 -1
- package/dest/validator.js +247 -266
- package/package.json +19 -19
- package/src/checkpoint_builder.ts +81 -52
- package/src/config.ts +31 -12
- package/src/duties/validation_service.ts +52 -48
- package/src/factory.ts +29 -5
- package/src/index.ts +1 -1
- package/src/key_store/web3signer_key_store.ts +43 -59
- package/src/metrics.ts +19 -1
- package/src/proposal_handler.ts +1314 -0
- package/src/validator.ts +329 -303
- package/dest/block_proposal_handler.d.ts +0 -64
- package/dest/block_proposal_handler.d.ts.map +0 -1
- package/dest/block_proposal_handler.js +0 -606
- package/src/block_proposal_handler.ts +0 -624
package/src/validator.ts
CHANGED
|
@@ -1,75 +1,82 @@
|
|
|
1
1
|
import type { BlobClientInterface } from '@aztec/blob-client/client';
|
|
2
2
|
import { type Blob, getBlobsPerL1Block } from '@aztec/blob-lib';
|
|
3
3
|
import type { EpochCache } from '@aztec/epoch-cache';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
BlockNumber,
|
|
7
|
-
CheckpointNumber,
|
|
8
|
-
EpochNumber,
|
|
9
|
-
IndexWithinCheckpoint,
|
|
10
|
-
SlotNumber,
|
|
11
|
-
} from '@aztec/foundation/branded-types';
|
|
4
|
+
import { CheckpointNumber, EpochNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types';
|
|
12
5
|
import { Fr } from '@aztec/foundation/curves/bn254';
|
|
13
|
-
import { TimeoutError } from '@aztec/foundation/error';
|
|
14
6
|
import type { EthAddress } from '@aztec/foundation/eth-address';
|
|
15
|
-
import
|
|
7
|
+
import { Signature } from '@aztec/foundation/eth-signature';
|
|
8
|
+
import { FifoSet } from '@aztec/foundation/fifo-set';
|
|
16
9
|
import { type LogData, type Logger, createLogger } from '@aztec/foundation/log';
|
|
17
|
-
import { retryUntil } from '@aztec/foundation/retry';
|
|
18
10
|
import { RunningPromise } from '@aztec/foundation/running-promise';
|
|
19
11
|
import { sleep } from '@aztec/foundation/sleep';
|
|
20
12
|
import { DateProvider } from '@aztec/foundation/timer';
|
|
21
13
|
import type { KeystoreManager } from '@aztec/node-keystore';
|
|
22
|
-
import type { DuplicateAttestationInfo, DuplicateProposalInfo, P2P, PeerId } from '@aztec/p2p';
|
|
14
|
+
import type { DuplicateAttestationInfo, DuplicateProposalInfo, OversizedProposalInfo, P2P, PeerId } from '@aztec/p2p';
|
|
23
15
|
import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
|
|
24
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
OffenseType,
|
|
18
|
+
WANT_TO_CLEAR_SLASH_EVENT,
|
|
19
|
+
WANT_TO_SLASH_EVENT,
|
|
20
|
+
type Watcher,
|
|
21
|
+
type WatcherEmitter,
|
|
22
|
+
getOffenseTypeName,
|
|
23
|
+
} from '@aztec/slasher';
|
|
25
24
|
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
|
|
26
|
-
import type { CommitteeAttestationsAndSigners,
|
|
27
|
-
import {
|
|
28
|
-
import { getEpochAtSlot
|
|
25
|
+
import type { CommitteeAttestationsAndSigners, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
|
|
26
|
+
import type { CheckpointReexecutionTracker } from '@aztec/stdlib/checkpoint';
|
|
27
|
+
import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
|
|
29
28
|
import type {
|
|
30
|
-
CreateCheckpointProposalLastBlockData,
|
|
31
29
|
ITxProvider,
|
|
32
30
|
Validator,
|
|
33
31
|
ValidatorClientFullConfig,
|
|
34
32
|
WorldStateSynchronizer,
|
|
35
33
|
} from '@aztec/stdlib/interfaces/server';
|
|
36
|
-
import {
|
|
34
|
+
import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
|
|
37
35
|
import {
|
|
38
36
|
type BlockProposal,
|
|
39
37
|
type BlockProposalOptions,
|
|
40
|
-
|
|
38
|
+
CheckpointAttestation,
|
|
41
39
|
CheckpointProposal,
|
|
42
40
|
type CheckpointProposalCore,
|
|
43
41
|
type CheckpointProposalOptions,
|
|
42
|
+
type CoordinationSignatureContext,
|
|
44
43
|
} from '@aztec/stdlib/p2p';
|
|
45
44
|
import type { CheckpointHeader } from '@aztec/stdlib/rollup';
|
|
46
|
-
import
|
|
45
|
+
import { ConsensusTimetable } from '@aztec/stdlib/timetable';
|
|
46
|
+
import type { BlockHeader, Tx } from '@aztec/stdlib/tx';
|
|
47
47
|
import { AttestationTimeoutError } from '@aztec/stdlib/validators';
|
|
48
48
|
import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client';
|
|
49
|
-
import {
|
|
50
|
-
|
|
49
|
+
import {
|
|
50
|
+
createHASigner,
|
|
51
|
+
createLocalSignerWithProtection,
|
|
52
|
+
createSignerFromSharedDb,
|
|
53
|
+
} from '@aztec/validator-ha-signer/factory';
|
|
54
|
+
import { DutyType, type SigningContext, type SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types';
|
|
51
55
|
import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
|
|
52
56
|
|
|
53
57
|
import { EventEmitter } from 'events';
|
|
54
58
|
import type { TypedDataDefinition } from 'viem';
|
|
55
59
|
|
|
56
|
-
import { BlockProposalHandler, type BlockProposalValidationFailureReason } from './block_proposal_handler.js';
|
|
57
60
|
import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
|
|
61
|
+
import { DEFAULT_MAX_GOSSIP_CLOCK_DISPARITY_MS } from './config.js';
|
|
58
62
|
import { ValidationService } from './duties/validation_service.js';
|
|
59
63
|
import { HAKeyStore } from './key_store/ha_key_store.js';
|
|
60
64
|
import type { ExtendedValidatorKeyStore } from './key_store/interface.js';
|
|
61
65
|
import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
|
|
62
66
|
import { ValidatorMetrics } from './metrics.js';
|
|
67
|
+
import {
|
|
68
|
+
type BlockProposalValidationFailureReason,
|
|
69
|
+
type CheckpointProposalValidationFailureResult,
|
|
70
|
+
ProposalHandler,
|
|
71
|
+
SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT,
|
|
72
|
+
SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT,
|
|
73
|
+
} from './proposal_handler.js';
|
|
63
74
|
|
|
64
75
|
// We maintain a set of proposers who have proposed invalid blocks.
|
|
65
76
|
// Just cap the set to avoid unbounded growth.
|
|
66
77
|
const MAX_PROPOSERS_OF_INVALID_BLOCKS = 1000;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidationFailureReason[] = [
|
|
70
|
-
'state_mismatch',
|
|
71
|
-
'failed_txs',
|
|
72
|
-
];
|
|
78
|
+
const MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS = 1000;
|
|
79
|
+
const MAX_TRACKED_BAD_ATTESTATIONS = 10_000;
|
|
73
80
|
|
|
74
81
|
/**
|
|
75
82
|
* Validator Client
|
|
@@ -93,7 +100,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
93
100
|
/** Tracks the last epoch in which each attester successfully submitted at least one attestation. */
|
|
94
101
|
private lastAttestedEpochByAttester: Map<string, EpochNumber> = new Map();
|
|
95
102
|
|
|
96
|
-
private proposersOfInvalidBlocks
|
|
103
|
+
private proposersOfInvalidBlocks = FifoSet.withLimit<string>(MAX_PROPOSERS_OF_INVALID_BLOCKS);
|
|
104
|
+
private invalidCheckpointProposalOffenseKeys = FifoSet.withLimit<string>(MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS);
|
|
105
|
+
private oversizedProposalOffenseKeys = FifoSet.withLimit<string>(MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS);
|
|
106
|
+
private badAttestationOffenseKeys = FifoSet.withLimit<string>(MAX_TRACKED_BAD_ATTESTATIONS);
|
|
97
107
|
|
|
98
108
|
/** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */
|
|
99
109
|
private lastAttestedProposal?: CheckpointProposalCore;
|
|
@@ -102,7 +112,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
102
112
|
private keyStore: ExtendedValidatorKeyStore,
|
|
103
113
|
private epochCache: EpochCache,
|
|
104
114
|
private p2pClient: P2P,
|
|
105
|
-
private
|
|
115
|
+
private proposalHandler: ProposalHandler,
|
|
106
116
|
private blockSource: L2BlockSource,
|
|
107
117
|
private checkpointsBuilder: FullNodeCheckpointsBuilder,
|
|
108
118
|
private worldState: WorldStateSynchronizer,
|
|
@@ -122,11 +132,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
122
132
|
this.tracer = telemetry.getTracer('Validator');
|
|
123
133
|
this.metrics = new ValidatorMetrics(telemetry);
|
|
124
134
|
|
|
125
|
-
this.validationService = new ValidationService(
|
|
135
|
+
this.validationService = new ValidationService(
|
|
136
|
+
keyStore,
|
|
137
|
+
this.getSignatureContext(),
|
|
138
|
+
this.log.createChild('validation-service'),
|
|
139
|
+
);
|
|
140
|
+
this.proposalHandler.setCheckpointProposalValidationFailureCallback((proposal, result, proposalInfo) =>
|
|
141
|
+
this.handleInvalidCheckpointProposal(proposal, result, proposalInfo),
|
|
142
|
+
);
|
|
126
143
|
|
|
127
144
|
// Refresh epoch cache every second to trigger alert if participation in committee changes
|
|
128
145
|
this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), this.log, 1000);
|
|
129
|
-
|
|
130
146
|
const myAddresses = this.getValidatorAddresses();
|
|
131
147
|
this.log.verbose(`Initialized validator with addresses: ${myAddresses.map(a => a.toString()).join(', ')}`);
|
|
132
148
|
}
|
|
@@ -195,15 +211,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
195
211
|
txProvider: ITxProvider,
|
|
196
212
|
keyStoreManager: KeystoreManager,
|
|
197
213
|
blobClient: BlobClientInterface,
|
|
214
|
+
reexecutionTracker: CheckpointReexecutionTracker,
|
|
198
215
|
dateProvider: DateProvider = new DateProvider(),
|
|
199
216
|
telemetry: TelemetryClient = getTelemetryClient(),
|
|
217
|
+
slashingProtectionDb?: SlashingProtectionDatabase,
|
|
200
218
|
) {
|
|
201
219
|
const metrics = new ValidatorMetrics(telemetry);
|
|
202
|
-
const
|
|
220
|
+
const consensusTimetable = new ConsensusTimetable({
|
|
221
|
+
l1Constants: epochCache.getL1Constants(),
|
|
222
|
+
blockDuration: config.blockDurationMs / 1000,
|
|
223
|
+
});
|
|
224
|
+
const blockProposalValidator = new BlockProposalValidator(epochCache, consensusTimetable, {
|
|
203
225
|
txsPermitted: !config.disableTransactions,
|
|
204
226
|
maxTxsPerBlock: config.validateMaxTxsPerBlock,
|
|
227
|
+
maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint,
|
|
228
|
+
skipSlotValidation: config.skipProposalSlotValidation,
|
|
229
|
+
signatureContext: {
|
|
230
|
+
chainId: config.l1ChainId,
|
|
231
|
+
rollupAddress: config.rollupAddress,
|
|
232
|
+
},
|
|
233
|
+
clockDisparityMs: config.maxGossipClockDisparityMs ?? DEFAULT_MAX_GOSSIP_CLOCK_DISPARITY_MS,
|
|
205
234
|
});
|
|
206
|
-
const
|
|
235
|
+
const proposalHandler = new ProposalHandler(
|
|
207
236
|
checkpointsBuilder,
|
|
208
237
|
worldState,
|
|
209
238
|
blockSource,
|
|
@@ -211,15 +240,25 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
211
240
|
txProvider,
|
|
212
241
|
blockProposalValidator,
|
|
213
242
|
epochCache,
|
|
243
|
+
consensusTimetable,
|
|
214
244
|
config,
|
|
245
|
+
blobClient,
|
|
246
|
+
reexecutionTracker,
|
|
215
247
|
metrics,
|
|
216
248
|
dateProvider,
|
|
217
249
|
telemetry,
|
|
250
|
+
undefined,
|
|
218
251
|
);
|
|
219
252
|
|
|
220
253
|
const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
|
|
221
254
|
let slashingProtectionSigner: ValidatorHASigner;
|
|
222
|
-
if (
|
|
255
|
+
if (slashingProtectionDb) {
|
|
256
|
+
// Shared database mode: use a pre-existing database (e.g. for testing HA setups).
|
|
257
|
+
({ signer: slashingProtectionSigner } = createSignerFromSharedDb(slashingProtectionDb, config, {
|
|
258
|
+
telemetryClient: telemetry,
|
|
259
|
+
dateProvider,
|
|
260
|
+
}));
|
|
261
|
+
} else if (config.haSigningEnabled) {
|
|
223
262
|
// Multi-node HA mode: use PostgreSQL-backed distributed locking.
|
|
224
263
|
// If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
|
|
225
264
|
const haConfig = {
|
|
@@ -244,7 +283,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
244
283
|
validatorKeyStore,
|
|
245
284
|
epochCache,
|
|
246
285
|
p2pClient,
|
|
247
|
-
|
|
286
|
+
proposalHandler,
|
|
248
287
|
blockSource,
|
|
249
288
|
checkpointsBuilder,
|
|
250
289
|
worldState,
|
|
@@ -265,14 +304,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
265
304
|
.filter(addr => !this.config.disabledValidators.some(disabled => disabled.equals(addr)));
|
|
266
305
|
}
|
|
267
306
|
|
|
268
|
-
public
|
|
269
|
-
return this.
|
|
307
|
+
public getProposalHandler() {
|
|
308
|
+
return this.proposalHandler;
|
|
270
309
|
}
|
|
271
310
|
|
|
272
311
|
public signWithAddress(addr: EthAddress, msg: TypedDataDefinition, context: SigningContext) {
|
|
273
312
|
return this.keyStore.signTypedDataWithAddress(addr, msg, context);
|
|
274
313
|
}
|
|
275
314
|
|
|
315
|
+
private getSignatureContext(): CoordinationSignatureContext {
|
|
316
|
+
return {
|
|
317
|
+
chainId: this.config.l1ChainId,
|
|
318
|
+
rollupAddress: this.config.rollupAddress,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
276
322
|
public getCoinbaseForAttestor(attestor: EthAddress): EthAddress {
|
|
277
323
|
return this.keyStore.getCoinbaseAddress(attestor);
|
|
278
324
|
}
|
|
@@ -285,14 +331,27 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
285
331
|
return this.config;
|
|
286
332
|
}
|
|
287
333
|
|
|
334
|
+
public hasProposalEquivocation(slotNumber: SlotNumber): boolean {
|
|
335
|
+
return this.proposalHandler.hasProposalEquivocation(slotNumber);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
public hasInvalidProposals(slotNumber: SlotNumber): boolean {
|
|
339
|
+
return this.proposalHandler.hasInvalidProposals(slotNumber);
|
|
340
|
+
}
|
|
341
|
+
|
|
288
342
|
public updateConfig(config: Partial<ValidatorClientFullConfig>) {
|
|
289
343
|
this.config = { ...this.config, ...config };
|
|
344
|
+
this.proposalHandler.updateConfig(config);
|
|
290
345
|
}
|
|
291
346
|
|
|
292
347
|
public reloadKeystore(newManager: KeystoreManager): void {
|
|
293
348
|
const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
|
|
294
349
|
this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
|
|
295
|
-
this.validationService = new ValidationService(
|
|
350
|
+
this.validationService = new ValidationService(
|
|
351
|
+
this.keyStore,
|
|
352
|
+
this.getSignatureContext(),
|
|
353
|
+
this.log.createChild('validation-service'),
|
|
354
|
+
);
|
|
296
355
|
}
|
|
297
356
|
|
|
298
357
|
public async start() {
|
|
@@ -339,18 +398,27 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
339
398
|
checkpoint: CheckpointProposalCore,
|
|
340
399
|
proposalSender: PeerId,
|
|
341
400
|
): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
|
|
342
|
-
this.p2pClient.
|
|
401
|
+
this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler);
|
|
343
402
|
|
|
344
403
|
// Duplicate proposal handler - triggers slashing for equivocation
|
|
345
404
|
this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
|
|
346
405
|
this.handleDuplicateProposal(info);
|
|
347
406
|
});
|
|
348
407
|
|
|
408
|
+
// Oversized proposal handler - triggers slashing for proposals beyond the per-checkpoint block limit
|
|
409
|
+
this.p2pClient.registerOversizedProposalCallback((info: OversizedProposalInfo) => {
|
|
410
|
+
this.handleOversizedProposal(info);
|
|
411
|
+
});
|
|
412
|
+
|
|
349
413
|
// Duplicate attestation handler - triggers slashing for attestation equivocation
|
|
350
414
|
this.p2pClient.registerDuplicateAttestationCallback((info: DuplicateAttestationInfo) => {
|
|
351
415
|
this.handleDuplicateAttestation(info);
|
|
352
416
|
});
|
|
353
417
|
|
|
418
|
+
this.p2pClient.registerCheckpointAttestationCallback((attestation: CheckpointAttestation) => {
|
|
419
|
+
this.handleCheckpointAttestation(attestation);
|
|
420
|
+
});
|
|
421
|
+
|
|
354
422
|
const myAddresses = this.getValidatorAddresses();
|
|
355
423
|
this.p2pClient.registerThisValidatorAddresses(myAddresses);
|
|
356
424
|
|
|
@@ -378,13 +446,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
378
446
|
return false;
|
|
379
447
|
}
|
|
380
448
|
|
|
381
|
-
//
|
|
449
|
+
// Log self-proposals from HA peers (same validator key on different nodes)
|
|
382
450
|
if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
383
|
-
this.log.
|
|
451
|
+
this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
|
|
384
452
|
proposer: proposer.toString(),
|
|
385
453
|
slotNumber,
|
|
386
454
|
});
|
|
387
|
-
return false;
|
|
388
455
|
}
|
|
389
456
|
|
|
390
457
|
// Check if we're in the committee (for metrics purposes)
|
|
@@ -398,22 +465,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
398
465
|
fishermanMode: this.config.fishermanMode || false,
|
|
399
466
|
});
|
|
400
467
|
|
|
401
|
-
// Reexecute
|
|
402
|
-
|
|
403
|
-
const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } =
|
|
404
|
-
this.config;
|
|
405
|
-
const shouldReexecute =
|
|
406
|
-
fishermanMode ||
|
|
407
|
-
(slashBroadcastedInvalidBlockPenalty > 0n && validatorReexecute) ||
|
|
408
|
-
(partOfCommittee && validatorReexecute) ||
|
|
409
|
-
alwaysReexecuteBlockProposals ||
|
|
410
|
-
this.blobClient.canUpload();
|
|
411
|
-
|
|
412
|
-
const validationResult = await this.blockProposalHandler.handleBlockProposal(
|
|
413
|
-
proposal,
|
|
414
|
-
proposalSender,
|
|
415
|
-
!!shouldReexecute && !escapeHatchOpen,
|
|
416
|
-
);
|
|
468
|
+
// Reexecute outside the escape hatch so slashing observers can detect invalid proposals even when penalties are 0.
|
|
469
|
+
const validationResult = await this.proposalHandler.handleBlockProposal(proposal, proposalSender, !escapeHatchOpen);
|
|
417
470
|
|
|
418
471
|
if (!validationResult.isValid) {
|
|
419
472
|
const reason = validationResult.reason || 'unknown';
|
|
@@ -436,15 +489,18 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
436
489
|
this.metrics.incFailedAttestationsNodeIssue(1, reason, partOfCommittee);
|
|
437
490
|
}
|
|
438
491
|
|
|
439
|
-
// Slash invalid block proposals (can happen even when not in committee)
|
|
440
492
|
if (
|
|
441
493
|
!escapeHatchOpen &&
|
|
442
494
|
validationResult.reason &&
|
|
443
|
-
SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason)
|
|
444
|
-
slashBroadcastedInvalidBlockPenalty > 0n
|
|
495
|
+
SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason)
|
|
445
496
|
) {
|
|
446
|
-
this.log.
|
|
497
|
+
this.log.info(`Detected invalid block proposal offense`, {
|
|
498
|
+
...proposalInfo,
|
|
499
|
+
amount: this.config.slashBroadcastedInvalidBlockPenalty,
|
|
500
|
+
offenseType: getOffenseTypeName(OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL),
|
|
501
|
+
});
|
|
447
502
|
this.slashInvalidBlock(proposal);
|
|
503
|
+
this.markInvalidProposalSlot(proposal.slotNumber);
|
|
448
504
|
}
|
|
449
505
|
return false;
|
|
450
506
|
}
|
|
@@ -474,66 +530,56 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
474
530
|
proposal: CheckpointProposalCore,
|
|
475
531
|
_proposalSender: PeerId,
|
|
476
532
|
): Promise<CheckpointAttestation[] | undefined> {
|
|
477
|
-
const
|
|
533
|
+
const proposalSlotNumber = proposal.slotNumber;
|
|
478
534
|
const proposer = proposal.getSender();
|
|
479
535
|
|
|
480
536
|
// If escape hatch is open for this slot's epoch, do not attest.
|
|
481
|
-
if (await this.epochCache.isEscapeHatchOpenAtSlot(
|
|
482
|
-
this.log.warn(`Escape hatch open for slot ${
|
|
537
|
+
if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
|
|
538
|
+
this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
|
|
483
539
|
return undefined;
|
|
484
540
|
}
|
|
485
541
|
|
|
486
|
-
//
|
|
487
|
-
if (!
|
|
488
|
-
this.log.warn(`Received checkpoint proposal with invalid signature for slot ${slotNumber}`);
|
|
542
|
+
// Early-out for equivocation: refuses if we've already attested to a higher slot.
|
|
543
|
+
if (!this.shouldAttestToSlot(proposalSlotNumber)) {
|
|
489
544
|
return undefined;
|
|
490
545
|
}
|
|
491
546
|
|
|
492
547
|
// Ignore proposals from ourselves (may happen in HA setups)
|
|
493
|
-
if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
494
|
-
this.log.debug(`
|
|
548
|
+
if (proposer && this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
549
|
+
this.log.debug(`Not attesting to block proposal from self for slot ${proposalSlotNumber}`, {
|
|
495
550
|
proposer: proposer.toString(),
|
|
496
|
-
|
|
551
|
+
proposalSlotNumber,
|
|
497
552
|
});
|
|
498
553
|
return undefined;
|
|
499
554
|
}
|
|
500
555
|
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
this.log.warn(
|
|
504
|
-
`Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${slotNumber}`,
|
|
505
|
-
);
|
|
506
|
-
return undefined;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// Check that I have any address in current committee before attesting
|
|
510
|
-
const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
|
|
556
|
+
// Check that I have any address in the committee where this checkpoint will land before attesting
|
|
557
|
+
const inCommittee = await this.epochCache.filterInCommittee(proposalSlotNumber, this.getValidatorAddresses());
|
|
511
558
|
const partOfCommittee = inCommittee.length > 0;
|
|
512
559
|
|
|
513
560
|
const proposalInfo = {
|
|
514
|
-
|
|
561
|
+
proposalSlotNumber,
|
|
515
562
|
archive: proposal.archive.toString(),
|
|
516
|
-
proposer: proposer
|
|
563
|
+
proposer: proposer?.toString(),
|
|
517
564
|
};
|
|
518
|
-
this.log.info(`Received checkpoint proposal for slot ${
|
|
565
|
+
this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
|
|
519
566
|
...proposalInfo,
|
|
520
567
|
fishermanMode: this.config.fishermanMode || false,
|
|
521
568
|
});
|
|
522
569
|
|
|
523
|
-
// Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
|
|
570
|
+
// Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set).
|
|
571
|
+
// Uses the cached result from the all-nodes callback if available (avoids double validation).
|
|
572
|
+
let checkpointNumber: CheckpointNumber;
|
|
524
573
|
if (this.config.skipCheckpointProposalValidation) {
|
|
525
|
-
this.log.warn(`Skipping checkpoint proposal validation for slot ${
|
|
574
|
+
this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
|
|
575
|
+
checkpointNumber = CheckpointNumber(0);
|
|
526
576
|
} else {
|
|
527
|
-
const validationResult = await this.
|
|
577
|
+
const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
|
|
528
578
|
if (!validationResult.isValid) {
|
|
529
579
|
this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
530
580
|
return undefined;
|
|
531
581
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
// Upload blobs to filestore if we can (fire and forget)
|
|
535
|
-
if (this.blobClient.canUpload()) {
|
|
536
|
-
void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
|
|
582
|
+
checkpointNumber = validationResult.checkpointNumber;
|
|
537
583
|
}
|
|
538
584
|
|
|
539
585
|
// Check that I have any address in current committee before attesting
|
|
@@ -544,16 +590,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
544
590
|
}
|
|
545
591
|
|
|
546
592
|
// Provided all of the above checks pass, we can attest to the proposal
|
|
547
|
-
this.log.info(
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
593
|
+
this.log.info(
|
|
594
|
+
`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${proposalSlotNumber}`,
|
|
595
|
+
{
|
|
596
|
+
...proposalInfo,
|
|
597
|
+
inCommittee: partOfCommittee,
|
|
598
|
+
fishermanMode: this.config.fishermanMode || false,
|
|
599
|
+
},
|
|
600
|
+
);
|
|
552
601
|
|
|
553
602
|
this.metrics.incSuccessfulAttestations(inCommittee.length);
|
|
554
603
|
|
|
555
604
|
// Track epoch participation per attester: count each (attester, epoch) pair at most once
|
|
556
|
-
const proposalEpoch = getEpochAtSlot(
|
|
605
|
+
const proposalEpoch = getEpochAtSlot(proposalSlotNumber, this.epochCache.getL1Constants());
|
|
557
606
|
for (const attester of inCommittee) {
|
|
558
607
|
const key = attester.toString();
|
|
559
608
|
const lastEpoch = this.lastAttestedEpochByAttester.get(key);
|
|
@@ -581,14 +630,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
581
630
|
|
|
582
631
|
if (this.config.fishermanMode) {
|
|
583
632
|
// bail out early and don't save attestations to the pool in fisherman mode
|
|
584
|
-
this.log.info(`Creating checkpoint attestations for slot ${
|
|
633
|
+
this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
|
|
585
634
|
...proposalInfo,
|
|
586
635
|
attestors: attestors.map(a => a.toString()),
|
|
587
636
|
});
|
|
588
637
|
return undefined;
|
|
589
638
|
}
|
|
590
639
|
|
|
591
|
-
return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
|
|
640
|
+
return await this.createCheckpointAttestationsFromProposal(proposal, attestors, checkpointNumber);
|
|
592
641
|
}
|
|
593
642
|
|
|
594
643
|
/**
|
|
@@ -615,13 +664,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
615
664
|
private async createCheckpointAttestationsFromProposal(
|
|
616
665
|
proposal: CheckpointProposalCore,
|
|
617
666
|
attestors: EthAddress[] = [],
|
|
667
|
+
checkpointNumber: CheckpointNumber,
|
|
618
668
|
): Promise<CheckpointAttestation[] | undefined> {
|
|
619
669
|
// Equivocation check: must happen right before signing to minimize the race window
|
|
620
670
|
if (!this.shouldAttestToSlot(proposal.slotNumber)) {
|
|
621
671
|
return undefined;
|
|
622
672
|
}
|
|
623
673
|
|
|
624
|
-
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
|
|
674
|
+
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors, checkpointNumber);
|
|
625
675
|
|
|
626
676
|
// Track the proposal we attested to (to prevent equivocation)
|
|
627
677
|
this.lastAttestedProposal = proposal;
|
|
@@ -630,178 +680,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
630
680
|
return attestations;
|
|
631
681
|
}
|
|
632
682
|
|
|
633
|
-
/**
|
|
634
|
-
* Validates a checkpoint proposal by building the full checkpoint and comparing it with the proposal.
|
|
635
|
-
* @returns Validation result with isValid flag and reason if invalid.
|
|
636
|
-
*/
|
|
637
|
-
private async validateCheckpointProposal(
|
|
638
|
-
proposal: CheckpointProposalCore,
|
|
639
|
-
proposalInfo: LogData,
|
|
640
|
-
): Promise<{ isValid: true } | { isValid: false; reason: string }> {
|
|
641
|
-
const slot = proposal.slotNumber;
|
|
642
|
-
|
|
643
|
-
// Timeout block syncing at the start of the next slot
|
|
644
|
-
const config = this.checkpointsBuilder.getConfig();
|
|
645
|
-
const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config));
|
|
646
|
-
const timeoutSeconds = Math.max(1, nextSlotTimestampSeconds - Math.floor(this.dateProvider.now() / 1000));
|
|
647
|
-
|
|
648
|
-
// Wait for last block to sync by archive
|
|
649
|
-
let lastBlockHeader: BlockHeader | undefined;
|
|
650
|
-
try {
|
|
651
|
-
lastBlockHeader = await retryUntil(
|
|
652
|
-
async () => {
|
|
653
|
-
await this.blockSource.syncImmediate();
|
|
654
|
-
return this.blockSource.getBlockHeaderByArchive(proposal.archive);
|
|
655
|
-
},
|
|
656
|
-
`waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
|
|
657
|
-
timeoutSeconds,
|
|
658
|
-
0.5,
|
|
659
|
-
);
|
|
660
|
-
} catch (err) {
|
|
661
|
-
if (err instanceof TimeoutError) {
|
|
662
|
-
this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
|
|
663
|
-
return { isValid: false, reason: 'last_block_not_found' };
|
|
664
|
-
}
|
|
665
|
-
this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
|
|
666
|
-
return { isValid: false, reason: 'block_fetch_error' };
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
if (!lastBlockHeader) {
|
|
670
|
-
this.log.warn(`Last block not found for checkpoint proposal`, proposalInfo);
|
|
671
|
-
return { isValid: false, reason: 'last_block_not_found' };
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
// Get all full blocks for the slot and checkpoint
|
|
675
|
-
const blocks = await this.blockSource.getBlocksForSlot(slot);
|
|
676
|
-
if (blocks.length === 0) {
|
|
677
|
-
this.log.warn(`No blocks found for slot ${slot}`, proposalInfo);
|
|
678
|
-
return { isValid: false, reason: 'no_blocks_for_slot' };
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
// Ensure the last block for this slot matches the archive in the checkpoint proposal
|
|
682
|
-
if (!blocks.at(-1)?.archive.root.equals(proposal.archive)) {
|
|
683
|
-
this.log.warn(`Last block archive mismatch for checkpoint proposal`, proposalInfo);
|
|
684
|
-
return { isValid: false, reason: 'last_block_archive_mismatch' };
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
|
|
688
|
-
...proposalInfo,
|
|
689
|
-
blockNumbers: blocks.map(b => b.number),
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
// Get checkpoint constants from first block
|
|
693
|
-
const firstBlock = blocks[0];
|
|
694
|
-
const constants = this.extractCheckpointConstants(firstBlock);
|
|
695
|
-
const checkpointNumber = firstBlock.checkpointNumber;
|
|
696
|
-
|
|
697
|
-
// Get L1-to-L2 messages for this checkpoint
|
|
698
|
-
const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
|
|
699
|
-
|
|
700
|
-
// Collect the out hashes of all the checkpoints before this one in the same epoch
|
|
701
|
-
const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
|
|
702
|
-
const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsDataForEpoch(epoch))
|
|
703
|
-
.filter(c => c.checkpointNumber < checkpointNumber)
|
|
704
|
-
.map(c => c.checkpointOutHash);
|
|
705
|
-
|
|
706
|
-
// Fork world state at the block before the first block
|
|
707
|
-
const parentBlockNumber = BlockNumber(firstBlock.number - 1);
|
|
708
|
-
const fork = await this.worldState.fork(parentBlockNumber);
|
|
709
|
-
|
|
710
|
-
try {
|
|
711
|
-
// Create checkpoint builder with all existing blocks
|
|
712
|
-
const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
|
|
713
|
-
checkpointNumber,
|
|
714
|
-
constants,
|
|
715
|
-
proposal.feeAssetPriceModifier,
|
|
716
|
-
l1ToL2Messages,
|
|
717
|
-
previousCheckpointOutHashes,
|
|
718
|
-
fork,
|
|
719
|
-
blocks,
|
|
720
|
-
this.log.getBindings(),
|
|
721
|
-
);
|
|
722
|
-
|
|
723
|
-
// Complete the checkpoint to get computed values
|
|
724
|
-
const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
|
|
725
|
-
|
|
726
|
-
// Compare checkpoint header with proposal
|
|
727
|
-
if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
|
|
728
|
-
this.log.warn(`Checkpoint header mismatch`, {
|
|
729
|
-
...proposalInfo,
|
|
730
|
-
computed: computedCheckpoint.header.toInspect(),
|
|
731
|
-
proposal: proposal.checkpointHeader.toInspect(),
|
|
732
|
-
});
|
|
733
|
-
return { isValid: false, reason: 'checkpoint_header_mismatch' };
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// Compare archive root with proposal
|
|
737
|
-
if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
|
|
738
|
-
this.log.warn(`Archive root mismatch`, {
|
|
739
|
-
...proposalInfo,
|
|
740
|
-
computed: computedCheckpoint.archive.root.toString(),
|
|
741
|
-
proposal: proposal.archive.toString(),
|
|
742
|
-
});
|
|
743
|
-
return { isValid: false, reason: 'archive_mismatch' };
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
// Check that the accumulated epoch out hash matches the value in the proposal.
|
|
747
|
-
// The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
|
|
748
|
-
const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
|
|
749
|
-
const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
|
|
750
|
-
const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
|
|
751
|
-
if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
|
|
752
|
-
this.log.warn(`Epoch out hash mismatch`, {
|
|
753
|
-
proposalEpochOutHash: proposalEpochOutHash.toString(),
|
|
754
|
-
computedEpochOutHash: computedEpochOutHash.toString(),
|
|
755
|
-
checkpointOutHash: checkpointOutHash.toString(),
|
|
756
|
-
previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
|
|
757
|
-
...proposalInfo,
|
|
758
|
-
});
|
|
759
|
-
return { isValid: false, reason: 'out_hash_mismatch' };
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
// Final round of validations on the checkpoint, just in case.
|
|
763
|
-
try {
|
|
764
|
-
validateCheckpoint(computedCheckpoint, {
|
|
765
|
-
rollupManaLimit: this.checkpointsBuilder.getConfig().rollupManaLimit,
|
|
766
|
-
maxDABlockGas: this.config.validateMaxDABlockGas,
|
|
767
|
-
maxL2BlockGas: this.config.validateMaxL2BlockGas,
|
|
768
|
-
maxTxsPerBlock: this.config.validateMaxTxsPerBlock,
|
|
769
|
-
maxTxsPerCheckpoint: this.config.validateMaxTxsPerCheckpoint,
|
|
770
|
-
});
|
|
771
|
-
} catch (err) {
|
|
772
|
-
this.log.warn(`Checkpoint validation failed: ${err}`, proposalInfo);
|
|
773
|
-
return { isValid: false, reason: 'checkpoint_validation_failed' };
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
|
|
777
|
-
return { isValid: true };
|
|
778
|
-
} finally {
|
|
779
|
-
await fork.close();
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
/**
|
|
784
|
-
* Extract checkpoint global variables from a block.
|
|
785
|
-
*/
|
|
786
|
-
private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
|
|
787
|
-
const gv = block.header.globalVariables;
|
|
788
|
-
return {
|
|
789
|
-
chainId: gv.chainId,
|
|
790
|
-
version: gv.version,
|
|
791
|
-
slotNumber: gv.slotNumber,
|
|
792
|
-
timestamp: gv.timestamp,
|
|
793
|
-
coinbase: gv.coinbase,
|
|
794
|
-
feeRecipient: gv.feeRecipient,
|
|
795
|
-
gasFees: gv.gasFees,
|
|
796
|
-
};
|
|
797
|
-
}
|
|
798
|
-
|
|
799
683
|
/**
|
|
800
684
|
* Uploads blobs for a checkpoint to the filestore (fire and forget).
|
|
801
685
|
*/
|
|
802
686
|
protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
|
|
803
687
|
try {
|
|
804
|
-
const lastBlockHeader = await this.blockSource.
|
|
688
|
+
const lastBlockHeader = (await this.blockSource.getBlockData({ archive: proposal.archive }))?.header;
|
|
805
689
|
if (!lastBlockHeader) {
|
|
806
690
|
this.log.warn(`Failed to get last block header for blob upload`, proposalInfo);
|
|
807
691
|
return;
|
|
@@ -834,12 +718,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
834
718
|
return;
|
|
835
719
|
}
|
|
836
720
|
|
|
837
|
-
// Trim the set if it's too big.
|
|
838
|
-
if (this.proposersOfInvalidBlocks.size > MAX_PROPOSERS_OF_INVALID_BLOCKS) {
|
|
839
|
-
// remove oldest proposer. `values` is guaranteed to be in insertion order.
|
|
840
|
-
this.proposersOfInvalidBlocks.delete(this.proposersOfInvalidBlocks.values().next().value!);
|
|
841
|
-
}
|
|
842
|
-
|
|
843
721
|
this.proposersOfInvalidBlocks.add(proposer.toString());
|
|
844
722
|
|
|
845
723
|
this.emit(WANT_TO_SLASH_EVENT, [
|
|
@@ -852,20 +730,148 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
852
730
|
]);
|
|
853
731
|
}
|
|
854
732
|
|
|
733
|
+
private handleInvalidCheckpointProposal(
|
|
734
|
+
proposal: CheckpointProposalCore,
|
|
735
|
+
result: CheckpointProposalValidationFailureResult,
|
|
736
|
+
proposalInfo: LogData,
|
|
737
|
+
): void {
|
|
738
|
+
if (!SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// The slot is already marked invalid by the all-nodes checkpoint handler that invokes this callback,
|
|
743
|
+
// so we only emit the proposer slash event here.
|
|
744
|
+
if (this.slashInvalidCheckpointProposal(proposal)) {
|
|
745
|
+
this.log.info(`Detected invalid checkpoint proposal offense`, {
|
|
746
|
+
...proposalInfo,
|
|
747
|
+
reason: result.reason,
|
|
748
|
+
amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
|
|
749
|
+
offenseType: getOffenseTypeName(OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL),
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
private slashInvalidCheckpointProposal(proposal: CheckpointProposalCore): boolean {
|
|
755
|
+
const proposer = proposal.getSender();
|
|
756
|
+
if (!proposer) {
|
|
757
|
+
this.log.warn(`Cannot slash checkpoint proposal with invalid signature`, {
|
|
758
|
+
slotNumber: proposal.slotNumber,
|
|
759
|
+
archive: proposal.archive.toString(),
|
|
760
|
+
});
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const offenseType = OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL;
|
|
765
|
+
const offenseKey = `${proposer.toString()}:${offenseType}:${proposal.slotNumber}`;
|
|
766
|
+
if (!this.invalidCheckpointProposalOffenseKeys.addIfAbsent(offenseKey)) {
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
this.emit(WANT_TO_SLASH_EVENT, [
|
|
771
|
+
{
|
|
772
|
+
validator: proposer,
|
|
773
|
+
amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
|
|
774
|
+
offenseType,
|
|
775
|
+
epochOrSlot: BigInt(proposal.slotNumber),
|
|
776
|
+
},
|
|
777
|
+
]);
|
|
778
|
+
return true;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
private markInvalidProposalSlot(slotNumber: SlotNumber): void {
|
|
782
|
+
this.proposalHandler.markInvalidProposalSlot(slotNumber);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private handleCheckpointAttestation(attestation: CheckpointAttestation): void {
|
|
786
|
+
const slotNumber = attestation.slotNumber;
|
|
787
|
+
if (
|
|
788
|
+
!this.proposalHandler.hasInvalidProposals(slotNumber) ||
|
|
789
|
+
this.proposalHandler.hasProposalEquivocation(slotNumber)
|
|
790
|
+
) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const attester = attestation.getSender();
|
|
795
|
+
if (!attester) {
|
|
796
|
+
this.log.warn(`Cannot slash checkpoint attestation with invalid signature`, {
|
|
797
|
+
slotNumber,
|
|
798
|
+
archive: attestation.archive.toString(),
|
|
799
|
+
});
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
this.slashAttestedToInvalidCheckpointProposal(slotNumber, attester);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
private slashAttestedToInvalidCheckpointProposal(slotNumber: SlotNumber, attester: EthAddress): void {
|
|
807
|
+
const offenseKey = `${attester.toString()}:${OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL}:${slotNumber}`;
|
|
808
|
+
if (!this.badAttestationOffenseKeys.addIfAbsent(offenseKey)) {
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
this.log.info(`Detected attestation to invalid checkpoint proposal offense`, {
|
|
813
|
+
attester: attester.toString(),
|
|
814
|
+
slotNumber,
|
|
815
|
+
amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
|
|
816
|
+
offenseType: getOffenseTypeName(OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL),
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
this.emit(WANT_TO_SLASH_EVENT, [
|
|
820
|
+
{
|
|
821
|
+
validator: attester,
|
|
822
|
+
amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
|
|
823
|
+
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
|
|
824
|
+
epochOrSlot: BigInt(slotNumber),
|
|
825
|
+
},
|
|
826
|
+
]);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Handle detection of an oversized block proposal: one whose index within its checkpoint lands at or
|
|
831
|
+
* beyond the consensus per-checkpoint block limit. A single signed proposal at an illegal index is
|
|
832
|
+
* self-contained evidence, so emit an invalid-block-proposal slash event for the proposer, deduped per
|
|
833
|
+
* (proposer, slot) since the p2p layer reports every oversized proposal it stores.
|
|
834
|
+
*/
|
|
835
|
+
private handleOversizedProposal(info: OversizedProposalInfo): void {
|
|
836
|
+
const { slot, proposer } = info;
|
|
837
|
+
const offenseType = OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL;
|
|
838
|
+
if (!this.oversizedProposalOffenseKeys.addIfAbsent(`${proposer.toString()}:${offenseType}:${slot}`)) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
this.log.info(`Detected oversized block proposal offense from ${proposer.toString()} at slot ${slot}`, {
|
|
843
|
+
proposer: proposer.toString(),
|
|
844
|
+
slot,
|
|
845
|
+
amount: this.config.slashBroadcastedInvalidBlockPenalty,
|
|
846
|
+
offenseType: getOffenseTypeName(offenseType),
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
this.emit(WANT_TO_SLASH_EVENT, [
|
|
850
|
+
{
|
|
851
|
+
validator: proposer,
|
|
852
|
+
amount: this.config.slashBroadcastedInvalidBlockPenalty,
|
|
853
|
+
offenseType,
|
|
854
|
+
epochOrSlot: BigInt(slot),
|
|
855
|
+
},
|
|
856
|
+
]);
|
|
857
|
+
}
|
|
858
|
+
|
|
855
859
|
/**
|
|
856
860
|
* Handle detection of a duplicate proposal (equivocation).
|
|
857
861
|
* Emits a slash event when a proposer sends multiple proposals for the same position.
|
|
858
862
|
*/
|
|
859
863
|
private handleDuplicateProposal(info: DuplicateProposalInfo): void {
|
|
860
864
|
const { slot, proposer, type } = info;
|
|
865
|
+
this.proposalHandler.markProposalEquivocation(slot);
|
|
861
866
|
|
|
862
|
-
this.log.
|
|
867
|
+
this.log.info(`Detected duplicate ${type} proposal offense from ${proposer.toString()} at slot ${slot}`, {
|
|
863
868
|
proposer: proposer.toString(),
|
|
864
869
|
slot,
|
|
865
870
|
type,
|
|
871
|
+
amount: this.config.slashDuplicateProposalPenalty,
|
|
872
|
+
offenseType: getOffenseTypeName(OffenseType.DUPLICATE_PROPOSAL),
|
|
866
873
|
});
|
|
867
874
|
|
|
868
|
-
// Emit slash event
|
|
869
875
|
this.emit(WANT_TO_SLASH_EVENT, [
|
|
870
876
|
{
|
|
871
877
|
validator: proposer,
|
|
@@ -874,6 +880,13 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
874
880
|
epochOrSlot: BigInt(slot),
|
|
875
881
|
},
|
|
876
882
|
]);
|
|
883
|
+
|
|
884
|
+
this.emit(WANT_TO_CLEAR_SLASH_EVENT, [
|
|
885
|
+
{
|
|
886
|
+
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
|
|
887
|
+
epochOrSlot: BigInt(slot),
|
|
888
|
+
},
|
|
889
|
+
]);
|
|
877
890
|
}
|
|
878
891
|
|
|
879
892
|
/**
|
|
@@ -883,9 +896,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
883
896
|
private handleDuplicateAttestation(info: DuplicateAttestationInfo): void {
|
|
884
897
|
const { slot, attester } = info;
|
|
885
898
|
|
|
886
|
-
this.log.
|
|
899
|
+
this.log.info(`Detected duplicate attestation offense from ${attester.toString()} at slot ${slot}`, {
|
|
887
900
|
attester: attester.toString(),
|
|
888
901
|
slot,
|
|
902
|
+
amount: this.config.slashDuplicateAttestationPenalty,
|
|
903
|
+
offenseType: getOffenseTypeName(OffenseType.DUPLICATE_ATTESTATION),
|
|
889
904
|
});
|
|
890
905
|
|
|
891
906
|
this.emit(WANT_TO_SLASH_EVENT, [
|
|
@@ -900,6 +915,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
900
915
|
|
|
901
916
|
async createBlockProposal(
|
|
902
917
|
blockHeader: BlockHeader,
|
|
918
|
+
checkpointNumber: CheckpointNumber,
|
|
903
919
|
indexWithinCheckpoint: IndexWithinCheckpoint,
|
|
904
920
|
inHash: Fr,
|
|
905
921
|
archive: Fr,
|
|
@@ -926,6 +942,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
926
942
|
);
|
|
927
943
|
const newProposal = await this.validationService.createBlockProposal(
|
|
928
944
|
blockHeader,
|
|
945
|
+
checkpointNumber,
|
|
929
946
|
indexWithinCheckpoint,
|
|
930
947
|
inHash,
|
|
931
948
|
archive,
|
|
@@ -933,7 +950,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
933
950
|
proposerAddress,
|
|
934
951
|
{
|
|
935
952
|
...options,
|
|
936
|
-
broadcastInvalidBlockProposal:
|
|
953
|
+
broadcastInvalidBlockProposal:
|
|
954
|
+
options.broadcastInvalidBlockProposal || this.config.broadcastInvalidBlockProposal,
|
|
937
955
|
},
|
|
938
956
|
);
|
|
939
957
|
this.lastProposedBlock = newProposal;
|
|
@@ -943,8 +961,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
943
961
|
async createCheckpointProposal(
|
|
944
962
|
checkpointHeader: CheckpointHeader,
|
|
945
963
|
archive: Fr,
|
|
964
|
+
checkpointNumber: CheckpointNumber,
|
|
946
965
|
feeAssetPriceModifier: bigint,
|
|
947
|
-
|
|
966
|
+
lastBlockProposal: BlockProposal | undefined,
|
|
948
967
|
proposerAddress: EthAddress | undefined,
|
|
949
968
|
options: CheckpointProposalOptions = {},
|
|
950
969
|
): Promise<CheckpointProposal> {
|
|
@@ -965,12 +984,20 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
965
984
|
const newProposal = await this.validationService.createCheckpointProposal(
|
|
966
985
|
checkpointHeader,
|
|
967
986
|
archive,
|
|
987
|
+
checkpointNumber,
|
|
968
988
|
feeAssetPriceModifier,
|
|
969
|
-
|
|
989
|
+
lastBlockProposal,
|
|
970
990
|
proposerAddress,
|
|
971
991
|
options,
|
|
972
992
|
);
|
|
973
993
|
this.lastProposedCheckpoint = newProposal;
|
|
994
|
+
// Self-record this slot's outcome on the re-execution tracker. Proposers don't run their
|
|
995
|
+
// own proposals through `handleCheckpointProposal`, so without this call the proposer's
|
|
996
|
+
// sentinel would see no outcome for slots it proposed and would mis-attribute itself as
|
|
997
|
+
// inactive. We pass the locally-computed `archive` (not `newProposal.archive`, which may
|
|
998
|
+
// be intentionally corrupted under test-only flags); from the proposer's local-view
|
|
999
|
+
// perspective the work it just completed is valid by definition.
|
|
1000
|
+
this.proposalHandler.recordOwnCheckpointProposalAsValid(checkpointHeader.slotNumber, archive, checkpointNumber);
|
|
974
1001
|
return newProposal;
|
|
975
1002
|
}
|
|
976
1003
|
|
|
@@ -982,16 +1009,24 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
982
1009
|
attestationsAndSigners: CommitteeAttestationsAndSigners,
|
|
983
1010
|
proposer: EthAddress,
|
|
984
1011
|
slot: SlotNumber,
|
|
985
|
-
|
|
1012
|
+
checkpointNumber: CheckpointNumber,
|
|
986
1013
|
): Promise<Signature> {
|
|
987
|
-
return await this.validationService.signAttestationsAndSigners(
|
|
1014
|
+
return await this.validationService.signAttestationsAndSigners(
|
|
1015
|
+
attestationsAndSigners,
|
|
1016
|
+
proposer,
|
|
1017
|
+
slot,
|
|
1018
|
+
checkpointNumber,
|
|
1019
|
+
);
|
|
988
1020
|
}
|
|
989
1021
|
|
|
990
|
-
async collectOwnAttestations(
|
|
1022
|
+
async collectOwnAttestations(
|
|
1023
|
+
proposal: CheckpointProposal,
|
|
1024
|
+
checkpointNumber: CheckpointNumber,
|
|
1025
|
+
): Promise<CheckpointAttestation[]> {
|
|
991
1026
|
const slot = proposal.slotNumber;
|
|
992
1027
|
const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
|
|
993
1028
|
this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
|
|
994
|
-
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
|
|
1029
|
+
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee, checkpointNumber);
|
|
995
1030
|
|
|
996
1031
|
if (!attestations) {
|
|
997
1032
|
return [];
|
|
@@ -1010,6 +1045,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
1010
1045
|
proposal: CheckpointProposal,
|
|
1011
1046
|
required: number,
|
|
1012
1047
|
deadline: Date,
|
|
1048
|
+
checkpointNumber: CheckpointNumber,
|
|
1013
1049
|
): Promise<CheckpointAttestation[]> {
|
|
1014
1050
|
// Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
|
|
1015
1051
|
const slot = proposal.slotNumber;
|
|
@@ -1022,33 +1058,23 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
1022
1058
|
throw new AttestationTimeoutError(0, required, slot);
|
|
1023
1059
|
}
|
|
1024
1060
|
|
|
1025
|
-
await this.collectOwnAttestations(proposal);
|
|
1061
|
+
await this.collectOwnAttestations(proposal, checkpointNumber);
|
|
1026
1062
|
|
|
1027
|
-
const
|
|
1063
|
+
const proposalPayloadHash = proposal.getPayloadHash();
|
|
1028
1064
|
const myAddresses = this.getValidatorAddresses();
|
|
1029
1065
|
|
|
1030
1066
|
let attestations: CheckpointAttestation[] = [];
|
|
1031
1067
|
while (true) {
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
if (!attestation.archive.equals(proposal.archive)) {
|
|
1037
|
-
this.log.warn(
|
|
1038
|
-
`Received attestation for slot ${slot} with mismatched archive from ${attestation.getSender()?.toString()}`,
|
|
1039
|
-
{ attestationArchive: attestation.archive.toString(), proposalArchive: proposal.archive.toString() },
|
|
1040
|
-
);
|
|
1041
|
-
return false;
|
|
1042
|
-
}
|
|
1043
|
-
return true;
|
|
1044
|
-
},
|
|
1045
|
-
);
|
|
1068
|
+
// The pool already filters by proposal payload hash; if any attestation slips through with a
|
|
1069
|
+
// mismatched payload hash, drop it defensively. Equivocations are emitted as separate slash
|
|
1070
|
+
// events from libp2p_service.
|
|
1071
|
+
const collectedAttestations = await this.p2pClient.getCheckpointAttestationsForSlot(slot, proposalPayloadHash);
|
|
1046
1072
|
|
|
1047
1073
|
// Log new attestations we collected
|
|
1048
1074
|
const oldSenders = attestations.map(attestation => attestation.getSender());
|
|
1049
1075
|
for (const collected of collectedAttestations) {
|
|
1050
1076
|
const collectedSender = collected.getSender();
|
|
1051
|
-
// Skip attestations with invalid signatures
|
|
1077
|
+
// Skip attestations with invalid signatures. Should not happen as we don't add invalid attestations to our pool.
|
|
1052
1078
|
if (!collectedSender) {
|
|
1053
1079
|
this.log.warn(`Skipping attestation with invalid signature for slot ${slot}`);
|
|
1054
1080
|
continue;
|