diff --git a/app/src/components/launchpad/LaunchMechanism.tsx b/app/src/components/launchpad/LaunchMechanism.tsx
index f1f2a0c2..a3aa5597 100644
--- a/app/src/components/launchpad/LaunchMechanism.tsx
+++ b/app/src/components/launchpad/LaunchMechanism.tsx
@@ -39,7 +39,9 @@ export default function LaunchMechanism() {
// `live` figures roll as they change (compact); the rest stay exact text. Both carry the same "≈" guard.
const usd = (v: number, live = false) => (live ? : `${t.usd_partial ? "≈" : ""}${fmtUsd(v)}`);
// the compact figure rounds, so its tooltip carries the exact sum and says what kind of number it is
- const usdTitle = (v: number, what: string) => usdNote ?? `${fmtUsd(v)} · ${what} valued at current rates`;
+ // 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)`;
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
diff --git a/app/src/components/launchpad/hero-layout.test.ts b/app/src/components/launchpad/hero-layout.test.ts
index f8d1e410..ad8d5547 100644
--- a/app/src/components/launchpad/hero-layout.test.ts
+++ b/app/src/components/launchpad/hero-layout.test.ts
@@ -140,7 +140,9 @@ 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", () => {
- assert.match(totals, /usdNote \?\? `\$\{fmtUsd\(v\)\} · \$\{what\} valued at current rates`/);
+ // 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.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");
const details = totals.slice(totals.indexOf(" | null;
b20SupplyReads: number;
@@ -97,7 +97,7 @@ function fakeDb(strings: TemplateStringsArray, ...values: unknown[]): Promise {
offline(t);
resetMock();
- const row = (quote: string, volume: bigint) => ({ chain_id: BASE, quote, launches: 1n, trades: 4n, volume: volume.toString(), burned: "0", creators: "0" });
+ // trade-time model (trade-time-usd.ts): a trade with no stored value (here, all of them) counts at the quote's price now
+ const row = (quote: string, volume: bigint, storedUsd = 0, unpriced = volume) => ({
+ chain_id: BASE, quote, launches: 1n, trades: 4n, volume: volume.toString(), burned: "0", creators: "0",
+ volume_usd: storedUsd, collected_usd: 0, burned_usd: 0, volume_unpriced: unpriced.toString(), collected_unpriced: "0", burned_unpriced: "0",
+ });
getMock().launchTotalsRows = [row(NATIVE, 10n ** 18n), row(NFLX, 5n * 10n ** 8n), row(NOT_A_STOCK, 10n ** 18n)];
const quiet = await getLaunchTotals(2500);
assert.equal(quiet.launches, 3);
@@ -59,6 +63,13 @@ test("hero totals: volume in a feed-less stock is left out without the undercoun
// an idle launch (no volume, no fees) never marks the totals, whatever its quote
getMock().launchTotalsRows = [row(NATIVE, 10n ** 18n), row(NVDA, 0n)];
assert.equal((await getLaunchTotals(2500)).usd_partial, false);
+
+ // trades priced at their own hour count at that value, whatever the price is now; and a quote with no price right now
+ // does not mark the totals for its trades that already have one
+ getMock().launchTotalsRows = [row(NATIVE, 10n ** 18n, 3_100, 0n), row(NVDA, 10n ** 8n, 180, 0n)];
+ const stored = await getLaunchTotals(2500);
+ assert.equal(stored.volume_usd, 3_280, "1 ETH traded at $3,100 stays $3,100 at an ETH price of $2,500; NVDAc's $180 needs no price now");
+ assert.equal(stored.usd_partial, false);
resetMock();
});
@@ -69,7 +80,7 @@ test("the points run waits for a missing price only when the quote has a price s
// and that answer is the same one the hero totals use
const queries = read("./queries.ts");
assert.match(queries, /return \{ key: q\.key, decimals: q\.decimals, usd: quoteUsd\(q, ethUsd\), priced: hasPriceSource\(q\) \};/);
- assert.match(queries, /if \(usd === null && hasPriceSource\(q\) && \(/);
+ assert.match(queries, /const priced = hasPriceSource\(q\);\s*if \(missingNow\(r\.volume_unpriced, usd, priced\) \|\| missingNow\(r\.collected_unpriced, usd, priced\) \|\| missingNow\(r\.burned_unpriced, usd, priced\)\) t\.usd_partial = true;/);
assert.match(queries, /\.\.\.\(stock\.priced \? \{\} : \{ unpriced: true \}\)/);
// every launch row carries the same answer to the client (the wallet page's holdings total reads it)
assert.match(queries, /quote_decimals_known: q\.decimalsKnown !== false,\s*quote_priced: hasPriceSource\(q\),/);
diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts
index d45d61e8..d8ac85c5 100644
--- a/app/src/lib/launchpad/queries.ts
+++ b/app/src/lib/launchpad/queries.ts
@@ -21,6 +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 type { RawCandle } from "./candles";
import { normalizeQuery, isAddressQuery, escapeLike, compareSearchHit, compareSearchHitByHolders, type LaunchFilter } from "./search";
@@ -196,10 +197,12 @@ function quoteUsd(q: Quote, ethUsd: number | null): number | null {
}
/** A launch row as the app uses it: indexer-only bigint columns dropped (JSON-safe), price, FDV and USD figures worked out. */
-function shape(raw: Raw & { last_swap_block?: bigint; last_swap_log?: number; log_index?: number; holders_synced_block?: bigint | null }, ethUsd: number | null): LaunchRow {
- // drop indexer-only bigint columns so the row is JSON-safe
+/** Columns a row carries for the trade-time USD figures (trade-time-usd.ts); read here, never passed on as they are. */
+type TradeTimeCols = { volume_usd?: number | null; fees_collected_usd?: number | null; fees_burned_usd?: number | null; volume_quote_unpriced?: string | null };
+function shape(raw: Raw & TradeTimeCols & { last_swap_block?: bigint; last_swap_log?: number; log_index?: number; holders_synced_block?: bigint | null }, ethUsd: number | null): LaunchRow {
+ // drop indexer-only bigint columns so the row is JSON-safe, and the stored trade-time sums (volume_usd below is computed from them)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
- const { last_swap_block, last_swap_log, log_index, holders_synced_block, ...r } = raw;
+ const { last_swap_block, last_swap_log, log_index, holders_synced_block, volume_usd: storedVolumeUsd, fees_collected_usd, fees_burned_usd, volume_quote_unpriced, ...r } = raw;
const chain = chainKeyOf(r.chain_id) ?? DEFAULT_CHAIN;
const q = quoteInfo(chain, r.quote);
const supply = BigInt(r.supply);
@@ -240,7 +243,8 @@ function shape(raw: Raw & { last_swap_block?: bigint; last_swap_log?: number; lo
quote_usd: qu,
price_usd: qu === null ? null : price * qu,
fdv_usd: qu === null ? null : fdv * qu,
- volume_usd: usd(r.volume_quote),
+ // 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_1h_usd: usd(String(r.volume_1h ?? "0")),
volume_24h_usd: usd(String(r.volume_24h ?? "0")),
};
@@ -256,9 +260,11 @@ function shape(raw: Raw & { last_swap_block?: bigint; last_swap_log?: number; lo
const OUTSIDE = `s.trader <> l.launcher AND s.block_number > l.block_number + ${SNIPER_BLOCKS}`;
const HOUR = `s.block_time > now() - interval '1 hour'`;
export const SELECT = `SELECT l.*, m.description, m.image_url, m.banner_url, m.website, m.x_handle,
- w.n1 AS trades_1h, w.t1 AS traders_1h, w.t1_ex AS traders_1h_ex, w.v1 AS volume_1h, w.n24 AS trades_24h, w.t24_ex AS traders_24h_ex, w.v24 AS volume_24h, w.last_outside_at AS last_outside_trade_at
+ w.n1 AS trades_1h, w.t1 AS traders_1h, w.t1_ex AS traders_1h_ex, w.v1 AS volume_1h, w.n24 AS trades_24h, w.t24_ex AS traders_24h_ex, w.v24 AS volume_24h, w.last_outside_at AS last_outside_trade_at,
+ 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,
@@ -358,7 +364,11 @@ export async function listLaunchesPage(opts: ListOpts = {}): Promise {
const pairDecCase = pairDec.length ? pairDec.map((p) => db`WHEN (l.chain_id = ${chainIdOf(p.chain)} AND l.quote = ${p.address}) THEN ${p.decimals}`).reduce((acc, c) => db`${acc} ${c}`) : db``;
const qd = db`(CASE ${stableDecCase} ${decCase} ${pairDecCase} ELSE 18 END)`;
const volCol = db.unsafe(VOLUME_COLUMN_SQL[win]);
- const volUsd = db`(${volCol} / power(10, ${qd}) * ${usdPerUnit})`;
+ // the all-time window ranks by trade-time USD (each trade at its own hour, trades with no stored value at today's price, as
+ // shape() shows it); 1h and 24h stay quote volume at today's price, where every trade is priced live anyway
+ const volUsd = win === "all"
+ ? db`(l.volume_usd + COALESCE(u.uq, 0) / power(10, ${qd}) * ${usdPerUnit})`
+ : db`(${volCol} / power(10, ${qd}) * ${usdPerUnit})`;
// quote per token = 1 / (1.0001^tick · 10^(qd-18)); mcap = that · supply/1e18 · usd
const mcapUsd = db`((1.0 / (power(1.0001, COALESCE(l.tick, l.start_tick)::double precision) * power(10, ${qd} - 18))) * (l.supply / 1e18) * ${usdPerUnit})`;
const newest = db`l.block_time DESC, l.chain_id DESC, l.block_number DESC`;
@@ -560,11 +570,22 @@ export async function getLaunchTotals(ethUsd: number | null = null): Promise`
- SELECT chain_id, quote, count(*)::bigint AS launches, COALESCE(sum(buys + sells), 0)::bigint AS trades,
- COALESCE(sum(volume_quote), 0)::text AS volume, COALESCE(sum(fees_quote_burned), 0)::text AS burned,
- COALESCE(sum(fees_quote_collected - fees_quote_burned), 0)::text AS creators
- FROM bb_launches GROUP BY chain_id, quote`,
+ db<{ chain_id: number; quote: string; launches: bigint; trades: bigint; volume: string; burned: string; creators: string; volume_usd: number; collected_usd: number; burned_usd: number; volume_unpriced: string; collected_unpriced: string; burned_unpriced: string }[]>`
+ SELECT l.chain_id, l.quote, count(*)::bigint AS launches, COALESCE(sum(l.buys + l.sells), 0)::bigint AS trades,
+ COALESCE(sum(l.volume_quote), 0)::text AS volume, COALESCE(sum(l.fees_quote_burned), 0)::text AS burned,
+ COALESCE(sum(l.fees_quote_collected - l.fees_quote_burned), 0)::text AS creators,
+ -- trade-time USD (trade-time-usd.ts): the stored sums, and the raw amounts with no stored value
+ COALESCE(sum(l.volume_usd), 0)::float8 AS volume_usd, COALESCE(sum(l.fees_collected_usd), 0)::float8 AS collected_usd,
+ COALESCE(sum(l.fees_burned_usd), 0)::float8 AS burned_usd, COALESCE(sum(u.uq), 0)::text AS volume_unpriced,
+ COALESCE(sum(uf.collected), 0)::text AS collected_unpriced, COALESCE(sum(uf.burned), 0)::text AS burned_unpriced
+ FROM bb_launches l
+ LEFT JOIN (SELECT chain_id, token, sum(abs(amount0)) AS uq FROM bb_launch_swaps WHERE usd IS NULL GROUP BY chain_id, token) u ON u.chain_id = l.chain_id AND u.token = l.token
+ LEFT JOIN (SELECT f.chain_id, f.token, sum(f.quote_amount) FILTER (WHERE f.kind = 'collected') AS collected,
+ sum(f.amount) FILTER (WHERE f.kind = 'burned') AS burned
+ FROM bb_launch_fee_events f JOIN bb_launches fl ON fl.chain_id = f.chain_id AND fl.token = f.token
+ WHERE f.usd IS NULL AND (f.kind = 'collected' OR (f.kind = 'burned' AND f.currency = fl.quote))
+ GROUP BY f.chain_id, f.token) uf ON uf.chain_id = l.chain_id AND uf.token = l.token
+ GROUP BY l.chain_id, l.quote`,
// boosts are TWIG sent to 0x…dEaD too (boost.ts): they join the TWIG burned figure while the switch is on, and
// nothing else reads them here. Only boosts that name a launch count (the lane's own join), so a stray burn whose
// last digits name nothing is never called a boost. Display only: a failed read counts none rather than taking
@@ -586,15 +607,19 @@ export async function getLaunchTotals(ethUsd: number | null = null): Promise 0n || BigInt(r.burned) > 0n || BigInt(r.creators) > 0n)) t.usd_partial = true;
+ // trade-time USD: every trade and fee at its own hour (stored), the ones with no stored value (no price history) at
+ // today's price. Only that last part can be missing: a quote that normally prices but has no price this instant → the
+ // USD sums undercount; an unlisted ERC-20 (key "other") and a stock without a feed are unpriced by design and are left
+ // out of the USD figures silently, as always
+ const priced = hasPriceSource(q);
+ if (missingNow(r.volume_unpriced, usd, priced) || missingNow(r.collected_unpriced, usd, priced) || missingNow(r.burned_unpriced, usd, priced)) t.usd_partial = true;
+ const collectedUsd = tradeTimeUsd(r.collected_usd, r.collected_unpriced, q.decimals, usd) ?? 0;
+ const burnedUsd = tradeTimeUsd(r.burned_usd, r.burned_unpriced, q.decimals, usd) ?? 0;
t.launches += Number(r.launches);
t.trades += Number(r.trades);
- t.volume_usd += units(r.volume, q.decimals) * qu;
- t.fees_burned_usd += units(r.burned, q.decimals) * qu;
- t.fees_to_creators_usd += units(r.creators, q.decimals) * qu;
+ t.volume_usd += tradeTimeUsd(r.volume_usd, r.volume_unpriced, q.decimals, usd) ?? 0;
+ t.fees_burned_usd += burnedUsd;
+ t.fees_to_creators_usd += collectedUsd - burnedUsd;
const bc = t.by_chain[chain];
bc.launches += Number(r.launches);
bc.trades += Number(r.trades);
diff --git a/app/src/lib/launchpad/trade-time-usd.test.ts b/app/src/lib/launchpad/trade-time-usd.test.ts
new file mode 100644
index 00000000..5ca90d49
--- /dev/null
+++ b/app/src/lib/launchpad/trade-time-usd.test.ts
@@ -0,0 +1,58 @@
+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";
+
+const ETH = 10n ** 18n;
+
+test("trade-time USD = the stored sum, plus what has no stored value at today's price", () => {
+ // all priced at their own hour: the stored sum, whatever today's price is (the old method would say 2 x 2,000)
+ assert.equal(tradeTimeUsd(5_300, "0", 18, 2_000), 5_300);
+ assert.equal(tradeTimeUsd(5_300, 0n, 18, null), 5_300, "a quote with no price right now keeps what was stored");
+ // 1 ETH with no stored value counts at today's price
+ assert.equal(tradeTimeUsd(5_300, ETH.toString(), 18, 2_000), 7_300);
+ // 6-decimal quotes
+ assert.equal(tradeTimeUsd(0, "100000000", 6, 1), 100);
+ // nothing priced it at its hour and nothing prices it now: no USD
+ assert.equal(tradeTimeUsd(0, ETH, 18, null), null);
+ assert.equal(tradeTimeUsd(250, ETH, 18, null), 250, "the stored part still counts; the caller flags the rest as partial");
+ // no trades at all
+ assert.equal(tradeTimeUsd(0, "0", 18, 2_000), 0);
+ assert.equal(tradeTimeUsd(0, "0", 18, null), null, "an unlisted quote with no trades shows no USD, as before");
+ assert.equal(tradeTimeUsd(null, null, 18, 3), 0);
+ assert.equal(tradeTimeUsd(Number.NaN, "0", 18, 3), 0, "a bad stored value is never shown");
+});
+
+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);
+ assert.equal(missingNow(ETH.toString(), null, false), false, "an unlisted quote is unpriced by design, never 'partial'");
+ assert.equal(missingNow("0", null, true), false);
+ assert.equal(missingNow(null, null, true), false);
+});
+
+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$/);
+ 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/);
+ // a launch row's all-time volume
+ assert.match(q, /volume_usd: tradeTimeUsd\(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\}\)`/);
+ // the site totals: stored sums plus the unpriced parts, fees to recipients = collected - burned in USD
+ const totals = q.slice(q.indexOf("export async function getLaunchTotals("), q.indexOf("export async function getLaunchTotals(") + 6000);
+ assert.match(totals, /COALESCE\(sum\(l\.volume_usd\), 0\)::float8 AS volume_usd/);
+ assert.ok(totals.includes(`LEFT JOIN (${UNPRICED_SWAPS_SQL}) u ON u.chain_id = l.chain_id AND u.token = l.token`), "the totals inline the same unpriced read as the lists");
+ assert.match(totals, /WHERE f\.usd IS NULL AND \(f\.kind = 'collected' OR \(f\.kind = 'burned' AND f\.currency = fl\.quote\)\)/);
+ assert.match(totals, /t\.volume_usd \+= tradeTimeUsd\(r\.volume_usd, r\.volume_unpriced, q\.decimals, usd\) \?\? 0;/);
+ assert.match(totals, /t\.fees_to_creators_usd \+= collectedUsd - burnedUsd;/);
+ assert.doesNotMatch(totals, /units\(r\.volume, q\.decimals\) \* qu/, "the old method is gone from the totals");
+});
diff --git a/app/src/lib/launchpad/trade-time-usd.ts b/app/src/lib/launchpad/trade-time-usd.ts
new file mode 100644
index 00000000..9ead228d
--- /dev/null
+++ b/app/src/lib/launchpad/trade-time-usd.ts
@@ -0,0 +1,28 @@
+/**
+ * Reading trade-time USD (pure; node --test loads it). Every trade and quote-side fee carries its USD value at its own
+ * hour (bb_launch_swaps.usd, bb_launch_fee_events.usd; usd-history.ts), summed per launch in bb_launches.volume_usd,
+ * fees_collected_usd and fees_burned_usd. A row with no stored value is one whose quote has no price history (registry
+ * stocks, community pairs and unlisted tokens traded before 2026-11-10, when the live capture started): it counts at
+ * the quote's price now, the old method, so the totals stay complete. The site says so where the figures are shown.
+ */
+import { units } from "./math.ts";
+
+/** Swaps with no stored USD value, summed per launch in raw quote units (the partial index bb_launch_swaps_usd_missing_idx keeps it small). */
+export const UNPRICED_SWAPS_SQL = `SELECT chain_id, token, sum(abs(amount0)) AS uq FROM bb_launch_swaps WHERE usd IS NULL GROUP BY chain_id, token`;
+
+/**
+ * A USD total: the stored trade-time sum plus the raw quote amount with no stored value at `quoteUsdNow`.
+ * null = nothing prices any of it (an unlisted quote, or a quote with no price right now and nothing stored).
+ */
+export function tradeTimeUsd(storedUsd: number | null | undefined, unpricedRaw: string | bigint | null | undefined, decimals: number, quoteUsdNow: number | null): number | null {
+ const stored = typeof storedUsd === "number" && Number.isFinite(storedUsd) ? storedUsd : 0;
+ const unpriced = unpricedRaw === null || unpricedRaw === undefined ? 0 : units(unpricedRaw, decimals);
+ if (unpriced > 0) return quoteUsdNow !== null ? stored + unpriced * quoteUsdNow : stored > 0 ? stored : null;
+ return stored > 0 || quoteUsdNow !== null ? stored : null;
+}
+
+/** Whether a total undercounts right now: a part with no stored value whose quote normally has a price but has none at the moment. */
+export function missingNow(unpricedRaw: string | bigint | null | undefined, quoteUsdNow: number | null, priced: boolean): boolean {
+ if (unpricedRaw === null || unpricedRaw === undefined) return false;
+ return quoteUsdNow === null && priced && BigInt(unpricedRaw) > 0n;
+}
diff --git a/app/src/lib/launchpad/unlisted-quote.test.ts b/app/src/lib/launchpad/unlisted-quote.test.ts
index 79e2ced1..f8731e43 100644
--- a/app/src/lib/launchpad/unlisted-quote.test.ts
+++ b/app/src/lib/launchpad/unlisted-quote.test.ts
@@ -82,7 +82,7 @@ test("the server resolves unlisted quotes from bb_quote_tokens, fails soft, and
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 && hasPriceSource\(q\)/, "unlisted quotes do not flag the site USD totals as partial (price-source.test.ts runs it)");
+ assert.match(q, /const priced = hasPriceSource\(q\);\s*if \(missingNow\(r\.volume_unpriced, usd, priced\)/, "unlisted quotes do not flag the site USD totals as partial (price-source.test.ts runs it)");
});
test("the indexer reads unlisted quotes into bb_quote_tokens with printable text only", () => {
diff --git a/app/src/lib/launchpad/volume-window.test.ts b/app/src/lib/launchpad/volume-window.test.ts
index 5376aa2a..3e0edf5d 100644
--- a/app/src/lib/launchpad/volume-window.test.ts
+++ b/app/src/lib/launchpad/volume-window.test.ts
@@ -2,6 +2,7 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { SELECT, VOLUME_COLUMN_SQL, VOLUME_WINDOWS } from "./queries.ts";
+import { UNPRICED_SWAPS_SQL } from "./trade-time-usd.ts";
/**
* The Volume sort orders by a column per window. The window volumes live on the lateral subquery aliased `w`
@@ -10,12 +11,13 @@ import { SELECT, VOLUME_COLUMN_SQL, VOLUME_WINDOWS } from "./queries.ts";
const lateral = SELECT.slice(SELECT.indexOf("LEFT JOIN LATERAL ("), SELECT.lastIndexOf(") w ON"));
const wColumns = new Set([...lateral.matchAll(/\bAS (\w+)/g)].map((m) => m[1]));
// aliases visible to the ORDER BY (the lateral's own inner alias is not): "FROM bb_launches l", "JOIN bb_launch_meta m", "LATERAL (...) w"
-const definedAliases = new Set([...SELECT.replace(lateral, "LATERAL (...").matchAll(/\b(?:FROM|JOIN) bb_\w+ (\w+)|\) (\w+) ON/g)].map((m) => m[1] ?? m[2]));
+// "LEFT JOIN () u" is the trade-time USD join (trade-time-usd.ts); its inner FROM is not an alias of the outer query
+const definedAliases = new Set([...SELECT.replace(lateral, "LATERAL (...").replace(UNPRICED_SWAPS_SQL, "...").matchAll(/\b(?:FROM|JOIN) bb_\w+ (\w+)|\) (\w+) ON/g)].map((m) => m[1] ?? m[2]));
test("the lateral subquery is still aliased w and carries the window volumes", () => {
assert.ok(SELECT.includes(") w ON true"));
assert.ok(wColumns.has("v1") && wColumns.has("v24"));
- assert.deepEqual([...definedAliases].sort(), ["l", "m", "w"]);
+ assert.deepEqual([...definedAliases].sort(), ["l", "m", "u", "w"]);
});
test("every volume window orders by columns the query defines", () => {