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]>
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]>
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]>
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]>
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.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]>
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]>
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]>
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]>
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.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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
One process indexes six chains, and the AMM factories were resolved by
falling back to Lux mainnet's when a Config left them blank. Every chain
that did not name its own therefore matched PoolCreated against Lux's
factory and denominated prices in WLUX. The zoo subgraph registered no
Zoo pool and quoted Lux's coin under Zoo's slug.
A default is only honest when one answer is right everywhere. 0x9999 is;
a factory address is not. FactoryV2, FactoryV3 and Native now come from
Config alone, so a chain that names none indexes none — the failure is
visible instead of wrong. The hardcoded per-network tables go with them:
they had no readers, and Zoo's entry had drifted to Lux's addresses.
ZUSD joins the stable anchors, so Zoo's book prices in dollars.
Co-authored-by: Hanzo Dev <[email protected]>
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]>
The valuation pass has always priced every token in USD — that is where TVL and
volume come from — but it published none of it. The subgraph format carries a
price only as the product `token.derivedETH * bundle.ethPriceUSD`, and both
sides were left unwritten: mainnet served derivedETH="" for all 8 tokens against
a null bundle. lux.exchange and the explorer therefore rendered real TVL and
real volume beside a blank price and no market cap, on every market.
"ETH" is the wire's name for the chain's own coin, so the bundle now carries the
native coin's USD price and each token its price in native units. The two
multiply back to exactly the USD price the pass computed, and the wrapped native
quotes 1 of itself by construction. No second price source, no oracle: one
relaxation, two projections of it.
Unpriced stays unpriced. A token no pool connects to a stable publishes an empty
derivedETH and no bundle is written at all — a zero there would render every
token free rather than unquoted.
fmtUSD could not carry these numbers: it is a CURRENCY format, and at two
decimals LUX at $0.00041334 renders "0.00". fmtPrice keeps 12 significant
digits.
Wrapped-native address is per-chain (Zoo's differs), so it follows the factories'
existing pattern — Config field, Lux mainnet default, env override.
Tests run under -tags nosqlite: the default store links two sqlite C
implementations (mattn + sqlite3vfs) and 283 duplicate symbols make the test
binary unlinkable, which is why the swap-count tests added earlier had never
once executed. They pass now, along with the new ones. Removing the SeedBundle
call fails TestPricesReachTheWire with "bundle absent", so the test tracks the
publication and not a reimplementation of it.
Co-authored-by: Hanzo Dev <[email protected]>
The indexer stored every swap via SeedSwap but nothing incremented txCount, so
each aggregate the explorer served read 0 — pools, tokens and all-time trades
alike. lux.exchange rendered a TXNS column of zeros beside real volume, and
"ALL-TIME TRADES" showed the factory's create count rather than trades.
bumpSwapCounts is the one place a swap is counted: pool, both tokens, factory.
All three handlers (V2/V3/V4) call it and nothing else counts swaps, so the four
aggregates cannot drift apart. An unregistered pool is a no-op — the handlers
already drop rogue emitters, and inventing an entity here would reopen the
phantom-pool bypass those gates exist to close.
Tests cover the increment, accumulation across swaps, and the unknown-pool no-op.
They are NOT verified on this machine: every test in this package, including
pre-existing ones, fails to link against two sqlite drivers (284 duplicate
symbols) under the local darwin toolchain. go vet and the package build are
clean; CI builds on golang-alpine, where the link succeeds.
Co-authored-by: Hanzo Dev <[email protected]>
The D-Chain cannot settle yet, so no DEXFill has ever been emitted on any
network and the dex pipe cannot be validated by waiting for data. The existing
tests cover the handler and the schema separately; nothing covered the seams
between them, which is where this pipe actually fails — a topic0 the log filter
never asks for, a field name the frontend does not read, an empty result that
encodes as null.
Drives the real poll loop against a JSON-RPC server serving exactly one DEXFill
at 0x9999, then asserts the frontend's own query
(`fills(first:25){ id market taker amountOut timestamp txHash }`) returns it
with those exact field names, and that it rolls into the market the page's
headline stats read. Asserts the poller actually filtered on the DEXFill topic0
rather than trusting that the stub served it, and asserts the pre-fill answer is
an empty list rather than null.
Co-authored-by: Hanzo Dev <[email protected]>
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]>
Currency bump: golang builder images -> 1.26.5 (latest stable).
Adds ENV GOTOOLCHAIN=auto to each Go builder stage. The official golang
images set GOTOOLCHAIN=local, so a builder image older than the module's
go directive is a hard mid-build failure. The pin stays the hermetic fast
path; auto is the safety net for the next go.mod bump.
No go.mod go directive was changed. Builder images move; directives stay.
Co-authored-by: Hanzo Dev <[email protected]>
The only thing that ever pushed ghcr.io/luxfi/graph was
.github/workflows/docker.yml on `runs-on: lux-build` — an ARC autoscaling
runner set that no longer exists. The cluster has no ARC CRDs, no ARC namespace
and no such pool, so every v* tag queued a job nothing could pick up. An
unclaimable job does not fail; it waits, and they accumulate.
It moves to .hanzo/workflows/release.yml on `lux-build-linux-amd64`, a label
the fleet actually carries. Two other things had to change or the job would
have failed on the first runner that did claim it:
* ghcr login used the instance GITHUB_TOKEN. On this forge that is a Hanzo
Git token and carries no rights at ghcr.io. It now uses GHCR_USER and
GHCR_TOKEN, the org Actions secrets sourced from KMS that luxfi/node and
luxfi/kms authenticate with.
* the layer cache was `type=gha`, the GitHub-hosted Actions cache service.
It is now the registry cache, which is backend-independent and lives
beside the image it caches.
docker.yml was the whole of .github/workflows, so nothing is stranded. That
matters generally: the forge stops at the first workflow directory that
exists, so the moment a repo gains .hanzo/workflows its entire
.github/workflows suite goes dark at once — a migration has to land whole or
it silently deletes coverage.
Co-authored-by: Hanzo Dev <[email protected]>
These hashes were recorded from direct VCS fetches made while GOPRIVATE covered
github.com/luxfi/*, which switches off the module proxy and checksum
verification together. Tags moved afterwards, so what is written down here no
longer matches what sum.golang.org holds and the build refuses to verify.
proxy.golang.org still serves the originally-published bytes and those verify
against the log, so dropping only the disagreeing lines and re-tidying restores
a verified module graph with no version change. Lines that agree are untouched;
this is not a regenerated go.sum.
The failure followed the machine rather than the repository — the same commit
built wherever the module cache was clean. Any machine or CI runner that
fetched luxfi modules under the old GOPRIVATE holds the same poisoned entries.
Co-authored-by: Hanzo Dev <[email protected]>
The CLOB read base is `<node>/v1/bc/D/dex/...`, not /ext/. luxd serves
one prefix (node server/http/server.go baseURL = "/v1").
Measured 2026-07-27 on both fleets, each with a bogus-path control:
mainnet luxd-1:9630 /v1/bc/C/rpc 200 0x17871 | /ext/ 404 | control 404
devnet luxd-2:9650 /v1/bc/C/rpc 200 0x1786f | /ext/ 404 | control 404
Ports come from each net's LuxNetwork CR spec.ports.http — never assume.
go vet ./... clean; go test ./indexer/ -run CLOB -> 3/3 PASS.
Co-authored-by: Hanzo Dev <[email protected]>
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]>