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]>
Deployed v1.2.10 to a six-chain explorer and watched one chain produce
aggregates while two others produced nothing at all — no result line, no error
line. Two distinct defects, both found by that silence.
1. The pass wrote O(swaps) rows per interval.
valueSwaps rewrote every swap's own amountUSD, one serialized INSERT OR
REPLACE each, every 30s. A chain with a real trade history never finished a
pass, so it never wrote its pool/factory aggregates either — while a quiet
chain beside it looked perfectly healthy. Volume is a per-pool rollup and
belongs on the pool: valueSwaps now READS the swap rows and writes none.
(Per-swap amountUSD stays "0". It is a separate gap, and the honest fix
values a swap at the price of its OWN block, not at today's.)
2. A V3 Swap emits int256, and the handler read it unsigned.
One leg of every V3 swap is negative — the token leaving the pool. Reading
it unsigned persisted ~2^256 in place of a small negative number. Nothing
had ever added those amounts up, so the corruption was invisible until the
volume rollup landed and reported $4.07e72 of trading on Zoo.
handleSwapV3 now uses decodeInt256, the same decoder the V4 handler already
used for the same reason. Rows the old build already wrote are healed on
read rather than by a re-index: a stored leg at or above 2^255 IS the
two's-complement encoding of a negative int256, and the V2 handler's
difference of two uint112 reserves can never reach that magnitude, so the
boundary is unambiguous.
Both were invisible for the same reason, so the third change is observability:
- Config.Label names the indexer ("cchain/amm") and every indexer log line
now carries it. One process runs seven of these; a line that does not say
whose it is cannot be acted on.
- Every revalue exit logs, including "read no balances", and the result line
reports valued/total pools and elapsed time. An indexer that reports
nothing was indistinguishable from one that was never scheduled.
- Each pass runs under a 60s budget, so a pass that cannot finish is
cancelled and says so instead of wedging its chain forever.
Tests pin all three: the wrapped-negative leg valuing identically to the signed
one, handleSwapV3 persisting "-2500", and revalue leaving swap rows untouched.
Co-authored-by: Hanzo Dev <[email protected]>
Every USD field the exchange reads was an empty string. `pools`, `tokens` and
`factories` all resolved — with real addresses, real symbols, real fee tiers —
and then reported totalValueLockedUSD:"", volumeUSD:"", token0Price:"". The
landing page's TVL / 24h-volume / active-pool header had nothing to render, so
it rendered nothing.
Nothing was ever writing those fields. processLog's Sync/Mint/Burn arm was an
explicit no-op ("storage for these types not yet wired"), and bumpFactory's
comment deferred the USD aggregates to a "price oracle / day data" that does not
exist. Sync is the only event carrying V2 reserves, so with it inert there was
no path — at any block height — from the chain to a TVL number.
Fixed by adding the missing concern rather than by decoding one more event:
indexer/valuation.go is the ONE owner of the derived USD aggregates. It reads
balanceOf(pool) for both legs of every registered pool in a single JSON-RPC
batch, anchors stablecoins at $1 by symbol, and relaxes prices outward through
the pool graph — each unknown token taking its quote from the DEEPEST pool
that already has a priced side, which is both deterministic and the least
manipulable choice when pools disagree. A token no path connects to a stable
stays unpriced and contributes nothing: unknown is reported as unknown.
Reserves are chain STATE, not chain HISTORY, so this is a pass beside the log
poller, not a handler inside it. That distinction is the whole point:
- Sync exists only on V2. balanceOf answers the same question for V2, V3 and
V4 identically.
- An event-sourced TVL only converges by replaying all history, and a pool
whose last Sync is a million blocks back reads empty until it trades again.
On an idle chain it never trades again.
- It is one fact, so it gets one writer. The Sync/Mint/Burn arm stays inert on
purpose and now says why.
poolCount moves to the same pass for the same reason: it is derived from the
store, not accumulated by the create handlers, so it can no longer drift across
a restart, a re-index or a duplicate create. bumpFactory keeps the one field
that genuinely is a running total of events — txCount.
Fails closed: an unreachable RPC aborts the pass rather than overwriting good
aggregates with zeros.
Tests: valuation_test.go drives a stub RPC shaped like the live mainnet book —
a stablecoin pair anchoring a WLUX pair — and asserts derived TVL, spot prices,
per-token TVL, factory poolCount, the deeper-quote rule, non-clobbering of
fields the pass does not own, and the RPC-blip guard. Full suite green.
Co-authored-by: Hanzo Dev <[email protected]>
handleInitializeV4 now sets the DEX Market symbol to the human BASE/QUOTE pair
(from the two currencies' ERC20 symbols) and marks assetsBound=true when both
resolve to a clean symbol. This is the exact shape the public exchange-api
real-asset gate (dexMarkets.ts isStructurallyReal) requires: without it every
native 0x9999 market was stripped (poolId-hex symbol failed the clean-pair check)
and the markets surface was empty despite real on-chain DEXFills. A token with no
identifiable symbol yields no pair -> the market stays unbound and hidden.
One place computes a market's display identity, read by every downstream surface
(explorer /dex, exchange-api gate, FE). New TDD: BindsCleanPairSymbol + UnnamedTokenStaysUnbound.
The D-Chain read surface (luxfi/dex pkg/dchain/read.go:50-53) serves
dex_get_{markets,trades,orders,book}; the CLOB source still called the old
clob_get_* names -> 404 -> empty dex subgraph while real fills matched
on-chain (lux.exchange showed no markets though the chain was correct).
Proven: GOWORK=off go test ./indexer/ -run TestCLOB green.
go-sqlite3 v2.0.3+incompatible is a deleted upstream tag (unknown revision),
breaking every standalone graph build. Pin to v1.14.47 (latest v1.14 patch;
the cgo driver store_sqlite.go imports). Regenerate go.sum first-party hashes
stale from the org tag re-move (luxfi/age etc.).
The dex subgraph indexer only consumed EVM 0x9999 settlement logs (the
cross-chain settlement view). On a native-DEX deployment the trade engine
IS the D-Chain (dexvm): a trade is a consensus state transition matched at
Block.Verify, not an EVM event, so native fills never appear on the EVM RPC
and the dex subgraph stays empty (markets:null).
Add CLOBSource (indexer/clob.go): an orthogonal, READ-ONLY poller of the
D-Chain committed-state read RPC clob_get_{markets,trades,orders} that writes
the same Market/Fill/Order entities the existing dex resolvers serve. Enabled
by Config.DexRPC; empty preserves EVM-only behaviour. The two sources share
only the store and the entity shapes, so this is purely additive — the amm
(EVM) subgraph is untouched.
- markets <- clob_get_markets (per-market authoritative book summary; bound-
market hex symbol decoded to its human pair, e.g. LUX/LUSD)
- fills <- clob_get_trades?since=<h> (global height-ordered log; keyed
height:seq -> idempotent; incremental by accepted height)
- orders <- clob_get_orders?market=<id> (resting book per market)
Tests (httptest doubles serving the exact read.go JSON shapes captured live
from lux-devnet): markets/trades/orders ingest + symbol decode, incremental
cursor + idempotent re-read, and EVM-AMM no-regression with DexRPC set.
Token entities were seeded with placeholder metadata (symbol=addr[:8],
name=full address, decimals=18) — no contract read happened, so the AMM
graph returned every token as its own address. Add erc20.go: read
symbol()/name()/decimals() via the existing eth_call RPC (canonical ABI
string + legacy bytes32 fallback, garbage-decimals guard), memoised once
per token (revert→placeholder cached too, never re-probed). Route all four
seed sites through a cache-first seedToken with a cold-start no-downgrade
guard. BackfillTokens (opt-in BACKFILL_TOKENS=1) enriches existing
placeholder rows in place — ≤3 eth_calls per token, no re-sync.
Red re-verify found the V4 AMM-spoof gate left a parallel bypass.
MEDIUM — V2/V3 AMM handlers were UNGATED: handlePairCreated, handlePoolCreated,
handleSwapV2, handleSwapV3 accepted events from ANY emitter — a parallel path to
seed phantom pairs/pools/swaps and inflate the SAME public Factory/TVL/volume
aggregates the now-gated V4 handlers (0x9999) block. Mirror the V4 gate:
- PairCreated/PoolCreated now require the canonical FactoryV2/FactoryV3 emitter
(the AMM trust root), exactly as V4 requires 0x9999.
- V2/V3 Swaps are emitted by the pair/pool, not the factory, so the gate is
"is the emitter a pool the canonical factory created?" (isKnownPool). The
factory authorizes a pool; only that pool's Swaps are honored. A rogue
factory's PairCreated is rejected, so its pools are never registered, so its
Swaps are rejected — the whole parallel path is closed.
- Pair/pool keys lower-cased at seed so the Swap-side lookup is RPC-casing safe.
- Factory addresses configurable (Config.FactoryV2/V3 + FACTORY_V2/V3 env),
default to canonical Lux mainnet (LuxMainnet.Factory{V2,V3}); main wires env.
INFO — the per-namespace resolver limit lacked the `if limit < 1 { limit = 100 }`
floor that resolvers/dex has, so a negative/zero `first` flowed to ListByType
(`first:-1` = "no limit" in SQLite — an unbounded scan). Brought every pl()/
parseLimit() copy to the dex-canonical body (floor <1->100 + cap 1000); fhe and
platform also gained the previously-missing 1000 cap. All namespaces now identical.
Tests (go test ./...): indexer + resolvers/{dex,exchange,fhe,platform} green.
- TestHandleV2_RejectsNonFactory, TestHandleV3_RejectsNonFactory: rogue
PairCreated/PoolCreated/Swap seeds nothing and does not inflate the factory
aggregate; the canonical-source event IS honored end-to-end.
- TestPL_ClampsFirst / TestParseLimit_ClampsFirst (exchange/fhe/platform):
negative/zero floored to 100, capped at 1000.
Three confirmed findings in the explorer/graph indexer:
- MEDIUM: handleInitializeV4/handleSwapV4 seeded AMM pools/tokens/swaps from
ANY emitting contract, so any contract could emit lookalike V4 events to
create phantom pools/tokens and inflate the explorer's public TVL/volume
aggregates. Gate both to the canonical 0x9999 PoolManager (idx.isPoolManager),
mirroring handleDEXFill. The now-always-true inner Market guard in
handleInitializeV4 is de-nested (one path).
- LOW (DoS): unguarded fixed-offset slices (token0[:8], l.Address[:8]) panic on
a short/malformed RPC log, and the poll goroutine had no recover() -> one bad
log crashed graphd. Route those symbol slices through the length-checked
shortAddr helper, and wrap each poll in pollGuarded() which recovers any panic
into an error so the loop continues.
- LOW (query-DoS): resolvers/dex parseLimit had no max cap. Clamp <1 -> 100 and
cap min(limit, 1000), matching every other resolver namespace.
Tests:
- TestHandleV4_RejectsNonPoolManager: rogue Initialize/Swap seeds nothing; the
same event from 0x9999 is honored.
- TestPoll_MalformedLogDoesNotCrash: malformed logs don't panic processLog, and
pollGuarded converts a poll panic into an error.
- TestParseLimit_Caps: default 100, floor 1->100, hard cap 1000.
The classic [self-hosted, linux, amd64] triple is serviced by fleet arcd hosts
that are not reliably available (v1.2.3 queued 30+ min). Use the in-cluster
lux-build autoscalingrunnerset (same as node + exchange-api). amd64-only — QEMU
arm64 pushed builds to ~2h. Artifact identical to v1.2.3; CI-plumbing only.
Item 4: the 3-consecutive-poll hysteresis alone could be satisfied by a deeply-stale
load-balanced RPC backend held >reorgDepth behind for the full window (~6s), wiping a
still-canonical chain and re-indexing from genesis (DoS amplification).
Keep the streak as a CHEAP PRE-FILTER, but gate the actual rewind on a deterministic
signal: the chain's genesis (block 0) hash. Every node serves block 0 regardless of
how far behind its head is, so an unchanged genesis hash deterministically rules out
a re-genesis even on a deeply-lagging backend (unlike a cursor by-number/by-hash probe,
which a backend whose head is below the cursor cannot serve and would false-trip).
Honors the no-eth_getCode rule (only eth_getBlockByNumber 0x0 header is read). The
probe is a struct field (genesisHashFn) so tests drive canonicality without a live RPC;
baseline captured lazily before any reset decision. Fail-closed: a probe error or
missing baseline never wipes.
3 existing regenesis tests updated to the new signature/semantics + 3 new
(deeply-behind-but-canonical => NO wipe; changed genesis => wipe; probe-fails => NO
wipe). Full indexer+engine suites green.