mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
main
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]>
|
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]> |