fix(indexer): persist every multihop AMM hop in protocol_fee_events #1269
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#1269
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
/protocolAMM commissions undercount multihop swaps.parse_swapsassignsswap_indexper pair (restarts at 0 on the next pair — #287).process_swapthen storesswap_ammfees with that index asordinalintoprotocol_fee_events, whose uniqueness isUNIQUE (tx_hash, source, ordinal)and has no pair. Hop 2+ in the same router tx collide;ON CONFLICT DO NOTHINGkeeps the first hop and silently drops the rest.swap_eventsalready stores every hop via(tx_hash, pair_id, swap_index). Volume is right; treasury fee census is not.Operator reconstruction of a recent trailing 7-day AMM window: on-chain commissions ≈ $176 vs dashboard ≈ $110. Dropping later hops that share
(tx_hash, swap_amm, ordinal=0)closely reproduces the dashboard total.Related (not this ticket): #287 (swap_events uniqueness — shipped), #586 (fee ledger that introduced the 3-column unique key), #316 / #331 (fill ↔ swap_index), #1209 / #1210 / #1211 (new sources — must inherit the widened key, not copy the colliding one).
Given / When / Then
Given a single router tx with two (or more) factory-listed hops on distinct pairs, each wasm
action=swapcarryingcommission_amount > 0, andparse_swapsassigningswap_index = 0to both hopsWhen the indexer runs
process_swap→ingest_protocol_fee(FeeSource::SwapAmm, i64::from(swap.swap_index), …)againstUNIQUE (tx_hash, source, ordinal)Then both hop commissions persist as
protocol_fee_events(source=swap_amm,event_count2 for that tx)And
refresh_protocol_fees/GET /api/v1/protocol/fees?window=7d/ overviewtotal_fees_7d_usdinclude the sum of both stampedfee_usdvaluesAnd a poller replay of the same tx inserts zero extra fee rows
Given two genuine swaps on the same pair in one tx (
swap_index0 then 1 — #287)When both have
commission_amount > 0Then both
swap_ammrows persist (ordinals 0 and 1 must not collapse either)Expected vs actual
swap_eventsdoes: pair + per-pairswap_index(or a deterministic per-tx fee ordinal that never restarts per pair)UNIQUE (tx_hash, source, ordinal)— no pairswap_ammswap.swap_indexfromper_pair_swap_index(pair B restarts at 0)swap_ammrowsON CONFLICT DO NOTHINGCurrent codebase
parse_swaps:per_pair_swap_index: HashMap<pair, i32>— each_contract_addressstarts at 0. Unit testparse_swaps_assigns_per_pair_swap_index: pairA→0, pairA→1, pairB→0.insert_swap/trade_existsunique(tx_hash, pair_id, swap_index). Multihop volume rows are complete.process_swapafter successful insert:ingest_protocol_fee(…, SwapAmm, i64::from(swap.swap_index), ask_asset_id, commission, …).indexer/migrations/20260821120000_protocol_fees.sql:UNIQUE (tx_hash, source, ordinal). Nopair_idcolumn. Comment: replay must not double-count.insert_fee_event:ON CONFLICT (tx_hash, source, ordinal) DO NOTHING.FeeEventDrafthas no pair field.trade_exists→Ok(())before fee ingest. Historical hops already inswap_eventswill not grow missing fee rows on replay.ordinal.book_take/limit_place:order_idas ordinal (usually unique in-tx). Collision isswap_amm× per-pair index.refresh_protocol_fees→global_stats_24h.total_fees_*+protocol_fee_stats_by_source(window24h/7d/30d, incl.swap_amm). GET is O(1) / 60s cache — no liveSUM.ProtocolFeeStatsheadlines overview 7d; source table fromGET /api/v1/protocol/fees. Dropped hops never enter the SUM.commission_amount+ fillcommission_amountonce. Do not “fix” undercount by also addingbook_commission_amount.Router (
smartcontracts/contracts/router) chains pairexecute; each hop emits wasmaction=swap+commission_amount(pool leg) under onetxhashwith distinct_contract_address.Why the new implementation is needed
#287made swap persistence pair-aware.#586reused the per-pair index as a per-tx fee ordinal. Multihop is the default Swap path (#101); every extra hop withswap_index == 0is a dropped treasury row./protocoland DeFiLlama daily fees are the public census. A ~38% 7d AMM gap (measured window) is not a display rounding bug.trade_existsskipsingest_protocol_fee. Need a uniqueness fix and a one-shot backfill fromswap_events.commission_amount.UNIQUE (tx_hash, source, ordinal). Leaving the colliding key in place spreads the bug.Constraints / guardrails
ON CONFLICT DO UPDATE— replay/spoof must not overwrite a stored amount.spread_amount, burn tax, gas,hook_fee_amount/ AfterSwap, LP, book escrow, orbook_commission_amount(L7 / #196). Treasury pool commission only forswap_amm._contract_addressthat is a discovered factory pair. Unreservedcontract_address/ spoof hops are not fee rows.UNIQUEtreatsNULLas distinct — a nullablepair_idwithout a partial unique / sentinel will break wrap replay dedup. Use one of: (a)pair_id NOT NULLwith sentinel0for non-pair sources, (b) two partial uniques (pair_id IS NOT NULLvsIS NULL), (c) per-txswap_ammordinal (no pair column) that does not restart per pair.SUM protocol_fee_eventson/overviewor/protocol/fees. Refresh rollup after backfill.fee_usdat ingest / backfill from the same catalog as volume (P522-Q / hub / economic marks #683). Do not rewrite historical non-nullfee_usdfrom the live hub (#568). Unpriced → store raw,fee_usdNULL; activity + all unpriced → APInull, not$0.amount_raw > 0still required. Zero-commission hops: no fee row.FeeSource::ALLlength / CHECK until #1209 lands its own source.Relevant files
indexer/src/indexer/parser.rsparse_swapsper-pairswap_index;process_swappasses it as feeordinal;trade_existsearly-returnindexer/src/indexer/protocol_fees.rsFeeEventDraft,FeeSource::SwapAmmindexer/src/db/queries/protocol_fees.rsinsert_fee_eventON CONFLICT (tx_hash, source, ordinal)indexer/migrations/20260821120000_protocol_fees.sqlindexer/migrations/20260605000000_swap_events_per_tx_pair_swap_index.sqlindexer/src/indexer/volume_aggregator.rs+db/queries/volume.rsrefresh_protocol_fee_statsafter backfillindexer/src/api/protocol_fees.rs+api/overview.rsindexer/tests/indexer_protocol_fees.rsindexer/src/indexer/parser.rsparse_swaps_assigns_per_pair_swap_indexdocs/indexer-invariants.mddocs/runbooks/overview-global-stats-brin.md+indexer-reorg-replay-dedup.mdskills/AGENTS_FRONTEND_PROTOCOL_STATS.mdscripts/qa/verify-issue-586.shverify-issue-<this>frontend-dapp/src/components/protocol/ProtocolFeeStats.tsxRecommended direction
Preferred (mirror #287, keep fee ↔ swap alignment):
pair_id INT NULL REFERENCES pairs(id)(orNOT NULL DEFAULT 0sentinel). ReplaceUNIQUE (tx_hash, source, ordinal)with a key that includes pair for pair-scoped sources. Document NULL/sentinel semantics for wrap/window.FeeEventDraft+insert_fee_eventbindpair_id.process_swappassespair.idand existingswap.swap_index.swap_eventsrow withcommission_amount > 0and no matchingswap_ammfee, insert one event (same USD helper as ingest). Do not rely on indexer replay (trade_existswill skip). Then onerefresh_protocol_fees.swap_index == 0→ two fee rows; same-pair 0 then 1 → two rows; second insert of the same key → 0 rows; wrap two ordinals in one tx still dedup.Simpler alternative: keep three-column unique; assign a per-tx
swap_ammordinal (global walk counter inparse_swaps/process_block_txs), independent ofswap_index. Wrap already does this. Still needs backfill. Do not mix this with per-pairswap_indexon the same unique key.Do not use
DO UPDATE.Acceptance criteria
commission_amount > 0, bothswap_index == 0→COUNT(*) FILTER (source=swap_amm)for thattx_hashis 2.swap_index0, 1) with commissions → twoswap_ammrows.ON CONFLICT DO NOTHING).swap_ammUSD equalsSUM(fee_usd)of surviving priced hop commissions (within existing clamp / unpriced rules). Collision-only reconstruction no longer matches the dashboard.GET /overviewandGET /protocol/feesstill do not scanprotocol_fee_events.make verify-issue-586stays green. Newmake verify-issue-<this>exists.Test plan (functional paths)
swap_index0, 1, 0 (existing). Fee ingest stores 3swap_ammrows if all commissions > 0insert_fee_eventtwice with same(tx, swap_amm, pair, ordinal)rows_affected == 0process_block_txsfixture: 2-hop router wasm with commissionsprotocol_fee_eventscount 2;swap_eventscount 2protocol_fee_stats_by_sourceswap_ammevent_count+ USD include both hopscommission_amount = 0hopswap_ammhybrid_counts_amm_and_book_onceVitest/RTL not required (indexer census). Postgres tests:
indexer_protocol_fees.rs+ parser unit + optional process_block fixture.Test plan (attack, hack, and abuse)
swap_ammset; amounts unchangedON CONFLICT DO UPDATEcontract_address(no underscore) spoof swap + commission (#285)swap_indexwasm attrpair_idunique (PG NULLS DISTINCT)(tx, source, ordinal)swap_ammto “close the gap”SUM(protocol_fee_events)on GET to hide missing ingestVerification criteria
make verify-issue-<this>: parser unit (pairB→0 still, fees do not collapse),indexer_protocol_feeshop insert + replay, unique-constraint grep (pair or per-tx ordinal — not the old three-column-only comment as the sole truth), docs/skills.make verify-issue-586,verify-issue-613,verify-issue-614,verify-issue-683stay green./protocolmatches a hop-completeSUMfromswap_events.commission_amount(priced), not the collision-truncated sum.fee_event_count.Out of scope
nullthrow.ProtocolFeeStats.First-pass model recommendation
Recommendation: grok-high
Rationale: This is a persisted uniqueness + backfill change across schema (
protocol_fee_events), ingest (parser.rs/FeeEventDraft/insert_fee_event), aggregator refresh, invariants/runbook, and focused Postgres tests — more than three production files and not a local helper edit. Wrong NULL unique semantics would double-count wrap/window on replay; wrong backfill would fork/protocoland DeFiLlama. Comparable to control-plane work that crosses persisted state (not a single-file docs/test pass). Verify with the newverify-issue-*plusindexer_protocol_fees/ parser ordinal tests; no live host choice in this ticket./agent implement
cl8y-agent-control: queued
implementjobf87a932f-0734-4a30-bb0f-24e6b4cde642(not executed; no Hetzner VM).Design record PR #1275 (ADR 0005) merged to main (
16dfa302). Ingest uniqueness from #1269 / PR #1274 was already on main. sqlx leftover (shared20260916120000prefix) was resolved in PR #1282: USDT stays20260916120000, hops file is20260916120001.