Files
dex/pkg/lx/persist.go
zeekay 9df775cca2 dchain: fail-closed settlement identity, ownership invariant, no-collision keys
orderuser->full 16-byte user is the only settlement identity; 8-byte UserID is a matcher/STP handle only, never a money key. Missing orderuser fails closed (ErrMissingSettlementUser), no matcher-handle fallback. Ownership invariant harness alongside conservation (catches value-conserving theft). txHash-ref custody idempotency closes vault-drain/strand. Reject zero-lock orders; persist orderuser before book insert; restart asserts coverage.
2026-06-19 11:34:41 -07:00

426 lines
15 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package lx
import (
"encoding/binary"
"math"
"sort"
"unsafe"
)
// persist.go is the DEXOrder <-> OrderBook bridge: the deterministic, flat-row
// serialization the d-chain VM uses to persist a book to durable storage and to
// rebuild it on restart. pkg/lx has no other persistence; this is the single,
// canonical encoding.
//
// THE ROW IS THE GPU ABI. A persisted resting order is a 64-byte DEXOrder
// (orderbook_cuda_types.go) — the exact, byte-for-byte layout the CUDA/HIP/Metal
// matching kernels consume. Using one representation for "the order as stored"
// and "the order as fed to the matcher" means there is exactly one row format in
// the system: zero divergence between what consensus commits and what the
// accelerator matches.
//
// PRICE/SIZE ARE Q64.64 FIXED-POINT, NEVER float64. The book's in-RAM Order
// carries float64 Price/Size for the legacy API, but float64 is NOT
// byte-reproducible across architectures (x86_64 vs aarch64 rounding differs at
// the ULP), so persisting or merkleizing the float would fork consensus across a
// heterogeneous validator set. The row therefore stores the integer Q64.64 form
// (DEXPrice{Integer, Fraction}) and an integer base quantity in 1e8 fixed units.
// The conversion here is the ONE place float64 crosses into the deterministic
// domain, and it is a pure, total, deterministic function of the float bits.
//
// USER IDENTITY IN A ROW IS 8 BYTES. DEXOrder.UserID is a uint64 (the GPU ABI's
// user field, used for on-device self-trade prevention). The wire/protocol user
// is a 16-byte field and the book's Order.User is a string; the row folds that to
// the leading 8 bytes (big-endian). This is sufficient for the matcher's STP and
// for rebuilding price-time priority. The CANONICAL 16-byte settlement identity is
// NOT a property of the order-book row — it lives in d-chain state: the custody
// LEDGER keys balance:/locked: by the full user[16] (so two addresses sharing a
// leading 8-byte prefix are DISTINCT accounts, never able to drain each other),
// the idempotency index keys on user[16]+seq, and orderuser:<orderID> carries each
// resting order's full user[16] so a maker fill credits the right account even
// after a row-rebuilt restart truncated the in-RAM user to 8 bytes. Keeping the
// row at the GPU ABI's 8-byte user is the decomplected choice: the book row models
// matching, d-chain state models settlement identity.
// QuantityScale is the fixed-point scale for the integer base quantity stored in
// a DEXOrder row (Quantity / Remaining). A float64 size s is stored as
// round(s * 1e8); 1e8 matches PriceMultiplier so the integer domain is uniform
// across price and size. The matcher's resting quantities are always
// non-negative, so the unsigned domain is exact for any size representable in
// 1e8 units up to ~1.8e11 base units.
const QuantityScale = float64(PriceMultiplier) // 1e8
// floatToFixedPrice converts a float64 price to the Q64.64 DEXPrice form
// deterministically. The integer part is the floor; the fractional part is the
// remaining mantissa scaled by 2^64 and rounded to nearest. This is a pure
// function of the IEEE-754 value: every architecture that agrees on the float
// bits (which they do for a value carried verbatim on the wire) computes the
// identical {Integer, Fraction}. Negative or non-finite inputs clamp to zero;
// the matcher rejects those upstream so they never reach a persisted row.
func floatToFixedPrice(price float64) DEXPrice {
if !(price > 0) { // also catches NaN
return DEXPrice{}
}
intPart := math.Floor(price)
if intPart > math.MaxUint64 {
// Saturate rather than wrap; safePriceToInt already bounds real prices
// far below this, so this is a defensive clamp, not a live path.
return DEXPrice{Integer: math.MaxUint64, Fraction: math.MaxUint64}
}
frac := price - intPart
// frac in [0,1); scale to the 64-bit fraction domain. math.Round gives the
// nearest integer; 2^64 is represented exactly as a float64.
fracUnits := math.Round(frac * 18446744073709551616.0) // 2^64
var fraction uint64
if fracUnits >= 18446744073709551616.0 {
// Rounding pushed it to a full unit: carry into the integer part.
return DEXPrice{Integer: uint64(intPart) + 1, Fraction: 0}
}
fraction = uint64(fracUnits)
return DEXPrice{Integer: uint64(intPart), Fraction: fraction}
}
// fixedPriceToFloat converts a Q64.64 DEXPrice back to float64. This is the
// inverse used only to repopulate the in-RAM Order.Price when rebuilding the
// book from rows; the deterministic state (rows, merkle leaves) always uses the
// integer form, so any float rounding here never affects consensus.
func fixedPriceToFloat(p DEXPrice) float64 {
return float64(p.Integer) + float64(p.Fraction)/18446744073709551616.0 // /2^64
}
// floatToFixedQty converts a float64 base quantity to the integer 1e8 fixed-unit
// form stored in a row. Rounds to nearest; clamps a negative/NaN to zero.
func floatToFixedQty(qty float64) uint64 {
if !(qty > 0) {
return 0
}
scaled := math.Round(qty * QuantityScale)
if scaled >= math.MaxUint64 {
return math.MaxUint64
}
return uint64(scaled)
}
// fixedQtyToFloat converts an integer 1e8 fixed-unit quantity back to float64.
func fixedQtyToFloat(q uint64) float64 {
return float64(q) / QuantityScale
}
// userToUint64 folds a user identity string to the row's 8-byte UserID
// (big-endian over the leading 8 bytes, zero-padded). Deterministic and total.
func userToUint64(user string) uint64 {
var b [8]byte
copy(b[:], user)
return binary.BigEndian.Uint64(b[:])
}
// uint64ToUser renders an 8-byte UserID back to the trimmed string form used by
// the in-RAM book. Trailing NULs are removed so a short user round-trips to its
// original string (the common case where the user fits in 8 bytes).
func uint64ToUser(id uint64) string {
var b [8]byte
binary.BigEndian.PutUint64(b[:], id)
n := 8
for n > 0 && b[n-1] == 0 {
n--
}
return string(b[:n])
}
// sideToDEX maps the book Side to the DEXOrder side byte.
func sideToDEX(s Side) uint8 {
if s == Sell {
return DEXSideAsk
}
return DEXSideBid
}
// dexToSide maps a DEXOrder side byte back to the book Side.
func dexToSide(s uint8) Side {
if s == DEXSideAsk {
return Sell
}
return Buy
}
// typeToDEX maps the book OrderType to the DEXOrder type byte. Only the resting
// forms are persistable (a resting order is always a Limit by construction in
// the consensus CLOB path); Market/IOC/FOK takers never rest and so never reach
// a row. Anything non-Limit maps to DEXOrderLimit defensively.
func typeToDEX(t OrderType) uint8 {
switch t {
case Market:
return DEXOrderMarket
default:
return DEXOrderLimit
}
}
// dexToType maps a DEXOrder type byte back to the book OrderType.
func dexToType(t uint8) OrderType {
switch t {
case DEXOrderMarket:
return Market
default:
return Limit
}
}
// statusToDEX maps a book status string to the DEXOrder status byte.
func statusToDEX(status string, remaining, size float64) uint8 {
switch status {
case Canceled, StatusCancelled:
return DEXStatusCancelled
case Filled:
return DEXStatusFilled
}
if remaining < size {
return DEXStatusPartial
}
return DEXStatusOpen
}
// dexToStatus maps a DEXOrder status byte back to a book status string.
func dexToStatus(s uint8) string {
switch s {
case DEXStatusFilled:
return Filled
case DEXStatusCancelled:
return Canceled
case DEXStatusPartial:
return PartiallyFilled
default:
return Open
}
}
// OrderToRow converts a resting Order to its canonical 64-byte DEXOrder row. The
// price/size are committed to the deterministic Q64.64 / 1e8-integer domain; the
// padding is zeroed so two equal orders always produce byte-identical rows.
func OrderToRow(o *Order) DEXOrder {
remaining := o.RemainingSize
if remaining == 0 && o.Size > 0 && o.Filled == 0 {
remaining = o.Size
}
user := o.User
if user == "" {
user = o.UserID
}
row := DEXOrder{
OrderID: o.ID,
UserID: userToUint64(user),
Price: floatToFixedPrice(o.Price),
Quantity: floatToFixedQty(o.Size),
Remaining: floatToFixedQty(remaining),
Timestamp: uint64(o.Timestamp.UnixNano()),
Side: sideToDEX(o.Side),
Type: typeToDEX(o.Type),
Status: statusToDEX(o.Status, remaining, o.Size),
}
row.ClearPadding()
return row
}
// RowToOrder reconstructs an in-RAM Order from a canonical DEXOrder row. The
// deterministic integer fields are the source of truth; the float Price/Size are
// derived for the legacy book API only. Timestamp is rebuilt from the stored
// unix-nanos so the resting order keeps its consensus-assigned, block-derived
// stamp (price-time priority is preserved exactly across a restart).
func RowToOrder(row DEXOrder) *Order {
user := uint64ToUser(row.UserID)
return &Order{
ID: row.OrderID,
Type: dexToType(row.Type),
Side: dexToSide(row.Side),
Price: fixedPriceToFloat(row.Price),
Size: fixedQtyToFloat(row.Quantity),
RemainingSize: fixedQtyToFloat(row.Remaining),
Timestamp: nanosToTime(int64(row.Timestamp)),
User: user,
UserID: user,
Status: dexToStatus(row.Status),
}
}
// EncodeRow writes a DEXOrder as its 64 byte on-disk value. The bytes are the
// raw little-endian struct image (matching the GPU ABI's in-memory layout on the
// little-endian targets the venue runs); a fixed 64-byte record means a row is a
// single LSM value with zero framing overhead.
func EncodeRow(row DEXOrder) []byte {
row.ClearPadding()
out := make([]byte, unsafe.Sizeof(DEXOrder{}))
*(*DEXOrder)(unsafe.Pointer(&out[0])) = row
return out
}
// DecodeRow reads a 64-byte on-disk value back into a DEXOrder. Returns ok=false
// for any value that is not exactly the row width, so a truncated/garbage record
// can never be silently reinterpreted into a different order.
func DecodeRow(b []byte) (DEXOrder, bool) {
if uintptr(len(b)) != unsafe.Sizeof(DEXOrder{}) {
return DEXOrder{}, false
}
row := *(*DEXOrder)(unsafe.Pointer(&b[0]))
row.ClearPadding()
return row, true
}
// EncodeTrade writes a DEXTrade as its 80 byte on-disk value (the trade-log
// record). Same flat-image discipline as EncodeRow.
func EncodeTrade(t DEXTrade) []byte {
t.ClearPadding()
out := make([]byte, unsafe.Sizeof(DEXTrade{}))
*(*DEXTrade)(unsafe.Pointer(&out[0])) = t
return out
}
// DecodeTrade reads an 80-byte on-disk value back into a DEXTrade.
func DecodeTrade(b []byte) (DEXTrade, bool) {
if uintptr(len(b)) != unsafe.Sizeof(DEXTrade{}) {
return DEXTrade{}, false
}
t := *(*DEXTrade)(unsafe.Pointer(&b[0]))
t.ClearPadding()
return t, true
}
// TradeToRow converts a matched Trade to its canonical 80-byte DEXTrade row,
// committing price/size to the deterministic domain. takerSide is the side the
// taker submitted with (the resting maker is the opposite side); it is supplied
// by the caller because the in-RAM Trade does not always carry it.
func TradeToRow(t Trade, takerSide Side) DEXTrade {
row := DEXTrade{
TradeID: t.ID,
MakerUserID: userToUint64(makerUser(t, takerSide)),
TakerUserID: userToUint64(takerUser(t, takerSide)),
Price: floatToFixedPrice(t.Price),
Quantity: floatToFixedQty(t.Size),
Timestamp: uint64(t.Timestamp.UnixNano()),
TakerSide: sideToDEX(takerSide),
}
// Maker/taker order IDs: the buy order is BuyOrder, the sell is SellOrder;
// the taker is whichever matches takerSide.
if takerSide == Buy {
row.TakerOrderID = t.BuyOrder
row.MakerOrderID = t.SellOrder
} else {
row.TakerOrderID = t.SellOrder
row.MakerOrderID = t.BuyOrder
}
row.ClearPadding()
return row
}
// makerUser returns the resting (maker) user string for a trade given the taker
// side. If the taker bought, the maker sold (Seller); else the maker bought.
func makerUser(t Trade, takerSide Side) string {
if takerSide == Buy {
return pickUser(t.Seller, t.SellUserID)
}
return pickUser(t.Buyer, t.BuyUserID)
}
// takerUser returns the taker user string for a trade given the taker side.
func takerUser(t Trade, takerSide Side) string {
if takerSide == Buy {
return pickUser(t.Buyer, t.BuyUserID)
}
return pickUser(t.Seller, t.SellUserID)
}
func pickUser(primary, fallback string) string {
if primary != "" {
return primary
}
return fallback
}
// BookToRows serializes every resting order in the book to canonical DEXOrder
// rows, ordered deterministically: bids by descending price then ascending
// orderID, then asks by ascending price then ascending orderID. This ordering is
// the canonical book ordering used for the book merkle root, so the row stream is
// itself deterministic and a round-trip is byte-stable.
//
// Only the resting side of the book is serialized — the trade log and meta are
// persisted separately by the d-chain state layer. The book is, per the
// cryptographer's design, a rebuildable accelerator: these rows are sufficient to
// reconstruct it exactly.
func BookToRows(ob *OrderBook) []DEXOrder {
if ob == nil {
return nil
}
ob.mu.RLock()
defer ob.mu.RUnlock()
rows := make([]DEXOrder, 0, len(ob.Orders))
for _, o := range ob.Orders {
if o == nil {
continue
}
rows = append(rows, OrderToRow(o))
}
sortRows(rows)
return rows
}
// sortRows imposes the canonical book ordering on a row slice: bids (side=bid)
// first by descending price, asks by ascending price, ties broken by ascending
// orderID. Deterministic and total.
func sortRows(rows []DEXOrder) {
sort.Slice(rows, func(i, j int) bool {
a, b := rows[i], rows[j]
// bids before asks
if a.Side != b.Side {
return a.Side == DEXSideBid
}
if a.Side == DEXSideBid {
// descending price for bids
if a.Price.Integer != b.Price.Integer {
return a.Price.Integer > b.Price.Integer
}
if a.Price.Fraction != b.Price.Fraction {
return a.Price.Fraction > b.Price.Fraction
}
} else {
// ascending price for asks
if a.Price.Integer != b.Price.Integer {
return a.Price.Integer < b.Price.Integer
}
if a.Price.Fraction != b.Price.Fraction {
return a.Price.Fraction < b.Price.Fraction
}
}
return a.OrderID < b.OrderID
})
}
// RowsToBook reconstructs a fresh OrderBook for symbol from a row stream. Each
// row becomes a resting Order inserted via ConsensusAddOrder (deterministic
// path: the row's own ID and timestamp are authoritative, never re-minted). The
// LastOrderID watermark is advanced past the highest seen ID so any future
// consensus order keeps a monotonic ID space. Fully-filled / cancelled rows are
// skipped — only live resting liquidity is rebuilt.
func RowsToBook(symbol string, rows []DEXOrder) *OrderBook {
ob := NewOrderBook(symbol)
var maxID uint64
for _, row := range rows {
if row.OrderID > maxID {
maxID = row.OrderID
}
if row.Status == DEXStatusFilled || row.Status == DEXStatusCancelled {
continue
}
if row.Remaining == 0 {
continue
}
o := RowToOrder(row)
o.Symbol = symbol
ob.ConsensusAddOrder(o)
}
ob.LastOrderID = maxID
return ob
}