@aztec-labs/validator-client 6.0.0-rc.1 → 6.0.0-rc.2
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/dest/proposal_handler.d.ts +24 -6
- package/dest/proposal_handler.d.ts.map +1 -1
- package/dest/proposal_handler.js +182 -38
- package/dest/streaming_inbox_checks.d.ts +1 -1
- package/dest/streaming_inbox_checks.d.ts.map +1 -1
- package/dest/streaming_inbox_checks.js +13 -7
- package/dest/validator.d.ts +5 -1
- package/dest/validator.d.ts.map +1 -1
- package/dest/validator.js +59 -25
- package/package.json +19 -19
- package/src/proposal_handler.ts +236 -42
- package/src/streaming_inbox_checks.ts +15 -6
- package/src/validator.ts +68 -28
package/src/proposal_handler.ts
CHANGED
|
@@ -82,6 +82,9 @@ export type BlockProposalValidationFailureReason =
|
|
|
82
82
|
| 'invalid_signature'
|
|
83
83
|
| 'invalid_proposal'
|
|
84
84
|
| 'parent_block_not_found'
|
|
85
|
+
// The parent was found, but pruned from the local chain before validation finished, so a verdict read off the
|
|
86
|
+
// local chain may describe other blocks than the ones the proposal builds on. Never proposer misconduct.
|
|
87
|
+
| 'parent_block_pruned_during_validation'
|
|
85
88
|
| 'parent_block_wrong_slot'
|
|
86
89
|
// Streaming Inbox per-block acceptance failures.
|
|
87
90
|
| StreamingBlockCheckReason
|
|
@@ -124,12 +127,17 @@ export type CheckpointProposalValidationFailureReason =
|
|
|
124
127
|
| 'invalid_signature'
|
|
125
128
|
| 'invalid_fee_asset_price_modifier'
|
|
126
129
|
| 'last_block_not_found'
|
|
130
|
+
// The checkpoint's last block was found, but pruned from the local chain before validation finished, so a verdict
|
|
131
|
+
// read off the local chain may describe other blocks than the ones the proposal builds on. Never proposer misconduct.
|
|
132
|
+
| 'last_block_pruned_during_validation'
|
|
127
133
|
| 'block_fetch_error'
|
|
128
134
|
| 'world_state_not_synced'
|
|
129
135
|
| 'checkpoint_already_published'
|
|
130
136
|
| 'last_block_archive_mismatch'
|
|
131
137
|
| 'too_many_blocks_in_checkpoint'
|
|
132
138
|
| 'initial_archive_mismatch'
|
|
139
|
+
// The header's lastArchiveRoot is not the archive the checkpoint's first block builds on.
|
|
140
|
+
| 'last_archive_root_mismatch'
|
|
133
141
|
| 'checkpoint_header_mismatch'
|
|
134
142
|
| 'archive_mismatch'
|
|
135
143
|
| 'out_hash_mismatch'
|
|
@@ -161,8 +169,8 @@ type CheckpointInboxPrefixReason = Extract<
|
|
|
161
169
|
*
|
|
162
170
|
* Matched on the error name rather than with `instanceof`, because the archiver package is only a dev dependency
|
|
163
171
|
* here: the validator talks to its archiver through the `L2BlockSink` interface and must not take a runtime
|
|
164
|
-
* dependency on the implementation to classify its errors.
|
|
165
|
-
*
|
|
172
|
+
* dependency on the implementation to classify its errors. Every reason returned here is a local-view disagreement,
|
|
173
|
+
* so none is in {@link SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT}.
|
|
166
174
|
*/
|
|
167
175
|
function getInboxPrefixInsertFailureReason(err: unknown): StreamingBlockCheckReason | undefined {
|
|
168
176
|
if (!(err instanceof Error)) {
|
|
@@ -174,8 +182,11 @@ function getInboxPrefixInsertFailureReason(err: unknown): StreamingBlockCheckRea
|
|
|
174
182
|
case 'InboxPrefixNotSyncedError':
|
|
175
183
|
case 'ProposedBlockParentNotFoundError':
|
|
176
184
|
return 'inbox_prefix_unavailable';
|
|
185
|
+
// An insert-time consumption rewind means an L1 reorg landed between the deterministic metadata check (which
|
|
186
|
+
// already passed) and the insert, so it is a local-view disagreement, not proposer misconduct. Report it as the
|
|
187
|
+
// non-slashable prefix mismatch; the deterministic metadata check remains the only slashing evidence for a rewind.
|
|
177
188
|
case 'InboxConsumptionRewindsError':
|
|
178
|
-
return '
|
|
189
|
+
return 'inbox_prefix_mismatch';
|
|
179
190
|
default:
|
|
180
191
|
return undefined;
|
|
181
192
|
}
|
|
@@ -195,9 +206,11 @@ const CHECKPOINT_VALIDATION_REASON_TO_OUTCOME: Record<
|
|
|
195
206
|
invalid_fee_asset_price_modifier: 'invalid',
|
|
196
207
|
checkpoint_already_published: undefined,
|
|
197
208
|
last_block_not_found: 'unvalidated',
|
|
209
|
+
last_block_pruned_during_validation: 'unvalidated',
|
|
198
210
|
block_fetch_error: 'unvalidated',
|
|
199
211
|
world_state_not_synced: 'unvalidated',
|
|
200
212
|
initial_archive_mismatch: 'unvalidated',
|
|
213
|
+
last_archive_root_mismatch: 'invalid',
|
|
201
214
|
last_block_archive_mismatch: 'invalid',
|
|
202
215
|
too_many_blocks_in_checkpoint: 'invalid',
|
|
203
216
|
checkpoint_header_mismatch: 'invalid',
|
|
@@ -241,6 +254,9 @@ type CheckpointComputationResult =
|
|
|
241
254
|
| { checkpointNumber: CheckpointNumber; reason?: undefined }
|
|
242
255
|
| { checkpointNumber?: undefined; reason: 'invalid_proposal' | 'global_variables_mismatch' };
|
|
243
256
|
|
|
257
|
+
/** A block proposal's log context, completed with its block and checkpoint numbers as validation resolves them. */
|
|
258
|
+
type BlockProposalLogInfo = LogData & { blockNumber?: BlockNumber; checkpointNumber?: CheckpointNumber };
|
|
259
|
+
|
|
244
260
|
type BlockProposalSlotValidationResult =
|
|
245
261
|
| { isValid: true }
|
|
246
262
|
| { isValid: false; reason: 'block_proposal_beyond_checkpoint' | 'checkpoint_proposal_equivocation' };
|
|
@@ -332,16 +348,45 @@ export type BlockProposalObservers = {
|
|
|
332
348
|
) => void;
|
|
333
349
|
};
|
|
334
350
|
|
|
335
|
-
/**
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
'
|
|
343
|
-
'
|
|
344
|
-
]
|
|
351
|
+
/**
|
|
352
|
+
* Block-proposal validation failures classified by whether they constitute a slashable invalid-block offense.
|
|
353
|
+
* A `Record` over the whole reason union, not a plain array, so a newly added reason - in particular one that reaches
|
|
354
|
+
* `BlockProposalValidationFailureReason` through `StreamingBlockCheckReason` - fails to compile until it is classified
|
|
355
|
+
* here, the way {@link SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT} already does for checkpoints.
|
|
356
|
+
*/
|
|
357
|
+
export const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: Record<BlockProposalValidationFailureReason, boolean> = {
|
|
358
|
+
// enabled: deterministic offenses computed from the block's own content, so every honest node rejects them.
|
|
359
|
+
['invalid_proposal']: true,
|
|
360
|
+
['state_mismatch']: true,
|
|
361
|
+
['failed_txs']: true,
|
|
362
|
+
['global_variables_mismatch']: true,
|
|
363
|
+
['parent_block_wrong_slot']: true,
|
|
364
|
+
['duplicate_txs']: true,
|
|
365
|
+
['invalid_embedded_txs']: true,
|
|
366
|
+
// Deterministic streaming-Inbox violations: computed from the block's own content by the metadata check, so a
|
|
367
|
+
// reject is the proposer's fault.
|
|
368
|
+
['consumption_moves_backwards']: true,
|
|
369
|
+
['bundle_over_block_cap']: true,
|
|
370
|
+
['checkpoint_over_msg_cap']: true,
|
|
371
|
+
|
|
372
|
+
// disabled: local-view disagreements, timeouts, and this node's own inability to check - never proposer misconduct.
|
|
373
|
+
['invalid_signature']: false,
|
|
374
|
+
['parent_block_not_found']: false,
|
|
375
|
+
// A parent found once but pruned mid-validation; the verdict may describe another chain, not proposer misconduct.
|
|
376
|
+
['parent_block_pruned_during_validation']: false,
|
|
377
|
+
// The local-view streaming reasons: a trailing archiver or an unfollowed reorg, including an insert-time
|
|
378
|
+
// consumption rewind reported as inbox_prefix_mismatch.
|
|
379
|
+
['inbox_prefix_unavailable']: false,
|
|
380
|
+
['inbox_prefix_mismatch']: false,
|
|
381
|
+
['block_number_already_exists']: false,
|
|
382
|
+
['txs_not_available']: false,
|
|
383
|
+
['initial_state_mismatch']: false,
|
|
384
|
+
['timeout']: false,
|
|
385
|
+
['block_proposal_beyond_checkpoint']: false,
|
|
386
|
+
// Equivocation has its own offense path (markProposalEquivocation), so it is not a slashable invalid-block reason.
|
|
387
|
+
['checkpoint_proposal_equivocation']: false,
|
|
388
|
+
['unknown_error']: false,
|
|
389
|
+
};
|
|
345
390
|
|
|
346
391
|
/** Checkpoint-proposal validation failures that constitute a slashable invalid-checkpoint offense. */
|
|
347
392
|
export const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT: Record<
|
|
@@ -351,6 +396,8 @@ export const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT: Record<
|
|
|
351
396
|
// enabled
|
|
352
397
|
['invalid_fee_asset_price_modifier']: true,
|
|
353
398
|
['checkpoint_header_mismatch']: true,
|
|
399
|
+
// An unprovable header: the checkpoint circuit derives lastArchiveRoot from the first block's lastArchive.
|
|
400
|
+
['last_archive_root_mismatch']: true,
|
|
354
401
|
// These late mismatches should normally be caught by earlier checks, but if reached after validating the local
|
|
355
402
|
// checkpoint inputs, the proposer-signed payload disagrees with deterministic recomputation.
|
|
356
403
|
['archive_mismatch']: true,
|
|
@@ -372,13 +419,30 @@ export const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT: Record<
|
|
|
372
419
|
['inbox_endpoint_mismatch']: false,
|
|
373
420
|
['invalid_signature']: false,
|
|
374
421
|
['last_block_not_found']: false,
|
|
422
|
+
// The local chain was pruned under the validation, so a slashable verdict may describe blocks of another chain.
|
|
423
|
+
['last_block_pruned_during_validation']: false,
|
|
375
424
|
['block_fetch_error']: false,
|
|
376
425
|
['world_state_not_synced']: false,
|
|
377
|
-
//
|
|
426
|
+
// World state forked off the chain of the local archiver blocks being rebuilt (e.g. a reorg racing the fork), not
|
|
427
|
+
// a proposer offense (mirrors the block path's initial_state_mismatch).
|
|
378
428
|
['initial_archive_mismatch']: false,
|
|
379
429
|
['checkpoint_already_published']: false,
|
|
380
430
|
};
|
|
381
431
|
|
|
432
|
+
/** Whether a block-proposal validation result is a rejection for a slashable reason. */
|
|
433
|
+
function isSlashableBlockProposalResult(
|
|
434
|
+
result: BlockProposalValidationResult,
|
|
435
|
+
): result is BlockProposalValidationFailureResult {
|
|
436
|
+
return !result.isValid && SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT[result.reason];
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Whether a checkpoint-proposal validation result is a rejection for a slashable reason. */
|
|
440
|
+
function isSlashableCheckpointProposalResult(
|
|
441
|
+
result: CheckpointProposalValidationResult,
|
|
442
|
+
): result is CheckpointProposalValidationFailureResult {
|
|
443
|
+
return !result.isValid && SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason];
|
|
444
|
+
}
|
|
445
|
+
|
|
382
446
|
/**
|
|
383
447
|
* Handles block and checkpoint proposals for both validator and non-validator nodes. Also tracks which slots
|
|
384
448
|
* had a slashable invalid proposal or a proposal equivocation, exposing them via the
|
|
@@ -502,7 +566,7 @@ export class ProposalHandler {
|
|
|
502
566
|
...(await this.observedProposal(proposal)),
|
|
503
567
|
accepted: result.isValid && !escapeHatchOpen,
|
|
504
568
|
reason: result.isValid ? undefined : result.reason,
|
|
505
|
-
slashable:
|
|
569
|
+
slashable: isSlashableBlockProposalResult(result),
|
|
506
570
|
escapeHatchOpen,
|
|
507
571
|
});
|
|
508
572
|
}
|
|
@@ -608,7 +672,7 @@ export class ProposalHandler {
|
|
|
608
672
|
if (result.reason === 'checkpoint_proposal_equivocation') {
|
|
609
673
|
this.markProposalEquivocation(slotNumber);
|
|
610
674
|
} else if (
|
|
611
|
-
|
|
675
|
+
isSlashableBlockProposalResult(result) &&
|
|
612
676
|
!(await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber))
|
|
613
677
|
) {
|
|
614
678
|
this.markInvalidProposalSlot(slotNumber);
|
|
@@ -692,7 +756,7 @@ export class ProposalHandler {
|
|
|
692
756
|
// Track invalid checkpoint proposals so offense observers (the attested-invalid-proposal watcher)
|
|
693
757
|
// work on non-validator nodes too. This handler runs for all nodes; validators also mark via the
|
|
694
758
|
// failure callback below (idempotent).
|
|
695
|
-
if (
|
|
759
|
+
if (isSlashableCheckpointProposalResult(result)) {
|
|
696
760
|
this.markInvalidProposalSlot(proposal.slotNumber);
|
|
697
761
|
this.markInvalidCheckpointProposal(proposal.slotNumber, proposal.getPayloadHash());
|
|
698
762
|
}
|
|
@@ -735,11 +799,11 @@ export class ProposalHandler {
|
|
|
735
799
|
return { isValid: false, reason: 'invalid_signature' };
|
|
736
800
|
}
|
|
737
801
|
|
|
738
|
-
const proposalInfo = {
|
|
802
|
+
const proposalInfo: BlockProposalLogInfo = {
|
|
739
803
|
...proposal.toBlockInfo(),
|
|
740
804
|
proposer: proposer.toString(),
|
|
741
|
-
blockNumber: undefined
|
|
742
|
-
checkpointNumber: undefined
|
|
805
|
+
blockNumber: undefined,
|
|
806
|
+
checkpointNumber: undefined,
|
|
743
807
|
};
|
|
744
808
|
|
|
745
809
|
this.log.info(`Processing proposal for slot ${slotNumber}`, {
|
|
@@ -788,6 +852,55 @@ export class ProposalHandler {
|
|
|
788
852
|
return { isValid: false, reason: 'parent_block_not_found' };
|
|
789
853
|
}
|
|
790
854
|
|
|
855
|
+
const result = await this.validateBlockProposalOnParent(
|
|
856
|
+
proposal,
|
|
857
|
+
parentBlock,
|
|
858
|
+
proposalSender,
|
|
859
|
+
shouldReexecute,
|
|
860
|
+
proposalInfo,
|
|
861
|
+
);
|
|
862
|
+
// A genesis parent cannot be pruned.
|
|
863
|
+
return parentBlock !== 'genesis' && isSlashableBlockProposalResult(result)
|
|
864
|
+
? await this.checkParentStillLocal(proposal, result, proposalInfo)
|
|
865
|
+
: result;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Demotes a block-proposal rejection when the proposal's parent is no longer found by archive. The parent is
|
|
870
|
+
* fetched once, but later checks read the local chain by number, and the archiver can prune the parent and insert
|
|
871
|
+
* other blocks at the same numbers while they run: under pipelining, when a checkpoint lands on L1 differing from the
|
|
872
|
+
* version this node gossiped. A prune removes the pruned blocks' archive entries, and an archive root commits to the
|
|
873
|
+
* block's whole history, so a parent still found by archive means the chain below it is still its own ancestry. A
|
|
874
|
+
* parent that is gone means the verdict may rest on another chain, which says nothing about the proposer.
|
|
875
|
+
*/
|
|
876
|
+
private async checkParentStillLocal(
|
|
877
|
+
proposal: BlockProposal,
|
|
878
|
+
result: BlockProposalValidationFailureResult,
|
|
879
|
+
proposalInfo: BlockProposalLogInfo,
|
|
880
|
+
): Promise<BlockProposalValidationFailureResult> {
|
|
881
|
+
if ((await this.getParentBlock(proposal, { awaitSync: false })) !== undefined) {
|
|
882
|
+
return result;
|
|
883
|
+
}
|
|
884
|
+
this.log.warn(
|
|
885
|
+
`Parent block was pruned while validating the proposal, not attributing the rejection to its proposer`,
|
|
886
|
+
{
|
|
887
|
+
...proposalInfo,
|
|
888
|
+
originalReason: result.reason,
|
|
889
|
+
},
|
|
890
|
+
);
|
|
891
|
+
return { ...result, reason: 'parent_block_pruned_during_validation' };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Validates a block proposal against the parent block it builds on, as fetched by the parent archive. */
|
|
895
|
+
private async validateBlockProposalOnParent(
|
|
896
|
+
proposal: ValidatedBlockProposal,
|
|
897
|
+
parentBlock: 'genesis' | BlockData,
|
|
898
|
+
proposalSender: PeerId,
|
|
899
|
+
shouldReexecute: boolean,
|
|
900
|
+
proposalInfo: BlockProposalLogInfo,
|
|
901
|
+
): Promise<BlockProposalValidationResult> {
|
|
902
|
+
const slotNumber = proposal.slotNumber;
|
|
903
|
+
|
|
791
904
|
// Check that the parent block's slot is not greater than the proposal's slot.
|
|
792
905
|
if (parentBlock !== 'genesis' && parentBlock.header.getSlot() > slotNumber) {
|
|
793
906
|
this.log.warn(`Parent block slot is greater than proposal slot, skipping processing`, {
|
|
@@ -1030,7 +1143,14 @@ export class ProposalHandler {
|
|
|
1030
1143
|
}
|
|
1031
1144
|
}
|
|
1032
1145
|
|
|
1033
|
-
|
|
1146
|
+
/**
|
|
1147
|
+
* Looks up the block a proposal builds on by its parent archive. Unless `awaitSync` is false, a parent that is not
|
|
1148
|
+
* local yet is waited for through forced archiver syncs until the slot's attestation deadline.
|
|
1149
|
+
*/
|
|
1150
|
+
private async getParentBlock(
|
|
1151
|
+
proposal: BlockProposal,
|
|
1152
|
+
{ awaitSync = true }: { awaitSync?: boolean } = {},
|
|
1153
|
+
): Promise<'genesis' | BlockData | undefined> {
|
|
1034
1154
|
const parentArchive = proposal.blockHeader.lastArchive.root;
|
|
1035
1155
|
const { genesisArchiveRoot } = await this.blockSource.getGenesisValues();
|
|
1036
1156
|
|
|
@@ -1043,7 +1163,7 @@ export class ProposalHandler {
|
|
|
1043
1163
|
if (parentBlock !== undefined) {
|
|
1044
1164
|
return parentBlock;
|
|
1045
1165
|
}
|
|
1046
|
-
if (this.getReexecutionDeadline(proposal.slotNumber).getTime() - this.dateProvider.now() <= 0) {
|
|
1166
|
+
if (!awaitSync || this.getReexecutionDeadline(proposal.slotNumber).getTime() - this.dateProvider.now() <= 0) {
|
|
1047
1167
|
return undefined;
|
|
1048
1168
|
}
|
|
1049
1169
|
const synced = await this.awaitLocalSync(proposal.slotNumber, 'force archiver sync', () =>
|
|
@@ -1302,6 +1422,10 @@ export class ProposalHandler {
|
|
|
1302
1422
|
* never saw. The retry re-runs the whole read (metadata plus range) on each attempt rather than reusing a range
|
|
1303
1423
|
* from an earlier view, because the parent and checkpoint-start counts it derives can move while a reorg is being
|
|
1304
1424
|
* followed.
|
|
1425
|
+
*
|
|
1426
|
+
* Every attempt's unexpected error is kept, not only the first one's. A read that starts as ordinary sync lag and
|
|
1427
|
+
* later hits a store fault or a broken provider is the case worth reporting, and reporting only the first attempt
|
|
1428
|
+
* discards exactly that: the diagnosis a node whose retries ran out has to offer is its latest failure.
|
|
1305
1429
|
*/
|
|
1306
1430
|
private async awaitStreamingBlockBundle(
|
|
1307
1431
|
proposal: BlockProposal,
|
|
@@ -1309,9 +1433,16 @@ export class ProposalHandler {
|
|
|
1309
1433
|
parentBlock: 'genesis' | BlockData,
|
|
1310
1434
|
proposalInfo: LogData,
|
|
1311
1435
|
): Promise<StreamingBlockCheckResult> {
|
|
1436
|
+
let latestUnexpectedError: string | undefined;
|
|
1312
1437
|
const readBundle = async (): Promise<StreamingBlockCheckResult> => {
|
|
1313
1438
|
const metadata = await this.checkStreamingBlockMetadata(proposal, blockNumber, parentBlock);
|
|
1314
|
-
|
|
1439
|
+
const result: StreamingBlockCheckResult = metadata.accepted
|
|
1440
|
+
? await readStreamingBlockBundle(this.l1ToL2MessageSource, metadata)
|
|
1441
|
+
: metadata;
|
|
1442
|
+
if (!result.accepted && result.error !== undefined) {
|
|
1443
|
+
latestUnexpectedError = result.error;
|
|
1444
|
+
}
|
|
1445
|
+
return result;
|
|
1315
1446
|
};
|
|
1316
1447
|
|
|
1317
1448
|
const first = await readBundle();
|
|
@@ -1334,12 +1465,14 @@ export class ProposalHandler {
|
|
|
1334
1465
|
reason: 'inbox_prefix_sync_timeout',
|
|
1335
1466
|
firstReason: first.reason,
|
|
1336
1467
|
// Set only when the message source failed for a reason the checks did not anticipate, rather than sync lag.
|
|
1337
|
-
|
|
1468
|
+
// The latest such failure, since a later store fault says more about why this node gave up than the first
|
|
1469
|
+
// attempt's lag does.
|
|
1470
|
+
error: latestUnexpectedError,
|
|
1338
1471
|
slot: slotNumber,
|
|
1339
1472
|
waitedMs: timer.ms(),
|
|
1340
1473
|
...proposalInfo,
|
|
1341
1474
|
});
|
|
1342
|
-
return first;
|
|
1475
|
+
return { ...first, error: latestUnexpectedError };
|
|
1343
1476
|
}
|
|
1344
1477
|
return resolved;
|
|
1345
1478
|
}
|
|
@@ -1524,8 +1657,11 @@ export class ProposalHandler {
|
|
|
1524
1657
|
|
|
1525
1658
|
/**
|
|
1526
1659
|
* Reads the blocks of a slot as one snapshot for checkpoint validation, locating the checkpoint's last block by the
|
|
1527
|
-
* signed archive. Undefined when the archive is not among the slot's blocks
|
|
1528
|
-
* other,
|
|
1660
|
+
* signed archive. Undefined when the archive is not among the slot's blocks, the blocks do not chain onto each
|
|
1661
|
+
* other, or they are not one checkpoint's blocks from its first one on. All of these are local state in motion:
|
|
1662
|
+
* the archiver replacing or pruning blocks while they were read, or skipping a block whose body it cannot load.
|
|
1663
|
+
* Checkpoint validation relies on `blocks[0]` being the checkpoint's first block to attribute a wrong
|
|
1664
|
+
* lastArchiveRoot to the proposer.
|
|
1529
1665
|
*/
|
|
1530
1666
|
private async readCheckpointBlocksSnapshot(
|
|
1531
1667
|
slot: SlotNumber,
|
|
@@ -1538,8 +1674,11 @@ export class ProposalHandler {
|
|
|
1538
1674
|
}
|
|
1539
1675
|
const contiguous = blocks.every(
|
|
1540
1676
|
(block, i) =>
|
|
1541
|
-
|
|
1542
|
-
|
|
1677
|
+
block.checkpointNumber === blocks[0].checkpointNumber &&
|
|
1678
|
+
block.indexWithinCheckpoint === i &&
|
|
1679
|
+
(i === 0 ||
|
|
1680
|
+
(block.number === blocks[i - 1].number + 1 &&
|
|
1681
|
+
block.header.lastArchive.root.equals(blocks[i - 1].archive.root))),
|
|
1543
1682
|
);
|
|
1544
1683
|
return contiguous ? { blocks, lastBlockIndex } : undefined;
|
|
1545
1684
|
}
|
|
@@ -1770,7 +1909,10 @@ export class ProposalHandler {
|
|
|
1770
1909
|
);
|
|
1771
1910
|
result = { isValid: false, reason: 'invalid_fee_asset_price_modifier' };
|
|
1772
1911
|
} else {
|
|
1773
|
-
|
|
1912
|
+
const validation = await this.validateCheckpointProposal(proposal, proposalInfo);
|
|
1913
|
+
result = isSlashableCheckpointProposalResult(validation)
|
|
1914
|
+
? await this.checkLastBlockStillLocal(proposal, validation, proposalInfo)
|
|
1915
|
+
: validation;
|
|
1774
1916
|
}
|
|
1775
1917
|
|
|
1776
1918
|
this.lastCheckpointValidationResult = { payloadHash, result };
|
|
@@ -1803,10 +1945,16 @@ export class ProposalHandler {
|
|
|
1803
1945
|
// slot is a different question, and still records. A recorded `invalid` is protected for the slot outright:
|
|
1804
1946
|
// the tracker keys its slot entry by slot alone, so an equivocating proposer whose second proposal this node
|
|
1805
1947
|
// could not check would otherwise erase the first one's determination.
|
|
1948
|
+
//
|
|
1949
|
+
// The protected `valid` is matched by slot and archive as well as by checkpoint number, because the second look
|
|
1950
|
+
// can fail before the blocks are loaded and then carries no checkpoint number at all: pruning the block the
|
|
1951
|
+
// cached verdict rested on makes revalidation report `last_block_not_found`, which by checkpoint number alone
|
|
1952
|
+
// is indistinguishable from a first evaluation and would overwrite the verdict this node reached.
|
|
1806
1953
|
const outcome = result.isValid ? ('valid' as const) : CHECKPOINT_VALIDATION_REASON_TO_OUTCOME[result.reason];
|
|
1807
1954
|
const wouldForgetVerdict =
|
|
1808
1955
|
outcome === 'unvalidated' &&
|
|
1809
1956
|
(this.reexecutionTracker.getOutcomeForSlot(slot) === 'invalid' ||
|
|
1957
|
+
this.reexecutionTracker.hasValidOutcomeForSlot(slot, proposal.archive) ||
|
|
1810
1958
|
(result.checkpointNumber !== undefined &&
|
|
1811
1959
|
this.reexecutionTracker.hasReexecuted(result.checkpointNumber, proposal.archive)));
|
|
1812
1960
|
if (outcome !== undefined && !wouldForgetVerdict) {
|
|
@@ -1915,12 +2063,47 @@ export class ProposalHandler {
|
|
|
1915
2063
|
}
|
|
1916
2064
|
}
|
|
1917
2065
|
|
|
1918
|
-
/**
|
|
2066
|
+
/**
|
|
2067
|
+
* Demotes a checkpoint rejection when the checkpoint's last block is no longer found by the signed archive.
|
|
2068
|
+
* Validation reads the slot's blocks once, but reads the block before the checkpoint and the parent checkpoint by
|
|
2069
|
+
* number afterwards, and the archiver can prune those and insert other blocks at the same numbers while the
|
|
2070
|
+
* checkpoint is rebuilt. A prune removes the pruned blocks' archive entries, and the last block's archive commits to
|
|
2071
|
+
* its whole history, so a last block still found by archive means the chain below it is still its own ancestry.
|
|
2072
|
+
*/
|
|
2073
|
+
private async checkLastBlockStillLocal(
|
|
2074
|
+
proposal: CheckpointProposalCore,
|
|
2075
|
+
result: CheckpointProposalValidationFailureResult,
|
|
2076
|
+
proposalInfo: LogData,
|
|
2077
|
+
): Promise<CheckpointProposalValidationFailureResult> {
|
|
2078
|
+
if ((await this.blockSource.getBlockData({ archive: proposal.archive })) !== undefined) {
|
|
2079
|
+
return result;
|
|
2080
|
+
}
|
|
2081
|
+
this.log.warn(`Checkpoint's last block was pruned while validating the proposal, not attributing the rejection`, {
|
|
2082
|
+
...proposalInfo,
|
|
2083
|
+
originalReason: result.reason,
|
|
2084
|
+
});
|
|
2085
|
+
return { ...result, reason: 'last_block_pruned_during_validation' };
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/**
|
|
2089
|
+
* The endpoint gate's own ceiling, narrowed by a slot whose attestation window is nearly spent.
|
|
2090
|
+
*
|
|
2091
|
+
* The floor survives past the attestation deadline on purpose, and is bounded at
|
|
2092
|
+
* {@link INBOX_ENDPOINT_CHECK_MIN_WINDOW_MS} for it: a late node still wants the content verdict for telemetry,
|
|
2093
|
+
* and reaching one costs one bounded local L1 read. What the floor must not buy is a signature, which is why
|
|
2094
|
+
* {@link getAttestationDeadline} is re-read at the signing boundary rather than here — a window is not a
|
|
2095
|
+
* permission to attest.
|
|
2096
|
+
*/
|
|
1919
2097
|
private getInboxEndpointWindowMs(slot: SlotNumber): number {
|
|
1920
2098
|
const remainingMs = this.getReexecutionDeadline(slot).getTime() - this.dateProvider.now();
|
|
1921
2099
|
return Math.min(INBOX_ENDPOINT_CHECK_WINDOW_MS, Math.max(INBOX_ENDPOINT_CHECK_MIN_WINDOW_MS, remainingMs));
|
|
1922
2100
|
}
|
|
1923
2101
|
|
|
2102
|
+
/** The consensus attestation deadline for a slot: the last moment an attestation for it may be signed. */
|
|
2103
|
+
public getAttestationDeadline(slot: SlotNumber): Date {
|
|
2104
|
+
return this.getReexecutionDeadline(slot);
|
|
2105
|
+
}
|
|
2106
|
+
|
|
1924
2107
|
/**
|
|
1925
2108
|
* Validates a checkpoint proposal by building the full checkpoint and comparing it with the proposal.
|
|
1926
2109
|
* @returns Validation result with isValid flag and reason if invalid.
|
|
@@ -1941,9 +2124,10 @@ export class ProposalHandler {
|
|
|
1941
2124
|
// runs on: the block carrying the signed archive is the checkpoint's last block, and the slot's blocks before it
|
|
1942
2125
|
// are the checkpoint's. Reading the last block by archive and the slot's blocks separately would let a local prune
|
|
1943
2126
|
// between the two reads look like a proposer offense; a slot read without the signed archive, or one that is not
|
|
1944
|
-
// a contiguous chain, is local state in motion and is retried.
|
|
1945
|
-
// absolute date so the remaining budget is derived from the date
|
|
1946
|
-
// out after a single attempt instead of looping (the
|
|
2127
|
+
// a contiguous chain of one checkpoint's blocks starting at its first, is local state in motion and is retried.
|
|
2128
|
+
// The deadline is passed to retryUntil as an absolute date so the remaining budget is derived from the date
|
|
2129
|
+
// provider; a deadline already in the past times out after a single attempt instead of looping (the
|
|
2130
|
+
// immediate-timeout semantics of the deadline overload).
|
|
1947
2131
|
let snapshot: CheckpointBlocksSnapshot | undefined;
|
|
1948
2132
|
try {
|
|
1949
2133
|
snapshot = await retryUntil(
|
|
@@ -2031,6 +2215,18 @@ export class ProposalHandler {
|
|
|
2031
2215
|
const constants = this.extractCheckpointConstants(firstBlock);
|
|
2032
2216
|
const checkpointNumber = firstBlock.checkpointNumber;
|
|
2033
2217
|
|
|
2218
|
+
// The rebuild derives the header's lastArchiveRoot from the first block's lastArchive (as does the checkpoint
|
|
2219
|
+
// circuit), so any other claimed value is an unprovable header; reject it before paying for the rebuild.
|
|
2220
|
+
const expectedLastArchiveRoot = firstBlock.header.lastArchive.root;
|
|
2221
|
+
if (!proposal.checkpointHeader.lastArchiveRoot.equals(expectedLastArchiveRoot)) {
|
|
2222
|
+
this.log.warn(`Checkpoint proposal's last archive root does not match its first block`, {
|
|
2223
|
+
...proposalInfo,
|
|
2224
|
+
expectedLastArchiveRoot: expectedLastArchiveRoot.toString(),
|
|
2225
|
+
proposalLastArchiveRoot: proposal.checkpointHeader.lastArchiveRoot.toString(),
|
|
2226
|
+
});
|
|
2227
|
+
return { isValid: false, reason: 'last_archive_root_mismatch', checkpointNumber };
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2034
2230
|
// The checkpoint's Inbox consumption starts at the leaf count of the block before its first block. Without that
|
|
2035
2231
|
// block the consumed bundle cannot be derived; an empty bundle would make a valid proposal fail its rolling-hash
|
|
2036
2232
|
// recomputation and be classified as a proposer offense, so a missing parent is a local fetch failure instead.
|
|
@@ -2104,17 +2300,15 @@ export class ProposalHandler {
|
|
|
2104
2300
|
}
|
|
2105
2301
|
await using fork = forkResult;
|
|
2106
2302
|
|
|
2107
|
-
//
|
|
2108
|
-
//
|
|
2109
|
-
//
|
|
2110
|
-
// the block-proposal re-execution check and fails fast with a clean, non-slashable result instead of a
|
|
2111
|
-
// confusing downstream mismatch.
|
|
2303
|
+
// World state must be on the same chain as the archiver blocks being rebuilt; otherwise the rebuild would disagree
|
|
2304
|
+
// with an honest proposal. This is a local race (e.g. a reorg between the hash-pinned sync and the fork), never a
|
|
2305
|
+
// proposer offense. It mirrors the block-proposal re-execution check.
|
|
2112
2306
|
const forkArchiveRoot = new Fr((await fork.getTreeInfo(MerkleTreeId.ARCHIVE)).root);
|
|
2113
|
-
if (!forkArchiveRoot.equals(
|
|
2114
|
-
this.log.warn(`Fork archive root does not match checkpoint
|
|
2307
|
+
if (!forkArchiveRoot.equals(expectedLastArchiveRoot)) {
|
|
2308
|
+
this.log.warn(`Fork archive root does not match the checkpoint's first block last archive`, {
|
|
2115
2309
|
...proposalInfo,
|
|
2116
2310
|
forkArchiveRoot: forkArchiveRoot.toString(),
|
|
2117
|
-
expectedLastArchiveRoot:
|
|
2311
|
+
expectedLastArchiveRoot: expectedLastArchiveRoot.toString(),
|
|
2118
2312
|
});
|
|
2119
2313
|
return { isValid: false, reason: 'initial_archive_mismatch', checkpointNumber };
|
|
2120
2314
|
}
|
|
@@ -211,12 +211,21 @@ export async function readStreamingBlockBundle(
|
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
/**
|
|
214
|
-
*
|
|
215
|
-
* past what this node has synced, or its bounds are not a valid range at all. Matched on the message text rather
|
|
216
|
-
* on the error's class, since a JSON-RPC hop between the validator and its message source leaves the class
|
|
217
|
-
* while preserving the message.
|
|
214
|
+
* The complete message forms of an ordinary local-view shortfall from the archiver's range read: the range reaches
|
|
215
|
+
* past what this node has synced, or its bounds are not a valid range at all. Matched on the message text rather
|
|
216
|
+
* than on the error's class, since a JSON-RPC hop between the validator and its message source leaves the class
|
|
217
|
+
* behind while preserving the message.
|
|
218
|
+
*
|
|
219
|
+
* Anchored to the whole message on purpose. A substring test also swallows anything that quotes one of these — a
|
|
220
|
+
* store or provider fault reported as `database unavailable: Inbox message range [3, 7) is not fully synced` is a
|
|
221
|
+
* failure the checks did not anticipate, and reporting it as ordinary sync lag loses the only diagnosis a validator
|
|
222
|
+
* that runs out of retries can offer. Matching is about which failures are *reported*, not which are accepted:
|
|
223
|
+
* every range-read failure stays non-punitive either way.
|
|
218
224
|
*/
|
|
219
|
-
const EXPECTED_RANGE_READ_FAILURES
|
|
225
|
+
const EXPECTED_RANGE_READ_FAILURES: readonly RegExp[] = [
|
|
226
|
+
/^Inbox message range \[-?\d+, -?\d+\) is not fully synced(: [\s\S]*)?$/,
|
|
227
|
+
/^Invalid Inbox leaf count range \[-?\d+, -?\d+\)$/,
|
|
228
|
+
];
|
|
220
229
|
|
|
221
230
|
/** Upper bound on the error text carried onto a result, so a verbose provider error cannot blow up a log record. */
|
|
222
231
|
const MAX_REPORTED_ERROR_LENGTH = 200;
|
|
@@ -224,7 +233,7 @@ const MAX_REPORTED_ERROR_LENGTH = 200;
|
|
|
224
233
|
/** Bounded text of a range-read failure the checks did not anticipate, or undefined for ordinary sync lag. */
|
|
225
234
|
function unexpectedRangeReadError(err: unknown): string | undefined {
|
|
226
235
|
const message = err instanceof Error ? err.message : String(err);
|
|
227
|
-
return EXPECTED_RANGE_READ_FAILURES.some(
|
|
236
|
+
return EXPECTED_RANGE_READ_FAILURES.some(form => form.test(message))
|
|
228
237
|
? undefined
|
|
229
238
|
: message.slice(0, MAX_REPORTED_ERROR_LENGTH);
|
|
230
239
|
}
|