Commit Graph
163 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 23893c874f Every chain is read from its own pools, so no vendor has to say yes
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]>
2026-09-08 23:02:56 -07:00
zeekayandHanzo Dev 01f5bc490e The trading surface is served, and each chain is asked of whoever holds it
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]>
2026-09-08 22:57:42 -07:00
zeekay d0a7727277 dchain: pin the vmID, and say what it is
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.
2026-09-07 14:59:59 -07:00
zeekay 582ed0ea5e dchain: one construction path for the D-Chain VM
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.
2026-09-07 10:58:04 -07:00
zeekay e8610867ec feat(dex): update RPC routes to /v1/chain 2026-09-04 14:30:17 -07:00
zeekayandHanzo Dev c1de892454 fix(dex): resolve audit findings for NaN guards, side totality, and persistence symmetry
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-03 13:16:52 -07:00
zeekayandHanzo Dev 5a808d27f7 chain routes: /v1/bc -> /v1/chain
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]>
2026-09-01 08:21:06 -07:00
zeekayandHanzo Dev f56ed0a9c5 Restore the chain prefix the running nodes answer
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]>
2026-08-30 21:23:39 -07:00
zeekayandHanzo Dev 0abb4f609b chain routes: /v1/bc -> /v1/chain
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]>
2026-08-30 20:55:26 -07:00
zeekayandHanzo Dev 1d4ad3b282 Make an unsigned rebind a decided reject, not a chain halt
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]>
2026-08-21 14:09:52 -07:00
zeekayandHanzo Dev 81fe9fa775 Bind authorization scope on the strict-PQ order path
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]>
2026-08-21 10:56:48 -07:00
zeekayandHanzo Dev 33157d5fed dex: an order signature binds the chain, the network, and an expiry
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]>
2026-08-21 08:39:10 -07:00
zeekayandHanzo Dev e0a8a27436 dex: a signature covers the size the matcher acts on
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]>
2026-08-21 01:23:21 -07:00
zeekayandHanzo Dev 247b110b9d dex: bound the GPU book indices, and let a market sell actually cross
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]>
2026-08-21 01:18:02 -07:00
zeekayandHanzo Dev 0f70be9c6c dchain: say why the funding rail has no method, where someone would add one
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]>
2026-08-07 21:49:38 -07:00
zeekayandHanzo Dev df71b108f5 dchain: a signature named no chain, so it authorized every chain
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]>
2026-08-07 21:41:48 -07:00
zeekayandHanzo Dev f4968a0f7c dchain: the import authenticator had never run, and it halted the chain
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]>
2026-08-07 21:27:28 -07:00
zeekayandHanzo Dev d9da4dd317 dchain: a C chain id is not a rail, and the import half thought it was
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]>
2026-08-07 21:19:24 -07:00
zeekayandHanzo Dev dc4ae7c55a dexprotocol: the import path minted money from a struct literal
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]>
2026-08-07 21:08:21 -07:00
zeekayandHanzo Dev e2c5f8dd70 dchain: say rail where the code moves value, seam only where it ingests
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 17:44:38 -07:00
zeekayandHanzo Dev 4784571595 dchain: prove the funding path, and prove what it does not do
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]>
2026-08-07 17:36:42 -07:00
zeekayandHanzo Dev 4a90738c69 dchain: the funding rail carries value, and nothing else
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]>
2026-08-07 17:18:14 -07:00
zeekayandHanzo Dev c98afc12da dchain: a chain cannot be founded from a document that never arrived
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]>
2026-08-07 17:08:20 -07:00
zeekayandHanzo Dev b240160d91 dchain: pin the block root, not just the genesis root
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]>
2026-08-07 16:50:31 -07:00
zeekayandHanzo Dev 52905e489a dchain: genesis is its own construction, not a block with an empty parent
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]>
2026-08-07 16:44:15 -07:00
zeekayandHanzo Dev d338ca0df6 dexprotocol: the money always lands, the order is best effort
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]>
2026-08-07 16:40:05 -07:00
zeekayandHanzo Dev 518fdf778c dchain: pin the golden vector to the CreateChainTx, not the config file
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]>
2026-08-07 16:27:40 -07:00
zeekayandHanzo Dev fddf4f85a4 dchain: a head block the chain disowns cannot be its genesis
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]>
2026-08-07 16:23:28 -07:00
zeekayandHanzo Dev 47928ea08d dchain: the genesis is chain-creation data, not a property of the binary
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]>
2026-08-07 16:21:48 -07:00
zeekayandHanzo Dev aae6cd0857 dexprotocol: the third conservation proof, denominated in lots
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]>
2026-08-07 16:21:20 -07:00
zeekayandHanzo Dev 9061f81167 dexprotocol: a reclaimed export cannot become writable
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]>
2026-08-07 16:16:55 -07:00
zeekayandHanzo Dev 22ee0495b4 dchain: the seam's unit of work is an order, not an intent
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]>
2026-08-07 16:16:08 -07:00
zeekayandHanzo Dev 406ee2695a dexprotocol: move once, trade many — D owns what it trades
C and D each own value while it is on that chain, and a unit is spendable
on exactly one of them at a time:

  C --Export--> C_TO_D --Import--> D --Export--> D_TO_C --Import--> C

Those four transitions and no others. Never C to D directly, which would
mean a moment where value is in both places or neither.

The consequence is the point. Once imported, D is not matching against
hypothetical C collateral — it has real balances, and every order, match
and trade is a local movement. A million trades cost zero boundary
crossings. That is what makes D an exchange ledger rather than a remote
matching oracle attached to C.

So the atomic rail is narrowed to one job: move ownership exactly once.
It knows nothing about orders, executions, trades or settlement. The
per-trade cross-chain certificate, the C-parent reservation and the
accept-or-release round trip stay only for the V4 adapter, where a
contract genuinely demands synchronous C composability.

Import consumes and credits in one operation. Splitting them either
destroys value or lets a retry, a reorg, a racing relayer or a restart
import the same object twice, which is a double-spend of real money.
Refusal is loud rather than silent — a caller that imported twice has a
bug worth seeing. Export draws only on available balance, so value
committed to an open order cannot also leave the chain.

A claim names its beneficiary, which is what makes import safely
permissionless: delivering one can only credit the account it already
names. Co-location buys latency, never correctness.

Execution now carries MarketVersion and the exact token amounts D
resolved, so C verifies amounts rather than recomputing lots from
whatever the market says now — recomputing would replay a block to a
different result after a market update. Price is gone; it was Output over
Input stated twice inside a signed struct.

Partial execution is bounded by cross-multiplication,
executionOutput * orderInput >= executionInput * orderMinOutput, so there
is no division, no rounding convention, and the rule composes: if every
slice satisfies it, so does their sum.

60 tests. Conservation asserted after all 600 operations of a random run:
imported 4143, exported 2576, D owns 1567.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 16:04:26 -07:00
zeekayandHanzo Dev 4fb53657b1 dexprotocol: quantity is conserved per order, asserted every transition
Exclusive() proves an ExecID cannot sit in two domains. It says nothing
about how much of an order has been committed, so D could certify ten
executions against one order for its full quantity — each individually
valid, correctly certified, correctly scoped — and settle whichever the C
successors happened to include. Every defect the last review turned up
was a conservation bug rather than a determinism bug, so this is tracked
rather than left as a property that happens to hold.

Per order: reserved + traded <= authorized. Reserving takes from
available, settling moves reserved to traded, releasing gives it back.
That last one is what makes a portable Order work at all — an order whose
execution lost the race is still live and matchable again, and a release
that failed to return the quantity would silently shrink every order that
ever lost one.

Conserved() recomputes both totals from the domains themselves rather
than restating the counters, so an accounting update that drifted from
the objects it summarises is caught instead of trusted. A random
400-step lifecycle asserts it plus Exclusive() after every transition:
36 reserved, 96 traded, 86 released in the recorded run.

Units are the book's lots, matching Execution.Quantity, never the order's
Input in token base units. The lot size is market metadata and stays out
of this package: a caller that knows the market converts once and passes
authorized in, and the value is checked for agreement across every
reservation so a second, different conversion cannot enlarge an order.

51 tests, up from 25 before the reshape.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 12:15:32 -07:00
zeekayandHanzo Dev be195f4a8d dexprotocol: the Order is the authorization, and it is portable
The trader signs an Order and nothing moves. D's book matches it and
certifies an Execution; an operator carries Order + Execution + proof to
the C reactor. Only an accepted C block turns that Execution into a Trade.

CParent leaves the Order and stays on the Execution. An order is a
long-lived authorization; a reservation is scoped to one C opportunity.
Braiding them made the trader responsible for guessing which block their
order would match under, and killed the property the book depends on: an
unmatched order stays live, and one whose Execution was released can be
matched again.

Three things this shape forces, each a bug it makes unwritable:

Nonces are UNORDERED, a bitmap position rather than a counter. D matches
by price-time priority across every trader, so one trader's orders settle
out of signing order as a matter of course. A sequential nonce rejects
the low one arriving second and then blocks that trader permanently,
because the expected value can never advance past an order that may never
match.

The commitment binds NetworkID, CChainID and the reactor address, inside
the hash rather than beside it. 0x9999 runs on every EVM sharing the one
D venue; an unbound signature is one an operator replays against each in
turn, and the trader pays on all of them for the single trade they
authorized. Bound, that replay is not detected — it is unrepresentable.

Input and MinOutput are in token base units and 32 bytes wide, not the
book's lots in a uint64. They are different quantities describing the
same trade, and one type for both would let a lot count be added to a wei
balance. uint64 also caps an 18-decimal token near 18 units.

VerifiedOrder follows VerifiedExecution: unexported interface method as
the sentinel, since unexported fields alone leave Order{} constructible
anywhere. Verification stays a pure question — it does not consume the
nonce, or merely inspecting an order would burn the trader's position.
The deadline is read against the block being built, never a clock.

49 tests, up from 25.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 12:11:32 -07:00
zeekayandHanzo Dev e714094013 dexprotocol: Reserve binds to the accepted parent — rule 1 enforced
Settle and Release already required a verified AcceptedBlock; Reserve did not,
so a reservation could still be created without any proof that its C parent was
accepted. That left the abandoned-parent case reachable through the one door
that had not been closed.

Reserve(parent AcceptedBlock, v VerifiedExecution) now enforces both halves of
rule 1: the parent must carry the verified sentinel, and the execution must be
scoped to exactly that parent. Both arguments are unforgeable outside this
package, so a reservation can be taken neither against an unauthenticated
execution nor against an unproven C parent.

25 tests passing.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 09:15:56 -07:00
zeekayandHanzo Dev 438ad8eb9a dexprotocol: CParent means accepted C block, and non-inclusion is a real proof
THE INVARIANT

An Execution may be reserved only against an accepted C parent. The unique
accepted child of that parent permanently decides the Execution: membership
means Trade; non-membership means Release. Neither C nor D reads the other
chain's live state during consensus execution.

CPARENT ACCEPTED-ONLY

Acceptance is the finality boundary, so an accepted P is guaranteed exactly one
accepted child, and that child decides every execution reserved against P. This
removes the abandoned-parent case entirely rather than handling it: there is no
proposed parent to abandon. It also collapses two questions — "did P win?" and
"which child of P won?" — into one.

AcceptedBlock is now a struct with unexported fields, produced only by
VerifyAcceptedBlock. Unexported fields alone still leave AcceptedBlock{}
constructible from outside, so a `verified` sentinel closes that hole and every
transition requires it. Symmetric with VerifiedExecution.

Never use local C state as the proof. Even co-located, different D validators
observe C advancement at different instants, so `localC.LastAccepted()` is not a
function of the block being executed. The local C VM is delivery; the
authenticated statement is consensus input — the same rule already proven in the
other direction.

THREE RULES, NO CLOCKS

  Accepted(P)                                permits Reserve(E), E.CParent == P
  Accepted(Q), Q.Parent==P, E in Q.ExecRoot  permits Settle(E) -> Trade
  Accepted(Q), Q.Parent==P, E not in root    permits Release(E)

NON-INCLUSION IS FIRST-CLASS

Release is half the protocol, not a fallback, and it is the half that returns a
trader's liquidity. So ExecRoot is a sparse Merkle commitment over the 256-bit
ExecID keyspace where absence has the SAME proof shape as presence: the path to
the key, terminating in the empty leaf. One verifier, one code path, one bit of
difference. Leaf and node domains are separated against second-preimage
substitution, and the empty leaf is a distinct constant so absence cannot be
forged. Proofs carry a bitmap and omit empty siblings, so size tracks the
executions actually in the block, not the depth.

A useless reservation is still resolvable: if D reserves against P only after C
accepted Q, anyone can deliver Accepted(Q) plus non-inclusion and release it.
Correctness does not depend on D winning that race.

Also removes the last references to a flag day, which was never authorized —
0x9999 activated 2025-12-25 and settle_module is AlwaysOn. The wire-compatibility
test keeps its value and loses the dead concept from its name and prose.

24 tests passing in dexprotocol, dchain suite green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 09:14:23 -07:00
zeekayandHanzo Dev 05796eb725 fix: FIX Latest — ExecType said what OrdStatus already said
The gateway spoke 8=FIX.4.4 and reported a fill as ExecType 1/2 — the pair
FIX Latest retired precisely because it duplicated order state into the
event field. Both fields carried the same thing, so nothing on the wire
distinguished "a trade happened" from "the order is done", and a client
could read either.

Session layer is now FIXT.1.1; the application layer is FIX.5.0SP2 + EP307,
named on the Logon by DefaultApplVerID(1137) and ApplExtID(1156). Every
fill is ExecType=F (Trade) and OrdStatus alone moves Partial -> Filled.
Nothing depended on the old BeginString, so the wire moved with the
semantics instead of straddling both. This is the wire pkg/dexprotocol
already documents (150=F, 39=1|2).

A partially-filled IOC also never reached a terminal state: the last report
said Partial and nothing followed, leaving a client waiting for an end that
never came. It now closes with Canceled.

Vocabulary, one word per concept: execReport -> execution, gateway
OrderFill -> Fill, multisig ExecutionResult -> Result (it holds an error
string, never an execution).

Deletes the SDK's hand-rolled FIX encoder — it pinned a second protocol
version, omitted BodyLength entirely so the acceptor would have rejected
every frame, and sendFIXMessage returned nil without sending.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 09:09:37 -07:00
zeekayandHanzo Dev c935e4d228 dexprotocol: the cross-chain exchange vocabulary, with the bug class unwritable
The C transaction contains an Order. D matches it and certifies an Execution —
price and quantity guaranteed and reserved. The C block carries that Execution.
EVM execution verifies it and returns the resulting BalanceDelta. When the child
of the Execution's C parent is accepted, D settles it into a Trade if that child
used it, or Releases the reservation if it did not.

This is the consensus-side half: the certified object, its canonical encoding
and domain-separated commitment, and the lifecycle.

WHY IT IS SHAPED THIS WAY

C never queries D's live state during EVM execution — that would fork C on the
first swap, since validators sit at different D heights. Instead D's statement
rides in the block and C verifies it. On replay D can be dead, the GPU gone, D a
billion blocks ahead; C has parent state, transactions, the statement, and the
certificate. That is the entire input set.

Move-typed storage, not a status field. ReservedExecution, Trade and Released
are distinct types under distinct key prefixes, and a transition MOVES the
object. Settle(Trade) and Release(Trade) do not compile — there is no reserved
execution left to hand them. This is aimed squarely at the live defect where the
intent record is written back only inside the same-asset refund branch, so the
ordinary proceeds outcome leaves it Open with its full principal and the
deadline reclaim later refunds money already spent out of other traders' pooled
reserve. That is a field someone forgot to set on one code path; here there is
no field to forget.

An unauthenticated execution cannot enter the lifecycle: VerifiedExecution is an
interface with an unexported method, so only VerifyExecution can produce one, and
Reserve takes nothing else. AcceptedBlock is the same shape, so a settle or
release can never be driven by a clock or a bare block id.

The reservation is keyed by CChain + CParent + ExecID — the parent OPPORTUNITY,
never a candidate block — so competing candidates all name one reservation and
none of them moves it.

FIX Latest externally, small internal vocabulary. ExecType (the event) and
OrdStatus (the resulting state) exist on the wire and are kept distinct per FIX;
they are never the internal source of truth. There is deliberately NO FIX state
for the provisional reservation — it is internal consensus machinery, and a
Trade is published only after C acceptance, which is what keeps the explorer's
CLOB honest.

15 tests, all passing: encoding injectivity over every field, domain separation,
verifier-supplied parent binding, terminal exclusivity, replay refusal.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 08:57:08 -07:00
zeekayandHanzo Dev a388e5fb08 dchain: a proposer could strand any swap for free
The drive emitted the import and the submit as a PAIR in one block, to close the
window where value sits on D with no order against it. That was one block faster
and one hole deeper: a proposer that included the import and omitted the submit
produced a perfectly valid block whose escrow was stuck `open` forever. The
import drive skips already-imported intents, so no later block would ever emit
that submit; the export drive gates on `submitted`, so nothing would ever settle
it. The taker's value sat in a keyless seam account while C's deadline reclaim
refunded them — C's books balance, but the D-side entry is stranded permanently,
and any proposer could do it to any swap, for free, undetectably.

The drive is now ONE RULE: for each intent, emit whatever leg its COMMITTED
STATUS says comes next.

    no escrow + a pending object -> TxImport
    escrow `open`                -> TxIntentSubmit
    escrow `submitted`           -> TxExport

An `open` escrow is unfinished work the next proposer picks up, exactly as a
not-yet-imported intent is. A swap takes three blocks instead of two; in
exchange no block can strand one. Self-healing beats a block of latency, and it
is the simpler rule — the pair was an optimization braided into it.

124 tests pass, 0 fail.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 08:36:54 -07:00
zeekayandHanzo Dev 485289bb52 test(dchain): two independent nodes must agree on a seam block
The closest deterministic stand-in for the devnet gate: a second node with its
own state store, its own shared memory, and no sight of the proposer's mempool
parses the proposer's BYTES, verifies, and derives the identical root — through
the import, the cross, and the export.

Modelling shared memory as one store between the two nodes is what an earlier
draft did, and it is wrong in a way that hides the real question and invents a
fake one: the second node's Apply then collides with the first's for a Put it
would, in production, be making into its own store. Shared memory is per-NODE —
that node's local materialization of both chains' history — so the test gives
each node its own, and C's accept flushes the object into both.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 08:31:54 -07:00
zeekayandHanzo Dev 1378e366e2 dchain: one place decides what an imported intent means as an order
The seam-submit arm had grown three near-identical reject sites inline in
execute, each repeating "advance the escrow, then reject". resolveSeamOrder is
the single resolution point: escrow -> order frame, or a deterministic reject,
with the escrow advanced on every path exactly once.

That the escrow advances even on a reject is the load-bearing part, and it now
has one place to be true rather than three to stay true together.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 08:28:41 -07:00
zeekayandHanzo Dev dc370e6f44 dchain: a rejected block put the proposer's own seam legs back in the mempool
Reject requeues b.txs wholesale, which includes the import/submit/export legs the
drive generated. Those are a pure function of committed state — the next
BuildBlock regenerates exactly the ones still warranted — so the next block would
carry each leg twice, once from the mempool and once from the drive. The replay
guards refuse the second copy, so this is duplication rather than a double spend,
but a block that carries the same import twice is a block nobody can read.

Requeue only what a client actually submitted.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 08:26:52 -07:00
zeekayandHanzo Dev a1474019c0 test(dchain): assert the drive carries the real object bytes, not a re-encoding
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 08:23:12 -07:00
zeekayandHanzo Dev 975712a2c8 dchain: ask the drive whether there is work, do not re-derive the question
hasSeamWork was a SECOND definition of "the seam has something to do", and it
disagreed with the first. It answered "is there a settled escrow?" while the
export drive answers "would that escrow produce a leg?" — an escrow holding
nothing available (exported in an earlier block, or still locked) satisfied the
predicate and not the drive, so WaitForEvent told the engine to build forever
and BuildBlock returned ErrEmptyMempool every time.

It now calls the drives, bounded to one leg, so the trigger and the work cannot
drift apart. The bound is what keeps the poll path cheap: the question is
whether there is work, not how much.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 08:20:44 -07:00
zeekayandHanzo Dev 9109a24081 dchain: the seam imported money and never placed an order
The C->D object carried value only, so executeImport credited the taker's
account and stopped. Its one reference to TxSubmit was a comment. Nothing placed
an order, so nothing crossed, so driveSeamExports — which fired only when a
non-input asset appeared — never fired, so Phase B had no object and the settle
reverted. The seam was unreachable by construction, which is why 0x9999 has
emitted zero events on every chain since genesis.

The object now carries the operation the taker authorized on C (market, side,
limit, size) appended to the unchanged 69-byte value head, and the drive builds
the taker's order from it: BuildBlock emits TxImport and TxIntentSubmit as a
pair, in one block, so value is never on D without an order against it.

TxIntentSubmit is the object-authorized sibling of TxSubmit, as TxImport is of
TxDeposit. It cannot be a plain TxSubmit — that type requires a signature at
parse, at the RPC edge, and in authorizeTx, and weakening any of those would let
an unsigned submit move any account's funds. It carries the intent id alone; the
zapwire Submit frame is derived from the escrow by seamSubmitBody, so the
operation is declared once and never restated for the two sides to reconcile.
From there it flows through the identical custody, matcher and settlement path a
signed dex_submit takes.

Three things this fixes on the way:

D could never sync past a settled swap. executeImport read the object from
shared memory during execute() while the consuming Remove landed at Accept, so
any node re-executing the block found it gone, derived a different ledger root
and died on `execution root mismatch`. The object's bytes now ride in the
TxImport body and Block.verifySeamImports authenticates them at Verify, where a
forgery costs the producer a block instead of forking a root. Accept now checks
the root it derives too: that branch is the bootstrap path and it was committing
whatever it computed while persisting the block's claimed root.

The seam ran in the taker's own ledger account, and the export drive settles an
account's balances back to C. A taker holding native D balance would have had
all of it exported, and C credits that out of the shared seam reserve — a raid
on other takers' pooled input. Each intent now runs in its own account, so the
question does not arise and the spent witness becomes exact rather than
inferred.

A zero-fill order left only its input behind, which the old export gate could
never match, so the principal sat on D while C's deadline reclaim refunded the
taker the same principal. The escrow now records that the order RAN — including
when the custody gate refused it — and the drive gates on that fact.

A market id named by the other chain reached bookForPool and aborted the block.
Any C caller could have halted D with it. Unknown market and unbound assets are
now per-tx rejects, and the intent settles back in full.

SeamPendingTrait rotates to .v2 alongside the wire. Old and new nodes cannot
disagree about a block: the TxImport body is 150 bytes and an old node's
bodySize says 32, so ParseBlock refuses the frame outright. It stalls rather
than forking. No historical block anywhere contains a TxImport.

121 tests pass in pkg/dchain, 0 fail; whole repo green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 08:13:43 -07:00
zeekayandHanzo Dev 529e52a49c dchain: the seam commit handed a batch to Apply, which halts the chain
commitSeamAtomic passed the overlay's CommitBatch to sm.Apply — the
in-process platformvm acceptor pattern — and had NO batch-less path. The
D-Chain ships as an out-of-process plugin, so its SharedMemory is the ZAP
client, and that client refuses any batch (a database.Batch cannot cross a
process boundary). Every block carrying a cross-chain seam op would have
failed at Accept, which is fatal: the chain stops on the first intent it
ever imports. Blocks with no seam op take the plain commit path, which is
why the chain runs fine right up until the seam is used.

Commit the overlay state first, then Apply with no batch. Without a shared
batch exactly-once is unreachable, so the choice is at-most-once (a crash
between the two writes skips an op) or at-least-once (it replays one).
Replay is unsound here — a duplicate put is fatal and, after the peer has
consumed the object, re-creates it. A skip is survivable because neither
side's replay protection depends on the shared-memory op: D's guard is the
durable seamintent escrow row, written by the state commit that now happens
first.

Do not fix this by teaching atomiczap to take a batch.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 06:36:57 -07:00
zeekayandHanzo Dev 95693668b0 rpc: /ext/ -> /v1/ — the dchain doc comments described a route luxd does not serve
luxd serves exactly one HTTP prefix. server/http/server.go:33 sets
baseURL = "/v1"; addRoute and AddAliases derive every path from it and
router.go mounts chains at baseURL+"/bc/C/rpc". /ext/* 404s everywhere.

The pkg/dchain package comments were the load-bearing part: ingest.go,
read.go, vm.go, read_test.go, ingest_test.go and zapingest.go all told
the reader that luxd mounts the DEX handlers at /ext/bc/<chainID>/dex/<m>.
That is the URL an integrator would build, and it 404s. The handlers are
at /v1/bc/<chainID>/dex/<m>.

zapingest.go said "/ext/bc route" with no trailing slash, so it does not
match an /ext/bc/ search -- searching for the whole prefix found it.

Also covers /ext/info, /ext/health and /ext/admin in the two local-network
scripts and in docs/source/node.mdx: baseURL applies to those routes too,
so swapping only the /bc/ ones would leave the scripts half broken.

Measured, positive and negative control on one host:
  api.lux.network/v1/bc/C/rpc -> 200 {"result":"0x17871"}   /ext -> 404
  api.lux.network/v1/info     -> 200 {"networkID":"1"}      /ext -> 404
  api.lux.network/v1/health   -> 405 (exists, GET-only)     /ext -> 404

go build + go vet clean on pkg/dchain, pkg/price, internal/standalone.
go test ./pkg/dchain/... ok 5.855s; go test ./pkg/price -run QChain ok 1.950s.
bash -n clean on both scripts. The repo-wide `go build ./...` was already
failing before this change on cgo packages that need luxcpp and libresolv.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 18:34:12 -07:00
zeekayandHanzo Dev 45bff3f5b6 fix(dex): close price sources synchronously; build pkg/dpdk and pkg/orderbook on linux
`go test ./...` failed three ways on the runner. Two were build failures that
cannot happen on a mac, and one was a race that only loses under load.

pkg/dpdk — `undefined: syscall.SO_NOSIGPIPE`. DarwinOptimizations guarded itself
with `if runtime.GOOS != "darwin" { return }`, but SO_NOSIGPIPE is a COMPILE-time
constant that exists only on Darwin; a runtime check cannot save a reference the
linux build still has to resolve. Split into _darwin.go / _nodarwin.go. Verified
by cross-compiling both ways -- it failed for GOOS=linux before, both pass now.

pkg/orderbook — the cgo preamble uses uint64_t while including only <stdlib.h>.
macOS pulls stdint.h in transitively and glibc does not, so this built here and
never on the runner. Added the include.

pkg/price — TestXChainSourceStalePrice: "Expected price to be stale".

Not a flaky test; Close() was not a barrier. It closed the done channel and
returned WITHOUT waiting for the polling goroutine. A loop selecting on done
against a ticker picks a ready case AT RANDOM, so a poll could still be running
after Close() returned, and poll writes last[symbol] = time.Now(). The test
closes the source, backdates that timestamp by 10s, and asserts staleness -- an
in-flight poll overwrote it. Wide window on a loaded runner, near-zero on a
laptop, which is why it reproduces 0/30 here.

All EIGHT sources had it, so the fix is one shared `lifecycle` (embedded, so
`s.done` still resolves) whose stop() joins before returning and is safe to call
twice. Oracle had the same defect written as

    // Give time for goroutines to exit
    time.Sleep(100 * time.Millisecond)

which could not work either -- calcAverages blocks on a 1s ticker, so it ran up
to a full second after Stop() returned. Now on the same lifecycle, and its loops
select on done instead of polling a flag, so they leave promptly rather than
after one more tick. Nothing restarts a stopped source (checked: 0 tests), and
the other sources already could not.

    go build ./...                        OK
    GOOS=linux go build ./...             OK   (failed before)
    go vet price/dpdk/orderbook           OK
    go test ./pkg/price/ -race            ok, 3/3 runs
    go test -run StalePrice -count=30     ok

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 03:19:43 -07:00
zeekayandHanzo Dev 65b9c6be1c fix: qualify the Metal anchor symbol with the package that owns it
cevm and pkg/dex each defined a global lux_secp256k1_metal_anchor, so any
binary linking both failed with "duplicate symbol". cevm has been renamed to
lux_cevm_secp256k1_metal_anchor; this is the other half.

The anchor only exists to keep the Metal driver alive through linker dead-code
elimination. luxcpp resolves the DRIVER symbol
(secp256k1_ecrecover_address_batch_metal) via dlsym(RTLD_DEFAULT, ...), never
the anchor, so the anchor name is internal and safe to qualify. Naming it after
its package means the next one to appear cannot collide either.

Also drops 3 go.sum lines that disagree with sum.golang.org, recorded from
direct VCS fetches made while GOPRIVATE covered github.com/luxfi/*.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 08:48:43 -07:00