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]>
117 lines
5.2 KiB
Go
117 lines
5.2 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
package dchain
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/binary"
|
||
"math/big"
|
||
"testing"
|
||
|
||
"github.com/luxfi/dex/pkg/dex"
|
||
"github.com/luxfi/dex/pkg/zapwire"
|
||
)
|
||
|
||
// TestUserIDFoldMatchesLx pins the DECOMPLECTED identity split that the custody
|
||
// model rests on:
|
||
//
|
||
// - The matcher's order-book ROW (dex.DEXOrder.UserID) stays the 8-byte STP
|
||
// handle (the frozen GPU ABI). dex.OrderToRow folds the user to the leading 8
|
||
// bytes; that is UNCHANGED and is sufficient for self-trade prevention and
|
||
// price-time rebuild.
|
||
// - The d-chain SETTLEMENT identity (userKey16) is the FULL 16-byte wire user
|
||
// (zapwire.UserSize) — the value balance:/locked: key by. It is byte-identical
|
||
// to the wire's null-padded user[16] field, so the ledger credits the SAME
|
||
// account the orders/deposits name.
|
||
//
|
||
// The two are deliberately DIFFERENT widths: the row models matching, d-chain
|
||
// state models settlement identity. This test pins both so neither silently
|
||
// regresses (a widened row would break the GPU ABI; a narrowed ledger identity
|
||
// would reopen the cross-user drain).
|
||
func TestUserIDFoldMatchesLx(t *testing.T) {
|
||
for _, u := range []string{"", "a", "maker-a", "taker-x-very-long-name-overflow"} {
|
||
// The matcher ROW handle is the 8-byte fold (frozen GPU ABI / STP).
|
||
row := dex.OrderToRow(&dex.Order{ID: 1, User: u, UserID: u, Size: 1, Price: 1, Side: dex.Buy})
|
||
var want8 [8]byte
|
||
copy(want8[:], u)
|
||
if got, want := row.UserID, binary.BigEndian.Uint64(want8[:]); got != want {
|
||
t.Errorf("ROW handle: lx OrderToRow.UserID(%q) = %#x, want 8-byte fold %#x", u, got, want)
|
||
}
|
||
|
||
// The d-chain SETTLEMENT identity is the FULL 16-byte wire user.
|
||
var wantWire [zapwire.UserSize]byte
|
||
copy(wantWire[:], u)
|
||
if got := userKey16(u); got != wantWire {
|
||
t.Errorf("LEDGER identity: userKey16(%q) = %x, want full wire user[16] %x", u, got, wantWire)
|
||
}
|
||
if len(userKey16(u)) != zapwire.UserSize {
|
||
t.Errorf("LEDGER identity width = %d, want %d (zapwire.UserSize)", len(userKey16(u)), zapwire.UserSize)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestLedgerUserKeyNoCollision is the PERMANENT REGRESSION for CRITICAL-1
|
||
// (cross-user native drain). It tests the REAL production derivation — userKey16
|
||
// folded into balanceLedgerKey — not a test double. Two addresses that SHARE their
|
||
// leading 8 bytes but differ in bytes 8..15 MUST map to DISTINCT ledger accounts,
|
||
// so the second can never read/withdraw the first's balance.
|
||
//
|
||
// FAIL-on-old -> PASS-on-fix: the deleted 8-byte fold (userID8) produced an
|
||
// IDENTICAL uint64 for both addresses, so their balance keys collided — the drain.
|
||
// The 16-byte userKey16 keeps every byte, so the keys differ. The test asserts the
|
||
// keys differ AND demonstrates the old 8-byte fold would have collided.
|
||
func TestLedgerUserKeyNoCollision(t *testing.T) {
|
||
// Two 16-byte users sharing the leading 8 bytes, differing in bytes 8..15 —
|
||
// exactly the red PoC's "colliding address" shape.
|
||
victim := string([]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, 0x11, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08})
|
||
attacker := string([]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, 0x11, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8})
|
||
|
||
var asset [32]byte // native (ids.Empty) — the asset the PoC drains
|
||
for i := range asset {
|
||
asset[i] = 0
|
||
}
|
||
|
||
vKey := userKey16(victim)
|
||
aKey := userKey16(attacker)
|
||
if vKey == aKey {
|
||
t.Fatalf("userKey16 collided for leading-8-shared users: %x == %x (CRITICAL-1 regressed)", vKey, aKey)
|
||
}
|
||
|
||
vLedger := balanceLedgerKey(prefixBalance, vKey, asset)
|
||
aLedger := balanceLedgerKey(prefixBalance, aKey, asset)
|
||
if bytes.Equal(vLedger, aLedger) {
|
||
t.Fatalf("balance ledger keys collided for distinct users:\n victim = %x\n attacker = %x\n(CRITICAL-1: attacker would read victim's balance)", vLedger, aLedger)
|
||
}
|
||
|
||
// Demonstrate the DELETED 8-byte fold WOULD have collided (the bug we removed):
|
||
// folding either user to its leading 8 bytes yields the SAME uint64, so the
|
||
// pre-fix balance:<user:8> keys were identical.
|
||
old8 := func(u string) uint64 {
|
||
var b [8]byte
|
||
copy(b[:], u)
|
||
return binary.BigEndian.Uint64(b[:])
|
||
}
|
||
if old8(victim) != old8(attacker) {
|
||
t.Fatalf("test setup invalid: the two users do not share leading 8 bytes (old fold %#x != %#x)", old8(victim), old8(attacker))
|
||
}
|
||
}
|
||
|
||
// TestQuoteUnitsCeilCoversFloor proves the lock ceil is always >= the matcher's
|
||
// floor quote for the same (base, price): lock >= spend, so a settle never
|
||
// 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 _, 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 priceInt=%d", lock, floor, base, pi)
|
||
}
|
||
}
|
||
}
|
||
}
|