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]>
This commit is contained in:
2026-07-02 09:42:54 -07:00
co-authored by Hanzo Dev
parent 8179bf6ff6
commit 4f8df54e87
43 changed files with 1640 additions and 469 deletions
+34 -5
View File
@@ -230,7 +230,15 @@ func ensureMarket(ctx context.Context, c rpc.Client, pool [32]byte) error {
// is never an opposite bid to cross, so every order rests — a clean, sustainable
// unit for measuring the consensus-write + wire round trip.
func placeSell(ctx context.Context, c rpc.Client, a *account, pool [32]byte, price, size float64) (uint64, error) {
body := zapwire.EncodePlace(pool, zapwire.SideSell, price, size, a.user)
// Exact-integer wire: price is ×1e8 fixed-point, size is whole base units.
var pu, sz uint64
if price > 0 {
pu = uint64(price * float64(zapwire.PriceScale))
}
if size > 0 {
sz = uint64(size)
}
body := zapwire.EncodePlace(pool, zapwire.SideSell, pu, sz, a.user)
payload, err := a.sign(txPlace, body)
if err != nil {
return 0, err
@@ -251,7 +259,14 @@ func placeSell(ctx context.Context, c rpc.Client, a *account, pool [32]byte, pri
// submitBuy crosses the book with a marketable BUY and returns the fills.
func submitBuy(ctx context.Context, c rpc.Client, a *account, pool [32]byte, limit, size float64) ([]zapwire.Fill, error) {
body := zapwire.EncodeSubmit(pool, zapwire.SideBuy, false, limit, size, a.user)
var pu, sz uint64
if limit > 0 {
pu = uint64(limit * float64(zapwire.PriceScale))
}
if size > 0 {
sz = uint64(size)
}
body := zapwire.EncodeSubmit(pool, zapwire.SideBuy, false, pu, sz, a.user)
payload, err := a.sign(txSubmit, body)
if err != nil {
return nil, err
@@ -521,7 +536,14 @@ func runVerify(cfg config) error {
}
results[ci] = append(results[ci], outcome{})
case "place":
body := zapwire.EncodePlace(pool, s.side, s.price, s.size, cn.maker.user)
var spu, ssz uint64
if s.price > 0 {
spu = uint64(s.price * float64(zapwire.PriceScale))
}
if s.size > 0 {
ssz = uint64(s.size)
}
body := zapwire.EncodePlace(pool, s.side, spu, ssz, cn.maker.user)
payload, err := cn.maker.sign(txPlace, body)
if err != nil {
return err
@@ -536,7 +558,14 @@ func runVerify(cfg config) error {
}
results[ci] = append(results[ci], outcome{ackID: id})
case "submit":
body := zapwire.EncodeSubmit(pool, s.side, false, s.price, s.size, cn.taker.user)
var spu, ssz uint64
if s.price > 0 {
spu = uint64(s.price * float64(zapwire.PriceScale))
}
if s.size > 0 {
ssz = uint64(s.size)
}
body := zapwire.EncodeSubmit(pool, s.side, false, spu, ssz, cn.taker.user)
payload, err := cn.taker.sign(txSubmit, body)
if err != nil {
return err
@@ -576,7 +605,7 @@ func runVerify(cfg config) error {
if s.kind == "submit" {
fmt.Printf(" submit fills @%s : %d fill(s)\n", cfg.addrs[0], len(base[si].fills))
for fi, f := range base[si].fills {
fmt.Printf(" fill %d: %.4f @ %.4f (takerSide=%d)\n", fi, f.Size, f.Price, f.TakerSide)
fmt.Printf(" fill %d: %.4f @ %.4f (takerSide=%d)\n", fi, float64(f.Size), float64(f.Price)/float64(zapwire.PriceScale), f.TakerSide)
}
}
}
+6 -2
View File
@@ -149,8 +149,12 @@ func (h *VenueWS) handleOrder(ctx context.Context, conn *websocket.Conn, ord ven
Timestamp: time.Now().UnixMilli(),
}
for _, f := range fills {
resp.CumQty += f.Size
resp.Fills = append(resp.Fills, venueFill{Price: f.Price, Size: f.Size, TakerSide: sideStr(f.TakerSide)})
// Wire fills are EXACT integers (price = PriceInt ×1e8, size = base units);
// render human floats for the JSON venue client.
px := float64(f.Price) / float64(zapwire.PriceScale)
sz := float64(f.Size)
resp.CumQty += sz
resp.Fills = append(resp.Fills, venueFill{Price: px, Size: sz, TakerSide: sideStr(f.TakerSide)})
}
_ = conn.WriteJSON(resp)
}
+3 -2
View File
@@ -84,9 +84,10 @@ func readWS(t *testing.T, conn *websocket.Conn) wsResp {
// TestVenueWSFills proves a JSON marketable order routes to Venue.Submit with the
// right market/side/limit and the fills come back as JSON.
func TestVenueWSFills(t *testing.T) {
// Wire fills carry exact integers: Price is PriceInt (×1e8), Size is base units.
v := &fakeVenue{fills: []zapwire.Fill{
{Price: 100, Size: 10, TakerSide: zapwire.SideBuy},
{Price: 101, Size: 2, TakerSide: zapwire.SideBuy},
{Price: 100 * zapwire.PriceScale, Size: 10, TakerSide: zapwire.SideBuy},
{Price: 101 * zapwire.PriceScale, Size: 2, TakerSide: zapwire.SideBuy},
}}
conn := dialWS(t, startVenueWS(t, v))
+2 -2
View File
@@ -162,7 +162,7 @@ func TestSeam_TwoPhase_FullFill(t *testing.T) {
h.vm.mempool.Add(depositTx(t, "seam-maker", base, 100))
h.buildAccept(t)
h.vm.mempool.Add(maker.signed(t, TxPlace, zapwire.EncodePlace(pool, sideSell, 2.0, 100, maker.user)))
h.vm.mempool.Add(maker.signed(t, TxPlace, encPlace(pool, sideSell, 2.0, 100, maker.user)))
h.buildAccept(t)
if got := h.avail(t, maker.account, base); got != 0 {
t.Fatalf("maker base available after resting SELL = %d, want 0 (locked)", got)
@@ -197,7 +197,7 @@ func TestSeam_TwoPhase_FullFill(t *testing.T) {
// ---- NATIVE D: the taker crosses the maker under D consensus ----
// A signed limit BUY 100 base @ 2 locks 200 quote and fills against the maker.
h.vm.mempool.Add(taker.signed(t, TxSubmit, zapwire.EncodeSubmit(pool, sideBuy, false, 2.0, 100, taker.user)))
h.vm.mempool.Add(taker.signed(t, TxSubmit, encSubmit(pool, sideBuy, false, 2.0, 100, taker.user)))
h.buildAccept(t)
// Post-fill ledger: taker spent 200 quote, received 100 base; maker received 200 quote.
if got := h.avail(t, taker.account, quote); got != 0 {
+8 -8
View File
@@ -41,7 +41,7 @@ type mkAcct func(t *testing.T, name string) *testAcct
// string) — used to forge wrong-signer / unsigned frames where the body's
// asserted account is chosen independently of who (if anyone) signs.
func rawPlace(user string, side uint8, price, size float64) []byte {
return zapwire.EncodePlace(authPool, side, price, size, user)
return encPlace(authPool, side, price, size, user)
}
// setupAuthVM brings up a VM with the LUX/LUSD market opened (custody on) so
@@ -70,7 +70,7 @@ func runAuthSuite(t *testing.T, mk mkAcct) {
addBlock(t, vm, dep)
assertAcctBalance(t, vm, a, assetLUX, 100, 0)
place := a.signed(t, TxPlace, zapwire.EncodePlace(authPool, zapwire.SideSell, 5.0, 10.0, a.user))
place := a.signed(t, TxPlace, encPlace(authPool, zapwire.SideSell, 5.0, 10.0, a.user))
addBlock(t, vm, place)
// The lock moved available->locked: a correctly-signed order took effect.
assertAcctBalance(t, vm, a, assetLUX, 90, 10)
@@ -114,7 +114,7 @@ func runAuthSuite(t *testing.T, mk mkAcct) {
// A validly-signed place, then corrupt the signature so it no longer
// recovers alice's account.
body := zapwire.EncodePlace(authPool, zapwire.SideSell, 5.0, 10.0, a.user)
body := encPlace(authPool, zapwire.SideSell, 5.0, 10.0, a.user)
auth := a.authFor(TxPlace, a.nonce, body)
auth.Sig[0] ^= 0xff // tamper
forged, err := NewSignedTx(TxPlace, body, auth)
@@ -135,7 +135,7 @@ func runAuthSuite(t *testing.T, mk mkAcct) {
// Body asserts ALICE's account; signature is MALLORY's valid signature over
// that exact body. The recovered account is mallory != alice -> rejected.
body := zapwire.EncodePlace(authPool, zapwire.SideSell, 5.0, 10.0, alice.user)
body := encPlace(authPool, zapwire.SideSell, 5.0, 10.0, alice.user)
auth := mallory.authFor(TxPlace, 0, body)
tx, err := NewSignedTx(TxPlace, body, auth)
if err != nil {
@@ -153,13 +153,13 @@ func runAuthSuite(t *testing.T, mk mkAcct) {
addBlock(t, vm, a.signed(t, TxDeposit, zapwire.EncodeDeposit(a.user, assetLUX, 100, ref32(1))))
// nonce after deposit = 1; the next signed tx consumes nonce 1.
p1 := a.signed(t, TxPlace, zapwire.EncodePlace(authPool, zapwire.SideSell, 5.0, 10.0, a.user))
p1 := a.signed(t, TxPlace, encPlace(authPool, zapwire.SideSell, 5.0, 10.0, a.user))
addBlock(t, vm, p1)
assertAcctBalance(t, vm, a, assetLUX, 90, 10) // first place locked 10
// A SECOND, DISTINCT place (different size) but reusing the just-consumed
// nonce (1). The nonce gate refuses it; no further lock.
replayBody := zapwire.EncodePlace(authPool, zapwire.SideSell, 5.0, 20.0, a.user)
replayBody := encPlace(authPool, zapwire.SideSell, 5.0, 20.0, a.user)
replay, err := NewSignedTx(TxPlace, replayBody, a.authFor(TxPlace, 1, replayBody))
if err != nil {
t.Fatalf("NewSignedTx: %v", err)
@@ -173,7 +173,7 @@ func runAuthSuite(t *testing.T, mk mkAcct) {
vm := setupAuthVM(t)
a := mk(t, "alice")
addBlock(t, vm, a.signed(t, TxDeposit, zapwire.EncodeDeposit(a.user, assetLUX, 100, ref32(1))))
p1 := a.signed(t, TxPlace, zapwire.EncodePlace(authPool, zapwire.SideSell, 5.0, 10.0, a.user))
p1 := a.signed(t, TxPlace, encPlace(authPool, zapwire.SideSell, 5.0, 10.0, a.user))
addBlock(t, vm, p1)
assertAcctBalance(t, vm, a, assetLUX, 90, 10)
@@ -195,7 +195,7 @@ func runAuthSuite(t *testing.T, mk mkAcct) {
alice.signed(t, TxDeposit, zapwire.EncodeDeposit(alice.user, assetLUX, 100, ref32(1))),
)
// Alice rests a SELL of 10 LUX @ 5 (locks 10). It is HER order.
pblk := addBlock(t, vm, alice.signed(t, TxPlace, zapwire.EncodePlace(authPool, zapwire.SideSell, 5.0, 10.0, alice.user)))
pblk := addBlock(t, vm, alice.signed(t, TxPlace, encPlace(authPool, zapwire.SideSell, 5.0, 10.0, alice.user)))
orderID := blockDeterministicID(pblk.height, 0)
assertAcctBalance(t, vm, alice, assetLUX, 90, 10)
+64 -54
View File
@@ -8,7 +8,6 @@ import (
"encoding/binary"
"errors"
"fmt"
"math/big"
"time"
"github.com/luxfi/database"
@@ -662,7 +661,15 @@ func (b *Block) execute(ctx context.Context, overlay *versiondb.Database) (execR
}
sortRowsCanonical(allRows)
root, _, _, _ := ExecutionRoot(b.vm.lastRoot, allRows, allFills, b.txs, b.height)
// Commit the CUSTODY LEDGER (balance:/locked:/orderasset:) into the execution
// root so validators cannot finalize agreeing on the book while holding different
// money — the money is a term in consensus, not an off-root side effect.
lroot, lerr := ledgerRoot(overlay)
if lerr != nil {
return execResult{}, lerr
}
root, _, _, _ := ExecutionRoot(b.vm.lastRoot, allRows, allFills, b.txs, lroot, b.height)
return execResult{root: root, fills: allFills, rows: allRows, outcomes: outcomes, atomic: ar}, nil
}
@@ -856,11 +863,17 @@ func (b *Block) settleOrderEffects(db *versiondb.Database, poolID [32]byte, tx *
if err != nil {
return err
}
// Decrement each touched maker's per-order reserve by the base/quote it
// spent on this cross, so a later cancel of a partially-filled maker unlocks
// only the still-resting remainder (a fully-filled maker's reserve is
// deleted). The maker's aggregate locked: was already spent by settleFills.
if err := b.decrementMakerReserves(db, poolID, side, base, quote, res.Fills); err != nil {
// Makers the cross fully filled left the book (applySubmit tombstones them with
// Status=Filled). Their per-order reserve is CLOSED and any ceil−floor residual
// returned to the owner (a filled order can never be cancelled). Partial makers
// keep their decremented reserve for a later cancel.
fullyFilled := make(map[uint64]struct{}, len(res.Touched))
for _, m := range res.Touched {
if m != nil && m.ID != 0 && m.Status == dex.Filled {
fullyFilled[m.ID] = struct{}{}
}
}
if err := b.decrementMakerReserves(db, poolID, side, base, quote, res.Fills, fullyFilled); err != nil {
return err
}
// Refund the taker's unspent lock remainder (IOC never rests).
@@ -933,7 +946,18 @@ func (b *Block) releaseOrderReserve(db *versiondb.Database, poolID [32]byte, ord
// the remaining resting size. A maker on the opposite side of the taker locked:
// taker BUY -> maker SOLD base (reserve in base, reduced by fill base units);
// taker SELL -> maker BOUGHT base (reserve in quote, reduced by fill quote units).
func (b *Block) decrementMakerReserves(db *versiondb.Database, poolID [32]byte, takerSide uint8, base, quote [32]byte, fills []dex.Trade) error {
//
// fullyFilled names the makers that LEFT THE BOOK on this cross (a fully-filled
// order the matcher removed). For those the per-order reserve is CLOSED here and
// any residual is RETURNED to the owner: a resting BUY locks ceil(size*price) quote
// but each fill charges floor(base*price) (settlement_units.go quoteUnitsFor), so
// after a full fill a ceil−floor residual remains in the reserve. A filled order
// can never be cancelled, so that residual would be FROZEN in locked forever. We
// release it (locked -> available) through the SAME primitive a cancel uses, so a
// full fill conserves the maker's over-lock exactly like a cancel would. A maker
// that is still resting (partial fill) keeps its decremented reserve for a later
// cancel.
func (b *Block) decrementMakerReserves(db *versiondb.Database, poolID [32]byte, takerSide uint8, base, quote [32]byte, fills []dex.Trade, fullyFilled map[uint64]struct{}) error {
// Aggregate the consumed reserve per maker order id (a maker may fill across
// several fills in one cross, though typically one).
consumed := map[uint64]uint64{}
@@ -960,20 +984,37 @@ func (b *Block) decrementMakerReserves(db *versiondb.Database, poolID [32]byte,
if !ok {
continue // maker placed before custody / no reserve recorded
}
newReserve := uint64(0)
residual := uint64(0)
if reserve > amt {
newReserve = reserve - amt
residual = reserve - amt
}
if err := putOrderLock(db, poolID, makerOrderID, asset, newReserve); err != nil {
return err
}
// A fully-consumed maker reserve means the maker order left the book; drop
// its identity row in lockstep with the reserve so no orphan orderuser:
// survives a fully-filled order (keeps the two per-order rows consistent).
if newReserve == 0 {
if _, left := fullyFilled[makerOrderID]; left {
// The order left the book: return the ceil−floor residual to the owner
// (it can never be cancelled) and CLOSE the per-order rows. A zero residual
// (e.g. a SELL maker whose base lock is exact) makes the unlock a no-op.
owner, hasOwner, uerr := getOrderUser(db, poolID, makerOrderID)
if uerr != nil {
return uerr
}
if residual > 0 {
if !hasOwner {
return fmt.Errorf("%w: residual release for filled maker %d in market %x", ErrMissingSettlementUser, makerOrderID, poolID[:8])
}
if uerr := unlockToAvailable(db, owner, asset, residual); uerr != nil {
return uerr
}
}
if err := putOrderLock(db, poolID, makerOrderID, asset, 0); err != nil {
return err
}
if err := deleteOrderUser(db, poolID, makerOrderID); err != nil {
return err
}
continue
}
// Still resting (partial fill): keep the decremented reserve for a later cancel.
if err := putOrderLock(db, poolID, makerOrderID, asset, residual); err != nil {
return err
}
}
return nil
@@ -1024,46 +1065,15 @@ func rebuildBookFromDB(db database.Iteratee, poolID [32]byte, symbol string) (*d
return nil, err
}
ob := dex.RowsToBook(symbol, rows)
// Restore the EXACT-INTEGER value lane on every rebuilt resting order. The
// persisted DEXOrder row keeps quantities in fixed-point, not the matcher's
// atomic-unit SizeUnits/RemainingUnits, so an order rebuilt from disk would
// lose the integer lane — its fills would carry no BaseUnits/QuoteUnits and
// the custody ledger could not settle value-exactly. We re-derive the lane
// from the order's (now-restored) size so a maker resting across a restart, or
// rebuilt into a fresh per-block book, settles a taker's cross to the unit.
restoreIntegerLane(ob)
// The persisted DEXOrder row stores the EXACT atomic-unit quantity and PriceInt
// (persist.go), and RowToOrder restores SizeUnits/RemainingUnits/PriceUnits from
// those integers verbatim — so a maker rebuilt from disk carries the identical
// value lane the live one had, and settles a taker's cross to the unit. There is
// no float re-derivation (the old restoreIntegerLane), so a rebuilt book is
// byte-identical to the live one — a restart cannot fork the bookRoot.
return ob, nil
}
// restoreIntegerLane sets SizeUnits/RemainingUnits (the matcher's exact-integer
// quantity lane) on every resting order in a rebuilt book, derived from the
// order's float size/remaining via the same whole-unit conversion the consensus
// place/submit path uses (sizeToUnits). Without this a rebuilt maker has a nil
// lane and a taker crossing it produces fills with no integer truth (which the
// custody settle refuses). Idempotent: an order already carrying the lane is left
// as-is.
func restoreIntegerLane(ob *dex.OrderBook) {
for _, o := range ob.Orders {
if o == nil {
continue
}
if o.SizeUnits == nil {
if u := sizeToUnits(o.Size); u > 0 {
o.SizeUnits = new(big.Int).SetUint64(u)
}
}
if o.RemainingUnits == nil {
rem := o.RemainingSize
if rem == 0 {
rem = o.Size
}
if u := sizeToUnits(rem); u > 0 {
o.RemainingUnits = new(big.Int).SetUint64(u)
}
}
}
}
// poolSymbol renders a poolId as its hex market symbol.
func poolSymbol(poolID [32]byte) string {
const hexdigits = "0123456789abcdef"
+3 -3
View File
@@ -226,8 +226,8 @@ func TestByzantineMatcher_PriceTimePriority_BestPriceFirst(t *testing.T) {
t.Fatal("expected the taker to fill against the best resting ask, got zero fills")
}
for i, f := range oc.fills {
if f.Price != 5.0 {
t.Fatalf("PRICE-PRIORITY VIOLATED: fill[%d] executed @ %g while a better resting ask @ 5 existed "+
if f.Price != 5*uint64(zapwire.PriceScale) { // price 5.0 in ×1e8 fixed-point
t.Fatalf("PRICE-PRIORITY VIOLATED: fill[%d] executed @ %d while a better resting ask @ 5e8 existed "+
"(matcher filled a worse price first)", i, f.Price)
}
}
@@ -280,7 +280,7 @@ func TestByzantineMatcher_OverFillClampedToRestingLiquidity(t *testing.T) {
var filled float64
for _, f := range oc.fills {
filled += f.Size
filled += float64(f.Size) // wire fill size is exact base units
}
if filled > restingLiquidity {
t.Fatalf("OVER-FILL: matcher filled %g > resting liquidity %g (fabricated base from nothing)", filled, restingLiquidity)
+2 -3
View File
@@ -10,7 +10,6 @@ import (
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/database/memdb"
"github.com/luxfi/database/prefixdb"
"github.com/luxfi/dex/pkg/zapwire"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/log"
@@ -60,7 +59,7 @@ func TestDrive_AutonomousImportCrossExport(t *testing.T) {
h.vm.mempool.Add(openMarketTx(t, pool, base, quote))
h.vm.mempool.Add(depositTx(t, "drive-maker", base, 100))
h.buildAccept(t)
h.vm.mempool.Add(maker.signed(t, TxPlace, zapwire.EncodePlace(pool, sideSell, 2.0, 100, maker.user)))
h.vm.mempool.Add(maker.signed(t, TxPlace, encPlace(pool, sideSell, 2.0, 100, maker.user)))
h.buildAccept(t)
// ---- PHASE A (C side, modeled): lock 200 quote -> a tagged C->D intent object ----
@@ -98,7 +97,7 @@ func TestDrive_AutonomousImportCrossExport(t *testing.T) {
// ---- The taker submits a SIGNED crossing order (the normal mempool flow) ----
// The drive funds and settles; the taker still authors their own trade.
h.vm.mempool.Add(taker.signed(t, TxSubmit, zapwire.EncodeSubmit(pool, sideBuy, false, 2.0, 100, taker.user)))
h.vm.mempool.Add(taker.signed(t, TxSubmit, encSubmit(pool, sideBuy, false, 2.0, 100, taker.user)))
crossBlk := h.buildAccept(t)
// The cross is realized IN this block; at this block's BUILD the committed state had no
// output yet, so the drive correctly emitted NO export here (no-export-before-trade).
+51 -52
View File
@@ -12,15 +12,31 @@ import (
"github.com/luxfi/dex/pkg/zapwire"
)
// sizeUnitsBig returns the order's exact integer base-unit quantity as a big.Int
// (the value lane the matcher uses to produce BaseUnits/QuoteUnits on a fill).
// nil for a non-positive size so the matcher's legacy float path is unaffected
// for a degenerate order. Whole-unit truncation matches the ledger's sizeToUnits.
func sizeUnitsBig(size float64) *big.Int {
if !(size > 0) {
return nil
// 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,
}
return new(big.Int).SetUint64(uint64(size))
}
// execute.go is the deterministic state-transition function. applyTx takes a tx,
@@ -105,28 +121,18 @@ func applyTx(book *dex.OrderBook, tx *Tx, height uint64, ts time.Time, txIndex u
// 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) {
body := tx.Body
// Place body: poolId[32] + side[1] + price[8] + size[8] + user[16].
side := dex.Side(body[zapwire.PoolIDSize])
price := zapwire.Float64(body[zapwire.PoolIDSize+1 : zapwire.PoolIDSize+9])
size := zapwire.Float64(body[zapwire.PoolIDSize+9 : zapwire.PoolIDSize+17])
user := string(trimNull(body[zapwire.PoolIDSize+17 : zapwire.PoolIDSize+17+zapwire.UserSize]))
if !(price > 0) || !(size > 0) {
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 := &dex.Order{
ID: blockDeterministicID(height, txIndex),
Type: dex.Limit,
Side: side,
Price: price,
Size: size,
SizeUnits: sizeUnitsBig(size), // exact integer base units (value lane)
User: user,
UserID: user,
Symbol: book.Symbol,
Timestamp: ts,
}
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)
}
@@ -157,35 +163,28 @@ func applyCancel(book *dex.OrderBook, tx *Tx) (applyResult, error) {
// (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) {
body := tx.Body
// Submit body: poolId[32] + side[1] + isMarket[1] + price[8] + size[8] + user[16].
side := dex.Side(body[zapwire.PoolIDSize])
isMarket := body[zapwire.PoolIDSize+1] == 1
limitPrice := zapwire.Float64(body[zapwire.PoolIDSize+2 : zapwire.PoolIDSize+10])
size := zapwire.Float64(body[zapwire.PoolIDSize+10 : zapwire.PoolIDSize+18])
user := string(trimNull(body[zapwire.PoolIDSize+18 : zapwire.PoolIDSize+18+zapwire.UserSize]))
if !(size > 0) {
poolID, side, isMarket, limitPrice, size, user, derr := zapwire.DecodeSubmit(tx.Body)
_ = poolID
if derr != nil {
return applyResult{}, nil
}
order := &dex.Order{
ID: blockDeterministicID(height, txIndex),
Side: side,
Size: size,
SizeUnits: sizeUnitsBig(size), // exact integer base units (value lane)
User: user,
UserID: user,
Symbol: book.Symbol,
Timestamp: ts,
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 {
if !(limitPrice > 0) {
return applyResult{}, nil
}
order.Type = dex.Limit
order.Price = limitPrice
}
// Route through the GPU shadow gate. With the default EngineCPU this is
@@ -209,7 +208,7 @@ func applySubmit(book *dex.OrderBook, tx *Tx, height uint64, ts time.Time, txInd
seen := make(map[uint64]struct{}, len(fills))
for _, f := range fills {
makerID := f.SellOrder
if side == dex.Sell {
if dside == dex.Sell {
makerID = f.BuyOrder
}
if _, dup := seen[makerID]; dup {
@@ -225,7 +224,7 @@ func applySubmit(book *dex.OrderBook, tx *Tx, height uint64, ts time.Time, txInd
}
}
return applyResult{Fills: fills, TakerSide: side, Touched: touched, SelfCanceled: selfCanceled}, nil
return applyResult{Fills: fills, TakerSide: dside, Touched: touched, SelfCanceled: selfCanceled}, nil
}
// trimNull returns b with trailing NUL bytes removed (the inverse of zapwire's
+2 -2
View File
@@ -16,13 +16,13 @@ var testPool = [32]byte{0xaa, 0xbb, 0xcc}
func placeTx(t *testing.T, side uint8, price, size float64, user string) *Tx {
t.Helper()
a := acctFor(t, user)
return a.signed(t, TxPlace, zapwire.EncodePlace(testPool, side, price, size, a.user))
return a.signed(t, TxPlace, encPlace(testPool, side, price, size, a.user))
}
func submitTx(t *testing.T, side uint8, isMarket bool, price, size float64, user string) *Tx {
t.Helper()
a := acctFor(t, user)
return a.signed(t, TxSubmit, zapwire.EncodeSubmit(testPool, side, isMarket, price, size, a.user))
return a.signed(t, TxSubmit, encSubmit(testPool, side, isMarket, price, size, a.user))
}
// applyAll replays a tx sequence into a fresh book, returning every fill as a
+21 -18
View File
@@ -40,7 +40,6 @@ import (
"encoding/binary"
"fmt"
"io"
"math"
"math/big"
"net"
"net/http"
@@ -90,7 +89,7 @@ func (s *signingVenue) EnsureMarket(ctx context.Context, poolID [32]byte) error
}
func (s *signingVenue) Place(ctx context.Context, poolID [32]byte, side uint8, price, size float64, user string) (uint64, error) {
body := zapwire.EncodePlace(poolID, side, price, size, wireUser(s.t, user))
body := encPlace(poolID, side, price, size, wireUser(s.t, user))
resp, err := s.conn.Call(ctx, zapwire.MethodPlace, signedPayload(s.t, user, TxPlace, body))
if err != nil {
return 0, err
@@ -114,7 +113,7 @@ func (s *signingVenue) Cancel(ctx context.Context, poolID [32]byte, orderID uint
}
func (s *signingVenue) Submit(ctx context.Context, poolID [32]byte, side uint8, isMarket bool, limit, size float64, user string) ([]zapwire.Fill, error) {
body := zapwire.EncodeSubmit(poolID, side, isMarket, limit, size, wireUser(s.t, user))
body := encSubmit(poolID, side, isMarket, limit, size, wireUser(s.t, user))
resp, err := s.conn.Call(ctx, zapwire.MethodSubmit, signedPayload(s.t, user, TxSubmit, body))
if err != nil {
return nil, err
@@ -251,8 +250,8 @@ func v4SubmitFrame(poolID [32]byte, side uint8, isMarket bool, limit, size float
if isMarket {
payload[33] = 1
}
binary.BigEndian.PutUint64(payload[34:42], math.Float64bits(limit))
binary.BigEndian.PutUint64(payload[42:50], math.Float64bits(size))
binary.BigEndian.PutUint64(payload[34:42], wirePriceUnits(limit))
binary.BigEndian.PutUint64(payload[42:50], wireSizeUnits(size))
copy(payload[50:66], caller)
return payload
}
@@ -264,17 +263,21 @@ func v4SubmitFrame(poolID [32]byte, side uint8, isMarket bool, limit, size float
// quote (amount1>0). This is the value the precompile would hand the EVM
// PoolManager; deriving it here proves the V4 leg's settlement math end to end.
func v4BalanceDelta(zeroForOne bool, fills []zapwire.Fill) (amount0, amount1 *big.Int) {
base, quote := 0.0, 0.0
base := new(big.Int)
quote := new(big.Int)
scale := big.NewInt(int64(zapwire.PriceScale))
for _, f := range fills {
base += f.Size
quote += f.Price * f.Size
base.Add(base, new(big.Int).SetUint64(f.Size))
// quote per fill = floor(size * price / 1e8) — exact integers, matching the
// matcher's quoteUnitsFor. No float.
q := new(big.Int).Mul(new(big.Int).SetUint64(f.Size), new(big.Int).SetUint64(f.Price))
q.Quo(q, scale)
quote.Add(quote, q)
}
ceil := func(v float64) *big.Int { return big.NewInt(int64(math.Ceil(v))) }
floor := func(v float64) *big.Int { return big.NewInt(int64(math.Floor(v))) }
if zeroForOne {
return ceil(base), new(big.Int).Neg(floor(quote))
return base, new(big.Int).Neg(quote)
}
return new(big.Int).Neg(floor(base)), ceil(quote)
return new(big.Int).Neg(base), quote
}
// fixCross drives the FIX path: connect to the acceptor, Logon as `user`,
@@ -306,7 +309,7 @@ func fixCross(t *testing.T, addr, user, symbol string, limit, size float64) []za
case fix.ExecTypePartialFill, fix.ExecTypeFill:
lastQty, _ := m.GetFloat(fix.TagLastQty)
lastPx, _ := m.GetFloat(fix.TagLastPx)
fills = append(fills, zapwire.Fill{Price: lastPx, Size: lastQty, TakerSide: zapwire.SideBuy})
fills = append(fills, zapwire.Fill{Price: wirePriceUnits(lastPx), Size: wireSizeUnits(lastQty), TakerSide: zapwire.SideBuy})
if execType == fix.ExecTypeFill {
return fills
}
@@ -368,7 +371,7 @@ func wsCross(t *testing.T, wsURL, user, symbol string, limit, size float64) []za
if f.TakerSide == "sell" {
side = zapwire.SideSell
}
fills = append(fills, zapwire.Fill{Price: f.Price, Size: f.Size, TakerSide: side})
fills = append(fills, zapwire.Fill{Price: wirePriceUnits(f.Price), Size: wireSizeUnits(f.Size), TakerSide: side})
}
return fills
}
@@ -468,8 +471,8 @@ func TestFourPathTradingE2E(t *testing.T) {
t.Fatalf("%s: got %d fills, want exactly 1", l.path, len(fills))
}
f := fills[0]
if f.Size != askSize || f.Price != l.price {
t.Fatalf("%s: fill %g@%g, want %g@%g", l.path, f.Size, f.Price, askSize, l.price)
if f.Size != wireSizeUnits(askSize) || f.Price != wirePriceUnits(l.price) {
t.Fatalf("%s: fill %d@%d, want %d@%d", l.path, f.Size, f.Price, wireSizeUnits(askSize), wirePriceUnits(l.price))
}
expectedRemaining -= askSize
if _, rem, _, _, ok := v.vm.BookDepth(pool); !ok || rem != expectedRemaining {
@@ -480,7 +483,7 @@ func TestFourPathTradingE2E(t *testing.T) {
u := dexvenue.UserHandle(l.user)
assertBalance(t, v.vm, u, assetLUX, 1, 0)
assertBalance(t, v.vm, u, assetLUSD, 0, 0)
t.Logf(" [%s] FILL %g LUX @ %g LUSD (consensus-settled); book now %g; taker %s = 1 LUX / 0 LUSD",
t.Logf(" [%s] FILL %d LUX @ %d/1e8 LUSD (consensus-settled); book now %g; taker %s = 1 LUX / 0 LUSD",
l.path, f.Size, f.Price, expectedRemaining, u)
}
@@ -507,7 +510,7 @@ func TestFourPathTradingE2E(t *testing.T) {
// The V4 caller is the taker's key-derived account (the 16-byte wire user).
caller := wireUser(t, dexvenue.UserHandle(l.user))
frame := v4SubmitFrame(pool, zapwire.SideBuy, false, l.price, askSize, caller)
want := zapwire.EncodeSubmit(pool, zapwire.SideBuy, false, l.price, askSize, caller)
want := encSubmit(pool, zapwire.SideBuy, false, l.price, askSize, caller)
if string(frame) != string(want) {
t.Fatalf("V4 engine_zap frame != zapwire.EncodeSubmit (frozen-frame parity broken)")
}
+16 -17
View File
@@ -7,7 +7,6 @@ import (
"context"
"encoding/binary"
"fmt"
"math"
"sync"
"testing"
"time"
@@ -43,8 +42,8 @@ const (
)
type relayFill struct {
Price float64
Size float64
Price uint64 // PriceInt (×1e8 fixed-point)
Size uint64 // atomic base units
Side uint8
}
@@ -62,15 +61,15 @@ func relayDecodeFills(data []byte) ([]relayFill, error) {
fills := make([]relayFill, 0, n)
off := 4
for i := 0; i < n; i++ {
p := math.Float64frombits(binary.BigEndian.Uint64(data[off : off+8]))
s := math.Float64frombits(binary.BigEndian.Uint64(data[off+8 : off+16]))
p := binary.BigEndian.Uint64(data[off : off+8]) // PriceInt, exact
s := binary.BigEndian.Uint64(data[off+8 : off+16])
side := data[off+16]
off += relayFillWireSize
if !(p > 0) || math.IsInf(p, 0) {
return nil, fmt.Errorf("fill %d: invalid price %v", i, p)
if p == 0 {
return nil, fmt.Errorf("fill %d: invalid price %d", i, p)
}
if !(s > 0) || math.IsInf(s, 0) {
return nil, fmt.Errorf("fill %d: invalid size %v", i, s)
if s == 0 {
return nil, fmt.Errorf("fill %d: invalid size %d", i, s)
}
if side > 1 {
return nil, fmt.Errorf("fill %d: invalid side %d", i, side)
@@ -195,7 +194,7 @@ func TestHandlerByteParityOverSocket(t *testing.T) {
// gate refuses an unsigned money-moving frame); wire user is the maker's
// key-derived account, signed at its next nonce.
resp, err = conn.Call(ctx, relayMethodPlace,
signedPayload(t, "maker", TxPlace, zapwire.EncodePlace(pool, zapwire.SideSell, 101.0, 5.0, wireUser(t, "maker"))))
signedPayload(t, "maker", TxPlace, encPlace(pool, zapwire.SideSell, 101.0, 5.0, wireUser(t, "maker"))))
if err != nil {
t.Fatalf("place Call: %v", err)
}
@@ -210,7 +209,7 @@ func TestHandlerByteParityOverSocket(t *testing.T) {
// 3) submit a buy that crosses — frozen Submit frame, FILLS response decoded
// with the proxy's exact DecodeFills (the byte-parity assertion).
resp, err = conn.Call(ctx, relayMethodSubmit,
signedPayload(t, "taker", TxSubmit, zapwire.EncodeSubmit(pool, zapwire.SideBuy, false, 101.0, 3.0, wireUser(t, "taker"))))
signedPayload(t, "taker", TxSubmit, encSubmit(pool, zapwire.SideBuy, false, 101.0, 3.0, wireUser(t, "taker"))))
if err != nil {
t.Fatalf("submit Call: %v", err)
}
@@ -224,11 +223,11 @@ func TestHandlerByteParityOverSocket(t *testing.T) {
if fills[0].Side != zapwire.SideBuy {
t.Errorf("fill taker side = %d, want buy(0)", fills[0].Side)
}
if fills[0].Size != 3.0 {
t.Errorf("fill size = %v, want 3.0", fills[0].Size)
if fills[0].Size != 3 { // 3 base units
t.Errorf("fill size = %d, want 3", fills[0].Size)
}
if fills[0].Price != 101.0 {
t.Errorf("fill price = %v, want 101.0", fills[0].Price)
if fills[0].Price != 101*uint64(zapwire.PriceScale) { // price 101.0 in ×1e8 fixed-point
t.Errorf("fill price = %d, want %d", fills[0].Price, 101*uint64(zapwire.PriceScale))
}
// Cross-check: the same response decodes identically with the dex's own
@@ -273,13 +272,13 @@ func TestHandlerIdempotentReplay(t *testing.T) {
}
mustCall(relayMethodEnsureMarket, zapwire.EncodeEnsureMarket(pool))
mustCall(relayMethodPlace, signedPayload(t, "maker", TxPlace, zapwire.EncodePlace(pool, zapwire.SideSell, 50.0, 10.0, wireUser(t, "maker"))))
mustCall(relayMethodPlace, signedPayload(t, "maker", TxPlace, encPlace(pool, zapwire.SideSell, 50.0, 10.0, wireUser(t, "maker"))))
// First submit: a 4-unit buy crosses for 4 units. The signed payload is built
// ONCE so the replay below sends BYTE-IDENTICAL bytes (same nonce, same
// signature -> same tx id), exercising content-addressed idempotency rather than
// a fresh (distinct-nonce) order.
submit := signedPayload(t, "taker", TxSubmit, zapwire.EncodeSubmit(pool, zapwire.SideBuy, false, 50.0, 4.0, wireUser(t, "taker")))
submit := signedPayload(t, "taker", TxSubmit, encSubmit(pool, zapwire.SideBuy, false, 50.0, 4.0, wireUser(t, "taker")))
fills1, err := zapwire.DecodeFills(mustCall(relayMethodSubmit, submit))
if err != nil {
t.Fatalf("submit1 decode: %v", err)
+5 -5
View File
@@ -135,7 +135,7 @@ func TestIngestCrossingOrdersMatchInBlock(t *testing.T) {
// money-moving frame is signed by the maker's key-derived account (the auth gate
// refuses an unsigned place); the auth envelope rides as a trailer on the frame.
ack = postFrame(t, base, zapwire.MethodPlace,
signedPayload(t, "maker", TxPlace, zapwire.EncodePlace(pool, zapwire.SideSell, 101.0, 5.0, wireUser(t, "maker"))))
signedPayload(t, "maker", TxPlace, encPlace(pool, zapwire.SideSell, 101.0, 5.0, wireUser(t, "maker"))))
makerID, status, err := zapwire.DecodeAck(ack)
if err != nil || status != zapwire.StatusPlaced {
t.Fatalf("place ack: status=%d err=%v", status, err)
@@ -146,7 +146,7 @@ func TestIngestCrossingOrdersMatchInBlock(t *testing.T) {
// 3) submit a crossing buy @101 size 3 over HTTP -> CONSENSUS-computed fills.
fillsResp := postFrame(t, base, zapwire.MethodSubmit,
signedPayload(t, "taker", TxSubmit, zapwire.EncodeSubmit(pool, zapwire.SideBuy, false, 101.0, 3.0, wireUser(t, "taker"))))
signedPayload(t, "taker", TxSubmit, encSubmit(pool, zapwire.SideBuy, false, 101.0, 3.0, wireUser(t, "taker"))))
fills, err := zapwire.DecodeFills(fillsResp)
if err != nil {
t.Fatalf("DecodeFills: %v", err)
@@ -154,8 +154,8 @@ func TestIngestCrossingOrdersMatchInBlock(t *testing.T) {
if len(fills) != 1 {
t.Fatalf("expected 1 fill, got %d: %+v", len(fills), fills)
}
if fills[0].TakerSide != zapwire.SideBuy || fills[0].Size != 3.0 || fills[0].Price != 101.0 {
t.Fatalf("fill = %+v, want buy 3.0 @ 101.0", fills[0])
if fills[0].TakerSide != zapwire.SideBuy || fills[0].Size != 3 || fills[0].Price != 101*uint64(zapwire.PriceScale) {
t.Fatalf("fill = %+v, want buy 3 @ 101e8", fills[0])
}
// 4) STATE PROOF: a trade:<height><seq> row exists — the fill is committed
@@ -252,7 +252,7 @@ func TestIngestAckFrameShape(t *testing.T) {
pool[0] = 0x07
postFrame(t, base, zapwire.MethodEnsureMarket, zapwire.EncodeEnsureMarket(pool))
ack := postFrame(t, base, zapwire.MethodPlace,
signedPayload(t, "u", TxPlace, zapwire.EncodePlace(pool, zapwire.SideBuy, 5.0, 2.0, wireUser(t, "u"))))
signedPayload(t, "u", TxPlace, encPlace(pool, zapwire.SideBuy, 5.0, 2.0, wireUser(t, "u"))))
if len(ack) < 9 {
t.Fatalf("ack frame too short: %d", len(ack))
}
+11 -11
View File
@@ -154,7 +154,7 @@ func (c *e2eClient) ensureMarket(t *testing.T, pool [32]byte) {
// place rests a limit order (maker liquidity). Returns the consensus-assigned id.
func (c *e2eClient) place(t *testing.T, pool [32]byte, side uint8, price, size float64, user string) uint64 {
t.Helper()
body := zapwire.EncodePlace(pool, side, price, size, wireUser(t, user))
body := encPlace(pool, side, price, size, wireUser(t, user))
resp, err := c.conn.Call(c.ctx, zapwire.MethodPlace, signedPayload(t, user, TxPlace, body))
if err != nil {
t.Fatalf("dex_place %s %v@%v: %v", sideName(side), size, price, err)
@@ -169,7 +169,7 @@ func (c *e2eClient) place(t *testing.T, pool [32]byte, side uint8, price, size f
// consensus-computed fills (decoded via the proxy's exact frame).
func (c *e2eClient) submit(t *testing.T, pool [32]byte, side uint8, limit, size float64, user string) []zapwire.Fill {
t.Helper()
body := zapwire.EncodeSubmit(pool, side, false, limit, size, wireUser(t, user))
body := encSubmit(pool, side, false, limit, size, wireUser(t, user))
resp, err := c.conn.Call(c.ctx, zapwire.MethodSubmit, signedPayload(t, user, TxSubmit, body))
if err != nil {
t.Fatalf("dex_submit %s %v@%v: %v", sideName(side), size, limit, err)
@@ -255,14 +255,14 @@ func TestLocalnetVenueE2E(t *testing.T) {
if f.TakerSide != zapwire.SideBuy {
t.Errorf("fill %d takerSide=%d want buy", i, f.TakerSide)
}
got1 += f.Size
t.Logf(" LUX/LUSD fill %d: %v @ %v (takerSide=%s)", i, f.Size, f.Price, sideName(f.TakerSide))
got1 += float64(f.Size) // exact base units
t.Logf(" LUX/LUSD fill %d: %d @ %d/1e8 (takerSide=%s)", i, f.Size, f.Price, sideName(f.TakerSide))
}
if got1 != 12.0 {
t.Fatalf("LUX/LUSD filled %v, want 12.0", got1)
}
if f1[0].Price != 100.0 || f1[1].Price != 101.0 {
t.Fatalf("LUX/LUSD fill prices = %v,%v want 100,101 (price-time priority)", f1[0].Price, f1[1].Price)
if f1[0].Price != 100*uint64(zapwire.PriceScale) || f1[1].Price != 101*uint64(zapwire.PriceScale) {
t.Fatalf("LUX/LUSD fill prices = %d,%d want 100e8,101e8 (price-time priority)", f1[0].Price, f1[1].Price)
}
// After: the 5@101 ask is fully consumed by the 2-unit take? No: 10@100 fully
// + 2 of 5@101 -> 3@101 remains. Asks remaining = 3; bids untouched = 20.
@@ -278,10 +278,10 @@ func TestLocalnetVenueE2E(t *testing.T) {
if len(f2) != 1 {
t.Fatalf("LUX/LETH cross: got %d fills, want 1", len(f2))
}
if f2[0].Size != 3.0 || f2[0].Price != 0.049 || f2[0].TakerSide != zapwire.SideSell {
t.Fatalf("LUX/LETH fill = %v@%v side=%d, want 3[email protected] sell", f2[0].Size, f2[0].Price, f2[0].TakerSide)
if f2[0].Size != 3 || f2[0].Price != wirePriceUnits(0.049) || f2[0].TakerSide != zapwire.SideSell {
t.Fatalf("LUX/LETH fill = %d@%d side=%d, want 3@%d sell", f2[0].Size, f2[0].Price, f2[0].TakerSide, wirePriceUnits(0.049))
}
t.Logf(" LUX/LETH fill: %v @ %v (takerSide=sell)", f2[0].Size, f2[0].Price)
t.Logf(" LUX/LETH fill: %d @ %d/1e8 (takerSide=sell)", f2[0].Size, f2[0].Price)
// LUX/LETH bids now empty; asks ([email protected] + [email protected] = 10) untouched.
if n, rem := v.restingByMarket(leth); rem != 10.0 {
t.Fatalf("LUX/LETH post-cross resting=%v (orders=%d), want 10.0 (asks only)", rem, n)
@@ -314,8 +314,8 @@ func TestLocalnetVenueE2E(t *testing.T) {
cli2, closeCli2 := dialVenue(t, v2.addr)
defer closeCli2()
f3 := cli2.submit(t, lusd, zapwire.SideBuy, 101.0, 3.0, "taker2")
if len(f3) != 1 || f3[0].Size != 3.0 || f3[0].Price != 101.0 {
t.Fatalf("post-restart LUX/LUSD cross = %+v, want one 3.0@101 fill", f3)
if len(f3) != 1 || f3[0].Size != 3 || f3[0].Price != 101*uint64(zapwire.PriceScale) {
t.Fatalf("post-restart LUX/LUSD cross = %+v, want one 3@101e8 fill", f3)
}
if _, rem := v2.restingByMarket(lusd); rem != 20.0 {
t.Fatalf("post-restart LUX/LUSD resting=%v, want 20.0 (bids only, ask fully consumed)", rem)
+5 -3
View File
@@ -48,10 +48,12 @@ func TestNotionalFlooringCannotCreateFreeExecutableOrder(t *testing.T) {
// Sanity on the predicate itself: a sub-tick BUY floors to zero lock; a normal
// BUY does not.
mc := marketConstraint{assetLUX, assetLUSD}
if !mc.floorsToZeroLock(sideBuy, dustPrice, 10.0) {
t.Fatalf("predicate: sub-tick BUY %g x10 should floor to zero lock", dustPrice)
// dustPrice (5e-9) scales below one price tick, so wirePriceUnits(dustPrice)==0 —
// the exact zero-lock BUY the gate must catch. A normal price does not.
if !mc.floorsToZeroLock(sideBuy, wirePriceUnits(dustPrice), wireSizeUnits(10.0)) {
t.Fatalf("predicate: sub-tick BUY %g x10 (priceUnits=%d) should floor to zero lock", dustPrice, wirePriceUnits(dustPrice))
}
if mc.floorsToZeroLock(sideBuy, 5.0, 10.0) {
if mc.floorsToZeroLock(sideBuy, wirePriceUnits(5.0), wireSizeUnits(10.0)) {
t.Fatal("predicate: a normal BUY 5x10 must NOT floor to zero lock")
}
+11 -7
View File
@@ -169,12 +169,12 @@ func crossOneMarket(t *testing.T, base string, f readFixture) []zapwire.Fill {
dep(bobUser, f.quote, 53)
askResp := httpPost(t, base, zapwire.MethodPlace,
signedPayload(t, aliceUser, TxPlace, zapwire.EncodePlace(f.pool, zapwire.SideSell, askPrice, askSize, wireUser(t, aliceUser))))
signedPayload(t, aliceUser, TxPlace, encPlace(f.pool, zapwire.SideSell, askPrice, askSize, wireUser(t, aliceUser))))
if askResp[8] != zapwire.StatusPlaced {
t.Fatalf("ask not placed: %x", askResp)
}
subResp := httpPost(t, base, zapwire.MethodSubmit,
signedPayload(t, bobUser, TxSubmit, zapwire.EncodeSubmit(f.pool, zapwire.SideBuy, false, buyLimit, buySize, wireUser(t, bobUser))))
signedPayload(t, bobUser, TxSubmit, encSubmit(f.pool, zapwire.SideBuy, false, buyLimit, buySize, wireUser(t, bobUser))))
fills, err := zapwire.DecodeFills(subResp)
if err != nil {
t.Fatalf("decode fills: %v (resp=%x)", err, subResp)
@@ -194,7 +194,7 @@ func TestRead_TradesAfterCommittedFill(t *testing.T) {
f := newReadFixture()
fills := crossOneMarket(t, base, f)
t.Logf("submit reported %d fill(s): price=%g size=%g", len(fills), fills[0].Price, fills[0].Size)
t.Logf("submit reported %d fill(s): price=%d/1e8 size=%d", len(fills), fills[0].Price, fills[0].Size)
var got getTradesResp
httpGetJSON(t, base, MethodGetTrades, "", &got)
@@ -206,11 +206,15 @@ func TestRead_TradesAfterCommittedFill(t *testing.T) {
t.Fatalf("head height still 0 after a committed fill (head=%+v)", got.chainHead)
}
tr := got.Trades[0]
if tr.Price != fills[0].Price {
t.Errorf("committed trade price %g != submit fill price %g", tr.Price, fills[0].Price)
// The read API renders human floats; the wire fill carries exact integers
// (price = PriceInt ×1e8, size = base units). Compare in human units.
wantPx := float64(fills[0].Price) / float64(zapwire.PriceScale)
wantSz := float64(fills[0].Size)
if tr.Price != wantPx {
t.Errorf("committed trade price %g != submit fill price %g", tr.Price, wantPx)
}
if tr.Size != fills[0].Size {
t.Errorf("committed trade size %g != submit fill size %g", tr.Size, fills[0].Size)
if tr.Size != wantSz {
t.Errorf("committed trade size %g != submit fill size %g", tr.Size, wantSz)
}
if tr.TakerSide != "buy" {
t.Errorf("taker side = %q, want buy", tr.TakerSide)
+237
View File
@@ -0,0 +1,237 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dchain
// redteam_crossarch_test.go — RED TEAM (adversarial). CONSENSUS FORK via
// architecture-dependent float64->uint64 conversion on the value path.
//
// THREAT: the deterministic state transition computes the exact-integer
// settlement quantity as `uint64(size)` on the attacker-controlled float64 wire
// size (execute.go sizeUnitsBig line 19-24; settle.go sizeToUnits line 73-79).
// Per the Go spec a float64->uint64 conversion whose value is out of range is
// IMPLEMENTATION-DEFINED — and it differs by CPU architecture:
//
// uint64(1e20): arm64 = 0xFFFFFFFFFFFFFFFF amd64 = 0x8000000000000000
//
// A wire size >= 2^64 (1.845e19) — e.g. 1e20 base units = 100 tokens at 18
// decimals, the EXACT case settlement_units.go's comment says the integer lane
// exists to support — therefore produces a DIFFERENT resting-order reserve, a
// DIFFERENT locked balance, and (once it crosses) DIFFERENT fill units on an
// arm64 validator vs an amd64 validator, FROM THE SAME ordered block.
//
// This propagates into the execRoot: the resting order's per-order reserve is
// keyed/sized by uint64(size); the maker row that feeds bookRoot carries a
// remaining that, combined with the divergent integer lane, differs. The net
// effect proven here: an arm64 proposer's block, re-verified byte-for-byte by an
// amd64 validator, DERIVES A DIFFERENT execRoot and is REJECTED
// ("execution root mismatch"). Two honest validators on different architectures
// cannot agree on the same block => the chain forks / halts.
//
// The test is two-phase to eliminate the wall-clock timestamp confound
// (blockTimestamp() uses time.Now(), so each process stamps its own ts; a
// faithful test must hand the PROPOSER'S bytes — with its embedded ts — to the
// verifier, exactly as consensus does):
//
// PHASE=build (run on arm64): build blocks 1..3, write their exact bytes +
// arm64-derived execRoots to files.
// PHASE=verify (run on amd64 under Rosetta): fresh VM, ParseBlock+Verify+Accept
// each block's bytes. A Verify error / root diff is
// the fork.
//
// Reproduce:
// export PATH=/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin
// export GOPRIVATE='github.com/luxfi/*' GOFLAGS=-mod=mod CGO_ENABLED=0 GOWORK=off
// D=$SCRATCH # a writable dir
// ZEN_XARCH_DIR=$D ZEN_XARCH_PHASE=build go test ./pkg/dchain/ -run TestRedTeam_CrossArchConsensusFork -count=1 -v
// GOARCH=amd64 go test ./pkg/dchain/ -run TestRedTeam_CrossArchConsensusFork -c -o $D/x.test
// ZEN_XARCH_DIR=$D ZEN_XARCH_PHASE=verify arch -x86_64 $D/x.test -test.run TestRedTeam_CrossArchConsensusFork -test.v
import (
"context"
"encoding/hex"
"math"
"os"
"path/filepath"
"testing"
"github.com/luxfi/database/memdb"
"github.com/luxfi/dex/pkg/zapwire"
)
const (
xarchPool0 = 0xc0
xarchPool1 = 0x55
)
func xarchDir(t *testing.T) string {
d := os.Getenv("ZEN_XARCH_DIR")
if d == "" {
t.Skip("ZEN_XARCH_DIR not set — this is the two-phase cross-arch fork harness; see file header")
}
return d
}
// TestRedTeam_CrossArchConsensusFork proves an architecture-dependent execRoot.
func TestRedTeam_CrossArchConsensusFork(t *testing.T) {
dir := xarchDir(t)
phase := os.Getenv("ZEN_XARCH_PHASE")
// ARCH PROBE (language-level divergence, arch-local).
var bigSize = 1e20
t.Logf("ARCH PROBE: uint64(%g) = %d (0x%x) uint64(+Inf)=0x%x",
bigSize, uint64(bigSize), uint64(bigSize), uint64(math.Inf(1)))
switch phase {
case "build":
xarchBuild(t, dir, bigSize)
case "verify":
xarchVerify(t, dir)
default:
t.Skip("ZEN_XARCH_PHASE must be build|verify")
}
}
// xarchBuild (run on the PROPOSER arch) builds the 3-block scenario and writes
// each block's canonical bytes + the proposer-derived execRoot to files.
func xarchBuild(t *testing.T, dir string, bigSize float64) {
ctx := context.Background()
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(ctx)
const (
maker = "maker-cx"
taker = "taker-cx"
fund = uint64(math.MaxUint64)
)
pool := [32]byte{xarchPool0, xarchPool1, 0xa1, 0xcd}
b1 := addBlock(t, vm,
depositTx(t, taker, assetLUX, fund),
depositTx(t, maker, assetLUSD, fund),
openMarketTx(t, pool, assetLUX, assetLUSD),
)
b2 := addBlock(t, vm, placePoolTx(t, pool, zapwire.SideBuy, 1.0, bigSize, maker))
b3 := addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideSell, false, 1.0, bigSize, taker))
for i, b := range []*Block{b1, b2, b3} {
writeHex(t, filepath.Join(dir, blkFile(i)), b.Bytes())
writeHex(t, filepath.Join(dir, rootFile(i)), b.execRoot[:])
t.Logf("PROPOSER block%d execRoot=%x", i+1, b.execRoot[:])
}
avail, locked := ledgerTotals(t, vm)
t.Logf("PROPOSER LEDGER LUSD available=%d locked=%d", avail[assetLUSD], locked[assetLUSD])
t.Logf("PROPOSER LEDGER LUX available=%d locked=%d", avail[assetLUX], locked[assetLUX])
// Persist the proposer's ledger so the validator arch can compare the MONEY,
// not just the (arch-stable) execRoot.
writeHex(t, filepath.Join(dir, "ledger.hex"), encodeLedger(avail[assetLUSD], locked[assetLUSD], avail[assetLUX], locked[assetLUX]))
}
func encodeLedger(vals ...uint64) []byte {
out := make([]byte, 8*len(vals))
for i, v := range vals {
for j := 0; j < 8; j++ {
out[i*8+7-j] = byte(v >> (8 * j))
}
}
return out
}
func decodeLedger(b []byte) []uint64 {
out := make([]uint64, len(b)/8)
for i := range out {
var v uint64
for j := 0; j < 8; j++ {
v = (v << 8) | uint64(b[i*8+j])
}
out[i] = v
}
return out
}
// xarchVerify (run on the VALIDATOR arch) re-parses and independently verifies
// the proposer's EXACT block bytes — the real consensus validation path. A
// mismatch between the root this arch derives and the proposer's embedded
// execRoot is a CONSENSUS FORK: the two honest validators cannot agree.
func xarchVerify(t *testing.T, dir string) {
ctx := context.Background()
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(ctx)
forked := false
for i := 0; i < 3; i++ {
raw := readHex(t, filepath.Join(dir, blkFile(i)))
wantRoot := readHex(t, filepath.Join(dir, rootFile(i)))
blk, err := vm.ParseBlock(ctx, raw)
if err != nil {
t.Fatalf("block%d ParseBlock: %v", i+1, err)
}
verr := blk.Verify(ctx)
gotRoot := blk.(*Block).execRoot // the proposer's claim, carried in bytes
// Re-derive this arch's root the same way Verify does, to report it even
// when Verify rejects. Verify already compared derived vs claimed; a
// non-nil error here with a root-mismatch message is the fork signal.
if verr != nil {
t.Logf("VALIDATOR block%d Verify REJECTED proposer block: %v", i+1, verr)
t.Logf(" => proposer(other-arch) execRoot=%x, this-arch derived a DIFFERENT root", wantRoot)
forked = true
break
}
// Verify passed: this arch derived == proposer's claim. Accept and chain on.
if err := blk.Accept(ctx); err != nil {
t.Fatalf("block%d Accept: %v", i+1, err)
}
t.Logf("VALIDATOR block%d Verify OK (root %x matches proposer)", i+1, gotRoot[:])
}
if forked {
t.Errorf("HARD CONSENSUS FORK: an honest validator on this architecture REJECTS a valid block proposed on the other architecture, because uint64(float64 size) diverges across archs on the value path. Mixed-arch validator sets cannot agree.")
return
}
t.Logf("execRoots MATCHED across arch (rows use the clamped floatToFixedQty). Now compare the MONEY the block moved — the ledger is NOT a term in the execRoot.")
avail, locked := ledgerTotals(t, vm)
got := []uint64{avail[assetLUSD], locked[assetLUSD], avail[assetLUX], locked[assetLUX]}
want := decodeLedger(readHex(t, filepath.Join(dir, "ledger.hex")))
labels := []string{"LUSD.available", "LUSD.locked", "LUX.available", "LUX.locked"}
t.Logf("VALIDATOR LEDGER LUSD available=%d locked=%d", got[0], got[1])
t.Logf("VALIDATOR LEDGER LUX available=%d locked=%d", got[2], got[3])
desync := false
for i := range want {
if got[i] != want[i] {
desync = true
t.Logf(" LEDGER DESYNC on %s: proposer(other-arch)=%d this-arch=%d (delta=%d)",
labels[i], want[i], got[i], int64(want[i]-got[i]))
}
}
if desync {
t.Errorf("SILENT MONEY DESYNC PROVEN: both validators ACCEPTED the identical block bytes and AGREE on the execRoot, but hold DIFFERENT custody balances. The execRoot does not commit balance:/locked:, and settleFills moves BaseUnits=big.Int(uint64(size)) which diverges across arch. Consensus cannot detect this; withdrawals will be authorized inconsistently.")
} else {
t.Logf("ledgers also matched — no divergence on these blocks")
}
}
func blkFile(i int) string { return "xarch_blk" + string(rune('0'+i)) + ".hex" }
func rootFile(i int) string { return "xarch_root" + string(rune('0'+i)) + ".hex" }
func writeHex(t *testing.T, path string, b []byte) {
t.Helper()
if err := os.WriteFile(path, []byte(hex.EncodeToString(b)), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func readHex(t *testing.T, path string) []byte {
t.Helper()
s, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
b, err := hex.DecodeString(string(s))
if err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return b
}
+170
View File
@@ -0,0 +1,170 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// redteam_dust_strand_test.go — RED TEAM (adversarial). Attacks the ceil-lock /
// floor-charge asymmetry on a RESTING BUY order.
//
// A resting BUY locks ceil(size*price) quote (settle.go quoteUnitsCeil), but each
// fill charges floor(baseUnits*price) quote (settlement_units.go quoteUnitsFor).
// When the order is CANCELLED while still resting, releaseOrderReserve returns the
// full remaining reserve (ceil-side) — so the over-lock is refunded. But when the
// order FULLY FILLS, decrementMakerReserves only reduces the reserve by the floored
// consumed amount and NEVER releases the ceil-vs-floor residual: the order leaves
// the book (its order: row is deleted) with a non-zero orderasset:/orderuser: pair
// still recording locked quote that is now UNREACHABLE (a filled order cannot be
// cancelled: applyCancel sees GetOrder==nil => Canceled=0 => no release).
//
// Result: quote the maker deposited is PERMANENTLY FROZEN in locked:. It is NOT a
// mint (I = A + L still holds — the residual sits in L), which is exactly why the
// whole-keyspace conservation property (conservation_property_test.go) does NOT
// catch it. It IS a permanent loss/griefing vector, adversarially AMPLIFIED by
// fragmenting the maker's fill (each 1-unit fill strands ~frac(price) more).
package dchain
import (
"context"
"testing"
"github.com/luxfi/database/memdb"
"github.com/luxfi/dex/pkg/zapwire"
)
// TestRedteam_FullFillStrandsOverlock_Minimal is the minimal repro: a single full
// fill of a resting BUY at a fractional price strands ceil(1*1.5)-floor(1*1.5)=1
// quote unit in the maker's locked, unrecoverable by cancel or withdraw.
func TestRedteam_FullFillStrandsOverlock_Minimal(t *testing.T) {
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(context.Background())
const (
maker = "maker"
taker = "taker"
)
pool := [32]byte{0xd0, 0x57} // "dust"
accts := []string{maker, taker}
// Fund: maker holds quote (LUSD) to bid with, taker holds base (LUX) to sell.
addBlock(t, vm,
depositTx(t, maker, assetLUSD, 10),
depositTx(t, taker, assetLUX, 5),
openMarketTx(t, pool, assetLUX, assetLUSD),
)
assertConserved2(t, vm, "after fund", accts, assetLUX, assetLUSD, 5, 10)
// Maker rests BUY 1 LUX @ 1.5 LUSD. Lock = ceil(1 * 1.5) = 2 quote.
placeBlk := addBlock(t, vm, placePoolTx(t, pool, zapwire.SideBuy, 1.5, 1.0, maker))
makerOrderID := blockDeterministicID(placeBlk.height, 0)
assertBalance(t, vm, maker, assetLUSD, 8, 2) // avail 8, locked 2
// Taker SELL 1 LUX @ 1.0 crosses the 1.5 bid — fills 1 unit at MAKER price 1.5.
// quoteUnits = floor(1 * 1.5) = 1 : the maker is charged 1, not the 2 it locked.
blk := addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideSell, false, 1.0, 1.0, taker))
if n := len(outcomeForTx(blk, blk.txs[0]).fills); n != 1 {
t.Fatalf("expected exactly 1 fill, got %d", n)
}
// ATTACK ASSERTION: the maker's order fully filled and left the book, yet 1
// quote unit remains LOCKED — the ceil(2) reservation minus the floor(1) charge.
// A correct full-fill would release this residual (as a cancel would); it does
// not, so it is frozen.
availQ, lockedQ, err := vm.Balance(wireUser(t, maker), assetLUSD)
if err != nil {
t.Fatalf("Balance(maker,LUSD): %v", err)
}
t.Logf("after full fill: maker LUSD avail=%d locked=%d (locked SHOULD be 0)", availQ, lockedQ)
// NOT A MINT: at fill time (E=0), conservation I=A+L holds exactly. This is the
// blind spot — the whole-keyspace property suite passes because the residual sits
// in L, yet it is real user funds that can never be moved to A.
assertConserved2(t, vm, "at fill time (no mint)", accts, assetLUX, assetLUSD, 5, 10)
if lockedQ == 0 {
t.Skip("no residual stranded — behavior may have been fixed; nothing to prove")
}
if lockedQ != 1 {
t.Fatalf("unexpected stranded amount: got %d, analysis predicts 1", lockedQ)
}
// UNRECOVERABLE #1 — cancel the now-dead order id: it is gone from the book, so
// applyCancel returns Canceled=0 and releaseOrderReserve is a no-op. The 1 stays.
addBlock(t, vm, cancelPoolTx(t, pool, makerOrderID, maker))
_, lockedAfterCancel, _ := vm.Balance(wireUser(t, maker), assetLUSD)
if lockedAfterCancel != 1 {
t.Fatalf("cancel unexpectedly changed stranded locked: %d (want still 1)", lockedAfterCancel)
}
// UNRECOVERABLE #2 — withdraw ALL quote: withdraw only touches AVAILABLE, so the
// realized amount is the 8 available; the 1 locked is unreachable forever.
_, wo := addBlockOutcomes(t, vm, withdrawTx(t, maker, assetLUSD, 100))
realized := withdrawRealizedOf(wo, TxWithdraw)
_, lockedFinal, _ := vm.Balance(wireUser(t, maker), assetLUSD)
t.Logf("withdraw-all realized=%d, maker LUSD locked FROZEN=%d", realized, lockedFinal)
if lockedFinal != 1 {
t.Fatalf("expected 1 quote permanently frozen after withdraw-all, got locked=%d", lockedFinal)
}
t.Errorf("RED FINDING: %d quote unit(s) permanently FROZEN in maker.locked after full fill "+
"(unrecoverable by cancel or withdraw); conservation I=A+L masks it", lockedFinal)
}
// TestRedteam_FragmentationAmplifiesStranding shows an ADVERSARY (the taker) can
// scale the frozen amount by fragmenting the maker's fill: N single-unit sells
// against a BUY N @ 1.5 strand ceil(1.5N)-N = ceil(0.5N) quote in the maker's
// locked. Here N=10 => 15 locked, 10 charged, 5 frozen (33% of the reservation).
func TestRedteam_FragmentationAmplifiesStranding(t *testing.T) {
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(context.Background())
const (
maker = "maker"
taker = "taker"
)
pool := [32]byte{0xd0, 0x58} // "dust2"
accts := []string{maker, taker}
addBlock(t, vm,
depositTx(t, maker, assetLUSD, 20),
depositTx(t, taker, assetLUX, 20),
openMarketTx(t, pool, assetLUX, assetLUSD),
)
// Maker rests BUY 10 LUX @ 1.5 : lock = ceil(10*1.5) = 15 quote, avail 5.
addBlock(t, vm, placePoolTx(t, pool, zapwire.SideBuy, 1.5, 10.0, maker))
assertBalance(t, vm, maker, assetLUSD, 5, 15)
// Adversarial taker fragments the fill into 10 single-unit sells. Each charges
// floor(1*1.5)=1 quote; the maker's 15 reservation is only ever debited by 10.
const N = 10
for i := 0; i < N; i++ {
addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideSell, false, 1.0, 1.0, taker))
}
availQ, lockedQ, _ := vm.Balance(wireUser(t, maker), assetLUSD)
t.Logf("after %d fragmented fills: maker LUSD avail=%d locked=%d (locked SHOULD be 0)", N, availQ, lockedQ)
// NOT A MINT: conservation I=A+L holds at fill time (before any withdraw).
assertConserved2(t, vm, "at fill time (no mint)", accts, assetLUX, assetLUSD, 20, 20)
// Maker bought all 10 LUX?
availBase, _, _ := vm.Balance(wireUser(t, maker), assetLUX)
if availBase != 10 {
t.Fatalf("maker did not fully fill: got %d base of 10", availBase)
}
// The frozen residual: ceil(15) - 10 charged = 5.
if lockedQ == 0 {
t.Skip("no residual stranded — behavior may have been fixed")
}
if lockedQ != 5 {
t.Fatalf("unexpected frozen amount: got %d, analysis predicts 5 (ceil(1.5*10)-10)", lockedQ)
}
// Withdraw everything: only the 5 available comes out; 5 stays frozen forever.
_, wo := addBlockOutcomes(t, vm, withdrawTx(t, maker, assetLUSD, 1000))
realized := withdrawRealizedOf(wo, TxWithdraw)
_, lockedFinal, _ := vm.Balance(wireUser(t, maker), assetLUSD)
t.Errorf("RED FINDING (amplified): taker fragmentation FROZE %d/%d quote (%.0f%% of the maker's "+
"reservation) after full fill; withdraw realized only %d; I=A+L masks the loss",
lockedFinal, 15, float64(lockedFinal)/15*100, realized)
}
@@ -0,0 +1,74 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// RED-TEAM (consensus fork) at the ACTUAL dchain root function — now a REGRESSION
// GUARD for the fix: a resting order with a large EXACT wire size yields an
// IDENTICAL bookRoot / ExecutionRoot before vs after a restart-forced rebuild.
// The old float64 encoding (round(size*1e8) reloaded via fixedQtyToFloat) drifted
// by 1 unit and forked a restarted validator; the exact-integer row eliminates it.
package dchain
import (
"math/big"
"testing"
"time"
"github.com/luxfi/dex/pkg/dex"
)
func TestRedteam_RestartForksExecutionRoot(t *testing.T) {
const symbol = "LUX-LUSD"
// A large EXACT base-unit size above float64's 2^53 exact ceiling — the regime
// that used to fork. The wire now carries this integer verbatim (no float64).
const bigSize = uint64(0x0020000000000001) // 2^53 + 1
priceUnits := dex.PriceInt(25) * dex.PriceInt(dex.PriceMultiplier)
// FRESH validator: rest the order and serialize the canonical rows.
obFresh := dex.NewOrderBook(symbol)
ord := &dex.Order{
ID: 0xABCDEF01,
Type: dex.Limit,
Side: dex.Sell,
PriceUnits: priceUnits,
Price: float64(int64(priceUnits)) / float64(dex.PriceMultiplier),
Size: float64(bigSize),
RemainingSize: float64(bigSize),
SizeUnits: new(big.Int).SetUint64(bigSize),
User: "victimmaker",
UserID: "victimmaker",
Symbol: symbol,
Timestamp: time.Unix(0, 1_000_000_000).UTC(),
Status: dex.Open,
}
if obFresh.ConsensusAddOrder(ord) == 0 {
t.Fatalf("fresh: order rejected")
}
rowsFresh := dex.BookToRows(obFresh)
// RESTARTED validator: rebuild from the SAME committed rows (VM.Initialize
// path), then re-serialize — exactly what the next block's root computation
// consumes.
obRestart := dex.RowsToBook(symbol, rowsFresh)
rowsRestart := dex.BookToRows(obRestart)
// Compute the ACTUAL consensus roots the VM commits (state.go).
sortRowsCanonical(rowsFresh)
sortRowsCanonical(rowsRestart)
bookFresh := bookRoot(rowsFresh)
bookRestart := bookRoot(rowsRestart)
var parent, tr, txr, ledger [Size]byte
execFresh := ComposeRoot(parent, bookFresh, tr, txr, ledger, 2)
execRestart := ComposeRoot(parent, bookRestart, tr, txr, ledger, 2)
if rowsFresh[0].Quantity != bigSize {
t.Fatalf("row did not commit the exact size: Quantity=%d want %d", rowsFresh[0].Quantity, bigSize)
}
if bookFresh != bookRestart || execFresh != execRestart {
t.Fatalf(`RESTART FORK REGRESSION (dchain root function):
bookRoot fresh=%x restart=%x
execRoot fresh=%x restart=%x
A restarted validator derives a DIFFERENT execution root than a non-restarted
one from the SAME committed order set — the exact-integer row round-trip broke.`,
bookFresh, bookRestart, execFresh, execRestart)
}
t.Logf("GUARD OK: bookRoot/execRoot byte-stable across restart for a 2^53+1 size (exact-integer wire)")
}
@@ -0,0 +1,93 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// redteam_restart_refutation_test.go — RED TEAM verification of the "restart forks
// execRoot" hypothesis at the REAL VM consensus boundary (not the persist-layer
// helper functions in isolation).
//
// The hypothesis (from the persist.go floatToFixedQty non-idempotence): a resting
// order with a fractional wire size whose round(size*1e8) drifts by 1 on the
// fixedQtyToFloat reload makes a RESTARTED validator derive a different bookRoot /
// execRoot than a non-restarted one, forking the set.
//
// This test REFUTES that at the integration level. Block.execute rebuilds every
// touched market's book FROM THE COMMITTED DB ROWS via rebuildBookFromDB on EVERY
// block (block.go: "the SAME function used at startup ... and during execution"),
// on every node, restart or not. So the in-RAM wire float only reaches bookRoot in
// the order's PLACE block (which all nodes process from identical tx bytes); every
// subsequent block re-derives the same reloaded float on all nodes. A restarted
// validator therefore VERIFIES a continuing proposer's post-place block cleanly —
// no root divergence, no fork.
package dchain
import (
"context"
"math"
"testing"
"github.com/luxfi/database/memdb"
"github.com/luxfi/dex/pkg/zapwire"
)
func TestRedteam_RestartDoesNotForkExecRoot(t *testing.T) {
db := memdb.New()
vm1, _ := newTestVM(t, db) // the CONTINUING (never-restarted) proposer
defer vm1.Shutdown(context.Background())
const seller = "seller"
pool := [32]byte{0xf0, 0x2c} // "fork?"
// The exact wire size whose 1e8-fixed image is non-idempotent under reload
// (Q=4159461352541877 -> reload -> Q'=4159461352541878). If ANYTHING forks on
// restart, this size triggers it.
wireSize := math.Float64frombits(0x4183d577ac340ec1) // ~41,594,613.525419
// Fund + open market + rest a SELL of the fractional size (locks base=uint64(size)).
addBlock(t, vm1,
depositTx(t, seller, assetLUX, 50_000_000),
openMarketTx(t, pool, assetLUX, assetLUSD),
)
addBlock(t, vm1, placePoolTx(t, pool, zapwire.SideSell, 2.5, wireSize, seller))
rootAtPlace := vm1.lastRoot // committed accepted root through the place block (height Hp)
// RESTART: a fresh VM re-Initializes over the SAME committed db. It rebuilds
// every book from the durable rows (RowToOrder -> reloaded float S').
vm2, _ := newTestVM(t, db)
defer vm2.Shutdown(context.Background())
// A restart must preserve the accepted execution root exactly.
if vm2.lastRoot != rootAtPlace {
t.Fatalf("RESTART CHANGED ACCEPTED ROOT: continuing=%x restarted=%x", rootAtPlace[:8], vm2.lastRoot[:8])
}
// The CONTINUING proposer (vm1) builds height Hp+1: a benign second place in the
// same market (touches the market => bookRoot includes the resting fractional
// SELL, re-derived from the committed row). Build WITHOUT accepting so the shared
// db stays at Hp for the restarted verifier.
ctx := context.Background()
vm1.mempool.Add(placePoolTx(t, pool, zapwire.SideBuy, 1.0, 1.0, seller))
built, err := vm1.BuildBlock(ctx)
if err != nil {
t.Fatalf("continuing proposer BuildBlock: %v", err)
}
proposerRoot := built.(*Block).execRoot
// The RESTARTED validator (vm2) independently re-derives and verifies the SAME
// block bytes. Verify recomputes the execRoot from vm2's restarted view and
// checks it against the proposer's claim (block.go:188 result.root != execRoot).
reparsed, err := vm2.ParseBlock(ctx, built.Bytes())
if err != nil {
t.Fatalf("restarted validator ParseBlock: %v", err)
}
if err := reparsed.Verify(ctx); err != nil {
t.Fatalf("CONSENSUS FORK CONFIRMED: restarted validator REJECTED the continuing proposer's "+
"post-place block (root divergence): %v", err)
}
if got := reparsed.(*Block).execRoot; got != proposerRoot {
t.Fatalf("root divergence continuing=%x restarted=%x", proposerRoot[:8], got[:8])
}
t.Logf("REFUTED: restarted validator re-derived the identical execRoot %x for the post-place "+
"block and Verify passed — no fork. execute() rebuilds the book from committed DB rows every "+
"block, so the wire-float only reaches the root in the place block (processed identically by all).",
proposerRoot[:8])
}
@@ -0,0 +1,184 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// redteam_selfcross_interleave_test.go — RED TEAM. Attacks the recently-fixed
// self-trade-prevented maker removal (commit 16c2b00) for a DOUBLE-RELEASE of the
// per-order reserve: the self-cross path releases a maker's reserve (locked ->
// available) via releaseOrderReserve, and a TxCancel of the SAME order id releases
// it via the same primitive. If both could fire for one order, the owner would
// gain its reserve TWICE — a mint. This interleaves self-cross + external cross +
// explicit cancel + withdraw, within one block and across blocks, in both orders,
// and asserts conservation (no mint) after every step.
package dchain
import (
"context"
"testing"
"github.com/luxfi/database/memdb"
"github.com/luxfi/dex/pkg/zapwire"
)
// TestRedteam_SelfCrossThenCancel_NoDoubleRelease drives the exact double-release
// shape: rest a SELL (locks base), self-cross it away (release #1), then explicitly
// cancel the now-dead id (attempted release #2). A double release would push the
// owner's base ABOVE its deposit.
func TestRedteam_SelfCrossThenCancel_NoDoubleRelease(t *testing.T) {
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(context.Background())
const alice = "alice"
pool := [32]byte{0x5e, 0x1f, 0x99}
accts := []string{alice}
addBlock(t, vm,
depositTx(t, alice, assetLUX, 100),
depositTx(t, alice, assetLUSD, 1000),
openMarketTx(t, pool, assetLUX, assetLUSD),
)
assertConserved2(t, vm, "fund", accts, assetLUX, assetLUSD, 100, 1000)
// Alice rests SELL 20 LUX @ 5 (locks 20 base).
sBlk := addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, 5.0, 20.0, alice))
sID := blockDeterministicID(sBlk.height, 0)
assertBalance(t, vm, alice, assetLUX, 80, 20)
// Alice submits BUY 20 @ 6 — self-crosses her own SELL. The self-maker is
// cancelled: 20 base released locked->available (RELEASE #1). The BUY locks
// ceil(20*6)=120 quote, finds only its own sell => no fill => full refund.
scBlk := addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, 6.0, 20.0, alice))
if n := len(outcomeForTx(scBlk, scBlk.txs[0]).fills); n != 0 {
t.Fatalf("self-cross produced %d fills (must be 0)", n)
}
assertBalance(t, vm, alice, assetLUX, 100, 0) // base fully released
assertBalance(t, vm, alice, assetLUSD, 1000, 0) // quote lock fully refunded
assertConserved2(t, vm, "after self-cross", accts, assetLUX, assetLUSD, 100, 1000)
// ATTACK: explicitly cancel the now-dead order id (attempted RELEASE #2). If the
// reserve were released again, alice base would jump to 120 (mint).
addBlock(t, vm, cancelPoolTx(t, pool, sID, alice))
assertBalance(t, vm, alice, assetLUX, 100, 0)
assertConserved2(t, vm, "after double-cancel attempt", accts, assetLUX, assetLUSD, 100, 1000)
// Withdraw everything: realized must be exactly the deposit, never more.
_, wo := addBlockOutcomes(t, vm, withdrawTx(t, alice, assetLUX, 1_000_000))
if got := withdrawRealizedOf(wo, TxWithdraw); got != 100 {
t.Fatalf("MINT via double-release: withdrew %d base of a 100 deposit", got)
}
}
// TestRedteam_SelfCrossAndCancelSameBlock exercises the double-release across BOTH
// intra-block orderings: (a) cancel BEFORE the self-crossing submit, (b) submit
// BEFORE the cancel — in a single block each. Conservation must hold regardless of
// the order the two reserve-touching txs execute in.
func TestRedteam_SelfCrossAndCancelSameBlock(t *testing.T) {
run := func(t *testing.T, cancelFirst bool) {
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(context.Background())
const alice = "alice"
pool := [32]byte{0x5e, 0x1f, 0xab}
accts := []string{alice}
addBlock(t, vm,
depositTx(t, alice, assetLUX, 100),
depositTx(t, alice, assetLUSD, 1000),
openMarketTx(t, pool, assetLUX, assetLUSD),
)
sBlk := addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, 5.0, 20.0, alice))
sID := blockDeterministicID(sBlk.height, 0)
assertBalance(t, vm, alice, assetLUX, 80, 20)
submit := submitPoolTx(t, pool, zapwire.SideBuy, false, 6.0, 20.0, alice)
cancel := cancelPoolTx(t, pool, sID, alice)
if cancelFirst {
addBlock(t, vm, cancel, submit)
} else {
addBlock(t, vm, submit, cancel)
}
ba, bl, _ := vm.Balance(wireUser(t, alice), assetLUX)
qa, ql, _ := vm.Balance(wireUser(t, alice), assetLUSD)
t.Logf("cancelFirst=%v post-interleave: base(avail=%d locked=%d) quote(avail=%d locked=%d)", cancelFirst, ba, bl, qa, ql)
// Conservation (no mint) must hold either way.
assertConserved2(t, vm, "same-block interleave", accts, assetLUX, assetLUSD, 100, 1000)
_, woB := addBlockOutcomes(t, vm, withdrawTx(t, alice, assetLUX, 1_000_000))
_, woQ := addBlockOutcomes(t, vm, withdrawTx(t, alice, assetLUSD, 1_000_000))
gotB := withdrawRealizedOf(woB, TxWithdraw)
gotQ := withdrawRealizedOf(woQ, TxWithdraw)
t.Logf("cancelFirst=%v drained base=%d/100 quote=%d/1000", cancelFirst, gotB, gotQ)
if gotB > 100 || gotQ > 1000 {
t.Fatalf("MINT (cancelFirst=%v): drained base=%d quote=%d", cancelFirst, gotB, gotQ)
}
if gotB < 100 {
t.Logf("STUCK FUNDS (cancelFirst=%v): only %d/100 base drainable — %d base frozen", cancelFirst, gotB, 100-gotB)
}
}
t.Run("cancel_before_submit", func(t *testing.T) { run(t, true) })
t.Run("submit_before_cancel", func(t *testing.T) { run(t, false) })
}
// TestRedteam_SelfCrossPlusExternalCross_Conservation is the full task shape:
// interleave a self-cross with a REAL external cross in one submit, plus a resting
// buy the same account cancels, then withdraw. If the self-cancel reserve or the
// external maker reserve is mis-accounted, conservation breaks or the withdraw
// mints.
func TestRedteam_SelfCrossPlusExternalCross_Conservation(t *testing.T) {
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(context.Background())
const (
alice = "alice"
bob = "bob"
)
pool := [32]byte{0x5e, 0x1f, 0xcc}
accts := []string{alice, bob}
// alice: base+quote. bob: base to sell.
addBlock(t, vm,
depositTx(t, alice, assetLUX, 100),
depositTx(t, alice, assetLUSD, 1000),
depositTx(t, bob, assetLUX, 100),
openMarketTx(t, pool, assetLUX, assetLUSD),
)
assertConserved2(t, vm, "fund", accts, assetLUX, assetLUSD, 200, 1000)
// bob rests SELL 10 @ 5 (external liquidity, locks 10 base).
addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, bob))
// alice rests SELL 20 @ 5 (her OWN liquidity she will self-cross, locks 20 base).
aSell := addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, 5.0, 20.0, alice))
aSellID := blockDeterministicID(aSell.height, 0)
// alice rests BUY 5 @ 4 (resting bid she'll later cancel, locks ceil(20)=20 quote).
aBid := addBlock(t, vm, placePoolTx(t, pool, zapwire.SideBuy, 4.0, 5.0, alice))
aBidID := blockDeterministicID(aBid.height, 0)
assertConserved2(t, vm, "resting", accts, assetLUX, assetLUSD, 200, 1000)
// alice submits BUY 25 @ 6: crosses bob's 10 @ 5 (external fill) AND self-crosses
// her own 20 @ 5 (self-cancel, no fill). Locks ceil(25*6)=150 quote up front.
scBlk := addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, 6.0, 25.0, alice))
fills := outcomeForTx(scBlk, scBlk.txs[0]).fills
t.Logf("submit fills=%d (expect 1 external fill against bob, 0 self)", len(fills))
assertConserved2(t, vm, "after self+external cross", accts, assetLUX, assetLUSD, 200, 1000)
// alice cancels her resting bid (release its 20 quote) and the dead self-crossed
// sell id (must be a no-op — already released by the self-cross).
addBlock(t, vm, cancelPoolTx(t, pool, aBidID, alice))
addBlock(t, vm, cancelPoolTx(t, pool, aSellID, alice))
assertConserved2(t, vm, "after cancels", accts, assetLUX, assetLUSD, 200, 1000)
// Drain both accounts fully; total realized across both assets must never exceed
// what was deposited (no mint anywhere in the interleave).
_, woA1 := addBlockOutcomes(t, vm, withdrawTx(t, alice, assetLUX, 1_000_000))
_, woA2 := addBlockOutcomes(t, vm, withdrawTx(t, alice, assetLUSD, 1_000_000))
_, woB1 := addBlockOutcomes(t, vm, withdrawTx(t, bob, assetLUX, 1_000_000))
_, woB2 := addBlockOutcomes(t, vm, withdrawTx(t, bob, assetLUSD, 1_000_000))
totBase := withdrawRealizedOf(woA1, TxWithdraw) + withdrawRealizedOf(woB1, TxWithdraw)
totQuote := withdrawRealizedOf(woA2, TxWithdraw) + withdrawRealizedOf(woB2, TxWithdraw)
// Any residual still LOCKED (e.g. the ceil/floor freeze) legitimately reduces the
// drainable total; it can never EXCEED the deposit. > is a mint.
if totBase > 200 {
t.Fatalf("MINT: drained %d base of 200 deposited", totBase)
}
if totQuote > 1000 {
t.Fatalf("MINT: drained %d quote of 1000 deposited", totQuote)
}
t.Logf("drained base=%d/200 quote=%d/1000 (<= deposit => no mint)", totBase, totQuote)
}
@@ -0,0 +1,77 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// redteam_unbacked_deposit_test.go — RED TEAM (infinite money). The D-Chain custody
// ledger has NO in-protocol solvency invariant: TxDeposit (dex_deposit) credits the
// signer's OWN account an ARBITRARY amount, authorized ONLY by that account's own
// signature over (user, asset, amount, ref) + a nonzero ref. There is NO proof that
// value was escrowed on the C-Chain (the backed funding path is TxImport, which
// consumes a real C->D intent object). The public RPC surface (RegisterDEX /
// CreateHandlers / ingest.go httpHandlers) exposes dex_deposit at
// /ext/bc/<chainID>/dex/dex_deposit — the SAME public route group as the mandatory
// dex_place / dex_submit trading methods. So any principal who can reach the trading
// RPC can MINT unlimited balance and drain the vault via withdraw.
package dchain
import (
"context"
"testing"
"github.com/luxfi/database/memdb"
)
func TestRedteam_UnbackedSelfSignedDepositMints(t *testing.T) {
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(context.Background())
const attacker = "attacker"
// A colossal, entirely unbacked amount. No prior deposit, no TxImport, no C-side
// lock — the attacker has escrowed NOTHING.
const minted uint64 = 500_000_000_000
// Sanity: attacker starts with zero of the asset.
if a, l, _ := vm.Balance(wireUser(t, attacker), assetLUX); a != 0 || l != 0 {
t.Fatalf("precondition: attacker not empty (avail=%d locked=%d)", a, l)
}
// ONE self-signed deposit tx (attacker's first op, nonce 0). depositTx builds
// exactly the frame class a client posts to dex_deposit.
tx := depositTx(t, attacker, assetLUX, minted)
// (1) PUBLIC-BOUNDARY REACHABILITY: reconstruct the EXACT client wire frame the
// caller POSTs to dex_deposit (body ‖ auth envelope) and run the boundary check
// submitTx/handleDeposit apply at the PUBLIC RPC edge. It PASSES (signer ==
// credited account), so the public endpoint admits an unbacked self-deposit.
frame := append(append([]byte{}, tx.Body...), tx.Auth.encode()...)
parsed, err := parseTxFrame(TxDeposit, frame)
if err != nil {
t.Fatalf("frame parse: %v", err)
}
if _, verr := verifyTxSignature(parsed); verr != nil {
t.Fatalf("public dex_deposit boundary REJECTED the self-signed frame: %v", verr)
}
// (2) CONSENSUS ACCEPTANCE: the authoritative gate (authorizeTx -> verifyTxSignature
// -> nonce) accepts the SAME tx — no backing is ever checked. Drive it through
// BuildBlock -> Verify -> Accept.
addBlock(t, vm, tx)
avail, _, _ := vm.Balance(wireUser(t, attacker), assetLUX)
if avail != minted {
t.Fatalf("expected minted balance %d, got %d", minted, avail)
}
t.Logf("MINTED %d %x from a self-signature — attacker escrowed nothing", minted, assetLUX[:4])
// (3) DRAIN: the minted balance is fully withdrawable — the proxy/vault would
// export `minted` REAL tokens for value that was never deposited. This is the
// solvency break: exports exceed imports by the entire minted amount.
_, wo := addBlockOutcomes(t, vm, withdrawTx(t, attacker, assetLUX, minted))
realized := withdrawRealizedOf(wo, TxWithdraw)
if realized != minted {
t.Fatalf("expected to drain %d, realized %d", minted, realized)
}
t.Errorf("RED FINDING (INFINITE MONEY): a self-signed dex_deposit minted %d unbacked units "+
"(no TxImport, no C-side lock) and withdrew all %d — the custody ledger has no in-protocol "+
"backing invariant; solvency depends ENTIRELY on dex_deposit being unreachable by attackers, "+
"but ingest.go exposes it on the same public route as dex_place/dex_submit", minted, realized)
}
+8 -2
View File
@@ -43,9 +43,15 @@ type txOutcome struct {
func toWireFills(fills []dex.Trade, takerSide dex.Side) []zapwire.Fill {
out := make([]zapwire.Fill, len(fills))
for i, f := range fills {
// Exact integers: price = PriceInt (×1e8), size = atomic base units. The
// authoritative integer lane (PriceUnits/BaseUnits), never the float view.
var size uint64
if f.BaseUnits != nil {
size = f.BaseUnits.Uint64()
}
out[i] = zapwire.Fill{
Price: f.Price,
Size: f.Size,
Price: uint64(f.PriceUnits),
Size: size,
TakerSide: uint8(takerSide),
}
}
+28 -47
View File
@@ -66,42 +66,24 @@ func quoteUnitsCeil(baseUnits *big.Int, priceInt dex.PriceInt) uint64 {
return q.Uint64()
}
// sizeToUnits converts a wire size float to atomic base units. On the consensus
// path sizes are whole atomic-unit counts (the venue quotes integer lot sizes);
// the conversion truncates toward zero, so a fractional dust sub-unit is never
// minted into the lock. It is deterministic over the deterministic float input.
func sizeToUnits(size float64) uint64 {
if !(size > 0) {
return 0
}
u := uint64(size)
return u
}
// priceToInt converts a wire price float to the fixed-point PriceInt grid the
// matcher keys levels by (round(price * PriceMultiplier)), so the lock's price
// and the matcher's crossing price are the SAME integer.
func priceToInt(price float64) dex.PriceInt {
if !(price > 0) {
return 0
}
return dex.PriceInt(price * dex.PriceMultiplier)
}
// orderLock returns the (asset, amount) a place/limit-submit of (side, price,
// size) on a market with (base, quote) assets must lock. A buy locks quote =
// ceil(size * price); a sell locks base = size. amount 0 means "nothing to lock"
// (a degenerate order the matcher will reject anyway). asset is the FULL 32-byte
// id (base or quote), so the lock keys the SAME ledger row a deposit credited.
func orderLock(side uint8, price float64, size float64, base, quote [32]byte) (asset [32]byte, amount uint64) {
units := sizeToUnits(size)
if units == 0 {
// orderLock returns the (asset, amount) a place/limit-submit of (side, priceUnits,
// sizeUnits) on a market with (base, quote) assets must lock. Both inputs are the
// wire's EXACT integers: sizeUnits is atomic base units, priceUnits is the PriceInt
// grid (price × 1e8). A buy locks quote = ceil(size * price / 1e8); a sell locks
// base = size. amount 0 means "nothing to lock" (a degenerate order). asset is the
// FULL 32-byte id (base or quote), so the lock keys the SAME ledger row a deposit
// credited. No float64 touches this path — the lock is byte-identical on every arch.
func orderLock(side uint8, priceUnits uint64, sizeUnits uint64, base, quote [32]byte) (asset [32]byte, amount uint64) {
if sizeUnits == 0 {
return [32]byte{}, 0
}
if side == sideBuy {
return quote, quoteUnitsCeil(new(big.Int).SetUint64(units), priceToInt(price))
if priceUnits == 0 || priceUnits > uint64(1)<<63-1 {
return [32]byte{}, 0
}
return quote, quoteUnitsCeil(new(big.Int).SetUint64(sizeUnits), dex.PriceInt(priceUnits))
}
return base, units
return base, sizeUnits
}
// sideBuy / sideSell are the d-chain's local side constants (== zapwire/lx).
@@ -126,24 +108,23 @@ type marketConstraint struct {
base, quote [32]byte
}
// floorsToZeroLock reports whether an EXECUTABLE order (size>0, and for a limit
// price>0) on this market would lock ZERO — the zero-notional reject condition.
// It is exactly the negation of "orderLock yields a positive amount":
// floorsToZeroLock reports whether an EXECUTABLE order (sizeUnits>0) on this market
// would lock ZERO — the zero-notional reject condition. It is exactly "orderLock
// yields a zero amount":
//
// BUY : size>0 && price>0 && ceil(size*price) == 0 (sub-tick price)
// SELL : size>0 && size(base units) == 0 (sub-unit size)
// BUY : priceUnits==0 (price 0) => quote lock ceil(size*price/1e8) == 0
// SELL : sizeUnits==0 (handled below)
//
// A non-executable order (size<=0, or a limit with price<=0) is NOT this hazard —
// the matcher rejects it earlier as malformed; floorsToZeroLock returns false so
// the named dust reject is reserved for the precise "executable yet free" case.
func (mc marketConstraint) floorsToZeroLock(side uint8, price, size float64) bool {
if !(size > 0) {
return false // not executable (zero/negative size) — handled as malformed, not dust
// Because the wire now carries the EXACT PriceInt, the smallest positive price is
// priceUnits==1 (=1e-8), and ceil(size*1/1e8) >= 1 for any size>=1 — so a positive
// price can NEVER floor a buy to zero. Only priceUnits==0 does, which this gate
// catches (rejected with ErrDustPriceOrZeroLock). A zero size is not executable
// and is handled by the matcher's malformed reject, so it is excluded here.
func (mc marketConstraint) floorsToZeroLock(side uint8, priceUnits, sizeUnits uint64) bool {
if sizeUnits == 0 {
return false // not executable (zero size) — handled as malformed, not dust
}
if side == sideBuy && !(price > 0) {
return false // a limit buy needs a price; price<=0 is malformed, not dust
}
_, lockAmt := orderLock(side, price, size, mc.base, mc.quote)
_, lockAmt := orderLock(side, priceUnits, sizeUnits, mc.base, mc.quote)
return lockAmt == 0
}
+3 -3
View File
@@ -102,14 +102,14 @@ func TestLedgerUserKeyNoCollision(t *testing.T) {
// overspends the lock (the affordability invariant that makes the custody ledger
// conserving without matcher surgery).
func TestQuoteUnitsCeilCoversFloor(t *testing.T) {
// Exact PriceInt grid values (the wire's price ×1e8): 0.5, 1, 1.5, 5, 101.25, 3.333333.
for _, base := range []uint64{1, 3, 7, 100, 999} {
for _, price := range []float64{0.5, 1, 1.5, 5, 101.25, 3.333333} {
pi := priceToInt(price)
for _, pi := range []dex.PriceInt{50_000_000, 100_000_000, 150_000_000, 500_000_000, 10_125_000_000, 333_333_300} {
lock := quoteUnitsCeil(new(big.Int).SetUint64(base), pi)
// matcher floor: floor(base*priceInt/PriceMultiplier)
floor := base * uint64(pi) / uint64(dex.PriceMultiplier)
if lock < floor {
t.Errorf("ceil lock %d < floor spend %d for base=%d price=%v", lock, floor, base, price)
t.Errorf("ceil lock %d < floor spend %d for base=%d priceInt=%d", lock, floor, base, pi)
}
}
}
+42 -12
View File
@@ -856,20 +856,48 @@ func tradeLeafDigest(t dex.DEXTrade, i uint32) [Size]byte {
return hash.ComputeKeccak256Array(img, idx[:])
}
// ComposeRoot returns the d-chain execution root from the parent root, the three
// ComposeRoot returns the d-chain execution root from the parent root, the four
// sub-roots, and the height — the SAME fixed-shape un-tagged keccak256
// composition xvmroot.Compose uses (this is NOT a Merkle node):
//
// dex_execution_root = keccak256(
// parent ‖ bookRoot ‖ tradeRoot ‖ txRoot ‖ height_u64_le )
// parent ‖ bookRoot ‖ tradeRoot ‖ txRoot ‖ ledgerRoot ‖ height_u64_le )
//
// Chaining the parent root makes the root a commitment to the whole accepted
// history, not just the current snapshot — a validator that forked at any past
// block produces a different root here.
func ComposeRoot(parent, book, trade, txr [Size]byte, height uint64) [Size]byte {
// The ledgerRoot term BINDS THE CUSTODY BALANCES (balance:/locked:/orderasset:)
// into consensus. Without it, two validators could agree on the book/trade/tx
// roots while holding DIFFERENT money — a settle divergence would be invisible to
// consensus and finalize, and withdrawals would then realize inconsistently. A
// settlement VM MUST commit the money it moves. Chaining the parent root makes the
// root a commitment to the whole accepted history, not just the current snapshot.
func ComposeRoot(parent, book, trade, txr, ledger [Size]byte, height uint64) [Size]byte {
var h [8]byte
binary.LittleEndian.PutUint64(h[:], height)
return hash.ComputeKeccak256Array(parent[:], book[:], trade[:], txr[:], h[:])
return hash.ComputeKeccak256Array(parent[:], book[:], trade[:], txr[:], ledger[:], h[:])
}
// ledgerRoot is the RFC-6962 tagged Merkle root over the custody ledger: every
// balance:, locked:, and per-order orderasset: reserve row, folded in the DB's
// deterministic key order (each prefix scanned in ascending key order; the three
// prefixes in a fixed sequence). The leaf binds the full key AND the value, so any
// divergence in an account's available/locked balance — or a per-order reserve —
// changes the root. This is what makes a silent cross-arch / mis-settle money
// desync impossible: it can no longer finalize under an agreed execution root.
func ledgerRoot(db database.Iteratee) ([Size]byte, error) {
var leaves [][Size]byte
for _, prefix := range []string{prefixBalance, prefixLocked, prefixOrderLock} {
it := db.NewIteratorWithPrefix([]byte(prefix))
for it.Next() {
// leaf = keccak256(key ‖ value); the key already carries the prefix, so
// leaves from different prefixes never collide.
leaves = append(leaves, hash.ComputeKeccak256Array(it.Key(), it.Value()))
}
err := it.Error()
it.Release()
if err != nil {
return [Size]byte{}, err
}
}
return merkle.Root(leaves), nil
}
// sortRowsCanonical imposes a total, deterministic order on resting rows that may
@@ -904,13 +932,15 @@ func sortRowsCanonical(rows []dex.DEXOrder) {
}
// ExecutionRoot computes the full d-chain execution root over a block's resulting
// state: the canonical resting rows (book), the block's fills (trades), and the
// block's transactions (txs), composed with the parent root and height. Returns
// the execution root and the three sub-roots (for receipts / debugging).
func ExecutionRoot(parent [Size]byte, rows []dex.DEXOrder, trades []dex.DEXTrade, txs []*Tx, height uint64) (root, book, trade, txr [Size]byte) {
// state: the canonical resting rows (book), the block's fills (trades), the block's
// transactions (txs), and the custody LEDGER commitment (ledger), composed with the
// parent root and height. ledger is the [Size]byte ledgerRoot the caller computed
// over the committed balance state (or the zero value where no ledger applies, e.g.
// the genesis root). Returns the execution root and the three event/state sub-roots.
func ExecutionRoot(parent [Size]byte, rows []dex.DEXOrder, trades []dex.DEXTrade, txs []*Tx, ledger [Size]byte, height uint64) (root, book, trade, txr [Size]byte) {
book = bookRoot(rows)
trade = tradeRoot(trades)
txr = txRoot(txs)
root = ComposeRoot(parent, book, trade, txr, height)
root = ComposeRoot(parent, book, trade, txr, ledger, height)
return root, book, trade, txr
}
+8 -8
View File
@@ -23,8 +23,8 @@ func TestExecutionRootDeterministic(t *testing.T) {
fills, rows := applyAll(t, 10, ts, txs)
var parent [Size]byte
r1, b1, tr1, x1 := ExecutionRoot(parent, rows, fills, txs, 10)
r2, b2, tr2, x2 := ExecutionRoot(parent, rows, fills, txs, 10)
r1, b1, tr1, x1 := ExecutionRoot(parent, rows, fills, txs, [Size]byte{}, 10)
r2, b2, tr2, x2 := ExecutionRoot(parent, rows, fills, txs, [Size]byte{}, 10)
if r1 != r2 || b1 != b2 || tr1 != tr2 || x1 != x2 {
t.Fatal("ExecutionRoot not deterministic across two computations")
@@ -32,13 +32,13 @@ func TestExecutionRootDeterministic(t *testing.T) {
// The root chains the parent: a different parent -> different root.
parent2 := [Size]byte{0x01}
r3, _, _, _ := ExecutionRoot(parent2, rows, fills, txs, 10)
r3, _, _, _ := ExecutionRoot(parent2, rows, fills, txs, [Size]byte{}, 10)
if r3 == r1 {
t.Error("root did not change with a different parent (no history binding)")
}
// The root binds the height: a different height -> different root.
r4, _, _, _ := ExecutionRoot(parent, rows, fills, txs, 11)
r4, _, _, _ := ExecutionRoot(parent, rows, fills, txs, [Size]byte{}, 11)
if r4 == r1 {
t.Error("root did not change with a different height")
}
@@ -52,12 +52,12 @@ func TestExecutionRootChangesWithState(t *testing.T) {
txsA := []*Tx{placeTx(t, zapwire.SideSell, 101.0, 5.0, "maker-a")}
_, rowsA := applyAll(t, 1, ts, txsA)
rootA, _, _, _ := ExecutionRoot(parent, rowsA, nil, txsA, 1)
rootA, _, _, _ := ExecutionRoot(parent, rowsA, nil, txsA, [Size]byte{}, 1)
// Different resting price -> different book root -> different exec root.
txsB := []*Tx{placeTx(t, zapwire.SideSell, 102.0, 5.0, "maker-a")}
_, rowsB := applyAll(t, 1, ts, txsB)
rootB, _, _, _ := ExecutionRoot(parent, rowsB, nil, txsB, 1)
rootB, _, _, _ := ExecutionRoot(parent, rowsB, nil, txsB, [Size]byte{}, 1)
if rootA == rootB {
t.Error("execution root identical for different book state")
@@ -68,8 +68,8 @@ func TestExecutionRootChangesWithState(t *testing.T) {
// the public ExecutionRoot path (no rows/trades/txs).
func TestEmptyRootsAreStable(t *testing.T) {
var parent [Size]byte
r1, b1, tr1, x1 := ExecutionRoot(parent, nil, nil, nil, 0)
r2, b2, tr2, x2 := ExecutionRoot(parent, nil, nil, nil, 0)
r1, b1, tr1, x1 := ExecutionRoot(parent, nil, nil, nil, [Size]byte{}, 0)
r2, b2, tr2, x2 := ExecutionRoot(parent, nil, nil, nil, [Size]byte{}, 0)
if r1 != r2 || b1 != b2 || tr1 != tr2 || x1 != x2 {
t.Fatal("empty roots not stable")
}
+2 -1
View File
@@ -214,7 +214,8 @@ func (vm *VM) Initialize(ctx context.Context, init block.Init) error {
// recovery (a DB written before head blocks were persisted).
func (vm *VM) genesisBlock() (*Block, [Size]byte) {
var parentRoot [Size]byte // zero
root, _, _, _ := ExecutionRoot(parentRoot, nil, nil, nil, 0)
var emptyLedger [Size]byte // genesis has no custody ledger yet
root, _, _, _ := ExecutionRoot(parentRoot, nil, nil, nil, emptyLedger, 0)
return newBlock(vm, genesisParent, 0, time.Unix(0, 0).UTC(), root, nil), root
}
+31 -2
View File
@@ -50,16 +50,45 @@ func ensureMarketTx(t *testing.T, pool [32]byte) *Tx {
return tx
}
// wirePriceUnits / wireSizeUnits convert a test's human price/size into the EXACT
// wire integers (price × 1e8 fixed-point, whole base units) — the SAME derivation
// the old float path applied downstream (int64(price*1e8), uint64(size)), so every
// balance the conservation/byzantine tests assert is unchanged. The conversion
// happens ONCE here (the client/proposer), never per-validator on the value path.
func wirePriceUnits(price float64) uint64 {
if !(price > 0) {
return 0
}
return uint64(price * float64(zapwire.PriceScale))
}
func wireSizeUnits(size float64) uint64 {
if !(size > 0) {
return 0
}
return uint64(size)
}
// encPlace / encSubmit are float-taking convenience wrappers over the exact-integer
// wire codec: they scale a human price to the ×1e8 fixed-point wire integer and a
// human size to whole base units (the SAME derivation the consensus path applies),
// so existing tests keep their human-readable literals while the wire stays integer.
func encPlace(pool [32]byte, side uint8, price, size float64, user string) []byte {
return zapwire.EncodePlace(pool, side, wirePriceUnits(price), wireSizeUnits(size), user)
}
func encSubmit(pool [32]byte, side uint8, isMarket bool, price, size float64, user string) []byte {
return zapwire.EncodeSubmit(pool, side, isMarket, wirePriceUnits(price), wireSizeUnits(size), user)
}
func placePoolTx(t *testing.T, pool [32]byte, side uint8, price, size float64, user string) *Tx {
t.Helper()
a := acctFor(t, user)
return a.signed(t, TxPlace, zapwire.EncodePlace(pool, side, price, size, a.user))
return a.signed(t, TxPlace, zapwire.EncodePlace(pool, side, wirePriceUnits(price), wireSizeUnits(size), a.user))
}
func submitPoolTx(t *testing.T, pool [32]byte, side uint8, isMarket bool, price, size float64, user string) *Tx {
t.Helper()
a := acctFor(t, user)
return a.signed(t, TxSubmit, zapwire.EncodeSubmit(pool, side, isMarket, price, size, a.user))
return a.signed(t, TxSubmit, zapwire.EncodeSubmit(pool, side, isMarket, wirePriceUnits(price), wireSizeUnits(size), a.user))
}
// buildVerifyAccept runs the proposer+validator path for the txs currently in the
+3 -3
View File
@@ -93,8 +93,8 @@ func TestZAPIngestCrossingOrdersMatchInBlock(t *testing.T) {
if len(fills) != 1 {
t.Fatalf("submit over ZAP socket: got %d fills, want 1", len(fills))
}
if fills[0].Price != 5.0 || fills[0].Size != 10.0 {
t.Fatalf("fill = px %.4f sz %.4f, want px 5 sz 10", fills[0].Price, fills[0].Size)
if fills[0].Price != 5*uint64(zapwire.PriceScale) || fills[0].Size != 10 {
t.Fatalf("fill = px %d sz %d, want px 5e8 sz 10", fills[0].Price, fills[0].Size)
}
// The fill is committed D-Chain state: the VM's accepted height advanced past the
@@ -149,7 +149,7 @@ func TestZAPIngestSharesOneCoreWithHTTP(t *testing.T) {
// Taker crosses through the HTTP COMPAT handler against the SAME book.
resp := postFrame(t, httpBase, zapwire.MethodSubmit,
signedPayload(t, "core-taker", TxSubmit, zapwire.EncodeSubmit(pool, zapwire.SideBuy, false, 5.0, 10.0, wireUser(t, "core-taker"))))
signedPayload(t, "core-taker", TxSubmit, encSubmit(pool, zapwire.SideBuy, false, 5.0, 10.0, wireUser(t, "core-taker"))))
httpFills, err := zapwire.DecodeFills(resp)
if err != nil {
t.Fatalf("decode HTTP submit fills: %v", err)
+7 -1
View File
@@ -78,7 +78,13 @@ func (ob *OrderBook) addRestingLocked(order *Order) uint64 {
return 0
}
priceInt := PriceInt(order.Price * PriceMultiplier)
// Level key: use the EXACT integer price on the consensus path (PriceUnits set
// from the wire) so the resting level a maker keys into matches the integer the
// crossing loop compares — no float grid rounding. Legacy float orders fall back.
priceInt := order.PriceUnits
if priceInt == 0 {
priceInt = PriceInt(order.Price * PriceMultiplier)
}
ob.mu.Lock()
defer ob.mu.Unlock()
+3 -2
View File
@@ -235,10 +235,11 @@ func sortShadowRows(rows []DEXOrder, takerSide Side) {
// matcher gates every fill on pricesCross, so a market taker is given the maximal
// price so it crosses every resting level — mirroring the rich market sweep.
func orderToFlatTaker(order *Order) DEXOrder {
qty, _ := orderRowUnits(order)
t := DEXOrder{
OrderID: order.ID,
UserID: userToUint64(takerUserStr(order)),
Quantity: floatToFixedQty(order.Size),
Quantity: qty,
Timestamp: uint64(order.Timestamp.UnixNano()),
Side: sideToDEX(order.Side),
Type: typeToDEX(order.Type),
@@ -248,7 +249,7 @@ func orderToFlatTaker(order *Order) DEXOrder {
if order.Type == Market {
t.Price = DEXPrice{Integer: ^uint64(0), Fraction: ^uint64(0)}
} else {
t.Price = floatToFixedPrice(order.Price)
t.Price = priceRowFor(orderPriceUnits(order))
}
t.ClearPadding()
return t
+26 -8
View File
@@ -751,13 +751,25 @@ func (ob *OrderBook) tryMatchImmediateLocked(order *Order) []Trade {
break
}
// Price check
// Price check. On the consensus path both orders carry the exact integer
// PriceUnits (the matcher's PriceInt grid), so cross on integers — no float
// tie where two distinct prices collapse to one float64. Legacy float-API
// orders (PriceUnits==0) keep the float comparison.
if order.Type == Limit {
if order.Side == Buy && order.Price < bestOrder.Price {
break
}
if order.Side == Sell && order.Price > bestOrder.Price {
break
if order.PriceUnits != 0 && bestOrder.PriceUnits != 0 {
if order.Side == Buy && order.PriceUnits < bestOrder.PriceUnits {
break
}
if order.Side == Sell && order.PriceUnits > bestOrder.PriceUnits {
break
}
} else {
if order.Side == Buy && order.Price < bestOrder.Price {
break
}
if order.Side == Sell && order.Price > bestOrder.Price {
break
}
}
}
@@ -834,10 +846,16 @@ func (ob *OrderBook) tryMatchImmediateLocked(order *Order) []Trade {
}
if tradeUnits != nil {
// Quote is an exact integer function of the matched base and the
// maker's resting price (its PriceInt key) — never of any float size.
priceInt := PriceInt(bestOrder.Price * PriceMultiplier)
// maker's resting price (its EXACT PriceInt) — never of any float. On
// the consensus path bestOrder.PriceUnits is authoritative; a legacy
// order without it falls back to the float grid.
priceInt := bestOrder.PriceUnits
if priceInt == 0 {
priceInt = PriceInt(bestOrder.Price * PriceMultiplier)
}
trade.BaseUnits = tradeUnits
trade.QuoteUnits = quoteUnitsFor(tradeUnits, priceInt)
trade.PriceUnits = priceInt
// Decrement the exact integer remainders so a multi-level / partial
// sweep stays integer-consistent across iterations.
takerUnits.Sub(takerUnits, tradeUnits)
+107 -83
View File
@@ -5,7 +5,7 @@ package dex
import (
"encoding/binary"
"math"
"math/big"
"sort"
"unsafe"
)
@@ -45,78 +45,39 @@ import (
// row at the GPU ABI's 8-byte user is the decomplected choice: the book row models
// matching, d-chain state models settlement identity.
// QuantityScale is the fixed-point scale for the integer base quantity stored in
// a DEXOrder row (Quantity / Remaining). A float64 size s is stored as
// round(s * 1e8); 1e8 matches PriceMultiplier so the integer domain is uniform
// across price and size. The matcher's resting quantities are always
// non-negative, so the unsigned domain is exact for any size representable in
// 1e8 units up to ~1.8e11 base units.
const QuantityScale = float64(PriceMultiplier) // 1e8
// EXACT-INTEGER PRICE/QUANTITY ENCODING (no float64 crosses into a persisted row).
//
// A DEXOrder row stores the price as its exact PriceInt (price × PriceMultiplier)
// in DEXPrice.Integer (Fraction unused, always 0), and the quantity as an exact
// atomic-base-unit count in DEXOrder.Quantity/Remaining. This is a pure integer
// image of the wire's exact integers, so a row rebuilt from disk is byte-identical
// to the live one — there is NO float64 round-trip that could drift by a ULP and
// fork a restarted validator. (The old round(size*1e8) / Q64.64-of-float64
// encoding was non-idempotent under the reload and forked on restart.)
//
// Ordering is preserved: DEXPrice comparison is (Integer, Fraction); with
// Fraction≡0 and Integer=PriceInt, prices order exactly as their PriceInt values.
// floatToFixedPrice converts a float64 price to the Q64.64 DEXPrice form
// deterministically. The integer part is the floor; the fractional part is the
// remaining mantissa scaled by 2^64 and rounded to nearest. This is a pure
// function of the IEEE-754 value: every architecture that agrees on the float
// bits (which they do for a value carried verbatim on the wire) computes the
// identical {Integer, Fraction}. Negative or non-finite inputs clamp to zero;
// the matcher rejects those upstream so they never reach a persisted row.
func floatToFixedPrice(price float64) DEXPrice {
if !(price > 0) { // also catches NaN
return DEXPrice{}
// priceRowFor encodes an exact PriceInt into the row's DEXPrice slot.
func priceRowFor(p PriceInt) DEXPrice {
if p < 0 {
p = 0
}
intPart := math.Floor(price)
if intPart > math.MaxUint64 {
// Saturate rather than wrap; safePriceToInt already bounds real prices
// far below this, so this is a defensive clamp, not a live path.
return DEXPrice{Integer: math.MaxUint64, Fraction: math.MaxUint64}
}
frac := price - intPart
// frac in [0,1); scale to the 64-bit fraction domain. math.Round gives the
// nearest integer; 2^64 is represented exactly as a float64.
fracUnits := math.Round(frac * 18446744073709551616.0) // 2^64
var fraction uint64
if fracUnits >= 18446744073709551616.0 {
// Rounding pushed it to a full unit: carry into the integer part.
return DEXPrice{Integer: uint64(intPart) + 1, Fraction: 0}
}
fraction = uint64(fracUnits)
return DEXPrice{Integer: uint64(intPart), Fraction: fraction}
return DEXPrice{Integer: uint64(p), Fraction: 0}
}
// Float converts a Q64.64 DEXPrice to float64 — the single inverse of the
// fixed-point price form, used to repopulate the in-RAM Order.Price on rebuild
// and to render a committed price for display/read APIs. The deterministic state
// (rows, merkle leaves) always uses the integer form, so this float projection
// never affects consensus.
// priceUnitsFromRow decodes the exact PriceInt a row committed.
func priceUnitsFromRow(d DEXPrice) PriceInt { return PriceInt(int64(d.Integer)) }
// Float renders a row DEXPrice as a float64 human/legacy price = PriceInt /
// PriceMultiplier. Display/read only; the deterministic state uses the integer.
func (p DEXPrice) Float() float64 {
return float64(p.Integer) + float64(p.Fraction)/18446744073709551616.0 // /2^64
return float64(int64(p.Integer)) / float64(PriceMultiplier)
}
// fixedPriceToFloat is the package-internal alias for DEXPrice.Float (one
// implementation; the existing call sites keep their name).
func fixedPriceToFloat(p DEXPrice) float64 { return p.Float() }
// FixedQtyToFloat converts an integer 1e8 fixed-unit base quantity back to
// float64 — the single inverse of floatToFixedQty, exported so read APIs render
// a committed quantity through the SAME conversion the rebuild path uses.
func FixedQtyToFloat(q uint64) float64 { return float64(q) / QuantityScale }
// floatToFixedQty converts a float64 base quantity to the integer 1e8 fixed-unit
// form stored in a row. Rounds to nearest; clamps a negative/NaN to zero.
func floatToFixedQty(qty float64) uint64 {
if !(qty > 0) {
return 0
}
scaled := math.Round(qty * QuantityScale)
if scaled >= math.MaxUint64 {
return math.MaxUint64
}
return uint64(scaled)
}
// fixedQtyToFloat is the package-internal alias for FixedQtyToFloat (one
// implementation; the existing call sites keep their name).
func fixedQtyToFloat(q uint64) float64 { return FixedQtyToFloat(q) }
// FixedQtyToFloat renders an exact base-unit count as float64 for read APIs.
// The quantity domain is exact integers; this projection is display-only.
func FixedQtyToFloat(q uint64) float64 { return float64(q) }
// userToUint64 folds a user identity string to the row's 8-byte UserID
// (big-endian over the leading 8 bytes, zero-padded). Deterministic and total.
@@ -206,28 +167,63 @@ func dexToStatus(s uint8) string {
}
}
// OrderToRow converts a resting Order to its canonical 64-byte DEXOrder row. The
// price/size are committed to the deterministic Q64.64 / 1e8-integer domain; the
// padding is zeroed so two equal orders always produce byte-identical rows.
func OrderToRow(o *Order) DEXOrder {
remaining := o.RemainingSize
if remaining == 0 && o.Size > 0 && o.Filled == 0 {
remaining = o.Size
// orderRowUnits returns the exact (quantity, remaining) atomic base-unit counts a
// row commits for an order. The authoritative source is the integer lane
// (SizeUnits/RemainingUnits, carried exactly from the wire); a legacy float-API
// order lacking the lane derives whole units from its float Size (truncating).
func orderRowUnits(o *Order) (qty, rem uint64) {
if o.SizeUnits != nil {
qty = o.SizeUnits.Uint64()
} else {
qty = SizeToUnits(o.Size)
}
switch {
case o.RemainingUnits != nil:
rem = o.RemainingUnits.Uint64()
case o.SizeUnits != nil:
// Fresh resting order: the matcher only populates RemainingUnits once the
// order crosses; until then the full size remains.
rem = qty
default:
rf := o.RemainingSize
if rf == 0 && o.Size > 0 && o.Filled == 0 {
rf = o.Size
}
rem = SizeToUnits(rf)
}
return
}
// orderPriceUnits returns the exact PriceInt a row commits for an order — the
// authoritative PriceUnits carried from the wire, or (legacy) the float grid.
func orderPriceUnits(o *Order) PriceInt {
if o.PriceUnits != 0 {
return o.PriceUnits
}
return PriceInt(o.Price * PriceMultiplier)
}
// OrderToRow converts a resting Order to its canonical 64-byte DEXOrder row. Price
// and quantity are committed as EXACT INTEGERS (PriceInt in DEXPrice.Integer,
// atomic base units in Quantity/Remaining) so the row is a byte-stable image of
// the wire's exact integers — a rebuilt row equals the live one (no restart fork).
// Padding is zeroed so two equal orders always produce byte-identical rows.
func OrderToRow(o *Order) DEXOrder {
user := o.User
if user == "" {
user = o.UserID
}
qty, rem := orderRowUnits(o)
row := DEXOrder{
OrderID: o.ID,
UserID: userToUint64(user),
Price: floatToFixedPrice(o.Price),
Quantity: floatToFixedQty(o.Size),
Remaining: floatToFixedQty(remaining),
Price: priceRowFor(orderPriceUnits(o)),
Quantity: qty,
Remaining: rem,
Timestamp: uint64(o.Timestamp.UnixNano()),
Side: sideToDEX(o.Side),
Type: typeToDEX(o.Type),
Status: statusToDEX(o.Status, remaining, o.Size),
Status: statusToDEX(o.Status, float64(rem), float64(qty)),
}
row.ClearPadding()
return row
@@ -240,18 +236,34 @@ func OrderToRow(o *Order) DEXOrder {
// stamp (price-time priority is preserved exactly across a restart).
func RowToOrder(row DEXOrder) *Order {
user := uint64ToUser(row.UserID)
return &Order{
priceUnits := priceUnitsFromRow(row.Price)
o := &Order{
ID: row.OrderID,
Type: dexToType(row.Type),
Side: dexToSide(row.Side),
Price: fixedPriceToFloat(row.Price),
Size: fixedQtyToFloat(row.Quantity),
RemainingSize: fixedQtyToFloat(row.Remaining),
PriceUnits: priceUnits,
Price: float64(int64(priceUnits)) / float64(PriceMultiplier),
Size: float64(row.Quantity),
RemainingSize: float64(row.Remaining),
Timestamp: nanosToTime(int64(row.Timestamp)),
User: user,
UserID: user,
Status: dexToStatus(row.Status),
}
// Restore the AUTHORITATIVE integer lane directly from the row (identity — no
// float64 round-trip), so a rebuilt maker settles and re-serializes byte-for-byte
// like the live one. This is what makes a restart deterministic.
o.SizeUnits = new(big.Int).SetUint64(row.Quantity)
o.RemainingUnits = new(big.Int).SetUint64(row.Remaining)
// Restore Filled = Quantity − Remaining so the matcher's fill/removal accounting
// (bestOrder.Filled >= bestOrder.Size) still recognizes a partially-filled maker
// as complete when its remaining reaches zero across a rebuild. Without this a
// rebuilt partial order's Filled resets to 0 and it can never be removed once its
// remaining is consumed (a zombie resting order + a stranded reserve).
if row.Quantity >= row.Remaining {
o.Filled = float64(row.Quantity - row.Remaining)
}
return o
}
// EncodeRow writes a DEXOrder as its 64 byte on-disk value. The bytes are the
@@ -301,12 +313,24 @@ func DecodeTrade(b []byte) (DEXTrade, bool) {
// taker submitted with (the resting maker is the opposite side); it is supplied
// by the caller because the in-RAM Trade does not always carry it.
func TradeToRow(t Trade, takerSide Side) DEXTrade {
// Exact-integer commit: PriceInt in DEXPrice.Integer, atomic base units in
// Quantity. Falls back to the float grid only for a legacy fill with no lane.
priceUnits := t.PriceUnits
if priceUnits == 0 {
priceUnits = PriceInt(t.Price * PriceMultiplier)
}
var qty uint64
if t.BaseUnits != nil {
qty = t.BaseUnits.Uint64()
} else {
qty = SizeToUnits(t.Size)
}
row := DEXTrade{
TradeID: t.ID,
MakerUserID: userToUint64(makerUser(t, takerSide)),
TakerUserID: userToUint64(takerUser(t, takerSide)),
Price: floatToFixedPrice(t.Price),
Quantity: floatToFixedQty(t.Size),
Price: priceRowFor(priceUnits),
Quantity: qty,
Timestamp: uint64(t.Timestamp.UnixNano()),
TakerSide: sideToDEX(takerSide),
}
+26 -37
View File
@@ -8,36 +8,23 @@ import (
"time"
)
// TestFixedPointRoundTrip proves the Q64.64 price and 1e8 quantity conversions
// are stable: encoding a float into the deterministic integer domain and back
// recovers the value to within one ULP of the fixed-point grid, AND — the part
// that matters for consensus — the integer form itself is identical for the same
// input every time (it is a pure function of the float bits).
func TestFixedPointRoundTrip(t *testing.T) {
prices := []float64{1, 100, 100.5, 0.00000001, 12345.6789, 999999.99999999, 0.1, 3.14159265358979}
// TestExactIntegerPriceRoundTrip proves the row price codec carries an EXACT
// PriceInt with no float64 round-trip — including values above 2^53 that a
// float64 cannot represent — so a rebuilt row is byte-identical to the live one
// (the property that makes a restarted validator deterministic).
func TestExactIntegerPriceRoundTrip(t *testing.T) {
prices := []PriceInt{1, 100, 10025000000, 1, 1_234_567_890_123, PriceInt(1) << 40, PriceInt(1)<<52 + 1}
for _, p := range prices {
fx := floatToFixedPrice(p)
// determinism: same input -> same fixed-point.
if fx != floatToFixedPrice(p) {
t.Fatalf("floatToFixedPrice(%v) not deterministic", p)
row := priceRowFor(p)
if row.Fraction != 0 {
t.Errorf("priceRowFor(%d): Fraction must be 0, got %d", p, row.Fraction)
}
back := fixedPriceToFloat(fx)
// The grid step for the fraction is 2^-64, far finer than 1e-8, so the
// recovered value must match to 1e-8.
if diff := back - p; diff > 1e-8 || diff < -1e-8 {
t.Errorf("price round-trip %v -> %+v -> %v (diff %v)", p, fx, back, diff)
if got := priceUnitsFromRow(row); got != p {
t.Errorf("price round-trip %d -> %+v -> %d (NOT exact)", p, row, got)
}
}
sizes := []float64{1, 0.5, 0.00000001, 1000000, 42.123456}
for _, s := range sizes {
q := floatToFixedQty(s)
if q != floatToFixedQty(s) {
t.Fatalf("floatToFixedQty(%v) not deterministic", s)
}
back := fixedQtyToFloat(q)
if diff := back - s; diff > 1e-8 || diff < -1e-8 {
t.Errorf("qty round-trip %v -> %d -> %v (diff %v)", s, q, back, diff)
// Determinism: same input -> same row.
if priceRowFor(p) != row {
t.Fatalf("priceRowFor(%d) not deterministic", p)
}
}
}
@@ -48,9 +35,9 @@ func TestRowEncodeDecode(t *testing.T) {
row := DEXOrder{
OrderID: 42,
UserID: userToUint64("alice"),
Price: floatToFixedPrice(100.25),
Quantity: floatToFixedQty(5),
Remaining: floatToFixedQty(3),
Price: priceRowFor(10025000000), // 100.25 * 1e8, exact
Quantity: 5,
Remaining: 3,
Timestamp: 1700000000000000000,
Side: DEXSideBid,
Type: DEXOrderLimit,
@@ -96,12 +83,13 @@ func TestBookToRowsRoundTrip(t *testing.T) {
t.Fatalf("ConsensusAddOrder(id=%d) = %d", id, got)
}
}
place(1, Buy, 100.0, 2.0, "alice", 0)
place(2, Buy, 100.0, 1.5, "bob", 1) // same level, later -> behind id1
place(3, Buy, 99.5, 3.0, "carol", 2)
place(4, Sell, 101.0, 2.5, "dave", 3)
place(5, Sell, 101.5, 1.0, "erin", 4)
place(6, Sell, 101.0, 0.5, "frank", 5) // same level, later -> behind id4
// Quantities are exact atomic base-unit counts (whole integers).
place(1, Buy, 100.0, 20, "alice", 0)
place(2, Buy, 100.0, 15, "bob", 1) // same level, later -> behind id1
place(3, Buy, 99.5, 30, "carol", 2)
place(4, Sell, 101.0, 25, "dave", 3)
place(5, Sell, 101.5, 10, "erin", 4)
place(6, Sell, 101.0, 5, "frank", 5) // same level, later -> behind id4
rows1 := BookToRows(ob)
if len(rows1) != 6 {
@@ -181,7 +169,8 @@ func TestRowStreamIsCanonicallyOrdered(t *testing.T) {
if a[0].Side != DEXSideBid || a[len(a)-1].Side != DEXSideAsk {
t.Errorf("expected bids first then asks, got sides %d..%d", a[0].Side, a[len(a)-1].Side)
}
if a[0].Price.Integer != 51 || a[1].Price.Integer != 50 {
// Price.Integer now holds the exact PriceInt (price × PriceMultiplier).
if a[0].Price.Integer != 51*uint64(PriceMultiplier) || a[1].Price.Integer != 50*uint64(PriceMultiplier) {
t.Errorf("bids not descending: %d then %d", a[0].Price.Integer, a[1].Price.Integer)
}
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// RED-TEAM (consensus fork on restart) — now a REGRESSION GUARD for the fix.
//
// A RESTING order's CANONICAL ROW (the exact bytes orderLeafDigest hashes into
// the bookRoot) MUST be stable across a validator restart. The live path derives
// the row from the order's exact integer lane (SizeUnits/PriceUnits); a restart
// rebuilds that lane verbatim from the stored row (RowToOrder). With exact
// integers on the wire and in the row, fresh == restart byte-for-byte. The old
// float64 encoding (round(size*1e8) reloaded via fixedQtyToFloat) drifted by 1
// unit for reachable large sizes and forked the set on any restart; this guards
// that it stays fixed.
package dex
import (
"bytes"
"math/big"
"testing"
)
// TestRedteam_RestartForksBookRoot rests a LARGE-size limit order (a base-unit
// count above float64's 2^53 exact-integer ceiling — the exact regime that used
// to fork), snapshots its canonical row, simulates a restart via RowsToBook, and
// re-snapshots. The bytes MUST be identical.
func TestRedteam_RestartForksBookRoot(t *testing.T) {
const symbol = "LUX-LUSD"
// A large exact base-unit size that float64 cannot represent exactly (and that
// the old round(size*1e8) encoding would have clamped/drifted).
const bigSize = uint64(0x0020000000000001) // 2^53 + 1, > float64 exact ceiling
priceUnits := PriceInt(25) * PriceInt(PriceMultiplier)
// --- FRESH validator: rest the order, compute its canonical row bytes. ---
obFresh := NewOrderBook(symbol)
ord := &Order{
ID: 0xABCDEF01,
Type: Limit,
Side: Sell,
PriceUnits: priceUnits,
Price: float64(int64(priceUnits)) / float64(PriceMultiplier),
Size: float64(bigSize),
RemainingSize: float64(bigSize),
SizeUnits: new(big.Int).SetUint64(bigSize),
User: "victimmaker",
UserID: "victimmaker",
Symbol: symbol,
Timestamp: nanosToTime(1_000_000_000),
Status: Open,
}
if obFresh.ConsensusAddOrder(ord) == 0 {
t.Fatalf("fresh: order rejected by ConsensusAddOrder")
}
rowsFresh := BookToRows(obFresh)
if len(rowsFresh) != 1 {
t.Fatalf("fresh: expected 1 resting row, got %d", len(rowsFresh))
}
leafFresh := EncodeRow(rowsFresh[0])
// The row committed the EXACT integer size — no clamp, no ×1e8 drift.
if rowsFresh[0].Quantity != bigSize {
t.Fatalf("row did not commit the exact size: got Quantity=%d want %d", rowsFresh[0].Quantity, bigSize)
}
// --- RESTARTED validator: rebuild from the SAME committed rows, re-serialize. ---
obRestart := RowsToBook(symbol, rowsFresh)
rowsRestart := BookToRows(obRestart)
if len(rowsRestart) != 1 {
t.Fatalf("restart: expected 1 resting row, got %d", len(rowsRestart))
}
leafRestart := EncodeRow(rowsRestart[0])
if !bytes.Equal(leafFresh, leafRestart) {
t.Fatalf(`RESTART FORK REGRESSION:
a resting order's canonical row changed across a restart.
fresh Remaining = %d
restart Remaining = %d
fresh row bytes = %x
restart row bytes = %x`,
rowsFresh[0].Remaining, rowsRestart[0].Remaining, leafFresh, leafRestart)
}
t.Logf("GUARD OK: canonical row byte-stable across restart for a 2^53+1 size (exact-integer wire, no restart fork)")
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// RED-TEAM wide sweep, now a REGRESSION GUARD for the exact-integer fix.
//
// The wire carries exact integers (price = PriceInt, size = atomic base units)
// and the persisted row stores them verbatim (persist.go), so the fresh->restart
// projection is the IDENTITY: F(w)=w and G(F(w))=w, hence F(G(F(w)))=F(w) trivially.
// The former fork came from round(size*1e8)/Q64.64-of-float64 being non-idempotent
// under the fixedQtyToFloat reload; those lossy conversions no longer exist. This
// sweep hammers the FULL uint64 quantity range and the full PriceInt range —
// including values above 2^53 that float64 cannot represent — through the actual
// row codec and asserts byte-stability across a rebuild.
package dex
import (
"bytes"
"math/big"
"math/rand"
"testing"
"time"
)
func TestRedteam_WideSweep_RestartIdempotence(t *testing.T) {
rng := rand.New(rand.NewSource(0xC0FFEE)) // deterministic
const symbol = "LUX-LUSD"
const N = 5_000_000
priceFails, qtyFails := 0, 0
// Price codec: an exact PriceInt round-trips through the row with no drift.
for i := 0; i < N; i++ {
p := PriceInt(rng.Uint64() >> 1) // full non-negative int64 range
if priceUnitsFromRow(priceRowFor(p)) != p {
priceFails++
}
}
// Quantity + full row: a resting order with a full-range uint64 size projects
// to a row, rebuilds, and re-projects BYTE-IDENTICALLY.
ts := time.Unix(0, 1_000_000_000).UTC()
for i := 0; i < N/50; i++ {
size := rng.Uint64() // includes >2^53, top bit set
priceUnits := PriceInt(rng.Uint64() >> 1)
ord := &Order{
ID: uint64(i) + 1,
Type: Limit,
Side: Sell,
PriceUnits: priceUnits,
Price: float64(int64(priceUnits)) / float64(PriceMultiplier),
Size: float64(size),
RemainingSize: float64(size),
SizeUnits: new(big.Int).SetUint64(size),
User: "victim",
UserID: "victim",
Symbol: symbol,
Timestamp: ts,
Status: Open,
}
fresh := EncodeRow(OrderToRow(ord))
// Rebuild from the row (the restart path) then re-serialize.
rebuilt := EncodeRow(OrderToRow(RowToOrder(OrderToRow(ord))))
if !bytes.Equal(fresh, rebuilt) {
qtyFails++
}
}
if priceFails > 0 || qtyFails > 0 {
t.Fatalf("EXACT-INTEGER REGRESSION: priceFails=%d qtyFails=%d — a float64 round-trip "+
"has re-entered the persist layer and can fork a restarted validator", priceFails, qtyFails)
}
t.Logf("GUARD OK: %d price + %d full-row samples all round-trip byte-identically (exact-integer wire, no restart fork)", N, N/50)
}
+15
View File
@@ -70,6 +70,15 @@ type Order struct {
// SubmitMarketable) by the wire decoder. See settlement_units.go.
SizeUnits *big.Int
RemainingUnits *big.Int
// PriceUnits is the AUTHORITATIVE fixed-point price (the matcher's PriceInt
// grid, price × PriceMultiplier), carried exactly from the wire. The float
// Price above is a derived projection (PriceUnits / PriceMultiplier) for the
// legacy API / display; it is NEVER the source of a consensus value. Zero on
// the legacy float API path (set on the OrderBook / consensus path by the wire
// decoder). Persisting the exact PriceUnits makes a rebuilt row byte-identical
// to the live one (no float64 round-trip = no restart fork). See persist.go.
PriceUnits PriceInt
}
// Trade represents an executed trade
@@ -98,6 +107,12 @@ type Trade struct {
// Nil on the legacy float matcher path; set on SubmitMarketable fills.
BaseUnits *big.Int
QuoteUnits *big.Int
// PriceUnits is the exact fixed-point price this fill executed at (the maker's
// resting PriceInt). QuoteUnits = BaseUnits * PriceUnits / PriceMultiplier. It
// is carried so the persisted trade row commits the exact integer price, not a
// float64 projection. Zero on the legacy float matcher path.
PriceUnits PriceInt
}
// MarketDataUpdate represents market data updates
+9 -6
View File
@@ -432,11 +432,14 @@ func (s *session) handleNewOrderSingle(ctx context.Context, msg *Message) {
orderID = strconv.FormatInt(time.Now().UnixNano(), 10)
}
// Aggregate fill stats for cumulative fields.
// Aggregate fill stats for cumulative fields. Wire fills carry EXACT integers
// (price = PriceInt ×1e8, size = base units); render human floats for FIX.
fillPx := func(f zapwire.Fill) float64 { return float64(f.Price) / float64(zapwire.PriceScale) }
fillSz := func(f zapwire.Fill) float64 { return float64(f.Size) }
var cumQty, notional float64
for _, f := range fills {
cumQty += f.Size
notional += f.Price * f.Size
cumQty += fillSz(f)
notional += fillPx(f) * fillSz(f)
}
leaves := qty - cumQty
if leaves < 0 {
@@ -469,8 +472,8 @@ func (s *session) handleNewOrderSingle(ctx context.Context, msg *Message) {
runningCum := 0.0
runningNotional := 0.0
for i, f := range fills {
runningCum += f.Size
runningNotional += f.Price * f.Size
runningCum += fillSz(f)
runningNotional += fillPx(f) * fillSz(f)
avg := runningNotional / runningCum
last := i == len(fills)-1
execType := ExecTypePartialFill
@@ -492,7 +495,7 @@ func (s *session) handleNewOrderSingle(ctx context.Context, msg *Message) {
s.sendExecReport(execReport{
clOrdID: clOrdID, orderID: orderID, symbol: symbol, side: sideStr,
execType: execType, ordStatus: ordStatus,
orderQty: qty, lastQty: f.Size, lastPx: f.Price,
orderQty: qty, lastQty: fillSz(f), lastPx: fillPx(f),
leavesQty: thisLeaves, cumQty: runningCum, avgPx: avg,
})
}
+3 -2
View File
@@ -198,9 +198,10 @@ func TestAcceptorLogon(t *testing.T) {
// canned fills come back as ExecutionReports with correct ExecType/OrdStatus and
// quantity/price fields — the FIX fill path.
func TestAcceptorNewOrderFills(t *testing.T) {
// Wire fills carry exact integers: Price is PriceInt (×1e8), Size is base units.
v := &fakeVenue{fills: []zapwire.Fill{
{Price: 100, Size: 10, TakerSide: zapwire.SideBuy},
{Price: 101, Size: 2, TakerSide: zapwire.SideBuy},
{Price: 100 * zapwire.PriceScale, Size: 10, TakerSide: zapwire.SideBuy},
{Price: 101 * zapwire.PriceScale, Size: 2, TakerSide: zapwire.SideBuy},
}}
a := startAcceptor(t, v)
c := dialClient(t, a.Addr(), "CLIENT", "LXDEX")
+19 -2
View File
@@ -88,8 +88,25 @@ func (v *zapVenue) EnsureMarket(ctx context.Context, poolID [32]byte) error {
return nil
}
// wirePrice scales a human price to the exact ×1e8 fixed-point wire integer; wireSize
// truncates a human size to whole atomic base units. The ZAP wire carries exact
// integers only (no float64), so a value-bearing quantity is byte-identical on
// every validator.
func wirePrice(price float64) uint64 {
if !(price > 0) {
return 0
}
return uint64(price * float64(zapwire.PriceScale))
}
func wireSize(size float64) uint64 {
if !(size > 0) {
return 0
}
return uint64(size)
}
func (v *zapVenue) Place(ctx context.Context, poolID [32]byte, side uint8, price, size float64, user string) (uint64, error) {
resp, err := v.conn.Call(ctx, zapwire.MethodPlace, zapwire.EncodePlace(poolID, side, price, size, user))
resp, err := v.conn.Call(ctx, zapwire.MethodPlace, zapwire.EncodePlace(poolID, side, wirePrice(price), wireSize(size), user))
if err != nil {
return 0, fmt.Errorf("fix: dex_place: %w", err)
}
@@ -119,7 +136,7 @@ func (v *zapVenue) Cancel(ctx context.Context, poolID [32]byte, orderID uint64)
}
func (v *zapVenue) Submit(ctx context.Context, poolID [32]byte, side uint8, isMarket bool, limit, size float64, user string) ([]zapwire.Fill, error) {
resp, err := v.conn.Call(ctx, zapwire.MethodSubmit, zapwire.EncodeSubmit(poolID, side, isMarket, limit, size, user))
resp, err := v.conn.Call(ctx, zapwire.MethodSubmit, zapwire.EncodeSubmit(poolID, side, isMarket, wirePrice(limit), wireSize(size), user))
if err != nil {
return nil, fmt.Errorf("fix: dex_submit: %w", err)
}
+51 -40
View File
@@ -21,7 +21,6 @@ package zapwire
import (
"encoding/binary"
"fmt"
"math"
)
// DEX market-routed ZAP method names. These are the poolId-keyed framing of
@@ -53,34 +52,54 @@ const (
StatusRejected uint8 = 2
)
// Wire frame sizes. All multi-byte integers are big-endian; all floats are
// IEEE-754 float64 in big-endian bit form.
// Wire frame sizes. ALL multi-byte quantities are big-endian UNSIGNED INTEGERS.
//
// CONSENSUS-DETERMINISM (the reason there is no float64 on this wire): a
// float64 size/price forced every validator to re-derive the settlement
// integer via uint64(float) / int64(float*scale), whose out-of-range result is
// implementation-defined per the Go spec and DIFFERS BY CPU ARCHITECTURE
// (arm64 saturates, amd64 wraps). Two honest validators on different archs then
// derived different reserves / balances / fills from the SAME ordered block and
// forked. Carrying exact integers eliminates every value-bearing float→int
// conversion from the state transition, so the wire is the single source of the
// exact quantity and every validator agrees byte-for-byte.
//
// UNIT CONVENTIONS (opaque to this pure-Go leaf; interpreted by the d-chain):
// - size : uint64 ATOMIC BASE UNITS (a whole count of the base asset's
// smallest unit). Bounded by uint64 — the custody ledger is uint64.
// - price : uint64 FIXED-POINT with scale 1e8 (price × 1e8), i.e. the matcher's
// PriceInt grid. quote owed = size × price / 1e8, exact in integers.
const (
// PoolIDSize is the V4 poolId width: keccak256(PoolKey).
PoolIDSize = 32
// UserSize is the on-wire user identity width (null-padded).
UserSize = 16
// PriceScale is the fixed-point scale of the wire price field (price × 1e8).
// It mirrors dex.PriceMultiplier; duplicated as an untyped const so this
// pure-stdlib leaf need not import the matching engine. quote = size*price/1e8.
PriceScale = 100000000
// EnsureMarketReqSize: poolId[32].
EnsureMarketReqSize = PoolIDSize
// PlaceReqSize: poolId[32] + side[1] + price[8] + size[8] + user[16].
// PlaceReqSize: poolId[32] + side[1] + price[8:u64 fixed] + size[8:u64 units] + user[16].
PlaceReqSize = PoolIDSize + 1 + 8 + 8 + UserSize // 65
// CancelReqSize: poolId[32] + orderId[8].
CancelReqSize = PoolIDSize + 8 // 40
// SubmitReqSize: poolId[32] + side[1] + isMarket[1] + limitPrice[8] +
// size[8] + user[16].
// SubmitReqSize: poolId[32] + side[1] + isMarket[1] + limitPrice[8:u64 fixed] +
// size[8:u64 units] + user[16].
SubmitReqSize = PoolIDSize + 1 + 1 + 8 + 8 + UserSize // 66
// AckSize: orderId[8] + status[1] + seq[8] + pad[7].
AckSize = 24
// FillWireSize is one fill in a Submit response: price[8] + size[8] +
// takerSide[1]. Base traded = sum(size); quote traded = sum(price*size).
// The adapter derives the V4 BalanceDelta from these server-produced
// numbers ONLY — it never trusts a client-supplied amount.
// FillWireSize is one fill in a Submit response: price[8:u64 fixed] +
// size[8:u64 base units] + takerSide[1]. Base traded = sum(size); quote
// traded = sum(size*price/1e8). The adapter derives the V4 BalanceDelta from
// these server-produced integers ONLY — it never trusts a client amount.
FillWireSize = 17
)
@@ -90,23 +109,14 @@ const (
SideSell uint8 = 1
)
// Fill is one decoded fill from a Submit response.
// Fill is one decoded fill from a Submit response. Price is fixed-point ×1e8
// (PriceScale); Size is atomic base units. quote = Size*Price/PriceScale (exact).
type Fill struct {
Price float64
Size float64
Price uint64
Size uint64
TakerSide uint8
}
// PutFloat64 writes f as a big-endian IEEE-754 float64 into b[0:8].
func PutFloat64(b []byte, f float64) {
binary.BigEndian.PutUint64(b, math.Float64bits(f))
}
// Float64 reads a big-endian IEEE-754 float64 from b[0:8].
func Float64(b []byte) float64 {
return math.Float64frombits(binary.BigEndian.Uint64(b))
}
// padNull copies s into a fresh size-byte buffer, null-padded.
func padNull(s string, size int) []byte {
out := make([]byte, size)
@@ -131,13 +141,14 @@ func EncodeEnsureMarket(poolID [32]byte) []byte {
return out
}
// EncodePlace builds a Place (rest limit order) request payload.
func EncodePlace(poolID [32]byte, side uint8, price, size float64, user string) []byte {
// EncodePlace builds a Place (rest limit order) request payload. price is
// fixed-point ×1e8 (PriceScale); size is atomic base units.
func EncodePlace(poolID [32]byte, side uint8, price, size uint64, user string) []byte {
out := make([]byte, PlaceReqSize)
copy(out[0:PoolIDSize], poolID[:])
out[PoolIDSize] = side
PutFloat64(out[PoolIDSize+1:PoolIDSize+9], price)
PutFloat64(out[PoolIDSize+9:PoolIDSize+17], size)
binary.BigEndian.PutUint64(out[PoolIDSize+1:PoolIDSize+9], price)
binary.BigEndian.PutUint64(out[PoolIDSize+9:PoolIDSize+17], size)
copy(out[PoolIDSize+17:], padNull(user, UserSize))
return out
}
@@ -153,15 +164,15 @@ func EncodeCancel(poolID [32]byte, orderID uint64) []byte {
// EncodeSubmit builds a Submit (marketable order) request payload. When
// isMarket is true the order sweeps unconditionally and limitPrice is ignored;
// otherwise it is an IOC limit bounded by limitPrice.
func EncodeSubmit(poolID [32]byte, side uint8, isMarket bool, limitPrice, size float64, user string) []byte {
func EncodeSubmit(poolID [32]byte, side uint8, isMarket bool, limitPrice, size uint64, user string) []byte {
out := make([]byte, SubmitReqSize)
copy(out[0:PoolIDSize], poolID[:])
out[PoolIDSize] = side
if isMarket {
out[PoolIDSize+1] = 1
}
PutFloat64(out[PoolIDSize+2:PoolIDSize+10], limitPrice)
PutFloat64(out[PoolIDSize+10:PoolIDSize+18], size)
binary.BigEndian.PutUint64(out[PoolIDSize+2:PoolIDSize+10], limitPrice)
binary.BigEndian.PutUint64(out[PoolIDSize+10:PoolIDSize+18], size)
copy(out[PoolIDSize+18:], padNull(user, UserSize))
return out
}
@@ -181,8 +192,8 @@ func DecodeFills(resp []byte) ([]Fill, error) {
off := 4
for i := 0; i < n; i++ {
fills[i] = Fill{
Price: Float64(resp[off : off+8]),
Size: Float64(resp[off+8 : off+16]),
Price: binary.BigEndian.Uint64(resp[off : off+8]),
Size: binary.BigEndian.Uint64(resp[off+8 : off+16]),
TakerSide: resp[off+16],
}
off += FillWireSize
@@ -196,8 +207,8 @@ func EncodeFills(fills []Fill) []byte {
binary.BigEndian.PutUint32(out[0:4], uint32(len(fills)))
off := 4
for _, f := range fills {
PutFloat64(out[off:off+8], f.Price)
PutFloat64(out[off+8:off+16], f.Size)
binary.BigEndian.PutUint64(out[off:off+8], f.Price)
binary.BigEndian.PutUint64(out[off+8:off+16], f.Size)
out[off+16] = f.TakerSide
off += FillWireSize
}
@@ -206,7 +217,7 @@ func EncodeFills(fills []Fill) []byte {
// DecodeSubmit parses a Submit request payload into its fields. Clients build
// these; the server and test harnesses decode them.
func DecodeSubmit(payload []byte) (poolID [32]byte, side uint8, isMarket bool, limitPrice, size float64, user string, err error) {
func DecodeSubmit(payload []byte) (poolID [32]byte, side uint8, isMarket bool, limitPrice, size uint64, user string, err error) {
if len(payload) < SubmitReqSize {
err = fmt.Errorf("zapwire: submit request too short: %d", len(payload))
return
@@ -214,23 +225,23 @@ func DecodeSubmit(payload []byte) (poolID [32]byte, side uint8, isMarket bool, l
copy(poolID[:], payload[0:PoolIDSize])
side = payload[PoolIDSize]
isMarket = payload[PoolIDSize+1] == 1
limitPrice = Float64(payload[PoolIDSize+2 : PoolIDSize+10])
size = Float64(payload[PoolIDSize+10 : PoolIDSize+18])
limitPrice = binary.BigEndian.Uint64(payload[PoolIDSize+2 : PoolIDSize+10])
size = binary.BigEndian.Uint64(payload[PoolIDSize+10 : PoolIDSize+18])
user = string(trimNull(payload[PoolIDSize+18 : PoolIDSize+18+UserSize]))
return
}
// DecodePlace parses a Place request payload into its fields (poolId[32] +
// side[1] + price[8] + size[8] + user[16]).
func DecodePlace(payload []byte) (poolID [32]byte, side uint8, price, size float64, user string, err error) {
func DecodePlace(payload []byte) (poolID [32]byte, side uint8, price, size uint64, user string, err error) {
if len(payload) < PlaceReqSize {
err = fmt.Errorf("zapwire: place request too short: %d", len(payload))
return
}
copy(poolID[:], payload[0:PoolIDSize])
side = payload[PoolIDSize]
price = Float64(payload[PoolIDSize+1 : PoolIDSize+9])
size = Float64(payload[PoolIDSize+9 : PoolIDSize+17])
price = binary.BigEndian.Uint64(payload[PoolIDSize+1 : PoolIDSize+9])
size = binary.BigEndian.Uint64(payload[PoolIDSize+9 : PoolIDSize+17])
user = string(trimNull(payload[PoolIDSize+17 : PoolIDSize+17+UserSize]))
return
}
+18 -16
View File
@@ -4,7 +4,6 @@
package zapwire
import (
"math"
"testing"
)
@@ -132,22 +131,19 @@ func TestCustodyFramesRoundTrip(t *testing.T) {
}
}
func TestFloat64RoundTrip(t *testing.T) {
for _, f := range []float64{0, 1, 0.5, 1e18, 1.0 / 3.0, math.MaxFloat64, math.SmallestNonzeroFloat64} {
b := make([]byte, 8)
PutFloat64(b, f)
if got := Float64(b); got != f {
t.Errorf("Float64(PutFloat64(%v)) = %v", f, got)
}
}
}
func TestSubmitRoundTrip(t *testing.T) {
// TestExactIntegerWire pins that the value-bearing wire fields carry EXACT
// unsigned integers with no IEEE-754 float64 anywhere — a full-range uint64
// (including >2^53 and the top bit set) round-trips byte-for-byte, which is the
// property that makes the state transition architecture-independent.
func TestExactIntegerWire(t *testing.T) {
var poolID [32]byte
for i := range poolID {
poolID[i] = byte(i + 1)
}
req := EncodeSubmit(poolID, SideBuy, false, 123.5, 10.25, "alice")
// price = 123.5 in ×1e8 fixed-point; size = huge base-unit count > 2^53.
const wantPrice = uint64(12_350_000_000)
const wantSize = uint64(0xFFFFFFFFFFFFFFF0) // near uint64 max, unrepresentable as an exact float64
req := EncodeSubmit(poolID, SideBuy, false, wantPrice, wantSize, "alice")
if len(req) != SubmitReqSize {
t.Fatalf("EncodeSubmit len = %d, want %d", len(req), SubmitReqSize)
}
@@ -155,16 +151,22 @@ func TestSubmitRoundTrip(t *testing.T) {
if err != nil {
t.Fatalf("DecodeSubmit: %v", err)
}
if gotPool != poolID || side != SideBuy || isMarket || limit != 123.5 || size != 10.25 || user != "alice" {
t.Fatalf("DecodeSubmit mismatch: pool=%x side=%d market=%v limit=%v size=%v user=%q",
if gotPool != poolID || side != SideBuy || isMarket || limit != wantPrice || size != wantSize || user != "alice" {
t.Fatalf("DecodeSubmit mismatch: pool=%x side=%d market=%v limit=%d size=%d user=%q",
gotPool, side, isMarket, limit, size, user)
}
// Place round-trip with the same exact integers.
pReq := EncodePlace(poolID, SideSell, wantPrice, wantSize, "bob")
gp, ps, pp, psz, pu, perr := DecodePlace(pReq)
if perr != nil || gp != poolID || ps != SideSell || pp != wantPrice || psz != wantSize || pu != "bob" {
t.Fatalf("DecodePlace mismatch: %x %d %d %d %q err=%v", gp, ps, pp, psz, pu, perr)
}
}
func TestFillsRoundTrip(t *testing.T) {
in := []Fill{
{Price: 100, Size: 2, TakerSide: SideBuy},
{Price: 101.5, Size: 0.5, TakerSide: SideBuy},
{Price: 10_150_000_000, Size: 0xFFFFFFFFFFFFFFF0, TakerSide: SideBuy},
}
resp := EncodeFills(in)
if len(resp) != 4+len(in)*FillWireSize {