Files
dex/pkg/api/venue_ws.go
zeekay baa275e80f feat(dex): FIX 4.4 acceptor + unified 4-path trading e2e
Adds the missing SERVER side of the FIX trading path and a unified e2e
proving REAL fills over all four DEX access paths against ONE book.

The decomplection: the D-Chain CLOB (pkg/dchain) is the single matcher,
reached over the single frozen clob_* ZAP surface (pkg/zapwire). Every
access path is a thin wire dialect onto that surface — none owns a
matcher. This commit adds the two missing dialects (FIX, a focused WS
venue handler) and the shared seam they translate through.

New:
- pkg/venue: ONE Venue interface (ensure/place/cancel/submit = the
  clob_* ops) + ONE production ZAP-backed impl. The seam FIX, WS, and
  the 0x9010 precompile all route through. Dials the SAME rpc.Listen
  socket dvenue serves.
- pkg/fix: dependency-free FIX 4.4 codec (BodyLength tag 9 + CheckSum
  tag 10 computed AND validated; spec-correct emit/parse) + a TCP
  acceptor (Logon/Heartbeat/TestRequest/Logout, monotonic seq,
  NewOrderSingle->clob_submit->ExecutionReport, OrderCancelRequest->
  clob_cancel). Pure protocol front-end onto Venue; no matcher.
- pkg/api/venue_ws.go: minimal gorilla/websocket /ws handler that
  translates a JSON order to Venue.Submit and streams fills back —
  the WS dialect onto the SAME venue (kept orthogonal to the existing
  TradingEngine product WS server).
- cmd/fix-gateway: runs the acceptor in front of a live venue
  (-venue clob_* endpoint), the production form of the FIX path.
- pkg/dchain/fourpath_e2e_test.go: stands up ONE venue + ONE LUX/LUSD
  book and drives a real limit-order -> match -> FILL over EACH path
  (V4 precompile engine_zap frame | ZAP native | FIX | WS) against the
  SAME resting asks, asserting real consensus fills, per-path custody
  changes, and GLOBAL conservation (available+locked invariant).

V4 leg: exercised at the engine_zap WIRE boundary — the exact 66-byte
clob_submit frame engine_zap.Swap emits (asserted byte-identical to
zapwire.EncodeSubmit) + the V4 BalanceDelta sign convention derived from
the consensus fills. A live luxd EVM (StateDB replay-idempotency, gas,
PoolManager) is what the full on-chain precompile call additionally
needs; the matcher boundary is proven in-process here.

No deps added; no existing files modified (go.mod/go.sum untouched).
Builds CGO_ENABLED=0; race-clean.
2026-06-19 13:05:58 -07:00

179 lines
6.2 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 clob_* 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 CLOB.
//
// This is deliberately separate from WebSocketServer (this file), which drives the
// in-process lx.TradingEngine for the rich margin/lending/vault product surface.
// The two are orthogonal: WebSocketServer is the product API; VenueWS is the
// on-chain CLOB 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 CLOB 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 clob_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 {
resp.CumQty += f.Size
resp.Fills = append(resp.Fills, venueFill{Price: f.Price, Size: f.Size, 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)
}