Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/src/app/api/launch/meta/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 });
}
}
6 changes: 4 additions & 2 deletions app/src/app/api/launch/sync/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 });
}
}
13 changes: 10 additions & 3 deletions app/src/lib/chain.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -20,22 +21,28 @@ 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
* execute B20: inside a multicall such a node may report each call as a plain failure, not as an RPC
* 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;
Expand Down
21 changes: 13 additions & 8 deletions app/src/lib/launchpad/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -708,13 +708,18 @@ export async function pollAll(): Promise<Record<string, LaunchSyncResult>> {
const out: Record<string, LaunchSyncResult> = {};
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;
Expand Down
3 changes: 2 additions & 1 deletion app/src/lib/launchpad/pairPrice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -136,7 +137,7 @@ export async function readPairUsd(src: PairPriceSource): Promise<PairPriceReadin
const error = usd !== null ? undefined : leg.twap === null ? "no 30-minute average from its pool yet" : core.usd === null ? "its core quote has no price right now" : "its pool gave no price";
return { usd, spot: leg.spot, twap: leg.twap, source: g.source, deviation: g.deviation, ...(error ? { error } : {}) };
} catch (e) {
return { usd: null, spot: null, twap: null, source: null, deviation: null, error: e instanceof Error ? e.message : String(e) };
return { usd: null, spot: null, twap: null, source: null, deviation: null, error: publicError(e) };
}
}

Expand Down
5 changes: 3 additions & 2 deletions app/src/lib/launchpad/pairsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { tsFresh } from "./posts";
import { PAIR_CHAINS, PAIR_CHAINS_TEXT, PAIR_FEE_CHAIN, PAIR_DESCRIPTION_MAX, guardedPairPrice, pairWarmingUp, PAIR_MONITOR_EVERY_MS, PAIR_MONITOR_LIQUIDITY_USD, PAIR_SAMPLE_EVERY_MS, buildPairAdminMessage, isPairAdminAction, isPairPriceKind, isPairWebsite, isSiteLogoPath, isXHandle, nextPairStatus, pairSymbolProblem, type PairHealth, type PairListing, type PairPriceKind, type PairPublic, type PairStatus } from "./pairs";
import { codeHashOf, isCoreQuote, poolLiquidityUsd, readPairLeg, readPairUsd, samplePairSpot, univ3Tokens, univ4Key } from "./pairPrice";
import { coreQuoteNames } from "./config";
import { publicError } from "./redact";
import { ensureRegistry, stockTickers } from "./stocksServer";

/**
Expand Down Expand Up @@ -194,7 +195,7 @@ async function inspect(chain: ChainKey, address: string, l: PairListing, others:
}
// the pool must give a price now (a v4 pool its spot: its average needs 20 minutes of samples after listing)
const leg = await readPairLeg(src).catch((e: unknown) => 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
Expand Down Expand Up @@ -337,7 +338,7 @@ export async function checkPair(r: PairDbRow, chain: ChainKey): Promise<{ health
}
async function poolHealth(r: PairDbRow, chain: ChainKey): Promise<PairHealth> {
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());
Expand Down
11 changes: 10 additions & 1 deletion app/src/lib/launchpad/redact.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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"), "<url> and <url>");
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 <url> 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");
});
9 changes: 9 additions & 0 deletions app/src/lib/launchpad/redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,12 @@
export function redactUrls(message: string): string {
return message.replace(/\b(?:https?|wss?):\/\/[^\s"'`<>)\]]+/gi, "<url>");
}

/**
* 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;
}
98 changes: 98 additions & 0 deletions app/src/lib/rpc-transport.test.ts
Original file line number Diff line number Diff line change
@@ -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());
}
});
Loading
Loading