mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
dexprotocol: quantity is conserved per order, asserted every transition
Exclusive() proves an ExecID cannot sit in two domains. It says nothing about how much of an order has been committed, so D could certify ten executions against one order for its full quantity — each individually valid, correctly certified, correctly scoped — and settle whichever the C successors happened to include. Every defect the last review turned up was a conservation bug rather than a determinism bug, so this is tracked rather than left as a property that happens to hold. Per order: reserved + traded <= authorized. Reserving takes from available, settling moves reserved to traded, releasing gives it back. That last one is what makes a portable Order work at all — an order whose execution lost the race is still live and matchable again, and a release that failed to return the quantity would silently shrink every order that ever lost one. Conserved() recomputes both totals from the domains themselves rather than restating the counters, so an accounting update that drifted from the objects it summarises is caught instead of trusted. A random 400-step lifecycle asserts it plus Exclusive() after every transition: 36 reserved, 96 traded, 86 released in the recorded run. Units are the book's lots, matching Execution.Quantity, never the order's Input in token base units. The lot size is market metadata and stays out of this package: a caller that knows the market converts once and passes authorized in, and the value is checked for agreement across every reservation so a second, different conversion cannot enlarge an order. 51 tests, up from 25 before the reshape. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dexprotocol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// conservation_test.go covers the quantity invariant. Exclusive() proves an ExecID
|
||||
// cannot sit in two domains; it says nothing about HOW MUCH of an order has been
|
||||
// committed, and every defect the last review turned up was a conservation bug
|
||||
// rather than a determinism bug.
|
||||
|
||||
// oneOrder is the OrderID every sampleExecution carries.
|
||||
var oneOrder = ids.ID{0x01}
|
||||
|
||||
// execFor builds an execution against the shared order with a chosen id and size.
|
||||
func execFor(id byte, qty uint64) Execution {
|
||||
e := sampleExecution()
|
||||
e.ExecID = ids.ID{0xE0, id}
|
||||
e.Quantity = qty
|
||||
return e
|
||||
}
|
||||
|
||||
// assertSound checks BOTH ledger invariants. Real callers must do exactly this after
|
||||
// every transition — that is the whole point of exporting them.
|
||||
func assertSound(t *testing.T, l *Ledger, when string) {
|
||||
t.Helper()
|
||||
if err := l.Exclusive(); err != nil {
|
||||
t.Fatalf("%s: exclusivity broken: %v", when, err)
|
||||
}
|
||||
if err := l.Conserved(); err != nil {
|
||||
t.Fatalf("%s: conservation broken: %v", when, err)
|
||||
}
|
||||
}
|
||||
|
||||
// THE BUG THIS EXISTS TO CATCH. Each execution is individually valid — correctly
|
||||
// certified, correctly scoped to the accepted parent — and only the running total
|
||||
// reveals that D has committed more than the trader ever signed for.
|
||||
func TestCannotReserveBeyondWhatTheOrderAuthorized(t *testing.T) {
|
||||
l := NewLedger()
|
||||
p := acceptedParent(t)
|
||||
|
||||
if _, err := l.Reserve(p, verified(t, execFor(1, 600)), 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSound(t, l, "after first")
|
||||
|
||||
if _, err := l.Reserve(p, verified(t, execFor(2, 300)), 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSound(t, l, "after second")
|
||||
|
||||
// 600 + 300 + 200 = 1100 > 1000.
|
||||
_, err := l.Reserve(p, verified(t, execFor(3, 200)), 1000)
|
||||
if !errors.Is(err, ErrOverReserved) {
|
||||
t.Fatalf("got %v, want ErrOverReserved", err)
|
||||
}
|
||||
assertSound(t, l, "after refusal")
|
||||
|
||||
// The exact remainder still fits — the guard is a bound, not a margin.
|
||||
if _, err := l.Reserve(p, verified(t, execFor(4, 100)), 1000); err != nil {
|
||||
t.Fatalf("exact remainder refused: %v", err)
|
||||
}
|
||||
assertSound(t, l, "after exact remainder")
|
||||
|
||||
_, reserved, traded, ok := l.OrderQuantities(oneOrder)
|
||||
if !ok || reserved != 1000 || traded != 0 {
|
||||
t.Fatalf("reserved %d traded %d ok %v, want 1000/0/true", reserved, traded, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// A released reservation must return its quantity to the order. This is the property
|
||||
// the portable Order was designed around: an order whose execution lost the race is
|
||||
// still live and can be matched again. A release that failed to give the quantity
|
||||
// back would silently shrink every order that ever lost one.
|
||||
func TestReleaseReturnsQuantityToTheOrder(t *testing.T) {
|
||||
l := NewLedger()
|
||||
p := acceptedParent(t)
|
||||
e := execFor(1, 1000) // the order's entire quantity
|
||||
|
||||
if _, err := l.Reserve(p, verified(t, e), 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second reservation cannot fit while the first is held.
|
||||
if _, err := l.Reserve(p, verified(t, execFor(2, 1)), 1000); !errors.Is(err, ErrOverReserved) {
|
||||
t.Fatalf("got %v, want ErrOverReserved while fully reserved", err)
|
||||
}
|
||||
|
||||
// The accepted child does NOT consume it, so it is released.
|
||||
q, proof := acceptedConsuming(t, ids.ID{0xC1}, testParent)
|
||||
if _, err := l.Release(e.ExecID, q, proof); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSound(t, l, "after release")
|
||||
|
||||
if _, reserved, _, _ := l.OrderQuantities(oneOrder); reserved != 0 {
|
||||
t.Fatalf("released but %d still held", reserved)
|
||||
}
|
||||
// And now the order is matchable again, in full.
|
||||
if _, err := l.Reserve(p, verified(t, execFor(2, 1000)), 1000); err != nil {
|
||||
t.Fatalf("released order could not be matched again: %v", err)
|
||||
}
|
||||
assertSound(t, l, "after re-reserve")
|
||||
}
|
||||
|
||||
// Settling does NOT free quantity — it spends it. The distinction is the whole
|
||||
// difference between a partial fill and a refund, and getting it backwards is how a
|
||||
// trader's order gets spent twice.
|
||||
func TestSettleSpendsQuantityRatherThanFreeingIt(t *testing.T) {
|
||||
l := NewLedger()
|
||||
p := acceptedParent(t)
|
||||
e := execFor(1, 700)
|
||||
|
||||
if _, err := l.Reserve(p, verified(t, e), 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q, proof := acceptedConsuming(t, ids.ID{0xC1}, testParent, e.ExecID)
|
||||
if _, err := l.Settle(e.ExecID, q, proof); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSound(t, l, "after settle")
|
||||
|
||||
_, reserved, traded, _ := l.OrderQuantities(oneOrder)
|
||||
if reserved != 0 || traded != 700 {
|
||||
t.Fatalf("reserved %d traded %d, want 0/700", reserved, traded)
|
||||
}
|
||||
// Only the unspent remainder is still available.
|
||||
if _, err := l.Reserve(p, verified(t, execFor(2, 301)), 1000); !errors.Is(err, ErrOverReserved) {
|
||||
t.Fatalf("got %v, want ErrOverReserved — settled quantity must not come back", err)
|
||||
}
|
||||
if _, err := l.Reserve(p, verified(t, execFor(3, 300)), 1000); err != nil {
|
||||
t.Fatalf("remainder refused: %v", err)
|
||||
}
|
||||
assertSound(t, l, "after remainder")
|
||||
}
|
||||
|
||||
// The authorized quantity is a property of the signed order, so it cannot change
|
||||
// between reservations. Otherwise a caller converting Input to lots differently the
|
||||
// second time could quietly enlarge the order.
|
||||
func TestAuthorizedQuantityCannotChange(t *testing.T) {
|
||||
l := NewLedger()
|
||||
p := acceptedParent(t)
|
||||
if _, err := l.Reserve(p, verified(t, execFor(1, 100)), 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := l.Reserve(p, verified(t, execFor(2, 100)), 5000)
|
||||
if !errors.Is(err, ErrAuthorizedChanged) {
|
||||
t.Fatalf("got %v, want ErrAuthorizedChanged", err)
|
||||
}
|
||||
assertSound(t, l, "after refusal")
|
||||
}
|
||||
|
||||
func TestOrderAuthorizingNothingIsRefused(t *testing.T) {
|
||||
l := NewLedger()
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, execFor(1, 1)), 0); !errors.Is(err, ErrOverReserved) {
|
||||
t.Fatalf("got %v, want ErrOverReserved", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Conservation must hold after EVERY transition, not merely at the end of a happy
|
||||
// path. Drive a long random sequence of reserve/settle/release and assert both
|
||||
// invariants after each one — this is what catches an accounting update that drifts
|
||||
// from the objects it claims to summarise.
|
||||
func TestConservationHoldsAcrossRandomLifecycles(t *testing.T) {
|
||||
l := NewLedger()
|
||||
p := acceptedParent(t)
|
||||
rng := rand.New(rand.NewSource(11))
|
||||
|
||||
const authorized = 100_000
|
||||
live := map[ids.ID]Execution{}
|
||||
var next byte
|
||||
|
||||
for step := 0; step < 400; step++ {
|
||||
switch {
|
||||
case len(live) == 0 || rng.Intn(2) == 0:
|
||||
next++
|
||||
e := execFor(next, uint64(rng.Intn(500)+1))
|
||||
if _, err := l.Reserve(p, verified(t, e), authorized); err != nil {
|
||||
// Refusal is a legitimate outcome once the order fills up; what
|
||||
// must never happen is a refusal that leaves the ledger unsound.
|
||||
if !errors.Is(err, ErrOverReserved) {
|
||||
t.Fatalf("step %d: %v", step, err)
|
||||
}
|
||||
assertSound(t, l, "after refused reserve")
|
||||
continue
|
||||
}
|
||||
live[e.ExecID] = e
|
||||
default:
|
||||
// Pick one live execution and finish it, settling or releasing.
|
||||
var pick Execution
|
||||
for _, e := range live {
|
||||
pick = e
|
||||
break
|
||||
}
|
||||
delete(live, pick.ExecID)
|
||||
if rng.Intn(2) == 0 {
|
||||
q, proof := acceptedConsuming(t, ids.ID{0xC1}, testParent, pick.ExecID)
|
||||
if _, err := l.Settle(pick.ExecID, q, proof); err != nil {
|
||||
t.Fatalf("step %d settle: %v", step, err)
|
||||
}
|
||||
} else {
|
||||
q, proof := acceptedConsuming(t, ids.ID{0xC1}, testParent)
|
||||
if _, err := l.Release(pick.ExecID, q, proof); err != nil {
|
||||
t.Fatalf("step %d release: %v", step, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
assertSound(t, l, "mid-lifecycle")
|
||||
}
|
||||
|
||||
// Final accounting must agree with the domains, independently recomputed.
|
||||
authorizedGot, reserved, traded, ok := l.OrderQuantities(oneOrder)
|
||||
if !ok {
|
||||
t.Fatal("order account vanished")
|
||||
}
|
||||
if authorizedGot != authorized {
|
||||
t.Fatalf("authorized drifted to %d", authorizedGot)
|
||||
}
|
||||
if reserved+traded > authorized {
|
||||
t.Fatalf("committed %d beyond authorized %d", reserved+traded, authorized)
|
||||
}
|
||||
nReserved, nTraded, nReleased := l.Counts()
|
||||
if nReserved != len(live) {
|
||||
t.Fatalf("ledger holds %d reserved, tracker says %d", nReserved, len(live))
|
||||
}
|
||||
if nTraded+nReleased == 0 {
|
||||
t.Fatal("the run never reached a terminal state; the test proved nothing")
|
||||
}
|
||||
t.Logf("reserved=%d traded=%d released=%d, committed %d/%d",
|
||||
nReserved, nTraded, nReleased, reserved+traded, authorized)
|
||||
}
|
||||
|
||||
// Conservation is scoped per order: filling one order must not consume another's
|
||||
// quantity.
|
||||
func TestOrdersDoNotShareQuantity(t *testing.T) {
|
||||
l := NewLedger()
|
||||
p := acceptedParent(t)
|
||||
|
||||
a := execFor(1, 900)
|
||||
b := execFor(2, 900)
|
||||
b.OrderID = ids.ID{0x02}
|
||||
// b is scoped to a different order, so it needs its own accepted-parent scope
|
||||
// but the same C parent — that part is unchanged.
|
||||
if _, err := l.Reserve(p, verified(t, a), 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := l.Reserve(p, verified(t, b), 1000); err != nil {
|
||||
t.Fatalf("second order refused because the first was nearly full: %v", err)
|
||||
}
|
||||
assertSound(t, l, "two orders")
|
||||
|
||||
if _, reserved, _, _ := l.OrderQuantities(ids.ID{0x02}); reserved != 900 {
|
||||
t.Fatalf("order 2 holds %d, want 900", reserved)
|
||||
}
|
||||
if _, reserved, _, _ := l.OrderQuantities(oneOrder); reserved != 900 {
|
||||
t.Fatalf("order 1 holds %d, want 900", reserved)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,11 @@ type rejectVerifier struct{}
|
||||
|
||||
func (rejectVerifier) VerifyCertificate(ids.ID, []byte) error { return errors.New("nope") }
|
||||
|
||||
// testAuthorized is the order quantity the sample execution is drawn against —
|
||||
// comfortably larger than one execution, so tests exercise the lifecycle rather
|
||||
// than the over-reservation guard.
|
||||
const testAuthorized = 1000
|
||||
|
||||
func sampleExecution() Execution {
|
||||
return Execution{
|
||||
ExecID: ids.ID{0xE1},
|
||||
@@ -221,7 +226,7 @@ func TestSettleAndReleaseAreMutuallyExclusiveAndTerminal(t *testing.T) {
|
||||
t.Run("settled is terminal", func(t *testing.T) {
|
||||
l := NewLedger()
|
||||
e := sampleExecution()
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e), testAuthorized); err != nil {
|
||||
t.Fatalf("reserve: %v", err)
|
||||
}
|
||||
if _, err := l.Settle(e.ExecID, accepted, incl); err != nil {
|
||||
@@ -244,7 +249,7 @@ func TestSettleAndReleaseAreMutuallyExclusiveAndTerminal(t *testing.T) {
|
||||
t.Run("released is terminal", func(t *testing.T) {
|
||||
l := NewLedger()
|
||||
e := sampleExecution()
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e), testAuthorized); err != nil {
|
||||
t.Fatalf("reserve: %v", err)
|
||||
}
|
||||
if _, err := l.Release(e.ExecID, qEmpty, nonIncl); err != nil {
|
||||
@@ -266,16 +271,16 @@ func TestReplayedCertificateCannotReReserve(t *testing.T) {
|
||||
e := sampleExecution()
|
||||
accepted, incl := acceptedConsuming(t, testCBlock, testParent, e.ExecID)
|
||||
l := NewLedger()
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e), testAuthorized); err != nil {
|
||||
t.Fatalf("reserve: %v", err)
|
||||
}
|
||||
if _, err := l.Settle(e.ExecID, accepted, incl); err != nil {
|
||||
t.Fatalf("settle: %v", err)
|
||||
}
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); !errors.Is(err, ErrTerminalExists) {
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e), testAuthorized); !errors.Is(err, ErrTerminalExists) {
|
||||
t.Fatalf("re-reserving a terminal ExecID must be refused, got %v", err)
|
||||
}
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); !errors.Is(err, ErrTerminalExists) {
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e), testAuthorized); !errors.Is(err, ErrTerminalExists) {
|
||||
t.Fatalf("still refused on a second attempt, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -283,10 +288,10 @@ func TestReplayedCertificateCannotReReserve(t *testing.T) {
|
||||
func TestDoubleReserveRefused(t *testing.T) {
|
||||
l := NewLedger()
|
||||
e := sampleExecution()
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e), testAuthorized); err != nil {
|
||||
t.Fatalf("reserve: %v", err)
|
||||
}
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); !errors.Is(err, ErrAlreadyOpen) {
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e), testAuthorized); !errors.Is(err, ErrAlreadyOpen) {
|
||||
t.Fatalf("double reserve must be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -301,7 +306,7 @@ func TestSettleChecksTheAcceptedBlocksParent(t *testing.T) {
|
||||
accepted, incl := acceptedConsuming(t, testCBlock, wrongParent, e.ExecID)
|
||||
|
||||
l := NewLedger()
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e), testAuthorized); err != nil {
|
||||
t.Fatalf("reserve: %v", err)
|
||||
}
|
||||
if _, err := l.Settle(e.ExecID, accepted, incl); !errors.Is(err, ErrWrongSuccessor) {
|
||||
@@ -320,13 +325,13 @@ func TestStorageDomainsAreDistinct(t *testing.T) {
|
||||
qEmpty, nonIncl := acceptedConsuming(t, testCBlock, testParent)
|
||||
|
||||
l := NewLedger()
|
||||
r, _ := l.Reserve(acceptedParent(t), verified(t, e))
|
||||
r, _ := l.Reserve(acceptedParent(t), verified(t, e), testAuthorized)
|
||||
tr, err := l.Settle(e.ExecID, accepted, incl)
|
||||
if err != nil {
|
||||
t.Fatalf("settle: %v", err)
|
||||
}
|
||||
l2 := NewLedger()
|
||||
r2, _ := l2.Reserve(acceptedParent(t), verified(t, e))
|
||||
r2, _ := l2.Reserve(acceptedParent(t), verified(t, e), testAuthorized)
|
||||
rel, err := l2.Release(e.ExecID, qEmpty, nonIncl)
|
||||
if err != nil {
|
||||
t.Fatalf("release: %v", err)
|
||||
|
||||
@@ -125,7 +125,7 @@ func TestSettleRequiresInclusionReleaseRequiresNonInclusion(t *testing.T) {
|
||||
_, nonIncl := acceptedConsuming(t, testCBlock, testParent)
|
||||
|
||||
l := NewLedger()
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e), testAuthorized); err != nil {
|
||||
t.Fatalf("reserve: %v", err)
|
||||
}
|
||||
if _, err := l.Settle(e.ExecID, consumed, nonIncl); !errors.Is(err, ErrWrongProof) {
|
||||
@@ -148,7 +148,7 @@ func TestUnverifiedParentCannotSettleOrRelease(t *testing.T) {
|
||||
_, incl := acceptedConsuming(t, testCBlock, testParent, e.ExecID)
|
||||
|
||||
l := NewLedger()
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e), testAuthorized); err != nil {
|
||||
t.Fatalf("reserve: %v", err)
|
||||
}
|
||||
var forged AcceptedBlock // the zero value an outside package can build
|
||||
@@ -199,7 +199,7 @@ func TestUselessButResolvableReservation(t *testing.T) {
|
||||
q, nonIncl := acceptedConsuming(t, testCBlock, testParent)
|
||||
|
||||
l := NewLedger()
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e), testAuthorized); err != nil {
|
||||
t.Fatalf("reserve: %v", err)
|
||||
}
|
||||
if _, err := l.Release(e.ExecID, q, nonIncl); err != nil {
|
||||
@@ -261,7 +261,7 @@ func TestRule1_ReserveRequiresTheAcceptedParent(t *testing.T) {
|
||||
t.Run("unverified parent is refused", func(t *testing.T) {
|
||||
l := NewLedger()
|
||||
var proposed AcceptedBlock // the zero value an outside package can build
|
||||
if _, err := l.Reserve(proposed, verified(t, e)); !errors.Is(err, ErrAcceptedUnverified) {
|
||||
if _, err := l.Reserve(proposed, verified(t, e), testAuthorized); !errors.Is(err, ErrAcceptedUnverified) {
|
||||
t.Fatalf("reserving against an unverified parent must be refused, got %v", err)
|
||||
}
|
||||
if r, _, _ := l.Counts(); r != 0 {
|
||||
@@ -278,14 +278,14 @@ func TestRule1_ReserveRequiresTheAcceptedParent(t *testing.T) {
|
||||
t.Fatalf("VerifyAcceptedBlock: %v", err)
|
||||
}
|
||||
l := NewLedger()
|
||||
if _, err := l.Reserve(wrong, verified(t, e)); !errors.Is(err, ErrWrongParent) {
|
||||
if _, err := l.Reserve(wrong, verified(t, e), testAuthorized); !errors.Is(err, ErrWrongParent) {
|
||||
t.Fatalf("reserving against the wrong accepted parent must be refused, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the accepted parent it is scoped to succeeds", func(t *testing.T) {
|
||||
l := NewLedger()
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e)); err != nil {
|
||||
if _, err := l.Reserve(acceptedParent(t), verified(t, e), testAuthorized); err != nil {
|
||||
t.Fatalf("reserving against the correct accepted parent must succeed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
+131
-2
@@ -122,6 +122,10 @@ var (
|
||||
ErrTerminalExists = errors.New("dexprotocol: a terminal execution already exists under that id")
|
||||
ErrWrongProof = errors.New("dexprotocol: the proof does not match the transition being attempted")
|
||||
ErrWrongParent = errors.New("dexprotocol: the execution is not scoped to this accepted C parent")
|
||||
|
||||
ErrOverReserved = errors.New("dexprotocol: this would commit more than the order authorized")
|
||||
ErrAuthorizedChanged = errors.New("dexprotocol: the order's authorized quantity changed between reservations")
|
||||
ErrNotConserved = errors.New("dexprotocol: the order account disagrees with the executions it summarises")
|
||||
)
|
||||
|
||||
// THE ENTIRE PROOF SYSTEM, THREE RULES:
|
||||
@@ -206,6 +210,7 @@ type Ledger struct {
|
||||
reserved map[ids.ID]ReservedExecution
|
||||
traded map[ids.ID]Trade
|
||||
released map[ids.ID]Released
|
||||
orders map[ids.ID]*orderAccount
|
||||
}
|
||||
|
||||
func NewLedger() *Ledger {
|
||||
@@ -213,9 +218,34 @@ func NewLedger() *Ledger {
|
||||
reserved: make(map[ids.ID]ReservedExecution),
|
||||
traded: make(map[ids.ID]Trade),
|
||||
released: make(map[ids.ID]Released),
|
||||
orders: make(map[ids.ID]*orderAccount),
|
||||
}
|
||||
}
|
||||
|
||||
// orderAccount is the per-order quantity account. Exclusivity across the three
|
||||
// storage domains says an ExecID cannot be in two places; it says NOTHING about how
|
||||
// much of an order has been committed. Without this, D can certify ten executions
|
||||
// against one order for its full quantity, every one of them individually valid,
|
||||
// and settle whichever the C successors happen to include.
|
||||
//
|
||||
// EVERY DEFECT THE LAST REVIEW FOUND WAS A CONSERVATION BUG, not a determinism bug.
|
||||
// So the quantities are tracked here and asserted after every transition, rather
|
||||
// than left as a property that happens to hold.
|
||||
//
|
||||
// UNITS. These are Execution.Quantity's units — the market's LOTS — never the
|
||||
// order's Input, which is in token base units. The conversion is the market's lot
|
||||
// size, which is market metadata and deliberately does not live in this package: a
|
||||
// caller that knows the market converts once and passes `authorized` in. One
|
||||
// conversion, at one boundary, in the direction the book already speaks.
|
||||
type orderAccount struct {
|
||||
authorized uint64
|
||||
reserved uint64
|
||||
traded uint64
|
||||
}
|
||||
|
||||
// available is what is left to commit against this order.
|
||||
func (a *orderAccount) available() uint64 { return a.authorized - a.reserved - a.traded }
|
||||
|
||||
// terminalExists reports whether the id has already reached a terminal domain.
|
||||
// Checked on entry and on every transition, so an id can never be resurrected.
|
||||
func (l *Ledger) terminalExists(id ids.ID) bool {
|
||||
@@ -229,18 +259,42 @@ func (l *Ledger) terminalExists(id ids.ID) bool {
|
||||
// Reserve admits a verified execution. Refuses an id that is already reserved or
|
||||
// already terminal — the latter is what stops a replayed certificate from
|
||||
// re-reserving liquidity that was already settled or released.
|
||||
func (l *Ledger) Reserve(parent AcceptedBlock, v VerifiedExecution) (ReservedExecution, error) {
|
||||
id := v.Execution().ExecID
|
||||
// authorized is the order's total quantity in the book's units, converted by the
|
||||
// caller from the signed Order.Input. It is checked for AGREEMENT across every
|
||||
// reservation against the same order, so a caller cannot enlarge an order by
|
||||
// converting it differently the second time.
|
||||
func (l *Ledger) Reserve(parent AcceptedBlock, v VerifiedExecution, authorized uint64) (ReservedExecution, error) {
|
||||
e := v.Execution()
|
||||
id := e.ExecID
|
||||
if l.terminalExists(id) {
|
||||
return ReservedExecution{}, fmt.Errorf("%w: %s", ErrTerminalExists, id)
|
||||
}
|
||||
if _, ok := l.reserved[id]; ok {
|
||||
return ReservedExecution{}, fmt.Errorf("%w: %s", ErrAlreadyOpen, id)
|
||||
}
|
||||
if authorized == 0 {
|
||||
return ReservedExecution{}, fmt.Errorf("%w: order %s authorizes nothing", ErrOverReserved, e.OrderID)
|
||||
}
|
||||
acct, ok := l.orders[e.OrderID]
|
||||
if !ok {
|
||||
acct = &orderAccount{authorized: authorized}
|
||||
} else if acct.authorized != authorized {
|
||||
return ReservedExecution{}, fmt.Errorf("%w: order %s was %d, now %d",
|
||||
ErrAuthorizedChanged, e.OrderID, acct.authorized, authorized)
|
||||
}
|
||||
// THE CHECK THAT MAKES OVER-RESERVATION IMPOSSIBLE. Each execution against an
|
||||
// order is individually valid; only the running total says whether D has
|
||||
// certified more than the trader signed for.
|
||||
if e.Quantity > acct.available() {
|
||||
return ReservedExecution{}, fmt.Errorf("%w: order %s has %d left, execution wants %d",
|
||||
ErrOverReserved, e.OrderID, acct.available(), e.Quantity)
|
||||
}
|
||||
r, err := Reserve(parent, v)
|
||||
if err != nil {
|
||||
return ReservedExecution{}, err
|
||||
}
|
||||
acct.reserved += e.Quantity
|
||||
l.orders[e.OrderID] = acct
|
||||
l.reserved[id] = r
|
||||
return r, nil
|
||||
}
|
||||
@@ -257,6 +311,12 @@ func (l *Ledger) Settle(id ids.ID, q AcceptedBlock, proof ExecProof) (Trade, err
|
||||
if err != nil {
|
||||
return Trade{}, err
|
||||
}
|
||||
// Reserved becomes traded: the order's committed total is unchanged, which is
|
||||
// exactly right — settling spends what was already held, it does not spend more.
|
||||
if acct, ok := l.orders[r.exec.OrderID]; ok {
|
||||
acct.reserved -= r.exec.Quantity
|
||||
acct.traded += r.exec.Quantity
|
||||
}
|
||||
delete(l.reserved, id)
|
||||
l.traded[id] = t
|
||||
return t, nil
|
||||
@@ -272,6 +332,13 @@ func (l *Ledger) Release(id ids.ID, q AcceptedBlock, proof ExecProof) (Released,
|
||||
if err != nil {
|
||||
return Released{}, err
|
||||
}
|
||||
// The quantity returns to the order's available balance. This is what lets the
|
||||
// same unexecuted order be matched AGAIN — the property the portable Order was
|
||||
// designed for. A release that failed to give the quantity back would silently
|
||||
// shrink every order that ever lost a race.
|
||||
if acct, ok := l.orders[r.exec.OrderID]; ok {
|
||||
acct.reserved -= r.exec.Quantity
|
||||
}
|
||||
delete(l.reserved, id)
|
||||
l.released[id] = rel
|
||||
return rel, nil
|
||||
@@ -298,3 +365,65 @@ func (l *Ledger) Exclusive() error {
|
||||
func (l *Ledger) Counts() (reserved, traded, released int) {
|
||||
return len(l.reserved), len(l.traded), len(l.released)
|
||||
}
|
||||
|
||||
// Conserved is the quantity invariant, the counterpart to Exclusive. Exclusive says
|
||||
// an ExecID cannot be in two domains; Conserved says quantity is neither created nor
|
||||
// destroyed:
|
||||
//
|
||||
// per order: reserved + traded ≤ authorized
|
||||
// per order: reserved == Σ quantity of its executions still in the reserved domain
|
||||
// per order: traded == Σ quantity of its executions in the traded domain
|
||||
//
|
||||
// The second and third clauses are what make this an ASSERTION rather than a
|
||||
// restatement: they recompute the totals from the domains themselves, so an
|
||||
// accounting update that drifted from the objects it claims to summarise is caught
|
||||
// instead of trusted. Assert it after EVERY transition, not only after swaps that
|
||||
// went the happy way.
|
||||
func (l *Ledger) Conserved() error {
|
||||
reserved := make(map[ids.ID]uint64, len(l.orders))
|
||||
traded := make(map[ids.ID]uint64, len(l.orders))
|
||||
for _, r := range l.reserved {
|
||||
reserved[r.exec.OrderID] += r.exec.Quantity
|
||||
}
|
||||
for _, t := range l.traded {
|
||||
traded[t.exec.OrderID] += t.exec.Quantity
|
||||
}
|
||||
for id, acct := range l.orders {
|
||||
if acct.reserved+acct.traded > acct.authorized {
|
||||
return fmt.Errorf("%w: order %s authorized %d, reserved %d + traded %d",
|
||||
ErrOverReserved, id, acct.authorized, acct.reserved, acct.traded)
|
||||
}
|
||||
if acct.reserved != reserved[id] {
|
||||
return fmt.Errorf("%w: order %s account says reserved %d, domain holds %d",
|
||||
ErrNotConserved, id, acct.reserved, reserved[id])
|
||||
}
|
||||
if acct.traded != traded[id] {
|
||||
return fmt.Errorf("%w: order %s account says traded %d, domain holds %d",
|
||||
ErrNotConserved, id, acct.traded, traded[id])
|
||||
}
|
||||
}
|
||||
// An execution whose order has no account is quantity from nowhere.
|
||||
for id := range reserved {
|
||||
if _, ok := l.orders[id]; !ok {
|
||||
return fmt.Errorf("%w: reserved execution for unaccounted order %s", ErrNotConserved, id)
|
||||
}
|
||||
}
|
||||
for id := range traded {
|
||||
if _, ok := l.orders[id]; !ok {
|
||||
return fmt.Errorf("%w: traded execution for unaccounted order %s", ErrNotConserved, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OrderQuantities reports one order's account: what the trader authorized, what is
|
||||
// held against it right now, and what has been permanently spent. Released quantity
|
||||
// does not appear because it is not a category — it went back to available, which is
|
||||
// authorized - reserved - traded.
|
||||
func (l *Ledger) OrderQuantities(orderID ids.ID) (authorized, reserved, traded uint64, ok bool) {
|
||||
acct, ok := l.orders[orderID]
|
||||
if !ok {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return acct.authorized, acct.reserved, acct.traded, true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user