Skip to content

Commit 4bafe67

Browse files
committed
fix(fast-inbox): source the checkpoint bucket hint from the streaming cursor (A-1384)
When a checkpoint builds several blocks in one slot and the final sub-slot block fails to build after earlier blocks already consumed L1-to-L2 messages, no block is held for broadcast, so the L1 propose bucket hint fell back to genesis bucket 0 while the checkpoint header committed to a non-genesis rolling hash — L1 rejects this as Rollup__InvalidInboxRollingHash. Take the hint from the streaming consumption cursor's final bucket reference, which is always defined and always matches the bucket the header committed to, and carry it through the broadcast result. Behavior-preserving on the normal held-block path (block bucket refs are cumulative, so the held last block's ref already equals the cursor's). Adds a regression test for the no-held-block checkpoint.
1 parent 4b91fa1 commit 4bafe67

2 files changed

Lines changed: 65 additions & 5 deletions

File tree

yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1493,6 +1493,49 @@ describe('CheckpointProposalJob', () => {
14931493
expect(checkpointBuilder.buildBlockCalls[0].opts.l1ToL2Messages).toEqual(bundle);
14941494
expect(publisher.enqueueProposeCheckpoint).toHaveBeenCalledTimes(1);
14951495
});
1496+
1497+
it('carries the consumed bucket hint when the final block fails to build after earlier consumption', async () => {
1498+
// Two sub-slots. The first block consumes the pending bucket (seq 2) as a message-only block. The final
1499+
// sub-slot block has no txs and nothing left to consume (the cursor already sits at seq 2), so it fails to
1500+
// build and is not held for broadcast. The L1 propose bucket hint must still be the bucket the checkpoint
1501+
// header committed to (seq 2), not fall back to genesis bucket 0 — otherwise L1 rejects the proposal with an
1502+
// inbox-rolling-hash mismatch (the hint's bucket rolling hash would not match the header's).
1503+
jest
1504+
.spyOn(job.getTimetable(), 'selectNextSubslot')
1505+
.mockReturnValueOnce(subslot(10, 0, false))
1506+
.mockReturnValueOnce(subslot(18, 1, true))
1507+
.mockReturnValue(noSubslot());
1508+
1509+
const bucket: InboxBucket = {
1510+
seq: 2n,
1511+
inboxRollingHash: new Fr(99),
1512+
totalMsgCount: 5n,
1513+
timestamp: 0n,
1514+
msgCount: 2,
1515+
lastMessageIndex: 4n,
1516+
isOpen: false,
1517+
};
1518+
const bundle = Array.from({ length: 5 }, (_, i) => new Fr(i + 1));
1519+
l1ToL2MessageSource.getLatestInboxBucketAtOrBefore.mockResolvedValue(bucket);
1520+
l1ToL2MessageSource.getL1ToL2MessagesBetweenBuckets.mockResolvedValue(bundle);
1521+
1522+
// Seed a single message-only block; the second sub-slot has no seeded block, no txs, and no new bucket to
1523+
// consume, so it fails the min-work threshold and no block is held for broadcast.
1524+
const { lastBlock } = await setupMultipleBlocks(1, [0]);
1525+
validatorClient.collectAttestations.mockResolvedValue(getAttestations(lastBlock));
1526+
1527+
job.updateConfig({ minTxsPerBlock: 1, buildCheckpointIfEmpty: false });
1528+
const checkpoint = await job.executeAndAwait();
1529+
1530+
expect(checkpoint).toBeDefined();
1531+
// Only the first (message-only) block built and consumed through bucket 2; the final block did not build.
1532+
expect(checkpointBuilder.buildBlockCalls).toHaveLength(1);
1533+
expect(publisher.enqueueProposeCheckpoint).toHaveBeenCalledTimes(1);
1534+
1535+
// No held last block, yet the bucket hint (4th positional arg) is the consumed bucket seq 2, matching the
1536+
// checkpoint header's rolling hash. Before the fix this fell back to genesis bucket 0n.
1537+
expect(publisher.enqueueProposeCheckpoint.mock.calls[0][3]).toBe(2n);
1538+
});
14961539
});
14971540

14981541
describe('build single block', () => {

yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ type CheckpointProposalBroadcast = {
9797
checkpoint: Checkpoint;
9898
proposal: CheckpointProposal;
9999
blockProposedAt: number;
100+
/**
101+
* Sequence number of the last Inbox bucket the checkpoint consumed through — the L1 `propose` lookup aid, which
102+
* must resolve to the bucket whose rolling hash the checkpoint header committed to. Taken from the streaming
103+
* consumption cursor rather than the held last-block proposal, which is absent when the checkpoint's final block
104+
* fails to build after earlier blocks already consumed messages.
105+
*/
106+
bucketHint: bigint;
100107
};
101108

102109
/** Result after attestation collection and signing, ready for L1 submission. */
@@ -310,8 +317,11 @@ export class CheckpointProposalJob implements Traceable {
310317
votesPromises: Promise<unknown>[],
311318
): Promise<void> {
312319
const { checkpoint } = broadcast;
313-
// The checkpoint's consumed Inbox bucket is the last block's bucket reference (undefined ⇒ genesis bucket 0).
314-
const bucketHint = broadcast.proposal.lastBlock?.bucketRef?.bucketSeq ?? 0n;
320+
// The bucket the checkpoint consumed through, from the streaming cursor. Sourcing it from the held last-block
321+
// proposal is wrong: when the checkpoint's final block fails to build after earlier blocks already consumed
322+
// messages, no block is held, so the hint would fall back to genesis bucket 0 while the header commits to a
323+
// non-genesis rolling hash, which L1 rejects with Rollup__InvalidInboxRollingHash.
324+
const bucketHint = broadcast.bucketHint;
315325

316326
try {
317327
// Wait for all votes actions, enqueued at the beginning, to resolve
@@ -834,7 +844,12 @@ export class CheckpointProposalJob implements Traceable {
834844
);
835845
this.metrics.recordCheckpointSuccess();
836846
// Return a broadcast result with a dummy proposal — fisherman mode skips attestation collection
837-
return { checkpoint, proposal: undefined!, blockProposedAt: this.dateProvider.now() };
847+
return {
848+
checkpoint,
849+
proposal: undefined!,
850+
blockProposedAt: this.dateProvider.now(),
851+
bucketHint: streamingState.lastBucketRef.bucketSeq,
852+
};
838853
}
839854

840855
// Validate the header against L1 state before broadcasting.
@@ -888,8 +903,10 @@ export class CheckpointProposalJob implements Traceable {
888903
this.checkpointMetrics.noteCheckpointBroadcast(this.dateProvider.now());
889904
}
890905

891-
// Return immediately after broadcast — attestation collection happens in the background
892-
return { checkpoint, proposal, blockProposedAt };
906+
// Return immediately after broadcast — attestation collection happens in the background. The bucket hint is
907+
// the streaming cursor's final consumed bucket, which matches the header's rolling hash whether or not a last
908+
// block was held for broadcast.
909+
return { checkpoint, proposal, blockProposedAt, bucketHint: streamingState.lastBucketRef.bucketSeq };
893910
} catch (err) {
894911
if (err && (err instanceof DutyAlreadySignedError || err instanceof SlashingProtectionError)) {
895912
// swallow this error. It's already been logged by a function deeper in the stack

0 commit comments

Comments
 (0)