Skip to content

Commit a5869b8

Browse files
committed
feat(fast-inbox): wait for the local archiver before rejecting a block proposal for an unsynced inbox bucket
A proposer only consumes buckets at least one Ethereum slot old, so a bucket a validator cannot resolve is almost always its own archiver trailing L1, not a divergence. Rejecting on the spot with `bucket_unknown` lost an attestation for a pure race. The handler now forces an archiver sync and re-runs the whole metadata check every half second until it resolves or the attestation deadline passes. A hash mismatch on a known bucket gets one forced sync and one re-check (this node may be the stale side of an L1 reorg); every other reason still rejects immediately. The deadline-bounded sync waits in the handler now go through a shared `awaitLocalSync` helper, which the new wait reuses; the checkpoint last-block wait keeps its own copy because it must still attempt one lookup after the deadline has passed.
1 parent 7ef885b commit a5869b8

3 files changed

Lines changed: 278 additions & 48 deletions

File tree

yarn-project/validator-client/src/proposal_handler.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,6 +1135,140 @@ describe('ProposalHandler checkpoint validation', () => {
11351135
reason: 'bucket_too_new',
11361136
});
11371137
});
1138+
1139+
// A bucket the proposer already consumed is on L1 by construction, so a bucket this node cannot resolve is
1140+
// (usually) local archiver lag, not a divergence: the handler forces a sync and re-checks until the
1141+
// attestation deadline instead of dropping the attestation on the spot.
1142+
describe('bucket sync wait', () => {
1143+
// attestation_deadline(slot=1) = 1*24 + 24 - 8 = 40s. Waits run on a real timer against the remaining
1144+
// budget read off the fake clock, so holding it 2s short of the deadline keeps the tests short.
1145+
const DEADLINE_MS = 40_000;
1146+
const WAIT_BUDGET_MS = 2_000;
1147+
const BEFORE_DEADLINE_MS = DEADLINE_MS - WAIT_BUDGET_MS;
1148+
const PAST_DEADLINE_MS = DEADLINE_MS + 1_000;
1149+
const WAIT_INTERVAL_MS = 500;
1150+
1151+
/** A bucket old enough to be lag-eligible at {@link BEFORE_DEADLINE_MS}. */
1152+
const eligibleBucket = (overrides: Partial<InboxBucket> = {}) => bucket({ timestamp: 10n, ...overrides });
1153+
1154+
/** Wires the parent-bucket lookup and the bundle read for a proposal that consumes `eligibleBucket()`. */
1155+
function mockAcceptedSurroundings() {
1156+
l1ToL2MessageSource.getInboxBucketByTotalMsgCount.mockResolvedValue(
1157+
eligibleBucket({ seq: 0n, totalMsgCount: 0n, msgCount: 0 }),
1158+
);
1159+
l1ToL2MessageSource.getL1ToL2MessagesBetweenBuckets.mockResolvedValue([
1160+
{ timestamp: 10n, leaves: [new Fr(1000), new Fr(1001)] },
1161+
]);
1162+
}
1163+
1164+
it('attests once the referenced bucket shows up on a later archiver sync', async () => {
1165+
const ref = new InboxBucketRef(1n, 10n, new Fr(0xabc));
1166+
const { proposal, blockHandler } = await setupStreamingProposal(ref, { nowMs: BEFORE_DEADLINE_MS });
1167+
mockAcceptedSurroundings();
1168+
// Unknown on arrival, synced by the time the wait re-checks.
1169+
l1ToL2MessageSource.getInboxBucket.mockResolvedValueOnce(undefined).mockResolvedValue(eligibleBucket());
1170+
jest.spyOn(blockHandler, 'reexecuteTransactions').mockResolvedValue({ block: undefined } as any);
1171+
1172+
const result = await blockHandler.handleBlockProposal(proposal, {} as any, true);
1173+
1174+
expect(result.isValid).toBe(true);
1175+
expect(result.blockNumber).toEqual(BlockNumber(INITIAL_L2_BLOCK_NUM));
1176+
});
1177+
1178+
it('rejects with bucket_unknown when the bucket never syncs, no earlier than the deadline', async () => {
1179+
const ref = new InboxBucketRef(1n, 10n, new Fr(0xabc));
1180+
const { proposal, blockHandler, txProvider } = await setupStreamingProposal(ref, {
1181+
nowMs: BEFORE_DEADLINE_MS,
1182+
});
1183+
mockAcceptedSurroundings();
1184+
l1ToL2MessageSource.getInboxBucket.mockResolvedValue(undefined);
1185+
1186+
const startMs = Date.now();
1187+
const result = await blockHandler.handleBlockProposal(proposal, {} as any, true);
1188+
const elapsedMs = Date.now() - startMs;
1189+
1190+
expect(result).toEqual({
1191+
isValid: false,
1192+
blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM),
1193+
reason: 'bucket_unknown',
1194+
});
1195+
// The wait runs out the remaining budget and gives up within one retry interval of the deadline.
1196+
expect(elapsedMs).toBeGreaterThanOrEqual(WAIT_BUDGET_MS - 100);
1197+
expect(elapsedMs).toBeLessThan(WAIT_BUDGET_MS + 2 * WAIT_INTERVAL_MS);
1198+
// Waiting never buys the proposer any network work: the rejection still happens before tx collection.
1199+
expect(txProvider.getTxsForBlockProposal).not.toHaveBeenCalled();
1200+
});
1201+
1202+
it('rejects immediately without syncing when the attestation deadline has already passed', async () => {
1203+
const ref = new InboxBucketRef(1n, 10n, new Fr(0xabc));
1204+
const { proposal, blockHandler } = await setupStreamingProposal(ref, { nowMs: PAST_DEADLINE_MS });
1205+
mockAcceptedSurroundings();
1206+
l1ToL2MessageSource.getInboxBucket.mockResolvedValue(undefined);
1207+
1208+
const result = await blockHandler.handleBlockProposal(proposal, {} as any, true);
1209+
1210+
expect(result).toEqual({
1211+
isValid: false,
1212+
blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM),
1213+
reason: 'bucket_unknown',
1214+
});
1215+
// With no budget left there is nothing to wait for, so the archiver is not poked at all.
1216+
expect(blockSource.syncImmediate).not.toHaveBeenCalled();
1217+
});
1218+
1219+
it('rejects immediately without syncing when the proposal carries no bucket reference', async () => {
1220+
const { proposal, blockHandler } = await setupStreamingProposal(undefined, {
1221+
nowMs: BEFORE_DEADLINE_MS,
1222+
});
1223+
1224+
const result = await blockHandler.handleBlockProposal(proposal, {} as any, true);
1225+
1226+
expect(result).toEqual({
1227+
isValid: false,
1228+
blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM),
1229+
reason: 'bucket_unknown',
1230+
});
1231+
expect(blockSource.syncImmediate).not.toHaveBeenCalled();
1232+
});
1233+
1234+
it('rejects a hash mismatch that survives one forced sync, without looping', async () => {
1235+
const ref = new InboxBucketRef(1n, 10n, new Fr(0xdead));
1236+
const { proposal, blockHandler } = await setupStreamingProposal(ref, { nowMs: BEFORE_DEADLINE_MS });
1237+
mockAcceptedSurroundings();
1238+
l1ToL2MessageSource.getInboxBucket.mockResolvedValue(eligibleBucket({ inboxRollingHash: new Fr(0xabc) }));
1239+
1240+
const startMs = Date.now();
1241+
const result = await blockHandler.handleBlockProposal(proposal, {} as any, true);
1242+
const elapsedMs = Date.now() - startMs;
1243+
1244+
expect(result).toEqual({
1245+
isValid: false,
1246+
blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM),
1247+
reason: 'bucket_hash_mismatch',
1248+
});
1249+
// A persistent mismatch is a divergence from L1, not local lag: one sync, one re-check, no retry loop.
1250+
expect(blockSource.syncImmediate).toHaveBeenCalledTimes(1);
1251+
expect(elapsedMs).toBeLessThan(WAIT_INTERVAL_MS);
1252+
});
1253+
1254+
it('attests when the forced sync replaces our stale bucket with the proposed one', async () => {
1255+
// This validator held the orphaned side of an L1 reorg; the forced sync rolls it back and re-syncs.
1256+
const ref = new InboxBucketRef(1n, 10n, new Fr(0xabc));
1257+
const { proposal, blockHandler } = await setupStreamingProposal(ref, { nowMs: BEFORE_DEADLINE_MS });
1258+
mockAcceptedSurroundings();
1259+
l1ToL2MessageSource.getInboxBucket.mockResolvedValue(eligibleBucket({ inboxRollingHash: new Fr(0xbad) }));
1260+
blockSource.syncImmediate.mockImplementation(() => {
1261+
l1ToL2MessageSource.getInboxBucket.mockResolvedValue(eligibleBucket());
1262+
return Promise.resolve();
1263+
});
1264+
jest.spyOn(blockHandler, 'reexecuteTransactions').mockResolvedValue({ block: undefined } as any);
1265+
1266+
const result = await blockHandler.handleBlockProposal(proposal, {} as any, true);
1267+
1268+
expect(result.isValid).toBe(true);
1269+
expect(result.blockNumber).toEqual(BlockNumber(INITIAL_L2_BLOCK_NUM));
1270+
});
1271+
});
11381272
});
11391273

11401274
// Streaming Inbox: the checkpoint handler enforces the last-block minimum-consumption (censorship) rule before

yarn-project/validator-client/src/proposal_handler.ts

Lines changed: 137 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ export class ProposalHandler {
579579
// Streaming Inbox: run the metadata checks before committing to any network work. They are point lookups
580580
// against our own Inbox view, so a proposal carrying a bucket reference that does not resolve locally is
581581
// rejected without a proposer being able to make us spend the validation window collecting its txs.
582-
const streamingMetadata = await this.checkStreamingBlockMetadata(proposal, blockNumber, parentBlock);
582+
const streamingMetadata = await this.awaitStreamingBlockMetadata(proposal, blockNumber, parentBlock, proposalInfo);
583583
if (!streamingMetadata.accepted) {
584584
this.log.warn(`Streaming Inbox block acceptance check failed, skipping processing`, {
585585
reason: streamingMetadata.reason,
@@ -731,6 +731,40 @@ export class ProposalHandler {
731731
}
732732
}
733733

734+
/**
735+
* Re-runs `resolve` against this node's local view, forcing an archiver L1 sync before every attempt, until it
736+
* yields a value or the slot's attestation deadline passes. Returns `undefined` when the deadline had already
737+
* passed on entry (nothing is forced in that case) or when it passes while waiting; anything other than the
738+
* timeout propagates. Callers own their own logging and whatever they fall back to on `undefined`, and check
739+
* the deadline themselves when they need to tell "no budget on entry" apart from "timed out while waiting".
740+
*/
741+
private async awaitLocalSync<T>(
742+
slotNumber: SlotNumber,
743+
what: string,
744+
resolve: () => Promise<T | undefined>,
745+
): Promise<T | undefined> {
746+
const deadline = this.getReexecutionDeadline(slotNumber);
747+
if (deadline.getTime() - this.dateProvider.now() <= 0) {
748+
return undefined;
749+
}
750+
try {
751+
return await retryUntil(
752+
async () => {
753+
await this.blockSource.syncImmediate();
754+
return await resolve();
755+
},
756+
what,
757+
{ deadline, dateProvider: this.dateProvider },
758+
0.5,
759+
);
760+
} catch (err) {
761+
if (err instanceof TimeoutError) {
762+
return undefined;
763+
}
764+
throw err;
765+
}
766+
}
767+
734768
private async getParentBlock(proposal: BlockProposal): Promise<'genesis' | BlockData | undefined> {
735769
const parentArchive = proposal.blockHeader.lastArchive.root;
736770
const { genesisArchiveRoot } = await this.blockSource.getGenesisValues();
@@ -739,28 +773,23 @@ export class ProposalHandler {
739773
return 'genesis';
740774
}
741775

742-
const deadline = this.getReexecutionDeadline(proposal.slotNumber);
743-
const timeoutDurationMs = deadline.getTime() - this.dateProvider.now();
744-
745776
try {
746-
return (
747-
(await this.blockSource.getBlockData({ archive: parentArchive })) ??
748-
(timeoutDurationMs <= 0
749-
? undefined
750-
: await retryUntil(
751-
() =>
752-
this.blockSource.syncImmediate().then(() => this.blockSource.getBlockData({ archive: parentArchive })),
753-
'force archiver sync',
754-
{ deadline, dateProvider: this.dateProvider },
755-
0.5,
756-
))
777+
const parentBlock = await this.blockSource.getBlockData({ archive: parentArchive });
778+
if (parentBlock !== undefined) {
779+
return parentBlock;
780+
}
781+
if (this.getReexecutionDeadline(proposal.slotNumber).getTime() - this.dateProvider.now() <= 0) {
782+
return undefined;
783+
}
784+
const synced = await this.awaitLocalSync(proposal.slotNumber, 'force archiver sync', () =>
785+
this.blockSource.getBlockData({ archive: parentArchive }),
757786
);
758-
} catch (err) {
759-
if (err instanceof TimeoutError) {
787+
if (synced === undefined) {
760788
this.log.debug(`Timed out getting parent block by archive root`, { parentArchive });
761-
} else {
762-
this.log.error('Error getting parent block by archive root', err, { parentArchive });
763789
}
790+
return synced;
791+
} catch (err) {
792+
this.log.error('Error getting parent block by archive root', err, { parentArchive });
764793
return undefined;
765794
}
766795
}
@@ -786,8 +815,7 @@ export class ProposalHandler {
786815

787816
// A different block already occupies this number: it may be a stale fork being pruned during a reorg, not a
788817
// genuine duplicate. Wait for the local prune rather than permanently rejecting the proposal.
789-
const deadline = this.getReexecutionDeadline(slotNumber);
790-
if (deadline.getTime() - this.dateProvider.now() <= 0) {
818+
if (this.getReexecutionDeadline(slotNumber).getTime() - this.dateProvider.now() <= 0) {
791819
return existingBlock;
792820
}
793821

@@ -797,29 +825,19 @@ export class ProposalHandler {
797825
proposalArchive: proposalArchive.toString(),
798826
});
799827

800-
try {
801-
const { block } = await retryUntil(
802-
async () => {
803-
await this.blockSource.syncImmediate();
804-
const block = await this.blockSource.getBlockData({ number: blockNumber });
805-
// Resolve once the existing block is gone (pruned) or has been replaced by one matching the
806-
// proposal — the same condition as the early return above. A matching block is returned so the
807-
// caller still treats it as a genuine duplicate; an `undefined` (pruned) block lets the proposal
808-
// be processed. Wrap in an object so the `undefined` case is still a truthy retry result.
809-
return block === undefined || block.archive.root.equals(proposalArchive) ? { block } : undefined;
810-
},
811-
`prune of stale block ${blockNumber}`,
812-
{ deadline, dateProvider: this.dateProvider },
813-
0.5,
814-
);
815-
return block;
816-
} catch (err) {
817-
if (err instanceof TimeoutError) {
818-
this.log.warn(`Timed out waiting for stale block ${blockNumber} to be pruned`, { blockNumber });
819-
return existingBlock;
820-
}
821-
throw err;
828+
const pruned = await this.awaitLocalSync(slotNumber, `prune of stale block ${blockNumber}`, async () => {
829+
const block = await this.blockSource.getBlockData({ number: blockNumber });
830+
// Resolve once the existing block is gone (pruned) or has been replaced by one matching the
831+
// proposal — the same condition as the early return above. A matching block is returned so the
832+
// caller still treats it as a genuine duplicate; an `undefined` (pruned) block lets the proposal
833+
// be processed. Wrap in an object so the `undefined` case is still a truthy retry result.
834+
return block === undefined || block.archive.root.equals(proposalArchive) ? { block } : undefined;
835+
});
836+
if (pruned === undefined) {
837+
this.log.warn(`Timed out waiting for stale block ${blockNumber} to be pruned`, { blockNumber });
838+
return existingBlock;
822839
}
840+
return pruned.block;
823841
}
824842

825843
private computeCheckpointNumber(
@@ -971,6 +989,80 @@ export class ProposalHandler {
971989
}
972990
}
973991

992+
/**
993+
* Runs the streaming-Inbox metadata checks, waiting out a local sync lag. A bucket the proposer consumed is at
994+
* least one Ethereum slot old, so it is on L1 by the time the proposal arrives: a bucket this node cannot
995+
* resolve is almost always its own archiver trailing L1, not a divergence. That case (and the equivalent one
996+
* where the block before the checkpoint's first block has not synced) forces an archiver sync and re-checks
997+
* every half second until it resolves or the attestation deadline passes, instead of dropping the attestation
998+
* on the spot. A hash mismatch on a bucket we do know gets exactly one forced sync and one re-check, because
999+
* this node may be the stale side of an L1 reorg and that sync performs the rollback; a mismatch that survives
1000+
* it will not resolve by waiting. Every other reason is a structural rejection and returns immediately.
1001+
*
1002+
* The wait is bounded by the same consensus deadline as the other sync waits here, so a proposer referencing a
1003+
* bucket that never appears can at most make validators poll their own archiver for the remainder of its own
1004+
* slot — which it could waste anyway by not proposing.
1005+
*/
1006+
private async awaitStreamingBlockMetadata(
1007+
proposal: BlockProposal,
1008+
blockNumber: BlockNumber,
1009+
parentBlock: 'genesis' | BlockData,
1010+
proposalInfo: LogData,
1011+
): Promise<StreamingBlockMetadataCheckResult> {
1012+
const first = await this.checkStreamingBlockMetadata(proposal, blockNumber, parentBlock);
1013+
const bucketRef = proposal.bucketRef;
1014+
if (first.accepted || bucketRef === undefined) {
1015+
return first;
1016+
}
1017+
1018+
const slotNumber = proposal.slotNumber;
1019+
const bucketSeq = bucketRef.bucketSeq;
1020+
const outOfBudget = this.getReexecutionDeadline(slotNumber).getTime() - this.dateProvider.now() <= 0;
1021+
1022+
if (first.reason === 'bucket_hash_mismatch') {
1023+
if (outOfBudget) {
1024+
return first;
1025+
}
1026+
await this.blockSource.syncImmediate();
1027+
const rechecked = await this.checkStreamingBlockMetadata(proposal, blockNumber, parentBlock);
1028+
if (!rechecked.accepted && rechecked.reason === 'bucket_hash_mismatch') {
1029+
this.log.warn(`Inbox bucket ${bucketSeq} still disagrees with the proposal after forcing an archiver sync`, {
1030+
reason: 'bucket_hash_mismatch_after_sync',
1031+
bucketSeq,
1032+
expected: bucketRef.inboxRollingHash.toString(),
1033+
actual: (await this.l1ToL2MessageSource.getInboxBucket(bucketSeq))?.inboxRollingHash.toString(),
1034+
...proposalInfo,
1035+
});
1036+
}
1037+
return rechecked;
1038+
}
1039+
1040+
if (first.reason !== 'bucket_unknown') {
1041+
return first;
1042+
}
1043+
1044+
this.log.info(`Referenced Inbox bucket ${bucketSeq} not synced locally, awaiting archiver sync`, {
1045+
bucketSeq,
1046+
...proposalInfo,
1047+
});
1048+
const timer = new Timer();
1049+
const resolved = await this.awaitLocalSync(slotNumber, `inbox bucket ${bucketSeq}`, async () => {
1050+
const result = await this.checkStreamingBlockMetadata(proposal, blockNumber, parentBlock);
1051+
return !result.accepted && result.reason === 'bucket_unknown' ? undefined : result;
1052+
});
1053+
if (resolved === undefined) {
1054+
this.log.warn(`Timed out waiting for Inbox bucket ${bucketSeq} to sync, rejecting proposal`, {
1055+
reason: 'bucket_sync_timeout',
1056+
slot: slotNumber,
1057+
bucketSeq,
1058+
waitedMs: timer.ms(),
1059+
...proposalInfo,
1060+
});
1061+
return first;
1062+
}
1063+
return resolved;
1064+
}
1065+
9741066
/**
9751067
* Runs the streaming-Inbox per-block metadata checks for a block proposal, returning the bucket range its message
9761068
* bundle derives from or a rejection reason. The parent block's consumed total and the checkpoint's starting total
@@ -990,7 +1082,8 @@ export class ProposalHandler {
9901082
);
9911083
if (checkpointStartTotalMsgCount === undefined) {
9921084
// The block before the checkpoint's first block has not synced locally, so the per-checkpoint cap origin is
993-
// unavailable: treat as an unknown local view. There is no bounded wait for the missing block yet.
1085+
// unavailable: treat as an unknown local view. Like an unknown bucket this is local lag rather than a
1086+
// divergence, and `awaitStreamingBlockMetadata` waits it out by re-running the whole check after a sync.
9941087
return { accepted: false, reason: 'bucket_unknown' };
9951088
}
9961089
const nowSeconds = BigInt(Math.floor(this.dateProvider.now() / 1000));

0 commit comments

Comments
 (0)