feat: /protocol UTC volume chart — USD axis, hover, hourly/daily/monthly, responsive bars #668

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

Summary

/protocol UTC daily vol (protocol-volume-daily-chart) ships unlabeled bars with a 7d / 30d toggle. Retail cannot read a USD scale, hover does not surface a designed Day + USD tooltip, and the window selector is the wrong grain. Bundle into one issue: visible USD value axis, hover/focus tooltip (period + USD), selector Hourly / Daily / Monthly, and viewport-sized bar count. Do not split axis vs tooltip vs grain vs responsive fetch.

Related: #652 (UTC-day series + P652-1–P652-7), #550 (USD census), #576 (trailing tiles ≠ calendar bars), #489 (short copy), #653 (one chrome layer), #631 (Llama daily — wrong shape to N+1).

Current codebase

#652 added an additive UTC-day Protocol volume series. The chart exists; it is not yet a readable time-grain volume chart.

Layer Behavior today
Chart UI ProtocolVolumeDailyChart.tsx: custom SVG (viewBox="0 0 320 96", h-24). Bars only — no Y-axis ticks, no X-axis labels. Height is n / peak * 80. Hosted inside protocol-global-stats (ProtocolGlobalStats.tsx dailyDays state 7 | 30).
Hover Native SVG <title>{utc_day} {formatProtocolUsd}</title> only. No designed tooltip, no keyboard focus, unreliable on touch / in-app browsers.
Selector Tabs 7d / 30d (protocol-volume-daily-7d / -30d). Copy: UTC daily vol / UTC calendar day (trailingWindowCopy.ts PROTOCOL_VOLUME_DAILY_*).
Bar count Always series.length (7 or 30). Fixed viewBox width; not viewport-aware. Phone and desktop get the same 7 or 30 bars.
Client fetch getProtocolVolumeDaily allowlists 7 | 30 only. useProtocolVolumeDailyQuery keys ['indexer-protocol-volume-daily', days]. Hide on 404/501.
Indexer GET GET /api/v1/protocol/volume/daily?days=: allowlist 7 | 30 else 400. 60s cache keyed by days. Newest-last. Idle "0"; activity+unpriced null.
Rollup protocol_daily_volume: UTC day only. Refresh in volume_aggregator.rs via refresh_protocol_daily. Lookback 34d, prune ≥ 35d. No hourly table. No monthly table.
Methodology Same P522-Q catalog as overview total_volume_*_usd (includes gems / wrap / window). Not defillama_daily_stats.
Tests ProtocolPage.test.tsx asserts 7d default, 30d click, hide on 404, XSS text. e2e/protocol-page.spec.ts looks for protocol-volume-daily-7d. indexer_protocol_volume.rs allowlist + idle/unpriced + cache. make verify-issue-652.
Invariants P652-3 / P652-5: days=7|30, default 7d / 30d toggle, no PriceChart, UTC calendar-day copy. P652-4: cap 30 points. These must be extended, not silently dropped.

Why this is needed

  1. Unlabeled bars are not a USD chart. Peak-normalized rectangles with no axis force guesswork. The census page already headlines USD; the series under the volume row must show a visible USD scale.
  2. Native <title> is not hover UX. Retail needs a tooltip that shows the UTC period and the USD amount on pointer and keyboard focus. A browser tooltip is easy to miss and fails on coarse pointers.
  3. 7d / 30d is the wrong control. Those windows already exist as trailing tiles (#576). The chart is a calendar-bucket series. The selector should be Hourly / Daily / Monthly (UTC), not a second copy of 7d/30d.
  4. Fixed 7 or 30 bars waste or crush the plot. Phone width cannot hold 30 readable bars; a wide desktop can hold more than 7 hourly/daily points. Bar count must follow plot width, with a capped fetch so the API cannot be used as a dump.

Constraints / guardrails

  1. USD axis interpretation. The report asks for a horizontal axis in USD. Keep the existing vertical time-series bars. The value axis is USD (formatProtocolUsd ticks — never Inf / NaN / raw uluna). The category axis stays time (UTC hour / day / month). Do not rotate into a horizontal bar chart (that fights time grain). Do not put USD on the category axis.
  2. Keep P550 / P652 / P569 / #576. Trailing Last 24h / 7d / 30d vol tiles stay. Chart is additive calendar grain, not a replacement. Do not lecture in the Global stats lead.
  3. GET stays O(1) rollup. Do not SUM swap_events on the request path. Do not N+1 GET /defillama/daily. Do not add Llama from / to. OVERVIEW_GLOBAL_STATS_LIVE=1 still must not 60d-SUM volume.
  4. Allowlist query params. Grain hourly | daily | monthly only. Point count is a capped integer allowlist (see Recommended). Unknown / injection / arrays / negatives → 400. No free-form from / to dates. No ?ticker=.
  5. Keep days=7\|30 as a daily alias so make verify-issue-652 stays green. When grain is omitted and days is 7 or 30, behavior stays UTC-day newest-last (P652-3). New clients use grain + limit.
  6. Same methodology as #652. Protocol catalog (gems / wrap / window included). Idle bucket "0"; activity + unpriced null (do not invent $0 then a fake bar). Missing rollup row → idle "0". Newest-last.
  7. Retention must support the grain. Daily prune ≥ 35d is not enough for a useful Monthly view. Hourly needs an hourly table (do not ask GET to bucket swap_events). Monthly needs a monthly rollup (or daily history long enough to fill the capped monthly window — prefer a monthly table so GET stays one-row-per-bucket).
  8. Series caps (DoS). Suggested maxima (document in skill + invariants): hourly 168, daily 90, monthly 24. Frontend must not request above the grain max. Cache 60s keyed by allowlisted (grain, limit) (and the days alias).
  9. Do not mount PriceChart / lightweight-charts (P652-5). No trading overlay, no pair candles, no TV attribution on this census chart.
  10. Chrome / copy. Stay inside protocol-global-stats. Do not wrap the plot in a nested card-glass grid (C653). Labels ≤ ~5 words (#489): Hourly / Daily / Monthly. Subtitle must name the UTC bucket (hour / calendar day / calendar month). No unique-trader headline.
  11. Tooltip is text, not HTML. Period + formatProtocolUsd. Unpriced → em-dash. XSS / javascript: / "><script> in utc_day or volume_usd render as text. No innerHTML. No eval.
  12. Responsive count is clamped. Derive limit from plot width (ResizeObserver), then clamp to grain [min, max]. Do not refetch on every pixel; debounce / snap to a small step so resize is not a request flood.
  13. Old indexer. Missing grain route / 404 / 501 → hide chart; tiles still render (P652-6). Additive JSON.
  14. Advisory USD. Reference catalog, not settlement / TWAP / /ust1 window.
  15. Update invariants. Extend P652-3 / P652-5 (or add P6xx on the same skill). docs/frontend.md § Protocol, docs/indexer-invariants.md, docs/runbooks/overview-global-stats-brin.md. New make verify-issue-*. Keep verify-issue-652 / 550 / 569 / 586 / 576 / 577 / 631 / 653 green.

Relevant files

Path Role
frontend-dapp/src/components/protocol/ProtocolVolumeDailyChart.tsx USD axis, tooltip, grain tabs, responsive bars
frontend-dapp/src/components/protocol/useProtocolVolumeDailyQuery.ts Query key includes grain + limit
frontend-dapp/src/components/protocol/ProtocolGlobalStats.tsx Host; drop 7 | 30 days state
frontend-dapp/src/utils/formatProtocolStats.ts Axis + tooltip USD (never Inf)
frontend-dapp/src/utils/trailingWindowCopy.ts Grain labels + UTC titles
frontend-dapp/src/services/indexer/client.ts Allowlisted grain + limit (keep days helper for tests)
frontend-dapp/src/types/index.ts Additive grain / period fields
frontend-dapp/src/pages/ProtocolPage.test.tsx Axis, tooltip, grain, hide-on-404, XSS
frontend-dapp/e2e/protocol-page.spec.ts Grain tabs + chart still inside Global stats
indexer/src/api/protocol_volume.rs Grain + limit; keep days=7|30 alias
indexer/src/db/queries/protocol_volume.rs Hourly / daily / monthly rollup reads
indexer/src/indexer/volume_aggregator.rs Refresh + prune new tables
New migration (protocol_hourly_volume, protocol_monthly_volume; daily retain if needed) Materialize buckets; GET reads tables only
indexer/tests/indexer_protocol_volume.rs Allowlist, idle/unpriced, cache, EXPLAIN
skills/AGENTS_FRONTEND_PROTOCOL_STATS.md Extend P652 / add grain invariants
docs/frontend.md § Protocol Chart UX
docs/indexer-invariants.md GET DoS + grain allowlist
docs/runbooks/overview-global-stats-brin.md Rollup note
scripts/qa/verify-issue-652.sh Must stay green (daily alias)
  1. Indexer — rollups. Add protocol_hourly_volume (UTC hour, prune ~8–10d) and protocol_monthly_volume (UTC calendar month, keep ≥ 24 months). Refresh in the volume aggregator with the same P522-Q / idle / unpriced rules as daily. Do not scan swap_events on GET. Extend daily retain only if Monthly is derived from daily rows; prefer a monthly table.
  2. Indexer — GET. Keep GET /api/v1/protocol/volume/daily. Accept grain=hourly\|daily\|monthly and limit= (integer, grain-capped). Omit grain + days=7\|30 → current daily series (alias). Response: grain, limit, timezone: "UTC", methodology: "protocol_catalog", series[] with a stable period key (utc_hour / utc_day / utc_month as YYYY-MM-DDTHH / YYYY-MM-DD / YYYY-MM) + volume_usd + trade_count. 60s cache. EXPLAIN: no swap_events.
  3. Frontend — grain + width. Replace 7d/30d with Hourly / Daily / Monthly (default Daily). ResizeObserver on the plot: limit = clamp(floor(width / barSlotPx), min, grainMax) (suggest bar slot ~10–14px including gap). Debounce. Query key includes grain + limit.
  4. Frontend — USD axis + tooltip. SVG (or equivalent, not PriceChart): 3–5 Y ticks via formatProtocolUsd including $0 baseline; sparse X time labels (do not label every hourly bar). Pointer + keyboard tooltip: period + USD (or em-dash). Peak 0 → flat baseline, no divide-by-zero. Unpriced = outlined / empty bar, not $0.
  5. Docs + verify. New make verify-issue-*. Rewrite P652-5 selector language. Keep 652 daily-alias tests.

Acceptance criteria

  • Chart shows a visible USD value axis (formatProtocolUsd ticks). Bars are still a UTC time series (not a horizontal-bar USD category chart).
  • Hover and keyboard focus on a bar shows UTC period + USD (unpriced → em-dash). Not SVG <title>-only.
  • Selector is Hourly / Daily / Monthly (not 7d / 30d). Default Daily. Trailing 24h/7d/30d tiles unchanged.
  • Bar count follows plot width and is clamped per grain (hourly ≤ 168, daily ≤ 90, monthly ≤ 24). Phone (~375px) does not render an unreadable 30-bar crush; wide desktop requests more than 7 when width allows.
  • GET grain + limit allowlisted; injection / missing / over-max → 400. days=7\|30 without grain still returns the #652 daily series.
  • GET reads rollup tables only (60s cache). No swap_events SUM. No Llama. Idle "0"; unpriced null.
  • Hourly / monthly data exist after aggregator refresh; --fresh / young indexer shows idle zeros / available months, not a GET-path backfill.
  • 404/501 hides chart; Global stats tiles still render. No PriceChart. No nested card-glass around the plot.
  • XSS strings in period / volume_usd render as text. Axis / tooltip never Infinity.
  • Light + dark; 375px and 1280px: axis ticks readable, no overflow. Skills + invariants + make verify-issue-* for this ticket. verify-issue-652 / 550 / 569 / 586 / 576 / 631 / 653 still pass.

Test plan (all paths)

Indexer — allowlist + cache

  • grain=daily&limit=14 → 14 newest UTC days, newest-last, methodology=protocol_catalog.
  • days=7 and days=30 with no grain → same as #652 (len 7 / 30).
  • Bad: grain=week, grain=daily%3b, grain[]=, limit=-1, limit=999999, limit=, from= / to=, days=90 without valid grain → 400.
  • Hourly / monthly same allowlist matrix at their max.
  • 60s cache: second GET does not re-read after a mid-TTL rollup mutate (same pattern as #652 cache test).
  • EXPLAIN / grep: GET handler has no FROM swap_events.

Indexer — methodology

  • Idle hour/day/month → "0", trade_count=0.
  • Activity + all unpriced → volume_usd JSON null, trade_count>0.
  • Missing rollup row in window → filled idle "0".
  • Priced gems / wrap / window swaps count (Protocol catalog, not Llama exclude).
  • Hourly bucket = [hour, hour+1) UTC. Monthly = UTC calendar month, not trailing 30d.
  • Prune: hourly older than retain window gone; monthly retain ≥ 24 months; daily alias still works after prune.

Frontend — axis, tooltip, grain, width

  • Daily default: USD ticks present ($ / em-dash), time labels present, no 7d / 30d tabs.
  • Click Hourly / Monthly: query uses that grain; subtitle / aria-label names the UTC bucket.
  • Tooltip on pointer enter and on keyboard focus: period + formatProtocolUsd; unpriced —.
  • Narrow width (mock ~320–375px): limit at grain min; bars do not overflow.
  • Wide width (mock ~1280px): limit > phone count and ≤ grain max.
  • Resize debounce: rapid width changes do not fire one fetch per pixel.
  • Peak 0 / all idle: $0 baseline, no crash.
  • Loading skeleton; error + retry; empty copy when series all-null.
  • 404/501: chart hidden; volume tiles remain.
  • PriceChart / price-chart testid absent. No card-glass wrapper on the plot.
  • Light + dark smoke (existing Protocol page coverage).
  • e2e: chart inside protocol-global-stats; grain tabs visible when endpoint exists.

Copy / a11y

  • Visible labels ≤ ~5 words. aria-label includes UTC grain so mobile is not hover-only.
  • Trailing tile titles still say not-a-midnight-reset (#576). Chart title does not claim to be Last 24h/7d/30d.

Test plan (attack, hack, and abuse)

API / DoS

  • limit=999999, limit=1e308, overflow strings, repeated limit → 400, no huge series, no GET swap_events scan.
  • grain=hourly&from=1970-01-01&to=2099-12-31 ignored or 400 (no unbounded range dump).
  • Cache key ignores unknown extra query junk so attackers cannot bust cache with ?foo=1..n.
  • Concurrent GET during aggregator refresh: no 500 from empty/partial fill; series still capped.
  • SQL injection in grain / limit / days (7;, 7%3b, daily' OR 1=1) → 400, parameterized reads only.

Spoofed / hostile payload (XSS, prototype)

  • volume_usd / period = "><script>alert(1)</script>, javascript:alert(1), {{constructor}} render as text in axis, tooltip, and aria-label. No script node. No javascript: navigation.
  • Infinity / NaN / 1e309 → em-dash on axis and tooltip (same as formatProtocolUsd).
  • Hostile methodology / timezone strings are not executed; page does not mount third-party scripts from JSON.

Semantic abuse (fake volume)

  • Unpriced wash activity cannot become $0 bars that look like a crash to $0 (stay null / outlined).
  • Chart must not silently show Llama gem-exclude as Protocol volume (no GET /defillama/daily from /protocol).
  • Unique-trader counts stay off the chart (dust-swap gaming — #550 / #489).

Client fetch abuse

  • getProtocolVolumeDaily / new helper rejects non-allowlisted grain/limit before fetch (same as today’s days guard).
  • ResizeObserver loop cannot issue unbounded parallel requests (debounce + in-flight reuse).

Chrome / clickjack-adjacent

  • Tooltip is in-document text (no javascript: URL, no external iframe). Chart stays inside Global stats; no second chrome layer that could overlay wallet CTAs.

Verification criteria

  • make verify-issue-<this> (new script): skill invariants, allowlist grep, no Llama N+1 from Protocol chart, no GET swap_events, frontend grain/axis/tooltip tests, indexer allowlist + idle/unpriced + cache.
  • make verify-issue-652 still passes (days=7\|30 alias + hide-on-404 + XSS).
  • make verify-issue-550 / 569 / 586 / 576 / 577 / 631 / 653 green.
  • Manual / e2e: /protocol Global stats → chart has USD ticks; hover a bar → day/hour/month + $…; switch Hourly / Daily / Monthly; 375px vs 1280px bar counts differ and stay readable.
  • Docs: AGENTS_FRONTEND_PROTOCOL_STATS.md, docs/frontend.md, docs/indexer-invariants.md, AGENTS.md verify target.
## Summary `/protocol` **UTC daily vol** (`protocol-volume-daily-chart`) ships unlabeled bars with a `7d` / `30d` toggle. Retail cannot read a USD scale, hover does not surface a designed Day + USD tooltip, and the window selector is the wrong grain. Bundle into **one** issue: visible **USD value axis**, **hover/focus tooltip** (period + USD), selector **Hourly / Daily / Monthly**, and **viewport-sized bar count**. Do **not** split axis vs tooltip vs grain vs responsive fetch. Related: [#652](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/652) (UTC-day series + `P652-1–P652-7`), [#550](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/550) (USD census), [#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** shape to N+1). ## Current codebase [#652](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/652) added an additive UTC-day Protocol volume series. The chart exists; it is not yet a readable time-grain volume chart. | Layer | Behavior today | |-------|----------------| | **Chart UI** | [`ProtocolVolumeDailyChart.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/protocol/ProtocolVolumeDailyChart.tsx): custom SVG (`viewBox="0 0 320 96"`, `h-24`). Bars only — **no Y-axis ticks, no X-axis labels**. Height is `n / peak * 80`. Hosted inside `protocol-global-stats` ([`ProtocolGlobalStats.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/protocol/ProtocolGlobalStats.tsx) `dailyDays` state `7 \| 30`). | | **Hover** | Native SVG `<title>{utc_day} {formatProtocolUsd}</title>` only. No designed tooltip, no keyboard focus, unreliable on touch / in-app browsers. | | **Selector** | Tabs `7d` / `30d` (`protocol-volume-daily-7d` / `-30d`). Copy: **UTC daily vol** / **UTC calendar day** ([`trailingWindowCopy.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/trailingWindowCopy.ts) `PROTOCOL_VOLUME_DAILY_*`). | | **Bar count** | Always `series.length` (7 or 30). Fixed `viewBox` width; **not** viewport-aware. Phone and desktop get the same 7 or 30 bars. | | **Client fetch** | [`getProtocolVolumeDaily`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/indexer/client.ts) allowlists `7 \| 30` only. [`useProtocolVolumeDailyQuery`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/protocol/useProtocolVolumeDailyQuery.ts) keys `['indexer-protocol-volume-daily', days]`. Hide on 404/501. | | **Indexer GET** | [`GET /api/v1/protocol/volume/daily?days=`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/api/protocol_volume.rs): allowlist `7` \| `30` else **400**. 60s cache keyed by `days`. Newest-last. Idle `"0"`; activity+unpriced `null`. | | **Rollup** | [`protocol_daily_volume`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/migrations/20260826120000_protocol_volume_change_and_daily.sql): UTC **day** only. Refresh in [`volume_aggregator.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/indexer/volume_aggregator.rs) via [`refresh_protocol_daily`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/db/queries/protocol_volume.rs). Lookback 34d, **prune ≥ 35d**. No hourly table. No monthly table. | | **Methodology** | Same P522-Q catalog as overview `total_volume_*_usd` (includes gems / wrap / window). **Not** `defillama_daily_stats`. | | **Tests** | [`ProtocolPage.test.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/pages/ProtocolPage.test.tsx) asserts 7d default, 30d click, hide on 404, XSS text. [`e2e/protocol-page.spec.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/e2e/protocol-page.spec.ts) looks for `protocol-volume-daily-7d`. [`indexer_protocol_volume.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/indexer_protocol_volume.rs) allowlist + idle/unpriced + cache. `make verify-issue-652`. | | **Invariants** | **P652-3 / P652-5**: `days=7\|30`, default 7d / 30d toggle, no `PriceChart`, UTC calendar-day copy. **P652-4**: cap 30 points. These must be **extended**, not silently dropped. | ## Why this is needed 1. **Unlabeled bars are not a USD chart.** Peak-normalized rectangles with no axis force guesswork. The census page already headlines USD; the series under the volume row must show a **visible USD scale**. 2. **Native `<title>` is not hover UX.** Retail needs a tooltip that shows the **UTC period** and the **USD amount** on pointer and keyboard focus. A browser tooltip is easy to miss and fails on coarse pointers. 3. **`7d` / `30d` is the wrong control.** Those windows already exist as **trailing** tiles ([#576](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/576)). The chart is a **calendar-bucket** series. The selector should be **Hourly / Daily / Monthly** (UTC), not a second copy of 7d/30d. 4. **Fixed 7 or 30 bars waste or crush the plot.** Phone width cannot hold 30 readable bars; a wide desktop can hold more than 7 hourly/daily points. Bar count must follow **plot width**, with a **capped** fetch so the API cannot be used as a dump. ## Constraints / guardrails 1. **USD axis interpretation.** The report asks for a **horizontal axis in USD**. Keep the existing **vertical time-series bars**. The **value axis is USD** (`formatProtocolUsd` ticks — never Inf / `NaN` / raw `uluna`). The **category axis stays time** (UTC hour / day / month). Do **not** rotate into a horizontal bar chart (that fights time grain). Do **not** put USD on the category axis. 2. **Keep P550 / P652 / P569 / #576.** Trailing Last 24h / 7d / 30d vol tiles stay. Chart is additive calendar grain, not a replacement. Do not lecture in the Global stats lead. 3. **GET stays O(1) rollup.** Do **not** `SUM` `swap_events` on the request path. Do **not** N+1 `GET /defillama/daily`. Do **not** add Llama `from` / `to`. `OVERVIEW_GLOBAL_STATS_LIVE=1` still must not 60d-SUM volume. 4. **Allowlist query params.** Grain `hourly` \| `daily` \| `monthly` only. Point count is a **capped integer allowlist** (see Recommended). Unknown / injection / arrays / negatives → **400**. No free-form `from` / `to` dates. No `?ticker=`. 5. **Keep `days=7\|30` as a daily alias** so `make verify-issue-652` stays green. When `grain` is omitted and `days` is `7` or `30`, behavior stays UTC-day newest-last (P652-3). New clients use `grain` + `limit`. 6. **Same methodology as #652.** Protocol catalog (gems / wrap / window included). Idle bucket `"0"`; activity + unpriced `null` (do not invent `$0` then a fake bar). Missing rollup row → idle `"0"`. Newest-last. 7. **Retention must support the grain.** Daily prune ≥ 35d is **not** enough for a useful Monthly view. Hourly needs an hourly table (do not ask GET to bucket `swap_events`). Monthly needs a monthly rollup (or daily history long enough to fill the capped monthly window — prefer a monthly table so GET stays one-row-per-bucket). 8. **Series caps (DoS).** Suggested maxima (document in skill + invariants): hourly **168**, daily **90**, monthly **24**. Frontend must not request above the grain max. Cache 60s keyed by allowlisted `(grain, limit)` (and the `days` alias). 9. **Do not mount `PriceChart` / lightweight-charts** (P652-5). No trading overlay, no pair candles, no TV attribution on this census chart. 10. **Chrome / copy.** Stay inside `protocol-global-stats`. Do **not** wrap the plot in a nested `card-glass` grid (C653). Labels ≤ ~5 words (#489): **Hourly / Daily / Monthly**. Subtitle must name the UTC bucket (hour / calendar day / calendar month). No unique-trader headline. 11. **Tooltip is text, not HTML.** Period + `formatProtocolUsd`. Unpriced → em-dash. XSS / `javascript:` / `"><script>` in `utc_day` or `volume_usd` render as **text**. No `innerHTML`. No `eval`. 12. **Responsive count is clamped.** Derive `limit` from plot width (ResizeObserver), then clamp to grain `[min, max]`. Do not refetch on every pixel; debounce / snap to a small step so resize is not a request flood. 13. **Old indexer.** Missing grain route / 404 / 501 → hide chart; tiles still render (P652-6). Additive JSON. 14. **Advisory USD.** Reference catalog, not settlement / TWAP / `/ust1` window. 15. **Update invariants.** Extend **P652-3 / P652-5** (or add **P6xx** on the same skill). `docs/frontend.md` § Protocol, `docs/indexer-invariants.md`, `docs/runbooks/overview-global-stats-brin.md`. New `make verify-issue-*`. Keep `verify-issue-652` / `550` / `569` / `586` / `576` / `577` / `631` / `653` 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) | USD axis, tooltip, grain tabs, responsive bars | | [`frontend-dapp/src/components/protocol/useProtocolVolumeDailyQuery.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/protocol/useProtocolVolumeDailyQuery.ts) | Query key includes 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; drop `7 \| 30` days state | | [`frontend-dapp/src/utils/formatProtocolStats.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/formatProtocolStats.ts) | Axis + tooltip USD (never Inf) | | [`frontend-dapp/src/utils/trailingWindowCopy.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/trailingWindowCopy.ts) | Grain labels + UTC titles | | [`frontend-dapp/src/services/indexer/client.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/indexer/client.ts) | Allowlisted grain + limit (keep `days` helper for tests) | | [`frontend-dapp/src/types/index.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/types/index.ts) | Additive grain / period fields | | [`frontend-dapp/src/pages/ProtocolPage.test.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/pages/ProtocolPage.test.tsx) | Axis, tooltip, grain, hide-on-404, XSS | | [`frontend-dapp/e2e/protocol-page.spec.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/e2e/protocol-page.spec.ts) | Grain tabs + chart still inside Global stats | | [`indexer/src/api/protocol_volume.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/api/protocol_volume.rs) | Grain + limit; keep `days=7\|30` alias | | [`indexer/src/db/queries/protocol_volume.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/db/queries/protocol_volume.rs) | Hourly / daily / monthly rollup reads | | [`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 | | New migration (`protocol_hourly_volume`, `protocol_monthly_volume`; daily retain if needed) | Materialize buckets; GET reads tables only | | [`indexer/tests/indexer_protocol_volume.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/indexer_protocol_volume.rs) | Allowlist, idle/unpriced, cache, EXPLAIN | | [`skills/AGENTS_FRONTEND_PROTOCOL_STATS.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/skills/AGENTS_FRONTEND_PROTOCOL_STATS.md) | Extend P652 / add grain invariants | | [`docs/frontend.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/frontend.md) § Protocol | Chart UX | | [`docs/indexer-invariants.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/indexer-invariants.md) | GET DoS + grain allowlist | | [`docs/runbooks/overview-global-stats-brin.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/runbooks/overview-global-stats-brin.md) | Rollup note | | [`scripts/qa/verify-issue-652.sh`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/scripts/qa/verify-issue-652.sh) | Must stay green (daily alias) | ## Recommended direction 1. **Indexer — rollups.** Add `protocol_hourly_volume` (UTC hour, prune ~8–10d) and `protocol_monthly_volume` (UTC calendar month, keep ≥ 24 months). Refresh in the volume aggregator with the same P522-Q / idle / unpriced rules as daily. Do not scan `swap_events` on GET. Extend daily retain only if Monthly is derived from daily rows; prefer a monthly table. 2. **Indexer — GET.** Keep `GET /api/v1/protocol/volume/daily`. Accept `grain=hourly\|daily\|monthly` and `limit=` (integer, grain-capped). Omit `grain` + `days=7\|30` → current daily series (alias). Response: `grain`, `limit`, `timezone: "UTC"`, `methodology: "protocol_catalog"`, `series[]` with a stable period key (`utc_hour` / `utc_day` / `utc_month` as `YYYY-MM-DDTHH` / `YYYY-MM-DD` / `YYYY-MM`) + `volume_usd` + `trade_count`. 60s cache. EXPLAIN: no `swap_events`. 3. **Frontend — grain + width.** Replace 7d/30d with Hourly / Daily / Monthly (default **Daily**). `ResizeObserver` on the plot: `limit = clamp(floor(width / barSlotPx), min, grainMax)` (suggest bar slot ~10–14px including gap). Debounce. Query key includes grain + limit. 4. **Frontend — USD axis + tooltip.** SVG (or equivalent, **not** `PriceChart`): 3–5 Y ticks via `formatProtocolUsd` including `$0` baseline; sparse X time labels (do not label every hourly bar). Pointer + keyboard tooltip: period + USD (or em-dash). Peak `0` → flat baseline, no divide-by-zero. Unpriced = outlined / empty bar, not `$0`. 5. **Docs + verify.** New `make verify-issue-*`. Rewrite P652-5 selector language. Keep 652 daily-alias tests. ## Acceptance criteria - [ ] Chart shows a visible **USD value axis** (`formatProtocolUsd` ticks). Bars are still a UTC time series (not a horizontal-bar USD category chart). - [ ] Hover **and** keyboard focus on a bar shows **UTC period + USD** (unpriced → em-dash). Not SVG `<title>`-only. - [ ] Selector is **Hourly / Daily / Monthly** (not `7d` / `30d`). Default Daily. Trailing 24h/7d/30d tiles unchanged. - [ ] Bar count follows plot width and is clamped per grain (hourly ≤ 168, daily ≤ 90, monthly ≤ 24). Phone (~375px) does not render an unreadable 30-bar crush; wide desktop requests more than 7 when width allows. - [ ] `GET` grain + limit allowlisted; injection / missing / over-max → **400**. `days=7\|30` without `grain` still returns the #652 daily series. - [ ] GET reads rollup tables only (60s cache). No `swap_events` SUM. No Llama. Idle `"0"`; unpriced `null`. - [ ] Hourly / monthly data exist after aggregator refresh; `--fresh` / young indexer shows idle zeros / available months, not a GET-path backfill. - [ ] 404/501 hides chart; Global stats tiles still render. No `PriceChart`. No nested `card-glass` around the plot. - [ ] XSS strings in period / `volume_usd` render as text. Axis / tooltip never `Infinity`. - [ ] Light + dark; 375px and 1280px: axis ticks readable, no overflow. Skills + invariants + `make verify-issue-*` for this ticket. `verify-issue-652` / `550` / `569` / `586` / `576` / `631` / `653` still pass. ## Test plan (all paths) ### Indexer — allowlist + cache - [ ] `grain=daily&limit=14` → 14 newest UTC days, newest-last, `methodology=protocol_catalog`. - [ ] `days=7` and `days=30` with no `grain` → same as #652 (len 7 / 30). - [ ] Bad: `grain=week`, `grain=daily%3b`, `grain[]=`, `limit=-1`, `limit=999999`, `limit=`, `from=` / `to=`, `days=90` without valid grain → **400**. - [ ] Hourly / monthly same allowlist matrix at their max. - [ ] 60s cache: second GET does not re-read after a mid-TTL rollup mutate (same pattern as #652 cache test). - [ ] EXPLAIN / grep: GET handler has no `FROM swap_events`. ### Indexer — methodology - [ ] Idle hour/day/month → `"0"`, `trade_count=0`. - [ ] Activity + all unpriced → `volume_usd` JSON `null`, `trade_count>0`. - [ ] Missing rollup row in window → filled idle `"0"`. - [ ] Priced gems / wrap / window swaps **count** (Protocol catalog, not Llama exclude). - [ ] Hourly bucket = `[hour, hour+1)` UTC. Monthly = UTC calendar month, not trailing 30d. - [ ] Prune: hourly older than retain window gone; monthly retain ≥ 24 months; daily alias still works after prune. ### Frontend — axis, tooltip, grain, width - [ ] Daily default: USD ticks present (`$` / em-dash), time labels present, no `7d` / `30d` tabs. - [ ] Click Hourly / Monthly: query uses that grain; subtitle / `aria-label` names the UTC bucket. - [ ] Tooltip on pointer enter and on keyboard focus: period + `formatProtocolUsd`; unpriced `—`. - [ ] Narrow width (mock ~320–375px): `limit` at grain min; bars do not overflow. - [ ] Wide width (mock ~1280px): `limit` > phone count and ≤ grain max. - [ ] Resize debounce: rapid width changes do not fire one fetch per pixel. - [ ] Peak 0 / all idle: `$0` baseline, no crash. - [ ] Loading skeleton; error + retry; empty copy when series all-null. - [ ] 404/501: chart hidden; volume tiles remain. - [ ] `PriceChart` / `price-chart` testid absent. No `card-glass` wrapper on the plot. - [ ] Light + dark smoke (existing Protocol page coverage). - [ ] e2e: chart inside `protocol-global-stats`; grain tabs visible when endpoint exists. ### Copy / a11y - [ ] Visible labels ≤ ~5 words. `aria-label` includes UTC grain so mobile is not hover-only. - [ ] Trailing tile titles still say not-a-midnight-reset (#576). Chart title does not claim to be Last 24h/7d/30d. ## Test plan (attack, hack, and abuse) ### API / DoS - [ ] `limit=999999`, `limit=1e308`, overflow strings, repeated `limit` → **400**, no huge series, no GET `swap_events` scan. - [ ] `grain=hourly&from=1970-01-01&to=2099-12-31` ignored or **400** (no unbounded range dump). - [ ] Cache key ignores unknown extra query junk so attackers cannot bust cache with `?foo=1..n`. - [ ] Concurrent GET during aggregator refresh: no 500 from empty/partial fill; series still capped. - [ ] SQL injection in `grain` / `limit` / `days` (`7;`, `7%3b`, `daily' OR 1=1`) → **400**, parameterized reads only. ### Spoofed / hostile payload (XSS, prototype) - [ ] `volume_usd` / period = `"><script>alert(1)</script>`, `javascript:alert(1)`, `{{constructor}}` render as **text** in axis, tooltip, and `aria-label`. No `script` node. No `javascript:` navigation. - [ ] `Infinity` / `NaN` / `1e309` → em-dash on axis and tooltip (same as `formatProtocolUsd`). - [ ] Hostile `methodology` / `timezone` strings are not executed; page does not mount third-party scripts from JSON. ### Semantic abuse (fake volume) - [ ] Unpriced wash activity cannot become `$0` bars that look like a crash to `$0` (stay `null` / outlined). - [ ] Chart must not silently show Llama gem-exclude as Protocol volume (no `GET /defillama/daily` from `/protocol`). - [ ] Unique-trader counts stay off the chart (dust-swap gaming — #550 / #489). ### Client fetch abuse - [ ] `getProtocolVolumeDaily` / new helper rejects non-allowlisted grain/limit **before** fetch (same as today’s `days` guard). - [ ] ResizeObserver loop cannot issue unbounded parallel requests (debounce + in-flight reuse). ### Chrome / clickjack-adjacent - [ ] Tooltip is in-document text (no `javascript:` URL, no external iframe). Chart stays inside Global stats; no second chrome layer that could overlay wallet CTAs. ## Verification criteria - [ ] `make verify-issue-<this>` (new script): skill invariants, allowlist grep, no Llama N+1 from Protocol chart, no GET `swap_events`, frontend grain/axis/tooltip tests, indexer allowlist + idle/unpriced + cache. - [ ] `make verify-issue-652` still passes (`days=7\|30` alias + hide-on-404 + XSS). - [ ] `make verify-issue-550` / `569` / `586` / `576` / `577` / `631` / `653` green. - [ ] Manual / e2e: `/protocol` Global stats → chart has USD ticks; hover a bar → day/hour/month + `$…`; switch Hourly / Daily / Monthly; 375px vs 1280px bar counts differ and stay readable. - [ ] Docs: `AGENTS_FRONTEND_PROTOCOL_STATS.md`, `docs/frontend.md`, `docs/indexer-invariants.md`, `AGENTS.md` verify target.
PlasticDigits commented 2026-08-26 04:19:22 +00:00 (Migrated from gitlab.com)

marked as related to #652

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

mentioned in commit 2658686220

mentioned in commit 26586862203cd595bbd5e6515288cf0fb13f8a77
PlasticDigits commented 2026-08-26 07:06:00 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1163

mentioned in merge request !1163
PlasticDigits commented 2026-08-26 07:06:46 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1164

mentioned in merge request !1164
PlasticDigits commented 2026-08-26 07:07:09 +00:00 (Migrated from gitlab.com)

Implementation is in !1164 (feat/668-protocol-volume-chart).

Done in this change: USD value axis, pointer+keyboard tooltip, Hourly/Daily/Monthly (default Daily), viewport-clamped bar count, GET grain+limit allowlist with days=7|30 alias, hourly/monthly rollup tables, hide on 404/501, XSS as text, P668-1–P668-8, make verify-issue-668. make verify-issue-652 still passes.

Not done here: live Chrome light/dark at 375px vs 1280px; related verify-issue-550 / 569 / 586 / 576 / 577 / 631 / 653 ladder; Coolify migrate of the new rollup tables on deploy.

Implementation is in !1164 (`feat/668-protocol-volume-chart`). Done in this change: USD value axis, pointer+keyboard tooltip, Hourly/Daily/Monthly (default Daily), viewport-clamped bar count, GET `grain`+`limit` allowlist with `days=7|30` alias, hourly/monthly rollup tables, hide on 404/501, XSS as text, **P668-1–P668-8**, `make verify-issue-668`. `make verify-issue-652` still passes. Not done here: live Chrome light/dark at 375px vs 1280px; related `verify-issue-550` / `569` / `586` / `576` / `577` / `631` / `653` ladder; Coolify migrate of the new rollup tables on deploy.
PlasticDigits commented 2026-08-26 08:19:07 +00:00 (Migrated from gitlab.com)

mentioned in commit c9cf7106b6

mentioned in commit c9cf7106b63dd27f2531fa01c9a29dc46f7c47f0
PlasticDigits commented 2026-08-26 08:19:25 +00:00 (Migrated from gitlab.com)

mentioned in commit eb0238d68e

mentioned in commit eb0238d68e756ab3b3cfeee332adb8902572a6ab
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-26 08:19:25 +00:00
PlasticDigits commented 2026-08-26 09:30:52 +00:00 (Migrated from gitlab.com)

mentioned in issue #667

mentioned in issue #667
PlasticDigits commented 2026-08-26 09:30:58 +00:00 (Migrated from gitlab.com)

Merged to main via !1164. Playwright --open false kept. Docs merged with #667 Δ% prose.

Leftover: Coolify must apply indexer migration 20260826180000_protocol_volume_hourly_monthly.sql and redeploy indexer, then frontend rebuild. Confirm Hourly/Daily/Monthly on /protocol. make verify-issue-668.

Merged to `main` via !1164. Playwright `--open false` kept. Docs merged with #667 Δ% prose. Leftover: Coolify must apply indexer migration `20260826180000_protocol_volume_hourly_monthly.sql` and redeploy indexer, then frontend rebuild. Confirm Hourly/Daily/Monthly on `/protocol`. `make verify-issue-668`.
PlasticDigits commented 2026-08-26 09:31:35 +00:00 (Migrated from gitlab.com)

mentioned in issue #673

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

mentioned in issue #677

mentioned in issue #677
PlasticDigits commented 2026-08-27 00:17:42 +00:00 (Migrated from gitlab.com)

marked as related to #677

marked as related to #677
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:10 +00:00 (Migrated from gitlab.com)

marked as related to #689

marked as related to #689
PlasticDigits commented 2026-08-28 05:22:10 +00:00 (Migrated from gitlab.com)

mentioned in issue #692

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

mentioned in issue #703

mentioned in issue #703
PlasticDigits commented 2026-08-29 08:04:47 +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#668
No description provided.