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..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,13 +708,18 @@ 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) })); - 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/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..be171c17 --- /dev/null +++ b/app/src/lib/rpc-transport.test.ts @@ -0,0 +1,98 @@ +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.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\/upstream-\d+\n/); +}); + +test("a key in the query string is dropped from the label too", () => { + 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", () => { + 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"]); +}); + +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 new file mode 100644 index 00000000..ae2725e1 --- /dev/null +++ b/app/src/lib/rpc-transport.ts @@ -0,0 +1,43 @@ +/** 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 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). + */ +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. 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: `${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();