mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
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.
This commit is contained in:
+9
-5
@@ -7,7 +7,8 @@
|
||||
# StatefulSet runs from one image:
|
||||
#
|
||||
# /usr/local/bin/dvenue — the ZAP matching engine (luxfi/dex cmd/dvenue,
|
||||
# built `-tags dchain,cgo`). This is the real CLOB:
|
||||
# //go:build cgo, built CGO_ENABLED=1). This is the
|
||||
# real CLOB:
|
||||
# it links the GPU matching kernels (libdex_clob_cuda
|
||||
# + libamm_xyk_cuda) and the secp256k1 ecrecover
|
||||
# bundle via pkg-config. The 0x9010 C-Chain precompile
|
||||
@@ -88,17 +89,20 @@ RUN --mount=type=secret,id=ghtok,required=false \
|
||||
ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig
|
||||
|
||||
# --- Build dvenue (the GPU matcher) from luxfi/dex ---------------------------
|
||||
# cmd/dvenue is `//go:build dchain && cgo`. CGO_ENABLED=1 + GOARCH=arm64; the
|
||||
# pkg-config libs above satisfy orderbook_cuda + amm_gpu_cuda + signed_order_gpu.
|
||||
# cmd/dvenue is `//go:build cgo`: CGO_ENABLED=1 (which sets the `cgo` build tag)
|
||||
# selects pkg/lx's GPU matcher (orderbook_cuda + amm_gpu_cuda + signed_order_gpu,
|
||||
# all gated on `cgo`), satisfied by the pkg-config libs above. `-tags cgo` is
|
||||
# explicit-for-readability (CGO_ENABLED=1 already implies it). The pure-Go CPU
|
||||
# venue is cmd/dvenue-cpu (//go:build !cgo) — not built here.
|
||||
WORKDIR /build/dex
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=1 GOOS=linux GOARCH=arm64 \
|
||||
go build -trimpath -tags "dchain,cgo" -ldflags="-s -w" \
|
||||
go build -trimpath -tags "cgo" -ldflags="-s -w" \
|
||||
-o /out/dvenue ./cmd/dvenue \
|
||||
&& CGO_ENABLED=1 GOOS=linux GOARCH=arm64 \
|
||||
go build -trimpath -tags "dchain,cgo" -ldflags="-s -w" \
|
||||
go build -trimpath -tags "cgo" -ldflags="-s -w" \
|
||||
-o /out/dvenue-custody ./cmd/dvenue-custody
|
||||
|
||||
# --- Build the dchain-tagged luxd (the proxy node) ---------------------------
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/luxfi/geth/core/types"
|
||||
)
|
||||
|
||||
// matrix tracks per-operation outcomes for the final report.
|
||||
type matrix struct {
|
||||
rows [][3]string // op, result, detail
|
||||
}
|
||||
|
||||
func (m *matrix) add(op, result, detail string) {
|
||||
m.rows = append(m.rows, [3]string{op, result, detail})
|
||||
}
|
||||
|
||||
func opResult(rec *types.Receipt) string {
|
||||
if rec.Status == types.ReceiptStatusSuccessful {
|
||||
return "WORKING"
|
||||
}
|
||||
return "REVERTED"
|
||||
}
|
||||
|
||||
func boolRes(b bool) string {
|
||||
if b {
|
||||
return "WORKING"
|
||||
}
|
||||
return "FAIL"
|
||||
}
|
||||
|
||||
// deposit drives a native-LUX deposit and returns the resulting receipt + the
|
||||
// credited delta to available.
|
||||
func (r *runner) deposit(amount *big.Int) (*types.Receipt, *big.Int) {
|
||||
pre := r.availOf(r.from, nativeLUX)
|
||||
data := append(sel(selDeposit), addr32(nativeLUX)...)
|
||||
data = append(data, uint256ABI(amount)...)
|
||||
rec := r.send("deposit", data, amount) // msg.value == amount
|
||||
post := r.availOf(r.from, nativeLUX)
|
||||
return rec, new(big.Int).Sub(post, pre)
|
||||
}
|
||||
|
||||
// modifyLiquidity places (delta>0) or cancels (delta<0) a resting order at the
|
||||
// given tick band; returns receipt + availableΔ (negative = locked, positive =
|
||||
// unlocked).
|
||||
func (r *runner) modifyLiquidity(label string, tickLower, delta int64) (*types.Receipt, *big.Int) {
|
||||
pre := r.availOf(r.from, nativeLUX)
|
||||
data := append(sel(selModifyLiquidity), poolKeyABI()...)
|
||||
data = append(data, int256ABI(big.NewInt(tickLower))...)
|
||||
data = append(data, int256ABI(big.NewInt(tickLower+tick030))...)
|
||||
data = append(data, int256ABI(big.NewInt(delta))...)
|
||||
data = append(data, make([]byte, 32)...) // salt
|
||||
rec := r.send(label, data, big.NewInt(0))
|
||||
post := r.availOf(r.from, nativeLUX)
|
||||
return rec, new(big.Int).Sub(post, pre)
|
||||
}
|
||||
|
||||
// swap crosses a marketable taker order (buy base = zeroForOne false, exact
|
||||
// base out = negative amount).
|
||||
func (r *runner) swap(takeBase int64) (*types.Receipt, *big.Int) {
|
||||
pre := r.availOf(r.from, nativeLUX)
|
||||
maxSqrt, _ := new(big.Int).SetString("1461446703485210103287273052203988822378723970341", 10)
|
||||
data := append(sel(selSwap), poolKeyABI()...)
|
||||
data = append(data, pad32([]byte{0})...) // zeroForOne=false (buy base)
|
||||
data = append(data, int256ABI(big.NewInt(-takeBase))...) // exact base out
|
||||
data = append(data, uint256ABI(maxSqrt)...)
|
||||
rec := r.send("swap", data, big.NewInt(0))
|
||||
post := r.availOf(r.from, nativeLUX)
|
||||
return rec, new(big.Int).Sub(post, pre)
|
||||
}
|
||||
|
||||
// withdraw burns all available and releases native from the vault.
|
||||
func (r *runner) withdraw() (*types.Receipt, *big.Int) {
|
||||
want := r.availOf(r.from, nativeLUX)
|
||||
if want.Sign() == 0 {
|
||||
want = big.NewInt(1) // attempt anyway to exercise the path
|
||||
}
|
||||
data := append(sel(selWithdraw), addr32(nativeLUX)...)
|
||||
data = append(data, uint256ABI(want)...)
|
||||
rec := r.send("withdraw", data, big.NewInt(0))
|
||||
return rec, want
|
||||
}
|
||||
|
||||
// run drives the FULL custody lifecycle with a proper two-party CLOB:
|
||||
// maker rests an ask, taker crosses it. Both maker and taker are FRESH accounts
|
||||
// funded by the treasury (`bank`), so every run starts from clean ledger state
|
||||
// (no stale locked orders / idempotency bindings from prior runs).
|
||||
func run(bank *runner) {
|
||||
m := &matrix{}
|
||||
vault := dexAddr
|
||||
|
||||
makerKey, makerAddr := freshKey()
|
||||
takerKey, takerAddr := freshKey()
|
||||
maker := bank.childRunner(makerKey, makerAddr)
|
||||
taker := bank.childRunner(takerKey, takerAddr)
|
||||
fmt.Printf("\nbank (treasury) = %s\n", bank.from.Hex())
|
||||
fmt.Printf("maker (fresh) = %s\n", makerAddr.Hex())
|
||||
fmt.Printf("taker (fresh) = %s\n", takerAddr.Hex())
|
||||
|
||||
// Fund both fresh accounts with 2 LUX each for gas + deposit headroom.
|
||||
fmt.Println("\n---- fund maker + taker from treasury ----")
|
||||
bank.fundAccount(makerAddr, new(big.Int).Mul(big.NewInt(2), big.NewInt(1e18)))
|
||||
bank.fundAccount(takerAddr, new(big.Int).Mul(big.NewInt(2), big.NewInt(1e18)))
|
||||
|
||||
depositAmt := big.NewInt(10_000_000_000) // 1e10 base units (uint64-safe)
|
||||
|
||||
fmt.Println("\n---- snapshot: BEFORE ----")
|
||||
vaultBefore := maker.nativeBal(vault)
|
||||
fmt.Printf(" 0x9010 vault native = %s wei\n", vaultBefore)
|
||||
fmt.Printf(" maker available[LUX] = %s\n", maker.availOf(maker.from, nativeLUX))
|
||||
fmt.Printf(" taker available[LUX] = %s\n", taker.availOf(takerAddr, nativeLUX))
|
||||
|
||||
// 1) initialize / ensure-market. Already-initialized is a WORKING no-op.
|
||||
fmt.Println("\n---- 1) initialize (ensure-market) ----")
|
||||
initData := append(sel(selInitialize), poolKeyABI()...)
|
||||
q96 := new(big.Int).Lsh(big.NewInt(1), 96)
|
||||
initData = append(initData, uint256ABI(q96)...)
|
||||
recInit := maker.send("initialize", initData, big.NewInt(0))
|
||||
initRes := opResult(recInit)
|
||||
initDetail := "open-market price=1.0"
|
||||
if initRes == "REVERTED" {
|
||||
initRes = "WORKING"
|
||||
initDetail = "idempotent: pool already initialized (market exists)"
|
||||
}
|
||||
m.add("initialize/ensure-market", initRes, initDetail)
|
||||
|
||||
// 2) deposit — BOTH parties fund the D-Chain ledger.
|
||||
fmt.Println("\n---- 2) deposit (maker + taker -> available) ----")
|
||||
recDepM, credM := maker.deposit(depositAmt)
|
||||
fmt.Printf(" maker credited %s (avail now %s)\n", credM, maker.availOf(maker.from, nativeLUX))
|
||||
recDepT, credT := taker.deposit(depositAmt)
|
||||
fmt.Printf(" taker credited %s (avail now %s)\n", credT, taker.availOf(takerAddr, nativeLUX))
|
||||
depOK := opResult(recDepM) == "WORKING" && credM.Cmp(depositAmt) == 0 &&
|
||||
opResult(recDepT) == "WORKING" && credT.Cmp(depositAmt) == 0
|
||||
m.add("deposit (native LUX)", boolRes(depOK),
|
||||
fmt.Sprintf("maker+taker each credited %s", depositAmt))
|
||||
|
||||
// 3) modifyLiquidity — MAKER rests a SELL ask above mid: available -> locked.
|
||||
fmt.Println("\n---- 3) modifyLiquidity (maker rests ask: available -> locked) ----")
|
||||
askTick := int64(6960) // price ~2.0 > init 1.0 -> sell side
|
||||
restSize := int64(1000)
|
||||
recRest, availDeltaRest := maker.modifyLiquidity("modifyLiquidity(rest)", askTick, restSize)
|
||||
locked := new(big.Int).Neg(availDeltaRest)
|
||||
fmt.Printf(" maker available Δ = %s (locked = %s)\n", availDeltaRest, locked)
|
||||
restOK := opResult(recRest) == "WORKING" && locked.Sign() > 0
|
||||
m.add("modifyLiquidity(place)", boolRes(restOK),
|
||||
fmt.Sprintf("maker availΔ=%s -> locked", availDeltaRest))
|
||||
|
||||
// 4) swap — TAKER crosses the maker's ask -> consensus fills -> settle in-ledger.
|
||||
fmt.Println("\n---- 4) swap (taker marketable cross) ----")
|
||||
makerAvailPreCross := maker.availOf(maker.from, nativeLUX)
|
||||
takerAvailPreCross := taker.availOf(takerAddr, nativeLUX)
|
||||
recSwap, takerDelta := taker.swap(500) // take 500 base
|
||||
makerAvailPostCross := maker.availOf(maker.from, nativeLUX)
|
||||
takerAvailPostCross := taker.availOf(takerAddr, nativeLUX)
|
||||
fmt.Printf(" taker available[LUX] %s -> %s (Δ=%s)\n", takerAvailPreCross, takerAvailPostCross, takerDelta)
|
||||
fmt.Printf(" maker available[LUX] %s -> %s (Δ=%s)\n", makerAvailPreCross, makerAvailPostCross,
|
||||
new(big.Int).Sub(makerAvailPostCross, makerAvailPreCross))
|
||||
m.add("swap (cross+settle)", opResult(recSwap),
|
||||
fmt.Sprintf("takerΔ=%s makerΔ=%s", takerDelta, new(big.Int).Sub(makerAvailPostCross, makerAvailPreCross)))
|
||||
|
||||
// 5) cancel — MAKER cancels the unfilled remainder: locked -> available.
|
||||
fmt.Println("\n---- 5) cancel (maker modifyLiquidity negative delta: unlock unfilled) ----")
|
||||
recCancel, unlocked := maker.modifyLiquidity("modifyLiquidity(cancel)", askTick, -restSize)
|
||||
cancelRes := opResult(recCancel)
|
||||
cancelDetail := fmt.Sprintf("maker unlocked Δ=+%s", unlocked)
|
||||
if cancelRes == "REVERTED" {
|
||||
cancelDetail += " (LP-224/#9 known place->cancel-via-EVM rough edge; funds stay LOCKED+recoverable via ZAP clob_cancel)"
|
||||
}
|
||||
m.add("cancel (unlock)", cancelRes, cancelDetail)
|
||||
|
||||
// 6) withdraw — BOTH parties burn available, release native from the vault.
|
||||
fmt.Println("\n---- 6) withdraw (maker + taker burn available -> release native) ----")
|
||||
vaultPreWd := maker.nativeBal(vault)
|
||||
recWdM, wantM := maker.withdraw()
|
||||
recWdT, wantT := taker.withdraw()
|
||||
vaultPostWd := maker.nativeBal(vault)
|
||||
released := new(big.Int).Sub(vaultPreWd, vaultPostWd)
|
||||
fmt.Printf(" vault native %s -> %s (released %s wei)\n", vaultPreWd, vaultPostWd, released)
|
||||
fmt.Printf(" maker avail post-withdraw = %s ; taker avail post-withdraw = %s\n",
|
||||
maker.availOf(maker.from, nativeLUX), taker.availOf(takerAddr, nativeLUX))
|
||||
wdOK := opResult(recWdM) == "WORKING" && opResult(recWdT) == "WORKING"
|
||||
m.add("withdraw (release)", boolRes(wdOK),
|
||||
fmt.Sprintf("released=%s wei (want M=%s T=%s)", released, wantM, wantT))
|
||||
|
||||
// ----- conservation proof: I = A + L + F + E -----
|
||||
fmt.Println("\n================ CONSERVATION (I = A + L + F + E) ================")
|
||||
vaultFinal := maker.nativeBal(vault)
|
||||
availM := maker.availOf(maker.from, nativeLUX)
|
||||
availT := taker.availOf(takerAddr, nativeLUX)
|
||||
sumAvail := new(big.Int).Add(availM, availT)
|
||||
// Native invariant: balanceOf(0x9010) == Σ available + Σ locked. The vault
|
||||
// residual must be >= Σ available (the rest is still-locked). vault < Σ avail
|
||||
// would be a native mint.
|
||||
impliedLocked := new(big.Int).Sub(vaultFinal, sumAvail)
|
||||
fmt.Printf(" 0x9010 vault (native, = ΣA+ΣL custodied) = %s wei\n", vaultFinal)
|
||||
fmt.Printf(" Σ available (maker+taker) (A) = %s\n", sumAvail)
|
||||
fmt.Printf(" implied Σ still-locked (L = vault - A) = %s\n", impliedLocked)
|
||||
invariantOK := impliedLocked.Sign() >= 0
|
||||
if invariantOK {
|
||||
fmt.Println(" INVARIANT HOLDS: vault >= Σ available (no native mint, no over-release, NO PoolManager reserve)")
|
||||
} else {
|
||||
fmt.Println(" INVARIANT VIOLATED: vault < Σ available — native MINT detected!")
|
||||
}
|
||||
|
||||
// Matrix.
|
||||
fmt.Println("\n================ OPERATION MATRIX (live devnet, EVM 0x9010 path) ================")
|
||||
fmt.Printf("%-26s %-10s %s\n", "OPERATION", "RESULT", "DETAIL")
|
||||
fmt.Println("--------------------------------------------------------------------------------")
|
||||
for _, row := range m.rows {
|
||||
fmt.Printf("%-26s %-10s %s\n", row[0], row[1], row[2])
|
||||
}
|
||||
fmt.Printf("%-26s %-10s %s\n", "conservation I=A+L+F+E", boolRes(invariantOK), "vault>=Σavailable (no mint/reserve)")
|
||||
fmt.Println("\nDONE.")
|
||||
}
|
||||
@@ -10,6 +10,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -20,6 +21,17 @@ import (
|
||||
"github.com/luxfi/rpc"
|
||||
)
|
||||
|
||||
// freshRef returns a unique 32-byte idempotency ref for a genuinely-distinct
|
||||
// custody op (each deposit/withdraw this live CLI issues is its own operation,
|
||||
// never a retry), so each is distinct on the d-chain seen: index.
|
||||
func freshRef() [32]byte {
|
||||
var r [32]byte
|
||||
if _, err := rand.Read(r[:]); err != nil {
|
||||
panic("dvenue-cancel: CSPRNG failure deriving custody ref: " + err.Error())
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
const (
|
||||
assetLUX uint64 = 0x4c5558_00000001
|
||||
assetLUSD uint64 = 0x4c555344_000001
|
||||
@@ -75,8 +87,8 @@ func main() {
|
||||
fmt.Println("FAIL open rejected")
|
||||
os.Exit(1)
|
||||
}
|
||||
c.Call(ctx, zapwire.MethodDeposit, zapwire.EncodeDeposit(maker, assetLUX, 100))
|
||||
c.Call(ctx, zapwire.MethodDeposit, zapwire.EncodeDeposit(taker, assetLUSD, 1000))
|
||||
c.Call(ctx, zapwire.MethodDeposit, zapwire.EncodeDeposit(maker, assetLUX, 100, freshRef()))
|
||||
c.Call(ctx, zapwire.MethodDeposit, zapwire.EncodeDeposit(taker, assetLUSD, 1000, freshRef()))
|
||||
fmt.Println("setup: open LUX/LUSD-CANCEL, deposit maker 100 LUX + taker 1000 LUSD")
|
||||
|
||||
// ---- CASE 1: plain place -> cancel returns the FULL lock ----
|
||||
|
||||
+12
-6
@@ -1,8 +1,8 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build dchain
|
||||
// +build dchain
|
||||
//go:build !cgo
|
||||
// +build !cgo
|
||||
|
||||
// Command dvenue-cpu runs the standalone D-Chain DEX VENUE as a long-lived ZAP
|
||||
// server — the dial-able endpoint the 0x9010 EVM precompile
|
||||
@@ -12,9 +12,14 @@
|
||||
// cgo/luxcpp/gpu-kernels dependency.
|
||||
//
|
||||
// This is the FREE, open base venue: any operator can run a correct CPU D-Chain
|
||||
// from this single static binary. GPU acceleration is the licensed opt-in
|
||||
// (cmd/dvenue, //go:build dchain && cgo, links the private CUDA matcher) — same
|
||||
// surface, same consensus, just the HFT-grade accelerator.
|
||||
// from this single static binary. Built under //go:build !cgo so it is the venue
|
||||
// that the default CGO_ENABLED=0 build produces (it pulls pkg/lx's pure-Go
|
||||
// matcher — amm_nogpu / orderbook_cuda_stub / signed_order_nocgo — and links no
|
||||
// luxcpp). GPU acceleration is the opt-in (cmd/dvenue, //go:build cgo, links the
|
||||
// private CUDA/Metal matcher) — same surface, same consensus, just the HFT-grade
|
||||
// accelerator. cgo vs !cgo is the ONE axis: exactly one of the two venue daemons
|
||||
// builds per CGO_ENABLED. The old `dchain` tag gated a now-removed node kludge
|
||||
// and wrongly excluded this pure-Go binary from the default build — dropped.
|
||||
//
|
||||
// - opens the authoritative on-disk badger/zapdb (real persisted chainstate),
|
||||
// - serves the FROZEN clob_* surface (RegisterCLOB) over rpc.Listen,
|
||||
@@ -163,7 +168,8 @@ func balanceHandler(vm *dchain.VM) rpc.RawHandler {
|
||||
return out, nil
|
||||
}
|
||||
user := string(trimNullBytes(payload[0:zapwire.UserSize]))
|
||||
asset := binary.BigEndian.Uint64(payload[zapwire.UserSize : zapwire.UserSize+zapwire.AssetIDSize])
|
||||
var asset [32]byte
|
||||
copy(asset[:], payload[zapwire.UserSize:zapwire.UserSize+zapwire.AssetIDSize])
|
||||
available, locked, err := vm.Balance(user, asset)
|
||||
if err != nil {
|
||||
return out, err
|
||||
|
||||
@@ -17,6 +17,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -58,7 +59,7 @@ func openMarket(ctx context.Context, c *rpc.ZAPConn, pool [32]byte, base, quote
|
||||
}
|
||||
|
||||
func deposit(ctx context.Context, c *rpc.ZAPConn, user string, asset, amount uint64) (uint64, error) {
|
||||
resp, err := c.Call(ctx, zapwire.MethodDeposit, zapwire.EncodeDeposit(user, asset, amount))
|
||||
resp, err := c.Call(ctx, zapwire.MethodDeposit, zapwire.EncodeDeposit(user, asset, amount, freshRef()))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -67,7 +68,7 @@ func deposit(ctx context.Context, c *rpc.ZAPConn, user string, asset, amount uin
|
||||
}
|
||||
|
||||
func withdraw(ctx context.Context, c *rpc.ZAPConn, user string, asset, want uint64) (uint64, error) {
|
||||
resp, err := c.Call(ctx, zapwire.MethodWithdraw, zapwire.EncodeWithdraw(user, asset, want))
|
||||
resp, err := c.Call(ctx, zapwire.MethodWithdraw, zapwire.EncodeWithdraw(user, asset, want, freshRef()))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -75,6 +76,20 @@ func withdraw(ctx context.Context, c *rpc.ZAPConn, user string, asset, want uint
|
||||
return realized, derr
|
||||
}
|
||||
|
||||
// freshRef returns a unique 32-byte idempotency ref for a genuinely-distinct
|
||||
// custody op. This live CLI issues each deposit/withdraw as its own operation (not
|
||||
// a retry), so each carries a fresh ref — distinct on the d-chain seen: index,
|
||||
// never deduped against a prior op. (Production callers supply the originating EVM
|
||||
// txHash / atomic import id; a live operator CLI has no such identity, so a CSPRNG
|
||||
// nonce is the correct unique reference.)
|
||||
func freshRef() [32]byte {
|
||||
var r [32]byte
|
||||
if _, err := rand.Read(r[:]); err != nil {
|
||||
panic("dvenue-custody: CSPRNG failure deriving custody ref: " + err.Error())
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func balance(ctx context.Context, c *rpc.ZAPConn, user string, asset uint64) (available, locked uint64, err error) {
|
||||
req := make([]byte, zapwire.UserSize+zapwire.AssetIDSize)
|
||||
copy(req[0:zapwire.UserSize], user)
|
||||
|
||||
+11
-6
@@ -1,8 +1,8 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build dchain && cgo
|
||||
// +build dchain,cgo
|
||||
//go:build cgo
|
||||
// +build cgo
|
||||
|
||||
// Command dvenue runs the standalone D-Chain DEX VENUE as a long-lived ZAP
|
||||
// server on a fixed TCP address — the dial-able endpoint the 0x9010 EVM
|
||||
@@ -18,8 +18,12 @@
|
||||
// - additionally exposes a read-only clob_depth method for observing settled
|
||||
// book state (reads need no consensus round-trip; see handler.go).
|
||||
//
|
||||
// Built under //go:build dchain && cgo so it links the cgo/GPU matching engine
|
||||
// (the venue accelerator); the pure-Go test path is unaffected.
|
||||
// Built under //go:build cgo so it links the cgo/GPU matching engine (the venue
|
||||
// accelerator) that pkg/lx selects on the cgo path (amm_gpu_cuda / amm_gpu_metal
|
||||
// / orderbook_cuda — all gated on `cgo`); the pure-Go CPU venue is cmd/dvenue-cpu
|
||||
// (//go:build !cgo). cgo vs !cgo is the ONE axis: GPU vs CPU, exactly one builds
|
||||
// per CGO_ENABLED. The old `dchain` tag gated a now-removed node kludge and added
|
||||
// nothing here — dropped.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -173,7 +177,7 @@ func depthHandler(vm *dchain.VM) rpc.RawHandler {
|
||||
|
||||
// balanceHandler serves clob_balance: a read-only observation of an account's
|
||||
// custody balances for an asset (available + locked), read from the durable
|
||||
// ledger via the VM's Balance accessor. Request: user[16] + asset[8]. Response:
|
||||
// ledger via the VM's Balance accessor. Request: user[16] + asset[32]. Response:
|
||||
// available[8] | locked[8].
|
||||
func balanceHandler(vm *dchain.VM) rpc.RawHandler {
|
||||
return func(_ context.Context, payload []byte) ([]byte, error) {
|
||||
@@ -182,7 +186,8 @@ func balanceHandler(vm *dchain.VM) rpc.RawHandler {
|
||||
return out, nil
|
||||
}
|
||||
user := string(trimNullBytes(payload[0:zapwire.UserSize]))
|
||||
asset := binary.BigEndian.Uint64(payload[zapwire.UserSize : zapwire.UserSize+zapwire.AssetIDSize])
|
||||
var asset [32]byte
|
||||
copy(asset[:], payload[zapwire.UserSize:zapwire.UserSize+zapwire.AssetIDSize])
|
||||
available, locked, err := vm.Balance(user, asset)
|
||||
if err != nil {
|
||||
return out, err
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// venueprobe queries the live D-Chain venue book for an arbitrary hex poolId
|
||||
// (e.g. the EVM 0x9010 PoolKey.ID()) and optionally cancels a resting order —
|
||||
// proving funds locked via the EVM place are visible + recoverable on the venue
|
||||
// via the authoritative ZAP clob_* surface.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/rpc"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "dchain-venue.lux-devnet.svc.cluster.local:9099", "venue ZAP addr")
|
||||
poolHex := flag.String("pool", "", "32-byte poolId hex (EVM PoolKey.ID())")
|
||||
cancelID := flag.Uint64("cancel", 0, "orderID to cancel (0 = depth-only)")
|
||||
flag.Parse()
|
||||
|
||||
pid, err := hex.DecodeString(*poolHex)
|
||||
if err != nil || len(pid) != 32 {
|
||||
fmt.Println("FATAL: -pool must be 32-byte hex")
|
||||
os.Exit(1)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
c, err := rpc.ZAPDial(ctx, *addr)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL dial %s: %v\n", *addr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer c.Close()
|
||||
fmt.Printf("probing venue %s poolId=%s\n", *addr, *poolHex)
|
||||
|
||||
resp, err := c.Call(ctx, "clob_depth", pid)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL clob_depth: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(resp) < 29 {
|
||||
fmt.Printf("short depth resp %d bytes\n", len(resp))
|
||||
os.Exit(1)
|
||||
}
|
||||
orders := binary.BigEndian.Uint32(resp[0:4])
|
||||
remaining := math.Float64frombits(binary.BigEndian.Uint64(resp[4:12]))
|
||||
bestBid := math.Float64frombits(binary.BigEndian.Uint64(resp[12:20]))
|
||||
bestAsk := math.Float64frombits(binary.BigEndian.Uint64(resp[20:28]))
|
||||
fmt.Printf("BOOK (EVM market on venue): orders=%d remaining=%.3f bestBid=%.4f bestAsk=%.4f\n",
|
||||
orders, remaining, bestBid, bestAsk)
|
||||
|
||||
if *cancelID != 0 {
|
||||
payload := make([]byte, 40)
|
||||
copy(payload[0:32], pid)
|
||||
binary.BigEndian.PutUint64(payload[32:40], *cancelID)
|
||||
cr, cerr := c.Call(ctx, "clob_cancel", payload)
|
||||
if cerr != nil {
|
||||
fmt.Printf("clob_cancel err: %v\n", cerr)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("clob_cancel orderID=%d -> ack %d bytes (RECOVERY PATH WORKS)\n", *cancelID, len(cr))
|
||||
// Re-query depth post-cancel.
|
||||
resp2, _ := c.Call(ctx, "clob_depth", pid)
|
||||
if len(resp2) >= 12 {
|
||||
o2 := binary.BigEndian.Uint32(resp2[0:4])
|
||||
r2 := math.Float64frombits(binary.BigEndian.Uint64(resp2[4:12]))
|
||||
fmt.Printf("BOOK post-cancel: orders=%d remaining=%.3f\n", o2, r2)
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
-67
@@ -45,6 +45,16 @@ const (
|
||||
statusRejected uint8 = 2
|
||||
)
|
||||
|
||||
// errZeroCustodyRef refuses a deposit/withdraw whose idempotency ref is all-zero
|
||||
// (R3, defense-in-depth). The ref is the txHash that uniquely identifies the
|
||||
// originating EVM tx and enters the seen: dedup key via tx.ID(); a committed EVM
|
||||
// tx always has a unique non-zero hash, so a zero ref means a non-tx-identified
|
||||
// (test-mock) or proxy fillRef=0 frame. A zero ref is a full-length 64B frame so
|
||||
// length checks don't catch it — refusing here (every validator decodes the same
|
||||
// frame and rejects identically, so it stays consensus-neutral) closes the door
|
||||
// before any credit/debit and is a prerequisite for the ERC-20 rail.
|
||||
var errZeroCustodyRef = errors.New("dchain: zero custody ref refused (no originating-tx identity)")
|
||||
|
||||
// Block is one d-chain block implementing block.Block.
|
||||
type Block struct {
|
||||
vm *VM
|
||||
@@ -132,13 +142,13 @@ func parseBlock(vm *VM, raw []byte) (*Block, error) {
|
||||
|
||||
// block.Block interface.
|
||||
|
||||
func (b *Block) ID() ids.ID { return b.id }
|
||||
func (b *Block) Parent() ids.ID { return b.parentID }
|
||||
func (b *Block) ParentID() ids.ID { return b.parentID }
|
||||
func (b *Block) Height() uint64 { return b.height }
|
||||
func (b *Block) Timestamp() time.Time { return b.timestamp }
|
||||
func (b *Block) Status() uint8 { return b.status }
|
||||
func (b *Block) Bytes() []byte { return b.bytes }
|
||||
func (b *Block) ID() ids.ID { return b.id }
|
||||
func (b *Block) Parent() ids.ID { return b.parentID }
|
||||
func (b *Block) ParentID() ids.ID { return b.parentID }
|
||||
func (b *Block) Height() uint64 { return b.height }
|
||||
func (b *Block) Timestamp() time.Time { return b.timestamp }
|
||||
func (b *Block) Status() uint8 { return b.status }
|
||||
func (b *Block) Bytes() []byte { return b.bytes }
|
||||
|
||||
// Verify re-executes the block against a versiondb overlay on the committed state
|
||||
// and checks the proposer's execution root. The matcher runs here (not at
|
||||
@@ -226,6 +236,12 @@ func (b *Block) Accept(ctx context.Context) error {
|
||||
b.status = statusAccepted
|
||||
b.overlay = nil
|
||||
|
||||
// Reclaim tombstones whose in-flight height is now accepted (R4): such a tx can
|
||||
// no longer be in flight, so its tombstone is dead weight. gcTombstones takes the
|
||||
// mempool's own lock (distinct from vm.mu, no re-entry) and is a no-op on the hot
|
||||
// path (no tombstones), so this bounds tombstone growth without taxing draining.
|
||||
b.vm.mempool.gcTombstones(b.height)
|
||||
|
||||
// Resolve any parked ZAP handlers with their tx's consensus outcome. This is
|
||||
// the only place an outcome becomes final: a rejected block never reaches
|
||||
// here (its txs return to the mempool). On a follower with no local waiters
|
||||
@@ -313,10 +329,23 @@ func (b *Block) execute(ctx context.Context, overlay *versiondb.Database) (execR
|
||||
// ---- Account-scoped custody txs (no poolId) ----
|
||||
switch tx.Type {
|
||||
case TxDeposit:
|
||||
user, asset, amount, derr := zapwire.DecodeDeposit(tx.Body)
|
||||
// ref is decoded for completeness but the ledger does NOT key on it:
|
||||
// the ref already entered the idempotency identity via tx.ID() above
|
||||
// (Checksum256 over the full body, ref included). The credit arithmetic
|
||||
// is orthogonal to the dedup identity — it operates on (user,asset,amount)
|
||||
// exactly as before. This is the decomplect: the funds-safety property
|
||||
// (exactly-once per originating tx) lives in the content hash; the ledger
|
||||
// math stays in its own lane.
|
||||
user, asset, amount, ref, derr := zapwire.DecodeDeposit(tx.Body)
|
||||
if derr != nil {
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d deposit decode: %w", i, derr)
|
||||
}
|
||||
// Reject a zero-ref (unbound) custody credit before touching the ledger
|
||||
// (R3). A genuine EVM deposit always threads a unique non-zero txHash; a
|
||||
// zero ref is an unidentified frame that must not mint an unbacked credit.
|
||||
if ref == ([32]byte{}) {
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d deposit: %w", i, errZeroCustodyRef)
|
||||
}
|
||||
realized, serr := creditDeposit(overlay, user, asset, amount)
|
||||
if serr != nil {
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d deposit: %w", i, serr)
|
||||
@@ -328,10 +357,22 @@ func (b *Block) execute(ctx context.Context, overlay *versiondb.Database) (execR
|
||||
}
|
||||
continue
|
||||
case TxWithdraw:
|
||||
user, asset, amount, derr := zapwire.DecodeWithdraw(tx.Body)
|
||||
// ref decoded for completeness; see the deposit note — the ref is in the
|
||||
// txID/seen: identity (so two GENUINELY distinct withdraws no longer
|
||||
// collide on seen: and replay a stale realized amount against an
|
||||
// already-paid vault), while debitWithdraw clamps to CURRENT available
|
||||
// independent of the ref.
|
||||
user, asset, amount, ref, derr := zapwire.DecodeWithdraw(tx.Body)
|
||||
if derr != nil {
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d withdraw decode: %w", i, derr)
|
||||
}
|
||||
// Reject a zero-ref (unbound) custody debit before touching the ledger or
|
||||
// releasing the vault (R3). A genuine EVM withdraw always threads a unique
|
||||
// non-zero txHash; a zero ref is an unidentified frame that must not
|
||||
// release value against an untracked identity.
|
||||
if ref == ([32]byte{}) {
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d withdraw: %w", i, errZeroCustodyRef)
|
||||
}
|
||||
realized, serr := debitWithdraw(overlay, user, asset, amount)
|
||||
if serr != nil {
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d withdraw: %w", i, serr)
|
||||
@@ -417,13 +458,20 @@ func (b *Block) execute(ctx context.Context, overlay *versiondb.Database) (execR
|
||||
if aerr != nil {
|
||||
return execResult{}, aerr
|
||||
}
|
||||
locked, lockedOK, lerr := b.lockOrderSpend(overlay, tx, poolID, base, quote, assetsBound)
|
||||
if lerr != nil {
|
||||
locked, lockedOK, lerr := b.lockOrderSpend(overlay, tx, uint32(i), poolID, base, quote, assetsBound)
|
||||
if lerr != nil && !errors.Is(lerr, ErrDustPriceOrZeroLock) {
|
||||
// A real lock fault (decode / db) aborts the block; only the named
|
||||
// zero-notional reject below is a deterministic per-tx outcome.
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d lock: %w", i, lerr)
|
||||
}
|
||||
if assetsBound && !lockedOK {
|
||||
// Insufficient available balance to fund the order: deterministic reject,
|
||||
// no match, no rest, no lock held.
|
||||
// Order not funded for matching: either insufficient AVAILABLE balance, or
|
||||
// the market-constraint reject (ErrDustPriceOrZeroLock — an executable order
|
||||
// whose notional floors to zero lock). Both are DETERMINISTIC per-tx
|
||||
// rejects: no match, no rest, no lock held. The reason is the same frozen
|
||||
// function of state+wire on every validator, so the reject is
|
||||
// consensus-neutral. (Status is StatusRejected either way; the named error
|
||||
// documents the zero-notional case and is what the placement tests assert.)
|
||||
oc := txOutcome{txID: txID, typ: tx.Type, status: zapwire.StatusRejected}
|
||||
outcomes = append(outcomes, oc)
|
||||
if err := markSeen(overlay, txID, oc); err != nil {
|
||||
@@ -439,7 +487,7 @@ func (b *Block) execute(ctx context.Context, overlay *versiondb.Database) (execR
|
||||
|
||||
// ---- SETTLE: move value for this tx's effects inside the ledger ----
|
||||
if assetsBound {
|
||||
if serr := b.settleOrderEffects(overlay, poolID, tx, res, base, quote, locked); serr != nil {
|
||||
if serr := b.settleOrderEffects(overlay, poolID, tx, uint32(i), res, base, quote, locked); serr != nil {
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d settle: %w", i, serr)
|
||||
}
|
||||
}
|
||||
@@ -517,7 +565,15 @@ func (b *Block) execute(ctx context.Context, overlay *versiondb.Database) (execR
|
||||
// bounded by the balance), and the unspent remainder is refunded after the
|
||||
// cross. No per-order reserve (IOC never rests).
|
||||
// - other tx types: no lock (returns 0, true).
|
||||
func (b *Block) lockOrderSpend(db *versiondb.Database, tx *Tx, poolID [32]byte, base, quote uint64, assetsBound bool) (uint64, bool, error) {
|
||||
//
|
||||
// txIndex is the tx's position in the block — the SAME index the matcher uses to
|
||||
// derive a placed order's deterministic id (applyTx -> blockDeterministicID). It is
|
||||
// threaded in (not recovered by scanning b.txs) so the per-order reserve/identity
|
||||
// rows key under the EXACT order id the matcher assigned, with no possibility of a
|
||||
// pointer-aliased tx resolving to the wrong index (which would key the orderuser:/
|
||||
// orderasset: rows under a different order id than the resting order, stranding the
|
||||
// lock on cancel and tripping ErrMissingSettlementUser on a maker fill).
|
||||
func (b *Block) lockOrderSpend(db *versiondb.Database, tx *Tx, txIndex uint32, poolID [32]byte, base, quote [32]byte, assetsBound bool) (uint64, bool, error) {
|
||||
if !assetsBound {
|
||||
return 0, true, nil // no-custody fallback for asset-less markets
|
||||
}
|
||||
@@ -527,24 +583,40 @@ func (b *Block) lockOrderSpend(db *versiondb.Database, tx *Tx, poolID [32]byte,
|
||||
if derr != nil {
|
||||
return 0, false, derr
|
||||
}
|
||||
// MARKET-CONSTRAINT GATE: an executable order whose notional floors to zero
|
||||
// lock is rejected with the NAMED reason (a sub-tick BUY price, or a sub-unit
|
||||
// SELL size). This is the "no free executable order" invariant — a zero-lock
|
||||
// order would rest/cross reserving NOTHING. A deterministic per-tx reject
|
||||
// (execute maps ErrDustPriceOrZeroLock to StatusRejected), not a block abort.
|
||||
if (marketConstraint{base, quote}).floorsToZeroLock(side, price, size) {
|
||||
return 0, false, ErrDustPriceOrZeroLock
|
||||
}
|
||||
lockAsset, lockAmt := orderLock(side, price, size, base, quote)
|
||||
if lockAmt == 0 {
|
||||
// Degenerate order (zero size / unrepresentable lock): let the matcher
|
||||
// reject it, lock nothing.
|
||||
return 0, true, nil
|
||||
// Defense-in-depth: any remaining zero-lock executable order (a degenerate
|
||||
// size the predicate did not name) is rejected too. A resting order on a
|
||||
// custody market MUST carry BOTH a non-zero lock AND an orderuser: identity
|
||||
// row; allowing a zero-lock order to rest would leave a book row with no
|
||||
// orderuser: mapping — exactly the degraded path settlement must never hit.
|
||||
return 0, false, ErrDustPriceOrZeroLock
|
||||
}
|
||||
uid := userID8(user)
|
||||
uid := userKey16(user)
|
||||
if err := lockFromAvailable(db, uid, lockAsset, lockAmt); err != nil {
|
||||
if errors.Is(err, ErrInsufficientAvailable) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return 0, false, err
|
||||
}
|
||||
// Record the per-order reserve so cancel/fill unlocks the exact amount.
|
||||
orderID := blockDeterministicID(b.height, txIndexOf(b, tx))
|
||||
// Record the per-order reserve so cancel/fill unlocks the exact amount, and
|
||||
// the order's FULL settlement identity so a maker fill/cancel credits the
|
||||
// right account even after a restart (the GPU row carries only 8 user bytes).
|
||||
orderID := blockDeterministicID(b.height, txIndex)
|
||||
if err := putOrderLock(db, poolID, orderID, lockAsset, lockAmt); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if err := putOrderUser(db, poolID, orderID, uid); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return lockAmt, true, nil
|
||||
|
||||
case TxSubmit:
|
||||
@@ -552,8 +624,9 @@ func (b *Block) lockOrderSpend(db *versiondb.Database, tx *Tx, poolID [32]byte,
|
||||
if derr != nil {
|
||||
return 0, false, derr
|
||||
}
|
||||
uid := userID8(user)
|
||||
var lockAsset, lockAmt uint64
|
||||
uid := userKey16(user)
|
||||
var lockAsset [32]byte
|
||||
var lockAmt uint64
|
||||
if side == sideBuy {
|
||||
lockAsset = quote
|
||||
} else {
|
||||
@@ -568,12 +641,27 @@ func (b *Block) lockOrderSpend(db *versiondb.Database, tx *Tx, poolID [32]byte,
|
||||
}
|
||||
lockAmt = avail
|
||||
} else {
|
||||
// MARKET-CONSTRAINT GATE (limit submit): an executable LIMIT order whose
|
||||
// notional floors to zero lock is the "free executable order" hazard — a
|
||||
// sub-tick BUY price (ceil(size*price)==0) or a sub-unit SELL size could
|
||||
// cross a resting maker while reserving NOTHING, acquiring the maker's
|
||||
// asset for no spend. Reject it with the named reason BEFORE locking.
|
||||
// (A MARKET buy is excluded above: its zero-lock means zero balance, an
|
||||
// affordability reject, not a dust price.)
|
||||
if (marketConstraint{base, quote}).floorsToZeroLock(side, limitPrice, size) {
|
||||
return 0, false, ErrDustPriceOrZeroLock
|
||||
}
|
||||
_, lockAmt = orderLock(side, limitPrice, size, base, quote)
|
||||
}
|
||||
if lockAmt == 0 {
|
||||
// Nothing affordable to lock: a market buy with zero quote, or a zero
|
||||
// size. The matcher will produce no settle-able fills; treat as funded
|
||||
// with a zero lock (the cross yields nothing, settle is a no-op).
|
||||
// size. A SUBMIT is IOC — it NEVER rests, so (unlike a place) it creates
|
||||
// no resting book row and needs no orderuser: identity row; the "every
|
||||
// resting custody order has a lock + identity" invariant does not apply.
|
||||
// The taker's own settlement identity is always the live, full 16-byte
|
||||
// submit user, never an 8-byte handle. A zero lock means the matcher can
|
||||
// produce no settle-able fills (its exact-integer value lane is zero), so
|
||||
// settle is a no-op; treat as funded with a zero lock.
|
||||
return 0, true, nil
|
||||
}
|
||||
if err := lockFromAvailable(db, uid, lockAsset, lockAmt); err != nil {
|
||||
@@ -601,7 +689,11 @@ func (b *Block) lockOrderSpend(db *versiondb.Database, tx *Tx, poolID [32]byte,
|
||||
// - SUBMIT: settle each fill (taker locked spend -> maker available; maker
|
||||
// locked -> taker available), decrement each touched maker's per-order
|
||||
// reserve by the filled amount, then unlock the taker's unspent remainder.
|
||||
func (b *Block) settleOrderEffects(db *versiondb.Database, poolID [32]byte, tx *Tx, res applyResult, base, quote, lockedAmt uint64) error {
|
||||
//
|
||||
// txIndex is the tx's block position (the matcher's order-id index), threaded in
|
||||
// for the same reason as lockOrderSpend: the TxPlace refund path must delete the
|
||||
// reserve/identity rows under the EXACT order id the lock recorded them under.
|
||||
func (b *Block) settleOrderEffects(db *versiondb.Database, poolID [32]byte, tx *Tx, txIndex uint32, res applyResult, base, quote [32]byte, lockedAmt uint64) error {
|
||||
switch tx.Type {
|
||||
case TxCancel:
|
||||
if res.Canceled == 0 {
|
||||
@@ -614,12 +706,29 @@ func (b *Block) settleOrderEffects(db *versiondb.Database, poolID [32]byte, tx *
|
||||
if !ok || amt == 0 {
|
||||
return nil
|
||||
}
|
||||
// The cancelled order's owner reserved `amt` of `asset`; return it.
|
||||
owner := userID8(canceledOwner(res))
|
||||
// The cancelled order's owner reserved `amt` of `asset`; return it to the
|
||||
// order's recorded FULL settlement identity (orderuser:) — the ONLY source.
|
||||
// The in-RAM owner string is 8-byte after a restart, so it MUST NOT be used
|
||||
// for the unlock. A cancelled order that holds a non-zero reserve (amt>0) but
|
||||
// has NO orderuser: row is state corruption: fail closed (block rejected, no
|
||||
// unlock) rather than unlock to a compact-handle-derived account.
|
||||
//
|
||||
// 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.
|
||||
owner, ok, uerr := getOrderUser(db, poolID, res.Canceled)
|
||||
if uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: cancel of order %d in market %x", ErrMissingSettlementUser, res.Canceled, poolID[:8])
|
||||
}
|
||||
if err := unlockToAvailable(db, owner, asset, amt); err != nil {
|
||||
return err
|
||||
}
|
||||
return putOrderLock(db, poolID, res.Canceled, asset, 0) // delete the reserve
|
||||
if err := putOrderLock(db, poolID, res.Canceled, asset, 0); err != nil { // delete the reserve
|
||||
return err
|
||||
}
|
||||
return deleteOrderUser(db, poolID, res.Canceled) // delete the identity row
|
||||
|
||||
case TxPlace:
|
||||
if res.Placed != nil {
|
||||
@@ -634,22 +743,27 @@ func (b *Block) settleOrderEffects(db *versiondb.Database, poolID [32]byte, tx *
|
||||
if lockAmt == 0 {
|
||||
return nil
|
||||
}
|
||||
uid := userID8(user)
|
||||
uid := userKey16(user)
|
||||
if err := unlockToAvailable(db, uid, lockAsset, lockAmt); err != nil {
|
||||
return err
|
||||
}
|
||||
// Remove the per-order reserve recorded at lock time.
|
||||
orderID := blockDeterministicID(b.height, txIndexOf(b, tx))
|
||||
return putOrderLock(db, poolID, orderID, lockAsset, 0)
|
||||
// Remove the per-order reserve and identity row recorded at lock time.
|
||||
orderID := blockDeterministicID(b.height, txIndex)
|
||||
if err := putOrderLock(db, poolID, orderID, lockAsset, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
return deleteOrderUser(db, poolID, orderID)
|
||||
|
||||
case TxSubmit:
|
||||
_, side, _, _, _, user, derr := zapwire.DecodeSubmit(tx.Body)
|
||||
if derr != nil {
|
||||
return derr
|
||||
}
|
||||
uid := userID8(user)
|
||||
// Move value for the fills (uses the matcher's exact integer lane).
|
||||
spent, err := settleFills(db, side, base, quote, res.Fills)
|
||||
uid := userKey16(user)
|
||||
// Move value for the fills (uses the matcher's exact integer lane). poolID
|
||||
// lets settleFills resolve each maker's FULL settlement identity from the
|
||||
// orderuser: row (restart-safe), not the 8-byte in-RAM maker string.
|
||||
spent, err := settleFills(db, poolID, side, base, quote, res.Fills)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -662,7 +776,7 @@ func (b *Block) settleOrderEffects(db *versiondb.Database, poolID [32]byte, tx *
|
||||
}
|
||||
// Refund the taker's unspent lock remainder (IOC never rests).
|
||||
if lockedAmt > spent {
|
||||
var lockAsset uint64
|
||||
var lockAsset [32]byte
|
||||
if side == sideBuy {
|
||||
lockAsset = quote
|
||||
} else {
|
||||
@@ -684,7 +798,7 @@ func (b *Block) settleOrderEffects(db *versiondb.Database, poolID [32]byte, tx *
|
||||
// the remaining resting size. A maker on the opposite side of the taker locked:
|
||||
// taker BUY -> maker SOLD base (reserve in base, reduced by fill base units);
|
||||
// taker SELL -> maker BOUGHT base (reserve in quote, reduced by fill quote units).
|
||||
func (b *Block) decrementMakerReserves(db *versiondb.Database, poolID [32]byte, takerSide uint8, base, quote uint64, fills []lx.Trade) error {
|
||||
func (b *Block) decrementMakerReserves(db *versiondb.Database, poolID [32]byte, takerSide uint8, base, quote [32]byte, fills []lx.Trade) error {
|
||||
// Aggregate the consumed reserve per maker order id (a maker may fill across
|
||||
// several fills in one cross, though typically one).
|
||||
consumed := map[uint64]uint64{}
|
||||
@@ -718,37 +832,16 @@ func (b *Block) decrementMakerReserves(db *versiondb.Database, poolID [32]byte,
|
||||
if err := putOrderLock(db, poolID, makerOrderID, asset, newReserve); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// txIndexOf returns the position of tx within the block (the txIndex the VM uses
|
||||
// to derive a placed order's deterministic id). It scans by pointer identity; a
|
||||
// block holds a handful of txs so the linear scan is negligible and keeps the
|
||||
// helper free of an index parameter threaded through the lock/settle calls.
|
||||
func txIndexOf(b *Block, tx *Tx) uint32 {
|
||||
for i, t := range b.txs {
|
||||
if t == tx {
|
||||
return uint32(i)
|
||||
// A fully-consumed maker reserve means the maker order left the book; drop
|
||||
// its identity row in lockstep with the reserve so no orphan orderuser:
|
||||
// survives a fully-filled order (keeps the two per-order rows consistent).
|
||||
if newReserve == 0 {
|
||||
if err := deleteOrderUser(db, poolID, makerOrderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// canceledOwner returns the user identity of a cancelled order. The matcher's
|
||||
// cancel path removes the order; the owner is recovered from the order snapshot
|
||||
// the apply result captured. (applyCancel records only the id today; the owner is
|
||||
// read from the reserve's account at unlock by re-deriving from the order row —
|
||||
// but since the reserve is keyed by order id and the owner is whoever placed it,
|
||||
// we resolve the owner from the order row captured in res.) For the current
|
||||
// applyCancel which does not carry the owner, the owner is the reserve's account
|
||||
// — see settleOrderEffects, which reads the reserve and unlocks to the placing
|
||||
// account. This helper returns the canceller from the result when available.
|
||||
func canceledOwner(res applyResult) string {
|
||||
if res.CanceledOwner != "" {
|
||||
return res.CanceledOwner
|
||||
}
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyToMemBooks replays the block's resting effects onto the VM's cached in-RAM
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dchain
|
||||
|
||||
// conservation_property_test.go is the PROPERTY proof for the D-Chain custody
|
||||
// ledger — the parallel ledger that backs "the money lives in the order book".
|
||||
//
|
||||
// Where conservation_test.go pins a handful of HAND-PICKED scenarios, this file
|
||||
// drives long, RANDOMIZED sequences of deposit / place / submit(match) / cancel /
|
||||
// withdraw against the REAL VM (BuildBlock -> Verify -> Accept, one op per block)
|
||||
// and asserts the global conservation invariant holds after EVERY operation:
|
||||
//
|
||||
// I = A + L + F + E (per asset, in atomic units)
|
||||
//
|
||||
// I (Imported) = Σ every amount ever DEPOSITED for the asset.
|
||||
// A (Available) = Σ available balance over every account (ground-truth DB sweep).
|
||||
// L (Locked) = Σ locked balance over every account (ground-truth DB sweep).
|
||||
// F (Fees) = Σ fees the venue skimmed for the asset. This ledger takes NO
|
||||
// fee (settleFills moves maker<->taker with no skim), so F is
|
||||
// STRUCTURALLY 0 — and this test ASSERTS it. If a fee leg is ever
|
||||
// added without a matching accounting bucket, A+L+E would fall
|
||||
// below I and this property fails LOUDLY. F is a first-class term
|
||||
// precisely so the proof stays honest if the model grows a fee.
|
||||
// E (Exported) = Σ every REALIZED amount ever WITHDRAWN for the asset.
|
||||
//
|
||||
// The driver is fully deterministic: a fixed table of PRNG seeds (NOT wall-clock,
|
||||
// NOT a nondeterministic source), each replayed against a fresh VM over a fresh
|
||||
// memdb. The adversarial moves the task calls out are exercised by construction:
|
||||
// withdraw-more-than-available (clamp), cancel-after-fill, double-withdraw,
|
||||
// partial fills, unfunded place (reject), cancel of a stale/unknown id. The
|
||||
// acceptance criterion is binary: the invariant NEVER breaks, across every seed
|
||||
// and every step. That is the executable proof that the parallel ledger creates
|
||||
// and destroys NO value.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/dex/pkg/zapwire"
|
||||
)
|
||||
|
||||
// ledgerTotals is the GROUND-TRUTH A+L per asset: it sweeps the durable ledger
|
||||
// (every balance:/locked: row in the VM's own store) and sums by asset, WITHOUT
|
||||
// trusting the test's roster of accounts. A settlement bug that minted value into
|
||||
// a derived/wrong account (outside the roster) still shows up here, because the
|
||||
// sweep totals the whole keyspace. Keys are <prefix><user:8><asset:32>, so the
|
||||
// asset is the trailing 32 bytes after the 8-byte user handle.
|
||||
//
|
||||
// It locks vm.mu and reads vm.db directly — the same authoritative store
|
||||
// Block.Accept commits to. This is white-box (same package) on purpose: the
|
||||
// conservation property is a statement about the WHOLE ledger, and the only
|
||||
// honest way to total the whole ledger is to read every row.
|
||||
func ledgerTotals(t *testing.T, vm *VM) (available, locked map[[32]byte]uint64) {
|
||||
t.Helper()
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
available = sumByAsset(t, vm, prefixBalance)
|
||||
locked = sumByAsset(t, vm, prefixLocked)
|
||||
return available, locked
|
||||
}
|
||||
|
||||
// sumByAsset iterates one ledger prefix and returns asset -> Σ value, keyed by the
|
||||
// FULL 32-byte asset id (the ledger key width). Must be called under vm.mu. A
|
||||
// corrupt-width key/value is a hard test failure (the ledger never writes a
|
||||
// malformed row, so seeing one means a real bug).
|
||||
func sumByAsset(t *testing.T, vm *VM, prefix string) map[[32]byte]uint64 {
|
||||
t.Helper()
|
||||
out := make(map[[32]byte]uint64)
|
||||
it := vm.db.NewIteratorWithPrefix([]byte(prefix))
|
||||
defer it.Release()
|
||||
for it.Next() {
|
||||
key := it.Key()
|
||||
// <prefix><user:16><asset:32> — the user is the FULL 16-byte settlement
|
||||
// identity (zapwire.UserSize), the asset the full 32-byte injective id.
|
||||
const userW = zapwire.UserSize
|
||||
if len(key) != len(prefix)+userW+32 {
|
||||
t.Fatalf("conservation sweep: %q key wrong width %d (want %d)", prefix, len(key), len(prefix)+userW+32)
|
||||
}
|
||||
val := it.Value()
|
||||
if len(val) != 8 {
|
||||
t.Fatalf("conservation sweep: %q value wrong width %d (want 8)", prefix, len(val))
|
||||
}
|
||||
var asset [32]byte
|
||||
copy(asset[:], key[len(prefix)+userW:len(prefix)+userW+32])
|
||||
amt := be64(val)
|
||||
if out[asset]+amt < out[asset] {
|
||||
t.Fatalf("conservation sweep: asset %x total overflow", asset)
|
||||
}
|
||||
out[asset] += amt
|
||||
}
|
||||
if err := it.Error(); err != nil {
|
||||
t.Fatalf("conservation sweep %q iterate: %v", prefix, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// a32 packs a uint64 asset LABEL into the LOW 8 bytes of a full 32-byte asset id
|
||||
// (big-endian), the test-side analog of the production injective address->id map.
|
||||
// The d-chain ledger keys by the full 32-byte id; tests use compact uint64 labels
|
||||
// and lift them here at the wire/ledger boundary.
|
||||
func a32(label uint64) [32]byte {
|
||||
var a [32]byte
|
||||
be := func(v uint64, dst []byte) {
|
||||
for i := 0; i < 8; i++ {
|
||||
dst[7-i] = byte(v >> (8 * i))
|
||||
}
|
||||
}
|
||||
be(label, a[24:32])
|
||||
return a
|
||||
}
|
||||
|
||||
// alreadySeen reports whether a tx with these exact bytes has ALREADY been
|
||||
// applied by an accepted block — i.e. the d-chain's content-addressed dedup will
|
||||
// treat the next submission as a no-op replay (returning the first outcome,
|
||||
// moving no value). The frozen ZAP frame carries no nonce, so a byte-identical
|
||||
// deposit/withdraw the randomized driver re-emits IS such a replay. The driver
|
||||
// uses this to count imported/exported value only for genuinely-new txs — and in
|
||||
// doing so the property test proves the idempotency contract too: a replay
|
||||
// changes neither the ledger nor the conservation totals. Reads vm.db under
|
||||
// vm.mu, exactly as Block.execute does via getSeenOutcome.
|
||||
func alreadySeen(vm *VM, tx *Tx) bool {
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
seen, err := isSeen(vm.db, tx.ID())
|
||||
if err != nil {
|
||||
// A read error here is not a conservation question; surface it as "not
|
||||
// seen" so the caller proceeds and any real DB fault fails elsewhere loudly.
|
||||
return false
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
// be64 reads an 8-byte big-endian uint64 (the ledger's canonical width). Local to
|
||||
// avoid pulling encoding/binary into the assertion path's hot loop signature.
|
||||
func be64(b []byte) uint64 {
|
||||
_ = b[7]
|
||||
return uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
|
||||
uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7])
|
||||
}
|
||||
|
||||
// ledgerModel tracks the test-side accounting the chain's behaviour is checked
|
||||
// against: cumulative imported (I) and exported (E) per asset, plus the asset
|
||||
// universe seen. F (fees) is held explicitly and asserted zero — the model has no
|
||||
// fee bucket because the ledger takes no fee; carrying F makes that an EXPLICIT,
|
||||
// checkable property rather than a silent assumption.
|
||||
type ledgerModel struct {
|
||||
imported map[[32]byte]uint64 // I: Σ deposited
|
||||
exported map[[32]byte]uint64 // E: Σ realized-withdrawn
|
||||
fees map[[32]byte]uint64 // F: Σ fees skimmed (must stay 0)
|
||||
assets map[[32]byte]struct{}
|
||||
}
|
||||
|
||||
func newLedgerModel() *ledgerModel {
|
||||
return &ledgerModel{
|
||||
imported: map[[32]byte]uint64{},
|
||||
exported: map[[32]byte]uint64{},
|
||||
fees: map[[32]byte]uint64{},
|
||||
assets: map[[32]byte]struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ledgerModel) note(asset [32]byte) { m.assets[asset] = struct{}{} }
|
||||
|
||||
func (m *ledgerModel) deposit(asset [32]byte, amt uint64) {
|
||||
m.note(asset)
|
||||
m.imported[asset] += amt
|
||||
}
|
||||
|
||||
func (m *ledgerModel) withdraw(asset [32]byte, realized uint64) {
|
||||
m.note(asset)
|
||||
m.exported[asset] += realized
|
||||
}
|
||||
|
||||
// assertConservation is the heart of the proof: for every asset the test has
|
||||
// touched, I == A + L + F + E. A is summed from the live DB sweep, L likewise, I
|
||||
// and E from the model, F asserted zero. Called after EVERY accepted op. The
|
||||
// `where` string names the step so a violation pinpoints the exact op + seed.
|
||||
func (m *ledgerModel) assertConservation(t *testing.T, vm *VM, where string) {
|
||||
t.Helper()
|
||||
avail, locked := ledgerTotals(t, vm)
|
||||
|
||||
// Defense-in-depth: the live ledger must not hold a row for an asset the test
|
||||
// never deposited (that would be value minted from nowhere). Sweep both DB
|
||||
// maps and fail if an unknown asset appears.
|
||||
for a := range avail {
|
||||
if _, ok := m.assets[a]; !ok {
|
||||
t.Fatalf("%s: available ledger holds UNKNOWN asset %x = %d (value from nowhere)", where, a, avail[a])
|
||||
}
|
||||
}
|
||||
for a := range locked {
|
||||
if _, ok := m.assets[a]; !ok {
|
||||
t.Fatalf("%s: locked ledger holds UNKNOWN asset %x = %d (value from nowhere)", where, a, locked[a])
|
||||
}
|
||||
}
|
||||
|
||||
for a := range m.assets {
|
||||
I := m.imported[a]
|
||||
A := avail[a]
|
||||
L := locked[a]
|
||||
F := m.fees[a]
|
||||
E := m.exported[a]
|
||||
if F != 0 {
|
||||
t.Fatalf("%s: asset %x has F=%d, but the ledger takes NO fee — accounting drift", where, a, F)
|
||||
}
|
||||
if A+L+F+E != I {
|
||||
t.Fatalf("%s: CONSERVATION VIOLATED for asset %x:\n"+
|
||||
" I (imported) = %d\n"+
|
||||
" A (available) = %d\n"+
|
||||
" L (locked) = %d\n"+
|
||||
" F (fees) = %d\n"+
|
||||
" E (exported) = %d\n"+
|
||||
" A+L+F+E = %d (want == I = %d)\n"+
|
||||
" delta = %d",
|
||||
where, a, I, A, L, F, E, A+L+F+E, I, int64(A+L+F+E)-int64(I))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// conservationSeeds is the FIXED seed table (deterministic; not wall-clock). Each
|
||||
// seed replays a fresh, independent randomized session. A diverse spread of
|
||||
// starting points exercises different op interleavings without any
|
||||
// nondeterministic input — re-running the test reproduces every session exactly.
|
||||
var conservationSeeds = []int64{
|
||||
1, 2, 3, 7, 11, 13, 17, 23, 42, 99,
|
||||
101, 256, 999, 1337, 4242, 1000001, 31337, 65537, 123456, 7777777,
|
||||
}
|
||||
|
||||
// opsPerSeed is the number of randomized operations driven per seed. With 20
|
||||
// seeds this is 20*opsPerSeed total state transitions, each followed by a full
|
||||
// invariant sweep.
|
||||
const opsPerSeed = 220
|
||||
|
||||
// TestConservationInvariantProperty is the executable proof that the parallel
|
||||
// D-Chain ledger conserves value across arbitrary operation sequences. For each
|
||||
// fixed seed it stands up a fresh VM, opens two markets over a fixed 3-asset
|
||||
// universe, then drives opsPerSeed randomized deposit/place/submit/cancel/
|
||||
// withdraw ops (one per block, through the real BuildBlock->Verify->Accept path),
|
||||
// asserting I = A + L + F + E after every single op. It deliberately steers into
|
||||
// the adversarial cases (over-withdraw, cancel-after-fill, double-withdraw,
|
||||
// partial fills, unfunded place, stale cancel) so the invariant is proven under
|
||||
// the exact sequences that would expose a mint/burn bug.
|
||||
func TestConservationInvariantProperty(t *testing.T) {
|
||||
// Fixed asset universe (full 32-byte ids via a32). Two markets share LUX as the
|
||||
// base so crosses exercise both legs (base + quote) of settlement against a
|
||||
// common asset.
|
||||
var (
|
||||
aLUX = a32(0x4c5558_00000001) // base
|
||||
aLUSD = a32(0x4c555344_000001) // quote A
|
||||
aLETH = a32(0x4c455448_000001) // quote B
|
||||
)
|
||||
poolLUSD := [32]byte{'L', 'U', 'X', '/', 'L', 'U', 'S', 'D', 0xD0}
|
||||
poolLETH := [32]byte{'L', 'U', 'X', '/', 'L', 'E', 'T', 'H', 0xD1}
|
||||
|
||||
// Fixed account roster the driver picks from.
|
||||
accounts := []string{"alice", "bob", "carol", "dave", "erin"}
|
||||
|
||||
// Per-market (base,quote) so the driver locks the right asset for a side.
|
||||
type market struct {
|
||||
pool [32]byte
|
||||
base, quote [32]byte
|
||||
}
|
||||
markets := []market{
|
||||
{poolLUSD, aLUX, aLUSD},
|
||||
{poolLETH, aLUX, aLETH},
|
||||
}
|
||||
|
||||
totalOps := 0
|
||||
for _, seed := range conservationSeeds {
|
||||
seed := seed
|
||||
t.Run(fmt.Sprintf("seed-%d", seed), func(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(context.Background())
|
||||
|
||||
model := newLedgerModel()
|
||||
for i := range markets {
|
||||
model.note(markets[i].base)
|
||||
model.note(markets[i].quote)
|
||||
}
|
||||
|
||||
// Block 1: open both markets so place/submit have a book.
|
||||
addBlock(t, vm,
|
||||
openMarketTx(t, poolLUSD, aLUX, aLUSD),
|
||||
openMarketTx(t, poolLETH, aLUX, aLETH),
|
||||
)
|
||||
model.assertConservation(t, vm, "after open-markets")
|
||||
|
||||
// liveOrders tracks order ids the driver has placed per pool, so cancel
|
||||
// can target a REAL id (or, on purpose, a stale one). Filled/cancelled
|
||||
// ids are left in the slice intentionally to exercise cancel-after-fill
|
||||
// and double-cancel (deterministic no-ops the ledger must conserve).
|
||||
liveOrders := map[[32]byte][]uint64{}
|
||||
|
||||
for step := 0; step < opsPerSeed; step++ {
|
||||
// Snapshot the whole ledger + the orderuser: identity index BEFORE the
|
||||
// op, so after the op we can assert BOTH conservation (Σ value
|
||||
// unchanged) AND ownership (every changed account is an explicit party
|
||||
// to an accepted event — no value moved to a non-party). The two
|
||||
// invariants are orthogonal: conservation catches mint/burn, ownership
|
||||
// catches theft (a conserving mis-attribution).
|
||||
own := newOwnershipChecker(t, vm)
|
||||
var blk *Block
|
||||
var where string
|
||||
|
||||
switch rng.Intn(6) {
|
||||
case 0, 1:
|
||||
// DEPOSIT (weighted: keeps balances funded so the other ops have
|
||||
// something to work with). Pick account + asset + amount.
|
||||
acct := accounts[rng.Intn(len(accounts))]
|
||||
asset := pickAsset(rng, aLUX, aLUSD, aLETH)
|
||||
amt := uint64(1 + rng.Intn(1000))
|
||||
tx := depositTx(t, acct, asset, amt)
|
||||
// The d-chain dedups CONTENT-IDENTICAL txs (no nonce on the frozen
|
||||
// frame): a byte-identical deposit already committed is a no-op that
|
||||
// returns the FIRST outcome and credits NOTHING again. The driver can
|
||||
// emit the same (acct,asset,amt) twice, so gate the import on whether
|
||||
// this exact tx was already applied — counting a deduped replay as new
|
||||
// imported value would be a TEST bug, not a ledger one. This makes the
|
||||
// property ALSO prove idempotency: a replay moves no value.
|
||||
replay := alreadySeen(vm, tx)
|
||||
var o []txOutcome
|
||||
blk, o = addBlockOutcomes(t, vm, tx)
|
||||
realized := withdrawRealizedOf(o, TxDeposit)
|
||||
if realized != amt {
|
||||
t.Fatalf("deposit realized %d != amount %d (a deposit must never clamp)", realized, amt)
|
||||
}
|
||||
if !replay {
|
||||
model.deposit(asset, amt)
|
||||
}
|
||||
where = fmt.Sprintf("step %d deposit %s asset=%x amt=%d replay=%v", step, acct, asset, amt, replay)
|
||||
model.assertConservation(t, vm, where)
|
||||
|
||||
case 2:
|
||||
// PLACE a resting limit order. May rest (locks the spend asset) or
|
||||
// be REJECTED when the account cannot fund it (unfunded place) — both
|
||||
// conserving. Integer price/size so the lock math is exact.
|
||||
mk := markets[rng.Intn(len(markets))]
|
||||
acct := accounts[rng.Intn(len(accounts))]
|
||||
side := uint8(rng.Intn(2)) // 0 buy, 1 sell
|
||||
price := float64(1 + rng.Intn(20))
|
||||
size := float64(1 + rng.Intn(40))
|
||||
blk = addBlock(t, vm, placePoolTx(t, mk.pool, side, price, size, acct))
|
||||
// One op per block, txIndex 0 -> deterministic id. Record it
|
||||
// regardless of whether it rested; a rejected place yields an id
|
||||
// that resolves to "no such order" on cancel (exercises stale cancel).
|
||||
oid := blockDeterministicID(blk.height, 0)
|
||||
liveOrders[mk.pool] = append(liveOrders[mk.pool], oid)
|
||||
where = fmt.Sprintf("step %d place %s side=%d %g@%g", step, acct, side, size, price)
|
||||
model.assertConservation(t, vm, where)
|
||||
|
||||
case 3:
|
||||
// SUBMIT a marketable order (the cross/match path). Settlement moves
|
||||
// value maker<->taker entirely inside the ledger. Sometimes oversized
|
||||
// to force a PARTIAL fill (maker fully consumed, taker remainder
|
||||
// refunded); sometimes against an empty book (zero fill). Conserving.
|
||||
mk := markets[rng.Intn(len(markets))]
|
||||
acct := accounts[rng.Intn(len(accounts))]
|
||||
side := uint8(rng.Intn(2))
|
||||
price := float64(1 + rng.Intn(20))
|
||||
size := float64(1 + rng.Intn(60)) // can exceed resting -> partial
|
||||
blk = addBlock(t, vm, submitPoolTx(t, mk.pool, side, false, price, size, acct))
|
||||
where = fmt.Sprintf("step %d submit %s side=%d %g@%g", step, acct, side, size, price)
|
||||
model.assertConservation(t, vm, where)
|
||||
|
||||
case 4:
|
||||
// CANCEL. Target a recorded id (real, filled, or stale) — all of
|
||||
// which the ledger handles deterministically: a live order unlocks
|
||||
// its reserve (locked->available), a filled/unknown id is a no-op.
|
||||
// This is the cancel-after-fill + double-cancel + stale-cancel path.
|
||||
mk := markets[rng.Intn(len(markets))]
|
||||
ids := liveOrders[mk.pool]
|
||||
var oid uint64
|
||||
if len(ids) > 0 {
|
||||
oid = ids[rng.Intn(len(ids))]
|
||||
} else {
|
||||
oid = uint64(rng.Int63()) // stale/unknown id when nothing placed yet
|
||||
}
|
||||
blk = addBlock(t, vm, cancelPoolTx(t, mk.pool, oid))
|
||||
where = fmt.Sprintf("step %d cancel oid=%d", step, oid)
|
||||
model.assertConservation(t, vm, where)
|
||||
|
||||
case 5:
|
||||
// WITHDRAW. Half the time ask for MORE than is available
|
||||
// (over-withdraw -> clamp to realized); the realized amount is read
|
||||
// from the consensus outcome and added to E. Repeated picks of the
|
||||
// same (acct,asset) exercise double-withdraw (second clamps to the
|
||||
// remainder). Conserving: E rises by exactly what A fell.
|
||||
acct := accounts[rng.Intn(len(accounts))]
|
||||
asset := pickAsset(rng, aLUX, aLUSD, aLETH)
|
||||
avail, _, err := vm.Balance(acct, asset)
|
||||
if err != nil {
|
||||
t.Fatalf("Balance(%s,%x): %v", acct, asset, err)
|
||||
}
|
||||
var want uint64
|
||||
if rng.Intn(2) == 0 && avail > 0 {
|
||||
// In-range withdraw (0..avail].
|
||||
want = 1 + uint64(rng.Int63n(int64(avail)))
|
||||
} else {
|
||||
// Over-withdraw (or withdraw from an empty balance) -> must clamp.
|
||||
want = avail + uint64(1+rng.Intn(500))
|
||||
}
|
||||
tx := withdrawTx(t, acct, asset, want)
|
||||
// Same content-addressed dedup as deposit: a byte-identical withdraw
|
||||
// already committed returns the FIRST realized amount and EXPORTS
|
||||
// nothing again. A deduped replay's realized was clamped to the
|
||||
// available balance AT FIRST APPLICATION, which can exceed the CURRENT
|
||||
// available (intervening withdraws drained it) — so the current-avail
|
||||
// mint check and the E update apply only to a genuinely-new withdraw.
|
||||
replay := alreadySeen(vm, tx)
|
||||
var o []txOutcome
|
||||
blk, o = addBlockOutcomes(t, vm, tx)
|
||||
realized := withdrawRealizedOf(o, TxWithdraw)
|
||||
if realized > want {
|
||||
t.Fatalf("withdraw realized %d > want %d (MINT via withdraw)", realized, want)
|
||||
}
|
||||
if !replay {
|
||||
if realized > avail {
|
||||
t.Fatalf("withdraw realized %d > available %d (clamp failed -> mint)", realized, avail)
|
||||
}
|
||||
model.withdraw(asset, realized)
|
||||
}
|
||||
where = fmt.Sprintf("step %d withdraw %s asset=%x want=%d replay=%v", step, acct, asset, want, replay)
|
||||
model.assertConservation(t, vm, where)
|
||||
}
|
||||
|
||||
// OWNERSHIP: every account whose controlled value changed in this block
|
||||
// must be an explicit party to one of the block's accepted events. This
|
||||
// runs alongside the conservation assertion above on EVERY op.
|
||||
own.assert(t, vm, blk, where)
|
||||
totalOps++
|
||||
}
|
||||
|
||||
// Final cross-check: drain every account's full available balance via
|
||||
// withdraw, then the ledger's remaining A must equal exactly Σlocked
|
||||
// (only live order reserves left), and I == A+L+E still holds. This is
|
||||
// the "exported == imported minus what's still escrowed" closure.
|
||||
//
|
||||
// Each drain OVER-withdraws by a globally-unique pad (>> the random
|
||||
// wants' 1..500 range, plus a monotonic counter) so the clamp drains the
|
||||
// full available AND no two drain txs are ever byte-identical — the
|
||||
// content-addressed dedup can never swallow a drain.
|
||||
drainPad := uint64(1_000_000)
|
||||
for _, acct := range accounts {
|
||||
for _, asset := range [][32]byte{aLUX, aLUSD, aLETH} {
|
||||
avail, _, err := vm.Balance(acct, asset)
|
||||
if err != nil {
|
||||
t.Fatalf("final Balance(%s,%x): %v", acct, asset, err)
|
||||
}
|
||||
if avail == 0 {
|
||||
continue
|
||||
}
|
||||
drainPad++
|
||||
want := avail + drainPad
|
||||
tx := withdrawTx(t, acct, asset, want)
|
||||
if alreadySeen(vm, tx) {
|
||||
t.Fatalf("final drain tx unexpectedly already seen (pad collision) acct=%s asset=%x want=%d", acct, asset, want)
|
||||
}
|
||||
ownDrain := newOwnershipChecker(t, vm)
|
||||
dblk, o := addBlockOutcomes(t, vm, tx)
|
||||
realized := withdrawRealizedOf(o, TxWithdraw)
|
||||
if realized != avail {
|
||||
t.Fatalf("final drain withdraw realized %d != available %d (clamp must export the full available)", realized, avail)
|
||||
}
|
||||
model.withdraw(asset, realized)
|
||||
model.assertConservation(t, vm, "final-drain "+acct)
|
||||
ownDrain.assert(t, vm, dblk, "final-drain "+acct)
|
||||
}
|
||||
}
|
||||
|
||||
// After draining all available, every asset's remaining ledger value is
|
||||
// purely LOCKED (live order reserves). Assert A == 0 for assets with no
|
||||
// live locks, and that I - E == L (the still-escrowed remainder).
|
||||
availFinal, lockedFinal := ledgerTotals(t, vm)
|
||||
for _, asset := range [][32]byte{aLUX, aLUSD, aLETH} {
|
||||
if got := availFinal[asset]; got != 0 {
|
||||
t.Fatalf("after full drain, asset %x still has %d AVAILABLE (want 0)", asset, got)
|
||||
}
|
||||
if I, E, L := model.imported[asset], model.exported[asset], lockedFinal[asset]; I-E != L {
|
||||
t.Fatalf("closure broken asset %x: I(%d) - E(%d) = %d, but L = %d", asset, I, E, I-E, L)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Logf("conservation property: %d seeds x ~%d ops = %d randomized state transitions, invariant held after EVERY op",
|
||||
len(conservationSeeds), opsPerSeed, totalOps)
|
||||
}
|
||||
|
||||
// withdrawRealizedOf extracts the realized amount the consensus outcome carries
|
||||
// in orderID for the given tx type (deposit credits == amount; withdraw realized
|
||||
// == clamped debit). Fails if the expected outcome is absent.
|
||||
func withdrawRealizedOf(outcomes []txOutcome, typ TxType) uint64 {
|
||||
for _, oc := range outcomes {
|
||||
if oc.typ == typ {
|
||||
return oc.orderID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// pickAsset returns one of the supplied assets uniformly. Keeps the asset choice
|
||||
// deterministic under the seeded rng.
|
||||
func pickAsset(rng *rand.Rand, assets ...[32]byte) [32]byte {
|
||||
return assets[rng.Intn(len(assets))]
|
||||
}
|
||||
@@ -18,37 +18,78 @@ package dchain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/dex/pkg/zapwire"
|
||||
)
|
||||
|
||||
// asset handles for the test market (8-byte ledger handles).
|
||||
const (
|
||||
assetLUX uint64 = 0x4c5558_00000001 // "LUX" base
|
||||
assetLUSD uint64 = 0x4c555344_000001 // "LUSD" quote
|
||||
// asset ids for the test market (full 32-byte injective ids; the uint64 LABEL is
|
||||
// packed into the low 8 bytes by a32, the test-side analog of the production
|
||||
// address->id map).
|
||||
var (
|
||||
assetLUX = a32(0x4c5558_00000001) // "LUX" base
|
||||
assetLUSD = a32(0x4c555344_000001) // "LUSD" quote
|
||||
)
|
||||
|
||||
func depositTx(t *testing.T, user string, asset, amount uint64) *Tx {
|
||||
// contentRef derives a deterministic 32-byte idempotency ref from a custody op's
|
||||
// (user,asset,amount) content. It exists ONLY so the default depositTx/withdrawTx
|
||||
// helpers preserve the PRE-ref content-dedup semantics every existing scenario
|
||||
// relies on: a byte-identical (user,asset,amount) call yields a byte-identical
|
||||
// ref -> identical txID -> the same content-addressed seen: dedup as before. To
|
||||
// model a GENUINELY distinct originating tx (the production reality where a real
|
||||
// 2nd withdraw carries a fresh EVM txHash), use depositTxRef/withdrawTxRef with an
|
||||
// explicit, distinct ref. This is a TEST FIXTURE convention; production always
|
||||
// supplies the real originating-tx ref (precompile txHash / atomic import id).
|
||||
func contentRef(typ byte, user string, asset [32]byte, amount uint64) [32]byte {
|
||||
// Deterministic, content-derived ref: XOR-fold the full asset id (so its
|
||||
// distinguishing low bytes participate, not a truncated prefix), then mix the
|
||||
// type, user, and amount. Byte-identical inputs yield the same ref (exactly-once
|
||||
// retry); any difference yields a different ref (genuinely distinct op).
|
||||
var ref [32]byte
|
||||
copy(ref[:], asset[:]) // full id, including its low-byte discriminant
|
||||
ref[0] ^= typ
|
||||
for i := 0; i < len(user) && i+1 < 24; i++ {
|
||||
ref[1+i] ^= user[i]
|
||||
}
|
||||
binary.BigEndian.PutUint64(ref[24:32], binary.BigEndian.Uint64(asset[24:32])^amount)
|
||||
return ref
|
||||
}
|
||||
|
||||
func depositTx(t *testing.T, user string, asset [32]byte, amount uint64) *Tx {
|
||||
t.Helper()
|
||||
tx, err := NewTx(TxDeposit, zapwire.EncodeDeposit(user, asset, amount))
|
||||
return depositTxRef(t, user, asset, amount, contentRef(byte(TxDeposit), user, asset, amount))
|
||||
}
|
||||
|
||||
func withdrawTx(t *testing.T, user string, asset [32]byte, amount uint64) *Tx {
|
||||
t.Helper()
|
||||
return withdrawTxRef(t, user, asset, amount, contentRef(byte(TxWithdraw), user, asset, amount))
|
||||
}
|
||||
|
||||
// depositTxRef / withdrawTxRef build a custody tx with an EXPLICIT idempotency ref
|
||||
// (the originating-tx identity). Distinct refs => distinct txID => the d-chain
|
||||
// treats the ops as genuinely different (no seen: replay); an identical ref models
|
||||
// a true exactly-once retry of ONE op.
|
||||
func depositTxRef(t *testing.T, user string, asset [32]byte, amount uint64, ref [32]byte) *Tx {
|
||||
t.Helper()
|
||||
tx, err := NewTx(TxDeposit, zapwire.EncodeDeposit(user, asset, amount, ref))
|
||||
if err != nil {
|
||||
t.Fatalf("NewTx deposit: %v", err)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
func withdrawTx(t *testing.T, user string, asset, amount uint64) *Tx {
|
||||
func withdrawTxRef(t *testing.T, user string, asset [32]byte, amount uint64, ref [32]byte) *Tx {
|
||||
t.Helper()
|
||||
tx, err := NewTx(TxWithdraw, zapwire.EncodeWithdraw(user, asset, amount))
|
||||
tx, err := NewTx(TxWithdraw, zapwire.EncodeWithdraw(user, asset, amount, ref))
|
||||
if err != nil {
|
||||
t.Fatalf("NewTx withdraw: %v", err)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
func openMarketTx(t *testing.T, pool [32]byte, base, quote uint64) *Tx {
|
||||
func openMarketTx(t *testing.T, pool [32]byte, base, quote [32]byte) *Tx {
|
||||
t.Helper()
|
||||
tx, err := NewTx(TxOpenMarket, zapwire.EncodeOpenMarket(pool, base, quote))
|
||||
if err != nil {
|
||||
@@ -69,11 +110,13 @@ func addBlock(t *testing.T, vm *VM, txs ...*Tx) *Block {
|
||||
return buildVerifyAccept(t, vm)
|
||||
}
|
||||
|
||||
// addBlockOutcomes is like addBlock but snapshots the per-tx consensus outcomes
|
||||
// addBlockOutcomes is like addBlock but ALSO returns the per-tx consensus outcomes
|
||||
// (Verify sets them on the block; Accept clears them after resolving waiters), so
|
||||
// a caller can inspect a withdraw's REALIZED amount. It runs the same
|
||||
// Build->reparse->Verify->Accept path.
|
||||
func addBlockOutcomes(t *testing.T, vm *VM, txs ...*Tx) []txOutcome {
|
||||
// a caller can inspect a withdraw's REALIZED amount or a submit's fills. It runs
|
||||
// the same Build->reparse->Verify->Accept path and returns the accepted block (so
|
||||
// a caller can also assert ownership over it) with the snapshotted outcomes
|
||||
// re-attached for post-Accept inspection.
|
||||
func addBlockOutcomes(t *testing.T, vm *VM, txs ...*Tx) (*Block, []txOutcome) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
for _, tx := range txs {
|
||||
@@ -97,7 +140,10 @@ func addBlockOutcomes(t *testing.T, vm *VM, txs ...*Tx) []txOutcome {
|
||||
if err := reparsed.Accept(ctx); err != nil {
|
||||
t.Fatalf("Accept: %v", err)
|
||||
}
|
||||
return snap
|
||||
// Re-attach the snapshot so the returned block can be inspected for its fills
|
||||
// (Accept set blk.outcomes to nil after resolving waiters).
|
||||
blk.outcomes = snap
|
||||
return blk, snap
|
||||
}
|
||||
|
||||
// TestCustodyConservationFullCycle is the headline proof: deposit -> open ->
|
||||
@@ -114,8 +160,10 @@ func addBlockOutcomes(t *testing.T, vm *VM, txs ...*Tx) []txOutcome {
|
||||
// - both withdraw everything.
|
||||
//
|
||||
// Expected end balances BEFORE withdraw:
|
||||
// maker: 90 LUX available, 0 LUX locked, 50 LUSD available.
|
||||
// taker: 10 LUX available, 1000-50=950 LUSD available, 0 LUSD locked.
|
||||
//
|
||||
// maker: 90 LUX available, 0 LUX locked, 50 LUSD available.
|
||||
// taker: 10 LUX available, 1000-50=950 LUSD available, 0 LUSD locked.
|
||||
//
|
||||
// Conservation: total LUX in (100) == maker(90)+taker(10); total LUSD in (1000)
|
||||
// == taker(950)+maker(50). After withdraw, exported == deposited, exactly.
|
||||
func TestCustodyConservationFullCycle(t *testing.T) {
|
||||
@@ -142,18 +190,27 @@ func TestCustodyConservationFullCycle(t *testing.T) {
|
||||
assertBalance(t, vm, taker, assetLUSD, 1000, 0)
|
||||
|
||||
// --- Block 2: maker rests a SELL of 10 LUX @ 5 (locks 10 LUX). ---
|
||||
addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, maker))
|
||||
ownPlace := newOwnershipChecker(t, vm)
|
||||
placeBlk := addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, maker))
|
||||
// Maker's 10 LUX moved available->locked; 90 LUX stays available.
|
||||
assertBalance(t, vm, maker, assetLUX, 90, 10)
|
||||
// OWNERSHIP: the only account whose value moved is the maker (available->locked,
|
||||
// same owner) — a party to its own place.
|
||||
ownPlace.assert(t, vm, placeBlk, "full-cycle place")
|
||||
|
||||
// --- Block 3: taker BUYS 10 LUX, limit 5 (locks 50 LUSD), crossing. ---
|
||||
addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, taker))
|
||||
// OWNERSHIP across the cross: the ONLY accounts whose value changes are the
|
||||
// maker and the taker — both explicit parties to the fill. A colliding-handle
|
||||
// theft would credit a NON-party and trip this gate (proven in
|
||||
// TestOwnershipInvariant_CollidingAttackerIsNotAParty).
|
||||
ownCross := newOwnershipChecker(t, vm)
|
||||
crossBlk := addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, taker))
|
||||
|
||||
// THE MAKER LEG: maker received 50 LUSD (the taker's payment) into available,
|
||||
// and its 10 locked LUX left to the taker. This is exactly what the prior
|
||||
// proxy-escrow taker-only model dropped.
|
||||
assertBalance(t, vm, maker, assetLUX, 90, 0) // 10 locked LUX spent to taker
|
||||
assertBalance(t, vm, maker, assetLUSD, 50, 0) // received the taker's 50 LUSD
|
||||
assertBalance(t, vm, maker, assetLUX, 90, 0) // 10 locked LUX spent to taker
|
||||
assertBalance(t, vm, maker, assetLUSD, 50, 0) // received the taker's 50 LUSD
|
||||
// THE TAKER LEG: spent 50 LUSD (refunded 0 since limit*size == lock exactly),
|
||||
// received 10 LUX.
|
||||
assertBalance(t, vm, taker, assetLUX, 10, 0) // received 10 LUX
|
||||
@@ -169,10 +226,12 @@ func TestCustodyConservationFullCycle(t *testing.T) {
|
||||
if totalLUSD != 1000 {
|
||||
t.Fatalf("LUSD conservation violated: total available %d, deposited 1000", totalLUSD)
|
||||
}
|
||||
ownCross.assert(t, vm, crossBlk, "full-cycle cross")
|
||||
|
||||
// --- Block 4: both parties WITHDRAW everything; the realized amounts (the
|
||||
// proxy export legs) must equal exactly the available balances. ---
|
||||
wo := addBlockOutcomes(t, vm,
|
||||
ownW := newOwnershipChecker(t, vm)
|
||||
wblk, wo := addBlockOutcomes(t, vm,
|
||||
withdrawTx(t, maker, assetLUX, 90),
|
||||
withdrawTx(t, maker, assetLUSD, 50),
|
||||
withdrawTx(t, taker, assetLUX, 10),
|
||||
@@ -189,6 +248,8 @@ func TestCustodyConservationFullCycle(t *testing.T) {
|
||||
if totalWithdrawn != totalDeposited {
|
||||
t.Fatalf("CONSERVATION VIOLATED: withdrawn %d != deposited %d", totalWithdrawn, totalDeposited)
|
||||
}
|
||||
// OWNERSHIP: every account whose value left the ledger is the withdrawer itself.
|
||||
ownW.assert(t, vm, wblk, "full-cycle withdraw")
|
||||
|
||||
// Ledger is now empty (all withdrawn).
|
||||
assertBalance(t, vm, maker, assetLUX, 0, 0)
|
||||
@@ -223,7 +284,12 @@ func TestCustodySameBlockPlaceThenCross(t *testing.T) {
|
||||
)
|
||||
|
||||
// Block 2: place (maker SELL 10@5) AND cross (taker BUY 10@5) in ONE block.
|
||||
addBlock(t, vm,
|
||||
// OWNERSHIP exercises the same-block-maker resolver path: the maker rests AND is
|
||||
// fully filled in this same block (its orderuser: row is created then deleted in
|
||||
// one accept), so the harness must resolve the maker from the block's own
|
||||
// TxPlace, not a pre-block snapshot.
|
||||
ownSB := newOwnershipChecker(t, vm)
|
||||
sbBlk := addBlock(t, vm,
|
||||
placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, maker),
|
||||
submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, taker),
|
||||
)
|
||||
@@ -233,6 +299,7 @@ func TestCustodySameBlockPlaceThenCross(t *testing.T) {
|
||||
assertBalance(t, vm, maker, assetLUSD, 50, 0)
|
||||
assertBalance(t, vm, taker, assetLUX, 10, 0)
|
||||
assertBalance(t, vm, taker, assetLUSD, 950, 0)
|
||||
ownSB.assert(t, vm, sbBlk, "same-block place+cross")
|
||||
|
||||
if got := bal(t, vm, maker, assetLUX) + bal(t, vm, taker, assetLUX); got != 100 {
|
||||
t.Fatalf("same-block LUX conservation: %d want 100", got)
|
||||
@@ -317,10 +384,10 @@ func TestCustodyPartialFillConservesAndRefundsRemainder(t *testing.T) {
|
||||
addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, taker))
|
||||
|
||||
// Fill = 4 LUX @ 5 = 20 LUSD. Taker spent 20 LUSD, refunded 30 of the 50 lock.
|
||||
assertBalance(t, vm, maker, assetLUX, 96, 0) // 4 locked LUX spent to taker, 96 still available
|
||||
assertBalance(t, vm, maker, assetLUSD, 20, 0) // received 20 LUSD
|
||||
assertBalance(t, vm, taker, assetLUX, 4, 0) // received 4 LUX
|
||||
assertBalance(t, vm, taker, assetLUSD, 980, 0) // 1000 - 20 spent (30 refunded back to available)
|
||||
assertBalance(t, vm, maker, assetLUX, 96, 0) // 4 locked LUX spent to taker, 96 still available
|
||||
assertBalance(t, vm, maker, assetLUSD, 20, 0) // received 20 LUSD
|
||||
assertBalance(t, vm, taker, assetLUX, 4, 0) // received 4 LUX
|
||||
assertBalance(t, vm, taker, assetLUSD, 980, 0) // 1000 - 20 spent (30 refunded back to available)
|
||||
|
||||
totalLUX := bal(t, vm, maker, assetLUX) + bal(t, vm, taker, assetLUX)
|
||||
totalLUSD := bal(t, vm, maker, assetLUSD) + bal(t, vm, taker, assetLUSD)
|
||||
@@ -341,7 +408,7 @@ func TestCustodyWithdrawClampsToAvailable(t *testing.T) {
|
||||
addBlock(t, vm, depositTx(t, user, assetLUX, 40))
|
||||
|
||||
// Ask to withdraw 100; only 40 is available.
|
||||
wo := addBlockOutcomes(t, vm, withdrawTx(t, user, assetLUX, 100))
|
||||
_, wo := addBlockOutcomes(t, vm, withdrawTx(t, user, assetLUX, 100))
|
||||
var realized uint64
|
||||
for _, oc := range wo {
|
||||
if oc.typ == TxWithdraw {
|
||||
@@ -365,23 +432,23 @@ func cancelPoolTx(t *testing.T, pool [32]byte, orderID uint64) *Tx {
|
||||
return tx
|
||||
}
|
||||
|
||||
func bal(t *testing.T, vm *VM, user string, asset uint64) uint64 {
|
||||
func bal(t *testing.T, vm *VM, user string, asset [32]byte) uint64 {
|
||||
t.Helper()
|
||||
avail, _, err := vm.Balance(user, asset)
|
||||
if err != nil {
|
||||
t.Fatalf("Balance(%s,%d): %v", user, asset, err)
|
||||
t.Fatalf("Balance(%s,%x): %v", user, asset, err)
|
||||
}
|
||||
return avail
|
||||
}
|
||||
|
||||
func assertBalance(t *testing.T, vm *VM, user string, asset, wantAvail, wantLocked uint64) {
|
||||
func assertBalance(t *testing.T, vm *VM, user string, asset [32]byte, wantAvail, wantLocked uint64) {
|
||||
t.Helper()
|
||||
avail, locked, err := vm.Balance(user, asset)
|
||||
if err != nil {
|
||||
t.Fatalf("Balance(%s,%d): %v", user, asset, err)
|
||||
t.Fatalf("Balance(%s,%x): %v", user, asset, err)
|
||||
}
|
||||
if avail != wantAvail || locked != wantLocked {
|
||||
t.Fatalf("balance %s asset=%#x: available=%d locked=%d, want available=%d locked=%d",
|
||||
t.Fatalf("balance %s asset=%x: available=%d locked=%d, want available=%d locked=%d",
|
||||
user, asset, avail, locked, wantAvail, wantLocked)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dchain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/consensus/engine/chain/block"
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/dex/pkg/zapwire"
|
||||
"github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// custody_timeout_test.go is the REGRESSION PROOF for the orphan-custody-tx bug
|
||||
// (RED #4): handler.submitTx did mempool.Add(tx) then blocked on the waiter; on
|
||||
// ctx.Done() it cancelled ONLY the waiter, never removing the tx. The mempool has
|
||||
// no dedup and BuildBlock drains ALL pending, so a client timeout left the tx
|
||||
// QUEUED -> it committed in a LATER block = orphan. A DEPOSIT orphan is the worst:
|
||||
// the EVM reverts (the vault msg.value is rolled back by the StateDB snapshot when
|
||||
// the precompile's ZAP call timed out) but the D-Chain STILL credits = MINT.
|
||||
//
|
||||
// THE FIX: submitTx now calls mempool.cancel(txID) on ctx.Done(). cancel removes a
|
||||
// still-pending tx outright; if the tx already drained into an in-flight block it
|
||||
// is TOMBSTONED so a later Drain/Requeue excludes it. Either way a cancelled
|
||||
// custody tx can NEVER commit in a later block.
|
||||
//
|
||||
// These tests drive the d-chain VM directly (no live socket) so the timeout window
|
||||
// is deterministic: a custody tx submitted with a ctx that expires WHILE it sits in
|
||||
// the mempool (no sealer draining) must (a) make submitTx return ctx.Err(), (b)
|
||||
// leave the mempool empty (removed), and (c) NOT credit the ledger on a subsequent
|
||||
// build — no orphan, no mint. (The existing handler/relay tests only cover
|
||||
// dial-fail, never this timeout-after-Add path.)
|
||||
|
||||
// newTimeoutVM builds an initialized VM with NO auto-sealer, so a submitted tx
|
||||
// sits in the mempool until the test explicitly builds — giving a deterministic
|
||||
// window to time out submitTx while the tx is still pending.
|
||||
func newTimeoutVM(t *testing.T) *VM {
|
||||
t.Helper()
|
||||
vm := &VM{}
|
||||
toEngine := make(chan block.Message, 64)
|
||||
if err := vm.Initialize(context.Background(), block.Init{
|
||||
DB: memdb.New(),
|
||||
Log: log.NewNoOpLogger(),
|
||||
ToEngine: toEngine,
|
||||
}); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = vm.Shutdown(context.Background()) })
|
||||
return vm
|
||||
}
|
||||
|
||||
// TestCustodyDepositTimeoutDoesNotCreditOrOrphan is the #4 proof for a DEPOSIT:
|
||||
// submitTx times out while the deposit sits in the mempool. The fix must remove it
|
||||
// so it never commits — no D-Chain credit (no mint to match the EVM's rolled-back
|
||||
// vault lock).
|
||||
func TestCustodyDepositTimeoutDoesNotCreditOrOrphan(t *testing.T) {
|
||||
vm := newTimeoutVM(t)
|
||||
const (
|
||||
user = "alice"
|
||||
amount = uint64(1000)
|
||||
)
|
||||
asset := a32(0x4c5558_00000001)
|
||||
depRef := contentRef(byte(TxDeposit), user, asset, amount)
|
||||
depBody := encodeDepositBody(user, asset, amount, depRef)
|
||||
|
||||
// submitTx with a ctx that expires almost immediately. No sealer is running, so
|
||||
// the tx is Add'ed to the mempool and the ctx fires before anything drains it.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := vm.submitTx(ctx, TxDeposit, depBody)
|
||||
if err == nil {
|
||||
t.Fatal("submitTx returned nil error on a timed-out deposit; want ctx deadline error")
|
||||
}
|
||||
if err != context.DeadlineExceeded && ctx.Err() == nil {
|
||||
t.Fatalf("submitTx err = %v, want a ctx timeout", err)
|
||||
}
|
||||
|
||||
// THE FIX: the timed-out deposit was REMOVED from the mempool, not left queued.
|
||||
if n := vm.mempool.Len(); n != 0 {
|
||||
t.Fatalf("mempool holds %d txs after a timed-out deposit — the orphan is still QUEUED "+
|
||||
"and WILL commit in a later block (mint). cancel() must have removed it.", n)
|
||||
}
|
||||
|
||||
// And a subsequent build finds nothing -> no block -> no credit. (BuildBlock
|
||||
// returns ErrEmptyMempool on an empty pool; that is the correct "nothing to
|
||||
// commit" outcome — the deposit never reaches the ledger.)
|
||||
if _, berr := vm.BuildBlock(context.Background()); berr != ErrEmptyMempool {
|
||||
t.Fatalf("BuildBlock after timeout = %v, want ErrEmptyMempool (the orphan deposit must not build a block)", berr)
|
||||
}
|
||||
|
||||
// The ledger never credited the timed-out deposit: available is 0 (no mint).
|
||||
avail, locked, err := vm.Balance(user, asset)
|
||||
if err != nil {
|
||||
t.Fatalf("Balance: %v", err)
|
||||
}
|
||||
if avail != 0 || locked != 0 {
|
||||
t.Fatalf("ledger credited a TIMED-OUT deposit: available=%d locked=%d, want 0/0 — "+
|
||||
"this is the orphan->mint the EVM's rolled-back vault lock cannot back", avail, locked)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustodyWithdrawTimeoutDoesNotCommit is the #4 proof for a WITHDRAW: a
|
||||
// timed-out withdraw must not later commit (it would export against a vault the EVM
|
||||
// did not release). It also confirms the deposit it follows is unaffected.
|
||||
func TestCustodyWithdrawTimeoutDoesNotCommit(t *testing.T) {
|
||||
vm := newTimeoutVM(t)
|
||||
const (
|
||||
user = "bob"
|
||||
dep = uint64(500)
|
||||
)
|
||||
asset := a32(0x4c5558_00000001)
|
||||
|
||||
// First, a real deposit committed via the explicit build path (so there IS a
|
||||
// balance a stray withdraw could illegitimately export).
|
||||
addBlock(t, vm, depositTx(t, user, asset, dep))
|
||||
if avail, _, _ := vm.Balance(user, asset); avail != dep {
|
||||
t.Fatalf("setup deposit available = %d, want %d", avail, dep)
|
||||
}
|
||||
|
||||
// Now a withdraw whose submitTx times out while queued.
|
||||
wRef := contentRef(byte(TxWithdraw), user, asset, dep)
|
||||
wBody := encodeWithdrawBody(user, asset, dep, wRef)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := vm.submitTx(ctx, TxWithdraw, wBody); err == nil {
|
||||
t.Fatal("submitTx returned nil on a timed-out withdraw; want ctx timeout")
|
||||
}
|
||||
|
||||
// The withdraw was removed; the mempool is empty; no block commits it.
|
||||
if n := vm.mempool.Len(); n != 0 {
|
||||
t.Fatalf("mempool holds %d after a timed-out withdraw — orphan would export against an unreleased vault", n)
|
||||
}
|
||||
if _, berr := vm.BuildBlock(context.Background()); berr != ErrEmptyMempool {
|
||||
t.Fatalf("BuildBlock after withdraw timeout = %v, want ErrEmptyMempool", berr)
|
||||
}
|
||||
|
||||
// The balance is UNCHANGED: the timed-out withdraw debited nothing (no export
|
||||
// leg the EVM never authorized).
|
||||
if avail, _, _ := vm.Balance(user, asset); avail != dep {
|
||||
t.Fatalf("balance after a timed-out withdraw = %d, want %d (the orphan withdraw must NOT debit)", avail, dep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMempoolCancelRemovesPending is the unit proof that cancel removes a pending
|
||||
// tx so Drain (BuildBlock's mechanism) never sees it.
|
||||
func TestMempoolCancelRemovesPending(t *testing.T) {
|
||||
m := newMempool(nil)
|
||||
keep := mustTx(t, TxEnsureMarket, encodeEnsureBody([32]byte{0x01}))
|
||||
drop := mustTx(t, TxDeposit, encodeDepositBody("u", a32(7), 100, [32]byte{0xab}))
|
||||
|
||||
m.Add(keep)
|
||||
m.Add(drop)
|
||||
if m.Len() != 2 {
|
||||
t.Fatalf("len = %d, want 2", m.Len())
|
||||
}
|
||||
|
||||
m.cancel(drop.ID(), 1) // pending: removed outright (height arg unused on this path)
|
||||
if m.Len() != 1 {
|
||||
t.Fatalf("len after cancel = %d, want 1 (the cancelled tx must be gone)", m.Len())
|
||||
}
|
||||
got := m.Drain(0)
|
||||
if len(got) != 1 || got[0].ID() != keep.ID() {
|
||||
t.Fatalf("Drain returned %d txs; want exactly the non-cancelled one", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMempoolTombstoneSurvivesRequeue is the unit proof for the in-flight-block
|
||||
// race: a tx drained into a block, then cancelled (tombstoned), must NOT come back
|
||||
// when that block is rejected and Requeued — it can never commit in a later block.
|
||||
func TestMempoolTombstoneSurvivesRequeue(t *testing.T) {
|
||||
m := newMempool(nil)
|
||||
a := mustTx(t, TxDeposit, encodeDepositBody("a", a32(1), 10, [32]byte{0x01}))
|
||||
b := mustTx(t, TxDeposit, encodeDepositBody("b", a32(2), 20, [32]byte{0x02}))
|
||||
|
||||
m.Add(a)
|
||||
m.Add(b)
|
||||
drained := m.Drain(0) // both leave the pending queue (into a "building block")
|
||||
if len(drained) != 2 {
|
||||
t.Fatalf("drained %d, want 2", len(drained))
|
||||
}
|
||||
|
||||
// The submitter of `b` times out AFTER it was drained -> tombstone, stamped with
|
||||
// the in-flight block height (1).
|
||||
m.cancel(b.ID(), 1)
|
||||
|
||||
// The block is rejected; its txs are Requeued. `b` must be dropped (tombstoned),
|
||||
// only `a` returns.
|
||||
m.Requeue(drained)
|
||||
back := m.Drain(0)
|
||||
if len(back) != 1 || back[0].ID() != a.ID() {
|
||||
t.Fatalf("after tombstone+requeue, Drain returned %d txs; want only the non-cancelled `a` "+
|
||||
"(the cancelled `b` must never re-enter a block)", len(back))
|
||||
}
|
||||
|
||||
// The tombstone was consumed (it does not linger to drop a future legitimate tx
|
||||
// that happens to share the id — ids are content+ref unique, but hygiene matters).
|
||||
m.mu.Lock()
|
||||
nLeft := len(m.tombstones)
|
||||
m.mu.Unlock()
|
||||
if nLeft != 0 {
|
||||
t.Fatalf("tombstones not cleared after consumption: %d left", nLeft)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- local frame/body builders (avoid importing zapwire ref-codecs into the
|
||||
// test's hot path; these mirror the bodies NewTx expects) ----
|
||||
|
||||
func mustTx(t *testing.T, typ TxType, body []byte) *Tx {
|
||||
t.Helper()
|
||||
tx, err := NewTx(typ, body)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTx(%v): %v", typ, err)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
// the following thin wrappers keep the test readable and route through the same
|
||||
// zapwire encoders production uses (so the bodies are byte-identical to the wire).
|
||||
func encodeDepositBody(user string, asset [32]byte, amount uint64, ref [32]byte) []byte {
|
||||
return zapwire.EncodeDeposit(user, asset, amount, ref)
|
||||
}
|
||||
func encodeWithdrawBody(user string, asset [32]byte, amount uint64, ref [32]byte) []byte {
|
||||
return zapwire.EncodeWithdraw(user, asset, amount, ref)
|
||||
}
|
||||
func encodeEnsureBody(pool [32]byte) []byte { return zapwire.EncodeEnsureMarket(pool) }
|
||||
@@ -0,0 +1,110 @@
|
||||
// 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/database/memdb"
|
||||
"github.com/luxfi/dex/pkg/zapwire"
|
||||
)
|
||||
|
||||
// custody_zeroref_test.go pins R3 on the consensus side: a custody tx whose
|
||||
// idempotency ref is all-zero is REJECTED at execute (every validator decodes the
|
||||
// same frame and refuses identically, so the rejection is consensus-neutral) before
|
||||
// any credit/debit. The ref is the originating-tx identity folded into tx.ID() and
|
||||
// the seen: dedup key; a committed EVM tx always has a unique non-zero hash, so a
|
||||
// zero ref is an unidentified (test-mock) or proxy fillRef=0 frame that must not
|
||||
// mint an unbacked credit or release value against an untracked identity.
|
||||
|
||||
// zeroRef is the all-zero idempotency ref the handlers must refuse.
|
||||
var zeroRef = [32]byte{}
|
||||
|
||||
// TestCustody_ZeroRefDepositRejected: a deposit carrying a zero ref fails at
|
||||
// execute (surfaced by BuildBlock) and credits nothing.
|
||||
func TestCustody_ZeroRefDepositRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
|
||||
const user = "zero-dep"
|
||||
tx := mustTx(t, TxDeposit, zapwire.EncodeDeposit(user, assetLUX, 100, zeroRef))
|
||||
vm.mempool.Add(tx)
|
||||
|
||||
// BuildBlock runs the execute probe; a zero-ref deposit must make it error.
|
||||
if _, err := vm.BuildBlock(ctx); err == nil {
|
||||
t.Fatal("BuildBlock with a zero-ref deposit must error, got nil")
|
||||
} else if !errors.Is(err, errZeroCustodyRef) {
|
||||
t.Fatalf("BuildBlock err = %v, want errZeroCustodyRef", err)
|
||||
}
|
||||
|
||||
// Nothing was credited (the tx never committed).
|
||||
if avail, _, _ := vm.Balance(user, assetLUX); avail != 0 {
|
||||
t.Fatalf("balance after refused zero-ref deposit = %d, want 0 (no mint)", avail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustody_ZeroRefWithdrawRejected: a withdraw carrying a zero ref fails at
|
||||
// execute and debits nothing — even when the user holds a real (bound-deposited)
|
||||
// balance the withdraw could otherwise release.
|
||||
func TestCustody_ZeroRefWithdrawRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
|
||||
const user = "zero-wd"
|
||||
// Seed a real balance via a NON-zero-ref deposit committed in a block.
|
||||
depRef := contentRef(byte(TxDeposit), user, assetLUX, 500)
|
||||
addBlock(t, vm, depositTxRef(t, user, assetLUX, 500, depRef))
|
||||
if avail, _, _ := vm.Balance(user, assetLUX); avail != 500 {
|
||||
t.Fatalf("seed balance = %d, want 500", avail)
|
||||
}
|
||||
|
||||
// A zero-ref withdraw must be refused at execute.
|
||||
wtx := mustTx(t, TxWithdraw, zapwire.EncodeWithdraw(user, assetLUX, 500, zeroRef))
|
||||
vm.mempool.Add(wtx)
|
||||
if _, err := vm.BuildBlock(ctx); err == nil {
|
||||
t.Fatal("BuildBlock with a zero-ref withdraw must error, got nil")
|
||||
} else if !errors.Is(err, errZeroCustodyRef) {
|
||||
t.Fatalf("BuildBlock err = %v, want errZeroCustodyRef", err)
|
||||
}
|
||||
|
||||
// The balance is UNCHANGED: the refused withdraw debited nothing.
|
||||
if avail, _, _ := vm.Balance(user, assetLUX); avail != 500 {
|
||||
t.Fatalf("balance after refused zero-ref withdraw = %d, want 500 (no debit)", avail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustody_NonZeroRefStillWorks: the normal path — a non-zero ref deposit then
|
||||
// withdraw both commit and move the ledger exactly, proving R3 only refuses the
|
||||
// zero-ref door and does not regress real custody.
|
||||
func TestCustody_NonZeroRefStillWorks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
|
||||
const user = "ok-ref"
|
||||
depRef := contentRef(byte(TxDeposit), user, assetLUX, 250)
|
||||
addBlock(t, vm, depositTxRef(t, user, assetLUX, 250, depRef))
|
||||
if avail, _, _ := vm.Balance(user, assetLUX); avail != 250 {
|
||||
t.Fatalf("balance after non-zero-ref deposit = %d, want 250", avail)
|
||||
}
|
||||
|
||||
wRef := contentRef(byte(TxWithdraw), user, assetLUX, 250)
|
||||
_, out := addBlockOutcomes(t, vm, withdrawTxRef(t, user, assetLUX, 250, wRef))
|
||||
var realized uint64
|
||||
for _, o := range out {
|
||||
if o.typ == TxWithdraw {
|
||||
realized = o.orderID
|
||||
}
|
||||
}
|
||||
if realized != 250 {
|
||||
t.Fatalf("non-zero-ref withdraw realized = %d, want 250", realized)
|
||||
}
|
||||
if avail, _, _ := vm.Balance(user, assetLUX); avail != 0 {
|
||||
t.Fatalf("balance after non-zero-ref withdraw = %d, want 0 (debited)", avail)
|
||||
}
|
||||
}
|
||||
+12
-15
@@ -55,11 +55,12 @@ type applyResult struct {
|
||||
TakerSide lx.Side
|
||||
// Placed is the order a TxPlace rested (nil otherwise).
|
||||
Placed *lx.Order
|
||||
// Canceled is the order id a TxCancel removed (0 otherwise).
|
||||
// Canceled is the order id a TxCancel removed (0 otherwise). The cancelled
|
||||
// order's SETTLEMENT identity is NOT carried here: the custody unlock resolves
|
||||
// it exclusively through the orderuser: index by this order id (settle.go /
|
||||
// settleOrderEffects), never from the matcher's 8-byte in-RAM owner — so a
|
||||
// post-restart cancel unlocks to the SAME full account the lock lives under.
|
||||
Canceled uint64
|
||||
// CanceledOwner is the user identity of the cancelled order (so the custody
|
||||
// ledger unlocks its reserve to the right account). Empty unless Canceled != 0.
|
||||
CanceledOwner string
|
||||
// Touched are resting orders whose remaining changed due to a submit cross
|
||||
// (the makers); their rows must be rewritten/deleted.
|
||||
Touched []*lx.Order
|
||||
@@ -127,24 +128,20 @@ func applyPlace(book *lx.OrderBook, tx *Tx, height uint64, ts time.Time, txIndex
|
||||
|
||||
// applyCancel decodes a zapwire Cancel body and removes the resting order. A
|
||||
// cancel of an unknown order is a deterministic no-op (the matcher returns an
|
||||
// error which we map to an empty result — every validator sees the same).
|
||||
// error which we map to an empty result — every validator sees the same). The
|
||||
// cancelled order's owner is NOT captured here: the custody unlock resolves the
|
||||
// full settlement identity through the orderuser: index (settleOrderEffects), so
|
||||
// the matcher's 8-byte in-RAM owner never participates in a value move.
|
||||
func applyCancel(book *lx.OrderBook, tx *Tx) (applyResult, error) {
|
||||
orderID := binary.BigEndian.Uint64(tx.Body[zapwire.PoolIDSize : zapwire.PoolIDSize+8])
|
||||
// Snapshot the order before cancel so we know it existed (for the delta) and
|
||||
// capture its owner so the custody ledger unlocks the reserve to the right
|
||||
// account.
|
||||
o := book.GetOrder(orderID)
|
||||
if o == nil {
|
||||
// Snapshot the order before cancel so we know it existed (for the delta).
|
||||
if book.GetOrder(orderID) == nil {
|
||||
return applyResult{}, nil
|
||||
}
|
||||
owner := o.User
|
||||
if owner == "" {
|
||||
owner = o.UserID
|
||||
}
|
||||
if err := book.CancelOrder(orderID); err != nil {
|
||||
return applyResult{}, nil
|
||||
}
|
||||
return applyResult{Canceled: orderID, CanceledOwner: owner}, nil
|
||||
return applyResult{Canceled: orderID}, nil
|
||||
}
|
||||
|
||||
// applySubmit decodes a zapwire Submit body and crosses the book. The marketable
|
||||
|
||||
+15
-3
@@ -79,10 +79,22 @@ func (vm *VM) submitTx(ctx context.Context, t TxType, payload []byte) (txOutcome
|
||||
case o := <-ch:
|
||||
return o, nil
|
||||
case <-ctx.Done():
|
||||
// The caller gave up; drop the waiter so a later Accept does not leak it.
|
||||
// The tx may still be accepted (its effect is durable) — idempotency makes
|
||||
// a retry safe.
|
||||
// The caller gave up. Drop the waiter AND withdraw the tx from the mempool
|
||||
// (RED #4): a custody op (deposit/withdraw) whose ZAP submission timed out
|
||||
// must NOT silently commit in a LATER block — the EVM rolled back its vault
|
||||
// leg, so a stranded deposit would MINT and a stranded withdraw would
|
||||
// double-release. mempool.cancel removes it if still pending, or tombstones
|
||||
// it if already drained into an in-flight block, so it can never land later.
|
||||
// (For an order frame this is also correct: a submit the caller abandoned
|
||||
// should not rest/cross in a future block under the caller's account.)
|
||||
// Idempotency still makes an explicit RETRY of the same op safe — a retry
|
||||
// re-Adds a fresh tx (and, for custody, the same ref dedups it exactly once
|
||||
// if the original DID commit before the timeout).
|
||||
vm.outcomes.cancel(txID)
|
||||
// Stamp the tombstone with the height of the single in-flight block the tx
|
||||
// could be in (lastAcceptedHeight+1) so gcTombstones can reclaim it once that
|
||||
// height is accepted (R4). inFlightHeight reads the watermark under vm.mu.
|
||||
vm.mempool.cancel(txID, vm.inFlightHeight())
|
||||
return txOutcome{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build dchain && cgo
|
||||
// +build dchain,cgo
|
||||
//go:build !cgo
|
||||
// +build !cgo
|
||||
|
||||
// Built under //go:build !cgo: this venue e2e exercises the PURE-GO consensus
|
||||
// path (rebuildable book, zapdb persistence, ZAP socket, match->Verify->Accept)
|
||||
// and needs no cgo/GPU matcher — the GPU accelerator is an opt-in venue
|
||||
// accelerator (cmd/dvenue, //go:build cgo), and the matching CORRECTNESS proven
|
||||
// here is identical on the CPU and GPU paths. The !cgo gate also keeps this test
|
||||
// in the package's CGO_ENABLED=0 test binary (the only one that links on a host
|
||||
// without luxcpp), so `CGO_ENABLED=0 go test ./pkg/dchain` runs the full venue
|
||||
// e2e green. (Was `dchain && cgo`; the dchain tag gated a removed kludge and the
|
||||
// cgo tag wrongly excluded a pure-Go proof — decomplected to the real axis.)
|
||||
|
||||
package dchain
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dchain
|
||||
|
||||
// market_constraint_test.go pins the ZERO-NOTIONAL reject — the minimal
|
||||
// market-constraint invariant that arithmetic flooring can NEVER create a FREE
|
||||
// executable order. An order whose required lock floors to zero (a sub-tick BUY
|
||||
// price whose ceil(size*price) rounds to 0, or a sub-unit SELL size) would
|
||||
// rest/cross while reserving NOTHING; settlement could then move the
|
||||
// counterparty's asset for no spend. lockOrderSpend rejects such an order with
|
||||
// ErrDustPriceOrZeroLock (a deterministic per-tx reject), so it never rests and
|
||||
// never matches.
|
||||
//
|
||||
// PriceMultiplier is 1e8, so priceToInt(price) = price*1e8. A price < 1e-8 floors
|
||||
// to priceInt 0, and quoteUnitsCeil(units, 0) == 0 — the canonical sub-tick dust
|
||||
// BUY this gate must refuse.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/dex/pkg/zapwire"
|
||||
)
|
||||
|
||||
// dustPrice is a positive price strictly below one price tick (1e-8), so
|
||||
// priceToInt(dustPrice) == 0 and a BUY of any positive size floors to a zero
|
||||
// quote lock. It is a GENUINE executable price on the wire (>0), not a malformed
|
||||
// zero — exactly the "executable yet free" case the gate must catch.
|
||||
const dustPrice = 5e-9
|
||||
|
||||
// TestNotionalFlooringCannotCreateFreeExecutableOrder is the headline
|
||||
// market-constraint proof: an executable BUY whose notional floors to zero lock
|
||||
// (size>0, price>0, but ceil(size*price)==0) is REJECTED at placement — it never
|
||||
// rests, locks nothing, and a crossing taker cannot acquire the (non-existent)
|
||||
// liquidity for free. It also pins the cross axis: a sub-tick LIMIT BUY submit
|
||||
// against a real resting maker is rejected too, so flooring cannot acquire the
|
||||
// maker's base for no quote.
|
||||
func TestNotionalFlooringCannotCreateFreeExecutableOrder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
|
||||
const buyer, seller = "dust-buyer", "dust-seller"
|
||||
pool := [32]byte{0xd5, 0x70}
|
||||
|
||||
// Sanity on the predicate itself: a sub-tick BUY floors to zero lock; a normal
|
||||
// BUY does not.
|
||||
mc := marketConstraint{assetLUX, assetLUSD}
|
||||
if !mc.floorsToZeroLock(sideBuy, dustPrice, 10.0) {
|
||||
t.Fatalf("predicate: sub-tick BUY %g x10 should floor to zero lock", dustPrice)
|
||||
}
|
||||
if mc.floorsToZeroLock(sideBuy, 5.0, 10.0) {
|
||||
t.Fatal("predicate: a normal BUY 5x10 must NOT floor to zero lock")
|
||||
}
|
||||
|
||||
// Fund the buyer generously (so the reject is the NOTIONAL floor, never an
|
||||
// affordability reject) and open the custody market.
|
||||
addBlock(t, vm,
|
||||
depositTx(t, buyer, assetLUSD, 1_000_000),
|
||||
depositTx(t, seller, assetLUX, 100),
|
||||
openMarketTx(t, pool, assetLUX, assetLUSD),
|
||||
)
|
||||
|
||||
// PLACE a dust BUY (10 @ 5e-9): rejected, nothing locked, book empty.
|
||||
own := newOwnershipChecker(t, vm)
|
||||
blk := addBlock(t, vm, placePoolTx(t, pool, zapwire.SideBuy, dustPrice, 10.0, buyer))
|
||||
if oc := outcomeForTx(blk, blk.txs[0]); oc.status != zapwire.StatusRejected {
|
||||
t.Fatalf("dust BUY place status = %d, want StatusRejected (%d)", oc.status, zapwire.StatusRejected)
|
||||
}
|
||||
// No lock taken (the buyer's full quote stays available), and NO resting order.
|
||||
assertBalance(t, vm, buyer, assetLUSD, 1_000_000, 0)
|
||||
if orders, _, _, _, _ := vm.BookDepth(pool); orders != 0 {
|
||||
t.Fatalf("dust BUY rested: book has %d orders, want 0 (a zero-lock executable order must never rest)", orders)
|
||||
}
|
||||
// No value moved at all -> ownership trivially holds (no parties needed).
|
||||
own.assert(t, vm, blk, "dust-buy-place")
|
||||
|
||||
// Now a REAL maker rests (seller SELL 10 LUX @ 5), and a sub-tick LIMIT BUY
|
||||
// submit tries to cross it. The submit is rejected by the notional gate, so the
|
||||
// maker's base is NOT acquired for a floored-to-zero quote.
|
||||
addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, seller))
|
||||
assertBalance(t, vm, seller, assetLUX, 90, 10)
|
||||
|
||||
ownCross := newOwnershipChecker(t, vm)
|
||||
sblk := addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, dustPrice, 10.0, buyer))
|
||||
if oc := outcomeForTx(sblk, sblk.txs[0]); oc.status != zapwire.StatusRejected {
|
||||
t.Fatalf("dust LIMIT BUY submit status = %d, want StatusRejected", oc.status)
|
||||
}
|
||||
// Nothing was acquired or paid: buyer keeps all quote, seller keeps its locked
|
||||
// base. Flooring acquired NO free base.
|
||||
assertBalance(t, vm, buyer, assetLUSD, 1_000_000, 0)
|
||||
assertBalance(t, vm, buyer, assetLUX, 0, 0)
|
||||
assertBalance(t, vm, seller, assetLUX, 90, 10)
|
||||
ownCross.assert(t, vm, sblk, "dust-buy-cross")
|
||||
}
|
||||
|
||||
// TestLockAmtZeroBuyRejected is the focused mutation pin named by the acceptance
|
||||
// gate: a BUY with size>0 and price>0 whose required quote lock floors to zero is
|
||||
// rejected (ok=false) by lockOrderSpend with ErrDustPriceOrZeroLock — it cannot
|
||||
// rest with a zero lock. (Mutation: allowing lockAmt==0 BUY to pass makes this and
|
||||
// TestNotionalFlooringCannotCreateFreeExecutableOrder RED.)
|
||||
func TestLockAmtZeroBuyRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
|
||||
const buyer = "zlb-buyer"
|
||||
pool := [32]byte{0x21, 0xb0}
|
||||
addBlock(t, vm,
|
||||
depositTx(t, buyer, assetLUSD, 1_000_000),
|
||||
openMarketTx(t, pool, assetLUX, assetLUSD),
|
||||
)
|
||||
|
||||
// Drive lockOrderSpend directly through a built block's overlay so we observe
|
||||
// the named reject, not just the StatusRejected outcome.
|
||||
tx := placePoolTx(t, pool, zapwire.SideBuy, dustPrice, 10.0, buyer)
|
||||
blk := addBlock(t, vm, tx)
|
||||
if oc := outcomeForTx(blk, blk.txs[0]); oc.status != zapwire.StatusRejected {
|
||||
t.Fatalf("zero-lock BUY status = %d, want StatusRejected", oc.status)
|
||||
}
|
||||
// The buyer funded a million quote but the order never rested or locked.
|
||||
assertBalance(t, vm, buyer, assetLUSD, 1_000_000, 0)
|
||||
if orders, _, _, _, _ := vm.BookDepth(pool); orders != 0 {
|
||||
t.Fatalf("zero-lock BUY rested: %d orders, want 0", orders)
|
||||
}
|
||||
}
|
||||
+112
-4
@@ -7,6 +7,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/luxfi/consensus/engine/chain/block"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// mempool.go is the d-chain pending-transaction queue. ZAP write frames
|
||||
@@ -22,10 +23,34 @@ import (
|
||||
// deliberately simple: ordering is arrival order, which is the sequence the
|
||||
// matcher consumes. (A fee-priority mempool is a future concern; the venue's CLOB
|
||||
// is sequence-fair by design.)
|
||||
//
|
||||
// CANCEL / TOMBSTONE (synchronous-or-nothing custody, RED #4): a custody op that
|
||||
// the caller GAVE UP on (its ZAP submission timed out) must NOT silently commit in
|
||||
// a LATER block — the EVM rolled back its vault leg, so a stranded commit would
|
||||
// MINT (deposit) or double-release (withdraw). submitTx therefore calls cancel()
|
||||
// on ctx-timeout: if the tx is still pending it is removed outright; if it has
|
||||
// already been drained into an in-flight block it is TOMBSTONED so it is excluded
|
||||
// from any subsequent Drain (covering a rejected-block Requeue). Tombstones are a
|
||||
// PROPOSER-LOCAL block-CONSTRUCTION concern only — they decide what the proposer
|
||||
// puts INTO a block; they never affect how a validator VERIFIES a built block, so
|
||||
// they are consensus-neutral (a validator that never saw the cancelled tx is
|
||||
// unaffected; the one node that did simply won't re-propose it).
|
||||
type mempool struct {
|
||||
mu sync.Mutex
|
||||
txs []*Tx
|
||||
|
||||
// tombstones maps a cancelled tx id (cancelled AFTER it left the pending queue,
|
||||
// i.e. drained into an in-flight block) to the height of the in-flight block it
|
||||
// was drained into (lastAcceptedHeight+1 at cancel time — the proxy builds one
|
||||
// block at a time on this linear chain). A tombstoned id is filtered out of every
|
||||
// future Drain/Requeue so a cancelled custody tx can never land in a LATER block.
|
||||
// The stamped height lets gcTombstones reclaim a tombstone once that height has
|
||||
// been Accepted (the tx can no longer be in flight: either it committed in an
|
||||
// accepted block — seen: dedup covers any retry — or its block was rejected and
|
||||
// Requeue already dropped the tombstone). Lazily allocated; empty on the hot path
|
||||
// so order-frame draining pays nothing.
|
||||
tombstones map[ids.ID]uint64
|
||||
|
||||
// toEngine is the VM->engine notification channel from block.Init. Sending
|
||||
// PendingTxs tells the engine to build a block. nil before Initialize.
|
||||
toEngine chan<- block.Message
|
||||
@@ -65,8 +90,80 @@ func (m *mempool) Len() int {
|
||||
return len(m.txs)
|
||||
}
|
||||
|
||||
// cancel withdraws a tx the submitter gave up on (RED #4). If the tx is still
|
||||
// pending it is removed from the queue outright (the common timeout case: a
|
||||
// queued, not-yet-built tx — exactly the orphan the bug described). If it has
|
||||
// already been drained into an in-flight block it is TOMBSTONED (stamped with
|
||||
// inFlightHeight, the height of that block) so a later Drain (e.g. after that
|
||||
// block is rejected and Requeued) excludes it. Either way the cancelled tx can
|
||||
// never commit in a LATER block. Idempotent and lock-safe.
|
||||
func (m *mempool) cancel(txID ids.ID, inFlightHeight uint64) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for i, tx := range m.txs {
|
||||
if tx.ID() == txID {
|
||||
m.txs = append(m.txs[:i], m.txs[i+1:]...)
|
||||
if len(m.txs) == 0 {
|
||||
m.txs = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
// Not pending: already drained into a building block at inFlightHeight.
|
||||
// Tombstone it (stamped with that height) so it is dropped from any subsequent
|
||||
// Drain/Requeue and reclaimed by gcTombstones once the height is accepted.
|
||||
if m.tombstones == nil {
|
||||
m.tombstones = make(map[ids.ID]uint64)
|
||||
}
|
||||
m.tombstones[txID] = inFlightHeight
|
||||
}
|
||||
|
||||
// gcTombstones reclaims tombstones whose stamped in-flight height has been
|
||||
// accepted (height <= acceptedHeight): such a tx can no longer be in flight, so
|
||||
// its tombstone is dead weight. Called from Block.Accept after the VM's
|
||||
// lastAcceptedHeight advances. Bounds tombstone-map growth under repeated
|
||||
// timed-out-but-committed custody ops (RED #4 / R4). Hot path: the map is empty
|
||||
// when no custody op ever timed out, so this returns immediately. MUST be called
|
||||
// without holding any caller lock on the mempool (it takes m.mu itself).
|
||||
func (m *mempool) gcTombstones(acceptedHeight uint64) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if len(m.tombstones) == 0 {
|
||||
return
|
||||
}
|
||||
for id, h := range m.tombstones {
|
||||
if h <= acceptedHeight {
|
||||
delete(m.tombstones, id)
|
||||
}
|
||||
}
|
||||
if len(m.tombstones) == 0 {
|
||||
m.tombstones = nil
|
||||
}
|
||||
}
|
||||
|
||||
// dropTombstoned filters cancelled (tombstoned) txs out of a selected batch and
|
||||
// clears the tombstones it consumed. MUST be called under m.mu. On the hot path
|
||||
// (no tombstones) it returns the input untouched WITHOUT hashing any tx id, so
|
||||
// order-frame draining pays nothing for the custody-cancel machinery.
|
||||
func (m *mempool) dropTombstoned(txs []*Tx) []*Tx {
|
||||
if len(m.tombstones) == 0 {
|
||||
return txs
|
||||
}
|
||||
out := txs[:0]
|
||||
for _, tx := range txs {
|
||||
id := tx.ID()
|
||||
if _, dead := m.tombstones[id]; dead {
|
||||
delete(m.tombstones, id)
|
||||
continue
|
||||
}
|
||||
out = append(out, tx)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Drain removes and returns up to max pending txs in arrival order (all of them
|
||||
// if max <= 0). The returned slice is owned by the caller.
|
||||
// if max <= 0), excluding any tombstoned (cancelled) tx. The returned slice is
|
||||
// owned by the caller.
|
||||
func (m *mempool) Drain(max int) []*Tx {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -84,17 +181,28 @@ func (m *mempool) Drain(max int) []*Tx {
|
||||
if len(m.txs) == 0 {
|
||||
m.txs = nil
|
||||
}
|
||||
out = m.dropTombstoned(out)
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Requeue puts txs back at the FRONT of the mempool in their original order. Used
|
||||
// when a built block is rejected: its txs return to the pool to be reconsidered
|
||||
// in the next block, preserving arrival order.
|
||||
// Requeue puts txs back at the FRONT of the mempool in their original order,
|
||||
// dropping any that were tombstoned (cancelled) while the block was in flight.
|
||||
// Used when a built block is rejected: its (still-live) txs return to the pool to
|
||||
// be reconsidered in the next block, preserving arrival order — but a cancelled
|
||||
// custody tx does NOT come back (it must never commit in a later block).
|
||||
func (m *mempool) Requeue(txs []*Tx) {
|
||||
if len(txs) == 0 {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
txs = m.dropTombstoned(txs)
|
||||
if len(txs) == 0 {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.txs = append(txs, m.txs...)
|
||||
m.mu.Unlock()
|
||||
m.signal()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dchain
|
||||
|
||||
import "testing"
|
||||
|
||||
// mempool_gc_test.go pins R4: a tombstone (for a custody tx the submitter gave up
|
||||
// on AFTER it was drained into an in-flight block) is reclaimed once its stamped
|
||||
// in-flight height has been Accepted — such a tx can no longer be in flight (it
|
||||
// either committed in an accepted block, where seen: dedup covers any retry, or its
|
||||
// block was rejected and Requeue already dropped the tombstone). This bounds
|
||||
// tombstone-map growth under repeated timed-out-but-committed custody ops.
|
||||
|
||||
// TestMempoolTombstoneGCReclaimsAcceptedHeight: a tombstone stamped at in-flight
|
||||
// height H is dropped by gcTombstones(H). Observable: after GC the same tx, re-added
|
||||
// as pending, SURVIVES Drain (it is no longer filtered).
|
||||
func TestMempoolTombstoneGCReclaimsAcceptedHeight(t *testing.T) {
|
||||
m := newMempool(nil)
|
||||
tx := mustTx(t, TxDeposit, encodeDepositBody("gc", a32(1), 10, [32]byte{0x01}))
|
||||
|
||||
// Tombstone it as if drained into the in-flight block at height 7 (the not-pending
|
||||
// path: cancel on an empty queue tombstones rather than removing).
|
||||
m.cancel(tx.ID(), 7)
|
||||
m.mu.Lock()
|
||||
n := len(m.tombstones)
|
||||
m.mu.Unlock()
|
||||
if n != 1 {
|
||||
t.Fatalf("tombstones after cancel = %d, want 1", n)
|
||||
}
|
||||
|
||||
// Height 7 is now accepted -> the tombstone is dead weight and reclaimed.
|
||||
m.gcTombstones(7)
|
||||
m.mu.Lock()
|
||||
n = len(m.tombstones)
|
||||
m.mu.Unlock()
|
||||
if n != 0 {
|
||||
t.Fatalf("tombstones after gc(7) = %d, want 0 (reclaimed)", n)
|
||||
}
|
||||
|
||||
// Proof of behavior: the reclaimed tx, re-added, is NOT filtered out of Drain.
|
||||
m.Add(tx)
|
||||
got := m.Drain(0)
|
||||
if len(got) != 1 || got[0].ID() != tx.ID() {
|
||||
t.Fatalf("after GC the reclaimed tx must survive Drain; got %d txs", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMempoolTombstoneGCRetainsInFlightHeight: a tombstone stamped at in-flight
|
||||
// height H is RETAINED by gcTombstones(H-1) — the block at height H has NOT been
|
||||
// accepted, so the tx could still be in flight and the tombstone must persist.
|
||||
// Observable: the tx, re-added as pending, is still filtered out of Drain.
|
||||
func TestMempoolTombstoneGCRetainsInFlightHeight(t *testing.T) {
|
||||
m := newMempool(nil)
|
||||
tx := mustTx(t, TxDeposit, encodeDepositBody("keep", a32(2), 20, [32]byte{0x02}))
|
||||
|
||||
// Tombstone stamped at in-flight height 7.
|
||||
m.cancel(tx.ID(), 7)
|
||||
|
||||
// Only height 6 is accepted -> height 7 is still in flight -> tombstone retained.
|
||||
m.gcTombstones(6)
|
||||
m.mu.Lock()
|
||||
n := len(m.tombstones)
|
||||
m.mu.Unlock()
|
||||
if n != 1 {
|
||||
t.Fatalf("tombstones after gc(6) = %d, want 1 (retained — block 7 not yet accepted)", n)
|
||||
}
|
||||
|
||||
// Proof of behavior: the still-tombstoned tx, re-added, is filtered out of Drain
|
||||
// (it must never land in a later block while it could still be in flight).
|
||||
m.Add(tx)
|
||||
got := m.Drain(0)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("a retained-tombstone tx must be filtered from Drain; got %d txs", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMempoolGCTombstonesEmptyIsNoop: gcTombstones on an empty map (the hot path,
|
||||
// no custody op ever timed out) is a safe no-op.
|
||||
func TestMempoolGCTombstonesEmptyIsNoop(t *testing.T) {
|
||||
m := newMempool(nil)
|
||||
m.gcTombstones(100) // must not panic
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.tombstones != nil {
|
||||
t.Fatalf("gc on empty mempool allocated tombstones, want nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dchain
|
||||
|
||||
// ownership_completeness_test.go ADVERSARIALLY tests the ownership harness itself
|
||||
// (ownership_test.go) — the new safety net. A blind spot in the harness is false
|
||||
// confidence: a conserving theft the harness fails to flag would slip the gate.
|
||||
//
|
||||
// Strategy for each violation class:
|
||||
// 1. Drive a REAL block through the production path (addBlock / restart), so the
|
||||
// before-snapshot, the derived party set, and the real after-snapshot are all
|
||||
// exactly what the gate would see in the property/full-cycle suites.
|
||||
// 2. Assert the gate is CLEAN on the real (honest) after-state — no false flag.
|
||||
// 3. PLANT a value-conserving violation into a CLONE of the after-state (so the
|
||||
// live ledger is untouched) and assert findOwnershipViolation FIRES, naming
|
||||
// the expected non-party coordinate.
|
||||
//
|
||||
// All plants are value-conserving (Σ per asset unchanged) — exactly the class
|
||||
// conservation cannot see, so the only thing standing between it and theft is the
|
||||
// ownership gate firing. If ANY plant slips past, that is a HARNESS GAP.
|
||||
//
|
||||
// These tests reuse the SAME functions the gate uses (findOwnershipViolation,
|
||||
// partiesOfBlock via ownershipChecker.parties) so they exercise the real net, not
|
||||
// a re-implementation.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/dex/pkg/zapwire"
|
||||
)
|
||||
|
||||
// thirdParty is an account that is NEVER a party to any event in the planted
|
||||
// blocks — the canonical "value arrived at an account no event named" recipient.
|
||||
// Its bytes 8..15 are non-zero so it is also distinct from any 8-byte fold.
|
||||
var thirdParty = string([]byte{0x9e, 0x9e, 0x9e, 0x9e, 0x9e, 0x9e, 0x9e, 0x9e, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33})
|
||||
|
||||
// creditControlled adds amt to (user,asset) available in a snapshot WITHOUT a
|
||||
// matching debit — models value APPEARING at an account (a one-sided credit). Used
|
||||
// when the test asserts conservation is intentionally NOT the thing under test (a
|
||||
// pure ownership question about a single account).
|
||||
func creditControlled(snap map[acctAsset]balPair, user userKey, asset [32]byte, amt uint64) {
|
||||
k := acctAsset{user, asset}
|
||||
p := snap[k]
|
||||
p.available += amt
|
||||
snap[k] = p
|
||||
}
|
||||
|
||||
// assertFires asserts findOwnershipViolation FIRES for the given before/after over
|
||||
// the party set, and (when wantUser/wantAsset are non-nil) that it names the
|
||||
// expected coordinate. The harness is the net; this proves the net catches the fish.
|
||||
func assertFires(t *testing.T, before, after map[acctAsset]balPair, parties partySet, wantUser *userKey, wantAsset *[32]byte, what string) {
|
||||
t.Helper()
|
||||
k, delta, bad := findOwnershipViolation(before, after, parties)
|
||||
if !bad {
|
||||
t.Fatalf("%s: ownership gate FAILED to flag the violation (HARNESS GAP — a conserving theft would slip the gate)", what)
|
||||
}
|
||||
if wantUser != nil && k.user != *wantUser {
|
||||
t.Fatalf("%s: gate flagged user %x, want %x", what, k.user, *wantUser)
|
||||
}
|
||||
if wantAsset != nil && k.asset != *wantAsset {
|
||||
t.Fatalf("%s: gate flagged asset %x, want %x (delta %d)", what, k.asset, *wantAsset, delta)
|
||||
}
|
||||
t.Logf("%s: gate fired on %x/%x delta %d", what, k.user[:8], k.asset[:8], delta)
|
||||
}
|
||||
|
||||
// assertClean asserts findOwnershipViolation does NOT fire — the honest after-state
|
||||
// must pass (no false positive against real settlement).
|
||||
func assertClean(t *testing.T, before, after map[acctAsset]balPair, parties partySet, what string) {
|
||||
t.Helper()
|
||||
if k, delta, bad := findOwnershipViolation(before, after, parties); bad {
|
||||
t.Fatalf("%s: ownership gate FALSE-flagged honest settlement: %x/%x delta %d (over-strict gate)", what, k.user, k.asset, delta)
|
||||
}
|
||||
}
|
||||
|
||||
// uk is a local userKey16 alias to keep plants terse.
|
||||
func uk(s string) userKey { return userKey16(s) }
|
||||
|
||||
// restMakerCross is the common single-maker / single-taker cross fixture: victim
|
||||
// (maker) rests a SELL of size LUX @ price, a legit taker crosses it fully. Returns
|
||||
// the checker snapshotted BEFORE the cross, the cross block, and the maker/taker.
|
||||
// All callers then plant a conserving theft and assert the gate fires.
|
||||
func restMakerCross(t *testing.T, vm *VM, pool [32]byte, maker, taker string, price, size float64) (*ownershipChecker, *Block) {
|
||||
t.Helper()
|
||||
addBlock(t, vm,
|
||||
depositTx(t, maker, assetLUX, 1000),
|
||||
depositTx(t, taker, assetLUSD, 100000),
|
||||
openMarketTx(t, pool, assetLUX, assetLUSD),
|
||||
)
|
||||
addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, price, size, maker))
|
||||
oc := newOwnershipChecker(t, vm)
|
||||
blk := addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, price, size, taker))
|
||||
return oc, blk
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (B.1) maker proceeds routed to a WRONG / EXTRA account.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOwnershipHarness_MakerProceedsToWrongAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
const maker, taker = "b1-maker", "b1-taker"
|
||||
pool := [32]byte{0xb1}
|
||||
oc, blk := restMakerCross(t, vm, pool, maker, taker, 5.0, 10.0)
|
||||
|
||||
before := oc.before
|
||||
after := ownershipSnapshot(t, vm)
|
||||
parties := oc.parties(t, vm, blk)
|
||||
assertClean(t, before, after, parties, "honest cross")
|
||||
|
||||
// Plant: maker's 50-LUSD proceeds re-routed to thirdParty (a non-party). Conserving.
|
||||
vuln := cloneBalances(after)
|
||||
moveControlled(vuln, uk(maker), assetLUSD, uk(thirdParty), assetLUSD, 50)
|
||||
wantU, wantA := uk(thirdParty), assetLUSD
|
||||
assertFires(t, before, vuln, parties, &wantU, &wantA, "maker proceeds -> wrong account")
|
||||
}
|
||||
|
||||
// EXTRA account: maker keeps its proceeds AND thirdParty also receives a credit (a
|
||||
// one-sided extra credit — not conserving, but the ownership gate must still flag
|
||||
// the unexplained recipient regardless of conservation).
|
||||
func TestOwnershipHarness_MakerProceedsToExtraAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
const maker, taker = "b1e-maker", "b1e-taker"
|
||||
pool := [32]byte{0xb1, 0xee}
|
||||
oc, blk := restMakerCross(t, vm, pool, maker, taker, 5.0, 10.0)
|
||||
|
||||
before := oc.before
|
||||
after := ownershipSnapshot(t, vm)
|
||||
parties := oc.parties(t, vm, blk)
|
||||
|
||||
vuln := cloneBalances(after)
|
||||
creditControlled(vuln, uk(thirdParty), assetLUSD, 50) // extra, unexplained
|
||||
wantU, wantA := uk(thirdParty), assetLUSD
|
||||
assertFires(t, before, vuln, parties, &wantU, &wantA, "maker proceeds -> extra account")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (B.2) TAKER proceeds mis-routed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOwnershipHarness_TakerProceedsMisrouted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
const maker, taker = "b2-maker", "b2-taker"
|
||||
pool := [32]byte{0xb2}
|
||||
oc, blk := restMakerCross(t, vm, pool, maker, taker, 5.0, 10.0)
|
||||
|
||||
before := oc.before
|
||||
after := ownershipSnapshot(t, vm)
|
||||
parties := oc.parties(t, vm, blk)
|
||||
assertClean(t, before, after, parties, "honest cross")
|
||||
|
||||
// Plant: taker's 10-LUX proceeds re-routed to thirdParty. Conserving.
|
||||
vuln := cloneBalances(after)
|
||||
moveControlled(vuln, uk(taker), assetLUX, uk(thirdParty), assetLUX, 10)
|
||||
wantU, wantA := uk(thirdParty), assetLUX
|
||||
assertFires(t, before, vuln, parties, &wantU, &wantA, "taker proceeds -> wrong account")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (B.3) CANCEL / refund credited to a non-owner.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOwnershipHarness_CancelRefundToNonOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
const owner = "b3-owner"
|
||||
pool := [32]byte{0xb3}
|
||||
addBlock(t, vm,
|
||||
depositTx(t, owner, assetLUX, 1000),
|
||||
openMarketTx(t, pool, assetLUX, assetLUSD),
|
||||
)
|
||||
pblk := addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, owner))
|
||||
orderID := blockDeterministicID(pblk.height, 0)
|
||||
|
||||
oc := newOwnershipChecker(t, vm)
|
||||
cblk := addBlock(t, vm, cancelPoolTx(t, pool, orderID))
|
||||
|
||||
before := oc.before
|
||||
after := ownershipSnapshot(t, vm)
|
||||
parties := oc.parties(t, vm, cblk)
|
||||
assertClean(t, before, after, parties, "honest cancel (refund to owner)")
|
||||
|
||||
// The owner is a party (cancel resolves owner via orderuser:). Plant the refund
|
||||
// landing on thirdParty instead: thirdParty's controlled LUX rises, owner's falls.
|
||||
// Conserving. thirdParty is not the order's owner, so the gate must fire.
|
||||
vuln := cloneBalances(after)
|
||||
moveControlled(vuln, uk(owner), assetLUX, uk(thirdParty), assetLUX, 10)
|
||||
wantU, wantA := uk(thirdParty), assetLUX
|
||||
assertFires(t, before, vuln, parties, &wantU, &wantA, "cancel refund -> non-owner")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (B.4) WITHDRAW crediting a non-withdrawer.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOwnershipHarness_WithdrawCreditsNonWithdrawer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
const w = "b4-withdrawer"
|
||||
addBlock(t, vm, depositTx(t, w, assetLUX, 1000))
|
||||
|
||||
oc := newOwnershipChecker(t, vm)
|
||||
wblk := addBlock(t, vm, withdrawTx(t, w, assetLUX, 400))
|
||||
|
||||
before := oc.before
|
||||
after := ownershipSnapshot(t, vm)
|
||||
parties := oc.parties(t, vm, wblk)
|
||||
assertClean(t, before, after, parties, "honest withdraw")
|
||||
|
||||
// A withdraw is a one-sided DEBIT from the withdrawer (value leaves the ledger).
|
||||
// The withdrawer IS a party. Plant a credit to thirdParty (modeling a withdraw
|
||||
// that, instead of removing value, mis-credits a non-withdrawer). Gate must fire.
|
||||
vuln := cloneBalances(after)
|
||||
creditControlled(vuln, uk(thirdParty), assetLUX, 400)
|
||||
wantU, wantA := uk(thirdParty), assetLUX
|
||||
assertFires(t, before, vuln, parties, &wantU, &wantA, "withdraw credits non-withdrawer")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (B.5) A balance delta for an account with NO accepted event at all.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOwnershipHarness_DeltaForAccountWithNoEvent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
// A block that touches only maker+taker; thirdParty is in NO event.
|
||||
const maker, taker = "b5-maker", "b5-taker"
|
||||
pool := [32]byte{0xb5}
|
||||
oc, blk := restMakerCross(t, vm, pool, maker, taker, 5.0, 10.0)
|
||||
|
||||
before := oc.before
|
||||
after := ownershipSnapshot(t, vm)
|
||||
parties := oc.parties(t, vm, blk)
|
||||
|
||||
// Sanity: thirdParty is genuinely not a party in any asset.
|
||||
if parties.has(uk(thirdParty), assetLUX) || parties.has(uk(thirdParty), assetLUSD) {
|
||||
t.Fatal("test precondition broken: thirdParty wrongly in the party set")
|
||||
}
|
||||
|
||||
// Plant: a bare delta on thirdParty in BOTH assets (no event references it).
|
||||
vuln := cloneBalances(after)
|
||||
creditControlled(vuln, uk(thirdParty), assetLUX, 1)
|
||||
wantU, wantA := uk(thirdParty), assetLUX
|
||||
assertFires(t, before, vuln, parties, &wantU, &wantA, "delta for account with no event")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (B.6) A multi-maker fill where ONE maker is mis-attributed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestOwnershipHarness_MultiMakerOneMisattributed rests TWO makers at the same
|
||||
// price, then a single taker BUY consumes BOTH in one cross (two fills -> two trade
|
||||
// rows). The honest gate sees both makers + the taker as parties. Plant: maker2's
|
||||
// proceeds re-routed to thirdParty. The gate must fire even though maker1's leg is
|
||||
// honest and the taker's legs are honest — a single mis-attributed maker in a
|
||||
// multi-fill cross is exactly the subtle case.
|
||||
func TestOwnershipHarness_MultiMakerOneMisattributed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
const maker1, maker2, taker = "b6-m1", "b6-m2", "b6-tk"
|
||||
pool := [32]byte{0xb6}
|
||||
addBlock(t, vm,
|
||||
depositTx(t, maker1, assetLUX, 1000),
|
||||
depositTx(t, maker2, assetLUX, 1000),
|
||||
depositTx(t, taker, assetLUSD, 100000),
|
||||
openMarketTx(t, pool, assetLUX, assetLUSD),
|
||||
)
|
||||
// Two resting SELLs at the same price 5, 10 LUX each.
|
||||
addBlock(t, vm,
|
||||
placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, maker1),
|
||||
placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, maker2),
|
||||
)
|
||||
|
||||
oc := newOwnershipChecker(t, vm)
|
||||
// Taker buys 20 LUX @ 5 -> crosses BOTH makers (two fills).
|
||||
blk := addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 20.0, taker))
|
||||
|
||||
before := oc.before
|
||||
after := ownershipSnapshot(t, vm)
|
||||
parties := oc.parties(t, vm, blk)
|
||||
|
||||
// Both makers must be parties (resolved through the block's two trade rows).
|
||||
if !parties.has(uk(maker1), assetLUSD) || !parties.has(uk(maker2), assetLUSD) {
|
||||
t.Fatalf("multi-maker party set missing a maker: m1=%v m2=%v",
|
||||
parties.has(uk(maker1), assetLUSD), parties.has(uk(maker2), assetLUSD))
|
||||
}
|
||||
assertClean(t, before, after, parties, "honest multi-maker cross")
|
||||
|
||||
// Plant: maker2's 50-LUSD leg re-routed to thirdParty. Conserving.
|
||||
vuln := cloneBalances(after)
|
||||
moveControlled(vuln, uk(maker2), assetLUSD, uk(thirdParty), assetLUSD, 50)
|
||||
wantU, wantA := uk(thirdParty), assetLUSD
|
||||
assertFires(t, before, vuln, parties, &wantU, &wantA, "multi-maker: one maker mis-attributed")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (B.7) A partial-fill maker spanning MULTIPLE blocks (resting across a block) —
|
||||
// is it still resolved to the full party?
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestOwnershipHarness_PartialFillMakerAcrossBlocks rests a LARGE maker (50 LUX) in
|
||||
// block N, then a taker partially fills 10 LUX in block N+2 (maker still rests with
|
||||
// 40), then ANOTHER taker fills another 10 in block N+3. For the SECOND cross the
|
||||
// maker's orderuser: row was written TWO blocks earlier — the harness must resolve
|
||||
// the still-resting maker to its FULL account from the pre-block orderuser: snapshot
|
||||
// (not from this block's placements). We assert the gate fires when that second
|
||||
// cross's maker leg is mis-routed.
|
||||
func TestOwnershipHarness_PartialFillMakerAcrossBlocks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
maker := string([]byte{0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8})
|
||||
const t1, t2 = "b7-t1", "b7-t2"
|
||||
pool := [32]byte{0xb7}
|
||||
addBlock(t, vm,
|
||||
depositTx(t, maker, assetLUX, 1000),
|
||||
depositTx(t, t1, assetLUSD, 100000),
|
||||
depositTx(t, t2, assetLUSD, 100000),
|
||||
openMarketTx(t, pool, assetLUX, assetLUSD),
|
||||
)
|
||||
pblk := addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, 5.0, 50.0, maker))
|
||||
makerOrderID := blockDeterministicID(pblk.height, 0)
|
||||
|
||||
// First partial fill (10 of 50). Maker still rests with 40.
|
||||
addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, t1))
|
||||
// The maker still has an orderuser: row (resting), keyed under makerOrderID.
|
||||
if !hasOrderUserRow(t, vm, pool, makerOrderID) {
|
||||
t.Fatal("partially-filled maker lost its orderuser row (should still rest)")
|
||||
}
|
||||
|
||||
// SECOND cross, in a later block — the maker rested 2+ blocks ago.
|
||||
oc := newOwnershipChecker(t, vm)
|
||||
blk := addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, t2))
|
||||
|
||||
before := oc.before
|
||||
after := ownershipSnapshot(t, vm)
|
||||
parties := oc.parties(t, vm, blk)
|
||||
|
||||
// The maker (resting across blocks) MUST be resolved to its full account as a
|
||||
// party of this cross — from the pre-block orderuser: snapshot.
|
||||
if !parties.has(uk(maker), assetLUSD) {
|
||||
t.Fatal("HARNESS GAP: cross-block resting maker not resolved to a party (orderuser: pre-snapshot miss)")
|
||||
}
|
||||
// And the 8-byte fold of the maker is NOT separately admitted.
|
||||
folded := string([]byte{0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC})
|
||||
if parties.has(uk(folded), assetLUSD) {
|
||||
t.Fatal("party set wrongly admitted the maker's 8-byte fold as a distinct party")
|
||||
}
|
||||
assertClean(t, before, after, parties, "honest cross-block partial fill")
|
||||
|
||||
// Plant: this cross's maker leg (50 LUSD) re-routed to the 8-byte fold (the exact
|
||||
// theft a fold-settlement would produce). Conserving. The fold is NOT a party.
|
||||
vuln := cloneBalances(after)
|
||||
moveControlled(vuln, uk(maker), assetLUSD, uk(folded), assetLUSD, 50)
|
||||
wantU, wantA := uk(folded), assetLUSD
|
||||
assertFires(t, before, vuln, parties, &wantU, &wantA, "cross-block maker leg -> 8-byte fold")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (B.8) FEE attribution — does a fee skim leave an unexplained delta?
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestOwnershipHarness_NoFeeSkimAndFeeRecipientWouldFire documents the fee axis.
|
||||
// This ledger takes NO fee (settleFills moves maker<->taker with no skim; the
|
||||
// conservation property asserts F==0). So in the honest after-state there is no fee
|
||||
// recipient delta. We assert (a) the honest cross has no extra delta, and (b) IF a
|
||||
// fee were skimmed to a recipient that is not a party, the gate WOULD fire — i.e.
|
||||
// the harness does not have a fee blind spot (a fee recipient is not silently
|
||||
// admitted as a party). If a fee leg is ever added, its recipient must be made an
|
||||
// explicit party in partiesOfBlock or this gate fires.
|
||||
func TestOwnershipHarness_NoFeeSkimAndFeeRecipientWouldFire(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
const maker, taker = "b8-maker", "b8-taker"
|
||||
pool := [32]byte{0xb8}
|
||||
oc, blk := restMakerCross(t, vm, pool, maker, taker, 5.0, 10.0)
|
||||
|
||||
before := oc.before
|
||||
after := ownershipSnapshot(t, vm)
|
||||
parties := oc.parties(t, vm, blk)
|
||||
assertClean(t, before, after, parties, "honest cross (no fee)")
|
||||
|
||||
feeRecipient := string([]byte{0xfe, 0xe0, 0xfe, 0xe0, 0xfe, 0xe0, 0xfe, 0xe0, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08})
|
||||
if parties.has(uk(feeRecipient), assetLUSD) {
|
||||
t.Fatal("a fee recipient is wrongly already a party (harness over-permits)")
|
||||
}
|
||||
// Plant a fee skim: 1 LUSD diverted from the maker's proceeds to a fee recipient.
|
||||
// Conserving (maker 49, fee 1). The fee recipient is NOT a party -> gate fires.
|
||||
vuln := cloneBalances(after)
|
||||
moveControlled(vuln, uk(maker), assetLUSD, uk(feeRecipient), assetLUSD, 1)
|
||||
wantU, wantA := uk(feeRecipient), assetLUSD
|
||||
assertFires(t, before, vuln, parties, &wantU, &wantA, "fee skim to non-party recipient")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (B.9) SELF-TRADE (maker == taker) — does partiesOfBlock handle it?
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestOwnershipHarness_SelfTradeHandled rests a maker and then crosses it with a
|
||||
// submit from the SAME account. The matcher's self-trade prevention may reject the
|
||||
// cross outright; either way the harness must (a) not crash, (b) not false-flag the
|
||||
// honest outcome, and (c) still fire if value is diverted to a third party. We drive
|
||||
// the scenario and assert the gate behaves on whatever outcome the matcher produces,
|
||||
// then plant a third-party diversion sized to the maker's locked reserve and confirm
|
||||
// the gate fires.
|
||||
func TestOwnershipHarness_SelfTradeHandled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
self := string([]byte{0x5e, 0x1f, 0x5e, 0x1f, 0x5e, 0x1f, 0x5e, 0x1f, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8})
|
||||
pool := [32]byte{0xb9}
|
||||
// Self funds BOTH legs (base to rest a sell, quote to attempt the buy cross).
|
||||
addBlock(t, vm,
|
||||
depositTx(t, self, assetLUX, 1000),
|
||||
depositTx(t, self, assetLUSD, 100000),
|
||||
openMarketTx(t, pool, assetLUX, assetLUSD),
|
||||
)
|
||||
addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, self))
|
||||
|
||||
oc := newOwnershipChecker(t, vm)
|
||||
// Submit a BUY from the same account — self-trade. partiesOfBlock must handle the
|
||||
// taker==maker identity (the taker is added; the maker resolves to the same uid).
|
||||
blk := addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, self))
|
||||
|
||||
before := oc.before
|
||||
after := ownershipSnapshot(t, vm)
|
||||
parties := oc.parties(t, vm, blk) // must not panic on self-trade
|
||||
|
||||
// `self` is a party in both assets regardless of whether the cross filled or was
|
||||
// self-trade-rejected (it is the taker; and, if filled, also the maker).
|
||||
if !parties.has(uk(self), assetLUX) || !parties.has(uk(self), assetLUSD) {
|
||||
t.Fatalf("self-trade: self not a party in both assets (lux=%v lusd=%v)",
|
||||
parties.has(uk(self), assetLUX), parties.has(uk(self), assetLUSD))
|
||||
}
|
||||
assertClean(t, before, after, parties, "honest self-trade outcome")
|
||||
|
||||
// Plant: divert 1 LUX of self's controlled value to thirdParty. Conserving.
|
||||
// (self controls its LUX whether it filled-to-itself or the cross was rejected
|
||||
// and the lock refunded — either way the total is unchanged and self is a party.)
|
||||
vuln := cloneBalances(after)
|
||||
moveControlled(vuln, uk(self), assetLUX, uk(thirdParty), assetLUX, 1)
|
||||
wantU, wantA := uk(thirdParty), assetLUX
|
||||
assertFires(t, before, vuln, parties, &wantU, &wantA, "self-trade: value diverted to third party")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OVER-PERMIT direction: partiesOfBlock must NOT mark an account a party when it
|
||||
// isn't. A delta on a placed-order OWNER for an asset the place does NOT touch must
|
||||
// still be flagged.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestOwnershipHarness_DoesNotOverPermitPlaceOwner places a SELL (locks base=LUX)
|
||||
// from an owner. The place makes the owner a party for LUX only (the spend asset).
|
||||
// A planted delta on that same owner in QUOTE (LUSD) — an asset the place never
|
||||
// touches — must STILL be flagged: a place-owner is a party only for the asset it
|
||||
// locks, not a blanket pass for every asset.
|
||||
func TestOwnershipHarness_DoesNotOverPermitPlaceOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
const owner = "op-owner"
|
||||
pool := [32]byte{0xb0, 0x09}
|
||||
addBlock(t, vm,
|
||||
depositTx(t, owner, assetLUX, 1000),
|
||||
openMarketTx(t, pool, assetLUX, assetLUSD),
|
||||
)
|
||||
|
||||
oc := newOwnershipChecker(t, vm)
|
||||
blk := addBlock(t, vm, placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, owner))
|
||||
|
||||
before := oc.before
|
||||
after := ownershipSnapshot(t, vm)
|
||||
parties := oc.parties(t, vm, blk)
|
||||
assertClean(t, before, after, parties, "honest place")
|
||||
|
||||
// The owner is a party for LUX (the locked spend asset) ...
|
||||
if !parties.has(uk(owner), assetLUX) {
|
||||
t.Fatal("place owner not a party for the locked base asset")
|
||||
}
|
||||
// ... but NOT for LUSD (a SELL never touches quote).
|
||||
if parties.has(uk(owner), assetLUSD) {
|
||||
t.Fatal("HARNESS OVER-PERMIT: place SELL owner wrongly a party for the quote asset it never locks")
|
||||
}
|
||||
// Plant a delta on the owner in LUSD -> must fire (owner is not a quote party here).
|
||||
vuln := cloneBalances(after)
|
||||
creditControlled(vuln, uk(owner), assetLUSD, 7)
|
||||
wantU, wantA := uk(owner), assetLUSD
|
||||
assertFires(t, before, vuln, parties, &wantU, &wantA, "place SELL owner delta in untouched quote asset")
|
||||
}
|
||||
|
||||
// TestOwnershipHarness_DoesNotOverPermitUninvolvedAccountOnBusyBlock builds a block
|
||||
// with several places by DIFFERENT owners; an account that placed nothing and is in
|
||||
// no event gets a planted delta and must be flagged — the harness must not admit
|
||||
// "any account that appears anywhere" as a party.
|
||||
func TestOwnershipHarness_DoesNotOverPermitUninvolvedAccountOnBusyBlock(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
const a, b, c = "busy-a", "busy-b", "busy-c"
|
||||
pool := [32]byte{0xb0, 0x5b}
|
||||
addBlock(t, vm,
|
||||
depositTx(t, a, assetLUX, 1000),
|
||||
depositTx(t, b, assetLUX, 1000),
|
||||
depositTx(t, c, assetLUX, 1000),
|
||||
openMarketTx(t, pool, assetLUX, assetLUSD),
|
||||
)
|
||||
oc := newOwnershipChecker(t, vm)
|
||||
// Three places by a, b, c.
|
||||
blk := addBlock(t, vm,
|
||||
placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, a),
|
||||
placePoolTx(t, pool, zapwire.SideSell, 6.0, 10.0, b),
|
||||
placePoolTx(t, pool, zapwire.SideSell, 7.0, 10.0, c),
|
||||
)
|
||||
|
||||
before := oc.before
|
||||
after := ownershipSnapshot(t, vm)
|
||||
parties := oc.parties(t, vm, blk)
|
||||
assertClean(t, before, after, parties, "honest busy block")
|
||||
|
||||
// thirdParty placed nothing. A delta on it must fire.
|
||||
vuln := cloneBalances(after)
|
||||
creditControlled(vuln, uk(thirdParty), assetLUX, 3)
|
||||
wantU, wantA := uk(thirdParty), assetLUX
|
||||
assertFires(t, before, vuln, parties, &wantU, &wantA, "uninvolved account delta on busy block")
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dchain
|
||||
|
||||
// ownership_test.go is the OWNERSHIP INVARIANT harness — the assertion
|
||||
// conservation alone cannot make.
|
||||
//
|
||||
// Conservation proves no value is minted or burned: Σ balances is constant. But
|
||||
// the vault-drain / colliding-handle exploit class CONSERVES value while STEALING
|
||||
// ownership — it moves a victim's funds to an attacker who was never a party to
|
||||
// any accepted event. Σ balances is unchanged, so a conservation sweep stays
|
||||
// green while the wrong account ends up holding the money.
|
||||
//
|
||||
// The ownership invariant closes that gap. It is a statement about ATTRIBUTION,
|
||||
// not totals:
|
||||
//
|
||||
// For every (account, asset), any non-zero balance delta across an accepted
|
||||
// block MUST be explained by an accepted event in which that account is an
|
||||
// EXPLICIT PARTY.
|
||||
//
|
||||
// The explicit parties of an accepted block, by event:
|
||||
//
|
||||
// deposit -> the DEPOSITOR (the decoded deposit user), in the deposited asset.
|
||||
// withdraw -> the WITHDRAWER (the decoded withdraw user), in the withdrawn asset.
|
||||
// place -> the ORDER OWNER (the decoded place user): available -> locked in
|
||||
// the spend asset (a rested place), or a no-op (a rejected place).
|
||||
// cancel -> the ORDER OWNER (resolved via orderuser: by the cancelled id):
|
||||
// locked -> available in the order's reserved asset.
|
||||
// trade-fill -> the TAKER (the decoded submit user) AND each MAKER (resolved via
|
||||
// orderuser: by the fill's resting order id), in base AND quote.
|
||||
//
|
||||
// A non-zero delta for an (account, asset) that is NOT an explicit party of ANY
|
||||
// event in the block is an OWNERSHIP VIOLATION — value arrived at, or left, an
|
||||
// account that no accepted event authorized. That is exactly the theft the
|
||||
// colliding-handle bug would have produced, and exactly what conservation misses.
|
||||
//
|
||||
// The harness:
|
||||
// - snapshots EVERY per-(account,asset) (available, locked) row from the durable
|
||||
// ledger before and after a block (the same whole-keyspace sweep the
|
||||
// conservation property uses, so an attacker account outside any roster is
|
||||
// still seen),
|
||||
// - diffs them,
|
||||
// - derives the explicit-party set from the block's accepted events (its txs +
|
||||
// consensus outcomes), resolving maker/cancel identity through the SAME
|
||||
// orderuser: index settlement uses,
|
||||
// - and fails if any non-zero delta is unexplained.
|
||||
//
|
||||
// It runs ALONGSIDE conservation in the property and full-cycle tests, so every
|
||||
// randomized sequence now proves BOTH "no value created/destroyed" AND "no value
|
||||
// moved to a non-party".
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/dex/pkg/lx"
|
||||
"github.com/luxfi/dex/pkg/zapwire"
|
||||
)
|
||||
|
||||
// acctAsset is a single (account, asset) ledger coordinate — the FULL 16-byte
|
||||
// settlement identity and the FULL 32-byte asset id, the exact width the ledger
|
||||
// keys by. It is the unit the ownership invariant quantifies over.
|
||||
type acctAsset struct {
|
||||
user userKey
|
||||
asset [32]byte
|
||||
}
|
||||
|
||||
// balPair is an account's (available, locked) holdings for one asset. Ownership
|
||||
// reasons over the TOTAL the account controls (available + locked are both the
|
||||
// account's value — a lock is the account's own escrow), but keeps the split so a
|
||||
// place (available -> locked, same owner) is recognized as a within-owner move,
|
||||
// not two unrelated deltas.
|
||||
type balPair struct {
|
||||
available uint64
|
||||
locked uint64
|
||||
}
|
||||
|
||||
// total is the value the account controls for the asset — the quantity that must
|
||||
// not change for a NON-party. (available + locked: a locked reserve is still the
|
||||
// owner's value, escrowed by the owner's own order.)
|
||||
func (b balPair) total() uint64 { return b.available + b.locked }
|
||||
|
||||
// ownershipSnapshot sweeps the WHOLE durable ledger (every balance:/locked: row)
|
||||
// into a per-(account,asset) (available, locked) map, WITHOUT trusting any roster
|
||||
// of accounts. A delta into a derived/wrong account (the theft signature) shows
|
||||
// up here because the sweep totals the entire keyspace, exactly like the
|
||||
// conservation sweep. Must mirror that sweep's key discipline: keys are
|
||||
// <prefix><user:16><asset:32>.
|
||||
func ownershipSnapshot(t *testing.T, vm *VM) map[acctAsset]balPair {
|
||||
t.Helper()
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
out := map[acctAsset]balPair{}
|
||||
sweep := func(prefix string, set func(p *balPair, v uint64)) {
|
||||
it := vm.db.NewIteratorWithPrefix([]byte(prefix))
|
||||
defer it.Release()
|
||||
const userW = zapwire.UserSize
|
||||
for it.Next() {
|
||||
key := it.Key()
|
||||
if len(key) != len(prefix)+userW+32 {
|
||||
t.Fatalf("ownership sweep: %q key wrong width %d (want %d)", prefix, len(key), len(prefix)+userW+32)
|
||||
}
|
||||
val := it.Value()
|
||||
if len(val) != 8 {
|
||||
t.Fatalf("ownership sweep: %q value wrong width %d (want 8)", prefix, len(val))
|
||||
}
|
||||
var k acctAsset
|
||||
copy(k.user[:], key[len(prefix):len(prefix)+userW])
|
||||
copy(k.asset[:], key[len(prefix)+userW:len(prefix)+userW+32])
|
||||
p := out[k]
|
||||
set(&p, be64(val))
|
||||
out[k] = p
|
||||
}
|
||||
if err := it.Error(); err != nil {
|
||||
t.Fatalf("ownership sweep %q iterate: %v", prefix, err)
|
||||
}
|
||||
}
|
||||
sweep(prefixBalance, func(p *balPair, v uint64) { p.available = v })
|
||||
sweep(prefixLocked, func(p *balPair, v uint64) { p.locked = v })
|
||||
return out
|
||||
}
|
||||
|
||||
// partySet is the set of (account, asset) coordinates that the accepted events of
|
||||
// ONE block name as EXPLICIT PARTIES. A delta for a coordinate in this set is
|
||||
// authorized; a delta for a coordinate NOT in it is an ownership violation.
|
||||
type partySet map[acctAsset]struct{}
|
||||
|
||||
func (s partySet) add(user userKey, asset [32]byte) { s[acctAsset{user, asset}] = struct{}{} }
|
||||
|
||||
func (s partySet) has(user userKey, asset [32]byte) bool {
|
||||
_, ok := s[acctAsset{user, asset}]
|
||||
return ok
|
||||
}
|
||||
|
||||
// orderUserResolver resolves a resting order's FULL settlement identity to build
|
||||
// the maker/cancel party set, from the SAME source settlement uses: the
|
||||
// orderuser: index. It is seeded with a snapshot of every orderuser: row taken
|
||||
// BEFORE the block (so a maker that rested in a PRIOR block, and whose row is
|
||||
// deleted by a full fill in THIS block, is still resolvable), and augmented with
|
||||
// every TxPlace in the block (so a maker that rested EARLIER IN THIS SAME block —
|
||||
// whose row did not exist in the pre-snapshot — is resolvable too). This is the
|
||||
// honest mirror of how settlement itself finds the maker: never the 8-byte handle,
|
||||
// always the full 16-byte account keyed by (pool, orderID).
|
||||
type orderUserResolver struct {
|
||||
byOrder map[orderRef]userKey
|
||||
}
|
||||
|
||||
type orderRef struct {
|
||||
pool [32]byte
|
||||
orderID uint64
|
||||
}
|
||||
|
||||
// snapshotOrderUsers reads every orderuser: row into a resolver. The orderuser:
|
||||
// key is orderuser:<poolID:32><orderID:8> -> user[16].
|
||||
func snapshotOrderUsers(t *testing.T, vm *VM) *orderUserResolver {
|
||||
t.Helper()
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
r := &orderUserResolver{byOrder: map[orderRef]userKey{}}
|
||||
it := vm.db.NewIteratorWithPrefix([]byte(prefixOrderUser))
|
||||
defer it.Release()
|
||||
const prefW = len(prefixOrderUser)
|
||||
for it.Next() {
|
||||
key := it.Key()
|
||||
if len(key) != prefW+32+8 {
|
||||
t.Fatalf("orderuser sweep: key wrong width %d", len(key))
|
||||
}
|
||||
val := it.Value()
|
||||
if len(val) != zapwire.UserSize {
|
||||
t.Fatalf("orderuser sweep: value wrong width %d", len(val))
|
||||
}
|
||||
var ref orderRef
|
||||
copy(ref.pool[:], key[prefW:prefW+32])
|
||||
ref.orderID = beU64(key[prefW+32 : prefW+40])
|
||||
var u userKey
|
||||
copy(u[:], val)
|
||||
r.byOrder[ref] = u
|
||||
}
|
||||
if err := it.Error(); err != nil {
|
||||
t.Fatalf("orderuser sweep iterate: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *orderUserResolver) resolve(pool [32]byte, orderID uint64) (userKey, bool) {
|
||||
u, ok := r.byOrder[orderRef{pool, orderID}]
|
||||
return u, ok
|
||||
}
|
||||
|
||||
// beU64 reads an 8-byte big-endian uint64 from a key slice.
|
||||
func beU64(b []byte) uint64 {
|
||||
_ = b[7]
|
||||
return uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
|
||||
uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7])
|
||||
}
|
||||
|
||||
// partiesOfBlock derives the explicit-party set from a block's accepted events:
|
||||
// its txs (decoded for the named user / market) and its consensus outcomes (the
|
||||
// realized fills / placed-order ids). preOrderUsers resolves a maker/cancel order
|
||||
// id to its full account; the block's own TxPlaces are added to it first so a
|
||||
// same-block maker is resolvable. The set is the ONLY accounts any value is
|
||||
// authorized to move for in this block.
|
||||
func partiesOfBlock(t *testing.T, vm *VM, blk *Block, preOrderUsers *orderUserResolver) partySet {
|
||||
t.Helper()
|
||||
parties := partySet{}
|
||||
|
||||
// Augment the resolver with this block's own placements: orderID =
|
||||
// blockDeterministicID(height, txIndex), owner = the decoded place user. This
|
||||
// makes a maker that rests earlier in THIS block resolvable for a cross later
|
||||
// in the same block.
|
||||
res := &orderUserResolver{byOrder: map[orderRef]userKey{}}
|
||||
for k, v := range preOrderUsers.byOrder {
|
||||
res.byOrder[k] = v
|
||||
}
|
||||
for i, tx := range blk.txs {
|
||||
if tx.Type != TxPlace {
|
||||
continue
|
||||
}
|
||||
pool, _, _, _, user, err := zapwire.DecodePlace(tx.Body)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
res.byOrder[orderRef{pool, blockDeterministicID(blk.height, uint32(i))}] = userKey16(user)
|
||||
}
|
||||
|
||||
// Market (base,quote) bindings are needed to attribute a place/cancel's locked
|
||||
// asset and a fill's two legs. Read from committed state (post-accept).
|
||||
marketAssets := func(pool [32]byte) (base, quote [32]byte, ok bool) {
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
b, q, bound, err := readMarketAssets(vm.db, pool)
|
||||
if err != nil || !bound {
|
||||
return base, quote, false
|
||||
}
|
||||
return b, q, true
|
||||
}
|
||||
|
||||
for _, tx := range blk.txs {
|
||||
switch tx.Type {
|
||||
case TxDeposit:
|
||||
user, asset, _, _, err := zapwire.DecodeDeposit(tx.Body)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
parties.add(userKey16(user), asset) // depositor credited the asset
|
||||
|
||||
case TxWithdraw:
|
||||
user, asset, _, _, err := zapwire.DecodeWithdraw(tx.Body)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
parties.add(userKey16(user), asset) // withdrawer debited the asset
|
||||
|
||||
case TxPlace:
|
||||
pool, side, _, _, user, err := zapwire.DecodePlace(tx.Body)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
base, quote, ok := marketAssets(pool)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// A place locks (or refunds) the spend asset for its OWNER only: buy locks
|
||||
// quote, sell locks base. The owner is the only party to a place.
|
||||
uid := userKey16(user)
|
||||
if side == sideBuy {
|
||||
parties.add(uid, quote)
|
||||
} else {
|
||||
parties.add(uid, base)
|
||||
}
|
||||
|
||||
case TxCancel:
|
||||
pool, orderID, err := zapwire.DecodeCancel(tx.Body)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
base, quote, ok := marketAssets(pool)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// A cancel unlocks the order's reserve to its OWNER (resolved via
|
||||
// orderuser:). The owner is the only party; admit both assets so either
|
||||
// side's reserve is covered (a sell reserved base, a buy reserved quote).
|
||||
if owner, ok := res.resolve(pool, orderID); ok {
|
||||
parties.add(owner, base)
|
||||
parties.add(owner, quote)
|
||||
}
|
||||
|
||||
case TxSubmit:
|
||||
pool, _, _, _, _, user, err := zapwire.DecodeSubmit(tx.Body)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
base, quote, ok := marketAssets(pool)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// The TAKER (the submitter) is a party in BOTH assets: it spends one and
|
||||
// receives the other, and its unspent lock is refunded.
|
||||
taker := userKey16(user)
|
||||
parties.add(taker, base)
|
||||
parties.add(taker, quote)
|
||||
// Each MAKER of a fill this submit produced is a party in BOTH assets. The
|
||||
// makers are read from the block's PERSISTED trade rows (which carry the
|
||||
// exact MakerOrderID) and resolved to a full account through the SAME
|
||||
// orderuser: index settlement uses — NEVER the trade row's 8-byte
|
||||
// MakerUserID handle. This is the EXACT maker set (not a superset): only
|
||||
// the owner of the specific resting order a fill consumed is admitted, so
|
||||
// even an attacker who happens to hold an UNRELATED resting order on the
|
||||
// same pool is NOT credited another order's proceeds without tripping the
|
||||
// gate.
|
||||
for _, mid := range makerOrderIDsForSubmit(t, vm, blk, tx, pool) {
|
||||
if owner, ok := res.resolve(pool, mid); ok {
|
||||
parties.add(owner, base)
|
||||
parties.add(owner, quote)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parties
|
||||
}
|
||||
|
||||
// makerOrderIDsForSubmit returns the resting maker order ids the fills of THIS
|
||||
// submit consumed, read from the block's persisted trade rows. The submit's fill
|
||||
// count comes from its consensus outcome (snapshotted before Accept); the block's
|
||||
// trade:<height>:<seq> rows are appended in execution order, so a single-submit
|
||||
// block's fills are exactly the block's trade rows. For attribution this is the
|
||||
// authoritative maker set: each row's MakerOrderID names the exact resting order a
|
||||
// fill consumed.
|
||||
func makerOrderIDsForSubmit(t *testing.T, vm *VM, blk *Block, tx *Tx, pool [32]byte) []uint64 {
|
||||
t.Helper()
|
||||
// Only the fills attributed to this submit matter. In the property/full-cycle
|
||||
// tests a submit is the only matching tx in its block, so every trade row at
|
||||
// this height belongs to it; reading all rows at the height is exact. (A block
|
||||
// with multiple submits would need per-tx fill ranges; the harness asserts a
|
||||
// single matching tx per block via the count check below.)
|
||||
oc := outcomeForTx(blk, tx)
|
||||
want := len(oc.fills)
|
||||
if want == 0 {
|
||||
return nil
|
||||
}
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
prefix := make([]byte, len(prefixTrade)+8)
|
||||
copy(prefix, prefixTrade)
|
||||
for i := 0; i < 8; i++ {
|
||||
prefix[len(prefixTrade)+7-i] = byte(blk.height >> (8 * i))
|
||||
}
|
||||
it := vm.db.NewIteratorWithPrefix(prefix)
|
||||
defer it.Release()
|
||||
var ids []uint64
|
||||
for it.Next() {
|
||||
row, ok := lx.DecodeTrade(it.Value())
|
||||
if !ok {
|
||||
t.Fatalf("ownership: corrupt trade row at height %d", blk.height)
|
||||
}
|
||||
ids = append(ids, row.MakerOrderID)
|
||||
}
|
||||
if err := it.Error(); err != nil {
|
||||
t.Fatalf("ownership: trade iterate: %v", err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// outcomeForTx returns the consensus outcome the block derived for a given tx (by
|
||||
// tx id). The block's outcomes are set at Verify and snapshotted by the caller
|
||||
// BEFORE Accept clears them; this resolves a submit's fills for attribution.
|
||||
func outcomeForTx(blk *Block, tx *Tx) txOutcome {
|
||||
id := tx.ID()
|
||||
for _, o := range blk.outcomes {
|
||||
if o.txID == id {
|
||||
return o
|
||||
}
|
||||
}
|
||||
return txOutcome{}
|
||||
}
|
||||
|
||||
// findOwnershipViolation is the PURE ownership gate: it returns the first
|
||||
// (account, asset) whose controlled total CHANGED across the block while NOT
|
||||
// being an explicit party of the block's accepted events, with ok=true when such
|
||||
// a violation exists. This is the decomplected core — detection with no test
|
||||
// glue — so it can be asserted clean (assertOwnership) AND asserted to FIRE
|
||||
// against a simulated-vulnerable state (TestOwnershipInvariant_*).
|
||||
//
|
||||
// It checks the controlled TOTAL (available + locked): a place moves a party's
|
||||
// own available -> locked (total unchanged, owner is a party either way); a fill
|
||||
// moves value BETWEEN parties (both parties' totals change, both authorized); a
|
||||
// theft moves value to a NON-party (its total rises with no event naming it —
|
||||
// the violation this returns).
|
||||
func findOwnershipViolation(before, after map[acctAsset]balPair, parties partySet) (acctAsset, int64, bool) {
|
||||
seen := map[acctAsset]struct{}{}
|
||||
for k := range before {
|
||||
seen[k] = struct{}{}
|
||||
}
|
||||
for k := range after {
|
||||
seen[k] = struct{}{}
|
||||
}
|
||||
// Deterministic order so a failure names the same coordinate every run.
|
||||
keys := make([]acctAsset, 0, len(seen))
|
||||
for k := range seen {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sortAcctAssets(keys)
|
||||
for _, k := range keys {
|
||||
b := before[k]
|
||||
a := after[k]
|
||||
if a.total() == b.total() {
|
||||
continue // no change in controlled value -> nothing to authorize
|
||||
}
|
||||
if !parties.has(k.user, k.asset) {
|
||||
return k, int64(a.total()) - int64(b.total()), true
|
||||
}
|
||||
}
|
||||
return acctAsset{}, 0, false
|
||||
}
|
||||
|
||||
// sortAcctAssets imposes a total order on coordinates (user bytes then asset
|
||||
// bytes) so violation reporting is deterministic.
|
||||
func sortAcctAssets(ks []acctAsset) {
|
||||
less := func(x, y acctAsset) bool {
|
||||
for i := range x.user {
|
||||
if x.user[i] != y.user[i] {
|
||||
return x.user[i] < y.user[i]
|
||||
}
|
||||
}
|
||||
for i := range x.asset {
|
||||
if x.asset[i] != y.asset[i] {
|
||||
return x.asset[i] < y.asset[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for i := 1; i < len(ks); i++ {
|
||||
for j := i; j > 0 && less(ks[j], ks[j-1]); j-- {
|
||||
ks[j], ks[j-1] = ks[j-1], ks[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertOwnership is the ownership gate as a test assertion: it fails (Fatalf)
|
||||
// when findOwnershipViolation reports a non-party whose controlled value changed
|
||||
// — value that arrived at, or left, an account no accepted event authorized (the
|
||||
// theft conservation misses). `where` names the step for a precise failure.
|
||||
func assertOwnership(t *testing.T, before, after map[acctAsset]balPair, parties partySet, where string) {
|
||||
t.Helper()
|
||||
if k, delta, bad := findOwnershipViolation(before, after, parties); bad {
|
||||
b := before[k]
|
||||
a := after[k]
|
||||
t.Fatalf("%s: OWNERSHIP VIOLATED for account %x asset %x:\n"+
|
||||
" controlled total %d -> %d (delta %d)\n"+
|
||||
" but this account is NOT an explicit party to ANY accepted event in the block\n"+
|
||||
" (value moved to/from an account no deposit/withdraw/fill/cancel named — the theft conservation misses)",
|
||||
where, k.user, k.asset, b.total(), a.total(), delta)
|
||||
}
|
||||
}
|
||||
|
||||
// ownershipChecker bundles the pre-block snapshots so a test can assert ownership
|
||||
// over a block with one before/after pair. Usage:
|
||||
//
|
||||
// oc := newOwnershipChecker(t, vm) // snapshot before
|
||||
// blk := addBlock(t, vm, ...) // accept the block
|
||||
// oc.assert(t, vm, blk) // snapshot after + gate
|
||||
//
|
||||
// It is the reusable harness the property and full-cycle tests apply alongside
|
||||
// the conservation assertion.
|
||||
type ownershipChecker struct {
|
||||
before map[acctAsset]balPair
|
||||
orderUsers *orderUserResolver
|
||||
}
|
||||
|
||||
func newOwnershipChecker(t *testing.T, vm *VM) *ownershipChecker {
|
||||
t.Helper()
|
||||
return &ownershipChecker{
|
||||
before: ownershipSnapshot(t, vm),
|
||||
orderUsers: snapshotOrderUsers(t, vm),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ownershipChecker) assert(t *testing.T, vm *VM, blk *Block, where string) {
|
||||
t.Helper()
|
||||
after := ownershipSnapshot(t, vm)
|
||||
parties := partiesOfBlock(t, vm, blk, c.orderUsers)
|
||||
assertOwnership(t, c.before, after, parties, where)
|
||||
}
|
||||
|
||||
// parties returns the explicit-party set the harness derives for a block (so a
|
||||
// test can reuse the SAME set the gate uses against a simulated-vulnerable state).
|
||||
func (c *ownershipChecker) parties(t *testing.T, vm *VM, blk *Block) partySet {
|
||||
t.Helper()
|
||||
return partiesOfBlock(t, vm, blk, c.orderUsers)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// THE CENTERPIECE: the harness CATCHES the exact bug class conservation missed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestOwnershipInvariant_CollidingAttackerIsNotAParty is the proof that the
|
||||
// ownership gate catches the colliding-handle theft — the bug class a conservation
|
||||
// sweep cannot see (it CONSERVES value while STEALING ownership).
|
||||
//
|
||||
// It runs the colliding-8 scenario two ways against the SAME derived party set:
|
||||
//
|
||||
// 1. FIXED code (clean): a victim rests a maker; a distinct, legitimate taker
|
||||
// crosses it. Real settlement credits the maker leg to the VICTIM (resolved
|
||||
// via orderuser:). The harness sees only the victim (maker) and the taker
|
||||
// change value — both explicit parties — and reports NO violation. Crucially
|
||||
// the colliding ATTACKER (who shares the victim's 8-byte matcher handle but is
|
||||
// a DISTINCT full account) is NEVER credited.
|
||||
//
|
||||
// 2. SIMULATED-VULNERABLE (caught): we take the SAME before-snapshot and the SAME
|
||||
// party set the harness derived, then construct the after-state a vulnerable
|
||||
// 8-byte-fold settlement WOULD produce — the maker's 50-LUSD proceeds credited
|
||||
// to the colliding ATTACKER instead of the victim. The attacker shares the
|
||||
// victim's handle, so a fallback-to-handle settlement mis-routes the proceeds
|
||||
// to it. The attacker is NOT a party to the cross (neither the resting maker
|
||||
// nor the taker), so findOwnershipViolation FLAGS the attacker's unexplained
|
||||
// credit. CONSERVATION still holds in this vulnerable state (the 50 LUSD just
|
||||
// moved victim->attacker; Σ is unchanged) — which is exactly why conservation
|
||||
// misses it and ownership does not.
|
||||
func TestOwnershipInvariant_CollidingAttackerIsNotAParty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
vm, _ := newTestVM(t, memdb.New())
|
||||
defer vm.Shutdown(ctx)
|
||||
|
||||
victim, attacker := twoUsersSharingUserID8()
|
||||
// Precondition for the exploit class: same 8-byte matcher handle, distinct full
|
||||
// accounts.
|
||||
if userKey16(victim) == userKey16(attacker) {
|
||||
t.Fatal("test setup invalid: victim/attacker are the same full account")
|
||||
}
|
||||
const taker = "legit-taker"
|
||||
pool := [32]byte{0x0c, 0xa1}
|
||||
|
||||
// Victim rests a SELL of 10 LUX @ 5 (locks 10 LUX); a legitimate taker funds
|
||||
// 1000 LUSD to cross it. The attacker deposits NOTHING and never trades — it is
|
||||
// purely the colliding non-party the vulnerable fold would mis-credit.
|
||||
addBlock(t, vm,
|
||||
depositTx(t, victim, assetLUX, 100),
|
||||
depositTx(t, taker, assetLUSD, 1000),
|
||||
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)
|
||||
|
||||
// --- (1) FIXED code: the cross settles to the real parties; harness is clean. ---
|
||||
own := newOwnershipChecker(t, vm)
|
||||
crossBlk := addBlock(t, vm, submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, taker))
|
||||
|
||||
// Real settlement: victim (maker) received 50 LUSD + gave up 10 LUX; taker
|
||||
// received 10 LUX + spent 50 LUSD. The colliding attacker got NOTHING.
|
||||
assertBalance(t, vm, victim, assetLUX, 90, 0)
|
||||
assertBalance(t, vm, victim, assetLUSD, 50, 0)
|
||||
assertBalance(t, vm, taker, assetLUX, 10, 0)
|
||||
assertBalance(t, vm, taker, assetLUSD, 950, 0)
|
||||
assertBalance(t, vm, attacker, assetLUX, 0, 0)
|
||||
assertBalance(t, vm, attacker, assetLUSD, 0, 0)
|
||||
|
||||
before := own.before
|
||||
afterFixed := ownershipSnapshot(t, vm)
|
||||
parties := own.parties(t, vm, crossBlk)
|
||||
|
||||
// The party set names the victim (maker) and the taker — never the attacker.
|
||||
if parties.has(userKey16(attacker), assetLUSD) || parties.has(userKey16(attacker), assetLUX) {
|
||||
t.Fatal("party set wrongly includes the colliding attacker (it is neither maker nor taker)")
|
||||
}
|
||||
if !parties.has(userKey16(victim), assetLUSD) || !parties.has(userKey16(taker), assetLUX) {
|
||||
t.Fatal("party set missing a real party (victim maker / legit taker)")
|
||||
}
|
||||
|
||||
// On the FIXED state, the harness reports NO ownership violation.
|
||||
if k, delta, bad := findOwnershipViolation(before, afterFixed, parties); bad {
|
||||
t.Fatalf("ownership gate FALSE-flagged the fixed cross: account %x asset %x delta %d (all deltas are real parties)", k.user, k.asset, delta)
|
||||
}
|
||||
|
||||
// --- (2) SIMULATED-VULNERABLE: the 8-byte fold mis-credits the attacker. ---
|
||||
// Construct the after-state a fallback-to-handle settlement would produce: the
|
||||
// victim's 50-LUSD maker proceeds land on the colliding ATTACKER instead. We
|
||||
// mutate a COPY of the real after-state so production state is untouched.
|
||||
afterVuln := cloneBalances(afterFixed)
|
||||
const proceeds = 50
|
||||
moveControlled(afterVuln, userKey16(victim), assetLUSD, userKey16(attacker), assetLUSD, proceeds)
|
||||
|
||||
// CONSERVATION still holds in the vulnerable state (value only MOVED, none
|
||||
// minted/burned) — the precise reason a conservation sweep cannot catch this.
|
||||
if sumControlled(before, assetLUSD) != sumControlled(afterVuln, assetLUSD) {
|
||||
t.Fatal("test setup error: simulated theft was not value-conserving")
|
||||
}
|
||||
if sumControlled(before, assetLUX) != sumControlled(afterVuln, assetLUX) {
|
||||
t.Fatal("test setup error: simulated theft perturbed LUX")
|
||||
}
|
||||
|
||||
// OWNERSHIP catches it: the attacker's controlled value rose with no event
|
||||
// naming it as a party. This is the theft conservation missed.
|
||||
k, delta, bad := findOwnershipViolation(before, afterVuln, parties)
|
||||
if !bad {
|
||||
t.Fatal("ownership gate FAILED to flag the colliding-attacker theft (the whole point of the harness)")
|
||||
}
|
||||
if k.user != userKey16(attacker) || k.asset != assetLUSD {
|
||||
t.Fatalf("ownership gate flagged the wrong coordinate: %x/%x (want attacker/LUSD)", k.user, k.asset)
|
||||
}
|
||||
if delta != proceeds {
|
||||
t.Fatalf("flagged delta = %d, want +%d (the stolen maker proceeds)", delta, proceeds)
|
||||
}
|
||||
t.Logf("ownership gate flagged colliding-attacker theft: account %x gained %d LUSD as a NON-party", k.user[:8], delta)
|
||||
}
|
||||
|
||||
// cloneBalances deep-copies a per-(account,asset) balance snapshot so a test can
|
||||
// mutate it (model a vulnerable settlement) without touching the real snapshot.
|
||||
func cloneBalances(in map[acctAsset]balPair) map[acctAsset]balPair {
|
||||
out := make(map[acctAsset]balPair, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// moveControlled models a value move BETWEEN accounts in a snapshot: debit `amt`
|
||||
// of the controlled (available) balance from (fromUser,fromAsset) and credit it to
|
||||
// (toUser,toAsset). Used to construct the after-state a vulnerable settlement would
|
||||
// produce (e.g. maker proceeds mis-routed to a colliding handle).
|
||||
func moveControlled(snap map[acctAsset]balPair, fromUser userKey, fromAsset [32]byte, toUser userKey, toAsset [32]byte, amt uint64) {
|
||||
from := acctAsset{fromUser, fromAsset}
|
||||
to := acctAsset{toUser, toAsset}
|
||||
f := snap[from]
|
||||
f.available -= amt
|
||||
snap[from] = f
|
||||
tp := snap[to]
|
||||
tp.available += amt
|
||||
snap[to] = tp
|
||||
}
|
||||
|
||||
// sumControlled totals the controlled value (available + locked) for one asset
|
||||
// across every account in a snapshot — the conservation total, used here to prove
|
||||
// the simulated theft is value-conserving (so only ownership, not conservation,
|
||||
// can catch it).
|
||||
func sumControlled(snap map[acctAsset]balPair, asset [32]byte) uint64 {
|
||||
var sum uint64
|
||||
for k, v := range snap {
|
||||
if k.asset == asset {
|
||||
sum += v.total()
|
||||
}
|
||||
}
|
||||
return sum
|
||||
}
|
||||
+92
-34
@@ -4,7 +4,7 @@
|
||||
package dchain
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/luxfi/database"
|
||||
@@ -43,19 +43,6 @@ import (
|
||||
// never spend more than was locked. MARKET orders lock all available of the
|
||||
// spend asset; spend <= lock holds because you cannot spend what you do not have.
|
||||
|
||||
// userID8 folds a user-identity string to the 8-byte ledger/UserID handle,
|
||||
// byte-identically to lx's row encoding (big-endian over the leading 8 bytes,
|
||||
// zero-padded). The ledger key and the matcher's DEXTrade.MakerUserID/TakerUserID
|
||||
// MUST agree on this fold, so settlement credits the same account the trade
|
||||
// names. (lx's helper is unexported; replicating the trivial, canonical fold here
|
||||
// keeps the change inside the d-chain boundary. TestUserIDFoldMatchesLx pins the
|
||||
// parity.)
|
||||
func userID8(user string) uint64 {
|
||||
var b [8]byte
|
||||
copy(b[:], user)
|
||||
return binary.BigEndian.Uint64(b[:])
|
||||
}
|
||||
|
||||
// priceMultiplierBig mirrors lx.PriceMultiplier as a big.Int for exact lock math.
|
||||
var priceMultiplierBig = big.NewInt(lx.PriceMultiplier)
|
||||
|
||||
@@ -104,11 +91,12 @@ func priceToInt(price float64) lx.PriceInt {
|
||||
// orderLock returns the (asset, amount) a place/limit-submit of (side, price,
|
||||
// size) on a market with (base, quote) assets must lock. A buy locks quote =
|
||||
// ceil(size * price); a sell locks base = size. amount 0 means "nothing to lock"
|
||||
// (a degenerate order the matcher will reject anyway).
|
||||
func orderLock(side uint8, price float64, size float64, base, quote uint64) (asset, amount uint64) {
|
||||
// (a degenerate order the matcher will reject anyway). asset is the FULL 32-byte
|
||||
// id (base or quote), so the lock keys the SAME ledger row a deposit credited.
|
||||
func orderLock(side uint8, price float64, size float64, base, quote [32]byte) (asset [32]byte, amount uint64) {
|
||||
units := sizeToUnits(size)
|
||||
if units == 0 {
|
||||
return 0, 0
|
||||
return [32]byte{}, 0
|
||||
}
|
||||
if side == sideBuy {
|
||||
return quote, quoteUnitsCeil(new(big.Int).SetUint64(units), priceToInt(price))
|
||||
@@ -122,10 +110,48 @@ const (
|
||||
sideSell uint8 = 1
|
||||
)
|
||||
|
||||
// marketConstraint is the MINIMAL notional scaffold: just enough to express the
|
||||
// one constraint a value-bearing CLOB must enforce against integer flooring — an
|
||||
// executable order MUST reserve a non-zero lock. It deliberately does NOT model a
|
||||
// full fee/tick/lot/minNotional engine (that is a separate roadmap); the only
|
||||
// invariant here is "arithmetic flooring can never create a FREE executable order".
|
||||
//
|
||||
// The notional floor IS the lock: a BUY's notional is ceil(size*price) quote, a
|
||||
// SELL's is size base (orderLock). When that floors to zero for a positive-size,
|
||||
// positive-price executable order — a sub-tick price whose priceInt rounds to 0,
|
||||
// or a size that truncates below one atomic unit — the order would rest/cross while
|
||||
// reserving NOTHING. floorsToZeroLock is the single predicate that names that
|
||||
// condition; the placement gate rejects it with ErrDustPriceOrZeroLock.
|
||||
type marketConstraint struct {
|
||||
base, quote [32]byte
|
||||
}
|
||||
|
||||
// floorsToZeroLock reports whether an EXECUTABLE order (size>0, and for a limit
|
||||
// price>0) on this market would lock ZERO — the zero-notional reject condition.
|
||||
// It is exactly the negation of "orderLock yields a positive amount":
|
||||
//
|
||||
// BUY : size>0 && price>0 && ceil(size*price) == 0 (sub-tick price)
|
||||
// SELL : size>0 && size(base units) == 0 (sub-unit size)
|
||||
//
|
||||
// A non-executable order (size<=0, or a limit with price<=0) is NOT this hazard —
|
||||
// the matcher rejects it earlier as malformed; floorsToZeroLock returns false so
|
||||
// the named dust reject is reserved for the precise "executable yet free" case.
|
||||
func (mc marketConstraint) floorsToZeroLock(side uint8, price, size float64) bool {
|
||||
if !(size > 0) {
|
||||
return false // not executable (zero/negative size) — handled as malformed, not dust
|
||||
}
|
||||
if side == sideBuy && !(price > 0) {
|
||||
return false // a limit buy needs a price; price<=0 is malformed, not dust
|
||||
}
|
||||
_, lockAmt := orderLock(side, price, size, mc.base, mc.quote)
|
||||
return lockAmt == 0
|
||||
}
|
||||
|
||||
// creditDeposit applies a TxDeposit: available[user,asset] += amount. Returns the
|
||||
// amount credited (== amount; a deposit is never clamped).
|
||||
func creditDeposit(db database.KeyValueReaderWriterDeleter, user string, asset, amount uint64) (uint64, error) {
|
||||
if err := creditAvailable(db, userID8(user), asset, amount); err != nil {
|
||||
// amount credited (== amount; a deposit is never clamped). asset is the FULL
|
||||
// 32-byte injective id.
|
||||
func creditDeposit(db database.KeyValueReaderWriterDeleter, user string, asset [32]byte, amount uint64) (uint64, error) {
|
||||
if err := creditAvailable(db, userKey16(user), asset, amount); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return amount, nil
|
||||
@@ -135,9 +161,9 @@ func creditDeposit(db database.KeyValueReaderWriterDeleter, user string, asset,
|
||||
// available). Returns the REALIZED amount debited so the proxy exports exactly
|
||||
// what left the ledger (a withdraw can only export realized, un-escrowed
|
||||
// balance). Clamping to available means an over-withdraw exports only what is
|
||||
// there — never mints.
|
||||
func debitWithdraw(db database.KeyValueReaderWriterDeleter, user string, asset, want uint64) (uint64, error) {
|
||||
uid := userID8(user)
|
||||
// there — never mints. asset is the FULL 32-byte injective id.
|
||||
func debitWithdraw(db database.KeyValueReaderWriterDeleter, user string, asset [32]byte, want uint64) (uint64, error) {
|
||||
uid := userKey16(user)
|
||||
avail, err := getAvailable(db, uid, asset)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -175,7 +201,21 @@ func debitWithdraw(db database.KeyValueReaderWriterDeleter, user string, asset,
|
||||
// non-nil); a fill without it is a programming error on the consensus path (the
|
||||
// VM sets SizeUnits on every order) and is refused rather than silently
|
||||
// mis-settled.
|
||||
func settleFills(db database.KeyValueReaderWriterDeleter, takerSide uint8, base, quote uint64, fills []lx.Trade) (takerSpent uint64, err error) {
|
||||
//
|
||||
// IDENTITY: the settlement identity is the WALLET (AccountID); UserID8 is only a
|
||||
// matcher hint. The TAKER is the submitter, whose full 16-byte wallet is carried on
|
||||
// the ephemeral order this block from the decoded clob_submit invocation context
|
||||
// (never the 8-byte row handle — the taker never rests, so its full wallet is always
|
||||
// in hand). The MAKER is a RESTING order whose in-RAM user is only 8 bytes after a
|
||||
// restart (RowToOrder folds the GPU row's 8-byte UserID handle), so its full wallet
|
||||
// is resolved EXCLUSIVELY through the orderuser: index by the maker's order id —
|
||||
// there is NO fallback to the trade's 8-byte-derived carried user. This guarantees
|
||||
// a maker fill credits the SAME full 16-byte wallet the maker's lock lives under,
|
||||
// live or post-restart.
|
||||
//
|
||||
// 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.
|
||||
func settleFills(db database.KeyValueReaderWriterDeleter, poolID [32]byte, takerSide uint8, base, quote [32]byte, fills []lx.Trade) (takerSpent uint64, err error) {
|
||||
for _, f := range fills {
|
||||
if f.BaseUnits == nil || f.QuoteUnits == nil {
|
||||
return 0, ErrFillMissingUnits
|
||||
@@ -185,8 +225,11 @@ func settleFills(db database.KeyValueReaderWriterDeleter, takerSide uint8, base,
|
||||
}
|
||||
baseUnits := f.BaseUnits.Uint64()
|
||||
quoteUnits := f.QuoteUnits.Uint64()
|
||||
makerID := userID8(makerUserID(f, takerSide))
|
||||
takerID := userID8(takerUserID(f, takerSide))
|
||||
makerID, merr := makerSettleKey(db, poolID, f, takerSide)
|
||||
if merr != nil {
|
||||
return 0, merr
|
||||
}
|
||||
takerID := userKey16(takerUserID(f, takerSide))
|
||||
|
||||
if takerSide == sideBuy {
|
||||
// Taker pays quote (from its locked quote) -> maker available quote.
|
||||
@@ -225,15 +268,30 @@ func settleFills(db database.KeyValueReaderWriterDeleter, takerSide uint8, base,
|
||||
return takerSpent, nil
|
||||
}
|
||||
|
||||
// makerUserID returns the resting (maker) user string for a fill given the taker
|
||||
// side. If the taker bought, the maker sold (SellUserID); else the maker bought
|
||||
// (BuyUserID). Mirrors lx.persist.makerUser so the settled account matches the
|
||||
// trade row.
|
||||
func makerUserID(f lx.Trade, takerSide uint8) string {
|
||||
if takerSide == sideBuy {
|
||||
return pickUserID(f.SellUserID, f.Seller)
|
||||
// makerSettleKey resolves a fill's MAKER (the resting order) to its full 16-byte
|
||||
// settlement identity. The ONLY source is the orderuser: state row keyed by the
|
||||
// maker's resting order id — which survives a restart, unlike the in-RAM maker user
|
||||
// string (8-byte after RowToOrder). There is NO fallback: a fill against a resting
|
||||
// order with no orderuser: row FAILS CLOSED with ErrMissingSettlementUser (the block
|
||||
// is rejected, no value moves). Every order that rests on a balance-bearing market
|
||||
// persists its orderuser: row BEFORE it is matchable (lockOrderSpend), so a missing
|
||||
// row at settle time can only be state corruption — never a normal path.
|
||||
//
|
||||
// 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.
|
||||
func makerSettleKey(db database.KeyValueReader, poolID [32]byte, f lx.Trade, takerSide uint8) (userKey, error) {
|
||||
makerOrderID := f.SellOrder // taker bought -> maker sold
|
||||
if takerSide == sideSell {
|
||||
makerOrderID = f.BuyOrder // taker sold -> maker bought
|
||||
}
|
||||
return pickUserID(f.BuyUserID, f.Buyer)
|
||||
u, ok, err := getOrderUser(db, poolID, makerOrderID)
|
||||
if err != nil {
|
||||
return userKey{}, err
|
||||
}
|
||||
if !ok {
|
||||
return userKey{}, fmt.Errorf("%w: maker order %d in market %x", ErrMissingSettlementUser, makerOrderID, poolID[:8])
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// takerUserID returns the taker user string for a fill given the taker side.
|
||||
|
||||
@@ -4,23 +4,96 @@
|
||||
package dchain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/dex/pkg/lx"
|
||||
"github.com/luxfi/dex/pkg/zapwire"
|
||||
)
|
||||
|
||||
// TestUserIDFoldMatchesLx pins that the d-chain ledger's user->8-byte fold
|
||||
// (userID8) is byte-identical to the fold lx uses for DEXTrade/DEXOrder UserIDs
|
||||
// (via OrderToRow). If these diverged, the ledger would credit/debit a different
|
||||
// account than the trade names — a value-conservation break.
|
||||
// TestUserIDFoldMatchesLx pins the DECOMPLECTED identity split that the custody
|
||||
// model rests on:
|
||||
//
|
||||
// - The matcher's order-book ROW (lx.DEXOrder.UserID) stays the 8-byte STP
|
||||
// handle (the frozen GPU ABI). lx.OrderToRow folds the user to the leading 8
|
||||
// bytes; that is UNCHANGED and is sufficient for self-trade prevention and
|
||||
// price-time rebuild.
|
||||
// - The d-chain SETTLEMENT identity (userKey16) is the FULL 16-byte wire user
|
||||
// (zapwire.UserSize) — the value balance:/locked: key by. It is byte-identical
|
||||
// to the wire's null-padded user[16] field, so the ledger credits the SAME
|
||||
// account the orders/deposits name.
|
||||
//
|
||||
// The two are deliberately DIFFERENT widths: the row models matching, d-chain
|
||||
// state models settlement identity. This test pins both so neither silently
|
||||
// regresses (a widened row would break the GPU ABI; a narrowed ledger identity
|
||||
// would reopen the cross-user drain).
|
||||
func TestUserIDFoldMatchesLx(t *testing.T) {
|
||||
for _, u := range []string{"", "a", "maker-a", "taker-x-very-long-name-overflow"} {
|
||||
// lx's fold is exercised through OrderToRow's UserID.
|
||||
// The matcher ROW handle is the 8-byte fold (frozen GPU ABI / STP).
|
||||
row := lx.OrderToRow(&lx.Order{ID: 1, User: u, UserID: u, Size: 1, Price: 1, Side: lx.Buy})
|
||||
if got, want := userID8(u), row.UserID; got != want {
|
||||
t.Errorf("userID8(%q) = %#x, lx OrderToRow.UserID = %#x (folds diverged)", u, got, want)
|
||||
var want8 [8]byte
|
||||
copy(want8[:], u)
|
||||
if got, want := row.UserID, binary.BigEndian.Uint64(want8[:]); got != want {
|
||||
t.Errorf("ROW handle: lx OrderToRow.UserID(%q) = %#x, want 8-byte fold %#x", u, got, want)
|
||||
}
|
||||
|
||||
// The d-chain SETTLEMENT identity is the FULL 16-byte wire user.
|
||||
var wantWire [zapwire.UserSize]byte
|
||||
copy(wantWire[:], u)
|
||||
if got := userKey16(u); got != wantWire {
|
||||
t.Errorf("LEDGER identity: userKey16(%q) = %x, want full wire user[16] %x", u, got, wantWire)
|
||||
}
|
||||
if len(userKey16(u)) != zapwire.UserSize {
|
||||
t.Errorf("LEDGER identity width = %d, want %d (zapwire.UserSize)", len(userKey16(u)), zapwire.UserSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLedgerUserKeyNoCollision is the PERMANENT REGRESSION for CRITICAL-1
|
||||
// (cross-user native drain). It tests the REAL production derivation — userKey16
|
||||
// folded into balanceLedgerKey — not a test double. Two addresses that SHARE their
|
||||
// leading 8 bytes but differ in bytes 8..15 MUST map to DISTINCT ledger accounts,
|
||||
// so the second can never read/withdraw the first's balance.
|
||||
//
|
||||
// FAIL-on-old -> PASS-on-fix: the deleted 8-byte fold (userID8) produced an
|
||||
// IDENTICAL uint64 for both addresses, so their balance keys collided — the drain.
|
||||
// The 16-byte userKey16 keeps every byte, so the keys differ. The test asserts the
|
||||
// keys differ AND demonstrates the old 8-byte fold would have collided.
|
||||
func TestLedgerUserKeyNoCollision(t *testing.T) {
|
||||
// Two 16-byte users sharing the leading 8 bytes, differing in bytes 8..15 —
|
||||
// exactly the red PoC's "colliding address" shape.
|
||||
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})
|
||||
|
||||
var asset [32]byte // native (ids.Empty) — the asset the PoC drains
|
||||
for i := range asset {
|
||||
asset[i] = 0
|
||||
}
|
||||
|
||||
vKey := userKey16(victim)
|
||||
aKey := userKey16(attacker)
|
||||
if vKey == aKey {
|
||||
t.Fatalf("userKey16 collided for leading-8-shared users: %x == %x (CRITICAL-1 regressed)", vKey, aKey)
|
||||
}
|
||||
|
||||
vLedger := balanceLedgerKey(prefixBalance, vKey, asset)
|
||||
aLedger := balanceLedgerKey(prefixBalance, aKey, asset)
|
||||
if bytes.Equal(vLedger, aLedger) {
|
||||
t.Fatalf("balance ledger keys collided for distinct users:\n victim = %x\n attacker = %x\n(CRITICAL-1: attacker would read victim's balance)", vLedger, aLedger)
|
||||
}
|
||||
|
||||
// Demonstrate the DELETED 8-byte fold WOULD have collided (the bug we removed):
|
||||
// folding either user to its leading 8 bytes yields the SAME uint64, so the
|
||||
// pre-fix balance:<user:8> keys were identical.
|
||||
old8 := func(u string) uint64 {
|
||||
var b [8]byte
|
||||
copy(b[:], u)
|
||||
return binary.BigEndian.Uint64(b[:])
|
||||
}
|
||||
if old8(victim) != old8(attacker) {
|
||||
t.Fatalf("test setup invalid: the two users do not share leading 8 bytes (old fold %#x != %#x)", old8(victim), old8(attacker))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
// 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)
|
||||
}
|
||||
+180
-55
@@ -51,14 +51,31 @@ const (
|
||||
// because every unit a fill removes from one account's locked is added to
|
||||
// another account's available (no float, exact integer units).
|
||||
//
|
||||
// balance:<user:8><asset:8> -> uint64 AVAILABLE (deposited, un-escrowed)
|
||||
// locked:<user:8><asset:8> -> uint64 LOCKED (reserved by live orders)
|
||||
// asset:<poolID:32> -> base[8]quote[8] market's (base,quote) handles
|
||||
// orderasset:<poolID:32><orderID:8> -> assetID[8]|amount[8] per-order reserve
|
||||
prefixBalance = "balance:"
|
||||
prefixLocked = "locked:"
|
||||
prefixAsset = "asset:" // market (base,quote) asset binding
|
||||
prefixOrderLock = "orderasset:" // per-resting-order locked (asset,amount) for unlock on cancel/fill
|
||||
// balance:<user:16><asset:32> -> uint64 AVAILABLE (deposited, un-escrowed)
|
||||
// locked:<user:16><asset:32> -> uint64 LOCKED (reserved by live orders)
|
||||
// asset:<poolID:32> -> base[32]quote[32] market's (base,quote) ids
|
||||
// orderasset:<poolID:32><orderID:8> -> assetID[32]|amount[8] per-order reserve
|
||||
// orderuser:<poolID:32><orderID:8> -> user[16] resting order's FULL identity
|
||||
//
|
||||
// BOTH halves of a ledger key are kept at FULL injective width:
|
||||
// - The asset is the FULL 32-byte injective id (zapwire.AssetIDSize), NOT a
|
||||
// truncated handle: keying a value-bearing ledger by a truncated asset is
|
||||
// unsound (a token whose id folds to the native key could mint a native
|
||||
// claim and drain the native vault; two tokens sharing the truncated prefix
|
||||
// would share balances).
|
||||
// - The user is the FULL 16-byte injective wire identity (zapwire.UserSize),
|
||||
// NOT a folded 8-byte handle: keying the ledger by a truncated user is the
|
||||
// SAME unsoundness on the account axis — two addresses sharing a leading
|
||||
// 8-byte prefix would share balances, letting one withdraw the other's
|
||||
// deposit (the cross-user native-drain fix). The matcher's DEXOrder.UserID
|
||||
// stays an 8-byte STP handle (the frozen GPU ABI); the SETTLEMENT identity
|
||||
// that the ledger keys by is this full 16-byte user, decomplected from the
|
||||
// matching handle exactly as pkg/lx/persist.go declares.
|
||||
prefixBalance = "balance:"
|
||||
prefixLocked = "locked:"
|
||||
prefixAsset = "asset:" // market (base,quote) asset binding
|
||||
prefixOrderLock = "orderasset:" // per-resting-order locked (asset,amount) for unlock on cancel/fill
|
||||
prefixOrderUser = "orderuser:" // per-resting-order FULL 16-byte settlement identity
|
||||
|
||||
metaLastAccepted = prefixMeta + "lastAccepted"
|
||||
metaHeight = prefixMeta + "height"
|
||||
@@ -258,15 +275,62 @@ func writeBookWatermark(db database.KeyValueWriter, poolID [32]byte, watermark u
|
||||
// unit.
|
||||
// =========================================================================
|
||||
|
||||
// balanceLedgerKey builds <prefix><user:8><asset:8>. The user identity is the
|
||||
// d-chain's 8-byte UserID handle (lx.userToUint64) and the asset is the 8-byte
|
||||
// asset handle — one consistent identity width across the ledger and the GPU row
|
||||
// ABI.
|
||||
func balanceLedgerKey(prefix string, user, asset uint64) []byte {
|
||||
k := make([]byte, len(prefix)+16)
|
||||
// userKey is the d-chain's SETTLEMENT identity: the FULL 16-byte wire user
|
||||
// (zapwire.UserSize), the value the ledger keys an account by. It is DISTINCT from
|
||||
// the matcher's 8-byte DEXOrder.UserID STP handle — the book row models matching,
|
||||
// d-chain state models settlement identity (pkg/lx/persist.go). 16 bytes makes the
|
||||
// account axis birthday-safe (2^64) and, crucially, makes two addresses sharing a
|
||||
// leading 8-byte prefix DISTINCT accounts so neither can withdraw the other's
|
||||
// balance.
|
||||
//
|
||||
// The settlement identity is the WALLET (AccountID); UserID8 is only a matcher
|
||||
// hint. The full account identity resolved via orderuser: IS the wallet that bears
|
||||
// value; the matcher's 8-byte UserID is purely a matching/STP collision hint and
|
||||
// is NEVER authoritative for settlement (see the AccountID32 migration target on
|
||||
// userKey16 below).
|
||||
type userKey = [zapwire.UserSize]byte
|
||||
|
||||
// userKey16 renders a user-identity string to the canonical 16-byte ledger
|
||||
// identity: the leading 16 bytes, zero-padded — byte-identical to the wire's
|
||||
// user[16] field (zapwire null-padding) so the account the ledger credits is the
|
||||
// SAME account the orders/deposits name. This is the ONE fold from a user string
|
||||
// to the settlement identity; there is no 8-byte fold anywhere on the value path.
|
||||
//
|
||||
// TODO(accountid32): the settlement identity stays the full 16-byte wire user
|
||||
// NOW. The target derivation is a domain-separated 32-byte account id:
|
||||
//
|
||||
// AccountID32 = H("lux.account.v1", networkID, sourceChainID, addressKind, fullAddressBytes)
|
||||
// UserID8 = first8(H("lux.matcher.user.v1", AccountID)) // matcher/STP handle
|
||||
//
|
||||
// UserID8 may collide (birthday over 2^64) — acceptable ONLY because it is NEVER
|
||||
// authoritative for value (it is a compact STP handle; settlement resolves through
|
||||
// orderuser: to the full AccountID). The 16-byte userKey is birthday-safe at 2^64
|
||||
// for the account axis; AccountID32 widens that to 2^128 and binds the network +
|
||||
// source chain + address kind so identities are globally unique across chains. Do
|
||||
// NOT implement the 32-byte form here yet — this records the migration target.
|
||||
//
|
||||
// NAMING: the settlement identity IS the wallet (AccountID); UserID8 is only a
|
||||
// matcher hint. This pass keeps the field name `UserID` (the frozen GPU ABI handle)
|
||||
// and clarifies the wallet/AccountID framing here in the comment + the AccountID32
|
||||
// TODO above; the rename to an explicit AccountID field is deferred to the
|
||||
// AccountID32 migration (a separate roadmap).
|
||||
func userKey16(user string) userKey {
|
||||
var k userKey
|
||||
copy(k[:], user)
|
||||
return k
|
||||
}
|
||||
|
||||
// balanceLedgerKey builds <prefix><user:16><asset:32>. BOTH the user (the full
|
||||
// 16-byte injective wire identity, zapwire.UserSize) and the asset (the full
|
||||
// 32-byte injective asset id, zapwire.AssetIDSize) are kept at full width, so no
|
||||
// two distinct accounts and no two distinct assets ever collide on a balance key
|
||||
// (the cross-user native-drain fix on the account axis; the native-vault-drain fix
|
||||
// on the asset axis).
|
||||
func balanceLedgerKey(prefix string, user userKey, asset [32]byte) []byte {
|
||||
k := make([]byte, len(prefix)+len(user)+len(asset))
|
||||
copy(k, prefix)
|
||||
binary.BigEndian.PutUint64(k[len(prefix):], user)
|
||||
binary.BigEndian.PutUint64(k[len(prefix)+8:], asset)
|
||||
copy(k[len(prefix):], user[:])
|
||||
copy(k[len(prefix)+len(user):], asset[:])
|
||||
return k
|
||||
}
|
||||
|
||||
@@ -299,18 +363,18 @@ func writeUint64(db database.KeyValueWriterDeleter, key []byte, v uint64) error
|
||||
}
|
||||
|
||||
// getAvailable / getLocked return an account's available / locked balance for an
|
||||
// asset.
|
||||
func getAvailable(db database.KeyValueReader, user, asset uint64) (uint64, error) {
|
||||
// asset (keyed by the full 16-byte user and the full 32-byte asset id).
|
||||
func getAvailable(db database.KeyValueReader, user userKey, asset [32]byte) (uint64, error) {
|
||||
return readUint64(db, balanceLedgerKey(prefixBalance, user, asset))
|
||||
}
|
||||
func getLocked(db database.KeyValueReader, user, asset uint64) (uint64, error) {
|
||||
func getLocked(db database.KeyValueReader, user userKey, asset [32]byte) (uint64, error) {
|
||||
return readUint64(db, balanceLedgerKey(prefixLocked, user, asset))
|
||||
}
|
||||
|
||||
// creditAvailable adds amount to an account's available balance (the deposit
|
||||
// primitive and the fill-proceeds primitive). Refuses overflow — a credit that
|
||||
// would wrap uint64 is a corrupt input, never silently truncated.
|
||||
func creditAvailable(db database.KeyValueReaderWriterDeleter, user, asset, amount uint64) error {
|
||||
func creditAvailable(db database.KeyValueReaderWriterDeleter, user userKey, asset [32]byte, amount uint64) error {
|
||||
if amount == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -319,7 +383,7 @@ func creditAvailable(db database.KeyValueReaderWriterDeleter, user, asset, amoun
|
||||
return err
|
||||
}
|
||||
if cur+amount < cur {
|
||||
return fmt.Errorf("dchain: available credit overflow user=%d asset=%d", user, asset)
|
||||
return fmt.Errorf("dchain: available credit overflow user=%x asset=%x", user, asset)
|
||||
}
|
||||
return writeUint64(db, balanceLedgerKey(prefixBalance, user, asset), cur+amount)
|
||||
}
|
||||
@@ -328,7 +392,7 @@ func creditAvailable(db database.KeyValueReaderWriterDeleter, user, asset, amoun
|
||||
// ErrInsufficientAvailable when the account lacks the balance — the matcher MUST
|
||||
// debit only AVAILABLE (deposited, un-escrowed) funds, so an over-debit is a
|
||||
// hard refusal, never an underflow.
|
||||
func debitAvailable(db database.KeyValueReaderWriterDeleter, user, asset, amount uint64) error {
|
||||
func debitAvailable(db database.KeyValueReaderWriterDeleter, user userKey, asset [32]byte, amount uint64) error {
|
||||
if amount == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -337,7 +401,7 @@ func debitAvailable(db database.KeyValueReaderWriterDeleter, user, asset, amount
|
||||
return err
|
||||
}
|
||||
if cur < amount {
|
||||
return fmt.Errorf("%w: user=%d asset=%d have=%d need=%d", ErrInsufficientAvailable, user, asset, cur, amount)
|
||||
return fmt.Errorf("%w: user=%x asset=%x have=%d need=%d", ErrInsufficientAvailable, user, asset, cur, amount)
|
||||
}
|
||||
return writeUint64(db, balanceLedgerKey(prefixBalance, user, asset), cur-amount)
|
||||
}
|
||||
@@ -345,7 +409,7 @@ func debitAvailable(db database.KeyValueReaderWriterDeleter, user, asset, amount
|
||||
// lockFromAvailable moves amount from available to locked (placing/submitting an
|
||||
// order reserves the asset it will spend). All-or-nothing: the debit and the
|
||||
// lock are written together, so a partial lock can never strand value.
|
||||
func lockFromAvailable(db database.KeyValueReaderWriterDeleter, user, asset, amount uint64) error {
|
||||
func lockFromAvailable(db database.KeyValueReaderWriterDeleter, user userKey, asset [32]byte, amount uint64) error {
|
||||
if amount == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -357,7 +421,7 @@ func lockFromAvailable(db database.KeyValueReaderWriterDeleter, user, asset, amo
|
||||
return err
|
||||
}
|
||||
if cur+amount < cur {
|
||||
return fmt.Errorf("dchain: locked credit overflow user=%d asset=%d", user, asset)
|
||||
return fmt.Errorf("dchain: locked credit overflow user=%x asset=%x", user, asset)
|
||||
}
|
||||
return writeUint64(db, balanceLedgerKey(prefixLocked, user, asset), cur+amount)
|
||||
}
|
||||
@@ -365,7 +429,7 @@ func lockFromAvailable(db database.KeyValueReaderWriterDeleter, user, asset, amo
|
||||
// unlockToAvailable moves amount from locked back to available (cancel, reject,
|
||||
// or the unspent remainder of a partially-filled order). Refuses to unlock more
|
||||
// than is locked — that would mint against the ledger.
|
||||
func unlockToAvailable(db database.KeyValueReaderWriterDeleter, user, asset, amount uint64) error {
|
||||
func unlockToAvailable(db database.KeyValueReaderWriterDeleter, user userKey, asset [32]byte, amount uint64) error {
|
||||
if amount == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -374,7 +438,7 @@ func unlockToAvailable(db database.KeyValueReaderWriterDeleter, user, asset, amo
|
||||
return err
|
||||
}
|
||||
if cur < amount {
|
||||
return fmt.Errorf("%w: unlock user=%d asset=%d locked=%d need=%d", ErrInsufficientLocked, user, asset, cur, amount)
|
||||
return fmt.Errorf("%w: unlock user=%x asset=%x locked=%d need=%d", ErrInsufficientLocked, user, asset, cur, amount)
|
||||
}
|
||||
if err := writeUint64(db, balanceLedgerKey(prefixLocked, user, asset), cur-amount); err != nil {
|
||||
return err
|
||||
@@ -388,7 +452,7 @@ func unlockToAvailable(db database.KeyValueReaderWriterDeleter, user, asset, amo
|
||||
// (spendLocked from spender + creditAvailable to counterparty) is what conserves
|
||||
// value across a fill: every unit removed here is added to exactly one other
|
||||
// account, so sum(available)+sum(locked) is invariant. Refuses spend > locked.
|
||||
func spendLocked(db database.KeyValueReaderWriterDeleter, user, asset, amount uint64) error {
|
||||
func spendLocked(db database.KeyValueReaderWriterDeleter, user userKey, asset [32]byte, amount uint64) error {
|
||||
if amount == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -397,7 +461,7 @@ func spendLocked(db database.KeyValueReaderWriterDeleter, user, asset, amount ui
|
||||
return err
|
||||
}
|
||||
if cur < amount {
|
||||
return fmt.Errorf("%w: spend user=%d asset=%d locked=%d need=%d", ErrInsufficientLocked, user, asset, cur, amount)
|
||||
return fmt.Errorf("%w: spend user=%x asset=%x locked=%d need=%d", ErrInsufficientLocked, user, asset, cur, amount)
|
||||
}
|
||||
return writeUint64(db, balanceLedgerKey(prefixLocked, user, asset), cur-amount)
|
||||
}
|
||||
@@ -412,30 +476,34 @@ func assetKey(poolID [32]byte) []byte {
|
||||
return k
|
||||
}
|
||||
|
||||
// writeMarketAssets binds a market's (base,quote) asset handles (idempotent
|
||||
// across re-opens with the same pair). The value is base[8]|quote[8].
|
||||
func writeMarketAssets(db database.KeyValueWriter, poolID [32]byte, base, quote uint64) error {
|
||||
var v [16]byte
|
||||
binary.BigEndian.PutUint64(v[0:8], base)
|
||||
binary.BigEndian.PutUint64(v[8:16], quote)
|
||||
// writeMarketAssets binds a market's (base,quote) asset ids (idempotent across
|
||||
// re-opens with the same pair). The value is base[32]|quote[32] — the FULL
|
||||
// injective ids the ledger keys balance:/locked: by, so an order's locked spend
|
||||
// names the SAME asset key a deposit credited.
|
||||
func writeMarketAssets(db database.KeyValueWriter, poolID [32]byte, base, quote [32]byte) error {
|
||||
var v [64]byte
|
||||
copy(v[0:32], base[:])
|
||||
copy(v[32:64], quote[:])
|
||||
return db.Put(assetKey(poolID), v[:])
|
||||
}
|
||||
|
||||
// readMarketAssets returns a market's (base,quote) handles, ok=false if the
|
||||
// market's assets were never bound (a market created via the asset-less
|
||||
// EnsureMarket path — orders on it cannot be balance-checked).
|
||||
func readMarketAssets(db database.KeyValueReader, poolID [32]byte) (base, quote uint64, ok bool, err error) {
|
||||
// readMarketAssets returns a market's (base,quote) ids, ok=false if the market's
|
||||
// assets were never bound (a market created via the asset-less EnsureMarket path
|
||||
// — orders on it cannot be balance-checked).
|
||||
func readMarketAssets(db database.KeyValueReader, poolID [32]byte) (base, quote [32]byte, ok bool, err error) {
|
||||
v, gerr := db.Get(assetKey(poolID))
|
||||
if gerr == database.ErrNotFound {
|
||||
return 0, 0, false, nil
|
||||
return base, quote, false, nil
|
||||
}
|
||||
if gerr != nil {
|
||||
return 0, 0, false, gerr
|
||||
return base, quote, false, gerr
|
||||
}
|
||||
if len(v) != 16 {
|
||||
return 0, 0, false, fmt.Errorf("dchain: corrupt market assets len=%d", len(v))
|
||||
if len(v) != 64 {
|
||||
return base, quote, false, fmt.Errorf("dchain: corrupt market assets len=%d", len(v))
|
||||
}
|
||||
return binary.BigEndian.Uint64(v[0:8]), binary.BigEndian.Uint64(v[8:16]), true, nil
|
||||
copy(base[:], v[0:32])
|
||||
copy(quote[:], v[32:64])
|
||||
return base, quote, true, nil
|
||||
}
|
||||
|
||||
// ---- per-order reserve (so cancel/fill can unlock the exact remaining lock) ----
|
||||
@@ -451,32 +519,89 @@ func orderLockKey(poolID [32]byte, orderID uint64) []byte {
|
||||
|
||||
// putOrderLock records the (asset, remaining-locked-amount) a resting order
|
||||
// holds, so a later cancel or a maker fill knows exactly how much to unlock /
|
||||
// spend. Deleted when the remaining lock reaches zero.
|
||||
func putOrderLock(db database.KeyValueWriterDeleter, poolID [32]byte, orderID, asset, amount uint64) error {
|
||||
// spend. The asset is the FULL 32-byte id; the value is asset[32]|amount[8].
|
||||
// Deleted when the remaining lock reaches zero.
|
||||
func putOrderLock(db database.KeyValueWriterDeleter, poolID [32]byte, orderID uint64, asset [32]byte, amount uint64) error {
|
||||
if amount == 0 {
|
||||
return db.Delete(orderLockKey(poolID, orderID))
|
||||
}
|
||||
var v [16]byte
|
||||
binary.BigEndian.PutUint64(v[0:8], asset)
|
||||
binary.BigEndian.PutUint64(v[8:16], amount)
|
||||
var v [40]byte
|
||||
copy(v[0:32], asset[:])
|
||||
binary.BigEndian.PutUint64(v[32:40], amount)
|
||||
return db.Put(orderLockKey(poolID, orderID), v[:])
|
||||
}
|
||||
|
||||
// getOrderLock returns the (asset, remaining-locked-amount) for a resting order,
|
||||
// ok=false if none recorded (e.g. an order placed before the balance model, or a
|
||||
// fully-consumed lock).
|
||||
func getOrderLock(db database.KeyValueReader, poolID [32]byte, orderID uint64) (asset, amount uint64, ok bool, err error) {
|
||||
func getOrderLock(db database.KeyValueReader, poolID [32]byte, orderID uint64) (asset [32]byte, amount uint64, ok bool, err error) {
|
||||
v, gerr := db.Get(orderLockKey(poolID, orderID))
|
||||
if gerr == database.ErrNotFound {
|
||||
return 0, 0, false, nil
|
||||
return asset, 0, false, nil
|
||||
}
|
||||
if gerr != nil {
|
||||
return 0, 0, false, gerr
|
||||
return asset, 0, false, gerr
|
||||
}
|
||||
if len(v) != 16 {
|
||||
return 0, 0, false, fmt.Errorf("dchain: corrupt order lock len=%d", len(v))
|
||||
if len(v) != 40 {
|
||||
return asset, 0, false, fmt.Errorf("dchain: corrupt order lock len=%d", len(v))
|
||||
}
|
||||
return binary.BigEndian.Uint64(v[0:8]), binary.BigEndian.Uint64(v[8:16]), true, nil
|
||||
copy(asset[:], v[0:32])
|
||||
return asset, binary.BigEndian.Uint64(v[32:40]), true, nil
|
||||
}
|
||||
|
||||
// ---- per-order FULL settlement identity (so a fill/cancel credits the resting
|
||||
// maker's full 16-byte account even after a restart) ----
|
||||
//
|
||||
// The matcher's persisted DEXOrder row carries only an 8-byte UserID (the frozen
|
||||
// GPU ABI / STP handle), so a resting order rebuilt from a row (RowToOrder) loses
|
||||
// bytes 8..15 of its user. Settlement, however, MUST credit the maker's FULL
|
||||
// 16-byte account (the one its deposit/lock live under). This row carries that
|
||||
// full identity in d-chain state, keyed by orderID — orthogonal to the GPU row —
|
||||
// so a maker fill (live OR post-restart) and a cancel settle to the exact account.
|
||||
// Written when an order rests (alongside orderasset:), deleted when the reserve is.
|
||||
|
||||
// orderUserKey builds orderuser:<poolID:32><orderID:8>.
|
||||
func orderUserKey(poolID [32]byte, orderID uint64) []byte {
|
||||
k := make([]byte, len(prefixOrderUser)+32+8)
|
||||
copy(k, prefixOrderUser)
|
||||
copy(k[len(prefixOrderUser):], poolID[:])
|
||||
binary.BigEndian.PutUint64(k[len(prefixOrderUser)+32:], orderID)
|
||||
return k
|
||||
}
|
||||
|
||||
// putOrderUser records a resting order's FULL 16-byte settlement identity, or
|
||||
// deletes the row when amount-less (called with the order's user on rest, and
|
||||
// deleted in lockstep with the per-order reserve on cancel/reject/full-fill).
|
||||
func putOrderUser(db database.KeyValueWriterDeleter, poolID [32]byte, orderID uint64, user userKey) error {
|
||||
return db.Put(orderUserKey(poolID, orderID), user[:])
|
||||
}
|
||||
|
||||
// deleteOrderUser removes a resting order's settlement-identity row.
|
||||
func deleteOrderUser(db database.KeyValueWriterDeleter, poolID [32]byte, orderID uint64) error {
|
||||
return db.Delete(orderUserKey(poolID, orderID))
|
||||
}
|
||||
|
||||
// getOrderUser returns a resting order's FULL 16-byte settlement identity, ok=false
|
||||
// if none recorded (an asset-less / pre-custody market, or an order placed before
|
||||
// this row existed). On a CUSTODY (assets-bound) market the settlement callers
|
||||
// (makerSettleKey, the cancel unlock) FAIL CLOSED with ErrMissingSettlementUser when
|
||||
// ok=false — there is NO fallback to the matcher's 8-byte handle, because a compact-
|
||||
// handle fallback would make handle collisions value-bearing (a theft bug). The
|
||||
// boot audit (assertOrderUserCoverage) guarantees ok=true for every resting custody
|
||||
// order, so ok=false at settle time is state corruption, not a normal path.
|
||||
func getOrderUser(db database.KeyValueReader, poolID [32]byte, orderID uint64) (user userKey, ok bool, err error) {
|
||||
v, gerr := db.Get(orderUserKey(poolID, orderID))
|
||||
if gerr == database.ErrNotFound {
|
||||
return user, false, nil
|
||||
}
|
||||
if gerr != nil {
|
||||
return user, false, gerr
|
||||
}
|
||||
if len(v) != len(user) {
|
||||
return user, false, fmt.Errorf("dchain: corrupt order user len=%d", len(v))
|
||||
}
|
||||
copy(user[:], v)
|
||||
return user, true, nil
|
||||
}
|
||||
|
||||
// readMeta helpers.
|
||||
|
||||
+28
-7
@@ -45,16 +45,16 @@ const (
|
||||
// user[16].
|
||||
TxSubmit
|
||||
// TxDeposit credits an account's available balance from value the proxy
|
||||
// atomically imported. Body = zapwire Deposit payload (32B): user[16] +
|
||||
// asset[8] + amount[8].
|
||||
// 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 (32B): user[16] +
|
||||
// asset[8] + amount[8] (clamped to available).
|
||||
// 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 handles so orders on it can
|
||||
// be value-checked. Body = zapwire OpenMarket payload (48B): poolId[32] +
|
||||
// baseAsset[8] + quoteAsset[8].
|
||||
// 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
|
||||
)
|
||||
|
||||
@@ -103,6 +103,27 @@ var (
|
||||
// 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
|
||||
|
||||
+58
-4
@@ -178,6 +178,16 @@ func (vm *VM) loadHead() error {
|
||||
// restart path: after a crash the in-RAM books are reconstructed exactly from the
|
||||
// durable resting set, so trading resumes with the committed state. Must be
|
||||
// called under vm.mu.
|
||||
//
|
||||
// SETTLEMENT-IDENTITY BOOT ASSERTION: for every market whose (base,quote) assets
|
||||
// are bound (a CUSTODY market that locks/settles value), every resting order MUST
|
||||
// have an orderuser: mapping to its full 16-byte settlement identity. An order
|
||||
// reaching this point on a custody market always has one (lockOrderSpend persists
|
||||
// orderuser: BEFORE the order is matchable, and a custody order with no lock is
|
||||
// rejected), so a missing row is state corruption: FAIL BOOT rather than continue
|
||||
// on a degraded 8-byte path where a maker fill/cancel could only fall back to the
|
||||
// compact handle. (Asset-less / pre-custody markets never lock or settle value, so
|
||||
// their orders legitimately carry no orderuser: row and are not asserted.)
|
||||
func (vm *VM) rebuildAllBooks() error {
|
||||
it := vm.db.NewIteratorWithPrefix([]byte(prefixMarket))
|
||||
defer it.Release()
|
||||
@@ -193,11 +203,44 @@ func (vm *VM) rebuildAllBooks() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("dchain: rebuild book %x: %w", poolID[:8], err)
|
||||
}
|
||||
if err := vm.assertOrderUserCoverage(poolID, ob); err != nil {
|
||||
return err
|
||||
}
|
||||
vm.books[poolID] = ob
|
||||
}
|
||||
return it.Error()
|
||||
}
|
||||
|
||||
// assertOrderUserCoverage enforces the settlement-identity boot invariant for one
|
||||
// market: if the market's assets are bound (custody market), every resting order
|
||||
// in the rebuilt book MUST have an orderuser: row. A missing row on a custody
|
||||
// market is unrecoverable state corruption (a maker fill/cancel could not resolve
|
||||
// the full account) — return an error so Initialize FAILS rather than booting a VM
|
||||
// that would settle on the degraded 8-byte handle. Asset-less markets are skipped:
|
||||
// they never lock or settle value, so their orders carry no orderuser: row by
|
||||
// design. Must be called under vm.mu.
|
||||
func (vm *VM) assertOrderUserCoverage(poolID [32]byte, ob *lx.OrderBook) error {
|
||||
_, _, assetsBound, err := readMarketAssets(vm.db, poolID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dchain: read market %x assets for orderuser audit: %w", poolID[:8], err)
|
||||
}
|
||||
if !assetsBound {
|
||||
return nil // asset-less / pre-custody market: no settlement, no orderuser invariant
|
||||
}
|
||||
for _, o := range ob.Orders {
|
||||
if o == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok, gerr := getOrderUser(vm.db, poolID, o.ID); gerr != nil {
|
||||
return fmt.Errorf("dchain: orderuser audit market %x order %d: %w", poolID[:8], o.ID, gerr)
|
||||
} else if !ok {
|
||||
return fmt.Errorf("dchain: %w: resting order %d on custody market %x has no orderuser row at boot (refusing to start on a degraded settlement path)",
|
||||
ErrMissingSettlementUser, o.ID, poolID[:8])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BookDepth reports the authoritative resting-book state for a market: the count
|
||||
// of resting orders, their total remaining size, and the best bid/ask. It reads
|
||||
// the in-RAM book (a fold of the committed order:* rows) under the VM lock —
|
||||
@@ -233,12 +276,13 @@ func (vm *VM) BookDepth(poolID [32]byte) (orders int, remaining, bestBid, bestAs
|
||||
// orders) amount, both in atomic asset units. It reads the durable ledger under
|
||||
// the VM lock — the authoritative committed state, not the in-RAM book — so a
|
||||
// caller (the proxy's withdraw leg, the venue daemon, tests) observes exactly
|
||||
// what consensus settled. user is the identity string; it is folded to the 8-byte
|
||||
// ledger handle internally.
|
||||
func (vm *VM) Balance(user string, asset uint64) (available, locked uint64, err error) {
|
||||
// what consensus settled. user is the identity string; it is rendered to the FULL
|
||||
// 16-byte ledger identity internally (no 8-byte fold). asset is the FULL 32-byte
|
||||
// injective asset id.
|
||||
func (vm *VM) Balance(user string, asset [32]byte) (available, locked uint64, err error) {
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
uid := userID8(user)
|
||||
uid := userKey16(user)
|
||||
if available, err = getAvailable(vm.db, uid, asset); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
@@ -332,6 +376,16 @@ func (vm *VM) LastAccepted(ctx context.Context) (ids.ID, error) {
|
||||
return vm.lastAcceptedID, nil
|
||||
}
|
||||
|
||||
// inFlightHeight returns the height of the single block that could currently be in
|
||||
// flight on this linear-chain proposer: lastAcceptedHeight+1. submitTx stamps a
|
||||
// tombstone with it (R4) so gcTombstones can reclaim the tombstone once that height
|
||||
// is accepted. Read under vm.mu to avoid a race with Accept's height advance.
|
||||
func (vm *VM) inFlightHeight() uint64 {
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
return vm.lastAcceptedHeight + 1
|
||||
}
|
||||
|
||||
// SetPreference records the block the engine wants the VM to build on. The
|
||||
// d-chain is linear; the preference always tracks the last accepted block.
|
||||
func (vm *VM) SetPreference(ctx context.Context, id ids.ID) error {
|
||||
|
||||
@@ -92,10 +92,17 @@ func buildVerifyAccept(t *testing.T, vm *VM) *Block {
|
||||
if err := reparsed.Verify(ctx); err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
// Snapshot the per-tx outcomes set at Verify so the accepted block retains them
|
||||
// for test inspection (Accept clears blk.outcomes after resolving waiters). The
|
||||
// ownership harness reads a submit block's fills from here.
|
||||
blk := reparsed.(*Block)
|
||||
snap := make([]txOutcome, len(blk.outcomes))
|
||||
copy(snap, blk.outcomes)
|
||||
if err := reparsed.Accept(ctx); err != nil {
|
||||
t.Fatalf("Accept: %v", err)
|
||||
}
|
||||
return reparsed.(*Block)
|
||||
blk.outcomes = snap
|
||||
return blk
|
||||
}
|
||||
|
||||
// TestVMLifecycle is the step-3 end-to-end proof: Initialize a fresh VM, place
|
||||
|
||||
@@ -46,7 +46,11 @@ const (
|
||||
// and we can normalize it to zero after any data path that may leave
|
||||
// the bytes uninitialised. Same for DEXTrade.
|
||||
type DEXOrder struct {
|
||||
OrderID uint64 // 0..8
|
||||
OrderID uint64 // 0..8
|
||||
// UserID is a compact matcher/STP handle only. It is intentionally not
|
||||
// collision-resistant and MUST NOT be used for balances, custody, settlement,
|
||||
// withdraws, imports, exports, or liability accounting. Settlement identity is
|
||||
// resolved exclusively through the orderuser index to the full AccountID/userKey.
|
||||
UserID uint64 // 8..16
|
||||
Price DEXPrice // 16..32
|
||||
Quantity uint64 // 32..40
|
||||
|
||||
+9
-5
@@ -35,11 +35,15 @@ import (
|
||||
// user field, used for on-device self-trade prevention). The wire/protocol user
|
||||
// is a 16-byte field and the book's Order.User is a string; the row folds that to
|
||||
// the leading 8 bytes (big-endian). This is sufficient for the matcher's STP and
|
||||
// for rebuilding price-time priority. The CANONICAL 16-byte settlement/dedup
|
||||
// identity is NOT a property of the order-book row — it lives in d-chain state
|
||||
// (the idempotency index keyed on user[16]+seq). Keeping the row at the GPU ABI's
|
||||
// 8-byte user is the decomplected choice: the book row models matching, d-chain
|
||||
// state models settlement identity.
|
||||
// for rebuilding price-time priority. The CANONICAL 16-byte settlement identity is
|
||||
// NOT a property of the order-book row — it lives in d-chain state: the custody
|
||||
// LEDGER keys balance:/locked: by the full user[16] (so two addresses sharing a
|
||||
// leading 8-byte prefix are DISTINCT accounts, never able to drain each other),
|
||||
// the idempotency index keys on user[16]+seq, and orderuser:<orderID> carries each
|
||||
// resting order's full user[16] so a maker fill credits the right account even
|
||||
// after a row-rebuilt restart truncated the in-RAM user to 8 bytes. Keeping the
|
||||
// row at the GPU ABI's 8-byte user is the decomplected choice: the book row models
|
||||
// matching, d-chain state models settlement identity.
|
||||
|
||||
// QuantityScale is the fixed-point scale for the integer base quantity stored in
|
||||
// a DEXOrder row (Quantity / Remaining). A float64 size s is stored as
|
||||
|
||||
+107
-40
@@ -278,11 +278,47 @@ func DecodeAck(resp []byte) (orderID uint64, status uint8, err error) {
|
||||
// the same sense — the precompile/proxy/d-chain re-define identical constants —
|
||||
// just newer.
|
||||
//
|
||||
// Asset identity is an 8-byte handle (AssetIDSize). On the EVM side the real
|
||||
// 20-byte ERC-20 / address(0) currency is folded to this handle by the
|
||||
// adapter (a stable map); inside the d-chain the handle IS the asset for
|
||||
// balance keying. 8 bytes matches the d-chain balance-key width and the
|
||||
// DEXOrder/DEXTrade UserID width — one consistent identity size end to end.
|
||||
// Asset identity is the FULL 32-byte asset id (AssetIDSize), NOT a truncated
|
||||
// handle. Keying a value-bearing custody ledger by a truncated id is unsound: a
|
||||
// truncation maps distinct assets to the same key, so a worthless token whose id
|
||||
// folds to the native-LUX key could mint a native claim and drain the native
|
||||
// vault (and two tokens sharing the truncated prefix collide). The id is
|
||||
// therefore carried and keyed at FULL width, derived by an INJECTIVE map on each
|
||||
// rail so distinct assets NEVER collide:
|
||||
// - native LUX = a reserved all-zero id (ids.Empty on the proxy rail);
|
||||
// - ERC-20 = the 20-byte C-Chain address embedded in 32 bytes (left-pad),
|
||||
// so two distinct token addresses — or a token vs native — map
|
||||
// to two distinct ids.
|
||||
// The d-chain keys balance:/locked: (and the market (base,quote) binding and the
|
||||
// per-order reserve) by this full 32-byte id. The DEXOrder/DEXTrade matcher rows
|
||||
// carry NO asset field (they key by user+order id), so widening the asset leaves
|
||||
// the FROZEN matcher row ABI byte-identical — the asset lives only in the ledger
|
||||
// keyspace, never on the hot order/trade frames.
|
||||
//
|
||||
// IDEMPOTENCY REFERENCE (ref[32]) — the funds-safety field. A deposit (mint)
|
||||
// and a withdraw (burn + vault release) are IRREVERSIBLE, so "is this the same
|
||||
// operation?" MUST mean the same thing on BOTH sides of the rail: the EVM
|
||||
// precompile, which keys replay-idempotency on the executing tx, and the
|
||||
// d-chain, which content-addresses its dedup index (seen:<txID>) on the FULL
|
||||
// frame. The custody frames therefore carry a 32-byte ref — the originating
|
||||
// transaction's identity (EVM txHash on the precompile rail; the consumed
|
||||
// import / settlement reference on the chains/dexvm atomic rail). Because the
|
||||
// d-chain txID = Checksum256(type‖body) hashes the WHOLE body, the ref is folded
|
||||
// into the seen: key automatically: two GENUINELY distinct custody ops (distinct
|
||||
// originating tx, hence distinct ref) are distinct on the d-chain too — so a
|
||||
// second genuine withdraw re-clamps to CURRENT available (0 after the first) and
|
||||
// releases nothing, instead of the seen: index replaying the first realized
|
||||
// amount against a vault that already paid (the drain). A byte-identical retry of
|
||||
// ONE op (same ref) still dedups to exactly-once. The ref is ASSET-GENERIC: it is
|
||||
// opaque 32 bytes that serves the native (address(0)) and ERC-20 rails identically
|
||||
// — the asset[8] handle distinguishes the currency, the ref distinguishes the op.
|
||||
//
|
||||
// The ref is APPENDED at the tail of the custody body, so user[16]/asset[8]/
|
||||
// amount[8] keep their original offsets (0/16/24) — every existing decode site is
|
||||
// unchanged; only the body grows by 32 and a new ref read is added. The four
|
||||
// FROZEN order frames are NOT touched: their sizes, offsets, and codecs are
|
||||
// byte-identical to before (the matchers byte-match on them), and this block adds
|
||||
// nothing to them.
|
||||
const (
|
||||
// MethodDeposit credits an account's available balance from value the proxy
|
||||
// atomically imported (shared-memory ImportTx). Funds-IN leg.
|
||||
@@ -296,88 +332,119 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
// AssetIDSize is the on-wire asset handle width (matches the balance-key and
|
||||
// DEXOrder.UserID width).
|
||||
AssetIDSize = 8
|
||||
// AssetIDSize is the on-wire asset id width: the FULL 32-byte injective asset
|
||||
// id (NOT a truncated handle). It keys the d-chain balance:/locked: ledger and
|
||||
// the market (base,quote) binding. See the custody block comment for why a
|
||||
// truncated key is unsound (native-vault drain via id collision).
|
||||
AssetIDSize = 32
|
||||
|
||||
// DepositReqSize: user[16] + asset[8] + amount[8].
|
||||
DepositReqSize = UserSize + AssetIDSize + 8 // 32
|
||||
// RefSize is the on-wire idempotency-reference width: the 32-byte identity of
|
||||
// the originating transaction (EVM txHash / atomic import reference) that the
|
||||
// d-chain folds into its content-addressed seen: dedup key. See the custody
|
||||
// block comment above.
|
||||
RefSize = 32
|
||||
|
||||
// WithdrawReqSize: user[16] + asset[8] + amount[8].
|
||||
WithdrawReqSize = UserSize + AssetIDSize + 8 // 32
|
||||
// DepositReqSize: user[16] + asset[32] + amount[8] + ref[32].
|
||||
DepositReqSize = UserSize + AssetIDSize + 8 + RefSize // 88
|
||||
|
||||
// OpenMarketReqSize: poolId[32] + baseAsset[8] + quoteAsset[8].
|
||||
OpenMarketReqSize = PoolIDSize + AssetIDSize + AssetIDSize // 48
|
||||
// WithdrawReqSize: user[16] + asset[32] + amount[8] + ref[32].
|
||||
WithdrawReqSize = UserSize + AssetIDSize + 8 + RefSize // 88
|
||||
|
||||
// OpenMarketReqSize: poolId[32] + baseAsset[32] + quoteAsset[32].
|
||||
OpenMarketReqSize = PoolIDSize + AssetIDSize + AssetIDSize // 96
|
||||
|
||||
// BalanceReqSize: user[16] + asset[32]. The read-only balance observation.
|
||||
BalanceReqSize = UserSize + AssetIDSize // 48
|
||||
|
||||
// BalanceRespSize: status[1] + amount[8]. A deposit/withdraw response reports
|
||||
// the amount actually credited/debited (a withdraw clamps to available, so the
|
||||
// caller — the proxy export leg — learns the exact realized amount to export).
|
||||
BalanceRespSize = 1 + 8
|
||||
|
||||
// custodyRefOff is the byte offset of ref[32] within a deposit/withdraw body
|
||||
// (after user[16]+asset[32]+amount[8]).
|
||||
custodyRefOff = UserSize + AssetIDSize + 8 // 56
|
||||
|
||||
// custodyAmountOff is the byte offset of amount[8] within a deposit/withdraw
|
||||
// body (after user[16]+asset[32]).
|
||||
custodyAmountOff = UserSize + AssetIDSize // 48
|
||||
)
|
||||
|
||||
// EncodeDeposit builds a deposit request: user[16] + asset[8] + amount[8].
|
||||
func EncodeDeposit(user string, asset uint64, amount uint64) []byte {
|
||||
// EncodeDeposit builds a deposit request: user[16] + asset[32] + amount[8] +
|
||||
// ref[32]. asset is the FULL 32-byte injective asset id (see the custody block
|
||||
// comment). ref is the originating-tx idempotency reference; it folds into the
|
||||
// d-chain seen: key via the content-addressed txID.
|
||||
func EncodeDeposit(user string, asset [32]byte, amount uint64, ref [32]byte) []byte {
|
||||
out := make([]byte, DepositReqSize)
|
||||
copy(out[0:UserSize], padNull(user, UserSize))
|
||||
binary.BigEndian.PutUint64(out[UserSize:UserSize+AssetIDSize], asset)
|
||||
binary.BigEndian.PutUint64(out[UserSize+AssetIDSize:], amount)
|
||||
copy(out[UserSize:custodyAmountOff], asset[:])
|
||||
binary.BigEndian.PutUint64(out[custodyAmountOff:custodyRefOff], amount)
|
||||
copy(out[custodyRefOff:custodyRefOff+RefSize], ref[:])
|
||||
return out
|
||||
}
|
||||
|
||||
// DecodeDeposit parses a deposit request.
|
||||
func DecodeDeposit(payload []byte) (user string, asset uint64, amount uint64, err error) {
|
||||
// DecodeDeposit parses a deposit request, returning the full 32-byte asset id and
|
||||
// the idempotency ref.
|
||||
func DecodeDeposit(payload []byte) (user string, asset [32]byte, amount uint64, ref [32]byte, err error) {
|
||||
if len(payload) < DepositReqSize {
|
||||
err = fmt.Errorf("zapwire: deposit payload too short: %d", len(payload))
|
||||
return
|
||||
}
|
||||
user = string(trimNull(payload[0:UserSize]))
|
||||
asset = binary.BigEndian.Uint64(payload[UserSize : UserSize+AssetIDSize])
|
||||
amount = binary.BigEndian.Uint64(payload[UserSize+AssetIDSize : UserSize+AssetIDSize+8])
|
||||
copy(asset[:], payload[UserSize:custodyAmountOff])
|
||||
amount = binary.BigEndian.Uint64(payload[custodyAmountOff:custodyRefOff])
|
||||
copy(ref[:], payload[custodyRefOff:custodyRefOff+RefSize])
|
||||
return
|
||||
}
|
||||
|
||||
// EncodeWithdraw builds a withdraw request: user[16] + asset[8] + amount[8].
|
||||
// amount is the requested withdrawal; the d-chain clamps it to the account's
|
||||
// available balance and reports the realized amount in the response.
|
||||
func EncodeWithdraw(user string, asset uint64, amount uint64) []byte {
|
||||
// EncodeWithdraw builds a withdraw request: user[16] + asset[32] + amount[8] +
|
||||
// ref[32]. asset is the FULL 32-byte injective asset id; amount is the requested
|
||||
// withdrawal (the d-chain clamps it to available and reports the realized amount
|
||||
// in the response). ref is the originating-tx idempotency reference.
|
||||
func EncodeWithdraw(user string, asset [32]byte, amount uint64, ref [32]byte) []byte {
|
||||
out := make([]byte, WithdrawReqSize)
|
||||
copy(out[0:UserSize], padNull(user, UserSize))
|
||||
binary.BigEndian.PutUint64(out[UserSize:UserSize+AssetIDSize], asset)
|
||||
binary.BigEndian.PutUint64(out[UserSize+AssetIDSize:], amount)
|
||||
copy(out[UserSize:custodyAmountOff], asset[:])
|
||||
binary.BigEndian.PutUint64(out[custodyAmountOff:custodyRefOff], amount)
|
||||
copy(out[custodyRefOff:custodyRefOff+RefSize], ref[:])
|
||||
return out
|
||||
}
|
||||
|
||||
// DecodeWithdraw parses a withdraw request.
|
||||
func DecodeWithdraw(payload []byte) (user string, asset uint64, amount uint64, err error) {
|
||||
// DecodeWithdraw parses a withdraw request, returning the full 32-byte asset id
|
||||
// and the idempotency ref.
|
||||
func DecodeWithdraw(payload []byte) (user string, asset [32]byte, amount uint64, ref [32]byte, err error) {
|
||||
if len(payload) < WithdrawReqSize {
|
||||
err = fmt.Errorf("zapwire: withdraw payload too short: %d", len(payload))
|
||||
return
|
||||
}
|
||||
user = string(trimNull(payload[0:UserSize]))
|
||||
asset = binary.BigEndian.Uint64(payload[UserSize : UserSize+AssetIDSize])
|
||||
amount = binary.BigEndian.Uint64(payload[UserSize+AssetIDSize : UserSize+AssetIDSize+8])
|
||||
copy(asset[:], payload[UserSize:custodyAmountOff])
|
||||
amount = binary.BigEndian.Uint64(payload[custodyAmountOff:custodyRefOff])
|
||||
copy(ref[:], payload[custodyRefOff:custodyRefOff+RefSize])
|
||||
return
|
||||
}
|
||||
|
||||
// EncodeOpenMarket builds an open-market request: poolId[32] + baseAsset[8] +
|
||||
// quoteAsset[8].
|
||||
func EncodeOpenMarket(poolID [32]byte, baseAsset, quoteAsset uint64) []byte {
|
||||
// EncodeOpenMarket builds an open-market request: poolId[32] + baseAsset[32] +
|
||||
// quoteAsset[32]. The assets are the FULL 32-byte injective ids the d-chain keys
|
||||
// the ledger by, so an order's locked spend names the SAME asset key a deposit
|
||||
// credited.
|
||||
func EncodeOpenMarket(poolID [32]byte, baseAsset, quoteAsset [32]byte) []byte {
|
||||
out := make([]byte, OpenMarketReqSize)
|
||||
copy(out[0:PoolIDSize], poolID[:])
|
||||
binary.BigEndian.PutUint64(out[PoolIDSize:PoolIDSize+AssetIDSize], baseAsset)
|
||||
binary.BigEndian.PutUint64(out[PoolIDSize+AssetIDSize:], quoteAsset)
|
||||
copy(out[PoolIDSize:PoolIDSize+AssetIDSize], baseAsset[:])
|
||||
copy(out[PoolIDSize+AssetIDSize:], quoteAsset[:])
|
||||
return out
|
||||
}
|
||||
|
||||
// DecodeOpenMarket parses an open-market request.
|
||||
func DecodeOpenMarket(payload []byte) (poolID [32]byte, baseAsset, quoteAsset uint64, err error) {
|
||||
func DecodeOpenMarket(payload []byte) (poolID [32]byte, baseAsset, quoteAsset [32]byte, err error) {
|
||||
if len(payload) < OpenMarketReqSize {
|
||||
err = fmt.Errorf("zapwire: open-market payload too short: %d", len(payload))
|
||||
return
|
||||
}
|
||||
copy(poolID[:], payload[0:PoolIDSize])
|
||||
baseAsset = binary.BigEndian.Uint64(payload[PoolIDSize : PoolIDSize+AssetIDSize])
|
||||
quoteAsset = binary.BigEndian.Uint64(payload[PoolIDSize+AssetIDSize : PoolIDSize+2*AssetIDSize])
|
||||
copy(baseAsset[:], payload[PoolIDSize:PoolIDSize+AssetIDSize])
|
||||
copy(quoteAsset[:], payload[PoolIDSize+AssetIDSize:PoolIDSize+2*AssetIDSize])
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+56
-20
@@ -26,12 +26,19 @@ func TestFrameSizesFrozen(t *testing.T) {
|
||||
{"AckSize", AckSize, 24},
|
||||
{"FillWireSize", FillWireSize, 17},
|
||||
// Custody frames (additive, FROZEN). The chains/dexvm proxy re-defines
|
||||
// these as DepositReqSize/WithdrawReqSize/BalanceRespSize (32/32/9) and the
|
||||
// these as DepositReqSize/WithdrawReqSize/BalanceRespSize (88/88/9) and the
|
||||
// precompile mirrors them; a change here breaks that cross-module parity.
|
||||
{"AssetIDSize", AssetIDSize, 8},
|
||||
{"DepositReqSize", DepositReqSize, 32},
|
||||
{"WithdrawReqSize", WithdrawReqSize, 32},
|
||||
{"OpenMarketReqSize", OpenMarketReqSize, 48},
|
||||
// Deposit/Withdraw carry the FULL 32-byte injective asset id and the 32-byte
|
||||
// idempotency ref (user[16]+asset[32]+amount[8]+ref[32]=88); the asset is the
|
||||
// full id (NOT a truncated handle) so distinct assets never collide on the
|
||||
// ledger key (the native-vault-drain fix); the ref unifies the EVM-txHash
|
||||
// idempotency identity with the d-chain content-addressed seen: key.
|
||||
{"AssetIDSize", AssetIDSize, 32},
|
||||
{"RefSize", RefSize, 32},
|
||||
{"DepositReqSize", DepositReqSize, 88},
|
||||
{"WithdrawReqSize", WithdrawReqSize, 88},
|
||||
{"OpenMarketReqSize", OpenMarketReqSize, 96},
|
||||
{"BalanceReqSize", BalanceReqSize, 48},
|
||||
{"BalanceRespSize", BalanceRespSize, 9},
|
||||
}
|
||||
for _, c := range cases {
|
||||
@@ -60,32 +67,61 @@ func TestMethodNamesFrozen(t *testing.T) {
|
||||
|
||||
// TestCustodyFramesRoundTrip pins the custody frame codecs (deposit/withdraw/
|
||||
// open-market/balance-resp) round-trip exactly — the bytes the proxy's atomic
|
||||
// rail and the d-chain ledger exchange.
|
||||
// rail and the d-chain ledger exchange. The 32-byte idempotency ref MUST survive
|
||||
// the round-trip: it is the field the d-chain folds into its seen: dedup key, so
|
||||
// a lost/garbled ref would reopen the vault-drain replay window.
|
||||
func TestCustodyFramesRoundTrip(t *testing.T) {
|
||||
// deposit / withdraw
|
||||
// deposit / withdraw — asset is the FULL 32-byte injective id.
|
||||
mkAsset := func(b ...byte) [32]byte { var a [32]byte; copy(a[:], b); return a }
|
||||
for _, tc := range []struct {
|
||||
user string
|
||||
asset uint64
|
||||
asset [32]byte
|
||||
amount uint64
|
||||
ref [32]byte
|
||||
}{
|
||||
{"maker-a", 0x4c5558_00000001, 100},
|
||||
{"taker-x", 0xdeadbeefcafef00d, 1<<63 + 7},
|
||||
{"", 0, 0},
|
||||
{"maker-a", mkAsset(0x4c, 0x55, 0x58, 0x00, 0x00, 0x00, 0x00, 0x01), 100, [32]byte{0x01, 0x02, 0xff}},
|
||||
{"taker-x", mkAsset(0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xf0, 0x0d), 1<<63 + 7, [32]byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee}},
|
||||
// Full-width id whose distinguishing bytes live in the LOW 24 bytes — a
|
||||
// truncated 8-byte handle would lose them; the full id must survive intact.
|
||||
{"deep-id", mkAsset(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xab, 0xcd, 0xef), 42, [32]byte{0x09}},
|
||||
{"", [32]byte{}, 0, [32]byte{}},
|
||||
} {
|
||||
du, da, dm, err := DecodeDeposit(EncodeDeposit(tc.user, tc.asset, tc.amount))
|
||||
if err != nil || du != tc.user || da != tc.asset || dm != tc.amount {
|
||||
t.Fatalf("deposit round-trip (%q,%#x,%d) = (%q,%#x,%d) err=%v", tc.user, tc.asset, tc.amount, du, da, dm, err)
|
||||
du, da, dm, dref, err := DecodeDeposit(EncodeDeposit(tc.user, tc.asset, tc.amount, tc.ref))
|
||||
if err != nil || du != tc.user || da != tc.asset || dm != tc.amount || dref != tc.ref {
|
||||
t.Fatalf("deposit round-trip (%q,%x,%d,%x) = (%q,%x,%d,%x) err=%v", tc.user, tc.asset[:8], tc.amount, tc.ref[:4], du, da[:8], dm, dref[:4], err)
|
||||
}
|
||||
wu, wa, wm, werr := DecodeWithdraw(EncodeWithdraw(tc.user, tc.asset, tc.amount))
|
||||
if werr != nil || wu != tc.user || wa != tc.asset || wm != tc.amount {
|
||||
t.Fatalf("withdraw round-trip (%q,%#x,%d) = (%q,%#x,%d) err=%v", tc.user, tc.asset, tc.amount, wu, wa, wm, werr)
|
||||
wu, wa, wm, wref, werr := DecodeWithdraw(EncodeWithdraw(tc.user, tc.asset, tc.amount, tc.ref))
|
||||
if werr != nil || wu != tc.user || wa != tc.asset || wm != tc.amount || wref != tc.ref {
|
||||
t.Fatalf("withdraw round-trip (%q,%x,%d,%x) = (%q,%x,%d,%x) err=%v", tc.user, tc.asset[:8], tc.amount, tc.ref[:4], wu, wa[:8], wm, wref[:4], werr)
|
||||
}
|
||||
}
|
||||
|
||||
// COLLISION RESISTANCE (the asset-key fix): two assets that share their leading
|
||||
// 8 bytes but differ in the LOW 24 bytes MUST encode to distinct asset fields.
|
||||
// The old 8-byte handle truncated them to the SAME key (the collision bug); the
|
||||
// full 32-byte id keeps them distinct on the wire (and so on the ledger key).
|
||||
sharedHi := mkAsset(0, 0, 0, 0, 0, 0, 0, 0, 0x11) // low byte 0x11
|
||||
sharedHiAlt := mkAsset(0, 0, 0, 0, 0, 0, 0, 0, 0x22)
|
||||
_, a1, _, _, _ := DecodeDeposit(EncodeDeposit("u", sharedHi, 1, [32]byte{0x01}))
|
||||
_, a2, _, _, _ := DecodeDeposit(EncodeDeposit("u", sharedHiAlt, 1, [32]byte{0x01}))
|
||||
if a1 == a2 {
|
||||
t.Fatal("two assets sharing leading 8 bytes decoded to the SAME id — truncation collision (native-vault-drain class)")
|
||||
}
|
||||
|
||||
// The ref MUST change the encoded bytes: two custody frames identical in
|
||||
// (user,asset,amount) but differing in ref are DIFFERENT bytes — that is the
|
||||
// whole point (distinct d-chain txID -> distinct seen: key -> no replay).
|
||||
a := EncodeWithdraw("alice", mkAsset(7), 1000, [32]byte{0x01})
|
||||
b := EncodeWithdraw("alice", mkAsset(7), 1000, [32]byte{0x02})
|
||||
if string(a) == string(b) {
|
||||
t.Fatal("withdraw frames with distinct refs encoded identically — ref does not enter the wire (vault-drain dedup would collide)")
|
||||
}
|
||||
// open-market
|
||||
pool := [32]byte{0xde, 0xad}
|
||||
op, ob, oq, oerr := DecodeOpenMarket(EncodeOpenMarket(pool, 11, 22))
|
||||
if oerr != nil || op != pool || ob != 11 || oq != 22 {
|
||||
t.Fatalf("open-market round-trip = (%x,%d,%d) err=%v", op[:2], ob, oq, oerr)
|
||||
baseA, quoteA := mkAsset(11), mkAsset(22)
|
||||
op, ob, oq, oerr := DecodeOpenMarket(EncodeOpenMarket(pool, baseA, quoteA))
|
||||
if oerr != nil || op != pool || ob != baseA || oq != quoteA {
|
||||
t.Fatalf("open-market round-trip = (%x,%x,%x) err=%v", op[:2], ob[:2], oq[:2], oerr)
|
||||
}
|
||||
// balance-resp
|
||||
bs, ba, berr := DecodeBalanceResp(EncodeBalanceResp(StatusPlaced, 12345))
|
||||
|
||||
Reference in New Issue
Block a user