Skip to content

Commit d0f852e

Browse files
mo4islonaclaude
andcommitted
fix(portal): close silent-gap paths in backfill floor + stream realtime
Five correctness fixes, each a path where the fork could mark a block range "synced" while serving no (or wrong-fork) data. Every fix ships a regression test verified to fail without it. Historical backfill: - backfillStartBlock: an undefined fromBlock is genesis (the runtime reads `filter.fromBlock ?? 0`), so if ANY source omits it the floor is 0. The old Math.min-over-defined-fromBlocks let a bounded source (e.g. 15M) clamp every chunk fetch past the [0, 15M) history of an unbounded source, marking that prefix synced over a silent gap. (finding 3) Portal-native stream realtime (PORTAL_REALTIME=stream, experimental): - Refuse to start when a chain has non-log sources (trace/transfer/transaction/ block) or receipt-requiring log sources: stream mode emits only log-bearing block events, so those events were silently skipped while their intervals were finalized as cached. Fail loud instead. (finding 5) - A failed /finalized-head probe is now fatal at both seams (clampFinalized- ToPortalHead retries then throws; the historical seam throws on an unknown head) instead of passing the RPC finalized block through / returning [] — which left (portalHead, rpcFinalized] permanently skipped. (finding 6) - Force a /stream reconnect the moment a factory child is discovered (a bumped logsRevision streamHotBlocks watches), so the widened server-side filter takes effect on the next block instead of only after an unrelated reconnect. Same- block child logs remain a documented limitation. (finding 4) - An unknown-parent gap (a reorg deeper than the unfinalized window) is now fatal instead of silently clearing the window and continuing — which left the pre-fork blocks indexed on the wrong fork with no rollback event. (finding 7) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 802ceed commit d0f852e

6 files changed

Lines changed: 486 additions & 32 deletions

File tree

portal/portal-realtime-wire.test.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
} from "@/internal/types.js";
99
import type { PortalRealtimeEvent } from "./portal-realtime.js";
1010
import {
11+
assertStreamModeSupported,
1112
buildPortalLogRequests,
1213
clampFinalizedToPortalHead,
1314
discoverChildAddresses,
@@ -90,6 +91,31 @@ test("isPortalRealtime: only when a chain has a Portal source AND the flag is 's
9091
expect(isPortalRealtime({ portal: "http://p" })).toBe(false); // unset → A-path
9192
});
9293

94+
test("assertStreamModeSupported: log-only sources are accepted", () => {
95+
expect(() =>
96+
assertStreamModeSupported([{ filter: logFilter() }], "mainnet"),
97+
).not.toThrow();
98+
});
99+
100+
test("assertStreamModeSupported: a non-log source is refused — it would be silently skipped while marked synced (finding 5)", () => {
101+
const trace = { type: "trace" } as any;
102+
expect(() =>
103+
assertStreamModeSupported(
104+
[{ filter: logFilter() }, { filter: trace }],
105+
"mainnet",
106+
),
107+
).toThrow(/only log sources, but this chain has trace/);
108+
});
109+
110+
test("assertStreamModeSupported: a log source that needs transaction receipts is refused (finding 5)", () => {
111+
expect(() =>
112+
assertStreamModeSupported(
113+
[{ filter: logFilter({ hasTransactionReceipt: true }) }],
114+
"mainnet",
115+
),
116+
).toThrow(/transaction receipts/);
117+
});
118+
93119
// ─────────────────────────────── light-block conversion ───────────────────────────────
94120

95121
test("lightToLightBlock: Portal decimal number/timestamp → ponder hex, hashes passthrough", () => {
@@ -289,7 +315,7 @@ test("clampFinalizedToPortalHead: Portal at/ahead of RPC finalized → no clamp
289315
expect(out).toBe(finalized);
290316
});
291317

292-
test("clampFinalizedToPortalHead: Portal head unknown (probe fails) → conservative passthrough", async () => {
318+
test("clampFinalizedToPortalHead: Portal head unknown in stream mode → FATAL (never silently passes the RPC finalized through) (finding 6)", async () => {
293319
process.env.PORTAL_REALTIME = "stream";
294320
const fetchImpl = (async () => {
295321
throw new Error("down");
@@ -300,13 +326,16 @@ test("clampFinalizedToPortalHead: Portal head unknown (probe fails) → conserva
300326
parentHash: "0xp",
301327
timestamp: "0x1",
302328
} as LightBlock;
303-
const out = await clampFinalizedToPortalHead({
304-
chain: { portal: "http://p", name: "c" } as any,
305-
rpc: {} as any,
306-
finalizedBlock: finalized,
307-
fetchImpl,
308-
});
309-
expect(out).toBe(finalized);
329+
// Old behavior passed `finalized` through — leaving historical targeting (portalHead, rpcFinalized] while
330+
// realtime starts above it: a permanent silent gap. In stream mode a head we can't probe is fatal.
331+
await expect(
332+
clampFinalizedToPortalHead({
333+
chain: { portal: "http://p", name: "c" } as any,
334+
rpc: {} as any,
335+
finalizedBlock: finalized,
336+
fetchImpl,
337+
}),
338+
).rejects.toThrow(/finalized-head probe failed/);
310339
});
311340

312341
test("clampFinalizedToPortalHead: Portal head BELOW RPC finalized → refetch the block at the Portal head", async () => {

portal/portal-realtime-wire.ts

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,37 @@ export const isPortalRealtime = (chain: {
5555
}): boolean =>
5656
typeof chain.portal === "string" && process.env.PORTAL_REALTIME === "stream";
5757

58+
/**
59+
* Stream mode only emits `block` events carrying LOGS — no transactions, receipts, or traces (see
60+
* `toRealtimeSyncEvent`). So a non-log source (trace/transfer/transaction/block filter) would receive NO
61+
* realtime events, yet ponder still finalizes its intervals as cached → a permanent, SILENT gap. Likewise
62+
* a log source that requested transaction receipts (`hasTransactionReceipt`) can't be served. Refuse to
63+
* start rather than corrupt. The historical Portal backfill supports every source type up to the finalized
64+
* head, so this rejects only PORTAL_REALTIME=stream — not the chain. (Finding 5)
65+
*/
66+
export function assertStreamModeSupported(
67+
eventCallbacks: { filter: Filter }[],
68+
chainName: string,
69+
): void {
70+
const filters = eventCallbacks.map((e) => e.filter);
71+
const nonLog = [
72+
...new Set(filters.filter((f) => f.type !== "log").map((f) => f.type)),
73+
].sort();
74+
if (nonLog.length > 0)
75+
throw new Error(
76+
`Portal ${chainName}: PORTAL_REALTIME=stream serves only log sources, but this chain has ${nonLog.join(", ")} source(s). Realtime would silently skip their events while marking the range synced. Use the default RPC realtime (unset PORTAL_REALTIME) or remove the non-log sources.`,
77+
);
78+
79+
const needsReceipts = filters.some(
80+
(f) =>
81+
(f as { hasTransactionReceipt?: boolean }).hasTransactionReceipt === true,
82+
);
83+
if (needsReceipts)
84+
throw new Error(
85+
`Portal ${chainName}: PORTAL_REALTIME=stream cannot serve transaction receipts (a log source sets hasTransactionReceipt), so realtime would drop them. Use the default RPC realtime (unset PORTAL_REALTIME).`,
86+
);
87+
}
88+
5889
const portalHeaders = (): Record<string, string> => {
5990
const h: Record<string, string> = {
6091
"content-type": "application/json",
@@ -65,6 +96,8 @@ const portalHeaders = (): Record<string, string> => {
6596
};
6697

6798
const cleanUrl = (portal: string): string => portal.replace(/\/$/, "");
99+
const sleep = (ms: number): Promise<void> =>
100+
new Promise((r) => setTimeout(r, ms));
68101

69102
// ─────────────────────────────── Portal finalized head + finality clamp ───────────────────────────────
70103

@@ -102,15 +135,30 @@ export async function clampFinalizedToPortalHead(params: {
102135
if (isPortalRealtime(chain) === false) return finalizedBlock;
103136

104137
const portalUrl = cleanUrl(chain.portal!);
105-
const head = await portalFinalizedHead(
106-
portalUrl,
107-
portalHeaders(),
108-
params.fetchImpl,
109-
);
110-
// Head unknown → stay conservative and keep the RPC finalized block (historical's own RPC finality-gap
111-
// fallback remains the safety net). Portal at/ahead of RPC finalized → nothing to clamp.
112-
if (head === undefined || head >= hexToNumber(finalizedBlock.number))
113-
return finalizedBlock;
138+
// The Portal finalized head IS the finality boundary in stream mode — load-bearing, so retry the cheap
139+
// probe (transient blips are routine under multichain load) before deciding.
140+
let head: number | undefined;
141+
for (let attempt = 0; attempt < 3; attempt++) {
142+
head = await portalFinalizedHead(
143+
portalUrl,
144+
portalHeaders(),
145+
params.fetchImpl,
146+
);
147+
if (head !== undefined) break;
148+
149+
await sleep(200 * (attempt + 1));
150+
}
151+
// Head UNKNOWN after retries: FATAL in stream mode. The old behavior passed the RPC finalized block
152+
// through, which leaves historical targeting (portalHead, rpcFinalized] — a range stream mode's
153+
// historical seam serves nothing for — while realtime starts ABOVE it: a permanent silent gap. There is
154+
// no safe conservative default here (the RPC fallback is suppressed in stream mode), so fail loud at
155+
// startup rather than run with a hole. (Finding 6)
156+
if (head === undefined)
157+
throw new Error(
158+
`Portal ${chain.name}: /finalized-head probe failed in stream mode (PORTAL_REALTIME=stream) — cannot establish the finality boundary. Check Portal connectivity for ${portalUrl}.`,
159+
);
160+
// Portal at/ahead of RPC finalized → nothing to clamp (never RAISE the boundary).
161+
if (head >= hexToNumber(finalizedBlock.number)) return finalizedBlock;
114162

115163
const clamped = (await eth_getBlockByNumber(rpc, [numberToHex(head), false], {
116164
retryNullBlockRequest: true,
@@ -309,6 +357,9 @@ export function toRealtimeSyncEvent(
309357
hasMatchedFilter: ev.hasMatchedFilter,
310358
block: ev.block,
311359
logs: ev.logs,
360+
// LOG-ONLY: stream mode serves logs, not txs/receipts/traces, so `event.transaction` is undefined
361+
// in realtime handlers (the historical Portal backfill DOES populate it) — a known gap.
362+
// assertStreamModeSupported() refuses non-log sources and receipt-requiring log sources up front. (F5)
312363
transactions: [],
313364
transactionReceipts: [],
314365
traces: [],
@@ -345,6 +396,9 @@ export async function* getPortalRealtimeEventGenerator(params: {
345396
}) {
346397
const { common, chain, eventCallbacks, syncProgress, childAddresses } =
347398
params;
399+
// Fail loud BEFORE streaming if this chain has sources stream mode can't serve (non-log / receipts) —
400+
// else their events are silently skipped while their intervals are marked synced. (Finding 5)
401+
assertStreamModeSupported(eventCallbacks, chain.name);
348402
const portalUrl = cleanUrl(chain.portal!);
349403
const headers = portalHeaders();
350404
const factories = uniqueFactories(eventCallbacks);
@@ -355,9 +409,11 @@ export async function* getPortalRealtimeEventGenerator(params: {
355409
? hexToNumber(syncProgress.end.number)
356410
: undefined;
357411

358-
// Mutable: rebuilt (in place) whenever a new child is discovered so the next stream reconnection filters
359-
// the new child's logs too (portal-realtime.ts re-reads this array when it re-opens the stream).
412+
// Mutable: rebuilt (in place) whenever a new child is discovered so the stream filters the new child's
413+
// logs too. `logsRevision` bumps on every rebuild; streamHotBlocks re-opens the /stream the moment it
414+
// advances (the Portal filters server-side, so an open connection won't pick up the widened filter). (F4)
360415
const logs = buildPortalLogRequests(eventCallbacks, childAddresses);
416+
let logsRevision = 0;
361417

362418
let childCount = 0;
363419
for (const [, m] of childAddresses) childCount += m.size;
@@ -380,6 +436,7 @@ export async function* getPortalRealtimeEventGenerator(params: {
380436
logs,
381437
blockFields: BLOCK_FIELDS,
382438
logFields: LOG_FIELDS,
439+
getLogsRevision: () => logsRevision,
383440
finalizedHead: () =>
384441
portalFinalizedHead(portalUrl, headers, params.fetchImpl),
385442
finalizePollMs: params.finalizePollMs,
@@ -407,7 +464,8 @@ export async function* getPortalRealtimeEventGenerator(params: {
407464
if (applyDiscovered(discovered, childAddresses, blockNumber)) {
408465
const next = buildPortalLogRequests(eventCallbacks, childAddresses);
409466
logs.length = 0;
410-
logs.push(...next); // mutate in place — picked up on the next `/stream` reconnection
467+
logs.push(...next); // mutate in place
468+
logsRevision++; // force streamHotBlocks to re-open with the widened filter now (Finding 4)
411469
}
412470

413471
yield { chain, event: toRealtimeSyncEvent(ev, discovered) };

portal/portal-realtime.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type Light,
44
portalRealtimeEvents,
55
reconcile,
6+
streamHotBlocks,
67
takeFinalized,
78
} from "./portal-realtime.js";
89

@@ -162,3 +163,109 @@ test("portalRealtimeEvents: a re-streamed fork emits a reorg to the common ances
162163
expect(reorg.block.hash).toBe("a"); // common ancestor = block 10
163164
expect(reorg.reorgedBlocks.map((b: Light) => b.hash)).toEqual(["b"]);
164165
});
166+
167+
test("portalRealtimeEvents: an unknown-parent gap is FATAL, not silently skipped (finding 7)", async () => {
168+
const batches = [
169+
{
170+
header: { number: 10, hash: "a", parentHash: "z", timestamp: 10 },
171+
logs: [],
172+
},
173+
// block 12's parent is unknown to our window ([10]) — a reorg deeper than the window (e.g. one that
174+
// landed while disconnected, past the resume cursor). The old code silently cleared and continued.
175+
{
176+
header: { number: 12, hash: "c", parentHash: "unknown", timestamp: 12 },
177+
logs: [],
178+
},
179+
];
180+
const ac = new AbortController();
181+
const iter = portalRealtimeEvents({
182+
portalUrl: "http://portal",
183+
headers: {},
184+
fromBlock: 10,
185+
logs: [],
186+
fetchImpl: mockFetch(batches, () => ac.abort()),
187+
signal: ac.signal,
188+
finalizedHead: async () => 0,
189+
finalizePollMs: 999999,
190+
});
191+
const seen: any[] = [];
192+
await expect(
193+
(async () => {
194+
for await (const e of iter) seen.push(e);
195+
})(),
196+
).rejects.toThrow(/unknown parent/i);
197+
// block 10 was delivered before the gap; the gap block was NOT swallowed into a silent resync
198+
expect(seen.some((e) => e.type === "block" && e.block.number === "0xa")).toBe(
199+
true,
200+
);
201+
});
202+
203+
test("streamHotBlocks: re-opens the /stream with the widened filter the moment the logs revision advances (finding 4)", async () => {
204+
const enc = new TextEncoder();
205+
const streamOf = (block: any, close: boolean) =>
206+
new ReadableStream({
207+
start(c) {
208+
c.enqueue(enc.encode(JSON.stringify(block) + "\n"));
209+
if (close) c.close(); // else leave open — only the revision change tears it down
210+
},
211+
});
212+
const bodies: any[] = [];
213+
let conn = 0;
214+
const fetchImpl = (async (_url: string, init: any) => {
215+
bodies.push(JSON.parse(init.body));
216+
conn += 1;
217+
if (conn === 1)
218+
// connection 1: deliver block 100, then stay OPEN (no more data) so nothing but a rev change reopens it
219+
return {
220+
status: 200,
221+
ok: true,
222+
body: streamOf(
223+
{ header: { number: 100, hash: "h100", parentHash: "h99", timestamp: 1 }, logs: [] },
224+
false,
225+
),
226+
};
227+
if (conn === 2)
228+
// connection 2 (after the reopen): deliver block 101 and close
229+
return {
230+
status: 200,
231+
ok: true,
232+
body: streamOf(
233+
{ header: { number: 101, hash: "h101", parentHash: "h100", timestamp: 2 }, logs: [] },
234+
true,
235+
),
236+
};
237+
return { status: 204, ok: false, body: null };
238+
}) as any;
239+
240+
const logs: any[] = [{ address: ["0xfactory"], topic0: ["0xproxycreated"] }];
241+
let rev = 0;
242+
const ac = new AbortController();
243+
const gen = streamHotBlocks({
244+
portalUrl: "http://portal",
245+
headers: {},
246+
fromBlock: 100,
247+
logs,
248+
getLogsRevision: () => rev,
249+
fetchImpl,
250+
signal: ac.signal,
251+
});
252+
253+
const first = await gen.next(); // block 100 from connection 1
254+
expect(first.value?.header.number).toBe(100);
255+
expect(bodies).toHaveLength(1);
256+
expect(bodies[0].fromBlock).toBe(100);
257+
258+
// a newly-discovered factory child widens the filter and bumps the revision
259+
logs.length = 0;
260+
logs.push({ address: ["0xfactory", "0xnewchild"], topic0: ["0xproxycreated"] });
261+
rev = 1;
262+
263+
const second = await gen.next(); // rev advanced → connection 1 torn down, reopen resumes from cursor 101
264+
expect(second.value?.header.number).toBe(101);
265+
expect(bodies).toHaveLength(2);
266+
expect(bodies[1].fromBlock).toBe(101); // resumed PAST block 100 (no re-delivery / spurious reorg)
267+
expect(bodies[1].logs[0].address).toContain("0xnewchild"); // reopened with the widened filter
268+
269+
await gen.return(undefined); // stop the generator
270+
ac.abort();
271+
});

0 commit comments

Comments
 (0)