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
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+ 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} +Use 0.1–20%.
: null} - {!configured ? ( + {!configured || !tradable ? ( ) : !isConnected ? (= { 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