mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
dexprotocol: the third conservation proof, denominated in lots
Three separate proofs, none able to stand in for another:
cross-chain C + C->D + D + D->C constant token base units
D-local owned == available + reserved token base units
order-local original == open + reserved
+ traded + canceled LOTS
The units stay apart. Merging them is how a lot count gets added to a wei
balance and type-checks; the market's lot size connects the domains and
one test asserts the two numbers differ, so a future merge has to delete
an assertion rather than quietly succeed.
Four terms, not three. Open lots are restable and cancellable; reserved
lots are committed to a match already in flight. Collapsing them loses
the property that matters: Cancel takes the OPEN quantity only, because a
cancel reaching committed lots would let the same lots be cancelled and
traded, which is the order-book form of a double spend. The committed
remainder resolves when its match does, and the trader may cancel again
to retire whatever came back.
Cancel is a no-op on an order with nothing open rather than an error.
"Retire whatever you can" is the honest reading of the request, and
failing it would force callers to inspect state to avoid an error they
cannot prevent.
Every transition moves lots between two terms and never writes a total,
so conservation is a property of the shape. A 3000-step random run
asserts it after each: 548 trades, 451 cancels, 528 returns, 257 orders
replenished. The run replenishes exhausted orders deliberately — without
that it did 67 real transitions in 3000 steps, and a floor assertion now
fails if it degrades back to flattering the step count.
75 tests.
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dexprotocol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// book.go is the third conservation proof, and the only one denominated in LOTS.
|
||||
//
|
||||
// The three are deliberately separate, and none of them can stand in for another:
|
||||
//
|
||||
// cross-chain C + C->D + D + D->C constant token base units
|
||||
// D-local owned == available + reserved token base units
|
||||
// order-local original == open + reserved + traded + canceled LOTS
|
||||
//
|
||||
// Mixing the units is how a lot count gets added to a wei balance, so the two
|
||||
// domains are connected by the market's lot size and never merged into one type.
|
||||
//
|
||||
// WHY FOUR TERMS AND NOT THREE. `open` and `reserved` are different things and
|
||||
// collapsing them loses the property that matters: open quantity is restable and
|
||||
// cancellable, reserved quantity is committed to a match already in flight. A cancel
|
||||
// that could reach reserved quantity would let the same lots be cancelled and traded,
|
||||
// which is the order-book form of a double-spend.
|
||||
//
|
||||
// So Cancel takes the OPEN quantity only. The remainder resolves itself when the
|
||||
// match it is committed to either trades or comes back.
|
||||
|
||||
var (
|
||||
ErrNoOrder = errors.New("dexprotocol: no order under that id")
|
||||
ErrOrderExists = errors.New("dexprotocol: an order already exists under that id")
|
||||
ErrNotOpen = errors.New("dexprotocol: insufficient open quantity")
|
||||
ErrNotCommitted = errors.New("dexprotocol: insufficient reserved quantity")
|
||||
ErrBookBroken = errors.New("dexprotocol: an order's quantities do not sum to what was placed")
|
||||
ErrZeroOrder = errors.New("dexprotocol: an order must have a non-zero quantity")
|
||||
)
|
||||
|
||||
// orderState is one order's quantity account. Every transition moves lots between
|
||||
// terms and never changes their sum, so conservation is a property of the shape
|
||||
// rather than of remembering to update a total.
|
||||
type orderState struct {
|
||||
original uint64
|
||||
open uint64
|
||||
reserved uint64
|
||||
traded uint64
|
||||
canceled uint64
|
||||
}
|
||||
|
||||
func (o *orderState) sum() uint64 { return o.open + o.reserved + o.traded + o.canceled }
|
||||
|
||||
// Book is the D-side order accounting. It holds no value — Custody does that — and
|
||||
// no prices. It answers exactly one question: where did an order's lots go.
|
||||
type Book struct {
|
||||
orders map[ids.ID]*orderState
|
||||
}
|
||||
|
||||
func NewBook() *Book { return &Book{orders: make(map[ids.ID]*orderState)} }
|
||||
|
||||
// Place opens an order for a quantity. All of it starts open.
|
||||
func (b *Book) Place(orderID ids.ID, quantity uint64) error {
|
||||
if quantity == 0 {
|
||||
return ErrZeroOrder
|
||||
}
|
||||
if _, exists := b.orders[orderID]; exists {
|
||||
return fmt.Errorf("%w: %s", ErrOrderExists, orderID)
|
||||
}
|
||||
b.orders[orderID] = &orderState{original: quantity, open: quantity}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Commit moves open lots to reserved when the matcher takes them for a match.
|
||||
func (b *Book) Commit(orderID ids.ID, quantity uint64) error {
|
||||
o, ok := b.orders[orderID]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrNoOrder, orderID)
|
||||
}
|
||||
if quantity == 0 || o.open < quantity {
|
||||
return fmt.Errorf("%w: %d open, committing %d", ErrNotOpen, o.open, quantity)
|
||||
}
|
||||
o.open -= quantity
|
||||
o.reserved += quantity
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trade settles reserved lots. This is the only terminal transition that represents
|
||||
// value actually changing hands, and it is irreversible.
|
||||
func (b *Book) Trade(orderID ids.ID, quantity uint64) error {
|
||||
o, ok := b.orders[orderID]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrNoOrder, orderID)
|
||||
}
|
||||
if quantity == 0 || o.reserved < quantity {
|
||||
return fmt.Errorf("%w: %d reserved, trading %d", ErrNotCommitted, o.reserved, quantity)
|
||||
}
|
||||
o.reserved -= quantity
|
||||
o.traded += quantity
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return sends reserved lots back to open — a match that did not happen. The order
|
||||
// is still live and the lots are restable, which is the whole point of an order
|
||||
// outliving any single match attempt.
|
||||
func (b *Book) Return(orderID ids.ID, quantity uint64) error {
|
||||
o, ok := b.orders[orderID]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrNoOrder, orderID)
|
||||
}
|
||||
if quantity == 0 || o.reserved < quantity {
|
||||
return fmt.Errorf("%w: %d reserved, returning %d", ErrNotCommitted, o.reserved, quantity)
|
||||
}
|
||||
o.reserved -= quantity
|
||||
o.open += quantity
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel retires the OPEN quantity and returns how much was retired. Lots committed
|
||||
// to an in-flight match are deliberately untouched: cancelling them would let the
|
||||
// same lots be both cancelled and traded. They resolve when that match does, and the
|
||||
// trader may cancel again afterwards to retire whatever came back.
|
||||
//
|
||||
// Cancelling an order with nothing open is not an error — it is a no-op returning 0,
|
||||
// because "cancel everything you can" is the honest meaning of the request and
|
||||
// failing it would make the caller inspect state first to avoid an error it cannot
|
||||
// prevent.
|
||||
func (b *Book) Cancel(orderID ids.ID) (uint64, error) {
|
||||
o, ok := b.orders[orderID]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("%w: %s", ErrNoOrder, orderID)
|
||||
}
|
||||
retired := o.open
|
||||
o.canceled += retired
|
||||
o.open = 0
|
||||
return retired, nil
|
||||
}
|
||||
|
||||
// Quantities reports one order's four terms plus what it was placed for.
|
||||
func (b *Book) Quantities(orderID ids.ID) (original, open, reserved, traded, canceled uint64, ok bool) {
|
||||
o, exists := b.orders[orderID]
|
||||
if !exists {
|
||||
return 0, 0, 0, 0, 0, false
|
||||
}
|
||||
return o.original, o.open, o.reserved, o.traded, o.canceled, true
|
||||
}
|
||||
|
||||
// Live reports whether the order still has lots that could yet trade.
|
||||
func (b *Book) Live(orderID ids.ID) bool {
|
||||
o, ok := b.orders[orderID]
|
||||
return ok && (o.open > 0 || o.reserved > 0)
|
||||
}
|
||||
|
||||
// Conserved asserts the order-local invariant for every order:
|
||||
//
|
||||
// original == open + reserved + traded + canceled
|
||||
//
|
||||
// Assert it after every transition. Each one moves lots between two terms, so a
|
||||
// failure means a term was written rather than moved — which is the mutation this
|
||||
// shape exists to make visible.
|
||||
func (b *Book) Conserved() error {
|
||||
for id, o := range b.orders {
|
||||
if got := o.sum(); got != o.original {
|
||||
return fmt.Errorf("%w: order %s placed %d, holds open %d + reserved %d + traded %d + canceled %d = %d",
|
||||
ErrBookBroken, id, o.original, o.open, o.reserved, o.traded, o.canceled, got)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dexprotocol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/big"
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
func bookSound(t *testing.T, b *Book, when string) {
|
||||
t.Helper()
|
||||
if err := b.Conserved(); err != nil {
|
||||
t.Fatalf("%s: %v", when, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary CLOB life of a partially executed order, entirely on D — no C parent,
|
||||
// no certificate, no settlement round trip. This is what importing first buys.
|
||||
func TestPartialExecutionIsPurelyLocal(t *testing.T) {
|
||||
b := NewBook()
|
||||
id := ids.ID{0x01}
|
||||
if err := b.Place(id, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bookSound(t, b, "placed")
|
||||
|
||||
// Three partial trades: 30, 20, 15.
|
||||
for _, q := range []uint64{30, 20, 15} {
|
||||
if err := b.Commit(id, q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bookSound(t, b, "committed")
|
||||
if err := b.Trade(id, q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bookSound(t, b, "traded")
|
||||
}
|
||||
|
||||
_, open, reserved, traded, canceled, _ := b.Quantities(id)
|
||||
if open != 35 || reserved != 0 || traded != 65 || canceled != 0 {
|
||||
t.Fatalf("open %d reserved %d traded %d canceled %d, want 35/0/65/0", open, reserved, traded, canceled)
|
||||
}
|
||||
|
||||
// Cancel the rest.
|
||||
retired, err := b.Cancel(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retired != 35 {
|
||||
t.Fatalf("cancel retired %d, want 35", retired)
|
||||
}
|
||||
bookSound(t, b, "cancelled")
|
||||
|
||||
original, open, reserved, traded, canceled, _ := b.Quantities(id)
|
||||
if original != 100 || open != 0 || reserved != 0 || traded != 65 || canceled != 35 {
|
||||
t.Fatalf("final %d = %d + %d + %d + %d", original, open, reserved, traded, canceled)
|
||||
}
|
||||
if b.Live(id) {
|
||||
t.Fatal("a fully resolved order is still live")
|
||||
}
|
||||
}
|
||||
|
||||
// THE ORDER-BOOK FORM OF A DOUBLE-SPEND. Cancel must not reach lots committed to a
|
||||
// match already in flight, or the same lots are both cancelled and traded.
|
||||
func TestCancelCannotReachCommittedLots(t *testing.T) {
|
||||
b := NewBook()
|
||||
id := ids.ID{0x02}
|
||||
if err := b.Place(id, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Commit(id, 60); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
retired, err := b.Cancel(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retired != 40 {
|
||||
t.Fatalf("cancel retired %d, want only the 40 that were open", retired)
|
||||
}
|
||||
// The committed 60 survives the cancel and can still trade.
|
||||
if err := b.Trade(id, 60); err != nil {
|
||||
t.Fatalf("committed lots did not survive the cancel: %v", err)
|
||||
}
|
||||
bookSound(t, b, "traded after cancel")
|
||||
|
||||
original, open, reserved, traded, canceled, _ := b.Quantities(id)
|
||||
if original != 100 || open != 0 || reserved != 0 || traded != 60 || canceled != 40 {
|
||||
t.Fatalf("%d = %d + %d + %d + %d, want 100 = 0 + 0 + 60 + 40",
|
||||
original, open, reserved, traded, canceled)
|
||||
}
|
||||
}
|
||||
|
||||
// A match that does not happen returns its lots to open — the order outlives the
|
||||
// match attempt, which is what makes a resting order useful.
|
||||
func TestReturnedLotsRestAgain(t *testing.T) {
|
||||
b := NewBook()
|
||||
id := ids.ID{0x03}
|
||||
if err := b.Place(id, 50); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Commit(id, 50); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Nothing is cancellable while it is all committed.
|
||||
if retired, err := b.Cancel(id); err != nil || retired != 0 {
|
||||
t.Fatalf("cancel retired %d err %v, want 0/nil", retired, err)
|
||||
}
|
||||
if err := b.Return(id, 50); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bookSound(t, b, "returned")
|
||||
if !b.Live(id) {
|
||||
t.Fatal("an order whose match failed should still be live")
|
||||
}
|
||||
// And now it is cancellable, including the lots that came back.
|
||||
retired, err := b.Cancel(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retired != 50 {
|
||||
t.Fatalf("retired %d, want 50", retired)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelling twice, or cancelling an order with nothing open, is a no-op rather than
|
||||
// an error: "retire whatever you can" is the honest reading, and failing it would
|
||||
// force the caller to inspect state to avoid an error it cannot prevent.
|
||||
func TestCancelIsIdempotent(t *testing.T) {
|
||||
b := NewBook()
|
||||
id := ids.ID{0x04}
|
||||
if err := b.Place(id, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retired, err := b.Cancel(id); err != nil || retired != 10 {
|
||||
t.Fatalf("first cancel: %d, %v", retired, err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
retired, err := b.Cancel(id)
|
||||
if err != nil {
|
||||
t.Fatalf("repeat cancel %d errored: %v", i, err)
|
||||
}
|
||||
if retired != 0 {
|
||||
t.Fatalf("repeat cancel %d retired %d, want 0", i, retired)
|
||||
}
|
||||
}
|
||||
_, _, _, _, canceled, _ := b.Quantities(id)
|
||||
if canceled != 10 {
|
||||
t.Fatalf("canceled total is %d after four cancels, want 10", canceled)
|
||||
}
|
||||
bookSound(t, b, "after repeated cancels")
|
||||
}
|
||||
|
||||
func TestBookRefusesOverCommitAndOverTrade(t *testing.T) {
|
||||
b := NewBook()
|
||||
id := ids.ID{0x05}
|
||||
if err := b.Place(id, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Commit(id, 11); !errors.Is(err, ErrNotOpen) {
|
||||
t.Fatalf("over-commit: got %v, want ErrNotOpen", err)
|
||||
}
|
||||
if err := b.Commit(id, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Trade(id, 11); !errors.Is(err, ErrNotCommitted) {
|
||||
t.Fatalf("over-trade: got %v, want ErrNotCommitted", err)
|
||||
}
|
||||
if err := b.Return(id, 11); !errors.Is(err, ErrNotCommitted) {
|
||||
t.Fatalf("over-return: got %v, want ErrNotCommitted", err)
|
||||
}
|
||||
bookSound(t, b, "after refusals")
|
||||
}
|
||||
|
||||
func TestBookRejectsDuplicateAndEmptyOrders(t *testing.T) {
|
||||
b := NewBook()
|
||||
id := ids.ID{0x06}
|
||||
if err := b.Place(id, 0); !errors.Is(err, ErrZeroOrder) {
|
||||
t.Fatalf("got %v, want ErrZeroOrder", err)
|
||||
}
|
||||
if err := b.Place(id, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Place(id, 5); !errors.Is(err, ErrOrderExists) {
|
||||
t.Fatalf("got %v, want ErrOrderExists", err)
|
||||
}
|
||||
for _, err := range []error{
|
||||
b.Commit(ids.ID{0xFF}, 1), b.Trade(ids.ID{0xFF}, 1), b.Return(ids.ID{0xFF}, 1),
|
||||
} {
|
||||
if !errors.Is(err, ErrNoOrder) {
|
||||
t.Fatalf("got %v, want ErrNoOrder", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Random sequencing across many orders, asserting the four-term identity after every
|
||||
// transition. Each transition moves lots between two terms, so a failure means a term
|
||||
// was written rather than moved.
|
||||
func TestBookConservesUnderRandomSequencing(t *testing.T) {
|
||||
b := NewBook()
|
||||
rng := rand.New(rand.NewSource(23))
|
||||
var ids32 []ids.ID
|
||||
for i := 0; i < 12; i++ {
|
||||
id := ids.ID{0xB0, byte(i)}
|
||||
if err := b.Place(id, uint64(rng.Intn(500)+50)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids32 = append(ids32, id)
|
||||
}
|
||||
bookSound(t, b, "placed")
|
||||
|
||||
var trades, cancels, returns, placed int
|
||||
next := len(ids32)
|
||||
for step := 0; step < 3000; step++ {
|
||||
// Replenish exhausted orders, or the run degenerates into no-ops and the
|
||||
// step count flatters coverage it does not have.
|
||||
for i, id := range ids32 {
|
||||
if !b.Live(id) {
|
||||
fresh := ids.ID{0xB0, byte(next), byte(next >> 8)}
|
||||
next++
|
||||
if err := b.Place(fresh, uint64(rng.Intn(500)+50)); err != nil {
|
||||
t.Fatalf("step %d replenish: %v", step, err)
|
||||
}
|
||||
ids32[i] = fresh
|
||||
placed++
|
||||
}
|
||||
}
|
||||
id := ids32[rng.Intn(len(ids32))]
|
||||
_, open, reserved, _, _, _ := b.Quantities(id)
|
||||
switch rng.Intn(4) {
|
||||
case 0:
|
||||
if open > 0 {
|
||||
q := uint64(rng.Intn(int(open))) + 1
|
||||
if err := b.Commit(id, q); err != nil {
|
||||
t.Fatalf("step %d: %v", step, err)
|
||||
}
|
||||
}
|
||||
case 1:
|
||||
if reserved > 0 {
|
||||
q := uint64(rng.Intn(int(reserved))) + 1
|
||||
if err := b.Trade(id, q); err != nil {
|
||||
t.Fatalf("step %d: %v", step, err)
|
||||
}
|
||||
trades++
|
||||
}
|
||||
case 2:
|
||||
if reserved > 0 {
|
||||
q := uint64(rng.Intn(int(reserved))) + 1
|
||||
if err := b.Return(id, q); err != nil {
|
||||
t.Fatalf("step %d: %v", step, err)
|
||||
}
|
||||
returns++
|
||||
}
|
||||
case 3:
|
||||
retired, err := b.Cancel(id)
|
||||
if err != nil {
|
||||
t.Fatalf("step %d: %v", step, err)
|
||||
}
|
||||
if retired > 0 {
|
||||
cancels++
|
||||
}
|
||||
}
|
||||
bookSound(t, b, "mid-sequence")
|
||||
}
|
||||
if trades == 0 || cancels == 0 || returns == 0 {
|
||||
t.Fatalf("the run never exercised all three: trades %d cancels %d returns %d",
|
||||
trades, cancels, returns)
|
||||
}
|
||||
t.Logf("%d trades, %d cancels, %d returns, %d orders replenished across 3000 steps",
|
||||
trades, cancels, returns, placed)
|
||||
if trades+cancels+returns < 1000 {
|
||||
t.Fatalf("only %d real transitions in 3000 steps — the run is mostly no-ops",
|
||||
trades+cancels+returns)
|
||||
}
|
||||
}
|
||||
|
||||
// The book counts LOTS and the custody ledger counts token base units. They are
|
||||
// connected by the market's lot size and must never be added together — this test
|
||||
// exists to state that in code, so a future change that merges the types has to
|
||||
// delete an explicit assertion rather than quietly succeed.
|
||||
func TestBookAndCustodyStayInDifferentUnits(t *testing.T) {
|
||||
b := NewBook()
|
||||
c := NewCustody(dChain)
|
||||
id := ids.ID{0x07}
|
||||
|
||||
const lotSize = 1_000 // token base units per lot, a market property
|
||||
const lots = 7
|
||||
|
||||
if err := c.Import(claim(1, alice, testUSDC, lots*lotSize)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Place(id, lots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Placing an order reserves VALUE in custody and OPENS lots on the book. The
|
||||
// conversion happens exactly here, once, at the boundary.
|
||||
if err := c.Reserve(alice, testUSDC, n(lots*lotSize)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, open, _, _, _, _ := b.Quantities(id)
|
||||
_, reservedValue, _ := c.Balance(alice, testUSDC)
|
||||
if open != lots {
|
||||
t.Fatalf("book holds %d lots, want %d", open, lots)
|
||||
}
|
||||
if reservedValue.Cmp(n(lots*lotSize)) != 0 {
|
||||
t.Fatalf("custody holds %s, want %d", reservedValue, lots*lotSize)
|
||||
}
|
||||
// The numbers differ by exactly the lot size, which is the point: they are not
|
||||
// the same quantity and must not share a type.
|
||||
if reservedValue.Int64() == int64(open) {
|
||||
t.Fatal("lots and token base units coincided — the test is not discriminating")
|
||||
}
|
||||
bookSound(t, b, "placed")
|
||||
if err := c.Conserved(testUSDC, n(lots*lotSize), new(big.Int)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user