feat: Protocol DEX hub — cUSTC token contract link + LUNC/USD column #570

Closed
opened 2026-08-19 01:03:00 +00:00 by PlasticDigits · 12 comments
PlasticDigits commented 2026-08-19 01:03:00 +00:00 (Migrated from gitlab.com)

Summary

On /protocol, the DEX hub prices card (protocol-dex-hub-prices) shows cUSTC / USD, UST1 / USD, and USTR / USD. UST1 and USTR render an explorer/copy AddressRow under the price; cUSTC does not. Add the cUSTC wrap CW20 contract link in that column, and add a fourth column LUNC / USD (CEX LUNC mark, same 1:1 wrap pattern as cUSTC) with a cLUNC wrap CW20 contract link.

Related: #556 (DEX hub USD + Protocol card), #550 (Protocol layout + CEX tabs), #515 (CEX ustc | lunc | vfdusd), #541 (token identity / explorer), #378 (factory/router audit rows stay on /protocol only), #489 (short copy).

Current codebase

Protocol DEX hub card

ProtocolDexHubPrices.tsx maps allowlisted tickers from hubPriceTicker.ts:

export const HUB_PRICE_TICKERS = ['custc', 'ust1', 'ustr'] as const

Layout is sm:grid-cols-3. Each cell shows HUB_PRICE_TICKER_LABEL[ticker] / USD, formatHubUsd(row?.price_usd) (formatPairPrice, em-dash on null/non-finite/≤0), then only if row.source_pair is truthy an AddressRow of that address (copy + explorer via getExplorerAddressUrl).

GET /api/v1/hub-prices returns { metadata, tickers, prices[] } with ticker, price_usd, source_pair, tvl_usd, updated_at. It does not return the hub token CW20.

Indexer semantics (#556 H1–H10, AGENTS_INDEXER_HUB_USD.md):

Ticker USD source source_pair today
custc #515 USTC CEX oracle (usd(cUSTC) = usd(uusd)). Oracle down → NULL, not $1. always null (not a DEX pool mark)
ust1 Max USD-TVL factory pair whose legs are hub cUSTC + hub UST1 (reserve spot). winning pair contract
ustr Max USD-TVL factory pair vs already-priced cUSTC or UST1. winning pair contract

So the AddressRows under UST1/USTR are source pair (pricing pool), not the UST1/USTR token contracts. The cUSTC column has no AddressRow because source_pair is null. Operators looking at /protocol cannot click through to the cUSTC wrap they are actually quoting.

Hub identity is already configured:

hub_prices.ticker CHECK is IN ('custc', 'ust1', 'ustr') (indexer/migrations/20260818180000_hub_prices.sql). GET /api/v1/hub-prices/{ticker} unknown → 400. GET /api/v1/oracle/price/custc (and ust1, ustr) stay 400 (H6). CEX catalog stays ustc | lunc | vfdusd.

LUNC today

LUNC/USD already exists as a CEX oracle tab on the same page (ProtocolOracleCard.tsx, ?ticker=lunc allowlisted in protocolOracleTicker.ts). Volume ingest already uses the #515 LUNC feed for LUNC / cLUNC / uluna (P522-Q / X4). There is no DEX-hub column, so the census card that shows cUSTC/UST1/USTR cannot show LUNC next to them without switching CEX tabs (and the CEX card is history + sources, not a compact four-mark strip).

Native uluna is not a CW20. Token identity (T541-2): do not invent a Finder URL for native denoms — copy-only.

Page order (do not shuffle)

Title → Global stats → DEX hub prices → one CEX oracle card → factory/router audit → hooks (P550-1 / H5).

Why this is needed

  1. cUSTC identity gap. UST1/USTR columns expose a clickable Terra contract. cUSTC is the USD anchor of the hub graph and the wrap users actually hold/trade, but the column is a naked price. Auditors cannot confirm they are looking at the configured wrap vs a symbol-spoof CW20 without leaving the page.
  2. LUNC belongs in the same strip. cUSTC is USTC-oracle-anchored; LUNC is the other native/wrap USD handle the protocol already trusts for volume (P522-Q). Putting LUNC / USD beside cUSTC makes the hub card a complete “majors” reference without stuffing LUNC into a fourth CEX tab or pretending it is a DEX pool mark.
  3. Do not lie about source. Inventing a source_pair for cUSTC/LUNC (deepest UST1/cUSTC pool, etc.) would mislabel a CEX-oracle wrap as a pool mark. The missing chrome is token identity, not a fake pair.

Constraints / guardrails

  1. Advisory only (X5 / P550-11 / H5). Hub card stays “DEX reference — not CEX, not settlement.” Do not call it TWAP, /ust1 window, or settlement. Short copy (#489) — no essays.
  2. Do not add lunc (or clunc) as a fourth CEX oracle tab. CEX tabs remain exactly ustc | lunc | vfdusd (P550-2). ?ticker= allowlist unchanged. Do not fetch CEX ustr / ust1 / custc.
  3. Do not put lunc on GET /api/v1/oracle/price as a new alias. GET /oracle/price/lunc already exists (CEX). Unknown CEX tickers stay 400. Hub DEX snapshot stays GET /api/v1/hub-prices (H6).
  4. cUSTC USD formula unchanged (H1). Still #515 USTC CEX 1:1. Oracle down → —, not $1 / $0. Do not rank a factory pair to populate cUSTC source_pair.
  5. LUNC/USD in this card = #515 LUNC CEX oracle (usd(cLUNC) = usd(uluna) = lunc_oracle), same wrap 1:1 as cUSTC. Oracle down → —. Do not take LUNC from a DEX pool (manipulable thin book). Do not use vFDUSD/FDUSD (P550-10 / H7).
  6. Identity is contract, not symbol (A1 / H8). cUSTC link = configured hub cUSTC CW20 (HUB_CUSTC_ADDRESS / VITE_USTC_C_TOKEN_ADDRESS). LUNC column link = configured cLUNC wrap CW20 (VITE_LUNC_C_TOKEN_ADDRESS / new HUB_CLUNC_ADDRESS). Never match symbol === 'cUSTC' / 'LUNC'. Never substring-match cUSTC inside UST1.
  7. Native uluna is copy-only (T541-2). Do not build a Finder /address/uluna URL. Label the column LUNC / USD; the explorer target is the cLUNC wrap, with aria-labels that say wrap/cLUNC, not “native LUNC contract”.
  8. Explorer hrefs only via getExplorerAddressUrl (T541-2 / #430). Invalid bech32, javascript:, data:, HTML, ../, empty, wrong HRP → omit the <a>, keep copy if the string is a valid allowlisted address. rel="noopener noreferrer" + target="_blank".
  9. Do not clone factory/router AddressRow onto Swap confirmation (P550-12 / #378). This work is /protocol hub card only.
  10. Do not change UST1/USTR ranking, volume_usd ingest, Charts Price (USD), /ust1 window, wrap fees, or limit validate_limit_order_price. Adding LUNC to the hub display must not retarget volume conversion (already LUNC CEX).
  11. Keep existing UST1/USTR source_pair rows. Those addresses are pairs, not tokens. Do not relabel them as “UST1 token” / “USTR token”. Optional additive token AddressRow for UST1/USTR is in scope only if it stays visually secondary and aria-labels distinguish token vs source pair — default is: add token rows for oracle-anchored columns only (cUSTC, LUNC) to close the gap without doubling chrome (#489).
  12. Allowlist ticker paths. parseHubPriceTicker / HubTicker::parse: homoglyphs, ../, javascript:, extra underscores → null / 400. Never interpolate raw path/query into SQL or fetch URLs.
  13. Layout. Four columns must not overflow phone 390×844 or tablet 820×1180 (existing protocol e2e). Prefer grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 (or equivalent), not a forced sm:grid-cols-4 that crushes AddressRows.
  14. Outage banner. If LUNC in this card uses a new query, include it in detectMarketDataOutage. Prefer one getHubPrices() so the existing hub query remains the only extra Protocol fetch.
  15. LocalTerra. Hub wrap addresses come from deploy env, not hardcoded Columbus-5, when HUB_* / VITE_*_C_TOKEN_ADDRESS are set.

Relevant files

File Role
frontend-dapp/src/components/protocol/ProtocolDexHubPrices.tsx Hub card UI; source_pair-only AddressRow today
frontend-dapp/src/utils/hubPriceTicker.ts Ticker allowlist + labels
frontend-dapp/src/utils/__tests__/hubPriceTicker.test.ts Allowlist / parse tests
frontend-dapp/src/pages/ProtocolPage.tsx Page order; outage detection
frontend-dapp/src/pages/ProtocolPage.test.tsx Hub card RTL (3 tickers, no CEX ustr)
frontend-dapp/e2e/protocol-page.spec.ts Layout / tabs smoke
frontend-dapp/src/components/protocol/useProtocolHubPricesQuery.ts GET /hub-prices
frontend-dapp/src/components/ui/AddressRow.tsx Copy + explorer
frontend-dapp/src/utils/terraExplorer.ts getExplorerAddressUrl
frontend-dapp/src/utils/constants.ts USTC_C_TOKEN_ADDRESS, LUNC_C_TOKEN_ADDRESS
frontend-dapp/src/types/index.ts IndexerHubPriceEntry
indexer/src/api/hub_prices.rs Hub HTTP; tickers custc|ust1|ustr
indexer/src/indexer/hub_usd.rs Hub resolver (H1–H3)
indexer/src/db/queries/hub_prices.rs Materialized snapshot
indexer/migrations/20260818180000_hub_prices.sql CHECK constraint
indexer/tests/api_hub_prices.rs API tests
indexer/src/api/oracle.rs CEX — do not add hub tickers here
skills/AGENTS_INDEXER_HUB_USD.md H1–H10
skills/AGENTS_FRONTEND_PROTOCOL_STATS.md P550 page order
docs/frontend.md Protocol section
docs/indexer-invariants.md DEX hub USD / P522-Q rows
Makefile + scripts/qa/verify-issue-556.sh Existing hub regression; add verify-issue-<iid>

1. Token identity on oracle-anchored hub columns (cUSTC, LUNC)

Render an AddressRow for the wrap CW20 when a validated address is available:

  • cUSTC column: hub cUSTC CW20.
  • LUNC column: hub cLUNC CW20.
  • Prefer an additive JSON field on each hub price, e.g. asset_address (CW20) or asset: { contract, denom }, filled from indexer hub config / assets.contract_address for asset_id. Frontend must still pass it through getExplorerAddressUrl (never trust indexer to supply javascript: hrefs).
  • Fallback (frontend-only): if API is not extended, use allowlisted USTC_C_TOKEN_ADDRESS / LUNC_C_TOKEN_ADDRESS (env overlay, same as wrap stack). Empty / invalid → omit the row (price may still show). Do not hardcode a Finder URL string.
  • Aria: Copy cUSTC token contract / View cUSTC token contract on explorer (and cLUNC equivalents). Distinct from existing Copy UST1 source pair.
  • data-testid: protocol-dex-hub-custc-token and protocol-dex-hub-lunc-token (plus existing protocol-dex-hub-{ticker} / -usd).

2. LUNC / USD column

Add ticker lunc to the hub card allowlist (UI label LUNC / USD).

Preferred data path (one query): extend indexer hub_prices + HubTicker with lunc:

  • price_usd = latest #515 LUNC CEX oracle (same fail-closed as cUSTC).
  • source_pair = null.
  • asset_address = configured cLUNC wrap (new HUB_CLUNC_ADDRESS, LocalTerra override).
  • Migration: widen hub_prices_ticker_chk; refresh loop writes lunc on oracle tick (no swap_events scan).
  • GET /api/v1/hub-prices tickers array becomes ["custc","lunc","ust1","ustr"] or documented order. Unknown path still 400.
  • GET /oracle/price/lunc unchanged (CEX history/sources). Do not drop the CEX LUNC tab.

Acceptable smaller slice: frontend fetches getOraclePrice('lunc') only for this cell and still uses allowlisted cLUNC wrap for the AddressRow. Must fold that query into Protocol outage detection. Do not call getOraclePrice('lunc') with user-controlled ticker.

Column order (recommended): cUSTC, LUNC, UST1, USTR (oracle-anchored wraps first, then DEX-derived hubs). Document in docs/frontend.md.

3. Docs / skills / verify

  • Update H5 text: hub card shows cUSTC, LUNC, UST1, USTR; CEX tabs unchanged.
  • New invariants for token AddressRows + LUNC hub display (IDs in the verify script).
  • make verify-issue-<iid> wrapping hub ticker parse tests, Protocol RTL, indexer hub API (if extended), and make verify-issue-556 / 550 / 515 / 541 as regressions.

Acceptance criteria

  • P1. cUSTC / USD column shows copy + explorer for the configured cUSTC wrap CW20 when the address is valid. Explorer href is getExplorerAddressUrl only.
  • P2. UST1 / USTR keep existing source pair AddressRows; labels still say source pair, not token. cUSTC still has no fake source_pair.
  • P3. New LUNC / USD column on the same hub card. Price is #515 LUNC CEX (wrap 1:1). Null oracle → —, not $0 / $1 / USTC price.
  • P4. LUNC column shows copy + explorer for the configured cLUNC wrap CW20 when valid. Native uluna is not given a fabricated Finder URL.
  • P5. CEX oracle card still exactly three tabs (ustc | lunc | vfdusd). No getOraclePrice('ustr'|'ust1'|'custc'). ?ticker= reject javascript:, ../, unknown → ustc.
  • P6. Hub HTTP: unknown GET /hub-prices/{ticker} still 400. GET /oracle/price/custc|ust1|ustr still 400. If lunc is added to hub-prices, GET /hub-prices/lunc is the DEX-card snapshot, distinct from CEX /oracle/price/lunc.
  • P7. Grid usable at 390×844 and 820×1180; AddressRows do not overflow or overlap. Page order unchanged.
  • P8. Symbol-spoof / wrong HRP / empty env: omit token AddressRow; do not render javascript: or open-redirect href.
  • P9. volume_usd, UST1/USTR ranking, Charts USD, /ust1 window unchanged. vFDUSD not used for this column.
  • P10. Skills + docs/frontend.md + docs/indexer-invariants.md updated; make verify-issue-<iid> exists and passes.

Test plan — functional paths

Frontend RTL (ProtocolPage.test.tsx / hub ticker unit tests)

  • cUSTC token AddressRow present when mock/env supplies valid terra1… cUSTC; copy payload is that contract, not the label cUSTC.
  • cUSTC token row omitted when address empty/invalid; price still renders.
  • UST1/USTR source_pair rows still render from mock source_pair; cUSTC still has no source_pair row when source_pair: null.
  • LUNC column renders; mock LUNC price_usd formats with formatPairPrice ($), not formatNum compact T.
  • LUNC null/non-finite/≤0 → —.
  • LUNC token AddressRow uses cLUNC wrap; omitted if unset.
  • Still no getOraclePrice('ustr'|'ust1'|'custc').
  • parseHubPriceTicker('lunc') (if added) accepts; javascript:, ../ust1, LUNC\u200b, clunc (unless you explicitly alias — default no clunc path alias) → null/400.
  • Outage: hub 502 still banner without leaking VITE_INDEXER_URL / host:port (#422).

Indexer (if hub API extended; make setup-indexer-postgres)

  • GET /api/v1/hub-prices includes lunc with price_usd = LUNC oracle, source_pair null, asset_address = configured cLUNC.
  • custc entry includes asset_address = configured cUSTC; source_pair still null.
  • LUNC oracle down → lunc.price_usd null (or omitted mark), not last USTC tick.
  • USTC oracle down does not zero LUNC hub (independent feeds).
  • GET /hub-prices/not-a-ticker 400; GET /oracle/price/ustr 400; catalog CEX still three tickers.
  • CHECK constraint accepts lunc; refresh does not scan swap_events on GET.
  • Existing api_hub_prices / #556 tests still pass (UST1/USTR ranking, dust floor, symbol spoof).

E2E / LocalTerra

  • /protocol hub card: four columns; cUSTC and LUNC token links open explorer (or LCD account URL on local) for wrap CW20s from .env.local.
  • Phone 390×844 and tablet 820×1180: all four cells visible without covering the CEX card; CEX LUNC tab still works.
  • Manual: cUSTC link address equals wrap-mapper / tokenlist cUSTC, not the UST1/cUSTC pair.

Test plan — attack, hack, and abuse

Treat explorer/copy chrome as a trust-boundary surface (#430, #378, H8).

Open redirect / XSS via addresses and tickers

  • Hub JSON asset_address / source_pair = javascript:alert(1), data:text/html,…, https://evil.example/, terra1 + HTML, ../ → no <a href>. Copy either no-ops or copies only after bech32 allowlist.
  • GET /hub-prices/{ticker} and ?ticker= with lunc%2e%2e%2f, lunc%00, homoglyph lunc, LUNC, extra path segments → 400 or ignored; never concatenated into SQL or fetch(${base}/${user}).
  • Indexer asset_address pointing at a different CW20 than hub config must not win: display uses configured hub wrap, or omit. Symbol cUSTC on a gem CW20 must not appear as the cUSTC link.

Spoof / thin-pool / oracle confusion

  • Native gem symbol=LUNC or symbol=cUSTC cannot become the AddressRow target (contract allowlist only).
  • Dust UST1/cLUNC pool cannot become LUNC/USD (LUNC column is oracle, not max-TVL).
  • Attacker cannot pass custc into CEX getOraclePrice from this card.
  • vFDUSD/$1 must not appear as LUNC or cUSTC hub USD.

Clickjacking / new-window

  • Explorer anchors: rel="noopener noreferrer" target="_blank". No window.open with attacker URL.

Clipboard abuse

  • Copy writes the allowlisted contract string, never innerHTML or display symbol. No silent copy of source_pair when the control is labeled token.

Availability / DoS

  • Adding LUNC must not add a live swap_events aggregate on Protocol GET.
  • Extra frontend oracle fetch (if used) shares existing retry:false / interval; no unbounded refetch on invalid ticker.

LocalTerra / env injection

  • VITE_USTC_C_TOKEN_ADDRESS=' javascript:…' or non-bech32 → omit explorer. Do not interpolate env into href without getExplorerAddressUrl.

Verification criteria

Done when:

  1. make verify-issue-<iid> is green (new script + docs/skill grep as needed).
  2. make verify-issue-556, make verify-issue-550, make verify-issue-515, make verify-issue-541 still green.
  3. RTL: cUSTC token link + LUNC column + LUNC token link + source_pair regression + allowlist rejects.
  4. If indexer changed: cargo test hub/oracle API cases above with Postgres.
  5. Manual /protocol: four hub cells; cUSTC and LUNC explorer targets are wrap CW20s; UST1/USTR still show pair contracts; CEX tabs unchanged.

Out of scope

  • Fourth CEX tab, CEX history table for LUNC inside the hub card, or merging the two cards.
  • Pricing LUNC/cLUNC from DEX reserves.
  • Changing P522-Q / volume_usd / Charts candles / /ust1.
  • Native uluna Finder URLs.
  • Cloning hub AddressRows onto Swap, Trade ticket, or Charts pair chrome (those are #541).
## Summary On `/protocol`, the **DEX hub prices** card (`protocol-dex-hub-prices`) shows **cUSTC / USD**, **UST1 / USD**, and **USTR / USD**. UST1 and USTR render an explorer/copy `AddressRow` under the price; **cUSTC does not**. Add the **cUSTC wrap CW20** contract link in that column, and add a fourth column **LUNC / USD** (CEX LUNC mark, same 1:1 wrap pattern as cUSTC) with a **cLUNC wrap CW20** contract link. Related: [#556](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/556) (DEX hub USD + Protocol card), [#550](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/550) (Protocol layout + CEX tabs), [#515](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/515) (CEX `ustc` \| `lunc` \| `vfdusd`), [#541](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/541) (token identity / explorer), [#378](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/378) (factory/router audit rows stay on `/protocol` only), [#489](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/489) (short copy). ## Current codebase ### Protocol DEX hub card [`ProtocolDexHubPrices.tsx`](frontend-dapp/src/components/protocol/ProtocolDexHubPrices.tsx) maps allowlisted tickers from [`hubPriceTicker.ts`](frontend-dapp/src/utils/hubPriceTicker.ts): ```ts export const HUB_PRICE_TICKERS = ['custc', 'ust1', 'ustr'] as const ``` Layout is `sm:grid-cols-3`. Each cell shows `HUB_PRICE_TICKER_LABEL[ticker] / USD`, `formatHubUsd(row?.price_usd)` (`formatPairPrice`, em-dash on null/non-finite/`≤0`), then **only if** `row.source_pair` is truthy an [`AddressRow`](frontend-dapp/src/components/ui/AddressRow.tsx) of that address (copy + explorer via `getExplorerAddressUrl`). [`GET /api/v1/hub-prices`](indexer/src/api/hub_prices.rs) returns `{ metadata, tickers, prices[] }` with `ticker`, `price_usd`, `source_pair`, `tvl_usd`, `updated_at`. It does **not** return the hub **token** CW20. Indexer semantics ([#556](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/556) **H1–H10**, [`AGENTS_INDEXER_HUB_USD.md`](skills/AGENTS_INDEXER_HUB_USD.md)): | Ticker | USD source | `source_pair` today | |--------|------------|---------------------| | `custc` | #515 USTC CEX oracle (`usd(cUSTC) = usd(uusd)`). Oracle down → NULL, not `$1`. | **always null** (not a DEX pool mark) | | `ust1` | Max USD-TVL factory pair whose legs are hub cUSTC + hub UST1 (reserve spot). | winning **pair** contract | | `ustr` | Max USD-TVL factory pair vs already-priced cUSTC or UST1. | winning **pair** contract | So the AddressRows under UST1/USTR are **source pair** (pricing pool), not the UST1/USTR token contracts. The cUSTC column has **no** AddressRow because `source_pair` is null. Operators looking at `/protocol` cannot click through to the **cUSTC wrap** they are actually quoting. Hub identity is already configured: - Indexer: `HUB_CUSTC_ADDRESS` / `HUB_UST1_ADDRESS` / `HUB_USTR_ADDRESS` ([`indexer/.env.example`](indexer/.env.example), [`indexer/src/config.rs`](indexer/src/config.rs) `DEFAULT_HUB_CUSTC_ADDRESS`). - dApp: `VITE_USTC_C_TOKEN_ADDRESS` → `USTC_C_TOKEN_ADDRESS`; `VITE_LUNC_C_TOKEN_ADDRESS` → `LUNC_C_TOKEN_ADDRESS` ([`constants.ts`](frontend-dapp/src/utils/constants.ts)). Mainnet cUSTC also appears as `MAINNET_CUSTC_TOKEN_ADDRESS` in [`ust1SecondaryMarket.ts`](frontend-dapp/src/utils/ust1SecondaryMarket.ts). `hub_prices.ticker` CHECK is `IN ('custc', 'ust1', 'ustr')` ([`indexer/migrations/20260818180000_hub_prices.sql`](indexer/migrations/20260818180000_hub_prices.sql)). `GET /api/v1/hub-prices/{ticker}` unknown → **400**. `GET /api/v1/oracle/price/custc` (and `ust1`, `ustr`) stay **400** (**H6**). CEX catalog stays `ustc` \| `lunc` \| `vfdusd`. ### LUNC today LUNC/USD already exists as a **CEX oracle tab** on the same page ([`ProtocolOracleCard.tsx`](frontend-dapp/src/components/protocol/ProtocolOracleCard.tsx), `?ticker=lunc` allowlisted in [`protocolOracleTicker.ts`](frontend-dapp/src/utils/protocolOracleTicker.ts)). Volume ingest already uses the #515 LUNC feed for LUNC / cLUNC / `uluna` (**P522-Q** / **X4**). There is **no** DEX-hub column, so the census card that shows cUSTC/UST1/USTR cannot show LUNC next to them without switching CEX tabs (and the CEX card is history + sources, not a compact four-mark strip). Native `uluna` is **not** a CW20. Token identity (**T541-2**): do **not** invent a Finder URL for native denoms — copy-only. ### Page order (do not shuffle) Title → Global stats → **DEX hub prices** → one CEX oracle card → factory/router audit → hooks (**P550-1** / **H5**). ## Why this is needed 1. **cUSTC identity gap.** UST1/USTR columns expose a clickable Terra contract. cUSTC is the USD **anchor** of the hub graph and the wrap users actually hold/trade, but the column is a naked price. Auditors cannot confirm they are looking at the configured wrap vs a symbol-spoof CW20 without leaving the page. 2. **LUNC belongs in the same strip.** cUSTC is USTC-oracle-anchored; LUNC is the other native/wrap USD handle the protocol already trusts for volume (**P522-Q**). Putting **LUNC / USD** beside cUSTC makes the hub card a complete “majors” reference without stuffing LUNC into a fourth CEX tab or pretending it is a DEX pool mark. 3. **Do not lie about source.** Inventing a `source_pair` for cUSTC/LUNC (deepest UST1/cUSTC pool, etc.) would mislabel a CEX-oracle wrap as a pool mark. The missing chrome is **token identity**, not a fake pair. ## Constraints / guardrails 1. **Advisory only (X5 / P550-11 / H5).** Hub card stays “DEX reference — not CEX, not settlement.” Do not call it TWAP, `/ust1` window, or settlement. Short copy ([#489](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/489)) — no essays. 2. **Do not add `lunc` (or `clunc`) as a fourth CEX oracle tab.** CEX tabs remain exactly `ustc` \| `lunc` \| `vfdusd` (**P550-2**). `?ticker=` allowlist unchanged. Do not fetch CEX `ustr` / `ust1` / `custc`. 3. **Do not put `lunc` on `GET /api/v1/oracle/price` as a new alias.** `GET /oracle/price/lunc` already exists (CEX). Unknown CEX tickers stay **400**. Hub DEX snapshot stays `GET /api/v1/hub-prices` (**H6**). 4. **cUSTC USD formula unchanged (H1).** Still #515 USTC CEX 1:1. Oracle down → `—`, not `$1` / `$0`. Do **not** rank a factory pair to populate cUSTC `source_pair`. 5. **LUNC/USD in this card = #515 LUNC CEX oracle** (`usd(cLUNC) = usd(uluna) = lunc_oracle`), same wrap 1:1 as cUSTC. Oracle down → `—`. Do **not** take LUNC from a DEX pool (manipulable thin book). Do **not** use vFDUSD/FDUSD (**P550-10** / **H7**). 6. **Identity is contract, not symbol (A1 / H8).** cUSTC link = configured hub cUSTC CW20 (`HUB_CUSTC_ADDRESS` / `VITE_USTC_C_TOKEN_ADDRESS`). LUNC column link = configured **cLUNC wrap** CW20 (`VITE_LUNC_C_TOKEN_ADDRESS` / new `HUB_CLUNC_ADDRESS`). Never match `symbol === 'cUSTC'` / `'LUNC'`. Never substring-match `cUSTC` inside `UST1`. 7. **Native `uluna` is copy-only (T541-2).** Do not build a Finder `/address/uluna` URL. Label the column **LUNC / USD**; the explorer target is the **cLUNC wrap**, with aria-labels that say wrap/cLUNC, not “native LUNC contract”. 8. **Explorer hrefs only via `getExplorerAddressUrl` (T541-2 / #430).** Invalid bech32, `javascript:`, `data:`, HTML, `../`, empty, wrong HRP → omit the `<a>`, keep copy if the string is a valid allowlisted address. `rel="noopener noreferrer"` + `target="_blank"`. 9. **Do not clone factory/router `AddressRow` onto Swap confirmation (P550-12 / #378).** This work is `/protocol` hub card only. 10. **Do not change** UST1/USTR ranking, `volume_usd` ingest, Charts Price (USD), `/ust1` window, wrap fees, or limit `validate_limit_order_price`. Adding LUNC to the hub **display** must not retarget volume conversion (already LUNC CEX). 11. **Keep existing UST1/USTR `source_pair` rows.** Those addresses are **pairs**, not tokens. Do not relabel them as “UST1 token” / “USTR token”. Optional additive **token** `AddressRow` for UST1/USTR is in scope **only if** it stays visually secondary and aria-labels distinguish token vs source pair — default is: **add token rows for oracle-anchored columns only** (cUSTC, LUNC) to close the gap without doubling chrome ([#489](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/489)). 12. **Allowlist ticker paths.** `parseHubPriceTicker` / `HubTicker::parse`: homoglyphs, `../`, `javascript:`, extra underscores → `null` / **400**. Never interpolate raw path/query into SQL or `fetch` URLs. 13. **Layout.** Four columns must not overflow phone 390×844 or tablet 820×1180 (existing protocol e2e). Prefer `grid-cols-1 sm:grid-cols-2 xl:grid-cols-4` (or equivalent), not a forced `sm:grid-cols-4` that crushes AddressRows. 14. **Outage banner.** If LUNC in this card uses a new query, include it in `detectMarketDataOutage`. Prefer **one** `getHubPrices()` so the existing hub query remains the only extra Protocol fetch. 15. **LocalTerra.** Hub wrap addresses come from deploy env, not hardcoded Columbus-5, when `HUB_*` / `VITE_*_C_TOKEN_ADDRESS` are set. ## Relevant files | File | Role | |------|------| | [`frontend-dapp/src/components/protocol/ProtocolDexHubPrices.tsx`](frontend-dapp/src/components/protocol/ProtocolDexHubPrices.tsx) | Hub card UI; source_pair-only AddressRow today | | [`frontend-dapp/src/utils/hubPriceTicker.ts`](frontend-dapp/src/utils/hubPriceTicker.ts) | Ticker allowlist + labels | | [`frontend-dapp/src/utils/__tests__/hubPriceTicker.test.ts`](frontend-dapp/src/utils/__tests__/hubPriceTicker.test.ts) | Allowlist / parse tests | | [`frontend-dapp/src/pages/ProtocolPage.tsx`](frontend-dapp/src/pages/ProtocolPage.tsx) | Page order; outage detection | | [`frontend-dapp/src/pages/ProtocolPage.test.tsx`](frontend-dapp/src/pages/ProtocolPage.test.tsx) | Hub card RTL (3 tickers, no CEX `ustr`) | | [`frontend-dapp/e2e/protocol-page.spec.ts`](frontend-dapp/e2e/protocol-page.spec.ts) | Layout / tabs smoke | | [`frontend-dapp/src/components/protocol/useProtocolHubPricesQuery.ts`](frontend-dapp/src/components/protocol/useProtocolHubPricesQuery.ts) | `GET /hub-prices` | | [`frontend-dapp/src/components/ui/AddressRow.tsx`](frontend-dapp/src/components/ui/AddressRow.tsx) | Copy + explorer | | [`frontend-dapp/src/utils/terraExplorer.ts`](frontend-dapp/src/utils/terraExplorer.ts) | `getExplorerAddressUrl` | | [`frontend-dapp/src/utils/constants.ts`](frontend-dapp/src/utils/constants.ts) | `USTC_C_TOKEN_ADDRESS`, `LUNC_C_TOKEN_ADDRESS` | | [`frontend-dapp/src/types/index.ts`](frontend-dapp/src/types/index.ts) | `IndexerHubPriceEntry` | | [`indexer/src/api/hub_prices.rs`](indexer/src/api/hub_prices.rs) | Hub HTTP; tickers `custc`\|`ust1`\|`ustr` | | [`indexer/src/indexer/hub_usd.rs`](indexer/src/indexer/hub_usd.rs) | Hub resolver (H1–H3) | | [`indexer/src/db/queries/hub_prices.rs`](indexer/src/db/queries/hub_prices.rs) | Materialized snapshot | | [`indexer/migrations/20260818180000_hub_prices.sql`](indexer/migrations/20260818180000_hub_prices.sql) | CHECK constraint | | [`indexer/tests/api_hub_prices.rs`](indexer/tests/api_hub_prices.rs) | API tests | | [`indexer/src/api/oracle.rs`](indexer/src/api/oracle.rs) | CEX — **do not** add hub tickers here | | [`skills/AGENTS_INDEXER_HUB_USD.md`](skills/AGENTS_INDEXER_HUB_USD.md) | H1–H10 | | [`skills/AGENTS_FRONTEND_PROTOCOL_STATS.md`](skills/AGENTS_FRONTEND_PROTOCOL_STATS.md) | P550 page order | | [`docs/frontend.md`](docs/frontend.md) | Protocol section | | [`docs/indexer-invariants.md`](docs/indexer-invariants.md) | DEX hub USD / P522-Q rows | | [`Makefile`](Makefile) + `scripts/qa/verify-issue-556.sh` | Existing hub regression; add `verify-issue-<iid>` | ## Recommended direction ### 1. Token identity on oracle-anchored hub columns (cUSTC, LUNC) Render an `AddressRow` for the **wrap CW20** when a validated address is available: - cUSTC column: hub cUSTC CW20. - LUNC column: hub cLUNC CW20. - Prefer an additive JSON field on each hub price, e.g. `asset_address` (CW20) or `asset: { contract, denom }`, filled from indexer hub config / `assets.contract_address` for `asset_id`. Frontend must still pass it through `getExplorerAddressUrl` (never trust indexer to supply `javascript:` hrefs). - **Fallback (frontend-only):** if API is not extended, use allowlisted `USTC_C_TOKEN_ADDRESS` / `LUNC_C_TOKEN_ADDRESS` (env overlay, same as wrap stack). Empty / invalid → omit the row (price may still show). Do not hardcode a Finder URL string. - Aria: `Copy cUSTC token contract` / `View cUSTC token contract on explorer` (and cLUNC equivalents). Distinct from existing `Copy UST1 source pair`. - `data-testid`: `protocol-dex-hub-custc-token` and `protocol-dex-hub-lunc-token` (plus existing `protocol-dex-hub-{ticker}` / `-usd`). ### 2. LUNC / USD column Add ticker `lunc` to the **hub card allowlist** (UI label **LUNC / USD**). **Preferred data path (one query):** extend indexer `hub_prices` + `HubTicker` with `lunc`: - `price_usd` = latest #515 LUNC CEX oracle (same fail-closed as cUSTC). - `source_pair` = null. - `asset_address` = configured cLUNC wrap (new `HUB_CLUNC_ADDRESS`, LocalTerra override). - Migration: widen `hub_prices_ticker_chk`; refresh loop writes `lunc` on oracle tick (no `swap_events` scan). - `GET /api/v1/hub-prices` `tickers` array becomes `["custc","lunc","ust1","ustr"]` or documented order. Unknown path still **400**. - `GET /oracle/price/lunc` unchanged (CEX history/sources). Do **not** drop the CEX LUNC tab. **Acceptable smaller slice:** frontend fetches `getOraclePrice('lunc')` only for this cell **and** still uses allowlisted cLUNC wrap for the AddressRow. Must fold that query into Protocol outage detection. Do not call `getOraclePrice('lunc')` with user-controlled ticker. Column order (recommended): **cUSTC, LUNC, UST1, USTR** (oracle-anchored wraps first, then DEX-derived hubs). Document in `docs/frontend.md`. ### 3. Docs / skills / verify - Update **H5** text: hub card shows cUSTC, LUNC, UST1, USTR; CEX tabs unchanged. - New invariants for token AddressRows + LUNC hub display (IDs in the verify script). - `make verify-issue-<iid>` wrapping hub ticker parse tests, Protocol RTL, indexer hub API (if extended), and `make verify-issue-556` / `550` / `515` / `541` as regressions. ## Acceptance criteria - [ ] **P1.** cUSTC / USD column shows copy + explorer for the configured **cUSTC wrap CW20** when the address is valid. Explorer `href` is `getExplorerAddressUrl` only. - [ ] **P2.** UST1 / USTR keep existing **source pair** AddressRows; labels still say source pair, not token. cUSTC still has **no** fake `source_pair`. - [ ] **P3.** New **LUNC / USD** column on the same hub card. Price is #515 LUNC CEX (wrap 1:1). Null oracle → `—`, not `$0` / `$1` / USTC price. - [ ] **P4.** LUNC column shows copy + explorer for the configured **cLUNC wrap CW20** when valid. Native `uluna` is not given a fabricated Finder URL. - [ ] **P5.** CEX oracle card still exactly three tabs (`ustc` \| `lunc` \| `vfdusd`). No `getOraclePrice('ustr'|'ust1'|'custc')`. `?ticker=` reject `javascript:`, `../`, unknown → `ustc`. - [ ] **P6.** Hub HTTP: unknown `GET /hub-prices/{ticker}` still **400**. `GET /oracle/price/custc|ust1|ustr` still **400**. If `lunc` is added to hub-prices, `GET /hub-prices/lunc` is the DEX-card snapshot, distinct from CEX `/oracle/price/lunc`. - [ ] **P7.** Grid usable at 390×844 and 820×1180; AddressRows do not overflow or overlap. Page order unchanged. - [ ] **P8.** Symbol-spoof / wrong HRP / empty env: omit token AddressRow; do not render `javascript:` or open-redirect `href`. - [ ] **P9.** `volume_usd`, UST1/USTR ranking, Charts USD, `/ust1` window unchanged. vFDUSD not used for this column. - [ ] **P10.** Skills + `docs/frontend.md` + `docs/indexer-invariants.md` updated; `make verify-issue-<iid>` exists and passes. ## Test plan — functional paths ### Frontend RTL (`ProtocolPage.test.tsx` / hub ticker unit tests) - [ ] cUSTC token AddressRow present when mock/env supplies valid `terra1…` cUSTC; copy payload is that contract, not the label `cUSTC`. - [ ] cUSTC token row omitted when address empty/invalid; price still renders. - [ ] UST1/USTR source_pair rows still render from mock `source_pair`; cUSTC still has no source_pair row when `source_pair: null`. - [ ] LUNC column renders; mock LUNC `price_usd` formats with `formatPairPrice` (`$`), not `formatNum` compact `T`. - [ ] LUNC null/non-finite/`≤0` → `—`. - [ ] LUNC token AddressRow uses cLUNC wrap; omitted if unset. - [ ] Still **no** `getOraclePrice('ustr'|'ust1'|'custc')`. - [ ] `parseHubPriceTicker('lunc')` (if added) accepts; `javascript:`, `../ust1`, `LUNC\u200b`, `clunc` (unless you explicitly alias — default **no** `clunc` path alias) → null/400. - [ ] Outage: hub 502 still banner without leaking `VITE_INDEXER_URL` / host:port ([#422](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/422)). ### Indexer (if hub API extended; `make setup-indexer-postgres`) - [ ] `GET /api/v1/hub-prices` includes `lunc` with `price_usd` = LUNC oracle, `source_pair` null, `asset_address` = configured cLUNC. - [ ] `custc` entry includes `asset_address` = configured cUSTC; `source_pair` still null. - [ ] LUNC oracle down → `lunc.price_usd` null (or omitted mark), not last USTC tick. - [ ] USTC oracle down does **not** zero LUNC hub (independent feeds). - [ ] `GET /hub-prices/not-a-ticker` **400**; `GET /oracle/price/ustr` **400**; catalog CEX still three tickers. - [ ] CHECK constraint accepts `lunc`; refresh does not scan `swap_events` on GET. - [ ] Existing `api_hub_prices` / #556 tests still pass (UST1/USTR ranking, dust floor, symbol spoof). ### E2E / LocalTerra - [ ] `/protocol` hub card: four columns; cUSTC and LUNC token links open explorer (or LCD account URL on `local`) for wrap CW20s from `.env.local`. - [ ] Phone 390×844 and tablet 820×1180: all four cells visible without covering the CEX card; CEX LUNC tab still works. - [ ] Manual: cUSTC link address equals wrap-mapper / tokenlist cUSTC, not the UST1/cUSTC pair. ## Test plan — attack, hack, and abuse Treat explorer/copy chrome as a **trust-boundary** surface ([#430](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/430), [#378](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/378), **H8**). ### Open redirect / XSS via addresses and tickers - [ ] Hub JSON `asset_address` / `source_pair` = `javascript:alert(1)`, `data:text/html,…`, `https://evil.example/`, `terra1` + HTML, `../` → **no** `<a href>`. Copy either no-ops or copies only after bech32 allowlist. - [ ] `GET /hub-prices/{ticker}` and `?ticker=` with `lunc%2e%2e%2f`, `lunc%00`, homoglyph `lunc`, `LUNC`, extra path segments → 400 or ignored; never concatenated into SQL or `fetch(`${base}/${user}`)`. - [ ] Indexer `asset_address` pointing at a **different** CW20 than hub config must not win: display uses **configured** hub wrap, or omit. Symbol `cUSTC` on a gem CW20 must not appear as the cUSTC link. ### Spoof / thin-pool / oracle confusion - [ ] Native gem `symbol=LUNC` or `symbol=cUSTC` cannot become the AddressRow target (contract allowlist only). - [ ] Dust UST1/cLUNC pool cannot become LUNC/USD (LUNC column is oracle, not max-TVL). - [ ] Attacker cannot pass `custc` into CEX `getOraclePrice` from this card. - [ ] vFDUSD/`$1` must not appear as LUNC or cUSTC hub USD. ### Clickjacking / new-window - [ ] Explorer anchors: `rel="noopener noreferrer"` `target="_blank"`. No `window.open` with attacker URL. ### Clipboard abuse - [ ] Copy writes the **allowlisted contract string**, never innerHTML or display symbol. No silent copy of `source_pair` when the control is labeled token. ### Availability / DoS - [ ] Adding LUNC must not add a live `swap_events` aggregate on Protocol GET. - [ ] Extra frontend oracle fetch (if used) shares existing retry:false / interval; no unbounded refetch on invalid ticker. ### LocalTerra / env injection - [ ] `VITE_USTC_C_TOKEN_ADDRESS=' javascript:…'` or non-bech32 → omit explorer. Do not interpolate env into `href` without `getExplorerAddressUrl`. ## Verification criteria Done when: 1. `make verify-issue-<iid>` is green (new script + docs/skill grep as needed). 2. `make verify-issue-556`, `make verify-issue-550`, `make verify-issue-515`, `make verify-issue-541` still green. 3. RTL: cUSTC token link + LUNC column + LUNC token link + source_pair regression + allowlist rejects. 4. If indexer changed: `cargo test` hub/oracle API cases above with Postgres. 5. Manual `/protocol`: four hub cells; cUSTC and LUNC explorer targets are wrap CW20s; UST1/USTR still show **pair** contracts; CEX tabs unchanged. ## Out of scope - Fourth CEX tab, CEX history table for LUNC inside the hub card, or merging the two cards. - Pricing LUNC/cLUNC from DEX reserves. - Changing P522-Q / `volume_usd` / Charts candles / `/ust1`. - Native `uluna` Finder URLs. - Cloning hub AddressRows onto Swap, Trade ticket, or Charts pair chrome (those are #541).
PlasticDigits commented 2026-08-19 01:03:02 +00:00 (Migrated from gitlab.com)

marked as related to #556

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

marked as related to #550

marked as related to #550
PlasticDigits commented 2026-08-19 01:03:04 +00:00 (Migrated from gitlab.com)

marked as related to #515

marked as related to #515
PlasticDigits commented 2026-08-19 01:03:04 +00:00 (Migrated from gitlab.com)

marked as related to #541

marked as related to #541
PlasticDigits commented 2026-08-20 03:03:40 +00:00 (Migrated from gitlab.com)

mentioned in commit a356757ff3

mentioned in commit a356757ff3f23ebb2c220cc04dc398bc37b077cd
PlasticDigits commented 2026-08-20 03:03:43 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1106

mentioned in merge request !1106
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-20 03:52:18 +00:00
PlasticDigits commented 2026-08-20 03:54:29 +00:00 (Migrated from gitlab.com)

Merged via !1106.

Merge with #568/#571 left HubUsdConfig / test AppState missing clunc_address and hub_usd; those initializers were fixed on the branch before merge. Frontend RTL + Playwright protocol-page (5 workers) passed; hub/oracle integration tests passed after the fix.

Remaining manual:

  • /protocol LocalTerra: four hub cells; cUSTC/LUNC explorer targets are wrap CW20s from .env.local; UST1/USTR still show pair contracts; CEX LUNC tab still works
  • Phone 390×844 / tablet 820×1180: AddressRows do not overflow the CEX card
Merged via !1106. Merge with #568/#571 left `HubUsdConfig` / test `AppState` missing `clunc_address` and `hub_usd`; those initializers were fixed on the branch before merge. Frontend RTL + Playwright protocol-page (5 workers) passed; hub/oracle integration tests passed after the fix. Remaining manual: - `/protocol` LocalTerra: four hub cells; cUSTC/LUNC explorer targets are wrap CW20s from `.env.local`; UST1/USTR still show pair contracts; CEX LUNC tab still works - Phone 390×844 / tablet 820×1180: AddressRows do not overflow the CEX card
PlasticDigits commented 2026-08-20 03:54:51 +00:00 (Migrated from gitlab.com)

mentioned in issue #583

mentioned in issue #583
PlasticDigits commented 2026-08-20 03:54:56 +00:00 (Migrated from gitlab.com)

marked as related to #583

marked as related to #583
PlasticDigits commented 2026-08-22 03:10:04 +00:00 (Migrated from gitlab.com)

mentioned in issue #589

mentioned in issue #589
PlasticDigits commented 2026-08-27 01:00:18 +00:00 (Migrated from gitlab.com)

mentioned in issue #682

mentioned in issue #682
PlasticDigits commented 2026-08-27 01:00:28 +00:00 (Migrated from gitlab.com)

mentioned in issue #683

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