@aztec/validator-client 0.0.1-commit.993d240 → 0.0.1-commit.9a89641
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 +2 -0
- package/dest/checkpoint_builder.d.ts +17 -7
- package/dest/checkpoint_builder.d.ts.map +1 -1
- package/dest/checkpoint_builder.js +26 -9
- package/dest/config.d.ts +9 -3
- package/dest/config.d.ts.map +1 -1
- package/dest/config.js +9 -1
- package/dest/factory.d.ts +1 -1
- package/dest/factory.d.ts.map +1 -1
- package/dest/factory.js +10 -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 +1 -1
- package/dest/metrics.d.ts.map +1 -1
- package/dest/metrics.js +3 -1
- package/dest/proposal_handler.d.ts +39 -8
- package/dest/proposal_handler.d.ts.map +1 -1
- package/dest/proposal_handler.js +290 -98
- package/dest/validator.d.ts +11 -5
- package/dest/validator.d.ts.map +1 -1
- package/dest/validator.js +54 -43
- package/package.json +19 -19
- package/src/checkpoint_builder.ts +28 -10
- package/src/config.ts +16 -3
- package/src/factory.ts +9 -1
- package/src/key_store/web3signer_key_store.ts +43 -59
- package/src/metrics.ts +2 -0
- package/src/proposal_handler.ts +309 -107
- package/src/validator.ts +63 -46
package/src/proposal_handler.ts
CHANGED
|
@@ -13,27 +13,36 @@ import {
|
|
|
13
13
|
import { pick } from '@aztec/foundation/collection';
|
|
14
14
|
import { Fr } from '@aztec/foundation/curves/bn254';
|
|
15
15
|
import { TimeoutError } from '@aztec/foundation/error';
|
|
16
|
+
import { FifoSet } from '@aztec/foundation/fifo-set';
|
|
16
17
|
import type { LogData } from '@aztec/foundation/log';
|
|
17
18
|
import { createLogger } from '@aztec/foundation/log';
|
|
18
19
|
import { retryUntil } from '@aztec/foundation/retry';
|
|
19
20
|
import { DateProvider, Timer } from '@aztec/foundation/timer';
|
|
21
|
+
import { isErrorClass } from '@aztec/foundation/types';
|
|
20
22
|
import type { P2P, PeerId } from '@aztec/p2p';
|
|
21
23
|
import { BlockProposalValidator } from '@aztec/p2p/msg_validators';
|
|
22
24
|
import type { BlockData, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
|
|
23
25
|
import type { CheckpointReexecutionTracker, ReexecutionOutcome } from '@aztec/stdlib/checkpoint';
|
|
24
26
|
import { getPreviousCheckpointOutHashes, validateCheckpoint } from '@aztec/stdlib/checkpoint';
|
|
25
|
-
import { getEpochAtSlot
|
|
27
|
+
import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
|
|
26
28
|
import { Gas } from '@aztec/stdlib/gas';
|
|
27
|
-
import type {
|
|
29
|
+
import type {
|
|
30
|
+
ITxProvider,
|
|
31
|
+
MerkleTreeWriteOperations,
|
|
32
|
+
ValidatorClientFullConfig,
|
|
33
|
+
WorldStateSynchronizer,
|
|
34
|
+
} from '@aztec/stdlib/interfaces/server';
|
|
28
35
|
import {
|
|
29
36
|
type L1ToL2MessageSource,
|
|
30
37
|
accumulateCheckpointOutHashes,
|
|
31
38
|
computeInHashFromL1ToL2Messages,
|
|
32
39
|
} from '@aztec/stdlib/messaging';
|
|
33
40
|
import type { BlockProposal, CheckpointAttestation, CheckpointProposalCore } from '@aztec/stdlib/p2p';
|
|
41
|
+
import type { ConsensusTimetable } from '@aztec/stdlib/timetable';
|
|
34
42
|
import { MerkleTreeId } from '@aztec/stdlib/trees';
|
|
35
|
-
import type { CheckpointGlobalVariables, FailedTx, Tx } from '@aztec/stdlib/tx';
|
|
43
|
+
import type { CheckpointGlobalVariables, FailedTx, Tx, TxHash } from '@aztec/stdlib/tx';
|
|
36
44
|
import {
|
|
45
|
+
InvalidBlockProposalTxsError,
|
|
37
46
|
ReExFailedTxsError,
|
|
38
47
|
ReExInitialStateMismatchError,
|
|
39
48
|
ReExStateMismatchError,
|
|
@@ -49,12 +58,13 @@ export type BlockProposalValidationFailureReason =
|
|
|
49
58
|
| 'invalid_signature'
|
|
50
59
|
| 'invalid_proposal'
|
|
51
60
|
| 'parent_block_not_found'
|
|
52
|
-
| 'block_source_not_synced'
|
|
53
61
|
| 'parent_block_wrong_slot'
|
|
54
62
|
| 'in_hash_mismatch'
|
|
55
63
|
| 'global_variables_mismatch'
|
|
56
64
|
| 'block_number_already_exists'
|
|
57
65
|
| 'txs_not_available'
|
|
66
|
+
| 'duplicate_txs'
|
|
67
|
+
| 'invalid_embedded_txs'
|
|
58
68
|
| 'state_mismatch'
|
|
59
69
|
| 'failed_txs'
|
|
60
70
|
| 'initial_state_mismatch'
|
|
@@ -90,10 +100,12 @@ export type CheckpointProposalValidationFailureReason =
|
|
|
90
100
|
| 'invalid_fee_asset_price_modifier'
|
|
91
101
|
| 'last_block_not_found'
|
|
92
102
|
| 'block_fetch_error'
|
|
103
|
+
| 'world_state_not_synced'
|
|
93
104
|
| 'checkpoint_already_published'
|
|
94
105
|
| 'no_blocks_for_slot'
|
|
95
106
|
| 'last_block_archive_mismatch'
|
|
96
107
|
| 'too_many_blocks_in_checkpoint'
|
|
108
|
+
| 'initial_archive_mismatch'
|
|
97
109
|
| 'checkpoint_header_mismatch'
|
|
98
110
|
| 'archive_mismatch'
|
|
99
111
|
| 'out_hash_mismatch'
|
|
@@ -114,6 +126,8 @@ const CHECKPOINT_VALIDATION_REASON_TO_OUTCOME: Record<
|
|
|
114
126
|
checkpoint_already_published: undefined,
|
|
115
127
|
last_block_not_found: 'unvalidated',
|
|
116
128
|
block_fetch_error: 'unvalidated',
|
|
129
|
+
world_state_not_synced: 'unvalidated',
|
|
130
|
+
initial_archive_mismatch: 'unvalidated',
|
|
117
131
|
no_blocks_for_slot: 'unvalidated',
|
|
118
132
|
last_block_archive_mismatch: 'invalid',
|
|
119
133
|
too_many_blocks_in_checkpoint: 'invalid',
|
|
@@ -152,7 +166,54 @@ type BlockProposalSlotValidationResult =
|
|
|
152
166
|
| { isValid: true }
|
|
153
167
|
| { isValid: false; reason: 'block_proposal_beyond_checkpoint' | 'checkpoint_proposal_equivocation' };
|
|
154
168
|
|
|
155
|
-
|
|
169
|
+
const MAX_TRACKED_INVALID_PROPOSAL_SLOTS = 1000;
|
|
170
|
+
|
|
171
|
+
/** Block-proposal validation failures that constitute a slashable invalid-block offense. */
|
|
172
|
+
export const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidationFailureReason[] = [
|
|
173
|
+
'state_mismatch',
|
|
174
|
+
'failed_txs',
|
|
175
|
+
'global_variables_mismatch',
|
|
176
|
+
'invalid_proposal',
|
|
177
|
+
'parent_block_wrong_slot',
|
|
178
|
+
'in_hash_mismatch',
|
|
179
|
+
'duplicate_txs',
|
|
180
|
+
'invalid_embedded_txs',
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
/** Checkpoint-proposal validation failures that constitute a slashable invalid-checkpoint offense. */
|
|
184
|
+
export const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT: Record<
|
|
185
|
+
CheckpointProposalValidationFailureReason,
|
|
186
|
+
boolean
|
|
187
|
+
> = {
|
|
188
|
+
// enabled
|
|
189
|
+
['invalid_fee_asset_price_modifier']: true,
|
|
190
|
+
['checkpoint_header_mismatch']: true,
|
|
191
|
+
// These late mismatches should normally be caught by earlier checks, but if reached after validating the local
|
|
192
|
+
// checkpoint inputs, the proposer-signed payload disagrees with deterministic recomputation.
|
|
193
|
+
['archive_mismatch']: true,
|
|
194
|
+
['out_hash_mismatch']: true,
|
|
195
|
+
['no_blocks_for_slot']: true,
|
|
196
|
+
['too_many_blocks_in_checkpoint']: true,
|
|
197
|
+
['checkpoint_validation_failed']: true,
|
|
198
|
+
['last_block_archive_mismatch']: true,
|
|
199
|
+
|
|
200
|
+
// disabled
|
|
201
|
+
['invalid_signature']: false,
|
|
202
|
+
['last_block_not_found']: false,
|
|
203
|
+
['block_fetch_error']: false,
|
|
204
|
+
['world_state_not_synced']: false,
|
|
205
|
+
// A reorg / divergent local chain, not a proposer offense (mirrors the block path's initial_state_mismatch).
|
|
206
|
+
['initial_archive_mismatch']: false,
|
|
207
|
+
['checkpoint_already_published']: false,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Handles block and checkpoint proposals for both validator and non-validator nodes. Also tracks which slots
|
|
212
|
+
* had a slashable invalid proposal or a proposal equivocation, exposing them via the
|
|
213
|
+
* `InvalidProposalSlotSource` interface consumed by the attested-invalid-proposal slashing watcher. The
|
|
214
|
+
* tracking is populated as a side effect of validating/re-executing proposals, so any node that re-executes
|
|
215
|
+
* proposals (the default) can serve it — not only validators.
|
|
216
|
+
*/
|
|
156
217
|
export class ProposalHandler {
|
|
157
218
|
public readonly tracer: Tracer;
|
|
158
219
|
|
|
@@ -165,7 +226,7 @@ export class ProposalHandler {
|
|
|
165
226
|
};
|
|
166
227
|
|
|
167
228
|
/** Archiver reference for setting proposed checkpoints (pipelining). Set via register(). */
|
|
168
|
-
private archiver?: Pick<Archiver, 'addProposedCheckpoint'>;
|
|
229
|
+
private archiver?: Pick<Archiver, 'addProposedCheckpoint' | 'getProposedCheckpointData'>;
|
|
169
230
|
|
|
170
231
|
/** Returns current validator addresses for own-proposal detection. Set via register(). */
|
|
171
232
|
private getOwnValidatorAddresses?: () => string[];
|
|
@@ -175,6 +236,12 @@ export class ProposalHandler {
|
|
|
175
236
|
|
|
176
237
|
private checkpointProposalValidationFailureCallback?: CheckpointProposalValidationFailureCallback;
|
|
177
238
|
|
|
239
|
+
/** Slots at which a slashable invalid block or checkpoint proposal was observed. */
|
|
240
|
+
private readonly slotsWithInvalidProposals = FifoSet.withLimit<SlotNumber>(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
|
|
241
|
+
|
|
242
|
+
/** Slots at which a proposal equivocation was observed; suppresses attested-to-invalid-proposal slashing. */
|
|
243
|
+
private readonly slotsWithProposalEquivocation = FifoSet.withLimit<SlotNumber>(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
|
|
244
|
+
|
|
178
245
|
constructor(
|
|
179
246
|
private checkpointsBuilder: FullNodeCheckpointsBuilder,
|
|
180
247
|
private worldState: WorldStateSynchronizer,
|
|
@@ -183,6 +250,7 @@ export class ProposalHandler {
|
|
|
183
250
|
private txProvider: ITxProvider,
|
|
184
251
|
private blockProposalValidator: BlockProposalValidator,
|
|
185
252
|
private epochCache: EpochCache,
|
|
253
|
+
private timetable: ConsensusTimetable,
|
|
186
254
|
private config: ValidatorClientFullConfig,
|
|
187
255
|
private blobClient: BlobClientInterface,
|
|
188
256
|
private reexecutionTracker: CheckpointReexecutionTracker,
|
|
@@ -220,6 +288,26 @@ export class ProposalHandler {
|
|
|
220
288
|
this.reexecutionTracker.recordOutcome(slot, archive, 'valid', checkpointNumber);
|
|
221
289
|
}
|
|
222
290
|
|
|
291
|
+
/** Whether a slashable invalid block or checkpoint proposal was observed at the given slot (InvalidProposalSlotSource). */
|
|
292
|
+
public hasInvalidProposals(slotNumber: SlotNumber): boolean {
|
|
293
|
+
return this.slotsWithInvalidProposals.has(slotNumber);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Whether a proposal equivocation was observed at the given slot (InvalidProposalSlotSource). */
|
|
297
|
+
public hasProposalEquivocation(slotNumber: SlotNumber): boolean {
|
|
298
|
+
return this.slotsWithProposalEquivocation.has(slotNumber);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Records a slot as having a slashable invalid proposal, for offense observers (sentinel/slasher watchers). */
|
|
302
|
+
public markInvalidProposalSlot(slotNumber: SlotNumber): void {
|
|
303
|
+
this.slotsWithInvalidProposals.add(slotNumber);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Records a slot as having a proposal equivocation, which suppresses attested-to-invalid-proposal slashing. */
|
|
307
|
+
public markProposalEquivocation(slotNumber: SlotNumber): void {
|
|
308
|
+
this.slotsWithProposalEquivocation.add(slotNumber);
|
|
309
|
+
}
|
|
310
|
+
|
|
223
311
|
/**
|
|
224
312
|
* Registers handlers for block and checkpoint proposals on the p2p client.
|
|
225
313
|
* Records the p2p client so validation can inspect retained proposals.
|
|
@@ -231,7 +319,7 @@ export class ProposalHandler {
|
|
|
231
319
|
register(
|
|
232
320
|
p2pClient: P2P,
|
|
233
321
|
shouldReexecute: boolean,
|
|
234
|
-
archiver?: Pick<Archiver, 'addProposedCheckpoint'>,
|
|
322
|
+
archiver?: Pick<Archiver, 'addProposedCheckpoint' | 'getProposedCheckpointData'>,
|
|
235
323
|
getOwnValidatorAddresses?: () => string[],
|
|
236
324
|
): ProposalHandler {
|
|
237
325
|
this.p2pClient = p2pClient;
|
|
@@ -255,6 +343,18 @@ export class ProposalHandler {
|
|
|
255
343
|
});
|
|
256
344
|
return true;
|
|
257
345
|
} else {
|
|
346
|
+
// Track invalid proposals / equivocations so offense observers (the attested-invalid-proposal
|
|
347
|
+
// watcher) work on non-validator nodes too. Validators populate these via their own handlers.
|
|
348
|
+
// Skip invalid-proposal marking while the escape hatch is open, matching the validator path,
|
|
349
|
+
// which intentionally disables invalid-block slashing then.
|
|
350
|
+
if (result.reason === 'checkpoint_proposal_equivocation') {
|
|
351
|
+
this.markProposalEquivocation(slotNumber);
|
|
352
|
+
} else if (
|
|
353
|
+
SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(result.reason) &&
|
|
354
|
+
!(await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber))
|
|
355
|
+
) {
|
|
356
|
+
this.markInvalidProposalSlot(slotNumber);
|
|
357
|
+
}
|
|
258
358
|
this.log.warn(
|
|
259
359
|
`Non-validator block proposal ${blockNumber} at slot ${slotNumber} failed processing with ${result.reason}`,
|
|
260
360
|
{ blockNumber: result.blockNumber, slotNumber, reason: result.reason },
|
|
@@ -269,6 +369,11 @@ export class ProposalHandler {
|
|
|
269
369
|
|
|
270
370
|
p2pClient.registerBlockProposalHandler(blockHandler);
|
|
271
371
|
|
|
372
|
+
// p2p detects duplicate (equivocated) proposals without routing them through the handlers above, so mark
|
|
373
|
+
// the slot as equivocated here. This suppresses false-positive attested-to-invalid-proposal slashing on
|
|
374
|
+
// non-validator offense collectors. Validators overwrite this with their own richer handler.
|
|
375
|
+
p2pClient.registerDuplicateProposalCallback(info => this.markProposalEquivocation(info.slot));
|
|
376
|
+
|
|
272
377
|
// All-nodes checkpoint proposal handler: validates, caches, and sets proposed checkpoint for pipelining.
|
|
273
378
|
// Runs for all nodes (validators and non-validators). Validators get the cached result in the
|
|
274
379
|
// validator-specific callback (attestToCheckpointProposal) which runs after this one.
|
|
@@ -297,25 +402,35 @@ export class ProposalHandler {
|
|
|
297
402
|
return undefined;
|
|
298
403
|
}
|
|
299
404
|
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
405
|
+
// A proposal is "own" when it was signed by a validator key this node also owns. The true local
|
|
406
|
+
// proposer already built, validated, and stored this checkpoint before broadcasting, so a matching
|
|
407
|
+
// proposed checkpoint is already in its archiver — skip the redundant re-validation. An HA peer that
|
|
408
|
+
// shares the proposer's keys sees the same "own" proposal over gossip but never built it, so it has
|
|
409
|
+
// nothing stored; it falls through to the normal validate-and-persist path below to hydrate the
|
|
410
|
+
// proposed-checkpoint metadata it needs to build the next slot on top of this checkpoint.
|
|
305
411
|
const proposer = proposal.getSender();
|
|
306
412
|
const ownAddresses = this.getOwnValidatorAddresses?.();
|
|
307
413
|
const isOwnProposal = proposer && ownAddresses?.some(addr => addr === proposer.toString());
|
|
308
414
|
|
|
309
415
|
if (isOwnProposal) {
|
|
310
|
-
|
|
311
|
-
|
|
416
|
+
const existing = await this.archiver?.getProposedCheckpointData({ slot: proposal.slotNumber });
|
|
417
|
+
if (existing?.archive.root.equals(proposal.archive)) {
|
|
418
|
+
this.log.debug(`Skipping sync for existing own checkpoint proposal at slot ${proposal.slotNumber}`);
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
312
421
|
}
|
|
313
422
|
|
|
314
423
|
const result = await this.handleCheckpointProposal(proposal, proposalInfo);
|
|
315
424
|
if (!result.isValid) {
|
|
425
|
+
// Track invalid checkpoint proposals so offense observers (the attested-invalid-proposal watcher)
|
|
426
|
+
// work on non-validator nodes too. This handler runs for all nodes; validators also mark via the
|
|
427
|
+
// failure callback below (idempotent).
|
|
428
|
+
if (SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) {
|
|
429
|
+
this.markInvalidProposalSlot(proposal.slotNumber);
|
|
430
|
+
}
|
|
316
431
|
await this.checkpointProposalValidationFailureCallback?.(proposal, result, proposalInfo);
|
|
317
|
-
} else if (this.archiver
|
|
318
|
-
const set = await this.
|
|
432
|
+
} else if (this.archiver) {
|
|
433
|
+
const set = await this.setProposedCheckpoint(proposal);
|
|
319
434
|
if (set) {
|
|
320
435
|
this.metrics?.recordCheckpointProposalToPipelinedStateDuration(pipeliningTimer.ms());
|
|
321
436
|
}
|
|
@@ -338,7 +453,6 @@ export class ProposalHandler {
|
|
|
338
453
|
): Promise<BlockProposalValidationResult> {
|
|
339
454
|
const slotNumber = proposal.slotNumber;
|
|
340
455
|
const proposer = proposal.getSender();
|
|
341
|
-
const config = this.checkpointsBuilder.getConfig();
|
|
342
456
|
|
|
343
457
|
// Reject proposals with invalid signatures
|
|
344
458
|
if (!proposer) {
|
|
@@ -366,6 +480,20 @@ export class ProposalHandler {
|
|
|
366
480
|
return { isValid: false, reason: 'invalid_proposal' };
|
|
367
481
|
}
|
|
368
482
|
|
|
483
|
+
// A tx can only appear once in a block: the second copy would emit nullifiers already emitted by the
|
|
484
|
+
// first. This is not a relaying-peer fault, so it passes gossip validation and is classified here as
|
|
485
|
+
// proposer misbehavior. Tx collection also reconciles a deduplicated hash set against the full list,
|
|
486
|
+
// so it must not be handed a proposal with repeated hashes.
|
|
487
|
+
const uniqueTxHashes = new Set(proposal.txHashes.map(txHash => txHash.toString()));
|
|
488
|
+
if (uniqueTxHashes.size !== proposal.txHashes.length) {
|
|
489
|
+
this.log.warn(`Proposal lists duplicate tx hashes, skipping processing`, {
|
|
490
|
+
...proposalInfo,
|
|
491
|
+
txCount: proposal.txHashes.length,
|
|
492
|
+
uniqueTxCount: uniqueTxHashes.size,
|
|
493
|
+
});
|
|
494
|
+
return { isValid: false, reason: 'duplicate_txs' };
|
|
495
|
+
}
|
|
496
|
+
|
|
369
497
|
const retainedSlotValidation = await this.validateNewBlockInSlot(proposal);
|
|
370
498
|
if (!retainedSlotValidation.isValid) {
|
|
371
499
|
this.log.info(`Block proposal conflicts with retained proposals, skipping archiver processing`, {
|
|
@@ -376,20 +504,9 @@ export class ProposalHandler {
|
|
|
376
504
|
return { isValid: false, blockNumber: proposal.blockNumber, reason: retainedSlotValidation.reason };
|
|
377
505
|
}
|
|
378
506
|
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
// checkpoint prune could remove a block that would conflict with this proposal.
|
|
383
|
-
// When pipelining is enabled, the proposer builds ahead of L1 submission, so the
|
|
384
|
-
// block source won't have synced to the proposed slot yet. Skip the sync wait to
|
|
385
|
-
// avoid eating into the attestation window.
|
|
386
|
-
if (!this.epochCache.isProposerPipeliningEnabled()) {
|
|
387
|
-
const blockSourceSync = await this.waitForBlockSourceSync(slotNumber);
|
|
388
|
-
if (!blockSourceSync) {
|
|
389
|
-
this.log.warn(`Block source is not synced, skipping processing`, proposalInfo);
|
|
390
|
-
return { isValid: false, reason: 'block_source_not_synced' };
|
|
391
|
-
}
|
|
392
|
-
}
|
|
507
|
+
// The proposer builds ahead of L1 submission under pipelining, so the block source won't have
|
|
508
|
+
// synced to the proposed slot yet. We deliberately do not wait for it to sync here, to avoid
|
|
509
|
+
// eating into the attestation window.
|
|
393
510
|
|
|
394
511
|
// Check that the parent proposal is a block we know, otherwise reexecution would fail.
|
|
395
512
|
// If we don't find it immediately, we keep retrying for a while; it may be we still
|
|
@@ -417,8 +534,12 @@ export class ProposalHandler {
|
|
|
417
534
|
: BlockNumber(parentBlock.header.getBlockNumber() + 1);
|
|
418
535
|
proposalInfo.blockNumber = blockNumber;
|
|
419
536
|
|
|
420
|
-
// Check that this block number does not exist already
|
|
421
|
-
|
|
537
|
+
// Check that this block number does not exist already. During a reorg the archiver can still hold a
|
|
538
|
+
// stale block at this number (a different archive, about to be pruned) while the proposal carries the
|
|
539
|
+
// rebuilt replacement; resolveExistingBlockAtNumber waits for the local prune in that case so the
|
|
540
|
+
// rebuilt block is processed in time to attest, rather than being permanently dropped on a bare
|
|
541
|
+
// number collision.
|
|
542
|
+
const existingBlock = await this.resolveExistingBlockAtNumber(blockNumber, proposal.archive, slotNumber);
|
|
422
543
|
if (existingBlock) {
|
|
423
544
|
this.log.warn(`Block number ${blockNumber} already exists, skipping processing`, proposalInfo);
|
|
424
545
|
return { isValid: false, blockNumber, reason: 'block_number_already_exists' };
|
|
@@ -426,10 +547,11 @@ export class ProposalHandler {
|
|
|
426
547
|
|
|
427
548
|
// Collect txs from the proposal. We start doing this as early as possible,
|
|
428
549
|
// and we do it even if we don't plan to re-execute the txs, so that we have them if another node needs them.
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
}
|
|
550
|
+
const collected = await this.collectProposalTxs(proposal, blockNumber, proposalSender, proposalInfo);
|
|
551
|
+
if (collected === 'invalid_embedded_txs') {
|
|
552
|
+
return { isValid: false, blockNumber, reason: collected };
|
|
553
|
+
}
|
|
554
|
+
const { txs, missingTxs } = collected;
|
|
433
555
|
|
|
434
556
|
// Record the tx-collection outcome on the re-execution tracker
|
|
435
557
|
this.reexecutionTracker.recordTxsCollected(slotNumber, proposal.indexWithinCheckpoint, missingTxs.length === 0);
|
|
@@ -479,7 +601,7 @@ export class ProposalHandler {
|
|
|
479
601
|
epoch,
|
|
480
602
|
checkpointNumber,
|
|
481
603
|
l1Constants: this.epochCache.getL1Constants(),
|
|
482
|
-
pipeliningEnabled:
|
|
604
|
+
pipeliningEnabled: true,
|
|
483
605
|
log: this.log,
|
|
484
606
|
});
|
|
485
607
|
|
|
@@ -502,7 +624,7 @@ export class ProposalHandler {
|
|
|
502
624
|
}
|
|
503
625
|
|
|
504
626
|
// If we succeeded, push this block into the archiver (unless disabled)
|
|
505
|
-
if (reexecutionResult?.block && this.config.skipPushProposedBlocksToArchiver
|
|
627
|
+
if (reexecutionResult?.block && !this.config.skipPushProposedBlocksToArchiver) {
|
|
506
628
|
await this.blockSource.addBlock(reexecutionResult.block);
|
|
507
629
|
}
|
|
508
630
|
|
|
@@ -514,6 +636,36 @@ export class ProposalHandler {
|
|
|
514
636
|
return { isValid: true, blockNumber, reexecutionResult };
|
|
515
637
|
}
|
|
516
638
|
|
|
639
|
+
/**
|
|
640
|
+
* Collects the txs for a proposal, returning `invalid_embedded_txs` if the proposal carries a tx that fails
|
|
641
|
+
* minimum integrity validation. That is proposer misbehavior — the proposal signs both the tx hashes and the
|
|
642
|
+
* tx objects — so the caller turns it into an invalid-proposal result that reaches slashing and invalid-slot
|
|
643
|
+
* accounting, rather than letting it escape as an exception. Any other collection error is a local failure
|
|
644
|
+
* and keeps propagating.
|
|
645
|
+
*/
|
|
646
|
+
private async collectProposalTxs(
|
|
647
|
+
proposal: BlockProposal,
|
|
648
|
+
blockNumber: BlockNumber,
|
|
649
|
+
proposalSender: PeerId,
|
|
650
|
+
proposalInfo: LogData,
|
|
651
|
+
): Promise<{ txs: Tx[]; missingTxs: TxHash[] } | 'invalid_embedded_txs'> {
|
|
652
|
+
try {
|
|
653
|
+
return await this.txProvider.getTxsForBlockProposal(proposal, blockNumber, {
|
|
654
|
+
pinnedPeer: proposalSender,
|
|
655
|
+
deadline: this.getReexecutionDeadline(proposal.slotNumber),
|
|
656
|
+
});
|
|
657
|
+
} catch (error) {
|
|
658
|
+
if (!isErrorClass(error, InvalidBlockProposalTxsError)) {
|
|
659
|
+
throw error;
|
|
660
|
+
}
|
|
661
|
+
this.log.warn(`Block proposal carries ${error.invalidTxs.length} invalid txs`, {
|
|
662
|
+
...proposalInfo,
|
|
663
|
+
invalidTxs: error.invalidTxs.map(({ txHash, reasons }) => ({ txHash: txHash.toString(), reasons })),
|
|
664
|
+
});
|
|
665
|
+
return 'invalid_embedded_txs';
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
517
669
|
private async validateNewBlockInSlot(blockProposal: BlockProposal): Promise<BlockProposalSlotValidationResult> {
|
|
518
670
|
if (!this.p2pClient) {
|
|
519
671
|
return { isValid: true };
|
|
@@ -536,16 +688,14 @@ export class ProposalHandler {
|
|
|
536
688
|
|
|
537
689
|
private async getParentBlock(proposal: BlockProposal): Promise<'genesis' | BlockData | undefined> {
|
|
538
690
|
const parentArchive = proposal.blockHeader.lastArchive.root;
|
|
539
|
-
const config = this.checkpointsBuilder.getConfig();
|
|
540
691
|
const { genesisArchiveRoot } = await this.blockSource.getGenesisValues();
|
|
541
692
|
|
|
542
693
|
if (parentArchive.equals(genesisArchiveRoot)) {
|
|
543
694
|
return 'genesis';
|
|
544
695
|
}
|
|
545
696
|
|
|
546
|
-
const deadline = this.getReexecutionDeadline(proposal.slotNumber
|
|
547
|
-
const
|
|
548
|
-
const timeoutDurationMs = deadline.getTime() - currentTime;
|
|
697
|
+
const deadline = this.getReexecutionDeadline(proposal.slotNumber);
|
|
698
|
+
const timeoutDurationMs = deadline.getTime() - this.dateProvider.now();
|
|
549
699
|
|
|
550
700
|
try {
|
|
551
701
|
return (
|
|
@@ -556,7 +706,7 @@ export class ProposalHandler {
|
|
|
556
706
|
() =>
|
|
557
707
|
this.blockSource.syncImmediate().then(() => this.blockSource.getBlockData({ archive: parentArchive })),
|
|
558
708
|
'force archiver sync',
|
|
559
|
-
|
|
709
|
+
{ deadline, dateProvider: this.dateProvider },
|
|
560
710
|
0.5,
|
|
561
711
|
))
|
|
562
712
|
);
|
|
@@ -570,6 +720,63 @@ export class ProposalHandler {
|
|
|
570
720
|
}
|
|
571
721
|
}
|
|
572
722
|
|
|
723
|
+
/**
|
|
724
|
+
* Resolves whether a block genuinely already exists at `blockNumber`. Returns the existing block only if
|
|
725
|
+
* it is a true duplicate of the proposal (matching archive). During a reorg the archiver can still hold a
|
|
726
|
+
* stale fork at this number (different archive) that is about to be pruned; in that case this forces L1
|
|
727
|
+
* sync and waits, bounded by the re-execution deadline, for the prune to land, then returns `undefined` so
|
|
728
|
+
* the rebuilt block proposal can be processed in time to attest. If the prune does not complete before the
|
|
729
|
+
* deadline it returns the stale block, so the caller falls back to the safe `block_number_already_exists`
|
|
730
|
+
* rejection.
|
|
731
|
+
*/
|
|
732
|
+
private async resolveExistingBlockAtNumber(
|
|
733
|
+
blockNumber: BlockNumber,
|
|
734
|
+
proposalArchive: Fr,
|
|
735
|
+
slotNumber: SlotNumber,
|
|
736
|
+
): Promise<BlockData | undefined> {
|
|
737
|
+
const existingBlock = await this.blockSource.getBlockData({ number: blockNumber });
|
|
738
|
+
if (!existingBlock || existingBlock.archive.root.equals(proposalArchive)) {
|
|
739
|
+
return existingBlock;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// A different block already occupies this number: it may be a stale fork being pruned during a reorg, not a
|
|
743
|
+
// genuine duplicate. Wait for the local prune rather than permanently rejecting the proposal.
|
|
744
|
+
const deadline = this.getReexecutionDeadline(slotNumber);
|
|
745
|
+
if (deadline.getTime() - this.dateProvider.now() <= 0) {
|
|
746
|
+
return existingBlock;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
this.log.warn(`Block number ${blockNumber} already exists, awaiting potential prune`, {
|
|
750
|
+
blockNumber,
|
|
751
|
+
existingArchive: existingBlock.archive.root.toString(),
|
|
752
|
+
proposalArchive: proposalArchive.toString(),
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
try {
|
|
756
|
+
const { block } = await retryUntil(
|
|
757
|
+
async () => {
|
|
758
|
+
await this.blockSource.syncImmediate();
|
|
759
|
+
const block = await this.blockSource.getBlockData({ number: blockNumber });
|
|
760
|
+
// Resolve once the existing block is gone (pruned) or has been replaced by one matching the
|
|
761
|
+
// proposal — the same condition as the early return above. A matching block is returned so the
|
|
762
|
+
// caller still treats it as a genuine duplicate; an `undefined` (pruned) block lets the proposal
|
|
763
|
+
// be processed. Wrap in an object so the `undefined` case is still a truthy retry result.
|
|
764
|
+
return block === undefined || block.archive.root.equals(proposalArchive) ? { block } : undefined;
|
|
765
|
+
},
|
|
766
|
+
`prune of stale block ${blockNumber}`,
|
|
767
|
+
{ deadline, dateProvider: this.dateProvider },
|
|
768
|
+
0.5,
|
|
769
|
+
);
|
|
770
|
+
return block;
|
|
771
|
+
} catch (err) {
|
|
772
|
+
if (err instanceof TimeoutError) {
|
|
773
|
+
this.log.warn(`Timed out waiting for stale block ${blockNumber} to be pruned`, { blockNumber });
|
|
774
|
+
return existingBlock;
|
|
775
|
+
}
|
|
776
|
+
throw err;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
573
780
|
private computeCheckpointNumber(
|
|
574
781
|
proposal: BlockProposal,
|
|
575
782
|
parentBlock: 'genesis' | BlockData,
|
|
@@ -693,52 +900,14 @@ export class ProposalHandler {
|
|
|
693
900
|
return undefined;
|
|
694
901
|
}
|
|
695
902
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
return new Date(nextSlotTimestampSeconds * 1000);
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
/** Waits for the block source to sync L1 data up to at least the slot before the given one. */
|
|
708
|
-
private async waitForBlockSourceSync(slot: SlotNumber): Promise<boolean> {
|
|
709
|
-
const deadline = this.getReexecutionDeadline(slot, this.checkpointsBuilder.getConfig());
|
|
710
|
-
const timeoutMs = deadline.getTime() - this.dateProvider.now();
|
|
711
|
-
if (slot === 0) {
|
|
712
|
-
return true;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
// Make a quick check before triggering an archiver sync
|
|
716
|
-
// If we are pipelining and have a pending checkpoint number stored, we will allow the block proposal to be for a slot further
|
|
717
|
-
const syncedSlot = await this.blockSource.getSyncedL2SlotNumber();
|
|
718
|
-
if (syncedSlot !== undefined && syncedSlot + 1 + this.epochCache.pipeliningOffset() >= slot) {
|
|
719
|
-
return true;
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
try {
|
|
723
|
-
// Trigger an immediate sync of the block source, and wait until it reports being synced to the required slot
|
|
724
|
-
return await retryUntil(
|
|
725
|
-
async () => {
|
|
726
|
-
await this.blockSource.syncImmediate();
|
|
727
|
-
const updatedSyncedSlot = await this.blockSource.getSyncedL2SlotNumber();
|
|
728
|
-
return updatedSyncedSlot !== undefined && updatedSyncedSlot + 1 >= slot;
|
|
729
|
-
},
|
|
730
|
-
'wait for block source sync',
|
|
731
|
-
timeoutMs / 1000,
|
|
732
|
-
0.5,
|
|
733
|
-
);
|
|
734
|
-
} catch (err) {
|
|
735
|
-
if (err instanceof TimeoutError) {
|
|
736
|
-
this.log.warn(`Timed out waiting for block source to sync to slot ${slot}`);
|
|
737
|
-
return false;
|
|
738
|
-
} else {
|
|
739
|
-
throw err;
|
|
740
|
-
}
|
|
741
|
-
}
|
|
903
|
+
/**
|
|
904
|
+
* Hard re-execution/validation deadline for any block or checkpoint proposal targeting `slotNumber`:
|
|
905
|
+
* the single consensus `attestation_deadline` (`target_slot_start + S - 2E`). This is the latest the
|
|
906
|
+
* checkpoint can land on L1 in the target slot; all nodes agree on it. Loosened from the previous
|
|
907
|
+
* next-wall-clock-slot-boundary bound (see the timetable spec / refactor notes).
|
|
908
|
+
*/
|
|
909
|
+
private getReexecutionDeadline(slotNumber: SlotNumber): Date {
|
|
910
|
+
return new Date(this.timetable.getAttestationDeadline(slotNumber) * 1000);
|
|
742
911
|
}
|
|
743
912
|
|
|
744
913
|
private getReexecuteFailureReason(err: any): BlockProposalValidationFailureReason {
|
|
@@ -818,7 +987,7 @@ export class ProposalHandler {
|
|
|
818
987
|
);
|
|
819
988
|
|
|
820
989
|
// Build the new block
|
|
821
|
-
const deadline = this.getReexecutionDeadline(slot
|
|
990
|
+
const deadline = this.getReexecutionDeadline(slot);
|
|
822
991
|
const maxBlockGas =
|
|
823
992
|
this.config.validateMaxL2BlockGas !== undefined || this.config.validateMaxDABlockGas !== undefined
|
|
824
993
|
? new Gas(this.config.validateMaxDABlockGas ?? Infinity, this.config.validateMaxL2BlockGas ?? Infinity)
|
|
@@ -950,15 +1119,15 @@ export class ProposalHandler {
|
|
|
950
1119
|
): Promise<CheckpointProposalValidationResult> {
|
|
951
1120
|
const slot = proposal.slotNumber;
|
|
952
1121
|
|
|
953
|
-
// Block-sync deadline = the
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
const
|
|
958
|
-
const deadline = this.getReexecutionDeadline(slot, config);
|
|
959
|
-
const timeoutSeconds = Math.max(1, Math.floor((deadline.getTime() - this.dateProvider.now()) / 1000));
|
|
1122
|
+
// Block-sync/validation deadline = the single consensus attestation_deadline (target_slot_start + S
|
|
1123
|
+
// - 2E): the latest moment the proposer can submit this checkpoint and still have it land on L1 in
|
|
1124
|
+
// the target slot. Keeping validation/attestation alive until then lets validators keep attesting
|
|
1125
|
+
// right up to the proposer's real publish cutoff.
|
|
1126
|
+
const deadline = this.getReexecutionDeadline(slot);
|
|
960
1127
|
|
|
961
|
-
// Wait for last block to sync by archive
|
|
1128
|
+
// Wait for last block to sync by archive. The deadline is passed to retryUntil as an absolute date so
|
|
1129
|
+
// the remaining budget is derived from the date provider; a deadline already in the past times out
|
|
1130
|
+
// after a single attempt instead of looping (the immediate-timeout semantics of the deadline overload).
|
|
962
1131
|
let lastBlockData;
|
|
963
1132
|
try {
|
|
964
1133
|
lastBlockData = await retryUntil(
|
|
@@ -967,7 +1136,7 @@ export class ProposalHandler {
|
|
|
967
1136
|
return await this.blockSource.getBlockData({ archive: proposal.archive });
|
|
968
1137
|
},
|
|
969
1138
|
`waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
|
|
970
|
-
|
|
1139
|
+
{ deadline, dateProvider: this.dateProvider },
|
|
971
1140
|
0.5,
|
|
972
1141
|
);
|
|
973
1142
|
} catch (err) {
|
|
@@ -1015,6 +1184,7 @@ export class ProposalHandler {
|
|
|
1015
1184
|
};
|
|
1016
1185
|
}
|
|
1017
1186
|
|
|
1187
|
+
// Note this condition should never trigger, since we dont process block proposals that exceed indexWithinCheckpoint
|
|
1018
1188
|
const maxBlocksPerCheckpoint = this.config.maxBlocksPerCheckpoint;
|
|
1019
1189
|
if (maxBlocksPerCheckpoint !== undefined && blocks.length > maxBlocksPerCheckpoint) {
|
|
1020
1190
|
this.log.warn(`Checkpoint proposal exceeds maxBlocksPerCheckpoint`, {
|
|
@@ -1050,13 +1220,45 @@ export class ProposalHandler {
|
|
|
1050
1220
|
epoch,
|
|
1051
1221
|
checkpointNumber,
|
|
1052
1222
|
l1Constants: this.epochCache.getL1Constants(),
|
|
1053
|
-
pipeliningEnabled:
|
|
1223
|
+
pipeliningEnabled: true,
|
|
1054
1224
|
log: this.log,
|
|
1055
1225
|
});
|
|
1056
1226
|
|
|
1057
|
-
// Fork world state at the block before the first block
|
|
1227
|
+
// Fork world state at the block before the first block. getFork syncs world state to the parent block
|
|
1228
|
+
// first (see its doc): the block source (archiver) can already hold the block while world state still
|
|
1229
|
+
// trails it by one, and forking a not-yet-applied block throws a raw tree error that would otherwise
|
|
1230
|
+
// escape as an uncaught gossipsub error. We pass the parent's expected block hash so the sync detects a
|
|
1231
|
+
// world-state reorg (undefined for the genesis parent, where no block exists to pin). On failure we map
|
|
1232
|
+
// to a clean validation result rather than letting it escape.
|
|
1058
1233
|
const parentBlockNumber = BlockNumber(firstBlock.number - 1);
|
|
1059
|
-
|
|
1234
|
+
let forkResult: MerkleTreeWriteOperations;
|
|
1235
|
+
try {
|
|
1236
|
+
const parentBlockHash = (await this.blockSource.getBlockData({ number: parentBlockNumber }))?.blockHash;
|
|
1237
|
+
forkResult = await this.checkpointsBuilder.getFork(parentBlockNumber, parentBlockHash);
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
this.log.warn(`Failed to fork world state at block ${parentBlockNumber} for checkpoint proposal`, {
|
|
1240
|
+
...proposalInfo,
|
|
1241
|
+
parentBlockNumber,
|
|
1242
|
+
err,
|
|
1243
|
+
});
|
|
1244
|
+
return { isValid: false, reason: 'world_state_not_synced', checkpointNumber };
|
|
1245
|
+
}
|
|
1246
|
+
await using fork = forkResult;
|
|
1247
|
+
|
|
1248
|
+
// Verify the fork's archive root matches the checkpoint's expected starting archive (the archive after
|
|
1249
|
+
// the parent block). A mismatch means world state forked from a different chain than the proposal was
|
|
1250
|
+
// built on (e.g. a reorg), so recomputing the checkpoint against it would be meaningless. This mirrors
|
|
1251
|
+
// the block-proposal re-execution check and fails fast with a clean, non-slashable result instead of a
|
|
1252
|
+
// confusing downstream mismatch.
|
|
1253
|
+
const forkArchiveRoot = new Fr((await fork.getTreeInfo(MerkleTreeId.ARCHIVE)).root);
|
|
1254
|
+
if (!forkArchiveRoot.equals(proposal.checkpointHeader.lastArchiveRoot)) {
|
|
1255
|
+
this.log.warn(`Fork archive root does not match checkpoint proposal's last archive`, {
|
|
1256
|
+
...proposalInfo,
|
|
1257
|
+
forkArchiveRoot: forkArchiveRoot.toString(),
|
|
1258
|
+
expectedLastArchiveRoot: proposal.checkpointHeader.lastArchiveRoot.toString(),
|
|
1259
|
+
});
|
|
1260
|
+
return { isValid: false, reason: 'initial_archive_mismatch', checkpointNumber };
|
|
1261
|
+
}
|
|
1060
1262
|
|
|
1061
1263
|
// Create checkpoint builder with all existing blocks
|
|
1062
1264
|
const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
|
|
@@ -1178,11 +1380,11 @@ export class ProposalHandler {
|
|
|
1178
1380
|
}
|
|
1179
1381
|
|
|
1180
1382
|
/**
|
|
1181
|
-
* Derives proposed checkpoint data from validated blocks and sets it on the archiver
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1383
|
+
* Derives proposed checkpoint data from validated blocks and sets it on the archiver, so this node can
|
|
1384
|
+
* pipeline building on top of the checkpoint. Does not retry, since validation already waited for the
|
|
1385
|
+
* last block to sync.
|
|
1184
1386
|
*/
|
|
1185
|
-
private async
|
|
1387
|
+
private async setProposedCheckpoint(proposal: CheckpointProposalCore): Promise<boolean> {
|
|
1186
1388
|
if (!this.archiver) {
|
|
1187
1389
|
return false;
|
|
1188
1390
|
}
|