@aztec/validator-client 0.0.1-commit.181e2d196 → 0.0.1-commit.189eedb3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -11
- package/dest/checkpoint_builder.d.ts +14 -4
- package/dest/checkpoint_builder.d.ts.map +1 -1
- package/dest/checkpoint_builder.js +97 -29
- package/dest/config.d.ts +1 -1
- package/dest/config.d.ts.map +1 -1
- package/dest/config.js +33 -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 +10 -5
- 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/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 +134 -0
- package/dest/proposal_handler.d.ts.map +1 -0
- package/dest/proposal_handler.js +1072 -0
- package/dest/validator.d.ts +29 -21
- package/dest/validator.d.ts.map +1 -1
- package/dest/validator.js +248 -264
- package/package.json +19 -19
- package/src/checkpoint_builder.ts +120 -34
- package/src/config.ts +33 -6
- package/src/duties/validation_service.ts +52 -54
- package/src/factory.ts +20 -4
- package/src/index.ts +1 -1
- package/src/key_store/ha_key_store.ts +1 -1
- package/src/metrics.ts +19 -1
- package/src/proposal_handler.ts +1161 -0
- package/src/validator.ts +325 -307
- 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,38 +1,37 @@
|
|
|
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
7
|
import type { 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
14
|
import type { DuplicateAttestationInfo, DuplicateProposalInfo, 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,
|
|
@@ -40,36 +39,73 @@ import {
|
|
|
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 type { BlockHeader,
|
|
45
|
+
import type { BlockHeader, Tx } from '@aztec/stdlib/tx';
|
|
46
46
|
import { AttestationTimeoutError } from '@aztec/stdlib/validators';
|
|
47
47
|
import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client';
|
|
48
|
-
import {
|
|
49
|
-
|
|
48
|
+
import {
|
|
49
|
+
createHASigner,
|
|
50
|
+
createLocalSignerWithProtection,
|
|
51
|
+
createSignerFromSharedDb,
|
|
52
|
+
} from '@aztec/validator-ha-signer/factory';
|
|
53
|
+
import { DutyType, type SigningContext, type SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types';
|
|
50
54
|
import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
|
|
51
55
|
|
|
52
56
|
import { EventEmitter } from 'events';
|
|
53
57
|
import type { TypedDataDefinition } from 'viem';
|
|
54
58
|
|
|
55
|
-
import { BlockProposalHandler, type BlockProposalValidationFailureReason } from './block_proposal_handler.js';
|
|
56
59
|
import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
|
|
57
60
|
import { ValidationService } from './duties/validation_service.js';
|
|
58
61
|
import { HAKeyStore } from './key_store/ha_key_store.js';
|
|
59
62
|
import type { ExtendedValidatorKeyStore } from './key_store/interface.js';
|
|
60
63
|
import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
|
|
61
64
|
import { ValidatorMetrics } from './metrics.js';
|
|
65
|
+
import {
|
|
66
|
+
type BlockProposalValidationFailureReason,
|
|
67
|
+
type CheckpointProposalValidationFailureReason,
|
|
68
|
+
type CheckpointProposalValidationFailureResult,
|
|
69
|
+
ProposalHandler,
|
|
70
|
+
} from './proposal_handler.js';
|
|
62
71
|
|
|
63
72
|
// We maintain a set of proposers who have proposed invalid blocks.
|
|
64
73
|
// Just cap the set to avoid unbounded growth.
|
|
65
74
|
const MAX_PROPOSERS_OF_INVALID_BLOCKS = 1000;
|
|
75
|
+
const MAX_TRACKED_INVALID_PROPOSAL_SLOTS = 1000;
|
|
76
|
+
const MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS = 1000;
|
|
77
|
+
const MAX_TRACKED_BAD_ATTESTATIONS = 10_000;
|
|
66
78
|
|
|
67
79
|
// What errors from the block proposal handler result in slashing
|
|
68
80
|
const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidationFailureReason[] = [
|
|
69
81
|
'state_mismatch',
|
|
70
82
|
'failed_txs',
|
|
83
|
+
'global_variables_mismatch',
|
|
84
|
+
'invalid_proposal',
|
|
85
|
+
'parent_block_wrong_slot',
|
|
86
|
+
'in_hash_mismatch',
|
|
71
87
|
];
|
|
72
88
|
|
|
89
|
+
const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT: Record<CheckpointProposalValidationFailureReason, boolean> = {
|
|
90
|
+
// enabled
|
|
91
|
+
['invalid_fee_asset_price_modifier']: true,
|
|
92
|
+
['checkpoint_header_mismatch']: true,
|
|
93
|
+
// These late mismatches should normally be caught by earlier checks, but if reached after validating the local
|
|
94
|
+
// checkpoint inputs, the proposer-signed payload disagrees with deterministic recomputation.
|
|
95
|
+
['archive_mismatch']: true,
|
|
96
|
+
['out_hash_mismatch']: true,
|
|
97
|
+
['no_blocks_for_slot']: true,
|
|
98
|
+
['too_many_blocks_in_checkpoint']: true,
|
|
99
|
+
['checkpoint_validation_failed']: true,
|
|
100
|
+
['last_block_archive_mismatch']: true,
|
|
101
|
+
|
|
102
|
+
// disabled
|
|
103
|
+
['invalid_signature']: false,
|
|
104
|
+
['last_block_not_found']: false,
|
|
105
|
+
['block_fetch_error']: false,
|
|
106
|
+
['checkpoint_already_published']: false,
|
|
107
|
+
};
|
|
108
|
+
|
|
73
109
|
/**
|
|
74
110
|
* Validator Client
|
|
75
111
|
*/
|
|
@@ -92,7 +128,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
92
128
|
/** Tracks the last epoch in which each attester successfully submitted at least one attestation. */
|
|
93
129
|
private lastAttestedEpochByAttester: Map<string, EpochNumber> = new Map();
|
|
94
130
|
|
|
95
|
-
private proposersOfInvalidBlocks
|
|
131
|
+
private proposersOfInvalidBlocks = FifoSet.withLimit<string>(MAX_PROPOSERS_OF_INVALID_BLOCKS);
|
|
132
|
+
private slotsWithInvalidProposals = FifoSet.withLimit<SlotNumber>(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
|
|
133
|
+
private invalidCheckpointProposalOffenseKeys = FifoSet.withLimit<string>(MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS);
|
|
134
|
+
private badAttestationOffenseKeys = FifoSet.withLimit<string>(MAX_TRACKED_BAD_ATTESTATIONS);
|
|
135
|
+
private slotsWithProposalEquivocation = FifoSet.withLimit<SlotNumber>(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
|
|
96
136
|
|
|
97
137
|
/** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */
|
|
98
138
|
private lastAttestedProposal?: CheckpointProposalCore;
|
|
@@ -101,14 +141,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
101
141
|
private keyStore: ExtendedValidatorKeyStore,
|
|
102
142
|
private epochCache: EpochCache,
|
|
103
143
|
private p2pClient: P2P,
|
|
104
|
-
private
|
|
144
|
+
private proposalHandler: ProposalHandler,
|
|
105
145
|
private blockSource: L2BlockSource,
|
|
106
146
|
private checkpointsBuilder: FullNodeCheckpointsBuilder,
|
|
107
147
|
private worldState: WorldStateSynchronizer,
|
|
108
148
|
private l1ToL2MessageSource: L1ToL2MessageSource,
|
|
109
149
|
private config: ValidatorClientFullConfig,
|
|
110
150
|
private blobClient: BlobClientInterface,
|
|
111
|
-
private
|
|
151
|
+
private slashingProtectionSigner: ValidatorHASigner,
|
|
112
152
|
private dateProvider: DateProvider = new DateProvider(),
|
|
113
153
|
telemetry: TelemetryClient = getTelemetryClient(),
|
|
114
154
|
log = createLogger('validator'),
|
|
@@ -121,11 +161,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
121
161
|
this.tracer = telemetry.getTracer('Validator');
|
|
122
162
|
this.metrics = new ValidatorMetrics(telemetry);
|
|
123
163
|
|
|
124
|
-
this.validationService = new ValidationService(
|
|
164
|
+
this.validationService = new ValidationService(
|
|
165
|
+
keyStore,
|
|
166
|
+
this.getSignatureContext(),
|
|
167
|
+
this.log.createChild('validation-service'),
|
|
168
|
+
);
|
|
169
|
+
this.proposalHandler.setCheckpointProposalValidationFailureCallback((proposal, result, proposalInfo) =>
|
|
170
|
+
this.handleInvalidCheckpointProposal(proposal, result, proposalInfo),
|
|
171
|
+
);
|
|
125
172
|
|
|
126
173
|
// Refresh epoch cache every second to trigger alert if participation in committee changes
|
|
127
174
|
this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), this.log, 1000);
|
|
128
|
-
|
|
129
175
|
const myAddresses = this.getValidatorAddresses();
|
|
130
176
|
this.log.verbose(`Initialized validator with addresses: ${myAddresses.map(a => a.toString()).join(', ')}`);
|
|
131
177
|
}
|
|
@@ -194,15 +240,23 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
194
240
|
txProvider: ITxProvider,
|
|
195
241
|
keyStoreManager: KeystoreManager,
|
|
196
242
|
blobClient: BlobClientInterface,
|
|
243
|
+
reexecutionTracker: CheckpointReexecutionTracker,
|
|
197
244
|
dateProvider: DateProvider = new DateProvider(),
|
|
198
245
|
telemetry: TelemetryClient = getTelemetryClient(),
|
|
246
|
+
slashingProtectionDb?: SlashingProtectionDatabase,
|
|
199
247
|
) {
|
|
200
248
|
const metrics = new ValidatorMetrics(telemetry);
|
|
201
249
|
const blockProposalValidator = new BlockProposalValidator(epochCache, {
|
|
202
250
|
txsPermitted: !config.disableTransactions,
|
|
203
|
-
maxTxsPerBlock: config.
|
|
251
|
+
maxTxsPerBlock: config.validateMaxTxsPerBlock,
|
|
252
|
+
maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint,
|
|
253
|
+
skipSlotValidation: config.skipProposalSlotValidation,
|
|
254
|
+
signatureContext: {
|
|
255
|
+
chainId: config.l1ChainId,
|
|
256
|
+
rollupAddress: config.rollupAddress,
|
|
257
|
+
},
|
|
204
258
|
});
|
|
205
|
-
const
|
|
259
|
+
const proposalHandler = new ProposalHandler(
|
|
206
260
|
checkpointsBuilder,
|
|
207
261
|
worldState,
|
|
208
262
|
blockSource,
|
|
@@ -211,37 +265,55 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
211
265
|
blockProposalValidator,
|
|
212
266
|
epochCache,
|
|
213
267
|
config,
|
|
268
|
+
blobClient,
|
|
269
|
+
reexecutionTracker,
|
|
214
270
|
metrics,
|
|
215
271
|
dateProvider,
|
|
216
272
|
telemetry,
|
|
273
|
+
undefined,
|
|
217
274
|
);
|
|
218
275
|
|
|
219
276
|
const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
|
|
220
|
-
let
|
|
221
|
-
|
|
222
|
-
|
|
277
|
+
let slashingProtectionSigner: ValidatorHASigner;
|
|
278
|
+
if (slashingProtectionDb) {
|
|
279
|
+
// Shared database mode: use a pre-existing database (e.g. for testing HA setups).
|
|
280
|
+
({ signer: slashingProtectionSigner } = createSignerFromSharedDb(slashingProtectionDb, config, {
|
|
281
|
+
telemetryClient: telemetry,
|
|
282
|
+
dateProvider,
|
|
283
|
+
}));
|
|
284
|
+
} else if (config.haSigningEnabled) {
|
|
285
|
+
// Multi-node HA mode: use PostgreSQL-backed distributed locking.
|
|
223
286
|
// If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
|
|
224
287
|
const haConfig = {
|
|
225
288
|
...config,
|
|
226
289
|
maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
|
|
227
290
|
};
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
291
|
+
({ signer: slashingProtectionSigner } = await createHASigner(haConfig, {
|
|
292
|
+
telemetryClient: telemetry,
|
|
293
|
+
dateProvider,
|
|
294
|
+
}));
|
|
295
|
+
} else {
|
|
296
|
+
// Single-node mode: use LMDB-backed local signing protection.
|
|
297
|
+
// This prevents double-signing if the node crashes and restarts mid-proposal.
|
|
298
|
+
({ signer: slashingProtectionSigner } = await createLocalSignerWithProtection(config, {
|
|
299
|
+
telemetryClient: telemetry,
|
|
300
|
+
dateProvider,
|
|
301
|
+
}));
|
|
231
302
|
}
|
|
303
|
+
const validatorKeyStore: ExtendedValidatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
|
|
232
304
|
|
|
233
305
|
const validator = new ValidatorClient(
|
|
234
306
|
validatorKeyStore,
|
|
235
307
|
epochCache,
|
|
236
308
|
p2pClient,
|
|
237
|
-
|
|
309
|
+
proposalHandler,
|
|
238
310
|
blockSource,
|
|
239
311
|
checkpointsBuilder,
|
|
240
312
|
worldState,
|
|
241
313
|
l1ToL2MessageSource,
|
|
242
314
|
config,
|
|
243
315
|
blobClient,
|
|
244
|
-
|
|
316
|
+
slashingProtectionSigner,
|
|
245
317
|
dateProvider,
|
|
246
318
|
telemetry,
|
|
247
319
|
);
|
|
@@ -255,14 +327,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
255
327
|
.filter(addr => !this.config.disabledValidators.some(disabled => disabled.equals(addr)));
|
|
256
328
|
}
|
|
257
329
|
|
|
258
|
-
public
|
|
259
|
-
return this.
|
|
330
|
+
public getProposalHandler() {
|
|
331
|
+
return this.proposalHandler;
|
|
260
332
|
}
|
|
261
333
|
|
|
262
334
|
public signWithAddress(addr: EthAddress, msg: TypedDataDefinition, context: SigningContext) {
|
|
263
335
|
return this.keyStore.signTypedDataWithAddress(addr, msg, context);
|
|
264
336
|
}
|
|
265
337
|
|
|
338
|
+
private getSignatureContext(): CoordinationSignatureContext {
|
|
339
|
+
return {
|
|
340
|
+
chainId: this.config.l1ChainId,
|
|
341
|
+
rollupAddress: this.config.rollupAddress,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
266
345
|
public getCoinbaseForAttestor(attestor: EthAddress): EthAddress {
|
|
267
346
|
return this.keyStore.getCoinbaseAddress(attestor);
|
|
268
347
|
}
|
|
@@ -275,30 +354,27 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
275
354
|
return this.config;
|
|
276
355
|
}
|
|
277
356
|
|
|
357
|
+
public hasProposalEquivocation(slotNumber: SlotNumber): boolean {
|
|
358
|
+
return this.slotsWithProposalEquivocation.has(slotNumber);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
public hasInvalidProposals(slotNumber: SlotNumber): boolean {
|
|
362
|
+
return this.slotsWithInvalidProposals.has(slotNumber);
|
|
363
|
+
}
|
|
364
|
+
|
|
278
365
|
public updateConfig(config: Partial<ValidatorClientFullConfig>) {
|
|
279
366
|
this.config = { ...this.config, ...config };
|
|
367
|
+
this.proposalHandler.updateConfig(config);
|
|
280
368
|
}
|
|
281
369
|
|
|
282
370
|
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
371
|
const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
this.
|
|
300
|
-
|
|
301
|
-
|
|
372
|
+
this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
|
|
373
|
+
this.validationService = new ValidationService(
|
|
374
|
+
this.keyStore,
|
|
375
|
+
this.getSignatureContext(),
|
|
376
|
+
this.log.createChild('validation-service'),
|
|
377
|
+
);
|
|
302
378
|
}
|
|
303
379
|
|
|
304
380
|
public async start() {
|
|
@@ -345,7 +421,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
345
421
|
checkpoint: CheckpointProposalCore,
|
|
346
422
|
proposalSender: PeerId,
|
|
347
423
|
): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
|
|
348
|
-
this.p2pClient.
|
|
424
|
+
this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler);
|
|
349
425
|
|
|
350
426
|
// Duplicate proposal handler - triggers slashing for equivocation
|
|
351
427
|
this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
|
|
@@ -357,6 +433,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
357
433
|
this.handleDuplicateAttestation(info);
|
|
358
434
|
});
|
|
359
435
|
|
|
436
|
+
this.p2pClient.registerCheckpointAttestationCallback((attestation: CheckpointAttestation) => {
|
|
437
|
+
this.handleCheckpointAttestation(attestation);
|
|
438
|
+
});
|
|
439
|
+
|
|
360
440
|
const myAddresses = this.getValidatorAddresses();
|
|
361
441
|
this.p2pClient.registerThisValidatorAddresses(myAddresses);
|
|
362
442
|
|
|
@@ -384,13 +464,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
384
464
|
return false;
|
|
385
465
|
}
|
|
386
466
|
|
|
387
|
-
//
|
|
467
|
+
// Log self-proposals from HA peers (same validator key on different nodes)
|
|
388
468
|
if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
389
|
-
this.log.
|
|
469
|
+
this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
|
|
390
470
|
proposer: proposer.toString(),
|
|
391
471
|
slotNumber,
|
|
392
472
|
});
|
|
393
|
-
return false;
|
|
394
473
|
}
|
|
395
474
|
|
|
396
475
|
// Check if we're in the committee (for metrics purposes)
|
|
@@ -404,27 +483,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
404
483
|
fishermanMode: this.config.fishermanMode || false,
|
|
405
484
|
});
|
|
406
485
|
|
|
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
|
-
);
|
|
486
|
+
// Reexecute outside the escape hatch so slashing observers can detect invalid proposals even when penalties are 0.
|
|
487
|
+
const validationResult = await this.proposalHandler.handleBlockProposal(proposal, proposalSender, !escapeHatchOpen);
|
|
423
488
|
|
|
424
489
|
if (!validationResult.isValid) {
|
|
425
|
-
this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
426
|
-
|
|
427
490
|
const reason = validationResult.reason || 'unknown';
|
|
491
|
+
|
|
492
|
+
this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
|
|
493
|
+
|
|
428
494
|
// Classify failure reason: bad proposal vs node issue
|
|
429
495
|
const badProposalReasons: BlockProposalValidationFailureReason[] = [
|
|
430
496
|
'invalid_proposal',
|
|
@@ -441,15 +507,18 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
441
507
|
this.metrics.incFailedAttestationsNodeIssue(1, reason, partOfCommittee);
|
|
442
508
|
}
|
|
443
509
|
|
|
444
|
-
// Slash invalid block proposals (can happen even when not in committee)
|
|
445
510
|
if (
|
|
446
511
|
!escapeHatchOpen &&
|
|
447
512
|
validationResult.reason &&
|
|
448
|
-
SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason)
|
|
449
|
-
slashBroadcastedInvalidBlockPenalty > 0n
|
|
513
|
+
SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason)
|
|
450
514
|
) {
|
|
451
|
-
this.log.
|
|
515
|
+
this.log.info(`Detected invalid block proposal offense`, {
|
|
516
|
+
...proposalInfo,
|
|
517
|
+
amount: this.config.slashBroadcastedInvalidBlockPenalty,
|
|
518
|
+
offenseType: getOffenseTypeName(OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL),
|
|
519
|
+
});
|
|
452
520
|
this.slashInvalidBlock(proposal);
|
|
521
|
+
this.markInvalidProposalSlot(proposal.slotNumber);
|
|
453
522
|
}
|
|
454
523
|
return false;
|
|
455
524
|
}
|
|
@@ -479,68 +548,56 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
479
548
|
proposal: CheckpointProposalCore,
|
|
480
549
|
_proposalSender: PeerId,
|
|
481
550
|
): Promise<CheckpointAttestation[] | undefined> {
|
|
482
|
-
const
|
|
551
|
+
const proposalSlotNumber = proposal.slotNumber;
|
|
483
552
|
const proposer = proposal.getSender();
|
|
484
553
|
|
|
485
554
|
// 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 ${
|
|
555
|
+
if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
|
|
556
|
+
this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
|
|
488
557
|
return undefined;
|
|
489
558
|
}
|
|
490
559
|
|
|
491
|
-
//
|
|
492
|
-
if (!
|
|
493
|
-
this.log.warn(`Received checkpoint proposal with invalid signature for slot ${slotNumber}`);
|
|
560
|
+
// Early-out for equivocation: refuses if we've already attested to a higher slot.
|
|
561
|
+
if (!this.shouldAttestToSlot(proposalSlotNumber)) {
|
|
494
562
|
return undefined;
|
|
495
563
|
}
|
|
496
564
|
|
|
497
565
|
// Ignore proposals from ourselves (may happen in HA setups)
|
|
498
|
-
if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
499
|
-
this.log.
|
|
566
|
+
if (proposer && this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
567
|
+
this.log.debug(`Ignoring block proposal from self for slot ${proposalSlotNumber}`, {
|
|
500
568
|
proposer: proposer.toString(),
|
|
501
|
-
|
|
569
|
+
proposalSlotNumber,
|
|
502
570
|
});
|
|
503
571
|
return undefined;
|
|
504
572
|
}
|
|
505
573
|
|
|
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());
|
|
574
|
+
// Check that I have any address in the committee where this checkpoint will land before attesting
|
|
575
|
+
const inCommittee = await this.epochCache.filterInCommittee(proposalSlotNumber, this.getValidatorAddresses());
|
|
516
576
|
const partOfCommittee = inCommittee.length > 0;
|
|
517
577
|
|
|
518
578
|
const proposalInfo = {
|
|
519
|
-
|
|
579
|
+
proposalSlotNumber,
|
|
520
580
|
archive: proposal.archive.toString(),
|
|
521
|
-
proposer: proposer
|
|
522
|
-
txCount: proposal.txHashes.length,
|
|
581
|
+
proposer: proposer?.toString(),
|
|
523
582
|
};
|
|
524
|
-
this.log.info(`Received checkpoint proposal for slot ${
|
|
583
|
+
this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
|
|
525
584
|
...proposalInfo,
|
|
526
|
-
txHashes: proposal.txHashes.map(t => t.toString()),
|
|
527
585
|
fishermanMode: this.config.fishermanMode || false,
|
|
528
586
|
});
|
|
529
587
|
|
|
530
|
-
// Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
|
|
588
|
+
// Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set).
|
|
589
|
+
// Uses the cached result from the all-nodes callback if available (avoids double validation).
|
|
590
|
+
let checkpointNumber: CheckpointNumber;
|
|
531
591
|
if (this.config.skipCheckpointProposalValidation) {
|
|
532
|
-
this.log.warn(`Skipping checkpoint proposal validation for slot ${
|
|
592
|
+
this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
|
|
593
|
+
checkpointNumber = CheckpointNumber(0);
|
|
533
594
|
} else {
|
|
534
|
-
const validationResult = await this.
|
|
595
|
+
const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
|
|
535
596
|
if (!validationResult.isValid) {
|
|
536
597
|
this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
537
598
|
return undefined;
|
|
538
599
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
// Upload blobs to filestore if we can (fire and forget)
|
|
542
|
-
if (this.blobClient.canUpload()) {
|
|
543
|
-
void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
|
|
600
|
+
checkpointNumber = validationResult.checkpointNumber;
|
|
544
601
|
}
|
|
545
602
|
|
|
546
603
|
// Check that I have any address in current committee before attesting
|
|
@@ -551,16 +608,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
551
608
|
}
|
|
552
609
|
|
|
553
610
|
// Provided all of the above checks pass, we can attest to the proposal
|
|
554
|
-
this.log.info(
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
611
|
+
this.log.info(
|
|
612
|
+
`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${proposalSlotNumber}`,
|
|
613
|
+
{
|
|
614
|
+
...proposalInfo,
|
|
615
|
+
inCommittee: partOfCommittee,
|
|
616
|
+
fishermanMode: this.config.fishermanMode || false,
|
|
617
|
+
},
|
|
618
|
+
);
|
|
559
619
|
|
|
560
620
|
this.metrics.incSuccessfulAttestations(inCommittee.length);
|
|
561
621
|
|
|
562
622
|
// Track epoch participation per attester: count each (attester, epoch) pair at most once
|
|
563
|
-
const proposalEpoch = getEpochAtSlot(
|
|
623
|
+
const proposalEpoch = getEpochAtSlot(proposalSlotNumber, this.epochCache.getL1Constants());
|
|
564
624
|
for (const attester of inCommittee) {
|
|
565
625
|
const key = attester.toString();
|
|
566
626
|
const lastEpoch = this.lastAttestedEpochByAttester.get(key);
|
|
@@ -588,14 +648,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
588
648
|
|
|
589
649
|
if (this.config.fishermanMode) {
|
|
590
650
|
// bail out early and don't save attestations to the pool in fisherman mode
|
|
591
|
-
this.log.info(`Creating checkpoint attestations for slot ${
|
|
651
|
+
this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
|
|
592
652
|
...proposalInfo,
|
|
593
653
|
attestors: attestors.map(a => a.toString()),
|
|
594
654
|
});
|
|
595
655
|
return undefined;
|
|
596
656
|
}
|
|
597
657
|
|
|
598
|
-
return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
|
|
658
|
+
return await this.createCheckpointAttestationsFromProposal(proposal, attestors, checkpointNumber);
|
|
599
659
|
}
|
|
600
660
|
|
|
601
661
|
/**
|
|
@@ -622,13 +682,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
622
682
|
private async createCheckpointAttestationsFromProposal(
|
|
623
683
|
proposal: CheckpointProposalCore,
|
|
624
684
|
attestors: EthAddress[] = [],
|
|
685
|
+
checkpointNumber: CheckpointNumber,
|
|
625
686
|
): Promise<CheckpointAttestation[] | undefined> {
|
|
626
687
|
// Equivocation check: must happen right before signing to minimize the race window
|
|
627
688
|
if (!this.shouldAttestToSlot(proposal.slotNumber)) {
|
|
628
689
|
return undefined;
|
|
629
690
|
}
|
|
630
691
|
|
|
631
|
-
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
|
|
692
|
+
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors, checkpointNumber);
|
|
632
693
|
|
|
633
694
|
// Track the proposal we attested to (to prevent equivocation)
|
|
634
695
|
this.lastAttestedProposal = proposal;
|
|
@@ -637,164 +698,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
637
698
|
return attestations;
|
|
638
699
|
}
|
|
639
700
|
|
|
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
701
|
/**
|
|
793
702
|
* Uploads blobs for a checkpoint to the filestore (fire and forget).
|
|
794
703
|
*/
|
|
795
704
|
protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
|
|
796
705
|
try {
|
|
797
|
-
const lastBlockHeader = await this.blockSource.
|
|
706
|
+
const lastBlockHeader = (await this.blockSource.getBlockData({ archive: proposal.archive }))?.header;
|
|
798
707
|
if (!lastBlockHeader) {
|
|
799
708
|
this.log.warn(`Failed to get last block header for blob upload`, proposalInfo);
|
|
800
709
|
return;
|
|
@@ -827,12 +736,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
827
736
|
return;
|
|
828
737
|
}
|
|
829
738
|
|
|
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
739
|
this.proposersOfInvalidBlocks.add(proposer.toString());
|
|
837
740
|
|
|
838
741
|
this.emit(WANT_TO_SLASH_EVENT, [
|
|
@@ -845,20 +748,115 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
845
748
|
]);
|
|
846
749
|
}
|
|
847
750
|
|
|
751
|
+
private handleInvalidCheckpointProposal(
|
|
752
|
+
proposal: CheckpointProposalCore,
|
|
753
|
+
result: CheckpointProposalValidationFailureResult,
|
|
754
|
+
proposalInfo: LogData,
|
|
755
|
+
): void {
|
|
756
|
+
if (!SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
this.markInvalidProposalSlot(proposal.slotNumber);
|
|
761
|
+
|
|
762
|
+
if (this.slashInvalidCheckpointProposal(proposal)) {
|
|
763
|
+
this.log.info(`Detected invalid checkpoint proposal offense`, {
|
|
764
|
+
...proposalInfo,
|
|
765
|
+
reason: result.reason,
|
|
766
|
+
amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
|
|
767
|
+
offenseType: getOffenseTypeName(OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL),
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private slashInvalidCheckpointProposal(proposal: CheckpointProposalCore): boolean {
|
|
773
|
+
const proposer = proposal.getSender();
|
|
774
|
+
if (!proposer) {
|
|
775
|
+
this.log.warn(`Cannot slash checkpoint proposal with invalid signature`, {
|
|
776
|
+
slotNumber: proposal.slotNumber,
|
|
777
|
+
archive: proposal.archive.toString(),
|
|
778
|
+
});
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const offenseType = OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL;
|
|
783
|
+
const offenseKey = `${proposer.toString()}:${offenseType}:${proposal.slotNumber}`;
|
|
784
|
+
if (!this.invalidCheckpointProposalOffenseKeys.addIfAbsent(offenseKey)) {
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
this.emit(WANT_TO_SLASH_EVENT, [
|
|
789
|
+
{
|
|
790
|
+
validator: proposer,
|
|
791
|
+
amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
|
|
792
|
+
offenseType,
|
|
793
|
+
epochOrSlot: BigInt(proposal.slotNumber),
|
|
794
|
+
},
|
|
795
|
+
]);
|
|
796
|
+
return true;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private markInvalidProposalSlot(slotNumber: SlotNumber): void {
|
|
800
|
+
this.slotsWithInvalidProposals.add(slotNumber);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
private handleCheckpointAttestation(attestation: CheckpointAttestation): void {
|
|
804
|
+
const slotNumber = attestation.slotNumber;
|
|
805
|
+
if (!this.slotsWithInvalidProposals.has(slotNumber) || this.slotsWithProposalEquivocation.has(slotNumber)) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const attester = attestation.getSender();
|
|
810
|
+
if (!attester) {
|
|
811
|
+
this.log.warn(`Cannot slash checkpoint attestation with invalid signature`, {
|
|
812
|
+
slotNumber,
|
|
813
|
+
archive: attestation.archive.toString(),
|
|
814
|
+
});
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
this.slashAttestedToInvalidCheckpointProposal(slotNumber, attester);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
private slashAttestedToInvalidCheckpointProposal(slotNumber: SlotNumber, attester: EthAddress): void {
|
|
822
|
+
const offenseKey = `${attester.toString()}:${OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL}:${slotNumber}`;
|
|
823
|
+
if (!this.badAttestationOffenseKeys.addIfAbsent(offenseKey)) {
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
this.log.info(`Detected attestation to invalid checkpoint proposal offense`, {
|
|
828
|
+
attester: attester.toString(),
|
|
829
|
+
slotNumber,
|
|
830
|
+
amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
|
|
831
|
+
offenseType: getOffenseTypeName(OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL),
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
this.emit(WANT_TO_SLASH_EVENT, [
|
|
835
|
+
{
|
|
836
|
+
validator: attester,
|
|
837
|
+
amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
|
|
838
|
+
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
|
|
839
|
+
epochOrSlot: BigInt(slotNumber),
|
|
840
|
+
},
|
|
841
|
+
]);
|
|
842
|
+
}
|
|
843
|
+
|
|
848
844
|
/**
|
|
849
845
|
* Handle detection of a duplicate proposal (equivocation).
|
|
850
846
|
* Emits a slash event when a proposer sends multiple proposals for the same position.
|
|
851
847
|
*/
|
|
852
848
|
private handleDuplicateProposal(info: DuplicateProposalInfo): void {
|
|
853
849
|
const { slot, proposer, type } = info;
|
|
850
|
+
this.slotsWithProposalEquivocation.add(slot);
|
|
854
851
|
|
|
855
|
-
this.log.
|
|
852
|
+
this.log.info(`Detected duplicate ${type} proposal offense from ${proposer.toString()} at slot ${slot}`, {
|
|
856
853
|
proposer: proposer.toString(),
|
|
857
854
|
slot,
|
|
858
855
|
type,
|
|
856
|
+
amount: this.config.slashDuplicateProposalPenalty,
|
|
857
|
+
offenseType: getOffenseTypeName(OffenseType.DUPLICATE_PROPOSAL),
|
|
859
858
|
});
|
|
860
859
|
|
|
861
|
-
// Emit slash event
|
|
862
860
|
this.emit(WANT_TO_SLASH_EVENT, [
|
|
863
861
|
{
|
|
864
862
|
validator: proposer,
|
|
@@ -867,6 +865,13 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
867
865
|
epochOrSlot: BigInt(slot),
|
|
868
866
|
},
|
|
869
867
|
]);
|
|
868
|
+
|
|
869
|
+
this.emit(WANT_TO_CLEAR_SLASH_EVENT, [
|
|
870
|
+
{
|
|
871
|
+
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
|
|
872
|
+
epochOrSlot: BigInt(slot),
|
|
873
|
+
},
|
|
874
|
+
]);
|
|
870
875
|
}
|
|
871
876
|
|
|
872
877
|
/**
|
|
@@ -876,9 +881,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
876
881
|
private handleDuplicateAttestation(info: DuplicateAttestationInfo): void {
|
|
877
882
|
const { slot, attester } = info;
|
|
878
883
|
|
|
879
|
-
this.log.
|
|
884
|
+
this.log.info(`Detected duplicate attestation offense from ${attester.toString()} at slot ${slot}`, {
|
|
880
885
|
attester: attester.toString(),
|
|
881
886
|
slot,
|
|
887
|
+
amount: this.config.slashDuplicateAttestationPenalty,
|
|
888
|
+
offenseType: getOffenseTypeName(OffenseType.DUPLICATE_ATTESTATION),
|
|
882
889
|
});
|
|
883
890
|
|
|
884
891
|
this.emit(WANT_TO_SLASH_EVENT, [
|
|
@@ -893,6 +900,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
893
900
|
|
|
894
901
|
async createBlockProposal(
|
|
895
902
|
blockHeader: BlockHeader,
|
|
903
|
+
checkpointNumber: CheckpointNumber,
|
|
896
904
|
indexWithinCheckpoint: IndexWithinCheckpoint,
|
|
897
905
|
inHash: Fr,
|
|
898
906
|
archive: Fr,
|
|
@@ -919,6 +927,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
919
927
|
);
|
|
920
928
|
const newProposal = await this.validationService.createBlockProposal(
|
|
921
929
|
blockHeader,
|
|
930
|
+
checkpointNumber,
|
|
922
931
|
indexWithinCheckpoint,
|
|
923
932
|
inHash,
|
|
924
933
|
archive,
|
|
@@ -926,7 +935,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
926
935
|
proposerAddress,
|
|
927
936
|
{
|
|
928
937
|
...options,
|
|
929
|
-
broadcastInvalidBlockProposal:
|
|
938
|
+
broadcastInvalidBlockProposal:
|
|
939
|
+
options.broadcastInvalidBlockProposal || this.config.broadcastInvalidBlockProposal,
|
|
930
940
|
},
|
|
931
941
|
);
|
|
932
942
|
this.lastProposedBlock = newProposal;
|
|
@@ -936,8 +946,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
936
946
|
async createCheckpointProposal(
|
|
937
947
|
checkpointHeader: CheckpointHeader,
|
|
938
948
|
archive: Fr,
|
|
949
|
+
checkpointNumber: CheckpointNumber,
|
|
939
950
|
feeAssetPriceModifier: bigint,
|
|
940
|
-
|
|
951
|
+
lastBlockProposal: BlockProposal | undefined,
|
|
941
952
|
proposerAddress: EthAddress | undefined,
|
|
942
953
|
options: CheckpointProposalOptions = {},
|
|
943
954
|
): Promise<CheckpointProposal> {
|
|
@@ -958,12 +969,20 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
958
969
|
const newProposal = await this.validationService.createCheckpointProposal(
|
|
959
970
|
checkpointHeader,
|
|
960
971
|
archive,
|
|
972
|
+
checkpointNumber,
|
|
961
973
|
feeAssetPriceModifier,
|
|
962
|
-
|
|
974
|
+
lastBlockProposal,
|
|
963
975
|
proposerAddress,
|
|
964
976
|
options,
|
|
965
977
|
);
|
|
966
978
|
this.lastProposedCheckpoint = newProposal;
|
|
979
|
+
// Self-record this slot's outcome on the re-execution tracker. Proposers don't run their
|
|
980
|
+
// own proposals through `handleCheckpointProposal`, so without this call the proposer's
|
|
981
|
+
// sentinel would see no outcome for slots it proposed and would mis-attribute itself as
|
|
982
|
+
// inactive. We pass the locally-computed `archive` (not `newProposal.archive`, which may
|
|
983
|
+
// be intentionally corrupted under test-only flags); from the proposer's local-view
|
|
984
|
+
// perspective the work it just completed is valid by definition.
|
|
985
|
+
this.proposalHandler.recordOwnCheckpointProposalAsValid(checkpointHeader.slotNumber, archive, checkpointNumber);
|
|
967
986
|
return newProposal;
|
|
968
987
|
}
|
|
969
988
|
|
|
@@ -975,16 +994,24 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
975
994
|
attestationsAndSigners: CommitteeAttestationsAndSigners,
|
|
976
995
|
proposer: EthAddress,
|
|
977
996
|
slot: SlotNumber,
|
|
978
|
-
|
|
997
|
+
checkpointNumber: CheckpointNumber,
|
|
979
998
|
): Promise<Signature> {
|
|
980
|
-
return await this.validationService.signAttestationsAndSigners(
|
|
999
|
+
return await this.validationService.signAttestationsAndSigners(
|
|
1000
|
+
attestationsAndSigners,
|
|
1001
|
+
proposer,
|
|
1002
|
+
slot,
|
|
1003
|
+
checkpointNumber,
|
|
1004
|
+
);
|
|
981
1005
|
}
|
|
982
1006
|
|
|
983
|
-
async collectOwnAttestations(
|
|
1007
|
+
async collectOwnAttestations(
|
|
1008
|
+
proposal: CheckpointProposal,
|
|
1009
|
+
checkpointNumber: CheckpointNumber,
|
|
1010
|
+
): Promise<CheckpointAttestation[]> {
|
|
984
1011
|
const slot = proposal.slotNumber;
|
|
985
1012
|
const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
|
|
986
1013
|
this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
|
|
987
|
-
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
|
|
1014
|
+
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee, checkpointNumber);
|
|
988
1015
|
|
|
989
1016
|
if (!attestations) {
|
|
990
1017
|
return [];
|
|
@@ -1003,6 +1030,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
1003
1030
|
proposal: CheckpointProposal,
|
|
1004
1031
|
required: number,
|
|
1005
1032
|
deadline: Date,
|
|
1033
|
+
checkpointNumber: CheckpointNumber,
|
|
1006
1034
|
): Promise<CheckpointAttestation[]> {
|
|
1007
1035
|
// Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
|
|
1008
1036
|
const slot = proposal.slotNumber;
|
|
@@ -1015,33 +1043,23 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
1015
1043
|
throw new AttestationTimeoutError(0, required, slot);
|
|
1016
1044
|
}
|
|
1017
1045
|
|
|
1018
|
-
await this.collectOwnAttestations(proposal);
|
|
1046
|
+
await this.collectOwnAttestations(proposal, checkpointNumber);
|
|
1019
1047
|
|
|
1020
|
-
const
|
|
1048
|
+
const proposalPayloadHash = proposal.getPayloadHash();
|
|
1021
1049
|
const myAddresses = this.getValidatorAddresses();
|
|
1022
1050
|
|
|
1023
1051
|
let attestations: CheckpointAttestation[] = [];
|
|
1024
1052
|
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
|
-
);
|
|
1053
|
+
// The pool already filters by proposal payload hash; if any attestation slips through with a
|
|
1054
|
+
// mismatched payload hash, drop it defensively. Equivocations are emitted as separate slash
|
|
1055
|
+
// events from libp2p_service.
|
|
1056
|
+
const collectedAttestations = await this.p2pClient.getCheckpointAttestationsForSlot(slot, proposalPayloadHash);
|
|
1039
1057
|
|
|
1040
1058
|
// Log new attestations we collected
|
|
1041
1059
|
const oldSenders = attestations.map(attestation => attestation.getSender());
|
|
1042
1060
|
for (const collected of collectedAttestations) {
|
|
1043
1061
|
const collectedSender = collected.getSender();
|
|
1044
|
-
// Skip attestations with invalid signatures
|
|
1062
|
+
// Skip attestations with invalid signatures. Should not happen as we don't add invalid attestations to our pool.
|
|
1045
1063
|
if (!collectedSender) {
|
|
1046
1064
|
this.log.warn(`Skipping attestation with invalid signature for slot ${slot}`);
|
|
1047
1065
|
continue;
|