Files
dex/pkg/dchain/tx.go
T
zeekayandHanzo Dev 4a90738c69 dchain: the funding rail carries value, and nothing else
Move once, trade many. Value crosses the C<->D boundary twice in its life; in
between, D owns it and every order, match and trade is a local balance movement.
So the crossing has exactly one job — move ownership exactly once — and the
object shrinks to what that job needs:

    beneficiary(20) | asset(32) | amount(8)   = 60 bytes

Out of the wire: the 49-byte market/side/limit/size tail, the `spent` execution
witness, and the lane byte that told C which trading pot to debit. Out of the
package: the per-order escrow, the per-order ledger account, the proposer-side
drive that scanned shared memory for orders to run, and TxOrderSubmit. An
import now credits the beneficiary's OWN account — the one their signed orders
already draw from — so the money is tradable the moment it lands, with no second
crossing and no order created by the crossing.

TxExport is now signed. It used to draw on a per-order account whose only
possible payee was the escrow's recorded owner, so the object was its own
authority. Draining a real account to a caller-named address is not, so the
body asserts its owner and txWireUser binds that to the recovered signer, the
same way a withdraw does.

One claim id, both directions, both repos: SHA-256(domain | source | dest |
sourceTx | index). It replaces DeriveOrderID and deriveSeamOutputID, which were
two derivations of the same fact.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 17:18:14 -07:00

389 lines
15 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package dchain is the standalone D-Chain DEX virtual machine: a
// block.ChainVM (github.com/luxfi/consensus/engine/chain/block) that runs the
// dex.OrderBook matcher inside consensus. Orders arrive as ZAP frames, are
// queued in a mempool (never executed synchronously), drained into a block in
// sequence order, matched at Block.Verify against a versiondb overlay so every
// validator re-derives the fills, and committed to durable zapdb at
// Block.Accept. The persisted chainstate is authoritative; the in-RAM book is a
// rebuildable accelerator folded from the order:* rows on restart.
//
// This file defines the transaction layer: the four DEX operations, their
// canonical [type][body] wire form, and the deterministic TxID.
package dchain
import (
"encoding/binary"
"errors"
"fmt"
"github.com/luxfi/dex/pkg/zapwire"
"github.com/luxfi/ids"
)
// TxType is the one-byte discriminant prefixing every transaction body. The body
// for the order operations is the FROZEN zapwire payload verbatim (see
// pkg/zapwire), so the chains/dexvm proxy can forward a client frame into a
// d-chain tx without re-encoding, and the precompile/maker/server all speak the
// same bytes.
type TxType uint8
const (
// TxEnsureMarket idempotently creates a market (order book) for a poolId.
// Body = zapwire EnsureMarket payload: poolId[32].
TxEnsureMarket TxType = iota + 1
// TxPlace rests a limit order. Body = zapwire Place payload (65B):
// poolId[32] + side[1] + price[8] + size[8] + user[16].
TxPlace
// TxCancel cancels a resting order. Body = zapwire Cancel payload (40B):
// poolId[32] + orderId[8].
TxCancel
// TxSubmit crosses the book with a marketable order. Body = zapwire Submit
// payload (66B): poolId[32] + side[1] + isMarket[1] + price[8] + size[8] +
// user[16].
TxSubmit
// TxDeposit credits an account's available balance from value the proxy
// atomically imported. Body = zapwire Deposit payload (88B): user[16] +
// asset[32] + amount[8] + ref[32].
TxDeposit
// TxWithdraw debits an account's realized available balance for the proxy to
// atomically export. Body = zapwire Withdraw payload (88B): user[16] +
// asset[32] + amount[8] + ref[32] (clamped to available).
TxWithdraw
// TxOpenMarket binds a market's (base,quote) asset ids so orders on it can be
// value-checked. Body = zapwire OpenMarket payload (96B): poolId[32] +
// baseAsset[32] + quoteAsset[32].
TxOpenMarket
// TxImport CONSUMES a C->D funding claim and credits its BENEFICIARY (atomic.go
// executeImport). Body = claimID[32] | claim[60].
//
// It is NOT signature-authorized, and that is a property rather than an omission:
// a claim names the account it credits, so delivering one can only ever pay the
// party it already names. Anyone may deliver it, which is what stops a relayer who
// refuses to act from stranding a depositor. Its authority is the consumed object
// — the depositor already authenticated and paid on C — exactly as a platformvm
// ImportTx is authorized by the UTXO it consumes.
TxImport
// TxExport DEBITS the signer's available balance and writes a C-bound funding
// claim the 0x9999 precompile consumes exactly once (atomic.go executeExport).
// Body = owner[16] | asset[32] | amount[8] | beneficiary[20].
//
// It is SIGNED, like every other tx that removes value from an account. It draws
// on a real account's balance and names where the value lands, so only that
// account's own signer may authorize one — txWireUser binds the body's owner to
// the recovered signer, exactly as it does for a withdraw.
TxExport
)
// requiresAuth reports whether a tx type moves USER funds out of an account and
// therefore must carry a signature authorizing the asserted account. The order
// operations, the two custody legs and the export do; market admin (ensure/open)
// moves no user value and is unauthenticated (it has no asserted account to bind);
// an import moves value INTO the account a claim already names, so it needs none.
func (t TxType) requiresAuth() bool {
switch t {
case TxPlace, TxCancel, TxSubmit, TxDeposit, TxWithdraw, TxExport:
return true
default:
return false
}
}
func (t TxType) String() string {
switch t {
case TxEnsureMarket:
return "ensure_market"
case TxPlace:
return "place"
case TxCancel:
return "cancel"
case TxSubmit:
return "submit"
case TxDeposit:
return "deposit"
case TxWithdraw:
return "withdraw"
case TxOpenMarket:
return "open_market"
case TxImport:
return "import"
case TxExport:
return "export"
default:
return "unknown"
}
}
// Errors returned by the tx layer.
var (
ErrEmptyTx = errors.New("dchain: empty transaction")
ErrUnknownTx = errors.New("dchain: unknown transaction type")
ErrShortTxBody = errors.New("dchain: transaction body too short for its type")
ErrEmptyMempool = errors.New("dchain: no pending transactions")
// Custody-ledger errors (the "money lives in the order book" invariants).
// ErrInsufficientAvailable: an order/withdraw would debit more available
// balance than an account holds — the matcher debits only deposited funds.
ErrInsufficientAvailable = errors.New("dchain: insufficient available balance")
// ErrInsufficientLocked: an unlock/spend would move more than is locked —
// would mint against the ledger.
ErrInsufficientLocked = errors.New("dchain: insufficient locked balance")
// ErrMarketAssetsUnbound: a balance-bearing order on a market whose (base,
// quote) assets were never bound (dex_open_market) — cannot value-check it.
ErrMarketAssetsUnbound = errors.New("dchain: market assets not bound")
// ErrFillMissingUnits: a consensus fill reached settlement without the exact
// integer lane (BaseUnits/QuoteUnits) — the VM must set SizeUnits on every
// order so settlement is value-exact; a missing lane is refused, never guessed.
ErrFillMissingUnits = errors.New("dchain: fill missing integer units")
// ErrFillUnitsOverflow: a fill's integer units exceed uint64 — refused rather
// than truncated into a mint/burn.
ErrFillUnitsOverflow = errors.New("dchain: fill units exceed uint64")
// ErrMissingSettlementUser: a fill/cancel reached settlement for a resting order
// that has NO orderuser: row — its full 16-byte settlement identity is
// unavailable. This is a STATE-CORRUPTION error, NOT a deterministic per-tx
// reject: it aborts block execution (every validator reads the identical overlay
// state, so the row is missing on all of them — the abort is consensus-neutral),
// the block/tx is REJECTED with NO partial settlement and NO balance mutation.
//
// Settlement fails closed if full account identity is unavailable. Falling back
// to matcher UserID would make compact-handle collisions value-bearing — a critical theft bug.
ErrMissingSettlementUser = errors.New("dchain: resting order missing full settlement identity (orderuser)")
// ErrDustPriceOrZeroLock: an EXECUTABLE order (qty>0, and for a limit a price>0)
// whose REQUIRED LOCK floors to zero under the market's integer notional —
// a BUY whose ceil(size*price) quote rounds to 0 (a sub-tick price), or a SELL
// whose base size truncates to 0. Such an order is REJECTED at placement: a
// zero-lock executable order is the "free executable order" hazard — it could
// rest or cross while reserving NOTHING, letting arithmetic flooring acquire the
// counterparty's asset for no spend. This is a DETERMINISTIC per-tx reject (the
// notional floor is a pure function of the frozen wire fields, so every validator
// rejects identically) — NOT a block abort.
ErrDustPriceOrZeroLock = errors.New("dchain: executable order floors to zero lock (dust price / zero notional)")
)
// Tx is a parsed DEX transaction: the type discriminant plus the raw zapwire body
// for that type. The raw bytes are retained so the tx is re-serialized
// byte-identically (Bytes) and the TxID is stable.
type Tx struct {
Type TxType
Body []byte // the zapwire payload for Type (verbatim)
// Auth is the authorization envelope (signature + per-account nonce) that
// proves the asserted account authorized this exact operation. It is non-nil
// for every money-moving tx (Type.requiresAuth()) and nil for market admin.
// It is part of the canonical Bytes (hence the TxID), so a tx is bound to its
// signature and a replay carries the identical, single-use nonce.
Auth *TxAuth
}
// isMarketScoped reports whether a tx body begins with poolId[32] (the order
// operations + open-market). Deposit/withdraw are ACCOUNT-scoped (their body
// begins with user[16]), not market-scoped, so they have no poolId.
func (t TxType) isMarketScoped() bool {
switch t {
case TxEnsureMarket, TxPlace, TxCancel, TxSubmit, TxOpenMarket:
return true
default:
return false
}
}
// poolID returns the 32-byte market id a MARKET-SCOPED tx body begins with.
// Account-scoped txs (deposit/withdraw) return ok=false — their body has no
// poolId, so callers must dispatch on the type first.
func (tx *Tx) poolID() ([32]byte, bool) {
var id [32]byte
if !tx.Type.isMarketScoped() {
return id, false
}
if len(tx.Body) < zapwire.PoolIDSize {
return id, false
}
copy(id[:], tx.Body[:zapwire.PoolIDSize])
return id, true
}
// NewTx builds an UNAUTHENTICATED Tx (market admin: ensure/open) from a type and
// its zapwire body, validating the body length matches the type's fixed frame
// size. A money-moving type is refused here — it MUST carry a signature, so use
// NewSignedTx. A malformed length is rejected so a short/garbage body can never
// reach the matcher.
func NewTx(t TxType, body []byte) (*Tx, error) {
if t.requiresAuth() {
return nil, fmt.Errorf("dchain: %s is money-moving and requires a signature; use NewSignedTx", t)
}
want, ok := bodySize(t)
if !ok {
return nil, ErrUnknownTx
}
if len(body) < want {
return nil, fmt.Errorf("%w: type=%s have=%d want=%d", ErrShortTxBody, t, len(body), want)
}
// Copy so the tx owns its bytes independent of the caller's buffer.
b := make([]byte, want)
copy(b, body[:want])
return &Tx{Type: t, Body: b}, nil
}
// NewSignedTx builds a money-moving Tx from a type, its zapwire body, and the
// authorization envelope. The envelope must be non-nil; the body length must
// match the type's fixed frame size. The signature is NOT verified here (that is
// the gate's job — verifyTxSignature at the RPC boundary and authoritatively in
// block.execute); this constructor only assembles a well-formed frame.
func NewSignedTx(t TxType, body []byte, auth *TxAuth) (*Tx, error) {
if !t.requiresAuth() {
return nil, fmt.Errorf("dchain: %s takes no signature; use NewTx", t)
}
if auth == nil {
return nil, fmt.Errorf("%w: %s", ErrTxUnsigned, t)
}
want, ok := bodySize(t)
if !ok {
return nil, ErrUnknownTx
}
if len(body) < want {
return nil, fmt.Errorf("%w: type=%s have=%d want=%d", ErrShortTxBody, t, len(body), want)
}
b := make([]byte, want)
copy(b, body[:want])
return &Tx{Type: t, Body: b, Auth: auth}, nil
}
// bodySize returns the fixed zapwire body length for a tx type.
func bodySize(t TxType) (int, bool) {
switch t {
case TxEnsureMarket:
return zapwire.EnsureMarketReqSize, true
case TxPlace:
return zapwire.PlaceReqSize, true
case TxCancel:
return zapwire.CancelReqSize, true
case TxSubmit:
return zapwire.SubmitReqSize, true
case TxDeposit:
return zapwire.DepositReqSize, true
case TxWithdraw:
return zapwire.WithdrawReqSize, true
case TxOpenMarket:
return zapwire.OpenMarketReqSize, true
case TxImport:
return importBodySize, true
case TxExport:
return exportBodySize, true
default:
return 0, false
}
}
// Bytes returns the canonical wire form: [type:1][body][auth?]. The auth
// envelope is appended for a money-moving tx (nil for admin), so the TxID hashes
// over the signature + nonce too — a forged or replayed authorization yields a
// different (or already-seen) id. The fixed per-type body size delimits body
// from the auth trailer on parse. Deterministic and self-describing.
func (tx *Tx) Bytes() []byte {
var auth []byte
if tx.Auth != nil {
auth = tx.Auth.encode()
}
out := make([]byte, 1+len(tx.Body)+len(auth))
out[0] = byte(tx.Type)
n := copy(out[1:], tx.Body)
copy(out[1+n:], auth)
return out
}
// ID is the deterministic transaction id: Checksum256 over the canonical bytes.
// ids.Checksum256 is sha256(sha256(b))-style content addressing used across the
// Lux stack, so the same tx bytes always yield the same id on every node.
func (tx *Tx) ID() ids.ID {
return ids.Checksum256(tx.Bytes())
}
// ParseTx decodes a [type:1][body] wire transaction, validating the type and the
// body length. Used by ParseBlock and by the mempool when accepting a relayed
// frame.
func ParseTx(b []byte) (*Tx, error) {
if len(b) == 0 {
return nil, ErrEmptyTx
}
return parseTxFrame(TxType(b[0]), b[1:])
}
// parseTxFrame decodes a type's [body][auth?] tail into a Tx — the shared body of
// ParseTx (block decode) and the handler's RPC-payload decode, so the wire split
// (fixed body size delimits the auth trailer) is defined exactly once.
func parseTxFrame(t TxType, rest []byte) (*Tx, error) {
want, ok := bodySize(t)
if !ok {
return nil, ErrUnknownTx
}
if len(rest) < want {
return nil, fmt.Errorf("%w: type=%s have=%d want=%d", ErrShortTxBody, t, len(rest), want)
}
body := rest[:want]
if !t.requiresAuth() {
// Admin tx: body must be exactly the frame, no trailer.
if len(rest) != want {
return nil, fmt.Errorf("%w: %s carries unexpected %d trailing bytes", ErrTxMalformedAuth, t, len(rest)-want)
}
return NewTx(t, body)
}
auth, err := decodeTxAuth(rest[want:])
if err != nil {
return nil, err
}
return NewSignedTx(t, body, auth)
}
// encodeTxList serializes a list of txs as [count:4][len:4 body]*, the block body
// format. Each tx is length-prefixed so ParseBlock can split them back exactly.
func encodeTxList(txs []*Tx) []byte {
// Materialize each tx's canonical bytes first (they include the variable-length
// auth trailer), then size the buffer from them — never from len(Body) alone.
raws := make([][]byte, len(txs))
size := 4
for i, tx := range txs {
raws[i] = tx.Bytes()
size += 4 + len(raws[i])
}
out := make([]byte, size)
binary.BigEndian.PutUint32(out[0:4], uint32(len(txs)))
off := 4
for _, raw := range raws {
binary.BigEndian.PutUint32(out[off:off+4], uint32(len(raw)))
off += 4
off += copy(out[off:], raw)
}
return out
}
// decodeTxList parses a [count:4][len:4 body]* tx list. Every length is bounds
// checked so a truncated block body is rejected rather than panicking.
func decodeTxList(b []byte) ([]*Tx, error) {
if len(b) < 4 {
return nil, fmt.Errorf("dchain: tx list too short: %d", len(b))
}
n := int(binary.BigEndian.Uint32(b[0:4]))
off := 4
txs := make([]*Tx, 0, n)
for i := 0; i < n; i++ {
if off+4 > len(b) {
return nil, fmt.Errorf("dchain: tx list truncated at tx %d (len header)", i)
}
l := int(binary.BigEndian.Uint32(b[off : off+4]))
off += 4
if off+l > len(b) {
return nil, fmt.Errorf("dchain: tx list truncated at tx %d (body): need %d have %d", i, l, len(b)-off)
}
tx, err := ParseTx(b[off : off+l])
if err != nil {
return nil, fmt.Errorf("dchain: tx %d: %w", i, err)
}
off += l
txs = append(txs, tx)
}
return txs, nil
}