Files
dex/pkg/dchain/execute.go
T
zeekayandHanzo Dev 4f8df54e87 fix(dex): exact-integer ZAP wire + ledger-in-root — kill cross-arch/restart execRoot forks, silent money desync, and ceil/floor strand
Consensus-correctness fix for the native DEX settlement VM. Root cause of the
launch-blocking forks: order size/price were IEEE-754 float64 on the ZAP wire, so
every validator re-derived the settlement integer via uint64(float)/int64(float*1e8),
whose out-of-range result is implementation-defined per the Go spec and DIFFERS BY
CPU ARCHITECTURE (arm64 saturates to 2^64-1; amd64 wraps to 2^63). Two honest
validators on different archs then derived different reserves/balances/fills from the
SAME ordered block.

F1/F3/F5 — exact-integer size/price end to end:
- pkg/zapwire: Place/Submit/Fill carry price as uint64 fixed-point (×1e8 = PriceInt)
  and size as uint64 atomic base units. No float64 on the wire. Removed PutFloat64/
  Float64. Decode is exact bytes->uint64 (deterministic on every arch).
- pkg/dchain/execute: decode the exact integers, set the authoritative PriceUnits/
  SizeUnits lane; the float Price/Size become derived projections only (never a
  source of a consensus value). Reject price==0/size==0/price>maxPriceUnits.
- pkg/dex/persist: the DEXOrder/DEXTrade row stores the EXACT PriceInt (in
  DEXPrice.Integer) and EXACT base units (Quantity/Remaining); RowToOrder restores
  the integer lane verbatim (identity — no float round-trip) and Filled=Qty-Remaining.
  Removed floatToFixedQty/fixedQtyToFloat/floatToFixedPrice (the non-idempotent
  round(size*1e8)/Q64.64 encoding that clamped at ~1.845e11 and forked on restart).
- pkg/dex matcher: cross and level-key on the exact integer PriceUnits.
- pkg/dchain/settle: orderLock/floorsToZeroLock take exact integers; removed
  sizeToUnits/priceToInt float conversions and the dead restoreIntegerLane.

F2 — commit the custody ledger into the execution root:
- state.ComposeRoot/ExecutionRoot gain a ledgerRoot term (Merkle over balance:/
  locked:/orderasset: rows). Validators can no longer finalize agreeing on the book
  while holding different money.

F4 — release the ceil-lock/floor-charge residual on full fill:
- decrementMakerReserves now closes a fully-filled maker's reserve and returns the
  ceil(size*price)-floor residual to the owner (a filled order can never be
  cancelled), through the same unlock path a cancel uses. No more permanently
  frozen quote.

PROVEN: the two-phase cross-arch harness (arm64 build / amd64 Rosetta verify) now
derives byte-identical execRoots AND byte-identical custody ledgers for the
identical block (uint64(1e20) still differs at the language level — 0xffff.. vs
0x8000.. — but the exact-integer wire freezes the value in the bytes so no
per-validator float->int conversion remains). Red guards flipped to assert the fix;
conservation/byzantine/batch-parity/KAT/e2e all green (go test ./... -count=1),
go vet clean.

OUT OF SCOPE (flagged, NOT fixed here): redteam_unbacked_deposit_test documents a
SEPARATE critical finding (F9) — dex_deposit credits an arbitrary self-signed amount
with no C-side backing proof, exposed on the public RPC. That needs an access-control/
custody-model decision, not a mechanical fix, and is left for a follow-up.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-02 09:42:54 -07:00

240 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dchain
import (
"encoding/binary"
"math/big"
"time"
"github.com/luxfi/dex/pkg/dex"
"github.com/luxfi/dex/pkg/zapwire"
)
// maxPriceUnits is the largest wire price the matcher's PriceInt (int64) grid can
// hold. A price at/above 2^63 would overflow PriceInt, so it is rejected at decode
// — deterministically, on every validator (the wire is an exact uint64, so there
// is no architecture-dependent float→int conversion left to diverge).
const maxPriceUnits = uint64(1)<<63 - 1
// newWireOrder builds an order from the wire's EXACT integers: PriceUnits is the
// authoritative PriceInt grid value, SizeUnits the authoritative atomic base-unit
// count. The float Price/Size are DERIVED projections for the matcher's crossing
// loop and legacy/display only — they are never the source of a consensus value,
// so no float→int conversion (whose out-of-range result is architecture-dependent)
// touches the state transition.
func newWireOrder(id uint64, side dex.Side, priceUnits, sizeUnits uint64, user, symbol string, ts time.Time) *dex.Order {
return &dex.Order{
ID: id,
Side: side,
PriceUnits: dex.PriceInt(priceUnits),
Price: float64(priceUnits) / float64(dex.PriceMultiplier),
Size: float64(sizeUnits),
SizeUnits: new(big.Int).SetUint64(sizeUnits),
User: user,
UserID: user,
Symbol: symbol,
Timestamp: ts,
}
}
// execute.go is the deterministic state-transition function. applyTx takes a tx,
// the in-memory book for its market, the block height, the block timestamp, and
// the tx's index in the block, and applies it — minting NOTHING from wall-clock
// or a process-local counter. Every value that participates in matched state is
// derived from block context, so two validators replaying the same ordered block
// produce byte-identical results.
// blockDeterministicID derives a resting order's ID from (height, txIndex). It
// is globally unique across the chain (height monotone, txIndex unique within a
// block) and identical on every validator. The top 32 bits are the height and
// the bottom 32 are the txIndex, with a +1 bias so an ID is never zero (zero is
// the "unset" sentinel the matcher rejects). For heights/indices beyond 32 bits
// this would alias; a venue block never holds 4 billion txs and a chain does not
// reach height 4 billion in any realistic horizon, but if it ever must, widen to
// a keccak-derived 64-bit id (kept simple here for a stable, debuggable id).
func blockDeterministicID(height uint64, txIndex uint32) uint64 {
id := (height << 32) | uint64(txIndex)
return id + 1
}
// applyResult is the outcome of applying one tx: the fills it produced (for a
// submit) and the resting-order delta to persist. The VM writes these to the
// versiondb overlay; on a place the placed order is the delta, on a cancel the
// cancelled order id is removed, on a submit the affected makers are updated.
type applyResult struct {
// Fills are the trades a TxSubmit produced (empty for other tx types).
Fills []dex.Trade
// TakerSide is the side the submit took with (for encoding fill rows).
TakerSide dex.Side
// Placed is the order a TxPlace rested (nil otherwise).
Placed *dex.Order
// Canceled is the order id a TxCancel removed (0 otherwise). The cancelled
// order's SETTLEMENT identity is NOT carried here: the custody unlock resolves
// it exclusively through the orderuser: index by this order id (settle.go /
// settleOrderEffects), never from the matcher's 8-byte in-RAM owner — so a
// post-restart cancel unlocks to the SAME full account the lock lives under.
Canceled uint64
// Touched are resting orders whose remaining changed due to a submit cross
// (the makers); their rows must be rewritten/deleted.
Touched []*dex.Order
// SelfCanceled are resting maker order ids the submit's cross removed for
// self-trade prevention (the taker crossed its OWN resting liquidity). They
// produced NO fill; the VM persists each removal through the SAME cancel path a
// TxCancel uses — delete the order: row, release the reserve (locked ->
// available), delete the orderuser: row — so the durable state matches the
// in-memory book the matcher already updated (no orphan resting row a later
// cross would trip on). Empty for every non-self-crossing submit.
SelfCanceled []uint64
}
// applyTx applies a single transaction to the book deterministically. It is a
// pure function of (tx, book state, height, ts, txIndex): no time.Now(), no
// LastOrderID mint. EnsureMarket is handled by the VM (it has no book effect
// beyond existence, recorded in state); applyTx handles place/cancel/submit.
//
// For TxPlace: the order's ID = blockDeterministicID(height, txIndex), its
// Timestamp = ts, then ConsensusAddOrder rests it.
// For TxCancel: CancelOrder removes the resting order.
// For TxSubmit: the marketable order also gets a deterministic ID/ts (ephemeral —
// it never rests) and SubmitMarketable crosses the book, returning its fills.
func applyTx(book *dex.OrderBook, tx *Tx, height uint64, ts time.Time, txIndex uint32) (applyResult, error) {
switch tx.Type {
case TxPlace:
return applyPlace(book, tx, height, ts, txIndex)
case TxCancel:
return applyCancel(book, tx)
case TxSubmit:
return applySubmit(book, tx, height, ts, txIndex)
case TxEnsureMarket:
// No book mutation; market existence is recorded in d-chain state by the
// VM. Return an empty result.
return applyResult{}, nil
default:
return applyResult{}, ErrUnknownTx
}
}
// applyPlace decodes a zapwire Place body and rests it via the deterministic
// consensus entrypoint. Rejected orders (bad price/size, post-only-would-take,
// self-trade) yield an empty result with no error: a rejected order is a valid,
// deterministic outcome (no fills, no resting delta), not a chain-level failure.
func applyPlace(book *dex.OrderBook, tx *Tx, height uint64, ts time.Time, txIndex uint32) (applyResult, error) {
poolID, side, price, size, user, derr := zapwire.DecodePlace(tx.Body)
_ = poolID
if derr != nil {
return applyResult{}, nil // malformed body: deterministic reject
}
// price is fixed-point ×1e8 (PriceInt), size is atomic base units — both exact
// integers. Reject zero price/size or a price that overflows the PriceInt grid.
if price == 0 || size == 0 || price > maxPriceUnits {
return applyResult{}, nil // deterministic reject
}
order := newWireOrder(blockDeterministicID(height, txIndex), dex.Side(side), price, size, user, book.Symbol, ts)
order.Type = dex.Limit
if book.ConsensusAddOrder(order) == 0 {
return applyResult{}, nil // rejected (e.g. post-only would take)
}
return applyResult{Placed: order}, nil
}
// applyCancel decodes a zapwire Cancel body and removes the resting order. A
// cancel of an unknown order is a deterministic no-op (the matcher returns an
// error which we map to an empty result — every validator sees the same). The
// cancelled order's owner is NOT captured here: the custody unlock resolves the
// full settlement identity through the orderuser: index (settleOrderEffects), so
// the matcher's 8-byte in-RAM owner never participates in a value move.
func applyCancel(book *dex.OrderBook, tx *Tx) (applyResult, error) {
orderID := binary.BigEndian.Uint64(tx.Body[zapwire.PoolIDSize : zapwire.PoolIDSize+8])
// Snapshot the order before cancel so we know it existed (for the delta).
if book.GetOrder(orderID) == nil {
return applyResult{}, nil
}
if err := book.CancelOrder(orderID); err != nil {
return applyResult{}, nil
}
return applyResult{Canceled: orderID}, nil
}
// applySubmit decodes a zapwire Submit body and crosses the book. The marketable
// order gets a deterministic (height,txIndex) ID and the block ts so the fills it
// produces are byte-identical across validators; the order itself never rests
// (SubmitMarketable is IOC). Touched makers are captured BEFORE the cross so the
// VM can rewrite their rows; SubmitMarketable mutates resting orders in place.
func applySubmit(book *dex.OrderBook, tx *Tx, height uint64, ts time.Time, txIndex uint32) (applyResult, error) {
poolID, side, isMarket, limitPrice, size, user, derr := zapwire.DecodeSubmit(tx.Body)
_ = poolID
if derr != nil {
return applyResult{}, nil
}
if size == 0 {
return applyResult{}, nil
}
// For a limit submit the price bounds the cross and must fit the PriceInt grid;
// a pure market submit ignores it (sweeps unconditionally). Exact integers, no float.
priceUnits := limitPrice
if isMarket {
priceUnits = 0
} else if limitPrice == 0 || limitPrice > maxPriceUnits {
return applyResult{}, nil
}
dside := dex.Side(side)
order := newWireOrder(blockDeterministicID(height, txIndex), dside, priceUnits, size, user, book.Symbol, ts)
if isMarket {
order.Type = dex.Market
} else {
order.Type = dex.Limit
}
// Route through the GPU shadow gate. With the default EngineCPU this is
// byte-for-byte SubmitMarketable; with EngineGPUVerified (a node-local opt-in)
// the GPU kernel is ALSO dispatched and byte-compared against this CPU result,
// which is what is committed REGARDLESS — so the engine choice can never change
// the committed fills and a mixed GPU/CPU validator set cannot fork.
fills, err := book.SubmitMarketableVerified(order, dex.MatchEngine())
if err != nil {
return applyResult{}, nil // deterministic reject (e.g. invalid)
}
// Drain the self-trade-prevented maker removals this cross performed. The taker
// crossed its OWN resting liquidity; those makers left the book with no fill and
// must be cancelled in durable state too (reserve released, orderuser:/order:
// rows deleted) so the persisted book matches the matcher's in-memory book.
selfCanceled := book.DrainSelfCanceled()
// Collect the makers touched by these fills so the VM rewrites their rows.
touched := make([]*dex.Order, 0, len(fills))
seen := make(map[uint64]struct{}, len(fills))
for _, f := range fills {
makerID := f.SellOrder
if dside == dex.Sell {
makerID = f.BuyOrder
}
if _, dup := seen[makerID]; dup {
continue
}
seen[makerID] = struct{}{}
if m := book.GetOrder(makerID); m != nil {
touched = append(touched, m)
} else {
// Maker fully filled and removed from the book: synthesize a
// tombstone row so the VM deletes its persisted row.
touched = append(touched, &dex.Order{ID: makerID, Status: dex.Filled})
}
}
return applyResult{Fills: fills, TakerSide: dside, Touched: touched, SelfCanceled: selfCanceled}, nil
}
// trimNull returns b with trailing NUL bytes removed (the inverse of zapwire's
// null padding for the user field).
func trimNull(b []byte) []byte {
for i := len(b) - 1; i >= 0; i-- {
if b[i] != 0 {
return b[:i+1]
}
}
return b[:0]
}