From 12b7612fbd46e34b8a349362960c7f7a4a8fcd26 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Thu, 12 Nov 2026 23:22:52 +0800 Subject: [PATCH 1/2] fix(rpc): keep the keyed RPC URL out of every error message viem quotes the transport's URL in each request error, and a keyed provider carries its API key in the path. That message was returned as-is by POST /api/launch/sync and POST /api/launch/meta, written into a pair's status reason, and printed by the server's error logs. - lib/rpc-transport.ts: the server transports are given the host alone and the request goes to the full URL through fetchFn, so no error holds the key. Each distinct upstream keeps its own label (viem batches the requests of one tick by transport URL), and basic-auth URLs still send their header. - redact.ts: publicError (first line, URLs removed, bounded) for API responses and stored reasons; the sync route's per-chain reason has its URLs removed. --- app/src/app/api/launch/meta/route.ts | 4 +- app/src/app/api/launch/sync/route.ts | 6 ++- app/src/lib/chain.ts | 13 +++-- app/src/lib/launchpad/indexer.ts | 3 +- app/src/lib/launchpad/pairPrice.ts | 3 +- app/src/lib/launchpad/pairsServer.ts | 5 +- app/src/lib/launchpad/redact.test.ts | 11 +++- app/src/lib/launchpad/redact.ts | 9 ++++ app/src/lib/rpc-transport.test.ts | 75 ++++++++++++++++++++++++++++ app/src/lib/rpc-transport.ts | 42 ++++++++++++++++ 10 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 app/src/lib/rpc-transport.test.ts create mode 100644 app/src/lib/rpc-transport.ts diff --git a/app/src/app/api/launch/meta/route.ts b/app/src/app/api/launch/meta/route.ts index 7ba815bc..5c5fe2e5 100644 --- a/app/src/app/api/launch/meta/route.ts +++ b/app/src/app/api/launch/meta/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { rateLimited } from "@/lib/launchpad/editServer"; import { MetaConflict, saveMeta, validateMeta } from "@/lib/launchpad/meta"; import { LAUNCHPAD_CONFIGURED } from "@/lib/launchpad/config"; +import { publicError } from "@/lib/launchpad/redact"; export const dynamic = "force-dynamic"; @@ -24,6 +25,7 @@ export async function POST(req: Request) { return NextResponse.json(await saveMeta(v.value)); } catch (err) { if (err instanceof MetaConflict) return NextResponse.json({ error: err.message }, { status: 409 }); - return NextResponse.json({ error: err instanceof Error ? err.message : "failed" }, { status: 502 }); + // never the raw message: predictToken is an RPC read, and its failure quotes the upstream URL (a keyed one carries its API key) + return NextResponse.json({ error: publicError(err) }, { status: 502 }); } } diff --git a/app/src/app/api/launch/sync/route.ts b/app/src/app/api/launch/sync/route.ts index 6b6b5ef0..aa2b8385 100644 --- a/app/src/app/api/launch/sync/route.ts +++ b/app/src/app/api/launch/sync/route.ts @@ -3,6 +3,7 @@ import { DEFAULT_CHAIN, isChainKey } from "@/lib/chainPublic"; import { rateLimited } from "@/lib/launchpad/editServer"; import { applyLaunchTx, pollAll } from "@/lib/launchpad/indexer"; import { forgetBoostLane } from "@/lib/launchpad/boostServer"; +import { publicError } from "@/lib/launchpad/redact"; export const dynamic = "force-dynamic"; @@ -25,12 +26,13 @@ export async function POST(req: Request) { if (r.boosts) forgetBoostLane(); return NextResponse.json(r); } catch (err) { - return NextResponse.json({ error: err instanceof Error ? err.message : "sync failed" }, { status: 502 }); + // never the raw message: a failed RPC read quotes the upstream URL, and a keyed provider URL carries its API key + return NextResponse.json({ error: publicError(err, "sync failed") }, { status: 502 }); } } try { return NextResponse.json(await pollAll()); } catch (err) { - return NextResponse.json({ error: err instanceof Error ? err.message : "sync failed" }, { status: 502 }); + return NextResponse.json({ error: publicError(err, "sync failed") }, { status: 502 }); } } diff --git a/app/src/lib/chain.ts b/app/src/lib/chain.ts index 4498973b..9db96b2a 100644 --- a/app/src/lib/chain.ts +++ b/app/src/lib/chain.ts @@ -1,6 +1,7 @@ import "server-only"; import { createPublicClient, fallback, http, type PublicClient } from "viem"; import { CHAINS, RPC_ENV_NAME, type ChainKey } from "./chainPublic"; +import { keylessHttp } from "./rpc-transport"; export * from "./chainPublic"; @@ -20,6 +21,12 @@ export function b20RpcUrl(): string { return process.env.BASE_B20_RPC_URL?.trim() || "https://base-rpc.publicnode.com"; } +/** An HTTP transport whose errors never quote the URL's path or query (where a keyed provider puts its API key). */ +function keyless(url: string | undefined, options: { batch: true; retryCount: number; timeout?: number }) { + const k = keylessHttp(url); + return http(k.url, k.fetchFn ? { ...options, fetchFn: k.fetchFn } : options); +} + let b20: PublicClient | null = null; /** * A Base client on the B20-capable node alone, for reads that must not be answered by a node that cannot @@ -27,15 +34,15 @@ let b20: PublicClient | null = null; * error the fallback in publicClient would retry. */ export function b20Client(): PublicClient { - b20 ??= createPublicClient({ chain: CHAINS.base, transport: http(b20RpcUrl(), { batch: true, retryCount: 1, timeout: 5_000 }) }); + b20 ??= createPublicClient({ chain: CHAINS.base, transport: keyless(b20RpcUrl(), { batch: true, retryCount: 1, timeout: 5_000 }) }); return b20; } export function publicClient(key: ChainKey): PublicClient { const hit = cached.get(key); if (hit) return hit; - const primary = http(rpcUrl(key), { batch: true, retryCount: 3 }); - const transport = key === "base" ? fallback([primary, http(b20RpcUrl(), { batch: true, retryCount: 2 })], { rank: false }) : primary; + const primary = keyless(rpcUrl(key), { batch: true, retryCount: 3 }); + const transport = key === "base" ? fallback([primary, keyless(b20RpcUrl(), { batch: true, retryCount: 2 })], { rank: false }) : primary; const c = createPublicClient({ chain: CHAINS[key], transport }); cached.set(key, c); return c; diff --git a/app/src/lib/launchpad/indexer.ts b/app/src/lib/launchpad/indexer.ts index c90cb461..e7628032 100644 --- a/app/src/lib/launchpad/indexer.ts +++ b/app/src/lib/launchpad/indexer.ts @@ -708,7 +708,8 @@ export async function pollAll(): Promise> { const out: Record = {}; await Promise.all( CONFIGURED_CHAINS.map(async (c) => { - out[c] = await pollLaunches(c).catch((e): LaunchSyncResult => ({ status: "skipped", reason: errMessage(e) })); + // the reason is returned by POST /api/launch/sync, unauthenticated: never with the upstream URL + out[c] = await pollLaunches(c).catch((e): LaunchSyncResult => ({ status: "skipped", reason: redactUrls(errMessage(e)) })); await healLaunchReads(c).catch((e) => console.warn(`[launch-sync] heal ${c}:`, errMessage(e))); await healQuoteTokens(c).catch((e) => console.warn(`[launch-sync] quote tokens ${c}:`, errMessage(e))); await healSwapTraders(c).catch((e) => console.warn(`[launch-sync] heal traders ${c}:`, errMessage(e))); diff --git a/app/src/lib/launchpad/pairPrice.ts b/app/src/lib/launchpad/pairPrice.ts index 162fc353..65101837 100644 --- a/app/src/lib/launchpad/pairPrice.ts +++ b/app/src/lib/launchpad/pairPrice.ts @@ -11,6 +11,7 @@ import { gitlawbUsd } from "./gitlawbServer"; import { twapTick } from "./gitlawb"; import { gitlawbLinkedUsd } from "./twig"; import { poolIdOf, quotePerToken } from "./math"; +import { publicError } from "./redact"; import { PAIR_MIN_SAMPLED_S, PAIR_TWAP_WINDOW_S, guardedPairPrice, pairPriceFromSqrt, pairPriceFromTick, sortsBelow, timeWeightedPrice, twapSpan, type PairPriceKind } from "./pairs"; /** @@ -136,7 +137,7 @@ export async function readPairUsd(src: PairPriceSource): Promise e as Error); - if (leg instanceof Error) return fail(`the price pool could not be read: ${leg.message}`, 400); + if (leg instanceof Error) return fail(`the price pool could not be read: ${publicError(leg)}`, 400); if (leg.spot === null) return fail("the price pool gives no price", 400); if (l.price_kind === "univ3" && leg.twap === null) return fail("that pool has no 30-minute oracle history: pick another pool, or a v4 pool", 400); // the monitor suspends a pair whose pool holds less than its line: never list one that would start failing at once @@ -337,7 +338,7 @@ export async function checkPair(r: PairDbRow, chain: ChainKey): Promise<{ health } async function poolHealth(r: PairDbRow, chain: ChainKey): Promise { const leg = await readPairLeg({ chain, address: r.address, decimals: r.decimals, price_kind: r.price_kind, price_pool: r.price_pool, price_quote: r.price_quote }).catch((e: unknown) => e as Error); - if (leg instanceof Error) return { ok: false, hard: false, reason: `Its price pool cannot be read: ${leg.message}.` }; + if (leg instanceof Error) return { ok: false, hard: false, reason: `Its price pool cannot be read: ${publicError(leg)}` }; // a v4 pool's average comes from our own minute samples: a pair listed minutes ago has none yet, which is not a failure // (only the missing average is excused: a bad spot fails as before, and liquidity is still checked below) const warming = leg.twap === null && leg.spot !== null && leg.spot > 0 && pairWarmingUp(r.price_kind, new Date(r.listed_at).getTime(), Date.now()); diff --git a/app/src/lib/launchpad/redact.test.ts b/app/src/lib/launchpad/redact.test.ts index f71c3361..de35c0c1 100644 --- a/app/src/lib/launchpad/redact.test.ts +++ b/app/src/lib/launchpad/redact.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { redactUrls } from "./redact.ts"; +import { publicError, redactUrls } from "./redact.ts"; test("redactUrls removes every URL, keyed provider paths included, and leaves the rest of the message", () => { const msg = "HTTP request failed.\n\nURL: https://arc-mainnet.g.alchemy.com/v2/abc123SECRET\nRequest body: {\"method\":\"eth_getLogs\"}\nDetails: fetch failed"; @@ -12,3 +12,12 @@ test("redactUrls removes every URL, keyed provider paths included, and leaves th assert.equal(redactUrls("wss://host/ws?key=k and http://a.b/c"), " and "); assert.equal(redactUrls("no urls here"), "no urls here"); }); + +test("publicError keeps the first line alone, without URLs, bounded, with a fallback", () => { + const err = new Error("HTTP request failed.\n\nStatus: 429\nURL: https://base-mainnet.g.alchemy.com/v2/abc123SECRET\nRequest body: [{\"method\":\"eth_blockNumber\"}]"); + assert.equal(publicError(err), "HTTP request failed."); + assert.equal(publicError(new Error("could not reach https://x.example/v2/abc123SECRET now")), "could not reach now"); + assert.equal(publicError(new Error("x".repeat(500))).length, 200); + assert.equal(publicError(new Error("")), "failed"); + assert.equal(publicError({ secret: "abc123SECRET" }, "sync failed"), "sync failed"); +}); diff --git a/app/src/lib/launchpad/redact.ts b/app/src/lib/launchpad/redact.ts index edd11fea..0e1e2156 100644 --- a/app/src/lib/launchpad/redact.ts +++ b/app/src/lib/launchpad/redact.ts @@ -7,3 +7,12 @@ export function redactUrls(message: string): string { return message.replace(/\b(?:https?|wss?):\/\/[^\s"'`<>)\]]+/gi, ""); } + +/** + * An error as an API response or a stored, publicly shown reason may carry it: its first line alone (viem puts the + * request URL, the request body and the node's own answer on the lines after it), with any URL removed, and bounded. + */ +export function publicError(err: unknown, fallback = "failed"): string { + const message = err instanceof Error ? err.message : typeof err === "string" ? err : ""; + return redactUrls(message.split("\n")[0]).trim().slice(0, 200) || fallback; +} diff --git a/app/src/lib/rpc-transport.test.ts b/app/src/lib/rpc-transport.test.ts new file mode 100644 index 00000000..047c08c9 --- /dev/null +++ b/app/src/lib/rpc-transport.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createPublicClient, http } from "viem"; +import { base } from "viem/chains"; +import { keylessHttp } from "./rpc-transport.ts"; + +const KEYED = "https://base-mainnet.g.alchemy.com/v2/abc123SECRET"; + +test("a keyed URL is requested in full but never appears in a viem error", async () => { + const requested: string[] = []; + const k = keylessHttp(KEYED, async (input) => { + requested.push(String(input)); + return new Response('{"error":"throughput exceeded"}', { status: 429, headers: { "content-type": "application/json" } }); + }); + assert.equal(k.url, "https://base-mainnet.g.alchemy.com"); + const client = createPublicClient({ chain: base, transport: http(k.url, { batch: true, retryCount: 0, fetchFn: k.fetchFn }) }); + const err = await client.getBlockNumber().then(() => null, (e: unknown) => e as Error & { details?: string; cause?: unknown }); + assert.ok(err); + assert.deepEqual(requested, [KEYED]); + // the message, and everything hanging off the error (cause chain, meta lines, stack) + for (const text of [err.message, String(err.stack), JSON.stringify(err, Object.getOwnPropertyNames(err)), String((err.cause as Error | undefined)?.message)]) assert.ok(!text.includes("abc123SECRET"), text); + assert.match(err.message, /URL: https:\/\/base-mainnet\.g\.alchemy\.com\/?\n/); +}); + +test("a key in the query string is dropped from the label too", () => { + assert.equal(keylessHttp("https://rpc.example.test/?apikey=abc123SECRET").url, "https://rpc.example.test"); +}); + +test("URLs with nothing to hide, unset and unparsable ones pass through unchanged", () => { + for (const url of [undefined, "", "https://rpc.mainnet.arc.io", "https://rpc.mainnet.arc.io/", "https://user:pass@rpc.example.test", "not a url"]) { + const k = keylessHttp(url); + assert.equal(k.url, url); + assert.equal(k.fetchFn, undefined); + } +}); + +test("basic-auth credentials still reach the node as a header, with the keyed path, and neither is quoted", async () => { + const seen: { url: string; auth: string | null }[] = []; + const k = keylessHttp("https://user:p%40ss@rpc.example.test/v2/abc123SECRET", async (input, init) => { + seen.push({ url: String(input), auth: new Headers(init?.headers).get("authorization") }); + return new Response("nope", { status: 500 }); + }); + const client = createPublicClient({ chain: base, transport: http(k.url, { retryCount: 0, fetchFn: k.fetchFn }) }); + const err = await client.getBlockNumber().then(() => null, (e: unknown) => e as Error); + assert.ok(err); + assert.deepEqual(seen, [{ url: "https://rpc.example.test/v2/abc123SECRET", auth: `Basic ${btoa("user:p@ss")}` }]); + for (const secret of ["abc123SECRET", "p%40ss", "p@ss", "user:"]) assert.ok(!err.message.includes(secret), err.message); +}); + +test("only http(s) URLs are rewritten", () => { + const k = keylessHttp("ftp://host/v2/abc123SECRET"); + assert.equal(k.url, "ftp://host/v2/abc123SECRET"); + assert.equal(k.fetchFn, undefined); +}); + +test("two keyed URLs on one host never share a batch: each read goes to its own upstream", async () => { + // viem merges the requests of one tick by transport URL; with the same label for both, the second chain's reads would ride the first chain's request + const seen: { url: string; methods: string[] }[] = []; + const impl = async (input: string | URL | Request, init?: RequestInit) => { + const calls = JSON.parse(String(init?.body)) as { id: number; method: string }[]; + seen.push({ url: String(input), methods: calls.map((c) => c.method) }); + return Response.json(calls.map((c) => ({ jsonrpc: "2.0", id: c.id, result: String(input).includes("/base/") ? "0xa" : "0xb" }))); + }; + const a = keylessHttp("https://gateway.example.test/base/abc123SECRET", impl); + const b = keylessHttp("https://gateway.example.test/arc/abc123SECRET", impl); + assert.notEqual(a.url, b.url); + assert.equal(keylessHttp("https://gateway.example.test/base/abc123SECRET", impl).url, a.url); // the same upstream keeps one label (and one batch) + for (const label of [a.url, b.url]) assert.ok(!String(label).includes("abc123SECRET") && !String(label).includes("/base") && !String(label).includes("/arc"), label); + const clientA = createPublicClient({ chain: base, transport: http(a.url, { batch: true, retryCount: 0, fetchFn: a.fetchFn }) }); + const clientB = createPublicClient({ chain: base, transport: http(b.url, { batch: true, retryCount: 0, fetchFn: b.fetchFn }) }); + const [ga, gb] = await Promise.all([clientA.getGasPrice(), clientB.getGasPrice()]); + assert.equal(ga, 10n); + assert.equal(gb, 11n); + assert.deepEqual(seen.map((r) => r.url).sort(), ["https://gateway.example.test/arc/abc123SECRET", "https://gateway.example.test/base/abc123SECRET"]); +}); diff --git a/app/src/lib/rpc-transport.ts b/app/src/lib/rpc-transport.ts new file mode 100644 index 00000000..f0d207f9 --- /dev/null +++ b/app/src/lib/rpc-transport.ts @@ -0,0 +1,42 @@ +/** Pure (node --test loads it directly): build viem HTTP transport settings that never carry a keyed RPC URL. */ + +type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise; + +/** + * viem quotes the transport's URL in every request error ("URL: https://…/v2/"), and a keyed provider carries its + * API key in the path or the query. So the transport is given the origin alone (the host is not a secret, and still + * says which node failed), and the request itself goes to the full URL through fetchFn: the key is in no error message, + * so it cannot reach a log line, a stored error or an API response whichever caller forgets to redact. + * An unset or unparsable URL is passed through unchanged (viem then uses the chain's public RPC, or fails as before). + */ +export function keylessHttp(url: string | undefined, fetchImpl: FetchFn = (input, init) => fetch(input, init)): { url: string | undefined; fetchFn?: FetchFn } { + if (!url) return { url }; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { url }; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return { url }; + if (parsed.pathname === "/" && !parsed.search) return { url }; // nothing to hide (viem itself moves user:pass@ into a header) + // Basic-auth credentials stay on the label: viem turns them into the Authorization header and drops them from the + // URL it quotes, and fetch refuses a URL that still carries them, so the request URL goes without + const label = new URL(parsed.origin); + label.username = parsed.username; + label.password = parsed.password; + const target = new URL(url); + target.username = ""; + target.password = ""; + const to = target.toString(); + // viem merges the requests of one tick by transport URL (its batch scheduler is keyed by it), so two upstreams must + // never share a label: a second chain on the same host would ride the first chain's request. The first upstream on + // a host keeps the bare origin; each further one gets a numbered path, which says nothing about the real one. + const host = parsed.username ? label.toString() : parsed.origin; + const known = upstreams.get(host) ?? []; + if (!known.includes(to)) upstreams.set(host, [...known, to]); + const n = (upstreams.get(host) ?? []).indexOf(to); + return { url: n === 0 ? host : `${host.replace(/\/$/, "")}/upstream-${n + 1}`, fetchFn: (_input, init) => fetchImpl(to, init) }; +} + +/** The real upstream URLs seen per label host, in first-seen order (per process; a handful of entries). */ +const upstreams = new Map(); From 728e297d373bbf0ce3b439e82e9ce0623ce9ea00 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Thu, 12 Nov 2026 23:41:05 +0800 Subject: [PATCH 2/2] fix(rpc): address CodeRabbit's review of #146 - The per-chain reason in the public sync response is the first line alone (publicError), like the route's other error path; the whole message, with URLs removed, goes to the log where it is caught. - Every rewritten upstream gets a numbered label, the first one included, so it can never share viem's batch with a URL passed through unchanged on the same host. A test pairs the two. - The sync loop's heal and backfill warnings remove URLs from what they log. --- app/src/lib/launchpad/indexer.ts | 22 +++++++++++++--------- app/src/lib/rpc-transport.test.ts | 29 ++++++++++++++++++++++++++--- app/src/lib/rpc-transport.ts | 11 ++++++----- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/app/src/lib/launchpad/indexer.ts b/app/src/lib/launchpad/indexer.ts index e7628032..1bd38a97 100644 --- a/app/src/lib/launchpad/indexer.ts +++ b/app/src/lib/launchpad/indexer.ts @@ -9,7 +9,7 @@ import { validDecimals } from "./unlisted-quote"; import { DEAD_ADDR, ZERO_ADDR } from "./holders"; import { SYNC_CHUNK_BLOCKS, SYNC_MAX_CHUNKS_PER_CALL, syncOverlapBlocks } from "@/lib/config"; import { byChainOrder, fetchBySelectors, fetchLogsSplit, isRangeTooLarge } from "./log-range"; -import { redactUrls } from "./redact"; +import { publicError, redactUrls } from "./redact"; import { ENTRY_POINTS, attributeSwap, type ReceiptLog } from "./attribution"; import { appTagOf, swapInApp, type AppTag } from "./appTag"; import { BUILDER_DATA_SUFFIX } from "@/lib/chainPublic"; @@ -708,14 +708,18 @@ export async function pollAll(): Promise> { const out: Record = {}; await Promise.all( CONFIGURED_CHAINS.map(async (c) => { - // the reason is returned by POST /api/launch/sync, unauthenticated: never with the upstream URL - out[c] = await pollLaunches(c).catch((e): LaunchSyncResult => ({ status: "skipped", reason: redactUrls(errMessage(e)) })); - await healLaunchReads(c).catch((e) => console.warn(`[launch-sync] heal ${c}:`, errMessage(e))); - await healQuoteTokens(c).catch((e) => console.warn(`[launch-sync] quote tokens ${c}:`, errMessage(e))); - await healSwapTraders(c).catch((e) => console.warn(`[launch-sync] heal traders ${c}:`, errMessage(e))); - await attributeBacklog(c).catch((e) => console.warn(`[launch-sync] attribute swaps ${c}:`, errMessage(e))); - await healAppTags(c).catch((e) => console.warn(`[launch-sync] app tags ${c}:`, errMessage(e))); - await backfillHolders(c).catch((e) => console.warn(`[launch-sync] holders backfill ${c}:`, errMessage(e))); + // the reason is returned by POST /api/launch/sync, unauthenticated: the first line alone, never the request or the + // node's own answer (the whole message, without URLs, goes to the log here) + out[c] = await pollLaunches(c).catch((e): LaunchSyncResult => { + console.warn(`[launch-sync] poll ${c}:`, redactUrls(errMessage(e))); + return { status: "skipped", reason: publicError(e, "sync failed") }; + }); + await healLaunchReads(c).catch((e) => console.warn(`[launch-sync] heal ${c}:`, redactUrls(errMessage(e)))); + await healQuoteTokens(c).catch((e) => console.warn(`[launch-sync] quote tokens ${c}:`, redactUrls(errMessage(e)))); + await healSwapTraders(c).catch((e) => console.warn(`[launch-sync] heal traders ${c}:`, redactUrls(errMessage(e)))); + await attributeBacklog(c).catch((e) => console.warn(`[launch-sync] attribute swaps ${c}:`, redactUrls(errMessage(e)))); + await healAppTags(c).catch((e) => console.warn(`[launch-sync] app tags ${c}:`, redactUrls(errMessage(e)))); + await backfillHolders(c).catch((e) => console.warn(`[launch-sync] holders backfill ${c}:`, redactUrls(errMessage(e)))); }), ); return out; diff --git a/app/src/lib/rpc-transport.test.ts b/app/src/lib/rpc-transport.test.ts index 047c08c9..be171c17 100644 --- a/app/src/lib/rpc-transport.test.ts +++ b/app/src/lib/rpc-transport.test.ts @@ -12,18 +12,18 @@ test("a keyed URL is requested in full but never appears in a viem error", async requested.push(String(input)); return new Response('{"error":"throughput exceeded"}', { status: 429, headers: { "content-type": "application/json" } }); }); - assert.equal(k.url, "https://base-mainnet.g.alchemy.com"); + assert.match(String(k.url), /^https:\/\/base-mainnet\.g\.alchemy\.com\/upstream-\d+$/); const client = createPublicClient({ chain: base, transport: http(k.url, { batch: true, retryCount: 0, fetchFn: k.fetchFn }) }); const err = await client.getBlockNumber().then(() => null, (e: unknown) => e as Error & { details?: string; cause?: unknown }); assert.ok(err); assert.deepEqual(requested, [KEYED]); // the message, and everything hanging off the error (cause chain, meta lines, stack) for (const text of [err.message, String(err.stack), JSON.stringify(err, Object.getOwnPropertyNames(err)), String((err.cause as Error | undefined)?.message)]) assert.ok(!text.includes("abc123SECRET"), text); - assert.match(err.message, /URL: https:\/\/base-mainnet\.g\.alchemy\.com\/?\n/); + assert.match(err.message, /URL: https:\/\/base-mainnet\.g\.alchemy\.com\/upstream-\d+\n/); }); test("a key in the query string is dropped from the label too", () => { - assert.equal(keylessHttp("https://rpc.example.test/?apikey=abc123SECRET").url, "https://rpc.example.test"); + assert.match(String(keylessHttp("https://rpc.example.test/?apikey=abc123SECRET").url), /^https:\/\/rpc\.example\.test\/upstream-\d+$/); }); test("URLs with nothing to hide, unset and unparsable ones pass through unchanged", () => { @@ -73,3 +73,26 @@ test("two keyed URLs on one host never share a batch: each read goes to its own assert.equal(gb, 11n); assert.deepEqual(seen.map((r) => r.url).sort(), ["https://gateway.example.test/arc/abc123SECRET", "https://gateway.example.test/base/abc123SECRET"]); }); + +test("a URL passed through unchanged and a keyed URL on the same host never share a batch", async () => { + const seen: string[] = []; + const impl = async (input: string | URL | Request, init?: RequestInit) => { + const calls = JSON.parse(String(init?.body)) as { id: number }[]; + seen.push(String(input)); + return Response.json(calls.map((c) => ({ jsonrpc: "2.0", id: c.id, result: String(input).includes("/v2/") ? "0xb" : "0xa" }))); + }; + for (const root of ["https://shared.example.test", "https://shared.example.test/"]) { + seen.length = 0; + const plain = keylessHttp(root); + const keyed = keylessHttp("https://shared.example.test/v2/abc123SECRET", impl); + assert.equal(plain.url, root); + assert.notEqual(keyed.url, root); + // the unchanged URL has no fetchFn of its own: the test gives viem one, as viem would use the global fetch + const clientPlain = createPublicClient({ chain: base, transport: http(plain.url, { batch: true, retryCount: 0, fetchFn: impl }) }); + const clientKeyed = createPublicClient({ chain: base, transport: http(keyed.url, { batch: true, retryCount: 0, fetchFn: keyed.fetchFn }) }); + const [a, b] = await Promise.all([clientPlain.getGasPrice(), clientKeyed.getGasPrice()]); + assert.equal(a, 10n); + assert.equal(b, 11n); + assert.deepEqual([...seen].sort(), [new URL(root).toString(), "https://shared.example.test/v2/abc123SECRET"].sort()); + } +}); diff --git a/app/src/lib/rpc-transport.ts b/app/src/lib/rpc-transport.ts index f0d207f9..ae2725e1 100644 --- a/app/src/lib/rpc-transport.ts +++ b/app/src/lib/rpc-transport.ts @@ -4,8 +4,8 @@ type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise"), and a keyed provider carries its - * API key in the path or the query. So the transport is given the origin alone (the host is not a secret, and still - * says which node failed), and the request itself goes to the full URL through fetchFn: the key is in no error message, + * API key in the path or the query. So the transport is given the origin and a numbered path (the host is not a secret, + * and still says which node failed), and the request itself goes to the full URL through fetchFn: the key is in no error message, * so it cannot reach a log line, a stored error or an API response whichever caller forgets to redact. * An unset or unparsable URL is passed through unchanged (viem then uses the chain's public RPC, or fails as before). */ @@ -29,13 +29,14 @@ export function keylessHttp(url: string | undefined, fetchImpl: FetchFn = (input target.password = ""; const to = target.toString(); // viem merges the requests of one tick by transport URL (its batch scheduler is keyed by it), so two upstreams must - // never share a label: a second chain on the same host would ride the first chain's request. The first upstream on - // a host keeps the bare origin; each further one gets a numbered path, which says nothing about the real one. + // never share a label: a second chain on the same host would ride the first chain's request. Every rewritten + // upstream gets a numbered path, which says nothing about the real one; a URL passed through unchanged above has no + // path at all, so it can never match one. const host = parsed.username ? label.toString() : parsed.origin; const known = upstreams.get(host) ?? []; if (!known.includes(to)) upstreams.set(host, [...known, to]); const n = (upstreams.get(host) ?? []).indexOf(to); - return { url: n === 0 ? host : `${host.replace(/\/$/, "")}/upstream-${n + 1}`, fetchFn: (_input, init) => fetchImpl(to, init) }; + return { url: `${host.replace(/\/$/, "")}/upstream-${n + 1}`, fetchFn: (_input, init) => fetchImpl(to, init) }; } /** The real upstream URLs seen per label host, in first-seen order (per process; a handful of entries). */