feat: /protocol fee tracking — 24h/7d/30d USD, % change, token mix, source (swap/book/place/wrap) #586

Closed
opened 2026-08-21 00:21:00 +00:00 by PlasticDigits · 24 comments
PlasticDigits commented 2026-08-21 00:21:00 +00:00 (Migrated from gitlab.com)

Summary

Add protocol fee tracking to /protocol: trailing 24h / 7d / 30d treasury fees (USD), % change vs the previous equal window, which tokens fees were earned in, and source (AMM swap, book take, limit place, wrap, unwrap). Bundle into one issue. Do not split “USD totals” vs “Δ%” vs “token table” vs “source table” vs “wrap ingest” vs “overview JSON”.

Related: #550 (Protocol USD census + overview rollup), #569 (pool TVL + snapshot Δ%), #576 (trailing-window copy), #577 (window decay), #556 (hub USD), #548 / #522 (P522-Q catalog), #515 (CEX USTC/LUNC; not vFDUSD for DEX conversion), #196 (L7 pool vs book commission), #516 (wrap-mapper split fees), #281 / #333 (/overview 60s cache + O(1) rollup), #489 (short retail copy), #568 (do not rewrite historical USD from live hub).

Current codebase

/protocol is the DEX USD census page after #550 / #556 / #569. Global stats show volume (flow) and pool TVL (stock). There is no protocol-wide treasury fee headline, no fee Δ%, no fee token mix, and no wrap/unwrap fee ingest.

Layer Behavior today
Global stats UI ProtocolGlobalStats.tsx: TVL + 24h/30d liquidity Δ%, Last 24h/7d/30d vol, tokens, new tokens/pairs, active pairs, 24h trades. formatProtocolUsd / formatProtocolPct → em-dash on missing / non-finite. No fee boxes, source table, or token mix.
Page order (P550-1) ProtocolPage.tsx: title → Global stats → DEX hub prices → one CEX oracle card → audit contracts → hooks.
Overview API GET /api/v1/overview (indexer/src/api/overview.rs): 60s whole-response cache. Cache miss reads global_stats_24h (volume windows, census, TVL / Δ%) + cheap COUNT(*) + O(1) hub_prices. No fee fields.
Swap commissions stored swap_events.commission_amount is pool-leg only (Terraport baseline; L7 / #196). Parser parse_swaps does not persist book_commission_amount. Amounts are raw ask-asset integer, not USD.
Book taker fees limit_order_fills.commission_amount is the taker half per fill (bid: token1; ask: token0). Summing fills plus swap commission_amount is the hybrid total if book_commission_amount is not also added.
Limit placement fees On-chain place_limit_order emits maker_fee_amount (docs/limit-orders.md). limit_order_placements stores owner/side/price — not maker_fee_amount. Parser ParsedLimitOrderPlacement has no fee field.
Wrap / unwrap Wrap-mapper fee_wrap_bps / fee_unwrap_bps live in ustr-cmm; DEX UI quotes them (#516). Indexer has no wrap/unwrap event table and does not expose mapper fees (AGENTS_WRAP_MAPPER_SPLIT_FEES.md).
Trader total_fees_paid traders.total_fees_paid is a lifetime mixed-unit counter. position_tracker.rs adds spread_amount + commission_amount (spread is not treasury). Not windowed, not USD, not protocol-earned. Do not headline this.
USD catalog Fee tokens must use the same catalog as volume/TVL: USTC/cUSTC/uusd → USTC oracle; LUNC/cLUNC/uluna → LUNC; UST1/USTR → hub_prices. Never vFDUSD/FDUSD. Never $1 UST1 or 2.5× USTR (X4 / P522-Q / H1–H8). Stamp fee_usd as-of ingest (#568) — do not rewrite history from the live hub.
Rollup refresh volume_aggregator.rs ~5 min + startup. Cache-miss /overview must stay O(1) (P550-5, V5). Do not SUM 30d swap_events / fills / wrap rows on GET.
Trailing windows 24h/7d/30d volume is Utc::now() − N, not calendar buckets (#576). Fee windows must match. Idle window → $0; unpriced + activity → — (same as volume C3 / W4).
Tests / verify ProtocolPage.test.tsx, e2e/protocol-page.spec.ts, indexer/tests/api_overview.rs, make verify-issue-550 / 569 / 576 / 577.
Docs / skills skills/AGENTS_FRONTEND_PROTOCOL_STATS.md (P550, P569), docs/indexer-invariants.md, docs/frontend.md § Protocol, docs/runbooks/overview-global-stats-brin.md, skills/AGENTS_HOOK_COMMISSION.md (L7).

Why this is needed

  1. Volume ≠ fees. 24h vol answers “how much traded.” Operators, CMM, and retail who open /protocol also ask how much the protocol earned, in which tokens, and from which product (swap vs book vs wrap).
  2. Stored commissions are incomplete. Pool-only commission_amount undercounts hybrid takes. Placement maker_fee_amount and wrap-mapper fees never enter Postgres. A naive SUM(commission_amount) would be wrong and mixed-unit.
  3. traders.total_fees_paid is the wrong object. It is trader-paid, lifetime, mixed decimals, and includes spread. Protocol fees are treasury inflows in USD, windowed, sourced.
  4. % change needs a previous window. Unlike TVL (#569 stock snapshots), fees are flow. Compare trailing 24h vs the prior 24h (and 7d/30d vs prior equal windows). Missing prior / then = 0 → JSON null, UI em-dash — never Infinity.
  5. Wrap is a first-class fee source (~2% unwrap all-in is product-visible; #516). Leaving it off Protocol would show “DEX fees” that ignore the wrap stack the dApp already quotes.

Constraints / guardrails

  1. Keep P550 / P569 / H1–H16 / X4 / V5. Cache-miss /overview stays rollup + cheap census + O(1) hub. Do not live-scan swap_events, limit_order_fills, wrap rows, or 60d windows on GET. 60s response cache stays. Fee breakdown JSON must not turn overview into an unbounded payload.
  2. Treasury inflows only. Count amounts sent to pair / wrap-mapper treasury. Do not count: spread_amount, Terra Classic burn tax, gas, hook_fee_amount / AfterSwap hook deductions, LP, book escrow, parked dust.
  3. No double-count (L7). Hybrid swap fee = pool commission_amount + book taker (limit_order_fills.commission_amount or persisted book_commission_amount, not both). Placement maker fee is additional. Wrap/unwrap is additional (different contract).
  4. Humanize then USD. Raw 18-dec vs 6-dec SUM is forbidden. humanize_raw_amount + fits_numeric_38_18 + P522-Q / hub. Overflow / non-positive / unpriced → omit that row from USD (omit ≠ $0 when other priced fees exist; idle window with zero trades → $0).
  5. Same catalog as volume. Never vFDUSD. Never $1 UST1 / 2.5× USTR. Oracle/hub down → that handle unpriced. Identity is contract/denom (A1); spoof natives skipped.
  6. Stamp fee_usd at ingest (or at aggregator from stored ingest USD). Do not rewrite historical fee USD from the live hub snapshot (#568).
  7. Trailing windows, not calendar (#576 / #577). Cutoffs now − 24h / 7d / 30d. Successful refresh zeros a window when events age out. Copy: Last 24h / 7d / 30d — not “today” / midnight reset.
  8. Δ% math (flow). For each window W: pct = (current_W − previous_W) / previous_W × 100 when previous_W > 0. Previous 24h = [now−48h, now−24h); previous 7d = [now−14d, now−7d); previous 30d = [now−60d, now−30d). No events in prior window, then = 0, or overflow → JSON null, UI em-dash. Never Infinity / NaN / "∞%" / fake 0%. Cold start / --fresh / indexer younger than 2×W → null Δ% (copy must not claim on-chain genesis fees).
  9. This is not TVL Δ%. Do not reuse global_liquidity_snapshots or formatProtocolPct semantics that compare stock-to-stock without documenting flow-vs-stock. Reuse the formatter; labels must say the window is trailing fee flow.
  10. Pin wrap (and optional window) contracts. Count wrap/unwrap wasm events only from configured WRAP_MAPPER_ADDRESS (bech32 allowlist). Random contracts emitting action=wrap must not inflate totals. Unknown / empty env → wrap source omitted (not $0 pretending the mapper is idle).
  11. Cap breakdown cardinality. Token and source arrays bounded (e.g. top 8 tokens by USD + other; fixed source enum). Oversized JSON is a DoS/UX bug.
  12. Additive JSON. New overview keys optional until indexer ships. Charts overview strip stays additive-compatible. Do not break existing IndexerOverview volume/TVL fields.
  13. Retail copy (#489). Visible labels ≤ ~5 words. No lecture on Global stats vs Fees. Trailing-window title / aria-label like volume. Fees are reference / indexed, not settlement, not CMM bank balance, not “guaranteed USD.”
  14. Out of scope (this issue). ust1-window mint/redeem fees (different contract; follow-up). Pair-creation uluna (#276). Per-pair fee pages. CSV export. Changing on-chain fee bps. CoinGecko liquidity_in_usd. Headlining unique_traders_24h.
  15. Do not bind-mount indexer/ into root Docker for cargo (workspace rule). Host cargo test / make verify-issue-*.

Relevant files

Path Role
indexer/src/indexer/parser.rs Persist book_commission_amount; parse maker_fee_amount; ingest wrap-mapper events
indexer/src/db/queries/swap_events.rs Store book commission + fee USD
indexer/src/db/queries/limit_order_lifecycle.rs Placement maker fee column
indexer/src/db/queries/limit_order_fills.rs Book taker amounts (dedup vs swap book column)
indexer/src/db/queries/volume.rs refresh_global_stats pattern; do not 24h-only INSERT that zeros new columns
indexer/src/indexer/volume_aggregator.rs ~5 min refresh hook
indexer/src/indexer/pair_price_usd.rs P522-Q catalog, humanize, overflow, A1 spoof
indexer/src/api/overview.rs Additive scalar fee totals + Δ% or keep scalars here and breakdown on a dedicated route
indexer/src/api/mod.rs Route + rate limit (standard governor, not LCD-heavy)
indexer/migrations/ book_commission_amount / fee_usd on swaps; placement fee; wrap_events; rollup columns; token/source rollup tables
indexer/tests/api_overview.rs Overview shape; EXPLAIN no request-path scan
indexer/tests/indexer_overview_global_stats.rs Rollup vs live
indexer/tests/swap_events_hybrid_columns.rs Hybrid attrs
indexer/tests/indexer_volume_window_decay.rs Window decay pattern
indexer/tests/security.rs Injection / caps / sanitized errors
frontend-dapp/src/types/index.ts IndexerOverview Additive scalars
frontend-dapp/src/services/indexer/client.ts Client for overview and/or GET /api/v1/protocol/fees
frontend-dapp/src/pages/ProtocolPage.tsx Mount fee panel; outage banner includes new query
frontend-dapp/src/components/protocol/ProtocolGlobalStats.tsx Do not cram unbounded tables into volume grid
frontend-dapp/src/utils/formatProtocolStats.ts USD + Δ%
frontend-dapp/src/utils/trailingWindowCopy.ts Fee labels + titles
frontend-dapp/src/pages/ProtocolPage.test.tsx RTL
frontend-dapp/e2e/protocol-page.spec.ts Playwright smoke
docs/runbooks/overview-global-stats-brin.md Fee rollup + freshness
docs/indexer-invariants.md New row + DoS list
docs/frontend.md Protocol section
skills/AGENTS_FRONTEND_PROTOCOL_STATS.md New invariants
AGENTS.md Crosslink + make verify-issue-<iid>
scripts/qa/verify-issue-569.sh Pattern for verify-issue-<iid>

Fee event of record (ingest)

Introduce a canonical fee row (table name may vary: protocol_fee_events) or additive columns plus a wrap table, as long as the aggregator can SUM(fee_usd) by source × asset_id × trailing window without double-count.

source (enum) On-chain origin Token Notes
swap_amm Swap wasm commission_amount (pool leg) Ask asset Terraport baseline; still required
book_take book_commission_amount or sum of limit_order_fills.commission_amount for that swap Ask / fill token Pick one representation; test hybrid + pool-only
limit_place maker_fee_amount on place_limit_order Offer / escrow token Persist on limit_order_placements; skip 0
wrap Wrap-mapper wrap fee to treasury Native or CW20 per mapper event Pin WRAP_MAPPER_ADDRESS
unwrap Wrap-mapper unwrap fee to treasury Native or CW20 per mapper event Distinct from burn tax

Persist: block_timestamp, tx_hash, source, asset_id, amount_raw, decimals, fee_usd (nullable), unique (tx_hash, source, ordinal) so replay / reorg does not double-count (ON CONFLICT DO NOTHING).

Parse wrap-mapper wasm from LCD the same way as pair events. Document the exact attrs against current ustr-cmm (post-#9 split fees). Fail closed on missing amount/denom. Do not infer fee as amount × bps from config at query time (bps can change; historical txs used the then-config).

Indexer — USD

At ingest (preferred) or in the aggregator from stored raw+asset: humanize × catalog/hub as of that block’s oracle/hub row when available, else latest-at-ingest snapshot used for volume_usd today. Clamp with the same LEAST / fits_numeric_38_18 pattern as volume USD. Unpriced → fee_usd NULL; still keep the raw token amount for the token table (human units + symbol; USD —).

Indexer — rollup (aggregator ~5 min + startup)

Materialize on global_stats_24h (scalars) and small child tables for breakdowns:

Scalars (overview-safe):

Field Meaning
total_fees_24h_usd SUM priced fees in trailing 24h
total_fees_7d_usd trailing 7d
total_fees_30d_usd trailing 30d
fees_change_24h_pct vs prior 24h; JSON null if no baseline
fees_change_7d_pct vs prior 7d
fees_change_30d_pct vs prior 30d

JSON "0" only when the window has no fee events (idle). Activity + all unpriced → JSON null (UI —), same spirit as total_volume_24h_usd C3.

Breakdown (dedicated GET /api/v1/protocol/fees recommended):

  • by_source[]: { source, amount_usd, share_pct } for the 24h window (also 7d/30d as query window=24h\|7d\|30d allowlisted — unknown → 400).
  • by_token[]: { asset_id, symbol, contract_or_denom, amount_human, amount_usd, share_pct } sorted by USD desc, cap 8 + other.
  • Whole-response cache 60s. limit unused or clamped. No LCD.

Keep overview additive scalars so /protocol can show three fee StatBoxes without a second payload; fetch breakdown only for the fee panel tables. Alternatively one dedicated endpoint for both if overview size is a concern — then Global stats must not block on it (fee panel owns the query). Prefer overview scalars + dedicated breakdown so volume/TVL cache stays stable.

Do not run a 24h-only INSERT that leaves fee columns stale (same class of bug as #550/#569). OVERVIEW_GLOBAL_STATS_LIVE=1 must not live-SUM 60d fees on GET.

Frontend — /protocol

New panel protocol-fee-stats immediately after Global stats and before DEX hub (P550-1 extended: title → global stats → fees → hub → oracle → audit → hooks). Do not merge factory/router into fees. Do not clone onto Swap confirmation.

Headlines (StatBox, trailing copy):

  • Last 24h fees + 24h Δ%
  • Last 7d fees + 7d Δ%
  • Last 30d fees + 30d Δ%

Tables (compact, #489):

  • Source — wrap / unwrap / AMM swap / book take / limit place (hide sources with $0 or show $0 with em-dash USD when that source is unconfigured, e.g. no wrap mapper — pick one and document; unconfigured ≠ idle).
  • Tokens — human amount + USD; unpriced token shows human + USD —.

Outage: include the fee query in detectMarketDataOutage / retry. Missing indexer fields → hide panel or em-dash, not $0 invented.

Testids (suggested): protocol-fee-stats, protocol-stat-fees-24h, protocol-stat-fees-7d, protocol-stat-fees-30d, protocol-stat-fees-24h-chg, protocol-stat-fees-7d-chg, protocol-stat-fees-30d-chg, protocol-fees-by-source, protocol-fees-by-token.

Docs / skills

New invariant block PFee-1–PFee-n in AGENTS_FRONTEND_PROTOCOL_STATS.md (or a short sibling skill linked from AGENTS.md). Indexer-invariants row Protocol fees. Runbook: aggregator SQL, freshness (same 15 min stale log as #577 D6 — still serve rollup). make verify-issue-<iid>.

Acceptance criteria

  1. /protocol shows trailing 24h / 7d / 30d protocol fee USD and Δ% vs the previous equal window.
  2. A source breakdown includes at least: AMM swap, book take, limit place, wrap, unwrap (when mapper is configured).
  3. A token breakdown lists human units + USD for fees earned in those tokens (capped).
  4. Hybrid swap: pool + book taker counted once each; pool-only swap does not invent book fees.
  5. Placement maker fees appear under limit_place, not as swap volume.
  6. Wrap/unwrap fees appear only from the pinned mapper; burn tax is not included.
  7. GET paths are O(1) rollup / cached; EXPLAIN of cache-miss overview (and fees GET) does not scan swap_events / fills / wrap tables.
  8. Idle window → $0; unpriced activity → —; missing prior window → Δ% —; never Infinity.
  9. Windows decay when events age out (#577). --fresh / young indexer → Δ% empty until 2×W fills; copy does not claim chain genesis.
  10. Same USD catalog as volume/TVL; no vFDUSD; no $1 UST1; ingest-time USD not rewritten from live hub.
  11. P550 page order preserved with the new fee panel; hub/oracle/audit unchanged.
  12. Additive JSON; Charts/overview volume/TVL fields unchanged.
  13. Docs, skill invariants, make verify-issue-<iid>, e2e smoke testids.
  14. traders.total_fees_paid is not the Protocol headline.

Test plan (all paths)

Indexer unit / integration (Postgres; make setup-indexer-postgres)

  1. Pool-only swap — commission_amount > 0, no fills → swap_amm only; USD = human(ask)×catalog.
  2. Hybrid swap — pool commission + one+ fills → swap_amm + book_take = pool + fill commissions; not 2× book if both book_commission_amount and fills exist.
  3. Pool-only does not emit book — no book_take row.
  4. Limit place — maker_fee_amount indexed; limit_place USD; zero fee place skipped.
  5. Wrap / unwrap — seeded mapper events; sources split; burn-tax-sized native remainder not added.
  6. Unconfigured mapper — wrap source omitted / not fake idle $0 (per documented choice).
  7. Spoof mapper — wasm action=wrap from a non-allowlisted contract ignored.
  8. Token mix — UST1 + cUSTC fees in one window; two token rows; hub vs USTC oracle; spoof native omitted.
  9. Unpriced gem — raw stored; USD null; headline null if only unpriced; mixed priced+unpriced omits unpriced from USD sum.
  10. Windows — events at now−23h, now−25h, now−8d, now−31d, now−61d; 24h/7d/30d and prior windows match FILTER bounds (mutate block_timestamp, no 24h sleep — V3).
  11. Decay — age last event past 24h; 24h USD → 0; 7d may remain.
  12. Δ% — prior 24h $100, current $150 → +50%; prior 0 → null; equal → 0%.
  13. Replay / unique — insert same tx twice → one fee row.
  14. Overflow / negative — skip; no wrap to Inf.
  15. 18-dec vs 6-dec — 1 USTR (18) vs 1 UST1 (6) not summed as raw integers.
  16. Overview EXPLAIN — cache miss does not mention swap_events / protocol_fee_events / limit_order_fills.
  17. Fees GET window= — allowlist; window=1';drop / javascript: / ../ → 400.
  18. Cap — 20 tokens → 8 + other; other.amount_usd = remainder.
  19. Live flag — OVERVIEW_GLOBAL_STATS_LIVE=1 still must not 60d-SUM fees on GET.
  20. Stale rollup — updated_at > 15 min logs warning, still serves last fee rollup (D6).
  21. Additive overview — existing volume/TVL keys still present (api_overview.rs).

Frontend unit (Vitest)

  1. Fee panel renders totals, Δ%, source table, token table from mock JSON.
  2. null USD / pct → em-dash; "0" → $0 / 0%.
  3. Missing new fields (old indexer) → no crash; panel hidden or em-dash.
  4. Trailing labels ≤ ~5 words; title discloses trailing window (not calendar).
  5. Source labels: wrap / unwrap / swap / book / limit — not wasm action strings.
  6. Outage banner retries fee query.
  7. ?ticker= still allowlisted; fee panel ignores ticker.

E2E smoke (Playwright, 5 workers; no chain required if mocked)

  1. /protocol shows protocol-fee-stats after protocol-global-stats and before protocol-dex-hub-prices.
  2. Testids for 24h/7d/30d + Δ% visible (value $0 or $… or — acceptable on empty LocalTerra).
  3. Phone 390×844: fee panel visible; no overlap with hub card.
  4. Existing protocol e2e (liquidity, oracle tabs, audit) still pass.

Docs / verify script

  1. scripts/qa/verify-issue-<iid>.sh: skill invariants, overview keys, EXPLAIN grep, cargo tests, vitest, optional e2e-smoke.
  2. docs/indexer-invariants.md + docs/frontend.md + AGENTS.md + runbook updated.
  3. Regression: make verify-issue-550 569 576 577 still pass.

Test plan (attack, hack, abuse)

  1. GET-path DoS — cache-miss /overview and /protocol/fees must not SUM 30d/60d event tables. EXPLAIN + integration assert. Burst within 60s served from cache.
  2. Unbounded breakdown — 10k distinct spoof assets in-window still returns ≤ cap+1 rows; response size bounded.
  3. SQL injection — window, ticker leftovers, format, sort on the new route: allowlist only; parameterized SQL (security.rs pattern).
  4. Contract spoof — wrap events from attacker address with mapper-like attrs ignored without WRAP_MAPPER_ADDRESS match (bech32, not substring).
  5. Symbol spoof — native denom=ugem symbol=USTR must not price as USTR (A1).
  6. Double-count hybrid — fuzz/fixture: pool + book_commission_amount + N fills → USD equals pool + book once.
  7. Spread / tax / hook inflation — rows with only spread_amount, InstantWithdraw tax, or hook_fee_amount do not increase protocol fees.
  8. Mixed-unit bomb — 10^38-ish raw 18-dec commission skipped (fits_numeric_38_18); no Inf JSON.
  9. Δ% divide-by-zero / negative prior — prior 0 or negative (corrupt rollup) → null, never Inf XSS into UI.
  10. XSS / HTML in symbol — token symbol <img onerror> / javascript: rendered as text, not HTML.
  11. Open redirect / ticker — fee panel must not parse ?ticker= for API paths; P550-2 unchanged.
  12. CSV formula injection — if any CSV is added (not required): prefix = + - @ (text_csv.rs / #432). Prefer no CSV in v1.
  13. Cache / live mode abuse — OVERVIEW_GLOBAL_STATS_LIVE=1 must not become a 60d fee scan gadget.
  14. Rate limit — new GET on standard governor (not unauthenticated LCD amplify). RATE_LIMIT_RPS still applies.
  15. Error leakage — DB failures → "Internal server error"; no sqlx text (internal_err).
  16. Reorg / replay — unique constraint prevents double treasury credit after poller replay.
  17. Wash / dust — dust swaps do count (same as volume / active pairs). Do not add an undocumented USD floor. Document: fees can be gamed the same way 24h volume can; not a security fix in this issue.
  18. Hub rewrite — changing live UST1 hub must not change already-ingested fee_usd for old txs (#568 analogue).
  19. Unpinned mapper env injection — WRAP_MAPPER_ADDRESS with spaces / terra1 + newline / wrong HRP rejected at ingest; no LIKE % match.
  20. Prototype pollution / JSON — unknown source strings from DB not interpolated into eval; enum map in Rust + frontend allowlist.

Verification criteria

make setup-indexer-postgres   # if indexer/.env missing
make verify-issue-<iid>       # added by implementation
make verify-issue-550
make verify-issue-569
make verify-issue-576
make verify-issue-577

Manual /protocol (LocalTerra or prod indexer): fee panel under Global stats; 24h/7d/30d USD; Δ% em-dash until 2× windows exist; source + token tables; wrap rows only after mapper txs; hybrid swap increases AMM + book, not double book; unwrap does not include burn tax; hub/oracle still follow P550-1.

Done when ACs 1–14 pass, verify script is green, and invariants are in skills + indexer-invariants + frontend.md.

## Summary Add **protocol fee tracking** to `/protocol`: trailing **24h / 7d / 30d** treasury fees (USD), **% change** vs the previous equal window, **which tokens** fees were earned in, and **source** (AMM swap, book take, limit place, wrap, unwrap). Bundle into **one** issue. Do **not** split “USD totals” vs “Δ%” vs “token table” vs “source table” vs “wrap ingest” vs “overview JSON”. Related: [#550](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/550) (Protocol USD census + overview rollup), [#569](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/569) (pool TVL + snapshot Δ%), [#576](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/576) (trailing-window copy), [#577](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/577) (window decay), [#556](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/556) (hub USD), [#548](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/548) / [#522](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/522) (P522-Q catalog), [#515](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/515) (CEX USTC/LUNC; **not** vFDUSD for DEX conversion), [#196](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/196) (L7 pool vs book commission), [#516](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/516) (wrap-mapper split fees), [#281](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/281) / [#333](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/333) (`/overview` 60s cache + O(1) rollup), [#489](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/489) (short retail copy), [#568](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/568) (do not rewrite historical USD from live hub). ## Current codebase `/protocol` is the DEX **USD census** page after #550 / #556 / #569. Global stats show **volume** (flow) and **pool TVL** (stock). There is **no** protocol-wide **treasury fee** headline, **no** fee Δ%, **no** fee token mix, and **no** wrap/unwrap fee ingest. | Layer | Behavior today | |-------|----------------| | **Global stats UI** | [`ProtocolGlobalStats.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/protocol/ProtocolGlobalStats.tsx): TVL + 24h/30d liquidity Δ%, Last 24h/7d/30d **vol**, tokens, new tokens/pairs, active pairs, 24h trades. `formatProtocolUsd` / `formatProtocolPct` → em-dash on missing / non-finite. **No** fee boxes, source table, or token mix. | | **Page order (P550-1)** | [`ProtocolPage.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/pages/ProtocolPage.tsx): title → **Global stats** → **DEX hub prices** → **one** CEX oracle card → audit contracts → hooks. | | **Overview API** | `GET /api/v1/overview` ([`indexer/src/api/overview.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/api/overview.rs)): 60s whole-response cache. Cache miss reads **`global_stats_24h`** (volume windows, census, TVL / Δ%) + cheap `COUNT(*)` + O(1) `hub_prices`. **No** fee fields. | | **Swap commissions stored** | [`swap_events.commission_amount`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/migrations/20260310000001_initial_schema.sql) is **pool-leg only** (Terraport baseline; **L7** / [#196](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/196)). Parser [`parse_swaps`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/indexer/parser.rs) does **not** persist `book_commission_amount`. Amounts are **raw ask-asset integer**, not USD. | | **Book taker fees** | [`limit_order_fills.commission_amount`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/migrations/20260326000001_limit_order_fills.sql) is the **taker** half per fill (bid: token1; ask: token0). Summing fills **plus** swap `commission_amount` is the hybrid total **if** `book_commission_amount` is **not** also added. | | **Limit placement fees** | On-chain `place_limit_order` emits `maker_fee_amount` ([`docs/limit-orders.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/limit-orders.md)). [`limit_order_placements`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/migrations/20260326120000_swap_events_unique_limit_lifecycle.sql) stores owner/side/price — **not** `maker_fee_amount`. Parser `ParsedLimitOrderPlacement` has no fee field. | | **Wrap / unwrap** | Wrap-mapper `fee_wrap_bps` / `fee_unwrap_bps` live in **ustr-cmm**; DEX UI quotes them ([#516](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/516)). Indexer has **no** wrap/unwrap event table and **does not** expose mapper fees ([`AGENTS_WRAP_MAPPER_SPLIT_FEES.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/skills/AGENTS_WRAP_MAPPER_SPLIT_FEES.md)). | | **Trader `total_fees_paid`** | [`traders.total_fees_paid`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/migrations/20260310000003_add_pnl_tracking.sql) is a **lifetime mixed-unit** counter. [`position_tracker.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/indexer/position_tracker.rs) adds **`spread_amount + commission_amount`** (spread is **not** treasury). Not windowed, not USD, not protocol-earned. **Do not** headline this. | | **USD catalog** | Fee tokens must use the same catalog as volume/TVL: USTC/cUSTC/`uusd` → USTC oracle; LUNC/cLUNC/`uluna` → LUNC; UST1/USTR → `hub_prices`. **Never** vFDUSD/FDUSD. **Never** `$1` UST1 or `2.5×` USTR (**X4** / **P522-Q** / **H1–H8**). Stamp `fee_usd` **as-of ingest** (#568) — do not rewrite history from the live hub. | | **Rollup refresh** | [`volume_aggregator.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/indexer/volume_aggregator.rs) ~5 min + startup. Cache-miss `/overview` must stay O(1) (**P550-5**, **V5**). Do **not** `SUM` 30d `swap_events` / fills / wrap rows **on GET**. | | **Trailing windows** | 24h/7d/30d volume is `Utc::now() − N`, not calendar buckets (#576). Fee windows must match. Idle window → `$0`; unpriced + activity → `—` (same as volume **C3** / **W4**). | | **Tests / verify** | [`ProtocolPage.test.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/pages/ProtocolPage.test.tsx), [`e2e/protocol-page.spec.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/e2e/protocol-page.spec.ts), [`indexer/tests/api_overview.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/api_overview.rs), `make verify-issue-550` / `569` / `576` / `577`. | | **Docs / skills** | [`skills/AGENTS_FRONTEND_PROTOCOL_STATS.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/skills/AGENTS_FRONTEND_PROTOCOL_STATS.md) (**P550**, **P569**), [`docs/indexer-invariants.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/indexer-invariants.md), [`docs/frontend.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/frontend.md) § Protocol, [`docs/runbooks/overview-global-stats-brin.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/runbooks/overview-global-stats-brin.md), [`skills/AGENTS_HOOK_COMMISSION.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/skills/AGENTS_HOOK_COMMISSION.md) (**L7**). | ## Why this is needed 1. **Volume ≠ fees.** 24h vol answers “how much traded.” Operators, CMM, and retail who open `/protocol` also ask **how much the protocol earned**, in which tokens, and from which product (swap vs book vs wrap). 2. **Stored commissions are incomplete.** Pool-only `commission_amount` undercounts hybrid takes. Placement `maker_fee_amount` and wrap-mapper fees never enter Postgres. A naive `SUM(commission_amount)` would be wrong and mixed-unit. 3. **`traders.total_fees_paid` is the wrong object.** It is trader-paid, lifetime, mixed decimals, and includes **spread**. Protocol fees are **treasury inflows** in USD, windowed, sourced. 4. **% change needs a previous window.** Unlike TVL (#569 stock snapshots), fees are **flow**. Compare trailing 24h vs the **prior** 24h (and 7d/30d vs prior equal windows). Missing prior / `then = 0` → JSON `null`, UI em-dash — never `Infinity`. 5. **Wrap is a first-class fee source** (~2% unwrap all-in is product-visible; #516). Leaving it off Protocol would show “DEX fees” that ignore the wrap stack the dApp already quotes. ## Constraints / guardrails 1. **Keep P550 / P569 / H1–H16 / X4 / V5.** Cache-miss `/overview` stays rollup + cheap census + O(1) hub. Do **not** live-scan `swap_events`, `limit_order_fills`, wrap rows, or 60d windows **on GET**. 60s response cache stays. Fee **breakdown** JSON must not turn overview into an unbounded payload. 2. **Treasury inflows only.** Count amounts sent to pair / wrap-mapper **treasury**. Do **not** count: `spread_amount`, Terra Classic **burn tax**, gas, `hook_fee_amount` / AfterSwap hook deductions, LP, book escrow, parked dust. 3. **No double-count (L7).** Hybrid swap fee = pool `commission_amount` **+** book taker (`limit_order_fills.commission_amount` **or** persisted `book_commission_amount`, **not both**). Placement maker fee is **additional**. Wrap/unwrap is **additional** (different contract). 4. **Humanize then USD.** Raw 18-dec vs 6-dec `SUM` is forbidden. `humanize_raw_amount` + `fits_numeric_38_18` + P522-Q / hub. Overflow / non-positive / unpriced → omit that row from USD (omit ≠ `$0` when other priced fees exist; idle window with zero trades → `$0`). 5. **Same catalog as volume.** Never vFDUSD. Never `$1` UST1 / `2.5×` USTR. Oracle/hub down → that handle unpriced. Identity is **contract/denom** (A1); spoof natives skipped. 6. **Stamp `fee_usd` at ingest** (or at aggregator from **stored** ingest USD). Do **not** rewrite historical fee USD from the live hub snapshot (#568). 7. **Trailing windows, not calendar** (#576 / #577). Cutoffs `now − 24h / 7d / 30d`. Successful refresh **zeros** a window when events age out. Copy: Last 24h / 7d / 30d — not “today” / midnight reset. 8. **Δ% math (flow).** For each window W: `pct = (current_W − previous_W) / previous_W × 100` when `previous_W > 0`. Previous 24h = `[now−48h, now−24h)`; previous 7d = `[now−14d, now−7d)`; previous 30d = `[now−60d, now−30d)`. No events in prior window, `then = 0`, or overflow → JSON **`null`**, UI em-dash. **Never** `Infinity` / `NaN` / `"∞%"` / fake `0%`. Cold start / `--fresh` / indexer younger than 2×W → null Δ% (copy must not claim on-chain genesis fees). 9. **This is not TVL Δ%.** Do not reuse `global_liquidity_snapshots` or `formatProtocolPct` semantics that compare stock-to-stock without documenting flow-vs-stock. Reuse the formatter; labels must say the window is trailing fee flow. 10. **Pin wrap (and optional window) contracts.** Count wrap/unwrap wasm events **only** from configured `WRAP_MAPPER_ADDRESS` (bech32 allowlist). Random contracts emitting `action=wrap` must not inflate totals. Unknown / empty env → wrap source **omitted** (not `$0` pretending the mapper is idle). 11. **Cap breakdown cardinality.** Token and source arrays bounded (e.g. top **8** tokens by USD + `other`; fixed source enum). Oversized JSON is a DoS/UX bug. 12. **Additive JSON.** New overview keys optional until indexer ships. Charts overview strip stays additive-compatible. Do not break existing `IndexerOverview` volume/TVL fields. 13. **Retail copy (#489).** Visible labels ≤ ~5 words. No lecture on Global stats vs Fees. Trailing-window `title` / `aria-label` like volume. Fees are **reference / indexed**, not settlement, not CMM bank balance, not “guaranteed USD.” 14. **Out of scope (this issue).** ust1-window mint/redeem fees (different contract; follow-up). Pair-creation `uluna` (#276). Per-pair fee pages. CSV export. Changing on-chain fee bps. CoinGecko `liquidity_in_usd`. Headlining `unique_traders_24h`. 15. **Do not bind-mount `indexer/` into root Docker for cargo** (workspace rule). Host `cargo test` / `make verify-issue-*`. ## Relevant files | Path | Role | |------|------| | [`indexer/src/indexer/parser.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/indexer/parser.rs) | Persist `book_commission_amount`; parse `maker_fee_amount`; ingest wrap-mapper events | | [`indexer/src/db/queries/swap_events.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/db/queries/swap_events.rs) | Store book commission + fee USD | | [`indexer/src/db/queries/limit_order_lifecycle.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/db/queries/limit_order_lifecycle.rs) | Placement maker fee column | | [`indexer/src/db/queries/limit_order_fills.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/db/queries/limit_order_fills.rs) | Book taker amounts (dedup vs swap book column) | | [`indexer/src/db/queries/volume.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/db/queries/volume.rs) | `refresh_global_stats` pattern; do not 24h-only INSERT that zeros new columns | | [`indexer/src/indexer/volume_aggregator.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/indexer/volume_aggregator.rs) | ~5 min refresh hook | | [`indexer/src/indexer/pair_price_usd.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/indexer/pair_price_usd.rs) | P522-Q catalog, humanize, overflow, A1 spoof | | [`indexer/src/api/overview.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/api/overview.rs) | Additive scalar fee totals + Δ% **or** keep scalars here and breakdown on a dedicated route | | [`indexer/src/api/mod.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/api/mod.rs) | Route + rate limit (standard governor, not LCD-heavy) | | [`indexer/migrations/`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/tree/main/indexer/migrations) | `book_commission_amount` / `fee_usd` on swaps; placement fee; wrap_events; rollup columns; token/source rollup tables | | [`indexer/tests/api_overview.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/api_overview.rs) | Overview shape; EXPLAIN no request-path scan | | [`indexer/tests/indexer_overview_global_stats.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/indexer_overview_global_stats.rs) | Rollup vs live | | [`indexer/tests/swap_events_hybrid_columns.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/swap_events_hybrid_columns.rs) | Hybrid attrs | | [`indexer/tests/indexer_volume_window_decay.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/indexer_volume_window_decay.rs) | Window decay pattern | | [`indexer/tests/security.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/security.rs) | Injection / caps / sanitized errors | | [`frontend-dapp/src/types/index.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/types/index.ts) `IndexerOverview` | Additive scalars | | [`frontend-dapp/src/services/indexer/client.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/indexer/client.ts) | Client for overview and/or `GET /api/v1/protocol/fees` | | [`frontend-dapp/src/pages/ProtocolPage.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/pages/ProtocolPage.tsx) | Mount fee panel; outage banner includes new query | | [`frontend-dapp/src/components/protocol/ProtocolGlobalStats.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/protocol/ProtocolGlobalStats.tsx) | Do not cram unbounded tables into volume grid | | [`frontend-dapp/src/utils/formatProtocolStats.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/formatProtocolStats.ts) | USD + Δ% | | [`frontend-dapp/src/utils/trailingWindowCopy.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/trailingWindowCopy.ts) | Fee labels + titles | | [`frontend-dapp/src/pages/ProtocolPage.test.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/pages/ProtocolPage.test.tsx) | RTL | | [`frontend-dapp/e2e/protocol-page.spec.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/e2e/protocol-page.spec.ts) | Playwright smoke | | [`docs/runbooks/overview-global-stats-brin.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/runbooks/overview-global-stats-brin.md) | Fee rollup + freshness | | [`docs/indexer-invariants.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/indexer-invariants.md) | New row + DoS list | | [`docs/frontend.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/frontend.md) | Protocol section | | [`skills/AGENTS_FRONTEND_PROTOCOL_STATS.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/skills/AGENTS_FRONTEND_PROTOCOL_STATS.md) | New invariants | | [`AGENTS.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/AGENTS.md) | Crosslink + `make verify-issue-<iid>` | | [`scripts/qa/verify-issue-569.sh`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/scripts/qa/verify-issue-569.sh) | Pattern for `verify-issue-<iid>` | ## Recommended direction ### Fee event of record (ingest) Introduce a **canonical fee row** (table name may vary: `protocol_fee_events`) **or** additive columns plus a wrap table, as long as the aggregator can `SUM(fee_usd)` by `source` × `asset_id` × trailing window without double-count. | `source` (enum) | On-chain origin | Token | Notes | |-----------------|-----------------|-------|--------| | `swap_amm` | Swap wasm `commission_amount` (pool leg) | Ask asset | Terraport baseline; still required | | `book_take` | `book_commission_amount` **or** sum of `limit_order_fills.commission_amount` for that swap | Ask / fill token | Pick **one** representation; test hybrid + pool-only | | `limit_place` | `maker_fee_amount` on `place_limit_order` | Offer / escrow token | Persist on `limit_order_placements`; skip `0` | | `wrap` | Wrap-mapper wrap fee to treasury | Native or CW20 per mapper event | Pin `WRAP_MAPPER_ADDRESS` | | `unwrap` | Wrap-mapper unwrap fee to treasury | Native or CW20 per mapper event | Distinct from burn tax | Persist: `block_timestamp`, `tx_hash`, `source`, `asset_id`, `amount_raw`, `decimals`, `fee_usd` (nullable), unique `(tx_hash, source, ordinal)` so replay / reorg does not double-count (`ON CONFLICT DO NOTHING`). Parse wrap-mapper wasm from LCD the same way as pair events. Document the exact attrs against current ustr-cmm (post-#9 split fees). Fail closed on missing amount/denom. Do not infer fee as `amount × bps` from config at query time (bps can change; historical txs used the then-config). ### Indexer — USD At ingest (preferred) or in the aggregator from stored raw+asset: humanize × catalog/hub **as of that block’s oracle/hub row** when available, else latest-at-ingest snapshot used for `volume_usd` today. Clamp with the same `LEAST` / `fits_numeric_38_18` pattern as volume USD. Unpriced → `fee_usd` NULL; still keep the raw token amount for the token table (human units + symbol; USD `—`). ### Indexer — rollup (aggregator ~5 min + startup) Materialize on `global_stats_24h` (scalars) **and** small child tables for breakdowns: **Scalars (overview-safe):** | Field | Meaning | |-------|---------| | `total_fees_24h_usd` | SUM priced fees in trailing 24h | | `total_fees_7d_usd` | trailing 7d | | `total_fees_30d_usd` | trailing 30d | | `fees_change_24h_pct` | vs prior 24h; JSON `null` if no baseline | | `fees_change_7d_pct` | vs prior 7d | | `fees_change_30d_pct` | vs prior 30d | JSON `"0"` only when the window has **no fee events** (idle). Activity + all unpriced → JSON `null` (UI `—`), same spirit as `total_volume_24h_usd` **C3**. **Breakdown (dedicated `GET /api/v1/protocol/fees` recommended):** - `by_source[]`: `{ source, amount_usd, share_pct }` for the **24h** window (also 7d/30d as query `window=24h\|7d\|30d` allowlisted — unknown → **400**). - `by_token[]`: `{ asset_id, symbol, contract_or_denom, amount_human, amount_usd, share_pct }` sorted by USD desc, cap 8 + `other`. - Whole-response cache **60s**. `limit` unused or clamped. No LCD. Keep overview additive scalars so `/protocol` can show three fee StatBoxes without a second payload; fetch breakdown only for the fee panel tables. Alternatively one dedicated endpoint for both if overview size is a concern — then Global stats must not block on it (fee panel owns the query). Prefer **overview scalars + dedicated breakdown** so volume/TVL cache stays stable. Do **not** run a 24h-only `INSERT` that leaves fee columns stale (same class of bug as #550/#569). `OVERVIEW_GLOBAL_STATS_LIVE=1` must **not** live-SUM 60d fees on GET. ### Frontend — `/protocol` New panel **`protocol-fee-stats`** immediately **after** Global stats and **before** DEX hub (P550-1 extended: title → global stats → **fees** → hub → oracle → audit → hooks). Do not merge factory/router into fees. Do not clone onto Swap confirmation. Headlines (StatBox, trailing copy): - Last 24h fees + 24h Δ% - Last 7d fees + 7d Δ% - Last 30d fees + 30d Δ% Tables (compact, `#489`): - **Source** — wrap / unwrap / AMM swap / book take / limit place (hide sources with `$0` **or** show `$0` with em-dash USD when that source is unconfigured, e.g. no wrap mapper — pick one and document; unconfigured ≠ idle). - **Tokens** — human amount + USD; unpriced token shows human + USD `—`. Outage: include the fee query in `detectMarketDataOutage` / retry. Missing indexer fields → hide panel or em-dash, not `$0` invented. Testids (suggested): `protocol-fee-stats`, `protocol-stat-fees-24h`, `protocol-stat-fees-7d`, `protocol-stat-fees-30d`, `protocol-stat-fees-24h-chg`, `protocol-stat-fees-7d-chg`, `protocol-stat-fees-30d-chg`, `protocol-fees-by-source`, `protocol-fees-by-token`. ### Docs / skills New invariant block **PFee-1–PFee-n** in `AGENTS_FRONTEND_PROTOCOL_STATS.md` (or a short sibling skill linked from AGENTS.md). Indexer-invariants row **Protocol fees**. Runbook: aggregator SQL, freshness (same 15 min stale log as #577 **D6** — still serve rollup). `make verify-issue-<iid>`. ## Acceptance criteria 1. `/protocol` shows trailing **24h / 7d / 30d** protocol fee USD and **Δ%** vs the previous equal window. 2. A **source** breakdown includes at least: AMM swap, book take, limit place, wrap, unwrap (when mapper is configured). 3. A **token** breakdown lists human units + USD for fees earned in those tokens (capped). 4. Hybrid swap: pool + book taker counted **once** each; pool-only swap does not invent book fees. 5. Placement maker fees appear under `limit_place`, not as swap volume. 6. Wrap/unwrap fees appear only from the pinned mapper; burn tax is **not** included. 7. GET paths are O(1) rollup / cached; EXPLAIN of cache-miss overview (and fees GET) does **not** scan `swap_events` / fills / wrap tables. 8. Idle window → `$0`; unpriced activity → `—`; missing prior window → Δ% `—`; never `Infinity`. 9. Windows **decay** when events age out (#577). `--fresh` / young indexer → Δ% empty until 2×W fills; copy does not claim chain genesis. 10. Same USD catalog as volume/TVL; no vFDUSD; no `$1` UST1; ingest-time USD not rewritten from live hub. 11. P550 page order preserved with the new fee panel; hub/oracle/audit unchanged. 12. Additive JSON; Charts/overview volume/TVL fields unchanged. 13. Docs, skill invariants, `make verify-issue-<iid>`, e2e smoke testids. 14. `traders.total_fees_paid` is **not** the Protocol headline. ## Test plan (all paths) ### Indexer unit / integration (Postgres; `make setup-indexer-postgres`) 1. **Pool-only swap** — `commission_amount` > 0, no fills → `swap_amm` only; USD = human(ask)×catalog. 2. **Hybrid swap** — pool commission + one+ fills → `swap_amm` + `book_take` = pool + fill commissions; **not** 2× book if both `book_commission_amount` and fills exist. 3. **Pool-only does not emit book** — no `book_take` row. 4. **Limit place** — `maker_fee_amount` indexed; `limit_place` USD; zero fee place skipped. 5. **Wrap / unwrap** — seeded mapper events; sources split; burn-tax-sized native remainder **not** added. 6. **Unconfigured mapper** — wrap source omitted / not fake idle `$0` (per documented choice). 7. **Spoof mapper** — wasm `action=wrap` from a non-allowlisted contract ignored. 8. **Token mix** — UST1 + cUSTC fees in one window; two token rows; hub vs USTC oracle; spoof native omitted. 9. **Unpriced gem** — raw stored; USD null; headline null if **only** unpriced; mixed priced+unpriced omits unpriced from USD sum. 10. **Windows** — events at now−23h, now−25h, now−8d, now−31d, now−61d; 24h/7d/30d and prior windows match FILTER bounds (mutate `block_timestamp`, no 24h sleep — **V3**). 11. **Decay** — age last event past 24h; 24h USD → 0; 7d may remain. 12. **Δ%** — prior 24h $100, current $150 → `+50%`; prior 0 → null; equal → `0%`. 13. **Replay / unique** — insert same tx twice → one fee row. 14. **Overflow / negative** — skip; no wrap to Inf. 15. **18-dec vs 6-dec** — 1 USTR (18) vs 1 UST1 (6) not summed as raw integers. 16. **Overview EXPLAIN** — cache miss does not mention `swap_events` / `protocol_fee_events` / `limit_order_fills`. 17. **Fees GET `window=`** — allowlist; `window=1';drop` / `javascript:` / `../` → **400**. 18. **Cap** — 20 tokens → 8 + `other`; `other.amount_usd` = remainder. 19. **Live flag** — `OVERVIEW_GLOBAL_STATS_LIVE=1` still must not 60d-SUM fees on GET. 20. **Stale rollup** — `updated_at` > 15 min logs warning, still serves last fee rollup (**D6**). 21. **Additive overview** — existing volume/TVL keys still present (`api_overview.rs`). ### Frontend unit (Vitest) 22. Fee panel renders totals, Δ%, source table, token table from mock JSON. 23. `null` USD / pct → em-dash; `"0"` → `$0` / `0%`. 24. Missing new fields (old indexer) → no crash; panel hidden or em-dash. 25. Trailing labels ≤ ~5 words; `title` discloses trailing window (not calendar). 26. Source labels: wrap / unwrap / swap / book / limit — not wasm action strings. 27. Outage banner retries fee query. 28. `?ticker=` still allowlisted; fee panel ignores ticker. ### E2E smoke (Playwright, 5 workers; no chain required if mocked) 29. `/protocol` shows `protocol-fee-stats` after `protocol-global-stats` and before `protocol-dex-hub-prices`. 30. Testids for 24h/7d/30d + Δ% visible (value `$0` or `$…` or `—` acceptable on empty LocalTerra). 31. Phone 390×844: fee panel visible; no overlap with hub card. 32. Existing protocol e2e (liquidity, oracle tabs, audit) still pass. ### Docs / verify script 33. `scripts/qa/verify-issue-<iid>.sh`: skill invariants, overview keys, EXPLAIN grep, cargo tests, vitest, optional e2e-smoke. 34. `docs/indexer-invariants.md` + `docs/frontend.md` + `AGENTS.md` + runbook updated. 35. Regression: `make verify-issue-550` `569` `576` `577` still pass. ## Test plan (attack, hack, abuse) 1. **GET-path DoS** — cache-miss `/overview` and `/protocol/fees` must not `SUM` 30d/60d event tables. EXPLAIN + integration assert. Burst within 60s served from cache. 2. **Unbounded breakdown** — 10k distinct spoof assets in-window still returns ≤ cap+1 rows; response size bounded. 3. **SQL injection** — `window`, `ticker` leftovers, `format`, `sort` on the new route: allowlist only; parameterized SQL (`security.rs` pattern). 4. **Contract spoof** — wrap events from attacker address with mapper-like attrs ignored without `WRAP_MAPPER_ADDRESS` match (bech32, not substring). 5. **Symbol spoof** — native `denom=ugem` `symbol=USTR` must not price as USTR (A1). 6. **Double-count hybrid** — fuzz/fixture: pool + `book_commission_amount` + N fills → USD equals pool + book once. 7. **Spread / tax / hook inflation** — rows with only `spread_amount`, InstantWithdraw tax, or `hook_fee_amount` do not increase protocol fees. 8. **Mixed-unit bomb** — 10^38-ish raw 18-dec commission skipped (`fits_numeric_38_18`); no Inf JSON. 9. **Δ% divide-by-zero / negative prior** — prior 0 or negative (corrupt rollup) → null, never Inf XSS into UI. 10. **XSS / HTML in symbol** — token `symbol` `<img onerror>` / `javascript:` rendered as text, not HTML. 11. **Open redirect / ticker** — fee panel must not parse `?ticker=` for API paths; P550-2 unchanged. 12. **CSV formula injection** — if any CSV is added (not required): prefix `= + - @` (`text_csv.rs` / #432). Prefer no CSV in v1. 13. **Cache / live mode abuse** — `OVERVIEW_GLOBAL_STATS_LIVE=1` must not become a 60d fee scan gadget. 14. **Rate limit** — new GET on standard governor (not unauthenticated LCD amplify). `RATE_LIMIT_RPS` still applies. 15. **Error leakage** — DB failures → `"Internal server error"`; no sqlx text (`internal_err`). 16. **Reorg / replay** — unique constraint prevents double treasury credit after poller replay. 17. **Wash / dust** — dust swaps **do** count (same as volume / active pairs). Do **not** add an undocumented USD floor. Document: fees can be gamed the same way 24h volume can; not a security fix in this issue. 18. **Hub rewrite** — changing live UST1 hub must **not** change already-ingested `fee_usd` for old txs (#568 analogue). 19. **Unpinned mapper env injection** — `WRAP_MAPPER_ADDRESS` with spaces / `terra1` + newline / wrong HRP rejected at ingest; no `LIKE %` match. 20. **Prototype pollution / JSON** — unknown `source` strings from DB not interpolated into `eval`; enum map in Rust + frontend allowlist. ## Verification criteria ```bash make setup-indexer-postgres # if indexer/.env missing make verify-issue-<iid> # added by implementation make verify-issue-550 make verify-issue-569 make verify-issue-576 make verify-issue-577 ``` Manual `/protocol` (LocalTerra or prod indexer): fee panel under Global stats; 24h/7d/30d USD; Δ% em-dash until 2× windows exist; source + token tables; wrap rows only after mapper txs; hybrid swap increases AMM + book, not double book; unwrap does not include burn tax; hub/oracle still follow P550-1. Done when ACs 1–14 pass, verify script is green, and invariants are in skills + indexer-invariants + frontend.md.
PlasticDigits commented 2026-08-21 11:46:37 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1112

mentioned in merge request !1112
PlasticDigits commented 2026-08-22 06:03:59 +00:00 (Migrated from gitlab.com)

mentioned in commit d1d2222ccc

mentioned in commit d1d2222ccca36a83c119d130246c6d7a669cacbb
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-22 06:04:01 +00:00
PlasticDigits commented 2026-08-22 06:07:59 +00:00 (Migrated from gitlab.com)

mentioned in commit 833687d003

mentioned in commit 833687d00392c6534af605f2aac6f33be0e7a5f6
PlasticDigits commented 2026-08-22 06:28:40 +00:00 (Migrated from gitlab.com)

Post-merge note (!1112 on main)

Merged: https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/merge_requests/394

Sanity on main (make verify-issue-586): docs, indexer lib (protocol_fees 10/10), Protocol RTL, Playwright e2e-smoke (5 workers) passed. Indexer integration (indexer_protocol_fees / api_overview) and related 550/569 integration failed here with pool timed out to 127.0.0.1:5432 — compose postgres was not running on this host. Not treated as a code defect in !1112; re-run after make setup-indexer-postgres.

Still open vs AC / test plan (not blockers for the merge):

  1. Ops: apply indexer/migrations/20260821120000_protocol_fees.sql on QA/prod; set indexer WRAP_MAPPER_ADDRESS or wrap/unwrap sources stay omitted.
  2. LocalTerra: hybrid swap (L7: AMM + book once), limit place → limit_place, wrap/unwrap from pinned mapper only; unwrap must not count InstantWithdraw burn tax.
  3. Parser round-trip: hybrid L7 is covered by seeded protocol_fee_events, not LCD wasm attrs through parser.rs.
  4. UI: breakdown query is fixed window=24h (useProtocolFeesQuery.ts); 7d/30d breakdown is API-only.

Do not treat /protocol fee USD as CMM bank balance. Windows are trailing flow (#576).

## Post-merge note (!1112 on `main`) Merged: https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/merge_requests/394 **Sanity on `main` (`make verify-issue-586`):** docs, indexer lib (`protocol_fees` 10/10), Protocol RTL, Playwright e2e-smoke (5 workers) **passed**. Indexer integration (`indexer_protocol_fees` / `api_overview`) and related 550/569 integration **failed** here with `pool timed out` to `127.0.0.1:5432` — compose `postgres` was **not running** on this host. Not treated as a code defect in !1112; re-run after `make setup-indexer-postgres`. **Still open vs AC / test plan (not blockers for the merge):** 1. **Ops:** apply `indexer/migrations/20260821120000_protocol_fees.sql` on QA/prod; set indexer `WRAP_MAPPER_ADDRESS` or wrap/unwrap sources stay omitted. 2. **LocalTerra:** hybrid swap (L7: AMM + book once), limit place → `limit_place`, wrap/unwrap from pinned mapper only; unwrap must not count InstantWithdraw burn tax. 3. **Parser round-trip:** hybrid L7 is covered by seeded `protocol_fee_events`, not LCD wasm attrs through `parser.rs`. 4. **UI:** breakdown query is fixed `window=24h` (`useProtocolFeesQuery.ts`); 7d/30d breakdown is API-only. Do **not** treat `/protocol` fee USD as CMM bank balance. Windows are trailing flow (#576).
PlasticDigits commented 2026-08-22 06:29:12 +00:00 (Migrated from gitlab.com)

mentioned in issue #590

mentioned in issue #590
PlasticDigits commented 2026-08-22 06:29:12 +00:00 (Migrated from gitlab.com)

marked as related to #590

marked as related to #590
PlasticDigits commented 2026-08-22 06:29:28 +00:00 (Migrated from gitlab.com)

Follow-up tracker: #590 (indexer migrate + LocalTerra fee smoke).

Follow-up tracker: #590 (indexer migrate + LocalTerra fee smoke).
PlasticDigits commented 2026-08-22 06:29:42 +00:00 (Migrated from gitlab.com)

Update: after make setup-indexer-postgres, cargo test --test indexer_protocol_fees --test api_overview -- --test-threads=1 is green (11 + 1). The earlier verify-586 integration FAIL was Postgres down, not !1112.

**Update:** after `make setup-indexer-postgres`, `cargo test --test indexer_protocol_fees --test api_overview -- --test-threads=1` is green (11 + 1). The earlier verify-586 integration FAIL was Postgres down, not !1112.
PlasticDigits commented 2026-08-24 00:30:13 +00:00 (Migrated from gitlab.com)

mentioned in issue #613

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

marked as related to #613

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

mentioned in issue #614

mentioned in issue #614
PlasticDigits commented 2026-08-24 00:30:20 +00:00 (Migrated from gitlab.com)

marked as related to #614

marked as related to #614
PlasticDigits commented 2026-08-24 00:43:14 +00:00 (Migrated from gitlab.com)

mentioned in commit 4de6d8395c

mentioned in commit 4de6d8395cb35656d05ebeed99054cac6d6fc32a
PlasticDigits commented 2026-08-25 01:55:34 +00:00 (Migrated from gitlab.com)

mentioned in issue #631

mentioned in issue #631
PlasticDigits commented 2026-08-25 01:55:37 +00:00 (Migrated from gitlab.com)

marked as related to #631

marked as related to #631
PlasticDigits commented 2026-08-26 01:10:54 +00:00 (Migrated from gitlab.com)

mentioned in issue #652

mentioned in issue #652
PlasticDigits commented 2026-08-26 01:11:06 +00:00 (Migrated from gitlab.com)

mentioned in issue #653

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

mentioned in issue #667

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

marked as related to #667

marked as related to #667
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
PlasticDigits commented 2026-08-27 06:03:02 +00:00 (Migrated from gitlab.com)

mentioned in issue #687

mentioned in issue #687
PlasticDigits commented 2026-08-27 09:00:10 +00:00 (Migrated from gitlab.com)

mentioned in issue #689

mentioned in issue #689
PlasticDigits commented 2026-08-27 09:00:12 +00:00 (Migrated from gitlab.com)

marked as related to #689

marked as related to #689
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#586
No description provided.