mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
dexd is now the ONLY command in the repo. One binary, two run modes:
dexd run as a luxd rpcchainvm PLUGIN. luxd execs the plugin from
dexd plugin its PluginDir with NO positional args (engine address arrives
via the runtime.EngineAddressKey env var) and probes the
version with `dexd version`. No-subcommand == plugin is
REQUIRED to preserve that contract — byte-for-byte the old
cmd/dchain behavior (rpc.Serve over the plugin transport).
dexd run run the STANDALONE D-Chain venue: open the on-disk zapdb,
dexd standalone serve the frozen dex_* surface over ZAP, and drive the
consensus sealer loop (WaitForEvent->BuildBlock->Verify->
Accept). -addr/-db flags preserved from the old cmd/dvenue.
Relocated logic (engine code NOT duplicated — pkg/dchain + pkg/lx untouched):
cmd/dchain/{main,runner} -> internal/plugin (plugin.Run)
cmd/dvenue/{main,engine_*} -> internal/standalone (standalone.Run + engineName)
cmd/dexd/main.go = thin run-mode dispatcher
Removed cmd dirs (12): dchain, dvenue, demo, dex-api-server, dex-server,
dexfillproof, dexseed, fix-gateway, gateway, monitor, persistent-server,
price-feed. Removed committed build artifacts: gateway, dex-server-linux.
Build/ship ONLY dexd:
- Dockerfile: canonical CPU image (CGO=0, ghcr.io/luxfi/dex) running
`dexd run`; no GPU/MLX required.
- Dockerfile.dvenue: GPU operator bundle repointed cmd/dvenue -> cmd/dexd.
- Deleted Dockerfile.{gateway,devnet,minimal,dvenue-cpu} and
docker/{Dockerfile,Dockerfile.cuda,backend/Dockerfile,compose-simple.yml}.
- Makefile rewritten to build/run/ship dexd only.
- docker.yml: dropped the dex-gateway image; dex-venue-image.yml: dvenue->dexd.
- Deleted legacy scripts that built removed/long-gone cmds; fixed deploy.sh +
dev-setup.sh; .gitignore ignores root build binaries.
Also included (coupled, environment-applied, settled): the in-progress
clob -> dex/orderbook terminology refactor across pkg/* + sdk/* + tests/*
(identifier + file renames, e.g. RegisterCLOB->RegisterDEX,
source_clob.go->source_orderbook.go). The relocated standalone venue calls the
renamed API, so it lands in the same consistent snapshot.
Verified (GOWORK=off CGO_ENABLED=0): `go build ./cmd/dexd`, `go build ./...`,
`go vet ./...` all exit 0; `go test ./pkg/dchain` (renamed RegisterDEX socket
path) green. GPU/cgo path not compiled locally (no CUDA); its rename mirrors
the verified nocgo twins and preserves the C ABI symbol names.
368 lines
13 KiB
Go
368 lines
13 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Package fix is a focused, dependency-free FIX 4.4 codec and session acceptor —
|
|
// the SERVER side of the FIX trading path, the protocol front-end the SDK's
|
|
// EnableFIX() client (sdk/go/client.go) talks to.
|
|
//
|
|
// It is ONE of four protocol front-ends onto the SAME matcher: the D-Chain DEX
|
|
// (pkg/dchain) served over the FROZEN dex_* ZAP surface (pkg/zapwire). ZAP
|
|
// clients speak dex_* directly; the 0x9010 V4 precompile speaks it via
|
|
// engine_zap; the WS server speaks it via a JSON translation; and FIX speaks it
|
|
// via this codec + acceptor. NONE of them owns a matcher — the acceptor decodes a
|
|
// FIX NewOrderSingle, calls the SAME dex_place / dex_submit the others call, and
|
|
// renders the consensus-computed result back as a FIX ExecutionReport. DRY,
|
|
// orthogonal: the matcher lives in ONE place and FIX is purely a wire translation.
|
|
//
|
|
// message.go is the wire codec: a FIX message is an ordered list of
|
|
// tag=value fields delimited by SOH (0x01). The codec computes and VALIDATES the
|
|
// two integrity fields every FIX message carries:
|
|
//
|
|
// - BodyLength (tag 9): the byte count from the field AFTER BodyLength up to and
|
|
// INCLUDING the SOH before CheckSum (tag 10). It is the second field, right
|
|
// after BeginString (tag 8).
|
|
// - CheckSum (tag 10): (sum of every byte up to and including the SOH before the
|
|
// CheckSum field) mod 256, rendered as a zero-padded 3-digit string. It is
|
|
// always the LAST field.
|
|
//
|
|
// This is the canonical FIX 4.4 framing. The SDK's EnableFIX() builder was a
|
|
// non-wire-correct stub (it omitted BodyLength and its sendFIXMessage was a
|
|
// no-op); this codec is the single correct definition of the format both sides
|
|
// use — emit spec-correct, parse spec-correct, integrity-checked.
|
|
package fix
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// SOH is the FIX field delimiter (Start Of Header, ASCII 0x01).
|
|
const SOH = '\x01'
|
|
|
|
// BeginString is the FIX 4.4 protocol identifier carried in tag 8.
|
|
const BeginString = "FIX.4.4"
|
|
|
|
// FIX tag numbers used by this gateway. Only the tags the order path needs are
|
|
// named; the codec carries arbitrary tags generically.
|
|
const (
|
|
TagBeginString = 8 // protocol version, e.g. "FIX.4.4"
|
|
TagBodyLength = 9 // body length (integrity)
|
|
TagMsgType = 35 // message type, e.g. "D" NewOrderSingle
|
|
TagSenderCompID = 49 // sender identity
|
|
TagTargetCompID = 56 // target identity
|
|
TagMsgSeqNum = 34 // sequence number
|
|
TagSendingTime = 52 // sending timestamp
|
|
TagCheckSum = 10 // checksum (integrity), always last
|
|
TagClOrdID = 11 // client order id
|
|
TagOrderID = 37 // exchange order id
|
|
TagExecID = 17 // execution id
|
|
TagOrdStatus = 39 // order status
|
|
TagExecType = 150 // execution type
|
|
TagSymbol = 55 // instrument
|
|
TagSide = 54 // 1=buy 2=sell
|
|
TagOrderQty = 38 // quantity
|
|
TagOrdType = 40 // 1=market 2=limit
|
|
TagPrice = 44 // limit price
|
|
TagTimeInForce = 59 // 0=day 3=IOC ...
|
|
TagTransactTime = 60 // transaction time
|
|
TagLastQty = 32 // qty of this fill
|
|
TagLastPx = 31 // price of this fill
|
|
TagLeavesQty = 151 // unfilled remaining qty
|
|
TagCumQty = 14 // cumulative filled qty
|
|
TagAvgPx = 6 // average fill price
|
|
TagText = 58 // free text (reject reason)
|
|
TagOrigClOrdID = 41 // original ClOrdID (cancel)
|
|
TagTestReqID = 112 // test request id (heartbeat challenge)
|
|
TagEncryptMethod = 98 // logon: encryption method (0=none)
|
|
TagHeartBtInt = 108 // logon: heartbeat interval (seconds)
|
|
)
|
|
|
|
// MsgType values (tag 35).
|
|
const (
|
|
MsgTypeHeartbeat = "0"
|
|
MsgTypeTestRequest = "1"
|
|
MsgTypeReject = "3"
|
|
MsgTypeLogout = "5"
|
|
MsgTypeExecutionReport = "8"
|
|
MsgTypeLogon = "A"
|
|
MsgTypeNewOrderSingle = "D"
|
|
MsgTypeOrderCancelReq = "F"
|
|
MsgTypeOrderCancelReject = "9"
|
|
)
|
|
|
|
// Side values (tag 54).
|
|
const (
|
|
SideBuy = "1"
|
|
SideSell = "2"
|
|
)
|
|
|
|
// OrdType values (tag 40).
|
|
const (
|
|
OrdTypeMarket = "1"
|
|
OrdTypeLimit = "2"
|
|
)
|
|
|
|
// ExecType / OrdStatus values (tags 150 / 39). FIX 4.4 uses the same alphabet for
|
|
// the common subset this gateway emits: 0=New, 1=PartialFill, 2=Fill, 4=Canceled,
|
|
// 8=Rejected.
|
|
const (
|
|
ExecTypeNew = "0"
|
|
ExecTypePartialFill = "1"
|
|
ExecTypeFill = "2"
|
|
ExecTypeCanceled = "4"
|
|
ExecTypeRejected = "8"
|
|
|
|
OrdStatusNew = "0"
|
|
OrdStatusPartialFill = "1"
|
|
OrdStatusFilled = "2"
|
|
OrdStatusCanceled = "4"
|
|
OrdStatusRejected = "8"
|
|
)
|
|
|
|
// ErrMalformed is returned when bytes are not a parseable FIX frame.
|
|
var ErrMalformed = errors.New("fix: malformed message")
|
|
|
|
// ErrChecksum is returned when a frame's CheckSum (tag 10) does not match.
|
|
var ErrChecksum = errors.New("fix: checksum mismatch")
|
|
|
|
// ErrBodyLength is returned when a frame's BodyLength (tag 9) does not match.
|
|
var ErrBodyLength = errors.New("fix: body length mismatch")
|
|
|
|
// field is one tag=value pair, preserving wire order.
|
|
type field struct {
|
|
tag int
|
|
val string
|
|
}
|
|
|
|
// Message is an ordered FIX message: a list of tag=value fields. The integrity
|
|
// fields (8/9/10) are NOT stored in fields — they are derived at Serialize time
|
|
// and verified at Parse time, so a Message describes the SEMANTIC content (msg
|
|
// type + body) independent of framing. Order of body fields is preserved.
|
|
type Message struct {
|
|
fields []field
|
|
index map[int]string // last value per tag, for O(1) Get
|
|
}
|
|
|
|
// NewMessage builds an empty message of the given MsgType (tag 35). MsgType is
|
|
// always the FIRST body field after the standard header in FIX, which Serialize
|
|
// enforces.
|
|
func NewMessage(msgType string) *Message {
|
|
m := &Message{index: make(map[int]string)}
|
|
m.Set(TagMsgType, msgType)
|
|
return m
|
|
}
|
|
|
|
// Set appends (or, for the integrity/header-derived tags, would replace) a field.
|
|
// It preserves insertion order for body fields and updates the lookup index. The
|
|
// caller must not Set tags 8/9/10 — they are framing, computed by Serialize.
|
|
func (m *Message) Set(tag int, val string) *Message {
|
|
if tag == TagBeginString || tag == TagBodyLength || tag == TagCheckSum {
|
|
// Framing tags are derived, never set on the semantic message.
|
|
return m
|
|
}
|
|
// MsgType is unique and must stay first; replace in place if already present.
|
|
if tag == TagMsgType {
|
|
if _, ok := m.index[tag]; ok {
|
|
for i := range m.fields {
|
|
if m.fields[i].tag == tag {
|
|
m.fields[i].val = val
|
|
}
|
|
}
|
|
m.index[tag] = val
|
|
return m
|
|
}
|
|
}
|
|
m.fields = append(m.fields, field{tag: tag, val: val})
|
|
m.index[tag] = val
|
|
return m
|
|
}
|
|
|
|
// SetInt is Set for an integer value.
|
|
func (m *Message) SetInt(tag int, v int) *Message { return m.Set(tag, strconv.Itoa(v)) }
|
|
|
|
// Get returns the last value for a tag and whether it was present.
|
|
func (m *Message) Get(tag int) (string, bool) {
|
|
v, ok := m.index[tag]
|
|
return v, ok
|
|
}
|
|
|
|
// GetInt returns a tag's value parsed as int. Missing or non-numeric => error.
|
|
func (m *Message) GetInt(tag int) (int, error) {
|
|
v, ok := m.index[tag]
|
|
if !ok {
|
|
return 0, fmt.Errorf("fix: missing tag %d", tag)
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("fix: tag %d not an int: %q", tag, v)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// GetFloat returns a tag's value parsed as float64. Missing or non-numeric =>
|
|
// error.
|
|
func (m *Message) GetFloat(tag int) (float64, error) {
|
|
v, ok := m.index[tag]
|
|
if !ok {
|
|
return 0, fmt.Errorf("fix: missing tag %d", tag)
|
|
}
|
|
f, err := strconv.ParseFloat(v, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("fix: tag %d not a float: %q", tag, v)
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// MsgType returns the message's tag-35 value (empty if absent).
|
|
func (m *Message) MsgType() string {
|
|
v, _ := m.index[TagMsgType]
|
|
return v
|
|
}
|
|
|
|
// Serialize renders the message to canonical FIX 4.4 wire bytes with computed
|
|
// BodyLength (tag 9) and CheckSum (tag 10). Layout:
|
|
//
|
|
// 8=FIX.4.4 <SOH> 9=<bodyLen> <SOH> <body...> 10=<checksum> <SOH>
|
|
//
|
|
// where <body...> is 35=<type> followed by every Set field IN ORDER, and
|
|
// bodyLen counts the bytes of <body...> exactly (from the first byte after the
|
|
// 9=...<SOH> up to and including the <SOH> preceding 10=).
|
|
func (m *Message) Serialize() []byte {
|
|
var body strings.Builder
|
|
// MsgType (35) must lead the body; Set keeps it first, but enforce defensively.
|
|
if mt, ok := m.index[TagMsgType]; ok {
|
|
body.WriteString(strconv.Itoa(TagMsgType))
|
|
body.WriteByte('=')
|
|
body.WriteString(mt)
|
|
body.WriteByte(SOH)
|
|
}
|
|
for _, f := range m.fields {
|
|
if f.tag == TagMsgType {
|
|
continue // already written first
|
|
}
|
|
body.WriteString(strconv.Itoa(f.tag))
|
|
body.WriteByte('=')
|
|
body.WriteString(f.val)
|
|
body.WriteByte(SOH)
|
|
}
|
|
bodyBytes := body.String()
|
|
|
|
var head strings.Builder
|
|
head.WriteString(strconv.Itoa(TagBeginString))
|
|
head.WriteByte('=')
|
|
head.WriteString(BeginString)
|
|
head.WriteByte(SOH)
|
|
head.WriteString(strconv.Itoa(TagBodyLength))
|
|
head.WriteByte('=')
|
|
head.WriteString(strconv.Itoa(len(bodyBytes)))
|
|
head.WriteByte(SOH)
|
|
|
|
prefix := head.String() + bodyBytes
|
|
cs := checksum([]byte(prefix))
|
|
return []byte(fmt.Sprintf("%s%d=%03d%c", prefix, TagCheckSum, cs, SOH))
|
|
}
|
|
|
|
// checksum is the FIX integrity checksum: sum of all bytes mod 256.
|
|
func checksum(b []byte) int {
|
|
var sum int
|
|
for _, c := range b {
|
|
sum += int(c)
|
|
}
|
|
return sum % 256
|
|
}
|
|
|
|
// Parse decodes one complete FIX frame, VALIDATING BodyLength (tag 9) and
|
|
// CheckSum (tag 10). It returns the semantic Message (framing tags stripped). A
|
|
// frame MUST begin with 8=, carry 9= second, end with a well-formed 10= field,
|
|
// and its integrity fields must match the content exactly — otherwise an error
|
|
// (ErrMalformed / ErrBodyLength / ErrChecksum) is returned. This is the strict
|
|
// acceptor-side check: a corrupt or truncated frame is rejected, never matched.
|
|
func Parse(raw []byte) (*Message, error) {
|
|
if len(raw) == 0 {
|
|
return nil, fmt.Errorf("%w: empty", ErrMalformed)
|
|
}
|
|
// Split into tag=value tokens on SOH. A valid frame ends with SOH, so the
|
|
// final split element is empty and dropped.
|
|
s := string(raw)
|
|
if s[len(s)-1] != SOH {
|
|
return nil, fmt.Errorf("%w: no trailing SOH", ErrMalformed)
|
|
}
|
|
tokens := strings.Split(s[:len(s)-1], string(SOH))
|
|
if len(tokens) < 3 {
|
|
return nil, fmt.Errorf("%w: too few fields", ErrMalformed)
|
|
}
|
|
|
|
type kv struct {
|
|
tag int
|
|
val string
|
|
}
|
|
parsed := make([]kv, 0, len(tokens))
|
|
for _, t := range tokens {
|
|
eq := strings.IndexByte(t, '=')
|
|
if eq <= 0 {
|
|
return nil, fmt.Errorf("%w: bad field %q", ErrMalformed, t)
|
|
}
|
|
tag, err := strconv.Atoi(t[:eq])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: bad tag %q", ErrMalformed, t[:eq])
|
|
}
|
|
parsed = append(parsed, kv{tag: tag, val: t[eq+1:]})
|
|
}
|
|
|
|
// Mandatory positional fields: 8 first, 9 second, 10 last.
|
|
if parsed[0].tag != TagBeginString {
|
|
return nil, fmt.Errorf("%w: first field is tag %d, want 8", ErrMalformed, parsed[0].tag)
|
|
}
|
|
if parsed[0].val != BeginString {
|
|
return nil, fmt.Errorf("%w: BeginString=%q, want %q", ErrMalformed, parsed[0].val, BeginString)
|
|
}
|
|
if parsed[1].tag != TagBodyLength {
|
|
return nil, fmt.Errorf("%w: second field is tag %d, want 9", ErrMalformed, parsed[1].tag)
|
|
}
|
|
last := parsed[len(parsed)-1]
|
|
if last.tag != TagCheckSum {
|
|
return nil, fmt.Errorf("%w: last field is tag %d, want 10", ErrMalformed, last.tag)
|
|
}
|
|
|
|
// --- CheckSum: recompute over everything up to and including the SOH before
|
|
// the 10= field, compare to the carried 3-digit value. ---
|
|
csFieldStr := fmt.Sprintf("%d=%s%c", TagCheckSum, last.val, SOH)
|
|
prefix := raw[:len(raw)-len(csFieldStr)]
|
|
wantCS, err := strconv.Atoi(last.val)
|
|
if err != nil || len(last.val) != 3 {
|
|
return nil, fmt.Errorf("%w: bad checksum field %q", ErrMalformed, last.val)
|
|
}
|
|
if got := checksum(prefix); got != wantCS {
|
|
return nil, fmt.Errorf("%w: got %03d, frame carried %03d", ErrChecksum, got, wantCS)
|
|
}
|
|
|
|
// --- BodyLength: the carried 9= value must equal the byte length of the body
|
|
// (everything after the 9=...SOH up to and including the SOH before 10=). ---
|
|
wantBL, err := strconv.Atoi(parsed[1].val)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: bad body length %q", ErrMalformed, parsed[1].val)
|
|
}
|
|
headStr := fmt.Sprintf("%d=%s%c%d=%s%c",
|
|
TagBeginString, parsed[0].val, SOH, TagBodyLength, parsed[1].val, SOH)
|
|
bodyLen := len(prefix) - len(headStr)
|
|
if bodyLen != wantBL {
|
|
return nil, fmt.Errorf("%w: header says %d, body is %d bytes", ErrBodyLength, wantBL, bodyLen)
|
|
}
|
|
|
|
// Build the semantic message: every field except the framing tags 8/9/10.
|
|
m := &Message{index: make(map[int]string)}
|
|
for _, p := range parsed {
|
|
if p.tag == TagBeginString || p.tag == TagBodyLength || p.tag == TagCheckSum {
|
|
continue
|
|
}
|
|
m.fields = append(m.fields, field{tag: p.tag, val: p.val})
|
|
m.index[p.tag] = p.val
|
|
}
|
|
if m.MsgType() == "" {
|
|
return nil, fmt.Errorf("%w: missing MsgType (tag 35)", ErrMalformed)
|
|
}
|
|
return m, nil
|
|
}
|