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.
My previous commit here raised this module's go directive from 1.26.4 to 1.26.5.
That was not intended and not needed: `go mod tidy` writes the local toolchain's
version, and a go directive is the MAXIMUM of the module's own and every
dependency's — so hanzos3/go v1.0.1 and zapdb v1.10.4, both of which had picked
up 1.26.5 the same way, dragged this one up with them.
Both are fixed upstream (v1.0.2 back to 1.26.1, v1.10.6 back to 1.26.4), so this
declares 1.26.4 again, which is what it asked for before I touched it. Still zero
minio; build passes.
The S3 client moves from hanzos3/go-sdk to hanzos3/go, and zapdb comes up to
v1.10.4.
go-sdk pointed at our checksum forks with `replace`. Go ignores `replace` in a
non-main module, so that redirect applied when you built go-sdk and to none of
its consumers — this repo resolved the real minio packages regardless of how the
arrows were drawn. hanzos3/go requires the forks by name, which does travel, and
zapdb v1.10.4 is the release that stops pulling go-sdk back in on every tidy.
go.mod names zero minio modules. build passes.
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]>