@@ -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