@@ -126,13 +126,42 @@ export const createPortalHistoricalSync = (
126126 const delegated = new Set < string > ( ) ; // interval keys routed to RPC (finality gap)
127127 let chunkBlocks = cfg . chunkBlocks ;
128128 let chunkSizeP : Promise < void > | undefined ;
129- let discStartIdx : number | undefined ; // factory-deploy chunk = discovery floor (C4: clamps DOWNWARD)
130129 let portalHead : number | undefined = cfg . finalizedHead ;
131130 let startTime = 0 ;
132131
132+ // FIX 2 (INV-3/INV-15): the discovery floor is the earliest block ANY factory could create a child —
133+ // `min` over the compiled spec's factories of `fromBlock ?? 0` (undefined ⇒ genesis). It is a property
134+ // of the SPEC, not of the intervals ponder happens to still need, so it is pinned at CONSTRUCTION (and
135+ // re-pinned per call, since chunkBlocks can scale) — see `pinDiscoveryFloor`. `discFloorBlock` is the
136+ // downward-clamped floor BLOCK (grid-independent, so it survives a rescale); requiredFactoryIntervals /
137+ // the interval start only REFINE it downward (C4/INV-4).
138+ const specFloorBlock =
139+ spec . factories . length > 0
140+ ? Math . min ( ...spec . factories . map ( ( f ) => f . fromBlock ?? 0 ) )
141+ : undefined ;
142+ let discFloorBlock = specFloorBlock ;
143+
133144 const ikey = ( i : Interval ) : string => `${ i [ 0 ] } -${ i [ 1 ] } ` ;
145+ // FIX 1 (INV-9/INV-13): the data ceiling is the LOWER of the configured backfill end and the live Portal
146+ // head — clamp BOTH. With every source bounded (`backfillEnd` defined) the old `backfillEnd ?? portalHead`
147+ // ignored the head, so a frontier chunk's `desiredTo`/`coveredTo` extended PAST the head; the Portal
148+ // 204s/truncates above its head, `coveredTo` recorded phantom coverage, and once the head advanced later
149+ // intervals blind-hit the stale cache and were marked synced EMPTY (permanent silent gap). Clamping here
150+ // flows to desiredTo, coveredTo, endHint and raEnd, and re-arms the INV-13 extend as the head advances.
134151 const dataEnd = ( ) : number =>
135- spec . backfillEnd ?? portalHead ?? Number . POSITIVE_INFINITY ;
152+ Math . min (
153+ spec . backfillEnd ?? Number . POSITIVE_INFINITY ,
154+ portalHead ?? Number . POSITIVE_INFINITY ,
155+ ) ;
156+
157+ // FIX 2: snap `discFloorBlock` to the current grid and install it as the discovery floor. Idempotent —
158+ // called at construction and again on every syncBlockRangeData before any fetch (chunkBlocks may have
159+ // scaled since). No-op when there are no factories.
160+ const pinDiscoveryFloor = ( ) : void => {
161+ if ( discFloorBlock === undefined ) return ;
162+
163+ discovery . setFloor ( idxOf ( discFloorBlock , chunkBlocks ) * chunkBlocks ) ;
164+ } ;
136165
137166 // finality-gap fallback: Portal serves only finalized data, and its finalized head can (rarely) lag
138167 // Ponder's target. Any interval reaching past the head is delegated whole to the stock RPC sync.
@@ -163,7 +192,12 @@ export const createPortalHistoricalSync = (
163192
164193 const h = await client . finalizedHead ( ) ;
165194 if ( h !== undefined ) {
166- portalHead = h ;
195+ // FIX 5: a live probe drives chunk SCALING, but it must NOT overwrite an explicit
196+ // PORTAL_FINALIZED_HEAD pin — the pin is authoritative for the finality/delegation decision, and
197+ // clobbering it here (which happened whenever the pin was set but PORTAL_CHUNK_FIXED was not) let
198+ // intervals above the pin but below the live head be served instead of delegated. Only adopt the
199+ // probe as the head when there is no pin; scaling may still use the live `h`.
200+ if ( cfg . finalizedHead === undefined ) portalHead = h ;
167201 chunkBlocks = scaleChunkBlocks ( cfg . chunkBlocks , h ) ;
168202 log . debug ( {
169203 service : 'portal' ,
@@ -186,6 +220,17 @@ export const createPortalHistoricalSync = (
186220 token : RowToken ,
187221 ) : Promise < void > => {
188222 const neededMissing = new Set < string > ( ) ;
223+ // FIX 3: the needed-field crash check must consider ONLY the rows THIS call adds. On a frontier EXTEND
224+ // `cd` already carries the base chunk's data; the tail streams the disjoint range (coveredTo, desiredTo]
225+ // whose block numbers are all > coveredTo (new map keys), so a size delta captures exactly the tail's
226+ // matched rows. Inspecting the whole accumulated `cd` (as before) let a data-bearing base + an
227+ // event-less tail whose dataset lacks a needed column throw fatally → evict → crash-loop on retry.
228+ const matchedSize = ( ) : number =>
229+ cd . logs . size +
230+ cd . traceBlocks . size +
231+ cd . txBlocks . size +
232+ cd . blockHeaders . size ;
233+ const matchedBefore = matchedSize ( ) ;
189234 const onRows = ( n : number ) : void => {
190235 if ( token . freed ) return ;
191236
@@ -270,15 +315,10 @@ export const createPortalHistoricalSync = (
270315 } ) ;
271316 }
272317 }
273- // A NEEDED field the dataset lacked on THIS range: crash ONLY IF the chunk yielded MATCHED data —
274- // an event the indexer processes would be incomplete. Event-less (old/irrelevant) ranges proceed.
275- if (
276- neededMissing . size &&
277- ( cd . logs . size ||
278- cd . traceBlocks . size ||
279- cd . txBlocks . size ||
280- cd . blockHeaders . size )
281- ) {
318+ // A NEEDED field the dataset lacked on THIS range: crash ONLY IF this call added MATCHED data — an
319+ // event the indexer processes would be incomplete. An event-less (old/irrelevant) range — or an
320+ // event-less EXTEND tail over a data-bearing base (FIX 3) — proceeds.
321+ if ( neededMissing . size && matchedSize ( ) > matchedBefore ) {
282322 throw new Error (
283323 `Portal dataset for ${ chain . name } is missing [${ [ ...neededMissing ] . join ( ', ' ) } ] on blocks [${ from } ,${ to } ], which contain matched data your indexer needs — a Portal dataset-completeness gap. Failing fast rather than serving incomplete data; report the gap to SQD, or start your indexer past the affected range.` ,
284324 ) ;
@@ -296,19 +336,21 @@ export const createPortalHistoricalSync = (
296336 spec . backfillStart ,
297337 dataEnd ( ) ,
298338 ) ;
299- // INV-9: a Portal data request never targets past the known finalized head (an explicit backfill
300- // toBlock may exceed it by configuration — the delegation branch already guards served intervals).
339+ // INV-9: a Portal data request never targets past the known finalized head. `dataEnd()` now clamps to
340+ // the head (FIX 1), so `desiredTo <= portalHead` holds BY CONSTRUCTION whenever the head is known — the
341+ // former `spec.backfillEnd !== undefined` escape (which let a bounded backfill over-reach) is gone and
342+ // this assert is a strictly stronger tripwire. Intervals past the head are already delegated to RPC.
301343 invariant (
302344 'INV-9' ,
303- portalHead === undefined ||
304- desiredTo <= portalHead ||
305- spec . backfillEnd !== undefined ,
345+ portalHead === undefined || desiredTo <= portalHead ,
306346 'data request targets past the Portal finalized head' ,
307347 ( ) => ( { idx, desiredTo, portalHead } ) ,
308348 ) ;
349+ // Discovery scans as far as the backfill will need in one pass; head-clamped (FIX 1) via dataEnd().
350+ const de = dataEnd ( ) ;
309351 const ensureOpts = {
310352 chunkBlocks,
311- endHint : spec . backfillEnd ?? portalHead ?? desiredTo ,
353+ endHint : Number . isFinite ( de ) ? de : desiredTo ,
312354 } ;
313355
314356 const cached = dataCache . get ( idx ) ;
@@ -334,11 +376,12 @@ export const createPortalHistoricalSync = (
334376 const extended = ( async ( ) : Promise < ChunkData > => {
335377 const cd = await prev ;
336378 await discovery . ensure ( desiredTo , ensureOpts ) ; // discovery must reach the extended tail too
379+ // FIX 2: the floor is pinned from the spec at construction, so a factory sync always has a floor —
380+ // the former `discStartIdx === undefined` escape (which silently disabled this check on the very
381+ // chunks that fetched before requiredFactoryIntervals arrived) is gone.
337382 invariant (
338383 'INV-3' ,
339- spec . factories . length === 0 ||
340- discStartIdx === undefined ||
341- discovery . through ( ) >= desiredTo ,
384+ spec . factories . length === 0 || discovery . through ( ) >= desiredTo ,
342385 'chunk extend under a stale discovery watermark' ,
343386 ( ) => ( { idx, through : discovery . through ( ) , desiredTo } ) ,
344387 ) ;
@@ -360,11 +403,10 @@ export const createPortalHistoricalSync = (
360403 const token : RowToken = { rows : 0 , freed : false } ;
361404 const p = ( async ( ) : Promise < ChunkData > => {
362405 await discovery . ensure ( desiredTo , ensureOpts ) ; // children ≤ this chunk are known (INV-3)
406+ // FIX 2: floor pinned from the spec at construction ⇒ no `discStartIdx === undefined` escape (see extend).
363407 invariant (
364408 'INV-3' ,
365- spec . factories . length === 0 ||
366- discStartIdx === undefined ||
367- discovery . through ( ) >= desiredTo ,
409+ spec . factories . length === 0 || discovery . through ( ) >= desiredTo ,
368410 'data fetch under a stale discovery watermark' ,
369411 ( ) => ( { idx, through : discovery . through ( ) , desiredTo } ) ,
370412 ) ;
@@ -402,6 +444,10 @@ export const createPortalHistoricalSync = (
402444 }
403445 } ;
404446
447+ // FIX 2: pin the discovery floor from the spec NOW, before the first fetch (re-pinned per call once
448+ // chunkBlocks is finalized). A no-op when there are no factory sources.
449+ pinDiscoveryFloor ( ) ;
450+
405451 return {
406452 async syncBlockRangeData ( params ) {
407453 const { interval, requiredFactoryIntervals, syncStore } = params ;
@@ -456,28 +502,25 @@ export const createPortalHistoricalSync = (
456502 chunkBlocks = capped ;
457503 for ( const entry of dataCache . values ( ) ) freeToken ( entry . token ) ;
458504 dataCache . clear ( ) ;
459- discStartIdx = undefined ;
460- discovery . reset ( ) ;
505+ discovery . reset ( ) ; // discFloorBlock (block-space) survives; re-pinned to the new grid just below
461506 log . debug ( {
462507 service : 'portal' ,
463508 msg : `Portal ${ chain . name } : dense sources → chunkBlocks capped to ${ chunkBlocks } (grid reset)` ,
464509 } ) ;
465510 }
466511
467- // pin the discovery floor at the factory's real start (NOT block 0). C4: clamp DOWNWARD only.
468- if ( requiredFactoryIntervals . length > 0 ) {
469- const floor = idxOf (
470- Math . min (
471- ... requiredFactoryIntervals
472- . map ( ( r ) => r . interval [ 0 ] )
473- . concat ( interval [ 0 ] ) ,
474- ) ,
475- chunkBlocks ,
512+ // FIX 2: (re-) pin the discovery floor BEFORE any fetch. Base floor = the spec's earliest factory
513+ // start (`discFloorBlock`, seeded at construction); requiredFactoryIntervals and the interval start
514+ // only REFINE it downward (C4/INV-4). Applied on EVERY call (not just when ponder hands over
515+ // requiredFactoryIntervals) so an early spanning-chunk fetch never runs without discovery.
516+ if ( discFloorBlock !== undefined ) {
517+ discFloorBlock = Math . min (
518+ discFloorBlock ,
519+ interval [ 0 ] ,
520+ ... requiredFactoryIntervals . map ( ( r ) => r . interval [ 0 ] ) ,
476521 ) ;
477- discStartIdx =
478- discStartIdx === undefined ? floor : Math . min ( discStartIdx , floor ) ;
479- discovery . setFloor ( discStartIdx * chunkBlocks ) ;
480522 }
523+ pinDiscoveryFloor ( ) ;
481524
482525 const startIdx = idxOf ( interval [ 0 ] , chunkBlocks ) ;
483526 const endIdx = idxOf ( interval [ 1 ] , chunkBlocks ) ;
0 commit comments