@aztec/validator-client 0.0.1-commit.a072138 → 0.0.1-commit.a5db02d
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 +63 -18
- package/dest/checkpoint_builder.d.ts +21 -8
- package/dest/checkpoint_builder.d.ts.map +1 -1
- package/dest/checkpoint_builder.js +126 -46
- package/dest/config.d.ts +9 -3
- package/dest/config.d.ts.map +1 -1
- package/dest/config.js +45 -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.d.ts +1 -1
- package/dest/key_store/ha_key_store.d.ts.map +1 -1
- package/dest/key_store/ha_key_store.js +3 -3
- package/dest/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 +65 -24
- package/dest/validator.d.ts.map +1 -1
- package/dest/validator.js +376 -233
- package/package.json +19 -19
- package/src/checkpoint_builder.ts +144 -39
- package/src/config.ts +53 -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 +3 -3
- 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 +509 -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 { 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 {
|
|
35
|
-
import
|
|
36
|
-
BlockProposal,
|
|
37
|
-
BlockProposalOptions,
|
|
34
|
+
import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
|
|
35
|
+
import {
|
|
36
|
+
type BlockProposal,
|
|
37
|
+
type BlockProposalOptions,
|
|
38
38
|
CheckpointAttestation,
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
CheckpointProposal,
|
|
40
|
+
type CheckpointProposalCore,
|
|
41
|
+
type CheckpointProposalOptions,
|
|
42
|
+
type CoordinationSignatureContext,
|
|
41
43
|
} from '@aztec/stdlib/p2p';
|
|
42
|
-
import { CheckpointProposal } 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,29 +86,40 @@ 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
|
|
|
83
|
-
|
|
84
|
-
private
|
|
92
|
+
/** Tracks the last block proposal we created, to detect duplicate proposal attempts. */
|
|
93
|
+
private lastProposedBlock?: BlockProposal;
|
|
94
|
+
|
|
95
|
+
/** Tracks the last checkpoint proposal we created. */
|
|
96
|
+
private lastProposedCheckpoint?: CheckpointProposal;
|
|
85
97
|
|
|
86
98
|
private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined;
|
|
87
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();
|
|
102
|
+
|
|
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);
|
|
88
107
|
|
|
89
|
-
|
|
108
|
+
/** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */
|
|
109
|
+
private lastAttestedProposal?: CheckpointProposalCore;
|
|
90
110
|
|
|
91
111
|
protected constructor(
|
|
92
112
|
private keyStore: ExtendedValidatorKeyStore,
|
|
93
113
|
private epochCache: EpochCache,
|
|
94
114
|
private p2pClient: P2P,
|
|
95
|
-
private
|
|
115
|
+
private proposalHandler: ProposalHandler,
|
|
96
116
|
private blockSource: L2BlockSource,
|
|
97
117
|
private checkpointsBuilder: FullNodeCheckpointsBuilder,
|
|
98
118
|
private worldState: WorldStateSynchronizer,
|
|
99
119
|
private l1ToL2MessageSource: L1ToL2MessageSource,
|
|
100
120
|
private config: ValidatorClientFullConfig,
|
|
101
121
|
private blobClient: BlobClientInterface,
|
|
122
|
+
private slashingProtectionSigner: ValidatorHASigner,
|
|
102
123
|
private dateProvider: DateProvider = new DateProvider(),
|
|
103
124
|
telemetry: TelemetryClient = getTelemetryClient(),
|
|
104
125
|
log = createLogger('validator'),
|
|
@@ -111,11 +132,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
111
132
|
this.tracer = telemetry.getTracer('Validator');
|
|
112
133
|
this.metrics = new ValidatorMetrics(telemetry);
|
|
113
134
|
|
|
114
|
-
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
|
+
);
|
|
115
143
|
|
|
116
144
|
// Refresh epoch cache every second to trigger alert if participation in committee changes
|
|
117
145
|
this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), this.log, 1000);
|
|
118
|
-
|
|
119
146
|
const myAddresses = this.getValidatorAddresses();
|
|
120
147
|
this.log.verbose(`Initialized validator with addresses: ${myAddresses.map(a => a.toString()).join(', ')}`);
|
|
121
148
|
}
|
|
@@ -152,6 +179,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
152
179
|
this.log.trace(`No committee found for slot`);
|
|
153
180
|
return;
|
|
154
181
|
}
|
|
182
|
+
this.metrics.setCurrentEpoch(epoch);
|
|
155
183
|
if (epoch !== this.lastEpochForCommitteeUpdateLoop) {
|
|
156
184
|
const me = this.getValidatorAddresses();
|
|
157
185
|
const committeeSet = new Set(committee.map(v => v.toString()));
|
|
@@ -183,14 +211,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
183
211
|
txProvider: ITxProvider,
|
|
184
212
|
keyStoreManager: KeystoreManager,
|
|
185
213
|
blobClient: BlobClientInterface,
|
|
214
|
+
reexecutionTracker: CheckpointReexecutionTracker,
|
|
186
215
|
dateProvider: DateProvider = new DateProvider(),
|
|
187
216
|
telemetry: TelemetryClient = getTelemetryClient(),
|
|
217
|
+
slashingProtectionDb?: SlashingProtectionDatabase,
|
|
188
218
|
) {
|
|
189
219
|
const metrics = new ValidatorMetrics(telemetry);
|
|
190
|
-
const
|
|
220
|
+
const consensusTimetable = new ConsensusTimetable({
|
|
221
|
+
l1Constants: epochCache.getL1Constants(),
|
|
222
|
+
blockDuration: config.blockDurationMs / 1000,
|
|
223
|
+
});
|
|
224
|
+
const blockProposalValidator = new BlockProposalValidator(epochCache, consensusTimetable, {
|
|
191
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,
|
|
192
234
|
});
|
|
193
|
-
const
|
|
235
|
+
const proposalHandler = new ProposalHandler(
|
|
194
236
|
checkpointsBuilder,
|
|
195
237
|
worldState,
|
|
196
238
|
blockSource,
|
|
@@ -198,34 +240,57 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
198
240
|
txProvider,
|
|
199
241
|
blockProposalValidator,
|
|
200
242
|
epochCache,
|
|
243
|
+
consensusTimetable,
|
|
201
244
|
config,
|
|
245
|
+
blobClient,
|
|
246
|
+
reexecutionTracker,
|
|
202
247
|
metrics,
|
|
203
248
|
dateProvider,
|
|
204
249
|
telemetry,
|
|
250
|
+
undefined,
|
|
205
251
|
);
|
|
206
252
|
|
|
207
|
-
|
|
208
|
-
|
|
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.
|
|
209
263
|
// If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
|
|
210
264
|
const haConfig = {
|
|
211
265
|
...config,
|
|
212
266
|
maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
|
|
213
267
|
};
|
|
214
|
-
|
|
215
|
-
|
|
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
|
+
}));
|
|
216
279
|
}
|
|
280
|
+
const validatorKeyStore: ExtendedValidatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
|
|
217
281
|
|
|
218
282
|
const validator = new ValidatorClient(
|
|
219
283
|
validatorKeyStore,
|
|
220
284
|
epochCache,
|
|
221
285
|
p2pClient,
|
|
222
|
-
|
|
286
|
+
proposalHandler,
|
|
223
287
|
blockSource,
|
|
224
288
|
checkpointsBuilder,
|
|
225
289
|
worldState,
|
|
226
290
|
l1ToL2MessageSource,
|
|
227
291
|
config,
|
|
228
292
|
blobClient,
|
|
293
|
+
slashingProtectionSigner,
|
|
229
294
|
dateProvider,
|
|
230
295
|
telemetry,
|
|
231
296
|
);
|
|
@@ -239,14 +304,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
239
304
|
.filter(addr => !this.config.disabledValidators.some(disabled => disabled.equals(addr)));
|
|
240
305
|
}
|
|
241
306
|
|
|
242
|
-
public
|
|
243
|
-
return this.
|
|
307
|
+
public getProposalHandler() {
|
|
308
|
+
return this.proposalHandler;
|
|
244
309
|
}
|
|
245
310
|
|
|
246
311
|
public signWithAddress(addr: EthAddress, msg: TypedDataDefinition, context: SigningContext) {
|
|
247
312
|
return this.keyStore.signTypedDataWithAddress(addr, msg, context);
|
|
248
313
|
}
|
|
249
314
|
|
|
315
|
+
private getSignatureContext(): CoordinationSignatureContext {
|
|
316
|
+
return {
|
|
317
|
+
chainId: this.config.l1ChainId,
|
|
318
|
+
rollupAddress: this.config.rollupAddress,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
250
322
|
public getCoinbaseForAttestor(attestor: EthAddress): EthAddress {
|
|
251
323
|
return this.keyStore.getCoinbaseAddress(attestor);
|
|
252
324
|
}
|
|
@@ -259,8 +331,27 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
259
331
|
return this.config;
|
|
260
332
|
}
|
|
261
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
|
+
|
|
262
342
|
public updateConfig(config: Partial<ValidatorClientFullConfig>) {
|
|
263
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
|
+
);
|
|
264
355
|
}
|
|
265
356
|
|
|
266
357
|
public async start() {
|
|
@@ -307,7 +398,26 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
307
398
|
checkpoint: CheckpointProposalCore,
|
|
308
399
|
proposalSender: PeerId,
|
|
309
400
|
): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
|
|
310
|
-
this.p2pClient.
|
|
401
|
+
this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler);
|
|
402
|
+
|
|
403
|
+
// Duplicate proposal handler - triggers slashing for equivocation
|
|
404
|
+
this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
|
|
405
|
+
this.handleDuplicateProposal(info);
|
|
406
|
+
});
|
|
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
|
+
|
|
413
|
+
// Duplicate attestation handler - triggers slashing for attestation equivocation
|
|
414
|
+
this.p2pClient.registerDuplicateAttestationCallback((info: DuplicateAttestationInfo) => {
|
|
415
|
+
this.handleDuplicateAttestation(info);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
this.p2pClient.registerCheckpointAttestationCallback((attestation: CheckpointAttestation) => {
|
|
419
|
+
this.handleCheckpointAttestation(attestation);
|
|
420
|
+
});
|
|
311
421
|
|
|
312
422
|
const myAddresses = this.getValidatorAddresses();
|
|
313
423
|
this.p2pClient.registerThisValidatorAddresses(myAddresses);
|
|
@@ -336,6 +446,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
336
446
|
return false;
|
|
337
447
|
}
|
|
338
448
|
|
|
449
|
+
// Log self-proposals from HA peers (same validator key on different nodes)
|
|
450
|
+
if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
451
|
+
this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
|
|
452
|
+
proposer: proposer.toString(),
|
|
453
|
+
slotNumber,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
339
457
|
// Check if we're in the committee (for metrics purposes)
|
|
340
458
|
const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
|
|
341
459
|
const partOfCommittee = inCommittee.length > 0;
|
|
@@ -347,27 +465,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
347
465
|
fishermanMode: this.config.fishermanMode || false,
|
|
348
466
|
});
|
|
349
467
|
|
|
350
|
-
// Reexecute
|
|
351
|
-
|
|
352
|
-
const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } =
|
|
353
|
-
this.config;
|
|
354
|
-
const shouldReexecute =
|
|
355
|
-
fishermanMode ||
|
|
356
|
-
(slashBroadcastedInvalidBlockPenalty > 0n && validatorReexecute) ||
|
|
357
|
-
(partOfCommittee && validatorReexecute) ||
|
|
358
|
-
alwaysReexecuteBlockProposals ||
|
|
359
|
-
this.blobClient.canUpload();
|
|
360
|
-
|
|
361
|
-
const validationResult = await this.blockProposalHandler.handleBlockProposal(
|
|
362
|
-
proposal,
|
|
363
|
-
proposalSender,
|
|
364
|
-
!!shouldReexecute && !escapeHatchOpen,
|
|
365
|
-
);
|
|
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);
|
|
366
470
|
|
|
367
471
|
if (!validationResult.isValid) {
|
|
368
|
-
this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
369
|
-
|
|
370
472
|
const reason = validationResult.reason || 'unknown';
|
|
473
|
+
|
|
474
|
+
this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
|
|
475
|
+
|
|
371
476
|
// Classify failure reason: bad proposal vs node issue
|
|
372
477
|
const badProposalReasons: BlockProposalValidationFailureReason[] = [
|
|
373
478
|
'invalid_proposal',
|
|
@@ -384,15 +489,18 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
384
489
|
this.metrics.incFailedAttestationsNodeIssue(1, reason, partOfCommittee);
|
|
385
490
|
}
|
|
386
491
|
|
|
387
|
-
// Slash invalid block proposals (can happen even when not in committee)
|
|
388
492
|
if (
|
|
389
493
|
!escapeHatchOpen &&
|
|
390
494
|
validationResult.reason &&
|
|
391
|
-
SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason)
|
|
392
|
-
slashBroadcastedInvalidBlockPenalty > 0n
|
|
495
|
+
SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(validationResult.reason)
|
|
393
496
|
) {
|
|
394
|
-
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
|
+
});
|
|
395
502
|
this.slashInvalidBlock(proposal);
|
|
503
|
+
this.markInvalidProposalSlot(proposal.slotNumber);
|
|
396
504
|
}
|
|
397
505
|
return false;
|
|
398
506
|
}
|
|
@@ -422,51 +530,56 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
422
530
|
proposal: CheckpointProposalCore,
|
|
423
531
|
_proposalSender: PeerId,
|
|
424
532
|
): Promise<CheckpointAttestation[] | undefined> {
|
|
425
|
-
const
|
|
533
|
+
const proposalSlotNumber = proposal.slotNumber;
|
|
426
534
|
const proposer = proposal.getSender();
|
|
427
535
|
|
|
428
536
|
// If escape hatch is open for this slot's epoch, do not attest.
|
|
429
|
-
if (await this.epochCache.isEscapeHatchOpenAtSlot(
|
|
430
|
-
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`);
|
|
431
539
|
return undefined;
|
|
432
540
|
}
|
|
433
541
|
|
|
434
|
-
//
|
|
435
|
-
if (!
|
|
436
|
-
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)) {
|
|
437
544
|
return undefined;
|
|
438
545
|
}
|
|
439
546
|
|
|
440
|
-
//
|
|
441
|
-
|
|
547
|
+
// Ignore proposals from ourselves (may happen in HA setups)
|
|
548
|
+
if (proposer && this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
549
|
+
this.log.debug(`Not attesting to block proposal from self for slot ${proposalSlotNumber}`, {
|
|
550
|
+
proposer: proposer.toString(),
|
|
551
|
+
proposalSlotNumber,
|
|
552
|
+
});
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
|
|
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());
|
|
442
558
|
const partOfCommittee = inCommittee.length > 0;
|
|
443
559
|
|
|
444
560
|
const proposalInfo = {
|
|
445
|
-
|
|
561
|
+
proposalSlotNumber,
|
|
446
562
|
archive: proposal.archive.toString(),
|
|
447
|
-
proposer: proposer
|
|
448
|
-
txCount: proposal.txHashes.length,
|
|
563
|
+
proposer: proposer?.toString(),
|
|
449
564
|
};
|
|
450
|
-
this.log.info(`Received checkpoint proposal for slot ${
|
|
565
|
+
this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
|
|
451
566
|
...proposalInfo,
|
|
452
|
-
txHashes: proposal.txHashes.map(t => t.toString()),
|
|
453
567
|
fishermanMode: this.config.fishermanMode || false,
|
|
454
568
|
});
|
|
455
569
|
|
|
456
|
-
// 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;
|
|
457
573
|
if (this.config.skipCheckpointProposalValidation) {
|
|
458
|
-
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);
|
|
459
576
|
} else {
|
|
460
|
-
const validationResult = await this.
|
|
577
|
+
const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
|
|
461
578
|
if (!validationResult.isValid) {
|
|
462
579
|
this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
463
580
|
return undefined;
|
|
464
581
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
// Upload blobs to filestore if we can (fire and forget)
|
|
468
|
-
if (this.blobClient.canUpload()) {
|
|
469
|
-
void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
|
|
582
|
+
checkpointNumber = validationResult.checkpointNumber;
|
|
470
583
|
}
|
|
471
584
|
|
|
472
585
|
// Check that I have any address in current committee before attesting
|
|
@@ -477,14 +590,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
477
590
|
}
|
|
478
591
|
|
|
479
592
|
// Provided all of the above checks pass, we can attest to the proposal
|
|
480
|
-
this.log.info(
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
+
);
|
|
485
601
|
|
|
486
602
|
this.metrics.incSuccessfulAttestations(inCommittee.length);
|
|
487
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
|
+
|
|
488
615
|
// Determine which validators should attest
|
|
489
616
|
let attestors: EthAddress[];
|
|
490
617
|
if (partOfCommittee) {
|
|
@@ -503,174 +630,62 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
503
630
|
|
|
504
631
|
if (this.config.fishermanMode) {
|
|
505
632
|
// bail out early and don't save attestations to the pool in fisherman mode
|
|
506
|
-
this.log.info(`Creating checkpoint attestations for slot ${
|
|
633
|
+
this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
|
|
507
634
|
...proposalInfo,
|
|
508
635
|
attestors: attestors.map(a => a.toString()),
|
|
509
636
|
});
|
|
510
637
|
return undefined;
|
|
511
638
|
}
|
|
512
639
|
|
|
513
|
-
return this.createCheckpointAttestationsFromProposal(proposal, attestors);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
private async createCheckpointAttestationsFromProposal(
|
|
517
|
-
proposal: CheckpointProposalCore,
|
|
518
|
-
attestors: EthAddress[] = [],
|
|
519
|
-
): Promise<CheckpointAttestation[]> {
|
|
520
|
-
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
|
|
521
|
-
await this.p2pClient.addCheckpointAttestations(attestations);
|
|
522
|
-
return attestations;
|
|
640
|
+
return await this.createCheckpointAttestationsFromProposal(proposal, attestors, checkpointNumber);
|
|
523
641
|
}
|
|
524
642
|
|
|
525
643
|
/**
|
|
526
|
-
*
|
|
527
|
-
* @returns
|
|
644
|
+
* Checks if we should attest to a slot based on equivocation prevention rules.
|
|
645
|
+
* @returns true if we should attest, false if we should skip
|
|
528
646
|
*/
|
|
529
|
-
private
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const timeoutSeconds = 10; // TODO(palla/mbps): This should map to the timetable settings
|
|
647
|
+
private shouldAttestToSlot(slotNumber: SlotNumber): boolean {
|
|
648
|
+
// If attestToEquivocatedProposals is true, always allow
|
|
649
|
+
if (this.config.attestToEquivocatedProposals) {
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
535
652
|
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
async () => {
|
|
541
|
-
await this.blockSource.syncImmediate();
|
|
542
|
-
return this.blockSource.getBlockHeaderByArchive(proposal.archive);
|
|
543
|
-
},
|
|
544
|
-
`waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
|
|
545
|
-
timeoutSeconds,
|
|
546
|
-
0.5,
|
|
653
|
+
// Check if incoming slot is strictly greater than last attested
|
|
654
|
+
if (this.lastAttestedProposal && slotNumber <= this.lastAttestedProposal.slotNumber) {
|
|
655
|
+
this.log.warn(
|
|
656
|
+
`Refusing to process a proposal for slot ${slotNumber} given we already attested to a proposal for slot ${this.lastAttestedProposal.slotNumber}`,
|
|
547
657
|
);
|
|
548
|
-
|
|
549
|
-
if (err instanceof TimeoutError) {
|
|
550
|
-
this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
|
|
551
|
-
return { isValid: false, reason: 'last_block_not_found' };
|
|
552
|
-
}
|
|
553
|
-
this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
|
|
554
|
-
return { isValid: false, reason: 'block_fetch_error' };
|
|
658
|
+
return false;
|
|
555
659
|
}
|
|
556
660
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
return { isValid: false, reason: 'last_block_not_found' };
|
|
560
|
-
}
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
561
663
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
664
|
+
private async createCheckpointAttestationsFromProposal(
|
|
665
|
+
proposal: CheckpointProposalCore,
|
|
666
|
+
attestors: EthAddress[] = [],
|
|
667
|
+
checkpointNumber: CheckpointNumber,
|
|
668
|
+
): Promise<CheckpointAttestation[] | undefined> {
|
|
669
|
+
// Equivocation check: must happen right before signing to minimize the race window
|
|
670
|
+
if (!this.shouldAttestToSlot(proposal.slotNumber)) {
|
|
671
|
+
return undefined;
|
|
567
672
|
}
|
|
568
673
|
|
|
569
|
-
this.
|
|
570
|
-
...proposalInfo,
|
|
571
|
-
blockNumbers: blocks.map(b => b.number),
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
// Get checkpoint constants from first block
|
|
575
|
-
const firstBlock = blocks[0];
|
|
576
|
-
const constants = this.extractCheckpointConstants(firstBlock);
|
|
577
|
-
const checkpointNumber = firstBlock.checkpointNumber;
|
|
578
|
-
|
|
579
|
-
// Get L1-to-L2 messages for this checkpoint
|
|
580
|
-
const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
|
|
581
|
-
|
|
582
|
-
// Compute the previous checkpoint out hashes for the epoch.
|
|
583
|
-
// TODO: There can be a more efficient way to get the previous checkpoint out hashes without having to fetch the
|
|
584
|
-
// actual checkpoints and the blocks/txs in them.
|
|
585
|
-
const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
|
|
586
|
-
const previousCheckpoints = (await this.blockSource.getCheckpointsForEpoch(epoch))
|
|
587
|
-
.filter(b => b.number < checkpointNumber)
|
|
588
|
-
.sort((a, b) => a.number - b.number);
|
|
589
|
-
const previousCheckpointOutHashes = previousCheckpoints.map(c => c.getCheckpointOutHash());
|
|
674
|
+
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors, checkpointNumber);
|
|
590
675
|
|
|
591
|
-
//
|
|
592
|
-
|
|
593
|
-
const fork = await this.worldState.fork(parentBlockNumber);
|
|
594
|
-
|
|
595
|
-
try {
|
|
596
|
-
// Create checkpoint builder with all existing blocks
|
|
597
|
-
const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
|
|
598
|
-
checkpointNumber,
|
|
599
|
-
constants,
|
|
600
|
-
l1ToL2Messages,
|
|
601
|
-
previousCheckpointOutHashes,
|
|
602
|
-
fork,
|
|
603
|
-
blocks,
|
|
604
|
-
this.log.getBindings(),
|
|
605
|
-
);
|
|
676
|
+
// Track the proposal we attested to (to prevent equivocation)
|
|
677
|
+
this.lastAttestedProposal = proposal;
|
|
606
678
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
// Compare checkpoint header with proposal
|
|
611
|
-
if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
|
|
612
|
-
this.log.warn(`Checkpoint header mismatch`, {
|
|
613
|
-
...proposalInfo,
|
|
614
|
-
computed: computedCheckpoint.header.toInspect(),
|
|
615
|
-
proposal: proposal.checkpointHeader.toInspect(),
|
|
616
|
-
});
|
|
617
|
-
return { isValid: false, reason: 'checkpoint_header_mismatch' };
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
// Compare archive root with proposal
|
|
621
|
-
if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
|
|
622
|
-
this.log.warn(`Archive root mismatch`, {
|
|
623
|
-
...proposalInfo,
|
|
624
|
-
computed: computedCheckpoint.archive.root.toString(),
|
|
625
|
-
proposal: proposal.archive.toString(),
|
|
626
|
-
});
|
|
627
|
-
return { isValid: false, reason: 'archive_mismatch' };
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// Check that the accumulated epoch out hash matches the value in the proposal.
|
|
631
|
-
// The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
|
|
632
|
-
const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
|
|
633
|
-
const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
|
|
634
|
-
const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
|
|
635
|
-
if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
|
|
636
|
-
this.log.warn(`Epoch out hash mismatch`, {
|
|
637
|
-
proposalEpochOutHash: proposalEpochOutHash.toString(),
|
|
638
|
-
computedEpochOutHash: computedEpochOutHash.toString(),
|
|
639
|
-
checkpointOutHash: checkpointOutHash.toString(),
|
|
640
|
-
previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
|
|
641
|
-
...proposalInfo,
|
|
642
|
-
});
|
|
643
|
-
return { isValid: false, reason: 'out_hash_mismatch' };
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
|
|
647
|
-
return { isValid: true };
|
|
648
|
-
} finally {
|
|
649
|
-
await fork.close();
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* Extract checkpoint global variables from a block.
|
|
655
|
-
*/
|
|
656
|
-
private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
|
|
657
|
-
const gv = block.header.globalVariables;
|
|
658
|
-
return {
|
|
659
|
-
chainId: gv.chainId,
|
|
660
|
-
version: gv.version,
|
|
661
|
-
slotNumber: gv.slotNumber,
|
|
662
|
-
coinbase: gv.coinbase,
|
|
663
|
-
feeRecipient: gv.feeRecipient,
|
|
664
|
-
gasFees: gv.gasFees,
|
|
665
|
-
};
|
|
679
|
+
await this.p2pClient.addOwnCheckpointAttestations(attestations);
|
|
680
|
+
return attestations;
|
|
666
681
|
}
|
|
667
682
|
|
|
668
683
|
/**
|
|
669
684
|
* Uploads blobs for a checkpoint to the filestore (fire and forget).
|
|
670
685
|
*/
|
|
671
|
-
|
|
686
|
+
protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
|
|
672
687
|
try {
|
|
673
|
-
const lastBlockHeader = await this.blockSource.
|
|
688
|
+
const lastBlockHeader = (await this.blockSource.getBlockData({ archive: proposal.archive }))?.header;
|
|
674
689
|
if (!lastBlockHeader) {
|
|
675
690
|
this.log.warn(`Failed to get last block header for blob upload`, proposalInfo);
|
|
676
691
|
return;
|
|
@@ -683,7 +698,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
683
698
|
}
|
|
684
699
|
|
|
685
700
|
const blobFields = blocks.flatMap(b => b.toBlobFields());
|
|
686
|
-
const blobs: Blob[] = getBlobsPerL1Block(blobFields);
|
|
701
|
+
const blobs: Blob[] = await getBlobsPerL1Block(blobFields);
|
|
687
702
|
await this.blobClient.sendBlobsToFilestore(blobs);
|
|
688
703
|
this.log.debug(`Uploaded ${blobs.length} blobs to filestore for checkpoint at slot ${proposal.slotNumber}`, {
|
|
689
704
|
...proposalInfo,
|
|
@@ -703,12 +718,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
703
718
|
return;
|
|
704
719
|
}
|
|
705
720
|
|
|
706
|
-
// Trim the set if it's too big.
|
|
707
|
-
if (this.proposersOfInvalidBlocks.size > MAX_PROPOSERS_OF_INVALID_BLOCKS) {
|
|
708
|
-
// remove oldest proposer. `values` is guaranteed to be in insertion order.
|
|
709
|
-
this.proposersOfInvalidBlocks.delete(this.proposersOfInvalidBlocks.values().next().value!);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
721
|
this.proposersOfInvalidBlocks.add(proposer.toString());
|
|
713
722
|
|
|
714
723
|
this.emit(WANT_TO_SLASH_EVENT, [
|
|
@@ -721,8 +730,192 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
721
730
|
]);
|
|
722
731
|
}
|
|
723
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
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Handle detection of a duplicate proposal (equivocation).
|
|
861
|
+
* Emits a slash event when a proposer sends multiple proposals for the same position.
|
|
862
|
+
*/
|
|
863
|
+
private handleDuplicateProposal(info: DuplicateProposalInfo): void {
|
|
864
|
+
const { slot, proposer, type } = info;
|
|
865
|
+
this.proposalHandler.markProposalEquivocation(slot);
|
|
866
|
+
|
|
867
|
+
this.log.info(`Detected duplicate ${type} proposal offense from ${proposer.toString()} at slot ${slot}`, {
|
|
868
|
+
proposer: proposer.toString(),
|
|
869
|
+
slot,
|
|
870
|
+
type,
|
|
871
|
+
amount: this.config.slashDuplicateProposalPenalty,
|
|
872
|
+
offenseType: getOffenseTypeName(OffenseType.DUPLICATE_PROPOSAL),
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
this.emit(WANT_TO_SLASH_EVENT, [
|
|
876
|
+
{
|
|
877
|
+
validator: proposer,
|
|
878
|
+
amount: this.config.slashDuplicateProposalPenalty,
|
|
879
|
+
offenseType: OffenseType.DUPLICATE_PROPOSAL,
|
|
880
|
+
epochOrSlot: BigInt(slot),
|
|
881
|
+
},
|
|
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
|
+
]);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Handle detection of a duplicate attestation (equivocation).
|
|
894
|
+
* Emits a slash event when an attester signs attestations for different proposals at the same slot.
|
|
895
|
+
*/
|
|
896
|
+
private handleDuplicateAttestation(info: DuplicateAttestationInfo): void {
|
|
897
|
+
const { slot, attester } = info;
|
|
898
|
+
|
|
899
|
+
this.log.info(`Detected duplicate attestation offense from ${attester.toString()} at slot ${slot}`, {
|
|
900
|
+
attester: attester.toString(),
|
|
901
|
+
slot,
|
|
902
|
+
amount: this.config.slashDuplicateAttestationPenalty,
|
|
903
|
+
offenseType: getOffenseTypeName(OffenseType.DUPLICATE_ATTESTATION),
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
this.emit(WANT_TO_SLASH_EVENT, [
|
|
907
|
+
{
|
|
908
|
+
validator: attester,
|
|
909
|
+
amount: this.config.slashDuplicateAttestationPenalty,
|
|
910
|
+
offenseType: OffenseType.DUPLICATE_ATTESTATION,
|
|
911
|
+
epochOrSlot: BigInt(slot),
|
|
912
|
+
},
|
|
913
|
+
]);
|
|
914
|
+
}
|
|
915
|
+
|
|
724
916
|
async createBlockProposal(
|
|
725
917
|
blockHeader: BlockHeader,
|
|
918
|
+
checkpointNumber: CheckpointNumber,
|
|
726
919
|
indexWithinCheckpoint: IndexWithinCheckpoint,
|
|
727
920
|
inHash: Fr,
|
|
728
921
|
archive: Fr,
|
|
@@ -730,17 +923,26 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
730
923
|
proposerAddress: EthAddress | undefined,
|
|
731
924
|
options: BlockProposalOptions = {},
|
|
732
925
|
): Promise<BlockProposal> {
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
926
|
+
// Validate that we're not creating a proposal for an older or equal position
|
|
927
|
+
if (this.lastProposedBlock) {
|
|
928
|
+
const lastSlot = this.lastProposedBlock.slotNumber;
|
|
929
|
+
const lastIndex = this.lastProposedBlock.indexWithinCheckpoint;
|
|
930
|
+
const newSlot = blockHeader.globalVariables.slotNumber;
|
|
931
|
+
|
|
932
|
+
if (newSlot < lastSlot || (newSlot === lastSlot && indexWithinCheckpoint <= lastIndex)) {
|
|
933
|
+
throw new Error(
|
|
934
|
+
`Cannot create block proposal for slot ${newSlot} index ${indexWithinCheckpoint}: ` +
|
|
935
|
+
`already proposed block for slot ${lastSlot} index ${lastIndex}`,
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
738
939
|
|
|
739
940
|
this.log.info(
|
|
740
941
|
`Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`,
|
|
741
942
|
);
|
|
742
943
|
const newProposal = await this.validationService.createBlockProposal(
|
|
743
944
|
blockHeader,
|
|
945
|
+
checkpointNumber,
|
|
744
946
|
indexWithinCheckpoint,
|
|
745
947
|
inHash,
|
|
746
948
|
archive,
|
|
@@ -748,28 +950,55 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
748
950
|
proposerAddress,
|
|
749
951
|
{
|
|
750
952
|
...options,
|
|
751
|
-
broadcastInvalidBlockProposal:
|
|
953
|
+
broadcastInvalidBlockProposal:
|
|
954
|
+
options.broadcastInvalidBlockProposal || this.config.broadcastInvalidBlockProposal,
|
|
752
955
|
},
|
|
753
956
|
);
|
|
754
|
-
this.
|
|
957
|
+
this.lastProposedBlock = newProposal;
|
|
755
958
|
return newProposal;
|
|
756
959
|
}
|
|
757
960
|
|
|
758
961
|
async createCheckpointProposal(
|
|
759
962
|
checkpointHeader: CheckpointHeader,
|
|
760
963
|
archive: Fr,
|
|
761
|
-
|
|
964
|
+
checkpointNumber: CheckpointNumber,
|
|
965
|
+
feeAssetPriceModifier: bigint,
|
|
966
|
+
lastBlockProposal: BlockProposal | undefined,
|
|
762
967
|
proposerAddress: EthAddress | undefined,
|
|
763
968
|
options: CheckpointProposalOptions = {},
|
|
764
969
|
): Promise<CheckpointProposal> {
|
|
970
|
+
// Validate that we're not creating a proposal for an older or equal slot
|
|
971
|
+
if (this.lastProposedCheckpoint) {
|
|
972
|
+
const lastSlot = this.lastProposedCheckpoint.slotNumber;
|
|
973
|
+
const newSlot = checkpointHeader.slotNumber;
|
|
974
|
+
|
|
975
|
+
if (newSlot <= lastSlot) {
|
|
976
|
+
throw new Error(
|
|
977
|
+
`Cannot create checkpoint proposal for slot ${newSlot}: ` +
|
|
978
|
+
`already proposed checkpoint for slot ${lastSlot}`,
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
765
983
|
this.log.info(`Assembling checkpoint proposal for slot ${checkpointHeader.slotNumber}`);
|
|
766
|
-
|
|
984
|
+
const newProposal = await this.validationService.createCheckpointProposal(
|
|
767
985
|
checkpointHeader,
|
|
768
986
|
archive,
|
|
769
|
-
|
|
987
|
+
checkpointNumber,
|
|
988
|
+
feeAssetPriceModifier,
|
|
989
|
+
lastBlockProposal,
|
|
770
990
|
proposerAddress,
|
|
771
991
|
options,
|
|
772
992
|
);
|
|
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);
|
|
1001
|
+
return newProposal;
|
|
773
1002
|
}
|
|
774
1003
|
|
|
775
1004
|
async broadcastBlockProposal(proposal: BlockProposal): Promise<void> {
|
|
@@ -780,16 +1009,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
780
1009
|
attestationsAndSigners: CommitteeAttestationsAndSigners,
|
|
781
1010
|
proposer: EthAddress,
|
|
782
1011
|
slot: SlotNumber,
|
|
783
|
-
|
|
1012
|
+
checkpointNumber: CheckpointNumber,
|
|
784
1013
|
): Promise<Signature> {
|
|
785
|
-
return await this.validationService.signAttestationsAndSigners(
|
|
1014
|
+
return await this.validationService.signAttestationsAndSigners(
|
|
1015
|
+
attestationsAndSigners,
|
|
1016
|
+
proposer,
|
|
1017
|
+
slot,
|
|
1018
|
+
checkpointNumber,
|
|
1019
|
+
);
|
|
786
1020
|
}
|
|
787
1021
|
|
|
788
|
-
async collectOwnAttestations(
|
|
1022
|
+
async collectOwnAttestations(
|
|
1023
|
+
proposal: CheckpointProposal,
|
|
1024
|
+
checkpointNumber: CheckpointNumber,
|
|
1025
|
+
): Promise<CheckpointAttestation[]> {
|
|
789
1026
|
const slot = proposal.slotNumber;
|
|
790
1027
|
const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
|
|
791
1028
|
this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
|
|
792
|
-
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
|
|
1029
|
+
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee, checkpointNumber);
|
|
1030
|
+
|
|
1031
|
+
if (!attestations) {
|
|
1032
|
+
return [];
|
|
1033
|
+
}
|
|
793
1034
|
|
|
794
1035
|
// We broadcast our own attestations to our peers so, in case our block does not get mined on L1,
|
|
795
1036
|
// other nodes can see that our validators did attest to this block proposal, and do not slash us
|
|
@@ -804,6 +1045,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
804
1045
|
proposal: CheckpointProposal,
|
|
805
1046
|
required: number,
|
|
806
1047
|
deadline: Date,
|
|
1048
|
+
checkpointNumber: CheckpointNumber,
|
|
807
1049
|
): Promise<CheckpointAttestation[]> {
|
|
808
1050
|
// Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
|
|
809
1051
|
const slot = proposal.slotNumber;
|
|
@@ -816,33 +1058,23 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
816
1058
|
throw new AttestationTimeoutError(0, required, slot);
|
|
817
1059
|
}
|
|
818
1060
|
|
|
819
|
-
await this.collectOwnAttestations(proposal);
|
|
1061
|
+
await this.collectOwnAttestations(proposal, checkpointNumber);
|
|
820
1062
|
|
|
821
|
-
const
|
|
1063
|
+
const proposalPayloadHash = proposal.getPayloadHash();
|
|
822
1064
|
const myAddresses = this.getValidatorAddresses();
|
|
823
1065
|
|
|
824
1066
|
let attestations: CheckpointAttestation[] = [];
|
|
825
1067
|
while (true) {
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
if (!attestation.archive.equals(proposal.archive)) {
|
|
831
|
-
this.log.warn(
|
|
832
|
-
`Received attestation for slot ${slot} with mismatched archive from ${attestation.getSender()?.toString()}`,
|
|
833
|
-
{ attestationArchive: attestation.archive.toString(), proposalArchive: proposal.archive.toString() },
|
|
834
|
-
);
|
|
835
|
-
return false;
|
|
836
|
-
}
|
|
837
|
-
return true;
|
|
838
|
-
},
|
|
839
|
-
);
|
|
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);
|
|
840
1072
|
|
|
841
1073
|
// Log new attestations we collected
|
|
842
1074
|
const oldSenders = attestations.map(attestation => attestation.getSender());
|
|
843
1075
|
for (const collected of collectedAttestations) {
|
|
844
1076
|
const collectedSender = collected.getSender();
|
|
845
|
-
// Skip attestations with invalid signatures
|
|
1077
|
+
// Skip attestations with invalid signatures. Should not happen as we don't add invalid attestations to our pool.
|
|
846
1078
|
if (!collectedSender) {
|
|
847
1079
|
this.log.warn(`Skipping attestation with invalid signature for slot ${slot}`);
|
|
848
1080
|
continue;
|