The unit and the predicate are both things you cannot infer from a field named totalSupply, and getting either wrong is silent: one prints a valuation 10^18 out, the other prints a dash forever. The driver note is there because three packages in this binary want to be sqlite and only one of them can have the name. Co-authored-by: Hanzo Dev <[email protected]>
9.8 KiB
graph
Org: luxfi · Ecosystem: lux · Path: /Users/a/work/lux/luxfi/graph
Origin: https://github.com/luxfi/graph.git
Discovery
This file (CLAUDE.md) is the canonical agent-facing readme; LLM.md is a symlink to it. Update either name and both stay in sync.
Where to look first
README.md— human-facing overview (if present)package.json/Cargo.toml/pyproject.toml/go.mod— language & deps.github/workflows/— CI surfacedocs/— extended docs (if present)
A token's totalSupply is WHOLE TOKENS, and a named row is not a finished one
Two things about Token that a reader has to know before touching erc20.go:
- One unit.
SeedTokenData.TotalSupply(andStaked) is whole tokens as an exact decimal string, never base units. The contract reports base units andpublishNativeSupplyreports a genesis figure already whole; both land in the same field, and a client that scales one and not the other is a valuation 10^18 out.wholeUnitsdoes the conversion, with integer division and the remainder — float64 gives up around 2^53, four orders of magnitude below a two-trillion-token supply. The exchange multiplies this by a price directly. - One predicate.
settled(row, addr)— a real symbol AND a supply — decides whether a row still has anything to ask the chain.token()is the only resolver: store first, contract for the rest, memoised per address for the process, persisted once.BackfillTokensasks the same predicate at every start, so a row is completed by starting rather than by remembering a flag. Keying "finished" on the symbol alone is what left every token indexed before v1.2.25 supply-less forever: the row had a name, so nothing asked again, and market cap and fully diluted value printed a dash however much it traded.
resolve folds a read over the stored row and never erases: a reverting call
must not downgrade a real symbol, and SeedToken is INSERT OR REPLACE, so a
write built only from the contract read zeroes the value locked, volume, price
and trade count the valuation pass put on that same row.
🪤 The sqlite driver is hanzoai/csqlite, opened as "sqlite3". Not upstream
mattn (a second copy of the same amalgamation — every sqlite3_* symbol twice,
a link failure on darwin), and not the hanzoai/sqlite wrapper, which registers
the name "sqlite" — as does modernc.org/sqlite, which arrives here with
hanzoai/replicate next door in storage. Two packages under one name is a
panic in database/sql init, before main runs: v1.2.28 through v1.2.30 could
not start at all.
A swap's timestamp is a TIME, and the poll is what makes it one
eth_getLogs says which block a log was in and never when it was mined. Until
v1.2.17 the swap handlers wrote the block NUMBER into timestamp, so trade
history dated every fill to 1970 and no day could be computed from a swap at
all. stampTimes (blocks.go) now reads one batch of headers per poll and hangs
the time on the logEntry, and poll returns an error rather than advancing
past a block whose header did not answer — a block is indexed when it has been
read whole.
SeedSwapData.Block is what tells an old row from a new one: no swap is mined
in block 0, so Block == 0 means "the number in Timestamp is a block number",
which is exactly what healSwapTimes needs to look the real time up. That is
Dated(), and both the heal and the day series ask it. The heal runs once at
Run and stops when every row has a time.
The day series (series.go) is a SECOND FOLD over the valuation pass's trades
tokenDayDatas / poolDayDatas / uniswapDayDatas are rolled out of the swaps
already on disk, which is why history appears the moment a build ships instead
of starting that day. valuedTrades reads and values the swap window ONCE;
valueSwaps folds it into running totals and writeSeries folds the same
stream into days. Do not add a second read or a second pricing path —
valuation.go owns pricing (see its header).
- What is exact. A pool's candle is its own execution ratio
amount0/amount1. 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. Pricing a token off its OWN leg yields a flat line at today's price — that mutation is covered by a test. - What is not. Value locked is not in the trades. Each day records it while
it is today; a day that passed before the series existed reports
"", never"0.00"(fmtHeld) — zero would draw a cliff to no liquidity. - Write only what moved. A pass recomputes the whole history every 30s;
idx.writtenremembers each cell as persisted so the steady state is one write per subject that traded. Rewriting everything is the O(rows) storm that once stopped this pass from finishing. - Rows are entities (
storage.TokenDay/PoolDay/FactoryDay) built from theenginestructs, so the json tags ARE the wire.Store.Entitiesreads a whole type then filters/orders/limits — a SQL LIMIT before the filter would answer with the first N rows rather than the first N matches. - The client draws nothing under three points; fewer renders "Missing chart
data", the same as an error.
pairDayDatasand the hour series stay stubs — no client asks for them.
where learned two things a chart needs: a filter naming a reference
({token: "0x…"}) compares against the referenced entity's id, and an exact
text match ignores case, because an address arrives checksummed as often as
lower-cased.
DEX indexing has TWO orthogonal sources (EVM 0x9999 logs + native D-Chain CLOB)
The dex subgraph (markets/fills/orders/orderbook) can be fed from either or both of:
- EVM 0x9999 settlement logs — the cross-chain SETTLEMENT view.
handleDEXFill/handleInitializeV4derive Market/Fill fromDEXFill/Initializelogs emitted by the 0x9999 PoolManager on an EVM chain (C-Chain). Source =indexer.goeth_getLogs. - Native D-Chain CLOB read RPC — the TRADING view. On a native-DEX deployment the
trade engine IS the D-Chain (dexvm): a trade is a consensus state transition at
Block.Verify, NOT an EVM event, so it NEVER appears on the EVM RPC.CLOBSource(indexer/clob.go) polls the committed-state read RPC…/v1/bc/<D>/dex/clob_get_{markets,trades,orders}(GET/JSON, READ-ONLY, zero consensus impact — seeluxfi/dex pkg/dchain/read.go) and writes the SAME Market/Fill/Order entities the resolvers serve. Enabled byConfig.DexRPC(graph) /dex_rpc:per-chain (explorerchains.yaml); empty = source 1 only.
The two sources are ORTHOGONAL — distinct loop, distinct protocol, distinct endpoint;
they share only the store (the sink) and the entity shapes (the contract). Running the
CLOB source NEVER regresses AMM/EVM indexing (the amm subgraph never gets dex_rpc).
Both Markets key by poolID, so when both run they converge on one Market per pool:
writeMarket refreshes the live CLOB book summary and MERGES through any EVM-accrued
volume24h/tradeCount/lastPrice it does not own.
Markets serve on the same host under distinct slugs (.../cchain/amm/graphql,
.../cchain/dex/graphql).
Native CLOB source notes (clob.go)
- Fills are a GLOBAL feed. The 80-byte on-chain trade row carries no poolID (frozen
GPU ABI), so
clob_get_tradesis a global height-ordered log.writeTraderecords every fill as aFill(the trade-history view) keyedheight:seq(deterministic → idempotent re-read). Per-market figures (open orders, depth, best bid/ask, remaining) come fromclob_get_markets' authoritative per-market book summary — we do NOT fabricate a per-market volume by mis-attributing the global log. - Incremental.
drainTradespages bysince=lastHeight+1; a steady chain costs one small query per tick. Symbol decode: a bound market's hex symbol (4c55…= ASCIILUX/LUSD) → human pair; an unbound poolID stays hex.
Native DEX indexing (0x9999) — the EVM-settlement source detail
- Single Fill source. A settled fill is recorded ONLY from the authoritative
DEXFillevent inhandleDEXFill.handleSwapV4is the AMM-side view and does NOT write a Fill. Do not add a second fill source. - Emitter-scoped.
handleDEXFilldrops anyDEXFillnot emitted by the configuredPoolManager(0x9999) —!isPoolManager(l.Address)returns early. This is the anti-spoof control; keep it. - Topic0 hashes (
indexer/events.go) are keccak256 of the EXACT signatures theluxfi/precompileemits (see~/work/lux/precompile/dex/events.go). The LuxSwapcarries a trailinguint24 fee— NOT the upstream Uniswap-v4 signature.DEXFill=keccak256("DEXFill(bytes32,address,uint256,uint256)"). If you change a signature in the precompile, recompute the hash here or it matches nothing. - Re-genesis self-heal rewinds the cursor only on a confirmed re-genesis (changed genesis-block hash) after a hysteresis streak — never on a transient low-head blip.
Deployment topology — TWO exchange-api images (do not conflate)
ghcr.io/luxfi/graph(this repo,Dockerfile→graphd) backs the explorer. Built by.github/workflows/docker.ymlonv*tags, self-hosted ARC pool.ghcr.io/luxfi/exchange-apihas TWO independent source/version lines:v2.2.x← repoluxfi/exchange-api(TypeScript,/v1/graphqlwith amm/dex routing +DEX_SUBGRAPH_URL). Deployed in lux-explorer. This is the markets API.v5.x← repoluxfi/exchangeDockerfile.api(server.js, REST/v1/tokens|pools|trades|price+/subgraph/*). Deployed in lux-ns. These are NOT the same codebase. A change to one does not reach the other's image.
Sibling repos
See the org-level LLM.md at /Users/a/work/lux/luxfi/LLM.md for the full inventory of sibling repos and inter-repo dependencies.