8 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 1d41fb2e7f Give every trade its price back
A pool reported thousands of dollars of volume over a list of trades that each
said they were worth nothing. The value was already computed — the pass folds
it into the pool total — and then dropped, because writing it back had once
sent every row out as its own committed statement and a chain with real trade
history never finished a pass. Behind one transaction fifty thousand rows take
a quarter of a second, so the trades now carry the figures that add up to the
total above them.

Also swaps SQLite for the pure-Go build. The C driver ships its own copy of the
amalgamation, and anything else in the binary carrying one collided with it at
link time — 283 duplicate symbols, which took down every test in the repo on
macOS. With that gone the suite runs again, and it immediately found two
fixtures that never created their tables: the seeds were writing into nothing
and reporting no error.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-11 16:34:45 -07:00
zeekayandHanzo Dev 1dec0e83f2 Give a chart something to draw: a day series over the trades already indexed
Every price chart on the site read "Missing chart data". Not because the
trades were missing — they were on disk, and the swaps query serves them —
but because tokenDayDatas, poolDayDatas and uniswapDayDatas each answered
[] from a hardcoded stub, and a client draws nothing under three points.

Underneath that was a clock problem. eth_getLogs says which block a log was
in and never when, so a swap's `timestamp` held the block NUMBER — trade
history dated every fill to January 1970, and a day series was not merely
empty but impossible. The poll now reads the header of each block it saw an
event in, and declines to advance past one it could not read: a block is
indexed when it has been read whole. Rows an older build wrote are dated
from the same headers, which is what lets the series cover the history
already indexed rather than starting the day it ships.

The series itself is a second fold over the trade stream the valuation pass
already builds — one read, one valuation, two questions. A pool's candle is
its own execution ratio, exact for every day. A token's is that ratio carried
to USD through the other leg, so it is exact against a stablecoin and exact
in shape against a volatile one. Value locked is not in the trades at all, so
each day records it while it is today.

Recomputing the whole history every thirty seconds must not rewrite it, so a
day is written only when it moves: in the steady state that is one day per
subject that traded.

Filtering learned two things a chart needs. `where: {token: "0x…"}` names a
reference, and the row carries the token itself so the client can select
through it — so a filter now compares against the referenced id. And an
address arrives checksummed as often as lower-cased, so exact matches on text
ignore case.

pairDayDatas and the hour series stay stubs. Nothing asks for them.

Tests: 18 deliberate mutations — an off-by-one day boundary, high taking the
smallest price, the where filter dropped, the limit applied before the sort,
trades ordered by id, a log index read as decimal, a candle priced off its own
leg, and the call sites that write the series and read the block times removed
outright — each one turns a test red.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-11 11:17:16 -07:00
zeekayandHanzo Dev 843678ae8b fix(storage): an empty list is [], not null
A store getter that answers with a list built it from `var result
[]interface{}` — a nil slice — so "nothing matched" marshalled to JSON
`null` instead of `[]`.

On a GraphQL wire those are opposite diagnoses. `{"data":{"markets":[]}}`
says "deployed, healthy, nothing has happened yet"; `{"data":{"markets":
null}}` is the signature of a subgraph that is absent, unsubscribed or
erroring behind a 200. The `dex` subgraph shipped the second while being
the first: mounted at /v1/graph/cchain/dex, subscribed to the 0x9999
DEXFill topic and caught up to the chain head, it reported itself dead on
every query purely because no fill had been emitted yet — leaving no way
to tell a quiet DEX from a broken pipe.

Six getters were affected (ListByType, GetTokens, GetFactories, GetPools,
GetPairs via GetPools, GetSwaps) plus the shared FilterResults tail. Every
other list getter in both backends already returned []interface{}{}; this
restores the convention rather than introducing one.

The regression test is reflective — it walks every (interface{}, error)
getter on *Store and fails any that answers with a nil slice — so a getter
added later cannot reintroduce the bug by being forgotten in a hand-written
list. 26 getters are covered.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 14:03:37 -07:00
zeekayandHanzo Dev 84a4bdf91e perf(indexer): bound the swap history one valuation pass reads
The previous commit stopped the pass WRITING a row per swap. It still READ
every swap row in the table, every 30 seconds, into a map.

That is the same shape of mistake one layer down. A live chain's swap table
grows without limit — the Lux C-Chain's already spans blocks 53..1,096,496 in
a 732 MB store — so the cost of a pass grows with chain age forever, and the
allocation churn lands on a process sharing 4Gi with six other chains.

storage.SwapsRaw is replaced by RecentSwapsRaw(limit), which uses the indexed
timestamp column to take the newest `limit` rows in SQL rather than filtering
in Go. The pass takes the newest 50,000.

Where that bound binds, the volume figure is the volume of those 50,000 trades
and the log line says so outright. A partial sum silently presented as a total
is worse than a smaller number that is honest about its window.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 18:42:27 -07:00
zeekayandHanzo Dev 1015359a85 feat(indexer): valuation pass — derive AMM TVL, prices and volume from chain state
Every USD field the exchange reads was an empty string. `pools`, `tokens` and
`factories` all resolved — with real addresses, real symbols, real fee tiers —
and then reported totalValueLockedUSD:"", volumeUSD:"", token0Price:"". The
landing page's TVL / 24h-volume / active-pool header had nothing to render, so
it rendered nothing.

Nothing was ever writing those fields. processLog's Sync/Mint/Burn arm was an
explicit no-op ("storage for these types not yet wired"), and bumpFactory's
comment deferred the USD aggregates to a "price oracle / day data" that does not
exist. Sync is the only event carrying V2 reserves, so with it inert there was
no path — at any block height — from the chain to a TVL number.

Fixed by adding the missing concern rather than by decoding one more event:

  indexer/valuation.go is the ONE owner of the derived USD aggregates. It reads
  balanceOf(pool) for both legs of every registered pool in a single JSON-RPC
  batch, anchors stablecoins at $1 by symbol, and relaxes prices outward through
  the pool graph — each unknown token taking its quote from the DEEPEST pool
  that already has a priced side, which is both deterministic and the least
  manipulable choice when pools disagree. A token no path connects to a stable
  stays unpriced and contributes nothing: unknown is reported as unknown.

Reserves are chain STATE, not chain HISTORY, so this is a pass beside the log
poller, not a handler inside it. That distinction is the whole point:

  - Sync exists only on V2. balanceOf answers the same question for V2, V3 and
    V4 identically.
  - An event-sourced TVL only converges by replaying all history, and a pool
    whose last Sync is a million blocks back reads empty until it trades again.
    On an idle chain it never trades again.
  - It is one fact, so it gets one writer. The Sync/Mint/Burn arm stays inert on
    purpose and now says why.

poolCount moves to the same pass for the same reason: it is derived from the
store, not accumulated by the create handlers, so it can no longer drift across
a restart, a re-index or a duplicate create. bumpFactory keeps the one field
that genuinely is a running total of events — txCount.

Fails closed: an unreachable RPC aborts the pass rather than overwriting good
aggregates with zeros.

Tests: valuation_test.go drives a stub RPC shaped like the live mainnet book —
a stablecoin pair anchoring a WLUX pair — and asserts derived TVL, spot prices,
per-token TVL, factory poolCount, the deeper-quote rule, non-clobbering of
fields the pass does not own, and the RPC-blip guard. Full suite green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 18:20:23 -07:00
zeekay f04061d5fa fix: gofmt -s across repo (CI format check) 2026-06-02 11:38:29 -07:00
zeekay 6aeee65a92 Add CI/CD pipelines, unit tests, release workflow
CI:
- ci.yml: build, vet, unit tests, e2e (SQLite + in-memory), benchmarks
  on ubuntu + macos matrix
- release.yml: cross-compile binaries (linux/darwin x amd64/arm64),
  multi-platform Docker image to ghcr.io/luxfi/graph, GitHub Release

Tests:
- engine/engine_test.go: parser (fields, args, where filters, aliases,
  depth, paren matching, limit clamping)
- indexer/indexer_test.go: hex parsing, uint256 decoding, topic address
  extraction, event signature coverage
- storage/store_test.go: FilterResults (exact, gte, lt, empty, no-match),
  sortResults (numeric, asc, string fallback), compareValues (numeric
  strings, mixed types)
2026-04-11 14:25:57 -07:00
zeekay de2e43e606 Initial commit: Lux Graph Engine
Single-binary GraphQL query layer replacing The Graph's Graph Node.
SQLite WAL + hanzoai/replicate S3 streaming + luxfi/age PQ encryption.

- 18 schemas: AMM V2/V3/V4, DEX, FHE, Platform, XChain, Bridge,
  Privacy, Quantum, Key, AI, Identity, Oracle, Relay, ServiceNode,
  Precompile, ERC20, ERC721
- WASM subgraph loader (uni-v2/v3/v4 build/ dirs)
- EVM indexer: eth_blockNumber + eth_getLogs + V2/V3/V4 event decoding
- Production hardened: rate limiter, query depth/field caps,
  MaxBytesReader, structured logging, /ready, /metrics
- 32 e2e tests, 13 benchmarks, 775 qps @ 100 clients on SQLite
- Dockerfile (multi-stage CGO, non-root, healthcheck)
2026-04-11 13:47:05 -07:00