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