Indexer stores swap price with no base/quote orientation — 24h high/low and CG/CMC feeds flip between P and 1/P by trade direction #466

Closed
opened 2026-07-01 12:57:03 +00:00 by Brouie · 11 comments
Brouie commented 2026-07-01 12:57:03 +00:00 (Migrated from gitlab.com)

Came out of the pre-launch security/data-integrity sweep (rolls up under the #381 hardening umbrella). This one's a data bug, not a contract exploit, but it's already feeding garbage to CoinGecko/CoinMarketCap and the dapp, so I'm flagging it hot.

What it is

The indexer computes and stores a swap's price without any reference to the pair's base/quote orientation. Every swap just records return_amount / offer_amount — but "offer" and "ask" flip depending on which way the trade went. So a buy stores P and the very next sell stores 1/P into the same price column. OHLC, 24h high/low, last_price, and bid/ask all end up mixing a number and its reciprocal.

Where / the mechanism

indexer/src/indexer/parser.rs:400

let price = if swap.offer_amount > BigDecimal::from(0) {
    &swap.return_amount / &swap.offer_amount
} else {
    BigDecimal::from(0)
};

There's zero reference to pair.asset_0 / pair.asset_1 here. The pair is resolved just above (pairs::get_pair_by_address, :375) and both leg asset ids are known (offer_asset_id / ask_asset_id, :397-398), but none of that is used to normalize the ratio. Whatever leg happened to be the offer becomes the denominator.

That raw value then:

  • gets persisted straight into swap_events.price via insert_swap(...) at :433, and
  • gets handed to candle_builder::update_candles_for_swap(...) at :448 as the candle price.

Downstream it's consumed orientation-blind:

  • indexer/src/db/queries/swap_events.rs:227-228 — MAX(price) AS high, MIN(price) AS low over the 24h window, plus open/close picked by timestamp order (:242-249). So across a window with trades in both directions, high is the max of {P values and 1/P values} and low is the min — the "low" is quite literally the reverse-direction price.
  • indexer/src/api/cg.rs:170-188 — last_price = close_price, high/low from the stats above, and bid = last * 0.999 / ask = last * 1.001 (:177-178) built off that same last_price. cmc.rs reads the same stats.

Same root cause also poisons the volume rows right there in the query (SUM(offer_amount) AS volume_base, SUM(return_amount) AS volume_quote, :223-224) — those sum offer/return regardless of direction, so base and quote volume are cross-contaminated too. But the price inversion is the loud one.

How to hit it

Nothing exotic — normal two-sided trading triggers it:

  1. Trade offering asset_0 into a pair → stores asset_1/asset_0 (e.g. 50.5).
  2. Anyone trades the other way, offering asset_1 → stores asset_0/asset_1 (the reciprocal, ~0.0198) into the same column.
  3. Query the 24h window → MAX(price) picks up the forward-direction ~50, MIN(price) picks up the reverse-direction ~0.0198.

You don't even need to reconstruct it — it's live on the QA indexer right now. From /cg/tickers:

  • EMBER_ONYX: last 48.5, low 0.0198 (= 1/50.5)
  • EMBER_JADE: last 97, low 0.0099 (= 1/~101)
  • EMBER_COBALT: last 485, low 0.00198 (= 1/~505)

Every one of those "lows" is just 1 / (a forward-direction price). That's not a market low, it's the inverse unit sitting in the same column.

Impact

  • CoinGecko/CoinMarketCap get inverted/nonsense high/low and a bid/ask spread computed off a last_price that itself flips by direction. Public feeds, pre-launch — reputational and listing-integrity risk.
  • The dapp reads the same candles/stats, so charts and 24h stats are wrong.
  • Any consumer that trusts OHLC (alerts, external aggregators) inherits garbage.

Fix direction

Normalize price to a fixed base/quote orientation at write time, in process_swap before it's stored and before it hits the candle builder:

  • Decide the canonical quote leg from pair.asset_0/pair.asset_1 (whatever convention CG/CMC ticker_id already uses — cg.rs builds a0.symbol_a1.symbol with asset_0 as base).
  • If the swap's offer leg is the base asset, price = return/offer; if the offer leg is the quote asset, invert it (price = offer/return). So the column always means "quote per base" regardless of trade direction.
  • Same normalization needs to apply to the volume aggregation (bucket offer/return into base/quote by asset id, not by SUM-of-offer / SUM-of-return).
  • Backfill: recompute swap_events.price (and the volume split) for existing rows and rebuild candles, since the historical column is already mixed.

Happy to write the normalization + a repro test (two-sided swap → assert high/low aren't reciprocals) once we agree on the canonical orientation.

@PlasticDigits — this is going out to CG/CMC on the public feed and it's wrong right now, so I'd treat it as launch-blocking for the data side. Wanted it on your radar before any listing push.

Came out of the pre-launch security/data-integrity sweep (rolls up under the #381 hardening umbrella). This one's a data bug, not a contract exploit, but it's already feeding garbage to CoinGecko/CoinMarketCap and the dapp, so I'm flagging it hot. ## What it is The indexer computes and stores a swap's `price` without any reference to the pair's base/quote orientation. Every swap just records `return_amount / offer_amount` — but "offer" and "ask" flip depending on which way the trade went. So a buy stores `P` and the very next sell stores `1/P` into the same `price` column. OHLC, 24h high/low, last_price, and bid/ask all end up mixing a number and its reciprocal. ## Where / the mechanism `indexer/src/indexer/parser.rs:400` ```rust let price = if swap.offer_amount > BigDecimal::from(0) { &swap.return_amount / &swap.offer_amount } else { BigDecimal::from(0) }; ``` There's zero reference to `pair.asset_0` / `pair.asset_1` here. The pair *is* resolved just above (`pairs::get_pair_by_address`, :375) and both leg asset ids are known (`offer_asset_id` / `ask_asset_id`, :397-398), but none of that is used to normalize the ratio. Whatever leg happened to be the offer becomes the denominator. That raw value then: - gets persisted straight into `swap_events.price` via `insert_swap(...)` at :433, and - gets handed to `candle_builder::update_candles_for_swap(...)` at :448 as the candle price. Downstream it's consumed orientation-blind: - `indexer/src/db/queries/swap_events.rs:227-228` — `MAX(price) AS high`, `MIN(price) AS low` over the 24h window, plus open/close picked by timestamp order (:242-249). So across a window with trades in both directions, `high` is the max of {P values and 1/P values} and `low` is the min — the "low" is quite literally the reverse-direction price. - `indexer/src/api/cg.rs:170-188` — `last_price` = `close_price`, `high`/`low` from the stats above, and `bid = last * 0.999` / `ask = last * 1.001` (:177-178) built off that same last_price. `cmc.rs` reads the same stats. Same root cause also poisons the volume rows right there in the query (`SUM(offer_amount) AS volume_base`, `SUM(return_amount) AS volume_quote`, :223-224) — those sum offer/return regardless of direction, so base and quote volume are cross-contaminated too. But the price inversion is the loud one. ## How to hit it Nothing exotic — normal two-sided trading triggers it: 1. Trade offering asset_0 into a pair → stores `asset_1/asset_0` (e.g. 50.5). 2. Anyone trades the other way, offering asset_1 → stores `asset_0/asset_1` (the reciprocal, ~0.0198) into the same column. 3. Query the 24h window → `MAX(price)` picks up the forward-direction ~50, `MIN(price)` picks up the reverse-direction ~0.0198. You don't even need to reconstruct it — it's live on the QA indexer right now. From `/cg/tickers`: - EMBER_ONYX: last 48.5, low 0.0198 (= 1/50.5) - EMBER_JADE: last 97, low 0.0099 (= 1/~101) - EMBER_COBALT: last 485, low 0.00198 (= 1/~505) Every one of those "lows" is just `1 / (a forward-direction price)`. That's not a market low, it's the inverse unit sitting in the same column. ## Impact - CoinGecko/CoinMarketCap get inverted/nonsense high/low and a bid/ask spread computed off a last_price that itself flips by direction. Public feeds, pre-launch — reputational and listing-integrity risk. - The dapp reads the same candles/stats, so charts and 24h stats are wrong. - Any consumer that trusts OHLC (alerts, external aggregators) inherits garbage. ## Fix direction Normalize price to a fixed base/quote orientation at write time, in `process_swap` before it's stored and before it hits the candle builder: - Decide the canonical quote leg from `pair.asset_0`/`pair.asset_1` (whatever convention CG/CMC ticker_id already uses — cg.rs builds `a0.symbol_a1.symbol` with asset_0 as base). - If the swap's offer leg is the base asset, `price = return/offer`; if the offer leg is the quote asset, invert it (`price = offer/return`). So the column always means "quote per base" regardless of trade direction. - Same normalization needs to apply to the volume aggregation (bucket offer/return into base/quote by asset id, not by SUM-of-offer / SUM-of-return). - Backfill: recompute `swap_events.price` (and the volume split) for existing rows and rebuild candles, since the historical column is already mixed. Happy to write the normalization + a repro test (two-sided swap → assert high/low aren't reciprocals) once we agree on the canonical orientation. @PlasticDigits — this is going out to CG/CMC on the public feed and it's wrong right now, so I'd treat it as launch-blocking for the data side. Wanted it on your radar before any listing push.
PlasticDigits commented 2026-07-07 02:18:04 +00:00 (Migrated from gitlab.com)

mentioned in commit ff28fbba19

mentioned in commit ff28fbba19d3fdf38b8d3300331f6589c2d2cb98
PlasticDigits commented 2026-07-07 02:18:18 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1008

mentioned in merge request !1008
PlasticDigits commented 2026-07-07 02:34:59 +00:00 (Migrated from gitlab.com)

mentioned in commit a5bbd55e16

mentioned in commit a5bbd55e16b3c5b753a1d8793aeb4ba410c59437
PlasticDigits commented 2026-07-07 02:54:31 +00:00 (Migrated from gitlab.com)

Verification — #466 (swap price orientation)

Result: PASS — fix merged on main (ff28fbba / MR fix/466-swap-price-orientation).

Acceptance criteria

Item Result How verified
Normalize swap_events.price to quote per base (asset_1 / asset_0) at index time PASS indexer/src/indexer/swap_orientation.rs + parser.rs calls orient_swap_leg before insert_swap and update_candles_for_swap
Offer base → return/offer; offer quote → invert to same canonical price PASS cargo test swap_orientation --lib — 3/3
Two-sided trading → 24h high/low are not reciprocals PASS cargo test --test swap_price_orientation oriented_two_sided_swaps_high_low_are_not_reciprocals -- --test-threads=1
Volume aggregation buckets by asset id, not raw offer/return PASS cargo test --test swap_price_orientation oriented_volume_aggregation_sums_base_and_quote_legs -- --test-threads=1; get_24h_stats_for_pair / get_24h_stats_all_pairs SQL uses CASE WHEN offer_asset_id = asset_0_id
CG/CMC feeds consume oriented stats PASS cg.rs reads close_price / oriented high/low / volumes from stats; cargo test --test api_aggregator_batch -- --test-threads=1 — 5/5
Backfill migration for historical rows + candle rebuild PASS migrations/20260707000000_normalize_swap_price_orientation.sql recomputes price and TRUNCATE candles
Docs / invariants updated PASS docs/indexer-invariants.md (#466 row), docs/CG_CMC_COMPLIANCE.md

Environment

  • Postgres via make setup-indexer-postgres
  • LocalTerra not required for this issue (indexer-only data normalization)

SKIP

Item Reason
Live QA /cg/tickers reciprocal-low check Pre-fix symptom on external QA host; requires post-deploy migration + seed-qa --clean (or candle rebuild) on that environment — not available in this verify VM

Follow-ups

  • After deploying this build to QA/production: run migration, rebuild candles (cargo run -- seed-qa --clean on QA or per-pair rebuild), then spot-check /cg/tickers and /cmc/summary that low is no longer 1/high.
## Verification — #466 (swap price orientation) **Result: PASS** — fix merged on `main` (`ff28fbba` / MR `fix/466-swap-price-orientation`). ### Acceptance criteria | Item | Result | How verified | |------|--------|--------------| | Normalize `swap_events.price` to **quote per base** (`asset_1` / `asset_0`) at index time | **PASS** | `indexer/src/indexer/swap_orientation.rs` + `parser.rs` calls `orient_swap_leg` before `insert_swap` and `update_candles_for_swap` | | Offer base → `return/offer`; offer quote → invert to same canonical price | **PASS** | `cargo test swap_orientation --lib` — 3/3 | | Two-sided trading → 24h `high`/`low` are not reciprocals | **PASS** | `cargo test --test swap_price_orientation oriented_two_sided_swaps_high_low_are_not_reciprocals -- --test-threads=1` | | Volume aggregation buckets by asset id, not raw offer/return | **PASS** | `cargo test --test swap_price_orientation oriented_volume_aggregation_sums_base_and_quote_legs -- --test-threads=1`; `get_24h_stats_for_pair` / `get_24h_stats_all_pairs` SQL uses `CASE WHEN offer_asset_id = asset_0_id` | | CG/CMC feeds consume oriented stats | **PASS** | `cg.rs` reads `close_price` / oriented `high`/`low` / volumes from stats; `cargo test --test api_aggregator_batch -- --test-threads=1` — 5/5 | | Backfill migration for historical rows + candle rebuild | **PASS** | `migrations/20260707000000_normalize_swap_price_orientation.sql` recomputes `price` and `TRUNCATE candles` | | Docs / invariants updated | **PASS** | `docs/indexer-invariants.md` (#466 row), `docs/CG_CMC_COMPLIANCE.md` | ### Environment - Postgres via `make setup-indexer-postgres` - LocalTerra not required for this issue (indexer-only data normalization) ### SKIP | Item | Reason | |------|--------| | Live QA `/cg/tickers` reciprocal-low check | Pre-fix symptom on external QA host; requires post-deploy migration + `seed-qa --clean` (or candle rebuild) on that environment — not available in this verify VM | ### Follow-ups - After deploying this build to QA/production: run migration, rebuild candles (`cargo run -- seed-qa --clean` on QA or per-pair rebuild), then spot-check `/cg/tickers` and `/cmc/summary` that `low` is no longer `1/high`.
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-07-07 02:54:32 +00:00
PlasticDigits commented 2026-08-15 11:39:18 +00:00 (Migrated from gitlab.com)

mentioned in issue #522

mentioned in issue #522
PlasticDigits commented 2026-08-15 11:39:18 +00:00 (Migrated from gitlab.com)

marked as related to #522

marked as related to #522
PlasticDigits commented 2026-08-15 11:52:33 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1055

mentioned in merge request !1055
PlasticDigits commented 2026-08-15 12:19:48 +00:00 (Migrated from gitlab.com)

mentioned in issue #524

mentioned in issue #524
PlasticDigits commented 2026-08-15 12:19:51 +00:00 (Migrated from gitlab.com)

marked as related to #524

marked as related to #524
PlasticDigits commented 2026-08-17 03:51:35 +00:00 (Migrated from gitlab.com)

mentioned in issue #543

mentioned in issue #543
PlasticDigits commented 2026-08-18 12:12:15 +00:00 (Migrated from gitlab.com)

mentioned in issue #564

mentioned in issue #564
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#466
No description provided.