mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
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]>
131 lines
6.0 KiB
Go
131 lines
6.0 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package dchain
|
|
|
|
// market_constraint_test.go pins the ZERO-NOTIONAL reject — the minimal
|
|
// market-constraint invariant that arithmetic flooring can NEVER create a FREE
|
|
// executable order. An order whose required lock floors to zero (a sub-tick BUY
|
|
// price whose ceil(size*price) rounds to 0, or a sub-unit SELL size) would
|
|
// rest/cross while reserving NOTHING; settlement could then move the
|
|
// counterparty's asset for no spend. lockOrderSpend rejects such an order with
|
|
// ErrDustPriceOrZeroLock (a deterministic per-tx reject), so it never rests and
|
|
// never matches.
|
|
//
|
|
// PriceMultiplier is 1e8, so priceToInt(price) = price*1e8. A price < 1e-8 floors
|
|
// to priceInt 0, and quoteUnitsCeil(units, 0) == 0 — the canonical sub-tick dust
|
|
// BUY this gate must refuse.
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/luxfi/database/memdb"
|
|
"github.com/luxfi/dex/pkg/zapwire"
|
|
)
|
|
|
|
// dustPrice is a positive price strictly below one price tick (1e-8), so
|
|
// priceToInt(dustPrice) == 0 and a BUY of any positive size floors to a zero
|
|
// quote lock. It is a GENUINE executable price on the wire (>0), not a malformed
|
|
// zero — exactly the "executable yet free" case the gate must catch.
|
|
const dustPrice = 5e-9
|
|
|
|
// TestNotionalFlooringCannotCreateFreeExecutableOrder is the headline
|
|
// market-constraint proof: an executable BUY whose notional floors to zero lock
|
|
// (size>0, price>0, but ceil(size*price)==0) is REJECTED at placement — it never
|
|
// rests, locks nothing, and a crossing taker cannot acquire the (non-existent)
|
|
// liquidity for free. It also pins the cross axis: a sub-tick LIMIT BUY submit
|
|
// against a real resting maker is rejected too, so flooring cannot acquire the
|
|
// maker's base for no quote.
|
|
func TestNotionalFlooringCannotCreateFreeExecutableOrder(t *testing.T) {
|
|
ctx := context.Background()
|
|
vm, _ := newTestVM(t, memdb.New())
|
|
defer vm.Shutdown(ctx)
|
|
|
|
const buyer, seller = "dust-buyer", "dust-seller"
|
|
pool := [32]byte{0xd5, 0x70}
|
|
|
|
// Sanity on the predicate itself: a sub-tick BUY floors to zero lock; a normal
|
|
// BUY does not.
|
|
mc := marketConstraint{assetLUX, assetLUSD}
|
|
// 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, wirePriceUnits(5.0), wireSizeUnits(10.0)) {
|
|
t.Fatal("predicate: a normal BUY 5x10 must NOT floor to zero lock")
|
|
}
|
|
|
|
// Fund the buyer generously (so the reject is the NOTIONAL floor, never an
|
|
// affordability reject) and open the custody market.
|
|
addBlock(t, vm,
|
|
depositTx(t, buyer, assetLUSD, 1_000_000),
|
|
depositTx(t, seller, assetLUX, 100),
|
|
openMarketTx(t, pool, assetLUX, assetLUSD),
|
|
)
|
|
|
|
// PLACE a dust BUY (10 @ 5e-9): rejected, nothing locked, book empty.
|
|
own := newOwnershipChecker(t, vm)
|
|
blk := addBlock(t, vm, placePoolTx(t, pool, zapwire.SideBuy, dustPrice, 10.0, buyer))
|
|
if oc := outcomeForTx(blk, blk.txs[0]); oc.status != zapwire.StatusRejected {
|
|
t.Fatalf("dust BUY place status = %d, want StatusRejected (%d)", oc.status, zapwire.StatusRejected)
|
|
}
|
|
// No lock taken (the buyer's full quote stays available), and NO resting order.
|
|
assertBalance(t, vm, buyer, assetLUSD, 1_000_000, 0)
|
|
if orders, _, _, _, _ := vm.BookDepth(pool); orders != 0 {
|
|
t.Fatalf("dust BUY rested: book has %d orders, want 0 (a zero-lock executable order must never rest)", orders)
|
|
}
|
|
// No value moved at all -> ownership trivially holds (no parties needed).
|
|
own.assert(t, vm, blk, "dust-buy-place")
|
|
|
|
// Now a REAL maker rests (seller SELL 10 LUX @ 5), and a sub-tick LIMIT BUY
|
|
// submit tries to cross it. The submit is rejected by the notional gate, so the
|
|
// maker's base is NOT acquired for a floored-to-zero quote.
|
|
addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, seller))
|
|
assertBalance(t, vm, seller, assetLUX, 90, 10)
|
|
|
|
ownCross := newOwnershipChecker(t, vm)
|
|
sblk := addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, dustPrice, 10.0, buyer))
|
|
if oc := outcomeForTx(sblk, sblk.txs[0]); oc.status != zapwire.StatusRejected {
|
|
t.Fatalf("dust LIMIT BUY submit status = %d, want StatusRejected", oc.status)
|
|
}
|
|
// Nothing was acquired or paid: buyer keeps all quote, seller keeps its locked
|
|
// base. Flooring acquired NO free base.
|
|
assertBalance(t, vm, buyer, assetLUSD, 1_000_000, 0)
|
|
assertBalance(t, vm, buyer, assetLUX, 0, 0)
|
|
assertBalance(t, vm, seller, assetLUX, 90, 10)
|
|
ownCross.assert(t, vm, sblk, "dust-buy-cross")
|
|
}
|
|
|
|
// TestLockAmtZeroBuyRejected is the focused mutation pin named by the acceptance
|
|
// gate: a BUY with size>0 and price>0 whose required quote lock floors to zero is
|
|
// rejected (ok=false) by lockOrderSpend with ErrDustPriceOrZeroLock — it cannot
|
|
// rest with a zero lock. (Mutation: allowing lockAmt==0 BUY to pass makes this and
|
|
// TestNotionalFlooringCannotCreateFreeExecutableOrder RED.)
|
|
func TestLockAmtZeroBuyRejected(t *testing.T) {
|
|
ctx := context.Background()
|
|
vm, _ := newTestVM(t, memdb.New())
|
|
defer vm.Shutdown(ctx)
|
|
|
|
const buyer = "zlb-buyer"
|
|
pool := [32]byte{0x21, 0xb0}
|
|
addBlock(t, vm,
|
|
depositTx(t, buyer, assetLUSD, 1_000_000),
|
|
openMarketTx(t, pool, assetLUX, assetLUSD),
|
|
)
|
|
|
|
// Drive lockOrderSpend directly through a built block's overlay so we observe
|
|
// the named reject, not just the StatusRejected outcome.
|
|
tx := placePoolTx(t, pool, zapwire.SideBuy, dustPrice, 10.0, buyer)
|
|
blk := addBlock(t, vm, tx)
|
|
if oc := outcomeForTx(blk, blk.txs[0]); oc.status != zapwire.StatusRejected {
|
|
t.Fatalf("zero-lock BUY status = %d, want StatusRejected", oc.status)
|
|
}
|
|
// The buyer funded a million quote but the order never rested or locked.
|
|
assertBalance(t, vm, buyer, assetLUSD, 1_000_000, 0)
|
|
if orders, _, _, _, _ := vm.BookDepth(pool); orders != 0 {
|
|
t.Fatalf("zero-lock BUY rested: %d orders, want 0", orders)
|
|
}
|
|
}
|