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]>
183 lines
6.3 KiB
Go
183 lines
6.3 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
package api
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/gorilla/websocket"
|
||
"github.com/luxfi/dex/pkg/venue"
|
||
"github.com/luxfi/dex/pkg/zapwire"
|
||
)
|
||
|
||
// venue_ws.go is the WebSocket trading FRONT-END onto THE matcher — the WS
|
||
// counterpart of the FIX acceptor (pkg/fix) and the 0x9010 precompile. It does
|
||
// ONE thing: accept a JSON order over /ws, translate it into a venue.Venue call
|
||
// (the SAME dex_* surface ZAP/FIX/precompile use), and stream the
|
||
// consensus-computed fills back as JSON. It owns NO matcher and NO book — that is
|
||
// the whole point of the decomplection: every access path is a thin wire dialect
|
||
// onto the single D-Chain DEX.
|
||
//
|
||
// This is deliberately separate from WebSocketServer (this file), which drives the
|
||
// in-process dex.TradingEngine for the rich margin/lending/vault product surface.
|
||
// The two are orthogonal: WebSocketServer is the product API; VenueWS is the
|
||
// on-chain DEX trading path. Mixing the two matchers into one handler would braid
|
||
// "what the product does" with "where the order book lives"; keeping VenueWS a
|
||
// focused Venue translator keeps the on-chain path identical across all four
|
||
// access methods.
|
||
|
||
// VenueWS is a minimal WebSocket handler that routes orders to a venue.Venue. One
|
||
// VenueWS serves many connections; each connection is independent.
|
||
type VenueWS struct {
|
||
venue venue.Venue
|
||
upgrader websocket.Upgrader
|
||
}
|
||
|
||
// NewVenueWS builds a WS venue front-end over the given matcher seam.
|
||
func NewVenueWS(v venue.Venue) *VenueWS {
|
||
return &VenueWS{
|
||
venue: v,
|
||
upgrader: websocket.Upgrader{
|
||
// The DEX trading path serves both browsers (allow-listed origins, the
|
||
// CSRF surface) and non-browser trading clients (server-to-server, CLI,
|
||
// the SDK) that send NO Origin header. An absent Origin is therefore
|
||
// allowed (there is no browser to forge a cross-site request); a present
|
||
// Origin must be on the allow-list. This mirrors how exchange order
|
||
// gateways treat machine clients vs. browser sessions.
|
||
CheckOrigin: venueCheckOrigin,
|
||
ReadBufferSize: 1024,
|
||
WriteBufferSize: 1024,
|
||
},
|
||
}
|
||
}
|
||
|
||
// venueOrder is the inbound JSON order over the WS venue path. It mirrors the FIX
|
||
// NewOrderSingle fields and the dex_submit frame: a marketable order bounded by
|
||
// an optional limit price that crosses the resting book.
|
||
type venueOrder struct {
|
||
Type string `json:"type"` // "submit" (marketable) — the cross primitive
|
||
Symbol string `json:"symbol"` // market, e.g. "LUX/LUSD"
|
||
Side string `json:"side"` // "buy" | "sell"
|
||
OrderQty float64 `json:"orderQty"` // base quantity
|
||
Price float64 `json:"price"` // limit; 0 or omitted with isMarket => market
|
||
IsMarket bool `json:"isMarket"` // true => sweep unconditionally
|
||
User string `json:"user"` // account whose ledger settles
|
||
ClOrdID string `json:"clOrdId"` // client handle echoed back
|
||
}
|
||
|
||
// venueFill is one fill rendered back to the WS client.
|
||
type venueFill struct {
|
||
Price float64 `json:"price"`
|
||
Size float64 `json:"size"`
|
||
TakerSide string `json:"takerSide"`
|
||
}
|
||
|
||
// venueResponse is the WS reply: the order ack plus the consensus-computed fills.
|
||
type venueResponse struct {
|
||
Type string `json:"type"` // "fills" | "error"
|
||
ClOrdID string `json:"clOrdId,omitempty"`
|
||
Symbol string `json:"symbol,omitempty"`
|
||
Fills []venueFill `json:"fills,omitempty"`
|
||
CumQty float64 `json:"cumQty,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
Timestamp int64 `json:"timestamp"`
|
||
}
|
||
|
||
// HandleConnection upgrades an HTTP request to a WebSocket and serves the venue
|
||
// order loop until the peer disconnects. Each inbound JSON order is translated to
|
||
// venue.Venue.Submit and answered with its fills.
|
||
func (h *VenueWS) HandleConnection(w http.ResponseWriter, r *http.Request) {
|
||
conn, err := h.upgrader.Upgrade(w, r, nil)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer conn.Close()
|
||
|
||
for {
|
||
var ord venueOrder
|
||
if err := conn.ReadJSON(&ord); err != nil {
|
||
return // peer closed or bad frame
|
||
}
|
||
h.handleOrder(r.Context(), conn, ord)
|
||
}
|
||
}
|
||
|
||
// handleOrder translates one JSON order into a Venue submit and writes the fills
|
||
// back. A bad order or a venue error is reported as a typed error response, never
|
||
// a silent drop.
|
||
func (h *VenueWS) handleOrder(ctx context.Context, conn *websocket.Conn, ord venueOrder) {
|
||
if ord.Symbol == "" {
|
||
h.writeErr(conn, ord.ClOrdID, "missing symbol")
|
||
return
|
||
}
|
||
var side uint8
|
||
switch ord.Side {
|
||
case "buy", "BUY":
|
||
side = zapwire.SideBuy
|
||
case "sell", "SELL":
|
||
side = zapwire.SideSell
|
||
default:
|
||
h.writeErr(conn, ord.ClOrdID, fmt.Sprintf("bad side %q", ord.Side))
|
||
return
|
||
}
|
||
if ord.OrderQty <= 0 {
|
||
h.writeErr(conn, ord.ClOrdID, "non-positive orderQty")
|
||
return
|
||
}
|
||
if !ord.IsMarket && ord.Price <= 0 {
|
||
h.writeErr(conn, ord.ClOrdID, "limit order needs a positive price")
|
||
return
|
||
}
|
||
|
||
poolID := venue.SymbolToPoolID(ord.Symbol)
|
||
user := venue.UserHandle(ord.User)
|
||
fills, err := h.venue.Submit(ctx, poolID, side, ord.IsMarket, ord.Price, ord.OrderQty, user)
|
||
if err != nil {
|
||
h.writeErr(conn, ord.ClOrdID, fmt.Sprintf("venue submit: %v", err))
|
||
return
|
||
}
|
||
|
||
resp := venueResponse{
|
||
Type: "fills",
|
||
ClOrdID: ord.ClOrdID,
|
||
Symbol: ord.Symbol,
|
||
Fills: make([]venueFill, 0, len(fills)),
|
||
Timestamp: time.Now().UnixMilli(),
|
||
}
|
||
for _, f := range fills {
|
||
// 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)
|
||
}
|
||
|
||
func (h *VenueWS) writeErr(conn *websocket.Conn, clOrdID, msg string) {
|
||
_ = conn.WriteJSON(venueResponse{Type: "error", ClOrdID: clOrdID, Error: msg, Timestamp: time.Now().UnixMilli()})
|
||
}
|
||
|
||
func sideStr(s uint8) string {
|
||
if s == zapwire.SideBuy {
|
||
return "buy"
|
||
}
|
||
return "sell"
|
||
}
|
||
|
||
// venueCheckOrigin allows non-browser clients (no Origin header) and browser
|
||
// clients from an allow-listed origin. A present-but-disallowed Origin is
|
||
// refused.
|
||
func venueCheckOrigin(r *http.Request) bool {
|
||
origin := r.Header.Get("Origin")
|
||
if origin == "" {
|
||
return true // non-browser client (SDK / server-to-server)
|
||
}
|
||
return isOriginAllowed(origin)
|
||
}
|