diff --git a/README.md b/README.md index fadb2a6..9830e9b 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Creators choose a 0 / 1 / 3 % trading fee that is pushed **in full** to benefici - **Any quote asset.** ETH, USDG, USDC on Arc, **TWIG** ([Twigpine](https://twigpine.com)'s token on Base: a 1:1 wrapper of GITLAWB, wrap or unwrap any time with no fee; priced at GITLAWB's price; fees paid or burned in TWIG), **GITLAWB** ([Twigpine](https://twigpine.com)'s token, on Base and bridged 1:1 to Robinhood Chain; priced from its Base Uniswap v4 WETH pool; fees paid or burned in GITLAWB; the Base form offers TWIG in its place), or a **tokenized stock**: Coinbase's B20 stocks on Base - (every stock Coinbase lists: NVDAc, AAPLc, TSLAc, NFLXc, …; shown in USD where Chainlink publishes an on-chain feed) and Robinhood Stock Tokens on Robinhood Chain. + (every stock Coinbase lists: NVDAc, AAPLc, TSLAc, NFLXc, …, and new listings picked up from Coinbase's registry as they appear; shown in USD where Chainlink publishes an on-chain feed) and Robinhood Stock Tokens on Robinhood Chain. - **Trade in-page.** Buys and sells go straight to the Uniswap v4 pool through the Universal Router; the site never touches funds. - **Live everything.** One poller feeds the list, the trending strip, the tape, toasts and totals. diff --git a/app/src/app/api/quotes/route.ts b/app/src/app/api/quotes/route.ts index 76bd2d8..4808942 100644 --- a/app/src/app/api/quotes/route.ts +++ b/app/src/app/api/quotes/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { isChainKey } from "@/lib/chainPublic"; import { launchpad } from "@/lib/launchpad/config"; -import { STOCK_SOURCE, ensureRegistry, stockIssued, stockList, stockPrices } from "@/lib/launchpad/stocksServer"; +import { STOCK_SOURCE, baseStockList, ensureRegistry, refreshBaseRegistry, stockIssued, stockList, stockPrices } from "@/lib/launchpad/stocksServer"; import { searchStocks } from "@/lib/launchpad/stocks"; import { BASE_STOCK_SEARCH_LIMIT, searchBaseStocks, stockTileSvg } from "@/lib/launchpad/baseStocks"; import { memo } from "@/lib/launchpad/memo"; @@ -33,26 +33,33 @@ export async function GET(req: Request) { const { stocks, stocksTotal, stocksMatched } = await memo(`quotes:stocks:${chain}:${q.toUpperCase()}`, 30_000, async (): Promise<{ stocks: StockHit[]; stocksTotal: number; stocksMatched: number }> => { let hits: { address: string; symbol: string; name: string; decimals: number; logo: string | null }[]; let matched: number; + // the total comes from the same list the search ran on: a registry refresh can land while prices are read below + let total: number; const source = STOCK_SOURCE[chain]; if (source === null) return { stocks: [], stocksTotal: 0, stocksMatched: 0 }; if (source === "coinbase-b20") { - const all = searchBaseStocks(q, Infinity); + void refreshBaseRegistry(); // background: the list as it stands answers now + const list = baseStockList(); + const all = searchBaseStocks(q, Infinity, list); matched = all.length; + total = list.length; hits = all.slice(0, BASE_STOCK_SEARCH_LIMIT).map((s) => ({ ...s, logo: stockTileSvg(s.symbol) })); } else { if (!(await ensureRegistry())) return { stocks: [], stocksTotal: 0, stocksMatched: 0 }; + const list = stockList(chain); const all = searchStocks( - stockList(chain).map((s) => ({ ...s, multiplier: 1 })), + list.map((s) => ({ ...s, multiplier: 1 })), q, Infinity, ); matched = all.length; + total = list.length; hits = all.slice(0, 12); } const [px, issued] = await Promise.all([stockPrices(chain, hits.map((h) => h.address)), stockIssued(chain)]); return { stocks: hits.map((h) => ({ key: "stock" as const, address: h.address, symbol: h.symbol, name: h.name, decimals: h.decimals, usd: px.get(h.address) ?? null, logo: h.logo, issued: issued.get(h.address) ?? null })), - stocksTotal: stockList(chain).length, + stocksTotal: total, stocksMatched: matched, }; }); diff --git a/app/src/lib/launchpad/base-registry.test.ts b/app/src/lib/launchpad/base-registry.test.ts new file mode 100644 index 0000000..c570c89 --- /dev/null +++ b/app/src/lib/launchpad/base-registry.test.ts @@ -0,0 +1,111 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { getMock } from "../db-mock.ts"; +import { BASE_STOCKS, BASE_STOCK_REGISTRY_URL, searchBaseStocks } from "./baseStocks.ts"; +import { baseStockList, ensureRegistry, refreshBaseRegistry, stockByAddress, stockIssued, stockList } from "./stocksServer.ts"; + +// The Base list lives in the module, so this file walks it through one timeline: static, grown, cached, failed, retried. +// fetch is mocked per URL: Coinbase's registry answers `reply`, anything else (Robinhood's registry) answers nothing useful. + +const NEW = "0xb200000000000000000000abcdef0123456789aa"; +const SPOOF = "0xb200000000000000000000abcdef0123456789ff"; +const NVDA = "0xb20000000000000000000078ee7ce2fe4908108c"; +const NFLX = "0xb20000000000000000000058b8c947e44011dfe6"; +const coinbaseRow = (symbol: string, address: string, name: string) => ({ contract_address: address, symbol, name, decimals: 8 }); + +test("base list: static until a good registry read, then only grows; a failed read keeps it and is retried after a minute", async (t) => { + t.mock.timers.enable({ apis: ["Date"], now: 1_000_000 }); + const warn = t.mock.method(console, "warn", () => {}); + const info = t.mock.method(console, "info", () => {}); + const coinbaseWarnings = () => warn.mock.calls.filter((c) => String(c.arguments[0]).includes("coinbase registry")).length; // Robinhood's registry warns on its own + let reply: (url: string) => Response = () => Response.json({ tokens: [...BASE_STOCKS.filter((s) => s.symbol !== "COINc").map((s) => coinbaseRow(s.symbol, s.address, `${s.name}, Inc.`))] }); + const asked: string[] = []; + t.mock.method(globalThis, "fetch", async (url: unknown) => { + asked.push(String(url)); + return String(url).startsWith(BASE_STOCK_REGISTRY_URL) ? reply(String(url)) : Response.json({ assets: [] }); + }); + + // before any read: the static list, nothing fetched by a lookup + const before = stockList("base"); + assert.equal(before.length, 146); + assert.equal(stockByAddress("base", NEW), null); + assert.deepEqual(asked, []); + + // today's registry (145, no COINc): nothing new, so the very same rows + await refreshBaseRegistry(); + assert.deepEqual(asked, [BASE_STOCK_REGISTRY_URL]); + assert.equal(stockList("base"), before); + assert.ok(stockByAddress("base", BASE_STOCKS.find((s) => s.symbol === "COINc")!.address), "COINc stays though the registry leaves it out"); + assert.equal(stockByAddress("base", NFLX)?.name, "Netflix", "listed names are not replaced by the registry's"); + assert.equal(info.mock.callCount(), 0); + + // inside the 10 minutes: no read + await refreshBaseRegistry(); + assert.equal(asked.length, 1); + + // Coinbase lists a new stock (and the reply also carries a second "NVDAc" at another address) + reply = () => Response.json({ tokens: [coinbaseRow("NEWc", NEW, "New Robotics Corporation"), coinbaseRow("NVDAc", SPOOF, "NVIDIA Corporation")] }); + t.mock.timers.tick(10 * 60_000); + await ensureRegistry(); // the page-data path starts it too + await refreshBaseRegistry(); + assert.equal(asked.filter((u) => u === BASE_STOCK_REGISTRY_URL).length, 2); + const grown = stockList("base"); + assert.equal(grown.length, 147); + assert.deepEqual(stockByAddress("base", NEW), { chain: "base", address: NEW, symbol: "NEWc", name: "New Robotics", decimals: 8, logo: grown.find((s) => s.address === NEW)!.logo, priced: false }); + assert.ok(stockByAddress("base", NEW)!.logo?.startsWith("data:image/svg+xml,")); + assert.equal(stockByAddress("base", SPOOF), null, "a second NVDAc is never a stock"); + assert.equal(stockByAddress("base", NVDA)?.priced, true); + assert.equal(stockByAddress("base", NVDA), before.find((s) => s.address === NVDA), "rows already built are reused, not rebuilt"); + assert.deepEqual(searchBaseStocks("new", 13, baseStockList()).map((s) => s.symbol), ["NEWc"], "and search finds it"); + assert.match(String(info.mock.calls[0]?.arguments[0]), /added 1 Base stock\(s\): NEWc/); + + // the supply read covers the grown list + getMock().b20Supply = Object.fromEntries(stockList("base").map((s) => [s.address, 0n])); + assert.equal((await stockIssued("base")).get(NEW), false); + + // registry down after the next 10 minutes: the list stays as it is; the failure is retried after a minute, not per request + reply = () => new Response("bad gateway", { status: 502 }); + t.mock.timers.tick(10 * 60_000); + await refreshBaseRegistry(); + assert.equal(stockList("base"), grown); + assert.equal(coinbaseWarnings(), 1); + await refreshBaseRegistry(); + assert.equal(asked.filter((u) => u === BASE_STOCK_REGISTRY_URL).length, 3); + // a reply that is not the registry counts as a failure too, and never shrinks the list + reply = () => Response.json({ tokens: [] }); + t.mock.timers.tick(60_000); + await refreshBaseRegistry(); + assert.equal(asked.filter((u) => u === BASE_STOCK_REGISTRY_URL).length, 4); + assert.equal(stockList("base"), grown); + assert.equal(coinbaseWarnings(), 2); + // a later reply without NEWc does not remove it while the app runs + reply = () => Response.json({ tokens: [coinbaseRow("NVDAc", NVDA, "NVIDIA Corporation")] }); + t.mock.timers.tick(60_000); + await refreshBaseRegistry(); + assert.equal(asked.filter((u) => u === BASE_STOCK_REGISTRY_URL).length, 5); + assert.equal(stockList("base"), grown); + assert.ok(stockByAddress("base", NEW)); + + // a registry over two pages: the second is fetched with the first page's token, and its stock is added + const PAGED = "0xb200000000000000000000abcdef0123456789ab"; + reply = (url) => Response.json(url === BASE_STOCK_REGISTRY_URL ? { tokens: [coinbaseRow("NVDAc", NVDA, "NVIDIA")], next_page_token: "MHhi+/2=" } : { tokens: [coinbaseRow("PAGEc", PAGED, "Paged Stock Inc.")] }); + t.mock.timers.tick(10 * 60_000); + await refreshBaseRegistry(); + assert.deepEqual(asked.slice(-2), [BASE_STOCK_REGISTRY_URL, `${BASE_STOCK_REGISTRY_URL}&page_token=MHhi%2B%2F2%3D`]); + assert.equal(stockByAddress("base", PAGED)?.name, "Paged Stock"); + assert.equal(stockList("base").length, 148); + // ...and when a later page fails, nothing from the read is used (the list stays, retried after a minute) + const LOST = "0xb200000000000000000000abcdef0123456789ac"; + reply = (url) => (url === BASE_STOCK_REGISTRY_URL ? Response.json({ tokens: [coinbaseRow("LOSTc", LOST, "Lost")], next_page_token: "p2" }) : new Response("bad gateway", { status: 502 })); + t.mock.timers.tick(10 * 60_000); + await refreshBaseRegistry(); + assert.equal(stockByAddress("base", LOST), null); + assert.equal(stockList("base").length, 148); + assert.equal(coinbaseWarnings(), 3); +}); + +test("other chains never read Coinbase's registry and never see its stocks", async () => { + assert.equal(stockByAddress("robinhood", NEW), null); + assert.equal(stockByAddress("arc", NEW), null); + assert.deepEqual(stockList("arc"), []); +}); diff --git a/app/src/lib/launchpad/baseStocks.test.ts b/app/src/lib/launchpad/baseStocks.test.ts index 1e4cba8..5c2b7ce 100644 --- a/app/src/lib/launchpad/baseStocks.test.ts +++ b/app/src/lib/launchpad/baseStocks.test.ts @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { isAddress } from "viem"; -import { BASE_STOCKS, BASE_STOCK_MAX_FEED_AGE_S, BASE_STOCK_SEARCH_LIMIT, baseStockByAddress, feedUsd, isB20Address, isB20RpcError, issuedFromSupply, parseRoundData, pricedBaseStocks, responseHasB20Error, searchBaseStocks, stockTileSvg, underlyingTicker } from "./baseStocks.ts"; +import { BASE_STOCKS, BASE_STOCK_MAX_FEED_AGE_S, BASE_STOCK_SEARCH_LIMIT, baseStockByAddress, BASE_STOCK_REGISTRY_URL, baseRegistryPageUrl, cleanStockName, feedUsd, isB20Address, isB20RpcError, issuedFromSupply, mergeBaseStocks, parseBaseRegistry, readBaseRegistry, parseRoundData, pricedBaseStocks, responseHasB20Error, searchBaseStocks, stockTileSvg, underlyingTicker } from "./baseStocks.ts"; const symbols = (list: readonly { symbol: string }[]) => list.map((s) => s.symbol); /** The 13 stocks Chainlink publishes a "Coinbase " feed for on Base. */ @@ -125,6 +125,112 @@ test("feedUsd: 8-dec answer → USD; stale, future, zero or negative → null", assert.equal(feedUsd(null, now), null); }); +test("cleanStockName: the issuer's name the way the list writes it", () => { + const cases: [string, string][] = [ + ["Tesla Inc.", "Tesla"], + ["Advanced Micro Devices, Inc.", "Advanced Micro Devices"], + ["Eli Lilly & Co", "Eli Lilly"], + ["Berkshire Hathaway Inc. Class B", "Berkshire Hathaway Class B"], + ["Eastman Kodak Company", "Eastman Kodak"], + ["The Hershey Company", "The Hershey Company"], + ["Papa John\u2019s International, Inc.", "Papa John's International"], + ["Novo Nordisk A/S", "Novo Nordisk"], + ["Anheuser-Busch InBev SA/NV", "Anheuser-Busch InBev"], + ["Banco Santander, S.A.", "Banco Santander"], + ["Planet Labs PBC", "Planet Labs"], + ["Arm Holdings plc", "Arm Holdings"], + [" Sea Limited ", "Sea"], + ]; + for (const [raw, want] of cases) assert.equal(cleanStockName(raw), want, raw); + assert.equal(cleanStockName("A".repeat(60)).length, 40); +}); + +// one row of Coinbase's reply (api.coinbase.com/v1/tokenized-stocks), trimmed to the fields that are read +const row = (symbol: string, address: string, extra: Record = {}) => ({ contract_address: address, symbol, name: `${symbol.slice(0, -1)} Holdings, Inc.`, decimals: 8, isin: "US0000000000", ...extra }); +const NEW = "0xb200000000000000000000AbCdEf0123456789aa"; // a B20 address the static list does not have +const NVDA = BASE_STOCKS.find((s) => s.symbol === "NVDAc")!; + +test("parseBaseRegistry: keeps well-formed B20 stock rows, lowercased, with no feed; drops everything else", () => { + const rows = parseBaseRegistry({ + tokens: [ + row("NEWc", NEW), + row("NEWc", "0xb200000000000000000000abcdef0123456789bb"), // repeated symbol: the first wins + row("DUPc", NEW), // repeated address + row("BADc", "0x1234567890123456789012345678901234567890"), // not B20 + row("NVDA", "0xb200000000000000000000abcdef0123456789cc"), // no issuer "c" + row("nvdac", "0xb200000000000000000000abcdef0123456789cd"), + row("TOOLONGc", "0xb200000000000000000000abcdef0123456789ce"), + row("DECc", "0xb200000000000000000000abcdef0123456789cf", { decimals: 19 }), + row("DECc", "0xb200000000000000000000abcdef0123456789d0", { decimals: "8" }), // numeric string: accepted, and DECc is free again + row("ODDc", "0xb200000000000000000000abcdef0123456789d1", { name: "" }), + row("NONc", "0xb200000000000000000000abcdef0123456789d2", { name: 42 }), + null, + "row", + ], + }); + assert.deepEqual(rows, [ + { symbol: "NEWc", name: "NEW Holdings", address: NEW.toLowerCase(), feed: null, decimals: 8 }, + { symbol: "DECc", name: "DEC Holdings", address: "0xb200000000000000000000abcdef0123456789d0", feed: null, decimals: 8 }, + { symbol: "ODDc", name: "ODD", address: "0xb200000000000000000000abcdef0123456789d1", feed: null, decimals: 8 }, + { symbol: "NONc", name: "NON", address: "0xb200000000000000000000abcdef0123456789d2", feed: null, decimals: 8 }, + ]); + for (const junk of [null, 42, "x", [], { tokens: "x" }, { data: [row("NEWc", NEW)] }]) assert.deepEqual(parseBaseRegistry(junk), [], JSON.stringify(junk)); + // decimals must be a whole number or a string of digits: Number() would read null, "" and false as 0, true as 1, [8] as 8 + for (const decimals of [null, undefined, "", " ", false, true, [8], 8.5, "8.0", "-1", -1, 19, "x"]) { + assert.deepEqual(parseBaseRegistry({ tokens: [row("NEWc", NEW, { decimals })] }), [], `decimals ${JSON.stringify(decimals)}`); + } + assert.equal(parseBaseRegistry({ tokens: [row("NEWc", NEW, { decimals: 0 })] })[0]?.decimals, 0, "a real 0 is still a number"); + assert.deepEqual(parseBaseRegistry({ tokens: Array.from({ length: 1001 }, () => row("NEWc", NEW)) }), [], "a reply far larger than the registry is not the registry"); +}); + +test("mergeBaseStocks: the registry can only add; it never relabels, unprices or shadows a listed stock", () => { + assert.equal(mergeBaseStocks(BASE_STOCKS, []), BASE_STOCKS); + assert.equal(mergeBaseStocks(BASE_STOCKS, BASE_STOCKS.map((s) => ({ ...s, name: "Renamed", feed: null }))), BASE_STOCKS, "every address known: same list back, static names and feeds untouched"); + const added = { symbol: "NEWc", name: "New", address: NEW.toLowerCase(), feed: null, decimals: 8 }; + const spoof = { ...added, symbol: "NVDAc", address: "0xb200000000000000000000abcdef0123456789ff" }; + const lower = { ...added, symbol: "aaplc", address: "0xb200000000000000000000abcdef0123456789fe" }; // AAPLc in other case + const merged = mergeBaseStocks(BASE_STOCKS, [spoof, added, lower, { ...NVDA, name: "Not Nvidia", feed: null }]); + assert.equal(merged.length, BASE_STOCKS.length + 1, "only the new stock"); + assert.ok(merged.includes(added)); + assert.equal(merged.find((s) => s.symbol === "NVDAc"), NVDA, "NVDAc stays the listed row, feed and all"); + assert.ok(!merged.some((s) => s.address === spoof.address), "a second NVDAc at another address is dropped"); + const tickers = merged.map((s) => underlyingTicker(s.symbol)); + assert.deepEqual(tickers, [...tickers].sort(), "still sorted by ticker"); + assert.equal(mergeBaseStocks(merged, [added]), merged, "already there: nothing new"); + // two new rows for one stock (say, across registry pages): the first wins, by address and by symbol + const twice = mergeBaseStocks(BASE_STOCKS, [added, { ...added }, { ...added, name: "Other", address: "0xb200000000000000000000abcdef0123456789fd" }]); + assert.equal(twice.length, BASE_STOCKS.length + 1); + assert.equal(twice.find((s) => s.symbol === "NEWc"), added); +}); + +test("readBaseRegistry: follows next_page_token through every page; a partial or looping read throws instead of passing for the registry", async () => { + assert.equal(baseRegistryPageUrl(null), BASE_STOCK_REGISTRY_URL); + assert.equal(baseRegistryPageUrl("ab+/="), `${BASE_STOCK_REGISTRY_URL}&page_token=ab%2B%2F%3D`, "the token is URL-encoded"); + const NEW2 = "0xb200000000000000000000abcdef0123456789bb"; + const pages: Record = { + first: { tokens: [row("NEWc", NEW), row("AAPLc", BASE_STOCKS[1].address)], next_page_token: "p2" }, + p2: { tokens: [row("NEXc", NEW2), row("NEWc", NEW)], next_page_token: "" }, + }; + const asked: (string | null)[] = []; + const rows = await readBaseRegistry(async (token) => { + asked.push(token); + return pages[token ?? "first"]; + }); + assert.deepEqual(asked, [null, "p2"]); + assert.deepEqual(rows.map((r) => r.symbol), ["NEWc", "AAPLc", "NEXc"], "both pages, NEWc once"); + for (const end of [undefined, null, ""]) assert.equal((await readBaseRegistry(async () => ({ tokens: [row("NEWc", NEW)], next_page_token: end }))).length, 1, `next_page_token ${JSON.stringify(end)} ends it`); + + const fails = async (name: string, getPage: (token: string | null) => Promise, message: RegExp) => assert.rejects(readBaseRegistry(getPage), message, name); + await fails("a page without a token list", async (t) => (t === null ? { tokens: [], next_page_token: "p2" } : { error: "rate limited" }), /page 2 has no token list/); + await fails("a token that comes round again", async (t) => ({ tokens: [], next_page_token: t === "a" ? "b" : "a" }), /repeated next_page_token/); + await fails("a token that is not a string", async () => ({ tokens: [], next_page_token: 7 }), /bad or repeated next_page_token/); + let n = 0; + await fails("more than 10 pages", async () => ({ tokens: [], next_page_token: `p${++n}` }), /more than 10 pages/); + await fails("more than 1000 rows in all", async (t) => ({ tokens: Array.from({ length: 600 }, () => row("NEWc", NEW)), next_page_token: t === null ? "p2" : undefined }), /more than 1000 rows/); + await fails("a failed page fetch", async (t) => { if (t) throw new Error("base registry 502"); return { tokens: [], next_page_token: "p2" }; }, /502/); + assert.equal(n, 10, "stopped at the tenth page"); +}); + test("issuedFromSupply: supply above zero is issued, zero is not, a failed or odd read says nothing", () => { const [a, b, c, d] = BASE_STOCKS.slice(0, 4).map((s) => s.address); const out = issuedFromSupply([a.toUpperCase().replace("0X", "0x"), b, c, d], [ diff --git a/app/src/lib/launchpad/baseStocks.ts b/app/src/lib/launchpad/baseStocks.ts index 2f03a89..84dc918 100644 --- a/app/src/lib/launchpad/baseStocks.ts +++ b/app/src/lib/launchpad/baseStocks.ts @@ -9,6 +9,8 @@ * which the API leaves out but which exists on-chain, has a Chainlink feed and has launches quoted in it. * Names are the issuer's without the legal suffix ("Netflix Inc" → "Netflix"); the address is the identity. * Rows are sorted by underlying ticker. A stock the issuer has listed may have no supply yet. + * This list is the floor: at run time the server also reads Coinbase's registry and adds any stock listed there that this + * list lacks (parseBaseRegistry, mergeBaseStocks; stocksServer.ts). It only adds, so a stock never drops out of the app. * * Prices come from Chainlink's on-chain "Coinbase " feeds on Base (8 decimals, 24/5, the * feed holds the last close while markets are shut) — no third-party API, no key. Chainlink publishes a @@ -187,6 +189,99 @@ export function baseStockByAddress(address: string): BaseStock | null { return BY_ADDRESS.get(address.toLowerCase()) ?? null; } +/** Coinbase's public registry of its tokenized stocks (no key; docs.base.org/sdks/tokenized-stocks), first page. A reply with `next_page_token` continues at `page_token`. */ +export const BASE_STOCK_REGISTRY_URL = "https://api.coinbase.com/v1/tokenized-stocks?page_size=500"; +/** A registry with more rows or pages than this is not the registry. */ +const MAX_REGISTRY_ROWS = 1000; +const MAX_REGISTRY_PAGES = 10; + +/** The URL of a registry page: the first one, or the one a `next_page_token` points at. */ +export function baseRegistryPageUrl(pageToken: string | null): string { + return pageToken === null ? BASE_STOCK_REGISTRY_URL : `${BASE_STOCK_REGISTRY_URL}&page_token=${encodeURIComponent(pageToken)}`; +} +/** A ticker ("U", "GOOGL", "BRK.B") and the issuer's "c". */ +const BASE_STOCK_SYMBOL = /^[A-Z]{1,5}(\.[A-Z])?c$/; +const LEGAL_SUFFIX = /(?:,?\s+(?:Inc\.?|Incorporated|Corp\.?|Corporation|Ltd\.?|Limited|plc|S\.A\.?|SA\/NV|N\.V\.?|A\/S|SE|AG|PBC|L\.P\.?|&\s+Co\.?|Co\.?))+$/i; + +/** The issuer's name the way this list writes it: no legal suffix ("Tesla Inc." → "Tesla", "Eli Lilly & Co" → "Eli Lilly"), plain apostrophes, at most 40 characters. */ +export function cleanStockName(name: string): string { + let s = name.normalize("NFKC").replace(/[\u2018\u2019\u02bc]/g, "'").replace(/\s+/g, " ").trim(); + s = s.replace(/,?\s+(?:Inc\.?|Corp\.?|Corporation|Ltd\.?|plc)(?=\s+Class\s)/i, ""); // "Berkshire Hathaway Inc. Class B" + s = s.replace(LEGAL_SUFFIX, ""); + if (!/^The\s/i.test(s)) s = s.replace(/\s+Company$/i, ""); // "Eastman Kodak Company", but "The Hershey Company" keeps it + return s.slice(0, 40).trim(); +} + +/** + * Coinbase's registry reply → stock rows, defensively. A row is kept only with a B20 address, a "c" symbol and + * 0-18 decimals; the first row wins a repeated address or symbol; a name that is not plain printable text becomes the ticker. + * No row gets a price feed: a feed is matched to a stock by hand, in the static list. + */ +export function parseBaseRegistry(json: unknown): BaseStock[] { + const items = (json as { tokens?: unknown } | null)?.tokens; + if (!Array.isArray(items) || items.length > MAX_REGISTRY_ROWS) return []; + const out: BaseStock[] = []; + const seen = new Set(); + for (const raw of items) { + const t = (raw ?? {}) as Record; + const address = typeof t.contract_address === "string" ? t.contract_address.toLowerCase() : ""; + const symbol = typeof t.symbol === "string" ? t.symbol.trim() : ""; + if (!isB20Address(address) || !BASE_STOCK_SYMBOL.test(symbol) || seen.has(address) || seen.has(symbol)) continue; + // a number, or a string of digits: never null, "", true or [8], which Number() would turn into 0, 1 or 8 + const decimals = typeof t.decimals === "number" ? t.decimals : typeof t.decimals === "string" && /^\d{1,2}$/.test(t.decimals) ? Number(t.decimals) : NaN; + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 18) continue; + const name = typeof t.name === "string" ? cleanStockName(t.name) : ""; + seen.add(address).add(symbol); + out.push({ symbol, name: /^[\p{L}\p{N} .,&'!()-]{2,40}$/u.test(name) ? name : underlyingTicker(symbol), address, feed: null, decimals }); + } + return out; +} + +/** + * Read the whole registry: every page, following `next_page_token`, parsed as one list (so a stock repeated across pages + * is kept once). Throws on a page with no token list, a page token seen before, or more than MAX_REGISTRY_PAGES pages or + * MAX_REGISTRY_ROWS rows, so a partial read never stands in for the registry. `getPage` fetches one page's JSON. + */ +export async function readBaseRegistry(getPage: (pageToken: string | null) => Promise): Promise { + const items: unknown[] = []; + const seen = new Set(); + let pageToken: string | null = null; + for (let page = 1; ; page++) { + const json = (await getPage(pageToken)) as { tokens?: unknown; next_page_token?: unknown } | null; + if (!Array.isArray(json?.tokens)) throw new Error(`registry page ${page} has no token list`); + items.push(...json.tokens); + if (items.length > MAX_REGISTRY_ROWS) throw new Error(`registry has more than ${MAX_REGISTRY_ROWS} rows`); + const next = json.next_page_token; + if (next === undefined || next === null || next === "") break; + if (typeof next !== "string" || seen.has(next)) throw new Error(`registry page ${page} has a bad or repeated next_page_token`); + if (page === MAX_REGISTRY_PAGES) throw new Error(`registry has more than ${MAX_REGISTRY_PAGES} pages`); + seen.add(next); + pageToken = next; + } + return parseBaseRegistry({ tokens: items }); +} + +/** + * The static list plus the registry rows it lacks, sorted by ticker like the static list. The registry can only add: a + * listed address keeps its static row (name, feed), and a new address under a symbol already listed is dropped, so no + * reply can relabel, unprice or shadow a stock. Nothing new → the same list object back. + */ +export function mergeBaseStocks(known: readonly BaseStock[], live: readonly BaseStock[]): readonly BaseStock[] { + const addresses = new Set(known.map((s) => s.address)); + const symbols = new Set(known.map((s) => s.symbol.toUpperCase())); + const added: BaseStock[] = []; + for (const s of live) { + const symbol = s.symbol.toUpperCase(); + if (addresses.has(s.address) || symbols.has(symbol)) continue; // listed, or already added from an earlier row: the first wins + addresses.add(s.address); + symbols.add(symbol); + added.push(s); + } + if (added.length === 0) return known; + const ticker = (s: BaseStock) => underlyingTicker(s.symbol); + return [...known, ...added].sort((a, b) => (ticker(a) < ticker(b) ? -1 : ticker(a) > ticker(b) ? 1 : 0)); +} + /** Address shape of a B20 token (does NOT mean it is a Coinbase stock — only the registry decides that). */ export function isB20Address(address: string): boolean { return /^0x[0-9a-fA-F]{40}$/.test(address) && address.toLowerCase().startsWith(B20_PREFIX); @@ -202,14 +297,14 @@ export function pricedBaseStocks(addresses: string[]): PricedBaseStock[] { * price feed lead and the rest keep registry (ticker) order, so "U" finds Uc ahead of UBERc. An empty query is the * whole registry in that order: the priced stocks fill the first page. */ -export function searchBaseStocks(q: string, limit = 12): BaseStock[] { +export function searchBaseStocks(q: string, limit = 12, list: readonly BaseStock[] = BASE_STOCKS): BaseStock[] { const n = q.trim().toUpperCase().replace(/^\$/, ""); const rank = (s: BaseStock): number => { if (!n || s.symbol.toUpperCase().startsWith(n)) return 0; // the ticker, not the symbol: every symbol ends in "c", so a "C" would otherwise match all of them return underlyingTicker(s.symbol).includes(n) || s.name.toUpperCase().includes(n) ? 1 : -1; }; - return BASE_STOCKS.map((s) => ({ s, r: rank(s) })) + return list.map((s) => ({ s, r: rank(s) })) .filter((x) => x.r >= 0) .sort((a, b) => a.r - b.r || Number(b.s.feed !== null) - Number(a.s.feed !== null)) .slice(0, limit) diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts index f3d2cc4..2fd62b4 100644 --- a/app/src/lib/launchpad/queries.ts +++ b/app/src/lib/launchpad/queries.ts @@ -180,7 +180,7 @@ async function loadQuoteTokens(): Promise> { async function withStocks(): Promise { gitlawbUsdNow = await gitlawbUsd(); // never throws; served from cache once warm (stale-while-revalidate) try { - await ensureRegistry(); // robinhood registry (fail-soft); the Base list is static + await ensureRegistry(); // robinhood registry (fail-soft); also starts a background refresh of the Base list stockUsdNow = await stockUsdInUse(); } catch { /* fail soft: stocks show without USD */ diff --git a/app/src/lib/launchpad/quotes-api.test.ts b/app/src/lib/launchpad/quotes-api.test.ts index 39207ed..5500460 100644 --- a/app/src/lib/launchpad/quotes-api.test.ts +++ b/app/src/lib/launchpad/quotes-api.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import ts from "typescript"; import { isChainKey, type ChainKey } from "../chainPublic.ts"; -import { BASE_STOCKS, BASE_STOCK_SEARCH_LIMIT, pricedBaseStocks, searchBaseStocks, stockTileSvg } from "./baseStocks.ts"; +import { BASE_STOCKS, BASE_STOCK_SEARCH_LIMIT, pricedBaseStocks, searchBaseStocks, stockTileSvg, type BaseStock } from "./baseStocks.ts"; import { STOCK_SOURCE, launchpad } from "./config.ts"; import { searchStocks } from "./stocks.ts"; import { gitlawbLinkedUsd } from "./twig.ts"; @@ -18,9 +18,12 @@ const ISSUED = new Map([[addressOf("AAPLc"), true], [addressOf("COINc"), false], // Compile only the handler and hand it explicit imports: the registry, search and ranking are the real modules, the // server ones (prices, the Robinhood registry fetch, GITLAWB's price, the memo) are local fakes. No network, no database. -function isolatedRoute(robinhoodRegistry: number | null = 30) { - const calls = { registry: 0, prices: [] as { chain: string; addresses: string[] }[], issued: [] as string[] }; - const robinhood = robinhoodRegistry === null ? null : Array.from({ length: robinhoodRegistry }, (_, i) => ({ chain: "robinhood" as ChainKey, address: "0x" + (i + 1).toString(16).padStart(40, "0"), symbol: `RH${String(i).padStart(2, "0")}`, name: `Robinhood ${i}`, decimals: 18, logo: null })); +// `grow`: a registry refresh that lands while the route reads prices (one more stock on the chain asked about) +function isolatedRoute(robinhoodRegistry: number | null = 30, baseAdded: BaseStock[] = [], grow = false) { + const calls = { registry: 0, baseRefresh: 0, prices: [] as { chain: string; addresses: string[] }[], issued: [] as string[] }; + // the Base list as the server holds it: the static one plus what Coinbase's registry added + let baseList: readonly BaseStock[] = [...BASE_STOCKS, ...baseAdded]; + let robinhood = robinhoodRegistry === null ? null : Array.from({ length: robinhoodRegistry }, (_, i) => ({ chain: "robinhood" as ChainKey, address: "0x" + (i + 1).toString(16).padStart(40, "0"), symbol: `RH${String(i).padStart(2, "0")}`, name: `Robinhood ${i}`, decimals: 18, logo: null })); const imports: Record = { "next/server": { NextResponse: { json: (body: unknown, options?: ResponseInit) => Response.json(body, options) } }, "@/lib/chainPublic": { isChainKey }, @@ -28,10 +31,14 @@ function isolatedRoute(robinhoodRegistry: number | null = 30) { "@/lib/launchpad/stocksServer": { STOCK_SOURCE, ensureRegistry: async () => { calls.registry++; return robinhood ? { list: robinhood } : null; }, - stockList: (chain: ChainKey) => (chain === "base" ? BASE_STOCKS.map((s) => ({ chain, address: s.address, symbol: s.symbol, name: s.name, decimals: s.decimals, logo: stockTileSvg(s.symbol) })) : chain === "robinhood" ? (robinhood ?? []) : []), + baseStockList: () => baseList, + refreshBaseRegistry: async () => { calls.baseRefresh++; }, + stockList: (chain: ChainKey) => (chain === "base" ? baseList.map((s) => ({ chain, address: s.address, symbol: s.symbol, name: s.name, decimals: s.decimals, logo: stockTileSvg(s.symbol) })) : chain === "robinhood" ? (robinhood ?? []) : []), // like the real one on Base: a dollar price for a stock with a feed, nothing for the rest stockPrices: async (chain: ChainKey, addresses: string[]) => { calls.prices.push({ chain, addresses }); + if (grow && chain === "base") baseList = [...baseList, { symbol: "LATEc", name: "Late Listing", address: "0xb200000000000000000000abcdef0123456789ee", feed: null, decimals: 8 }]; + if (grow && chain === "robinhood" && robinhood) robinhood = [...robinhood, { ...robinhood[0], address: "0x" + "ee".repeat(20), symbol: "LATE" }]; return new Map(chain === "base" ? pricedBaseStocks(addresses).map((s) => [s.address, 100 + s.symbol.length]) : addresses.map((a) => [a, 7])); }, // like the real one: Base only, and only what a supply read found @@ -75,7 +82,8 @@ test("base, empty search: the first page is the 13 priced stocks, and the total assert.equal(s.usd, 100 + s.symbol.length); } assert.deepEqual(calls.prices, [{ chain: "base", addresses: body.stocks.map((s) => s.address) }], "prices are asked for the page shown, not for the registry"); - assert.equal(calls.registry, 0, "the Base list is static: Robinhood's registry is not fetched"); + assert.equal(calls.registry, 0, "Robinhood's registry is not fetched for Base"); + assert.equal(calls.baseRefresh, 1, "the Base list refresh is started (in the background)"); assert.ok(body.fixed.some((q) => q.key === "eth"), "the fixed quotes still come along"); }); @@ -114,7 +122,7 @@ test("arc has no stock registry: no stocks, total 0, nothing looked up", async ( assert.deepEqual(body.stocks, []); assert.equal(body.stocksTotal, 0); assert.equal(body.stocksMatched, 0); - assert.deepEqual(calls, { registry: 0, prices: [], issued: [] }); + assert.deepEqual(calls, { registry: 0, baseRefresh: 0, prices: [], issued: [] }); }); test("robinhood: one page of twelve from its own registry with that registry's total; none when the registry is unavailable", async () => { @@ -153,6 +161,24 @@ test("robinhood: its stocks are never marked issued or not (no supply read there assert.deepEqual(calls.issued, ["robinhood"], "asked, and the real one answers an empty map off Base"); }); +test("base: a stock Coinbase's registry added at run time is found, counted and unpriced", async () => { + const added: BaseStock = { symbol: "NEWc", name: "New Robotics", address: "0xb200000000000000000000abcdef0123456789aa", feed: null, decimals: 8 }; + const { get } = isolatedRoute(30, [added]); + const { body } = await get("chain=base&q=new%20rob"); + assert.deepEqual(body.stocks.map((s) => [s.symbol, s.name, s.usd]), [["NEWc", "New Robotics", null]]); + assert.equal(body.stocksTotal, 147); + assert.equal((await get("chain=base")).body.stocksTotal, 147); +}); + +test("a registry refresh landing mid-request: the total and the matches still describe the one list the search ran on", async () => { + const base = (await isolatedRoute(30, [], true).get("chain=base")).body; + assert.equal(base.stocksTotal, 146); + assert.equal(base.stocksMatched, 146, "an empty search matches the list it counted"); + const rh = (await isolatedRoute(30, [], true).get("chain=robinhood")).body; + assert.equal(rh.stocksTotal, 30); + assert.equal(rh.stocksMatched, 30); +}); + test("an unknown chain is refused", async () => { const { get } = isolatedRoute(); const { response, body } = await get("chain=solana"); diff --git a/app/src/lib/launchpad/stocksServer.ts b/app/src/lib/launchpad/stocksServer.ts index c49571f..f98a292 100644 --- a/app/src/lib/launchpad/stocksServer.ts +++ b/app/src/lib/launchpad/stocksServer.ts @@ -3,15 +3,15 @@ import { maybeDb } from "@/lib/db"; import { b20Client, publicClient } from "@/lib/chain"; import { CHAIN_KEYS, chainKeyOf, type ChainKey } from "@/lib/chainPublic"; import { STOCK_PRICE_URL, STOCK_REGISTRY_URL, parseRegistry, stockUsd, type StockQuote } from "./stocks"; -import { BASE_STOCKS, feedUsd, issuedFromSupply, pricedBaseStocks, stockTileSvg } from "./baseStocks"; +import { BASE_STOCKS, baseRegistryPageUrl, feedUsd, issuedFromSupply, mergeBaseStocks, pricedBaseStocks, readBaseRegistry, stockTileSvg, type BaseStock } from "./baseStocks"; /** * Stock quote assets, server-side only. Two registries, one shape: * - robinhood: Robinhood Stock Tokens from Robinhood's registry (fetched, parsed defensively, * cached 10 min); prices from Robinhood's quote API, cached 30s, only for quotes in use. - * - base: Coinbase tokenized stocks (B20) from the static list in baseStocks.ts (Coinbase's registry); - * prices from Chainlink's on-chain feeds via one multicall, cached 60s. A stock without a feed - * has no USD price and no read is made for it. + * - base: Coinbase tokenized stocks (B20): the static list in baseStocks.ts plus any stock Coinbase's registry + * lists that it lacks (fetched in the background every 10 min; it can only add); prices from Chainlink's + * on-chain feeds via one multicall, cached 60s. A stock without a feed has no USD price and no read is made for it. * An address is a stock ONLY if it is in a registry (never trust on-chain names). * Everything fails soft: on error the last good copy stays; with none, stocks are simply not * offered and existing stock-quoted launches show no USD. @@ -39,8 +39,9 @@ async function fetchRegistry(): Promise { } } -/** Ensure the Robinhood registry copy exists (refreshing in the background when stale). Cheap when warm. */ +/** Ensure the Robinhood registry copy exists (refreshing in the background when stale), and start a Base list refresh if that is stale. Cheap when warm. */ export async function ensureRegistry(): Promise { + void refreshBaseRegistry(); const stale = !registry || Date.now() - registry.at > REGISTRY_TTL; if (!registry) { registryInflight ??= fetchRegistry().finally(() => (registryInflight = null)); @@ -60,13 +61,53 @@ export async function ensureRegistry(): Promise { import { STOCK_SOURCE } from "./config"; export { STOCK_SOURCE, type StockSource } from "./config"; -/** The Base list never changes at run time, so its rows (each with a tile logo) are built once per chain that uses it, not on every call. */ -const baseRows = new Map }>(); +/** + * The Base list: the static one, plus what Coinbase's registry adds (mergeBaseStocks only adds, so a stock never drops out + * while the app runs). Refreshed in the background every 10 min; a request never waits for it, so until the first good + * read the static list answers. A failed read keeps the list and is retried after a minute. + */ +let baseList: readonly BaseStock[] = BASE_STOCKS; +let baseListAt = 0; // last good read; 0 = none yet +let baseListInflight: Promise | null = null; +const BASE_REGISTRY_RETRY = 60_000; + +async function fetchBaseRegistry(): Promise { + try { + const live = await readBaseRegistry(async (pageToken) => { + const res = await fetch(baseRegistryPageUrl(pageToken), { headers: { accept: "application/json", "user-agent": "openlaunch.lol" }, signal: AbortSignal.timeout(8_000), cache: "no-store" }); + if (!res.ok) throw new Error(`base registry ${res.status}`); + return res.json(); + }); + if (live.length === 0) throw new Error("base registry empty"); + const next = mergeBaseStocks(baseList, live); + if (next !== baseList) console.info(`[stocks] coinbase registry added ${next.length - baseList.length} Base stock(s): ${next.filter((s) => !baseList.includes(s)).map((s) => s.symbol).join(", ")}`); + baseList = next; + baseListAt = Date.now(); + } catch (err) { + console.warn("[stocks] coinbase registry fetch failed:", err instanceof Error ? err.message : err); + baseListAt = Date.now() - REGISTRY_TTL + BASE_REGISTRY_RETRY; + } +} + +/** Start a background refresh of the Base list when it is stale (resolves when that read is done; callers need not wait). */ +export function refreshBaseRegistry(): Promise { + if (!baseListInflight && Date.now() - baseListAt >= REGISTRY_TTL) baseListInflight = fetchBaseRegistry().finally(() => (baseListInflight = null)); + return baseListInflight ?? Promise.resolve(); +} + +/** The Base registry rows as they stand (static + added), for search. */ +export function baseStockList(): readonly BaseStock[] { + return baseList; +} + +/** Base rows (each with a tile logo) are built once per stock and rebuilt only when the list grows, not on every call. */ +const baseRows = new Map }>(); function baseStocksFor(chain: ChainKey) { let rows = baseRows.get(chain); - if (!rows) { - const list = BASE_STOCKS.map((s): Stock => ({ chain, address: s.address, symbol: s.symbol, name: s.name, decimals: s.decimals, logo: stockTileSvg(s.symbol), priced: s.feed !== null })); - rows = { list, byAddress: new Map(list.map((s) => [s.address, s])) }; + if (!rows || rows.from !== baseList) { + const built = rows?.byAddress; + const list = baseList.map((s): Stock => built?.get(s.address) ?? { chain, address: s.address, symbol: s.symbol, name: s.name, decimals: s.decimals, logo: stockTileSvg(s.symbol), priced: s.feed !== null }); + rows = { from: baseList, list, byAddress: new Map(list.map((s) => [s.address, s])) }; baseRows.set(chain, rows); } return rows;