@aztec/validator-client 6.0.0-nightly.20260810 → 6.0.0-nightly.20260821
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 +5 -5
- package/dest/checkpoint_builder.d.ts +6 -3
- package/dest/checkpoint_builder.d.ts.map +1 -1
- package/dest/checkpoint_builder.js +14 -10
- package/dest/duties/validation_service.d.ts +3 -3
- package/dest/duties/validation_service.d.ts.map +1 -1
- package/dest/duties/validation_service.js +2 -3
- package/dest/factory.d.ts +2 -2
- package/dest/factory.d.ts.map +1 -1
- package/dest/factory.js +1 -13
- package/dest/metrics.d.ts +1 -1
- package/dest/metrics.d.ts.map +1 -1
- package/dest/metrics.js +0 -1
- package/dest/proposal_handler.d.ts +32 -10
- package/dest/proposal_handler.d.ts.map +1 -1
- package/dest/proposal_handler.js +181 -46
- package/dest/streaming_inbox_checks.d.ts +103 -0
- package/dest/streaming_inbox_checks.d.ts.map +1 -0
- package/dest/streaming_inbox_checks.js +112 -0
- package/dest/validator.d.ts +6 -6
- package/dest/validator.d.ts.map +1 -1
- package/dest/validator.js +5 -18
- package/package.json +19 -19
- package/src/checkpoint_builder.ts +25 -11
- package/src/duties/validation_service.ts +3 -3
- package/src/factory.ts +1 -13
- package/src/metrics.ts +0 -1
- package/src/proposal_handler.ts +229 -40
- package/src/streaming_inbox_checks.ts +198 -0
- package/src/validator.ts +10 -22
package/src/proposal_handler.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
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
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
INITIAL_L2_BLOCK_NUM,
|
|
6
|
+
MAX_BLOCKS_PER_CHECKPOINT,
|
|
7
|
+
MAX_L1_TO_L2_MSGS_PER_BLOCK,
|
|
8
|
+
MAX_L1_TO_L2_MSGS_PER_CHECKPOINT,
|
|
9
|
+
} from '@aztec/constants';
|
|
5
10
|
import type { EpochCache } from '@aztec/epoch-cache';
|
|
6
11
|
import { validateFeeAssetPriceModifier } from '@aztec/ethereum/contracts';
|
|
7
12
|
import {
|
|
@@ -20,10 +25,13 @@ import { retryUntil } from '@aztec/foundation/retry';
|
|
|
20
25
|
import { DateProvider, Timer } from '@aztec/foundation/timer';
|
|
21
26
|
import { isErrorClass } from '@aztec/foundation/types';
|
|
22
27
|
import type { P2P, PeerId } from '@aztec/p2p';
|
|
23
|
-
import { BlockProposalValidator } from '@aztec/p2p/msg_validators';
|
|
24
28
|
import type { BlockData, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
|
|
25
29
|
import type { CheckpointReexecutionTracker, ReexecutionOutcome } from '@aztec/stdlib/checkpoint';
|
|
26
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
getPreviousCheckpointInboxRollingHash,
|
|
32
|
+
getPreviousCheckpointOutHashes,
|
|
33
|
+
validateCheckpoint,
|
|
34
|
+
} from '@aztec/stdlib/checkpoint';
|
|
27
35
|
import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
|
|
28
36
|
import { Gas } from '@aztec/stdlib/gas';
|
|
29
37
|
import type {
|
|
@@ -35,9 +43,16 @@ import type {
|
|
|
35
43
|
import {
|
|
36
44
|
type L1ToL2MessageSource,
|
|
37
45
|
accumulateCheckpointOutHashes,
|
|
38
|
-
|
|
46
|
+
getInboxCutoffTimestamp,
|
|
47
|
+
isInboxConsumptionSufficient,
|
|
39
48
|
} from '@aztec/stdlib/messaging';
|
|
40
|
-
import type {
|
|
49
|
+
import type {
|
|
50
|
+
BlockProposal,
|
|
51
|
+
CheckpointAttestation,
|
|
52
|
+
CheckpointProposalCore,
|
|
53
|
+
ValidatedBlockProposal,
|
|
54
|
+
ValidatedCheckpointProposalCore,
|
|
55
|
+
} from '@aztec/stdlib/p2p';
|
|
41
56
|
import type { ConsensusTimetable } from '@aztec/stdlib/timetable';
|
|
42
57
|
import { MerkleTreeId } from '@aztec/stdlib/trees';
|
|
43
58
|
import type { CheckpointGlobalVariables, FailedTx, Tx, TxHash } from '@aztec/stdlib/tx';
|
|
@@ -53,13 +68,20 @@ import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/te
|
|
|
53
68
|
|
|
54
69
|
import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
|
|
55
70
|
import type { ValidatorMetrics } from './metrics.js';
|
|
71
|
+
import {
|
|
72
|
+
type StreamingBlockCheckReason,
|
|
73
|
+
type StreamingBlockMetadataCheckResult,
|
|
74
|
+
checkStreamingBlockProposalMetadata,
|
|
75
|
+
getStreamingBlockBundle,
|
|
76
|
+
} from './streaming_inbox_checks.js';
|
|
56
77
|
|
|
57
78
|
export type BlockProposalValidationFailureReason =
|
|
58
79
|
| 'invalid_signature'
|
|
59
80
|
| 'invalid_proposal'
|
|
60
81
|
| 'parent_block_not_found'
|
|
61
82
|
| 'parent_block_wrong_slot'
|
|
62
|
-
|
|
83
|
+
// Streaming Inbox per-block acceptance failures.
|
|
84
|
+
| StreamingBlockCheckReason
|
|
63
85
|
| 'global_variables_mismatch'
|
|
64
86
|
| 'block_number_already_exists'
|
|
65
87
|
| 'txs_not_available'
|
|
@@ -109,6 +131,8 @@ export type CheckpointProposalValidationFailureReason =
|
|
|
109
131
|
| 'checkpoint_header_mismatch'
|
|
110
132
|
| 'archive_mismatch'
|
|
111
133
|
| 'out_hash_mismatch'
|
|
134
|
+
// Streaming Inbox last-block censorship failure.
|
|
135
|
+
| 'inbox_consumption_insufficient'
|
|
112
136
|
| 'checkpoint_validation_failed';
|
|
113
137
|
|
|
114
138
|
/**
|
|
@@ -134,6 +158,7 @@ const CHECKPOINT_VALIDATION_REASON_TO_OUTCOME: Record<
|
|
|
134
158
|
checkpoint_header_mismatch: 'invalid',
|
|
135
159
|
archive_mismatch: 'invalid',
|
|
136
160
|
out_hash_mismatch: 'invalid',
|
|
161
|
+
inbox_consumption_insufficient: 'invalid',
|
|
137
162
|
checkpoint_validation_failed: 'invalid',
|
|
138
163
|
};
|
|
139
164
|
|
|
@@ -175,7 +200,6 @@ export const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidation
|
|
|
175
200
|
'global_variables_mismatch',
|
|
176
201
|
'invalid_proposal',
|
|
177
202
|
'parent_block_wrong_slot',
|
|
178
|
-
'in_hash_mismatch',
|
|
179
203
|
'duplicate_txs',
|
|
180
204
|
'invalid_embedded_txs',
|
|
181
205
|
];
|
|
@@ -198,6 +222,9 @@ export const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT: Record<
|
|
|
198
222
|
['last_block_archive_mismatch']: true,
|
|
199
223
|
|
|
200
224
|
// disabled
|
|
225
|
+
// Streaming Inbox last-block censorship: kept out of slashing while the streaming path is new; L1 `propose` is the
|
|
226
|
+
// authoritative reject (Rollup__UnconsumedInboxMessages).
|
|
227
|
+
['inbox_consumption_insufficient']: false,
|
|
201
228
|
['invalid_signature']: false,
|
|
202
229
|
['last_block_not_found']: false,
|
|
203
230
|
['block_fetch_error']: false,
|
|
@@ -248,7 +275,6 @@ export class ProposalHandler {
|
|
|
248
275
|
private blockSource: L2BlockSource & L2BlockSink,
|
|
249
276
|
private l1ToL2MessageSource: L1ToL2MessageSource,
|
|
250
277
|
private txProvider: ITxProvider,
|
|
251
|
-
private blockProposalValidator: BlockProposalValidator,
|
|
252
278
|
private epochCache: EpochCache,
|
|
253
279
|
private timetable: ConsensusTimetable,
|
|
254
280
|
private config: ValidatorClientFullConfig,
|
|
@@ -328,7 +354,7 @@ export class ProposalHandler {
|
|
|
328
354
|
|
|
329
355
|
// Non-validator handler that processes or re-executes for monitoring but does not attest.
|
|
330
356
|
// Returns boolean indicating whether the proposal was valid.
|
|
331
|
-
const blockHandler = async (proposal:
|
|
357
|
+
const blockHandler = async (proposal: ValidatedBlockProposal, proposalSender: PeerId): Promise<boolean> => {
|
|
332
358
|
try {
|
|
333
359
|
const { slotNumber, blockNumber } = proposal;
|
|
334
360
|
const result = await this.handleBlockProposal(proposal, proposalSender, shouldReexecute);
|
|
@@ -378,7 +404,7 @@ export class ProposalHandler {
|
|
|
378
404
|
// Runs for all nodes (validators and non-validators). Validators get the cached result in the
|
|
379
405
|
// validator-specific callback (attestToCheckpointProposal) which runs after this one.
|
|
380
406
|
const checkpointHandler = async (
|
|
381
|
-
proposal:
|
|
407
|
+
proposal: ValidatedCheckpointProposalCore,
|
|
382
408
|
_sender: PeerId,
|
|
383
409
|
): Promise<CheckpointAttestation[] | undefined> => {
|
|
384
410
|
try {
|
|
@@ -446,8 +472,15 @@ export class ProposalHandler {
|
|
|
446
472
|
return this;
|
|
447
473
|
}
|
|
448
474
|
|
|
475
|
+
/**
|
|
476
|
+
* Processes a block proposal: collects its txs and, if requested, re-executes them to check the resulting
|
|
477
|
+
* block against the proposal. Expects the proposal to have already passed p2p ingress validation (signature
|
|
478
|
+
* context, signature, expected proposer, index within checkpoint, tx field checks, and the receive-window
|
|
479
|
+
* timeliness check) — none of those are re-applied here, and only deterministic properties of the payload
|
|
480
|
+
* are validated before processing.
|
|
481
|
+
*/
|
|
449
482
|
async handleBlockProposal(
|
|
450
|
-
proposal:
|
|
483
|
+
proposal: ValidatedBlockProposal,
|
|
451
484
|
proposalSender: PeerId,
|
|
452
485
|
shouldReexecute: boolean,
|
|
453
486
|
): Promise<BlockProposalValidationResult> {
|
|
@@ -472,13 +505,9 @@ export class ProposalHandler {
|
|
|
472
505
|
txHashes: proposal.txHashes.map(t => t.toString()),
|
|
473
506
|
});
|
|
474
507
|
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
if (validationResult.result !== 'accept') {
|
|
479
|
-
this.log.warn(`Proposal is not valid, skipping processing`, proposalInfo);
|
|
480
|
-
return { isValid: false, reason: 'invalid_proposal' };
|
|
481
|
-
}
|
|
508
|
+
// The receive-window check from p2p ingress is deliberately not re-applied here: its outcome depends on
|
|
509
|
+
// the wall clock at evaluation time, so re-running it turned node-local processing latency into an
|
|
510
|
+
// invalid-proposal verdict against an honest proposer, which then fed the invalid-block slashing path.
|
|
482
511
|
|
|
483
512
|
// A tx can only appear once in a block: the second copy would emit nullifiers already emitted by the
|
|
484
513
|
// first. This is not a relaying-peer fault, so it passes gossip validation and is classified here as
|
|
@@ -545,9 +574,29 @@ export class ProposalHandler {
|
|
|
545
574
|
return { isValid: false, blockNumber, reason: 'block_number_already_exists' };
|
|
546
575
|
}
|
|
547
576
|
|
|
577
|
+
// Streaming Inbox: run the metadata checks before committing to any network work. They are point lookups
|
|
578
|
+
// against our own Inbox view, so a proposal carrying a bucket reference that does not resolve locally is
|
|
579
|
+
// rejected without a proposer being able to make us spend the validation window collecting its txs.
|
|
580
|
+
const streamingMetadata = await this.checkStreamingBlockMetadata(proposal, blockNumber, parentBlock);
|
|
581
|
+
if (!streamingMetadata.accepted) {
|
|
582
|
+
this.log.warn(`Streaming Inbox block acceptance check failed, skipping processing`, {
|
|
583
|
+
reason: streamingMetadata.reason,
|
|
584
|
+
bucketRef: proposal.bucketRef?.toInspect(),
|
|
585
|
+
...proposalInfo,
|
|
586
|
+
});
|
|
587
|
+
return { isValid: false, blockNumber, reason: streamingMetadata.reason };
|
|
588
|
+
}
|
|
589
|
+
|
|
548
590
|
// Collect txs from the proposal. We start doing this as early as possible,
|
|
549
591
|
// 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.
|
|
550
|
-
|
|
592
|
+
// The block's message bundle is an independent read, so derive it concurrently with the collection.
|
|
593
|
+
const txsPromise = this.collectProposalTxs(proposal, blockNumber, proposalSender, proposalInfo);
|
|
594
|
+
const bundlePromise = getStreamingBlockBundle(this.l1ToL2MessageSource, streamingMetadata);
|
|
595
|
+
// Promise.all settles on the first rejection, so without a handler of its own the loser's later rejection
|
|
596
|
+
// would surface as an unhandled rejection. Awaiting below still observes whichever rejected first.
|
|
597
|
+
txsPromise.catch(() => {});
|
|
598
|
+
bundlePromise.catch(() => {});
|
|
599
|
+
const [collected, l1ToL2Messages] = await Promise.all([txsPromise, bundlePromise]);
|
|
551
600
|
if (collected === 'invalid_embedded_txs') {
|
|
552
601
|
return { isValid: false, blockNumber, reason: collected };
|
|
553
602
|
}
|
|
@@ -573,19 +622,6 @@ export class ProposalHandler {
|
|
|
573
622
|
const checkpointNumber = checkpointResult.checkpointNumber;
|
|
574
623
|
proposalInfo.checkpointNumber = checkpointNumber;
|
|
575
624
|
|
|
576
|
-
// Check that I have the same set of l1ToL2Messages as the proposal
|
|
577
|
-
const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
|
|
578
|
-
const computedInHash = computeInHashFromL1ToL2Messages(l1ToL2Messages);
|
|
579
|
-
const proposalInHash = proposal.inHash;
|
|
580
|
-
if (!computedInHash.equals(proposalInHash)) {
|
|
581
|
-
this.log.warn(`L1 to L2 messages in hash mismatch, skipping processing`, {
|
|
582
|
-
proposalInHash: proposalInHash.toString(),
|
|
583
|
-
computedInHash: computedInHash.toString(),
|
|
584
|
-
...proposalInfo,
|
|
585
|
-
});
|
|
586
|
-
return { isValid: false, blockNumber, reason: 'in_hash_mismatch' };
|
|
587
|
-
}
|
|
588
|
-
|
|
589
625
|
// Check that all of the transactions in the proposal are available
|
|
590
626
|
if (missingTxs.length > 0) {
|
|
591
627
|
this.log.warn(`Missing ${missingTxs.length} txs to process proposal`, { ...proposalInfo, missingTxs });
|
|
@@ -605,6 +641,12 @@ export class ProposalHandler {
|
|
|
605
641
|
log: this.log,
|
|
606
642
|
});
|
|
607
643
|
|
|
644
|
+
const previousInboxRollingHash = await getPreviousCheckpointInboxRollingHash({
|
|
645
|
+
blockSource: this.blockSource,
|
|
646
|
+
checkpointNumber,
|
|
647
|
+
log: this.log,
|
|
648
|
+
});
|
|
649
|
+
|
|
608
650
|
// Try re-executing the transactions in the proposal if needed
|
|
609
651
|
let reexecutionResult;
|
|
610
652
|
try {
|
|
@@ -616,6 +658,7 @@ export class ProposalHandler {
|
|
|
616
658
|
txs,
|
|
617
659
|
l1ToL2Messages,
|
|
618
660
|
previousCheckpointOutHashes,
|
|
661
|
+
previousInboxRollingHash,
|
|
619
662
|
);
|
|
620
663
|
} catch (error) {
|
|
621
664
|
this.log.error(`Error reexecuting txs while processing block proposal`, error, proposalInfo);
|
|
@@ -926,6 +969,121 @@ export class ProposalHandler {
|
|
|
926
969
|
}
|
|
927
970
|
}
|
|
928
971
|
|
|
972
|
+
/**
|
|
973
|
+
* Runs the streaming-Inbox per-block metadata checks for a block proposal, returning the bucket range its message
|
|
974
|
+
* bundle derives from or a rejection reason. The parent block's consumed total and the checkpoint's starting total
|
|
975
|
+
* are derived from L1-to-L2 tree leaf counts; a parent whose count does not sit on a bucket boundary is rejected
|
|
976
|
+
* inside {@link checkStreamingBlockProposalMetadata}.
|
|
977
|
+
*/
|
|
978
|
+
private async checkStreamingBlockMetadata(
|
|
979
|
+
proposal: BlockProposal,
|
|
980
|
+
blockNumber: BlockNumber,
|
|
981
|
+
parentBlock: 'genesis' | BlockData,
|
|
982
|
+
): Promise<StreamingBlockMetadataCheckResult> {
|
|
983
|
+
const parentTotalMsgCount = this.getConsumedMsgTotal(parentBlock);
|
|
984
|
+
const checkpointStartTotalMsgCount = await this.resolveCheckpointStartTotal(
|
|
985
|
+
blockNumber,
|
|
986
|
+
proposal.indexWithinCheckpoint,
|
|
987
|
+
parentTotalMsgCount,
|
|
988
|
+
);
|
|
989
|
+
if (checkpointStartTotalMsgCount === undefined) {
|
|
990
|
+
// The block before the checkpoint's first block has not synced locally, so the per-checkpoint cap origin is
|
|
991
|
+
// unavailable: treat as an unknown local view. There is no bounded wait for the missing block yet.
|
|
992
|
+
return { accepted: false, reason: 'bucket_unknown' };
|
|
993
|
+
}
|
|
994
|
+
const nowSeconds = BigInt(Math.floor(this.dateProvider.now() / 1000));
|
|
995
|
+
return checkStreamingBlockProposalMetadata({
|
|
996
|
+
messageSource: this.l1ToL2MessageSource,
|
|
997
|
+
bucketRef: proposal.bucketRef,
|
|
998
|
+
parentTotalMsgCount,
|
|
999
|
+
checkpointStartTotalMsgCount,
|
|
1000
|
+
nowSeconds,
|
|
1001
|
+
minBucketAgeSeconds: this.epochCache.getL1Constants().ethereumSlotDuration,
|
|
1002
|
+
perBlockCap: MAX_L1_TO_L2_MSGS_PER_BLOCK,
|
|
1003
|
+
perCheckpointCap: MAX_L1_TO_L2_MSGS_PER_CHECKPOINT,
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** A block's L1-to-L2 message tree leaf count: the cumulative Inbox message count it consumed through. */
|
|
1008
|
+
private blockLeafCount(block: BlockData | L2Block): bigint {
|
|
1009
|
+
return BigInt(block.header.state.l1ToL2MessageTree.nextAvailableLeafIndex);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** The cumulative Inbox message count consumed through a block: its L1-to-L2 tree leaf count (0 at genesis). */
|
|
1013
|
+
private getConsumedMsgTotal(block: 'genesis' | BlockData): bigint {
|
|
1014
|
+
return block === 'genesis' ? 0n : this.blockLeafCount(block);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* The cumulative Inbox message count consumed as of the parent checkpoint (the per-checkpoint cap origin). For a
|
|
1019
|
+
* checkpoint's first block this is the parent block's total; for a later block it is the leaf count of the block
|
|
1020
|
+
* before the checkpoint's first block. Returns undefined when that block has not synced locally.
|
|
1021
|
+
*/
|
|
1022
|
+
private resolveCheckpointStartTotal(
|
|
1023
|
+
blockNumber: BlockNumber,
|
|
1024
|
+
indexWithinCheckpoint: number,
|
|
1025
|
+
parentTotalMsgCount: bigint,
|
|
1026
|
+
): Promise<bigint | undefined> {
|
|
1027
|
+
return indexWithinCheckpoint === 0
|
|
1028
|
+
? Promise.resolve(parentTotalMsgCount)
|
|
1029
|
+
: this.getPreBlockConsumedTotal(blockNumber - indexWithinCheckpoint);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* The cumulative Inbox message count consumed as of the block immediately before `firstBlockNumber` (its L1-to-L2
|
|
1034
|
+
* tree leaf count): 0 when that block is genesis, undefined when it has not synced locally.
|
|
1035
|
+
*/
|
|
1036
|
+
private async getPreBlockConsumedTotal(firstBlockNumber: number): Promise<bigint | undefined> {
|
|
1037
|
+
const preBlockNumber = firstBlockNumber - 1;
|
|
1038
|
+
if (preBlockNumber < INITIAL_L2_BLOCK_NUM) {
|
|
1039
|
+
return 0n;
|
|
1040
|
+
}
|
|
1041
|
+
const preBlock = await this.blockSource.getBlockData({ number: BlockNumber(preBlockNumber) });
|
|
1042
|
+
return preBlock === undefined ? undefined : this.blockLeafCount(preBlock);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Enforces the streaming-Inbox last-block minimum-consumption (censorship) rule for a checkpoint, mirroring
|
|
1047
|
+
* `ProposeLib.validateInboxConsumption`: the first bucket the checkpoint left unconsumed must be absent, past the
|
|
1048
|
+
* cutoff, or a cap-escape. Returns true (sufficient) when the checkpoint's consumption cannot be resolved against
|
|
1049
|
+
* the local Inbox view, deferring to L1 `propose` as the authoritative reject.
|
|
1050
|
+
*/
|
|
1051
|
+
private async isLastBlockConsumptionSufficient(slot: SlotNumber, blocks: L2Block[]): Promise<boolean> {
|
|
1052
|
+
const lastBlockTotal = this.blockLeafCount(blocks[blocks.length - 1]);
|
|
1053
|
+
const checkpointStartTotal = await this.getPreBlockConsumedTotal(blocks[0].number);
|
|
1054
|
+
const lastConsumedBucket = await this.l1ToL2MessageSource.getInboxBucketByTotalMsgCount(lastBlockTotal);
|
|
1055
|
+
if (checkpointStartTotal === undefined || lastConsumedBucket === undefined) {
|
|
1056
|
+
return true;
|
|
1057
|
+
}
|
|
1058
|
+
const nextBucket = await this.l1ToL2MessageSource.getInboxBucket(lastConsumedBucket.seq + 1n);
|
|
1059
|
+
const cutoffTimestamp = getInboxCutoffTimestamp(slot, this.epochCache.getL1Constants());
|
|
1060
|
+
return isInboxConsumptionSufficient({
|
|
1061
|
+
nextBucket,
|
|
1062
|
+
cutoffTimestamp,
|
|
1063
|
+
checkpointStartTotalMsgCount: checkpointStartTotal,
|
|
1064
|
+
perCheckpointCap: MAX_L1_TO_L2_MSGS_PER_CHECKPOINT,
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Derives the ordered list of L1-to-L2 messages a checkpoint consumed across its blocks, from the Inbox buckets
|
|
1070
|
+
* between the parent checkpoint's consumed position and the checkpoint's last block. Empty when
|
|
1071
|
+
* the checkpoint consumed nothing or its consumption cannot be resolved against the local Inbox view.
|
|
1072
|
+
*/
|
|
1073
|
+
private async deriveCheckpointConsumedMessages(blocks: L2Block[]): Promise<Fr[]> {
|
|
1074
|
+
const checkpointStartTotal = await this.getPreBlockConsumedTotal(blocks[0].number);
|
|
1075
|
+
const lastBlockTotal = this.blockLeafCount(blocks[blocks.length - 1]);
|
|
1076
|
+
if (checkpointStartTotal === undefined || lastBlockTotal <= checkpointStartTotal) {
|
|
1077
|
+
return [];
|
|
1078
|
+
}
|
|
1079
|
+
const startBucket = await this.l1ToL2MessageSource.getInboxBucketByTotalMsgCount(checkpointStartTotal);
|
|
1080
|
+
const endBucket = await this.l1ToL2MessageSource.getInboxBucketByTotalMsgCount(lastBlockTotal);
|
|
1081
|
+
if (startBucket === undefined || endBucket === undefined) {
|
|
1082
|
+
return [];
|
|
1083
|
+
}
|
|
1084
|
+
return this.l1ToL2MessageSource.getL1ToL2MessagesBetweenBuckets(startBucket.seq, endBucket.seq);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
929
1087
|
async reexecuteTransactions(
|
|
930
1088
|
proposal: BlockProposal,
|
|
931
1089
|
blockNumber: BlockNumber,
|
|
@@ -933,6 +1091,7 @@ export class ProposalHandler {
|
|
|
933
1091
|
txs: Tx[],
|
|
934
1092
|
l1ToL2Messages: Fr[],
|
|
935
1093
|
previousCheckpointOutHashes: Fr[],
|
|
1094
|
+
previousInboxRollingHash: Fr,
|
|
936
1095
|
): Promise<ReexecuteTransactionsResult> {
|
|
937
1096
|
const { blockHeader, txHashes } = proposal;
|
|
938
1097
|
|
|
@@ -974,13 +1133,15 @@ export class ProposalHandler {
|
|
|
974
1133
|
gasFees: blockHeader.globalVariables.gasFees,
|
|
975
1134
|
};
|
|
976
1135
|
|
|
977
|
-
// Create checkpoint builder with prior blocks
|
|
1136
|
+
// Create checkpoint builder with prior blocks. The messages the prior blocks consumed are not needed: this path
|
|
1137
|
+
// only re-executes and compares the new block, never completing the checkpoint (whose rolling hash they seed).
|
|
978
1138
|
const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
|
|
979
1139
|
checkpointNumber,
|
|
980
1140
|
constants,
|
|
981
1141
|
0n, // only takes effect in the following checkpoint.
|
|
982
|
-
|
|
1142
|
+
[],
|
|
983
1143
|
previousCheckpointOutHashes,
|
|
1144
|
+
previousInboxRollingHash,
|
|
984
1145
|
fork,
|
|
985
1146
|
priorBlocks,
|
|
986
1147
|
this.log.getBindings(),
|
|
@@ -999,6 +1160,7 @@ export class ProposalHandler {
|
|
|
999
1160
|
expectedEndState: blockHeader.state,
|
|
1000
1161
|
maxTransactions: this.config.validateMaxTxsPerBlock,
|
|
1001
1162
|
maxBlockGas,
|
|
1163
|
+
l1ToL2Messages,
|
|
1002
1164
|
});
|
|
1003
1165
|
|
|
1004
1166
|
const { block, failedTxs } = result;
|
|
@@ -1054,9 +1216,11 @@ export class ProposalHandler {
|
|
|
1054
1216
|
* Validates a checkpoint proposal, caches the result, and uploads blobs if configured.
|
|
1055
1217
|
* Returns a cached result if the same proposal (archive + slot) was already validated.
|
|
1056
1218
|
* Used by both the all-nodes callback (via register) and the validator client (via delegation).
|
|
1219
|
+
* Expects the proposal to have already passed p2p ingress validation (expected proposer and receive-window
|
|
1220
|
+
* timeliness); only deterministic properties of the signed payload are checked here.
|
|
1057
1221
|
*/
|
|
1058
1222
|
async handleCheckpointProposal(
|
|
1059
|
-
proposal:
|
|
1223
|
+
proposal: ValidatedCheckpointProposalCore,
|
|
1060
1224
|
proposalInfo: LogData,
|
|
1061
1225
|
): Promise<CheckpointProposalValidationResult> {
|
|
1062
1226
|
const slot = proposal.slotNumber;
|
|
@@ -1184,9 +1348,15 @@ export class ProposalHandler {
|
|
|
1184
1348
|
};
|
|
1185
1349
|
}
|
|
1186
1350
|
|
|
1187
|
-
//
|
|
1188
|
-
|
|
1189
|
-
|
|
1351
|
+
// The checkpoint root circuit caps how many blocks a checkpoint may contain, and L1 cannot check it when the
|
|
1352
|
+
// checkpoint is proposed (`ProposedHeader` carries no block count, only `blockHeadersHash`). An over-cap
|
|
1353
|
+
// checkpoint would therefore be accepted on L1 and then be unprovable, forcing an epoch reorg, so validators
|
|
1354
|
+
// reject it before attesting. An operator-configured limit may only tighten the protocol cap, never relax it.
|
|
1355
|
+
const maxBlocksPerCheckpoint = Math.min(
|
|
1356
|
+
this.config.maxBlocksPerCheckpoint ?? MAX_BLOCKS_PER_CHECKPOINT,
|
|
1357
|
+
MAX_BLOCKS_PER_CHECKPOINT,
|
|
1358
|
+
);
|
|
1359
|
+
if (blocks.length > maxBlocksPerCheckpoint) {
|
|
1190
1360
|
this.log.warn(`Checkpoint proposal exceeds maxBlocksPerCheckpoint`, {
|
|
1191
1361
|
...proposalInfo,
|
|
1192
1362
|
blocksInProposal: blocks.length,
|
|
@@ -1209,8 +1379,20 @@ export class ProposalHandler {
|
|
|
1209
1379
|
const constants = this.extractCheckpointConstants(firstBlock);
|
|
1210
1380
|
const checkpointNumber = firstBlock.checkpointNumber;
|
|
1211
1381
|
|
|
1212
|
-
//
|
|
1213
|
-
|
|
1382
|
+
// Streaming Inbox: on the last block of a checkpoint, enforce the minimum-consumption
|
|
1383
|
+
// (censorship) rule before attesting. Reject (no attestation) if a mandatory bucket was left unconsumed.
|
|
1384
|
+
if (!(await this.isLastBlockConsumptionSufficient(slot, blocks))) {
|
|
1385
|
+
this.log.warn(`Streaming Inbox last-block censorship check failed, refusing to attest`, {
|
|
1386
|
+
...proposalInfo,
|
|
1387
|
+
checkpointNumber,
|
|
1388
|
+
});
|
|
1389
|
+
return { isValid: false, reason: 'inbox_consumption_insufficient', checkpointNumber };
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Derive the checkpoint's consumed L1-to-L2 message list from the Inbox buckets between the parent checkpoint's
|
|
1393
|
+
// consumed position and the last block's (compact indexing). The messages are already in the db from per-block
|
|
1394
|
+
// validation; this list only drives the checkpoint's rolling-hash recomputation in completeCheckpoint.
|
|
1395
|
+
const l1ToL2Messages = await this.deriveCheckpointConsumedMessages(blocks);
|
|
1214
1396
|
|
|
1215
1397
|
// Collect the out hashes of all the checkpoints before this one in the same epoch.
|
|
1216
1398
|
// See note on the analogous block-proposal site: the helper handles pipelining lag.
|
|
@@ -1224,6 +1406,12 @@ export class ProposalHandler {
|
|
|
1224
1406
|
log: this.log,
|
|
1225
1407
|
});
|
|
1226
1408
|
|
|
1409
|
+
const previousInboxRollingHash = await getPreviousCheckpointInboxRollingHash({
|
|
1410
|
+
blockSource: this.blockSource,
|
|
1411
|
+
checkpointNumber,
|
|
1412
|
+
log: this.log,
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1227
1415
|
// Fork world state at the block before the first block. getFork syncs world state to the parent block
|
|
1228
1416
|
// first (see its doc): the block source (archiver) can already hold the block while world state still
|
|
1229
1417
|
// trails it by one, and forking a not-yet-applied block throws a raw tree error that would otherwise
|
|
@@ -1267,6 +1455,7 @@ export class ProposalHandler {
|
|
|
1267
1455
|
proposal.feeAssetPriceModifier,
|
|
1268
1456
|
l1ToL2Messages,
|
|
1269
1457
|
previousCheckpointOutHashes,
|
|
1458
|
+
previousInboxRollingHash,
|
|
1270
1459
|
fork,
|
|
1271
1460
|
blocks,
|
|
1272
1461
|
this.log.getBindings(),
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { Fr } from '@aztec/foundation/curves/bn254';
|
|
2
|
+
import type { InboxBucket, InboxBucketRef, L1ToL2MessageSource } from '@aztec/stdlib/messaging';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reason a streaming-Inbox block proposal fails the per-block acceptance checks. Follows the
|
|
6
|
+
* handler's existing `{ isValid, reason }` string style.
|
|
7
|
+
*/
|
|
8
|
+
export type StreamingBlockCheckReason =
|
|
9
|
+
| 'bucket_unknown'
|
|
10
|
+
| 'bucket_hash_mismatch'
|
|
11
|
+
| 'parent_bucket_unresolved'
|
|
12
|
+
| 'bucket_moves_backwards'
|
|
13
|
+
| 'bucket_too_new'
|
|
14
|
+
| 'bundle_over_block_cap'
|
|
15
|
+
| 'checkpoint_over_msg_cap';
|
|
16
|
+
|
|
17
|
+
/** The subset of the archiver's Inbox-bucket queries the per-block streaming checks need. */
|
|
18
|
+
export type StreamingInboxBucketSource = Pick<
|
|
19
|
+
L1ToL2MessageSource,
|
|
20
|
+
'getInboxBucket' | 'getInboxBucketByTotalMsgCount' | 'getL1ToL2MessagesBetweenBuckets'
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
/** Inputs to the per-block streaming Inbox metadata checks. */
|
|
24
|
+
export type StreamingBlockMetadataCheckInput = {
|
|
25
|
+
/** Archiver Inbox-bucket queries (resolved against this node's own Inbox view). */
|
|
26
|
+
messageSource: Pick<StreamingInboxBucketSource, 'getInboxBucket' | 'getInboxBucketByTotalMsgCount'>;
|
|
27
|
+
/** The proposal's bucket reference: `bucketSeq` is the lookup hint, `inboxRollingHash` the expected commitment. */
|
|
28
|
+
bucketRef: InboxBucketRef | undefined;
|
|
29
|
+
/** Cumulative Inbox message count consumed through the parent block (its L1-to-L2 tree leaf count; 0 at genesis). */
|
|
30
|
+
parentTotalMsgCount: bigint;
|
|
31
|
+
/** Cumulative Inbox message count consumed as of the parent checkpoint; the per-checkpoint cap origin. */
|
|
32
|
+
checkpointStartTotalMsgCount: bigint;
|
|
33
|
+
/** Validation-time wall clock in seconds; the lag-eligibility anchor. */
|
|
34
|
+
nowSeconds: bigint;
|
|
35
|
+
/**
|
|
36
|
+
* Minimum bucket age in seconds for a bucket to be lag-eligible: one configured Ethereum slot, the same value the
|
|
37
|
+
* sequencer's selection uses.
|
|
38
|
+
*
|
|
39
|
+
* Age in seconds is a proxy for L1 reorg depth, and only an exact one when no L1 slot is missed: with missed slots a
|
|
40
|
+
* bucket can be a full Ethereum slot old and still sit in the latest L1 block. A block-depth rule would be
|
|
41
|
+
* stronger — require at least one later L1 block to have synced — and is implementable without new L1 state, since
|
|
42
|
+
* the archiver records each bucket's `l1BlockNumber`.
|
|
43
|
+
*/
|
|
44
|
+
minBucketAgeSeconds: number;
|
|
45
|
+
/** Maximum number of messages this block may consume (`MAX_L1_TO_L2_MSGS_PER_BLOCK`). */
|
|
46
|
+
perBlockCap: number;
|
|
47
|
+
/** Maximum number of messages the checkpoint may consume in total (`MAX_L1_TO_L2_MSGS_PER_CHECKPOINT`). */
|
|
48
|
+
perCheckpointCap: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Inputs to the full per-block streaming Inbox acceptance checks, metadata plus bundle derivation. */
|
|
52
|
+
export type StreamingBlockCheckInput = Omit<StreamingBlockMetadataCheckInput, 'messageSource'> & {
|
|
53
|
+
messageSource: StreamingInboxBucketSource;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** The buckets a passing block proposal consumes between; the input to bundle derivation. */
|
|
57
|
+
export type StreamingBlockBucketRange = {
|
|
58
|
+
/** The bucket the block consumes through, resolved from this node's own Inbox view. */
|
|
59
|
+
bucket: InboxBucket;
|
|
60
|
+
/** The bucket the parent block consumed through. Equal to `bucket` when the block consumes nothing. */
|
|
61
|
+
parentBucket: InboxBucket;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Result of the per-block streaming Inbox metadata checks. */
|
|
65
|
+
export type StreamingBlockMetadataCheckResult =
|
|
66
|
+
| ({
|
|
67
|
+
/** Every metadata check passed; the block's bundle can be derived from the returned bucket range. */
|
|
68
|
+
accepted: true;
|
|
69
|
+
} & StreamingBlockBucketRange)
|
|
70
|
+
| {
|
|
71
|
+
/** A check failed; `reason` mirrors the L1 acceptance condition that would have rejected the proposal. */
|
|
72
|
+
accepted: false;
|
|
73
|
+
reason: StreamingBlockCheckReason;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Result of the per-block streaming Inbox acceptance checks. */
|
|
77
|
+
export type StreamingBlockCheckResult =
|
|
78
|
+
| {
|
|
79
|
+
/** All checks passed; `bundle` is the message-leaf bundle this block consumes, for re-execution. */
|
|
80
|
+
accepted: true;
|
|
81
|
+
bundle: Fr[];
|
|
82
|
+
}
|
|
83
|
+
| {
|
|
84
|
+
/** A check failed; `reason` mirrors the L1 acceptance condition that would have rejected the proposal. */
|
|
85
|
+
accepted: false;
|
|
86
|
+
reason: StreamingBlockCheckReason;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Runs the metadata half of the per-block acceptance checks a validator applies to a streaming block proposal:
|
|
91
|
+
* every check that is a bounded number of point lookups against the local Inbox view, with no message data read.
|
|
92
|
+
* Mirrors the L1 acceptance conditions:
|
|
93
|
+
*
|
|
94
|
+
* 1. **Exists**: the referenced bucket resolves in this node's own Inbox view, and its consensus rolling hash matches
|
|
95
|
+
* the reference. An unknown bucket is an immediate reject here (there is no bounded wait yet); a hash
|
|
96
|
+
* mismatch means the wire reference disagrees with the local bucket. The reference is trusted only as a `bucketSeq`
|
|
97
|
+
* lookup hint — timestamp and message counts are read from the locally resolved bucket, never from the wire.
|
|
98
|
+
* 2. **Moves forward**: the bucket's cumulative total is at least the parent block's, so consumption never rewinds.
|
|
99
|
+
* Equal totals mean the block consumes nothing (empty bundle).
|
|
100
|
+
* 3. **Not too new**: the bucket is at least `minBucketAgeSeconds` old at validation time
|
|
101
|
+
* (`timestamp <= now - minBucketAgeSeconds`, inclusive — a bucket exactly that old is eligible, matching L1's
|
|
102
|
+
* strict `>` "too new" test).
|
|
103
|
+
* 4. **Caps**: the per-block message count and the running per-checkpoint total fit their respective caps.
|
|
104
|
+
* 5. **Parent boundary**: the parent block's cumulative total sits on a bucket boundary, so the consumed range is
|
|
105
|
+
* well defined.
|
|
106
|
+
*
|
|
107
|
+
* Because this phase is cheap and needs nothing off the network, a caller can run it before committing to any
|
|
108
|
+
* expensive work on a proposal — notably before collecting the proposal's transactions over P2P.
|
|
109
|
+
*
|
|
110
|
+
* The reject branch is a single function so a future bounded wait can wrap `bucket_unknown`.
|
|
111
|
+
*/
|
|
112
|
+
export async function checkStreamingBlockProposalMetadata(
|
|
113
|
+
input: StreamingBlockMetadataCheckInput,
|
|
114
|
+
): Promise<StreamingBlockMetadataCheckResult> {
|
|
115
|
+
const {
|
|
116
|
+
messageSource,
|
|
117
|
+
bucketRef,
|
|
118
|
+
parentTotalMsgCount,
|
|
119
|
+
checkpointStartTotalMsgCount,
|
|
120
|
+
nowSeconds,
|
|
121
|
+
minBucketAgeSeconds,
|
|
122
|
+
perBlockCap,
|
|
123
|
+
perCheckpointCap,
|
|
124
|
+
} = input;
|
|
125
|
+
|
|
126
|
+
// A streaming proposal must carry a bucket reference to derive its bundle from.
|
|
127
|
+
if (bucketRef === undefined) {
|
|
128
|
+
return { accepted: false, reason: 'bucket_unknown' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check 1: exists in our own Inbox view, and the resolved rolling hash matches the reference.
|
|
132
|
+
const bucket = await messageSource.getInboxBucket(bucketRef.bucketSeq);
|
|
133
|
+
if (bucket === undefined) {
|
|
134
|
+
return { accepted: false, reason: 'bucket_unknown' };
|
|
135
|
+
}
|
|
136
|
+
if (!bucket.inboxRollingHash.equals(bucketRef.inboxRollingHash)) {
|
|
137
|
+
return { accepted: false, reason: 'bucket_hash_mismatch' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check 2: consumption moves forward relative to the parent block.
|
|
141
|
+
if (bucket.totalMsgCount < parentTotalMsgCount) {
|
|
142
|
+
return { accepted: false, reason: 'bucket_moves_backwards' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Check 3: the bucket is at least `minBucketAgeSeconds` old at validation time.
|
|
146
|
+
if (bucket.timestamp > nowSeconds - BigInt(minBucketAgeSeconds)) {
|
|
147
|
+
return { accepted: false, reason: 'bucket_too_new' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check 4a: the per-block message count fits the per-block cap.
|
|
151
|
+
const blockCount = bucket.totalMsgCount - parentTotalMsgCount;
|
|
152
|
+
if (blockCount > BigInt(perBlockCap)) {
|
|
153
|
+
return { accepted: false, reason: 'bundle_over_block_cap' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check 4b: the running per-checkpoint total fits the per-checkpoint cap.
|
|
157
|
+
const checkpointCount = bucket.totalMsgCount - checkpointStartTotalMsgCount;
|
|
158
|
+
if (checkpointCount > BigInt(perCheckpointCap)) {
|
|
159
|
+
return { accepted: false, reason: 'checkpoint_over_msg_cap' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check 5: the parent bucket is the one whose cumulative total equals the parent block's leaf count (messages are
|
|
163
|
+
// indexed compactly, with no padding); a parent whose count does not sit on a bucket boundary is unresolvable.
|
|
164
|
+
const parentBucket = await messageSource.getInboxBucketByTotalMsgCount(parentTotalMsgCount);
|
|
165
|
+
if (parentBucket === undefined) {
|
|
166
|
+
return { accepted: false, reason: 'parent_bucket_unresolved' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { accepted: true, bucket, parentBucket };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Derives the message-leaf bundle a streaming block proposal consumes (for re-execution): the leaves between the
|
|
174
|
+
* parent bucket and the proposed one, bounded by the per-block cap that
|
|
175
|
+
* {@link checkStreamingBlockProposalMetadata} already enforced.
|
|
176
|
+
*/
|
|
177
|
+
export function getStreamingBlockBundle(
|
|
178
|
+
messageSource: Pick<StreamingInboxBucketSource, 'getL1ToL2MessagesBetweenBuckets'>,
|
|
179
|
+
range: StreamingBlockBucketRange,
|
|
180
|
+
): Promise<Fr[]> {
|
|
181
|
+
const { bucket, parentBucket } = range;
|
|
182
|
+
return parentBucket.seq === bucket.seq
|
|
183
|
+
? Promise.resolve([])
|
|
184
|
+
: messageSource.getL1ToL2MessagesBetweenBuckets(parentBucket.seq, bucket.seq);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Runs the per-block acceptance checks a validator applies to a streaming block proposal, and derives the
|
|
189
|
+
* message-leaf bundle the block consumes. Composes {@link checkStreamingBlockProposalMetadata} with
|
|
190
|
+
* {@link getStreamingBlockBundle} for callers that have no use for running the two phases separately.
|
|
191
|
+
*/
|
|
192
|
+
export async function checkStreamingBlockProposal(input: StreamingBlockCheckInput): Promise<StreamingBlockCheckResult> {
|
|
193
|
+
const metadata = await checkStreamingBlockProposalMetadata(input);
|
|
194
|
+
if (!metadata.accepted) {
|
|
195
|
+
return metadata;
|
|
196
|
+
}
|
|
197
|
+
return { accepted: true, bundle: await getStreamingBlockBundle(input.messageSource, metadata) };
|
|
198
|
+
}
|