24 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 2476031987 An absent bundle is an empty list, not a list holding null
The bundle carries the native coin's USD price. A chain with no wrapped
native has no anchor to publish, so the store reports a miss — and the
resolver wrapped that miss in a one-element list anyway, so `bundles`
answered [null].

Every other collection here answers [] when it holds nothing, and a test
already pins that as the wire signature separating a deployed-and-idle
subgraph from a broken one. Bundles is the one singleton collection, and it
was the exception.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-05 19:35:45 -07:00
zeekayandHanzo Dev 82fd335da0 A token is swappable when a pool holds it
The list was every Token the indexer had ever written, and a Uniswap-v2 PAIR is
itself an ERC-20 — so are v3 position receipts, vault shares and an NFT
collection. All four were offered in the picker as things to trade. Pool
membership excludes them without naming any of them, and is the honest rule
anyway: you can trade a token exactly when there is somewhere to trade it.

It also never offered the chain's own coin. The form sells native LUX through
the zero address, so without that row the commonest trade on the exchange cannot
be expressed at all. The sentinel takes the wrapped token's decimals, because a
scale that disagrees misprices every trade starting in native, and it appears
only when a pool actually holds the wrapped token.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 18:01:42 -07:00
zeekayandHanzo Dev b57874547a Name the venue a route uses rather than call its fee missing
The quoter prices constant-product pairs as well as concentrated ones; this
builds only the concentrated router's calls. A pair carries reserves and no fee
tier, so the refusal read "missing tokenIn/tokenOut/fee" and sent the reader
after a field that does not belong on that hop.

The gap itself stands: a route the quote endpoint returns is one the swap
endpoint cannot build. Nothing on this chain routes through a pair today — the
deepest holds eight dollars — so it is a matter of the two endpoints agreeing
about venues, not a break. Naming the venue is what makes that visible when it
happens.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 17:36:48 -07:00
zeekayandHanzo Dev 4e05024a06 Refuse a hop address, a foreign quote and a slippage outside a percentage
An adversarial pass over the calldata found three more ways a crafted quote
became a signable transaction.

A hop address was taken as given. An address is twenty bytes and the path packs
it against a uint24 with nothing marking the boundary, so a short one shifted
every byte after it — and the path still parsed, as a route through pools
nobody named. Width is the only thing separating the two, so both hop addresses
are now checked the way the swapper already was.

A quote says which chain priced it, and that was ignored. Building this chain's
router call from another chain's quote addresses pools by the same numbers on a
book that never quoted them, and these chains share deployment addresses, so it
is a real trade at a price nobody was shown.

Slippage is a percentage of an amount and lives between none of it and all of
it. Below zero the factor inverted and the bound grew a minus sign; above a
hundred it ran past a word. Clamping the percentage bounds both directions at
one place, where the alternative was clamping every product it reaches.

IsAddress is exported: whether a chain has an address to address a transaction
to is one question, and the process that mounts these handlers has to ask it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 17:30:19 -07:00
zeekayandHanzo Dev 933f97f8a5 Refuse a hop fee that is not a uint24
The path packs a fee in exactly three bytes with an address either side, so
position is the only thing separating them. A fee above 2^24-1 rendered seven
hex characters instead of six and shifted every byte after it — the path still
parsed, and named real pools at addresses nobody asked for. A negative one
rendered as hex with a minus sign in it.

Bounded where the rest of the hop is checked, so a crafted quote is a 400 and
not a transaction.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 17:18:14 -07:00
zeekayandHanzo Dev 6136695afa Refuse an amount that does not fit the word it is rendered into
Every argument in a call is one word wide and position is the only thing that
says which argument is which. An amount above 2^256-1 rendered 65 hex
characters instead of 64, shifting every argument after it — the call stayed
well-formed and meant something else: a different recipient, a different bound.

The bound belongs at parseWei, where an amount enters, so the quote, the swap
and the approval all get it from one place rather than three.

Inflating an exact-output bound could carry a value that fits past what fits,
so it stops at the width instead. A bound wider than the type can express is
not a wider bound.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 17:14:52 -07:00
zeekayandHanzo Dev 256137fc2f Read the input token from the hop when the quote states it only there
A quote names its input token twice — once at the top and once on the first
hop. A quote carrying only the hop is still a quote, and reading just the top
left native input with no value on the transaction, so the router had nothing
to wrap and the swap reverted with nothing in the request to explain it.

Two more cases against recorded bytes: the reversed path of a multi-hop exact
output, and that reversal carrying the fees with it. Both hops of the live
route charge the same tier, so the recording alone cannot tell a reversed fee
list from an unreversed one; distinct tiers pin which pool each fee enters.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 17:06:31 -07:00
zeekayandHanzo Dev a9310b0af3 Build the swap transaction and the approval beside the quote that priced it
The swap form's four questions were split across two services: the indexer
answered what can be traded and what it pays, while a TypeScript service
answered what to approve and what to sign. The last two need the route the
first two produced, so the split meant a quote crossed a process boundary to
be turned into calldata.

HandleSwap encodes the router call from a quote and bounds it by the caller's
slippage. It reads no state — the quote already carries the route, the amounts
and each hop's fee, and re-reading here would be a second opinion about a price
the caller has already accepted, a block later than the one they saw.

HandleCheckApproval reads the allowance the router holds and returns the grant
when it falls short. Silence from a token is not an allowance of zero, and an
unreachable node is not either; both would skip an approval and leave the swap
to revert.

The conversation with a chain moves to one type. It was a method on the quoter,
which left the approval endpoint either reaching through a quoter to ask an
unrelated question or growing a second copy of the batching, timeout and decode.

Calldata is asserted against bytes recorded from the service being retired,
across exact-input, exact-output, single-hop and multi-hop. A wallet signs
these, so a byte is the unit of correctness.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 16:58:58 -07:00
zeekayandHanzo Dev 900a3af738 Move the quote and token handlers where the serving process can reach them
They were written into cmd/graphd, which is package main. Production does not
run graphd: lux/explorer imports graph as a library and mounts the routes
itself, so a handler in main was reachable by nothing that serves traffic.

Same code, now in engine beside the GraphQL engine that reads the same store.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 16:23:34 -07:00
zeekayandHanzo Dev f3c6e28a43 a query that cannot be parsed is refused, not held open
Every pass of the top-level scan must consume something. Standing still on
input it does not understand returns no answer, forever, holding the request
open — and this is a public endpoint. Two deliberate breaks of the scan hung
the whole test suite instead of failing it; now they fail it.

Also covers the whitespace GraphQL allows and clients emit: a space before a
field's arguments, and around an alias's colon.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-11 12:51:43 -07:00
zeekayandHanzo Dev 9bf7bae609 answer every field a document asks for, minified or not
parseTopFields split top-level fields on NEWLINES, so a one-line document
parsed as a single field — name taken up to the first paren — and every field
after it was dropped with no error. The explore page asks for its bundle,
factory, tokens, pools and day series in one document; minified, it would have
been answered with the bundle alone. A field now ends where its own arguments
and selection set end, which is the same place whether the client pretty-prints
or not.

Also from reading the live wire: a day's token-denominated value locked was
rendered as currency (two decimals reads a small holding as none), and the
token a row points at was a stub — a client selecting through it saw a token
that had never traded.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-11 11:47:49 -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 80a4098363 Let a page ask for as many tables as it needs
The Explore transactions view requests 23 tables in one query and got
"too many top-level fields (23), maximum 20" — the whole tab died on it.

20 was a bare literal at the check while every neighbouring limit lives on
Config. Asking for several tables at once is ordinary, not abusive: the cap
exists to bound work per request, so it belongs with the other limits and
starts high enough for real pages. A zero value means unset, not zero
allowed, so configs written before this field keep working.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-11 10:56:23 -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
zeekay 305636ac07 graph: re-genesis hysteresis + preserve factory TVL/volume + rich-market merge
Red MEDIUM (FIX C): maybeResetForRegenesis rewound to StartBlock on the FIRST poll
where head << cursor, so an idle chain (eth_blockNumber=0x0, 'Building block') or a
briefly-lagging RPC backend triggered a full re-index from genesis (DoS amplification).
Add hysteresis: require the low head to persist across regenesisConfirmations
consecutive polls; a recovered/normal head clears the streak. A genuine relaunch holds
low for many polls; a transient dip recovers in one or two.

Red MEDIUM: bumpFactory wrote SeedFactoryData with empty TVL/volume, and SeedFactory is
INSERT OR REPLACE, so every pool-create/swap clobbered totalVolumeUSD/totalValueLockedUSD
(emptying the landing-page header). Carry both USD aggregates through the
read-modify-write (asString helper).

Red LOW: handleInitializeV4 now MERGES with any volume stub a prior fill accumulated
(don't reset volume24h/tradeCount to 0 when enriching with the token pair/fee).
decodeUint256 guards the SetString ok bool (malformed hex -> clean 0, not a half-parsed
amount).

Tests: regenesis requires sustained low head; idle-low-head-that-recovers does NOT wipe;
shallow reorg no reset; Initialize builds a rich Market (pair+fee), merges stub volume;
bumpFactory preserves TVL/volume; decodeInt256 signed. New engine end-to-end: the dex
schema resolves the FE markets/fills query shapes against indexed entities, amm does NOT
(the exchange-api routing rationale, mirroring Red's live finding). Full graph suite
green (CGO_ENABLED=1).
2026-06-21 13:15:52 -07:00
zeekay ed9e132af8 fix(dex): wire native 0x9999 V4/DEXFill indexing + uniswapFactories alias + re-genesis self-heal
- indexer: handleSwapV4 (AMM swap/pool), handleInitializeV4 (pool/Market/factory),
  handleDEXFill (sole CLOB Fill source, scoped to 0x9999), bumpFactory, signed int128 decode
- indexer: poll() re-genesis self-heal (head<<cursor by >reorgDepth => rewind to StartBlock)
- indexer: correct SigInitializeV4/SigModifyLiquidity hashes; add SigDEXFill + LXSettleAddress
- engine: uniswapFactory(ies) alias resolvers (single-source schema-delta fix)
- tests: dex_v4_test (V4/Fill/regenesis/scoping), e2e dex_native_test (dex schema + alias)
- fix pre-existing e2e/wasm_test.go missing os import
2026-06-21 12:26:11 -07:00
zeekay f04061d5fa fix: gofmt -s across repo (CI format check) 2026-06-02 11:38:29 -07:00
zeekay e7e1a95cf8 docs: update comment reference node/vms/graphvm → chains/graphvm 2026-04-13 05:27:19 -07:00
zeekay f0a5411f10 fix: cap GraphQL pagination to 1000 items per page
Prevents OOM from unbounded first:999999999 queries.
All 24 resolver pl() functions + engine.resolveBlocks capped via min(limit, 1000).
2026-04-13 03:11:36 -07:00
zeekay cfb7f3ee6f graph: add utxo + zk + mpc resolvers for full 15-chain coverage
graph had resolvers for 10 of 15 platform chains. This adds the three
missing ones:

- resolvers/utxo/: X-Chain (XVM) — UTXO, Asset, AssetCreateTx, UTXOBalance
- resolvers/zk/:   Z-Chain (ZKVM) — ZKProof, Verifier, CircuitHash, ProofBatch, ZKSession
- resolvers/mpc/:  M-Chain (MPC)  — SigningSession, KeygenSession, SignerSet, Presignature, SignRequest

Registered in engine.go under "utxo", "zk"/"zchain", "mpc"/"mchain"
selectors and added to the "all" batch. Removed the duplicate
"zchain" alias from "privacy" (privacy is a cross-chain protocol, not
Z-Chain — Z-Chain is ZK proofs, now correctly wired to zk.Register).

Now graph supports the same 15 platform chains as indexer:
P, X, C, A, B, D, G, I, K, M, O, Q, R, T, Z.
2026-04-12 22:42:05 -07:00
zeekay 1c5f1f2568 feat: Liquidity Protocol + Liquid Protocol graph resolvers
Liquidity Protocol resolvers:
- CrossChainSwap, LimitOrder, Arbitrage
- OMAPool, OMASwap, PriceUpdate
- StrategyOrder, MarketMaker, BridgeTransfer

Liquid Protocol resolvers:
- LiquidFee, ValidatorReward, SlashingEvent
- DepositMint, YieldMint, WithdrawBurn
- BackingUpdate, VaultStrategy, YieldHarvest

Both registered in engine under 'liquidity', 'liquid-protocol', and 'all'.
2026-04-12 11:48:05 -07:00
zeekay 72147a0672 feat: 8 new graph resolvers — governance, DAO, treasury, liquid, DID, prediction, securities, derivatives (84 tests) 2026-04-12 11:34:18 -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