@aztec/validator-client 0.0.1-commit.2f68f620 → 0.0.1-commit.321f6a9
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/proposal_handler.d.ts +38 -7
- package/dest/proposal_handler.d.ts.map +1 -1
- package/dest/proposal_handler.js +230 -49
- package/dest/validator.d.ts +11 -5
- package/dest/validator.d.ts.map +1 -1
- package/dest/validator.js +51 -42
- 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/proposal_handler.ts +250 -53
- package/src/validator.ts +61 -46
package/src/proposal_handler.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Archiver } from '@aztec/archiver';
|
|
|
2
2
|
import type { BlobClientInterface } from '@aztec/blob-client/client';
|
|
3
3
|
import { type Blob, encodeCheckpointBlobDataFromBlocks, getBlobsPerL1Block } from '@aztec/blob-lib';
|
|
4
4
|
import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants';
|
|
5
|
-
import {
|
|
5
|
+
import type { EpochCache } from '@aztec/epoch-cache';
|
|
6
6
|
import { validateFeeAssetPriceModifier } from '@aztec/ethereum/contracts';
|
|
7
7
|
import {
|
|
8
8
|
BlockNumber,
|
|
@@ -13,6 +13,7 @@ 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';
|
|
@@ -22,15 +23,21 @@ import { BlockProposalValidator } from '@aztec/p2p/msg_validators';
|
|
|
22
23
|
import type { BlockData, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
|
|
23
24
|
import type { CheckpointReexecutionTracker, ReexecutionOutcome } from '@aztec/stdlib/checkpoint';
|
|
24
25
|
import { getPreviousCheckpointOutHashes, validateCheckpoint } from '@aztec/stdlib/checkpoint';
|
|
25
|
-
import { getEpochAtSlot
|
|
26
|
+
import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
|
|
26
27
|
import { Gas } from '@aztec/stdlib/gas';
|
|
27
|
-
import type {
|
|
28
|
+
import type {
|
|
29
|
+
ITxProvider,
|
|
30
|
+
MerkleTreeWriteOperations,
|
|
31
|
+
ValidatorClientFullConfig,
|
|
32
|
+
WorldStateSynchronizer,
|
|
33
|
+
} from '@aztec/stdlib/interfaces/server';
|
|
28
34
|
import {
|
|
29
35
|
type L1ToL2MessageSource,
|
|
30
36
|
accumulateCheckpointOutHashes,
|
|
31
37
|
computeInHashFromL1ToL2Messages,
|
|
32
38
|
} from '@aztec/stdlib/messaging';
|
|
33
39
|
import type { BlockProposal, CheckpointAttestation, CheckpointProposalCore } from '@aztec/stdlib/p2p';
|
|
40
|
+
import type { ConsensusTimetable } from '@aztec/stdlib/timetable';
|
|
34
41
|
import { MerkleTreeId } from '@aztec/stdlib/trees';
|
|
35
42
|
import type { CheckpointGlobalVariables, FailedTx, Tx } from '@aztec/stdlib/tx';
|
|
36
43
|
import {
|
|
@@ -89,10 +96,12 @@ export type CheckpointProposalValidationFailureReason =
|
|
|
89
96
|
| 'invalid_fee_asset_price_modifier'
|
|
90
97
|
| 'last_block_not_found'
|
|
91
98
|
| 'block_fetch_error'
|
|
99
|
+
| 'world_state_not_synced'
|
|
92
100
|
| 'checkpoint_already_published'
|
|
93
101
|
| 'no_blocks_for_slot'
|
|
94
102
|
| 'last_block_archive_mismatch'
|
|
95
103
|
| 'too_many_blocks_in_checkpoint'
|
|
104
|
+
| 'initial_archive_mismatch'
|
|
96
105
|
| 'checkpoint_header_mismatch'
|
|
97
106
|
| 'archive_mismatch'
|
|
98
107
|
| 'out_hash_mismatch'
|
|
@@ -113,6 +122,8 @@ const CHECKPOINT_VALIDATION_REASON_TO_OUTCOME: Record<
|
|
|
113
122
|
checkpoint_already_published: undefined,
|
|
114
123
|
last_block_not_found: 'unvalidated',
|
|
115
124
|
block_fetch_error: 'unvalidated',
|
|
125
|
+
world_state_not_synced: 'unvalidated',
|
|
126
|
+
initial_archive_mismatch: 'unvalidated',
|
|
116
127
|
no_blocks_for_slot: 'unvalidated',
|
|
117
128
|
last_block_archive_mismatch: 'invalid',
|
|
118
129
|
too_many_blocks_in_checkpoint: 'invalid',
|
|
@@ -151,7 +162,52 @@ type BlockProposalSlotValidationResult =
|
|
|
151
162
|
| { isValid: true }
|
|
152
163
|
| { isValid: false; reason: 'block_proposal_beyond_checkpoint' | 'checkpoint_proposal_equivocation' };
|
|
153
164
|
|
|
154
|
-
|
|
165
|
+
const MAX_TRACKED_INVALID_PROPOSAL_SLOTS = 1000;
|
|
166
|
+
|
|
167
|
+
/** Block-proposal validation failures that constitute a slashable invalid-block offense. */
|
|
168
|
+
export const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidationFailureReason[] = [
|
|
169
|
+
'state_mismatch',
|
|
170
|
+
'failed_txs',
|
|
171
|
+
'global_variables_mismatch',
|
|
172
|
+
'invalid_proposal',
|
|
173
|
+
'parent_block_wrong_slot',
|
|
174
|
+
'in_hash_mismatch',
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
/** Checkpoint-proposal validation failures that constitute a slashable invalid-checkpoint offense. */
|
|
178
|
+
export const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT: Record<
|
|
179
|
+
CheckpointProposalValidationFailureReason,
|
|
180
|
+
boolean
|
|
181
|
+
> = {
|
|
182
|
+
// enabled
|
|
183
|
+
['invalid_fee_asset_price_modifier']: true,
|
|
184
|
+
['checkpoint_header_mismatch']: true,
|
|
185
|
+
// These late mismatches should normally be caught by earlier checks, but if reached after validating the local
|
|
186
|
+
// checkpoint inputs, the proposer-signed payload disagrees with deterministic recomputation.
|
|
187
|
+
['archive_mismatch']: true,
|
|
188
|
+
['out_hash_mismatch']: true,
|
|
189
|
+
['no_blocks_for_slot']: true,
|
|
190
|
+
['too_many_blocks_in_checkpoint']: true,
|
|
191
|
+
['checkpoint_validation_failed']: true,
|
|
192
|
+
['last_block_archive_mismatch']: true,
|
|
193
|
+
|
|
194
|
+
// disabled
|
|
195
|
+
['invalid_signature']: false,
|
|
196
|
+
['last_block_not_found']: false,
|
|
197
|
+
['block_fetch_error']: false,
|
|
198
|
+
['world_state_not_synced']: false,
|
|
199
|
+
// A reorg / divergent local chain, not a proposer offense (mirrors the block path's initial_state_mismatch).
|
|
200
|
+
['initial_archive_mismatch']: false,
|
|
201
|
+
['checkpoint_already_published']: false,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Handles block and checkpoint proposals for both validator and non-validator nodes. Also tracks which slots
|
|
206
|
+
* had a slashable invalid proposal or a proposal equivocation, exposing them via the
|
|
207
|
+
* `InvalidProposalSlotSource` interface consumed by the attested-invalid-proposal slashing watcher. The
|
|
208
|
+
* tracking is populated as a side effect of validating/re-executing proposals, so any node that re-executes
|
|
209
|
+
* proposals (the default) can serve it — not only validators.
|
|
210
|
+
*/
|
|
155
211
|
export class ProposalHandler {
|
|
156
212
|
public readonly tracer: Tracer;
|
|
157
213
|
|
|
@@ -164,7 +220,7 @@ export class ProposalHandler {
|
|
|
164
220
|
};
|
|
165
221
|
|
|
166
222
|
/** Archiver reference for setting proposed checkpoints (pipelining). Set via register(). */
|
|
167
|
-
private archiver?: Pick<Archiver, 'addProposedCheckpoint'>;
|
|
223
|
+
private archiver?: Pick<Archiver, 'addProposedCheckpoint' | 'getProposedCheckpointData'>;
|
|
168
224
|
|
|
169
225
|
/** Returns current validator addresses for own-proposal detection. Set via register(). */
|
|
170
226
|
private getOwnValidatorAddresses?: () => string[];
|
|
@@ -174,6 +230,12 @@ export class ProposalHandler {
|
|
|
174
230
|
|
|
175
231
|
private checkpointProposalValidationFailureCallback?: CheckpointProposalValidationFailureCallback;
|
|
176
232
|
|
|
233
|
+
/** Slots at which a slashable invalid block or checkpoint proposal was observed. */
|
|
234
|
+
private readonly slotsWithInvalidProposals = FifoSet.withLimit<SlotNumber>(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
|
|
235
|
+
|
|
236
|
+
/** Slots at which a proposal equivocation was observed; suppresses attested-to-invalid-proposal slashing. */
|
|
237
|
+
private readonly slotsWithProposalEquivocation = FifoSet.withLimit<SlotNumber>(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
|
|
238
|
+
|
|
177
239
|
constructor(
|
|
178
240
|
private checkpointsBuilder: FullNodeCheckpointsBuilder,
|
|
179
241
|
private worldState: WorldStateSynchronizer,
|
|
@@ -182,6 +244,7 @@ export class ProposalHandler {
|
|
|
182
244
|
private txProvider: ITxProvider,
|
|
183
245
|
private blockProposalValidator: BlockProposalValidator,
|
|
184
246
|
private epochCache: EpochCache,
|
|
247
|
+
private timetable: ConsensusTimetable,
|
|
185
248
|
private config: ValidatorClientFullConfig,
|
|
186
249
|
private blobClient: BlobClientInterface,
|
|
187
250
|
private reexecutionTracker: CheckpointReexecutionTracker,
|
|
@@ -219,6 +282,26 @@ export class ProposalHandler {
|
|
|
219
282
|
this.reexecutionTracker.recordOutcome(slot, archive, 'valid', checkpointNumber);
|
|
220
283
|
}
|
|
221
284
|
|
|
285
|
+
/** Whether a slashable invalid block or checkpoint proposal was observed at the given slot (InvalidProposalSlotSource). */
|
|
286
|
+
public hasInvalidProposals(slotNumber: SlotNumber): boolean {
|
|
287
|
+
return this.slotsWithInvalidProposals.has(slotNumber);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Whether a proposal equivocation was observed at the given slot (InvalidProposalSlotSource). */
|
|
291
|
+
public hasProposalEquivocation(slotNumber: SlotNumber): boolean {
|
|
292
|
+
return this.slotsWithProposalEquivocation.has(slotNumber);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Records a slot as having a slashable invalid proposal, for offense observers (sentinel/slasher watchers). */
|
|
296
|
+
public markInvalidProposalSlot(slotNumber: SlotNumber): void {
|
|
297
|
+
this.slotsWithInvalidProposals.add(slotNumber);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Records a slot as having a proposal equivocation, which suppresses attested-to-invalid-proposal slashing. */
|
|
301
|
+
public markProposalEquivocation(slotNumber: SlotNumber): void {
|
|
302
|
+
this.slotsWithProposalEquivocation.add(slotNumber);
|
|
303
|
+
}
|
|
304
|
+
|
|
222
305
|
/**
|
|
223
306
|
* Registers handlers for block and checkpoint proposals on the p2p client.
|
|
224
307
|
* Records the p2p client so validation can inspect retained proposals.
|
|
@@ -230,7 +313,7 @@ export class ProposalHandler {
|
|
|
230
313
|
register(
|
|
231
314
|
p2pClient: P2P,
|
|
232
315
|
shouldReexecute: boolean,
|
|
233
|
-
archiver?: Pick<Archiver, 'addProposedCheckpoint'>,
|
|
316
|
+
archiver?: Pick<Archiver, 'addProposedCheckpoint' | 'getProposedCheckpointData'>,
|
|
234
317
|
getOwnValidatorAddresses?: () => string[],
|
|
235
318
|
): ProposalHandler {
|
|
236
319
|
this.p2pClient = p2pClient;
|
|
@@ -254,6 +337,18 @@ export class ProposalHandler {
|
|
|
254
337
|
});
|
|
255
338
|
return true;
|
|
256
339
|
} else {
|
|
340
|
+
// Track invalid proposals / equivocations so offense observers (the attested-invalid-proposal
|
|
341
|
+
// watcher) work on non-validator nodes too. Validators populate these via their own handlers.
|
|
342
|
+
// Skip invalid-proposal marking while the escape hatch is open, matching the validator path,
|
|
343
|
+
// which intentionally disables invalid-block slashing then.
|
|
344
|
+
if (result.reason === 'checkpoint_proposal_equivocation') {
|
|
345
|
+
this.markProposalEquivocation(slotNumber);
|
|
346
|
+
} else if (
|
|
347
|
+
SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(result.reason) &&
|
|
348
|
+
!(await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber))
|
|
349
|
+
) {
|
|
350
|
+
this.markInvalidProposalSlot(slotNumber);
|
|
351
|
+
}
|
|
257
352
|
this.log.warn(
|
|
258
353
|
`Non-validator block proposal ${blockNumber} at slot ${slotNumber} failed processing with ${result.reason}`,
|
|
259
354
|
{ blockNumber: result.blockNumber, slotNumber, reason: result.reason },
|
|
@@ -268,6 +363,11 @@ export class ProposalHandler {
|
|
|
268
363
|
|
|
269
364
|
p2pClient.registerBlockProposalHandler(blockHandler);
|
|
270
365
|
|
|
366
|
+
// p2p detects duplicate (equivocated) proposals without routing them through the handlers above, so mark
|
|
367
|
+
// the slot as equivocated here. This suppresses false-positive attested-to-invalid-proposal slashing on
|
|
368
|
+
// non-validator offense collectors. Validators overwrite this with their own richer handler.
|
|
369
|
+
p2pClient.registerDuplicateProposalCallback(info => this.markProposalEquivocation(info.slot));
|
|
370
|
+
|
|
271
371
|
// All-nodes checkpoint proposal handler: validates, caches, and sets proposed checkpoint for pipelining.
|
|
272
372
|
// Runs for all nodes (validators and non-validators). Validators get the cached result in the
|
|
273
373
|
// validator-specific callback (attestToCheckpointProposal) which runs after this one.
|
|
@@ -296,25 +396,35 @@ export class ProposalHandler {
|
|
|
296
396
|
return undefined;
|
|
297
397
|
}
|
|
298
398
|
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
399
|
+
// A proposal is "own" when it was signed by a validator key this node also owns. The true local
|
|
400
|
+
// proposer already built, validated, and stored this checkpoint before broadcasting, so a matching
|
|
401
|
+
// proposed checkpoint is already in its archiver — skip the redundant re-validation. An HA peer that
|
|
402
|
+
// shares the proposer's keys sees the same "own" proposal over gossip but never built it, so it has
|
|
403
|
+
// nothing stored; it falls through to the normal validate-and-persist path below to hydrate the
|
|
404
|
+
// proposed-checkpoint metadata it needs to build the next slot on top of this checkpoint.
|
|
304
405
|
const proposer = proposal.getSender();
|
|
305
406
|
const ownAddresses = this.getOwnValidatorAddresses?.();
|
|
306
407
|
const isOwnProposal = proposer && ownAddresses?.some(addr => addr === proposer.toString());
|
|
307
408
|
|
|
308
409
|
if (isOwnProposal) {
|
|
309
|
-
|
|
310
|
-
|
|
410
|
+
const existing = await this.archiver?.getProposedCheckpointData({ slot: proposal.slotNumber });
|
|
411
|
+
if (existing?.archive.root.equals(proposal.archive)) {
|
|
412
|
+
this.log.debug(`Skipping sync for existing own checkpoint proposal at slot ${proposal.slotNumber}`);
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
311
415
|
}
|
|
312
416
|
|
|
313
417
|
const result = await this.handleCheckpointProposal(proposal, proposalInfo);
|
|
314
418
|
if (!result.isValid) {
|
|
419
|
+
// Track invalid checkpoint proposals so offense observers (the attested-invalid-proposal watcher)
|
|
420
|
+
// work on non-validator nodes too. This handler runs for all nodes; validators also mark via the
|
|
421
|
+
// failure callback below (idempotent).
|
|
422
|
+
if (SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) {
|
|
423
|
+
this.markInvalidProposalSlot(proposal.slotNumber);
|
|
424
|
+
}
|
|
315
425
|
await this.checkpointProposalValidationFailureCallback?.(proposal, result, proposalInfo);
|
|
316
426
|
} else if (this.archiver) {
|
|
317
|
-
const set = await this.
|
|
427
|
+
const set = await this.setProposedCheckpoint(proposal);
|
|
318
428
|
if (set) {
|
|
319
429
|
this.metrics?.recordCheckpointProposalToPipelinedStateDuration(pipeliningTimer.ms());
|
|
320
430
|
}
|
|
@@ -337,7 +447,6 @@ export class ProposalHandler {
|
|
|
337
447
|
): Promise<BlockProposalValidationResult> {
|
|
338
448
|
const slotNumber = proposal.slotNumber;
|
|
339
449
|
const proposer = proposal.getSender();
|
|
340
|
-
const config = this.checkpointsBuilder.getConfig();
|
|
341
450
|
|
|
342
451
|
// Reject proposals with invalid signatures
|
|
343
452
|
if (!proposer) {
|
|
@@ -405,8 +514,12 @@ export class ProposalHandler {
|
|
|
405
514
|
: BlockNumber(parentBlock.header.getBlockNumber() + 1);
|
|
406
515
|
proposalInfo.blockNumber = blockNumber;
|
|
407
516
|
|
|
408
|
-
// Check that this block number does not exist already
|
|
409
|
-
|
|
517
|
+
// Check that this block number does not exist already. During a reorg the archiver can still hold a
|
|
518
|
+
// stale block at this number (a different archive, about to be pruned) while the proposal carries the
|
|
519
|
+
// rebuilt replacement; resolveExistingBlockAtNumber waits for the local prune in that case so the
|
|
520
|
+
// rebuilt block is processed in time to attest, rather than being permanently dropped on a bare
|
|
521
|
+
// number collision.
|
|
522
|
+
const existingBlock = await this.resolveExistingBlockAtNumber(blockNumber, proposal.archive, slotNumber);
|
|
410
523
|
if (existingBlock) {
|
|
411
524
|
this.log.warn(`Block number ${blockNumber} already exists, skipping processing`, proposalInfo);
|
|
412
525
|
return { isValid: false, blockNumber, reason: 'block_number_already_exists' };
|
|
@@ -416,7 +529,7 @@ export class ProposalHandler {
|
|
|
416
529
|
// 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.
|
|
417
530
|
const { txs, missingTxs } = await this.txProvider.getTxsForBlockProposal(proposal, blockNumber, {
|
|
418
531
|
pinnedPeer: proposalSender,
|
|
419
|
-
deadline: this.getReexecutionDeadline(slotNumber
|
|
532
|
+
deadline: this.getReexecutionDeadline(slotNumber),
|
|
420
533
|
});
|
|
421
534
|
|
|
422
535
|
// Record the tx-collection outcome on the re-execution tracker
|
|
@@ -490,7 +603,7 @@ export class ProposalHandler {
|
|
|
490
603
|
}
|
|
491
604
|
|
|
492
605
|
// If we succeeded, push this block into the archiver (unless disabled)
|
|
493
|
-
if (reexecutionResult?.block && this.config.skipPushProposedBlocksToArchiver
|
|
606
|
+
if (reexecutionResult?.block && !this.config.skipPushProposedBlocksToArchiver) {
|
|
494
607
|
await this.blockSource.addBlock(reexecutionResult.block);
|
|
495
608
|
}
|
|
496
609
|
|
|
@@ -524,16 +637,14 @@ export class ProposalHandler {
|
|
|
524
637
|
|
|
525
638
|
private async getParentBlock(proposal: BlockProposal): Promise<'genesis' | BlockData | undefined> {
|
|
526
639
|
const parentArchive = proposal.blockHeader.lastArchive.root;
|
|
527
|
-
const config = this.checkpointsBuilder.getConfig();
|
|
528
640
|
const { genesisArchiveRoot } = await this.blockSource.getGenesisValues();
|
|
529
641
|
|
|
530
642
|
if (parentArchive.equals(genesisArchiveRoot)) {
|
|
531
643
|
return 'genesis';
|
|
532
644
|
}
|
|
533
645
|
|
|
534
|
-
const deadline = this.getReexecutionDeadline(proposal.slotNumber
|
|
535
|
-
const
|
|
536
|
-
const timeoutDurationMs = deadline.getTime() - currentTime;
|
|
646
|
+
const deadline = this.getReexecutionDeadline(proposal.slotNumber);
|
|
647
|
+
const timeoutDurationMs = deadline.getTime() - this.dateProvider.now();
|
|
537
648
|
|
|
538
649
|
try {
|
|
539
650
|
return (
|
|
@@ -544,7 +655,7 @@ export class ProposalHandler {
|
|
|
544
655
|
() =>
|
|
545
656
|
this.blockSource.syncImmediate().then(() => this.blockSource.getBlockData({ archive: parentArchive })),
|
|
546
657
|
'force archiver sync',
|
|
547
|
-
|
|
658
|
+
{ deadline, dateProvider: this.dateProvider },
|
|
548
659
|
0.5,
|
|
549
660
|
))
|
|
550
661
|
);
|
|
@@ -558,6 +669,63 @@ export class ProposalHandler {
|
|
|
558
669
|
}
|
|
559
670
|
}
|
|
560
671
|
|
|
672
|
+
/**
|
|
673
|
+
* Resolves whether a block genuinely already exists at `blockNumber`. Returns the existing block only if
|
|
674
|
+
* it is a true duplicate of the proposal (matching archive). During a reorg the archiver can still hold a
|
|
675
|
+
* stale fork at this number (different archive) that is about to be pruned; in that case this forces L1
|
|
676
|
+
* sync and waits, bounded by the re-execution deadline, for the prune to land, then returns `undefined` so
|
|
677
|
+
* the rebuilt block proposal can be processed in time to attest. If the prune does not complete before the
|
|
678
|
+
* deadline it returns the stale block, so the caller falls back to the safe `block_number_already_exists`
|
|
679
|
+
* rejection.
|
|
680
|
+
*/
|
|
681
|
+
private async resolveExistingBlockAtNumber(
|
|
682
|
+
blockNumber: BlockNumber,
|
|
683
|
+
proposalArchive: Fr,
|
|
684
|
+
slotNumber: SlotNumber,
|
|
685
|
+
): Promise<BlockData | undefined> {
|
|
686
|
+
const existingBlock = await this.blockSource.getBlockData({ number: blockNumber });
|
|
687
|
+
if (!existingBlock || existingBlock.archive.root.equals(proposalArchive)) {
|
|
688
|
+
return existingBlock;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// A different block already occupies this number: it may be a stale fork being pruned during a reorg, not a
|
|
692
|
+
// genuine duplicate. Wait for the local prune rather than permanently rejecting the proposal.
|
|
693
|
+
const deadline = this.getReexecutionDeadline(slotNumber);
|
|
694
|
+
if (deadline.getTime() - this.dateProvider.now() <= 0) {
|
|
695
|
+
return existingBlock;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
this.log.warn(`Block number ${blockNumber} already exists, awaiting potential prune`, {
|
|
699
|
+
blockNumber,
|
|
700
|
+
existingArchive: existingBlock.archive.root.toString(),
|
|
701
|
+
proposalArchive: proposalArchive.toString(),
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
try {
|
|
705
|
+
const { block } = await retryUntil(
|
|
706
|
+
async () => {
|
|
707
|
+
await this.blockSource.syncImmediate();
|
|
708
|
+
const block = await this.blockSource.getBlockData({ number: blockNumber });
|
|
709
|
+
// Resolve once the existing block is gone (pruned) or has been replaced by one matching the
|
|
710
|
+
// proposal — the same condition as the early return above. A matching block is returned so the
|
|
711
|
+
// caller still treats it as a genuine duplicate; an `undefined` (pruned) block lets the proposal
|
|
712
|
+
// be processed. Wrap in an object so the `undefined` case is still a truthy retry result.
|
|
713
|
+
return block === undefined || block.archive.root.equals(proposalArchive) ? { block } : undefined;
|
|
714
|
+
},
|
|
715
|
+
`prune of stale block ${blockNumber}`,
|
|
716
|
+
{ deadline, dateProvider: this.dateProvider },
|
|
717
|
+
0.5,
|
|
718
|
+
);
|
|
719
|
+
return block;
|
|
720
|
+
} catch (err) {
|
|
721
|
+
if (err instanceof TimeoutError) {
|
|
722
|
+
this.log.warn(`Timed out waiting for stale block ${blockNumber} to be pruned`, { blockNumber });
|
|
723
|
+
return existingBlock;
|
|
724
|
+
}
|
|
725
|
+
throw err;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
561
729
|
private computeCheckpointNumber(
|
|
562
730
|
proposal: BlockProposal,
|
|
563
731
|
parentBlock: 'genesis' | BlockData,
|
|
@@ -681,15 +849,14 @@ export class ProposalHandler {
|
|
|
681
849
|
return undefined;
|
|
682
850
|
}
|
|
683
851
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
return new Date(nextSlotTimestampSeconds * 1000);
|
|
852
|
+
/**
|
|
853
|
+
* Hard re-execution/validation deadline for any block or checkpoint proposal targeting `slotNumber`:
|
|
854
|
+
* the single consensus `attestation_deadline` (`target_slot_start + S - 2E`). This is the latest the
|
|
855
|
+
* checkpoint can land on L1 in the target slot; all nodes agree on it. Loosened from the previous
|
|
856
|
+
* next-wall-clock-slot-boundary bound (see the timetable spec / refactor notes).
|
|
857
|
+
*/
|
|
858
|
+
private getReexecutionDeadline(slotNumber: SlotNumber): Date {
|
|
859
|
+
return new Date(this.timetable.getAttestationDeadline(slotNumber) * 1000);
|
|
693
860
|
}
|
|
694
861
|
|
|
695
862
|
private getReexecuteFailureReason(err: any): BlockProposalValidationFailureReason {
|
|
@@ -769,7 +936,7 @@ export class ProposalHandler {
|
|
|
769
936
|
);
|
|
770
937
|
|
|
771
938
|
// Build the new block
|
|
772
|
-
const deadline = this.getReexecutionDeadline(slot
|
|
939
|
+
const deadline = this.getReexecutionDeadline(slot);
|
|
773
940
|
const maxBlockGas =
|
|
774
941
|
this.config.validateMaxL2BlockGas !== undefined || this.config.validateMaxDABlockGas !== undefined
|
|
775
942
|
? new Gas(this.config.validateMaxDABlockGas ?? Infinity, this.config.validateMaxL2BlockGas ?? Infinity)
|
|
@@ -901,18 +1068,15 @@ export class ProposalHandler {
|
|
|
901
1068
|
): Promise<CheckpointProposalValidationResult> {
|
|
902
1069
|
const slot = proposal.slotNumber;
|
|
903
1070
|
|
|
904
|
-
// Block-sync deadline = the
|
|
905
|
-
// this checkpoint and still have it land on L1 in
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
const timeoutSeconds = Math.max(1, Math.floor((deadline.getTime() - this.dateProvider.now()) / 1000));
|
|
914
|
-
|
|
915
|
-
// Wait for last block to sync by archive
|
|
1071
|
+
// Block-sync/validation deadline = the single consensus attestation_deadline (target_slot_start + S
|
|
1072
|
+
// - 2E): the latest moment the proposer can submit this checkpoint and still have it land on L1 in
|
|
1073
|
+
// the target slot. Keeping validation/attestation alive until then lets validators keep attesting
|
|
1074
|
+
// right up to the proposer's real publish cutoff.
|
|
1075
|
+
const deadline = this.getReexecutionDeadline(slot);
|
|
1076
|
+
|
|
1077
|
+
// Wait for last block to sync by archive. The deadline is passed to retryUntil as an absolute date so
|
|
1078
|
+
// the remaining budget is derived from the date provider; a deadline already in the past times out
|
|
1079
|
+
// after a single attempt instead of looping (the immediate-timeout semantics of the deadline overload).
|
|
916
1080
|
let lastBlockData;
|
|
917
1081
|
try {
|
|
918
1082
|
lastBlockData = await retryUntil(
|
|
@@ -921,7 +1085,7 @@ export class ProposalHandler {
|
|
|
921
1085
|
return await this.blockSource.getBlockData({ archive: proposal.archive });
|
|
922
1086
|
},
|
|
923
1087
|
`waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
|
|
924
|
-
|
|
1088
|
+
{ deadline, dateProvider: this.dateProvider },
|
|
925
1089
|
0.5,
|
|
926
1090
|
);
|
|
927
1091
|
} catch (err) {
|
|
@@ -969,6 +1133,7 @@ export class ProposalHandler {
|
|
|
969
1133
|
};
|
|
970
1134
|
}
|
|
971
1135
|
|
|
1136
|
+
// Note this condition should never trigger, since we dont process block proposals that exceed indexWithinCheckpoint
|
|
972
1137
|
const maxBlocksPerCheckpoint = this.config.maxBlocksPerCheckpoint;
|
|
973
1138
|
if (maxBlocksPerCheckpoint !== undefined && blocks.length > maxBlocksPerCheckpoint) {
|
|
974
1139
|
this.log.warn(`Checkpoint proposal exceeds maxBlocksPerCheckpoint`, {
|
|
@@ -1008,9 +1173,41 @@ export class ProposalHandler {
|
|
|
1008
1173
|
log: this.log,
|
|
1009
1174
|
});
|
|
1010
1175
|
|
|
1011
|
-
// Fork world state at the block before the first block
|
|
1176
|
+
// Fork world state at the block before the first block. getFork syncs world state to the parent block
|
|
1177
|
+
// first (see its doc): the block source (archiver) can already hold the block while world state still
|
|
1178
|
+
// trails it by one, and forking a not-yet-applied block throws a raw tree error that would otherwise
|
|
1179
|
+
// escape as an uncaught gossipsub error. We pass the parent's expected block hash so the sync detects a
|
|
1180
|
+
// world-state reorg (undefined for the genesis parent, where no block exists to pin). On failure we map
|
|
1181
|
+
// to a clean validation result rather than letting it escape.
|
|
1012
1182
|
const parentBlockNumber = BlockNumber(firstBlock.number - 1);
|
|
1013
|
-
|
|
1183
|
+
let forkResult: MerkleTreeWriteOperations;
|
|
1184
|
+
try {
|
|
1185
|
+
const parentBlockHash = (await this.blockSource.getBlockData({ number: parentBlockNumber }))?.blockHash;
|
|
1186
|
+
forkResult = await this.checkpointsBuilder.getFork(parentBlockNumber, parentBlockHash);
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
this.log.warn(`Failed to fork world state at block ${parentBlockNumber} for checkpoint proposal`, {
|
|
1189
|
+
...proposalInfo,
|
|
1190
|
+
parentBlockNumber,
|
|
1191
|
+
err,
|
|
1192
|
+
});
|
|
1193
|
+
return { isValid: false, reason: 'world_state_not_synced', checkpointNumber };
|
|
1194
|
+
}
|
|
1195
|
+
await using fork = forkResult;
|
|
1196
|
+
|
|
1197
|
+
// Verify the fork's archive root matches the checkpoint's expected starting archive (the archive after
|
|
1198
|
+
// the parent block). A mismatch means world state forked from a different chain than the proposal was
|
|
1199
|
+
// built on (e.g. a reorg), so recomputing the checkpoint against it would be meaningless. This mirrors
|
|
1200
|
+
// the block-proposal re-execution check and fails fast with a clean, non-slashable result instead of a
|
|
1201
|
+
// confusing downstream mismatch.
|
|
1202
|
+
const forkArchiveRoot = new Fr((await fork.getTreeInfo(MerkleTreeId.ARCHIVE)).root);
|
|
1203
|
+
if (!forkArchiveRoot.equals(proposal.checkpointHeader.lastArchiveRoot)) {
|
|
1204
|
+
this.log.warn(`Fork archive root does not match checkpoint proposal's last archive`, {
|
|
1205
|
+
...proposalInfo,
|
|
1206
|
+
forkArchiveRoot: forkArchiveRoot.toString(),
|
|
1207
|
+
expectedLastArchiveRoot: proposal.checkpointHeader.lastArchiveRoot.toString(),
|
|
1208
|
+
});
|
|
1209
|
+
return { isValid: false, reason: 'initial_archive_mismatch', checkpointNumber };
|
|
1210
|
+
}
|
|
1014
1211
|
|
|
1015
1212
|
// Create checkpoint builder with all existing blocks
|
|
1016
1213
|
const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
|
|
@@ -1132,11 +1329,11 @@ export class ProposalHandler {
|
|
|
1132
1329
|
}
|
|
1133
1330
|
|
|
1134
1331
|
/**
|
|
1135
|
-
* Derives proposed checkpoint data from validated blocks and sets it on the archiver
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1332
|
+
* Derives proposed checkpoint data from validated blocks and sets it on the archiver, so this node can
|
|
1333
|
+
* pipeline building on top of the checkpoint. Does not retry, since validation already waited for the
|
|
1334
|
+
* last block to sync.
|
|
1138
1335
|
*/
|
|
1139
|
-
private async
|
|
1336
|
+
private async setProposedCheckpoint(proposal: CheckpointProposalCore): Promise<boolean> {
|
|
1140
1337
|
if (!this.archiver) {
|
|
1141
1338
|
return false;
|
|
1142
1339
|
}
|