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]>
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]>
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]>
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]>
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 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]>
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]>
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]>
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]>