opus / engineering / pr-184
merged · live in prod 2026-05-13
spec-001/ spec-002/ supabase rls & architecture

Two access lanes, one DTO contract, /liquidations off the Starknet fan-out.

Every Supabase read in opus_frontend is now classified into one of two enforced lanes, with a DTO mapping rule that prevents silent zero-coercion of missing values. The /liquidations page is migrated from N parallel client-side Starknet reads to a single server-side Supabase RPC.

Access lanes defined 0 aggregate-server · rls-user
DTO contracts frozen 0 aggregate · user · mixed · paginated
Observability signals 0 zero-value · empty-hydration · lane misuse ×2 · unbounded
/liquidations chain reads 0% 17958 StarkNet RPC requests per page load

↳ the incident this closes

The staking page silently shipped 0 TVL and 0% APY because a missing data source was zero-coerced into the UI instead of surfacing as empty. The architecture work makes that class of silent degradation impossible to ship without an observability signal firing — the root fix lives in the DTO contract, not in any one mapper.

↳ invariants now enforced

  • No zero-coercionnull + sourceStatus: "empty", never 0
  • Server-complete payload — no corrective client fetch
  • Credential boundary — service-role never reaches the browser
  • Bounded initial payload — heavy pages declare a cap
  • Empty ≠ error — fetch failure ≠ "open a trove" CTA

What landed

spec-001 architecture · spec-002 /liquidations

fixes

002·19
Distinguish rows-fetch error from genuine empty /liquidations no longer presents a fetch failure as "No trove yet".
002·17
Propagate offset to rows RPC; derive hasNextPage Pagination cursor was silently capped to page 1.
002·16
Pad owner address to 64-char Starknet form Truncated owner display now matches chain-derived rendering.
002·15
Scale shrine units; show ever-opened trove count + total TVL Cairo Ray/Wad scaled server-side; "482 troves" from MAX(trove_id).
001·tz
Normalize HistoryPointDTO timestamps to ISO datetime Date-only Postgres columns normalize to noon UTC — no TZ-shifted calendar days.
001·apy
Dual-source absorber APY history (v1 + v2) Merge frozen in get_absorber_apy_history(); single-sourced in SQL.
001·xax
ExpandableChart XAxis now receives Date Tooltip x-scale was treating ISO strings as a category.

improvements

001·T1
Two-lane data-access policy aggregate-server-lane for public reads, rls-user-lane for session-bound reads. Mixed-page composition rule.
001·T3
Server-only aggregate Supabase client Service-role credential boundary enforced at the module level.
001·T6
Frozen DTO contracts Aggregate · User · Mixed · Paginated. Raw Supabase rows never cross the mapper.
001·T7
Observability signal catalog 5 named signals at lane/page/DTO scope. Pluggable reporter for prod sinks.
001·st
STALE_TIMES tiers + namespaced query options Per-slice namespaces (staking, save, borrow, wallet activity) on shared primitives.
002·rpc
/liquidations on Supabase RPC get_liquidations_page_rows replaces N useTroveInfo calls. Pre-formatted numerics, DB-side filter of closed troves.
001·tst
Aggregate parity + RLS contract test suites Per-slice parity spec: SSR payload matches client-rendered values.

Before vs after — /liquidations

dev preview · pre-PR-184  ·  prod · PR-184 live
StarkNet RPC requests
179 58
−68% · −121 requests
DOMContentLoaded
12.27s 8.33s
−32% · −3.94 s
Resources fetched
250 129
−48% · −121 fewer

The dev preview is built from a branch that does not contain PR #184, so its /liquidations page still does the legacy client-side Starknet fan-out — one chain read per trove, after JS hydration. Prod runs PR #184 and gets the same rows in a single server-side Supabase RPC. Stat badges agree because both read the same chain state; the cost of getting there is what changed.

dev preview · BEFORE

no PR #184
dev--opus-frontend.netlify.app
dev /liquidations (pre-PR-184)
ttfb
fcp
load
5.20s
5.65s
12.29s
resources
starknet rpc
troves
250
179
482

prod · AFTER

PR #184 live
app.opus.money
prod /liquidations (post-PR-184)
ttfb
fcp
load
8.12s
8.46s
8.46s
resources
starknet rpc
troves
129
58
482

Alchemy RPC cost savings

pay-as-you-go · $0.45 / 1M CU · starknet 20 CU/call
Per page load 2,420 CUs −$0.00109 (179 → 58 calls) × 20 CU
Per user / year (1 visit/day) −$0.40 365 page loads 883k CUs saved
100 daily users · 1 visit / day −$39.74/yr $0.109/day · $3.27/month 88.3M CUs saved over 12 months
100 daily users · 5 visits / day −$198.73/yr $0.545/day · $16.34/month 441.6M CUs saved · enters $0.40/M tier

Honest framing: at today's user volume the absolute savings are modest. The value is in the shape of the cost curve — it now scales with server-side requests (1 RPC / page) instead of with active troves on chain (currently 482 → 482 client calls per user). Doubling on-chain activity no longer doubles per-user RPC cost. The same lane policy also retires N-fan-out reads on staking, save, and borrow pages; this section measures only the /liquidations delta we directly captured.

Assumptions & sources
  • Pricing — Alchemy PAYG: $0.45 per 1M CU for the first 300M CUs/month, then $0.40/M (alchemy.com/pricing).
  • CU cost per call — StarkNet JSON-RPC methods uniformly 20 CU (alchemy compute-unit-costs).
  • Request counts — measured via performance.getEntriesByType('resource') on the warm sample of each URL, filtered to RPC hosts (starknet, alchemy, blastapi, lava.build).
  • Scope — direct RPC requests on the /liquidations page only; does not include staking/save/borrow which also see request reductions under PR #184.
  • Not included — websocket subscriptions, cache invalidation refetches, manual user-triggered refreshes.

Timing samples

headless chromium · 2026-05-13 · single sample per state
urlsamplettfbfcpdclloadresources
prodcold8.32 s13.08 s11.38 s14.38 s130
prodwarm8.12 s8.46 s8.33 s8.46 s129
devcold15.88 s17.50 s17.83 s18.39 s250
devwarm5.20 s5.65 s12.27 s12.29 s250

Headline: −68% StarkNet chain requests and −32% time to interactive on /liquidations — measured against the pre-PR-184 dev preview. Cold samples include CDN cold-start. The payoff isn't in TTFB; it's in the shape of the request graph: row data now travels in one server-side Supabase RPC instead of N parallel useTroveInfo chain reads.

Architecture in three lines

↳ lanes

aggregate-server-lane uses the service-role client and is server-only. rls-user-lane uses the anon/auth client with RLS enforced. Mixed pages compose both sections with explicit per-section credential ownership.

↳ DTO contract

Mappers transform raw Supabase rows into slice-owned DTOs. Missing sources surface as sourceStatus: "empty" with value: null. Raw rows do not cross the mapper boundary into page or UI code.

↳ observability

Five named signals at lane, page, and DTO scope: unexpected_zero_aggregate_value, empty_hydration_for_prefetched_query, aggregate_lane_used_in_client_context, rls_lane_elevated_access_exception, unbounded_initial_payload.