mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
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]>
304 lines
11 KiB
Go
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)
|
|
}
|
|
}
|