121 Commits
Author SHA1 Message Date
zeekay 8142506538 Value pools only at a head that has reached the index, and read stake where luxd serves it
A chain re-imported from genesis reports a head far under the indexed cursor;
its state there predates the events, so the pass now leaves the aggregates as
they are until the head catches up.

Stake is GET /v1/chain/p/ops/stake/total, in microLUX. The read used the
removed /bc/ path, JSON-RPC and a 10^9 divisor, so it always reported zero.
go.sum records hanzoai/replicate v0.6.0 as sum.golang.org does.
v1.2.57
2026-09-23 00:35:11 -07:00
zeekayandClaude Opus 5.5 1c2a90f9f0 Header batches fit through a public RPC door
A batch of 256 headers is refused whole by a door that caps batches at
100, and the refusal is one error object where the array was, so every
block-times read failed and the AMM subgraph never advanced. 100 is the
cap such doors enforce, ours included.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_016dDw5Lp9sZFctwvZLn4nVz
v1.2.56
2026-09-22 23:20:23 -07:00
zeekayandHanzo Dev f453339212 Build on Go 1.27
Moved the go directive to 1.27.1. go build and go vet are clean across
all 33 packages on the 1.27.1 toolchain. The test suite did not finish
inside the time budget on a heavily loaded machine; it reported no
failures, and the same run behaves identically without this change.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.55
2026-09-17 13:34:58 -07:00
zeekayandHanzo Dev 96a5ea3829 go 1.26.8
One toolchain across the estate. The tree carried 1.26.0, .1, .3, .4 and .5
at once, so which compiler built a binary depended on which repo it came
from.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 23:59:59 -07:00
zeekayandHanzo Dev 65c1aa20fe run on nothing but the binary
alpine carried a shell, a package manager, libc and sqlite-libs so that a
dynamically linked binary could find them. Linking statically removes the
reason for all four: the image is the binary and the TLS roots, on
distroless static, as nonroot.

The explorer serves its frontend from //go:embed, so nothing else was ever
needed at runtime.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 23:22:04 -07:00
zeekayandHanzo Dev 28bc143e48 chain letters are lowercase
The primary-network chains answer to either case; one spelling beats two.
/v1/chain/c/rpc, /v1/chain/p, /v1/chain/x across the estate.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 15:24:03 -07:00
zeekayandHanzo Dev ac42de990c ci: a runner label names the platform, not the org
hanzo-build-linux-amd64, lux-build-linux-amd64, zoo-build-linux-amd64 and
every other org-prefixed spelling all meant the same machine. Which org a
job belongs to does not change which host can build it, so the org bought
one pool name per org per arch and each could go stale on its own -- the
evo host alone answered to fourteen names for one label.

One vocabulary now: linux-amd64, linux-arm64, macos-arm64, win-amd64. The
generic labels every plain workflow uses (ubuntu-latest, self-hosted) are
untouched, so naive CI keeps working.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 12:09:47 -07:00
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]>
v1.2.54
2026-09-05 19:35:45 -07:00
zeekay fd6c0ff95a ci(release): publish graph for arm64 as well as amd64
The lab runs both architectures, so a tag carrying one of them deploys
on half the fleet. One job now builds both platforms under a single
manifest list, with binfmt registered for the arm64 leg: the Dockerfile
compiles graphd with CGO against sqlite-dev, so that leg runs inside the
target-arch container rather than cross-compiling on TARGETARCH. The
timeout covers the emulated pass instead of aborting part-way through it.
2026-09-05 19:04:33 -07:00
zeekayandHanzo Dev 5046c280d7 chore: symlink AGENTS.md + GEMINI.md + CLAUDE.md -> LLM.md
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-03 15:47:04 -07:00
zeekay 408138f895 reach chains at /v1/chain; the node deleted /v1/bc 2026-08-30 20:39:52 -07:00
zeekay c287fa46b3 docs: chain routes are /v1/chain
luxd serves chain APIs under one segment from constants.ChainAliasPrefix
(constants v1.6.4, node v1.36.181):

  /v1/bc/<alias>/...   -> /v1/chain/<alias>/...
  /ext/bc/<alias>/...  -> /v1/chain/<alias>/...

Observations keep their original addresses: a dated note or a recorded status
code is a fact about when it was written, not an instruction to follow.
2026-08-30 16:41:57 -07:00
zeekayandHanzo Dev dfb9c57fc3 The page before a cursor is bounded in SQL, beside the LIMIT
A client pages a pool's history as "the N trades before this time". The bound
was applied by FilterResults after the database had already taken the newest N,
so every page after the first threw its rows away and came back empty. It sits
in the WHERE now, on the same index the pool does.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.53
2026-08-16 12:40:39 -07:00
zeekayandHanzo Dev 7c5736ac42 A pool's history is read through an index that matches the comparison
GetSwaps compares pool COLLATE NOCASE and orders by timestamp. SQLite consults an
index only when its collation matches the comparison's, so idx_swaps_pool — built
BINARY — was never used for that read: one quiet pool's last trades was a scan of
every swap on the chain, seconds per pool, and a token page asks once per pool it
trades in. Seventeen pools took forty seconds through the exchange-api and timed
out on the way.

The index now names the collation and carries the timestamp, so the read is a
range on the index in the order it is asked for. The old one is dropped on Init,
so a store that already built it stops carrying it.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.52
2026-08-16 12:36:11 -07:00
zeekayandHanzo Dev 74f7996ba3 The trade count comes from the same read as the volume beside it
A token's page showed $74,100 of volume over zero transactions, and the protocol
claimed 16,062 trades above pools holding 14,827 of them. The volume had just
been made a fact about the whole swap table while the count was still a tally
kept as events arrived — and a tally drifts on every restart and re-index, which
is the argument that made poolCount derived in the first place.

Pool, token and protocol counts are folds of the same grouped read that answers
for volume, so the two columns cannot disagree. The tallies are deleted rather
than corrected: a second writer for a derived field is the defect, not its
arithmetic.

This makes txCount mean trades. Mints and burns stop counting toward it. That is
a narrowing, and it is the number a reader comparing it to the volume next to it
is owed. What a create handler owns is the registration itself, which is what its
tests assert now.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.51
2026-08-14 07:55:52 -07:00
zeekayandHanzo Dev bba6bbe253 A pool's volume and the protocol's come from one answer
Valuing the history fixed the protocol's total and left every pool reporting the
window a pass had just read, so a landing page claimed $206,718 in all-time
volume above a list of pools claiming $4,159. Two sums of the same trades, taken
in different places over different sets, and the reader has no way to tell which
is which.

They are one question sliced two ways. The store groups the swap table by pool
in a single scan — the pool column has been indexed since a pool's own trade
history needed it — and the pass folds that: each pool takes its row, each token
takes the rows of the pools holding it, and the protocol takes the sum. What the
pass valued this time around stays in the log, where it says what the pass did
rather than what the chain has done.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.50
2026-08-14 07:39:56 -07:00
zeekayandHanzo Dev 14b0771d1d Value the history, not just the last window of it
Promoting the trade value to a column made the sum honest and, in doing so,
showed what it was a sum of: 1,018,429 trades on this chain, of which the newest
50,000 had ever been priced. The other 95% were stored at zero — so the all-time
total was $4,048 where the newest window alone was $4,159, and every day before
that window was missing from every chart.

A pass now reads both ends of what it has not seen. The head keeps arriving
trades priced; the tail walks forward from a mark and covers the table a batch at
a time until it meets the head, after which it costs a query that returns
nothing. The mark records having LOOKED, not having priced: a trade whose tokens
reach no stablecoin can never be priced, and a mark that waited for one would
stop at it forever. It advances only once the values are on disk.

Days are written whole. A day row is replaced by the pass that writes it, and
both reads cut somewhere, so the day a cut lands in is dropped and comes back
from the other side. A read that came back short of its limit was not cut and
keeps everything — the case where the whole table fits in one window, which is
every small chain.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.49
2026-08-14 07:07:13 -07:00
zeekayandHanzo Dev d3e86b3eb8 All-time volume is a sum over the swap table, not over a window
v1.2.47 replaced one window sum with the day series added up. Those rows are
folded from the same window, so their sum is that window again — a change that
read correctly and moved nothing. Live proof: the day series summed to exactly
the pass's own figure, and its trade counts to exactly the window size.

The value of a trade has always lived inside the swap's JSON document, where SQL
cannot reach it — this driver's amalgamation carries no JSON1, so json_extract
is not an escape either. So amountUSD joins timestamp and pool as a column: the
queryable projection of the document, written by both paths that touch a swap,
summed by one reader. What the protocol has traded is now one scan of one column
and covers every trade ever indexed.

A database that predates the column gets it on the start that introduces it,
filled from the rows themselves. Empty, it would have zeroed every trade older
than the current window — the fix erasing the number it exists to correct — so
that is the case the test pins, and the ALTER failing is how a later start knows
the fill is done.

txCount is left where it is. It counts every interaction, mints and burns among
them, which is a different question from what has traded.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.48
2026-08-14 06:48:41 -07:00
zeekayandHanzo Dev 84d71e5a98 Volume for all time is the days added up, not one window summed
A pass values a bounded slice of the swap table, newest first, because the table
grows forever and rescanning it would cost more every day the chain lives. That
bound is right for the pass and wrong for the field the wire calls total volume:
totalling a window answers for the newest trades only. On the Lux C-Chain, with
a million swaps behind it, the protocol reported the last twelve percent of its
trading and called it everything.

The day series already holds each day's volume, written once by the pass that
saw that day and kept since. Their sum is the whole history at the cost of
reading a row per day, and it grows correctly as days accrue rather than sliding
forward and losing the past. The window is untouched; only what is claimed of it
changes.

The test gives the store three days and a window that would cover one.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.47
2026-08-14 06:34:29 -07:00
zeekayandHanzo Dev c8725690c3 A preflight is answered with the headers it asked about
The allow-list named Content-Type and nothing else, and a browser refuses the
entire request over one header missing from it. The exchange sends x-api-key, so
every call to /v1/quote and /v1/swappable_tokens failed its preflight and the
swap panel had neither a token list nor a price.

A literal "*" is not the fix. Safari does not honour the wildcard in this header
and refuses in exactly the same way — which is how a header set to "*" elsewhere
in this stack still produced "x-api-key is not allowed by
Access-Control-Allow-Headers". Echoing the request's own
Access-Control-Request-Headers is precise, needs no maintenance as clients add
headers, and every browser understands it.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.46
2026-08-13 22:39:33 -07:00
zeekayandHanzo Dev 71c1ff73fd The coin's supply is what was minted, not what sits in the wrapper
A chain's own coin has no contract, so its minted supply, its treasury and its
stake are published onto the wrapped native's row — the entity a token page
opens for LUX. That write went to the store only, and the pass that makes it
reads every token into memory at the top and writes those copies back near the
end. The published figure landed inside that window and a copy read before it
came down on top.

So the coin reported whatever its wrapper happens to hold wrapped. Zoo's two
trillion showed as the fifteen billion sitting in the contract, and its market
cap and fully diluted value were both wrong by a factor of 133 — while the
indexer's own log said "2000000000000 minted" every thirty seconds.

The publish now corrects the copy the pass is holding as well as the row on
disk, so the answer is the same whichever write lands last, and it happens after
the token rows rather than before them. Lux was right only by accident: its row
already held the minted figure, so the stale copy wrote the same number back.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.45
2026-08-13 21:32:05 -07:00
zeekayandHanzo Dev faececcdd9 A pool's trades are its own, not whatever survived the limit
Asking the store for one pool's last N trades read the N newest swaps on the
whole chain and then kept the ones that matched. The limit belongs to the
database and ran first, so the filter was filtering what the limit had already
thrown away.

One pair does most of the trading on a live chain and fills any recent window by
itself. On the Lux C-Chain that is WLUX/LZOO, and every other pool answered with
nothing — LETH's 145,386 trades, LSOL's 52,266, LBTC's 14,857, all present in
the store and unreachable through the query that asks for them. The pool column
has been indexed for exactly this since the table was created.

The test gives a quiet pool five trades and buries it under five hundred. Before
this it returns none of them.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.44
2026-08-13 20:45:05 -07:00
zeekayandHanzo Dev c6daeaaa34 Stop when the store is whole, not when a window is
v1.2.42 gave the heal a loop so a long history takes as many passes as it needs,
and then read the wrong number to decide it was done: the count of rows in the
current window it had FAILED to date. A window that heals completely has no
failures in it, so the very first success looked like completion. On the Lux
C-Chain that dated 50,000 of 912,492 rows and stopped, leaving 862,492 trades
still sorting to 1970 — better than before by one window, invisible by the same
mechanism.

A pass now reports what it found and what it dated. Nothing found is the only
way out; progress goes straight on to the next window; found-but-none-dated
waits and retries, which is the RPC being unreachable rather than the work being
over.

The test seeds more undated rows than one window holds and drains to zero.
Against v1.2.42 it stops with rows still holding a block number.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.43
2026-08-13 20:28:24 -07:00
zeekayandHanzo Dev 76245f08b1 The heal looked for broken rows in the one place they never are
A swap written by an older build holds its block NUMBER where a time belongs.
healSwapTimes exists to date those rows, and it asked for them with
RecentSwapsRaw — the newest rows by timestamp.

A block number is small beside a unix second. So every row needing repair sorts
behind every row already correct, and once a full window of dated rows existed
the heal saw nothing stale, reported success, and stopped. On the Lux C-Chain
that is 435,444 swaps across seventeen pools indexed and dated to 1970, sorting
below everything on every newest-first list the exchange draws: the token page
showed no trades for LETH, LBTC, LSOL, LTON, LPOL, LBNB and the rest while their
trades sat in the store the whole time.

Rows are now asked for by the property that identifies them, and the heal
repeats while a pass is still dating rows rather than stopping after one window.

The test builds the production shape — one undated row under a full window of
dated ones — and fails against the old read, which dates nothing and calls it
success.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.42
2026-08-13 19:57:25 -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]>
v1.2.41
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]>
v1.2.40
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]>
v1.2.39
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]>
v1.2.38
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]>
v1.2.37
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]>
v1.2.36
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]>
v1.2.35
2026-08-13 16:58:58 -07:00
zeekayandHanzo Dev ccc70cd13e Make the cursor mean nothing when the addresses under it change
A cursor is only meaningful for the trust root it was read with. Change the
factory and every PoolCreated it ever emitted is already behind the cursor, so
the new address is never seen and the old rows stay — the store keeps answering
for a book that is not on this chain.

Zoo mainnet sat that way. Its declared V3 factory held no code; the real one, at
the address Lux uses, has twelve pools. Correcting the address alone would have
changed nothing, because the cursor was at the tip.

So the source is part of the store's identity, exactly as the genesis hash is
part of the chain's, and a change to either rewinds. An unrecorded source is
unknown rather than different: every store written before this has none, and
reading that as a change would re-read a million blocks on Lux to rebuild rows
that are already right.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.34
2026-08-13 16:45: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]>
v1.2.33
2026-08-13 16:23:34 -07:00
zeekayandHanzo Dev e2a5e254d8 Quote a swap from the book the indexer already holds
exchange-api answered /v1/quote, and it was the last TypeScript service standing
in front of this one. Its route search could try two intermediaries, WLUX and
LUSD, because those are the two someone typed into a list — while the indexer
beside it holds the whole pool graph. Searching the real graph finds what the
list cannot: WLUX to LUSD pays 1221887840049623 through LBLAST against
611022579164403 direct, twice as much, on the same block.

A quote is two questions and they have different authorities. Which pools could
carry the trade is the index's to answer, and that is why the endpoint belongs
here. What a pool actually pays is the pool's: a constant-product pair is its
two reserves, and that arithmetic is in this file, exact, in integers; a
concentrated pool is a walk across whatever ticks the trade crosses, and only
its quoter holds the tick map.

Doing the concentrated case here was tried and measured before it was rejected.
Treating a pool as constant-liquidity around slot0 is exact while a trade stays
inside the current tick and wrong the moment it does not — 15% high on
CYRUS/LUSD, 44% on Z/LUSD, 89% on TRUMP/LUSD, every error in the direction of
promising output the pool cannot pay. Those are the pools holding their
liquidity away from spot, which is most of a thin book. Closing the gap needs
the tick map; the tick map is not indexed, and event-sourcing it is exactly what
valuation.go already declined to do for reserves.

Constant-product pairs are quoted here for the first time. QuoterV2 is a V3
contract and never knew them, so the one pair on Lux mainnet has been invisible
to the swap form; it prices at 987158034397061298 for a token in against its
100/100 reserves, read live.

The store cannot tell a pair from a pool — handlePairCreated records feeTier
3000 whichever it is — so the pool is asked, once, and the answer kept for as
long as the process runs. A pool that answers neither is left out rather than
guessed at.

Eleven of thirteen live probes reproduce exchange-api's amount exactly. The two
that differ are the better routes.

The response shape is exchange-api's, unchanged, down to the empty route that a
missing quote returns as a 200 — the swap form reads that as "no price" and
retries a 404.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.32
2026-08-13 15:51:05 -07:00
zeekayandHanzo Dev d46a6da07c Write down the two invariants a Token row has, and which sqlite to open
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]>
2026-08-13 15:37:57 -07:00
zeekayandHanzo Dev 8be9add36b The sqlite this binary opens is the one nothing else has claimed
v1.2.28 moved off upstream mattn for a real reason — mattn compiles its own
copy of the amalgamation, and a binary that links it beside the house C
defines every sqlite3_* symbol twice. But it moved to hanzoai/sqlite, which
registers the driver under the name "sqlite", and modernc.org/sqlite registers
that name too. modernc is in every binary that stores here: hanzoai/replicate
imports it, and replicate.go sits in this package. Two packages, one name,
and database/sql panics in init — before main runs. Every build from v1.2.28
on could not start.

csqlite is the same house C under the name "sqlite3", which nothing else in
this graph takes. One amalgamation, two names, no argument.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.31
2026-08-13 15:11:24 -07:00
zeekayandHanzo Dev 4188033699 A supply is whole tokens, and a named row is not a finished one
Every already-indexed token had a symbol and no supply, so market cap and
fully diluted value printed a dash however much the asset traded. The read
existed; nothing ever reached it. The cold-start guard treated a real symbol
as proof the row was complete, so a token seen once was never asked about
again — and the row it adopted dropped the supply field on the way through.

One predicate now says whether a row still has something to ask the chain:
a real symbol AND a supply. One resolver answers it — store first, contract
for the rest, memoised per address — and one merge folds a fresh read over
what is already known, so a reverting call never erases a symbol and never
erases the value locked, volume and price the valuation pass put on the same
row. That last one was live: SeedToken is INSERT OR REPLACE and the write was
built from the contract read alone.

And one unit. The contract reports base units, the native coin's genesis
figure is already whole, and both landed in the same field — a client that
scales one and not the other is a valuation 10^18 out. The field is whole
tokens now, converted where it is read, exactly: integer division and the
remainder, because float64 gives up four orders of magnitude below a
two-trillion-token supply.

The backfill is what a start does rather than a flag to remember.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.30
2026-08-13 15:03:47 -07:00
zeekayandHanzo Dev 8fcdf82097 One sqlite in the binary
Three packages were linked: hanzoai/csqlite (the C), hanzoai/sqlite (the house
driver, which carries that C), and upstream mattn/go-sqlite3, which compiles its
own copy of the same amalgamation. Every sqlite3_* symbol and every cgo
trampoline was therefore defined twice, and the link failed — on darwin. On
linux it survived, because that linker is laxer about duplicates, which is the
only reason CI never showed it.

hanzoai/sqlite is the one already standard here, so mattn goes. The driver it
registers is named "sqlite", not "sqlite3".

Storage, indexer and engine tests pass, and the binary links.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.29
2026-08-13 12:58:31 -07:00
zeekayandHanzo Dev 2b4d82066f Serve the swap form's token list from the indexer that has it
exchange-api answered this, and exchange-api is a forwarder: four deployments,
one per brand per network, four image tags of a small TypeScript file whose
answer comes from the indexer this binary already is. A second copy of a list is
a second thing that can be wrong about it, and one was — the Lux copy served a
13.27B LUX supply for two days after the indexer had been corrected to the real
two trillion.

The response shape is exchange-api's, unchanged, because the client reads it
today and moving a service is not an occasion to break a client.

The chain identifies itself. eth_chainId is asked once at boot rather than
configured, because a flag or an env keyed by deployment is another copy of a
fact the node already holds — and two copies of "which chain is this" is exactly
how zoo.exchange came to serve Lux's tokens under a ZOO label. If the node will
not say, the route is not registered at all: the client keeps the endpoint it
has rather than being handed a list labelled with a guess.

Quote and the portfolio API are not here. Quote needs routing over pools and is
real work; the portfolio routes the client calls are answered by nothing at all
today — exchange-api returns 404 for every one of them — so that is a gap to
close, not a service to move.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.28
2026-08-13 12:49:00 -07:00
zeekayandHanzo Dev 413ed9a82c Supply is what genesis minted, not what the P-Chain counts
platform.getCurrentSupply is the P-Chain's own counter. On Lux it reads 13.27
billion against a real supply of two trillion, so every market cap and fully
diluted value built on it came out 150 times too small — $4.8M for a token whose
diluted value is nearer $886M. I shipped that. This is the correction.

The mint is a fact of block 0: 2,000,000,000,000 LUX allocated to one address,
confirmed against the live chain at height 0. It is not exposed over RPC and
summing every account is not a query, so it is declared beside the factory
addresses, which are facts of the same kind.

What moves is read live. That address holds 994.7B today, so a trillion has
reached holders; the rest is undistributed and is no more circulating than the
2.5B bonded to validators. Circulating is the mint less both.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.27
2026-08-13 06:46:37 -07:00
zeekayandHanzo Dev d7884c7a92 Ask the primary network what circulates
Market cap was blank because nothing knew what was locked. The chain knows: the
P-Chain keeps the native token's whole supply and the part bonded to validators,
and answers in nLUX. Lux today is 13,272,095,200 total with 2,500,000,002
staked — 18.8% that exists and cannot be sold.

So market cap is price times supply less stake, and fully diluted value is price
times supply, and both come from the chain rather than a list somebody maintains.
Read every pass, because supply grows with each reward and the staked figure
moves with each delegation.

The endpoint is derived from the EVM one — the two chains sit beside each other
under the same node — so there is nothing to configure and nothing to drift.
A chain with no validators answers zero staked, which is the true answer for it.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.26
2026-08-12 19:17:19 -07:00
zeekayandHanzo Dev e0c7ae8a1d Read what a token's supply is
TotalSupply was in the schema and nothing ever filled it, so a token page could
price an asset to six decimals and still print a dash where fully diluted value
belongs. It is one more eth_call beside symbol, name and decimals, cached the
same way — per token, once — and read at head, so it follows a mint or a burn.

Kept as text: a uint256 fits no float, and the client divides by decimals to
display it anyway.

The call-count guards move 3 → 4. What they protect is that this is per token
and not per log, which the cache loop beside them still proves.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.25
2026-08-12 18:59:43 -07:00
zeekayandHanzo Dev c0572efa7f Price the trades without the JSON extension
json_set belongs to SQLite's JSON1 extension, which this driver's amalgamation
is not always built with — present on a developer's machine, absent in the
image. The statement prepared cleanly in every test and failed on every pass in
production: "no such function: json_set", on all 50,000 swaps, every 30 seconds.

The field is set in Go now and the row written whole, so it does not depend on
how SQLite was compiled. Fifty thousand rows take 372ms.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.24
2026-08-11 17:32:01 -07:00
zeekayandHanzo Dev 2da8d72ef1 Say how many trades were priced
The write reported nothing, so when it changed no rows in production the pass
still printed a clean summary and every swap kept reading zero under a pool
claiming thousands in volume. It now returns rows-changed and any error, and
the valuation line carries both.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.23
2026-08-11 17:25:07 -07:00
zeekayandHanzo Dev dd5aecb043 Drop the ORM nobody imported
Every test in this repo failed to link on macOS: 283 duplicate SQLite symbols.
The module required an ORM no source file imports, and it brought a second copy
of the C amalgamation alongside the driver's own. Nothing here used it.

With it gone the suite runs on the same driver the indexer and explorer load,
which is the point — one copy of SQLite per binary, and one driver across the
stack.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.22
2026-08-11 16:56:24 -07:00
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]>
v1.2.21
2026-08-11 16:34:45 -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]>
v1.2.20
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]>
v1.2.19
2026-08-11 11:47:49 -07:00
zeekayandHanzo Dev db0bd14382 note how a swap gets a time, and what the day series is exact about
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-11 11:32:00 -07:00
zeekayandHanzo Dev acfbb5520b an unobserved day reports no value locked, not zero
What a pool holds is not in its trades — the balances say, and only about
now. Days that passed before the series existed carry the volume and the
candles they can be shown; reporting "0.00" locked would claim the pool was
empty and a chart would draw the claim as a cliff to zero.

Co-authored-by: Hanzo Dev <[email protected]>
v1.2.18
2026-08-11 11:24:55 -07:00