Skip to content

Commit 3eca01a

Browse files
dzhelezovclaude
andcommitted
refactor(realtime): make the lib RPC-agnostic — no "Portal-backed RPC" concept
We test the RPC implementation through a generic interface the lib is blind to. Removed portalRpc/portalRealtime from portal/realtime.ts — the realtime source is just an RPC (authenticated RPCs are plain viem http with headers; nothing special). rpcRealtime() composes URL strings or viem Transports into a latency-ranked fallback. The Euler eval config/probe use generic naming (REALTIME_RPC_URL/KEY, "realtime-rpc") — the Portal-backed implementation detail stays out of the repo. 29 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0df1247 commit 3eca01a

4 files changed

Lines changed: 50 additions & 85 deletions

File tree

harness/euler-multichain/ponder.config.ts

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,44 +9,43 @@ import chainsData from "./chains.json";
99
//
1010
// Historical backfill is ALWAYS the Portal. The realtime source is a config choice:
1111
// • bench mode (default): bounded [deploy, head] — a clean fixed-range benchmark; RPC only for setup.
12-
// • EULER_REALTIME=true: unbounded → live, realtime served by the Portal-backed RPC — the service
13-
// offered to clients (Portal-backed under the hood, designed for the recent tip).
12+
// • EULER_REALTIME=true: unbounded → live, realtime served by an authenticated RPC (URL + x-api-key
13+
// from env). Ponder owns reorg handling and is agnostic to what the RPC is or how it's implemented.
1414
//
1515
// Portal base URLs are deployment config (from env); relative to a base the documented Portal API paths
1616
// apply — /datasets/<>, /metadata, … (see docs.sqd.dev). Public examples default to the public Portal.
17-
// PORTAL_URL = Portal base (default https://portal.sqd.dev — the public, rate-limited Portal)
18-
// PORTAL_RPC_URL = Portal-backed RPC base (from env; provisioned per client)
19-
// One config serves any client by swapping env. `rpc.subsquid.io` is a generic proxied RPC (like Alchemy)
20-
// — NOT the Portal-backed product — and is deliberately not used; keyless public RPCs (chains.json
21-
// `freeRpcs`) are only for setup / finality tail.
17+
// PORTAL_URL = Portal base (default https://portal.sqd.dev — the public, rate-limited Portal)
18+
// REALTIME_RPC_URL = authenticated realtime RPC base (with REALTIME_RPC_KEY, from env)
19+
// One config serves any deployment by swapping env. Keyless public RPCs (chains.json `freeRpcs`) are only
20+
// for setup / finality tail — excluded from the realtime path (403/timeout cascades stall it).
2221
//
23-
// No secrets/tenancy in the repo: PORTAL_API_KEY, PORTAL_URL, PORTAL_RPC_KEY, PORTAL_RPC_URL come from env.
22+
// No secrets/tenancy in the repo: PORTAL_API_KEY, PORTAL_URL, REALTIME_RPC_KEY, REALTIME_RPC_URL come from env.
2423
const proxyCreated = parseAbiItem(
2524
"event ProxyCreated(address indexed proxy, bool upgradeable, address implementation, bytes trailingData)",
2625
);
2726

2827
type ChainRow = { id: number; name: string; ds: string; factory: string; head: number; deploy: number; sqdSlug: string | null; freeRpcs: string[] };
2928
const rows = chainsData as ChainRow[];
3029
const PORTAL_URL = process.env.PORTAL_URL ?? "https://portal.sqd.dev";
31-
const PORTAL_RPC_KEY = process.env.PORTAL_RPC_KEY;
32-
const PORTAL_RPC_URL = process.env.PORTAL_RPC_URL; // Portal-backed RPC base (from env; provisioned per client)
30+
const REALTIME_RPC_KEY = process.env.REALTIME_RPC_KEY;
31+
const REALTIME_RPC_URL = process.env.REALTIME_RPC_URL; // realtime RPC base (from env; provisioned per client)
3332
const REALTIME = process.env.EULER_REALTIME === "true";
3433

35-
// Chains served by the Portal-backed RPC. Realtime uses it (alone) on these.
36-
const PORTAL_RPC_CHAINS = new Set([1, 42161, 8453, 43114, 137, 56, 9745, 143]);
37-
const portalRpc = (chainId: number) =>
38-
http(`${PORTAL_RPC_URL}/${chainId}`, { fetchOptions: { headers: { "x-api-key": PORTAL_RPC_KEY ?? "" } } });
34+
// Chains served by the realtime RPC. Realtime uses it (alone) on these.
35+
const REALTIME_CHAINS = new Set([1, 42161, 8453, 43114, 137, 56, 9745, 143]);
36+
const realtimeRpc = (chainId: number) =>
37+
http(`${REALTIME_RPC_URL}/${chainId}`, { fetchOptions: { headers: { "x-api-key": REALTIME_RPC_KEY ?? "" } } });
3938

40-
// realtime → the Portal-backed RPC ALONE (the endpoint under evaluation): no generic proxy, no flaky
39+
// realtime → the realtime RPC ALONE (the endpoint under evaluation): no generic proxy, no flaky
4140
// keyless public fallback (their 403/timeout cascades stall the single-thread). bench → public RPCs only.
4241
const rpcFor = (c: ChainRow) =>
43-
REALTIME && PORTAL_RPC_KEY && PORTAL_RPC_URL && PORTAL_RPC_CHAINS.has(c.id) ? portalRpc(c.id) : c.freeRpcs;
42+
REALTIME && REALTIME_RPC_KEY && REALTIME_RPC_URL && REALTIME_CHAINS.has(c.id) ? realtimeRpc(c.id) : c.freeRpcs;
4443

4544
// optional subset: EULER_CHAINS=ethereum,base limits the run. In realtime mode with no explicit subset,
46-
// default to the Portal-backed-RPC chains (the 8 that have a realtime source).
45+
// default to the realtime-RPC chains (the 8 that have a realtime source).
4746
const only = (process.env.EULER_CHAINS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
4847
let active = only.length ? rows.filter((c) => only.includes(c.name)) : rows;
49-
if (REALTIME && !only.length) active = rows.filter((c) => PORTAL_RPC_CHAINS.has(c.id));
48+
if (REALTIME && !only.length) active = rows.filter((c) => REALTIME_CHAINS.has(c.id));
5049

5150
export default createConfig({
5251
// Postgres (production shape, separate process → own memory) when DATABASE_URL is set; else pglite.
@@ -61,7 +60,7 @@ export default createConfig({
6160
chain: Object.fromEntries(
6261
active.map((c) => [
6362
c.name,
64-
// realtime: no endBlock → backfill to finalized head, then go live on the Portal-backed RPC.
63+
// realtime: no endBlock → backfill to finalized head, then go live on the realtime RPC.
6564
{ address: factory({ address: c.factory as `0x${string}`, event: proxyCreated, parameter: "proxy" }), startBlock: c.deploy, ...(REALTIME ? {} : { endBlock: c.head }) },
6665
]),
6766
),

harness/euler-multichain/probe.mjs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,28 @@
33
// responsiveness. Standalone (no imports beyond node) so it survives an overnight run alongside ponder.
44
// Mirrors portal/realtime.ts (unit-tested). Writes PROBE_METRICS_FILE (rolling JSON) + a .log trail.
55
//
6-
// PORTAL_RPC_KEY=... [SQD_RPC_KEY=...] [PROBE_INTERVAL_MS=15000] node probe.mjs
6+
// REALTIME_RPC_KEY=... [SQD_RPC_KEY=...] [PROBE_INTERVAL_MS=15000] node probe.mjs
77
import fs from "node:fs";
88
import path from "node:path";
99
import { fileURLToPath } from "node:url";
1010

1111
const dir = path.dirname(fileURLToPath(import.meta.url));
1212
const chains = JSON.parse(fs.readFileSync(path.join(dir, "chains.json"), "utf8"));
13-
const KEY = process.env.PORTAL_RPC_KEY;
14-
const RPC_URL = process.env.PORTAL_RPC_URL; // Portal-backed RPC base (from env; provisioned per client)
15-
const PORTAL_RPC_CHAINS = new Set([1, 42161, 8453, 43114, 137, 56, 9745, 143]);
13+
const KEY = process.env.REALTIME_RPC_KEY;
14+
const RPC_URL = process.env.REALTIME_RPC_URL; // realtime RPC base (from env; provisioned per client)
15+
const REALTIME_CHAINS = new Set([1, 42161, 8453, 43114, 137, 56, 9745, 143]);
1616
const METRICS = process.env.PROBE_METRICS_FILE || path.join(dir, "probe-metrics.json");
1717
const INTERVAL = Number(process.env.PROBE_INTERVAL_MS || 15000);
1818
const WINDOW = Number(process.env.PROBE_WINDOW || 40); // rolling ticks
1919

20-
if (!KEY || !RPC_URL) { console.error("PORTAL_RPC_KEY + PORTAL_RPC_URL required (the Portal-backed RPC base + x-api-key)"); process.exit(1); }
20+
if (!KEY || !RPC_URL) { console.error("REALTIME_RPC_KEY + REALTIME_RPC_URL required (the realtime RPC base + x-api-key)"); process.exit(1); }
2121

22-
// per chain, probe the Portal-backed RPC (the product) alongside a keyless public RPC (freshness baseline).
23-
// rpc.subsquid.io is a generic proxy, not the Portal-backed product — deliberately excluded.
24-
const targets = chains.filter((c) => PORTAL_RPC_CHAINS.has(c.id)).map((c) => ({
22+
// per chain, probe the realtime RPC alongside a keyless public RPC (freshness baseline).
23+
// rpc.subsquid.io is a generic proxy — deliberately excluded.
24+
const targets = chains.filter((c) => REALTIME_CHAINS.has(c.id)).map((c) => ({
2525
chainId: c.id, chain: c.name,
2626
endpoints: [
27-
{ name: "portal-rpc", url: `${RPC_URL}/${c.id}`, headers: { "x-api-key": KEY } },
27+
{ name: "realtime-rpc", url: `${RPC_URL}/${c.id}`, headers: { "x-api-key": KEY } },
2828
...(c.freeRpcs || []).slice(0, 1).map((u, i) => ({ name: `public${i}`, url: u })),
2929
],
3030
}));
@@ -67,8 +67,8 @@ async function tick() {
6767
avgLagBlk: lags.length ? Math.round(lags.reduce((a, b) => a + b, 0) / lags.length) : null, lastError: [...ss].reverse().find((s) => s.error)?.error };
6868
}
6969
try { fs.writeFileSync(METRICS, JSON.stringify({ ts: new Date().toISOString(), intervalMs: INTERVAL, endpoints: summary }, null, 2)); } catch { /* best-effort */ }
70-
const line = Object.values(summary).filter((v) => v.endpoint === "portal-rpc").map((v) => `${v.chain} p50=${v.latP50} p95=${v.latP95} ok=${(v.okRate * 100).toFixed(0)}% lag=${v.avgLagBlk ?? "?"}`).join(" | ");
71-
try { fs.appendFileSync(METRICS + ".log", `${new Date().toISOString()} [portal-rpc] ${line}\n`); } catch { /* best-effort */ }
70+
const line = Object.values(summary).filter((v) => v.endpoint === "realtime-rpc").map((v) => `${v.chain} p50=${v.latP50} p95=${v.latP95} ok=${(v.okRate * 100).toFixed(0)}% lag=${v.avgLagBlk ?? "?"}`).join(" | ");
71+
try { fs.appendFileSync(METRICS + ".log", `${new Date().toISOString()} [realtime-rpc] ${line}\n`); } catch { /* best-effort */ }
7272
}
7373

7474
console.log(`probe: ${targets.length} chains × endpoints, interval ${INTERVAL}ms -> ${METRICS}`);

portal/realtime.test.ts

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,12 @@
11
import { expect, test } from "vitest";
2-
import { portalRpc, portalRealtime, rpcRealtime, freshnessLag, summarize, probeOnce, type ProbeSample } from "./realtime.js";
2+
import { http } from "viem";
3+
import { rpcRealtime, freshnessLag, summarize, probeOnce, type ProbeSample } from "./realtime.js";
34

4-
test("portalRpc: appends chainId to the client-specific base + x-api-key header", () => {
5-
const t = portalRpc(42161, "prt_test", { baseUrl: "https://portal.example/rpc" });
6-
expect(typeof t).toBe("function");
7-
// viem http transport exposes its url on the instantiated value
8-
const inst: any = t({} as any);
9-
expect(inst.value?.url).toBe("https://portal.example/rpc/42161");
10-
const t2: any = portalRpc(1, "k", { baseUrl: "http://local/rpc" })({} as any);
11-
expect(t2.value?.url).toBe("http://local/rpc/1");
12-
});
13-
14-
test("portalRpc: throws without a base (never hardcodes a client domain)", () => {
15-
const saved = process.env.PORTAL_RPC_URL;
16-
delete process.env.PORTAL_RPC_URL;
17-
expect(() => portalRpc(1, "k")).toThrow(/baseUrl|PORTAL_RPC_URL/);
18-
if (saved !== undefined) process.env.PORTAL_RPC_URL = saved;
19-
});
20-
21-
test("portalRealtime / rpcRealtime return composed viem transports", () => {
22-
expect(typeof portalRealtime(1, "k", ["http://a", "http://b"], { baseUrl: "https://portal.example/rpc" })).toBe("function");
23-
expect(typeof rpcRealtime(["http://a", "http://b"])).toBe("function");
5+
test("rpcRealtime: composes URL strings and authed transports into a latency-ranked fallback", () => {
6+
// an authenticated RPC is just a viem transport — the lib is agnostic to what backs it
7+
const authed = http("http://b", { fetchOptions: { headers: { "x-api-key": "k" } } });
8+
expect(typeof rpcRealtime(["http://a", authed])).toBe("function");
9+
expect(typeof rpcRealtime(["http://a", "http://b"], { rank: false })).toBe("function");
2410
});
2511

2612
test("freshnessLag: blocks behind the freshest endpoint, per chain", () => {

portal/realtime.ts

Lines changed: 14 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,30 @@
11
/**
22
* Realtime source helpers + endpoint instrumentation.
33
*
4-
* Ponder owns realtime and reorg handling natively (RealtimeSync: parent-hash tracking + rollback to
5-
* the common ancestor, bounded by the finalized block). The fork only swaps the HISTORICAL sync to
6-
* the Portal. So "which realtime source" is purely a `rpc` config choice — ponder polls whatever
7-
* transport(s) you give it and reorg-handles the result identically:
4+
* Ponder owns realtime and reorg handling natively (RealtimeSync: parent-hash tracking + rollback to the
5+
* common ancestor, bounded by the finalized block). The fork only swaps the HISTORICAL sync to the Portal.
6+
* So the realtime source is purely a `rpc` config choice — ponder polls whatever transport(s) you give it
7+
* and reorg-handles the result identically. The lib is RPC-agnostic: an authenticated RPC (an x-api-key
8+
* header, a keyed proxy, …) is just `http(url, { fetchOptions: { headers } })` — nothing special.
89
*
9-
* import { createConfig } from "@subsquid/ponder";
1010
* import { http, fallback } from "viem";
11-
* import { portalRpc } from "@subsquid/ponder/realtime";
12-
*
13-
* // (A) Portal realtime, RPC fallback — Portal-backed RPC leads; public RPCs cover downtime/lag.
14-
* // base URL is provisioned per client → set PORTAL_RPC_URL (or pass { baseUrl }).
15-
* rpc: fallback([ portalRpc(1, process.env.PORTAL_RPC_KEY!), http(process.env.RPC_1), http(process.env.RPC_1B) ]),
16-
*
17-
* // (B) RPC(s) realtime — a plain Ponder-style list, or latency-ranked for fastest-tip.
11+
* // a plain list (Ponder-style), or latency-ranked for fastest-tip:
1812
* rpc: [process.env.RPC_1!, process.env.RPC_1B!],
1913
* rpc: fallback([http(process.env.RPC_1!), http(process.env.RPC_1B!)], { rank: true }),
14+
* // an authenticated RPC + fallback:
15+
* rpc: fallback([ http(process.env.RPC_URL!, { fetchOptions: { headers: { "x-api-key": process.env.RPC_KEY! } } }), http(process.env.RPC_1!) ]),
2016
*
21-
* `fallback` gives smooth failover; `{ rank: true }` probes latency and routes to the fastest, so the
22-
* freshest tip lands. Both (A) and (B) get ponder's reorg safety for free.
17+
* `fallback` gives smooth failover; `{ rank: true }` probes latency and routes to the fastest tip. Either
18+
* way ponder's reorg safety comes for free.
2319
*/
2420
import { http, fallback, type Transport } from "viem";
2521

2622
/**
27-
* Portal-backed EVM RPC transport (auth via `x-api-key`). The base URL is provisioned per client, so
28-
* pass `opts.baseUrl` or set `PORTAL_RPC_URL` — never hardcode a client domain.
23+
* Compose realtime RPC(s) into a latency-ranked fallback so the fastest tip lands. Accepts URL strings or
24+
* viem Transports — pass a Transport for an authenticated RPC: `http(url, { fetchOptions: { headers } })`.
2925
*/
30-
export function portalRpc(chainId: number, apiKey: string, opts?: { baseUrl?: string; timeout?: number; retryCount?: number }): Transport {
31-
const base = opts?.baseUrl ?? process.env.PORTAL_RPC_URL;
32-
if (!base) throw new Error("portalRpc: pass opts.baseUrl or set PORTAL_RPC_URL (the Portal-backed RPC base, provisioned per client)");
33-
return http(`${base}/${chainId}`, {
34-
fetchOptions: { headers: { "x-api-key": apiKey } },
35-
timeout: opts?.timeout ?? 10_000,
36-
retryCount: opts?.retryCount ?? 2,
37-
});
38-
}
39-
40-
/** Portal realtime with RPC fallback(s): Portal-backed RPC preferred, public RPCs as fallback. */
41-
export function portalRealtime(chainId: number, apiKey: string, fallbackRpcs: string[] = [], opts?: { baseUrl?: string; rank?: boolean }): Transport {
42-
return fallback([portalRpc(chainId, apiKey, { baseUrl: opts?.baseUrl }), ...fallbackRpcs.map((u) => http(u))], { rank: opts?.rank ?? false });
43-
}
44-
45-
/** RPC(s)-only realtime: a list of RPC URLs, latency-ranked so the fastest tip lands. */
46-
export function rpcRealtime(rpcs: string[], opts?: { rank?: boolean }): Transport {
47-
return fallback(rpcs.map((u) => http(u)), { rank: opts?.rank ?? true });
26+
export function rpcRealtime(rpcs: Array<string | Transport>, opts?: { rank?: boolean }): Transport {
27+
return fallback(rpcs.map((r) => (typeof r === "string" ? http(r) : r)), { rank: opts?.rank ?? true });
4828
}
4929

5030
// ─────────────────────────────── endpoint instrumentation ───────────────────────────────

0 commit comments

Comments
 (0)