feat(indexer): redacted UTC-day evidence JSON tagged swap/wrap/limit/LP #1205
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:implement
agent:implement
agent:open_issues
agent:ready
agent:research
agent:security_audit
agent:verify
architecture
backend
blocker:hybrid
blocker:launch
blocker:limit-orders
blocker:v2
block:log_only
block:security
bug
ci
contracts
correctness
deploy
dev
devops
docs
documentation
duplicate
e2e
enhancement
epic
feature
frontend
functional-completion
gas
good first issue
governance
help wanted
high-risk
hooks
hybrid
indexer
infra
infrastructure
integrators
invalid
launch-blocker
limit-orders
localnet
localterra
low priority
missing-implementation
needs-design
ops
performance
priority
high
priority
medium
product
qa
QA
question
ready
ready
research
scripts
security
security-hardening
smartcontracts
tech-debt
testing
ux
UX
v2
verification
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-dex-terraclassic#1205
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Add one read-only indexer HTTP export that returns a UTC calendar-day JSON of already-indexed on-chain activity, each row tagged
surface∈ {swap,wrap,limit,lp}, with user bech32 redacted. Research and incident-triage agents today must fan out across pair/trader tapes,/gt/events, and fee rollups, then strip wallets themselves. That is not an export.Bundle (do not split):
GET /api/v1/evidence/daily— requiredday=YYYY-MM-DD(UTC[00:00Z, +1d)), optionalsurface=allowlist, cursor pagination, bounded page size.swapfromswap_events;lpfromliquidity_events;limitfrom placements + cancellations + fills;wrapfromprotocol_fee_eventssource ∈ {wrap, unwrap}.sender,receiver,maker,owner, orprovider. Emit a non-reversibleactor_hashwhen the source row has an actor. Pair/token contracts stay.utoipapath onApiDoc, handler tests for every surface and the abuse matrix below.This is a new business endpoint on the existing ingest. No frontend. No wrap-principal ingest. No CSV. No API keys.
Related (do not merge into this ticket):
tx_succeeded). Off-chain, unredacted by design, not an on-chain tape./gt/events(swap+join/exit, unredactedmaker, block window, 5000-row 400).swap_eventsrow; do not also countlimit_order_fillsasswap.notify_deposit/unwrap) intoprotocol_fee_eventsonly.docs/runbooks/suspicious-activity-queries.mdstays the unredacted incident path.Current codebase
There is no unified event stream, no
surfaceenum, and no address redaction on JSON. Events live in separate tables and pair/trader routes. Daily HTTP exists only as aggregates.Tapes are pair- or trader-scoped and unredacted
swap_eventsGET /api/v1/pairs/{addr}/trades,GET /api/v1/traders/{addr}/tradessender(+ optionalreceiver)liquidity_events(event_typeadd|remove)GET /api/v1/pairs/{addr}/liquidity-eventsproviderlimit_order_placements,limit_order_cancellations,limit_order_fillslimit-placements/limit-cancellations/limit-fillsowner/makerprotocol_fee_events.source=wrap|unwrap/api/v1/protocol/fees, DeFiLlama daily)Router:
indexer/src/api/mod.rsbuild_router. Pair handlers:indexer/src/api/pairs.rs. Trader handlers:indexer/src/api/traders.rs. Rows:indexer/src/db/queries/swap_events.rs(SwapEventRow.sender),liquidity.rs(provider),limit_order_fills.rs(maker).GET /api/v1/traders/{addr}/trades?format=csvis a wallet-scoped history dump with the fullsendercolumn (#163). It is not a protocol-wide daily export and it is not redacted./gt/eventsis a listing adapter, not evidenceindexer/src/api/gt.rs:eventTypeswap|join|exit, fullmaker, inclusivefromBlock/toBlock(max 2000 blocks), combined rows ≤MAX_GT_EVENT_ROWS5000 else 400 (GT_EVENT_ROW_CAP_MSG). No limit book, no wrap. Gems omitted (L639-2). Reserves are persisted post-event columns, never livepair_reserves(#684).Wrap is a fee source, not a wrap tape
indexer/src/indexer/protocol_fees.rs:FeeSource::{Wrap, Unwrap, …}. Retail wrap wasm is pinned-mapperaction=notify_deposit; unwrap isaction=unwrap; amount keyfee(not principal). Insert:indexer/src/db/queries/protocol_fees.rsprotocol_fee_events (block_height, block_timestamp, tx_hash, source, ordinal, asset_id, amount_raw, decimals, fee_usd)— no sender. Spoofnotify_depositoff the pin is ignored. UST1 mint/redeem is a different family (ust1_mint/ust1_redeem) and is not wrap.Daily HTTP is aggregates
GET /api/v1/defillama/daily?timestamp=(#631) andGET /api/v1/protocol/{volume,fees,liquidity}/dailyare UTC-day sums. Volume rule: parentswap_eventsonly — never fills, wrap, or UST1 window. They are not event dumps.Redaction exists only in logs
indexer/src/lcd/mod.rsredacts LCD paths at WARN. API JSON returns full bech32. Nosha256actor alias helper.Auth and governors
Public
GET. Globaltower_governor(default 60 RPS; prod refuses0). LCD-heavy router is a second 10 RPS list (limit-book, route/solve, blacklist-check, CG/CMC orderbook). Keys are socket peer IP (PeerIpKeyExtractor) — no trustedX-Forwarded-For. SQL listlimitis clamped (SEC-F05 / #431). Internal errors:internal_err()→"Internal server error".Docs / discovery today
docs/runbooks/suspicious-activity-queries.mdtells operators to curl unredacted leaderboard + trader trades or raw SQL. That is the opposite of a shareable daily blob. #1204 will document existing paths; it will not add this route.Why the new implementation is needed
Evidence consumers need one day’s protocol activity as JSON they can store and share without a wallet list. Today that requires:
/trades+/liquidity-events+ three limit routes per pair.join/exitvs indexeradd/removevs limit fills that must not be double-counted as swaps (L10).sender/maker/owner/providerafter the fact.Without a dedicated export, agents either keep PII, miss wrap/limit, or mix listing adapters (
/gt/events) with protocol truth. A UTC-day, surface-tagged, redacted document is the missing contract.Constraints / guardrails
GETonly. No indexer DB writes. Not onlcd_heavy_router(Postgres only). No LCD on the request path.wrap_events. Do not persist wrap principal. Do not re-parse historical wasm. Export what is already indexed.?redact=0. The public API stays unauthenticated. Redaction is the access control for this route. Do not add a secret salt (new key material). Do not add an unredacted twin.[day 00:00:00Z, next 00:00:00Z)). No multi-dayfrom/to. No trailing24h.dayisYYYY-MM-DDparsed in Rust; bindtimestamptzparameters — never concatenate SQL.swap|wrap|limit|lponly (lowercase after trim). Repeatablesurface=or comma list. Unknown / empty token after split → 400. Default: all four.surface=swapisswap_eventsonly. Limit fills aresurface=limit+kind=fill. Do not emit a swap row and a fill row that an agent would sum as two volumes. Optionalswap_event_idon fill rows is correlation, not a second swap.amount_raw= indexed fee). Labelkindwrap|unwrapfromsource. Omitactor_hashwhen the table has no actor. Do not foldust1_mint/ust1_redeem/swap_amm/book_take/limit_placeinto wrap.kindadd|removefromliquidity_events.event_type. Do not rename to GTjoin/exiton this route.actor_hash= first 32 hex chars (16 bytes). Drop actor fields entirely (notnull). Pair address, token contracts, native denoms (uusd/uluna) stay.tx_hashstays so the blob is chain-linkable; document that LCD lookup deanonymizes a row. That tradeoff is accepted for evidence; the goal is “no wallet list in the JSON,” not cryptographic unlinkability.limit=500, clamp 1–1000. Opaquecursor/next_cursoron(block_height, tx_hash, surface, kind, ordinal).has_morewhen another page exists. Do not 400 when the day has >5000 events.bd_plain_string/ #557). No scientific notation. Optionaldecimalswhen known.fee_usdon wrap may be JSON number/string consistent with fee APIs, or omitted if unpriced — do not emitnullthat a client treats as$0without a field.day/ futureday(strictly after today’s UTC date) / badsurface/ badcursor/ badlimit→ 400 with a short message. DB failure → 500"Internal server error". Empty day → 200{ "events": [], "has_more": false }. Today’s incomplete UTC day → 200 with"complete": false./gt/events, pair/trader tapes, DeFiLlama, or CSV. Those stay unredacted.format=csv→ 400). Formula injection is out of scope because the body is JSON.TimeoutLayer. One page = bounded SQL (four optionalUNION ALLbranches, eachLIMIT n+1). No unboundedSELECT *for the day.Relevant files
indexer/src/api/mod.rsbuild_router,ApiDocpaths/tags/schemas, governorsindexer/src/api/gt.rsmakeror 400-over-capindexer/src/api/pairs.rsbd_plain_stringindexer/src/api/traders.rsindexer/src/api/defillama.rsindexer/src/api/protocol_fees.rs/protocol_fee_series.rsindexer/src/indexer/protocol_fees.rsFeeSource, wrap pin, spoof rejectionindexer/src/db/queries/swap_events.rsindexer/src/db/queries/liquidity.rsindexer/src/db/queries/limit_order_fills.rs/limit_order_lifecycle.rsindexer/src/db/queries/protocol_fees.rsindexer/src/api/errors.rsinternal_errindexer/tests/security.rsindexer/tests/api_gt.rs/gt_event_reserves.rsindexer/tests/api_limit_lower_bound.rs/limit_clamp_guardrail.rslimitclamp idiomdocs/indexer-invariants.mddocs/runbooks/suspicious-activity-queries.mdNew (expected):
indexer/src/api/evidence.rs(handler + redaction helper + query),indexer/tests/api_evidence_daily.rs.Recommended direction
One handler, four parameterized SELECTs, merge in Rust.
Response shape (illustrative — keep additive JSON,
#[serde(skip_serializing_if)]for absent optional fields):Implementation notes:
daywithchrono::NaiveDate+and_hms_opt(0,0,0)+Utc. Reject2026-13-40,2026-09-03T00:00:00Z, unix timestamps, andday=today.complete=day < Utc::now().date_naive().[0-9a-f]{32}; input with spaces rejected the same way as other terra1 pins (do not hash garbage — omitactor_hashif the stored actor is empty).limitclamp: reuse the SEC-F05 idiom (.clamp(1, 1000)), notunwrap_or(n).min(n)."invalid cursor"./api/v1/pairs/.../trades), notlcd_heavy_router.utoipa::path+ApiDocpath/schema + tag Evidence.docs/indexer-invariants.md(new row) and a 10-line subsection — do not expand #1204’s five-surface pack here.pool_return_amount/book_return_amount) may be included onsurface=swapas plain strings when indexed; they do not create extra rows.Wrap row example:
surface=wrap,kind=unwrap,amount_rawfee,tokendenom or CW20, noactor_hash, nopair_addressunless you join a pair (do not invent a pair).Limit row example:
surface=limit,kindplace|cancel|fill,order_id,pair_address,actor_hashfrom owner/maker, fill may includeswap_tx_hash/swap_event_idwhen linkage exists (#316) without duplicating the parent swap’s amounts as a second swap.Acceptance criteria
GET /api/v1/evidence/daily?day=YYYY-MM-DDreturns 200 JSON withevents[]sorted by(block_height, tx_hash, surface, kind, ordinal)ascending.surfacein{swap, wrap, limit, lp}and akindfrom that surface’s set (swap;wrap/unwrap;place/cancel/fill;add/remove).terra1user actor matching seeded senders/makers/owners/providers. Pair/token contracts may appear. Keyssender,receiver,maker,owner,providerare absent.actor_hashacross swap and limit rows on that day. Wrap fee rows omitactor_hash.surface=swaprow count equalsswap_eventsin the UTC window. Limit fills are not extrasurface=swaprows (L10).surface=wrapincludes onlyprotocol_fee_eventswithsourcewraporunwrapin the window.ust1_mint/swap_amm/limit_placeare absent from wrap.surface=lpkindmatchesliquidity_events.event_type.surface=limitcovers place + cancel + fill tables (cancels that omit still-open placements follow existing lifecycle listing rules: export indexed rows, not LCD resting book).surface=limit(only) omits swap/lp/wrap rows. Comma listsurface=swap,wrapis the union of those two.day, malformedday, futureday, unknownsurface,format=csv,limit=0(clamped to 1, not 500),limit=10000(clamped to 1000), invalidcursor→ 400 exceptlimitclamp which is 200 with clamped page (match existing list routes: negative/zero → 1, oversized → max). Pick one behavior and test it: prefer existing clamp (200) forlimit; 400 for the rest.events: []. Today UTC →"complete": false. A past day with rows →"complete": true.has_more=true+next_cursorwhen more thanlimitevents exist; page 2 with that cursor returns the remainder without overlap or omission (stable sort).ApiDocincludes the path;GET /api-docs/openapi.jsonlists/api/v1/evidence/daily.GET /gt/eventscaps and bodies unchanged.amount_rawlikewise.internal_erron DB failure (no sqlx text).Test plan (functional paths)
Use the existing indexer test Postgres harness (same pattern as
indexer/tests/api_pairs.rs/indexer/tests/indexer_protocol_fees.rs). New fileindexer/tests/api_evidence_daily.rs.day=returns only the middle day.surface=swap— only swaps; previous-day swap absent.surface=wrap— wrap + unwrap;ust1_mintseeded sibling absent.surface=limit— place, cancel, fill; fill does not appear asswap.surface=lp— add and remove; GT-style namesjoin/exitnever appear.surface=swap&surface=lp(and comma form if implemented — if only one form, document and test that form only).terra1…senders/makers/owners/providers do not appear as substrings inserde_json::to_string.actor_hashlength 32 hex. Same sender hashed equally on two swaps.limit+2swaps;limit=limit→has_more; second page last two; union of pages equals full set; intersection empty.Utc::now().date_naive();complete=false.openapi_spec_availableor a dedicated assert)./gt/eventstest still passes with unredactedmaker(do not “fix” GT as part of this).swap_eventsrow with pool+book legs; onesurface=swapevent (L10); optional leg fields if exposed.1e+19.Test plan (attack, hack, and abuse)
day—day=2026-09-03'%20OR%201=1--,day=2026-09-03;DROP TABLE swap_events. 400, tables intact.surface—surface=swap' UNION SELECT sender FROM swap_events. 400. No actor leak.cursor— raw SQL /..// huge binary. 400invalid cursor.redact=0,unredacted=1,raw=1— ignored; body still redacted (no extra mode).sender=terra1…/trader=must not switch this route into trader history. Ignore unknown params (or 400 — pick ignore to match most list routes) and never echo the address.day=2099-01-01→ 400.limit—limit=-1,0,999999,limit=1e999. Clamp or 400; never negative SQLLIMIT(500).events.len() <= 1000).security.rs; 429 withRetry-After. Not LCD-heavy (a test that LCD-heavy 10 RPS does not uniquely wrap this route is enough if dual governors are hard to split in-process — at minimum, route is registered onapi_routernotlcd_heavy_router)."Internal server error", no sqlx.tokenmay be a CW20 contract (protocol asset, allowed). User actors still absent.terra1garbage: omitactor_hash, do not panic.POST/PUT/DELETE→ 405.format=csv→ 400. JSON strings starting with=are amounts/hashes only; no spreadsheet download./gt/eventsstill omits (existing test). Confirms this is not a GT clone.pair_reserves— unit/integration: handler SQL (string assert or query spy) mentions only the four event tables (+pairs/assetsfor addresses). Fail ifpair_reservesappears.Verification criteria
cargo test --manifest-path indexer/Cargo.toml --test api_evidence_daily -- --nocapture(and existingsecurity.rs/api_gt.rs) green.GET /api/v1/evidence/daily?day=<seeded>against the test server;jqshowssurfacetags;rg -o 'terra1[a-z0-9]+'on the body matches only pair/token contracts from fixtures, not the seeded trader.curl /api-docs/openapi.json | jq '.paths["/api/v1/evidence/daily"]'non-null./gt/eventsrequest/response contract.Out of scope
/gt/eventsfor block windows)./agent implement
cl8y-agent-control: queued
implementjob887a0276-f6a0-41ee-ab3a-5474c6bb3ab5(not executed; no Hetzner VM).cl8y-agent-control: queued
implementjob0251ae65-cf71-49e2-8592-35abc24de29b(not executed; no Hetzner VM).cl8y-agent-control: needs_human inbox card POST failed. Job stays parked.