From 49778b3f398a4ecdbe39ff9e61b614ab353a9064 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 25 Sep 2026 23:25:07 +0800 Subject: [PATCH] fix(launchpad): show launches paired with unlisted ERC-20 quotes properly The factory accepts any ERC-20 as the quote. A quote not on our lists showed as "?", with 18 decimals assumed and no warning (SHEAF/MUSEWORLD on Base). - indexer reads symbol/name/decimals of unlisted quotes into bb_quote_tokens - new quote key "other": token's own symbol, cleaned, and replaced by its address when it is or looks like a listed asset (USDC, ETH, GITLAWB, stocks) - real decimals; trading stays disabled until they are known - "Unlisted pair" badge on token page, list rows and dashboard; trade panel notice with the pair token address; never priced in USD, never in Trending --- app/db/schema.sql | 14 +++ app/src/app/t/[chain]/[token]/page.tsx | 12 +- app/src/components/launchpad/LaunchRow.tsx | 2 + app/src/components/launchpad/MeDashboard.tsx | 3 + app/src/components/launchpad/TradePanel.tsx | 18 ++- .../launchpad/UnlistedPairBadge.tsx | 11 ++ .../launchpad/transaction-safety.test.ts | 18 ++- app/src/lib/launchpad/config.ts | 26 +++-- app/src/lib/launchpad/first-buy.ts | 2 +- app/src/lib/launchpad/indexer.ts | 43 ++++++- app/src/lib/launchpad/native-quote.test.ts | 2 +- app/src/lib/launchpad/queries.ts | 45 ++++++-- app/src/lib/launchpad/unlisted-quote.test.ts | 105 ++++++++++++++++++ app/src/lib/launchpad/unlisted-quote.ts | 72 ++++++++++++ 14 files changed, 343 insertions(+), 30 deletions(-) create mode 100644 app/src/components/launchpad/UnlistedPairBadge.tsx create mode 100644 app/src/lib/launchpad/unlisted-quote.test.ts create mode 100644 app/src/lib/launchpad/unlisted-quote.ts diff --git a/app/db/schema.sql b/app/db/schema.sql index 0f1448f..72f2ef1 100644 --- a/app/db/schema.sql +++ b/app/db/schema.sql @@ -304,3 +304,17 @@ ALTER TABLE bb_launches ALTER COLUMN chain_id DROP DEFAULT; ALTER TABLE bb_launch_swaps ALTER COLUMN chain_id DROP DEFAULT; ALTER TABLE bb_launch_fee_events ALTER COLUMN chain_id DROP DEFAULT; ALTER TABLE bb_launch_meta ALTER COLUMN chain_id DROP DEFAULT; + +-- ── unlisted quote tokens (2026-09-25) ───────────────────────────────────── +-- The factory takes any ERC-20 as the quote. For a quote no list knows, the indexer reads the token's own +-- symbol / name / decimals once and keeps them here (lib/launchpad/unlisted-quote.ts decides what is shown). +-- A NULL field is one the contract did not answer; checked_at spaces out the retries. Addresses LOWERCASE hex. +CREATE TABLE IF NOT EXISTS bb_quote_tokens ( + chain_id integer NOT NULL, + address text NOT NULL, + symbol text, + name text, + decimals integer, + checked_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (chain_id, address) +); diff --git a/app/src/app/t/[chain]/[token]/page.tsx b/app/src/app/t/[chain]/[token]/page.tsx index e9b51a5..f012ad0 100644 --- a/app/src/app/t/[chain]/[token]/page.tsx +++ b/app/src/app/t/[chain]/[token]/page.tsx @@ -21,7 +21,8 @@ import { memo } from "@/lib/launchpad/memo"; import { ago, nowMs } from "@/lib/launchpad/time"; import { getLaunch, getSwaps } from "@/lib/launchpad/queries"; import { ethUsd } from "@/lib/launchpad/ethPrice"; -import { NATIVE, SWAP_SITES, TICK_SPACING, quoteKeyOf, type Quote } from "@/lib/launchpad/config"; +import { NATIVE, SWAP_SITES, TICK_SPACING, type Quote } from "@/lib/launchpad/config"; +import UnlistedPairBadge from "@/components/launchpad/UnlistedPairBadge"; import { GITLAWB_SITE } from "@/lib/launchpad/gitlawb"; import GitlawbBadge from "@/components/launchpad/GitlawbBadge"; import { fmtCompact, fmtPrice, fmtQuote, fmtUsd, pipsToPct } from "@/lib/launchpad/math"; @@ -62,13 +63,16 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s const usd = await ethUsd(); const l = await getLaunch(chain, token, usd); if (!l) notFound(); + // the key the server resolved (a registry stock, an unlisted ERC-20), not the static list's const quote: Quote = { - key: quoteKeyOf(chain, l.quote), + key: l.quote_key, address: l.quote as Address, symbol: l.quote_symbol, decimals: l.quote_decimals, usd: l.quote_usd, + decimalsKnown: l.quote_decimals_known, }; + const unlisted = quote.key === "other"; const stockQuote = quote.key === "stock" ? stockByAddress(chain, l.quote) : null; const [swaps, holders] = await Promise.all([getSwaps(chain, l.token, quote.decimals, 40), memo(`holders:${chain}:${l.token}`, 5_000, () => getHolderPanel(chain, l.token))]); const now = nowMs(); @@ -117,7 +121,7 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s

{l.name}

-
${l.symbol}·{quote.key === "gitlawb" ? : Paired with {quote.symbol}}·Launched {ago(l.block_time, now)} ago
+
${l.symbol}·{quote.key === "gitlawb" ? : Paired with {quote.symbol}}{unlisted ? : null}·Launched {ago(l.block_time, now)} ago
@@ -163,10 +167,12 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s {shortAddr(l.tx_hash)} ↗} /> } /> + {unlisted ? {shortAddr(l.quote)} ↗} /> : null} + {unlisted ?

Paired with {quote.symbol}, a token openlaunch does not list. The launch contracts accept any ERC-20 as the pair; its name here is what its own contract reports, so check the pair token address above. Prices are in {quote.symbol} only, with no USD figure, and this launch is not ranked in Trending.

: null} {stockQuote ?

Paired with {stockQuote.name} ({stockQuote.symbol}), a third-party tokenized stock. These securities are not offered to US persons. The quote asset is identified from the issuer registry, not its token name.

: null} {quote.key === "gitlawb" ?

Paired with GITLAWB, Gitlawb's token{GITLAWB_ORIGIN[chain]}. {mode === "free" ? "This pool has no trading fee." : mode === "burn" ? "Every trading fee on this pool is burned as GITLAWB." : "Trading fees on this pool are paid out in GITLAWB."} USD figures use the Uniswap v4 WETH/GITLAWB pool price on Base.

: null} } diff --git a/app/src/components/launchpad/LaunchRow.tsx b/app/src/components/launchpad/LaunchRow.tsx index b9afa04..04997ec 100644 --- a/app/src/components/launchpad/LaunchRow.tsx +++ b/app/src/components/launchpad/LaunchRow.tsx @@ -9,6 +9,7 @@ import { CHAIN_SHORT } from "@/lib/chainPublic"; import { ago } from "@/lib/launchpad/time"; import ChangeChip from "./ChangeChip"; import GitlawbBadge, { isGitlawbQuote } from "./GitlawbBadge"; +import UnlistedPairBadge from "./UnlistedPairBadge"; import { marketUsd } from "@/lib/launchpad/market-format"; import { capDisplay } from "@/lib/launchpad/market-cap"; import type { LiveTier } from "@/lib/launchpad/ranking"; @@ -55,6 +56,7 @@ export default function LaunchRow({ l, rank, window = "all", hl = null, now, pop
{l.name} {isGitlawbQuote(l.quote_key) ? : null} + {l.quote_key === "other" ? : null} {hl?.kind === "new" ? New : null}
diff --git a/app/src/components/launchpad/MeDashboard.tsx b/app/src/components/launchpad/MeDashboard.tsx index 5923aab..f8ac253 100644 --- a/app/src/components/launchpad/MeDashboard.tsx +++ b/app/src/components/launchpad/MeDashboard.tsx @@ -9,6 +9,7 @@ import type { Address } from "viem"; import TokenAvatar from "./TokenAvatar"; import ChainBadge from "./ChainBadge"; import GitlawbBadge, { isGitlawbQuote } from "./GitlawbBadge"; +import UnlistedPairBadge from "./UnlistedPairBadge"; import FeeChip, { feeModeOf } from "./FeeChip"; import EditTokenSheet from "./EditTokenSheet"; import { toast } from "./TxToasts"; @@ -247,6 +248,7 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine {l.symbol} {isGitlawbQuote(l.quote_key) ? : null} + {l.quote_key === "other" ? : null}
@@ -299,6 +301,7 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine {t.symbol} {isGitlawbQuote(t.quote_key) ? : null} + {t.quote_key === "other" ? : null}
{t.my_buys} buys · {t.my_sells} sells · mc {capDisplay(t.fdv_quote, t.quote_usd, { key: t.quote_key, symbol: t.quote_symbol, decimals: t.quote_decimals }).compact} diff --git a/app/src/components/launchpad/TradePanel.tsx b/app/src/components/launchpad/TradePanel.tsx index fa25dee..eb90e80 100644 --- a/app/src/components/launchpad/TradePanel.tsx +++ b/app/src/components/launchpad/TradePanel.tsx @@ -15,7 +15,7 @@ import { gasReserveInQuote } from "@/lib/launchpad/first-buy"; import { fmtCompact, fmtQuoteUnits, fmtUsd, minOut, units, pipsToPct } from "@/lib/launchpad/math"; import { sanitizeDecimalInput } from "@/lib/launchpad/decimal-input"; import { encodeV4ExactInSingle, type PoolKey } from "@/lib/launchpad/swap"; -import { CHAINS, CHAIN_LABELS, BUILDER_DATA_SUFFIX, explorerTx, type ChainKey } from "@/lib/chainPublic"; +import { CHAINS, CHAIN_LABELS, BUILDER_DATA_SUFFIX, explorerAddress, explorerTx, shortAddr, type ChainKey } from "@/lib/chainPublic"; import { tradeQuoteKey } from "@/lib/launchpad/token-market"; import { SLIPPAGE_PRESETS_BPS, formatSlippageBps, getSlippageBps, getSlippageBpsServer, parseSlippageField, setSlippageBps, subscribeSlippage } from "@/lib/launchpad/trade-slippage"; import { friendlyError } from "@/lib/errors"; @@ -47,6 +47,10 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs const CHAIN_LABEL = CHAIN_LABELS[chain]; const V4 = launchpad(chain).v4; const configured = launchpad(chain).configured; + // An unlisted quote (any ERC-20 the factory was handed) trades like any ERC-20 quote, once its decimals are known: + // until then every amount would be scaled wrong, so the panel shows but does not send. + const unlisted = quote.key === "other"; + const tradable = quote.decimalsKnown !== false; const isNative = quote.address.toLowerCase() === NATIVE; // the native asset, whatever the chain calls it (ETH, or USDC on Arc) // A buy paid from the gas balance (the native asset, or on Arc the USDC quote that is its ERC-20 face) keeps the swap's gas back, // in the quote's own units; a buy paid in any other ERC-20 does not touch the gas balance @@ -117,7 +121,7 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs }, [amountIn, side, poolKey, config, amount, V4.quoter, CHAIN.id, quoteKey]); async function trade() { - if (!address || amountIn === null || !quote_ || quote_.forKey !== quoteKey || busy || insufficient || transactionLock.current) return; + if (!tradable || !address || amountIn === null || !quote_ || quote_.forKey !== quoteKey || busy || insufficient || transactionLock.current) return; // Lock before the first await, including wallet lookup and RPC preflight. transactionLock.current = true; setPhase({ k: "preparing" }); @@ -204,6 +208,12 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs {(["buy", "sell"] as Side[]).map((s) => {s === "buy" ? "Buy" : "Sell"})} + {unlisted ? ( +

+ Unlisted pair. Priced in {quote.symbol} ({shortAddr(quote.address)} ↗), a token openlaunch does not list. Anyone can deploy a token with any name, so check that address before you trade. No USD price is shown. +

+ ) : null} +
@@ -271,9 +281,9 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs {slippageError ? : null} - {!configured ? ( + {!configured || !tradable ? ( ) : !isConnected ? ( diff --git a/app/src/components/launchpad/UnlistedPairBadge.tsx b/app/src/components/launchpad/UnlistedPairBadge.tsx new file mode 100644 index 0000000..4bd4b2c --- /dev/null +++ b/app/src/components/launchpad/UnlistedPairBadge.tsx @@ -0,0 +1,11 @@ +/** + * Marks a launch paired with an ERC-20 no list knows (lib/launchpad/unlisted-quote.ts): the pool is real and indexed, + * but the quote's name is only what its own contract says, and it has no USD price here. + */ +export default function UnlistedPairBadge({ symbol, className = "" }: { symbol: string; className?: string }) { + return ( + + Unlisted pair + + ); +} diff --git a/app/src/components/launchpad/transaction-safety.test.ts b/app/src/components/launchpad/transaction-safety.test.ts index 09fe3a5..a01608e 100644 --- a/app/src/components/launchpad/transaction-safety.test.ts +++ b/app/src/components/launchpad/transaction-safety.test.ts @@ -30,7 +30,7 @@ test("trade locks synchronously during preflight and a rejection allows retry", let rejectWallet!: (error: Error) => void; const walletPending = new Promise((_, reject) => { rejectWallet = reject; }); const trade = handler("./TradePanel.tsx", "trade", { - address: "wallet", amountIn: 1n, quote_: { out: 2n, forKey: "quote" }, quoteKey: "quote", + tradable: true, address: "wallet", amountIn: 1n, quote_: { out: 2n, forKey: "quote" }, quoteKey: "quote", busy: false, insufficient: false, transactionLock, onChain: true, CHAIN: { id: 8453 }, config: {}, getPublicClient: () => ({}), getWalletClient: () => { walletLookups++; return walletPending; }, @@ -55,7 +55,7 @@ test("trade releases the lock when synchronous preflight fails", async () => { const transactionLock = { current: false }; const phases: { k: string }[] = []; const trade = handler("./TradePanel.tsx", "trade", { - address: "wallet", amountIn: 1n, quote_: { out: 2n, forKey: "quote" }, quoteKey: "quote", + tradable: true, address: "wallet", amountIn: 1n, quote_: { out: 2n, forKey: "quote" }, quoteKey: "quote", busy: false, insufficient: false, transactionLock, onChain: true, CHAIN: { id: 8453 }, config: {}, getPublicClient: () => { throw new Error("No RPC client"); }, setPhase: (phase: { k: string }) => phases.push(phase), friendlyError: (error: Error) => error.message, @@ -65,6 +65,20 @@ test("trade releases the lock when synchronous preflight fails", async () => { assert.deepEqual(phases.map((phase) => phase.k), ["preparing", "error"]); }); +test("trade never starts while an unlisted pair token's decimals are unknown", async () => { + const transactionLock = { current: false }; + const phases: { k: string }[] = []; + const trade = handler("./TradePanel.tsx", "trade", { + tradable: false, address: "wallet", amountIn: 1n, quote_: { out: 2n, forKey: "quote" }, quoteKey: "quote", + busy: false, insufficient: false, transactionLock, onChain: true, CHAIN: { id: 8453 }, config: {}, + getPublicClient: () => { throw new Error("must not be reached"); }, + setPhase: (phase: { k: string }) => phases.push(phase), friendlyError: (error: Error) => error.message, + }); + await trade(); + assert.deepEqual(phases, [], "an amount parsed with placeholder decimals is never sent"); + assert.equal(transactionLock.current, false); +}); + for (const status of ["reverted", "success"]) { test(`fee-panel collect and claim handle a ${status} receipt honestly`, async () => { for (const what of ["collect", "claim"]) { diff --git a/app/src/lib/launchpad/config.ts b/app/src/lib/launchpad/config.ts index 56c8c8e..e66aa1f 100644 --- a/app/src/lib/launchpad/config.ts +++ b/app/src/lib/launchpad/config.ts @@ -1,5 +1,6 @@ import { isAddress, type Address } from "viem"; import { CHAIN_KEYS, SITE_URL, isChainKey, type ChainKey } from "@/lib/chainPublic"; +import { unlistedQuote } from "./unlisted-quote"; import { GITLAWB_ADDRESS, GITLAWB_ADDRESS_ROBINHOOD, GITLAWB_DECIMALS, GITLAWB_LOGO_PATH, GITLAWB_NAME, GITLAWB_SYMBOL } from "./gitlawb"; /** @@ -16,7 +17,11 @@ export const BPS = 10_000; /** Locker limit on beneficiaries per launch (LaunchLocker.MAX_RECIPIENTS); recipients.test.ts keeps the two in sync. */ export const MAX_RECIPIENTS = 7; -export type Quote = { key: "eth" | "usdg" | "usdc" | "gitlawb" | "stock"; address: Address; symbol: string; decimals: number; usd: number | null /* fixed USD price (stables); live for stocks + GITLAWB (server-filled) */; name?: string; logo?: string | null }; +/** + * key "stock" = a registry stock; "other" = an ERC-20 no list knows (an unlisted pair, unlisted-quote.ts). `decimalsKnown` + * is false only for an unlisted quote whose decimals have not been read yet: its 18 is a placeholder and it must not trade. + */ +export type Quote = { key: "eth" | "usdg" | "usdc" | "gitlawb" | "stock" | "other"; address: Address; symbol: string; decimals: number; usd: number | null /* fixed USD price (stables); live for stocks + GITLAWB (server-filled) */; name?: string; logo?: string | null; decimalsKnown?: boolean }; export type V4 = { poolManager: Address; positionManager: Address; stateView: Address; quoter: Address; universalRouter: Address; permit2: Address; swapLayout: "v1" | "v2" }; export type ChainLaunchpad = { key: ChainKey; factory: Address | null; locker: Address | null; v4: V4; quotes: Quote[]; configured: boolean }; @@ -80,6 +85,10 @@ export function fixedUsdQuotes(): { chain: ChainKey; key: Quote["key"]; address: export function quotesWithKey(key: Quote["key"]): { chain: ChainKey; address: string }[] { return CHAIN_KEYS.flatMap((chain) => allQuotes(chain).flatMap((q) => (q.key === key ? [{ chain, address: q.address.toLowerCase() }] : []))); } +/** Lowercase addresses of every quote a chain lists (offered + native); anything else is unlisted, or a registry stock. */ +export function listedQuoteAddresses(chain: ChainKey): string[] { + return allQuotes(chain).map((q) => q.address.toLowerCase()); +} /** The chain's offered quotes plus its native asset (already among them where the form offers it). */ function allQuotes(chain: ChainKey): Quote[] { const offered = QUOTES_BY_CHAIN[chain]; @@ -169,16 +178,17 @@ export function visibleChainOr(v: unknown): ChainKey | null { } /** - * Quote metadata for an on-chain quote address. Unknown ERC20 → generic 18-dec, symbol "?", NEVER priced - * (key "stock" = "some ERC20": Permit2 path, no USD). It must not fall back to "eth": an unknown quote - * priced at ETH once inflated site volume ~2× (NVDA-quoted launches before the stock registry shipped). + * Quote metadata for an on-chain quote address. Unknown ERC20 → an unlisted quote (key "other", labelled by its + * address, placeholder 18 decimals, NEVER priced); the server upgrades it to a registry stock or to the token's own + * symbol and decimals (queries.ts). It must not fall back to "eth": an unknown quote priced at ETH once inflated site + * volume ~2× (NVDA-quoted launches before the stock registry shipped). */ export function quoteInfo(key: ChainKey, address: string): Quote { const a = address.toLowerCase(); - return allQuotes(key).find((q) => q.address.toLowerCase() === a) ?? { key: "stock", address: address as Address, symbol: "?", decimals: 18, usd: null }; + return allQuotes(key).find((q) => q.address.toLowerCase() === a) ?? unlistedQuote(address, null); } -/** Quote key for a stored quote address: a fixed quote's key, else "stock" (= some ERC20, priced only if a registry knows it). */ +/** Quote key for a stored quote address: a fixed quote's key, else "other" (the server may still find it in a stock registry). */ export function quoteKeyOf(key: ChainKey, address: string): Quote["key"] { return quoteInfo(key, address).key; } @@ -193,9 +203,9 @@ export const FEE_PRESETS = [ ] as const; /** Starting market cap presets per quote (fully diluted, in quote units). */ -export const MCAP_PRESETS: Record = { eth: [1, 5, 10, 25], usdg: [5_000, 10_000, 25_000, 100_000], usdc: [5_000, 10_000, 25_000, 100_000], gitlawb: [] /* derived from the live price */, stock: [] /* derived from the live price */ }; +export const MCAP_PRESETS: Record = { eth: [1, 5, 10, 25], usdg: [5_000, 10_000, 25_000, 100_000], usdc: [5_000, 10_000, 25_000, 100_000], gitlawb: [] /* derived from the live price */, stock: [] /* derived from the live price */, other: [] /* never offered */ }; /** Buy amount presets per quote. */ -export const BUY_PRESETS: Record = { eth: ["0.01", "0.05", "0.1", "0.5"], usdg: ["5", "25", "100", "500"], usdc: ["5", "25", "100", "500"], gitlawb: ["100000", "500000", "1000000", "5000000"], stock: ["0.1", "0.5", "1", "5"] }; +export const BUY_PRESETS: Record = { eth: ["0.01", "0.05", "0.1", "0.5"], usdg: ["5", "25", "100", "500"], usdc: ["5", "25", "100", "500"], gitlawb: ["100000", "500000", "1000000", "5000000"], stock: ["0.1", "0.5", "1", "5"], other: [] /* unknown token, unknown scale */ }; // NEXT_PUBLIC_* must be read as literal `process.env.X` expressions: Next inlines them at build time. const DEV_RPC: Record = { diff --git a/app/src/lib/launchpad/first-buy.ts b/app/src/lib/launchpad/first-buy.ts index aa987c7..f4ca46a 100644 --- a/app/src/lib/launchpad/first-buy.ts +++ b/app/src/lib/launchpad/first-buy.ts @@ -16,7 +16,7 @@ import type { Quote } from "./config"; export const SUGGESTED_BUY_USD = 25; /** Quick-pick amounts per quote; the first one is the suggestion (about $25 at the prices this was written at). */ -export const BUY_PRESETS: Record = { eth: ["0.01", "0.05", "0.1", "0.25"], usdg: ["25", "100", "250"], usdc: ["25", "100", "250"], gitlawb: ["500000", "1000000", "5000000"], stock: [] }; +export const BUY_PRESETS: Record = { eth: ["0.01", "0.05", "0.1", "0.25"], usdg: ["25", "100", "250"], usdc: ["25", "100", "250"], gitlawb: ["500000", "1000000", "5000000"], stock: [], other: [] /* the form never offers an unlisted quote */ }; export type NoSuggestion = "no-price" | "unknown-balance" | "insufficient" | "no-gas" | "declined"; /** `provisional`: shown as selected but not yet confirmed against a balance (no wallet yet, or the read is pending). */ diff --git a/app/src/lib/launchpad/indexer.ts b/app/src/lib/launchpad/indexer.ts index 28b3c4e..eaa02ea 100644 --- a/app/src/lib/launchpad/indexer.ts +++ b/app/src/lib/launchpad/indexer.ts @@ -4,7 +4,8 @@ import { publicClient } from "@/lib/chain"; import { chainIdOf, type ChainKey } from "@/lib/chainPublic"; import { maybeDb, errMessage, type Db } from "@/lib/db"; import { LAUNCH_FACTORY_ABI, LAUNCH_LOCKER_ABI, POOL_MANAGER_ABI, ERC20_MIN_ABI, ERC20_TRANSFER_EVENT, LAUNCHED_EVENT, POOL_SWAP_EVENT, LOCKER_EVENTS } from "./abi"; -import { CONFIGURED_CHAINS, launchpad } from "./config"; +import { CONFIGURED_CHAINS, launchpad, listedQuoteAddresses } from "./config"; +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 { fetchLogsSplit, isRangeTooLarge } from "./log-range"; @@ -475,6 +476,7 @@ export async function pollAll(): Promise> { 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 backfillHolders(c).catch((e) => console.warn(`[launch-sync] holders backfill ${c}:`, errMessage(e))); }), @@ -527,6 +529,45 @@ export async function healLaunchReads(chain: ChainKey): Promise { return healed; } +/** + * Read symbol / name / decimals of quotes no list knows (unlisted pairs, lib/launchpad/unlisted-quote.ts) into + * bb_quote_tokens. New quotes first; a quote whose decimals did not answer is retried every 10 minutes. The raw + * symbol is stored: what may be shown is decided at read time, so a change to the reserved list applies to old rows. + */ +export async function healQuoteTokens(chain: ChainKey): Promise { + const db = maybeDb(); + if (!db || skipReason(chain)) return 0; + const cid = chainIdOf(chain); + const rows = await db<{ quote: string }[]>` + SELECT l.quote FROM bb_launches l + LEFT JOIN bb_quote_tokens q ON q.chain_id = l.chain_id AND q.address = l.quote + WHERE l.chain_id = ${cid} AND l.quote <> ALL(${listedQuoteAddresses(chain)}) + AND (q.address IS NULL OR (q.decimals IS NULL AND q.checked_at < now() - interval '10 minutes')) + GROUP BY l.quote, q.address ORDER BY (q.address IS NULL) DESC, max(l.block_number) DESC LIMIT 10`; + if (rows.length === 0) return 0; + const client = publicClient(chain); + let resolved = 0; + for (const r of rows) { + const address = r.quote as Address; + const read = (functionName: "symbol" | "name" | "decimals") => client.readContract({ address, abi: ERC20_MIN_ABI, functionName }).then((v) => v as T).catch(() => null); + const [symbol, name, decimals] = await Promise.all([read("symbol"), read("name"), read("decimals")]); + const d = decimals !== null && validDecimals(Number(decimals)) ? Number(decimals) : null; + // Postgres text refuses NUL, and a token picks its own strings: keep printable characters only + const text = (v: string | null) => (v === null ? null : v.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 64)); + await db` + INSERT INTO bb_quote_tokens (chain_id, address, symbol, name, decimals, checked_at) + VALUES (${cid}, ${r.quote}, ${text(symbol)}, ${text(name)}, ${d}, now()) + ON CONFLICT (chain_id, address) DO UPDATE SET + symbol = COALESCE(EXCLUDED.symbol, bb_quote_tokens.symbol), + name = COALESCE(EXCLUDED.name, bb_quote_tokens.name), + decimals = COALESCE(EXCLUDED.decimals, bb_quote_tokens.decimals), + checked_at = now()`; + if (d !== null) resolved++; + } + if (resolved) console.log(`[launch-sync] read ${resolved} unlisted quote token(s) on ${chain}`); + return resolved; +} + /** * Self-heal swaps stored without a trader (the sender lookup failed at index time, typically a rate-limited * node during a busy launch block). Those rows carry no wallet facts: the holder panel skips them and the diff --git a/app/src/lib/launchpad/native-quote.test.ts b/app/src/lib/launchpad/native-quote.test.ts index 36e86ff..5bbd382 100644 --- a/app/src/lib/launchpad/native-quote.test.ts +++ b/app/src/lib/launchpad/native-quote.test.ts @@ -31,7 +31,7 @@ test("USD per unit: ETH's live price on ETH chains, one dollar for Arc's native assert.equal(quoteUsdOf(quoteInfo("arc", NATIVE), ethUsd), 1, "never the ETH price on Arc"); assert.equal(quoteUsdOf(quoteInfo("arc", "0x3600000000000000000000000000000000000000"), ethUsd), 1, "the ERC-20 face of the same USDC"); const stray = quoteInfo("arc", "0x000000000000000000000000000000000000dEaD"); - assert.equal(stray.key, "stock"); + assert.equal(stray.key, "other", "an unlisted pair, never a stock and never ETH"); assert.equal(quoteUsdOf(stray, ethUsd), null, "an unknown ERC-20 is never priced"); }); diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts index 5d9eefc..166d0f0 100644 --- a/app/src/lib/launchpad/queries.ts +++ b/app/src/lib/launchpad/queries.ts @@ -3,7 +3,9 @@ import { cache } from "react"; import { maybeDb } from "@/lib/db"; import { CHAIN_KEYS, DEFAULT_CHAIN, chainIdOf, chainKeyOf, type ChainKey } from "@/lib/chainPublic"; import { quoteInfo as staticQuoteInfo, quoteUsdOf, type Quote, NATIVE_QUOTES, fixedUsdQuotes, quotesWithKey } from "./config"; -import { ensureRegistry, stockByAddress, stockUsdInUse } from "./stocksServer"; +import { ensureRegistry, stockByAddress, stockList, stockUsdInUse } from "./stocksServer"; +import { unlistedQuote, type QuoteTokenMeta } from "./unlisted-quote"; +import { memo } from "./memo"; import { gitlawbUsd } from "./gitlawbServer"; import { canonicalImageUrl } from "./images"; import { GRACE_HOURS, LIVE_WINDOW_HOURS, rankTrending, type LiveTier } from "./ranking"; @@ -27,9 +29,10 @@ export type LaunchRow = { token_id: number; launcher: string; quote: string; - quote_key: Quote["key"]; // eth | usdg | gitlawb | stock (= any other ERC20) + quote_key: Quote["key"]; // eth | usdg | usdc | gitlawb | stock (a registry stock) | other (an unlisted ERC-20, unlisted-quote.ts) quote_symbol: string; quote_decimals: number; + quote_decimals_known: boolean; // false only for an unlisted quote not read yet: quote_decimals is a placeholder, do not trade pool_id: string; start_tick: number; lp_fee: number; @@ -78,7 +81,7 @@ export type LaunchRow = { volume_24h_usd: number | null; }; -type Raw = Omit & { +type Raw = Omit & { token_id: bigint; block_number: bigint; tick: number | null; @@ -96,16 +99,28 @@ const TIERS: LiveTier[] = ["live", "new", "quiet"]; // index = the live_tier CAS /** Stock USD prices for this request (filled by `withStocks`). */ let stockUsdNow = new Map(); +/** Unlisted quotes' on-chain symbol / decimals (bb_quote_tokens, keyed `${chain_id}:${address}`), and each chain's stock tickers they may not borrow. */ +let quoteTokensNow = new Map(); +let stockSymbolsNow = new Map(); /** GITLAWB USD for this request (filled by `withStocks`; null = unknown → no USD, 0 weight in USD sorts). */ let gitlawbUsdNow: number | null = null; -/** Server-side quote resolution: static ETH/USDG/GITLAWB (GITLAWB gets the live price), else a registry stock, else unknown. */ +/** Server-side quote resolution: static ETH/USDG/GITLAWB (GITLAWB gets the live price), else a registry stock, else an unlisted quote. */ function quoteInfo(chain: ChainKey, address: string): Quote { const q = staticQuoteInfo(chain, address); if (q.key === "gitlawb") return { ...q, usd: gitlawbUsdNow }; - if (q.symbol !== "?") return q; + if (q.key !== "other") return q; const st = stockByAddress(chain, address); - return st ? { key: "stock", address: st.address as Quote["address"], symbol: st.symbol, decimals: st.decimals, usd: stockUsdNow.get(st.address) ?? null, name: st.name, logo: st.logo } : q; + if (st) return { key: "stock", address: st.address as Quote["address"], symbol: st.symbol, decimals: st.decimals, usd: stockUsdNow.get(st.address) ?? null, name: st.name, logo: st.logo }; + return unlistedQuote(address, quoteTokensNow.get(`${chainIdOf(chain)}:${address.toLowerCase()}`) ?? null, stockSymbolsNow.get(chain) ?? []); +} + +/** bb_quote_tokens is tiny (one row per unlisted quote) and changes only when the indexer reads a new one. */ +async function loadQuoteTokens(): Promise> { + const db = maybeDb(); + if (!db) return new Map(); + const rows = await db<{ chain_id: number; address: string; symbol: string | null; name: string | null; decimals: number | null }[]>`SELECT chain_id, address, symbol, name, decimals FROM bb_quote_tokens`; + return new Map(rows.map((r) => [`${r.chain_id}:${r.address}`, { symbol: r.symbol, name: r.name, decimals: r.decimals }])); } /** Warm the stock registry + prices for the stocks in use (and the GITLAWB price), so `shape()` can stay synchronous. */ @@ -117,6 +132,12 @@ async function withStocks(): Promise { } catch { /* fail soft: stocks show without USD */ } + stockSymbolsNow = new Map(CHAIN_KEYS.map((k) => [k, stockList(k).map((s) => s.symbol)])); + try { + quoteTokensNow = await memo("quote-tokens", 30_000, loadQuoteTokens); + } catch { + /* fail soft (table not migrated yet, db blip): unlisted quotes show by address and do not trade */ + } } function quoteUsd(q: Quote, ethUsd: number | null): number | null { @@ -142,6 +163,7 @@ function shape(raw: Raw & { last_swap_block?: bigint; last_swap_log?: number; lo quote_key: q.key, quote_symbol: q.symbol, quote_decimals: q.decimals, + quote_decimals_known: q.decimalsKnown !== false, token_id: Number(r.token_id), block_number: Number(r.block_number), tick, @@ -428,8 +450,8 @@ export async function getLaunchTotals(ethUsd: number | null = null): Promise 0n || BigInt(r.burned) > 0n || BigInt(r.creators) > 0n)) t.usd_partial = true; + // an unlisted ERC-20 (key "other") is unpriced by design and is left out of the USD figures silently, as always + if (usd === null && q.key !== "other" && (BigInt(r.volume) > 0n || BigInt(r.burned) > 0n || BigInt(r.creators) > 0n)) t.usd_partial = true; t.launches += Number(r.launches); t.trades += Number(r.trades); t.volume_usd += units(r.volume, q.decimals) * qu; @@ -597,9 +619,12 @@ export type TrendingSnap = { window: "1h" | "24h"; items: LaunchRow[] }; export const TRENDING_CANDIDATES = 40; -/** "Hot right now" from rows already fetched: the first page of the live sort (all chains, no filter) is the candidate set. */ +/** + * "Hot right now" from rows already fetched: the first page of the live sort (all chains, no filter) is the candidate set. + * Unlisted pairs stay in the lists (with their badge) but are never promoted here. + */ export function trendingFrom(rows: LaunchRow[]): TrendingSnap { - return rankTrending(rows, Date.now()); + return rankTrending(rows.filter((r) => r.quote_key !== "other"), Date.now()); } /** True when a list request's first page doubles as the trending candidate set, so callers can skip `getTrending`. */ diff --git a/app/src/lib/launchpad/unlisted-quote.test.ts b/app/src/lib/launchpad/unlisted-quote.test.ts new file mode 100644 index 0000000..476bfe3 --- /dev/null +++ b/app/src/lib/launchpad/unlisted-quote.test.ts @@ -0,0 +1,105 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { NATIVE, listedQuoteAddresses, quoteInfo, quoteUsdOf } from "./config.ts"; +import { MAX_QUOTE_DECIMALS, addressLabel, cleanQuoteSymbol, unlistedQuote, validDecimals } from "./unlisted-quote.ts"; +import { capDisplay } from "./market-cap.ts"; +import { fdvQuote } from "./math.ts"; + +/** + * The factory takes any ERC-20 as the quote (2026-09-25: SHEAF on Base, paired with MUSEWORLD). Such a pair is shown + * as unlisted: the token's own symbol when it is safe to show, its real decimals, never a USD price, never trending. + */ +const MUSEWORLD = "0x882c8e35504d58979b1280fe76d6553f717029f8"; +const src = (rel: string) => readFileSync(path.join(import.meta.dirname, rel), "utf8"); + +test("an address no list knows is an unlisted quote: key other, labelled by address, never priced", () => { + const q = quoteInfo("base", MUSEWORLD); + assert.equal(q.key, "other"); + assert.equal(q.symbol, "0x882c…29f8"); + assert.equal(q.decimalsKnown, false, "not read yet: 18 is a placeholder"); + assert.equal(quoteUsdOf(q, 4_000), null, "never priced, above all never at the ETH price"); + assert.ok(!listedQuoteAddresses("base").includes(MUSEWORLD)); + assert.ok(listedQuoteAddresses("base").includes(NATIVE), "the native asset is listed, so the indexer never reads address(0)"); +}); + +test("the on-chain symbol and decimals are used once read", () => { + const q = unlistedQuote(MUSEWORLD, { symbol: "MUSEWORLD", name: "MUSWORLD", decimals: 18 }); + assert.deepEqual([q.key, q.symbol, q.decimals, q.decimalsKnown, q.usd], ["other", "MUSEWORLD", 18, true, null]); +}); + +test("a 6-decimal unlisted quote prices with 6 decimals, not the 18 placeholder (10^12 apart)", () => { + const q = unlistedQuote(MUSEWORLD, { symbol: "MEME", name: null, decimals: 6 }); + assert.equal(q.decimals, 6); + // 1 MEME per token: sqrtPriceX96 for price (currency1 per currency0 raw) = 1e18 / 1e6 = 1e12 → sqrt = 1e6 · 2^96 + const sqrt = 10n ** 6n * 2n ** 96n; + const cap = fdvQuote(sqrt, 10n ** 27n, q.decimals); + assert.ok(Math.abs(cap - 1e9) / 1e9 < 1e-9, `1B supply at 1 MEME = 1B MEME, got ${cap}`); + assert.match(capDisplay(cap, null, q).compact, /MEME · no USD price$/); +}); + +test("unreadable or absurd decimals stay unknown", () => { + for (const d of [null, -1, 1.5, MAX_QUOTE_DECIMALS + 1, 255, Number.NaN]) { + assert.equal(validDecimals(d), false, String(d)); + assert.equal(unlistedQuote(MUSEWORLD, { symbol: "X", name: null, decimals: d as number | null }).decimalsKnown, false, String(d)); + } + for (const d of [0, 6, 8, 18, MAX_QUOTE_DECIMALS]) assert.equal(validDecimals(d), true, String(d)); +}); + +test("a symbol that is, or dresses up as, a listed asset is replaced by the address", () => { + for (const fake of ["USDC", "usdc", "$ETH", "WETH", "USDC.e", "USD+", "USDbC", "cbBTC", "GITLAWB", "gitlawb2", "U​SDC", " E T H "]) { + assert.equal(cleanQuoteSymbol(fake), null, fake); + assert.equal(unlistedQuote(MUSEWORLD, { symbol: fake, name: null, decimals: 6 }).symbol, addressLabel(MUSEWORLD), fake); + } + assert.equal(cleanQuoteSymbol("NVDAx", ["NVDAx"]), null, "the chain's stock tickers are reserved too"); +}); + +test("look-alike letters from other scripts cannot spell a listed symbol", () => { + // Cyrillic С (U+0421) and Greek Τ/Η: stripped, never shown as the ASCII look-alike + assert.notEqual(cleanQuoteSymbol("USDС"), "USDC"); + assert.equal(cleanQuoteSymbol("EΤΗ"), "E"); + assert.equal(cleanQuoteSymbol("ЕΤΗ"), null, "nothing ASCII left"); +}); + +test("symbols are cleaned: ASCII word characters only, bounded, no control or markup", () => { + assert.equal(cleanQuoteSymbol("MUSEWORLD"), "MUSEWORLD"); + assert.equal(cleanQuoteSymbol("$PEPE"), "PEPE"); + assert.equal(cleanQuoteSymbol("DOGE"), "bDOGEb"); + assert.equal(cleanQuoteSymbol("A\u0000B\nC"), "ABC"); + assert.equal(cleanQuoteSymbol("ABCDEFGHIJKLMNOPQRST")?.length, 12); + assert.equal(cleanQuoteSymbol(""), null); + assert.equal(cleanQuoteSymbol(null), null); + assert.equal(cleanQuoteSymbol("🚀🚀"), null); +}); + +test("the server resolves unlisted quotes from bb_quote_tokens, fails soft, and keeps them out of Trending", () => { + const q = src("queries.ts"); + assert.match(q, /return unlistedQuote\(address, quoteTokensNow\.get\(`\$\{chainIdOf\(chain\)\}:\$\{address\.toLowerCase\(\)\}`\)/); + assert.match(q, /quoteTokensNow = await memo\("quote-tokens"/); + assert.match(q, /\/\* fail soft \(table not migrated yet/); + assert.match(q, /rankTrending\(rows\.filter\(\(r\) => r\.quote_key !== "other"\)/); + assert.match(q, /quote_decimals_known: q\.decimalsKnown !== false/); + assert.match(q, /usd === null && q\.key !== "other"/, "unlisted quotes do not flag the site USD totals as partial"); +}); + +test("the indexer reads unlisted quotes into bb_quote_tokens with printable text only", () => { + const i = src("indexer.ts"); + assert.match(i, /await healQuoteTokens\(c\)\.catch/); + assert.match(i, /l\.quote <> ALL\(\$\{listedQuoteAddresses\(chain\)\}\)/); + assert.match(i, /replace\(\/\[\\u0000-\\u001f\\u007f\]\/g, ""\)/, "Postgres text refuses NUL; a token picks its own strings"); + const schema = readFileSync(path.join(import.meta.dirname, "../../../db/schema.sql"), "utf8"); + assert.match(schema, /CREATE TABLE IF NOT EXISTS bb_quote_tokens \(/); +}); + +test("the token page and trade panel mark the pair and refuse to trade unknown decimals", () => { + const page = src("../../app/t/[chain]/[token]/page.tsx"); + assert.match(page, /key: l\.quote_key,/, "the server's key, not the static list's"); + assert.match(page, /decimalsKnown: l\.quote_decimals_known/); + assert.match(page, //); + const panel = src("../../components/launchpad/TradePanel.tsx"); + assert.match(panel, /const tradable = quote\.decimalsKnown !== false;/); + assert.match(panel, /if \(!tradable \|\| !address/); + assert.match(panel, /\{!configured \|\| !tradable \?/); + assert.match(panel, /Unlisted pair\.<\/b>/); +}); diff --git a/app/src/lib/launchpad/unlisted-quote.ts b/app/src/lib/launchpad/unlisted-quote.ts new file mode 100644 index 0000000..fc0adb5 --- /dev/null +++ b/app/src/lib/launchpad/unlisted-quote.ts @@ -0,0 +1,72 @@ +import type { Address } from "viem"; +import type { Quote } from "./config"; + +/** + * Quotes the site does not list. The factory is permissionless: a launch can pair with any ERC-20, not only the + * quotes the form offers (config.ts) or a stock registry knows. Such a pool is indexed and shown, but as an + * UNLISTED pair: key "other", never priced in USD, never trending, and its symbol is only a label. + * + * The label is the token's own on-chain symbol, read once by the indexer (bb_quote_tokens). Anyone can deploy a + * token named "USDC", so a symbol that is or looks like one we list is replaced by the address; so is one we could + * not read. Decimals come from the chain too; until they are known the page shows the pool but does not trade it, + * since every amount would otherwise be off by the difference (a 6-decimal quote read as 18 is off by 10^12). + * Pure, node --test loads it directly. + */ + +/** Symbols an unlisted quote may never show as: the quotes we list anywhere, and the assets people take them for. */ +export const RESERVED_QUOTE_SYMBOLS = ["ETH", "WETH", "CBETH", "USDC", "USDG", "USDT", "USDBC", "DAI", "EURC", "BTC", "WBTC", "CBBTC", "GITLAWB"]; +/** Any symbol starting with these reads as a dollar or as our own token ("USDC.e", "USD+", "GITLAWB2"). */ +const RESERVED_PREFIXES = ["USD", "GITLAWB"]; + +export const UNLISTED_SYMBOL_MAX = 12; +/** Beyond this, 10^decimals leaves double precision and the tick maths is meaningless. */ +export const MAX_QUOTE_DECIMALS = 36; + +/** What the indexer read from the quote's contract: null for a field the contract did not answer. */ +export type QuoteTokenMeta = { symbol: string | null; name: string | null; decimals: number | null }; + +function comparable(s: string): string { + return s.toUpperCase().replace(/[^A-Z0-9]/g, ""); +} + +/** + * The on-chain symbol as a display label, or null when it must not be shown: empty after cleaning, or the same as + * (or dressed up as) a listed asset. Only ASCII letters, digits and . _ - survive, so look-alike letters from other + * scripts (Cyrillic "С", full-width "U") cannot spell a listed symbol; NFKC first folds full-width to plain ASCII, + * which the reserved check then catches. `extraReserved` is the chain's stock tickers. + */ +export function cleanQuoteSymbol(raw: string | null | undefined, extraReserved: Iterable = []): string | null { + if (typeof raw !== "string") return null; + const s = raw.normalize("NFKC").replace(/^\$+/, "").replace(/[^A-Za-z0-9._-]/g, "").slice(0, UNLISTED_SYMBOL_MAX); + const c = comparable(s); + if (!c) return null; + const reserved = new Set([...RESERVED_QUOTE_SYMBOLS, ...[...extraReserved].map(comparable)]); + if (reserved.has(c) || RESERVED_PREFIXES.some((p) => c.startsWith(p))) return null; + return s; +} + +export function validDecimals(d: unknown): d is number { + return typeof d === "number" && Number.isInteger(d) && d >= 0 && d <= MAX_QUOTE_DECIMALS; +} + +/** "0x882c…29f8": what an unlisted quote is called when its own symbol cannot be shown. */ +export function addressLabel(address: string): string { + const a = address.toLowerCase(); + return `${a.slice(0, 6)}…${a.slice(-4)}`; +} + +/** + * The Quote for an address no list knows. Without readable decimals it keeps the 18-decimal placeholder (the common + * case, so the page stays roughly right) and says so with `decimalsKnown: false`; callers must not trade it. + */ +export function unlistedQuote(address: string, meta: QuoteTokenMeta | null, extraReserved: Iterable = []): Quote { + const known = validDecimals(meta?.decimals); + return { + key: "other", + address: address as Address, + symbol: cleanQuoteSymbol(meta?.symbol, extraReserved) ?? addressLabel(address), + decimals: known ? meta!.decimals! : 18, + usd: null, + decimalsKnown: known, + }; +}