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