feat: /pool table v2 LP USD column (pair AMM TVL from #569 catalog) #655

Closed
opened 2026-08-26 03:06:31 +00:00 by PlasticDigits · 23 comments
PlasticDigits commented 2026-08-26 03:06:31 +00:00 (Migrated from gitlab.com)

Summary

Add a v2 LP USD column to the /pool pairs table (Pair / Vol / Fee / Created / Source) that shows the human USD value of each factory v2 AMM pool. Bundle indexer list JSON + sort + dApp column + docs/verify in one issue. Do not split “API field” vs “table cell” vs “sort caret.”

This is pair TVL (both reserve legs, P522-Q / hub catalog), not the connected wallet’s LP-token mark-to-market, not book escrow, not V3 Grid / #619 vaults, not CoinGecko liquidity_in_usd (that field is mislabeled 24h volume).

Related: #547 (/pool table), #569 (protocol TVL catalog), #556 (hub USD), #534 (catalog rank), #562 (hide gems), #576 (Vol = trailing 24h), #489 (no lectures), #653 (no nested chrome), #243 (pair_volume_24h rollup pattern).

Current codebase

/pool is a sortable indexer table. Protocol already knows how to price a factory pair in USD. The pair list API does not expose that number, so the table cannot show it without per-row LCD (forbidden) or inventing TVL from volume.

Layer Behavior today
/pool table PoolPairsTable.tsx: columns Pair, Vol, Fee, Created, Source, Actions (Charts / Manage). Vol uses formatQuoteVolume24h(volume_quote_24h, asset_1.decimals). Created cells are always — (list JSON has no timestamp). Source is Factory / Indexer (+ Frozen). Expand Manage colSpan={6} mounts PoolAdvancedManage (LCD getPool only on expand — P547-9 / A8).
List fetch PoolPage.tsx + poolListQuery.ts: default catalog fetches limit=500 sort=volume_24h&order=desc, client sortIndexerPairsByCatalog, paginate 20. Search uses indexer relevance. Column clicks use indexer sort/order (symbol, volume_24h, fee, created) with no catalog overlay (P547-3–P547-4). Production omits gems (P562-3).
Indexer pair list GET /api/v1/pairs (indexer/src/api/pairs.rs PairResponse): pair_address, asset_0 / asset_1, lp_token, fee_bps, is_active, code_id_frozen, optional volume_quote_24h. No liquidity_usd / TVL. IndexerPair in frontend-dapp/src/types/index.ts matches. Sort allowlist: id, fee, created, symbol, volume_24h, relevance. Invalid sort → 400. limit clamp 1–100 (PAIR_LIST_LIMIT_MAX); offset > 10_000 → 400. Search q is ILIKE-escaped (SEC-I04).
Volume rollup List JOINs materialized pair_volume_24h (~5 min). sort=volume_24h is COALESCE(pv.volume_quote, 0) — raw quote units, not USD. Unpriced idle pairs zero on refresh (#577 D3).
Protocol pair TVL (exists, unused by /pool) protocol_tvl.rs protocol_pair_tvl: P522-Q + hub. Both legs priced → h0×usd0 + h1×usd1. Exactly one catalogued → 2× that leg (CPAMM). Neither / stale / same-asset / overflow / reserve_*=0 → omit (not $0). Book escrow not included. Refresh writes global total_liquidity_usd only — no per-pair rollup for the list API.
USD catalog USTC/cUSTC/uusd → USTC oracle; LUNC/cLUNC/uluna → LUNC; UST1/USTR → hub_prices. Never $1 UST1, never 2.5× USTR, never vFDUSD/FDUSD (P569-4, P550-10). Identity is contract/denom (symbol-spoof natives skipped).
Reserves pair_reserves upserted by book_snapshot.rs (~10s). Raw NUMERIC. Stale TTL same class as hub / book mirror.
USD format (dApp) formatProtocolUsd: missing / non-finite → —, never Infinity. Protocol Total liquidity already uses this on total_liquidity_usd.
Known drift (do not “fix” here unless required) poolListQuery.ts comments claim list limit max 1000; indexer PAIR_LIST_LIMIT_MAX is 100, so catalog limit=500 is clamped to 100. Out of scope unless the new sort cannot page correctly.
Tests / verify PoolPage.test.tsx, poolListQuery.test.ts, e2e/pool-table-547.spec.ts, make verify-issue-547. Indexer: api_pairs.rs, indexer_pair_volume_pagination.rs, indexer_protocol_liquidity.rs.
Docs / skills docs/frontend.md § Liquidity pools list (P547-1–P547-10), skills/AGENTS_FRONTEND_POOL_TABLE.md, skills/AGENTS_FRONTEND_PROTOCOL_STATS.md (P569), docs/indexer-invariants.md (pair list volume + protocol TVL).

Why this is needed

  1. LPs cannot compare pool size. Vol is 24h quote flow (mixed 6/18-dec raw). Fee is bps. Neither answers “how much USD is locked in this v2 pool.” Retail and operators opening /pool to add liquidity need stock (TVL) next to flow (Vol).
  2. The number already exists in-process. protocol_pair_tvl is the protocol census primitive. /protocol only shows the sum. Re-deriving TVL in the browser from LCD getPool would re-open A8 (N+1 on first paint) and drift from Protocol’s catalog.
  3. Without a list field, the UI will cheat. The failure mode is inventing USD from volume_quote_24h, CG liquidity_in_usd, LP supply × last price, or $1 UST1. Those are the same mistakes #550 / #569 already forbade.
  4. Sort needs a rollup. sort=volume_24h is a JOIN on pair_volume_24h, not a live SUM. Per-pair USD sort must be the same class: materialized, indexed, NULLS LAST — not pair_reserves ⋈ oracle_prices on GET.

Constraints / guardrails

  1. Reuse #569 math. Do not invent a second catalog. Call protocol_pair_tvl / pair_usable_for_protocol_tvl (or extract a shared helper). Same quotes load as refresh_protocol_liquidity. Never vFDUSD. Never $1 UST1. Never 2.5× USTR. Identity = contract/denom.
  2. GET /api/v1/pairs stays a rollup JOIN. Do not walk pair_reserves + oracles + hub on the request path (including sort=liquidity_usd). Stamp per-pair USD on the aggregator / hub / protocol-TVL refresh (same cadence as #569). Cache-miss list = SQL only.
  3. Additive JSON. New optional string field on PairResponse / IndexerPair, e.g. liquidity_usd (human USD decimal string, same class as total_liquidity_usd). Omit or null when unpriced. Old indexers → dApp shows —. Do not break existing clients. Do not rename volume_quote_24h.
  4. Unpriced ≠ $0. Stale reserves, zero reserves, same-asset, overflow, hub/oracle down, neither leg catalogued → omit field / null. UI em-dash. $0 is only a real empty-but-priced pool (if that can exist after reserves_usable; if not, still —). Sorting: NULLS LAST, then p.id. Do not COALESCE(..., 0) for display or for “unpriced ranks as poorest.”
  5. AMM v2 pool reserves only. Humanized USD of pair_reserves. No resting book escrow, parked dust, wallet LP balances, wrap-mapper inventory, or V3 Grid vault AUM. Header v2 LP USD + title that this is factory AMM pool USD (trailing snapshot), not 24h volume.
  6. No per-row LCD on first paint (P547-9 / A8). Do not getPool / getPairFeeConfig / hub-price fetch per table row. Manage expand stays the only LCD pair query.
  7. Column sort is indexer-only (P547-4). Add liquidity_usd (or lp_usd — pick one, document it) to PairListSort, IndexerPairSort, POOL_COLUMN_SORTS. Default order desc. Catalog default (no column click) still UST1-hub rank — do not overlay catalog on this column. Search stays relevance.
  8. Do not raise PAIR_LIST_LIMIT_MAX or change catalog fetch unless the new sort is broken without it. If you touch the clamp, document why and keep offset 10_000 / negative-limit → 1.
  9. Format. formatProtocolUsd (or a shared wrapper). Never Infinity / NaN / scientific-notation raw in the cell. XSS/javascript: in a spoofed numeric string → — (text node only).
  10. #489 / #653. No “how TVL is calculated” lecture in the header. No card-glass around each <tr>. Keep overflow-x-auto; bump min-w if the extra column wraps badly. Light + dark; phone 375px horizontal scroll OK.
  11. a11y. Same caret + aria-sort pattern as Vol. data-testid for header + cell (e.g. pool-sort-lp-usd, pool-row-lp-usd). Manage expand colSpan must include the new column.
  12. Production gems (#562). Hidden pairs must not appear just because they have USD. Frozen mark stays on Source.
  13. Do not “fix” CG liquidity_in_usd or Protocol headlines here.
  14. Copy. Visible header v2 LP USD (user-facing). Not “TVL”, not “liquidity_usd”, not “AUM”. Optional title: “Factory v2 pool USD (reference).”

Relevant files

File Role
indexer/src/api/pairs.rs PairResponse, parse_pair_list_sort, list mapping
indexer/src/db/queries/pairs.rs PairListRow, PairListSort, JOIN + ORDER BY
indexer/src/indexer/protocol_tvl.rs protocol_pair_tvl — reuse
indexer/src/indexer/hub_usd.rs pair_tvl / ReservePair
indexer/src/indexer/pair_price_usd.rs P522-Q catalog
indexer/src/db/queries/hub_prices.rs list_reserve_pairs, hub quotes
indexer/migrations/ New pair_liquidity_usd (or equivalent) rollup + index, mirror pair_volume_24h
indexer/tests/api_pairs.rs List JSON + invalid sort 400
indexer/tests/indexer_protocol_liquidity.rs Shared TVL math still holds
indexer/tests/indexer_pair_volume_pagination.rs Pattern: sort uses rollup, not live scan (EXPLAIN)
frontend-dapp/src/types/index.ts IndexerPair, IndexerPairSort
frontend-dapp/src/services/indexer/client.ts getPairs types only
frontend-dapp/src/utils/poolListQuery.ts POOL_COLUMN_SORTS, default order desc
frontend-dapp/src/components/pool/PoolPairsTable.tsx Column + cell + colSpan
frontend-dapp/src/pages/PoolPage.tsx Pass-through; no extra queries
frontend-dapp/src/utils/formatProtocolStats.ts formatProtocolUsd
frontend-dapp/src/pages/PoolPage.test.tsx Header click → sort=liquidity_usd
frontend-dapp/src/utils/__tests__/poolListQuery.test.ts Allowlist + default desc
frontend-dapp/e2e/pool-table-547.spec.ts Column present / sort
docs/frontend.md Extend P547 (new P-id for this column)
docs/indexer-invariants.md Pair-list liquidity USD row
skills/AGENTS_FRONTEND_POOL_TABLE.md Do/don’t + verify target
scripts/qa/verify-issue-NNN.sh + Makefile make verify-issue-NNN
  1. Materialize pair_liquidity_usd (pair_id PK, liquidity_usd NUMERIC(38,18) nullable, updated_at). Refresh inside refresh_protocol_liquidity (or the same hub tick): for each usable reserve pair, protocol_pair_tvl → upsert; unusable / omitted → NULL (or delete row). Index (liquidity_usd DESC NULLS LAST).
  2. List JOIN like pair_volume_24h. Map to liquidity_usd: Option<String> (skip_serializing_if). Sort key liquidity_usd (default desc). Invalid sort string still 400 (allowlist — no raw SQL from query).
  3. dApp: add v2 LP USD SortHeader after Vol (right-aligned, mono). Cell = formatProtocolUsd(ip.liquidity_usd) or —. Wire POOL_COLUMN_SORTS + defaultOrderForPoolSort desc. Update Manage colSpan.
  4. Docs + make verify-issue-NNN (frontend Vitest + indexer lib/integration + grep invariants). Do not require a new CosmWasm migrate.

Do not compute USD in the browser from reserves + GET /hub-prices. Do not LCD-simulate every row.

Acceptance criteria

  • AC1. /pool table shows a v2 LP USD column between Vol and Fee (or immediately after Vol if layout requires; not buried after Source).
  • AC2. Cell is compact USD ($1.2k / $12.34 via existing formatProtocolUsd / formatNum) or —. Never Infinity, NaN, raw 18-dec integer, or quote-denom volume.
  • AC3. GET /api/v1/pairs items include optional liquidity_usd string when priced; omitted/null when not. Additive: old fields unchanged.
  • AC4. Header is sortable; first click sort=liquidity_usd&order=desc; toggle asc/desc; aria-sort on that <th> only. Catalog default (no click) unchanged.
  • AC5. Priced UST1/cUSTC (or UST1/USTR) row USD matches protocol_pair_tvl for the same reserves + quotes (not $1/leg, not 2.5× USTR).
  • AC6. Unpriced gem/gem (LocalTerra) or hub-down UST1-only → —, and sorts after priced rows (NULLS LAST).
  • AC7. Default table paint: zero new getPool / getPairFeeConfig / per-row LCD. Network tab / Vitest spies stay A8-clean.
  • AC8. Production hide-gems still applies. Frozen / Factory marks unchanged.
  • AC9. Manage expand still full-width under the new column count.
  • AC10. Invalid sort=liquidity_usd;DROP / sort=__proto__ / sort=tvl → 400. limit=-1 still clamps to 1.
  • AC11. EXPLAIN (or equivalent test) for sort=liquidity_usd does not scan swap_events or live-sum reserves on GET.
  • AC12. Docs/skills/invariants + make verify-issue-NNN land with the implementation.

Test plan (all paths)

Indexer

ID Path Expect
I1 Pair with both legs catalogued (6-dec + 18-dec) liquidity_usd = h0×usd0 + h1×usd1 string
I2 Exactly one catalogued leg 2× priced leg (CPAMM)
I3 Neither catalogued field omitted/null
I4 UST1/cUSTC uses hub UST1 + USTC oracle — not peg $1 numeric ≠ h_ust1 + h_custc×ustc with ust1=1 unless hub is 1
I5 UST1/USTR uses hub marks — not 2.5× USTC
I6 Stale snapshot_at / reserve_*=0 / same-asset omitted
I7 Hub or USTC/LUNC oracle down affected pairs omitted; others still priced
I8 GET /pairs?sort=liquidity_usd&order=desc priced desc, NULLS LAST, stable id
I9 order=asc priced asc, NULLS LAST
I10 sort=liquidity_usd unknown-typo / injection 400
I11 List without sort still returns field (additive) catalog/volume_24h/relevance pages include liquidity_usd when set
I12 Refresh then GET list value matches last protocol_pair_tvl stamp (not a second formula)
I13 Overflow / fits_numeric_38_18 fail omit pair; GET still 200
I14 limit/offset caps unchanged clamp / 400 as today
I15 q= + sort=liquidity_usd filter then USD sort (or document: sort ignored under relevance — prefer USD sort only when q empty / column mode)
I16 Double-count across two pools sharing USTC each pair has its own TVL; no “unique-token” collapse

Frontend

ID Path Expect
F1 Catalog default column visible; values from JSON; no sort=liquidity_usd until click
F2 Click v2 LP USD getPairs({ sort: 'liquidity_usd', order: 'desc', limit: 20 })
F3 Second click order: 'asc'; aria-sort flips
F4 Missing / null / "" / "Infinity" / "NaN" cell —
F5 "1234.5" $ + compact number
F6 Search mode column still renders; sort click leaves search or is disabled — pick one, test it (prefer: column click clears to column mode like Vol)
F7 Production gems EMBER/CORAL omitted even if liquidity_usd huge
F8 Manage expand panel colSpan matches column count; Advanced still LCD-on-expand only
F9 Light + dark, 375px / 1280px header readable; table scrolls horizontally; no nested card-glass
F10 Created / Vol / Fee / Source / Charts regressions #547 tests still pass
F11 Old indexer mock without field all LP cells —, page does not crash

E2E / LocalTerra

ID Path Expect
E1 make has-localterra + /pool at least one factory pair shows $ or — (not blank)
E2 Sort desc first priced row ≥ later priced rows (parse formatted or intercept JSON)
E3 Charts / Manage still work after column add

Test plan (attack, hack, abuse)

ID Vector Expect
A1 Symbol spoof native symbol=USTR / USTR on ugem not priced (P569 A5)
A2 vFDUSD / FDUSD as a leg never enters liquidity_usd (A7)
A3 Raw 18-dec takeover huge gem reserve vs 1 human USTC one-sided 2× USTC only; not 1e24 USD
A4 XSS liquidity_usd = <img onerror=…> / javascript: rendered as text or —; no HTML
A5 Sort injection sort=liquidity_usd;DELETE / sort=id FROM pairs-- 400; parameterized ORDER BY enum only
A6 N+1 LCD (A8) default /pool paint does not query pair contracts
A7 GET-path DoS sort=liquidity_usd EXPLAIN has no swap_events / no live reserve×oracle join
A8 Deep offset offset=10001 400
A9 Negative limit clamp to 1, not 500
A10 ILIKE % / _ in q while sorting USD still escaped; no full-catalog dump beyond limit
A11 Unpriced as $0 must not rank as poorest / richest; NULLS LAST + cell —
A12 Client invents TVL from volume_quote_24h when field missing forbidden — — only
A13 Wallet LP share using connected address on every row forbidden (privacy + N+1)
A14 Book escrow / parked dust in the number forbidden
A15 CG liquidity_in_usd reused as cell forbidden
A16 Scientific JSON 1e+19 dApp Number.isFinite → — if non-finite; indexer should emit normalized decimal like volume
A17 javascript: pair address in Charts still no Link (P547-5)
A18 Header lecture / nested chrome no TVL essay; no card-glass per row (#489 / #653)
A19 Gem wash TVL on production pair omitted (#562), not a fake #1 USD pool
A20 Stale reserves after pause / drain next refresh omits or updates; UI must not keep a zombie $ from a deleted rollup row across refresh (NULL/delete the stamp)

Verification criteria

Ship is done when all of the following hold:

  1. make verify-issue-NNN (new) is green: indexer tests for stamp + list + sort + 400 + EXPLAIN; Vitest PoolPage / poolListQuery / format; static grep that PoolPairsTable does not call getPool.
  2. make verify-issue-547 still green (table, catalog default, Vol/Fee/Pair sorts, Charts href).
  3. make verify-issue-569 still green (global TVL formula unchanged).
  4. LocalTerra smoke: /pool shows v2 LP USD; sort desc; Manage expand; no extra LCD storm on first paint.
  5. Docs updated: docs/frontend.md P547 extension, docs/indexer-invariants.md pair-list liquidity row, skills/AGENTS_FRONTEND_POOL_TABLE.md do/don’t + verify target. AGENTS.md verify one-liner if that is the house style.
  6. Coolify / old-indexer: missing field → —, no console throw.

Out of scope: V3 Grid TVL (#619), per-wallet LP USD on /portfolio, Created timestamp cells (#547 leftover), raising pair-list limit to 500/1000, CG liquidity_in_usd rename, Protocol page changes.

## Summary Add a **v2 LP USD** column to the `/pool` pairs table (Pair / Vol / Fee / Created / Source) that shows the **human USD value of each factory v2 AMM pool**. Bundle indexer list JSON + sort + dApp column + docs/verify in **one** issue. Do **not** split “API field” vs “table cell” vs “sort caret.” This is **pair TVL** (both reserve legs, P522-Q / hub catalog), **not** the connected wallet’s LP-token mark-to-market, **not** book escrow, **not** V3 Grid / #619 vaults, **not** CoinGecko `liquidity_in_usd` (that field is mislabeled 24h volume). Related: [#547](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/547) (`/pool` table), [#569](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/569) (protocol TVL catalog), [#556](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/556) (hub USD), [#534](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/534) (catalog rank), [#562](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/562) (hide gems), [#576](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/576) (Vol = trailing 24h), [#489](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/489) (no lectures), [#653](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/653) (no nested chrome), [#243](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/243) (`pair_volume_24h` rollup pattern). ## Current codebase `/pool` is a sortable **indexer table**. Protocol already knows how to price a factory pair in USD. The pair **list** API does not expose that number, so the table cannot show it without per-row LCD (forbidden) or inventing TVL from volume. | Layer | Behavior today | |-------|----------------| | **`/pool` table** | [`PoolPairsTable.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/pool/PoolPairsTable.tsx): columns **Pair**, **Vol**, **Fee**, **Created**, **Source**, Actions (Charts / Manage). Vol uses `formatQuoteVolume24h(volume_quote_24h, asset_1.decimals)`. Created cells are always `—` (list JSON has no timestamp). Source is Factory / Indexer (+ Frozen). Expand **Manage** `colSpan={6}` mounts `PoolAdvancedManage` (LCD `getPool` **only** on expand — **P547-9** / A8). | | **List fetch** | [`PoolPage.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/pages/PoolPage.tsx) + [`poolListQuery.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/poolListQuery.ts): default **catalog** fetches `limit=500` `sort=volume_24h&order=desc`, client `sortIndexerPairsByCatalog`, paginate 20. Search uses indexer `relevance`. Column clicks use indexer `sort`/`order` (`symbol`, `volume_24h`, `fee`, `created`) with **no** catalog overlay (**P547-3–P547-4**). Production omits gems (**P562-3**). | | **Indexer pair list** | `GET /api/v1/pairs` ([`indexer/src/api/pairs.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/api/pairs.rs) `PairResponse`): `pair_address`, `asset_0` / `asset_1`, `lp_token`, `fee_bps`, `is_active`, `code_id_frozen`, optional `volume_quote_24h`. **No** `liquidity_usd` / TVL. `IndexerPair` in [`frontend-dapp/src/types/index.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/types/index.ts) matches. Sort allowlist: `id`, `fee`, `created`, `symbol`, `volume_24h`, `relevance`. Invalid `sort` → **400**. `limit` clamp **1–100** (`PAIR_LIST_LIMIT_MAX`); `offset` > 10_000 → **400**. Search `q` is `ILIKE`-escaped (**SEC-I04**). | | **Volume rollup** | List JOINs materialized [`pair_volume_24h`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/migrations/20260531143000_pair_volume_24h_rollup.sql) (~5 min). `sort=volume_24h` is `COALESCE(pv.volume_quote, 0)` — **raw quote units**, not USD. Unpriced idle pairs zero on refresh (**#577 D3**). | | **Protocol pair TVL (exists, unused by `/pool`)** | [`protocol_tvl.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/indexer/protocol_tvl.rs) `protocol_pair_tvl`: P522-Q + hub. Both legs priced → `h0×usd0 + h1×usd1`. Exactly one catalogued → `2×` that leg (CPAMM). Neither / stale / same-asset / overflow / `reserve_*=0` → **omit** (not `$0`). Book escrow **not** included. Refresh writes **global** `total_liquidity_usd` only — **no per-pair rollup** for the list API. | | **USD catalog** | USTC/cUSTC/`uusd` → USTC oracle; LUNC/cLUNC/`uluna` → LUNC; UST1/USTR → `hub_prices`. Never `$1` UST1, never `2.5×` USTR, never vFDUSD/FDUSD (**P569-4**, **P550-10**). Identity is contract/denom (symbol-spoof natives skipped). | | **Reserves** | `pair_reserves` upserted by [`book_snapshot.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/indexer/book_snapshot.rs) (~10s). Raw `NUMERIC`. Stale TTL same class as hub / book mirror. | | **USD format (dApp)** | [`formatProtocolUsd`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/formatProtocolStats.ts): missing / non-finite → `—`, never `Infinity`. Protocol **Total liquidity** already uses this on `total_liquidity_usd`. | | **Known drift (do not “fix” here unless required)** | `poolListQuery.ts` comments claim list `limit` max **1000**; indexer `PAIR_LIST_LIMIT_MAX` is **100**, so catalog `limit=500` is **clamped to 100**. Out of scope unless the new sort cannot page correctly. | | **Tests / verify** | [`PoolPage.test.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/pages/PoolPage.test.tsx), [`poolListQuery.test.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/__tests__/poolListQuery.test.ts), [`e2e/pool-table-547.spec.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/e2e/pool-table-547.spec.ts), `make verify-issue-547`. Indexer: [`api_pairs.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/api_pairs.rs), [`indexer_pair_volume_pagination.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/indexer_pair_volume_pagination.rs), [`indexer_protocol_liquidity.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/indexer_protocol_liquidity.rs). | | **Docs / skills** | [`docs/frontend.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/frontend.md) § Liquidity pools list (**P547-1–P547-10**), [`skills/AGENTS_FRONTEND_POOL_TABLE.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/skills/AGENTS_FRONTEND_POOL_TABLE.md), [`skills/AGENTS_FRONTEND_PROTOCOL_STATS.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/skills/AGENTS_FRONTEND_PROTOCOL_STATS.md) (**P569**), [`docs/indexer-invariants.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/indexer-invariants.md) (pair list volume + protocol TVL). | ## Why this is needed 1. **LPs cannot compare pool size.** Vol is **24h quote flow** (mixed 6/18-dec raw). Fee is bps. Neither answers “how much USD is locked in this v2 pool.” Retail and operators opening `/pool` to add liquidity need **stock** (TVL) next to **flow** (Vol). 2. **The number already exists in-process.** `protocol_pair_tvl` is the protocol census primitive. `/protocol` only shows the **sum**. Re-deriving TVL in the browser from LCD `getPool` would re-open **A8** (N+1 on first paint) and drift from Protocol’s catalog. 3. **Without a list field, the UI will cheat.** The failure mode is inventing USD from `volume_quote_24h`, CG `liquidity_in_usd`, LP supply × last price, or `$1` UST1. Those are the same mistakes #550 / #569 already forbade. 4. **Sort needs a rollup.** `sort=volume_24h` is a JOIN on `pair_volume_24h`, not a live `SUM`. Per-pair USD sort must be the same class: materialized, indexed, NULLS LAST — not `pair_reserves ⋈ oracle_prices` on GET. ## Constraints / guardrails 1. **Reuse #569 math. Do not invent a second catalog.** Call `protocol_pair_tvl` / `pair_usable_for_protocol_tvl` (or extract a shared helper). Same quotes load as `refresh_protocol_liquidity`. Never vFDUSD. Never `$1` UST1. Never `2.5×` USTR. Identity = contract/denom. 2. **GET `/api/v1/pairs` stays a rollup JOIN.** Do **not** walk `pair_reserves` + oracles + hub on the request path (including `sort=liquidity_usd`). Stamp per-pair USD on the **aggregator / hub / protocol-TVL refresh** (same cadence as #569). Cache-miss list = SQL only. 3. **Additive JSON.** New optional string field on `PairResponse` / `IndexerPair`, e.g. `liquidity_usd` (human USD decimal string, same class as `total_liquidity_usd`). Omit or `null` when unpriced. Old indexers → dApp shows `—`. Do not break existing clients. Do **not** rename `volume_quote_24h`. 4. **Unpriced ≠ `$0`.** Stale reserves, zero reserves, same-asset, overflow, hub/oracle down, neither leg catalogued → omit field / `null`. UI **em-dash**. `$0` is only a real empty-but-priced pool (if that can exist after `reserves_usable`; if not, still `—`). Sorting: **NULLS LAST**, then `p.id`. Do **not** `COALESCE(..., 0)` for display or for “unpriced ranks as poorest.” 5. **AMM v2 pool reserves only.** Humanized USD of `pair_reserves`. **No** resting book escrow, parked dust, wallet LP balances, wrap-mapper inventory, or V3 Grid vault AUM. Header **v2 LP USD** + `title` that this is factory AMM pool USD (trailing snapshot), not 24h volume. 6. **No per-row LCD on first paint (P547-9 / A8).** Do not `getPool` / `getPairFeeConfig` / hub-price fetch per table row. Manage expand stays the only LCD pair query. 7. **Column sort is indexer-only (P547-4).** Add `liquidity_usd` (or `lp_usd` — pick one, document it) to `PairListSort`, `IndexerPairSort`, `POOL_COLUMN_SORTS`. Default order **desc**. Catalog default (**no** column click) still UST1-hub rank — **do not** overlay catalog on this column. Search stays `relevance`. 8. **Do not raise `PAIR_LIST_LIMIT_MAX` or change catalog fetch** unless the new sort is broken without it. If you touch the clamp, document why and keep `offset` 10_000 / negative-limit → 1. 9. **Format.** `formatProtocolUsd` (or a shared wrapper). Never `Infinity` / `NaN` / scientific-notation raw in the cell. XSS/`javascript:` in a spoofed numeric string → `—` (text node only). 10. **#489 / #653.** No “how TVL is calculated” lecture in the header. No `card-glass` around each `<tr>`. Keep `overflow-x-auto`; bump `min-w` if the extra column wraps badly. Light + dark; phone 375px horizontal scroll OK. 11. **a11y.** Same caret + `aria-sort` pattern as Vol. `data-testid` for header + cell (e.g. `pool-sort-lp-usd`, `pool-row-lp-usd`). Manage expand `colSpan` must include the new column. 12. **Production gems (#562).** Hidden pairs must not appear just because they have USD. Frozen mark stays on Source. 13. **Do not “fix” CG `liquidity_in_usd` or Protocol headlines here.** 14. **Copy.** Visible header **v2 LP USD** (user-facing). Not “TVL”, not “liquidity_usd”, not “AUM”. Optional `title`: “Factory v2 pool USD (reference).” ## Relevant files | File | Role | |------|------| | `indexer/src/api/pairs.rs` | `PairResponse`, `parse_pair_list_sort`, list mapping | | `indexer/src/db/queries/pairs.rs` | `PairListRow`, `PairListSort`, JOIN + `ORDER BY` | | `indexer/src/indexer/protocol_tvl.rs` | `protocol_pair_tvl` — reuse | | `indexer/src/indexer/hub_usd.rs` | `pair_tvl` / `ReservePair` | | `indexer/src/indexer/pair_price_usd.rs` | P522-Q catalog | | `indexer/src/db/queries/hub_prices.rs` | `list_reserve_pairs`, hub quotes | | `indexer/migrations/` | New `pair_liquidity_usd` (or equivalent) rollup + index, mirror `pair_volume_24h` | | `indexer/tests/api_pairs.rs` | List JSON + invalid sort **400** | | `indexer/tests/indexer_protocol_liquidity.rs` | Shared TVL math still holds | | `indexer/tests/indexer_pair_volume_pagination.rs` | Pattern: sort uses rollup, not live scan (`EXPLAIN`) | | `frontend-dapp/src/types/index.ts` | `IndexerPair`, `IndexerPairSort` | | `frontend-dapp/src/services/indexer/client.ts` | `getPairs` types only | | `frontend-dapp/src/utils/poolListQuery.ts` | `POOL_COLUMN_SORTS`, default order desc | | `frontend-dapp/src/components/pool/PoolPairsTable.tsx` | Column + cell + `colSpan` | | `frontend-dapp/src/pages/PoolPage.tsx` | Pass-through; no extra queries | | `frontend-dapp/src/utils/formatProtocolStats.ts` | `formatProtocolUsd` | | `frontend-dapp/src/pages/PoolPage.test.tsx` | Header click → `sort=liquidity_usd` | | `frontend-dapp/src/utils/__tests__/poolListQuery.test.ts` | Allowlist + default desc | | `frontend-dapp/e2e/pool-table-547.spec.ts` | Column present / sort | | `docs/frontend.md` | Extend **P547** (new P-id for this column) | | `docs/indexer-invariants.md` | Pair-list liquidity USD row | | `skills/AGENTS_FRONTEND_POOL_TABLE.md` | Do/don’t + verify target | | `scripts/qa/verify-issue-NNN.sh` + `Makefile` | `make verify-issue-NNN` | ## Recommended direction 1. **Materialize `pair_liquidity_usd`** (`pair_id` PK, `liquidity_usd NUMERIC(38,18)` **nullable**, `updated_at`). Refresh inside `refresh_protocol_liquidity` (or the same hub tick): for each usable reserve pair, `protocol_pair_tvl` → upsert; unusable / omitted → `NULL` (or delete row). Index `(liquidity_usd DESC NULLS LAST)`. 2. **List JOIN** like `pair_volume_24h`. Map to `liquidity_usd: Option<String>` (`skip_serializing_if`). Sort key `liquidity_usd` (default desc). Invalid sort string still **400** (allowlist — no raw SQL from query). 3. **dApp:** add **v2 LP USD** `SortHeader` after **Vol** (right-aligned, mono). Cell = `formatProtocolUsd(ip.liquidity_usd)` or `—`. Wire `POOL_COLUMN_SORTS` + `defaultOrderForPoolSort` desc. Update Manage `colSpan`. 4. **Docs + `make verify-issue-NNN`** (frontend Vitest + indexer lib/integration + grep invariants). Do not require a new CosmWasm migrate. Do **not** compute USD in the browser from reserves + `GET /hub-prices`. Do **not** LCD-simulate every row. ## Acceptance criteria - [ ] **AC1.** `/pool` table shows a **v2 LP USD** column between **Vol** and **Fee** (or immediately after **Vol** if layout requires; not buried after Source). - [ ] **AC2.** Cell is compact USD (`$1.2k` / `$12.34` via existing `formatProtocolUsd` / `formatNum`) or **—**. Never `Infinity`, `NaN`, raw 18-dec integer, or quote-denom volume. - [ ] **AC3.** `GET /api/v1/pairs` items include optional `liquidity_usd` string when priced; omitted/`null` when not. Additive: old fields unchanged. - [ ] **AC4.** Header is sortable; first click `sort=liquidity_usd&order=desc`; toggle asc/desc; `aria-sort` on that `<th>` only. Catalog default (no click) **unchanged**. - [ ] **AC5.** Priced UST1/cUSTC (or UST1/USTR) row USD matches `protocol_pair_tvl` for the same reserves + quotes (not `$1`/leg, not `2.5×` USTR). - [ ] **AC6.** Unpriced gem/gem (LocalTerra) or hub-down UST1-only → **—**, and sorts after priced rows (NULLS LAST). - [ ] **AC7.** Default table paint: **zero** new `getPool` / `getPairFeeConfig` / per-row LCD. Network tab / Vitest spies stay A8-clean. - [ ] **AC8.** Production hide-gems still applies. Frozen / Factory marks unchanged. - [ ] **AC9.** Manage expand still full-width under the new column count. - [ ] **AC10.** Invalid `sort=liquidity_usd;DROP` / `sort=__proto__` / `sort=tvl` → **400**. `limit=-1` still clamps to **1**. - [ ] **AC11.** `EXPLAIN` (or equivalent test) for `sort=liquidity_usd` does **not** scan `swap_events` or live-sum reserves on GET. - [ ] **AC12.** Docs/skills/invariants + `make verify-issue-NNN` land with the implementation. ## Test plan (all paths) ### Indexer | ID | Path | Expect | |----|------|--------| | I1 | Pair with both legs catalogued (6-dec + 18-dec) | `liquidity_usd` = `h0×usd0 + h1×usd1` string | | I2 | Exactly one catalogued leg | `2×` priced leg (CPAMM) | | I3 | Neither catalogued | field omitted/`null` | | I4 | UST1/cUSTC uses hub UST1 + USTC oracle — not peg `$1` | numeric ≠ `h_ust1 + h_custc×ustc` with ust1=1 unless hub is 1 | | I5 | UST1/USTR uses hub marks — not `2.5×` USTC | | | I6 | Stale `snapshot_at` / `reserve_*=0` / same-asset | omitted | | I7 | Hub or USTC/LUNC oracle down | affected pairs omitted; others still priced | | I8 | `GET /pairs?sort=liquidity_usd&order=desc` | priced desc, NULLS LAST, stable `id` | | I9 | `order=asc` | priced asc, NULLS LAST | | I10 | `sort=liquidity_usd` unknown-typo / injection | **400** | | I11 | List without sort still returns field (additive) | catalog/`volume_24h`/`relevance` pages include `liquidity_usd` when set | | I12 | Refresh then GET | list value matches last `protocol_pair_tvl` stamp (not a second formula) | | I13 | Overflow / `fits_numeric_38_18` fail | omit pair; GET still **200** | | I14 | `limit`/`offset` caps unchanged | clamp / **400** as today | | I15 | `q=` + `sort=liquidity_usd` | filter then USD sort (or document: sort ignored under relevance — **prefer USD sort only when `q` empty / column mode**) | | I16 | Double-count across two pools sharing USTC | each pair has its own TVL; no “unique-token” collapse | ### Frontend | ID | Path | Expect | |----|------|--------| | F1 | Catalog default | column visible; values from JSON; **no** `sort=liquidity_usd` until click | | F2 | Click **v2 LP USD** | `getPairs({ sort: 'liquidity_usd', order: 'desc', limit: 20 })` | | F3 | Second click | `order: 'asc'`; `aria-sort` flips | | F4 | Missing / `null` / `""` / `"Infinity"` / `"NaN"` | cell `—` | | F5 | `"1234.5"` | `$` + compact number | | F6 | Search mode | column still renders; sort click leaves search or is disabled — pick one, test it (prefer: column click clears to column mode like Vol) | | F7 | Production gems | EMBER/CORAL omitted even if `liquidity_usd` huge | | F8 | Manage expand | panel `colSpan` matches column count; Advanced still LCD-on-expand only | | F9 | Light + dark, 375px / 1280px | header readable; table scrolls horizontally; no nested `card-glass` | | F10 | Created / Vol / Fee / Source / Charts regressions | #547 tests still pass | | F11 | Old indexer mock without field | all LP cells `—`, page does not crash | ### E2E / LocalTerra | ID | Path | Expect | |----|------|--------| | E1 | `make has-localterra` + `/pool` | at least one factory pair shows `$` or `—` (not blank) | | E2 | Sort desc | first priced row ≥ later priced rows (parse formatted or intercept JSON) | | E3 | Charts / Manage still work after column add | | ## Test plan (attack, hack, abuse) | ID | Vector | Expect | |----|--------|--------| | A1 | **Symbol spoof** native `symbol=USTR` / `USTR` on `ugem` | not priced (**P569** A5) | | A2 | **vFDUSD / FDUSD** as a leg | never enters `liquidity_usd` (A7) | | A3 | **Raw 18-dec takeover** huge gem reserve vs 1 human USTC | one-sided `2×` USTC only; not `1e24` USD | | A4 | **XSS** `liquidity_usd` = `<img onerror=…>` / `javascript:` | rendered as text or `—`; no HTML | | A5 | **Sort injection** `sort=liquidity_usd;DELETE` / `sort=id FROM pairs--` | **400**; parameterized `ORDER BY` enum only | | A6 | **N+1 LCD (A8)** | default `/pool` paint does not query pair contracts | | A7 | **GET-path DoS** | `sort=liquidity_usd` EXPLAIN has no `swap_events` / no live reserve×oracle join | | A8 | **Deep offset** `offset=10001` | **400** | | A9 | **Negative limit** | clamp to 1, not 500 | | A10 | **ILIKE `%` / `_` in `q`** while sorting USD | still escaped; no full-catalog dump beyond limit | | A11 | **Unpriced as `$0`** | must not rank as poorest / richest; NULLS LAST + cell `—` | | A12 | **Client invents TVL** from `volume_quote_24h` when field missing | **forbidden** — `—` only | | A13 | **Wallet LP share** using connected address on every row | **forbidden** (privacy + N+1) | | A14 | **Book escrow / parked dust** in the number | **forbidden** | | A15 | **CG `liquidity_in_usd`** reused as cell | **forbidden** | | A16 | **Scientific JSON** `1e+19` | dApp `Number.isFinite` → `—` if non-finite; indexer should emit normalized decimal like volume | | A17 | **`javascript:` pair address** in Charts | still no `Link` (**P547-5**) | | A18 | **Header lecture / nested chrome** | no TVL essay; no `card-glass` per row (**#489** / **#653**) | | A19 | **Gem wash TVL** on production | pair omitted (**#562**), not a fake #1 USD pool | | A20 | **Stale reserves after pause / drain** | next refresh omits or updates; UI must not keep a zombie `$` from a deleted rollup row across refresh (NULL/delete the stamp) | ## Verification criteria Ship is done when **all** of the following hold: 1. `make verify-issue-NNN` (new) is green: indexer tests for stamp + list + sort + 400 + EXPLAIN; Vitest PoolPage / `poolListQuery` / format; static grep that `PoolPairsTable` does not call `getPool`. 2. `make verify-issue-547` still green (table, catalog default, Vol/Fee/Pair sorts, Charts href). 3. `make verify-issue-569` still green (global TVL formula unchanged). 4. LocalTerra smoke: `/pool` shows **v2 LP USD**; sort desc; Manage expand; no extra LCD storm on first paint. 5. Docs updated: `docs/frontend.md` P547 extension, `docs/indexer-invariants.md` pair-list liquidity row, `skills/AGENTS_FRONTEND_POOL_TABLE.md` do/don’t + verify target. AGENTS.md verify one-liner if that is the house style. 6. Coolify / old-indexer: missing field → `—`, no console throw. **Out of scope:** V3 Grid TVL (#619), per-wallet LP USD on `/portfolio`, Created timestamp cells (#547 leftover), raising pair-list `limit` to 500/1000, CG `liquidity_in_usd` rename, Protocol page changes.
PlasticDigits commented 2026-08-26 03:06:33 +00:00 (Migrated from gitlab.com)

marked as related to #547

marked as related to #547
PlasticDigits commented 2026-08-26 03:06:34 +00:00 (Migrated from gitlab.com)

marked as related to #569

marked as related to #569
PlasticDigits commented 2026-08-26 03:06:35 +00:00 (Migrated from gitlab.com)

marked as related to #556

marked as related to #556
PlasticDigits commented 2026-08-26 03:06:35 +00:00 (Migrated from gitlab.com)

marked as related to #534

marked as related to #534
PlasticDigits commented 2026-08-26 03:06:36 +00:00 (Migrated from gitlab.com)

marked as related to #562

marked as related to #562
PlasticDigits commented 2026-08-26 04:12:49 +00:00 (Migrated from gitlab.com)

mentioned in issue #661

mentioned in issue #661
PlasticDigits commented 2026-08-26 04:13:28 +00:00 (Migrated from gitlab.com)

mentioned in issue #662

mentioned in issue #662
PlasticDigits commented 2026-08-26 04:13:30 +00:00 (Migrated from gitlab.com)

marked as related to #662

marked as related to #662
PlasticDigits commented 2026-08-26 04:15:26 +00:00 (Migrated from gitlab.com)

mentioned in issue #664

mentioned in issue #664
PlasticDigits commented 2026-08-26 04:15:26 +00:00 (Migrated from gitlab.com)

marked as related to #664

marked as related to #664
PlasticDigits commented 2026-08-26 07:00:44 +00:00 (Migrated from gitlab.com)

mentioned in commit cbb45740f0

mentioned in commit cbb45740f08819ea993dd6d81b8b6e4065e02932
PlasticDigits commented 2026-08-26 07:27:29 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1173

mentioned in merge request !1173
PlasticDigits commented 2026-08-26 08:24:22 +00:00 (Migrated from gitlab.com)

mentioned in commit 638e9ca59c

mentioned in commit 638e9ca59c3ac81160e05f4df685ff5398804b69
PlasticDigits commented 2026-08-26 09:00:57 +00:00 (Migrated from gitlab.com)

mentioned in commit d52bdf039a

mentioned in commit d52bdf039a3e57a4db6eb5a9aa0b0095b73c9c22
PlasticDigits commented 2026-08-26 09:01:21 +00:00 (Migrated from gitlab.com)

mentioned in commit b64331ffec

mentioned in commit b64331ffeca9cf1272d5a1622a364e54ce0cb386
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-26 09:01:22 +00:00
PlasticDigits commented 2026-08-26 09:31:06 +00:00 (Migrated from gitlab.com)

Merged to main via !1173. List JOIN now fills liquidity_usd via pair_to_response. Module is pair_liquidity_usd (not a second pair_liquidity.rs). Default sort desc includes liquidity_usd.

Leftover: Coolify indexer migrate 20260826150000_pair_liquidity_usd.sql + redeploy so /pool USD and Created/#662 stay populated. Unstamped rows still omit USD (by design). make verify-issue-655.

Merged to `main` via !1173. List JOIN now fills `liquidity_usd` via `pair_to_response`. Module is `pair_liquidity_usd` (not a second `pair_liquidity.rs`). Default sort desc includes `liquidity_usd`. Leftover: Coolify indexer migrate `20260826150000_pair_liquidity_usd.sql` + redeploy so `/pool` USD and Created/#662 stay populated. Unstamped rows still omit USD (by design). `make verify-issue-655`.
PlasticDigits commented 2026-08-26 09:31:36 +00:00 (Migrated from gitlab.com)

mentioned in issue #673

mentioned in issue #673
PlasticDigits commented 2026-08-27 04:50:03 +00:00 (Migrated from gitlab.com)

mentioned in issue #685

mentioned in issue #685
PlasticDigits commented 2026-08-27 04:50:04 +00:00 (Migrated from gitlab.com)

marked as related to #685

marked as related to #685
PlasticDigits commented 2026-08-27 11:52:21 +00:00 (Migrated from gitlab.com)

mentioned in issue #690

mentioned in issue #690
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:12 +00:00 (Migrated from gitlab.com)

marked as related to #692

marked as related to #692
PlasticDigits commented 2026-08-29 04:56:14 +00:00 (Migrated from gitlab.com)

mentioned in issue #701

mentioned in issue #701
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#655
No description provided.