feat: /protocol UTC chart — Volume / Liquidity / Fees metric toggle #689

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

Summary

/protocol already has a UTC Hourly / Daily / Monthly bar chart, but it is volume-only. Operators cannot inspect liquidity (stock) or treasury fees (flow) on the same grain. Bundle into one issue: metric buttons Volume / Liquidity / Fees on the existing census chart, with indexer UTC-grain rollups that match P668 GET/DoS rules.

Do not split liquidity vs fees vs UI toggle into separate issues. Do not overlay three series by default. Do not reuse DeFiLlama daily volume/fees.

Related: #668 (USD axis + grain chart), #652 (UTC-day volume series), #569 (pool TVL stock + 24h Δ%), #677 (liquidity 24h-only chip + dense x-axis), #586 (trailing treasury fees), #613 / #614 / #683 (wrap / UST1 window / economic fee USD), #576 (trailing tiles ≠ calendar bars), #489 (short copy), #653 (one chrome layer), #631 (Llama daily — wrong methodology).

Current codebase

The chart exists and is readable as a volume time series. Liquidity and fees exist only as trailing tiles (and a fee breakdown table), not as UTC-grain bars.

Layer Behavior today
Chart UI ProtocolVolumeDailyChart.tsx inside ProtocolGlobalStats.tsx. Custom SVG (protocol-volume-daily-chart): USD Y-axis, pointer/keyboard tooltip, Hourly / Daily / Monthly tabs (protocol-volume-grain-*), default Daily. Title copy is UTC volume. No metric toggle.
Grain / width protocolVolumeGrain.ts: ResizeObserver → limit clamped hourly 12–168, daily 7–90, monthly 6–24. Query key (grain, limit). X-axis density P668-9.
Client fetch getProtocolVolumeSeries → GET /api/v1/protocol/volume/daily?grain=&limit=. useProtocolVolumeSeriesQuery keys ['indexer-protocol-volume-series', grain, limit]. Hide on 404/501. No liquidity / fees series helpers.
Volume indexer GET /api/v1/protocol/volume/daily: grain=hourly|daily|monthly + capped limit else 400. from/to → 400. days=7|30 without grain stays the #652 alias. 60s cache keyed by allowlisted (grain, limit) or days. Reads protocol_hourly_volume / protocol_daily_volume / protocol_monthly_volume only — never swap_events / Llama. Idle "0"; activity + unpriced null. Newest-last.
Volume refresh volume_aggregator.rs (~5 min) calls refresh_protocol_{hourly,daily,monthly}. Hourly prune ~10d; daily ≥ 95d; monthly ≥ 24 months. Methodology = Protocol catalog (gems / wrap / window included).
Liquidity tile Total liquidity in Global stats (protocol-stat-liquidity) is stock TVL from GET /overview (total_liquidity_usd + one 24h snapshot Δ%). P569 / P677: no 30d chip on the tile. Not a time series.
Liquidity history global_liquidity_snapshots: ~5 min samples, retain ≥ 35 days, SNAPSHOT_MIN_INTERVAL 4 min. Used only to compute trailing 24h/30d Δ% on the aggregator. GET /overview must not walk this table. There is no hourly/daily/monthly liquidity rollup and no series GET.
Fees tile + table protocol-fee-stats sits after Global stats. Trailing 24h / 7d / 30d USD + source/token tables via GET /api/v1/protocol/fees?window= (24h | 7d | 30d else 400). O(1) child rollups. Not UTC calendar buckets.
Fee events protocol_fee_events ingested with stamped fee_usd (PFee / I613 / I614 / EFee). Refresh SUMs into trailing windows off the GET path. GET must not SUM protocol_fee_events. There is no UTC-hour/day/month fee series table for Protocol catalog (Llama defillama_daily_fees is gem-excluded and must not feed /protocol).
Llama GET /api/v1/defillama/daily is a different product: UTC-day, gem-exclude, wrap/window out of volume. Playbook forbids calling it from /protocol.
Copy trailingWindowCopy.ts: PROTOCOL_VOLUME_DAILY_LABEL = UTC volume; grain labels Hourly/Daily/Monthly; titles stress calendar ≠ trailing tiles. No liquidity/fees chart strings.
Tests Vitest ProtocolPage.test.tsx; Playwright e2e/protocol-page.spec.ts (5 workers); indexer indexer_protocol_volume.rs, indexer_protocol_liquidity.rs, indexer_protocol_fees.rs. Gates: make verify-issue-668 / 652 / 569 / 677 / 586 / 576.

Why this is needed

  1. Volume answers “how much traded.” Operators also need how much is locked and how much the protocol earned on the same UTC grain, without leaving /protocol or mentally aligning trailing 24h/7d/30d tiles with calendar bars.
  2. Trailing tiles are the wrong grain for a history chart. Last 24h/7d/30d liquidity Δ% and fee totals are now − N windows (#576). The chart is already a UTC calendar series (#668). Adding liquidity/fees must stay calendar buckets — not a second 7d/30d toggle and not a 30d chip restored onto Total liquidity (#677).
  3. There is no honest GET today. Walking global_liquidity_snapshots or SUM(protocol_fee_events) on the request path would violate P550-5 / PFee-8 / indexer DoS invariants. Llama daily is the wrong catalog (gems excluded). New materialized hour/day/month tables are required, same pattern as volume.
  4. Stock vs flow must be visible. Liquidity is a level; volume and fees are sums in a bucket. A single unlabeled chart would invite “sum the TVL bars” mistakes. Metric buttons + metric-specific title/empty copy make the unit obvious without a lecture in the Global stats lead.

Constraints / guardrails

  1. One chart, exclusive metric. Buttons Volume / Liquidity / Fees (default Volume). Do not stacked/grouped overlay of all three as v1. Grain stays Hourly / Daily / Monthly (default Daily). Switching metric keeps grain; switching grain resets the active tooltip. Labels ≤ ~5 words (#489).
  2. Keep trailing tiles unchanged. Last 24h/7d/30d vol, Total liquidity + one 24h Δ% chip, Protocol fees panel + GET /protocol/fees?window= all stay. Chart is additive. Do not move the chart into protocol-fee-stats. Do not restore protocol-stat-liquidity-30d. Do not restore 7d / 30d as the grain selector.
  3. Stock vs flow (correctness).
    • Volume / fees = FLOW: SUM priced USD in [bucket, next). Idle → JSON "0"; activity + all unpriced → null (outlined bar, tooltip em-dash — never invent $0 then a filled bar). Missing rollup row → idle "0".
    • Liquidity = STOCK: last snapshot in the bucket (end-of-hour / UTC day / UTC month), not SUM of samples, not average unless documented as a separate metric (v1 is last sample). No snapshot in bucket → JSON null (unknown ≠ $0 TVL). Cold start / --fresh → many nulls until samples exist; empty copy No liquidity yet. Do not interpolate on GET. Do not backfill 30d from zeros.
  4. GET stays O(1) rollup. Series routes read grain tables only. Do not SUM swap_events / protocol_fee_events / fills on GET. Do not walk global_liquidity_snapshots / pair_reserves on GET. Do not N+1 GET /defillama/daily. OVERVIEW_GLOBAL_STATS_LIVE=1 still must not 60d-SUM. from / to → 400. Extra query junk must not bust the 60s cache.
  5. Allowlists. metric conceptually volume | liquidity | fees. grain hourly | daily | monthly. Integer limit 1..=max (hourly 168, daily 90, monthly 24) else 400. Unknown / arrays / negatives / javascript: / ../ → 400. Client rejects non-allowlisted metric/grain/limit before fetch. Fetch only the selected metric (do not prefetch all three on every paint). Debounce resize (P668-4).
  6. Keep volume path intact. GET /api/v1/protocol/volume/daily + days=7\|30 alias stay so make verify-issue-652 / 668 stay green. New series live on sibling routes, not a breaking metric= on the volume path:
    • GET /api/v1/protocol/liquidity/daily?grain=&limit=
    • GET /api/v1/protocol/fees/daily?grain=&limit=
      Existing GET /api/v1/protocol/fees?window= is the trailing breakdown — do not overload it with grain.
  7. Methodology.
    • Volume: unchanged Protocol catalog (P652-4 / P668-6) — gems / wrap / window included.
    • Liquidity: P569 pool TVL (humanized AMM pair_reserves, P522-Q + hub). Not book escrow / parked dust. Not CG liquidity_in_usd. Unpriced / omitted pairs ≠ $0. Never $1 UST1, 2.5× USTR, or vFDUSD conversion.
    • Fees: PFee treasury sources only (swap_amm, book_take, limit_place, wrap, unwrap, ust1_mint, ust1_redeem). Hybrid = pool commission_amount + fill commission_amount — not both fill commission and swap book_commission_amount (PFee-5). Spread / burn tax / hook / community-tax extra-debit are not protocol fees. Stamp fee_usd at ingest — do not rewrite non-null stamps from the live mark (#568). Headline series is priced SUM for the bucket (same idle/unpriced rule as volume). Unconfigured wrap mapper / UST1 window simply have no those events — do not invent idle wrap rows on the chart.
  8. Retention. Snapshot table prune stays ~35d (P569-3). Downsample into grain tables on the aggregator before prune so Monthly liquidity can retain ≥ 24 months. Hourly grain prune ~10d; daily ≥ 95d; monthly ≥ 24 months — match volume. --fresh / young indexer shows idle zeros (flow) or nulls (stock) — not a GET-path backfill.
  9. Chrome / a11y / XSS. Stay inside protocol-global-stats. No nested card-glass (C653). Do not mount PriceChart. Tooltip is text (period + formatProtocolUsd; missing/unpriced → em-dash). No innerHTML / eval / javascript: URLs. XSS in period / usd fields render as text. USD axis never Inf / NaN / raw uluna. Keyboard focus still shows tooltip (P668-2). X-axis density stays P668-9.
  10. Old indexer. Missing liquidity/fees series (404/501) → that metric’s plot hides / empty; Volume still works. Missing volume route → hide chart as today (P652-6). Additive JSON. Metric tabs should not crash when one sibling is absent.
  11. Copy must not lecture. Title/subtitle name the metric + UTC bucket (e.g. UTC liquidity / UTC calendar day). title / aria-label may say the chart is not the trailing tile. Do not add a banner about stock vs flow in the Global stats lead.
  12. Page order unchanged (P550-1). Title → Global stats (tiles + this chart) → Protocol fees → DEX hub → oracle → audit. Chart stays under the volume row even when metric is Liquidity or Fees (the control is on the census chart, not a second plot in the fee panel).
  13. Docs. Extend AGENTS_FRONTEND_PROTOCOL_STATS.md with P6xx invariants (do not silently weaken P668 / P569 / PFee). Update docs/frontend.md § Protocol, docs/indexer-invariants.md, docs/runbooks/overview-global-stats-brin.md, AGENTS.md + make verify-issue-*. Keep verify-issue-668 / 652 / 667 / 677 / 569 / 586 / 576 / 577 / 631 / 653 / 613 / 614 / 683 green.

Relevant files

Path Role
frontend-dapp/src/components/protocol/ProtocolVolumeDailyChart.tsx Metric tablist + reuse grain/axis/tooltip; keep host data-testid or alias so #668 e2e stays green
frontend-dapp/src/components/protocol/useProtocolVolumeSeriesQuery.ts Generalize or add liquidity/fees queries keyed by (metric, grain, limit)
frontend-dapp/src/components/protocol/ProtocolGlobalStats.tsx Host only — do not add a second chart
frontend-dapp/src/utils/protocolVolumeGrain.ts Shared grain/limit/axis helpers (metric-agnostic USD series)
frontend-dapp/src/utils/trailingWindowCopy.ts UTC liquidity / UTC fees labels + titles (≤ ~5 words visible)
frontend-dapp/src/utils/formatProtocolStats.ts Axis + tooltip USD
frontend-dapp/src/services/indexer/client.ts Allowlisted sibling GETs
frontend-dapp/src/types/index.ts Additive series types (liquidity_usd / fees_usd or shared value_usd)
frontend-dapp/src/pages/ProtocolPage.test.tsx Metric toggle, grain keep, hide-on-404 per metric, XSS, empty copy
frontend-dapp/src/utils/__tests__/trailingWindowCopy.test.ts New labels in visible-length allowlist
frontend-dapp/e2e/protocol-page.spec.ts Metric + grain tabs; chart still in Global stats; 5 Playwright workers
indexer/src/api/protocol_volume.rs Unchanged contract; do not break days alias
New indexer/src/api/protocol_liquidity.rs (or sibling module) Liquidity series GET + 60s allowlisted cache
indexer/src/api/protocol_fees.rs Keep window= breakdown; add separate /protocol/fees/daily handler (do not overload window)
indexer/src/api/mod.rs Route registration + governor (standard, not LCD-heavy)
indexer/src/db/queries/liquidity_snapshots.rs Aggregator downsample into grain tables; GET still must not walk snapshots
indexer/src/db/queries/protocol_fees.rs Aggregator hour/day/month SUM into fee grain tables
indexer/src/indexer/volume_aggregator.rs Refresh + prune new tables on the existing ~5 min loop
New migration (protocol_{hourly,daily,monthly}_{liquidity,fees}) Materialize buckets; comments must say GET reads these only
indexer/tests/indexer_protocol_volume.rs Regression: volume allowlist/cache unchanged
New / extended indexer tests for liquidity + fees series Allowlist, stock vs flow, idle/unpriced/null, cache, EXPLAIN no event/snapshot scan
skills/AGENTS_FRONTEND_PROTOCOL_STATS.md New P6xx + do/don’t
docs/frontend.md § Protocol Metric toggle UX
docs/indexer-invariants.md GET DoS + stock vs flow
docs/runbooks/overview-global-stats-brin.md Aggregator note
New scripts/qa/verify-issue-*.sh + Makefile target Docs grep + Vitest + indexer tests + optional Playwright
  1. UI. Add a second tablist (or segmented control) Volume / Liquidity / Fees next to Hourly/Daily/Monthly. Reuse the SVG plot: map volume_usd / liquidity_usd / fees_usd through one value_usd helper. Title + empty string + aria-label follow the selected metric. Keep protocol-volume-daily-chart as the host testid (or add protocol-utc-series-chart and alias the old id) so #668 e2e does not flake.
  2. Indexer tables. Mirror volume:
    • Liquidity: protocol_hourly_liquidity (utc_hour, liquidity_usd NULLABLE, priced_pair_count, refreshed_at), plus daily (utc_day) and monthly (utc_month). liquidity_usd NULL = no snapshot in bucket.
    • Fees: protocol_hourly_fees (utc_hour, fees_usd NULLABLE, event_count, unpriced_count, refreshed_at) (+ daily/monthly). fees_usd "0" idle / null all-unpriced — same as volume.
  3. Aggregator. On the existing volume loop, after TVL snapshot insert and fee ingest refresh:
    • Liquidity: take latest snapshot timestamp in each open bucket (and close prior buckets). Persist monthly before 35d snapshot prune.
    • Fees: SUM(fee_usd) / count unpriced in [bucket, next) from protocol_fee_events off GET.
  4. GET. Clone protocol_volume.rs allowlist + 60s Mutex cache keyed only by (grain, limit). Fill missing flow buckets with "0"; fill missing stock buckets with null. Newest-last. timezone: "UTC". methodology: "protocol_catalog" (not defillama).
  5. Frontend client. getProtocolLiquiditySeries / getProtocolFeesSeries with the same grain/limit guards as volume. React Query key includes metric. Switching to a 404 metric hides the plot, not the whole Global stats card.
  6. Do not add Llama from/to, CSV, unique-trader, or a second chart under Protocol fees.

Acceptance criteria

  • /protocol Global stats chart shows Volume / Liquidity / Fees buttons; default Volume; default grain Daily.
  • Hourly / Daily / Monthly still work for each metric; grain is preserved across metric clicks.
  • Volume series, axis, tooltip, unpriced outline, x-axis density, days=7\|30 alias, and hide-on-404 behave as P668 / P652.
  • Liquidity bars are stock (last snapshot in bucket). Missing sample → outlined / em-dash, not $0. Title UTC liquidity. Empty: No liquidity yet.
  • Fees bars are flow treasury USD (PFee sources, Protocol catalog). Idle $0; all-unpriced outlined / em-dash. Title UTC fees. Empty: No fees yet.
  • Trailing tiles and fee panel unchanged (including no 30d liquidity chip; GET /protocol/fees?window= still 24h/7d/30d).
  • GET /protocol/liquidity/daily and GET /protocol/fees/daily: allowlisted grain+limit; from/to 400; 60s cache; EXPLAIN shows grain tables only.
  • Copy ≤ ~5 words; no Global stats lecture; no nested card-glass; no PriceChart.
  • XSS strings in series fields render as text. Tooltip has no innerHTML.
  • Old indexer: volume-only still works; missing sibling routes do not blank the page.
  • Docs + skill invariants + make verify-issue-<this> exist. Prior protocol verify targets stay green.

Test plan (functional paths)

Frontend (Vitest)

  1. Default: Volume + Daily; getProtocolVolumeSeries called; liquidity/fees not fetched.
  2. Click Liquidity / Fees: correct sibling GET; title/empty/aria update; grain stays Daily.
  3. Click Hourly then Fees: fees hourly fetch; tooltip reset.
  4. Resize debounce: limit clamped per grain; no fetch above max.
  5. Volume unpriced null → outlined bar + tooltip —; idle "0" → zero-height.
  6. Liquidity all-null series → No liquidity yet (not a flat $0 chart). Mixed null/priced → outlined gaps.
  7. Fees idle "0" vs unpriced null match volume rules.
  8. 404/501 on liquidity GET → plot hidden/empty; Volume tab still loads.
  9. XSS in utc_day / liquidity_usd / fees_usd → text only.
  10. Trailing tiles still present; protocol-stat-liquidity-30d count 0.
  11. Visible labels still in TRAILING_WINDOW_VISIBLE_LABELS length check.

Indexer (Postgres integration, --test-threads=1)

  1. Liquidity GET: grain/limit allowlist; junk from/to/window/days/metric → 400 (or ignored without cache bust — prefer 400 for from/to).
  2. Liquidity: snapshot at 10:59 and 11:01 UTC → hourly 10:00 bar is 10:59 value, 11:00 bar is 11:01 (last-in-bucket).
  3. Liquidity: empty bucket → null, not "0".
  4. Liquidity: GET EXPLAIN does not mention global_liquidity_snapshots / pair_reserves.
  5. Fees: idle hour "0"; unpriced-only hour null; mixed priced+unpriced → priced SUM (do not null the whole bucket because one event is unpriced — match volume’s “activity + all unpriced” rule: if any priced, SUM priced).
  6. Fees: wrap/window events only when pins ingest them; spread/burn/hook amounts absent from SUM.
  7. Fees: GET EXPLAIN does not mention protocol_fee_events / swap_events.
  8. Cache: second GET within 60s same (grain, limit) does not hit a new scan; extra ?foo= does not create a new cache entry.
  9. Prune: hourly rows older than ~10d gone; monthly retained ≥ 24 months; liquidity monthly survives snapshot 35d prune.
  10. Volume tests in indexer_protocol_volume.rs still pass (alias + grain).

Playwright (e2e/protocol-page.spec.ts, 5 workers, smoke)

  1. Chart visible inside protocol-global-stats.
  2. Metric buttons Volume/Liquidity/Fees; grain Hourly/Daily/Monthly.
  3. Click Liquidity: subtitle still UTC grain; Y-axis $; tooltip on hover/focus.
  4. Click Fees: same.
  5. Phone 390 / desktop: chart does not nest card-glass; liquidity tile still 24h-only Δ%.
  6. Fee panel still after Global stats with 24h/7d/30d tiles.

Manual / LocalTerra (optional)

--fresh indexer: Volume shows idle zeros; Liquidity empty/null until snapshots; Fees idle zeros. After swaps + LP + a wrap/swap fee, Daily volume/fees move; liquidity level moves without matching volume height.

Test plan (attack, hack, and abuse)

Treat these as must-fail-closed cases in indexer tests + Vitest. No exploit PoC in the repo — assert 400/ignore/text-escape only.

Vector Expect
Query injection grain=hourly;DROP, limit[]=, limit=-1, limit=999999, grain=week, metric=javascript:alert(1), ../, %00 → 400. Client never fetches.
Range dump from= / to= / unbounded ISO range on new routes → 400. No CSV.
Cache DoS Random extra query keys must not multiply 60s cache entries (key only allowlisted grain+limit, same as P668-5).
GET-path CPU/IO Handler must not SUM events or walk snapshots. Integration EXPLAIN + OVERVIEW_GLOBAL_STATS_LIVE=1 still forbidden from 60d-SUM.
Request flood Metric switch fetches one series. ResizeObserver debounced. No prefetch of all metrics+grains.
Stale paint / confused deputy In-flight volume response must not render after user selected Fees (query key includes metric; ignore outdated).
XSS / HTML utc_hour / utc_day / usd fields containing <script>, "><img onerror=, javascript: render as text. Tooltip DOM is text nodes / React children, not innerHTML.
Stock as flow Test that liquidity GET is not SUM(total_liquidity_usd) across snapshots in the hour (would inflate TVL). Abuse of “sum the bars” is mitigated by title UTC liquidity and null gaps — do not document bars as additive flow.
Unpriced → $0 wash All-unpriced volume/fees must not become filled $0 bars (hides oracle holes). Liquidity null must not become $0 (hides missing samples / omits as empty book).
Wrong catalog Assert handlers do not read defillama_daily_* or CG liquidity_in_usd. Llama gem-exclude must not silently apply to Protocol fees/volume.
Fee double-count Hybrid fill must not add both limit_order_fills.commission_amount and swap book_commission_amount. Placement maker fee is limit_place only.
Wrap/window spoof Events from non-pinned contracts must not enter protocol_fee_events (existing ingest pins). Series SUM inherits that fail-closed ingest.
Stamp rewrite Refresh must not overwrite non-null fee_usd from the live hub (#568). Series uses stamped values.
Identity / ticker ?ticker= on series routes ignored or 400; must not change methodology or open SSRF. Oracle chips stay allowlisted on the page (P550-2).
Authz Public read-only GETs; no admin mutate via query. Standard governor only (not a new unbounded work queue).
Chrome spoof Nested card-glass / PriceChart / trading overlay rejected by verify script greps (same as #668).

Verification criteria

Ship is done when all of the following are true:

  1. make verify-issue-<iid> (new script) PASSes: skill/docs invariants greps, no Llama N+1 / no GET swap_events / no GET snapshot walk / no PriceChart, Vitest Protocol page + copy tests, indexer volume and new liquidity/fees series tests (--test-threads=1), Playwright protocol-page metric+grain (5 workers when frontend-dapp/node_modules exists).
  2. make verify-issue-668 652 667 677 569 586 576 613 614 683 653 631 still green.
  3. Live /protocol (LocalTerra or Coolify after indexer migrate): Volume default looks like today; Liquidity / Fees buttons switch USD bars; Hourly/Daily/Monthly still labeled; Total liquidity tile still 24h-only; fee panel still trailing 24h/7d/30d.
  4. docs/indexer-invariants.md has a row for Protocol UTC liquidity/fees grain GET (allowlist, stock vs flow, cache, prune).
  5. No wasm / factory change. No bind-mount of indexer/ into root Docker for cargo.
## Summary `/protocol` already has a UTC **Hourly / Daily / Monthly** bar chart, but it is **volume-only**. Operators cannot inspect **liquidity (stock)** or **treasury fees (flow)** on the same grain. Bundle into **one** issue: metric buttons **Volume / Liquidity / Fees** on the existing census chart, with indexer UTC-grain rollups that match **P668** GET/DoS rules. Do **not** split liquidity vs fees vs UI toggle into separate issues. Do **not** overlay three series by default. Do **not** reuse DeFiLlama daily volume/fees. Related: [#668](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/668) (USD axis + grain chart), [#652](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/652) (UTC-day volume series), [#569](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/569) (pool TVL stock + 24h Δ%), [#677](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/677) (liquidity 24h-only chip + dense x-axis), [#586](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/586) (trailing treasury fees), [#613](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/613) / [#614](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/614) / [#683](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/683) (wrap / UST1 window / economic fee USD), [#576](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/576) (trailing tiles ≠ calendar bars), [#489](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/489) (short copy), [#653](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/653) (one chrome layer), [#631](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/631) (Llama daily — **wrong** methodology). ## Current codebase The chart exists and is readable as a **volume** time series. Liquidity and fees exist only as **trailing tiles** (and a fee breakdown table), not as UTC-grain bars. | Layer | Behavior today | |-------|----------------| | **Chart UI** | [`ProtocolVolumeDailyChart.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/protocol/ProtocolVolumeDailyChart.tsx) inside [`ProtocolGlobalStats.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/protocol/ProtocolGlobalStats.tsx). Custom SVG (`protocol-volume-daily-chart`): USD Y-axis, pointer/keyboard tooltip, **Hourly / Daily / Monthly** tabs (`protocol-volume-grain-*`), default Daily. Title copy is **UTC volume**. No metric toggle. | | **Grain / width** | [`protocolVolumeGrain.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/protocolVolumeGrain.ts): `ResizeObserver` → `limit` clamped hourly **12–168**, daily **7–90**, monthly **6–24**. Query key `(grain, limit)`. X-axis density **P668-9**. | | **Client fetch** | [`getProtocolVolumeSeries`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/indexer/client.ts) → `GET /api/v1/protocol/volume/daily?grain=&limit=`. [`useProtocolVolumeSeriesQuery`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/protocol/useProtocolVolumeSeriesQuery.ts) keys `['indexer-protocol-volume-series', grain, limit]`. Hide on 404/501. No liquidity / fees series helpers. | | **Volume indexer** | [`GET /api/v1/protocol/volume/daily`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/api/protocol_volume.rs): `grain=hourly\|daily\|monthly` + capped `limit` else **400**. `from`/`to` → **400**. `days=7\|30` without `grain` stays the #652 alias. 60s cache keyed by allowlisted `(grain, limit)` or `days`. Reads `protocol_hourly_volume` / `protocol_daily_volume` / `protocol_monthly_volume` only — never `swap_events` / Llama. Idle `"0"`; activity + unpriced `null`. Newest-last. | | **Volume refresh** | [`volume_aggregator.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/indexer/volume_aggregator.rs) (~5 min) calls `refresh_protocol_{hourly,daily,monthly}`. Hourly prune ~10d; daily ≥ 95d; monthly ≥ 24 months. Methodology = Protocol catalog (gems / wrap / window **included**). | | **Liquidity tile** | **Total liquidity** in Global stats (`protocol-stat-liquidity`) is **stock** TVL from `GET /overview` (`total_liquidity_usd` + one 24h snapshot Δ%). **P569** / **P677**: no 30d chip on the tile. Not a time series. | | **Liquidity history** | [`global_liquidity_snapshots`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/db/queries/liquidity_snapshots.rs): ~5 min samples, **retain ≥ 35 days**, `SNAPSHOT_MIN_INTERVAL` 4 min. Used only to compute trailing 24h/30d Δ% on the aggregator. **GET `/overview` must not walk this table.** There is **no** hourly/daily/monthly liquidity rollup and **no** series GET. | | **Fees tile + table** | [`protocol-fee-stats`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/protocol/ProtocolFeeStats.tsx) sits **after** Global stats. Trailing **24h / 7d / 30d** USD + source/token tables via [`GET /api/v1/protocol/fees?window=`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/api/protocol_fees.rs) (`24h` \| `7d` \| `30d` else **400**). O(1) child rollups. **Not** UTC calendar buckets. | | **Fee events** | `protocol_fee_events` ingested with stamped `fee_usd` (**PFee** / **I613** / **I614** / **EFee**). Refresh SUMs into trailing windows off the GET path. **GET must not SUM `protocol_fee_events`.** There is **no** UTC-hour/day/month fee series table for Protocol catalog (Llama `defillama_daily_fees` is gem-excluded and **must not** feed `/protocol`). | | **Llama** | `GET /api/v1/defillama/daily` is a **different** product: UTC-day, gem-exclude, wrap/window out of volume. Playbook forbids calling it from `/protocol`. | | **Copy** | [`trailingWindowCopy.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/trailingWindowCopy.ts): `PROTOCOL_VOLUME_DAILY_LABEL` = **UTC volume**; grain labels Hourly/Daily/Monthly; titles stress calendar ≠ trailing tiles. No liquidity/fees chart strings. | | **Tests** | Vitest [`ProtocolPage.test.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/pages/ProtocolPage.test.tsx); Playwright [`e2e/protocol-page.spec.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/e2e/protocol-page.spec.ts) (5 workers); indexer [`indexer_protocol_volume.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/indexer_protocol_volume.rs), [`indexer_protocol_liquidity.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/indexer_protocol_liquidity.rs), [`indexer_protocol_fees.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/indexer_protocol_fees.rs). Gates: `make verify-issue-668` / `652` / `569` / `677` / `586` / `576`. | ## Why this is needed 1. **Volume answers “how much traded.”** Operators also need **how much is locked** and **how much the protocol earned** on the **same UTC grain**, without leaving `/protocol` or mentally aligning trailing 24h/7d/30d tiles with calendar bars. 2. **Trailing tiles are the wrong grain for a history chart.** Last 24h/7d/30d liquidity Δ% and fee totals are **now − N** windows ([#576](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/576)). The chart is already a **UTC calendar** series ([#668](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/668)). Adding liquidity/fees must stay calendar buckets — not a second 7d/30d toggle and not a 30d chip restored onto Total liquidity ([#677](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/677)). 3. **There is no honest GET today.** Walking `global_liquidity_snapshots` or `SUM(protocol_fee_events)` on the request path would violate **P550-5** / **PFee-8** / indexer DoS invariants. Llama daily is the wrong catalog (gems excluded). New **materialized** hour/day/month tables are required, same pattern as volume. 4. **Stock vs flow must be visible.** Liquidity is a **level**; volume and fees are **sums in a bucket**. A single unlabeled chart would invite “sum the TVL bars” mistakes. Metric buttons + metric-specific title/empty copy make the unit obvious without a lecture in the Global stats lead. ## Constraints / guardrails 1. **One chart, exclusive metric.** Buttons **Volume / Liquidity / Fees** (default **Volume**). Do **not** stacked/grouped overlay of all three as v1. Grain stays **Hourly / Daily / Monthly** (default Daily). Switching metric **keeps** grain; switching grain resets the active tooltip. Labels ≤ ~5 words ([#489](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/489)). 2. **Keep trailing tiles unchanged.** Last 24h/7d/30d vol, Total liquidity + **one** 24h Δ% chip, Protocol fees panel + `GET /protocol/fees?window=` all stay. Chart is additive. Do **not** move the chart into `protocol-fee-stats`. Do **not** restore `protocol-stat-liquidity-30d`. Do **not** restore `7d` / `30d` as the grain selector. 3. **Stock vs flow (correctness).** - **Volume / fees** = FLOW: `SUM` priced USD in `[bucket, next)`. Idle → JSON `"0"`; activity + all unpriced → `null` (outlined bar, tooltip em-dash — never invent `$0` then a filled bar). Missing rollup row → idle `"0"`. - **Liquidity** = STOCK: **last snapshot in the bucket** (end-of-hour / UTC day / UTC month), **not** `SUM` of samples, **not** average unless documented as a separate metric (v1 is last sample). **No snapshot in bucket → JSON `null`** (unknown ≠ `$0` TVL). Cold start / `--fresh` → many nulls until samples exist; empty copy **No liquidity yet**. Do **not** interpolate on GET. Do **not** backfill 30d from zeros. 4. **GET stays O(1) rollup.** Series routes read **grain tables only**. Do **not** `SUM` `swap_events` / `protocol_fee_events` / fills on GET. Do **not** walk `global_liquidity_snapshots` / `pair_reserves` on GET. Do **not** N+1 `GET /defillama/daily`. `OVERVIEW_GLOBAL_STATS_LIVE=1` still must not 60d-SUM. `from` / `to` → **400**. Extra query junk must **not** bust the 60s cache. 5. **Allowlists.** `metric` conceptually `volume` \| `liquidity` \| `fees`. `grain` `hourly` \| `daily` \| `monthly`. Integer `limit` 1..=max (hourly **168**, daily **90**, monthly **24**) else **400**. Unknown / arrays / negatives / `javascript:` / `../` → **400**. Client rejects non-allowlisted metric/grain/limit **before** fetch. Fetch **only the selected metric** (do not prefetch all three on every paint). Debounce resize (**P668-4**). 6. **Keep volume path intact.** `GET /api/v1/protocol/volume/daily` + `days=7\|30` alias stay so `make verify-issue-652` / `668` stay green. New series live on **sibling** routes, not a breaking `metric=` on the volume path: - `GET /api/v1/protocol/liquidity/daily?grain=&limit=` - `GET /api/v1/protocol/fees/daily?grain=&limit=` Existing `GET /api/v1/protocol/fees?window=` is the **trailing breakdown** — do **not** overload it with `grain`. 7. **Methodology.** - **Volume:** unchanged Protocol catalog (**P652-4** / **P668-6**) — gems / wrap / window included. - **Liquidity:** **P569** pool TVL (humanized AMM `pair_reserves`, P522-Q + hub). Not book escrow / parked dust. Not CG `liquidity_in_usd`. Unpriced / omitted pairs ≠ `$0`. Never `$1` UST1, `2.5×` USTR, or vFDUSD conversion. - **Fees:** **PFee** treasury sources only (`swap_amm`, `book_take`, `limit_place`, `wrap`, `unwrap`, `ust1_mint`, `ust1_redeem`). Hybrid = pool `commission_amount` + fill `commission_amount` — **not** both fill commission and swap `book_commission_amount` (**PFee-5**). Spread / burn tax / hook / community-tax extra-debit are **not** protocol fees. Stamp `fee_usd` at ingest — do **not** rewrite non-null stamps from the live mark ([#568](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/568)). Headline series is **priced SUM** for the bucket (same idle/unpriced rule as volume). Unconfigured wrap mapper / UST1 window simply have no those events — do not invent idle wrap rows on the chart. 8. **Retention.** Snapshot table prune stays ~35d (**P569-3**). **Downsample into grain tables on the aggregator before prune** so Monthly liquidity can retain ≥ 24 months. Hourly grain prune ~10d; daily ≥ 95d; monthly ≥ 24 months — match volume. `--fresh` / young indexer shows idle zeros (flow) or nulls (stock) — **not** a GET-path backfill. 9. **Chrome / a11y / XSS.** Stay inside `protocol-global-stats`. No nested `card-glass` (**C653**). Do **not** mount `PriceChart`. Tooltip is **text** (period + `formatProtocolUsd`; missing/unpriced → em-dash). No `innerHTML` / `eval` / `javascript:` URLs. XSS in period / usd fields render as **text**. USD axis never Inf / `NaN` / raw `uluna`. Keyboard focus still shows tooltip (**P668-2**). X-axis density stays **P668-9**. 10. **Old indexer.** Missing liquidity/fees series (404/501) → that metric’s plot hides / empty; Volume still works. Missing volume route → hide chart as today (**P652-6**). Additive JSON. Metric tabs should not crash when one sibling is absent. 11. **Copy must not lecture.** Title/subtitle name the metric + UTC bucket (e.g. **UTC liquidity** / **UTC calendar day**). `title` / `aria-label` may say the chart is not the trailing tile. Do **not** add a banner about stock vs flow in the Global stats lead. 12. **Page order unchanged (P550-1).** Title → Global stats (tiles + this chart) → Protocol fees → DEX hub → oracle → audit. Chart stays under the volume row even when metric is Liquidity or Fees (the control is on the census chart, not a second plot in the fee panel). 13. **Docs.** Extend [`AGENTS_FRONTEND_PROTOCOL_STATS.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/skills/AGENTS_FRONTEND_PROTOCOL_STATS.md) with **P6xx** invariants (do not silently weaken P668 / P569 / PFee). Update `docs/frontend.md` § Protocol, `docs/indexer-invariants.md`, `docs/runbooks/overview-global-stats-brin.md`, `AGENTS.md` + `make verify-issue-*`. Keep `verify-issue-668` / `652` / `667` / `677` / `569` / `586` / `576` / `577` / `631` / `653` / `613` / `614` / `683` green. ## Relevant files | Path | Role | |------|------| | [`frontend-dapp/src/components/protocol/ProtocolVolumeDailyChart.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/protocol/ProtocolVolumeDailyChart.tsx) | Metric tablist + reuse grain/axis/tooltip; keep host `data-testid` or alias so #668 e2e stays green | | [`frontend-dapp/src/components/protocol/useProtocolVolumeSeriesQuery.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/protocol/useProtocolVolumeSeriesQuery.ts) | Generalize or add liquidity/fees queries keyed by `(metric, grain, limit)` | | [`frontend-dapp/src/components/protocol/ProtocolGlobalStats.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/protocol/ProtocolGlobalStats.tsx) | Host only — do not add a second chart | | [`frontend-dapp/src/utils/protocolVolumeGrain.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/protocolVolumeGrain.ts) | Shared grain/limit/axis helpers (metric-agnostic USD series) | | [`frontend-dapp/src/utils/trailingWindowCopy.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/trailingWindowCopy.ts) | **UTC liquidity** / **UTC fees** labels + titles (≤ ~5 words visible) | | [`frontend-dapp/src/utils/formatProtocolStats.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/formatProtocolStats.ts) | Axis + tooltip USD | | [`frontend-dapp/src/services/indexer/client.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/indexer/client.ts) | Allowlisted sibling GETs | | [`frontend-dapp/src/types/index.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/types/index.ts) | Additive series types (`liquidity_usd` / `fees_usd` or shared `value_usd`) | | [`frontend-dapp/src/pages/ProtocolPage.test.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/pages/ProtocolPage.test.tsx) | Metric toggle, grain keep, hide-on-404 per metric, XSS, empty copy | | [`frontend-dapp/src/utils/__tests__/trailingWindowCopy.test.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/__tests__/trailingWindowCopy.test.ts) | New labels in visible-length allowlist | | [`frontend-dapp/e2e/protocol-page.spec.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/e2e/protocol-page.spec.ts) | Metric + grain tabs; chart still in Global stats; 5 Playwright workers | | [`indexer/src/api/protocol_volume.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/api/protocol_volume.rs) | Unchanged contract; do not break `days` alias | | New `indexer/src/api/protocol_liquidity.rs` (or sibling module) | Liquidity series GET + 60s allowlisted cache | | [`indexer/src/api/protocol_fees.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/api/protocol_fees.rs) | Keep `window=` breakdown; add **separate** `/protocol/fees/daily` handler (do not overload `window`) | | [`indexer/src/api/mod.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/api/mod.rs) | Route registration + governor (standard, not LCD-heavy) | | [`indexer/src/db/queries/liquidity_snapshots.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/db/queries/liquidity_snapshots.rs) | Aggregator downsample into grain tables; GET still must not walk snapshots | | [`indexer/src/db/queries/protocol_fees.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/db/queries/protocol_fees.rs) | Aggregator hour/day/month SUM into fee grain tables | | [`indexer/src/indexer/volume_aggregator.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/indexer/volume_aggregator.rs) | Refresh + prune new tables on the existing ~5 min loop | | New migration (`protocol_{hourly,daily,monthly}_{liquidity,fees}`) | Materialize buckets; comments must say GET reads these only | | [`indexer/tests/indexer_protocol_volume.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/indexer_protocol_volume.rs) | Regression: volume allowlist/cache unchanged | | New / extended indexer tests for liquidity + fees series | Allowlist, stock vs flow, idle/unpriced/null, cache, EXPLAIN no event/snapshot scan | | [`skills/AGENTS_FRONTEND_PROTOCOL_STATS.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/skills/AGENTS_FRONTEND_PROTOCOL_STATS.md) | New P6xx + do/don’t | | [`docs/frontend.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/frontend.md) § Protocol | Metric toggle UX | | [`docs/indexer-invariants.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/indexer-invariants.md) | GET DoS + stock vs flow | | [`docs/runbooks/overview-global-stats-brin.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/runbooks/overview-global-stats-brin.md) | Aggregator note | | New `scripts/qa/verify-issue-*.sh` + Makefile target | Docs grep + Vitest + indexer tests + optional Playwright | ## Recommended direction 1. **UI.** Add a second tablist (or segmented control) **Volume / Liquidity / Fees** next to Hourly/Daily/Monthly. Reuse the SVG plot: map `volume_usd` / `liquidity_usd` / `fees_usd` through one `value_usd` helper. Title + empty string + `aria-label` follow the selected metric. Keep `protocol-volume-daily-chart` as the host testid (or add `protocol-utc-series-chart` **and** alias the old id) so #668 e2e does not flake. 2. **Indexer tables.** Mirror volume: - Liquidity: `protocol_hourly_liquidity (utc_hour, liquidity_usd NULLABLE, priced_pair_count, refreshed_at)`, plus daily (`utc_day`) and monthly (`utc_month`). `liquidity_usd` NULL = no snapshot in bucket. - Fees: `protocol_hourly_fees (utc_hour, fees_usd NULLABLE, event_count, unpriced_count, refreshed_at)` (+ daily/monthly). `fees_usd` `"0"` idle / `null` all-unpriced — same as volume. 3. **Aggregator.** On the existing volume loop, after TVL snapshot insert and fee ingest refresh: - Liquidity: take **latest snapshot timestamp in each open bucket** (and close prior buckets). Persist monthly **before** 35d snapshot prune. - Fees: `SUM(fee_usd)` / count unpriced in `[bucket, next)` from `protocol_fee_events` **off GET**. 4. **GET.** Clone `protocol_volume.rs` allowlist + 60s `Mutex` cache keyed only by `(grain, limit)`. Fill missing flow buckets with `"0"`; fill missing stock buckets with `null`. Newest-last. `timezone: "UTC"`. `methodology: "protocol_catalog"` (not `defillama`). 5. **Frontend client.** `getProtocolLiquiditySeries` / `getProtocolFeesSeries` with the same grain/limit guards as volume. React Query key includes metric. Switching to a 404 metric hides the plot, not the whole Global stats card. 6. **Do not** add Llama `from`/`to`, CSV, unique-trader, or a second chart under Protocol fees. ## Acceptance criteria - [ ] `/protocol` Global stats chart shows **Volume / Liquidity / Fees** buttons; default **Volume**; default grain **Daily**. - [ ] Hourly / Daily / Monthly still work for **each** metric; grain is preserved across metric clicks. - [ ] Volume series, axis, tooltip, unpriced outline, x-axis density, `days=7\|30` alias, and hide-on-404 behave as **P668** / **P652**. - [ ] Liquidity bars are **stock** (last snapshot in bucket). Missing sample → outlined / em-dash, **not** `$0`. Title **UTC liquidity**. Empty: **No liquidity yet**. - [ ] Fees bars are **flow** treasury USD (PFee sources, Protocol catalog). Idle `$0`; all-unpriced outlined / em-dash. Title **UTC fees**. Empty: **No fees yet**. - [ ] Trailing tiles and fee panel unchanged (including no 30d liquidity chip; `GET /protocol/fees?window=` still 24h/7d/30d). - [ ] `GET /protocol/liquidity/daily` and `GET /protocol/fees/daily`: allowlisted `grain`+`limit`; `from`/`to` **400**; 60s cache; EXPLAIN shows grain tables only. - [ ] Copy ≤ ~5 words; no Global stats lecture; no nested `card-glass`; no `PriceChart`. - [ ] XSS strings in series fields render as text. Tooltip has no `innerHTML`. - [ ] Old indexer: volume-only still works; missing sibling routes do not blank the page. - [ ] Docs + skill invariants + `make verify-issue-<this>` exist. Prior protocol verify targets stay green. ## Test plan (functional paths) ### Frontend (Vitest) 1. Default: Volume + Daily; `getProtocolVolumeSeries` called; liquidity/fees **not** fetched. 2. Click Liquidity / Fees: correct sibling GET; title/empty/aria update; grain stays Daily. 3. Click Hourly then Fees: fees hourly fetch; tooltip reset. 4. Resize debounce: limit clamped per grain; no fetch above max. 5. Volume unpriced `null` → outlined bar + tooltip `—`; idle `"0"` → zero-height. 6. Liquidity all-`null` series → **No liquidity yet** (not a flat `$0` chart). Mixed null/priced → outlined gaps. 7. Fees idle `"0"` vs unpriced `null` match volume rules. 8. 404/501 on liquidity GET → plot hidden/empty; Volume tab still loads. 9. XSS in `utc_day` / `liquidity_usd` / `fees_usd` → text only. 10. Trailing tiles still present; `protocol-stat-liquidity-30d` count 0. 11. Visible labels still in `TRAILING_WINDOW_VISIBLE_LABELS` length check. ### Indexer (Postgres integration, `--test-threads=1`) 1. Liquidity GET: grain/limit allowlist; junk `from`/`to`/`window`/`days`/`metric` → **400** (or ignored without cache bust — prefer **400** for `from`/`to`). 2. Liquidity: snapshot at 10:59 and 11:01 UTC → hourly 10:00 bar is 10:59 value, 11:00 bar is 11:01 (last-in-bucket). 3. Liquidity: empty bucket → `null`, not `"0"`. 4. Liquidity: GET EXPLAIN does **not** mention `global_liquidity_snapshots` / `pair_reserves`. 5. Fees: idle hour `"0"`; unpriced-only hour `null`; mixed priced+unpriced → priced SUM (do not null the whole bucket because one event is unpriced — match volume’s “activity + **all** unpriced” rule: if any priced, SUM priced). 6. Fees: wrap/window events only when pins ingest them; spread/burn/hook amounts absent from SUM. 7. Fees: GET EXPLAIN does **not** mention `protocol_fee_events` / `swap_events`. 8. Cache: second GET within 60s same `(grain, limit)` does not hit a new scan; extra `?foo=` does not create a new cache entry. 9. Prune: hourly rows older than ~10d gone; monthly retained ≥ 24 months; liquidity monthly survives snapshot 35d prune. 10. Volume tests in `indexer_protocol_volume.rs` still pass (alias + grain). ### Playwright (`e2e/protocol-page.spec.ts`, **5 workers**, smoke) 1. Chart visible inside `protocol-global-stats`. 2. Metric buttons Volume/Liquidity/Fees; grain Hourly/Daily/Monthly. 3. Click Liquidity: subtitle still UTC grain; Y-axis `$`; tooltip on hover/focus. 4. Click Fees: same. 5. Phone 390 / desktop: chart does not nest `card-glass`; liquidity tile still 24h-only Δ%. 6. Fee panel still after Global stats with 24h/7d/30d tiles. ### Manual / LocalTerra (optional) `--fresh` indexer: Volume shows idle zeros; Liquidity empty/null until snapshots; Fees idle zeros. After swaps + LP + a wrap/swap fee, Daily volume/fees move; liquidity level moves without matching volume height. ## Test plan (attack, hack, and abuse) Treat these as **must-fail-closed** cases in indexer tests + Vitest. No exploit PoC in the repo — assert 400/ignore/text-escape only. | Vector | Expect | |--------|--------| | **Query injection** | `grain=hourly;DROP`, `limit[]=`, `limit=-1`, `limit=999999`, `grain=week`, `metric=javascript:alert(1)`, `../`, `%00` → **400**. Client never fetches. | | **Range dump** | `from=` / `to=` / unbounded ISO range on new routes → **400**. No CSV. | | **Cache DoS** | Random extra query keys must **not** multiply 60s cache entries (key only allowlisted grain+limit, same as **P668-5**). | | **GET-path CPU/IO** | Handler must not `SUM` events or walk snapshots. Integration EXPLAIN + `OVERVIEW_GLOBAL_STATS_LIVE=1` still forbidden from 60d-SUM. | | **Request flood** | Metric switch fetches **one** series. ResizeObserver debounced. No prefetch of all metrics+grains. | | **Stale paint / confused deputy** | In-flight volume response must not render after user selected Fees (query key includes metric; ignore outdated). | | **XSS / HTML** | `utc_hour` / `utc_day` / usd fields containing `<script>`, `"><img onerror=`, `javascript:` render as **text**. Tooltip DOM is text nodes / React children, not `innerHTML`. | | **Stock as flow** | Test that liquidity GET is **not** `SUM(total_liquidity_usd)` across snapshots in the hour (would inflate TVL). Abuse of “sum the bars” is mitigated by title **UTC liquidity** and null gaps — do not document bars as additive flow. | | **Unpriced → $0 wash** | All-unpriced volume/fees must not become filled `$0` bars (hides oracle holes). Liquidity null must not become `$0` (hides missing samples / omits as empty book). | | **Wrong catalog** | Assert handlers do **not** read `defillama_daily_*` or CG `liquidity_in_usd`. Llama gem-exclude must not silently apply to Protocol fees/volume. | | **Fee double-count** | Hybrid fill must not add both `limit_order_fills.commission_amount` and swap `book_commission_amount`. Placement maker fee is `limit_place` only. | | **Wrap/window spoof** | Events from non-pinned contracts must not enter `protocol_fee_events` (existing ingest pins). Series SUM inherits that fail-closed ingest. | | **Stamp rewrite** | Refresh must not overwrite non-null `fee_usd` from the live hub (#568). Series uses stamped values. | | **Identity / ticker** | `?ticker=` on series routes ignored or **400**; must not change methodology or open SSRF. Oracle chips stay allowlisted on the page (**P550-2**). | | **Authz** | Public read-only GETs; no admin mutate via query. Standard governor only (not a new unbounded work queue). | | **Chrome spoof** | Nested `card-glass` / `PriceChart` / trading overlay rejected by verify script greps (same as #668). | ## Verification criteria Ship is done when **all** of the following are true: 1. `make verify-issue-<iid>` (new script) PASSes: skill/docs invariants greps, no Llama N+1 / no GET `swap_events` / no GET snapshot walk / no `PriceChart`, Vitest Protocol page + copy tests, indexer volume **and** new liquidity/fees series tests (`--test-threads=1`), Playwright protocol-page metric+grain (5 workers when `frontend-dapp/node_modules` exists). 2. `make verify-issue-668` `652` `667` `677` `569` `586` `576` `613` `614` `683` `653` `631` still green. 3. Live `/protocol` (LocalTerra or Coolify after indexer migrate): Volume default looks like today; Liquidity / Fees buttons switch USD bars; Hourly/Daily/Monthly still labeled; Total liquidity tile still 24h-only; fee panel still trailing 24h/7d/30d. 4. `docs/indexer-invariants.md` has a row for Protocol UTC liquidity/fees grain GET (allowlist, stock vs flow, cache, prune). 5. No wasm / factory change. No bind-mount of `indexer/` into root Docker for cargo.
PlasticDigits commented 2026-08-27 09:00:10 +00:00 (Migrated from gitlab.com)

marked as related to #668

marked as related to #668
PlasticDigits commented 2026-08-27 09:00:10 +00:00 (Migrated from gitlab.com)

marked as related to #652

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

marked as related to #569

marked as related to #569
PlasticDigits commented 2026-08-27 09:00:12 +00:00 (Migrated from gitlab.com)

marked as related to #586

marked as related to #586
PlasticDigits commented 2026-08-27 09:00:13 +00:00 (Migrated from gitlab.com)

marked as related to #576

marked as related to #576
PlasticDigits commented 2026-08-27 09:00:14 +00:00 (Migrated from gitlab.com)

marked as related to #677

marked as related to #677
PlasticDigits commented 2026-08-27 10:54:14 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1191

mentioned in merge request !1191
PlasticDigits commented 2026-08-27 10:54:26 +00:00 (Migrated from gitlab.com)

Implemented on feat/689-protocol-utc-metric-toggle — MR !1191.

Acceptance criteria done in this MR:

  • Volume / Liquidity / Fees buttons; default Volume; default grain Daily
  • Grain works per metric; metric switch keeps grain
  • Volume P668/P652 path intact (days=7|30 alias, hide-on-404, axis/tooltip)
  • Liquidity stock last-in-bucket; missing sample null / No liquidity yet / UTC liquidity
  • Fees flow idle 0 / all-unpriced null; UTC fees / No fees yet
  • Trailing tiles unchanged (no 30d liquidity chip; GET /protocol/fees?window= still 24h/7d/30d)
  • Sibling GETs allowlist + 60s cache + EXPLAIN grain tables only
  • Copy ≤ ~5 words; no nested card-glass; no PriceChart; no innerHTML
  • XSS series fields as text
  • Old indexer: volume-only still works; missing sibling hides that plot
  • Docs + P689 skill + make verify-issue-689

Not in this MR (issue listed as optional / later):

  • Live LocalTerra or Coolify paint after indexer migrate
  • Re-running the full prior verify ladder beyond nested verify-issue-668
Implemented on `feat/689-protocol-utc-metric-toggle` — MR !1191. Acceptance criteria done in this MR: - [x] Volume / Liquidity / Fees buttons; default Volume; default grain Daily - [x] Grain works per metric; metric switch keeps grain - [x] Volume P668/P652 path intact (days=7|30 alias, hide-on-404, axis/tooltip) - [x] Liquidity stock last-in-bucket; missing sample null / No liquidity yet / UTC liquidity - [x] Fees flow idle 0 / all-unpriced null; UTC fees / No fees yet - [x] Trailing tiles unchanged (no 30d liquidity chip; GET /protocol/fees?window= still 24h/7d/30d) - [x] Sibling GETs allowlist + 60s cache + EXPLAIN grain tables only - [x] Copy ≤ ~5 words; no nested card-glass; no PriceChart; no innerHTML - [x] XSS series fields as text - [x] Old indexer: volume-only still works; missing sibling hides that plot - [x] Docs + P689 skill + make verify-issue-689 Not in this MR (issue listed as optional / later): - Live LocalTerra or Coolify paint after indexer migrate - Re-running the full prior verify ladder beyond nested verify-issue-668
PlasticDigits commented 2026-08-27 11:03:29 +00:00 (Migrated from gitlab.com)

mentioned in commit f7b8c8e85d

mentioned in commit f7b8c8e85d94ea33b1bf676aba0319f95cd7d12c
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-27 11:03:32 +00:00
PlasticDigits commented 2026-08-29 08:04:46 +00:00 (Migrated from gitlab.com)

mentioned in issue #703

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

marked as related to #703

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