feat: default pair 24h volume to USD (fix UST1/USTR VOL 19T and UST1/cUSTC 48.2m) #544

Closed
opened 2026-08-17 03:52:28 +00:00 by PlasticDigits · 18 comments
PlasticDigits commented 2026-08-17 03:52:28 +00:00 (Migrated from gitlab.com)

Summary

Retail pair volume is still quote-token units (and often raw integers), so UST1/USTR shows an impossible VOL 19297048T and UST1/cUSTC shows vol 48.2m. The dApp must default to 24h volume in USD on Trade pair search and every other retail volume surface.

Related (do not treat as done): #534 human quote-decimal badges (P534-4), #540 Charts stats still formatNum(raw), #522 human/price_usd catalog, #515 oracles (X4 still USTC-only for volume_usd), #243 pair-list rollup, #216 hybrid headline volume (L10).

Current codebase

Indexer — pair list is quote-raw, not USD

GET /api/v1/pairs (indexer/src/api/pairs.rs PairResponse) exposes only volume_quote_24h: a raw quote-side integer from the pair_volume_24h rollup (indexer/migrations/20260531143000_pair_volume_24h_rollup.sql, refresh_pair_volumes). The rollup stores SUM of quote-side offer_amount / return_amount — no volume_usd column. sort=volume_24h compares those raw amounts, so 18-dec USTR always outranks 6-dec cUSTC.

GET /api/v1/pairs/{addr}/stats already has volume_usd (SUM(swap_events.volume_usd)), but ingest is USTC-leg only:

async fn compute_volume_usd(...) -> Option<BigDecimal> {
    let price_usd = ustc_price.read().await.clone()?;
    // ...
    if is_ustc_asset(&offer_asset, ustc_denom) { /* human USTC × USTC oracle */ }
    if is_ustc_asset(&ask_asset, ustc_denom) { /* human USTC × USTC oracle */ }
    None
}

X4 (docs/runbooks/indexer-external-oracle.md): indexer volume_usd uses the USTC feed only. UST1/USTR has neither leg as USTC → volume_usd is NULL. UST1/cUSTC can get a USD sum, but the pair list never returns it, so pickers cannot show it.

#522 already has a quote USD catalog in pair_price_usd.rs (P522-Q): UST1 = $1; USTC/cUSTC/uusd = #515 USTC; LUNC/cLUNC/uluna = #515 LUNC; USTR = 2.5 × USTC. That catalog prices 1 human base, not swap notional. compute_volume_usd does not use it.

Overview GET /api/v1/overview already has total_volume_24h_usd (rollup SUM(volume_usd)). Because ingest is USTC-only, the global USD total under-counts UST1/USTR (and any LUNC-quoted pair without a USTC leg).

Frontend — quote units (or raw) everywhere retail sees “vol”

Surface What it shows today Why it lies
Trade / Limits pair search PairSearchSelect.tsx Badge vol {formatQuoteVolume24h(raw, quoteDecimals)} #534 scaled USTR by 18-dec so the unit test expects vol 19.3 USTR, not USD. Production still reports VOL 19297048T when decimals are missing (?? 6) or an older bundle still formatNum(raw). Either way the retailer reads a token amount, not dollars.
UST1/cUSTC same badge 48.2m Human (or still-raw) cUSTC quote volume. cUSTC ≉ $1; even a correct 48.2M cUSTC print is not 24h USD.
/pool cards PoolPage.tsx 24h vol (quote, indexed): {formatQuoteVolume24h} Same quote-token figure. Sort volume_24h is raw.
/charts 24h stats ChartsPage.tsx formatNum(stats.volume_base) / formatNum(stats.volume_quote) Raw integers → …T on USTR (#540). Stats JSON has volume_usd but the UI ignores it.
/charts overview Primary 24h Volume = formatNum(total_volume_24h) (mixed raw offer_amount); second box is USD Retail default is the meaningless raw sum.
Catalog rank pairCatalogRank.ts P534-3 Human quote raw / 10^decimals Better than raw, still not USD; 1 human USTR ≠ 1 human cUSTC.

Candle histogram (volume_quote / volume_base per bar) is not a pair-search 24h figure — leave it unless converting via price_usd is free. CG/CMC base_volume / target_volume stay raw quote/base (do not silently switch aggregators to USD).

IndexerPair in frontend-dapp/src/types/index.ts has no volume_usd_24h. IndexerPairStats omits volume_usd even though the API returns it.

Why this is needed

  1. Impossible prints destroy trust. 19 million trillion USTR of 24h volume cannot be real. 48.2m on UST1/cUSTC is the same class: a token amount presented as “VOL” with no unit and no dollars.
  2. #534 is not the product fix. Scaling raw USTR by 18 decimals yields ~19.3 USTR, not USD. Mixed 6/18-dec markets remain incomparable. #540 only asks Charts to apply the same quote-decimal helper.
  3. USD is already the language of Price. #522 / #524 made Price (USD) the retail default. Volume still speaks USTR/cUSTC/raw. Retailers cannot compare UST1/USTR vs UST1/cUSTC vs cLUNC/UST1.
  4. Indexer already stores per-swap volume_usd — it is just USTC-only and not on the pair list the pickers call. Without expanding the catalog, UST1/USTR can never show a truthful USD badge.
  5. Wash / rank distortion. Raw sort=volume_24h and human-quote catalog rank let an 18-dec quote look like the deepest market.

Constraints / guardrails

  1. USD is advisory, not settlement (same as X5 / P522). On-chain swaps still use max_spread / min_return / deadlines. Label 24h vol (USD) or badge vol $… — never imply a peg guarantee.
  2. Reuse P522-Q. Do not invent a second USD catalog. Wire compute_volume_usd through quote_usd_kind / usd_per_human_quote in pair_price_usd.rs. Unknown quotes (gems, hostile tickers) → volume_usd NULL, not $0, not a guessed price.
  3. Classify by factory asset row (symbol and denom / contract), never by the UI invert label (#524). A pair that displays as USTR/UST1 is still asset_0/asset_1 for ingest.
  4. One notional per swap (L10). volume_usd is the USD of the consolidated offer_amount/return_amount once. Do not add pool_leg + book_leg or limit_order_fills. Prefer the catalog-known oriented quote (human quote volume × quote USD). If only the other leg is in the catalog, use that leg. If both are known, use one side (quote preferred) — never sum both.
  5. Human amounts before × USD. Divide by 10^decimals of the priced leg (not a hardcoded 1_000_000). USTR is 18-dec; UST1/cUSTC/cLUNC are 6. Do not reuse the USTC-only decimals_factor = 1_000_000.
  6. Pair-list JSON stays additive. Keep volume_quote_24h (raw) for integrators / #534 tests. Add volume_usd_24h (human USD string, same scale as stats/overview). Do not humanize volume_quote_24h in the indexer.
  7. Rollup, not live scan (V1). Extend pair_volume_24h + refresh_pair_volumes (~5 min). Pair list must not SUM(swap_events) per request. Pagination caps (V4) unchanged.
  8. Backfill required. Historical UST1/USTR rows have NULL volume_usd. Migration/job must recompute from stored amounts + catalog + latest (or snapshot) oracles, then refresh rollups. Document that USD is as-of ingest/backfill oracles, not a live mark-to-market of old trades.
  9. Missing USD → hide, don’t lie. Badge omitted (same as today’s zero-quote hide). Stats/overview show —, not $0.00, when the pair has quote volume but no catalog USD. Do not fall back to formatNum(raw) or 19297048T.
  10. Do not change CG/CMC / integrator headline fields to USD (base_volume, target_volume, last_price). Optional volume_usd on those feeds already exists where documented — do not swap units.
  11. Do not change candle OHLC math, #522 price / price_usd, or hybrid attribution columns.
  12. Retail copy (#489). Badge vol $12.4K or 24h vol $12.4K. No “oracle-valued quote-side notional” essays. Tooltip ≤ one short sentence if needed (24h volume in USD).
  13. Compact format. Shared helper (e.g. formatUsdVolume24h): $ + formatNum on a human USD number, 2–3 sigfigs. Never pass a raw 18-dec integer in. Reject non-finite / negative.
  14. Sort. dApp empty browse / catalog P534-3 should rank by USD 24h when present, else existing human-quote fallback. Optional indexer sort=volume_usd_24h; do not break sort=volume_24h clients. /pool user-selected sort may keep volume_24h or switch the label to USD once the field exists — pick one and document it.
  15. Docs + skill + make verify-issue-544 in the same MR. Invariants V544-1–V544-8. Update X4, docs/indexer-invariants.md pair-list row, docs/frontend.md P534-4, and skills/AGENTS_FRONTEND_PAIR_CATALOG_RANK.md.
  16. #540: implement USD on Charts stats in this issue (primary Vol (USD)). Quote/base token vols may remain as secondary compact human figures (formatQuoteVolume24h / base decimals) — that satisfies #540’s …T bug without making quote the default.

Relevant files

File Role
indexer/src/indexer/parser.rs compute_volume_usd (USTC-only today)
indexer/src/indexer/pair_price_usd.rs P522-Q catalog to reuse
indexer/src/db/queries/volume.rs refresh_pair_volumes / global USD rollup
indexer/src/db/queries/pairs.rs Pair list JOIN volume_quote_24h
indexer/src/db/queries/swap_events.rs get_24h_stats_for_pair.volume_usd
indexer/src/api/pairs.rs PairResponse / PairStatsResponse
indexer/migrations/20260531143000_pair_volume_24h_rollup.sql Add volume_usd + index (new migration, do not edit old)
indexer/tests/indexer_pair_volume_pagination.rs Rollup still no live swap_events scan
indexer/tests/api_pairs.rs List JSON additive field
indexer/tests/api_oracle.rs Existing stats volume_usd
frontend-dapp/src/components/trade/PairSearchSelect.tsx Trade / Limits VOL badge
frontend-dapp/src/utils/formatAmount.ts Add USD vol helper; keep formatQuoteVolume24h for secondary
frontend-dapp/src/utils/pairCatalogRank.ts Rank by USD when present
frontend-dapp/src/types/index.ts volume_usd_24h on IndexerPair; volume_usd on stats
frontend-dapp/src/pages/PoolPage.tsx Card 24h vol
frontend-dapp/src/pages/ChartsPage.tsx Overview + pair 24h stats
frontend-dapp/src/components/trade/__tests__/PairSearchSelect.issue534.test.tsx Update: no T; USD badge for USTR fixture
docs/frontend.md Pair search / catalog rank
docs/indexer-invariants.md Pair list + X4
skills/AGENTS_FRONTEND_PAIR_CATALOG_RANK.md Vol badge now USD
skills/AGENTS_INDEXER_PAIR_PRICE_USD.md Cross-link volume vs price
skills/AGENTS_INDEXER_EXTERNAL_ORACLE.md X4 update
skills/AGENTS_INDEXER_VOLUME_PAGINATION.md Rollup column
  1. Ingest: replace USTC-only compute_volume_usd with catalog conversion of human oriented quote (else offer) × usd_per_human_quote. Unit-test UST1/USTR (USTR × 2.5 × USTC), UST1/cUSTC (cUSTC × USTC), cLUNC/UST1 (UST1 = $1), gem/unknown → None. Oracle missing for a needed ticker → None (do not use a stale hardcoded peg except UST1 = $1 and the documented USTR multiplier).
  2. Backfill swap_events.volume_usd where NULL and a catalog leg exists; rebuild candles only if this issue touches them (it should not). Refresh pair_volume_24h and global_stats_24h.
  3. Rollup + API: new migration pair_volume_24h.volume_usd. PairResponse.volume_usd_24h: Option<String> (skip_serializing_if). Stats already have volume_usd — keep the name; frontend types must include it.
  4. Frontend helper formatUsdVolume24h(usd: string | null): string | null → $19.3 / $48.2K / $1.20M. Shared by picker, pool, charts.
  5. Picker: badge uses volume_usd_24h. Title 24h volume (USD). No badge when null/0. Offline factory fallback: no invented USD.
  6. Pool: default card line 24h vol $…. Keep quote vol in title or <details> if useful.
  7. Charts: overview 24h Volume uses total_volume_24h_usd (drop or demote the raw box). Pair stats: primary Vol (USD) from stats.volume_usd; optional secondary human base/quote (#540).
  8. Rank: PairCatalogVolume gains usd; compare USD desc, then human quote, then symbol.
  9. Docs/skill/verify as in guardrail 15.

Invariants:

ID Meaning
V544-1 Every retail pair 24h volume control (Trade/Limits search badge, Pool card, Charts overview + pair stats) defaults to USD, labeled as USD.
V544-2 UST1/USTR and UST1/cUSTC never show …T / impossible trillions. USTR badge is catalog USD (USTR × 2.5 × USTC), not raw and not “19.3 USTR” as the default.
V544-3 volume_usd ingest uses P522-Q + human decimals; unknown/gem → NULL.
V544-4 One USD notional per swap_events row (L10). No fill/leg double count.
V544-5 Pair list stays rollup-backed; volume_quote_24h raw unchanged; volume_usd_24h additive.
V544-6 Missing USD → omit / —, never $0 and never formatNum(raw).
V544-7 CG/CMC headline volumes stay quote/base raw.
V544-8 Display invert does not change which USD figure is shown (pair notional is pair-level).

Acceptance criteria

  • AC1 — Trade (and Limits) pair search: UST1/USTR badge is compact USD (e.g. $19.3 / $1.2K). Must not match /19,?297,?048T/i or /vol\s+[\d.,]+T/i.
  • AC2 — UST1/cUSTC badge is compact USD from cUSTC × USTC oracle (or UST1 × $1 — one documented side), not 48.2m of cUSTC. Order of magnitude matches oracle × human quote, not raw 6-dec dump.
  • AC3 — /pool selected / card 24h vol defaults to the same USD helper. Quote-token remainder is secondary or hidden.
  • AC4 — /charts overview primary 24h volume is total_volume_24h_usd. /charts pair 24h stats primary vol is stats.volume_usd. Raw formatNum(volume_quote) is gone from the default row.
  • AC5 — Gem / unknown-quote pairs: no USD badge (and no $0). Economic catalog pairs with oracle down: — / hidden, not a T fallback.
  • AC6 — GET /api/v1/pairs items include volume_usd_24h when the rollup has it; volume_quote_24h still raw. sort=volume_24h still works.
  • AC7 — Empty pair browse rank uses USD when present (P534-1/2 economic/hub grouping unchanged).
  • AC8 — Hybrid swap: USD equals consolidated swap notional, not pool+book+fills.
  • AC9 — Docs, skill, make verify-issue-544, X4 / pair-list invariant updated.
  • AC10 — #534 regression: compact T must not return on USTR fixtures even if USD is mocked.

Test plan (all paths)

Indexer unit / lib

  • compute_volume_usd / catalog helper: UST1 offer → human UST1 × $1; USTR ask → human USTR × 2.5 × USTC; cUSTC leg → human cUSTC × USTC; cLUNC × LUNC; gem/unknown → None; USTC oracle missing and kind needs it → None; UST1 still works without oracle.
  • Decimals: 18-dec USTR raw 19300000000000000000 → human 19.3 × quote USD; 6-dec must not apply 1e6 to USTR.
  • Both legs catalog-known: one side only (quote preferred); assert not 2×.
  • Zero / negative / non-finite oracle → None.

Indexer integration (--test-threads=1)

  • Seed UST1/USTR + UST1/cUSTC swaps; refresh_pair_volumes; GET /api/v1/pairs returns volume_usd_24h matching SUM(volume_usd) and still raw volume_quote_24h.
  • GET /api/v1/pairs/{addr}/stats.volume_usd matches rollup for those pairs.
  • EXPLAIN pair list: no per-request 24h swap_events aggregate (indexer_pair_volume_pagination.rs).
  • Overview total_volume_24h_usd includes UST1/USTR after catalog ingest (regression vs USTC-only under-count).
  • Hybrid fixture: USD == consolidated row, not legs+fills (L10).
  • Backfill: NULL volume_usd on a catalog pair becomes non-null; gem stays NULL.
  • sort=volume_24h + pagination caps unchanged; invalid sort still 400.

Frontend unit

  • formatUsdVolume24h: 19.3 → $19.3; 48200 → $48.2K; 0 / '' / NaN / -1 → null; never T from a human USD in normal range.
  • PairSearchSelect: mocked volume_usd_24h renders vol $…; USTR raw-only fixture (no USD) renders no T badge (hide).
  • Catalog rank: pair with $100 ranks above pair with $10 even if quote-raw is larger (18-dec).
  • Pool card / Charts stats tests: primary string is USD; #540 raw formatNum(volume_quote) assertion inverted.
  • Charts overview uses total_volume_24h_usd for the primary Volume box.
  • Invert (#524): badge value unchanged when the pill flips.

Frontend / QA visual

  • make dev + indexer: open /trade pair search — UST1/USTR and UST1/cUSTC badges are $…, not 19297048T / 48.2m.
  • /limits same combobox.
  • /pool cards and selected-pair 24h line.
  • /charts overview + pair stats.
  • Oracle down / indexer 500: degraded pair search (offline factory) has no fake USD; Charts outage banner unchanged.
  • Empty volume (new pair): no badge, not $0.00.

Regression commands

make verify-issue-534   # compact T must stay gone
# after this issue:
make verify-issue-544
cd indexer && cargo test --lib pair_price_usd -- --quiet
cd indexer && cargo test --test indexer_pair_volume_pagination --test api_pairs --test api_oracle -- --test-threads=1

(Optional live) VERIFY540 / Charts UST1/USTR: primary vol is USD.

Test plan (attack, hack, abuse)

Vector Expectation
A1 Symbol spoof — hostile CW20 symbol=UST1 / USTR / cUSTC not in factory catalog quote_usd_kind must not price it as the real hub. Prefer denom/contract allowlist used for wrap/registry assets; unknown contract + spoofed ticker → volume_usd NULL. Test a gem renamed UST1 in fixtures.
A2 Double notional — hybrid pool+book swap + fill rows USD = one swap row. Adding fills or legs must fail the test.
A3 Oracle / peg game — attacker pumps a thin USTR print or relies on 2.5× USTC USD is advisory; do not use volume_usd for settlement, fee, or on-chain limits. Document USTR multiplier. Stale/missing oracle → NULL, not last-good infinite reuse without a documented TTL (follow #515 cache rules).
A4 Wash rank — many tiny swaps to win picker sort Rank may use USD (same wash surface as today). Do not add client-side “trust” of user-supplied volume. Pair list values come only from indexer JSON, never from query-string injection.
A5 JSON injection — volume_usd_24h = "><script> / huge string / 1e309 Parse as decimal; invalid → hide badge. No dangerouslySetInnerHTML. Cap display length.
A6 Overflow / DoS — 38-digit raw passed to USD formatter Helper must not lock the tab or print …T from raw. Pair list stays rollup (V1); reject widening to live 24h scans.
A7 Unit confusion — client multiplies already-human volume_usd by 1e6 or by price_usd again Tests: stats volume_usd is human USD; UI does not × decimals or × price.
A8 Invert / wrong leg — UI invert causes UST1/USTR to use the other token’s decimals in a fallback No quote-decimal fallback on the USD badge. Invert does not swap the USD number.
A9 Integrator break — CG/CMC target_volume switched to USD Assert ticker fields still raw quote/base.
A10 Auth / cache — pair list is public No new secrets. Do not cache a single pair’s USD onto another address (key by pair_address).
A11 Negative / dust — volume_usd = -1 or dust 1e-18 Hide or clamp; never negative compact.
A12 Replay backfill — running backfill twice Idempotent; totals do not double.

Verification criteria

Issue is done when:

  1. A reviewer on mainnet or LocalTerra with real UST1/USTR + UST1/cUSTC volume opens /trade pair search and sees USD badges whose magnitude matches GET /api/v1/pairs/{addr}/stats volume_usd (not quote units, not T).
  2. /pool and /charts primary 24h volume match that same USD number (rollup freshness ≤ ~5 min is OK).
  3. make verify-issue-544 and listed indexer/frontend tests pass in CI.
  4. Docs/skills state: pair-list volume_quote_24h remains raw; retail default is volume_usd_24h; X4 is catalog (not USTC-only); L10 / CG units unchanged.
  5. Abuse cases A1, A2, A5, A6, A7, A9 have automated coverage; A3/A4/A8/A10–A12 are tested or explicitly waived in the MR with a reason.

Out of scope: changing candle histogram units; trader leaderboard total_volume (follow-up if it still prints raw offer sums); inventing USD for faucet gems; using USD volume in fee/settlement math.

## Summary Retail pair volume is still **quote-token units** (and often **raw integers**), so UST1/USTR shows an impossible **VOL 19297048T** and UST1/cUSTC shows **vol 48.2m**. The dApp must **default to 24h volume in USD** on Trade pair search and every other retail volume surface. Related (do not treat as done): [#534](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/534) human quote-decimal badges (**P534-4**), [#540](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/540) Charts stats still `formatNum(raw)`, [#522](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/522) human/`price_usd` catalog, [#515](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/515) oracles (**X4** still USTC-only for `volume_usd`), [#243](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/243) pair-list rollup, [#216](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/216) hybrid headline volume (**L10**). ## Current codebase ### Indexer — pair list is quote-raw, not USD `GET /api/v1/pairs` ([`indexer/src/api/pairs.rs`](indexer/src/api/pairs.rs) `PairResponse`) exposes only `volume_quote_24h`: a **raw quote-side integer** from the `pair_volume_24h` rollup ([`indexer/migrations/20260531143000_pair_volume_24h_rollup.sql`](indexer/migrations/20260531143000_pair_volume_24h_rollup.sql), [`refresh_pair_volumes`](indexer/src/db/queries/volume.rs)). The rollup stores `SUM` of quote-side `offer_amount` / `return_amount` — **no `volume_usd` column**. `sort=volume_24h` compares those raw amounts, so 18-dec USTR always outranks 6-dec cUSTC. `GET /api/v1/pairs/{addr}/stats` already has `volume_usd` (`SUM(swap_events.volume_usd)`), but ingest is **USTC-leg only**: ```518:545:indexer/src/indexer/parser.rs async fn compute_volume_usd(...) -> Option<BigDecimal> { let price_usd = ustc_price.read().await.clone()?; // ... if is_ustc_asset(&offer_asset, ustc_denom) { /* human USTC × USTC oracle */ } if is_ustc_asset(&ask_asset, ustc_denom) { /* human USTC × USTC oracle */ } None } ``` **X4** ([`docs/runbooks/indexer-external-oracle.md`](docs/runbooks/indexer-external-oracle.md)): indexer `volume_usd` uses the **USTC** feed only. UST1/USTR has **neither** leg as USTC → `volume_usd` is **NULL**. UST1/cUSTC can get a USD sum, but the pair **list** never returns it, so pickers cannot show it. [#522](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/522) already has a quote USD catalog in [`pair_price_usd.rs`](indexer/src/indexer/pair_price_usd.rs) (**P522-Q**): UST1 = `$1`; USTC/cUSTC/`uusd` = #515 USTC; LUNC/cLUNC/`uluna` = #515 LUNC; USTR = `2.5 ×` USTC. That catalog prices **1 human base**, not swap notional. `compute_volume_usd` does **not** use it. Overview `GET /api/v1/overview` already has `total_volume_24h_usd` (rollup `SUM(volume_usd)`). Because ingest is USTC-only, the global USD total **under-counts** UST1/USTR (and any LUNC-quoted pair without a USTC leg). ### Frontend — quote units (or raw) everywhere retail sees “vol” | Surface | What it shows today | Why it lies | |---------|---------------------|-------------| | **Trade / Limits pair search** [`PairSearchSelect.tsx`](frontend-dapp/src/components/trade/PairSearchSelect.tsx) | Badge `vol {formatQuoteVolume24h(raw, quoteDecimals)}` | #534 scaled USTR by 18-dec so the unit test expects `vol 19.3` **USTR**, not USD. Production still reports **VOL 19297048T** when decimals are missing (`?? 6`) or an older bundle still `formatNum(raw)`. Either way the retailer reads a token amount, not dollars. | | **UST1/cUSTC** same badge | **48.2m** | Human (or still-raw) **cUSTC** quote volume. cUSTC ≉ $1; even a correct 48.2M cUSTC print is not 24h USD. | | **`/pool` cards** [`PoolPage.tsx`](frontend-dapp/src/pages/PoolPage.tsx) | `24h vol (quote, indexed): {formatQuoteVolume24h}` | Same quote-token figure. Sort `volume_24h` is raw. | | **`/charts` 24h stats** [`ChartsPage.tsx`](frontend-dapp/src/pages/ChartsPage.tsx) | `formatNum(stats.volume_base)` / `formatNum(stats.volume_quote)` | **Raw** integers → `…T` on USTR ([#540](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/540)). Stats JSON has `volume_usd` but the UI ignores it. | | **`/charts` overview** | Primary **24h Volume** = `formatNum(total_volume_24h)` (mixed raw `offer_amount`); second box is USD | Retail default is the meaningless raw sum. | | **Catalog rank** [`pairCatalogRank.ts`](frontend-dapp/src/utils/pairCatalogRank.ts) **P534-3** | Human quote `raw / 10^decimals` | Better than raw, still not USD; 1 human USTR ≠ 1 human cUSTC. | Candle histogram (`volume_quote` / `volume_base` per bar) is **not** a pair-search 24h figure — leave it unless converting via `price_usd` is free. CG/CMC `base_volume` / `target_volume` stay raw quote/base (**do not** silently switch aggregators to USD). `IndexerPair` in [`frontend-dapp/src/types/index.ts`](frontend-dapp/src/types/index.ts) has no `volume_usd_24h`. `IndexerPairStats` omits `volume_usd` even though the API returns it. ## Why this is needed 1. **Impossible prints destroy trust.** 19 million trillion USTR of 24h volume cannot be real. 48.2m on UST1/cUSTC is the same class: a token amount presented as “VOL” with no unit and no dollars. 2. **#534 is not the product fix.** Scaling raw USTR by 18 decimals yields ~19.3 **USTR**, not USD. Mixed 6/18-dec markets remain incomparable. #540 only asks Charts to apply the same quote-decimal helper. 3. **USD is already the language of Price.** #522 / #524 made **Price (USD)** the retail default. Volume still speaks USTR/cUSTC/raw. Retailers cannot compare UST1/USTR vs UST1/cUSTC vs cLUNC/UST1. 4. **Indexer already stores per-swap `volume_usd`** — it is just USTC-only and not on the pair list the pickers call. Without expanding the catalog, UST1/USTR can never show a truthful USD badge. 5. **Wash / rank distortion.** Raw `sort=volume_24h` and human-quote catalog rank let an 18-dec quote look like the deepest market. ## Constraints / guardrails 1. **USD is advisory, not settlement** (same as **X5** / **P522**). On-chain swaps still use `max_spread` / `min_return` / deadlines. Label **24h vol (USD)** or badge `vol $…` — never imply a peg guarantee. 2. **Reuse P522-Q.** Do not invent a second USD catalog. Wire `compute_volume_usd` through `quote_usd_kind` / `usd_per_human_quote` in [`pair_price_usd.rs`](indexer/src/indexer/pair_price_usd.rs). Unknown quotes (gems, hostile tickers) → `volume_usd` **NULL**, not `$0`, not a guessed price. 3. **Classify by factory asset row** (symbol **and** denom / contract), never by the UI invert label ([#524](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/524)). A pair that *displays* as USTR/UST1 is still `asset_0`/`asset_1` for ingest. 4. **One notional per swap (L10).** `volume_usd` is the USD of the **consolidated** `offer_amount`/`return_amount` once. Do **not** add `pool_leg` + `book_leg` or `limit_order_fills`. Prefer the catalog-known **oriented quote** (human quote volume × quote USD). If only the other leg is in the catalog, use that leg. If both are known, use **one** side (quote preferred) — never sum both. 5. **Human amounts before × USD.** Divide by `10^decimals` of the priced leg (not a hardcoded `1_000_000`). USTR is 18-dec; UST1/cUSTC/cLUNC are 6. Do not reuse the USTC-only `decimals_factor = 1_000_000`. 6. **Pair-list JSON stays additive.** Keep `volume_quote_24h` (raw) for integrators / #534 tests. Add `volume_usd_24h` (human USD string, same scale as stats/`overview`). Do not humanize `volume_quote_24h` in the indexer. 7. **Rollup, not live scan (V1).** Extend `pair_volume_24h` + `refresh_pair_volumes` (~5 min). Pair list must **not** `SUM(swap_events)` per request. Pagination caps (**V4**) unchanged. 8. **Backfill required.** Historical UST1/USTR rows have NULL `volume_usd`. Migration/job must recompute from stored amounts + catalog + latest (or snapshot) oracles, then refresh rollups. Document that USD is as-of ingest/backfill oracles, not a live mark-to-market of old trades. 9. **Missing USD → hide, don’t lie.** Badge omitted (same as today’s zero-quote hide). Stats/overview show **—**, not `$0.00`, when the pair has quote volume but no catalog USD. Do **not** fall back to `formatNum(raw)` or `19297048T`. 10. **Do not change CG/CMC / integrator headline fields** to USD (`base_volume`, `target_volume`, `last_price`). Optional `volume_usd` on those feeds already exists where documented — do not swap units. 11. **Do not change** candle OHLC math, #522 `price` / `price_usd`, or hybrid attribution columns. 12. **Retail copy (#489).** Badge `vol $12.4K` or `24h vol $12.4K`. No “oracle-valued quote-side notional” essays. Tooltip ≤ one short sentence if needed (`24h volume in USD`). 13. **Compact format.** Shared helper (e.g. `formatUsdVolume24h`): `$` + `formatNum` on a **human USD** number, 2–3 sigfigs. Never pass a raw 18-dec integer in. Reject non-finite / negative. 14. **Sort.** dApp empty browse / catalog **P534-3** should rank by **USD 24h** when present, else existing human-quote fallback. Optional indexer `sort=volume_usd_24h`; do not break `sort=volume_24h` clients. `/pool` user-selected sort may keep `volume_24h` **or** switch the label to USD once the field exists — pick one and document it. 15. **Docs + skill + `make verify-issue-544`** in the same MR. Invariants **V544-1–V544-8**. Update **X4**, [`docs/indexer-invariants.md`](docs/indexer-invariants.md) pair-list row, [`docs/frontend.md`](docs/frontend.md) **P534-4**, and [`skills/AGENTS_FRONTEND_PAIR_CATALOG_RANK.md`](skills/AGENTS_FRONTEND_PAIR_CATALOG_RANK.md). 16. **#540:** implement USD on Charts stats in **this** issue (primary **Vol (USD)**). Quote/base token vols may remain as secondary compact **human** figures (`formatQuoteVolume24h` / base decimals) — that satisfies #540’s `…T` bug without making quote the default. ## Relevant files | File | Role | |------|------| | [`indexer/src/indexer/parser.rs`](indexer/src/indexer/parser.rs) | `compute_volume_usd` (USTC-only today) | | [`indexer/src/indexer/pair_price_usd.rs`](indexer/src/indexer/pair_price_usd.rs) | P522-Q catalog to reuse | | [`indexer/src/db/queries/volume.rs`](indexer/src/db/queries/volume.rs) | `refresh_pair_volumes` / global USD rollup | | [`indexer/src/db/queries/pairs.rs`](indexer/src/db/queries/pairs.rs) | Pair list JOIN `volume_quote_24h` | | [`indexer/src/db/queries/swap_events.rs`](indexer/src/db/queries/swap_events.rs) | `get_24h_stats_for_pair.volume_usd` | | [`indexer/src/api/pairs.rs`](indexer/src/api/pairs.rs) | `PairResponse` / `PairStatsResponse` | | [`indexer/migrations/20260531143000_pair_volume_24h_rollup.sql`](indexer/migrations/20260531143000_pair_volume_24h_rollup.sql) | Add `volume_usd` + index (new migration, do not edit old) | | [`indexer/tests/indexer_pair_volume_pagination.rs`](indexer/tests/indexer_pair_volume_pagination.rs) | Rollup still no live `swap_events` scan | | [`indexer/tests/api_pairs.rs`](indexer/tests/api_pairs.rs) | List JSON additive field | | [`indexer/tests/api_oracle.rs`](indexer/tests/api_oracle.rs) | Existing stats `volume_usd` | | [`frontend-dapp/src/components/trade/PairSearchSelect.tsx`](frontend-dapp/src/components/trade/PairSearchSelect.tsx) | Trade / Limits VOL badge | | [`frontend-dapp/src/utils/formatAmount.ts`](frontend-dapp/src/utils/formatAmount.ts) | Add USD vol helper; keep `formatQuoteVolume24h` for secondary | | [`frontend-dapp/src/utils/pairCatalogRank.ts`](frontend-dapp/src/utils/pairCatalogRank.ts) | Rank by USD when present | | [`frontend-dapp/src/types/index.ts`](frontend-dapp/src/types/index.ts) | `volume_usd_24h` on `IndexerPair`; `volume_usd` on stats | | [`frontend-dapp/src/pages/PoolPage.tsx`](frontend-dapp/src/pages/PoolPage.tsx) | Card 24h vol | | [`frontend-dapp/src/pages/ChartsPage.tsx`](frontend-dapp/src/pages/ChartsPage.tsx) | Overview + pair 24h stats | | [`frontend-dapp/src/components/trade/__tests__/PairSearchSelect.issue534.test.tsx`](frontend-dapp/src/components/trade/__tests__/PairSearchSelect.issue534.test.tsx) | Update: no `T`; USD badge for USTR fixture | | [`docs/frontend.md`](docs/frontend.md) | Pair search / catalog rank | | [`docs/indexer-invariants.md`](docs/indexer-invariants.md) | Pair list + X4 | | [`skills/AGENTS_FRONTEND_PAIR_CATALOG_RANK.md`](skills/AGENTS_FRONTEND_PAIR_CATALOG_RANK.md) | Vol badge now USD | | [`skills/AGENTS_INDEXER_PAIR_PRICE_USD.md`](skills/AGENTS_INDEXER_PAIR_PRICE_USD.md) | Cross-link volume vs price | | [`skills/AGENTS_INDEXER_EXTERNAL_ORACLE.md`](skills/AGENTS_INDEXER_EXTERNAL_ORACLE.md) | X4 update | | [`skills/AGENTS_INDEXER_VOLUME_PAGINATION.md`](skills/AGENTS_INDEXER_VOLUME_PAGINATION.md) | Rollup column | ## Recommended direction 1. **Ingest:** replace USTC-only `compute_volume_usd` with catalog conversion of **human** oriented quote (else offer) × `usd_per_human_quote`. Unit-test UST1/USTR (USTR × 2.5 × USTC), UST1/cUSTC (cUSTC × USTC), cLUNC/UST1 (UST1 = $1), gem/unknown → `None`. Oracle missing for a needed ticker → `None` (do not use a stale hardcoded peg except UST1 = $1 and the documented USTR multiplier). 2. **Backfill** `swap_events.volume_usd` where NULL and a catalog leg exists; rebuild candles **only if** this issue touches them (it should not). Refresh `pair_volume_24h` and `global_stats_24h`. 3. **Rollup + API:** new migration `pair_volume_24h.volume_usd`. `PairResponse.volume_usd_24h: Option<String>` (`skip_serializing_if`). Stats already have `volume_usd` — keep the name; frontend types must include it. 4. **Frontend helper** `formatUsdVolume24h(usd: string | null): string | null` → `$19.3` / `$48.2K` / `$1.20M`. Shared by picker, pool, charts. 5. **Picker:** badge uses `volume_usd_24h`. Title `24h volume (USD)`. No badge when null/0. Offline factory fallback: no invented USD. 6. **Pool:** default card line `24h vol $…`. Keep quote vol in `title` or `<details>` if useful. 7. **Charts:** overview **24h Volume** uses `total_volume_24h_usd` (drop or demote the raw box). Pair stats: primary **Vol (USD)** from `stats.volume_usd`; optional secondary human base/quote (**#540**). 8. **Rank:** `PairCatalogVolume` gains `usd`; compare USD desc, then human quote, then symbol. 9. **Docs/skill/verify** as in guardrail 15. Invariants: | ID | Meaning | |----|---------| | **V544-1** | Every retail **pair 24h volume** control (Trade/Limits search badge, Pool card, Charts overview + pair stats) defaults to **USD**, labeled as USD. | | **V544-2** | UST1/USTR and UST1/cUSTC never show `…T` / impossible trillions. USTR badge is catalog USD (USTR × 2.5 × USTC), not raw and not “19.3 USTR” as the default. | | **V544-3** | `volume_usd` ingest uses P522-Q + human decimals; unknown/gem → NULL. | | **V544-4** | One USD notional per `swap_events` row (**L10**). No fill/leg double count. | | **V544-5** | Pair list stays rollup-backed; `volume_quote_24h` raw unchanged; `volume_usd_24h` additive. | | **V544-6** | Missing USD → omit / **—**, never `$0` and never `formatNum(raw)`. | | **V544-7** | CG/CMC headline volumes stay quote/base raw. | | **V544-8** | Display invert does not change which USD figure is shown (pair notional is pair-level). | ## Acceptance criteria - [ ] **AC1** — Trade (and Limits) pair search: UST1/USTR badge is compact **USD** (e.g. `$19.3` / `$1.2K`). **Must not** match `/19,?297,?048T/i` or `/vol\s+[\d.,]+T/i`. - [ ] **AC2** — UST1/cUSTC badge is compact **USD** from cUSTC × USTC oracle (or UST1 × $1 — one documented side), **not** `48.2m` of cUSTC. Order of magnitude matches oracle × human quote, not raw 6-dec dump. - [ ] **AC3** — `/pool` selected / card 24h vol defaults to the same USD helper. Quote-token remainder is secondary or hidden. - [ ] **AC4** — `/charts` overview primary 24h volume is `total_volume_24h_usd`. `/charts` pair 24h stats primary vol is `stats.volume_usd`. Raw `formatNum(volume_quote)` is gone from the default row. - [ ] **AC5** — Gem / unknown-quote pairs: no USD badge (and no `$0`). Economic catalog pairs with oracle down: **—** / hidden, not a `T` fallback. - [ ] **AC6** — `GET /api/v1/pairs` items include `volume_usd_24h` when the rollup has it; `volume_quote_24h` still raw. `sort=volume_24h` still works. - [ ] **AC7** — Empty pair browse rank uses USD when present (**P534-1/2** economic/hub grouping unchanged). - [ ] **AC8** — Hybrid swap: USD equals consolidated swap notional, not pool+book+fills. - [ ] **AC9** — Docs, skill, `make verify-issue-544`, **X4** / pair-list invariant updated. - [ ] **AC10** — #534 regression: compact `T` must not return on USTR fixtures even if USD is mocked. ## Test plan (all paths) ### Indexer unit / lib - [ ] `compute_volume_usd` / catalog helper: UST1 offer → human UST1 × $1; USTR ask → human USTR × 2.5 × USTC; cUSTC leg → human cUSTC × USTC; cLUNC × LUNC; gem/unknown → `None`; USTC oracle missing and kind needs it → `None`; UST1 still works without oracle. - [ ] Decimals: 18-dec USTR raw `19300000000000000000` → human `19.3` × quote USD; 6-dec must not apply `1e6` to USTR. - [ ] Both legs catalog-known: **one** side only (quote preferred); assert not 2×. - [ ] Zero / negative / non-finite oracle → `None`. ### Indexer integration (`--test-threads=1`) - [ ] Seed UST1/USTR + UST1/cUSTC swaps; `refresh_pair_volumes`; `GET /api/v1/pairs` returns `volume_usd_24h` matching `SUM(volume_usd)` and still raw `volume_quote_24h`. - [ ] `GET /api/v1/pairs/{addr}/stats.volume_usd` matches rollup for those pairs. - [ ] EXPLAIN pair list: **no** per-request 24h `swap_events` aggregate ([`indexer_pair_volume_pagination.rs`](indexer/tests/indexer_pair_volume_pagination.rs)). - [ ] Overview `total_volume_24h_usd` includes UST1/USTR after catalog ingest (regression vs USTC-only under-count). - [ ] Hybrid fixture: USD == consolidated row, not legs+fills (**L10**). - [ ] Backfill: NULL `volume_usd` on a catalog pair becomes non-null; gem stays NULL. - [ ] `sort=volume_24h` + pagination caps unchanged; invalid `sort` still 400. ### Frontend unit - [ ] `formatUsdVolume24h`: `19.3` → `$19.3`; `48200` → `$48.2K`; `0` / `''` / `NaN` / `-1` → `null`; never `T` from a human USD in normal range. - [ ] `PairSearchSelect`: mocked `volume_usd_24h` renders `vol $…`; USTR raw-only fixture (no USD) renders **no** `T` badge (hide). - [ ] Catalog rank: pair with `$100` ranks above pair with `$10` even if quote-raw is larger (18-dec). - [ ] Pool card / Charts stats tests: primary string is USD; #540 raw `formatNum(volume_quote)` assertion inverted. - [ ] Charts overview uses `total_volume_24h_usd` for the primary Volume box. - [ ] Invert (#524): badge value unchanged when the pill flips. ### Frontend / QA visual - [ ] `make dev` + indexer: open `/trade` pair search — UST1/USTR and UST1/cUSTC badges are `$…`, not `19297048T` / `48.2m`. - [ ] `/limits` same combobox. - [ ] `/pool` cards and selected-pair 24h line. - [ ] `/charts` overview + pair stats. - [ ] Oracle down / indexer 500: degraded pair search (offline factory) has no fake USD; Charts outage banner unchanged. - [ ] Empty volume (new pair): no badge, not `$0.00`. ### Regression commands ```bash make verify-issue-534 # compact T must stay gone # after this issue: make verify-issue-544 cd indexer && cargo test --lib pair_price_usd -- --quiet cd indexer && cargo test --test indexer_pair_volume_pagination --test api_pairs --test api_oracle -- --test-threads=1 ``` (Optional live) `VERIFY540` / Charts UST1/USTR: primary vol is USD. ## Test plan (attack, hack, abuse) | Vector | Expectation | |--------|-------------| | **A1 Symbol spoof** — hostile CW20 `symbol=UST1` / `USTR` / `cUSTC` not in factory catalog | `quote_usd_kind` must not price it as the real hub. Prefer denom/contract allowlist used for wrap/registry assets; unknown contract + spoofed ticker → `volume_usd` NULL. Test a gem renamed UST1 in fixtures. | | **A2 Double notional** — hybrid pool+book swap + fill rows | USD = one swap row. Adding fills or legs must fail the test. | | **A3 Oracle / peg game** — attacker pumps a thin USTR print or relies on 2.5× USTC | USD is advisory; do not use volume_usd for settlement, fee, or on-chain limits. Document USTR multiplier. Stale/missing oracle → NULL, not last-good infinite reuse without a documented TTL (follow #515 cache rules). | | **A4 Wash rank** — many tiny swaps to win picker sort | Rank may use USD (same wash surface as today). Do not add client-side “trust” of user-supplied volume. Pair list values come only from indexer JSON, never from query-string injection. | | **A5 JSON injection** — `volume_usd_24h` = `"><script>` / huge string / `1e309` | Parse as decimal; invalid → hide badge. No `dangerouslySetInnerHTML`. Cap display length. | | **A6 Overflow / DoS** — 38-digit raw passed to USD formatter | Helper must not lock the tab or print `…T` from raw. Pair list stays rollup (V1); reject widening to live 24h scans. | | **A7 Unit confusion** — client multiplies already-human `volume_usd` by 1e6 or by `price_usd` again | Tests: stats `volume_usd` is human USD; UI does not × decimals or × price. | | **A8 Invert / wrong leg** — UI invert causes UST1/USTR to use the other token’s decimals in a fallback | No quote-decimal fallback on the USD badge. Invert does not swap the USD number. | | **A9 Integrator break** — CG/CMC `target_volume` switched to USD | Assert ticker fields still raw quote/base. | | **A10 Auth / cache** — pair list is public | No new secrets. Do not cache a single pair’s USD onto another address (key by `pair_address`). | | **A11 Negative / dust** — `volume_usd = -1` or dust `1e-18` | Hide or clamp; never negative compact. | | **A12 Replay backfill** — running backfill twice | Idempotent; totals do not double. | ## Verification criteria Issue is **done** when: 1. A reviewer on mainnet or LocalTerra with real UST1/USTR + UST1/cUSTC volume opens `/trade` pair search and sees **USD** badges whose magnitude matches `GET /api/v1/pairs/{addr}/stats` `volume_usd` (not quote units, not `T`). 2. `/pool` and `/charts` primary 24h volume match that same USD number (rollup freshness ≤ ~5 min is OK). 3. `make verify-issue-544` and listed indexer/frontend tests pass in CI. 4. Docs/skills state: pair-list `volume_quote_24h` remains raw; retail default is `volume_usd_24h`; **X4** is catalog (not USTC-only); **L10** / CG units unchanged. 5. Abuse cases A1, A2, A5, A6, A7, A9 have automated coverage; A3/A4/A8/A10–A12 are tested or explicitly waived in the MR with a reason. **Out of scope:** changing candle histogram units; trader leaderboard `total_volume` (follow-up if it still prints raw offer sums); inventing USD for faucet gems; using USD volume in fee/settlement math.
PlasticDigits commented 2026-08-17 03:52:29 +00:00 (Migrated from gitlab.com)

marked as related to #534

marked as related to #534
PlasticDigits commented 2026-08-17 03:52:30 +00:00 (Migrated from gitlab.com)

marked as related to #540

marked as related to #540
PlasticDigits commented 2026-08-17 03:52:30 +00:00 (Migrated from gitlab.com)

marked as related to #522

marked as related to #522
PlasticDigits commented 2026-08-17 03:52:31 +00:00 (Migrated from gitlab.com)

marked as related to #515

marked as related to #515
PlasticDigits commented 2026-08-17 03:52:54 +00:00 (Migrated from gitlab.com)

changed the description

changed the description
PlasticDigits commented 2026-08-17 04:53:57 +00:00 (Migrated from gitlab.com)

mentioned in issue #545

mentioned in issue #545
PlasticDigits commented 2026-08-17 10:29:10 +00:00 (Migrated from gitlab.com)

marked as related to #548

marked as related to #548
PlasticDigits commented 2026-08-17 10:29:10 +00:00 (Migrated from gitlab.com)

mentioned in issue #548

mentioned in issue #548
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-17 10:31:26 +00:00
PlasticDigits commented 2026-08-17 11:58:00 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1076

mentioned in merge request !1076
PlasticDigits commented 2026-08-17 13:49:47 +00:00 (Migrated from gitlab.com)

mentioned in issue #552

mentioned in issue #552
PlasticDigits commented 2026-08-17 14:58:02 +00:00 (Migrated from gitlab.com)

mentioned in issue #553

mentioned in issue #553
PlasticDigits commented 2026-08-17 14:58:04 +00:00 (Migrated from gitlab.com)

marked as related to #553

marked as related to #553
PlasticDigits commented 2026-08-18 12:13:07 +00:00 (Migrated from gitlab.com)

mentioned in issue #565

mentioned in issue #565
PlasticDigits commented 2026-08-18 12:13:08 +00:00 (Migrated from gitlab.com)

marked as related to #565

marked as related to #565
PlasticDigits commented 2026-08-19 01:02:30 +00:00 (Migrated from gitlab.com)

mentioned in commit e7bca66f81

mentioned in commit e7bca66f81fa61d0229430053537af79295ee3b5
PlasticDigits commented 2026-08-19 01:02:56 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1089

mentioned in merge request !1089
PlasticDigits commented 2026-08-28 05:22:08 +00:00 (Migrated from gitlab.com)

mentioned in issue #692

mentioned in issue #692
PlasticDigits commented 2026-08-28 05:22:08 +00:00 (Migrated from gitlab.com)

marked as related to #692

marked as related to #692
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-dex-terraclassic#544
No description provided.