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