@aztec/validator-client 0.0.1-commit.96bb3f7 → 0.0.1-commit.993d240
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 +93 -24
- package/dest/checkpoint_builder.d.ts +35 -26
- package/dest/checkpoint_builder.d.ts.map +1 -1
- package/dest/checkpoint_builder.js +144 -48
- package/dest/config.d.ts +1 -1
- package/dest/config.d.ts.map +1 -1
- package/dest/config.js +44 -20
- package/dest/duties/validation_service.d.ts +26 -14
- package/dest/duties/validation_service.d.ts.map +1 -1
- package/dest/duties/validation_service.js +79 -38
- package/dest/factory.d.ts +11 -5
- 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 +99 -0
- package/dest/key_store/ha_key_store.d.ts.map +1 -0
- package/dest/key_store/ha_key_store.js +208 -0
- package/dest/key_store/index.d.ts +2 -1
- package/dest/key_store/index.d.ts.map +1 -1
- package/dest/key_store/index.js +1 -0
- package/dest/key_store/interface.d.ts +36 -6
- package/dest/key_store/interface.d.ts.map +1 -1
- package/dest/key_store/local_key_store.d.ts +10 -5
- package/dest/key_store/local_key_store.d.ts.map +1 -1
- package/dest/key_store/local_key_store.js +8 -4
- package/dest/key_store/node_keystore_adapter.d.ts +18 -5
- package/dest/key_store/node_keystore_adapter.d.ts.map +1 -1
- package/dest/key_store/node_keystore_adapter.js +18 -4
- package/dest/key_store/web3signer_key_store.d.ts +10 -5
- package/dest/key_store/web3signer_key_store.d.ts.map +1 -1
- package/dest/key_store/web3signer_key_store.js +8 -4
- 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 +135 -0
- package/dest/proposal_handler.d.ts.map +1 -0
- package/dest/proposal_handler.js +1111 -0
- package/dest/validator.d.ts +60 -25
- package/dest/validator.d.ts.map +1 -1
- package/dest/validator.js +393 -257
- package/package.json +21 -17
- package/src/checkpoint_builder.ts +211 -61
- package/src/config.ts +43 -18
- package/src/duties/validation_service.ts +119 -46
- package/src/factory.ts +21 -3
- package/src/index.ts +1 -2
- package/src/key_store/ha_key_store.ts +269 -0
- package/src/key_store/index.ts +1 -0
- package/src/key_store/interface.ts +44 -5
- package/src/key_store/local_key_store.ts +13 -4
- package/src/key_store/node_keystore_adapter.ts +27 -4
- package/src/key_store/web3signer_key_store.ts +17 -4
- package/src/metrics.ts +81 -6
- package/src/proposal_handler.ts +1207 -0
- package/src/validator.ts +529 -307
- package/dest/block_proposal_handler.d.ts +0 -63
- package/dest/block_proposal_handler.d.ts.map +0 -1
- package/dest/block_proposal_handler.js +0 -551
- 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 -53
- package/src/block_proposal_handler.ts +0 -556
- 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 -133
package/dest/validator.js
CHANGED
|
@@ -1,41 +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';
|
|
10
|
+
import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
|
|
11
11
|
import { AttestationTimeoutError } from '@aztec/stdlib/validators';
|
|
12
12
|
import { getTelemetryClient } from '@aztec/telemetry-client';
|
|
13
|
+
import { createHASigner, createLocalSignerWithProtection, createSignerFromSharedDb } from '@aztec/validator-ha-signer/factory';
|
|
14
|
+
import { DutyType } from '@aztec/validator-ha-signer/types';
|
|
13
15
|
import { EventEmitter } from 'events';
|
|
14
|
-
import { BlockProposalHandler } from './block_proposal_handler.js';
|
|
15
16
|
import { ValidationService } from './duties/validation_service.js';
|
|
17
|
+
import { HAKeyStore } from './key_store/ha_key_store.js';
|
|
16
18
|
import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
|
|
17
19
|
import { ValidatorMetrics } from './metrics.js';
|
|
20
|
+
import { ProposalHandler } from './proposal_handler.js';
|
|
18
21
|
// We maintain a set of proposers who have proposed invalid blocks.
|
|
19
22
|
// Just cap the set to avoid unbounded growth.
|
|
20
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;
|
|
21
27
|
// What errors from the block proposal handler result in slashing
|
|
22
28
|
const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
23
29
|
'state_mismatch',
|
|
24
|
-
'failed_txs'
|
|
30
|
+
'failed_txs',
|
|
31
|
+
'global_variables_mismatch',
|
|
32
|
+
'invalid_proposal',
|
|
33
|
+
'parent_block_wrong_slot',
|
|
34
|
+
'in_hash_mismatch'
|
|
25
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
|
+
};
|
|
26
54
|
/**
|
|
27
55
|
* Validator Client
|
|
28
56
|
*/ export class ValidatorClient extends EventEmitter {
|
|
29
57
|
keyStore;
|
|
30
58
|
epochCache;
|
|
31
59
|
p2pClient;
|
|
32
|
-
|
|
60
|
+
proposalHandler;
|
|
33
61
|
blockSource;
|
|
34
62
|
checkpointsBuilder;
|
|
35
63
|
worldState;
|
|
36
64
|
l1ToL2MessageSource;
|
|
37
65
|
config;
|
|
38
66
|
blobClient;
|
|
67
|
+
slashingProtectionSigner;
|
|
39
68
|
dateProvider;
|
|
40
69
|
tracer;
|
|
41
70
|
validationService;
|
|
@@ -43,22 +72,25 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
43
72
|
log;
|
|
44
73
|
// Whether it has already registered handlers on the p2p client
|
|
45
74
|
hasRegisteredHandlers;
|
|
46
|
-
|
|
47
|
-
|
|
75
|
+
/** Tracks the last block proposal we created, to detect duplicate proposal attempts. */ lastProposedBlock;
|
|
76
|
+
/** Tracks the last checkpoint proposal we created. */ lastProposedCheckpoint;
|
|
48
77
|
lastEpochForCommitteeUpdateLoop;
|
|
49
78
|
epochCacheUpdateLoop;
|
|
79
|
+
/** Tracks the last epoch in which each attester successfully submitted at least one attestation. */ lastAttestedEpochByAttester;
|
|
50
80
|
proposersOfInvalidBlocks;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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);
|
|
57
88
|
// Create child logger with fisherman prefix if in fisherman mode
|
|
58
89
|
this.log = config.fishermanMode ? log.createChild('[FISHERMAN]') : log;
|
|
59
90
|
this.tracer = telemetry.getTracer('Validator');
|
|
60
91
|
this.metrics = new ValidatorMetrics(telemetry);
|
|
61
|
-
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));
|
|
62
94
|
// Refresh epoch cache every second to trigger alert if participation in committee changes
|
|
63
95
|
this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), this.log, 1000);
|
|
64
96
|
const myAddresses = this.getValidatorAddresses();
|
|
@@ -92,6 +124,7 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
92
124
|
this.log.trace(`No committee found for slot`);
|
|
93
125
|
return;
|
|
94
126
|
}
|
|
127
|
+
this.metrics.setCurrentEpoch(epoch);
|
|
95
128
|
if (epoch !== this.lastEpochForCommitteeUpdateLoop) {
|
|
96
129
|
const me = this.getValidatorAddresses();
|
|
97
130
|
const committeeSet = new Set(committee.map((v)=>v.toString()));
|
|
@@ -107,23 +140,64 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
107
140
|
this.log.error(`Error updating epoch committee`, err);
|
|
108
141
|
}
|
|
109
142
|
}
|
|
110
|
-
static 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) {
|
|
111
144
|
const metrics = new ValidatorMetrics(telemetry);
|
|
112
145
|
const blockProposalValidator = new BlockProposalValidator(epochCache, {
|
|
113
|
-
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
|
+
}
|
|
114
154
|
});
|
|
115
|
-
const
|
|
116
|
-
const
|
|
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.
|
|
166
|
+
// If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
|
|
167
|
+
const haConfig = {
|
|
168
|
+
...config,
|
|
169
|
+
maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000
|
|
170
|
+
};
|
|
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
|
+
}));
|
|
182
|
+
}
|
|
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);
|
|
117
185
|
return validator;
|
|
118
186
|
}
|
|
119
187
|
getValidatorAddresses() {
|
|
120
188
|
return this.keyStore.getAddresses().filter((addr)=>!this.config.disabledValidators.some((disabled)=>disabled.equals(addr)));
|
|
121
189
|
}
|
|
122
|
-
|
|
123
|
-
return this.
|
|
190
|
+
getProposalHandler() {
|
|
191
|
+
return this.proposalHandler;
|
|
124
192
|
}
|
|
125
|
-
signWithAddress(addr, msg) {
|
|
126
|
-
return this.keyStore.signTypedDataWithAddress(addr, msg);
|
|
193
|
+
signWithAddress(addr, msg, context) {
|
|
194
|
+
return this.keyStore.signTypedDataWithAddress(addr, msg, context);
|
|
195
|
+
}
|
|
196
|
+
getSignatureContext() {
|
|
197
|
+
return {
|
|
198
|
+
chainId: this.config.l1ChainId,
|
|
199
|
+
rollupAddress: this.config.rollupAddress
|
|
200
|
+
};
|
|
127
201
|
}
|
|
128
202
|
getCoinbaseForAttestor(attestor) {
|
|
129
203
|
return this.keyStore.getCoinbaseAddress(attestor);
|
|
@@ -134,17 +208,30 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
134
208
|
getConfig() {
|
|
135
209
|
return this.config;
|
|
136
210
|
}
|
|
211
|
+
hasProposalEquivocation(slotNumber) {
|
|
212
|
+
return this.slotsWithProposalEquivocation.has(slotNumber);
|
|
213
|
+
}
|
|
214
|
+
hasInvalidProposals(slotNumber) {
|
|
215
|
+
return this.slotsWithInvalidProposals.has(slotNumber);
|
|
216
|
+
}
|
|
137
217
|
updateConfig(config) {
|
|
138
218
|
this.config = {
|
|
139
219
|
...this.config,
|
|
140
220
|
...config
|
|
141
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'));
|
|
142
228
|
}
|
|
143
229
|
async start() {
|
|
144
230
|
if (this.epochCacheUpdateLoop.isRunning()) {
|
|
145
231
|
this.log.warn(`Validator client already started`);
|
|
146
232
|
return;
|
|
147
233
|
}
|
|
234
|
+
await this.keyStore.start();
|
|
148
235
|
await this.registerHandlers();
|
|
149
236
|
const myAddresses = this.getValidatorAddresses();
|
|
150
237
|
const inCommittee = await this.epochCache.filterInCommittee('now', myAddresses);
|
|
@@ -157,6 +244,7 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
157
244
|
}
|
|
158
245
|
async stop() {
|
|
159
246
|
await this.epochCacheUpdateLoop.stop();
|
|
247
|
+
await this.keyStore.stop();
|
|
160
248
|
}
|
|
161
249
|
/** Register handlers on the p2p client */ async registerHandlers() {
|
|
162
250
|
if (!this.hasRegisteredHandlers) {
|
|
@@ -169,7 +257,18 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
169
257
|
// The checkpoint is received as CheckpointProposalCore since the lastBlock is extracted
|
|
170
258
|
// and processed separately via the block handler above.
|
|
171
259
|
const checkpointHandler = (checkpoint, proposalSender)=>this.attestToCheckpointProposal(checkpoint, proposalSender);
|
|
172
|
-
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
|
+
});
|
|
173
272
|
const myAddresses = this.getValidatorAddresses();
|
|
174
273
|
this.p2pClient.registerThisValidatorAddresses(myAddresses);
|
|
175
274
|
await this.p2pClient.addReqRespSubProtocol(ReqRespSubProtocol.AUTH, this.handleAuthRequest.bind(this));
|
|
@@ -181,12 +280,22 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
181
280
|
* @returns true if the proposal is valid, false otherwise
|
|
182
281
|
*/ async validateBlockProposal(proposal, proposalSender) {
|
|
183
282
|
const slotNumber = proposal.slotNumber;
|
|
283
|
+
// Note: During escape hatch, we still want to "validate" proposals for observability,
|
|
284
|
+
// but we intentionally reject them and disable slashing invalid block and attestation flow.
|
|
285
|
+
const escapeHatchOpen = await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber);
|
|
184
286
|
const proposer = proposal.getSender();
|
|
185
287
|
// Reject proposals with invalid signatures
|
|
186
288
|
if (!proposer) {
|
|
187
289
|
this.log.warn(`Received block proposal with invalid signature for slot ${slotNumber}`);
|
|
188
290
|
return false;
|
|
189
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
|
+
}
|
|
190
299
|
// Check if we're in the committee (for metrics purposes)
|
|
191
300
|
const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
|
|
192
301
|
const partOfCommittee = inCommittee.length > 0;
|
|
@@ -199,14 +308,11 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
199
308
|
txHashes: proposal.txHashes.map((t)=>t.toString()),
|
|
200
309
|
fishermanMode: this.config.fishermanMode || false
|
|
201
310
|
});
|
|
202
|
-
// Reexecute
|
|
203
|
-
|
|
204
|
-
const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } = this.config;
|
|
205
|
-
const shouldReexecute = fishermanMode || slashBroadcastedInvalidBlockPenalty > 0n && validatorReexecute || partOfCommittee && validatorReexecute || alwaysReexecuteBlockProposals || this.blobClient.canUpload();
|
|
206
|
-
const validationResult = await this.blockProposalHandler.handleBlockProposal(proposal, proposalSender, !!shouldReexecute);
|
|
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);
|
|
207
313
|
if (!validationResult.isValid) {
|
|
208
|
-
this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
209
314
|
const reason = validationResult.reason || 'unknown';
|
|
315
|
+
this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
|
|
210
316
|
// Classify failure reason: bad proposal vs node issue
|
|
211
317
|
const badProposalReasons = [
|
|
212
318
|
'invalid_proposal',
|
|
@@ -221,21 +327,27 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
221
327
|
// Node issues so we can't validate
|
|
222
328
|
this.metrics.incFailedAttestationsNodeIssue(1, reason, partOfCommittee);
|
|
223
329
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
});
|
|
227
336
|
this.slashInvalidBlock(proposal);
|
|
337
|
+
this.markInvalidProposalSlot(proposal.slotNumber);
|
|
228
338
|
}
|
|
229
339
|
return false;
|
|
230
340
|
}
|
|
231
341
|
this.log.info(`Validated block proposal for slot ${slotNumber}`, {
|
|
232
342
|
...proposalInfo,
|
|
233
343
|
inCommittee: partOfCommittee,
|
|
234
|
-
fishermanMode: this.config.fishermanMode || false
|
|
344
|
+
fishermanMode: this.config.fishermanMode || false,
|
|
345
|
+
escapeHatchOpen
|
|
235
346
|
});
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
347
|
+
if (escapeHatchOpen) {
|
|
348
|
+
this.log.warn(`Escape hatch open for slot ${slotNumber}, rejecting block proposal`, proposalInfo);
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
239
351
|
return true;
|
|
240
352
|
}
|
|
241
353
|
/**
|
|
@@ -244,47 +356,50 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
244
356
|
* the lastBlock is extracted and processed separately via the block handler.
|
|
245
357
|
* @returns Checkpoint attestations if valid, undefined otherwise
|
|
246
358
|
*/ async attestToCheckpointProposal(proposal, _proposalSender) {
|
|
247
|
-
const
|
|
359
|
+
const proposalSlotNumber = proposal.slotNumber;
|
|
248
360
|
const proposer = proposal.getSender();
|
|
249
|
-
//
|
|
250
|
-
if (
|
|
251
|
-
this.log.warn(`
|
|
361
|
+
// If escape hatch is open for this slot's epoch, do not attest.
|
|
362
|
+
if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
|
|
363
|
+
this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
|
|
252
364
|
return undefined;
|
|
253
365
|
}
|
|
254
|
-
//
|
|
255
|
-
|
|
366
|
+
// Early-out for equivocation: refuses if we've already attested to a higher slot.
|
|
367
|
+
if (!this.shouldAttestToSlot(proposalSlotNumber)) {
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
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());
|
|
256
380
|
const partOfCommittee = inCommittee.length > 0;
|
|
257
381
|
const proposalInfo = {
|
|
258
|
-
|
|
382
|
+
proposalSlotNumber,
|
|
259
383
|
archive: proposal.archive.toString(),
|
|
260
|
-
proposer: proposer
|
|
261
|
-
txCount: proposal.txHashes.length
|
|
384
|
+
proposer: proposer?.toString()
|
|
262
385
|
};
|
|
263
|
-
this.log.info(`Received checkpoint proposal for slot ${
|
|
386
|
+
this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
|
|
264
387
|
...proposalInfo,
|
|
265
|
-
txHashes: proposal.txHashes.map((t)=>t.toString()),
|
|
266
388
|
fishermanMode: this.config.fishermanMode || false
|
|
267
389
|
});
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
// Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
|
|
275
|
-
// TODO(palla/mbps): Change default to false once checkpoint validation is stable.
|
|
276
|
-
if (this.config.skipCheckpointProposalValidation !== false) {
|
|
277
|
-
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);
|
|
278
396
|
} else {
|
|
279
|
-
const validationResult = await this.
|
|
397
|
+
const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
|
|
280
398
|
if (!validationResult.isValid) {
|
|
281
399
|
this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
282
400
|
return undefined;
|
|
283
401
|
}
|
|
284
|
-
|
|
285
|
-
// Upload blobs to filestore if we can (fire and forget)
|
|
286
|
-
if (this.blobClient.canUpload()) {
|
|
287
|
-
void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
|
|
402
|
+
checkpointNumber = validationResult.checkpointNumber;
|
|
288
403
|
}
|
|
289
404
|
// Check that I have any address in current committee before attesting
|
|
290
405
|
// In fisherman mode, we still create attestations for validation even if not in committee
|
|
@@ -293,12 +408,22 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
293
408
|
return undefined;
|
|
294
409
|
}
|
|
295
410
|
// Provided all of the above checks pass, we can attest to the proposal
|
|
296
|
-
this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${
|
|
411
|
+
this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${proposalSlotNumber}`, {
|
|
297
412
|
...proposalInfo,
|
|
298
413
|
inCommittee: partOfCommittee,
|
|
299
414
|
fishermanMode: this.config.fishermanMode || false
|
|
300
415
|
});
|
|
301
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
|
+
}
|
|
302
427
|
// Determine which validators should attest
|
|
303
428
|
let attestors;
|
|
304
429
|
if (partOfCommittee) {
|
|
@@ -315,186 +440,58 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
315
440
|
}
|
|
316
441
|
if (this.config.fishermanMode) {
|
|
317
442
|
// bail out early and don't save attestations to the pool in fisherman mode
|
|
318
|
-
this.log.info(`Creating checkpoint attestations for slot ${
|
|
443
|
+
this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
|
|
319
444
|
...proposalInfo,
|
|
320
445
|
attestors: attestors.map((a)=>a.toString())
|
|
321
446
|
});
|
|
322
447
|
return undefined;
|
|
323
448
|
}
|
|
324
|
-
return this.createCheckpointAttestationsFromProposal(proposal, attestors);
|
|
325
|
-
}
|
|
326
|
-
async createCheckpointAttestationsFromProposal(proposal, attestors = []) {
|
|
327
|
-
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
|
|
328
|
-
await this.p2pClient.addCheckpointAttestations(attestations);
|
|
329
|
-
return attestations;
|
|
449
|
+
return await this.createCheckpointAttestationsFromProposal(proposal, attestors, checkpointNumber);
|
|
330
450
|
}
|
|
331
451
|
/**
|
|
332
|
-
*
|
|
333
|
-
* @returns
|
|
334
|
-
*/
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
let lastBlockHeader;
|
|
339
|
-
try {
|
|
340
|
-
lastBlockHeader = await retryUntil(async ()=>{
|
|
341
|
-
await this.blockSource.syncImmediate();
|
|
342
|
-
return this.blockSource.getBlockHeaderByArchive(proposal.archive);
|
|
343
|
-
}, `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`, timeoutSeconds, 0.5);
|
|
344
|
-
} catch (err) {
|
|
345
|
-
if (err instanceof TimeoutError) {
|
|
346
|
-
this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
|
|
347
|
-
return {
|
|
348
|
-
isValid: false,
|
|
349
|
-
reason: 'last_block_not_found'
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
|
|
353
|
-
return {
|
|
354
|
-
isValid: false,
|
|
355
|
-
reason: 'block_fetch_error'
|
|
356
|
-
};
|
|
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;
|
|
357
458
|
}
|
|
358
|
-
if
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
reason: 'last_block_not_found'
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
// Get the last full block to determine checkpoint number
|
|
366
|
-
const lastBlock = await this.blockSource.getL2BlockNew(lastBlockHeader.getBlockNumber());
|
|
367
|
-
if (!lastBlock) {
|
|
368
|
-
this.log.warn(`Last block ${lastBlockHeader.getBlockNumber()} not found`, proposalInfo);
|
|
369
|
-
return {
|
|
370
|
-
isValid: false,
|
|
371
|
-
reason: 'last_block_not_found'
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
const checkpointNumber = lastBlock.checkpointNumber;
|
|
375
|
-
// Get all full blocks for the slot and checkpoint
|
|
376
|
-
const blocks = await this.getBlocksForSlot(slot, lastBlockHeader, checkpointNumber);
|
|
377
|
-
if (blocks.length === 0) {
|
|
378
|
-
this.log.warn(`No blocks found for slot ${slot}`, proposalInfo);
|
|
379
|
-
return {
|
|
380
|
-
isValid: false,
|
|
381
|
-
reason: 'no_blocks_for_slot'
|
|
382
|
-
};
|
|
383
|
-
}
|
|
384
|
-
this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
|
|
385
|
-
...proposalInfo,
|
|
386
|
-
blockNumbers: blocks.map((b)=>b.number)
|
|
387
|
-
});
|
|
388
|
-
// Get checkpoint constants from first block
|
|
389
|
-
const firstBlock = blocks[0];
|
|
390
|
-
const constants = this.extractCheckpointConstants(firstBlock);
|
|
391
|
-
// Get L1-to-L2 messages for this checkpoint
|
|
392
|
-
const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
|
|
393
|
-
// Fork world state at the block before the first block
|
|
394
|
-
const parentBlockNumber = BlockNumber(firstBlock.number - 1);
|
|
395
|
-
const fork = await this.worldState.fork(parentBlockNumber);
|
|
396
|
-
try {
|
|
397
|
-
// Create checkpoint builder with all existing blocks
|
|
398
|
-
const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(checkpointNumber, constants, l1ToL2Messages, fork, blocks);
|
|
399
|
-
// Complete the checkpoint to get computed values
|
|
400
|
-
const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
|
|
401
|
-
// Compare checkpoint header with proposal
|
|
402
|
-
if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
|
|
403
|
-
this.log.warn(`Checkpoint header mismatch`, {
|
|
404
|
-
...proposalInfo,
|
|
405
|
-
computed: computedCheckpoint.header.toInspect(),
|
|
406
|
-
proposal: proposal.checkpointHeader.toInspect()
|
|
407
|
-
});
|
|
408
|
-
return {
|
|
409
|
-
isValid: false,
|
|
410
|
-
reason: 'checkpoint_header_mismatch'
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
// Compare archive root with proposal
|
|
414
|
-
if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
|
|
415
|
-
this.log.warn(`Archive root mismatch`, {
|
|
416
|
-
...proposalInfo,
|
|
417
|
-
computed: computedCheckpoint.archive.root.toString(),
|
|
418
|
-
proposal: proposal.archive.toString()
|
|
419
|
-
});
|
|
420
|
-
return {
|
|
421
|
-
isValid: false,
|
|
422
|
-
reason: 'archive_mismatch'
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
|
|
426
|
-
return {
|
|
427
|
-
isValid: true
|
|
428
|
-
};
|
|
429
|
-
} finally{
|
|
430
|
-
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;
|
|
431
463
|
}
|
|
464
|
+
return true;
|
|
432
465
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
*/ async getBlocksForSlot(slot, lastBlockHeader, checkpointNumber) {
|
|
438
|
-
const blocks = [];
|
|
439
|
-
let currentHeader = lastBlockHeader;
|
|
440
|
-
const { genesisArchiveRoot } = await this.blockSource.getGenesisValues();
|
|
441
|
-
while(currentHeader.getSlot() === slot){
|
|
442
|
-
const block = await this.blockSource.getL2BlockNew(currentHeader.getBlockNumber());
|
|
443
|
-
if (!block) {
|
|
444
|
-
this.log.warn(`Block ${currentHeader.getBlockNumber()} not found while getting blocks for slot ${slot}`);
|
|
445
|
-
break;
|
|
446
|
-
}
|
|
447
|
-
if (block.checkpointNumber !== checkpointNumber) {
|
|
448
|
-
break;
|
|
449
|
-
}
|
|
450
|
-
blocks.unshift(block);
|
|
451
|
-
const prevArchive = currentHeader.lastArchive.root;
|
|
452
|
-
if (prevArchive.equals(genesisArchiveRoot)) {
|
|
453
|
-
break;
|
|
454
|
-
}
|
|
455
|
-
const prevHeader = await this.blockSource.getBlockHeaderByArchive(prevArchive);
|
|
456
|
-
if (!prevHeader || prevHeader.getSlot() !== slot) {
|
|
457
|
-
break;
|
|
458
|
-
}
|
|
459
|
-
currentHeader = prevHeader;
|
|
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;
|
|
460
470
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const gv = block.header.globalVariables;
|
|
467
|
-
return {
|
|
468
|
-
chainId: gv.chainId,
|
|
469
|
-
version: gv.version,
|
|
470
|
-
slotNumber: gv.slotNumber,
|
|
471
|
-
coinbase: gv.coinbase,
|
|
472
|
-
feeRecipient: gv.feeRecipient,
|
|
473
|
-
gasFees: gv.gasFees
|
|
474
|
-
};
|
|
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;
|
|
475
476
|
}
|
|
476
477
|
/**
|
|
477
478
|
* Uploads blobs for a checkpoint to the filestore (fire and forget).
|
|
478
479
|
*/ async uploadBlobsForCheckpoint(proposal, proposalInfo) {
|
|
479
480
|
try {
|
|
480
|
-
const lastBlockHeader = await this.blockSource.
|
|
481
|
+
const lastBlockHeader = (await this.blockSource.getBlockData({
|
|
482
|
+
archive: proposal.archive
|
|
483
|
+
}))?.header;
|
|
481
484
|
if (!lastBlockHeader) {
|
|
482
485
|
this.log.warn(`Failed to get last block header for blob upload`, proposalInfo);
|
|
483
486
|
return;
|
|
484
487
|
}
|
|
485
|
-
|
|
486
|
-
const lastBlock = await this.blockSource.getL2BlockNew(lastBlockHeader.getBlockNumber());
|
|
487
|
-
if (!lastBlock) {
|
|
488
|
-
this.log.warn(`Failed to get last block for blob upload`, proposalInfo);
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
const blocks = await this.getBlocksForSlot(proposal.slotNumber, lastBlockHeader, lastBlock.checkpointNumber);
|
|
488
|
+
const blocks = await this.blockSource.getBlocksForSlot(proposal.slotNumber);
|
|
492
489
|
if (blocks.length === 0) {
|
|
493
490
|
this.log.warn(`No blocks found for blob upload`, proposalInfo);
|
|
494
491
|
return;
|
|
495
492
|
}
|
|
496
493
|
const blobFields = blocks.flatMap((b)=>b.toBlobFields());
|
|
497
|
-
const blobs = getBlobsPerL1Block(blobFields);
|
|
494
|
+
const blobs = await getBlobsPerL1Block(blobFields);
|
|
498
495
|
await this.blobClient.sendBlobsToFilestore(blobs);
|
|
499
496
|
this.log.debug(`Uploaded ${blobs.length} blobs to filestore for checkpoint at slot ${proposal.slotNumber}`, {
|
|
500
497
|
...proposalInfo,
|
|
@@ -511,11 +508,6 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
511
508
|
this.log.warn(`Cannot slash proposal with invalid signature`);
|
|
512
509
|
return;
|
|
513
510
|
}
|
|
514
|
-
// Trim the set if it's too big.
|
|
515
|
-
if (this.proposersOfInvalidBlocks.size > MAX_PROPOSERS_OF_INVALID_BLOCKS) {
|
|
516
|
-
// remove oldest proposer. `values` is guaranteed to be in insertion order.
|
|
517
|
-
this.proposersOfInvalidBlocks.delete(this.proposersOfInvalidBlocks.values().next().value);
|
|
518
|
-
}
|
|
519
511
|
this.proposersOfInvalidBlocks.add(proposer.toString());
|
|
520
512
|
this.emit(WANT_TO_SLASH_EVENT, [
|
|
521
513
|
{
|
|
@@ -526,37 +518,185 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
526
518
|
}
|
|
527
519
|
]);
|
|
528
520
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
+
}
|
|
535
655
|
this.log.info(`Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`);
|
|
536
|
-
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, {
|
|
537
657
|
...options,
|
|
538
|
-
broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal
|
|
658
|
+
broadcastInvalidBlockProposal: options.broadcastInvalidBlockProposal || this.config.broadcastInvalidBlockProposal
|
|
539
659
|
});
|
|
540
|
-
this.
|
|
660
|
+
this.lastProposedBlock = newProposal;
|
|
541
661
|
return newProposal;
|
|
542
662
|
}
|
|
543
|
-
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
|
+
}
|
|
544
672
|
this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
|
|
545
|
-
|
|
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;
|
|
546
683
|
}
|
|
547
684
|
async broadcastBlockProposal(proposal) {
|
|
548
685
|
await this.p2pClient.broadcastProposal(proposal);
|
|
549
686
|
}
|
|
550
|
-
async signAttestationsAndSigners(attestationsAndSigners, proposer) {
|
|
551
|
-
return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer);
|
|
687
|
+
async signAttestationsAndSigners(attestationsAndSigners, proposer, slot, checkpointNumber) {
|
|
688
|
+
return await this.validationService.signAttestationsAndSigners(attestationsAndSigners, proposer, slot, checkpointNumber);
|
|
552
689
|
}
|
|
553
|
-
async collectOwnAttestations(proposal) {
|
|
690
|
+
async collectOwnAttestations(proposal, checkpointNumber) {
|
|
554
691
|
const slot = proposal.slotNumber;
|
|
555
692
|
const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
|
|
556
693
|
this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, {
|
|
557
694
|
inCommittee
|
|
558
695
|
});
|
|
559
|
-
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
|
|
696
|
+
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee, checkpointNumber);
|
|
697
|
+
if (!attestations) {
|
|
698
|
+
return [];
|
|
699
|
+
}
|
|
560
700
|
// We broadcast our own attestations to our peers so, in case our block does not get mined on L1,
|
|
561
701
|
// other nodes can see that our validators did attest to this block proposal, and do not slash us
|
|
562
702
|
// due to inactivity for missed attestations.
|
|
@@ -565,7 +705,7 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
565
705
|
});
|
|
566
706
|
return attestations;
|
|
567
707
|
}
|
|
568
|
-
async collectAttestations(proposal, required, deadline) {
|
|
708
|
+
async collectAttestations(proposal, required, deadline, checkpointNumber) {
|
|
569
709
|
// Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
|
|
570
710
|
const slot = proposal.slotNumber;
|
|
571
711
|
this.log.debug(`Collecting ${required} attestations for slot ${slot} with deadline ${deadline.toISOString()}`);
|
|
@@ -573,28 +713,20 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
573
713
|
this.log.error(`Deadline ${deadline.toISOString()} for collecting ${required} attestations for slot ${slot} is in the past`);
|
|
574
714
|
throw new AttestationTimeoutError(0, required, slot);
|
|
575
715
|
}
|
|
576
|
-
await this.collectOwnAttestations(proposal);
|
|
577
|
-
const
|
|
716
|
+
await this.collectOwnAttestations(proposal, checkpointNumber);
|
|
717
|
+
const proposalPayloadHash = proposal.getPayloadHash();
|
|
578
718
|
const myAddresses = this.getValidatorAddresses();
|
|
579
719
|
let attestations = [];
|
|
580
720
|
while(true){
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
this.log.warn(`Received attestation for slot ${slot} with mismatched archive from ${attestation.getSender()?.toString()}`, {
|
|
586
|
-
attestationArchive: attestation.archive.toString(),
|
|
587
|
-
proposalArchive: proposal.archive.toString()
|
|
588
|
-
});
|
|
589
|
-
return false;
|
|
590
|
-
}
|
|
591
|
-
return true;
|
|
592
|
-
});
|
|
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);
|
|
593
725
|
// Log new attestations we collected
|
|
594
726
|
const oldSenders = attestations.map((attestation)=>attestation.getSender());
|
|
595
727
|
for (const collected of collectedAttestations){
|
|
596
728
|
const collectedSender = collected.getSender();
|
|
597
|
-
// Skip attestations with invalid signatures
|
|
729
|
+
// Skip attestations with invalid signatures. Should not happen as we don't add invalid attestations to our pool.
|
|
598
730
|
if (!collectedSender) {
|
|
599
731
|
this.log.warn(`Skipping attestation with invalid signature for slot ${slot}`);
|
|
600
732
|
continue;
|
|
@@ -630,7 +762,11 @@ const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
|
|
|
630
762
|
return Buffer.alloc(0);
|
|
631
763
|
}
|
|
632
764
|
const payloadToSign = authRequest.getPayloadToSign();
|
|
633
|
-
|
|
765
|
+
// AUTH_REQUEST doesn't require HA protection - multiple signatures are safe
|
|
766
|
+
const context = {
|
|
767
|
+
dutyType: DutyType.AUTH_REQUEST
|
|
768
|
+
};
|
|
769
|
+
const signature = await this.keyStore.signMessageWithAddress(addressToUse, payloadToSign, context);
|
|
634
770
|
const authResponse = new AuthResponse(statusMessage, signature);
|
|
635
771
|
return authResponse.toBuffer();
|
|
636
772
|
}
|