mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
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.
179 lines
6.2 KiB
Go
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)
|
|
}
|