mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
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:
+34
-5
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user