Helm values, the staging and production manifests, and the operations
guide name the image .github/workflows/docker.yml publishes, so a
rollout pulls and a copied `docker pull` resolves.
deploy.sh deploys that published image; CI owns the multi-arch build.
The image table lists the tag the Dockerfile produces.
A chain's pools say what a pair trades at and nothing about what is listed on
it: an AMM has no list. Our own chains have an indexer that keeps one, and
every other chain had nothing — so an interface switched to Ethereum asked for
nothing at all and drew an empty table, which is exactly what a reader has been
looking at. The hosted API would answer and refuses without a contract.
CoinGecko publishes both halves openly: a per-chain token list with names,
decimals and logos, and a price by contract address with the day's change and
volume. No key, no account; a key only raises the rate limit. Measured on the
open endpoints: 5,821 tokens on Ethereum, 4,214 on BNB, 2,679 on Base, every
one with a logo.
The lists are large and change slowly, so they are held for six hours; a price
is a reading of a market and is held for a minute. Both are held in the gateway
rather than in a browser, so a thousand readers are one request to a service
that asks nothing of us. A token it does not price is absent from the answer
rather than present at zero — a screen can draw a dash, and it cannot un-draw a
wrong number. One batch refused is not every price unknown.
Measured through the running binary: /v1/trade/tokens?chainId=1 answers WETH,
USDC, 1INCH, AAVE with logos; a search for USDC returns USD Coin alone; and the
price still comes from our own pool read — WETH at 2474.5369 via uniswap_v3
against USDC — with this provider supplying only what the pool cannot.
Co-authored-by: Hanzo Dev <[email protected]>
Nothing could be read, so it answered that the chain could not be reached. Under
load that is this deployment running out of its own time budget with the chain
answering normally the whole while, and the two send a reader to different
places. A timeout says so and names what to do about it.
Co-authored-by: Hanzo Dev <[email protected]>
The answer is two readings compared, so one of them failing is the chain
failing. It was being treated as a pair no pool holds, which is remembered for a
minute where a chain that could not be read is asked again in ten seconds.
Co-authored-by: Hanzo Dev <[email protected]>
A price is a fan-out. The edge limits requests — thirty a second from one
address — and each may name fifty tokens, read at two sizes against two
numéraires across every arm. Nothing bounded what came out the other side, and
what comes out lands on public endpoints we hold no account with; being refused
there takes quoting down for everybody, on every chain.
So the budget is sixteen questions in flight against one chain for the whole
process, rather than eight per request multiplied by however many requests
arrive. It is taken around a single reading and not around a whole price,
because a price against the hub waits for the hub's own price, and a slot held
across that wait deadlocks the first time every slot is held by something
waiting on the hub.
Measured: one request of 50 cold Ethereum tokens went from 10.1s to 8.6s, and
four chains of 40 at once from 10.7s to 9.3s — and returned the same counts on
a repeat, where the same burst before this returned a different set of prices
each time.
Co-authored-by: Hanzo Dev <[email protected]>
The first cut read each market once, at a hundred dollars. That is right for a
pool with depth and nonsense for one without: a hundred dollars into an
abandoned pool buys a rounding error, and the implied price of a rounding error
is enormous. Measured over the fifty tokens Ethereum lists first, half of what
came back was more than one percent off an independent source and AUCTION — a
token worth $3.44 — read $889,434,176.
So the pools are asked twice, at ten dollars and at a hundred, and a price that
does not survive ten times the size is not returned at all. Twenty-five of the
twenty-seven that now come back agree with that source within two percent. The
two that do not are real pools here sitting off the wider market, and that is
this venue's price: /v1/trade/quote for the same size implies $0.036806 for ASM
where /v1/trade/price says $0.036834, so the screen and the swap panel agree,
which is the thing that has to be true.
The gate is on the best reading at each size, not on each numéraire separately.
Gating per numéraire refuses the cheapest route for being thin and then quotes a
dearer one that was not — ANT came back at $3.72 against a market of $0.07 that
way, because the route that knew better had been thrown out.
Both sizes are asked at once, so the second one costs calls and not latency:
fifty cold tokens on Ethereum in 10.1s, the same fifty warm in 36ms.
Co-authored-by: Hanzo Dev <[email protected]>
A second account of the trading surface sat further down the file and
contradicted the first: pools and order among the routes, a provider registry
where Uniswap is a fallback on every chain rather than something registered only
when a key exists, a native provider of stubs against a precompile at 0x0400,
and a future-work list asking for the quote and token-list caching that is now
in the binary. A document that disagrees with itself is worse than a shorter
one. The account at the top is the one held to the route table by a test.
Co-authored-by: Hanzo Dev <[email protected]>
The limit defaulted to a page size, and Ethereum lists 403. A client that did
not know to ask for more got two thirds of the chain with nothing to say so.
There is no paging here to need one: the largest chain lists 403 and all seven
together list 935, so the limit is a ceiling on a request and the default is
everything.
And a chain this deployment cannot quote now answers the way /v1/trade/venues
already answers about it, rather than with an empty list a screen would draw as
a market with nothing in it.
Co-authored-by: Hanzo Dev <[email protected]>
Two things the first cut got the wrong way round.
Search ranked a name starting with what was typed above a symbol containing it,
so "eth" reached Ethena and Ethereum Name Service before WETH. People type
tickers at a market screen. The rule is now one sentence — the symbol beats the
name, and within each an exact match beats a start beats a mention.
And a reading abandoned because the caller went away was still published to
whoever was waiting on it, and kept for as long as an unreadable chain. It is
neither: the answer and the entry now agree, and nothing is kept.
Co-authored-by: Hanzo Dev <[email protected]>
Explore on Ethereum drew nothing at all — not an empty table, no request made
— because enumerating a chain went to an indexer that only carries ours, and
the hosted API that would answer costs a contract. Two paths close that without
asking anybody for one.
/v1/trade/tokens serves a list the binary carries: 935 tokens over the seven
chains this gateway prices, identity only, checked at build time rather than
per request. No logo, and the struct has nowhere to put one — every public list
gives logos as URLs on four hosts belonging to somebody else, fetched by every
viewer's browser on every market screen.
/v1/trade/price is a reading of the same pools /v1/trade/quote reads: spend a
fixed hundred dollars of a numéraire, see what comes back, put the pool's fee
back in. Not one whole token — that is a fifty-cent trade in one market and a
hundred-thousand-dollar one in the next, and the second moves the pool it is
measuring. Not the raw quote either: a pool hands back the mid less its tier,
which is $1.0031 for a $1.00 token and $3018.51 for a $3000 one two hops deep.
Two numéraires per chain, the dollar and the wrapped native token, and the
cheaper reading wins. Measured against an independent source on seven chains,
every price inside half a percent.
A token no pool holds comes back WITHOUT priceUSD, never with a zero: a screen
can draw a dash and it cannot un-draw a wrong number. A reading stands for a
minute; an unreadable chain for ten seconds, because the pools have not changed
— our access to them has. Fifty per request, eight in flight, and thirty-two
readers of one cold price produce one fan-out.
Two things this needed that were wrong on their own account. A fee had two
units — the V2 arm answered 30 where the V3 arm answered 3000 for the same
three tenths of a percent — so feeFromBPS converts once and nothing else means
anything else by that number. And the arm reading our own precompiles answered
"no liquidity" to an endpoint that never replied, which is enough on its own to
report a chain nobody could reach as a pair nobody holds.
Co-authored-by: Hanzo Dev <[email protected]>
Both came out as an empty list, so WETH/USDC on Ethereum — one of the deepest
pools in existence — was reported as "no venue here holds this pair" whenever
its RPC was down. That sends a reader looking for liquidity instead of at their
endpoint, which is the same mistake as blaming the pair for an exact-output
request and blaming an upstream registry for our own chain.
The venues already distinguished the two answers: no liquidity is (nil, nil), a
chain they could not reach is (nil, err). QueryAllVenues discarded both. It now
returns the reason, and only when EVERY arm it asked failed — one arm down
while another answers is a quote, not an outage. It also stops returning the
routing label, which named V4_NATIVE for a V3 pool on somebody else's chain and
was read by one test assertion and nothing else.
Underneath, EVMClient marks the answers it never received: a connection
failure, a 5xx, a 429, a body that is not JSON-RPC. A contract answering by
REVERTING stays what it always was — this pool does not exist — because three
of the four V3 fee tiers revert on most pairs and that is the ordinary case.
Measured with chain 1 pointed at a closed port: chain 1 answers 502 naming the
dial error, 96369 still quotes 997001483075056712, a pair with no pool still
answers 404, and /providers stays 200 so one dark chain does not take a pod out
of its own Service.
Co-authored-by: Hanzo Dev <[email protected]>
The v4_native arm is configured on 96369 and silent: 0x9010 answers 0x there,
measured with the venue's own quoter selector, so every price on that chain
today comes from uniswap_v3 reading the QuoterV2 the venue deployment put
there. `native: true` says the arm is attached, not that it answered.
Also the addresses from luxfi/standard deployments/venue/96369.json, the reason
its `Router` key belongs to LUX_V3_ROUTER and never LUX_V2_ROUTER, and one
measured price per configured chain so a later reader can tell a broken arm
from a moved market.
Co-authored-by: Hanzo Dev <[email protected]>
lxrPadUint256 wrote a number wider than 32 bytes as b[:32] — its LEADING bytes,
not its low ones — so a value one bit over came out as a completely unrelated
quantity in the field that says how much to spend. The quote accepted any
string and the swap checked only the sign, so nothing stopped one arriving.
Two changes, and the boundary one is the real fix. wholeUnits is one parser for
every path that takes an amount: a whole number of a token's smallest unit, in
decimal, that fits in a uint256 — refused at the edge, where the sentence can
say what is wrong with it. And the encoder now keeps the LOW bytes, which is
what the EVM does to a uint256, so the failure mode of anything that slips past
is truncation rather than a different number entirely.
Co-authored-by: Hanzo Dev <[email protected]>
tail() read the id out of a subtree route and no route is a subtree any more;
lxrDecodeBytes32 and lxrPadBytes served the V4 router builders that went with
the precompile answering 0x. staticcheck is clean on pkg/gateway and
cmd/gateway.
Co-authored-by: Hanzo Dev <[email protected]>
SwapRouter02 dropped the deadline from exactInputSingle's tuple and takes it
through multicall(uint256,bytes[]) instead, which is how Uniswap's own
interface sends one. Without that wrap a deadline handed to /v1/trade/swap was
accepted and silently dropped on every V3 route while the V2 route honoured it
— one field meaning two things depending on which pool happened to win the
quote.
amountOutMinimum bounds the price; the deadline bounds the time. A signed swap
that sits in a mempool and lands an hour later still passes its floor, at a
price nobody would choose now. Unset means twenty minutes out, because zero is
a deadline in 1970 and every swap would revert as too old.
Measured against 96369's live SwapRouter02, both directions:
deadline ahead of block.timestamp -> 997001483075056712, the quoted amount
deadline behind it -> execution reverted: Transaction too old
Worth knowing for anything that tests this: 96369 seals a block when there is a
transaction, so block.timestamp ran 4709 seconds behind wall clock while this
was measured. A deadline computed from wall clock is generous here rather than
tight — the safe direction, but `now - 60` will not read as expired.
Co-authored-by: Hanzo Dev <[email protected]>
Access-Control-Allow-Headers named Authorization and X-API-Key, which nothing
here has ever read — every path is an unauthenticated read of public pools —
and X-Universal-Router-Version and X-Permit2-Disabled, which belonged to the
Uniswap-shaped /trading/* routes that are gone. A permitted header nobody sends
is an invitation to send it. It is Content-Type and X-Request-ID now, which are
the two the binary reads.
The methods dropped PUT and DELETE with the order and position paths that used
them.
The origin stays `*` and covers lux.exchange. There is nothing one origin could
be trusted with here that another could not, and naming one would break the
docs site, a wallet and any partner reading a price, each of them silently. If
a path here ever takes a credential, that is the line that changes first.
Co-authored-by: Hanzo Dev <[email protected]>
The swap looked for the venue that had priced the trade in the per-chain set
alone, while the quote falls back to the single set a one-chain deployment is
handed. Configured the second way, this surface priced a pair and then reported
the venue that had just priced it as unregistered. Both go through Server.arms
now, and Server.arm is that set looked up by the name a venue calls itself.
The swap also rebuilt the quote request by hand beside convertQuoteRequest,
which does the same thing for the quote path — six of seven fields, twice, free
to drift about what slippage or isExactIn mean. swapRequest embeds
quoteRequest; encoding/json flattens it, so the wire shape is unchanged and
there is one conversion.
Co-authored-by: Hanzo Dev <[email protected]>
leastAccepted computed the swap floor in whole basis points, so a tolerance
below 0.01% truncated to zero and amountOutMinimum came back EQUAL to the
quote. A caller asking for very tight protection got none: the router reverts
on the first wei of movement, including the movement their own trade causes.
The arithmetic is in hundredths of a basis point now, which is the unit a V3
fee tier is already stated in. Measured on 96369 against 997001483075056712:
slippage 0.005 gives 996951633000902959, four nines of the quote, where it used
to give the quote itself.
A direction is not a missing market. getAmountsOut and quoteExactInputSingle
price a known input and have no other direction, so an exact-output request
gets nothing back however deep the pool is — and "no venue here holds this
pair" sent someone looking for liquidity sitting right there. It says which of
the two it was, and what to ask instead. Named as the likely cause rather than
asserted, because a deployment carrying an arm that prices both ways reaches
that line only when the pair really is unheld.
A quote that named no pool built a swap against Route[0] of an empty slice.
venueQuotes always writes one hop, so this was reachable only by changing that
function — which is exactly when a panic in the swap path would be found in
production.
Co-authored-by: Hanzo Dev <[email protected]>
/v1/trade/swap asked the hosted provider registry for a price. This deployment
registers none on purpose, so it answered 500 while /v1/trade/quote priced the
same pair from the pools a line away — and then encoded whatever came back for
the PoolManager precompile at 0x9010 regardless of where the price came from. A
router on Ethereum quoted and a precompile on our chain addressed, in one
response. 0x9010 answers 0x on 96369, measured with the venue's own quoter
selector, so the calldata named a contract that is not there.
Venue has a Swap method now. Each one builds against the contract it read: the
V3 arm against the SwapRouter02 beside its quoter, the V2 arm against the same
Router02 that answered getAmountsOut. A venue that prices a market it cannot
settle returns nothing, and the surface says so naming it.
The selector was wrong in the way that spends money. calldata_lxrouter.go sent
04e45aaf under a signature it called ExactInputSingle(address,address,uint256,
uint256,uint160,bytes32); that hashes to f48f227e. 04e45aaf is SwapRouter02's
exactInputSingle((address,address,uint24,address,uint256,uint256,uint160)) — a
seven-field tuple. A router handed the six-field form reads amountIn where the
fee tier belongs and amountOutMinimum where the recipient belongs. Those
builders were reachable only from their own tests; the padding and decoding
survive in abi.go.
Proved end to end: the gateway's own calldata replayed through eth_call against
96369's real SwapRouter02, from the account holding the allowance, returns
997001483075056712 — the number the quote gave. The tier travels on
route[0].fee because a V3 quote asks four tiers and keeps one, and a swap that
does not name the same pool executes at a different price. amountOutMinimum is
99.5% of the quote by default, because a router handed the quoted amount as its
floor reverts on the movement the caller's own trade causes.
Permit2 is at one address everywhere it exists and does not exist everywhere:
9152 bytes on Ethereum, nothing on 96369. Both paths check first, so our chain
gets a sentence instead of calldata addressed to an empty account.
Nine more paths are gone because they could not work here. Five answered 500
"no providers available" — tokens, pools, pool/, positions, stats, price,
prices — because enumerating a chain is an index read and this reads one pool
by address, right now. api-explore.lux.cloud already answers all of it: 16
pools, $39.9M of TVL, real symbols and decimals for 96369. /v1/route is the
same read in a hat and returned {"routes":[]} forever, which a caller reads as
"no path exists" rather than "I cannot answer". The order paths were an
in-memory map behind two replicas, so an order placed on one pod was a 404 on
the other about half the time and every order died on restart — the D-Chain
venue is the order book and it is deployed beside this. The position builders
all addressed 0x9010.
Eight paths remain and all eight were exercised against the live chain.
Co-authored-by: Hanzo Dev <[email protected]>
Three things claimed to be this repo's trading API and no two agreed.
pkg/trading carried its own Venue interface, its own V4/native/V2/V3 and broker
venues, its own constant-product math and its own eth_call client — a copy of
what pkg/gateway already had — and nothing mounted it: grep for the import
outside that directory returns empty. api/openapi.yaml documented it anyway, so
the one published contract in the repo described the one package no binary
served. Meanwhile the binary answered at /v1/* and again at /trading/* in
Uniswap's shapes: two contracts for one API, reachable by both names, free to
drift. Deleted, both of them, rather than picked between and left beside.
Everything answers under /v1/trade now. api.lux.cloud carries the chain at
/v1/chain, the C-chain at the root and other services besides, so /v1/quote was
a generic name claimed on a shared host; one prefix is one namespace with one
owner, and the edge rule that publishes it collapses from a nine-clause
allowlist to a prefix. Server.routes() is the surface written once, and
TestTheDocumentIsTheSurface reads api/openapi.yaml and holds the two to each
other in both directions.
/v1/trade/venues is new. It is the question a screen asks before it asks
anything else — which chains this deployment can price, and from what — grouped
by chain, because a venue is a contract on a chain and the same name means a
different pool on each. Without it a client learns a chain is unquotable by
quoting on it and reading an error.
The allowance is read rather than assumed. approval/check and permit2/check
answered allowance 0 and needsApproval on every request, under a comment saying
the gateway had no way to reach a chain; it has one per chain, one field away —
the same endpoint the pool is quoted from. Measured against 96369: LUSD to the
position manager reads 6000000000000000000000000, which is what eth_call
returns for that pair, and the approve tx now rides along only when it is
needed. Handed back beside "you already approved" it is a transaction somebody
sends, and the user pays gas for a state change that changes nothing before
every swap. Permit2 reads the expiration too, because a grant that has run out
is not a grant.
Nothing here invents a number any more. /v1/history/prices and /v1/history/tvl
returned a random walk seeded from hardcoded base prices — LUX at 2.47, WBTC at
96420, 12.5M of TVL on a chain whose pools were seeded hours ago — and both
were routed to the public internet, where a chart drawn from them is
indistinguishable from a chart of the market. History belongs to the indexer,
which reads the chain. /v1/tokens/search answered 501 on a TODO, a route lying
about the surface in the other direction. Gone, with a test that keeps them
gone.
Co-authored-by: Hanzo Dev <[email protected]>
The three that cost time putting it behind api.lux.cloud — a single-arch image
in a mixed cluster, runAsNonRoot against a named user, and a health path that
answers 503 about a working gateway — plus why there is no admin surface.
Co-authored-by: Hanzo Dev <[email protected]>
Three things this gateway said were not true, all of them found by putting it
behind api.lux.cloud and asking it the questions a screen asks.
/v1/admin/* paused the DEX, paused a pool and froze one — "IRREVERSIBLE", its
own word — on the strength of `X-User-Role: admin`, a header the caller writes.
Nothing read the state it wrote: PauseState had no caller outside its own file,
so the freeze froze nothing and the pause paused nothing, per replica, until the
process restarted. A switch wired to nothing is worse than no switch, because in
an incident someone pulls it, reads {"status":"paused"}, and stops looking. The
real gate is checkPauseState in the pool manager, on chain, where a market is.
Deleted rather than authenticated: there is nothing here to protect.
/healthz reported the hosted provider registry alone. This deployment registers
no hosted provider on purpose — the trade API answers ACCESS_DENIED without a
contract — so the platform's own health path answered 503 forever about a
gateway quoting seven chains, and a readiness probe on it holds every replica
out of its own Service while every quote works. It reads the venues now, and
names them.
/v1/quotes is /v1/quote's question without the last step, and it was reading a
different source: the registry, so a chain we settle ourselves answered "no
providers available" while /v1/quote priced the same pair from the same pools a
line away. One source now — venueQuotes returns what each venue holds, best
first, and venueQuote is its head.
Co-authored-by: Hanzo Dev <[email protected]>
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]>
This never built. The workflow lived in .hanzo/workflows, which GitHub does not
read, and the forge does not carry this repository — so a v* tag published
nothing and the newest image on ghcr predates the gateway by two weeks. luxfi's
runners are self-hosted and registered against GitHub; the pool is
lux-build-linux-amd64, which is what every other luxfi image is built on. Same
lane as luxfi/node, and still never a GitHub-hosted builder.
Co-authored-by: Hanzo Dev <[email protected]>
One image, two entrypoints. They share the venues, the types and the router;
building them apart is two versions of one contract waiting to disagree, and
the binary is a few megabytes beside a daemon that already ships.
Co-authored-by: Hanzo Dev <[email protected]>
The hosted API answers ACCESS_DENIED without a contract, which made every
market outside our own chain unquotable and turned a pair with no pool into an
error about somebody else's quota. A pool answers anyone. So the gateway reads
contracts: our chain through the V4 precompiles, with V2 beside them when a
router is deployed, and every other chain through the V2 router and the V3
quoter Uniswap published there. The hosted provider is registered only when a
key is given, because a provider that always refuses is worse than none.
V3 was the missing arm and it is where the liquidity is. The gateway could read
a V2 router and our precompiles and nothing in between, so most markets on most
chains were invisible. The venue asks each of the four fee tiers and keeps the
best — which tier holds a pair is a fact about that pair — and a tier with no
pool reverts, which is the ordinary answer to three of the four rather than a
failure to report.
Venues are per chain now. One router for all of them could not tell which chain
it was answering about, so the shape only ever fit a single-chain deployment.
Each chain names its RPC, its router and its quoter, and any of them can be
overridden by name.
Quotes are cached for ten seconds. A pool moves when somebody trades in it, so
the window is short by nature; what it saves is the second reader of the same
pair in the same second, and every eth_call saved is one a public endpoint is
entitled to refuse. Measured: 1.26s cold, 22ms warm.
And a route names the venue that answered. The router's own routing label
describes its strategy, so a V3 pool on Ethereum came back marked V4_NATIVE —
our precompile, on somebody else's chain. That is the one thing a route must
never misreport, since it is what a reader checks to see where their order goes.
Measured live, no key: 1 WETH is 2494.500698 USDC on Ethereum via uniswap_v3,
2494.591730 on Base.
Co-authored-by: Hanzo Dev <[email protected]>
pkg/gateway has carried the whole thing for a while — quote, swap, approval,
orders, pools, prices, tokens, stats, history — with a Uniswap provider that
speaks the shapes at trade-api.gateway.uniswap.org, and nothing ever started
it. `dexd run -http` mounts the D-Chain venue's own handlers under /v1/dex,
which is a different surface for a different reader, so an interface asking for
a quote reached nothing on any chain. It has an entrypoint now.
Two things it did wrong the moment it ran, both the same mistake in different
places. The router asked every provider about every chain, so a quote for a Lux
market went to Uniswap and came back ACCESS_DENIED — somebody else's API error
dressed as a fact about our liquidity. A provider is asked only about the
chains it lists now, and one that lists none is taken to serve any, which is
the shape a single-chain deployment has.
And the venues were attached to the server and never read: `s.venues` was
stored by an option nothing consulted, so the chain we settle ourselves was
quoted by the chain we do not. Our own venues answer first — an upstream
provider that also lists the chain is a second opinion about a market we
settle — and a venue holding no liquidity returns nothing rather than an
error, so falling through to the providers stays the honest next question.
A chain of ours with no provider behind it then says no venue holds the pair,
rather than "no providers available", which sends a reader looking for a
misconfiguration that is not there.
Co-authored-by: Hanzo Dev <[email protected]>
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]>
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]>
The id is declared twice — here beside the Factory and in luxfi/constants,
which is the one genesis writes into the CreateChainTx — and nothing made them
agree. If this repo's literal moved, a node would look for a plugin under one
name while genesis named the other, and the chain would never appear: a plugin
that is absent and a plugin nobody asked for look identical to a directory
scan.
The second test records the property that decides how a plugin gets pinned. The
id is an ASCII name padded to 32 bytes, not a digest of the binary, and it must
not become one: genesis references it from a transaction already signed and
already in the chain, so an id derived from the build would change on every
rebuild and stop resolving the chain it created. Identity is stable across
versions by construction.
Which leaves integrity as a separate value. Nothing states which build of dexd
is the right one, and the registry runs whatever sits at the filename. What
CB58 does buy is that a mistyped filename fails its checksum instead of
resolving to nothing.
The VM had no factory, so the only way to build it was the literal in the
plugin entrypoint. That kept the D-Chain reachable exactly one way — as an
execed binary — and left the node unable to register it beside the chains it
links, which is why luxd came up without a D-Chain at all.
Factory is now the one constructor: the node registers it in-process and the
plugin entrypoint builds through it before handing the VM to rpc.Serve. VMID
moves in beside it so the chain and the id it answers to cannot drift apart.
luxd serves chain routes under /v1/chain, and the node, cli and sdk releases
already ship it. The deployed gateways still answer the old prefix, so remote
reads break until they are redeployed; the estate moves together rather than
sitting half converted.
Lines that record an observation - a probe, a status code, a past release -
keep the prefix they were written against, as do URLs on hosts that are not
ours. Avalanche endpoints move to /ext/bc, which is the path that host serves.
Co-authored-by: Hanzo Dev <[email protected]>
The rename to /v1/chain is real in luxd source, but the deployed fleet has not
taken it yet: probed just now, api.lux.network, api.lux-test.network,
api.zoo.network and api.hanzo.network each answer 200 on /v1/bc/C/rpc and 404 on
/v1/chain/C/rpc. Composing /v1/chain against those hosts reaches nothing, which
is the failure the rename set out to prevent.
The move belongs with the node redeploy, not ahead of it.
Co-authored-by: Hanzo Dev <[email protected]>
luxd serves chain routes under /v1/chain; the /v1/bc prefix it replaced is
gone, so anything composing it reaches nothing. Lines that record an
observation - a probe, a status code, a past release - keep the prefix they
were written against, as do URLs on hosts that are not ours.
Co-authored-by: Hanzo Dev <[email protected]>
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.
Lux Partners Limited is not the entity behind this work. Every place the
old name stood — copyright lines, site footers, brand config, legal and
compliance text — now names the company that actually holds it.
lux.fund is the exception and is not renamed to match: that site belongs
to Lux Assets Management LLC, a different company, and it says so.
Two kinds of line were left for a person rather than rewritten. A dated
news item recording that Lux Partners Limited was established in the Isle
of Man is a historical fact, and renaming it would falsify the record. And
a sentence asserting that the company is domiciled in the Isle of Man is a
claim about a jurisdiction, not a name — an Inc or an LLC is not an Isle
of Man company, so swapping the name there would assert something untrue.
Co-authored-by: Hanzo Dev <[email protected]>
dexd run could not start. The VM refuses to found a chain from a creation
document it was not given — correctly, since a genesis the binary derived
is one no other node would derive — and standalone called Initialize with
no document at all, so every invocation ended at that refusal. The mode
the binary's own help describes was unreachable.
It takes --genesis now: under luxd the document arrives as
InitializeRequest.GenesisBytes, and standalone has no P-Chain to read it
from, so an operator names it. The bytes are opaque; what matters is that
somebody chose them, so two nodes told to be the same chain are.
The surface also mounted a segment twice. httpHandlers returns keys that
already carry the chain's namespace — "/dex/<method>", the same segment a
node serves under /v1/bc/<D>/ — so a prefix of "/v1/dex" produced
/v1/dex/dex/<method>: a path no client written against a node would try,
and not the one the flag promises. The prefix supplies the version now.
Verified by running it: the venue starts against a document and answers
dex_get_markets and dex_get_trades at /v1/dex/<method>.
Co-authored-by: Hanzo Dev <[email protected]>
The clean-room posture is worth keeping; the surface it described had moved.
Settlement is 0x9999, which carries the V4 PoolManager ABI this path already
encodes. The LXPool precompile at 0x9010 it pointed to is deprecated —
eth_getCode returns empty for it on mainnet, so the calls it built went to an
address with no code behind it.
FIX is FIXT.1.1 at the session layer and FIX.5.0SP2 (EP307) at the application
layer. pkg/fix asserts Parse errors on 8=FIX.4.4 and 8=FIX.4.2, so the 4.4
profile advertised here could not open a session.
The ws subpath now says nothing serves it: pkg/api compiles and is tested, but
NewWebSocketServer and NewVenueWS have no caller outside those tests and no
binary in cmd/ imports the package. A client for a listener nobody started is
worth shipping only if it says so.
zap and the precompile path were checked the same way and both reach something
live.
Co-authored-by: Hanzo Dev <[email protected]>
Two source files behind a package name that was never published, and
nothing in the tree imports it — every reference is the package naming
itself in its own README, manifest and lockfile. The grpc client it
describes is served by @luxfi/trading, which is published and is what
the 33 call sites actually use.
Co-authored-by: Hanzo Dev <[email protected]>
sdk/typescript declared itself @luxfi/[email protected] with a prepublish script,
but @luxfi/dex on npm is the viem precompile client and its registry
history starts at 2.0.1 — 1.0.0 was never published and nothing here
imports this package. Two codebases claiming one name is a publish
accident waiting to happen, and the axios/ws REST client it holds is not
what that name means any more.
Co-authored-by: Hanzo Dev <[email protected]>
flare.mdx published an OrderTransactions that sorts by priority fee,
highest first. No such function exists, nothing anywhere reads a priority
fee, and the mempool says the opposite in as many words: ordering is
arrival order, and a fee-priority mempool is a future concern the venue
does not have.
That matters more than an ordinary doc error. This repository is public,
and a venue documented as selling position is a different venue from one
where order ids are (height << 32) | txIndex and cannot be bid for. The
page now describes the comparator that runs — price, then the id, FIFO
within a level — and says plainly that fee priority is not it.
Co-authored-by: Hanzo Dev <[email protected]>
docs/app/layout.tsx referenced ZenMono without importing it and without @hanzo/font in its dependencies at all, so the docs site could not compile: tsc reports "Cannot find name 'ZenMono'" on the bare tree. Both faces are imported from the package now, and the package is declared. The README hero card still asked for Inter.
The docs layout named three different faces in one place: a binding called
`inter` or `geist`, constructed from next/font/google so it actually held Inter,
publishing `--font-geist-sans`, which @hanzo/font does not set. Whichever a
reader trusted, two were wrong.
It takes Zen and ZenMono from @hanzo/font — named `sans` and `mono` for the role
they fill, setting the properties the package publishes.
TxOpenMarket does not require auth and execute() wrote its (base,quote)
binding unconditionally, so any unsigned open_market could rebind a live
market. A maker that rested a sell had locked its base; after a rebind
the settle path spent a different, unlocked asset and returned
ErrInsufficientLocked out of execute() as a block abort — so every later
BuildBlock/Verify failed identically and the chain could not advance past
that height. A market's assets are now decided once: bind when unbound,
no-op an identical rebind, and reject a conflicting one as a decided tx
while the block still succeeds. Separately, the maker leg was read from
the taker-side byte with opposite defaults in makerSettleKey and
decrementMakerReserves, so an out-of-domain side (the byte is uncontrolled
on the wire) made them name different legs; both now read one canonical
makerOf, unchanged on the valid {buy,sell} domain.
Co-authored-by: Hanzo Dev <[email protected]>
The classical SignedOrder binds ChainID, NetworkID and Expiry so a
captured order cannot replay onto another chain or after it expires.
The strict-PQ order carried none of them, so an ML-DSA-signed order was
good on every chain forever — the exact replay the classical path
already closed. Mirror the three scope fields onto SignedOrderPQ and
fold them into the shared SigningHash, so one digest binds the same
scope under either scheme. Mutating any scope field now breaks the
signature (regression test).
Co-authored-by: Hanzo Dev <[email protected]>
The signing digest covered the order's economic fields but named no chain, no
network, and no deadline, so a signature made for one chain recovered its signer
on every chain and never expired. On a permissionless exchange a signed order
could be lifted onto another D-Chain, or replayed forever.
ChainID, NetworkID and Expiry are packed into SigningHash as the authorization
scope, ahead of the economic fields, in the same fixed-width hand-written layout
so a future field cannot silently drop out of the signature. The D-Chain tx
digest binds the same scope for the same reason: a signature must name the chain
it spends on to spend on only one.
COORDINATED: this changes the order signing digest, so every signer and every
verifier moves together. It is a protocol change, not a lone patch, and the
order-authorization layout belongs in an LP alongside the finality-message spec.
Co-authored-by: Hanzo Dev <[email protected]>
SigningHash packed uint64(math.Round(Size*1e8)), so the digest committed to a
rounded tick count while the matcher's crossing loop and RemainingSize read the
float. Two sizes inside one rounding interval produced the SAME digest: a
signature taken over one authorized the other. On a permissionless exchange that
is a signed order whose quantity the holder can change.
An off-tick size is refused rather than snapped. Snapping decides, silently, that
the signer meant a quantity they did not write, and that is the one thing a
signature exists to prevent. Two on-tick sizes differ by at least a tick, so
their digests differ; an off-tick size cannot be signed at all. The collision is
unreachable from either side.
The epsilon absorbs the binary representation error of a decimal quantity and
nothing more — it is orders of magnitude below one tick, so it can never let two
distinct ticks meet.
Co-authored-by: Hanzo Dev <[email protected]>
The C matcher takes the book pointer and the indices but no book LENGTH, so it
cannot bounds-check, and the Go wrapper did not either. MatchOrderCPU guards the
same field because indices genuinely can be out of range — one field, two
readers, one guard. On a real Metal device that wrote past the end of a Go slice
and answered 420 where the oracle answered 490.
An empty book is the same hole from the other side: &book[0] panicked where the
CPU path returned cleanly, so one build was total and the other fatal on
identical input. Both cases hand off to the oracle rather than restate its bounds
rule here; a second copy of a bounds check is a second chance to get it wrong.
The shadow gate gave every market order the MAXIMAL price, "so it crosses every
resting level". That is true for a market BUY, which sits on the bid and crosses
any ask at or below it. A market SELL sits on the ask and crosses any bid at or
above it, and no bid is ever above +inf — so every market sell matched nothing,
compared empty against empty, and was counted VERIFIED. The mechanism the GPU
path is assured by was measuring nothing for an entire order type, while an
operator watched Verified climb and Divergences stay at zero.
Co-authored-by: Hanzo Dev <[email protected]>
LLM.md is the source and stays the source; CLAUDE.md is a symlink to it, so there is
one file and it cannot drift from itself. What changes is that the link is committed
rather than left for each checkout to recreate.
Left untracked, the doc was conditional on a step outside the repo: a fresh clone had
no CLAUDE.md, nothing loaded LLM.md, and an agent worked here having never read what
this repo already explains. Most of the estate already tracks theirs; this is the rest
agreeing with them.
The method table is the only caller of submitTx, submitTx is the only caller of
mempool.Add, and there is no gossip handler — so a tx type absent from this
table cannot exist. TxImport and TxExport are both absent, which is the whole
reason the rail moves nothing today and why every C->D deposit would lock on
activation: C debits into custody, stages a claim, and nothing on D can consume
it.
That is a decision, not an oversight, and it was recorded nowhere near the place
someone would undo it. An import method is not a method — it is an
authorization model. TxImport carries no signature by design, so the ingress is
an unauthenticated, free-to-call, value-moving endpoint, and what stands between
it and a chain halt is that the proposer screens what it cannot prove and that
an already-consumed claim needs no proof. Both are new this week, and neither
had ever run in a test before the block authenticator was switched on.
Co-authored-by: Hanzo Dev <[email protected]>
txAuthDigest bound the type, the nonce and the whole frozen body. Nothing in
that preimage names a chain, and nothing in a tx body does either. Account16
folds a key to an account with no network in the fold, so one key is the same
account on every D-Chain there is, and the native asset is all-zero on all of
them.
So a signed frame was valid wherever it could be parsed. An export signed on a
devnet is a valid export of the same account's mainnet balance to the same C
address, and anyone who saw the frame can submit it there. The signer authorized
one withdrawal and paid for as many chains as exist. Nothing had to be forged
and no key had to leak — the frame is replayed verbatim.
Put the network and the chain in the preimage. A frame now verifies on exactly
the chain its signer meant and is bytes on every other. This covers every signed
type, not only the export: a signed order was equally portable, and there is one
digest, so there is one place to fix it.
verifyTxSignature takes the scope from the VM, which fixes it at Initialize and
never changes it, so the check stays a pure function of the frame plus a
constant — still runnable at the RPC boundary before the mempool and identically
at consensus.
Account16 is deliberately NOT scoped. Its network-independence is what makes one
key one identity across the stack, and with the signature bound it is no longer
load-bearing for replay. It also keys every genesis allocation, and three chains
are about to be founded on those values.
Test VMs and the test signer now share one scope, because a signer and the chain
it signs for have to agree on it — which is the property.
Co-authored-by: Hanzo Dev <[email protected]>
verifyImports gates on normalOp. No test in the package ever set it, so every
rail test proved its property with the block-level check returning at its first
line: the forged-object rejection, the absent-object rejection and the bootstrap
gate were assertions nothing had executed. Turn normalOp on in the rail harness
and TestRail_ClaimImportsOnce — a test whose whole subject is replay — fails
immediately.
What it was hiding is a permanent halt reachable by an honest party. Delivery is
permissionless by design, so two relayers delivering the same claim, or one
retrying after a timeout, is the steady state. The second delivery is well
formed and carries the real recorded bytes; the object is simply gone from
shared memory, because this chain's own accepted Remove took it. The
authenticator read that as unbacked and rejected the block, Reject requeued
every tx in it, and the next build drained the same delivery and failed the same
way. Measured over four rounds, the chain never left the height it stopped at.
The consumed set is the committed fact that explains the absence, so consult it
first: an import that cannot credit needs no proof. executeImport's replay guard
runs before it decodes or credits anything, so a consumed claim pays out nothing
whatever bytes ride with it.
That is the safety half. The liveness half is that a validator has no lesser
answer than rejecting a block carrying an unprovable import — the proposer's
execution root already contains the credit — which makes proposing one
self-harm. So the proposer screens the same predicate at build and holds what it
cannot prove. Held, not dropped: a claim C has recorded but not yet flushed here
is unprovable now and provable in a moment, and that lag is the rail's known
liveness property, not an invalidity.
One predicate, proveClaim, with two callers doing two different jobs.
Also covers what running the authenticator buys back: forged bytes, an
unrecorded claim id, the bootstrap gate in both positions, an over-export
writing no claim, and commitAtomic's no-batch rule and commit-then-apply order.
Co-authored-by: Hanzo Dev <[email protected]>
executeImport refused only when the C chain id was empty. executeExport also
refused when shared memory was nil. The plugin server sets a C chain id
unconditionally and leaves shared memory nil whenever the node wired no atomic
server, which is the state of every live network today — so on the import side
60 self-authored bytes named a beneficiary and an amount and became real,
spendable balance, with no C-side debit anywhere behind them.
verifyImports exists to catch exactly that, and returned nil on the same nil
handle. Its reason was that "executeImport rejects without a C chain id": true,
and never the condition that holds. Both guards keyed on the wrong fact, and the
two holes covered for each other.
Bind both halves to the same condition. Execution refuses the credit without
shared memory; the block that carried the import is rejected rather than
accepted unproven. Either alone is sufficient, which is why there are two. A
block carrying no import is unaffected — the check is per-import, so an absent
rail is not an error, only crossing on one is.
commitAtomic's nil-shared-memory branch asserted this was already unreachable
while committing the state anyway and dropping the op. Now that both producers
really do refuse, make the claim self-enforcing: accumulated ops with nothing to
apply them to stop the node, the same answer an Apply that errors already gets.
Co-authored-by: Hanzo Dev <[email protected]>
Import took a bare Claim. Every other value-creating path in this package
already had the unforgeable-value discipline — VerifiedExecution,
VerifiedOrder, AcceptedBlock — and the export side has three storage
domains. The import side, the ONLY path that creates value on D, took an
exported struct with exported fields, so
Claim{Beneficiary: attacker, Asset: usdc, Amount: 1e30, ...}
returned nil and credited 1e30. Validate() checks that fields are
PRESENT, which is a different question from whether anyone said them.
It survived because permissionless delivery reads as a safety property:
"anyone may deliver a claim, because delivering it can only credit the
account it already names." That holds only if the claim is genuine. The
argument for the feature quietly assumed the check that was missing.
Import now takes a VerifiedClaim, produced only by VerifyClaim, with an
unexported interface method as the sentinel — unexported fields alone are
insufficient because T{} is zero-constructible anywhere. The strongest
statement is not a test: c.Import(Claim{...}) no longer compiles.
The commitment covers the corridor, not just the payment, so an
attestation for one direction cannot be replayed as the other and one for
a different pair of chains cannot be replayed here. Destination is the
verifier's own view, never the witness's claim about itself.
Second, same class: VerifyOrder read `ctx.Nonces != nil && ...`, so a
caller that forgot to wire the nonce set got no replay protection and no
error. Two lines above, a nil Signer fails closed. They agree now — the
quietest way to lose a safety property is to make its absence look like a
configuration choice.
87 tests.
Co-authored-by: Hanzo Dev <[email protected]>
The two lineages derive different genesis blocks. A fleet that keeps its v1.5
store keeps working, but any node whose store is wiped — or any validator added
later — adopts the v1.14 genesis, disagrees at height 0, and is stranded there
forever with isBootstrapped:false and no error naming the cause.
Found by wiping one devnet validator's D store after the roll: it came back on
genesis mpxP6xF3... while the fleet carried Muetn.... Wiping D on all five
converged them, and a subsequent wipe of one node then re-derived every block to
the fleet's exact root.
A matching height is not enough to verify this — every node reports height 0
either way. Compare lastAccepted.
Co-authored-by: Hanzo Dev <[email protected]>
Three tests against the real VM and real shared memory:
- a deposit crosses, credits the beneficiary's OWN account, and places NO
order — asserted as a negative, by counting resting rows across the
crossing. The beneficiary then trades that money with an ordinary signed
order, which is the whole reason the crossing is allowed to be this boring.
- a re-delivered claim credits nothing. The consumed set, not the shared-memory
Remove, is what makes that true, so a crash between the state commit and the
apply cannot resurrect it.
- an export cannot move value without its owner's signature: the constructor
refuses to build one, the parser refuses the hand-forged frame, and a
frame signed by someone else moves nothing.
The wire golden is pinned on both sides of the seam against the same strings,
including the id derivation — a drift there does not corrupt value, it loses it:
one side writes the claim under one key and the other looks under another.
Co-authored-by: Hanzo Dev <[email protected]>
Move once, trade many. Value crosses the C<->D boundary twice in its life; in
between, D owns it and every order, match and trade is a local balance movement.
So the crossing has exactly one job — move ownership exactly once — and the
object shrinks to what that job needs:
beneficiary(20) | asset(32) | amount(8) = 60 bytes
Out of the wire: the 49-byte market/side/limit/size tail, the `spent` execution
witness, and the lane byte that told C which trading pot to debit. Out of the
package: the per-order escrow, the per-order ledger account, the proposer-side
drive that scanned shared memory for orders to run, and TxOrderSubmit. An
import now credits the beneficiary's OWN account — the one their signed orders
already draw from — so the money is tradable the moment it lands, with no second
crossing and no order created by the crossing.
TxExport is now signed. It used to draw on a per-order account whose only
possible payee was the escrow's recorded owner, so the object was its own
authority. Draining a real account to a caller-named address is not, so the
body asserts its owner and txWireUser binds that to the recovered signer, the
same way a withdraw does.
One claim id, both directions, both repos: SHA-256(domain | source | dest |
sourceTx | index). It replaces DeriveOrderID and deriveSeamOutputID, which were
two derivations of the same fact.
Co-authored-by: Hanzo Dev <[email protected]>
An empty database and an empty creation document was the one combination
the three-way genesis check could not judge. Nothing delivered, nothing
stored, so no view contradicted another and Initialize returned the binary's
own default as the chain's genesis — the exact defect the check exists to
remove, reached through the absence of its inputs rather than their conflict.
A D-Chain's document is the dchain.json recorded in its P-Chain
CreateChainTx and reaches the VM as InitializeRequest.GenesisBytes. An empty
one therefore means the bytes did not arrive, which is a delivery failure,
not a chain that has none. Founding is refused, and the refusal names the
genesis it declined to use so the two cases are distinguishable in a log.
Existing chains are untouched: this fires only when the database holds no
chain at all. A chain already on disk that is handed no document still fails
the stored-vs-expected comparison, as before, and one born under a build that
kept no record still recovers from its own height-0 head.
Nineteen tests founded chains with no document and now supply one; the four
that model chains already on disk deliberately still supply none.
Co-authored-by: Hanzo Dev <[email protected]>
Every determinism test in this package compares ComposeRoot against
ComposeRoot, so both sides move together and a change to the composition
stays green. That is the pattern that let the ledger operand into v1.14.0
unseen. Genesis is now pinned against the written construction; every root
from height 1 on was not pinned at all, which is the same fork one block
later.
TestBlockRootGolden fixes distinct non-zero operands and pins the value,
recomputed on the spec side with a separate keccak so the vector cannot be
brought back into agreement by editing the code it checks. Height is pinned
at two values, and operand swaps are rejected, so a reorder is caught as
surely as an added term.
Injecting an operand into ComposeRoot fails it; so does moving height ahead
of the ledger.
Co-authored-by: Hanzo Dev <[email protected]>
Height 0 was built by calling the block composition with the creation
document's digest in the slot that means "parent" everywhere else. The slot
was unused, so the convention held — and nothing enforced it. When the custody
ledger entered ComposeRoot in v1.14.0 every chain's genesis moved with it,
silently, because genesis was downstream of a composition that has nothing to
do with chain identity.
Genesis now has its own domain-separated construction and no longer touches
the block root:
GenesisDigest = keccak256("dex/genesis/v1" | init.Genesis)
GenesisRoot = keccak256("dex/genesis-root/v1" | digest | book | trade | tx | ledger)
BlockRoot = keccak256("dex/block-root/v1" | parent | book | trade | tx | ledger | height)
Different creation document -> different digest -> different genesis root ->
different root at every descendant height. That is now structural rather than
a property of a field that happens to be free.
GenesisDigest is defined for every input including none, so a running chain
always has one; whether a chain may run without a creation document is a
policy question and stays where it was answered.
The vectors verify the derivation against the written construction, using a
separate keccak and the empty sub-roots read from pinned hex, so they do not
drift by being updated to match. They also hold the three fleet documents to
their on-chain sha256, and pin the genesis against a VM carrying arbitrary
runtime state: same bytes in, same chain out, whatever the binary is doing.
Perturbing the block root now leaves every fleet genesis unmoved; routing
genesis back through the block composition fails the vector.
Co-authored-by: Hanzo Dev <[email protected]>
The arriving-trader path: bring value across from C and place an order
with it in one D transition. The semantics look wrong until you see the
failure they prevent.
The tempting design makes the pair atomic in the strong sense, so a
trader never ends up funded without the order they wanted. That strands
people. A deadline can expire in transit — C accepts the export at t, D
imports at t+d, and an order whose deadline fell in between is dead on
arrival through nobody's fault. Failing as a unit leaves the value in the
in-flight state with its only consuming transaction permanently invalid,
and the trader has to be rescued by hand.
So the import is unconditional and the order is best effort. Order
failure is DATA, not transaction failure: expired deadline, bad
signature, consumed nonce, wrong asset, insufficient balance all come
back as a Rejected Result carrying a Reason, with the value sitting
available on D — a state the trader can act on alone, by placing another
order or exporting home.
The one real failure is an inadmissible claim. That is not an order
problem; there is nothing to land, so it is an error rather than a
Result.
Two details that follow. A rejected order does not burn the nonce — the
trader never used it, and an unordered nonce is a position they chose; a
test re-signs with the same nonce afterwards and it works. And Cancel
derives its refund from the lots it actually retired, never from a
caller-supplied amount, which is how a cancel comes to refund more than
the order ever committed; with three of ten lots committed to a match,
cancelling refunds seven and leaves the three held.
83 tests, including the full arriving-trader round trip: import and
place, partially trade, cancel the remainder, export home — one boundary
crossing in, one out, everything between it purely local.
Co-authored-by: Hanzo Dev <[email protected]>
The chain-creation documents I pinned came from luxfi/genesis's dchain.json. That
file created none of the three live D-Chains. The bytes in each fleet's
CreateChainTx differ from it: mainnet and testnet carry no chainId at all and end
with a newline (356 bytes); devnet puts chainId first in sorted order and has no
trailing newline (375 bytes — the same length as the config file and not the same
bytes, so a size check passes and proves nothing).
Pinned now: all three documents verbatim, each keyed on its keccak256, recovered
from the CreateChainTx and confirmed byte for byte against every pod's own
/data/genesis.json dChainGenesis. Two independent sources.
Two traps make the document opaque by necessity, which is how the VM already
treats it — hashed raw, never parsed, never re-encoded. The trailing newline is
load-bearing and present on only two of three. The em dash is the literal six
characters —, so a JSON round trip changes both length and hash.
TestGenesisLiveFleets now asserts the defect directly: not one live D-Chain has
the genesis its own creation record implies. All three were born from the binary.
Co-authored-by: Hanzo Dev <[email protected]>
Recovery blessed meta:headBlock as the genesis without checking the head pointer
named it. On a database where the two disagree that recorded a genesis out of thin
air — the same invention the record exists to prevent, reached by a shorter route.
Assert the head block is the head before trusting it, and record nothing when it
is not.
Co-authored-by: Hanzo Dev <[email protected]>
The D-Chain derived its height-0 block from compiled-in defaults and threw away
the genesis bytes the node handed it. Two consequences, both live:
* dex v1.4.2..v1.13.0 and v1.14.0+ derive DIFFERENT genesis blocks. v1.14.0
(e58e2a3) put the custody ledger into ComposeRoot, taking the keccak preimage
from 136 to 168 bytes, so the empty-state root moved. lux-mainnet and
lux-testnet run MuetnVSbs1UnPUDTH5q4kgbKcfRx97DjmzZXVxcCdQ52H9sES; lux-devnet
runs mpxP6xF3ahpjdwP6YgjtagSWQCZSR6pziEqDoDv573k2V3WJA. The fleets are split
and nothing said so.
* every network shared one genesis, because none of them reached the VM.
A wiped validator therefore adopted whatever its binary believed, looked healthy
alone, and was partitioned for good. Wiping chainData is the ordinary repair for
a stuck chain, so the ordinary repair was the thing that stranded the node.
Genesis now comes from block.Init.Genesis — the dchain.json recorded in the
P-Chain CreateChainTx, carried across the plugin boundary as
InitializeRequest.GenesisBytes and, until now, dropped on the floor. The document
is opaque: its digest occupies genesis's parent-root slot, which nothing used, so
the chain begins from its configuration and every later root descends from it. No
schema of the document enters consensus.
meta:genesis records the height-0 image in the batch that commits it. Every start
reconciles three views — the creation record, this binary, the database — and a
disagreement refuses to start naming all three. A chain born without the record
recovers it at height 0 from its own head block, which is durable data, not a
guess; past height 0 there is nothing honest to recover and it refuses.
Removed with it: loadHeadBlock's rebuild-genesis-from-code path, and readRoot's
silent zero for a missing root, which would have rebased the chain just as quietly.
genesis_test.go pins the images byte for byte, including both live fleet genesis
blocks read from luxd, so this cannot drift again unnoticed.
Co-authored-by: Hanzo Dev <[email protected]>
Three separate proofs, none able to stand in for another:
cross-chain C + C->D + D + D->C constant token base units
D-local owned == available + reserved token base units
order-local original == open + reserved
+ traded + canceled LOTS
The units stay apart. Merging them is how a lot count gets added to a wei
balance and type-checks; the market's lot size connects the domains and
one test asserts the two numbers differ, so a future merge has to delete
an assertion rather than quietly succeed.
Four terms, not three. Open lots are restable and cancellable; reserved
lots are committed to a match already in flight. Collapsing them loses
the property that matters: Cancel takes the OPEN quantity only, because a
cancel reaching committed lots would let the same lots be cancelled and
traded, which is the order-book form of a double spend. The committed
remainder resolves when its match does, and the trader may cancel again
to retire whatever came back.
Cancel is a no-op on an order with nothing open rather than an error.
"Retire whatever you can" is the honest reading of the request, and
failing it would force callers to inspect state to avoid an error they
cannot prevent.
Every transition moves lots between two terms and never writes a total,
so conservation is a property of the shape. A 3000-step random run
asserts it after each: 548 trades, 451 cancels, 528 returns, 257 orders
replenished. The run replenishes exhausted orders deliberately — without
that it did 67 real transitions in 3000 steps, and a floor assertion now
fails if it degrades back to flattering the step count.
75 tests.
Co-authored-by: Hanzo Dev <[email protected]>
The D->C leg is the sharpest monetary edge in the system. D debits the
owner and writes an object for C to consume; if the write is lost, the
value has left D and never arrives. Refunding on a timer is the obvious
repair and is a double-spend waiting to happen — the moment a refund and
a late delivery can both take effect, the value exists twice.
The property that must hold: after a reclaim, no copy of the original
object, however delayed or retried, can ever become importable on C.
It is structural rather than argued, and the mechanism is who can obtain
the bytes:
Available --Export--> Pending --Deliver--> Delivered --retry--> C
|
+---Reclaim---> Available
PendingExport exposes no Claim. There is no accessor, so the object to
write cannot be obtained while a reclaim is still possible. Only Deliver
produces one, and Deliver MOVES the record out of the pending domain, so
a record is either reclaimable or writable and never both. The write is
therefore only ever attempted from a committed Delivered state — the same
commit-then-apply discipline the import leg already uses.
A timeout is not the recovery mechanism. Delivery is retryable from
durable state for as long as it takes, and Redeliver returns a
byte-identical object, which is what makes C's exactly-once import treat
a duplicate arrival as the replay it is rather than a second claim.
Pending value still counts as D's in the rail equation:
imported - delivered == owned + earmarked. It has left the owner's
available balance but has not crossed, and a reclaim brings it back;
counting it as gone would make every open export read as a shortfall.
Six attacks, all refused: deliver after reclaim, redeliver after reclaim,
re-export under a reclaimed id, double reclaim, reclaim after deliver,
and the full two-chain reclaim-plus-late-delivery. Plus 500 randomly
sequenced steps asserting after each that no id is both reclaimable and
writable. 67 tests; the attack run closed at 10000 in - 660 delivered =
9323 owned + 17 earmarked.
Co-authored-by: Hanzo Dev <[email protected]>
Order -> Match -> Execution -> Trade is the vocabulary; "intent" was the old
name for what an Order is. Renames the identifiers on the D-side of the C<->D
settlement seam to match precompile/dex, which pins against these by golden.
Values that live on a chain keep their bytes, because renaming one of those is
a migration and not a rename:
- "lux.dex.native.intent.v1" and "lux.dex.native.intent.pending.v2" are hash
INPUTS; a byte changed there moves every derived order id and the pending
trait the precompile must agree with.
- "seamintent:" is the on-disk escrow key prefix; renaming it orphans every
escrow row already written.
Each now carries one line saying why the literal does not track the identifier
that names it, so the inconsistency is not "tidied" later into a fork.
TestSeamOrderWire_GoldenMatchesPrecompile and
TestSeamPendingTrait_MatchesPrecompileGolden still read the same goldens, which
is the evidence no derivation input moved.
vm.go carries the same rename in comments only; it is held back because that
file is being rewritten concurrently for the genesis record.
Co-authored-by: Hanzo Dev <[email protected]>
C and D each own value while it is on that chain, and a unit is spendable
on exactly one of them at a time:
C --Export--> C_TO_D --Import--> D --Export--> D_TO_C --Import--> C
Those four transitions and no others. Never C to D directly, which would
mean a moment where value is in both places or neither.
The consequence is the point. Once imported, D is not matching against
hypothetical C collateral — it has real balances, and every order, match
and trade is a local movement. A million trades cost zero boundary
crossings. That is what makes D an exchange ledger rather than a remote
matching oracle attached to C.
So the atomic rail is narrowed to one job: move ownership exactly once.
It knows nothing about orders, executions, trades or settlement. The
per-trade cross-chain certificate, the C-parent reservation and the
accept-or-release round trip stay only for the V4 adapter, where a
contract genuinely demands synchronous C composability.
Import consumes and credits in one operation. Splitting them either
destroys value or lets a retry, a reorg, a racing relayer or a restart
import the same object twice, which is a double-spend of real money.
Refusal is loud rather than silent — a caller that imported twice has a
bug worth seeing. Export draws only on available balance, so value
committed to an open order cannot also leave the chain.
A claim names its beneficiary, which is what makes import safely
permissionless: delivering one can only credit the account it already
names. Co-location buys latency, never correctness.
Execution now carries MarketVersion and the exact token amounts D
resolved, so C verifies amounts rather than recomputing lots from
whatever the market says now — recomputing would replay a block to a
different result after a market update. Price is gone; it was Output over
Input stated twice inside a signed struct.
Partial execution is bounded by cross-multiplication,
executionOutput * orderInput >= executionInput * orderMinOutput, so there
is no division, no rounding convention, and the rule composes: if every
slice satisfies it, so does their sum.
60 tests. Conservation asserted after all 600 operations of a random run:
imported 4143, exported 2576, D owns 1567.
Co-authored-by: Hanzo Dev <[email protected]>
v1.14.15 and v1.14.16 are both tagged and neither produced an image;
published stops at v1.14.14. Both died at the same step:
::error::Error response from daemon:
Get "https://ghcr.io/v2/": denied: denied
The credential is not the story. Measured on the forge over 7 days, luxfi
ran 365 ghcr logins with the same org secret and 27 failed — 7.4% — across
23 repos and a dozen runners, and the same secret succeeded on either side
of every burst. Three readings were tested and all three are wrong:
expired token the same secret succeeded hours after earlier failures
sick runner failures spread over 12 runners; a clean one is 12 of 12
throttling no correlation with concurrency — 7.4% at one login per
minute, 10.9% at two, 0% at four and five
The only defect on our side is that nothing retries. docker/login-action
does not, and the job dies at step 3 of 6, before the build. So each
occurrence is invisible: a tag, no image, and no failed push to point at.
Five attempts over 75s, backing off. Exhausting all five is a different
failure than the one this covers and says so rather than pretending —
which is how the dead org credential was told apart from this on
2026-08-07, when it stopped working outright.
Same change as luxfi/explorer. 21 luxfi repos still carry the unretried
copy; each holds its own release.yml, so the real fix is one reusable they
all call.
Co-authored-by: Hanzo Dev <[email protected]>
Exclusive() proves an ExecID cannot sit in two domains. It says nothing
about how much of an order has been committed, so D could certify ten
executions against one order for its full quantity — each individually
valid, correctly certified, correctly scoped — and settle whichever the C
successors happened to include. Every defect the last review turned up
was a conservation bug rather than a determinism bug, so this is tracked
rather than left as a property that happens to hold.
Per order: reserved + traded <= authorized. Reserving takes from
available, settling moves reserved to traded, releasing gives it back.
That last one is what makes a portable Order work at all — an order whose
execution lost the race is still live and matchable again, and a release
that failed to return the quantity would silently shrink every order that
ever lost one.
Conserved() recomputes both totals from the domains themselves rather
than restating the counters, so an accounting update that drifted from
the objects it summarises is caught instead of trusted. A random
400-step lifecycle asserts it plus Exclusive() after every transition:
36 reserved, 96 traded, 86 released in the recorded run.
Units are the book's lots, matching Execution.Quantity, never the order's
Input in token base units. The lot size is market metadata and stays out
of this package: a caller that knows the market converts once and passes
authorized in, and the value is checked for agreement across every
reservation so a second, different conversion cannot enlarge an order.
51 tests, up from 25 before the reshape.
Co-authored-by: Hanzo Dev <[email protected]>
The trader signs an Order and nothing moves. D's book matches it and
certifies an Execution; an operator carries Order + Execution + proof to
the C reactor. Only an accepted C block turns that Execution into a Trade.
CParent leaves the Order and stays on the Execution. An order is a
long-lived authorization; a reservation is scoped to one C opportunity.
Braiding them made the trader responsible for guessing which block their
order would match under, and killed the property the book depends on: an
unmatched order stays live, and one whose Execution was released can be
matched again.
Three things this shape forces, each a bug it makes unwritable:
Nonces are UNORDERED, a bitmap position rather than a counter. D matches
by price-time priority across every trader, so one trader's orders settle
out of signing order as a matter of course. A sequential nonce rejects
the low one arriving second and then blocks that trader permanently,
because the expected value can never advance past an order that may never
match.
The commitment binds NetworkID, CChainID and the reactor address, inside
the hash rather than beside it. 0x9999 runs on every EVM sharing the one
D venue; an unbound signature is one an operator replays against each in
turn, and the trader pays on all of them for the single trade they
authorized. Bound, that replay is not detected — it is unrepresentable.
Input and MinOutput are in token base units and 32 bytes wide, not the
book's lots in a uint64. They are different quantities describing the
same trade, and one type for both would let a lot count be added to a wei
balance. uint64 also caps an 18-decimal token near 18 units.
VerifiedOrder follows VerifiedExecution: unexported interface method as
the sentinel, since unexported fields alone leave Order{} constructible
anywhere. Verification stays a pure question — it does not consume the
nonce, or merely inspecting an order would burn the trader's position.
The deadline is read against the block being built, never a clock.
49 tests, up from 25.
Co-authored-by: Hanzo Dev <[email protected]>
Settle and Release already required a verified AcceptedBlock; Reserve did not,
so a reservation could still be created without any proof that its C parent was
accepted. That left the abandoned-parent case reachable through the one door
that had not been closed.
Reserve(parent AcceptedBlock, v VerifiedExecution) now enforces both halves of
rule 1: the parent must carry the verified sentinel, and the execution must be
scoped to exactly that parent. Both arguments are unforgeable outside this
package, so a reservation can be taken neither against an unauthenticated
execution nor against an unproven C parent.
25 tests passing.
Co-authored-by: Hanzo Dev <[email protected]>
THE INVARIANT
An Execution may be reserved only against an accepted C parent. The unique
accepted child of that parent permanently decides the Execution: membership
means Trade; non-membership means Release. Neither C nor D reads the other
chain's live state during consensus execution.
CPARENT ACCEPTED-ONLY
Acceptance is the finality boundary, so an accepted P is guaranteed exactly one
accepted child, and that child decides every execution reserved against P. This
removes the abandoned-parent case entirely rather than handling it: there is no
proposed parent to abandon. It also collapses two questions — "did P win?" and
"which child of P won?" — into one.
AcceptedBlock is now a struct with unexported fields, produced only by
VerifyAcceptedBlock. Unexported fields alone still leave AcceptedBlock{}
constructible from outside, so a `verified` sentinel closes that hole and every
transition requires it. Symmetric with VerifiedExecution.
Never use local C state as the proof. Even co-located, different D validators
observe C advancement at different instants, so `localC.LastAccepted()` is not a
function of the block being executed. The local C VM is delivery; the
authenticated statement is consensus input — the same rule already proven in the
other direction.
THREE RULES, NO CLOCKS
Accepted(P) permits Reserve(E), E.CParent == P
Accepted(Q), Q.Parent==P, E in Q.ExecRoot permits Settle(E) -> Trade
Accepted(Q), Q.Parent==P, E not in root permits Release(E)
NON-INCLUSION IS FIRST-CLASS
Release is half the protocol, not a fallback, and it is the half that returns a
trader's liquidity. So ExecRoot is a sparse Merkle commitment over the 256-bit
ExecID keyspace where absence has the SAME proof shape as presence: the path to
the key, terminating in the empty leaf. One verifier, one code path, one bit of
difference. Leaf and node domains are separated against second-preimage
substitution, and the empty leaf is a distinct constant so absence cannot be
forged. Proofs carry a bitmap and omit empty siblings, so size tracks the
executions actually in the block, not the depth.
A useless reservation is still resolvable: if D reserves against P only after C
accepted Q, anyone can deliver Accepted(Q) plus non-inclusion and release it.
Correctness does not depend on D winning that race.
Also removes the last references to a flag day, which was never authorized —
0x9999 activated 2025-12-25 and settle_module is AlwaysOn. The wire-compatibility
test keeps its value and loses the dead concept from its name and prose.
24 tests passing in dexprotocol, dchain suite green.
Co-authored-by: Hanzo Dev <[email protected]>
The gateway spoke 8=FIX.4.4 and reported a fill as ExecType 1/2 — the pair
FIX Latest retired precisely because it duplicated order state into the
event field. Both fields carried the same thing, so nothing on the wire
distinguished "a trade happened" from "the order is done", and a client
could read either.
Session layer is now FIXT.1.1; the application layer is FIX.5.0SP2 + EP307,
named on the Logon by DefaultApplVerID(1137) and ApplExtID(1156). Every
fill is ExecType=F (Trade) and OrdStatus alone moves Partial -> Filled.
Nothing depended on the old BeginString, so the wire moved with the
semantics instead of straddling both. This is the wire pkg/dexprotocol
already documents (150=F, 39=1|2).
A partially-filled IOC also never reached a terminal state: the last report
said Partial and nothing followed, leaving a client waiting for an end that
never came. It now closes with Canceled.
Vocabulary, one word per concept: execReport -> execution, gateway
OrderFill -> Fill, multisig ExecutionResult -> Result (it holds an error
string, never an execution).
Deletes the SDK's hand-rolled FIX encoder — it pinned a second protocol
version, omitted BodyLength entirely so the acceptor would have rejected
every frame, and sendFIXMessage returned nil without sending.
Co-authored-by: Hanzo Dev <[email protected]>
The C transaction contains an Order. D matches it and certifies an Execution —
price and quantity guaranteed and reserved. The C block carries that Execution.
EVM execution verifies it and returns the resulting BalanceDelta. When the child
of the Execution's C parent is accepted, D settles it into a Trade if that child
used it, or Releases the reservation if it did not.
This is the consensus-side half: the certified object, its canonical encoding
and domain-separated commitment, and the lifecycle.
WHY IT IS SHAPED THIS WAY
C never queries D's live state during EVM execution — that would fork C on the
first swap, since validators sit at different D heights. Instead D's statement
rides in the block and C verifies it. On replay D can be dead, the GPU gone, D a
billion blocks ahead; C has parent state, transactions, the statement, and the
certificate. That is the entire input set.
Move-typed storage, not a status field. ReservedExecution, Trade and Released
are distinct types under distinct key prefixes, and a transition MOVES the
object. Settle(Trade) and Release(Trade) do not compile — there is no reserved
execution left to hand them. This is aimed squarely at the live defect where the
intent record is written back only inside the same-asset refund branch, so the
ordinary proceeds outcome leaves it Open with its full principal and the
deadline reclaim later refunds money already spent out of other traders' pooled
reserve. That is a field someone forgot to set on one code path; here there is
no field to forget.
An unauthenticated execution cannot enter the lifecycle: VerifiedExecution is an
interface with an unexported method, so only VerifyExecution can produce one, and
Reserve takes nothing else. AcceptedBlock is the same shape, so a settle or
release can never be driven by a clock or a bare block id.
The reservation is keyed by CChain + CParent + ExecID — the parent OPPORTUNITY,
never a candidate block — so competing candidates all name one reservation and
none of them moves it.
FIX Latest externally, small internal vocabulary. ExecType (the event) and
OrdStatus (the resulting state) exist on the wire and are kept distinct per FIX;
they are never the internal source of truth. There is deliberately NO FIX state
for the provisional reservation — it is internal consensus machinery, and a
Trade is published only after C acceptance, which is what keeps the explorer's
CLOB honest.
15 tests, all passing: encoding injectivity over every field, domain separation,
verifier-supplied parent binding, terminal exclusivity, replay refusal.
Co-authored-by: Hanzo Dev <[email protected]>
The drive emitted the import and the submit as a PAIR in one block, to close the
window where value sits on D with no order against it. That was one block faster
and one hole deeper: a proposer that included the import and omitted the submit
produced a perfectly valid block whose escrow was stuck `open` forever. The
import drive skips already-imported intents, so no later block would ever emit
that submit; the export drive gates on `submitted`, so nothing would ever settle
it. The taker's value sat in a keyless seam account while C's deadline reclaim
refunded them — C's books balance, but the D-side entry is stranded permanently,
and any proposer could do it to any swap, for free, undetectably.
The drive is now ONE RULE: for each intent, emit whatever leg its COMMITTED
STATUS says comes next.
no escrow + a pending object -> TxImport
escrow `open` -> TxIntentSubmit
escrow `submitted` -> TxExport
An `open` escrow is unfinished work the next proposer picks up, exactly as a
not-yet-imported intent is. A swap takes three blocks instead of two; in
exchange no block can strand one. Self-healing beats a block of latency, and it
is the simpler rule — the pair was an optimization braided into it.
124 tests pass, 0 fail.
Co-authored-by: Hanzo Dev <[email protected]>
The closest deterministic stand-in for the devnet gate: a second node with its
own state store, its own shared memory, and no sight of the proposer's mempool
parses the proposer's BYTES, verifies, and derives the identical root — through
the import, the cross, and the export.
Modelling shared memory as one store between the two nodes is what an earlier
draft did, and it is wrong in a way that hides the real question and invents a
fake one: the second node's Apply then collides with the first's for a Put it
would, in production, be making into its own store. Shared memory is per-NODE —
that node's local materialization of both chains' history — so the test gives
each node its own, and C's accept flushes the object into both.
Co-authored-by: Hanzo Dev <[email protected]>
The seam-submit arm had grown three near-identical reject sites inline in
execute, each repeating "advance the escrow, then reject". resolveSeamOrder is
the single resolution point: escrow -> order frame, or a deterministic reject,
with the escrow advanced on every path exactly once.
That the escrow advances even on a reject is the load-bearing part, and it now
has one place to be true rather than three to stay true together.
Co-authored-by: Hanzo Dev <[email protected]>
Reject requeues b.txs wholesale, which includes the import/submit/export legs the
drive generated. Those are a pure function of committed state — the next
BuildBlock regenerates exactly the ones still warranted — so the next block would
carry each leg twice, once from the mempool and once from the drive. The replay
guards refuse the second copy, so this is duplication rather than a double spend,
but a block that carries the same import twice is a block nobody can read.
Requeue only what a client actually submitted.
Co-authored-by: Hanzo Dev <[email protected]>
hasSeamWork was a SECOND definition of "the seam has something to do", and it
disagreed with the first. It answered "is there a settled escrow?" while the
export drive answers "would that escrow produce a leg?" — an escrow holding
nothing available (exported in an earlier block, or still locked) satisfied the
predicate and not the drive, so WaitForEvent told the engine to build forever
and BuildBlock returned ErrEmptyMempool every time.
It now calls the drives, bounded to one leg, so the trigger and the work cannot
drift apart. The bound is what keeps the poll path cheap: the question is
whether there is work, not how much.
Co-authored-by: Hanzo Dev <[email protected]>
The C->D object carried value only, so executeImport credited the taker's
account and stopped. Its one reference to TxSubmit was a comment. Nothing placed
an order, so nothing crossed, so driveSeamExports — which fired only when a
non-input asset appeared — never fired, so Phase B had no object and the settle
reverted. The seam was unreachable by construction, which is why 0x9999 has
emitted zero events on every chain since genesis.
The object now carries the operation the taker authorized on C (market, side,
limit, size) appended to the unchanged 69-byte value head, and the drive builds
the taker's order from it: BuildBlock emits TxImport and TxIntentSubmit as a
pair, in one block, so value is never on D without an order against it.
TxIntentSubmit is the object-authorized sibling of TxSubmit, as TxImport is of
TxDeposit. It cannot be a plain TxSubmit — that type requires a signature at
parse, at the RPC edge, and in authorizeTx, and weakening any of those would let
an unsigned submit move any account's funds. It carries the intent id alone; the
zapwire Submit frame is derived from the escrow by seamSubmitBody, so the
operation is declared once and never restated for the two sides to reconcile.
From there it flows through the identical custody, matcher and settlement path a
signed dex_submit takes.
Three things this fixes on the way:
D could never sync past a settled swap. executeImport read the object from
shared memory during execute() while the consuming Remove landed at Accept, so
any node re-executing the block found it gone, derived a different ledger root
and died on `execution root mismatch`. The object's bytes now ride in the
TxImport body and Block.verifySeamImports authenticates them at Verify, where a
forgery costs the producer a block instead of forking a root. Accept now checks
the root it derives too: that branch is the bootstrap path and it was committing
whatever it computed while persisting the block's claimed root.
The seam ran in the taker's own ledger account, and the export drive settles an
account's balances back to C. A taker holding native D balance would have had
all of it exported, and C credits that out of the shared seam reserve — a raid
on other takers' pooled input. Each intent now runs in its own account, so the
question does not arise and the spent witness becomes exact rather than
inferred.
A zero-fill order left only its input behind, which the old export gate could
never match, so the principal sat on D while C's deadline reclaim refunded the
taker the same principal. The escrow now records that the order RAN — including
when the custody gate refused it — and the drive gates on that fact.
A market id named by the other chain reached bookForPool and aborted the block.
Any C caller could have halted D with it. Unknown market and unbound assets are
now per-tx rejects, and the intent settles back in full.
SeamPendingTrait rotates to .v2 alongside the wire. Old and new nodes cannot
disagree about a block: the TxImport body is 150 bytes and an old node's
bodySize says 32, so ParseBlock refuses the frame outright. It stalls rather
than forking. No historical block anywhere contains a TxImport.
121 tests pass in pkg/dchain, 0 fail; whole repo green.
Co-authored-by: Hanzo Dev <[email protected]>
commitSeamAtomic passed the overlay's CommitBatch to sm.Apply — the
in-process platformvm acceptor pattern — and had NO batch-less path. The
D-Chain ships as an out-of-process plugin, so its SharedMemory is the ZAP
client, and that client refuses any batch (a database.Batch cannot cross a
process boundary). Every block carrying a cross-chain seam op would have
failed at Accept, which is fatal: the chain stops on the first intent it
ever imports. Blocks with no seam op take the plain commit path, which is
why the chain runs fine right up until the seam is used.
Commit the overlay state first, then Apply with no batch. Without a shared
batch exactly-once is unreachable, so the choice is at-most-once (a crash
between the two writes skips an op) or at-least-once (it replays one).
Replay is unsound here — a duplicate put is fatal and, after the peer has
consumed the object, re-creates it. A skip is survivable because neither
side's replay protection depends on the shared-memory op: D's guard is the
durable seamintent escrow row, written by the state commit that now happens
first.
Do not fix this by teaching atomiczap to take a batch.
Co-authored-by: Hanzo Dev <[email protected]>
The table told a reader to add chain 96370 at api.testnet.lux.network and
to browse testnet.explorer.lux.network. All three values were wrong:
testnet is 96368 (api.lux-test.network answers 0x17870), and neither
hostname resolves at all. This is the page that tells users what to paste
into a wallet, so a wrong chain ID here sends funds to a chain that does
not exist.
Verified live: mainnet 0x17871 (96369), testnet 0x17870 (96368);
explore.lux-test.network and explorer.lux.network both 200.
Co-authored-by: Hanzo Dev <[email protected]>
luxd serves exactly one HTTP prefix. server/http/server.go:33 sets
baseURL = "/v1"; addRoute and AddAliases derive every path from it and
router.go mounts chains at baseURL+"/bc/C/rpc". /ext/* 404s everywhere.
The pkg/dchain package comments were the load-bearing part: ingest.go,
read.go, vm.go, read_test.go, ingest_test.go and zapingest.go all told
the reader that luxd mounts the DEX handlers at /ext/bc/<chainID>/dex/<m>.
That is the URL an integrator would build, and it 404s. The handlers are
at /v1/bc/<chainID>/dex/<m>.
zapingest.go said "/ext/bc route" with no trailing slash, so it does not
match an /ext/bc/ search -- searching for the whole prefix found it.
Also covers /ext/info, /ext/health and /ext/admin in the two local-network
scripts and in docs/source/node.mdx: baseURL applies to those routes too,
so swapping only the /bc/ ones would leave the scripts half broken.
Measured, positive and negative control on one host:
api.lux.network/v1/bc/C/rpc -> 200 {"result":"0x17871"} /ext -> 404
api.lux.network/v1/info -> 200 {"networkID":"1"} /ext -> 404
api.lux.network/v1/health -> 405 (exists, GET-only) /ext -> 404
go build + go vet clean on pkg/dchain, pkg/price, internal/standalone.
go test ./pkg/dchain/... ok 5.855s; go test ./pkg/price -run QChain ok 1.950s.
bash -n clean on both scripts. The repo-wide `go build ./...` was already
failing before this change on cgo packages that need luxcpp and libresolv.
Co-authored-by: Hanzo Dev <[email protected]>
The dexvm runs as a ZAP plugin, so the Runtime rebuilt on its side had
SharedMemory nil. That silently disabled the autonomous seam drive
(autoDriveSeam requires a non-nil handle), so the D-Chain never imported a
C->D intent, never matched one, and never exported a fill — the far half of
why no swap has ever settled.
vm v1.3.6 dials the node's per-chain atomic server at Initialize and
installs the handle, including Indexed, which enumerateSeamPending needs to
discover intents at all.
Co-authored-by: Hanzo Dev <[email protected]>
ghcr.io/luxfi/dex (root Dockerfile, cmd/dexd) already folds the venue,
plugin and version modes into one binary; Dockerfile.dvenue and the
docker/ variants were parallel build paths for the same artifact.
Co-authored-by: Hanzo Dev <[email protected]>
api.lux.network is returning 503 right now. The node reports one failing check:
D: failed to create chain on net 1111…LpoYY: failed to initialize VM:
zap decode initialize response: unexpected EOF
bootstrapped: chains not bootstrapped
healthy: false
Everything else — bls, database, diskspace, network, router — is ok. One chain
cannot start, and that alone takes the node unhealthy.
It is a wire-version mismatch, not a bug in either side.
zapwire.InitializeResponse gained a SIXTH field, `Capabilities uint64`, appended
last, in luxfi/api v1.0.16:
api v1.0.4, v1.0.12, v1.0.13, v1.0.15 no Capabilities (5 fields)
api v1.0.16, v1.1.1 Capabilities (6 fields)
luxfi/vm f39ce26 then made the plugin server actually SET it ("the generic ZAP VM
server never set InitializeResponse.Capabilities … handleInitialize now sets
Capabilities").
luxd pins api v1.1.1, so it decodes six fields. This repo — which builds the
D-Chain plugin, via internal/plugin → luxfi/vm/rpc → luxfi/api/zap — pinned
v1.0.15 and writes five. The client reads past the end of the response, and that
is the "unexpected EOF" verbatim.
Bumped to what luxd already runs: api v1.0.15 → v1.1.1, vm v1.2.5 → v1.3.5
(v1.3.5 also carries the NewZAPHandler export luxfi/node's rpcchainvm tests
build against). utils, validators, warp and zap move with them.
go build ./internal/plugin/ OK
go build ./... OK
go test ./... 23 packages ok, 0 failed
⚠️ This does not fix production by itself — the D-Chain plugin has to be REBUILT
from this commit and redeployed before the running node can complete the
handshake.
Co-authored-by: Hanzo Dev <[email protected]>
The docs job died at INSTALL, before any of the rest was visible:
postinstall: hanzo-mdx
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@hanzo/docs-core'
This package was mid-migration to the @hanzo toolchain and several pieces were
simply never finished. Each one only became visible once the one before it was
fixed, so they are listed in the order they surfaced:
1. @hanzo/docs-core undeclared — @hanzo/mdx imports it, so postinstall crashed.
2. `hanzo-mdx` with NO ARGUMENTS reports "generated files" and writes nothing.
It needs `source.config.ts .source`, the form luxfi/consensus uses. Both the
build and postinstall scripts were bare.
3. zod ^3 — @hanzo/mdx 14.2.x calls `z.looseObject`, which is zod 4:
`TypeError: z.looseObject is not a function`. Nothing in docs/ imports zod
directly (0 files), so it exists only for this toolchain; moved to ^4.2.1,
matching mpc which runs the same generator.
4. `import { docs } from "@/.source"` is a DIRECTORY import and the generator
emits no index — it writes browser/dynamic/server.ts. Now "@/.source/server".
5. @hanzo/docs undeclared — next.config.mjs imports @hanzo/docs/mdx/next.
6. app/global.css imported `fumadocs-ui/style.css`, an upstream package that is
not a dependency here. Now @hanzo/docs-ui/style.css, as consensus has it.
7. RootProvider, DocsLayout, DocsPage and defaultMdxComponents were imported
from `@hanzo/ui`, which exports NONE of them — checked at runtime, all five
names including `loader` come back undefined. That is the
"Element type is invalid … got: undefined" build failure. They live in
@hanzo/docs-ui, where consensus already gets them.
8. docs.toFumadocsSource() does not exist on the @hanzo runtime; loader now
comes from @hanzo/docs/core/source with toSource().
9. react-day-picker, react-resizable-panels and flexsearch were undeclared
peers. PINNED to what @hanzo/ui asks for (^9, ^2) — installing latest gives
react-resizable-panels 4, where `PanelGroup` no longer exists.
Verified as a fresh checkout would run it, with node_modules and .source deleted:
pnpm install --frozen-lockfile OK (was ERR_MODULE_NOT_FOUND)
.source after install generated by postinstall
pnpm build OK
Co-authored-by: Hanzo Dev <[email protected]>
`go test ./...` failed three ways on the runner. Two were build failures that
cannot happen on a mac, and one was a race that only loses under load.
pkg/dpdk — `undefined: syscall.SO_NOSIGPIPE`. DarwinOptimizations guarded itself
with `if runtime.GOOS != "darwin" { return }`, but SO_NOSIGPIPE is a COMPILE-time
constant that exists only on Darwin; a runtime check cannot save a reference the
linux build still has to resolve. Split into _darwin.go / _nodarwin.go. Verified
by cross-compiling both ways -- it failed for GOOS=linux before, both pass now.
pkg/orderbook — the cgo preamble uses uint64_t while including only <stdlib.h>.
macOS pulls stdint.h in transitively and glibc does not, so this built here and
never on the runner. Added the include.
pkg/price — TestXChainSourceStalePrice: "Expected price to be stale".
Not a flaky test; Close() was not a barrier. It closed the done channel and
returned WITHOUT waiting for the polling goroutine. A loop selecting on done
against a ticker picks a ready case AT RANDOM, so a poll could still be running
after Close() returned, and poll writes last[symbol] = time.Now(). The test
closes the source, backdates that timestamp by 10s, and asserts staleness -- an
in-flight poll overwrote it. Wide window on a loaded runner, near-zero on a
laptop, which is why it reproduces 0/30 here.
All EIGHT sources had it, so the fix is one shared `lifecycle` (embedded, so
`s.done` still resolves) whose stop() joins before returning and is safe to call
twice. Oracle had the same defect written as
// Give time for goroutines to exit
time.Sleep(100 * time.Millisecond)
which could not work either -- calcAverages blocks on a 1s ticker, so it ran up
to a full second after Stop() returned. Now on the same lifecycle, and its loops
select on done instead of polling a flag, so they leave promptly rather than
after one more tick. Nothing restarts a stopped source (checked: 0 tests), and
the other sources already could not.
go build ./... OK
GOOS=linux go build ./... OK (failed before)
go vet price/dpdk/orderbook OK
go test ./pkg/price/ -race ok, 3/3 runs
go test -run StalePrice -count=30 ok
Co-authored-by: Hanzo Dev <[email protected]>
The TypeScript SDK job failed at "Run tests with coverage" while reporting
`# pass 25 # fail 0`. Two separate defects, and the second hid the first.
1. The shell ate two thirds of the suite. `npm test` was
node --test --experimental-test-coverage dist/**/*.test.js
unquoted, so the SHELL expanded the glob. bash has no globstar by default, so
`**` degrades to `*` and matches exactly one level down:
dist/**/*.test.js -> dist/arbitrage/scanner.test.js
dist/arbitrage/types.test.js
The four top-level suites -- risk, orderbook, math, types -- were never run in
CI. It looked green-ish because the files it DID run passed. Quoting the glob
hands expansion to node, which walks recursively: 25 tests -> 158.
(zsh expands `**` recursively, so this is invisible from a dev machine. It only
drops tests on the runner.)
2. Node 20 crashed reporting coverage, after passing:
# Warning: Could not report code coverage.
TypeError: Cannot read properties of undefined (reading 'line')
# pass 25 # fail 0 <- then exit 1
Measured across versions with the quoted glob: 18 exit 1, 22 exit 0, 24 exit 0,
26 exit 0. Node 18 cannot expand the pattern at all, and both 18 and 20 are
EOL, so `engines` moves to >=22.0.0 to state what actually works rather than a
floor that does not.
sdk-publish.yml pinned NODE_VERSION 20 as well; left alone it would now fail
EBADENGINE against that floor.
Verified locally on node 22 with the CI commands:
npx tsc --noEmit CLEAN
npm run build CLEAN
npm test 158 tests, 158 pass, 0 fail, exit 0
Co-authored-by: Hanzo Dev <[email protected]>
With formatting fixed, the Rust SDK job advanced to `cargo clippy --all-targets
--all-features -- -D warnings` and failed there with 9 lib errors. Clearing those
exposed 21 more in examples/ that the lib failure had masked.
Library (9): unnecessary_sort_by x4 -> sort_by_key / Reverse; derivable_impls x2
-> #[derive(Default)] + #[default]; a hidden elided lifetime -> Timer<'_>;
field_reassign_with_default; and `VenueCapabilities::orderBook` -> `order_book`.
The `"orderBook"` at adapters/mod.rs:30 is a VENUE-NAME STRING LITERAL, not the
method, and is deliberately left alone.
Examples (21): 15 applied by rustfix (unused imports/variables, unnecessary f64
casts, clone-on-Copy). The 6 remaining were dead code, and one of them was a real
defect:
ArbitrageBot::handle_opportunity was never called. It holds the bot's entire
execution and stats path, so `router` was also never read and the example could
only ever print a final report of zeros. LxFirstArbitrage::on_opportunity is the
intended wire; start() now registers it. The callback is a sync
Box<dyn Fn + Send + Sync>, so it spawns.
The other five were vestigial fields carrying nothing: MakerState's two
Option<Order> (the loop builds OrderRequest and simulates fills directly, so they
were only ever None) and OrderResult::symbol (every result is for the one
request's symbol). Plus a manual checked division.
Verified in the order CI runs them:
cargo fmt --check CLEAN
cargo clippy --all-targets --all-features -D CLEAN
cargo test --all-features 106 passed, 0 failed
cargo build --release CLEAN
Co-authored-by: Hanzo Dev <[email protected]>
The Rust SDK job failed at its first step and could not have passed at any later
one. Three findings, in the order the job hits them:
`cargo fmt --check` — six example/test files were unformatted, but fmt could not
even finish:
Error writing files: failed to resolve mod `ffi`: src/ffi.rs does not exist
Error writing files: failed to resolve mod `python`: src/python.rs does not exist
Both modules are declared behind features (`ffi = []`, `python = ["pyo3"]`) and
NEITHER file has ever existed in this repo — no git history, and nothing
references `ffi::` or `python::`. So both features were unbuildable by
construction.
That matters because CI runs `cargo clippy --all-features` and
`cargo test --all-features`: every run enabled both dead features and failed on
the missing files.
pyo3 goes with them. An optional dependency implicitly declares a feature of the
same name, so --all-features kept building pyo3 for a `python` module that does
not exist.
Verified in the order CI runs them:
cargo fmt --check CLEAN
cargo check --all-features CLEAN
cargo build --release CLEAN
Co-authored-by: Hanzo Dev <[email protected]>
This workflow pinned a Go older than the `go` directive in go.mod, and the job
image sets GOTOOLCHAIN=local — so the toolchain cannot fetch a newer one and the
build refuses before compiling anything:
go: go.mod requires go >= X (running go Y; GOTOOLCHAIN=local)
go-version-file reads the requirement from go.mod, so the two cannot drift apart
the next time the module moves.
Co-authored-by: Hanzo Dev <[email protected]>
The build died on a checksum mismatch that reads like tampering:
verifying github.com/luxfi/[email protected]: checksum mismatch
downloaded: h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
go.sum: h1:ksw1dmfTR0dqqNMRS7BjGcprCO2Fhc+3Iiq2/NMuONw=
SECURITY ERROR
It is the go.sum line that is wrong, not the download. luxfi/pq is public, and
sum.golang.org -- an append-only transparency log -- notarizes exactly what CI
fetched. The go.sum value was never in that log; it came from a direct fetch of
a tree the tag no longer points at, which is what re-cutting a tag produces.
Audited all 46 luxfi/* hashes in this go.sum against sum.golang.org: this was
the only disagreement. After the correction `go mod download` exits 0 and
`go mod verify` reports all modules verified.
Also made the registry cache tolerate a cold start -- a missing buildcache image
was failing the build rather than just missing.
Co-authored-by: Hanzo Dev <[email protected]>