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
4 changes: 4 additions & 0 deletions app/db/concurrent-indexes.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
-- Indexes on the big, busy tables, built by scripts/migrate.mjs AFTER the schema transaction commits, one at a time
-- with CREATE INDEX CONCURRENTLY: reads and writes on the table never wait for the build. (Inside the schema
-- transaction a build would hold the exclusive lock an ALTER TABLE on that table already took, and every query on
Expand All @@ -20,3 +20,7 @@
-- trade-time USD (2026-11-10): the trades still without a USD value (every one until the first backfill, then only
-- those whose hour had no price yet), so a repeat backfill and the "partial" check never scan every swap
CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launch_swaps_usd_missing_idx ON bb_launch_swaps (chain_id, block_time) WHERE usd IS NULL;
-- trade-time USD review (2026-11-11): the lists' per-launch probe of trades with no USD value (queries.SELECT's LATERAL u), and
-- the site totals' read of quote-side fees with no USD value (getLaunchTotals): neither scans its whole table on a page view
CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launch_swaps_usd_missing_token_idx ON bb_launch_swaps (chain_id, token) WHERE usd IS NULL;
CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launch_fee_events_usd_missing_idx ON bb_launch_fee_events (chain_id, token) WHERE usd IS NULL AND kind IN ('collected', 'burned');
18 changes: 18 additions & 0 deletions app/scripts/rebuild-launch-totals.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
-- Recompute every launch's rolling totals from the raw event tables (bb_launch_swaps, bb_launch_fee_events).
-- Idempotent. Run when totals could have drifted (e.g. an ingestion interrupted between "insert swap" and
-- "update totals" before those became one transaction). Prints a drift report first, then repairs.
Expand All @@ -20,6 +20,12 @@
count(*) FILTER (WHERE l.buys <> s.buys OR l.sells <> s.sells OR l.volume_quote <> s.volume_quote) AS launches_with_wrong_totals,
count(*) FILTER (WHERE (l.buys + l.sells) > 0 AND s.token IS NULL) AS totals_without_swaps
FROM bb_launches l LEFT JOIN s ON s.chain_id = l.chain_id AND s.token = l.token;
-- trade-time USD totals vs the sums of their stored per-event values (lib/launchpad/trade-time-usd.ts)
SELECT 'usd drift before' AS what,
count(*) FILTER (WHERE abs(l.volume_usd - COALESCE((SELECT sum(s.usd) FROM bb_launch_swaps s WHERE s.chain_id = l.chain_id AND s.token = l.token), 0)) > 0.01
OR abs(l.fees_collected_usd - COALESCE((SELECT sum(f.usd) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'collected'), 0)) > 0.01
OR abs(l.fees_burned_usd - COALESCE((SELECT sum(f.usd) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'burned' AND f.currency = l.quote), 0)) > 0.01) AS launches_with_wrong_usd_totals
FROM bb_launches l;

-- swaps → buys / sells / volume / last trade
UPDATE bb_launches l SET
Expand Down Expand Up @@ -51,6 +57,12 @@
(SELECT COALESCE(sum(amount), 0) FROM bb_launch_fee_events e WHERE e.chain_id = l2.chain_id AND e.token = l2.token AND e.kind = 'burned' AND e.currency <> l2.quote) AS tb
FROM bb_launches l2) f
WHERE l.chain_id = f.chain_id AND l.token = f.token;
-- trade-time USD totals: the sums of the stored per-event values (rows with no value yet stay out; the indexer's heal pass
-- and scripts/usd-backfill.ts price those)
UPDATE bb_launches l SET
volume_usd = COALESCE((SELECT sum(s.usd) FROM bb_launch_swaps s WHERE s.chain_id = l.chain_id AND s.token = l.token), 0),
fees_collected_usd = COALESCE((SELECT sum(f.usd) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'collected'), 0),
fees_burned_usd = COALESCE((SELECT sum(f.usd) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'burned' AND f.currency = l.quote), 0);

WITH s AS (
SELECT chain_id, token, count(*) FILTER (WHERE is_buy) AS buys, count(*) FILTER (WHERE NOT is_buy) AS sells, COALESCE(sum(abs(amount0)), 0) AS volume_quote
Expand All @@ -59,4 +71,10 @@
SELECT 'drift after' AS what,
count(*) FILTER (WHERE l.buys <> COALESCE(s.buys, 0) OR l.sells <> COALESCE(s.sells, 0) OR l.volume_quote <> COALESCE(s.volume_quote, 0)) AS launches_with_wrong_totals
FROM bb_launches l LEFT JOIN s ON s.chain_id = l.chain_id AND s.token = l.token;
-- trade-time USD totals vs the sums of their stored per-event values (lib/launchpad/trade-time-usd.ts)
SELECT 'usd drift after' AS what,
count(*) FILTER (WHERE abs(l.volume_usd - COALESCE((SELECT sum(s.usd) FROM bb_launch_swaps s WHERE s.chain_id = l.chain_id AND s.token = l.token), 0)) > 0.01
OR abs(l.fees_collected_usd - COALESCE((SELECT sum(f.usd) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'collected'), 0)) > 0.01
OR abs(l.fees_burned_usd - COALESCE((SELECT sum(f.usd) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'burned' AND f.currency = l.quote), 0)) > 0.01) AS launches_with_wrong_usd_totals
FROM bb_launches l;
COMMIT;
14 changes: 11 additions & 3 deletions app/scripts/usd-backfill.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/**
* Trade-time USD backfill (lib/launchpad/usd-history.ts states the rule). Fetches hourly prices from launch day to now and
* writes ONE SQL file that fills bb_quote_usd_hourly, prices every trade and fee that has no USD value yet at its own
Expand Down Expand Up @@ -34,11 +34,19 @@
const UA = { "user-agent": "openlaunch-usd-backfill" };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

/** GET/POST JSON with retries on 429, 5xx, network errors and timeouts (a transient reset must not abort a minutes-long run). */
async function getJson(url: string, init?: RequestInit, tries = 4): Promise<unknown> {
for (let i = 0; ; i++) {
const res = await fetch(url, { ...init, headers: { ...UA, ...(init?.headers ?? {}) }, signal: AbortSignal.timeout(20_000) });
if (res.ok) return res.json();
if (i + 1 >= tries || (res.status !== 429 && res.status < 500)) throw new Error(`${url.split("?")[0]}: HTTP ${res.status}`);
let status: number | null = null;
try {
const res = await fetch(url, { ...init, headers: { ...UA, ...(init?.headers ?? {}) }, signal: AbortSignal.timeout(20_000) });
if (res.ok) return await res.json();
status = res.status;
if (status !== 429 && status < 500) throw Object.assign(new Error(`${url.split("?")[0]}: HTTP ${status}`), { final: true });
} catch (err) {
if ((err as { final?: boolean }).final || i + 1 >= tries) throw err;
}
if (i + 1 >= tries) throw new Error(`${url.split("?")[0]}: HTTP ${status}`);
await sleep(2_000 * (i + 1));
}
}
Expand Down
8 changes: 4 additions & 4 deletions app/src/components/launchpad/LaunchMechanism.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ export default function LaunchMechanism() {
// totals roll to their new value when a launch or trade lands (static on first paint and under reduced motion)
const count = (value: number) => <LiveNumber value={value} />;
// some quote has no price right now: the dollar sums undercount, so say "≈" instead of showing a confident smaller number
const usdNote = t.usd_partial ? "Some launches are quoted in an asset with no USD price right now; dollar totals exclude them until it returns." : undefined;
const usdNote = t.usd_partial ? "Some trades have no recorded USD value and their quote asset has no USD price right now; dollar totals leave them out until it returns." : undefined;
// `live` figures roll as they change (compact); the rest stay exact text. Both carry the same "≈" guard.
const usd = (v: number, live = false) => (live ? <LiveNumber value={v} format={COMPACT_USD} prefix={t.usd_partial ? "≈" : undefined} /> : `${t.usd_partial ? "≈" : ""}${fmtUsd(v)}`);
// the compact figure rounds, so its tooltip carries the exact sum and says what kind of number it is
// trade-time USD (lib/launchpad/trade-time-usd.ts): each trade and fee at the hour it happened; trades in a quote with no
// price history (registry stocks, community pairs and unlisted tokens before Nov 10, 2026) at today's rate
const usdTitle = (v: number, what: string) => usdNote ?? `${fmtUsd(v)} · ${what}, each valued in USD at the hour it happened (stock and community-pair trades before Nov 10, 2026 at today's rate)`;
// trade-time USD (lib/launchpad/trade-time-usd.ts): each trade and fee at the hour it happened; the ones with no recorded
// value (community pairs, stocks before Nov 10, 2026, an hour not priced yet) at today's rate; unlisted tokens have no USD
const usdTitle = (v: number, what: string) => usdNote ?? `${fmtUsd(v)} · ${what}, each valued in USD at the hour it happened (community-pair trades, and any without a recorded value, at today's rate)`;
const usdChars = USD_CHARS + (t.usd_partial ? 1 : 0);
const gitlawbBurnedExact = `${fmtUnitsExact(t.gitlawb_burned, GITLAWB_DECIMALS)} ${GITLAWB_SYMBOL}`; // every digit of the raw amount, no float
// TWIG burned: its own figure (the GITLAWB behind it stays in the TWIG contract), shown once there is any; null-safe for a poll from an older machine
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/launchpad/hero-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ test("the dollar readings are latched: shown totals change only when something h

test("the two dollar figures explain themselves, and the ≈ note is readable without a mouse", () => {
// trade-time USD: each figure says it is valued at the hour each trade happened, and names the part that is not
assert.match(totals, /usdNote \?\? `\$\{fmtUsd\(v\)\} · \$\{what\}, each valued in USD at the hour it happened \(stock and community-pair trades before Nov 10, 2026 at today's rate\)`/);
assert.match(totals, /usdNote \?\? `\$\{fmtUsd\(v\)\} · \$\{what\}, each valued in USD at the hour it happened \(community-pair trades, and any without a recorded value, at today's rate\)`/);
assert.doesNotMatch(totals, /valued at current rates/);
assert.match(totals, /title=\{usdTitle\(t\.volume_usd, "quote volume"\)\}/);
assert.match(totals, /title=\{usdTitle\(t\.fees_to_creators_usd, "quote-asset fees"\)\}/, "fees are not volume");
Expand Down
7 changes: 5 additions & 2 deletions app/src/lib/launchpad/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { TWIG_ADDRESSES } from "./twig";
import { boostFromTransfer } from "./boost";
import { pairBurnFromTransfer } from "./pairApply";
import { recordPairBurn } from "./pairApplyServer";
import { captureLiveUsd } from "./usdLedger";
import { captureLiveUsd, healUnpricedUsd } from "./usdLedger";
import { hourIso } from "./usd-history";

/**
Expand Down Expand Up @@ -859,6 +859,8 @@ async function run(chain: ChainKey): Promise<LaunchSyncResult> {
from = to + 1n;
chunks++;
}
// trades and fees whose hour has a price by now but had none when they were written (usdLedger.ts; throttled, never throws)
await healUnpricedUsd(db, chain);
return { status: "synced", from: from.toString(), to: to.toString(), head: head.toString(), ...totals, caught_up: from > confirmed };
} catch (err) {
const msg = errMessage(err);
Expand Down Expand Up @@ -901,7 +903,8 @@ export async function applyLaunchTx(chain: ChainKey, hash: Hex): Promise<ApplyLa
tokens.push(l.args.token.toLowerCase());
}
const { poolToToken, tokenIdToToken, tokenQuote } = await poolMaps(db, chain);
await captureLiveUsd(db, chain, tokenQuote.values()); // as in applyRange: this hour's live USD before the trades are priced
// no price capture here: this path runs while the user waits, and a capture can wait on price sources. Its trades take
// the hour's price the poller already captured, or are priced by the poller's heal pass a few minutes later.
const swapLogs = parseEventLogs({ abi: POOL_MANAGER_ABI, eventName: "Swap", logs: receipt.logs.filter((l) => l.address.toLowerCase() === pm) });
for (const l of swapLogs) {
if (await applySwap(db, chain, l as unknown as Log & { args: Swap }, poolToToken, tokenQuote)) {
Expand Down
12 changes: 6 additions & 6 deletions app/src/lib/launchpad/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { collectNonDust } from "./feed-dust";
import { PIPS_LEADER, PIPS_RUNNER, pipFromSwapRow, tapeOrNone, type Pip } from "./trending-live";
import { launchKey } from "./list-state";
import { fdvQuote, quotePerToken, tickToTokensPerQuote, units } from "./math";
import { UNPRICED_SWAPS_SQL, missingNow, tradeTimeUsd } from "./trade-time-usd";
import { UNPRICED_LATERAL_SQL, launchVolumeUsd, missingNow, tradeTimeUsd } from "./trade-time-usd";
import type { RawCandle } from "./candles";
import { normalizeQuery, isAddressQuery, escapeLike, compareSearchHit, compareSearchHitByHolders, type LaunchFilter } from "./search";

Expand Down Expand Up @@ -244,7 +244,7 @@ function shape(raw: Raw & TradeTimeCols & { last_swap_block?: bigint; last_swap_
price_usd: qu === null ? null : price * qu,
fdv_usd: qu === null ? null : fdv * qu,
// all-time volume at each trade's own hour; trades with no stored value (no price history) at today's price
volume_usd: tradeTimeUsd(storedVolumeUsd, volume_quote_unpriced, q.decimals, qu),
volume_usd: launchVolumeUsd(storedVolumeUsd, volume_quote_unpriced, q.decimals, qu),
volume_1h_usd: usd(String(r.volume_1h ?? "0")),
volume_24h_usd: usd(String(r.volume_24h ?? "0")),
};
Expand All @@ -264,14 +264,14 @@ export const SELECT = `SELECT l.*, m.description, m.image_url, m.banner_url, m.w
COALESCE(u.uq, 0)::text AS volume_quote_unpriced
FROM bb_launches l
LEFT JOIN bb_launch_meta m ON m.chain_id = l.chain_id AND m.token = l.token
LEFT JOIN (${UNPRICED_SWAPS_SQL}) u ON u.chain_id = l.chain_id AND u.token = l.token
LEFT JOIN LATERAL (
SELECT count(*) FILTER (WHERE ${HOUR})::int AS n1, count(DISTINCT s.trader) FILTER (WHERE ${HOUR})::int AS t1,
count(DISTINCT s.trader) FILTER (WHERE ${HOUR} AND ${OUTSIDE})::int AS t1_ex, COALESCE(sum(abs(amount0)) FILTER (WHERE ${HOUR}), 0)::text AS v1,
count(*)::int AS n24, count(DISTINCT s.trader) FILTER (WHERE ${OUTSIDE})::int AS t24_ex, COALESCE(sum(abs(amount0)), 0)::text AS v24,
max(s.block_time) FILTER (WHERE ${OUTSIDE}) AS last_outside_at
FROM bb_launch_swaps s WHERE s.chain_id = l.chain_id AND s.token = l.token AND s.block_time > now() - interval '${LIVE_WINDOW_HOURS} hours'
) w ON true`;
) w ON true
LEFT JOIN LATERAL (${UNPRICED_LATERAL_SQL}) u ON true`;

export type LaunchSort = "live" | "new" | "mcap" | "volume" | "gainers" | "holders";
export type VolumeWindow = "1h" | "24h" | "all";
Expand Down Expand Up @@ -858,12 +858,12 @@ export async function getTrending(ethUsd: number | null = null): Promise<Trendin
* an outside pool; the lists show them): decimals and USD per whole quote unit, null = unpriced. For the indexer's hourly
* live capture (usdLedger.ts), which is what prices each trade at its own hour.
*/
export async function quoteUsdNow(ethUsd: number | null): Promise<(chain: ChainKey, quote: string) => { decimals: number; decimalsKnown: boolean; usd: number | null }> {
export async function quoteUsdNow(ethUsd: number | null): Promise<(chain: ChainKey, quote: string) => { key: Quote["key"]; decimals: number; decimalsKnown: boolean; usd: number | null }> {
await withStocks();
const snap = priceState();
return (chain, quote) => {
const q = quoteInfo(chain, quote, snap);
return { decimals: q.decimals, decimalsKnown: q.decimalsKnown !== false, usd: quoteUsd(q, ethUsd) };
return { key: q.key, decimals: q.decimals, decimalsKnown: q.decimalsKnown !== false, usd: quoteUsd(q, ethUsd) };
};
}

Expand Down
18 changes: 15 additions & 3 deletions app/src/lib/launchpad/trade-time-usd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { UNPRICED_SWAPS_SQL, missingNow, tradeTimeUsd } from "./trade-time-usd.ts";
import { UNPRICED_LATERAL_SQL, UNPRICED_SWAPS_SQL, launchVolumeUsd, missingNow, tradeTimeUsd } from "./trade-time-usd.ts";

const ETH = 10n ** 18n;

Expand All @@ -24,6 +24,13 @@ test("trade-time USD = the stored sum, plus what has no stored value at today's
assert.equal(tradeTimeUsd(Number.NaN, "0", 18, 3), 0, "a bad stored value is never shown");
});

test("a launch row shows its quote amount, not a smaller dollar figure, while part of its volume has no price at all", () => {
assert.equal(launchVolumeUsd(500, (2n * ETH).toString(), 18, null), null, "ETH price down + 2 ETH unpriced: no dollar figure that looks complete");
assert.equal(launchVolumeUsd(500, (2n * ETH).toString(), 18, 2_000), 4_500);
assert.equal(launchVolumeUsd(500, "0", 18, null), 500, "everything has its trade-time value: no price needed now");
assert.equal(launchVolumeUsd(0, "0", 18, null), null);
});

test("missingNow flags only a part that normally has a price but has none right now", () => {
assert.equal(missingNow(ETH.toString(), null, true), true);
assert.equal(missingNow(ETH.toString(), 2_000, true), false);
Expand All @@ -34,16 +41,21 @@ test("missingNow flags only a part that normally has a price but has none right

test("the unpriced read only touches rows with no stored value, per launch", () => {
assert.match(UNPRICED_SWAPS_SQL, /FROM bb_launch_swaps WHERE usd IS NULL GROUP BY chain_id, token$/);
// the lists probe one launch at a time (bb_launch_swaps_usd_missing_token_idx), never aggregate every unpriced trade
assert.equal(UNPRICED_LATERAL_SQL, "SELECT sum(abs(x.amount0)) AS uq FROM bb_launch_swaps x WHERE x.chain_id = l.chain_id AND x.token = l.token AND x.usd IS NULL");
const idx = readFileSync(path.join(process.cwd(), "db/concurrent-indexes.sql"), "utf8");
assert.match(idx, /bb_launch_swaps_usd_missing_token_idx ON bb_launch_swaps \(chain_id, token\) WHERE usd IS NULL;/);
assert.match(idx, /bb_launch_fee_events_usd_missing_idx ON bb_launch_fee_events \(chain_id, token\) WHERE usd IS NULL AND kind IN \('collected', 'burned'\);/);
assert.match(UNPRICED_SWAPS_SQL, /sum\(abs\(amount0\)\) AS uq/, "buys and sells both count at their size, as volume_quote does");
});

test("the site reads trade-time USD everywhere the all-time figures are shown", () => {
const q = readFileSync(path.join(process.cwd(), "src/lib/launchpad/queries.ts"), "utf8");
// the list SELECT carries each launch's unpriced volume, joined once
assert.match(q, /COALESCE\(u\.uq, 0\)::text AS volume_quote_unpriced/);
assert.match(q, /LEFT JOIN \(\$\{UNPRICED_SWAPS_SQL\}\) u ON u\.chain_id = l\.chain_id AND u\.token = l\.token/);
assert.match(q, /LEFT JOIN LATERAL \(\$\{UNPRICED_LATERAL_SQL\}\) u ON true`;/);
// a launch row's all-time volume
assert.match(q, /volume_usd: tradeTimeUsd\(storedVolumeUsd, volume_quote_unpriced, q\.decimals, qu\)/);
assert.match(q, /volume_usd: launchVolumeUsd\(storedVolumeUsd, volume_quote_unpriced, q\.decimals, qu\)/);
assert.doesNotMatch(q, /volume_usd: usd\(r\.volume_quote\)/, "never quote volume at today's price again");
// the all-time Volume sort ranks by the same figure
assert.match(q, /win === "all"\s*\? db`\(l\.volume_usd \+ COALESCE\(u\.uq, 0\) \/ power\(10, \$\{qd\}\) \* \$\{usdPerUnit\}\)`/);
Expand Down
Loading
Loading