@aztec/validator-client 5.0.0-private.20260318 → 5.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -22
- package/dest/checkpoint_builder.d.ts +10 -8
- package/dest/checkpoint_builder.d.ts.map +1 -1
- package/dest/checkpoint_builder.js +31 -28
- package/dest/config.d.ts +9 -3
- package/dest/config.d.ts.map +1 -1
- package/dest/config.js +23 -10
- package/dest/duties/validation_service.d.ts +12 -13
- package/dest/duties/validation_service.d.ts.map +1 -1
- package/dest/duties/validation_service.js +32 -38
- 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/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 +142 -0
- package/dest/proposal_handler.d.ts.map +1 -0
- package/dest/proposal_handler.js +1081 -0
- package/dest/validator.d.ts +28 -20
- package/dest/validator.d.ts.map +1 -1
- package/dest/validator.js +241 -262
- package/package.json +19 -19
- package/src/checkpoint_builder.ts +40 -33
- package/src/config.ts +31 -12
- package/src/duties/validation_service.ts +51 -47
- package/src/factory.ts +29 -5
- package/src/index.ts +1 -1
- package/src/metrics.ts +19 -1
- package/src/proposal_handler.ts +1160 -0
- package/src/validator.ts +313 -294
- package/dest/block_proposal_handler.d.ts +0 -64
- package/dest/block_proposal_handler.d.ts.map +0 -1
- package/dest/block_proposal_handler.js +0 -606
- package/src/block_proposal_handler.ts +0 -624
package/dest/validator.js
CHANGED
|
@@ -1,42 +1,65 @@
|
|
|
1
1
|
import { getBlobsPerL1Block } from '@aztec/blob-lib';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { TimeoutError } from '@aztec/foundation/error';
|
|
2
|
+
import { CheckpointNumber } from '@aztec/foundation/branded-types';
|
|
3
|
+
import { FifoSet } from '@aztec/foundation/fifo-set';
|
|
5
4
|
import { createLogger } from '@aztec/foundation/log';
|
|
6
|
-
import { retryUntil } from '@aztec/foundation/retry';
|
|
7
5
|
import { RunningPromise } from '@aztec/foundation/running-promise';
|
|
8
6
|
import { sleep } from '@aztec/foundation/sleep';
|
|
9
7
|
import { DateProvider } from '@aztec/foundation/timer';
|
|
10
8
|
import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
|
|
11
|
-
import { OffenseType, WANT_TO_SLASH_EVENT } from '@aztec/slasher';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { accumulateCheckpointOutHashes } from '@aztec/stdlib/messaging';
|
|
9
|
+
import { OffenseType, WANT_TO_CLEAR_SLASH_EVENT, WANT_TO_SLASH_EVENT, getOffenseTypeName } from '@aztec/slasher';
|
|
10
|
+
import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
|
|
11
|
+
import { ConsensusTimetable } from '@aztec/stdlib/timetable';
|
|
15
12
|
import { AttestationTimeoutError } from '@aztec/stdlib/validators';
|
|
16
13
|
import { getTelemetryClient } from '@aztec/telemetry-client';
|
|
17
|
-
import { createHASigner, createLocalSignerWithProtection } from '@aztec/validator-ha-signer/factory';
|
|
14
|
+
import { createHASigner, createLocalSignerWithProtection, createSignerFromSharedDb } from '@aztec/validator-ha-signer/factory';
|
|
18
15
|
import { DutyType } from '@aztec/validator-ha-signer/types';
|
|
19
16
|
import { EventEmitter } from 'events';
|
|
20
|
-
import {
|
|
17
|
+
import { DEFAULT_MAX_GOSSIP_CLOCK_DISPARITY_MS } from './config.js';
|
|
21
18
|
import { ValidationService } from './duties/validation_service.js';
|
|
22
19
|
import { HAKeyStore } from './key_store/ha_key_store.js';
|
|
23
20
|
import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
|
|
24
21
|
import { ValidatorMetrics } from './metrics.js';
|
|
22
|
+
import { ProposalHandler } from './proposal_handler.js';
|
|
25
23
|
// We maintain a set of proposers who have proposed invalid blocks.
|
|
26
24
|
// Just cap the set to avoid unbounded growth.
|
|
27
25
|
const MAX_PROPOSERS_OF_INVALID_BLOCKS = 1000;
|
|
26
|
+
const MAX_TRACKED_INVALID_PROPOSAL_SLOTS = 1000;
|
|
27
|
+
const MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS = 1000;
|
|
28
|
+
const MAX_TRACKED_BAD_ATTESTATIONS = 10_000;
|
|
28
29
|
// What errors from the block proposal handler result in slashing
|
|
29
30
|
const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
30
31
|
'state_mismatch',
|
|
31
|
-
'failed_txs'
|
|
32
|
+
'failed_txs',
|
|
33
|
+
'global_variables_mismatch',
|
|
34
|
+
'invalid_proposal',
|
|
35
|
+
'parent_block_wrong_slot',
|
|
36
|
+
'in_hash_mismatch'
|
|
32
37
|
];
|
|
38
|
+
const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT = {
|
|
39
|
+
// enabled
|
|
40
|
+
['invalid_fee_asset_price_modifier']: true,
|
|
41
|
+
['checkpoint_header_mismatch']: true,
|
|
42
|
+
// These late mismatches should normally be caught by earlier checks, but if reached after validating the local
|
|
43
|
+
// checkpoint inputs, the proposer-signed payload disagrees with deterministic recomputation.
|
|
44
|
+
['archive_mismatch']: true,
|
|
45
|
+
['out_hash_mismatch']: true,
|
|
46
|
+
['no_blocks_for_slot']: true,
|
|
47
|
+
['too_many_blocks_in_checkpoint']: true,
|
|
48
|
+
['checkpoint_validation_failed']: true,
|
|
49
|
+
['last_block_archive_mismatch']: true,
|
|
50
|
+
// disabled
|
|
51
|
+
['invalid_signature']: false,
|
|
52
|
+
['last_block_not_found']: false,
|
|
53
|
+
['block_fetch_error']: false,
|
|
54
|
+
['checkpoint_already_published']: false
|
|
55
|
+
};
|
|
33
56
|
/**
|
|
34
57
|
* Validator Client
|
|
35
58
|
*/ export class ValidatorClient extends EventEmitter {
|
|
36
59
|
keyStore;
|
|
37
60
|
epochCache;
|
|
38
61
|
p2pClient;
|
|
39
|
-
|
|
62
|
+
proposalHandler;
|
|
40
63
|
blockSource;
|
|
41
64
|
checkpointsBuilder;
|
|
42
65
|
worldState;
|
|
@@ -57,14 +80,19 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
57
80
|
epochCacheUpdateLoop;
|
|
58
81
|
/** Tracks the last epoch in which each attester successfully submitted at least one attestation. */ lastAttestedEpochByAttester;
|
|
59
82
|
proposersOfInvalidBlocks;
|
|
83
|
+
slotsWithInvalidProposals;
|
|
84
|
+
invalidCheckpointProposalOffenseKeys;
|
|
85
|
+
badAttestationOffenseKeys;
|
|
86
|
+
slotsWithProposalEquivocation;
|
|
60
87
|
/** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */ lastAttestedProposal;
|
|
61
|
-
constructor(keyStore, epochCache, p2pClient,
|
|
62
|
-
super(), this.keyStore = keyStore, this.epochCache = epochCache, this.p2pClient = p2pClient, this.
|
|
88
|
+
constructor(keyStore, epochCache, p2pClient, proposalHandler, blockSource, checkpointsBuilder, worldState, l1ToL2MessageSource, config, blobClient, slashingProtectionSigner, dateProvider = new DateProvider(), telemetry = getTelemetryClient(), log = createLogger('validator')){
|
|
89
|
+
super(), this.keyStore = keyStore, this.epochCache = epochCache, this.p2pClient = p2pClient, this.proposalHandler = proposalHandler, this.blockSource = blockSource, this.checkpointsBuilder = checkpointsBuilder, this.worldState = worldState, this.l1ToL2MessageSource = l1ToL2MessageSource, this.config = config, this.blobClient = blobClient, this.slashingProtectionSigner = slashingProtectionSigner, this.dateProvider = dateProvider, this.hasRegisteredHandlers = false, this.lastAttestedEpochByAttester = new Map(), this.proposersOfInvalidBlocks = FifoSet.withLimit(MAX_PROPOSERS_OF_INVALID_BLOCKS), this.slotsWithInvalidProposals = FifoSet.withLimit(MAX_TRACKED_INVALID_PROPOSAL_SLOTS), this.invalidCheckpointProposalOffenseKeys = FifoSet.withLimit(MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS), this.badAttestationOffenseKeys = FifoSet.withLimit(MAX_TRACKED_BAD_ATTESTATIONS), this.slotsWithProposalEquivocation = FifoSet.withLimit(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
|
|
63
90
|
// Create child logger with fisherman prefix if in fisherman mode
|
|
64
91
|
this.log = config.fishermanMode ? log.createChild('[FISHERMAN]') : log;
|
|
65
92
|
this.tracer = telemetry.getTracer('Validator');
|
|
66
93
|
this.metrics = new ValidatorMetrics(telemetry);
|
|
67
|
-
this.validationService = new ValidationService(keyStore, this.log.createChild('validation-service'));
|
|
94
|
+
this.validationService = new ValidationService(keyStore, this.getSignatureContext(), this.log.createChild('validation-service'));
|
|
95
|
+
this.proposalHandler.setCheckpointProposalValidationFailureCallback((proposal, result, proposalInfo)=>this.handleInvalidCheckpointProposal(proposal, result, proposalInfo));
|
|
68
96
|
// Refresh epoch cache every second to trigger alert if participation in committee changes
|
|
69
97
|
this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), this.log, 1000);
|
|
70
98
|
const myAddresses = this.getValidatorAddresses();
|
|
@@ -114,16 +142,33 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
114
142
|
this.log.error(`Error updating epoch committee`, err);
|
|
115
143
|
}
|
|
116
144
|
}
|
|
117
|
-
static async new(config, checkpointsBuilder, worldState, epochCache, p2pClient, blockSource, l1ToL2MessageSource, txProvider, keyStoreManager, blobClient, dateProvider = new DateProvider(), telemetry = getTelemetryClient()) {
|
|
145
|
+
static async new(config, checkpointsBuilder, worldState, epochCache, p2pClient, blockSource, l1ToL2MessageSource, txProvider, keyStoreManager, blobClient, reexecutionTracker, dateProvider = new DateProvider(), telemetry = getTelemetryClient(), slashingProtectionDb) {
|
|
118
146
|
const metrics = new ValidatorMetrics(telemetry);
|
|
119
|
-
const
|
|
147
|
+
const consensusTimetable = new ConsensusTimetable({
|
|
148
|
+
l1Constants: epochCache.getL1Constants(),
|
|
149
|
+
blockDuration: config.blockDurationMs / 1000
|
|
150
|
+
});
|
|
151
|
+
const blockProposalValidator = new BlockProposalValidator(epochCache, consensusTimetable, {
|
|
120
152
|
txsPermitted: !config.disableTransactions,
|
|
121
|
-
maxTxsPerBlock: config.validateMaxTxsPerBlock
|
|
153
|
+
maxTxsPerBlock: config.validateMaxTxsPerBlock,
|
|
154
|
+
maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint,
|
|
155
|
+
skipSlotValidation: config.skipProposalSlotValidation,
|
|
156
|
+
signatureContext: {
|
|
157
|
+
chainId: config.l1ChainId,
|
|
158
|
+
rollupAddress: config.rollupAddress
|
|
159
|
+
},
|
|
160
|
+
clockDisparityMs: config.maxGossipClockDisparityMs ?? DEFAULT_MAX_GOSSIP_CLOCK_DISPARITY_MS
|
|
122
161
|
});
|
|
123
|
-
const
|
|
162
|
+
const proposalHandler = new ProposalHandler(checkpointsBuilder, worldState, blockSource, l1ToL2MessageSource, txProvider, blockProposalValidator, epochCache, consensusTimetable, config, blobClient, reexecutionTracker, metrics, dateProvider, telemetry, undefined);
|
|
124
163
|
const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
|
|
125
164
|
let slashingProtectionSigner;
|
|
126
|
-
if (
|
|
165
|
+
if (slashingProtectionDb) {
|
|
166
|
+
// Shared database mode: use a pre-existing database (e.g. for testing HA setups).
|
|
167
|
+
({ signer: slashingProtectionSigner } = createSignerFromSharedDb(slashingProtectionDb, config, {
|
|
168
|
+
telemetryClient: telemetry,
|
|
169
|
+
dateProvider
|
|
170
|
+
}));
|
|
171
|
+
} else if (config.haSigningEnabled) {
|
|
127
172
|
// Multi-node HA mode: use PostgreSQL-backed distributed locking.
|
|
128
173
|
// If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
|
|
129
174
|
const haConfig = {
|
|
@@ -143,18 +188,24 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
143
188
|
}));
|
|
144
189
|
}
|
|
145
190
|
const validatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
|
|
146
|
-
const validator = new ValidatorClient(validatorKeyStore, epochCache, p2pClient,
|
|
191
|
+
const validator = new ValidatorClient(validatorKeyStore, epochCache, p2pClient, proposalHandler, blockSource, checkpointsBuilder, worldState, l1ToL2MessageSource, config, blobClient, slashingProtectionSigner, dateProvider, telemetry);
|
|
147
192
|
return validator;
|
|
148
193
|
}
|
|
149
194
|
getValidatorAddresses() {
|
|
150
195
|
return this.keyStore.getAddresses().filter((addr)=>!this.config.disabledValidators.some((disabled)=>disabled.equals(addr)));
|
|
151
196
|
}
|
|
152
|
-
|
|
153
|
-
return this.
|
|
197
|
+
getProposalHandler() {
|
|
198
|
+
return this.proposalHandler;
|
|
154
199
|
}
|
|
155
200
|
signWithAddress(addr, msg, context) {
|
|
156
201
|
return this.keyStore.signTypedDataWithAddress(addr, msg, context);
|
|
157
202
|
}
|
|
203
|
+
getSignatureContext() {
|
|
204
|
+
return {
|
|
205
|
+
chainId: this.config.l1ChainId,
|
|
206
|
+
rollupAddress: this.config.rollupAddress
|
|
207
|
+
};
|
|
208
|
+
}
|
|
158
209
|
getCoinbaseForAttestor(attestor) {
|
|
159
210
|
return this.keyStore.getCoinbaseAddress(attestor);
|
|
160
211
|
}
|
|
@@ -164,16 +215,23 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
164
215
|
getConfig() {
|
|
165
216
|
return this.config;
|
|
166
217
|
}
|
|
218
|
+
hasProposalEquivocation(slotNumber) {
|
|
219
|
+
return this.slotsWithProposalEquivocation.has(slotNumber);
|
|
220
|
+
}
|
|
221
|
+
hasInvalidProposals(slotNumber) {
|
|
222
|
+
return this.slotsWithInvalidProposals.has(slotNumber);
|
|
223
|
+
}
|
|
167
224
|
updateConfig(config) {
|
|
168
225
|
this.config = {
|
|
169
226
|
...this.config,
|
|
170
227
|
...config
|
|
171
228
|
};
|
|
229
|
+
this.proposalHandler.updateConfig(config);
|
|
172
230
|
}
|
|
173
231
|
reloadKeystore(newManager) {
|
|
174
232
|
const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
|
|
175
233
|
this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
|
|
176
|
-
this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service'));
|
|
234
|
+
this.validationService = new ValidationService(this.keyStore, this.getSignatureContext(), this.log.createChild('validation-service'));
|
|
177
235
|
}
|
|
178
236
|
async start() {
|
|
179
237
|
if (this.epochCacheUpdateLoop.isRunning()) {
|
|
@@ -206,7 +264,7 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
206
264
|
// The checkpoint is received as CheckpointProposalCore since the lastBlock is extracted
|
|
207
265
|
// and processed separately via the block handler above.
|
|
208
266
|
const checkpointHandler = (checkpoint, proposalSender)=>this.attestToCheckpointProposal(checkpoint, proposalSender);
|
|
209
|
-
this.p2pClient.
|
|
267
|
+
this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler);
|
|
210
268
|
// Duplicate proposal handler - triggers slashing for equivocation
|
|
211
269
|
this.p2pClient.registerDuplicateProposalCallback((info)=>{
|
|
212
270
|
this.handleDuplicateProposal(info);
|
|
@@ -215,6 +273,9 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
215
273
|
this.p2pClient.registerDuplicateAttestationCallback((info)=>{
|
|
216
274
|
this.handleDuplicateAttestation(info);
|
|
217
275
|
});
|
|
276
|
+
this.p2pClient.registerCheckpointAttestationCallback((attestation)=>{
|
|
277
|
+
this.handleCheckpointAttestation(attestation);
|
|
278
|
+
});
|
|
218
279
|
const myAddresses = this.getValidatorAddresses();
|
|
219
280
|
this.p2pClient.registerThisValidatorAddresses(myAddresses);
|
|
220
281
|
await this.p2pClient.addReqRespSubProtocol(ReqRespSubProtocol.AUTH, this.handleAuthRequest.bind(this));
|
|
@@ -235,13 +296,12 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
235
296
|
this.log.warn(`Received block proposal with invalid signature for slot ${slotNumber}`);
|
|
236
297
|
return false;
|
|
237
298
|
}
|
|
238
|
-
//
|
|
299
|
+
// Log self-proposals from HA peers (same validator key on different nodes)
|
|
239
300
|
if (this.getValidatorAddresses().some((addr)=>addr.equals(proposer))) {
|
|
240
|
-
this.log.
|
|
301
|
+
this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
|
|
241
302
|
proposer: proposer.toString(),
|
|
242
303
|
slotNumber
|
|
243
304
|
});
|
|
244
|
-
return false;
|
|
245
305
|
}
|
|
246
306
|
// Check if we're in the committee (for metrics purposes)
|
|
247
307
|
const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
|
|
@@ -255,11 +315,8 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
255
315
|
txHashes: proposal.txHashes.map((t)=>t.toString()),
|
|
256
316
|
fishermanMode: this.config.fishermanMode || false
|
|
257
317
|
});
|
|
258
|
-
// Reexecute
|
|
259
|
-
|
|
260
|
-
const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } = this.config;
|
|
261
|
-
const shouldReexecute = fishermanMode || slashBroadcastedInvalidBlockPenalty > 0n && validatorReexecute || partOfCommittee && validatorReexecute || alwaysReexecuteBlockProposals || this.blobClient.canUpload();
|
|
262
|
-
const validationResult = await this.blockProposalHandler.handleBlockProposal(proposal, proposalSender, !!shouldReexecute && !escapeHatchOpen);
|
|
318
|
+
// Reexecute outside the escape hatch so slashing observers can detect invalid proposals even when penalties are 0.
|
|
319
|
+
const validationResult = await this.proposalHandler.handleBlockProposal(proposal, proposalSender, !escapeHatchOpen);
|
|
263
320
|
if (!validationResult.isValid) {
|
|
264
321
|
const reason = validationResult.reason || 'unknown';
|
|
265
322
|
this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
|
|
@@ -277,10 +334,14 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
277
334
|
// Node issues so we can't validate
|
|
278
335
|
this.metrics.incFailedAttestationsNodeIssue(1, reason, partOfCommittee);
|
|
279
336
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
337
|
+
if (!escapeHatchOpen && validationResult.reason && SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason)) {
|
|
338
|
+
this.log.info(`Detected invalid block proposal offense`, {
|
|
339
|
+
...proposalInfo,
|
|
340
|
+
amount: this.config.slashBroadcastedInvalidBlockPenalty,
|
|
341
|
+
offenseType: getOffenseTypeName(OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL)
|
|
342
|
+
});
|
|
283
343
|
this.slashInvalidBlock(proposal);
|
|
344
|
+
this.markInvalidProposalSlot(proposal.slotNumber);
|
|
284
345
|
}
|
|
285
346
|
return false;
|
|
286
347
|
}
|
|
@@ -302,56 +363,50 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
302
363
|
* the lastBlock is extracted and processed separately via the block handler.
|
|
303
364
|
* @returns Checkpoint attestations if valid, undefined otherwise
|
|
304
365
|
*/ async attestToCheckpointProposal(proposal, _proposalSender) {
|
|
305
|
-
const
|
|
366
|
+
const proposalSlotNumber = proposal.slotNumber;
|
|
306
367
|
const proposer = proposal.getSender();
|
|
307
368
|
// If escape hatch is open for this slot's epoch, do not attest.
|
|
308
|
-
if (await this.epochCache.isEscapeHatchOpenAtSlot(
|
|
309
|
-
this.log.warn(`Escape hatch open for slot ${
|
|
369
|
+
if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
|
|
370
|
+
this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
|
|
310
371
|
return undefined;
|
|
311
372
|
}
|
|
312
|
-
//
|
|
313
|
-
if (!
|
|
314
|
-
this.log.warn(`Received checkpoint proposal with invalid signature for slot ${slotNumber}`);
|
|
373
|
+
// Early-out for equivocation: refuses if we've already attested to a higher slot.
|
|
374
|
+
if (!this.shouldAttestToSlot(proposalSlotNumber)) {
|
|
315
375
|
return undefined;
|
|
316
376
|
}
|
|
317
377
|
// Ignore proposals from ourselves (may happen in HA setups)
|
|
318
|
-
if (this.getValidatorAddresses().some((addr)=>addr.equals(proposer))) {
|
|
319
|
-
this.log.debug(`
|
|
378
|
+
if (proposer && this.getValidatorAddresses().some((addr)=>addr.equals(proposer))) {
|
|
379
|
+
this.log.debug(`Not attesting to block proposal from self for slot ${proposalSlotNumber}`, {
|
|
320
380
|
proposer: proposer.toString(),
|
|
321
|
-
|
|
381
|
+
proposalSlotNumber
|
|
322
382
|
});
|
|
323
383
|
return undefined;
|
|
324
384
|
}
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
this.log.warn(`Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${slotNumber}`);
|
|
328
|
-
return undefined;
|
|
329
|
-
}
|
|
330
|
-
// Check that I have any address in current committee before attesting
|
|
331
|
-
const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
|
|
385
|
+
// Check that I have any address in the committee where this checkpoint will land before attesting
|
|
386
|
+
const inCommittee = await this.epochCache.filterInCommittee(proposalSlotNumber, this.getValidatorAddresses());
|
|
332
387
|
const partOfCommittee = inCommittee.length > 0;
|
|
333
388
|
const proposalInfo = {
|
|
334
|
-
|
|
389
|
+
proposalSlotNumber,
|
|
335
390
|
archive: proposal.archive.toString(),
|
|
336
|
-
proposer: proposer
|
|
391
|
+
proposer: proposer?.toString()
|
|
337
392
|
};
|
|
338
|
-
this.log.info(`Received checkpoint proposal for slot ${
|
|
393
|
+
this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
|
|
339
394
|
...proposalInfo,
|
|
340
395
|
fishermanMode: this.config.fishermanMode || false
|
|
341
396
|
});
|
|
342
|
-
// Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
|
|
397
|
+
// Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set).
|
|
398
|
+
// Uses the cached result from the all-nodes callback if available (avoids double validation).
|
|
399
|
+
let checkpointNumber;
|
|
343
400
|
if (this.config.skipCheckpointProposalValidation) {
|
|
344
|
-
this.log.warn(`Skipping checkpoint proposal validation for slot ${
|
|
401
|
+
this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
|
|
402
|
+
checkpointNumber = CheckpointNumber(0);
|
|
345
403
|
} else {
|
|
346
|
-
const validationResult = await this.
|
|
404
|
+
const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
|
|
347
405
|
if (!validationResult.isValid) {
|
|
348
406
|
this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
349
407
|
return undefined;
|
|
350
408
|
}
|
|
351
|
-
|
|
352
|
-
// Upload blobs to filestore if we can (fire and forget)
|
|
353
|
-
if (this.blobClient.canUpload()) {
|
|
354
|
-
void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
|
|
409
|
+
checkpointNumber = validationResult.checkpointNumber;
|
|
355
410
|
}
|
|
356
411
|
// Check that I have any address in current committee before attesting
|
|
357
412
|
// In fisherman mode, we still create attestations for validation even if not in committee
|
|
@@ -360,14 +415,14 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
360
415
|
return undefined;
|
|
361
416
|
}
|
|
362
417
|
// Provided all of the above checks pass, we can attest to the proposal
|
|
363
|
-
this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${
|
|
418
|
+
this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${proposalSlotNumber}`, {
|
|
364
419
|
...proposalInfo,
|
|
365
420
|
inCommittee: partOfCommittee,
|
|
366
421
|
fishermanMode: this.config.fishermanMode || false
|
|
367
422
|
});
|
|
368
423
|
this.metrics.incSuccessfulAttestations(inCommittee.length);
|
|
369
424
|
// Track epoch participation per attester: count each (attester, epoch) pair at most once
|
|
370
|
-
const proposalEpoch = getEpochAtSlot(
|
|
425
|
+
const proposalEpoch = getEpochAtSlot(proposalSlotNumber, this.epochCache.getL1Constants());
|
|
371
426
|
for (const attester of inCommittee){
|
|
372
427
|
const key = attester.toString();
|
|
373
428
|
const lastEpoch = this.lastAttestedEpochByAttester.get(key);
|
|
@@ -392,13 +447,13 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
392
447
|
}
|
|
393
448
|
if (this.config.fishermanMode) {
|
|
394
449
|
// bail out early and don't save attestations to the pool in fisherman mode
|
|
395
|
-
this.log.info(`Creating checkpoint attestations for slot ${
|
|
450
|
+
this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
|
|
396
451
|
...proposalInfo,
|
|
397
452
|
attestors: attestors.map((a)=>a.toString())
|
|
398
453
|
});
|
|
399
454
|
return undefined;
|
|
400
455
|
}
|
|
401
|
-
return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
|
|
456
|
+
return await this.createCheckpointAttestationsFromProposal(proposal, attestors, checkpointNumber);
|
|
402
457
|
}
|
|
403
458
|
/**
|
|
404
459
|
* Checks if we should attest to a slot based on equivocation prevention rules.
|
|
@@ -415,180 +470,24 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
415
470
|
}
|
|
416
471
|
return true;
|
|
417
472
|
}
|
|
418
|
-
async createCheckpointAttestationsFromProposal(proposal, attestors = []) {
|
|
473
|
+
async createCheckpointAttestationsFromProposal(proposal, attestors = [], checkpointNumber) {
|
|
419
474
|
// Equivocation check: must happen right before signing to minimize the race window
|
|
420
475
|
if (!this.shouldAttestToSlot(proposal.slotNumber)) {
|
|
421
476
|
return undefined;
|
|
422
477
|
}
|
|
423
|
-
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
|
|
478
|
+
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors, checkpointNumber);
|
|
424
479
|
// Track the proposal we attested to (to prevent equivocation)
|
|
425
480
|
this.lastAttestedProposal = proposal;
|
|
426
481
|
await this.p2pClient.addOwnCheckpointAttestations(attestations);
|
|
427
482
|
return attestations;
|
|
428
483
|
}
|
|
429
484
|
/**
|
|
430
|
-
* Validates a checkpoint proposal by building the full checkpoint and comparing it with the proposal.
|
|
431
|
-
* @returns Validation result with isValid flag and reason if invalid.
|
|
432
|
-
*/ async validateCheckpointProposal(proposal, proposalInfo) {
|
|
433
|
-
const slot = proposal.slotNumber;
|
|
434
|
-
// Timeout block syncing at the start of the next slot
|
|
435
|
-
const config = this.checkpointsBuilder.getConfig();
|
|
436
|
-
const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config));
|
|
437
|
-
const timeoutSeconds = Math.max(1, nextSlotTimestampSeconds - Math.floor(this.dateProvider.now() / 1000));
|
|
438
|
-
// Wait for last block to sync by archive
|
|
439
|
-
let lastBlockHeader;
|
|
440
|
-
try {
|
|
441
|
-
lastBlockHeader = await retryUntil(async ()=>{
|
|
442
|
-
await this.blockSource.syncImmediate();
|
|
443
|
-
return this.blockSource.getBlockHeaderByArchive(proposal.archive);
|
|
444
|
-
}, `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`, timeoutSeconds, 0.5);
|
|
445
|
-
} catch (err) {
|
|
446
|
-
if (err instanceof TimeoutError) {
|
|
447
|
-
this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
|
|
448
|
-
return {
|
|
449
|
-
isValid: false,
|
|
450
|
-
reason: 'last_block_not_found'
|
|
451
|
-
};
|
|
452
|
-
}
|
|
453
|
-
this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
|
|
454
|
-
return {
|
|
455
|
-
isValid: false,
|
|
456
|
-
reason: 'block_fetch_error'
|
|
457
|
-
};
|
|
458
|
-
}
|
|
459
|
-
if (!lastBlockHeader) {
|
|
460
|
-
this.log.warn(`Last block not found for checkpoint proposal`, proposalInfo);
|
|
461
|
-
return {
|
|
462
|
-
isValid: false,
|
|
463
|
-
reason: 'last_block_not_found'
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
// Get all full blocks for the slot and checkpoint
|
|
467
|
-
const blocks = await this.blockSource.getBlocksForSlot(slot);
|
|
468
|
-
if (blocks.length === 0) {
|
|
469
|
-
this.log.warn(`No blocks found for slot ${slot}`, proposalInfo);
|
|
470
|
-
return {
|
|
471
|
-
isValid: false,
|
|
472
|
-
reason: 'no_blocks_for_slot'
|
|
473
|
-
};
|
|
474
|
-
}
|
|
475
|
-
// Ensure the last block for this slot matches the archive in the checkpoint proposal
|
|
476
|
-
if (!blocks.at(-1)?.archive.root.equals(proposal.archive)) {
|
|
477
|
-
this.log.warn(`Last block archive mismatch for checkpoint proposal`, proposalInfo);
|
|
478
|
-
return {
|
|
479
|
-
isValid: false,
|
|
480
|
-
reason: 'last_block_archive_mismatch'
|
|
481
|
-
};
|
|
482
|
-
}
|
|
483
|
-
this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
|
|
484
|
-
...proposalInfo,
|
|
485
|
-
blockNumbers: blocks.map((b)=>b.number)
|
|
486
|
-
});
|
|
487
|
-
// Get checkpoint constants from first block
|
|
488
|
-
const firstBlock = blocks[0];
|
|
489
|
-
const constants = this.extractCheckpointConstants(firstBlock);
|
|
490
|
-
const checkpointNumber = firstBlock.checkpointNumber;
|
|
491
|
-
// Get L1-to-L2 messages for this checkpoint
|
|
492
|
-
const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
|
|
493
|
-
// Collect the out hashes of all the checkpoints before this one in the same epoch
|
|
494
|
-
const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
|
|
495
|
-
const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsDataForEpoch(epoch)).filter((c)=>c.checkpointNumber < checkpointNumber).map((c)=>c.checkpointOutHash);
|
|
496
|
-
// Fork world state at the block before the first block
|
|
497
|
-
const parentBlockNumber = BlockNumber(firstBlock.number - 1);
|
|
498
|
-
const fork = await this.worldState.fork(parentBlockNumber);
|
|
499
|
-
try {
|
|
500
|
-
// Create checkpoint builder with all existing blocks
|
|
501
|
-
const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(checkpointNumber, constants, proposal.feeAssetPriceModifier, l1ToL2Messages, previousCheckpointOutHashes, fork, blocks, this.log.getBindings());
|
|
502
|
-
// Complete the checkpoint to get computed values
|
|
503
|
-
const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
|
|
504
|
-
// Compare checkpoint header with proposal
|
|
505
|
-
if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
|
|
506
|
-
this.log.warn(`Checkpoint header mismatch`, {
|
|
507
|
-
...proposalInfo,
|
|
508
|
-
computed: computedCheckpoint.header.toInspect(),
|
|
509
|
-
proposal: proposal.checkpointHeader.toInspect()
|
|
510
|
-
});
|
|
511
|
-
return {
|
|
512
|
-
isValid: false,
|
|
513
|
-
reason: 'checkpoint_header_mismatch'
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
// Compare archive root with proposal
|
|
517
|
-
if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
|
|
518
|
-
this.log.warn(`Archive root mismatch`, {
|
|
519
|
-
...proposalInfo,
|
|
520
|
-
computed: computedCheckpoint.archive.root.toString(),
|
|
521
|
-
proposal: proposal.archive.toString()
|
|
522
|
-
});
|
|
523
|
-
return {
|
|
524
|
-
isValid: false,
|
|
525
|
-
reason: 'archive_mismatch'
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
// Check that the accumulated epoch out hash matches the value in the proposal.
|
|
529
|
-
// The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
|
|
530
|
-
const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
|
|
531
|
-
const computedEpochOutHash = accumulateCheckpointOutHashes([
|
|
532
|
-
...previousCheckpointOutHashes,
|
|
533
|
-
checkpointOutHash
|
|
534
|
-
]);
|
|
535
|
-
const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
|
|
536
|
-
if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
|
|
537
|
-
this.log.warn(`Epoch out hash mismatch`, {
|
|
538
|
-
proposalEpochOutHash: proposalEpochOutHash.toString(),
|
|
539
|
-
computedEpochOutHash: computedEpochOutHash.toString(),
|
|
540
|
-
checkpointOutHash: checkpointOutHash.toString(),
|
|
541
|
-
previousCheckpointOutHashes: previousCheckpointOutHashes.map((h)=>h.toString()),
|
|
542
|
-
...proposalInfo
|
|
543
|
-
});
|
|
544
|
-
return {
|
|
545
|
-
isValid: false,
|
|
546
|
-
reason: 'out_hash_mismatch'
|
|
547
|
-
};
|
|
548
|
-
}
|
|
549
|
-
// Final round of validations on the checkpoint, just in case.
|
|
550
|
-
try {
|
|
551
|
-
validateCheckpoint(computedCheckpoint, {
|
|
552
|
-
rollupManaLimit: this.checkpointsBuilder.getConfig().rollupManaLimit,
|
|
553
|
-
maxDABlockGas: this.config.validateMaxDABlockGas,
|
|
554
|
-
maxL2BlockGas: this.config.validateMaxL2BlockGas,
|
|
555
|
-
maxTxsPerBlock: this.config.validateMaxTxsPerBlock,
|
|
556
|
-
maxTxsPerCheckpoint: this.config.validateMaxTxsPerCheckpoint
|
|
557
|
-
});
|
|
558
|
-
} catch (err) {
|
|
559
|
-
this.log.warn(`Checkpoint validation failed: ${err}`, proposalInfo);
|
|
560
|
-
return {
|
|
561
|
-
isValid: false,
|
|
562
|
-
reason: 'checkpoint_validation_failed'
|
|
563
|
-
};
|
|
564
|
-
}
|
|
565
|
-
this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
|
|
566
|
-
return {
|
|
567
|
-
isValid: true
|
|
568
|
-
};
|
|
569
|
-
} finally{
|
|
570
|
-
await fork.close();
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
/**
|
|
574
|
-
* Extract checkpoint global variables from a block.
|
|
575
|
-
*/ extractCheckpointConstants(block) {
|
|
576
|
-
const gv = block.header.globalVariables;
|
|
577
|
-
return {
|
|
578
|
-
chainId: gv.chainId,
|
|
579
|
-
version: gv.version,
|
|
580
|
-
slotNumber: gv.slotNumber,
|
|
581
|
-
timestamp: gv.timestamp,
|
|
582
|
-
coinbase: gv.coinbase,
|
|
583
|
-
feeRecipient: gv.feeRecipient,
|
|
584
|
-
gasFees: gv.gasFees
|
|
585
|
-
};
|
|
586
|
-
}
|
|
587
|
-
/**
|
|
588
485
|
* Uploads blobs for a checkpoint to the filestore (fire and forget).
|
|
589
486
|
*/ async uploadBlobsForCheckpoint(proposal, proposalInfo) {
|
|
590
487
|
try {
|
|
591
|
-
const lastBlockHeader = await this.blockSource.
|
|
488
|
+
const lastBlockHeader = (await this.blockSource.getBlockData({
|
|
489
|
+
archive: proposal.archive
|
|
490
|
+
}))?.header;
|
|
592
491
|
if (!lastBlockHeader) {
|
|
593
492
|
this.log.warn(`Failed to get last block header for blob upload`, proposalInfo);
|
|
594
493
|
return;
|
|
@@ -616,11 +515,6 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
616
515
|
this.log.warn(`Cannot slash proposal with invalid signature`);
|
|
617
516
|
return;
|
|
618
517
|
}
|
|
619
|
-
// Trim the set if it's too big.
|
|
620
|
-
if (this.proposersOfInvalidBlocks.size > MAX_PROPOSERS_OF_INVALID_BLOCKS) {
|
|
621
|
-
// remove oldest proposer. `values` is guaranteed to be in insertion order.
|
|
622
|
-
this.proposersOfInvalidBlocks.delete(this.proposersOfInvalidBlocks.values().next().value);
|
|
623
|
-
}
|
|
624
518
|
this.proposersOfInvalidBlocks.add(proposer.toString());
|
|
625
519
|
this.emit(WANT_TO_SLASH_EVENT, [
|
|
626
520
|
{
|
|
@@ -631,17 +525,95 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
631
525
|
}
|
|
632
526
|
]);
|
|
633
527
|
}
|
|
528
|
+
handleInvalidCheckpointProposal(proposal, result, proposalInfo) {
|
|
529
|
+
if (!SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
this.markInvalidProposalSlot(proposal.slotNumber);
|
|
533
|
+
if (this.slashInvalidCheckpointProposal(proposal)) {
|
|
534
|
+
this.log.info(`Detected invalid checkpoint proposal offense`, {
|
|
535
|
+
...proposalInfo,
|
|
536
|
+
reason: result.reason,
|
|
537
|
+
amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
|
|
538
|
+
offenseType: getOffenseTypeName(OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL)
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
slashInvalidCheckpointProposal(proposal) {
|
|
543
|
+
const proposer = proposal.getSender();
|
|
544
|
+
if (!proposer) {
|
|
545
|
+
this.log.warn(`Cannot slash checkpoint proposal with invalid signature`, {
|
|
546
|
+
slotNumber: proposal.slotNumber,
|
|
547
|
+
archive: proposal.archive.toString()
|
|
548
|
+
});
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
const offenseType = OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL;
|
|
552
|
+
const offenseKey = `${proposer.toString()}:${offenseType}:${proposal.slotNumber}`;
|
|
553
|
+
if (!this.invalidCheckpointProposalOffenseKeys.addIfAbsent(offenseKey)) {
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
this.emit(WANT_TO_SLASH_EVENT, [
|
|
557
|
+
{
|
|
558
|
+
validator: proposer,
|
|
559
|
+
amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
|
|
560
|
+
offenseType,
|
|
561
|
+
epochOrSlot: BigInt(proposal.slotNumber)
|
|
562
|
+
}
|
|
563
|
+
]);
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
markInvalidProposalSlot(slotNumber) {
|
|
567
|
+
this.slotsWithInvalidProposals.add(slotNumber);
|
|
568
|
+
}
|
|
569
|
+
handleCheckpointAttestation(attestation) {
|
|
570
|
+
const slotNumber = attestation.slotNumber;
|
|
571
|
+
if (!this.slotsWithInvalidProposals.has(slotNumber) || this.slotsWithProposalEquivocation.has(slotNumber)) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const attester = attestation.getSender();
|
|
575
|
+
if (!attester) {
|
|
576
|
+
this.log.warn(`Cannot slash checkpoint attestation with invalid signature`, {
|
|
577
|
+
slotNumber,
|
|
578
|
+
archive: attestation.archive.toString()
|
|
579
|
+
});
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
this.slashAttestedToInvalidCheckpointProposal(slotNumber, attester);
|
|
583
|
+
}
|
|
584
|
+
slashAttestedToInvalidCheckpointProposal(slotNumber, attester) {
|
|
585
|
+
const offenseKey = `${attester.toString()}:${OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL}:${slotNumber}`;
|
|
586
|
+
if (!this.badAttestationOffenseKeys.addIfAbsent(offenseKey)) {
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
this.log.info(`Detected attestation to invalid checkpoint proposal offense`, {
|
|
590
|
+
attester: attester.toString(),
|
|
591
|
+
slotNumber,
|
|
592
|
+
amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
|
|
593
|
+
offenseType: getOffenseTypeName(OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL)
|
|
594
|
+
});
|
|
595
|
+
this.emit(WANT_TO_SLASH_EVENT, [
|
|
596
|
+
{
|
|
597
|
+
validator: attester,
|
|
598
|
+
amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
|
|
599
|
+
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
|
|
600
|
+
epochOrSlot: BigInt(slotNumber)
|
|
601
|
+
}
|
|
602
|
+
]);
|
|
603
|
+
}
|
|
634
604
|
/**
|
|
635
605
|
* Handle detection of a duplicate proposal (equivocation).
|
|
636
606
|
* Emits a slash event when a proposer sends multiple proposals for the same position.
|
|
637
607
|
*/ handleDuplicateProposal(info) {
|
|
638
608
|
const { slot, proposer, type } = info;
|
|
639
|
-
this.
|
|
609
|
+
this.slotsWithProposalEquivocation.add(slot);
|
|
610
|
+
this.log.info(`Detected duplicate ${type} proposal offense from ${proposer.toString()} at slot ${slot}`, {
|
|
640
611
|
proposer: proposer.toString(),
|
|
641
612
|
slot,
|
|
642
|
-
type
|
|
613
|
+
type,
|
|
614
|
+
amount: this.config.slashDuplicateProposalPenalty,
|
|
615
|
+
offenseType: getOffenseTypeName(OffenseType.DUPLICATE_PROPOSAL)
|
|
643
616
|
});
|
|
644
|
-
// Emit slash event
|
|
645
617
|
this.emit(WANT_TO_SLASH_EVENT, [
|
|
646
618
|
{
|
|
647
619
|
validator: proposer,
|
|
@@ -650,15 +622,23 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
650
622
|
epochOrSlot: BigInt(slot)
|
|
651
623
|
}
|
|
652
624
|
]);
|
|
625
|
+
this.emit(WANT_TO_CLEAR_SLASH_EVENT, [
|
|
626
|
+
{
|
|
627
|
+
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
|
|
628
|
+
epochOrSlot: BigInt(slot)
|
|
629
|
+
}
|
|
630
|
+
]);
|
|
653
631
|
}
|
|
654
632
|
/**
|
|
655
633
|
* Handle detection of a duplicate attestation (equivocation).
|
|
656
634
|
* Emits a slash event when an attester signs attestations for different proposals at the same slot.
|
|
657
635
|
*/ handleDuplicateAttestation(info) {
|
|
658
636
|
const { slot, attester } = info;
|
|
659
|
-
this.log.
|
|
637
|
+
this.log.info(`Detected duplicate attestation offense from ${attester.toString()} at slot ${slot}`, {
|
|
660
638
|
attester: attester.toString(),
|
|
661
|
-
slot
|
|
639
|
+
slot,
|
|
640
|
+
amount: this.config.slashDuplicateAttestationPenalty,
|
|
641
|
+
offenseType: getOffenseTypeName(OffenseType.DUPLICATE_ATTESTATION)
|
|
662
642
|
});
|
|
663
643
|
this.emit(WANT_TO_SLASH_EVENT, [
|
|
664
644
|
{
|
|
@@ -669,7 +649,7 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
669
649
|
}
|
|
670
650
|
]);
|
|
671
651
|
}
|
|
672
|
-
async createBlockProposal(blockHeader, indexWithinCheckpoint, inHash, archive, txs, proposerAddress, options = {}) {
|
|
652
|
+
async createBlockProposal(blockHeader, checkpointNumber, indexWithinCheckpoint, inHash, archive, txs, proposerAddress, options = {}) {
|
|
673
653
|
// Validate that we're not creating a proposal for an older or equal position
|
|
674
654
|
if (this.lastProposedBlock) {
|
|
675
655
|
const lastSlot = this.lastProposedBlock.slotNumber;
|
|
@@ -680,14 +660,14 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
680
660
|
}
|
|
681
661
|
}
|
|
682
662
|
this.log.info(`Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`);
|
|
683
|
-
const newProposal = await this.validationService.createBlockProposal(blockHeader, indexWithinCheckpoint, inHash, archive, txs, proposerAddress, {
|
|
663
|
+
const newProposal = await this.validationService.createBlockProposal(blockHeader, checkpointNumber, indexWithinCheckpoint, inHash, archive, txs, proposerAddress, {
|
|
684
664
|
...options,
|
|
685
|
-
broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal
|
|
665
|
+
broadcastInvalidBlockProposal: options.broadcastInvalidBlockProposal || this.config.broadcastInvalidBlockProposal
|
|
686
666
|
});
|
|
687
667
|
this.lastProposedBlock = newProposal;
|
|
688
668
|
return newProposal;
|
|
689
669
|
}
|
|
690
|
-
async createCheckpointProposal(checkpointHeader, archive, feeAssetPriceModifier,
|
|
670
|
+
async createCheckpointProposal(checkpointHeader, archive, checkpointNumber, feeAssetPriceModifier, lastBlockProposal, proposerAddress, options = {}) {
|
|
691
671
|
// Validate that we're not creating a proposal for an older or equal slot
|
|
692
672
|
if (this.lastProposedCheckpoint) {
|
|
693
673
|
const lastSlot = this.lastProposedCheckpoint.slotNumber;
|
|
@@ -697,23 +677,30 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
697
677
|
}
|
|
698
678
|
}
|
|
699
679
|
this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
|
|
700
|
-
const newProposal = await this.validationService.createCheckpointProposal(checkpointHeader, archive, feeAssetPriceModifier,
|
|
680
|
+
const newProposal = await this.validationService.createCheckpointProposal(checkpointHeader, archive, checkpointNumber, feeAssetPriceModifier, lastBlockProposal, proposerAddress, options);
|
|
701
681
|
this.lastProposedCheckpoint = newProposal;
|
|
682
|
+
// Self-record this slot's outcome on the re-execution tracker. Proposers don't run their
|
|
683
|
+
// own proposals through `handleCheckpointProposal`, so without this call the proposer's
|
|
684
|
+
// sentinel would see no outcome for slots it proposed and would mis-attribute itself as
|
|
685
|
+
// inactive. We pass the locally-computed `archive` (not `newProposal.archive`, which may
|
|
686
|
+
// be intentionally corrupted under test-only flags); from the proposer's local-view
|
|
687
|
+
// perspective the work it just completed is valid by definition.
|
|
688
|
+
this.proposalHandler.recordOwnCheckpointProposalAsValid(checkpointHeader.slotNumber, archive, checkpointNumber);
|
|
702
689
|
return newProposal;
|
|
703
690
|
}
|
|
704
691
|
async broadcastBlockProposal(proposal) {
|
|
705
692
|
await this.p2pClient.broadcastProposal(proposal);
|
|
706
693
|
}
|
|
707
|
-
async signAttestationsAndSigners(attestationsAndSigners, proposer, slot,
|
|
708
|
-
return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer, slot,
|
|
694
|
+
async signAttestationsAndSigners(attestationsAndSigners, proposer, slot, checkpointNumber) {
|
|
695
|
+
return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer, slot, checkpointNumber);
|
|
709
696
|
}
|
|
710
|
-
async collectOwnAttestations(proposal) {
|
|
697
|
+
async collectOwnAttestations(proposal, checkpointNumber) {
|
|
711
698
|
const slot = proposal.slotNumber;
|
|
712
699
|
const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
|
|
713
700
|
this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, {
|
|
714
701
|
inCommittee
|
|
715
702
|
});
|
|
716
|
-
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
|
|
703
|
+
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee, checkpointNumber);
|
|
717
704
|
if (!attestations) {
|
|
718
705
|
return [];
|
|
719
706
|
}
|
|
@@ -725,7 +712,7 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
725
712
|
});
|
|
726
713
|
return attestations;
|
|
727
714
|
}
|
|
728
|
-
async collectAttestations(proposal, required, deadline) {
|
|
715
|
+
async collectAttestations(proposal, required, deadline, checkpointNumber) {
|
|
729
716
|
// Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
|
|
730
717
|
const slot = proposal.slotNumber;
|
|
731
718
|
this.log.debug(`Collecting ${required} attestations for slot ${slot} with deadline ${deadline.toISOString()}`);
|
|
@@ -733,28 +720,20 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
733
720
|
this.log.error(`Deadline ${deadline.toISOString()} for collecting ${required} attestations for slot ${slot} is in the past`);
|
|
734
721
|
throw new AttestationTimeoutError(0, required, slot);
|
|
735
722
|
}
|
|
736
|
-
await this.collectOwnAttestations(proposal);
|
|
737
|
-
const
|
|
723
|
+
await this.collectOwnAttestations(proposal, checkpointNumber);
|
|
724
|
+
const proposalPayloadHash = proposal.getPayloadHash();
|
|
738
725
|
const myAddresses = this.getValidatorAddresses();
|
|
739
726
|
let attestations = [];
|
|
740
727
|
while(true){
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
this.log.warn(`Received attestation for slot ${slot} with mismatched archive from ${attestation.getSender()?.toString()}`, {
|
|
746
|
-
attestationArchive: attestation.archive.toString(),
|
|
747
|
-
proposalArchive: proposal.archive.toString()
|
|
748
|
-
});
|
|
749
|
-
return false;
|
|
750
|
-
}
|
|
751
|
-
return true;
|
|
752
|
-
});
|
|
728
|
+
// The pool already filters by proposal payload hash; if any attestation slips through with a
|
|
729
|
+
// mismatched payload hash, drop it defensively. Equivocations are emitted as separate slash
|
|
730
|
+
// events from libp2p_service.
|
|
731
|
+
const collectedAttestations = await this.p2pClient.getCheckpointAttestationsForSlot(slot, proposalPayloadHash);
|
|
753
732
|
// Log new attestations we collected
|
|
754
733
|
const oldSenders = attestations.map((attestation)=>attestation.getSender());
|
|
755
734
|
for (const collected of collectedAttestations){
|
|
756
735
|
const collectedSender = collected.getSender();
|
|
757
|
-
// Skip attestations with invalid signatures
|
|
736
|
+
// Skip attestations with invalid signatures. Should not happen as we don't add invalid attestations to our pool.
|
|
758
737
|
if (!collectedSender) {
|
|
759
738
|
this.log.warn(`Skipping attestation with invalid signature for slot ${slot}`);
|
|
760
739
|
continue;
|