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

732 lines
32 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dchain
import (
"context"
"errors"
"testing"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/database/memdb"
"github.com/luxfi/dex/pkg/zapwire"
"github.com/luxfi/log"
)
// settlement_identity_test.go is the PERMANENT REGRESSION suite for the custody
// settlement-identity collision class: the matcher's 8-byte DEXOrder.UserID is a
// compact STP handle that MAY collide, and it MUST NEVER bear value. Settlement
// identity is resolved EXCLUSIVELY through the orderuser: index to the full 16-byte
// account. These tests pin that settlement FAILS CLOSED (ErrMissingSettlementUser,
// block rejected, no value moves) whenever the full identity is unavailable —
// rather than falling back to the compact handle, which would make a handle
// collision a theft primitive.
//
// MUTATION CONTRACT: reintroducing ANY fallback from the orderuser: index to the
// 8-byte handle (or the trade's 8-byte-derived carried user) makes the fail-closed
// and exploit tests below RED. That is the whole point.
// twoUsersSharingUserID8 returns a victim/attacker pair of full 16-byte wire users
// that FOLD TO THE SAME 8-byte matcher handle (identical leading 8 bytes) but are
// DISTINCT full accounts (differ in bytes 8..15) — exactly the collision the
// matcher handle permits and settlement must never honor.
func twoUsersSharingUserID8() (victim, attacker string) {
victim = string([]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, 0x11, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08})
attacker = string([]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, 0x11, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8})
return victim, attacker
}
// buildVerify builds a block from the mempool's current txs and runs the VALIDATOR
// path (re-parse + Verify) WITHOUT Accept, returning the verify error (nil on
// success) and the verified block. Used to assert a block FAILS verification
// (fail-closed) without committing.
func buildVerify(t *testing.T, vm *VM) (*Block, error) {
t.Helper()
ctx := context.Background()
built, err := vm.BuildBlock(ctx)
if err != nil {
return nil, err
}
reparsed, err := vm.ParseBlock(ctx, built.Bytes())
if err != nil {
t.Fatalf("ParseBlock: %v", err)
}
blk := reparsed.(*Block)
return blk, blk.Verify(ctx)
}
// deleteOrderUserRow removes the orderuser: row for a resting order directly from
// the VM's durable store — modeling the corruption/attack the fail-closed rule
// defends against (a maker fill/cancel whose full settlement identity has been
// lost or hidden). Operates under vm.mu, like the production state writes.
func deleteOrderUserRow(t *testing.T, vm *VM, poolID [32]byte, orderID uint64) {
t.Helper()
vm.mu.Lock()
defer vm.mu.Unlock()
if err := vm.db.Delete(orderUserKey(poolID, orderID)); err != nil {
t.Fatalf("delete orderuser row: %v", err)
}
}
// hasOrderUserRow reports whether the orderuser: row exists for a resting order.
func hasOrderUserRow(t *testing.T, vm *VM, poolID [32]byte, orderID uint64) bool {
t.Helper()
vm.mu.Lock()
defer vm.mu.Unlock()
_, ok, err := getOrderUser(vm.db, poolID, orderID)
if err != nil {
t.Fatalf("getOrderUser: %v", err)
}
return ok
}
// fundOpenAndRestMaker is the common setup: deposit maker base + taker quote, open
// the LUX/LUSD custody market, and rest a maker SELL of `size` LUX @ `price`.
// Returns the resting maker order id.
func fundOpenAndRestMaker(t *testing.T, vm *VM, pool [32]byte, maker, taker string, makerBase, takerQuote uint64, price, size float64) uint64 {
t.Helper()
addBlock(t, vm,
depositTx(t, maker, assetLUX, makerBase),
depositTx(t, taker, assetLUSD, takerQuote),
openMarketTx(t, pool, assetLUX, assetLUSD),
)
blk := addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, price, size, maker))
return blockDeterministicID(blk.height, 0)
}
// ---------------------------------------------------------------------------
// 1. Settlement fails closed when the orderuser mapping is missing.
// ---------------------------------------------------------------------------
// TestMissingOrderUserFailsClosed proves a crossing submit against a resting maker
// whose orderuser: row is gone ABORTS with ErrMissingSettlementUser at Verify —
// no partial settlement, no balance mutation, head unchanged.
func TestMissingOrderUserFailsClosed(t *testing.T) {
ctx := context.Background()
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(ctx)
const maker, taker = "maker-fc", "taker-fc"
pool := [32]byte{0xfc, 0x01}
makerOrderID := fundOpenAndRestMaker(t, vm, pool, maker, taker, 100, 1000, 5.0, 10.0)
assertBalance(t, vm, maker, assetLUX, 90, 10)
// Build the crossing block (probe sees the row), THEN delete the row, THEN Verify
// (the validator re-executes against state with the row gone).
vm.mempool.Add(submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, taker))
built, err := vm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
deleteOrderUserRow(t, vm, pool, makerOrderID)
reparsed, err := vm.ParseBlock(ctx, built.Bytes())
if err != nil {
t.Fatalf("ParseBlock: %v", err)
}
if err := reparsed.Verify(ctx); !errors.Is(err, ErrMissingSettlementUser) {
t.Fatalf("Verify err = %v, want ErrMissingSettlementUser (settlement must fail closed, not fall back to the 8-byte handle)", err)
}
// No value moved: maker still has its locked 10 LUX, taker still has all quote.
assertBalance(t, vm, maker, assetLUX, 90, 10)
assertBalance(t, vm, maker, assetLUSD, 0, 0)
assertBalance(t, vm, taker, assetLUSD, 1000, 0)
assertBalance(t, vm, taker, assetLUX, 0, 0)
}
// ---------------------------------------------------------------------------
// 2. No maker fallback to the 8-byte handle.
// ---------------------------------------------------------------------------
// TestSettlementNeverFallsBackToMatcherUserID proves the maker's settlement
// identity comes ONLY from the orderuser: index: with the row deleted, the maker
// fill cannot resolve a 16-byte account and the block aborts — even though the
// matcher's 8-byte handle is still present on the fill/row. The settlement
// identity is the wallet (AccountID); the matcher's UserID8 is only a matching
// hint and NEVER a fallback for value. (If a fallback existed, the cross would
// settle, mis-routing the maker leg to a handle-derived account.)
func TestSettlementNeverFallsBackToMatcherUserID(t *testing.T) {
ctx := context.Background()
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(ctx)
const maker, taker = "mk-nofb", "tk-nofb"
pool := [32]byte{0xfb, 0x01}
makerOrderID := fundOpenAndRestMaker(t, vm, pool, maker, taker, 100, 1000, 5.0, 10.0)
vm.mempool.Add(submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, taker))
built, err := vm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
deleteOrderUserRow(t, vm, pool, makerOrderID)
reparsed, _ := vm.ParseBlock(ctx, built.Bytes())
if err := reparsed.Verify(ctx); !errors.Is(err, ErrMissingSettlementUser) {
t.Fatalf("maker-fill Verify err = %v, want ErrMissingSettlementUser (no fallback to 8-byte maker handle)", err)
}
}
// ---------------------------------------------------------------------------
// 3. No taker fallback to the 8-byte handle.
// ---------------------------------------------------------------------------
// TestNoTakerFallbackToUserID8 proves the TAKER's settlement identity is the FULL
// 16-byte submit user, never an 8-byte fold: two takers that share their leading 8
// bytes (same matcher handle) but differ in bytes 8..15 settle to DISTINCT accounts.
// If the taker side folded to 8 bytes, both takers' proceeds would land on ONE
// colliding account.
func TestNoTakerFallbackToUserID8(t *testing.T) {
ctx := context.Background()
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(ctx)
takerA, takerB := twoUsersSharingUserID8()
const maker = "mk-taker8"
pool := [32]byte{0x78, 0x01}
// Maker rests enough base for two crosses (20 LUX), each taker funded with 50 LUSD.
addBlock(t, vm,
depositTx(t, maker, assetLUX, 100),
depositTx(t, takerA, assetLUSD, 50),
depositTx(t, takerB, assetLUSD, 50),
openMarketTx(t, pool, assetLUX, assetLUSD),
)
addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, 5.0, 20.0, maker))
// Taker A buys 10 LUX @ 5 (50 LUSD). Then taker B buys 10 LUX @ 5 (50 LUSD).
addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, takerA))
addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, takerB))
// Each taker received its OWN 10 LUX — distinct accounts, no collision.
assertBalance(t, vm, takerA, assetLUX, 10, 0)
assertBalance(t, vm, takerB, assetLUX, 10, 0)
assertBalance(t, vm, takerA, assetLUSD, 0, 0)
assertBalance(t, vm, takerB, assetLUSD, 0, 0)
// The maker received both takers' 100 LUSD.
assertBalance(t, vm, maker, assetLUSD, 100, 0)
}
// ---------------------------------------------------------------------------
// 4. A resting order keeps its FULL 16-byte settlement identity across a restart.
// ---------------------------------------------------------------------------
// TestRestingOrderAfterRestartKeepsFullUser16 proves a maker that rests with a full
// 16-byte user, survives a VM restart (book + orderuser: rebuilt from durable
// state), and is then crossed, credits the FULL 16-byte account — not the 8-byte
// handle the rebuilt in-RAM order carries. The maker uses a user whose bytes 8..15
// are non-zero, so an 8-byte-folded credit would land on a DIFFERENT account.
func TestRestingOrderAfterRestartKeepsFullUser16(t *testing.T) {
ctx := context.Background()
db := memdb.New()
vm, _ := newTestVM(t, db)
// Maker user with significant bytes 8..15 (would be lost by an 8-byte fold).
maker := string([]byte{0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98})
const taker = "tk-restart"
pool := [32]byte{0x9e, 0x01}
fundOpenAndRestMaker(t, vm, pool, maker, taker, 100, 1000, 5.0, 10.0)
vm.Shutdown(ctx)
// Restart over the SAME db: book + ledger rebuilt from durable rows.
vm2, _ := newTestVM(t, db)
defer vm2.Shutdown(ctx)
// Cross the rebuilt maker.
addBlock(t, vm2, submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, taker))
// The maker's FULL 16-byte account received the 50 LUSD and gave up 10 LUX.
assertBalance(t, vm2, maker, assetLUX, 90, 0)
assertBalance(t, vm2, maker, assetLUSD, 50, 0)
// The 8-byte-folded would-be account (bytes 8..15 zeroed) got NOTHING.
folded := string([]byte{0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80})
assertBalance(t, vm2, folded, assetLUSD, 0, 0)
assertBalance(t, vm2, folded, assetLUX, 0, 0)
}
// ---------------------------------------------------------------------------
// 5. Crash after book-insert but before orderuser-persist cannot settle.
// ---------------------------------------------------------------------------
// TestCrashAfterBookInsertBeforeOrderUserCannotSettle models the worst ordering
// hazard: a resting maker order exists in the book but its orderuser: row is
// absent (as if a crash interleaved between the two writes — which the production
// ordering forbids, but we force it here). A crossing taker MUST abort fail-closed
// rather than settle the maker leg to the 8-byte handle.
func TestCrashAfterBookInsertBeforeOrderUserCannotSettle(t *testing.T) {
ctx := context.Background()
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(ctx)
const maker, taker = "mk-crash", "tk-crash"
pool := [32]byte{0xc5, 0x01}
makerOrderID := fundOpenAndRestMaker(t, vm, pool, maker, taker, 100, 1000, 5.0, 10.0)
// Force the hazard: drop the orderuser row, leaving the book row intact.
deleteOrderUserRow(t, vm, pool, makerOrderID)
// A crossing taker now aborts at BuildBlock's probe OR Verify — either way the
// block never settles. Drive both: BuildBlock executes the probe first.
vm.mempool.Add(submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, taker))
_, verr := buildVerify(t, vm)
if !errors.Is(verr, ErrMissingSettlementUser) {
t.Fatalf("crossing a book row with no orderuser err = %v, want ErrMissingSettlementUser", verr)
}
// Nothing settled.
assertBalance(t, vm, maker, assetLUX, 90, 10)
assertBalance(t, vm, taker, assetLUSD, 1000, 0)
}
// ---------------------------------------------------------------------------
// 6. orderuser is persisted BEFORE the order is matchable.
// ---------------------------------------------------------------------------
// TestOrderUserPersistedBeforeBookInsert proves the placement ordering: once a
// place is accepted, the resting order has BOTH a per-order lock reserve AND an
// orderuser: identity row — neither is deferred past the book insert. (The lock +
// identity are written inside lockOrderSpend, which runs BEFORE applyTx inserts the
// order into the book.) A funded resting order therefore NEVER exists without its
// settlement identity.
func TestOrderUserPersistedBeforeBookInsert(t *testing.T) {
ctx := context.Background()
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(ctx)
const maker, taker = "mk-order", "tk-order"
pool := [32]byte{0x0d, 0x01}
makerOrderID := fundOpenAndRestMaker(t, vm, pool, maker, taker, 100, 1000, 5.0, 10.0)
// The resting order carries its full 16-byte settlement identity.
if !hasOrderUserRow(t, vm, pool, makerOrderID) {
t.Fatal("resting order has NO orderuser row after place (identity not persisted before book insert)")
}
vm.mu.Lock()
gotUser, ok, err := getOrderUser(vm.db, pool, makerOrderID)
vm.mu.Unlock()
if err != nil || !ok {
t.Fatalf("getOrderUser ok=%v err=%v", ok, err)
}
if gotUser != userKey16(maker) {
t.Fatalf("orderuser row = %x, want full 16-byte maker identity %x", gotUser, userKey16(maker))
}
// And a per-order lock reserve exists in lockstep.
vm.mu.Lock()
_, lockedAmt, lockOK, lerr := getOrderLock(vm.db, pool, makerOrderID)
vm.mu.Unlock()
if lerr != nil || !lockOK || lockedAmt == 0 {
t.Fatalf("resting order missing per-order lock reserve: ok=%v amt=%d err=%v", lockOK, lockedAmt, lerr)
}
}
// ---------------------------------------------------------------------------
// 7. THE EXPLOIT: a colliding userID8 cannot steal a victim maker fill.
// ---------------------------------------------------------------------------
// TestCollidingUserID8CannotReceiveMakerProceeds is the headline theft PoC. A
// victim deposits and rests a custody maker. An attacker shares the victim's 8-byte
// matcher handle (differs in bytes 8..15). The attacker's orderuser: mapping is then
// hidden (modeling the attacker forcing settlement onto the bare 8-byte handle), and
// the attacker tries to cross/claim the victim's resting fill. The cross MUST abort
// ErrMissingSettlementUser: NO attacker credit, NO victim debit, conservation holds.
// (The ownership axis of this exact class — that the colliding attacker is never an
// explicit party to the fill — is pinned in
// TestOwnershipInvariant_CollidingAttackerIsNotAParty.)
//
// MUTATION: if settlement fell back to the 8-byte handle, the colliding attacker
// would resolve to the victim's handle and the value would mis-route — this test
// goes RED, exactly as required.
func TestCollidingUserID8CannotReceiveMakerProceeds(t *testing.T) {
ctx := context.Background()
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(ctx)
victim, attacker := twoUsersSharingUserID8()
// Sanity: the two users DO collide on the 8-byte matcher handle but are distinct
// full accounts (the precondition for the exploit class).
if userKey16(victim) == userKey16(attacker) {
t.Fatal("test setup invalid: victim/attacker are the same full account")
}
pool := [32]byte{0xae, 0x01}
// Victim deposits 100 LUX and rests a SELL of 10 LUX @ 5 (locks 10 LUX). Taker
// (the attacker, sharing the handle) is funded with quote to attempt the cross.
addBlock(t, vm,
depositTx(t, victim, assetLUX, 100),
depositTx(t, attacker, assetLUSD, 1000),
openMarketTx(t, pool, assetLUX, assetLUSD),
)
blk := addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, victim))
victimOrderID := blockDeterministicID(blk.height, 0)
assertBalance(t, vm, victim, assetLUX, 90, 10)
// Build the attacker's crossing buy (probe succeeds), then HIDE the victim's
// orderuser mapping (the attack: force settlement onto the bare 8-byte handle),
// then Verify as a validator would.
vm.mempool.Add(submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, attacker))
built, err := vm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
deleteOrderUserRow(t, vm, pool, victimOrderID)
reparsed, _ := vm.ParseBlock(ctx, built.Bytes())
if err := reparsed.Verify(ctx); !errors.Is(err, ErrMissingSettlementUser) {
t.Fatalf("colliding-handle cross Verify err = %v, want ErrMissingSettlementUser (THEFT: attacker would settle the victim's fill)", err)
}
// CONSERVATION + no theft: victim keeps its locked LUX, attacker keeps its quote.
assertBalance(t, vm, victim, assetLUX, 90, 10)
assertBalance(t, vm, victim, assetLUSD, 0, 0)
assertBalance(t, vm, attacker, assetLUSD, 1000, 0)
assertBalance(t, vm, attacker, assetLUX, 0, 0)
// Global LUX conserved (100 deposited == 90 available + 10 locked, all victim's;
// attacker holds zero LUX in either bucket).
vAvail, vLocked := mustBalance(t, vm, victim, assetLUX)
aAvail, aLocked := mustBalance(t, vm, attacker, assetLUX)
if got := vAvail + vLocked + aAvail + aLocked; got != 100 {
t.Fatalf("LUX conservation violated after aborted theft: %d want 100", got)
}
}
// mustBalance reads (available, locked) for (user, asset), failing the test on a
// ledger read error.
func mustBalance(t *testing.T, vm *VM, user string, asset [32]byte) (uint64, uint64) {
t.Helper()
avail, locked, err := vm.Balance(user, asset)
if err != nil {
t.Fatalf("Balance(%s): %v", user, err)
}
return avail, locked
}
// TestCollidingUserID8CannotWithdrawVictimFunds is the withdraw axis of the
// exploit: the victim's funds live in the ledger keyed by the FULL 16-byte account
// (the wallet / AccountID). The attacker, sharing only the 8-byte matcher handle,
// withdraws — and gets NOTHING from the victim's balance, because the ledger never
// keys by the handle. This proves the balance ledger (not just settlement) is
// collision-safe.
func TestCollidingUserID8CannotWithdrawVictimFunds(t *testing.T) {
ctx := context.Background()
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(ctx)
victim, attacker := twoUsersSharingUserID8()
pool := [32]byte{0xad, 0x01}
// Victim deposits 100 LUX and locks 10 in a resting sell; 90 stays available.
addBlock(t, vm,
depositTx(t, victim, assetLUX, 100),
openMarketTx(t, pool, assetLUX, assetLUSD),
)
addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, victim))
assertBalance(t, vm, victim, assetLUX, 90, 10)
// Attacker has deposited nothing.
assertBalance(t, vm, attacker, assetLUX, 0, 0)
// Attacker tries to withdraw 90 LUX (the victim's available). The ledger keys by
// the FULL 16-byte attacker account, which has zero balance -> realized 0.
_, wo := addBlockOutcomes(t, vm, withdrawTx(t, attacker, assetLUX, 90))
var attackerRealized uint64
for _, oc := range wo {
if oc.typ == TxWithdraw {
attackerRealized = oc.orderID
}
}
if attackerRealized != 0 {
t.Fatalf("attacker withdrew %d LUX from a colliding handle, want 0 (THEFT via the matcher handle)", attackerRealized)
}
// Victim's balance is untouched.
assertBalance(t, vm, victim, assetLUX, 90, 10)
assertBalance(t, vm, attacker, assetLUX, 0, 0)
}
// ---------------------------------------------------------------------------
// 8. Every resting book order has an orderuser mapping after a restart.
// ---------------------------------------------------------------------------
// TestEveryBookOrderHasOrderUserMappingAfterRestart proves the boot-time
// settlement-identity audit: after a restart over a healthy custody chain, EVERY
// resting order on EVERY custody market has an orderuser: row, and Initialize
// succeeds. The negative half — a missing row makes Initialize FAIL — is pinned in
// TestRestartRefusesBootWhenOrderUserMissing.
func TestEveryBookOrderHasOrderUserMappingAfterRestart(t *testing.T) {
ctx := context.Background()
db := memdb.New()
vm, _ := newTestVM(t, db)
const m1, m2, taker = "rest-m1", "rest-m2", "rest-tk"
pool := [32]byte{0xeb, 0x01}
addBlock(t, vm,
depositTx(t, m1, assetLUX, 100),
depositTx(t, m2, assetLUX, 100),
depositTx(t, taker, assetLUSD, 1000),
openMarketTx(t, pool, assetLUX, assetLUSD),
)
// Two resting makers on the custody market.
addBlock(t, vm,
placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, m1),
placePoolTx(t, pool, zapwire.SideSell, 6.0, 12.0, m2),
)
vm.Shutdown(ctx)
// Restart: the boot audit (assertOrderUserCoverage) runs inside Initialize.
vm2, _ := newTestVM(t, db)
defer vm2.Shutdown(ctx)
// Every resting order on the market has an orderuser row.
vm2.mu.Lock()
ob := vm2.books[pool]
vm2.mu.Unlock()
if ob == nil {
t.Fatal("market book did not rebuild on restart")
}
if len(ob.Orders) != 2 {
t.Fatalf("rebuilt book has %d resting orders, want 2", len(ob.Orders))
}
for _, o := range ob.Orders {
if !hasOrderUserRow(t, vm2, pool, o.ID) {
t.Fatalf("resting order %d has NO orderuser row after restart", o.ID)
}
}
}
// TestMultiPlaceInBlockKeysOrderUserByMatcherIndex proves the per-order reserve and
// identity rows key under the EXACT order id the matcher assigns — the index is
// threaded from execute's loop (not recovered by scanning the block for the tx
// pointer). Two maker PLACEs in ONE block land at txIndex 0 and 1; each rests under
// blockDeterministicID(height, its-own-index) with a matching orderuser: row, and
// each is independently crossable to its OWN account. (A pointer-scan id recovery
// could mis-resolve the second place's index to the first, keying its identity row
// under the wrong order id and tripping ErrMissingSettlementUser at fill time.)
func TestMultiPlaceInBlockKeysOrderUserByMatcherIndex(t *testing.T) {
ctx := context.Background()
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(ctx)
const m1, m2, taker = "mp-m1", "mp-m2", "mp-tk"
pool := [32]byte{0x37, 0x01}
addBlock(t, vm,
depositTx(t, m1, assetLUX, 100),
depositTx(t, m2, assetLUX, 100),
depositTx(t, taker, assetLUSD, 1000),
openMarketTx(t, pool, assetLUX, assetLUSD),
)
// Two places in ONE block: m1 at txIndex 0, m2 at txIndex 1.
blk := addBlock(t, vm,
placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, m1),
placePoolTx(t, pool, zapwire.SideSell, 7.0, 8.0, m2),
)
id0 := blockDeterministicID(blk.height, 0)
id1 := blockDeterministicID(blk.height, 1)
// Each resting order's orderuser row is keyed under its OWN matcher index and
// names its OWN full account.
vm.mu.Lock()
u0, ok0, _ := getOrderUser(vm.db, pool, id0)
u1, ok1, _ := getOrderUser(vm.db, pool, id1)
vm.mu.Unlock()
if !ok0 || u0 != userKey16(m1) {
t.Fatalf("order %d (txIndex 0) orderuser = %x ok=%v, want m1 %x", id0, u0, ok0, userKey16(m1))
}
if !ok1 || u1 != userKey16(m2) {
t.Fatalf("order %d (txIndex 1) orderuser = %x ok=%v, want m2 %x", id1, u1, ok1, userKey16(m2))
}
// Cross m1's order (sell 10@5): taker buys 10@5 -> m1 receives 50 LUSD.
addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, taker))
assertBalance(t, vm, m1, assetLUSD, 50, 0)
assertBalance(t, vm, m1, assetLUX, 90, 0)
// m2 untouched (its order still rests, 8 LUX locked).
assertBalance(t, vm, m2, assetLUX, 92, 8)
assertBalance(t, vm, m2, assetLUSD, 0, 0)
}
// TestRestartRequiresOrderUserForEveryRestingOrder is the NEGATIVE half of the boot
// audit: if a resting order on a custody market lacks its orderuser: row, a restart
// (Initialize) FAILS rather than booting onto a degraded 8-byte settlement path.
// The POSITIVE half — every resting order DOES have a row after a healthy restart —
// is TestEveryBookOrderHasOrderUserMappingAfterRestart.
func TestRestartRequiresOrderUserForEveryRestingOrder(t *testing.T) {
ctx := context.Background()
db := memdb.New()
vm, _ := newTestVM(t, db)
const maker, taker = "boot-mk", "boot-tk"
pool := [32]byte{0xbf, 0x01}
makerOrderID := fundOpenAndRestMaker(t, vm, pool, maker, taker, 100, 1000, 5.0, 10.0)
vm.Shutdown(ctx)
// Corrupt the durable state: drop the resting order's orderuser row while its
// book row survives.
if err := db.Delete(orderUserKey(pool, makerOrderID)); err != nil {
t.Fatalf("delete orderuser row: %v", err)
}
// A restart must REFUSE to boot.
toEngine := make(chan block.Message, 16)
vm2 := &VM{}
err := vm2.Initialize(ctx, block.Init{DB: db, Log: log.NewNoOpLogger(), ToEngine: toEngine})
if !errors.Is(err, ErrMissingSettlementUser) {
t.Fatalf("Initialize over corrupt custody state err = %v, want ErrMissingSettlementUser (must refuse to boot on a degraded settlement path)", err)
}
}
// ---------------------------------------------------------------------------
// 9. A sub-dust BUY never rests, so it never creates a zero-lock resting order
// with no settlement identity.
// ---------------------------------------------------------------------------
// TestSubDustBuyCannotRestWithoutOrderUser ties the zero-notional reject to the
// settlement-identity invariant: a BUY whose notional floors to zero lock (a
// sub-tick price) is REJECTED at placement, so it NEVER rests — and therefore can
// never become a resting book order that lacks a non-zero lock AND an orderuser:
// row (the degraded path settlement must never hit). This is the structural reason
// "every resting custody order carries a lock + identity" stays total: an order
// that cannot lock cannot rest, so it cannot be a resting order missing its
// identity. (Mutation: allowing a zero-lock BUY to rest would create exactly such
// an identity-less resting order; this test goes RED.)
func TestSubDustBuyCannotRestWithoutOrderUser(t *testing.T) {
ctx := context.Background()
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(ctx)
const buyer = "subdust-buyer"
pool := [32]byte{0x5d, 0x05}
addBlock(t, vm,
depositTx(t, buyer, assetLUSD, 1_000_000), // funded -> the reject is the notional floor, not affordability
openMarketTx(t, pool, assetLUX, assetLUSD),
)
// A sub-tick BUY (price 5e-9 < 1 tick, size 10) floors ceil(size*price) to 0.
blk := addBlock(t, vm, placePoolTx(t, pool, zapwire.SideBuy, dustPrice, 10.0, buyer))
orderID := blockDeterministicID(blk.height, 0)
// It did NOT rest: no book order, no per-order lock, and crucially NO orderuser
// identity row (the order never reached the book, so it has no settlement
// identity to be missing).
if orders, _, _, _, _ := vm.BookDepth(pool); orders != 0 {
t.Fatalf("sub-dust BUY rested: book has %d orders, want 0", orders)
}
if hasOrderUserRow(t, vm, pool, orderID) {
t.Fatal("sub-dust BUY created an orderuser row for an order that never rested")
}
vm.mu.Lock()
_, lockedAmt, lockOK, lerr := getOrderLock(vm.db, pool, orderID)
vm.mu.Unlock()
if lerr != nil {
t.Fatalf("getOrderLock: %v", lerr)
}
if lockOK || lockedAmt != 0 {
t.Fatalf("sub-dust BUY held a lock: ok=%v amt=%d, want none", lockOK, lockedAmt)
}
// The buyer's quote is fully available — nothing reserved.
assertBalance(t, vm, buyer, assetLUSD, 1_000_000, 0)
}
// ---------------------------------------------------------------------------
// 10. The settlement identity is exactly the full wire-user width.
// ---------------------------------------------------------------------------
// TestSettlementIdentityWidthEnforced pins that the settlement identity (the
// wallet / AccountID the ledger keys value by) is the FULL 16-byte wire user
// (zapwire.UserSize), NOT the matcher's 8-byte STP handle. It asserts: (a) the
// userKey type is 16 bytes; (b) a resting order's persisted orderuser: row is
// exactly 16 bytes and equals the full wire user; (c) two accounts sharing the
// leading 8 bytes resolve to DISTINCT 16-byte identities (so a handle collision is
// not an identity collision). Width is the load-bearing property: a fold to 8
// bytes is what would make the matcher handle value-bearing.
func TestSettlementIdentityWidthEnforced(t *testing.T) {
ctx := context.Background()
vm, _ := newTestVM(t, memdb.New())
defer vm.Shutdown(ctx)
// (a) The settlement identity type is the full wire width.
if got := len(userKey{}); got != zapwire.UserSize {
t.Fatalf("settlement identity width = %d, want zapwire.UserSize (%d)", got, zapwire.UserSize)
}
if zapwire.UserSize != 16 {
t.Fatalf("zapwire.UserSize = %d, want 16 (the settlement identity must be 16 bytes, not the 8-byte handle)", zapwire.UserSize)
}
// (b) A resting order persists its identity at full 16-byte width.
victim, attacker := twoUsersSharingUserID8()
pool := [32]byte{0x1d, 0x77}
makerOrderID := fundOpenAndRestMaker(t, vm, pool, victim, "tk-width", 100, 1000, 5.0, 10.0)
vm.mu.Lock()
raw, rerr := vm.db.Get(orderUserKey(pool, makerOrderID))
gotUser, ok, gerr := getOrderUser(vm.db, pool, makerOrderID)
vm.mu.Unlock()
if rerr != nil || gerr != nil || !ok {
t.Fatalf("orderuser read: raw_err=%v get_err=%v ok=%v", rerr, gerr, ok)
}
if len(raw) != zapwire.UserSize {
t.Fatalf("persisted orderuser row width = %d, want %d (settlement identity must be stored full-width)", len(raw), zapwire.UserSize)
}
if gotUser != userKey16(victim) {
t.Fatalf("orderuser = %x, want full 16-byte victim %x", gotUser, userKey16(victim))
}
// (c) Two accounts sharing the leading 8 bytes are DISTINCT 16-byte identities.
if userKey16(victim) == userKey16(attacker) {
t.Fatal("colliding-handle accounts resolved to the SAME 16-byte identity (width fold lost bytes 8..15)")
}
// And their leading 8 bytes DO match (the precondition: the collision is at the
// handle, not the identity).
v16, a16 := userKey16(victim), userKey16(attacker)
if string(v16[:8]) != string(a16[:8]) {
t.Fatal("test setup invalid: the two accounts do not share the 8-byte matcher handle")
}
if string(v16[8:]) == string(a16[8:]) {
t.Fatal("test setup invalid: the two accounts do not differ in bytes 8..15")
}
}
// ---------------------------------------------------------------------------
// 11. MUTATION: rebuilding a maker from the 8-byte handle after restart breaks
// settlement (the identity must come from the full-width orderuser: row).
// ---------------------------------------------------------------------------
// TestRestartRebuildsMakerFromFullIdentityNotUserID8 is the explicit mutation pin
// for the restart path: a maker that rests with a full 16-byte wallet, survives a
// VM restart (its in-RAM order is rebuilt from the GPU row carrying only 8 user
// bytes), and is then crossed, MUST credit the FULL 16-byte account — resolved
// from the orderuser: row, NOT the rebuilt order's 8-byte handle. The maker's
// bytes 8..15 are non-zero, so an 8-byte-fold rebuild would credit a DIFFERENT
// account and this test goes RED. (This is the named restart-axis companion to
// TestRestingOrderAfterRestartKeepsFullUser16.)
func TestRestartRebuildsMakerFromFullIdentityNotUserID8(t *testing.T) {
ctx := context.Background()
db := memdb.New()
vm, _ := newTestVM(t, db)
// A maker wallet with significant bytes 8..15 (an 8-byte fold would lose them).
maker := string([]byte{0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8})
const taker = "tk-rebuild"
pool := [32]byte{0x2b, 0x11}
fundOpenAndRestMaker(t, vm, pool, maker, taker, 100, 1000, 5.0, 10.0)
vm.Shutdown(ctx)
// Restart over the SAME db: the book is rebuilt from the durable order:* rows
// (whose UserID is only 8 bytes); the settlement identity must still come from
// the full-width orderuser: row.
vm2, _ := newTestVM(t, db)
defer vm2.Shutdown(ctx)
// Cross the rebuilt maker; ownership must hold (only the full-id maker and the
// taker change value), and the maker's FULL account is credited.
own := newOwnershipChecker(t, vm2)
blk := addBlock(t, vm2, submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, taker))
own.assert(t, vm2, blk, "restart-rebuild-cross")
assertBalance(t, vm2, maker, assetLUX, 90, 0)
assertBalance(t, vm2, maker, assetLUSD, 50, 0)
// The 8-byte-folded would-be account (bytes 8..15 zeroed) got NOTHING.
folded := string([]byte{0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8})
assertBalance(t, vm2, folded, assetLUSD, 0, 0)
assertBalance(t, vm2, folded, assetLUX, 0, 0)
}