Skip to content

feat(clickhouse): realtime point-in-time DEX fill state for richer swap filters - #242

Open
jxom wants to merge 3 commits into
mainfrom
feat/dex-enriched-fills
Open

feat(clickhouse): realtime point-in-time DEX fill state for richer swap filters#242
jxom wants to merge 3 commits into
mainfrom
feat/dex-enriched-fills

Conversation

@jxom

@jxom jxom commented Jun 11, 2026

Copy link
Copy Markdown
Member

Why

Cadent's swaps feed currently re-derives each fill's order state in the app on every request: it replays the raw logs order-state stream, walks each orderId to find the latest OrderPlaced/OrderFlipped before the fill, then computes price/orientation/at-peg and assembles routes in memory. That made the atPeg, sourceToken, and destinationToken filters slow and forced post-pagination filtering (they returned fewer than limit rows). Cadent had to remove them as a stopgap.

The existing dex_orders object can't fix this on its own — it decodes OrderPlaced only, so it's stale for flip orders whose token/side/tick change mid-life via OrderFlipped.

What

Two new ClickHouse objects that resolve fill state server-side, point-in-time, flip-correct, and realtime:

  • dex_order_events (table + insert-time MV) — decodes the union of OrderPlaced and OrderFlipped (DEX precompile 0xdec0…0000) into one positioned state stream ordered by (orderId, block_num, log_idx). Block-scoped, backfillable, public. Decoding is row-local, so it's safe to do incrementally/realtime — same pattern as dex_fills/dex_orders.
  • dex_fills_enriched (plain view) — ASOF-joins each fill to the latest dex_order_events row strictly before its (block_num, log_idx), exactly mirroring the app's "latest state event before the fill" rule. Exposes each fill book-natively (one taker filling one resting maker order on one book): token/quote_token, isBid, tick, at_peg, price, and the base/quote amounts (amountFilled/quote_amount).

Why book-native, not taker source→destination

A fill is natively book-oriented. Taker source→destination is a swap-level (route) notion, not a per-fill property: a swap groups fills by (tx, taker) and its source/destination are the route ends, which only come from assembling the group in order. So the API derives orientation/route/rate from isBid during getSwaps assembly; the view stays honest to what a fill is, and avoids redundant columns.

Why a plain view, not a materialized view

The join is resolved at query time over the already-decoded, sort-keyed source tables:

  • Realtime — a fill is filterable the instant it lands; no refresh lag, no rolling-window cutoff.
  • Correct — reads the complete, ordered, reorg-corrected state at query time, so same-block flips and late/out-of-order events resolve right.
    • An insert-time MV can't: sibling MVs (dex_fills_mv, dex_order_events_mv) off one logs insert have no guaranteed execution order, so a flip in the same block as a later fill could be joined before it exists.
    • A refreshable MV is correct but only as fresh as its last recompute.
  • Cheap enough — the heavy raw-log decode is already materialized + sort-keyed in dex_order_events (by orderId), so per-query cost is an ASOF join over decoded rows, far below the app's current per-request raw-logs replay. Callers scope cost with the usual time/token/pagination predicates, applied in SQL before LIMIT (which also fixes the original post-pagination-filtering bug).

Validation

  • cargo test --lib — passing (incl. order_events_decode_placed_and_flipped_from_logs, enriched_fills_are_realtime_point_in_time_join, updated decoded_tables_are_block_scoped_and_public)
  • cargo fmt --check, cargo clippy --all-targets — clean
  • Against a real ClickHouse instance (clickhouse-server 26.5):
    • ensure_schema() applies all DDL cleanly (caught + fixed a FINAL-alias ordering bug)
    • decode offsets verified against a synthetic OrderPlaced payload incl. a negative tick (two's-complement): amount=1000000, isBid=1, tick=-3
    • object confirmed as engine View; seeded fills appear in the view immediately, with no refresh
    • placed→flipped order with fills on either side → bid/at-peg pre-flip, ask/off-peg post-flip ✓
    • same-block flip: a fill at (300,5) correctly resolved to the flip at (300,1) rather than the earlier placed state — the exact case an insert-time MV mis-resolves ✓
  • Benchmark (local CH 26.5, synthetic 5.24M logs / 1M fills / 240k order events): token filter ~1.8× faster than the raw-logs subquery (65 vs 119 ms); at_peg now expressible in SQL (80 ms) instead of post-pagination JS; windowed unfiltered head page near parity (92 vs 78 ms, before counting OLD's separate state query).

Follow-up

Cadent side will rewrite getSwaps to read these objects, deriving swap source→destination/route/rate from the book-native fill columns during assembly, and restore atPeg/sourceToken/destinationToken without post-pagination filtering.

jxom added 2 commits June 12, 2026 08:47
…-in-time fill state

Decode OrderPlaced+OrderFlipped into a positioned order-state stream
(dex_order_events) and ASOF-join each fill to the latest preceding state
in a refreshable MV (dex_fills_enriched), materializing token/isBid/tick,
at_peg, price, quote amount and taker-oriented source/destination tokens.
Lets Cadent's swaps feed restore at-peg and source/destination-token
filters as SQL column reads instead of per-request raw-log replay.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb863-5051-7439-a545-3f40e9e65a55
Resolve the fill->order-state ASOF join at query time over the already
decoded, sort-keyed dex_order_events / dex_fills tables instead of in a
refreshable MV. A plain view is realtime (no refresh lag), correct for
same-block flips and reorgs (it reads the complete live state), and still
far cheaper than the app's per-request raw-logs replay. Validated against
ClickHouse 26.5 incl. a same-block flip+fill that an insert-time MV would
mis-resolve.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb863-5051-7439-a545-3f40e9e65a55
@jxom jxom changed the title feat(clickhouse): point-in-time DEX fill state for richer swap filters feat(clickhouse): realtime point-in-time DEX fill state for richer swap filters Jun 11, 2026
Drop the per-fill taker-oriented source_*/destination_* columns. A fill is
natively one taker filling one resting maker order on one book, so the view
exposes token/quote_token, isBid, tick, at_peg, price and the base/quote
amounts (amountFilled/quote_amount). Taker source->destination is a
swap-level (route) notion derived during getSwaps assembly, not a per-fill
property; removing it also drops the redundant quote_amount/quote_token
duplication.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb863-5051-7439-a545-3f40e9e65a55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant