Files
dex/pkg/dchain/execute.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

233 lines
9.4 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dchain
import (
"encoding/binary"
"math/big"
"time"
"github.com/luxfi/crypto/hash"
"github.com/luxfi/dex/pkg/lx"
"github.com/luxfi/dex/pkg/zapwire"
)
// sizeUnitsBig returns the order's exact integer base-unit quantity as a big.Int
// (the value lane the matcher uses to produce BaseUnits/QuoteUnits on a fill).
// nil for a non-positive size so the matcher's legacy float path is unaffected
// for a degenerate order. Whole-unit truncation matches the ledger's sizeToUnits.
func sizeUnitsBig(size float64) *big.Int {
if !(size > 0) {
return nil
}
return new(big.Int).SetUint64(uint64(size))
}
// execute.go is the deterministic state-transition function. applyTx takes a tx,
// the in-memory book for its market, the block height, the block timestamp, and
// the tx's index in the block, and applies it — minting NOTHING from wall-clock
// or a process-local counter. Every value that participates in matched state is
// derived from block context, so two validators replaying the same ordered block
// produce byte-identical results.
// blockDeterministicID derives a resting order's ID from (height, txIndex). It
// is globally unique across the chain (height monotone, txIndex unique within a
// block) and identical on every validator. The top 32 bits are the height and
// the bottom 32 are the txIndex, with a +1 bias so an ID is never zero (zero is
// the "unset" sentinel the matcher rejects). For heights/indices beyond 32 bits
// this would alias; a venue block never holds 4 billion txs and a chain does not
// reach height 4 billion in any realistic horizon, but if it ever must, widen to
// a keccak-derived 64-bit id (kept simple here for a stable, debuggable id).
func blockDeterministicID(height uint64, txIndex uint32) uint64 {
id := (height << 32) | uint64(txIndex)
return id + 1
}
// applyResult is the outcome of applying one tx: the fills it produced (for a
// submit) and the resting-order delta to persist. The VM writes these to the
// versiondb overlay; on a place the placed order is the delta, on a cancel the
// cancelled order id is removed, on a submit the affected makers are updated.
type applyResult struct {
// Fills are the trades a TxSubmit produced (empty for other tx types).
Fills []lx.Trade
// TakerSide is the side the submit took with (for encoding fill rows).
TakerSide lx.Side
// Placed is the order a TxPlace rested (nil otherwise).
Placed *lx.Order
// Canceled is the order id a TxCancel removed (0 otherwise). The cancelled
// order's SETTLEMENT identity is NOT carried here: the custody unlock resolves
// it exclusively through the orderuser: index by this order id (settle.go /
// settleOrderEffects), never from the matcher's 8-byte in-RAM owner — so a
// post-restart cancel unlocks to the SAME full account the lock lives under.
Canceled uint64
// Touched are resting orders whose remaining changed due to a submit cross
// (the makers); their rows must be rewritten/deleted.
Touched []*lx.Order
}
// applyTx applies a single transaction to the book deterministically. It is a
// pure function of (tx, book state, height, ts, txIndex): no time.Now(), no
// LastOrderID mint. EnsureMarket is handled by the VM (it has no book effect
// beyond existence, recorded in state); applyTx handles place/cancel/submit.
//
// For TxPlace: the order's ID = blockDeterministicID(height, txIndex), its
// Timestamp = ts, then ConsensusAddOrder rests it.
// For TxCancel: CancelOrder removes the resting order.
// For TxSubmit: the marketable order also gets a deterministic ID/ts (ephemeral —
// it never rests) and SubmitMarketable crosses the book, returning its fills.
func applyTx(book *lx.OrderBook, tx *Tx, height uint64, ts time.Time, txIndex uint32) (applyResult, error) {
switch tx.Type {
case TxPlace:
return applyPlace(book, tx, height, ts, txIndex)
case TxCancel:
return applyCancel(book, tx)
case TxSubmit:
return applySubmit(book, tx, height, ts, txIndex)
case TxEnsureMarket:
// No book mutation; market existence is recorded in d-chain state by the
// VM. Return an empty result.
return applyResult{}, nil
default:
return applyResult{}, ErrUnknownTx
}
}
// applyPlace decodes a zapwire Place body and rests it via the deterministic
// consensus entrypoint. Rejected orders (bad price/size, post-only-would-take,
// self-trade) yield an empty result with no error: a rejected order is a valid,
// deterministic outcome (no fills, no resting delta), not a chain-level failure.
func applyPlace(book *lx.OrderBook, tx *Tx, height uint64, ts time.Time, txIndex uint32) (applyResult, error) {
body := tx.Body
// Place body: poolId[32] + side[1] + price[8] + size[8] + user[16].
side := lx.Side(body[zapwire.PoolIDSize])
price := zapwire.Float64(body[zapwire.PoolIDSize+1 : zapwire.PoolIDSize+9])
size := zapwire.Float64(body[zapwire.PoolIDSize+9 : zapwire.PoolIDSize+17])
user := string(trimNull(body[zapwire.PoolIDSize+17 : zapwire.PoolIDSize+17+zapwire.UserSize]))
if !(price > 0) || !(size > 0) {
return applyResult{}, nil // deterministic reject
}
order := &lx.Order{
ID: blockDeterministicID(height, txIndex),
Type: lx.Limit,
Side: side,
Price: price,
Size: size,
SizeUnits: sizeUnitsBig(size), // exact integer base units (value lane)
User: user,
UserID: user,
Symbol: book.Symbol,
Timestamp: ts,
}
if book.ConsensusAddOrder(order) == 0 {
return applyResult{}, nil // rejected (e.g. post-only would take)
}
return applyResult{Placed: order}, nil
}
// applyCancel decodes a zapwire Cancel body and removes the resting order. A
// cancel of an unknown order is a deterministic no-op (the matcher returns an
// error which we map to an empty result — every validator sees the same). The
// cancelled order's owner is NOT captured here: the custody unlock resolves the
// full settlement identity through the orderuser: index (settleOrderEffects), so
// the matcher's 8-byte in-RAM owner never participates in a value move.
func applyCancel(book *lx.OrderBook, tx *Tx) (applyResult, error) {
orderID := binary.BigEndian.Uint64(tx.Body[zapwire.PoolIDSize : zapwire.PoolIDSize+8])
// Snapshot the order before cancel so we know it existed (for the delta).
if book.GetOrder(orderID) == nil {
return applyResult{}, nil
}
if err := book.CancelOrder(orderID); err != nil {
return applyResult{}, nil
}
return applyResult{Canceled: orderID}, nil
}
// applySubmit decodes a zapwire Submit body and crosses the book. The marketable
// order gets a deterministic (height,txIndex) ID and the block ts so the fills it
// produces are byte-identical across validators; the order itself never rests
// (SubmitMarketable is IOC). Touched makers are captured BEFORE the cross so the
// VM can rewrite their rows; SubmitMarketable mutates resting orders in place.
func applySubmit(book *lx.OrderBook, tx *Tx, height uint64, ts time.Time, txIndex uint32) (applyResult, error) {
body := tx.Body
// Submit body: poolId[32] + side[1] + isMarket[1] + price[8] + size[8] + user[16].
side := lx.Side(body[zapwire.PoolIDSize])
isMarket := body[zapwire.PoolIDSize+1] == 1
limitPrice := zapwire.Float64(body[zapwire.PoolIDSize+2 : zapwire.PoolIDSize+10])
size := zapwire.Float64(body[zapwire.PoolIDSize+10 : zapwire.PoolIDSize+18])
user := string(trimNull(body[zapwire.PoolIDSize+18 : zapwire.PoolIDSize+18+zapwire.UserSize]))
if !(size > 0) {
return applyResult{}, nil
}
order := &lx.Order{
ID: blockDeterministicID(height, txIndex),
Side: side,
Size: size,
SizeUnits: sizeUnitsBig(size), // exact integer base units (value lane)
User: user,
UserID: user,
Symbol: book.Symbol,
Timestamp: ts,
}
if isMarket {
order.Type = lx.Market
} else {
if !(limitPrice > 0) {
return applyResult{}, nil
}
order.Type = lx.Limit
order.Price = limitPrice
}
fills, err := book.SubmitMarketable(order)
if err != nil {
return applyResult{}, nil // deterministic reject (e.g. invalid)
}
// Collect the makers touched by these fills so the VM rewrites their rows.
touched := make([]*lx.Order, 0, len(fills))
seen := make(map[uint64]struct{}, len(fills))
for _, f := range fills {
makerID := f.SellOrder
if side == lx.Sell {
makerID = f.BuyOrder
}
if _, dup := seen[makerID]; dup {
continue
}
seen[makerID] = struct{}{}
if m := book.GetOrder(makerID); m != nil {
touched = append(touched, m)
} else {
// Maker fully filled and removed from the book: synthesize a
// tombstone row so the VM deletes its persisted row.
touched = append(touched, &lx.Order{ID: makerID, Status: lx.Filled})
}
}
return applyResult{Fills: fills, TakerSide: side, Touched: touched}, nil
}
// idempotencyKey derives the d-chain dedup key for a submit/place from the wire
// user[16] and the per-user sequence. Dedup lives in STATE (this key), keeping
// the 66B/65B frames FROZEN — no nonce field is added to the wire. The seq is the
// caller's monotone counter; the VM stores seen keys and drops a replay.
func idempotencyKey(user [zapwire.UserSize]byte, seq uint64) [32]byte {
var s [8]byte
binary.BigEndian.PutUint64(s[:], seq)
return hash.ComputeKeccak256Array(user[:], s[:])
}
// trimNull returns b with trailing NUL bytes removed (the inverse of zapwire's
// null padding for the user field).
func trimNull(b []byte) []byte {
for i := len(b) - 1; i >= 0; i-- {
if b[i] != 0 {
return b[:i+1]
}
}
return b[:0]
}