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

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

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

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

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

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

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

206 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zapwire
import (
"testing"
)
// TestFrameSizesFrozen pins the wire frame sizes. These bytes are shared
// verbatim with dex/pkg/api (server), the chains/dexvm proxy, and the EVM
// precompile. A change here is a wire-breaking change and must fail loudly.
func TestFrameSizesFrozen(t *testing.T) {
cases := []struct {
name string
got int
want int
}{
{"PoolIDSize", PoolIDSize, 32},
{"UserSize", UserSize, 16},
{"EnsureMarketReqSize", EnsureMarketReqSize, 32},
{"PlaceReqSize", PlaceReqSize, 65},
{"CancelReqSize", CancelReqSize, 40},
{"SubmitReqSize", SubmitReqSize, 66},
{"AckSize", AckSize, 24},
{"FillWireSize", FillWireSize, 17},
// Custody frames (additive, FROZEN). The chains/dexvm proxy re-defines
// these as DepositReqSize/WithdrawReqSize/BalanceRespSize (88/88/9) and the
// precompile mirrors them; a change here breaks that cross-module parity.
// Deposit/Withdraw carry the FULL 32-byte injective asset id and the 32-byte
// idempotency ref (user[16]+asset[32]+amount[8]+ref[32]=88); the asset is the
// full id (NOT a truncated handle) so distinct assets never collide on the
// ledger key (the native-vault-drain fix); the ref unifies the EVM-txHash
// idempotency identity with the d-chain content-addressed seen: key.
{"AssetIDSize", AssetIDSize, 32},
{"RefSize", RefSize, 32},
{"DepositReqSize", DepositReqSize, 88},
{"WithdrawReqSize", WithdrawReqSize, 88},
{"OpenMarketReqSize", OpenMarketReqSize, 96},
{"BalanceReqSize", BalanceReqSize, 48},
{"BalanceRespSize", BalanceRespSize, 9},
}
for _, c := range cases {
if c.got != c.want {
t.Errorf("%s = %d, want %d (FROZEN wire frame changed)", c.name, c.got, c.want)
}
}
}
func TestMethodNamesFrozen(t *testing.T) {
// Public wire names are dex_* (the D-Chain DEX namespace), per the luxd
// service-prefix convention. Frozen against accidental drift.
if MethodEnsureMarket != "dex_ensure_market" ||
MethodPlace != "dex_place" ||
MethodCancel != "dex_cancel" ||
MethodSubmit != "dex_submit" {
t.Fatalf("DEX method names changed: %q %q %q %q",
MethodEnsureMarket, MethodPlace, MethodCancel, MethodSubmit)
}
// Custody method names (additive, FROZEN, mirrored by the proxy).
if MethodDeposit != "dex_deposit" ||
MethodWithdraw != "dex_withdraw" ||
MethodOpenMarket != "dex_open_market" {
t.Fatalf("custody method names changed: %q %q %q",
MethodDeposit, MethodWithdraw, MethodOpenMarket)
}
}
// TestCustodyFramesRoundTrip pins the custody frame codecs (deposit/withdraw/
// open-market/balance-resp) round-trip exactly — the bytes the proxy's atomic
// rail and the d-chain ledger exchange. The 32-byte idempotency ref MUST survive
// the round-trip: it is the field the d-chain folds into its seen: dedup key, so
// a lost/garbled ref would reopen the vault-drain replay window.
func TestCustodyFramesRoundTrip(t *testing.T) {
// deposit / withdraw — asset is the FULL 32-byte injective id.
mkAsset := func(b ...byte) [32]byte { var a [32]byte; copy(a[:], b); return a }
for _, tc := range []struct {
user string
asset [32]byte
amount uint64
ref [32]byte
}{
{"maker-a", mkAsset(0x4c, 0x55, 0x58, 0x00, 0x00, 0x00, 0x00, 0x01), 100, [32]byte{0x01, 0x02, 0xff}},
{"taker-x", mkAsset(0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xf0, 0x0d), 1<<63 + 7, [32]byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee}},
// Full-width id whose distinguishing bytes live in the LOW 24 bytes — a
// truncated 8-byte handle would lose them; the full id must survive intact.
{"deep-id", mkAsset(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xab, 0xcd, 0xef), 42, [32]byte{0x09}},
{"", [32]byte{}, 0, [32]byte{}},
} {
du, da, dm, dref, err := DecodeDeposit(EncodeDeposit(tc.user, tc.asset, tc.amount, tc.ref))
if err != nil || du != tc.user || da != tc.asset || dm != tc.amount || dref != tc.ref {
t.Fatalf("deposit round-trip (%q,%x,%d,%x) = (%q,%x,%d,%x) err=%v", tc.user, tc.asset[:8], tc.amount, tc.ref[:4], du, da[:8], dm, dref[:4], err)
}
wu, wa, wm, wref, werr := DecodeWithdraw(EncodeWithdraw(tc.user, tc.asset, tc.amount, tc.ref))
if werr != nil || wu != tc.user || wa != tc.asset || wm != tc.amount || wref != tc.ref {
t.Fatalf("withdraw round-trip (%q,%x,%d,%x) = (%q,%x,%d,%x) err=%v", tc.user, tc.asset[:8], tc.amount, tc.ref[:4], wu, wa[:8], wm, wref[:4], werr)
}
}
// COLLISION RESISTANCE (the asset-key fix): two assets that share their leading
// 8 bytes but differ in the LOW 24 bytes MUST encode to distinct asset fields.
// The old 8-byte handle truncated them to the SAME key (the collision bug); the
// full 32-byte id keeps them distinct on the wire (and so on the ledger key).
sharedHi := mkAsset(0, 0, 0, 0, 0, 0, 0, 0, 0x11) // low byte 0x11
sharedHiAlt := mkAsset(0, 0, 0, 0, 0, 0, 0, 0, 0x22)
_, a1, _, _, _ := DecodeDeposit(EncodeDeposit("u", sharedHi, 1, [32]byte{0x01}))
_, a2, _, _, _ := DecodeDeposit(EncodeDeposit("u", sharedHiAlt, 1, [32]byte{0x01}))
if a1 == a2 {
t.Fatal("two assets sharing leading 8 bytes decoded to the SAME id — truncation collision (native-vault-drain class)")
}
// The ref MUST change the encoded bytes: two custody frames identical in
// (user,asset,amount) but differing in ref are DIFFERENT bytes — that is the
// whole point (distinct d-chain txID -> distinct seen: key -> no replay).
a := EncodeWithdraw("alice", mkAsset(7), 1000, [32]byte{0x01})
b := EncodeWithdraw("alice", mkAsset(7), 1000, [32]byte{0x02})
if string(a) == string(b) {
t.Fatal("withdraw frames with distinct refs encoded identically — ref does not enter the wire (vault-drain dedup would collide)")
}
// open-market
pool := [32]byte{0xde, 0xad}
baseA, quoteA := mkAsset(11), mkAsset(22)
op, ob, oq, oerr := DecodeOpenMarket(EncodeOpenMarket(pool, baseA, quoteA))
if oerr != nil || op != pool || ob != baseA || oq != quoteA {
t.Fatalf("open-market round-trip = (%x,%x,%x) err=%v", op[:2], ob[:2], oq[:2], oerr)
}
// balance-resp
bs, ba, berr := DecodeBalanceResp(EncodeBalanceResp(StatusPlaced, 12345))
if berr != nil || bs != StatusPlaced || ba != 12345 {
t.Fatalf("balance-resp round-trip = (%d,%d) err=%v", bs, ba, berr)
}
}
// 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)
}
// 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)
}
gotPool, side, isMarket, limit, size, user, err := DecodeSubmit(req)
if err != nil {
t.Fatalf("DecodeSubmit: %v", err)
}
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: 10_150_000_000, Size: 0xFFFFFFFFFFFFFFF0, TakerSide: SideBuy},
}
resp := EncodeFills(in)
if len(resp) != 4+len(in)*FillWireSize {
t.Fatalf("EncodeFills len = %d, want %d", len(resp), 4+len(in)*FillWireSize)
}
out, err := DecodeFills(resp)
if err != nil {
t.Fatalf("DecodeFills: %v", err)
}
if len(out) != len(in) {
t.Fatalf("DecodeFills len = %d, want %d", len(out), len(in))
}
for i := range in {
if out[i] != in[i] {
t.Errorf("fill %d = %+v, want %+v", i, out[i], in[i])
}
}
// Truncated frame must error, not panic.
if _, err := DecodeFills(resp[:len(resp)-1]); err == nil {
t.Error("DecodeFills(truncated) = nil error, want error")
}
}
func TestAckRoundTrip(t *testing.T) {
resp := EncodeAck(42, StatusPlaced, 7)
if len(resp) != AckSize {
t.Fatalf("EncodeAck len = %d, want %d", len(resp), AckSize)
}
id, status, err := DecodeAck(resp)
if err != nil {
t.Fatalf("DecodeAck: %v", err)
}
if id != 42 || status != StatusPlaced {
t.Fatalf("DecodeAck = (%d,%d), want (42,%d)", id, status, StatusPlaced)
}
}