fix: GeckoTerminal /gt/events must emit per-event post-trade reserves, not the live pair_reserves snapshot #684

Closed
opened 2026-08-27 04:45:32 +00:00 by PlasticDigits · 15 comments
PlasticDigits commented 2026-08-27 04:45:32 +00:00 (Migrated from gitlab.com)

Parent

Child / follow-up of #646 (GeckoTerminal Non-EVM /gt/*). Listing catalog parent: #639. Do not reopen #646 to change form copy or /cg/*.

Summary

GeckoTerminal /gt/events attaches the live pair_reserves snapshot to every swap / join / exit. Integrators therefore see identical reserves.asset0 / reserves.asset1 across many blocks even when asset0In / asset1Out prove the pool moved. That breaks GT liquidity, price impact, and chart reconstruction.

Bundle persist post-event reserves at ingest, serve them from /gt/events, backfill, and docs / verify in one issue. Do not split “SQL column” vs “GET JSON” vs “backfill script.”

Report (anonymized)

An on-chain listing integrator crawling GET https://indexer.dex.cl8y.com/gt/events on a columbus-5 factory pair observed two swaps on the same pool with unchanged reserves:

Event A Event B
blockNumber 30128918 30129103 (~185 blocks later)
eventType swap swap
Pair terra1q5karg99p5hc0jvplgn2fd8akg39r4ss00cu5rwkm600yqvmssgqhwqvq0 same
asset0In / asset1Out 0.831452971704399333 / 8016.781493 0.775464141105197631 / 7412.024205
reserves.asset0 185.074384741721120019 same
reserves.asset1 1793851.188616 same

If reserves were post-trade AMM state, Event B must differ from Event A by at least Event A’s pool delta (hybrid book may take some of the notional; the pool legs still cannot leave both reserves bit-identical after a non-zero pool_return_amount).

Do not paste integrator chat, handles, or emails on this issue.

Current codebase

#646 shipped the Integration API (spec): /gt/latest-block, /gt/asset, /gt/pair, /gt/events. The module documents the defect as intended:

//! Reserve fields use the current `pair_reserves` snapshot (not historical reconstruction).
Layer Behavior today
GET /gt/events indexer/src/api/gt.rs gt_events: loads swaps + join/exit in [fromBlock, toBlock] (max 2000), decimalizes amounts, then calls current_reserves() which looks up one (reserve_0, reserve_1) per pair_id from SELECT pair_id, reserve_0, reserve_1 FROM pair_reserves. Every event for that pair in the window gets the same humanized snapshot. Missing row → "0" / "0".
pair_reserves indexer/migrations/20260605010000_pair_reserves.sql: one current row per pair (PK pair_id). Written by book_snapshot.rs (~BOOK_SNAPSHOT_INTERVAL_MS, default 10s) from LCD Pool {}. Built for the hybrid solver (#279 / #322), not a reserve history. Upsert replaces the prior row.
swap_events Persists offer_amount, return_amount, optional hybrid pool_return_amount / book_return_amount / limit_book_offer_consumed (insert_swap). No reserve_0 / reserve_1. Parser: parser.rs ParsedSwap — same gap.
liquidity_events add / remove with asset_0_amount / asset_1_amount / lp_amount. No post-event reserves.
Pair wasm smartcontracts/contracts/pair/src/contract.rs updates RESERVES on swap / provide / withdraw. Swap attrs are offer_amount, return_amount, pool_return_amount, book_return_amount, commission_amount, … — no reserve_0 / reserve_1. Commission stays in the pool (gross_output leaves reserves; fee is not an extra reserve drain). Book fills do not mutate RESERVES. Sweep does not mutate RESERVES.
Hybrid math pool_input = offer_amount - limit_book_offer_consumed (when book attrs exist; else treat as pool-only). gross_output = pool_return_amount + commission_amount (pool commission attr). Applying full offer/return to reserves is wrong on hybrid swaps.
Invariants / verify docs/indexer-invariants.md row GeckoTerminal Integration API (#646) still says “Reserves = current pair_reserves snapshot.” scripts/qa/verify-issue-646.sh + make verify-issue-646 + indexer/tests/api_gt.rs assert a single snapshot ("2") on the seeded swap — they lock in the bug.
Not this ticket /cg/tickers liquidity_in_usd is mislabeled 24h volume (#224 / #569) — different crawler. Protocol TVL / pair_liquidity_usd stay current-state rollups. Do not point GT at /cg/*.

Why a new implementation is needed

  1. GT treats reserves as post-event pool state. Their crawler builds liquidity and impact from the event stream. A static snapshot makes every historical swap look like it printed against today’s (or last-snapshot) curve.
  2. pair_reserves is a solver mirror, not a tape. One row, replaced every ~10s. Even if the snapshot loop were instant, GET /gt/events?fromBlock=A&toBlock=B would still stamp the same current row on Event A and Event B.
  3. You cannot reconstruct a correct reserve from offer_amount/return_amount alone. Hybrid book takes part of the notional; commission stays in the pool; tax / wrap hops are out of band. Naive R += in; R -= out will poison GT liquidity on any pair with resting limits.
  4. Listing is live. Adapter base https://indexer.dex.cl8y.com/gt was submitted on the #646 DEX Addition form. This is a production integrator defect, not a docs nit.
  5. Computing history on GET is a DoS. Walking all prior swaps per 2000-block request is forbidden. Persist at ingest; GET reads columns.

Constraints / guardrails

  1. Do not change pair_reserves semantics. It remains the current LCD mirror for Phase 1c / hub / protocol TVL. GT must not JOIN it for historical events.
  2. Reserves on an event = AMM RESERVES after that event (factory asset_0 / asset_1, decimalized like amounts). Book escrow, parked dust, wallet balances, wrap-mapper inventory, and CW20 donations are out. Sweep / Skim does not change reported reserves.
  3. Hybrid: only the pool leg mutates reserves. Book-only swaps (non-zero book_return_amount, zero pool_return_amount) must leave reserves unchanged vs the previous event on that pair.
  4. Orientation: reserves.asset0 / asset1 follow factory asset_0 / asset_1, not offer/ask. Same decimalize / format_dec as #646 (no scientific notation).
  5. Gems / ALPHA / USTRIX / SpaceUSD stay 404 / omitted (L639-2 / #562). Do not leak them while adding columns.
  6. GET stays cheap. Max 2000-block span unchanged. No live LCD Pool {} per event. No pair_reserves ⋈ on the request path for event reserves. No unbounded walk of swap_events to rebuild history per GET.
  7. Additive JSON. Event shape stays GT (eventType, txnId, reserves.asset0 / asset1). Do not rename fields. Do not add USD / TVL / hub marks to /gt/events.
  8. Missing history: events ingested before this change may lack columns. Backfill must not silently re-stamp the current snapshot onto old rows (that is the bug). Prefer reverse-apply from current LCD/pair_reserves using stored hybrid legs; if a row cannot be reconstructed, omit reserves or emit "0" only if the spec forbids omit — pick one, document it, test it. Do not invent $1 UST1 / 2.5× USTR.
  9. Wasm emit is optional gold-standard, not a launch gate. If pair swap / provide_liquidity / withdraw_liquidity gain reserve_0 / reserve_1 attrs, parser must take them only from the runtime-reserved _contract_address (#285). Forged attrs from another contract are ignored. A factory migrate is not required to close this issue if ingest reconstruction is correct going forward.
  10. Reorg / replay: on rewind, delete or recompute persisted reserves for dropped heights the same way other event tables are handled. Do not leave post-reorg rows pointing at future reserves.
  11. DoS / abuse: fromBlock/toBlock stay i64, to >= from, span ≤ 2000, from > latest → []. Invalid range → 400. No raw SQL from query strings.
  12. Do not “fix” /cg/tickers liquidity_in_usd or Protocol TVL here.
  13. No emails, handles, or integrator chat in commits, docs, or verify scripts.

Relevant files

File Role
indexer/src/api/gt.rs current_reserves → per-event columns; module docs
indexer/src/api/mod.rs /gt/* routes (no new path required)
indexer/src/indexer/parser.rs Persist reserves on swap + liquidity ingest; optional wasm attrs
indexer/src/db/queries/swap_events.rs insert_swap + columns
indexer/src/db/queries/liquidity.rs insert_liquidity_event + columns
indexer/src/indexer/book_snapshot.rs Do not feed GT events from this table
indexer/src/db/queries/pair_reserves.rs Solver mirror only
indexer/migrations/ reserve_0 / reserve_1 on swap_events + liquidity_events (nullable for pre-fix rows); optional backfill SQL/job
indexer/tests/api_gt.rs Two swaps, same pair, different reserves; hybrid book-only unchanged; gems still hidden
smartcontracts/contracts/pair/src/contract.rs Optional: emit post-save reserve_0 / reserve_1
docs/indexer-invariants.md Replace “current snapshot” wording
docs/CG_CMC_COMPLIANCE.md GT section
scripts/geckoterminal/README.md Reserve semantics
scripts/qa/verify-issue-646.sh Keep #646 routes; this ticket gets verify-issue-NNN
skills/AGENTS_LISTINGS.md Cross-link only — do not dump adapter work into #639
  1. Columns swap_events.reserve_0 / reserve_1 and liquidity_events.reserve_0 / reserve_1 (NUMERIC(38,0), nullable). Values are raw factory units (same as offer_amount), not human.
  2. Ingest (forward): keep a per-pair running reserve (last persisted event, else LCD Pool {} / pair_reserves only as seed at first event after deploy). For each swap in block/tx/id order: if wasm attrs present and #285-scoped, use them; else pool_input = offer - coalesce(limit_book_offer_consumed, 0), gross_out = pool_return + pool_commission (NULL hybrid → pool-only: pool_input = offer, gross_out from CPAMM or return + commission). Apply to the offer/ask legs, then map back to asset_0/asset_1. Join/exit: ± asset_0_amount / ± asset_1_amount. Persist after the event.
  3. gt_events: SELECT the row’s reserve_*, decimalize. Do not call current_reserves() against pair_reserves.
  4. Backfill: one offline pass: seed from current pair_reserves (or LCD Pool {} at tip), walk events newest → oldest, invert the same deltas, write columns. Skip / leave NULL when hybrid legs are missing and inversion is ambiguous. Never write “today’s snapshot” onto every historical row.
  5. Tests + make verify-issue-NNN. Optional pair wasm attrs can land in the same MR or a follow-up migrate; indexer must work without them.
  6. Coolify: indexer redeploy after migrate. No dApp change. No GT form resubmit unless the vendor asks.

Do not query LCD Pool {} per event on GET. Do not treat return_amount (pool+book) as the reserve drain.

Acceptance criteria

  • AC1. Two sequential pool swaps on the same pair in one /gt/events window have different reserves when pool_return_amount > 0 (or pool-only offer/return > 0). Values match post-event AMM RESERVES (decimalized), not the live pair_reserves row.
  • AC2. Event N+1 reserves equal Event N reserves plus Event N’s pool delta (input added to offer leg, gross_output removed from ask leg). Hybrid book notional does not move reserves.
  • AC3. Book-only swap: reserves equal the previous event (or seed) for that pair.
  • AC4. Join increases both legs by amount0/amount1; exit decreases. Same persistence path as swaps.
  • AC5. GET /gt/events does not SELECT from pair_reserves for event reserves (test / EXPLAIN / grep). Solver snapshot loop unchanged.
  • AC6. Gem / ALPHA / USTRIX / SpaceUSD pairs still 404 / omitted. dexKey stays cl8y. Span > 2000 / inverted range still 400.
  • AC7. Decimalize + format_dec unchanged (6-dec and 18-dec). No scientific notation. Orientation is factory asset_0/asset_1.
  • AC8. Pre-fix rows: backfill or documented NULL/0 policy. Backfill must not copy the current snapshot onto all rows of a pair.
  • AC9. Reorg/replay does not leave future reserves on a rolled-back height.
  • AC10. Docs/invariants/scripts/geckoterminal/README.md + make verify-issue-NNN. #646 verify still passes for routes/form pack.
  • AC11. Production pair from the report (or LocalTerra equivalent): a window covering two real swaps shows changing reserves after indexer migrate + catch-up/backfill.

Test plan (all paths)

Indexer API (api_gt + new ingest tests)

ID Path Expect
T1 Two pool-only swaps, same pair, different heights reserves differ; second = first + delta
T2 Same two swaps after pair_reserves upserted to an unrelated snapshot Event reserves unchanged (ignore snapshot)
T3 Hybrid: offer 100, book consumed 60, pool return 30, commission 1 Only pool_input 40 and gross 31 move reserves
T4 Book-only (pool_return_amount = 0, book_return_amount > 0) Reserves unchanged
T5 Join then swap then exit in one window Running reserve after each event
T6 Two swaps same tx (swap_index 0, 1) Second uses first’s post-reserve
T7 6-dec / 18-dec pair Human strings match #646 decimalize
T8 Offer is asset_1 (sell quote) asset1In/asset0Out; reserve map not swapped vs factory
T9 Missing reserve columns (NULL) Documented "0" or omit — never today’s snapshot
T10 fromBlock > latest events: []
T11 toBlock < fromBlock / span 2001 400
T12 Gem pair swap in range Omitted; /gt/pair still 404
T13 Empty range / no events events: []
T14 price <= 0 or zero offer/return Still skipped (existing #646 filter)
T15 GET /gt/latest-block / /asset / /pair Unchanged

Ingest / backfill

ID Path Expect
I1 Forward ingest at tip with LCD seed First event reserves match LCD after apply
I2 Catch-up historical blocks Must not LCD-stamp current pool on old heights
I3 Backfill reverse-apply from current snapshot Oldest reconstructed row ≠ current snapshot when swaps exist
I4 Legacy swap without hybrid columns Pool-only assumption or NULL — documented
I5 Provide / withdraw amounts + / - both legs
I6 Dedup ON CONFLICT DO NOTHING Reserves not overwritten by a second insert of the same (tx, pair, swap_index)

Contracts (only if wasm attrs land)

ID Path Expect
C1 Swap / provide / withdraw emit reserve_0 / reserve_1 after RESERVES.save Equal storage
C2 Book-only swap attrs Reserves equal pre-swap storage

Test plan (attack, hack, abuse)

ID Vector Expect
A1 Naive full-notional apply on a hybrid swap (offer/return includes book) Rejected by tests T3/T4 — book must not drain/inflate GT reserves
A2 Forged wasm reserve_0 from a non-pair contract (#285) Parser ignores; only reserved _contract_address
A3 SQL / param injection fromBlock=1;DROP / toBlock=__proto__ 400; binds stay i64
A4 Range DoS fromBlock=0&toBlock=2^63 400 (span cap); GET must not walk full swap_events history to rebuild reserves
A5 Snapshot poison: attacker (or stale LCD) writes huge pair_reserves /gt/events history unchanged; solver mirror may move (out of scope)
A6 Donate / transfer-in to the pair CW20 Reported reserves stay internal RESERVES, not balanceOf (matches sweep design)
A7 Gem / soft-launch pair addresses in fromBlock window Still omitted; no reserve leak
A8 Reorg: events at H ingested, then rewind below H Those rows gone or reserves recomputed; no future-state leftover
A9 Decimal / overflow 38-digit raw NUMERIC(38,0); GET format_dec does not emit e+ that breaks GT parse
A10 Orientation spoof (symbol vs contract) Asset ids stay contract/denom; reserves follow factory indices
A11 Cache (if anyone adds a /gt/events cache) Key must include block range; must not cache one snapshot for all events
A12 Tax / community-tax sell (#615) Reserve delta is pool CW20 amount, not trader extra-debit
A13 UST1 / USTR marks Do not attach USD, $1, or 2.5× to GT reserves
A14 Skip-backfill cheat (stamp current snapshot on all historical rows) Explicitly forbidden; I3 / AC8 fail the MR

Verification criteria

make setup-indexer-postgres   # if indexer/.env missing
make verify-issue-NNN         # new: api_gt + ingest/backfill tests + invariant grep
make verify-issue-646         # routes / form pack still green

Live (after Coolify indexer migrate), without publishing chat:

# Pick a pair with ≥2 recent pool swaps in a ≤2000-block window.
curl -sS "https://indexer.dex.cl8y.com/gt/events?fromBlock=H1&toBlock=H2" \
  | python3 -c 'import json,sys; ev=[e for e in json.load(sys.stdin)["events"] if e.get("eventType")=="swap" and e.get("pairId")==sys.argv[1]];
print(ev[0]["reserves"], ev[-1]["reserves"]); assert ev[0]["reserves"]!=ev[-1]["reserves"]' PAIR
  • Local Postgres tests: T1–T15, I1–I6, A1–A14 as automated as possible (A6/A12 unit or documented skip).
  • docs/indexer-invariants.md no longer says GT reserves are the current snapshot.
  • Production window on a real factory pair (the report pair or UST1/USTR) shows moving reserves across swaps.
  • pair_reserves still one-row upsert; route/solve hybrid tests unchanged.
  • No integrator names, emails, or chat logs in the tree.

Out of scope

  • CoinGecko /cg/* ticker liquidity_in_usd rename
  • Protocol / pool list TVL (#569 / #655)
  • GeckoTerminal Google form resubmit
  • Adding Terra Classic to GT GET /api/v2/networks (vendor)
  • Emitting USD or hub prices on /gt/events
## Parent Child / follow-up of [#646](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/646) (GeckoTerminal Non-EVM `/gt/*`). Listing catalog parent: [#639](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/639). Do **not** reopen `#646` to change form copy or `/cg/*`. ## Summary GeckoTerminal `/gt/events` attaches the **live** `pair_reserves` snapshot to every swap / join / exit. Integrators therefore see **identical** `reserves.asset0` / `reserves.asset1` across many blocks even when `asset0In` / `asset1Out` prove the pool moved. That breaks GT liquidity, price impact, and chart reconstruction. Bundle **persist post-event reserves at ingest**, **serve them from `/gt/events`**, **backfill**, and **docs / verify** in **one** issue. Do **not** split “SQL column” vs “GET JSON” vs “backfill script.” ## Report (anonymized) An on-chain listing integrator crawling `GET https://indexer.dex.cl8y.com/gt/events` on a columbus-5 factory pair observed two swaps on the same pool with **unchanged** reserves: | | Event A | Event B | |---|---|---| | `blockNumber` | `30128918` | `30129103` (~185 blocks later) | | `eventType` | `swap` | `swap` | | Pair | `terra1q5karg99p5hc0jvplgn2fd8akg39r4ss00cu5rwkm600yqvmssgqhwqvq0` | same | | `asset0In` / `asset1Out` | `0.831452971704399333` / `8016.781493` | `0.775464141105197631` / `7412.024205` | | `reserves.asset0` | `185.074384741721120019` | **same** | | `reserves.asset1` | `1793851.188616` | **same** | If reserves were post-trade AMM state, Event B must differ from Event A by at least Event A’s pool delta (hybrid book may take some of the notional; the **pool** legs still cannot leave both reserves bit-identical after a non-zero `pool_return_amount`). Do **not** paste integrator chat, handles, or emails on this issue. ## Current codebase `#646` shipped the Integration API ([spec](https://docs.google.com/document/d/1ufjAJUa6rGO9PBGJGwfBMn-XMk9NE0ow3_iMYrS3drk)): `/gt/latest-block`, `/gt/asset`, `/gt/pair`, `/gt/events`. The module **documents** the defect as intended: ``` //! Reserve fields use the current `pair_reserves` snapshot (not historical reconstruction). ``` | Layer | Behavior today | |-------|----------------| | **`GET /gt/events`** | [`indexer/src/api/gt.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/api/gt.rs) `gt_events`: loads swaps + join/exit in `[fromBlock, toBlock]` (max **2000**), decimalizes amounts, then calls `current_reserves()` which looks up **one** `(reserve_0, reserve_1)` per `pair_id` from `SELECT pair_id, reserve_0, reserve_1 FROM pair_reserves`. Every event for that pair in the window gets the **same** humanized snapshot. Missing row → `"0"` / `"0"`. | | **`pair_reserves`** | [`indexer/migrations/20260605010000_pair_reserves.sql`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/migrations/20260605010000_pair_reserves.sql): **one current row per pair** (PK `pair_id`). Written by [`book_snapshot.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/indexer/book_snapshot.rs) (~`BOOK_SNAPSHOT_INTERVAL_MS`, default 10s) from LCD `Pool {}`. Built for the **hybrid solver** ([#279](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/279) / [#322](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/322)), not a reserve history. Upsert **replaces** the prior row. | | **`swap_events`** | Persists `offer_amount`, `return_amount`, optional hybrid `pool_return_amount` / `book_return_amount` / `limit_book_offer_consumed` ([`insert_swap`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/db/queries/swap_events.rs)). **No** `reserve_0` / `reserve_1`. Parser: [`parser.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/src/indexer/parser.rs) `ParsedSwap` — same gap. | | **`liquidity_events`** | `add` / `remove` with `asset_0_amount` / `asset_1_amount` / `lp_amount`. **No** post-event reserves. | | **Pair wasm** | [`smartcontracts/contracts/pair/src/contract.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/smartcontracts/contracts/pair/src/contract.rs) updates `RESERVES` on swap / provide / withdraw. Swap attrs are `offer_amount`, `return_amount`, `pool_return_amount`, `book_return_amount`, `commission_amount`, … — **no** `reserve_0` / `reserve_1`. Commission stays in the pool (`gross_output` leaves reserves; fee is not an extra reserve drain). Book fills do **not** mutate `RESERVES`. Sweep does **not** mutate `RESERVES`. | | **Hybrid math** | `pool_input = offer_amount - limit_book_offer_consumed` (when book attrs exist; else treat as pool-only). `gross_output = pool_return_amount + commission_amount` (pool commission attr). Applying **full** `offer`/`return` to reserves is **wrong** on hybrid swaps. | | **Invariants / verify** | [`docs/indexer-invariants.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/indexer-invariants.md) row **GeckoTerminal Integration API (#646)** still says “Reserves = current `pair_reserves` snapshot.” [`scripts/qa/verify-issue-646.sh`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/scripts/qa/verify-issue-646.sh) + `make verify-issue-646` + [`indexer/tests/api_gt.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/indexer/tests/api_gt.rs) assert a **single** snapshot (`"2"`) on the seeded swap — they lock in the bug. | | **Not this ticket** | `/cg/tickers` `liquidity_in_usd` is **mislabeled 24h volume** ([#224](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/224) / [#569](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/569)) — different crawler. Protocol TVL / `pair_liquidity_usd` stay current-state rollups. Do not point GT at `/cg/*`. | ## Why a new implementation is needed 1. **GT treats `reserves` as post-event pool state.** Their crawler builds liquidity and impact from the event stream. A static snapshot makes every historical swap look like it printed against today’s (or last-snapshot) curve. 2. **`pair_reserves` is a solver mirror, not a tape.** One row, replaced every ~10s. Even if the snapshot loop were instant, `GET /gt/events?fromBlock=A&toBlock=B` would still stamp the **same** current row on Event A and Event B. 3. **You cannot reconstruct a correct reserve from `offer_amount`/`return_amount` alone.** Hybrid book takes part of the notional; commission stays in the pool; tax / wrap hops are out of band. Naive `R += in; R -= out` will **poison** GT liquidity on any pair with resting limits. 4. **Listing is live.** Adapter base `https://indexer.dex.cl8y.com/gt` was submitted on the `#646` DEX Addition form. This is a production integrator defect, not a docs nit. 5. **Computing history on GET is a DoS.** Walking all prior swaps per 2000-block request is forbidden. Persist at ingest; GET reads columns. ## Constraints / guardrails 1. **Do not change `pair_reserves` semantics.** It remains the **current** LCD mirror for Phase 1c / hub / protocol TVL. GT must **not** JOIN it for historical events. 2. **Reserves on an event = AMM `RESERVES` after that event** (factory asset_0 / asset_1, decimalized like amounts). Book escrow, parked dust, wallet balances, wrap-mapper inventory, and CW20 donations are **out**. Sweep / `Skim` does not change reported reserves. 3. **Hybrid:** only the **pool** leg mutates reserves. Book-only swaps (non-zero `book_return_amount`, zero `pool_return_amount`) must leave reserves **unchanged** vs the previous event on that pair. 4. **Orientation:** `reserves.asset0` / `asset1` follow factory `asset_0` / `asset_1`, not offer/ask. Same decimalize / `format_dec` as `#646` (no scientific notation). 5. **Gems / ALPHA / USTRIX / SpaceUSD** stay **404** / omitted (**L639-2** / `#562`). Do not leak them while adding columns. 6. **GET stays cheap.** Max 2000-block span unchanged. No live LCD `Pool {}` per event. No `pair_reserves ⋈` on the request path for event reserves. No unbounded walk of `swap_events` to rebuild history per GET. 7. **Additive JSON.** Event shape stays GT (`eventType`, `txnId`, `reserves.asset0` / `asset1`). Do not rename fields. Do not add USD / TVL / hub marks to `/gt/events`. 8. **Missing history:** events ingested **before** this change may lack columns. Backfill must not silently re-stamp the **current** snapshot onto old rows (that is the bug). Prefer reverse-apply from current LCD/`pair_reserves` using stored hybrid legs; if a row cannot be reconstructed, omit reserves or emit `"0"` **only** if the spec forbids omit — pick one, document it, test it. Do **not** invent `$1` UST1 / `2.5×` USTR. 9. **Wasm emit is optional gold-standard**, not a launch gate. If pair `swap` / `provide_liquidity` / `withdraw_liquidity` gain `reserve_0` / `reserve_1` attrs, parser must take them **only** from the runtime-reserved `_contract_address` (**#285**). Forged attrs from another contract are ignored. A factory migrate is **not** required to close this issue if ingest reconstruction is correct going forward. 10. **Reorg / replay:** on rewind, delete or recompute persisted reserves for dropped heights the same way other event tables are handled. Do not leave post-reorg rows pointing at future reserves. 11. **DoS / abuse:** `fromBlock`/`toBlock` stay `i64`, `to >= from`, span ≤ 2000, `from > latest` → `[]`. Invalid range → **400**. No raw SQL from query strings. 12. **Do not “fix” `/cg/tickers` `liquidity_in_usd` or Protocol TVL here.** 13. **No emails, handles, or integrator chat** in commits, docs, or verify scripts. ## Relevant files | File | Role | |------|------| | `indexer/src/api/gt.rs` | `current_reserves` → per-event columns; module docs | | `indexer/src/api/mod.rs` | `/gt/*` routes (no new path required) | | `indexer/src/indexer/parser.rs` | Persist reserves on swap + liquidity ingest; optional wasm attrs | | `indexer/src/db/queries/swap_events.rs` | `insert_swap` + columns | | `indexer/src/db/queries/liquidity.rs` | `insert_liquidity_event` + columns | | `indexer/src/indexer/book_snapshot.rs` | **Do not** feed GT events from this table | | `indexer/src/db/queries/pair_reserves.rs` | Solver mirror only | | `indexer/migrations/` | `reserve_0` / `reserve_1` on `swap_events` + `liquidity_events` (nullable for pre-fix rows); optional backfill SQL/job | | `indexer/tests/api_gt.rs` | Two swaps, same pair, **different** reserves; hybrid book-only unchanged; gems still hidden | | `smartcontracts/contracts/pair/src/contract.rs` | Optional: emit post-save `reserve_0` / `reserve_1` | | `docs/indexer-invariants.md` | Replace “current snapshot” wording | | `docs/CG_CMC_COMPLIANCE.md` | GT section | | `scripts/geckoterminal/README.md` | Reserve semantics | | `scripts/qa/verify-issue-646.sh` | Keep `#646` routes; **this** ticket gets `verify-issue-NNN` | | `skills/AGENTS_LISTINGS.md` | Cross-link only — do not dump adapter work into `#639` | ## Recommended direction 1. **Columns** `swap_events.reserve_0` / `reserve_1` and `liquidity_events.reserve_0` / `reserve_1` (`NUMERIC(38,0)`, nullable). Values are **raw** factory units (same as `offer_amount`), not human. 2. **Ingest (forward):** keep a per-pair running reserve (last persisted event, else LCD `Pool {}` / `pair_reserves` **only as seed at first event after deploy**). For each swap in block/tx/id order: if wasm attrs present and `#285`-scoped, use them; else `pool_input = offer - coalesce(limit_book_offer_consumed, 0)`, `gross_out = pool_return + pool_commission` (NULL hybrid → pool-only: `pool_input = offer`, `gross_out` from CPAMM or `return + commission`). Apply to the offer/ask legs, then map back to asset_0/asset_1. Join/exit: `± asset_0_amount` / `± asset_1_amount`. Persist **after** the event. 3. **`gt_events`:** SELECT the row’s `reserve_*`, decimalize. Do **not** call `current_reserves()` against `pair_reserves`. 4. **Backfill:** one offline pass: seed from **current** `pair_reserves` (or LCD `Pool {}` at tip), walk events **newest → oldest**, invert the same deltas, write columns. Skip / leave NULL when hybrid legs are missing and inversion is ambiguous. Never write “today’s snapshot” onto every historical row. 5. **Tests + `make verify-issue-NNN`.** Optional pair wasm attrs can land in the same MR or a follow-up migrate; indexer must work without them. 6. **Coolify:** indexer redeploy after migrate. No dApp change. No GT form resubmit unless the vendor asks. Do **not** query LCD `Pool {}` per event on GET. Do **not** treat `return_amount` (pool+book) as the reserve drain. ## Acceptance criteria - [ ] **AC1.** Two sequential **pool** swaps on the same pair in one `/gt/events` window have **different** `reserves` when `pool_return_amount > 0` (or pool-only offer/return > 0). Values match post-event AMM `RESERVES` (decimalized), not the live `pair_reserves` row. - [ ] **AC2.** Event N+1 `reserves` equal Event N `reserves` **plus** Event N’s pool delta (input added to offer leg, `gross_output` removed from ask leg). Hybrid book notional does **not** move reserves. - [ ] **AC3.** Book-only swap: `reserves` equal the previous event (or seed) for that pair. - [ ] **AC4.** Join increases both legs by `amount0`/`amount1`; exit decreases. Same persistence path as swaps. - [ ] **AC5.** `GET /gt/events` does **not** SELECT from `pair_reserves` for event `reserves` (test / `EXPLAIN` / grep). Solver snapshot loop unchanged. - [ ] **AC6.** Gem / ALPHA / USTRIX / SpaceUSD pairs still 404 / omitted. `dexKey` stays `cl8y`. Span > 2000 / inverted range still **400**. - [ ] **AC7.** Decimalize + `format_dec` unchanged (6-dec and 18-dec). No scientific notation. Orientation is factory asset_0/asset_1. - [ ] **AC8.** Pre-fix rows: backfill **or** documented NULL/`0` policy. Backfill must **not** copy the current snapshot onto all rows of a pair. - [ ] **AC9.** Reorg/replay does not leave future reserves on a rolled-back height. - [ ] **AC10.** Docs/invariants/`scripts/geckoterminal/README.md` + `make verify-issue-NNN`. `#646` verify still passes for routes/form pack. - [ ] **AC11.** Production pair from the report (or LocalTerra equivalent): a window covering two real swaps shows changing reserves after indexer migrate + catch-up/backfill. ## Test plan (all paths) ### Indexer API (`api_gt` + new ingest tests) | ID | Path | Expect | |----|------|--------| | T1 | Two pool-only swaps, same pair, different heights | `reserves` differ; second = first + delta | | T2 | Same two swaps after `pair_reserves` upserted to an unrelated snapshot | Event reserves **unchanged** (ignore snapshot) | | T3 | Hybrid: offer 100, book consumed 60, pool return 30, commission 1 | Only pool_input 40 and gross 31 move reserves | | T4 | Book-only (`pool_return_amount = 0`, `book_return_amount > 0`) | Reserves unchanged | | T5 | Join then swap then exit in one window | Running reserve after each event | | T6 | Two swaps **same tx** (`swap_index` 0, 1) | Second uses first’s post-reserve | | T7 | 6-dec / 18-dec pair | Human strings match `#646` decimalize | | T8 | Offer is asset_1 (sell quote) | `asset1In`/`asset0Out`; reserve map not swapped vs factory | | T9 | Missing reserve columns (NULL) | Documented `"0"` or omit — never today’s snapshot | | T10 | `fromBlock > latest` | `events: []` | | T11 | `toBlock < fromBlock` / span 2001 | **400** | | T12 | Gem pair swap in range | Omitted; `/gt/pair` still **404** | | T13 | Empty range / no events | `events: []` | | T14 | `price <= 0` or zero offer/return | Still skipped (existing `#646` filter) | | T15 | `GET /gt/latest-block` / `/asset` / `/pair` | Unchanged | ### Ingest / backfill | ID | Path | Expect | |----|------|--------| | I1 | Forward ingest at tip with LCD seed | First event reserves match LCD after apply | | I2 | Catch-up historical blocks | Must **not** LCD-stamp current pool on old heights | | I3 | Backfill reverse-apply from current snapshot | Oldest reconstructed row ≠ current snapshot when swaps exist | | I4 | Legacy swap without hybrid columns | Pool-only assumption **or** NULL — documented | | I5 | Provide / withdraw amounts | `+` / `-` both legs | | I6 | Dedup `ON CONFLICT DO NOTHING` | Reserves not overwritten by a second insert of the same `(tx, pair, swap_index)` | ### Contracts (only if wasm attrs land) | ID | Path | Expect | |----|------|--------| | C1 | Swap / provide / withdraw emit `reserve_0` / `reserve_1` after `RESERVES.save` | Equal storage | | C2 | Book-only swap attrs | Reserves equal pre-swap storage | ## Test plan (attack, hack, abuse) | ID | Vector | Expect | |----|--------|--------| | A1 | **Naive full-notional apply** on a hybrid swap (offer/return includes book) | Rejected by tests T3/T4 — book must not drain/inflate GT reserves | | A2 | **Forged wasm** `reserve_0` from a non-pair contract (`#285`) | Parser ignores; only reserved `_contract_address` | | A3 | **SQL / param injection** `fromBlock=1;DROP` / `toBlock=__proto__` | **400**; binds stay `i64` | | A4 | **Range DoS** `fromBlock=0&toBlock=2^63` | **400** (span cap); GET must not walk full `swap_events` history to rebuild reserves | | A5 | **Snapshot poison:** attacker (or stale LCD) writes huge `pair_reserves` | `/gt/events` history unchanged; solver mirror may move (out of scope) | | A6 | **Donate / transfer-in** to the pair CW20 | Reported reserves stay **internal** `RESERVES`, not `balanceOf` (matches sweep design) | | A7 | **Gem / soft-launch** pair addresses in `fromBlock` window | Still omitted; no reserve leak | | A8 | **Reorg:** events at H ingested, then rewind below H | Those rows gone or reserves recomputed; no future-state leftover | | A9 | **Decimal / overflow** 38-digit raw | `NUMERIC(38,0)`; GET `format_dec` does not emit `e+` that breaks GT parse | | A10 | **Orientation spoof** (symbol vs contract) | Asset ids stay contract/denom; reserves follow factory indices | | A11 | **Cache** (if anyone adds a `/gt/events` cache) | Key must include block range; must not cache one snapshot for all events | | A12 | **Tax / community-tax** sell (`#615`) | Reserve delta is **pool** CW20 amount, not trader extra-debit | | A13 | **UST1 / USTR marks** | Do not attach USD, `$1`, or `2.5×` to GT reserves | | A14 | **Skip-backfill cheat** (stamp current snapshot on all historical rows) | Explicitly forbidden; I3 / AC8 fail the MR | ## Verification criteria ```bash make setup-indexer-postgres # if indexer/.env missing make verify-issue-NNN # new: api_gt + ingest/backfill tests + invariant grep make verify-issue-646 # routes / form pack still green ``` Live (after Coolify indexer migrate), without publishing chat: ```bash # Pick a pair with ≥2 recent pool swaps in a ≤2000-block window. curl -sS "https://indexer.dex.cl8y.com/gt/events?fromBlock=H1&toBlock=H2" \ | python3 -c 'import json,sys; ev=[e for e in json.load(sys.stdin)["events"] if e.get("eventType")=="swap" and e.get("pairId")==sys.argv[1]]; print(ev[0]["reserves"], ev[-1]["reserves"]); assert ev[0]["reserves"]!=ev[-1]["reserves"]' PAIR ``` - [ ] Local Postgres tests: T1–T15, I1–I6, A1–A14 as automated as possible (A6/A12 unit or documented skip). - [ ] `docs/indexer-invariants.md` no longer says GT reserves are the current snapshot. - [ ] Production window on a real factory pair (the report pair or UST1/USTR) shows **moving** reserves across swaps. - [ ] `pair_reserves` still one-row upsert; route/solve hybrid tests unchanged. - [ ] No integrator names, emails, or chat logs in the tree. ## Out of scope - CoinGecko `/cg/*` ticker `liquidity_in_usd` rename - Protocol / pool list TVL (`#569` / `#655`) - GeckoTerminal Google form resubmit - Adding Terra Classic to GT `GET /api/v2/networks` (vendor) - Emitting USD or hub prices on `/gt/events`
PlasticDigits commented 2026-08-27 04:45:33 +00:00 (Migrated from gitlab.com)

marked as related to #646

marked as related to #646
PlasticDigits commented 2026-08-27 04:45:38 +00:00 (Migrated from gitlab.com)

mentioned in issue #646

mentioned in issue #646
PlasticDigits commented 2026-08-27 04:50:03 +00:00 (Migrated from gitlab.com)

mentioned in issue #685

mentioned in issue #685
PlasticDigits commented 2026-08-27 04:50:04 +00:00 (Migrated from gitlab.com)

marked as related to #685

marked as related to #685
PlasticDigits commented 2026-08-27 05:29:52 +00:00 (Migrated from gitlab.com)

mentioned in issue #686

mentioned in issue #686
PlasticDigits commented 2026-08-27 07:01:47 +00:00 (Migrated from gitlab.com)

mentioned in commit 9b3fa7fff9

mentioned in commit 9b3fa7fff9fd50babc6a84bd1bf47faeede78f41
PlasticDigits commented 2026-08-27 07:03:58 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1188

mentioned in merge request !1188
PlasticDigits commented 2026-08-27 07:04:56 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1189

mentioned in merge request !1189
PlasticDigits commented 2026-08-27 07:38:08 +00:00 (Migrated from gitlab.com)

mentioned in commit 6ae8b7270b

mentioned in commit 6ae8b7270b55e370142e6fe75e3d510bdcc5f54d
PlasticDigits commented 2026-08-27 07:38:40 +00:00 (Migrated from gitlab.com)

mentioned in commit d9aa9432a0

mentioned in commit d9aa9432a099d239311d634d468abb373779151b
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-27 07:38:41 +00:00
PlasticDigits commented 2026-08-27 07:40:57 +00:00 (Migrated from gitlab.com)

mentioned in commit cf1c0a63e6

mentioned in commit cf1c0a63e63d1031d4852f0a04b5f6ab4a93906b
PlasticDigits commented 2026-08-27 07:43:02 +00:00 (Migrated from gitlab.com)

mentioned in issue #688

mentioned in issue #688
PlasticDigits commented 2026-08-27 07:43:02 +00:00 (Migrated from gitlab.com)

marked as related to #688

marked as related to #688
PlasticDigits commented 2026-08-27 07:43:21 +00:00 (Migrated from gitlab.com)

Merged as !1189 onto main (d9aa9432). Local AC1–AC10 passed (make verify-issue-684, make verify-issue-646). Conflicts vs #686/#687 leftovers resolved (kept GT post-event reserves row + Llama priced-SUM rows).

Leftovers (do not reopen this issue unless a merged invariant is wrong): Coolify migrate 20260827160000_gt_event_post_reserves.sql, indexer redeploy, cl8y-dex-indexer backfill-gt-event-reserves, then live AC11 two-swap window. Tracked on #688.

CI was failed (ci_quota_exceeded); not waited; not a product signal.

Non-blocking: first catch-up events stay NULL until backfill; same-tx apply order is all swaps then liquidity.

Merged as !1189 onto `main` (`d9aa9432`). Local AC1–AC10 passed (`make verify-issue-684`, `make verify-issue-646`). Conflicts vs #686/#687 leftovers resolved (kept GT post-event reserves row + Llama priced-SUM rows). **Leftovers (do not reopen this issue unless a merged invariant is wrong):** Coolify migrate `20260827160000_gt_event_post_reserves.sql`, indexer redeploy, `cl8y-dex-indexer backfill-gt-event-reserves`, then live AC11 two-swap window. Tracked on #688. CI was `failed` (`ci_quota_exceeded`); not waited; not a product signal. Non-blocking: first catch-up events stay NULL until backfill; same-tx apply order is all swaps then liquidity.
PlasticDigits commented 2026-08-28 09:48:48 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1193

mentioned in merge request !1193
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#684
No description provided.