Files
dex/pkg/dchain/byzantine_determinism_test.go
zeekayandHanzo Dev f37409171f test(dex): prove correct DEX operation under Byzantine faults
Add deterministic, seeded (no wall-clock) Byzantine-fault tests across the
two settlement-critical layers: the D-Chain CLOB matcher (single source of
truth, re-executed by every validator) and the quorum-finality boundary.

pkg/dchain/byzantine_matcher_test.go — matcher safety invariants + the
forged-execRoot keystone:
  - self-cross never trades an account against itself (no value move)
  - price-time priority: best price fills first; worse maker untouched
  - over-fill clamped to min(order qty, resting liquidity)
  - cancelled/withdrawn order is never filled (no phantom liquidity)
  - a byzantine proposer claiming ANY execRoot other than the honest
    re-derivation is rejected by every validator; last-accepted unchanged
    (subsumes conservation/self-cross/over-fill/phantom fabrication)

pkg/dchain/byzantine_determinism_test.go — determinism under reordering:
  - N independent validators replaying the same block bytes stay in lockstep
    (identical execRoot + balances) across a fill
  - re-verify is a pure function of block bytes (no local wall-clock)
  - the consensus timestamp is bound into the execRoot: a MITM timestamp
    tamper is rejected (block is a tamper-evident unit)

pkg/dchain/byzantine_conservation_property_test.go — conservation + no-fork
under adversarial matcher load (8 seeds x 160 ops): self-cross, deep
multi-price books, oversized takers, cancel-then-cross; asserts
I=A+L per asset and proposer/validator agreement after every op. Surfaces
(counts, does not hide) fail-safe matcher refusals and proves the market
recovers (no permanent wedge). See FINDING below.

pkg/consensus/byzantine_quorum_test.go — f<n/3 finality boundary with REAL
Ed25519 validator signatures:
  - executable BFT bound: a safe+live quorum exists iff f<=floor((n-1)/3)
  - n=4 tolerates 1, FAILS at 2; n=7 tolerates 2, FAILS at 3; n=10 3/4
  - equivocation: no double-finalize at f*; safety unsalvageable beyond
  - withholding: live at f*, stalls beyond (safety over liveness)
  - forged/outsider/tampered votes never contribute to a quorum

FINDING (liveness, fail-safe — NOT a safety break): a self-cross-heavy
stream can drive the matcher to REFUSE one later submit at build
("resting order missing full settlement identity (orderuser)" /
"insufficient locked balance"). Root cause: pkg/lx/orderbook.go
tryMatchImmediateLocked self-trade skip mutates the in-memory book
(removeOrder + delete Orders/ordersMap) without a matching consensus write,
so the in-mem book and persisted orderuser:/reserve rows transiently
diverge. It is FAIL-SAFE (no mint, no burn, no fork; conservation intact)
and PER-TX (the market recovers on the next clean cross). Deterministic
repro: seed 42, ~step 153, market LUX/LUSD.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-01 20:53:06 -07:00

304 lines
11 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// byzantine_determinism_test.go proves threat #5: honest validators applying the
// same settlement (the matcher's fills committed in a block) derive BYTE-IDENTICAL
// state — the execution root is a PURE FUNCTION of (parent root, block txs, height)
// and NOTHING else (not wall-clock, not which node computes it, not the order the
// mempool happened to receive the txs). This is the fork-freedom property that
// makes a byzantine proposer's only lever the execRoot itself (rejected by
// byzantine_matcher_test.go's forged-root keystone): given the SAME block, every
// honest validator agrees.
//
// Two independent facts are proven:
//
// (1) N independent validators, each a fresh VM over its own DB, replaying the
// SAME block-byte stream stay in LOCKSTEP: identical execRoot and identical
// balances at every height (including across a fill).
// (2) The execRoot is INDEPENDENT of the block timestamp: a block whose wall-clock
// field is mutated still verifies and derives the identical root — no
// time.Now() leaks into the settlement commitment.
package dchain
import (
"context"
"encoding/binary"
"testing"
"github.com/luxfi/database/memdb"
"github.com/luxfi/dex/pkg/zapwire"
)
// proposeBytes runs one VM as the block PROPOSER over the given txs (Build ->
// re-parse+Verify -> Accept) and returns the accepted block's canonical bytes, so
// the exact same bytes can be replayed to independent validator VMs.
func proposeBytes(t *testing.T, vm *VM, txs ...*Tx) []byte {
t.Helper()
ctx := context.Background()
for _, tx := range txs {
vm.mempool.Add(tx)
}
built, err := vm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
rp, err := vm.ParseBlock(ctx, built.Bytes())
if err != nil {
t.Fatalf("ParseBlock(proposer): %v", err)
}
if err := rp.Verify(ctx); err != nil {
t.Fatalf("Verify(proposer): %v", err)
}
if err := rp.Accept(ctx); err != nil {
t.Fatalf("Accept(proposer): %v", err)
}
return built.Bytes()
}
// applyBytes replays a proposer's block bytes on an independent validator VM
// (Parse -> Verify -> Accept) exactly as consensus delivers them.
func applyBytes(t *testing.T, vm *VM, raw []byte) {
t.Helper()
ctx := context.Background()
b, err := vm.ParseBlock(ctx, raw)
if err != nil {
t.Fatalf("ParseBlock(validator): %v", err)
}
if err := b.Verify(ctx); err != nil {
t.Fatalf("Verify(validator): %v", err)
}
if err := b.Accept(ctx); err != nil {
t.Fatalf("Accept(validator): %v", err)
}
}
// TestByzantineDeterminism_IndependentValidatorsAgree stands up 4 independent
// validator VMs (each its own DB) and drives a full fund -> rest -> cross sequence
// where VM0 proposes and VM1..3 replay the identical bytes. After EVERY block all 4
// must agree on the execution root and on every traded balance. A single diverging
// validator would be a consensus fork (the failure a byzantine reorder tries to
// induce); lockstep agreement is the determinism proof.
func TestByzantineDeterminism_IndependentValidatorsAgree(t *testing.T) {
const N = 4
vms := make([]*VM, N)
for i := range vms {
vms[i], _ = newTestVM(t, memdb.New())
defer vms[i].Shutdown(context.Background())
}
// Genesis must be identical across independent fresh VMs, or block 1's parent
// would not resolve on the validators — assert it up front.
for i := 1; i < N; i++ {
if vms[i].lastAcceptedID != vms[0].lastAcceptedID {
t.Fatalf("genesis diverges: VM%d id=%s != VM0 id=%s", i, vms[i].lastAcceptedID, vms[0].lastAcceptedID)
}
}
const (
maker = "maker"
taker = "taker"
)
pool := [32]byte{0xde, 0x7e, 0x11} // "determ"
// The block program: fund+open, rest liquidity, cross. Each entry is proposed by
// VM0 and replayed to VM1..3.
programs := [][]*Tx{
{
depositTx(t, maker, assetLUX, 100),
depositTx(t, taker, assetLUSD, 1000),
openMarketTx(t, pool, assetLUX, assetLUSD),
},
{placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, maker)},
{submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, taker)},
}
assertLockstep := func(where string) {
t.Helper()
root0 := vms[0].lastRoot
h0 := vms[0].lastAcceptedHeight
for i := 1; i < N; i++ {
if vms[i].lastRoot != root0 {
t.Fatalf("%s: VM%d execRoot %x != VM0 %x (CONSENSUS FORK)", where, i, vms[i].lastRoot[:8], root0[:8])
}
if vms[i].lastAcceptedHeight != h0 {
t.Fatalf("%s: VM%d height %d != VM0 %d", where, i, vms[i].lastAcceptedHeight, h0)
}
}
// Balances must also match bit-for-bit across validators.
for _, who := range []string{maker, taker} {
for _, asset := range [][32]byte{assetLUX, assetLUSD} {
a0, l0, _ := vms[0].Balance(wireUser(t, who), asset)
for i := 1; i < N; i++ {
ai, li, _ := vms[i].Balance(wireUser(t, who), asset)
if ai != a0 || li != l0 {
t.Fatalf("%s: VM%d balance(%s) = (%d,%d) != VM0 (%d,%d)", where, i, who, ai, li, a0, l0)
}
}
}
}
}
for step, prog := range programs {
raw := proposeBytes(t, vms[0], prog...)
for i := 1; i < N; i++ {
applyBytes(t, vms[i], raw)
}
assertLockstep(map[int]string{0: "after fund", 1: "after place", 2: "after cross"}[step])
}
// After the cross, the fill actually moved value — confirm it's the real
// crossing state (not a no-op that would trivially "agree").
if a, _, _ := vms[0].Balance(wireUser(t, taker), assetLUX); a != 10 {
t.Fatalf("expected the cross to deliver 10 base to the taker, got %d (test did not exercise a fill)", a)
}
t.Logf("determinism: %d independent validators stayed in lockstep across %d blocks incl. a fill", N, len(programs))
}
// TestByzantineDeterminism_ReVerifyIsPureFunction proves that block verification
// is a PURE FUNCTION of the block bytes over the committed state — it reads NO
// local wall-clock and NO per-node randomness. The proposer's crossing block is
// re-derived on THREE independent validators (each a fresh DB advanced to the same
// prior state) and every one derives the IDENTICAL execution root and the IDENTICAL
// fill. The block carries its OWN consensus-agreed timestamp, so replaying it later
// (or on another node) can never diverge — the fork-freedom guarantee. This is the
// dchain analogue of the batch==sequential parity: the same committed settlement is
// reproduced bit-for-bit by every honest validator.
func TestByzantineDeterminism_ReVerifyIsPureFunction(t *testing.T) {
ctx := context.Background()
const N = 3
proposer, _ := newTestVM(t, memdb.New())
defer proposer.Shutdown(ctx)
validators := make([]*VM, N)
for i := range validators {
validators[i], _ = newTestVM(t, memdb.New())
defer validators[i].Shutdown(ctx)
}
const (
maker = "maker"
taker = "taker"
)
pool := [32]byte{0xc1, 0x0c, 0x00} // "clock"
// Advance proposer + all validators through fund + rest (identical bytes).
for _, prog := range [][]*Tx{
{
depositTx(t, maker, assetLUX, 100),
depositTx(t, taker, assetLUSD, 1000),
openMarketTx(t, pool, assetLUX, assetLUSD),
},
{placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, maker)},
} {
raw := proposeBytes(t, proposer, prog...)
for i := range validators {
applyBytes(t, validators[i], raw)
}
}
// Proposer builds the crossing block once.
proposer.mempool.Add(submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, taker))
built, err := proposer.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock cross: %v", err)
}
origRoot := built.(*Block).execRoot
raw := built.Bytes()
// Every independent validator re-derives the SAME root from the SAME bytes.
for i := range validators {
b, err := validators[i].ParseBlock(ctx, raw)
if err != nil {
t.Fatalf("VM%d ParseBlock: %v", i, err)
}
if err := b.Verify(ctx); err != nil {
t.Fatalf("VM%d Verify: %v", i, err)
}
if got := b.(*Block).execRoot; got != origRoot {
t.Fatalf("VM%d derived root %x != proposer %x (non-deterministic verify)", i, got[:8], origRoot[:8])
}
if err := b.Accept(ctx); err != nil {
t.Fatalf("VM%d Accept: %v", i, err)
}
if a, _, _ := validators[i].Balance(wireUser(t, taker), assetLUX); a != 10 {
t.Fatalf("VM%d settled %d base to taker, want 10", i, a)
}
}
t.Logf("re-verify is a pure function: %d independent validators derived root %x identically", N, origRoot[:8])
}
// TestByzantineProposer_TimestampBoundIntoRoot proves the block's consensus
// timestamp is BOUND into the execution commitment: a MITM/relay that alters ONLY
// the timestamp of an already-built block (e.g. to skew time-priority or the
// settlement identity) is REJECTED, because every honest validator re-derives a
// root over the mutated timestamp that no longer matches the block's claimed root.
// So a block is a self-consistent, tamper-evident unit — no field can be changed in
// flight without invalidating it.
func TestByzantineProposer_TimestampBoundIntoRoot(t *testing.T) {
ctx := context.Background()
proposer, _ := newTestVM(t, memdb.New())
defer proposer.Shutdown(ctx)
validator, _ := newTestVM(t, memdb.New())
defer validator.Shutdown(ctx)
const (
maker = "maker"
taker = "taker"
)
pool := [32]byte{0x71, 0xed, 0x00} // "tied"
for _, prog := range [][]*Tx{
{
depositTx(t, maker, assetLUX, 100),
depositTx(t, taker, assetLUSD, 1000),
openMarketTx(t, pool, assetLUX, assetLUSD),
},
{placePoolTx(t, pool, zapwire.SideSell, 5.0, 10.0, maker)},
} {
raw := proposeBytes(t, proposer, prog...)
applyBytes(t, validator, raw)
}
proposer.mempool.Add(submitPoolTx(t, pool, zapwire.SideBuy, false, 5.0, 10.0, taker))
built, err := proposer.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock cross: %v", err)
}
headBefore := validator.lastAcceptedID
heightBefore := validator.lastAcceptedHeight
// Byzantine: rewrite ONLY the timestamp field (bytes[40:48]); leave the claimed
// execRoot in place.
raw := append([]byte(nil), built.Bytes()...)
origTS := binary.BigEndian.Uint64(raw[40:48])
binary.BigEndian.PutUint64(raw[40:48], origTS^0xdead_beef_0000_0001)
b, err := validator.ParseBlock(ctx, raw)
if err != nil {
t.Logf("timestamp-tampered block rejected at ParseBlock: %v", err)
} else if verr := b.Verify(ctx); verr == nil {
t.Fatalf("TIMESTAMP TAMPER ACCEPTED: a block with an altered timestamp verified — the timestamp is not bound into the settlement commitment")
} else {
t.Logf("timestamp-tampered block rejected at Verify: %v", verr)
}
if validator.lastAcceptedID != headBefore || validator.lastAcceptedHeight != heightBefore {
t.Fatalf("state advanced on a rejected timestamp tamper: head %s->%s", headBefore, validator.lastAcceptedID)
}
// The UNMODIFIED block still verifies + accepts on the validator (the tamper did
// not poison anything).
good, err := validator.ParseBlock(ctx, built.Bytes())
if err != nil {
t.Fatalf("ParseBlock(honest): %v", err)
}
if err := good.Verify(ctx); err != nil {
t.Fatalf("honest block must still verify: %v", err)
}
if err := good.Accept(ctx); err != nil {
t.Fatalf("Accept(honest): %v", err)
}
}