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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
12 changes: 9 additions & 3 deletions app/src/app/t/[chain]/[token]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -117,7 +121,7 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s
<TokenAvatar chain={l.chain} token={l.token} symbol={l.symbol} image={l.image_url} size={56} className="shrink-0 rounded-2xl" />
<div className="min-w-0">
<h1 className="break-words font-display text-2xl font-bold tracking-[-0.03em] text-ink sm:text-3xl">{l.name}</h1>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted"><span className="font-mono text-body">${l.symbol}</span><span aria-hidden>·</span>{quote.key === "gitlawb" ? <GitlawbBadge label="Paired with GITLAWB" /> : <span>Paired with {quote.symbol}</span>}<span aria-hidden>·</span><span title={new Date(l.block_time).toUTCString()}>Launched {ago(l.block_time, now)} ago</span></div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted"><span className="font-mono text-body">${l.symbol}</span><span aria-hidden>·</span>{quote.key === "gitlawb" ? <GitlawbBadge label="Paired with GITLAWB" /> : <span>Paired with {quote.symbol}</span>}{unlisted ? <UnlistedPairBadge symbol={quote.symbol} /> : null}<span aria-hidden>·</span><span title={new Date(l.block_time).toUTCString()}>Launched {ago(l.block_time, now)} ago</span></div>
</div>
</div>
<div className="flex max-w-full flex-wrap items-center gap-2">
Expand Down Expand Up @@ -163,10 +167,12 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s
<Row k="Launch transaction" v={<A href={explorerTx(chain, l.tx_hash)}>{shortAddr(l.tx_hash)} ↗</A>} />
<Row k="Pool ID" v={<CopyChip value={l.pool_id} />} />
<Row k="Market" v={`${quote.symbol} / ${l.symbol} · Uniswap v4 · no hook`} />
{unlisted ? <Row k="Pair token" v={<A href={explorerAddress(chain, l.quote)}>{shortAddr(l.quote)} ↗</A>} /> : null}
<Row k="Trading fee" v={feeRoute} />
<Row k="Fixed supply" v={`${supplyLabel} ${l.symbol}`} />
<Row k="Launched" v={new Date(l.block_time).toUTCString().replace(" GMT", " UTC")} />
</dl>
{unlisted ? <p className="mt-4 text-xs leading-relaxed text-muted text-pretty">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.</p> : null}
{stockQuote ? <p className="mt-4 text-xs leading-relaxed text-muted text-pretty">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.</p> : null}
{quote.key === "gitlawb" ? <p className="mt-4 text-xs leading-relaxed text-muted text-pretty">Paired with GITLAWB, <a href={GITLAWB_SITE} target="_blank" rel="noreferrer" className="underline decoration-line underline-offset-2 hover:text-ink">Gitlawb</a>&apos;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.</p> : null}
</section>}
Expand Down
2 changes: 2 additions & 0 deletions app/src/components/launchpad/LaunchRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -55,6 +56,7 @@ export default function LaunchRow({ l, rank, window = "all", hl = null, now, pop
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold text-ink">{l.name}</span>
{isGitlawbQuote(l.quote_key) ? <GitlawbBadge /> : null}
{l.quote_key === "other" ? <UnlistedPairBadge symbol={l.quote_symbol} className="shrink-0" /> : null}
{hl?.kind === "new" ? <span className="shrink-0 text-[10px] font-medium text-brand">New</span> : null}
</div>
<div className="mt-0.5 truncate text-[11px] text-muted" title={`${l.symbol} · ${CHAIN_SHORT[l.chain]} · paired with ${l.quote_symbol}`}>
Expand Down
3 changes: 3 additions & 0 deletions app/src/components/launchpad/MeDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -247,6 +248,7 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine
<span className="font-mono text-xs text-muted">{l.symbol}</span>
<ChainBadge chain={l.chain} />
{isGitlawbQuote(l.quote_key) ? <GitlawbBadge /> : null}
{l.quote_key === "other" ? <UnlistedPairBadge symbol={l.quote_symbol} /> : null}
</div>
<div className={styles.tokenMeta}>
<FeeChip lpFee={l.lp_fee} mode={feeModeOf(l.lp_fee, l.recipients)} />
Expand Down Expand Up @@ -299,6 +301,7 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine
<span className="font-mono text-xs text-muted">{t.symbol}</span>
<ChainBadge chain={t.chain} />
{isGitlawbQuote(t.quote_key) ? <GitlawbBadge /> : null}
{t.quote_key === "other" ? <UnlistedPairBadge symbol={t.quote_symbol} /> : null}
</div>
<div className={styles.tokenMeta}>
{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}
Expand Down
18 changes: 14 additions & 4 deletions app/src/components/launchpad/TradePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -204,6 +208,12 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs
{(["buy", "sell"] as Side[]).map((s) => <ToggleGroupItem key={s} value={s} disabled={busy} className={`min-h-10 text-sm ${s === "buy" ? "data-pressed:text-up" : "data-pressed:text-down-ink"}`}>{s === "buy" ? "Buy" : "Sell"}</ToggleGroupItem>)}
</ToggleGroup>

{unlisted ? (
<p role="note" className="rounded-xl border border-warm/30 bg-warm-soft px-3 py-2 text-[11px] leading-relaxed text-warm-ink text-pretty">
<b>Unlisted pair.</b> Priced in {quote.symbol} (<a href={explorerAddress(chain, quote.address)} target="_blank" rel="noreferrer" className="font-mono underline underline-offset-2">{shortAddr(quote.address)} ↗</a>), 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.
</p>
) : null}

<div className="relative">
<div className="rounded-xl border border-line bg-card px-4 pt-3 pb-4">
<div className="flex items-center justify-between gap-2 text-[11px] text-muted"><label htmlFor={`amount-${chain}-${token}`}>{side === "buy" ? "You pay" : "You sell"}</label>
Expand Down Expand Up @@ -271,9 +281,9 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs
{slippageError ? <p id={`slippage-err-${chain}`} role="alert" className="text-right text-[10px] text-down-ink">Use 0.1–20%.</p> : null}
</dl>

{!configured ? (
{!configured || !tradable ? (
<button type="button" disabled className={`${btn.secondary} !border-ink !bg-ink !text-inverse w-full min-h-12`}>
Trading unavailable
{configured ? "Checking the pair token…" : "Trading unavailable"}
</button>
) : !isConnected ? (
<ConnectWallet className={`${btn.secondary} !border-ink !bg-ink !text-inverse w-full min-h-12`}>
Expand Down
11 changes: 11 additions & 0 deletions app/src/components/launchpad/UnlistedPairBadge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className={`inline-flex h-5 items-center rounded-full border border-warm/30 bg-warm-soft px-2 text-[10px] font-medium text-warm-ink ${className}`} title={`Paired with ${symbol}, a token openlaunch does not list. Its name comes from its own contract; check the address.`}>
Unlisted pair
</span>
);
}
18 changes: 16 additions & 2 deletions app/src/components/launchpad/transaction-safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ test("trade locks synchronously during preflight and a rejection allows retry",
let rejectWallet!: (error: Error) => void;
const walletPending = new Promise<never>((_, 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; },
Expand All @@ -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,
Expand All @@ -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"]) {
Expand Down
Loading
Loading