mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
Consensus-correctness fix for the native DEX settlement VM. Root cause of the launch-blocking forks: order size/price were IEEE-754 float64 on the ZAP wire, so every validator re-derived the settlement integer via uint64(float)/int64(float*1e8), whose out-of-range result is implementation-defined per the Go spec and DIFFERS BY CPU ARCHITECTURE (arm64 saturates to 2^64-1; amd64 wraps to 2^63). Two honest validators on different archs then derived different reserves/balances/fills from the SAME ordered block. F1/F3/F5 — exact-integer size/price end to end: - pkg/zapwire: Place/Submit/Fill carry price as uint64 fixed-point (×1e8 = PriceInt) and size as uint64 atomic base units. No float64 on the wire. Removed PutFloat64/ Float64. Decode is exact bytes->uint64 (deterministic on every arch). - pkg/dchain/execute: decode the exact integers, set the authoritative PriceUnits/ SizeUnits lane; the float Price/Size become derived projections only (never a source of a consensus value). Reject price==0/size==0/price>maxPriceUnits. - pkg/dex/persist: the DEXOrder/DEXTrade row stores the EXACT PriceInt (in DEXPrice.Integer) and EXACT base units (Quantity/Remaining); RowToOrder restores the integer lane verbatim (identity — no float round-trip) and Filled=Qty-Remaining. Removed floatToFixedQty/fixedQtyToFloat/floatToFixedPrice (the non-idempotent round(size*1e8)/Q64.64 encoding that clamped at ~1.845e11 and forked on restart). - pkg/dex matcher: cross and level-key on the exact integer PriceUnits. - pkg/dchain/settle: orderLock/floorsToZeroLock take exact integers; removed sizeToUnits/priceToInt float conversions and the dead restoreIntegerLane. F2 — commit the custody ledger into the execution root: - state.ComposeRoot/ExecutionRoot gain a ledgerRoot term (Merkle over balance:/ locked:/orderasset: rows). Validators can no longer finalize agreeing on the book while holding different money. F4 — release the ceil-lock/floor-charge residual on full fill: - decrementMakerReserves now closes a fully-filled maker's reserve and returns the ceil(size*price)-floor residual to the owner (a filled order can never be cancelled), through the same unlock path a cancel uses. No more permanently frozen quote. PROVEN: the two-phase cross-arch harness (arm64 build / amd64 Rosetta verify) now derives byte-identical execRoots AND byte-identical custody ledgers for the identical block (uint64(1e20) still differs at the language level — 0xffff.. vs 0x8000.. — but the exact-integer wire freezes the value in the bytes so no per-validator float->int conversion remains). Red guards flipped to assert the fix; conservation/byzantine/batch-parity/KAT/e2e all green (go test ./... -count=1), go vet clean. OUT OF SCOPE (flagged, NOT fixed here): redteam_unbacked_deposit_test documents a SEPARATE critical finding (F9) — dex_deposit credits an arbitrary self-signed amount with no C-side backing proof, exposed on the public RPC. That needs an access-control/ custody-model decision, not a mechanical fix, and is left for a follow-up. Co-authored-by: Hanzo Dev <[email protected]>
131 lines
3.9 KiB
Go
131 lines
3.9 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
package api
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/gorilla/websocket"
|
||
"github.com/luxfi/dex/pkg/venue"
|
||
"github.com/luxfi/dex/pkg/zapwire"
|
||
)
|
||
|
||
// fakeVenue is a canned-fills Venue for unit-testing VenueWS in isolation from the
|
||
// matcher — the WS handler's job is JSON<->Venue translation, proven here without
|
||
// consensus (the unified pkg/dchain e2e proves the REAL matcher over WS).
|
||
type fakeVenue struct {
|
||
lastSubmit submitArgs
|
||
fills []zapwire.Fill
|
||
}
|
||
|
||
type submitArgs struct {
|
||
pool [32]byte
|
||
side uint8
|
||
isMarket bool
|
||
limit, size float64
|
||
user string
|
||
}
|
||
|
||
func (f *fakeVenue) EnsureMarket(context.Context, [32]byte) error { return nil }
|
||
func (f *fakeVenue) Place(context.Context, [32]byte, uint8, float64, float64, string) (uint64, error) {
|
||
return 1, nil
|
||
}
|
||
func (f *fakeVenue) Cancel(context.Context, [32]byte, uint64) error { return nil }
|
||
func (f *fakeVenue) Submit(_ context.Context, pool [32]byte, side uint8, isMarket bool, limit, size float64, user string) ([]zapwire.Fill, error) {
|
||
f.lastSubmit = submitArgs{pool, side, isMarket, limit, size, user}
|
||
return f.fills, nil
|
||
}
|
||
|
||
func startVenueWS(t *testing.T, v venue.Venue) string {
|
||
t.Helper()
|
||
h := NewVenueWS(v)
|
||
srv := httptest.NewServer(http.HandlerFunc(h.HandleConnection))
|
||
t.Cleanup(srv.Close)
|
||
return "ws" + strings.TrimPrefix(srv.URL, "http")
|
||
}
|
||
|
||
func dialWS(t *testing.T, url string) *websocket.Conn {
|
||
t.Helper()
|
||
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||
if err != nil {
|
||
t.Fatalf("WS dial: %v", err)
|
||
}
|
||
t.Cleanup(func() { conn.Close() })
|
||
return conn
|
||
}
|
||
|
||
type wsResp struct {
|
||
Type string `json:"type"`
|
||
Error string `json:"error"`
|
||
Fills []struct {
|
||
Price float64 `json:"price"`
|
||
Size float64 `json:"size"`
|
||
TakerSide string `json:"takerSide"`
|
||
} `json:"fills"`
|
||
CumQty float64 `json:"cumQty"`
|
||
}
|
||
|
||
func readWS(t *testing.T, conn *websocket.Conn) wsResp {
|
||
t.Helper()
|
||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||
var r wsResp
|
||
if err := conn.ReadJSON(&r); err != nil {
|
||
t.Fatalf("WS read: %v", err)
|
||
}
|
||
return r
|
||
}
|
||
|
||
// 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 * zapwire.PriceScale, Size: 10, TakerSide: zapwire.SideBuy},
|
||
{Price: 101 * zapwire.PriceScale, Size: 2, TakerSide: zapwire.SideBuy},
|
||
}}
|
||
conn := dialWS(t, startVenueWS(t, v))
|
||
|
||
if err := conn.WriteJSON(map[string]interface{}{
|
||
"type": "submit", "symbol": "LUX/LUSD", "side": "buy",
|
||
"orderQty": 12.0, "price": 101.0, "user": "alice", "clOrdId": "W1",
|
||
}); err != nil {
|
||
t.Fatalf("WS write: %v", err)
|
||
}
|
||
|
||
r := readWS(t, conn)
|
||
if r.Type != "fills" {
|
||
t.Fatalf("resp type=%q want fills (err=%q)", r.Type, r.Error)
|
||
}
|
||
if len(r.Fills) != 2 || r.CumQty != 12 {
|
||
t.Fatalf("fills=%+v cumQty=%v, want 2 fills / 12", r.Fills, r.CumQty)
|
||
}
|
||
if v.lastSubmit.pool != venue.SymbolToPoolID("LUX/LUSD") || v.lastSubmit.side != zapwire.SideBuy ||
|
||
v.lastSubmit.limit != 101 || v.lastSubmit.size != 12 || v.lastSubmit.user != "alice" {
|
||
t.Fatalf("submit args wrong: %+v", v.lastSubmit)
|
||
}
|
||
}
|
||
|
||
// TestVenueWSBadOrder proves a malformed order is reported as a typed error, never
|
||
// dropped or routed.
|
||
func TestVenueWSBadOrder(t *testing.T) {
|
||
v := &fakeVenue{}
|
||
conn := dialWS(t, startVenueWS(t, v))
|
||
|
||
// Missing side.
|
||
if err := conn.WriteJSON(map[string]interface{}{
|
||
"type": "submit", "symbol": "LUX/LUSD", "orderQty": 1.0, "price": 1.0,
|
||
}); err != nil {
|
||
t.Fatalf("WS write: %v", err)
|
||
}
|
||
r := readWS(t, conn)
|
||
if r.Type != "error" || r.Error == "" {
|
||
t.Fatalf("bad order must error, got %+v", r)
|
||
}
|
||
}
|