mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
Every determinism test in this package compares ComposeRoot against ComposeRoot, so both sides move together and a change to the composition stays green. That is the pattern that let the ledger operand into v1.14.0 unseen. Genesis is now pinned against the written construction; every root from height 1 on was not pinned at all, which is the same fork one block later. TestBlockRootGolden fixes distinct non-zero operands and pins the value, recomputed on the spec side with a separate keccak so the vector cannot be brought back into agreement by editing the code it checks. Height is pinned at two values, and operand swaps are rejected, so a reorder is caught as surely as an added term. Injecting an operand into ComposeRoot fails it; so does moving height ahead of the ledger. Co-authored-by: Hanzo Dev <[email protected]>
199 lines
7.7 KiB
Go
199 lines
7.7 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package dchain
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/sha3"
|
|
|
|
"github.com/luxfi/dex/pkg/dex"
|
|
"github.com/luxfi/dex/pkg/zapwire"
|
|
)
|
|
|
|
// The block root is pinned here the same way the genesis root is pinned in
|
|
// genesis_test.go, and for the same reason. Genesis was the fork that happened;
|
|
// every root above height 0 is the identical fork waiting to happen, because two
|
|
// nodes that compose the root differently disagree on the first block they build
|
|
// and neither can tell why.
|
|
//
|
|
// Determinism tests do not cover this. Comparing ComposeRoot against ComposeRoot
|
|
// moves both sides together, so a change to the composition keeps every such test
|
|
// green — which is exactly how the ledger operand landed in v1.14.0 unnoticed. A
|
|
// vector has to say what the value IS.
|
|
//
|
|
// blockRootVector's operands are all distinct and none is zero, so a reordering is
|
|
// caught as surely as an added or dropped term.
|
|
var blockRootVector = struct {
|
|
parent, book, trade, txr, ledger [Size]byte
|
|
height uint64
|
|
root string
|
|
}{
|
|
parent: [Size]byte{0x01, 0x11},
|
|
book: [Size]byte{0x02, 0x22},
|
|
trade: [Size]byte{0x03, 0x33},
|
|
txr: [Size]byte{0x04, 0x44},
|
|
ledger: [Size]byte{0x05, 0x55},
|
|
height: 0x0102030405060708,
|
|
root: "5120604309ac9adc9c63e4f5ac46ca81d543c787e2e41458c5d0f0a2e77ff79e",
|
|
}
|
|
|
|
// specBlockRoot recomputes a block root straight from the written construction:
|
|
//
|
|
// BlockRoot = keccak256( "dex/block-root/v1" ‖ parent ‖ book ‖ trade ‖ tx ‖ ledger ‖ height_u64_le )
|
|
//
|
|
// Independent of the VM: a different keccak implementation and the concatenation
|
|
// spelled out, so the vector cannot be brought back into agreement by editing the
|
|
// code it checks.
|
|
func specBlockRoot(parent, book, trade, txr, ledger [Size]byte, height uint64) [Size]byte {
|
|
var h [8]byte
|
|
binary.LittleEndian.PutUint64(h[:], height)
|
|
|
|
k := sha3.NewLegacyKeccak256()
|
|
k.Write([]byte("dex/block-root/v1"))
|
|
k.Write(parent[:])
|
|
k.Write(book[:])
|
|
k.Write(trade[:])
|
|
k.Write(txr[:])
|
|
k.Write(ledger[:])
|
|
k.Write(h[:])
|
|
|
|
var root [Size]byte
|
|
copy(root[:], k.Sum(nil))
|
|
return root
|
|
}
|
|
|
|
// TestBlockRootGolden pins the composition every root above height 0 is built
|
|
// from. A build that changes it forks the chain at the first block it produces.
|
|
func TestBlockRootGolden(t *testing.T) {
|
|
v := blockRootVector
|
|
got := ComposeRoot(v.parent, v.book, v.trade, v.txr, v.ledger, v.height)
|
|
|
|
if want := specBlockRoot(v.parent, v.book, v.trade, v.txr, v.ledger, v.height); got != want {
|
|
t.Fatalf("block root = %x, specification says %x", got, want)
|
|
}
|
|
if hex.EncodeToString(got[:]) != v.root {
|
|
t.Fatalf("block root = %x, pinned %s\n"+
|
|
"A node composing roots this way cannot agree with one that does not, from height 1 on.",
|
|
got, v.root)
|
|
}
|
|
|
|
// Height is in the preimage and it is the operand a refactor drops or moves
|
|
// without noticing: it is the only one that is not already a root, and every
|
|
// other term is what the block committed. Both values are pinned so a change
|
|
// cannot be absorbed by updating one of them.
|
|
next := ComposeRoot(v.parent, v.book, v.trade, v.txr, v.ledger, v.height+1)
|
|
if next == got {
|
|
t.Fatal("the height operand does not reach the root")
|
|
}
|
|
if want := specBlockRoot(v.parent, v.book, v.trade, v.txr, v.ledger, v.height+1); next != want {
|
|
t.Fatalf("block root at height+1 = %x, specification says %x", next, want)
|
|
}
|
|
|
|
// Every other operand must reach the root too, in its own position. Swapping
|
|
// any pair of them is a different chain and must be a different root.
|
|
for _, swap := range []struct {
|
|
name string
|
|
root [Size]byte
|
|
}{
|
|
{"parent<->book", ComposeRoot(v.book, v.parent, v.trade, v.txr, v.ledger, v.height)},
|
|
{"trade<->tx", ComposeRoot(v.parent, v.book, v.txr, v.trade, v.ledger, v.height)},
|
|
{"tx<->ledger", ComposeRoot(v.parent, v.book, v.trade, v.ledger, v.txr, v.height)},
|
|
} {
|
|
if swap.root == got {
|
|
t.Errorf("%s: swapping two operands left the root unchanged", swap.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestExecutionRootDeterministic proves the state root is a pure function of the
|
|
// committed rows/trades/txs + parent + height: same inputs -> identical root,
|
|
// computed independently twice.
|
|
func TestExecutionRootDeterministic(t *testing.T) {
|
|
ts := time.Unix(0, 1_700_000_000_000_000_000).UTC()
|
|
txs := []*Tx{
|
|
placeTx(t, zapwire.SideSell, 101.0, 5.0, "maker-a"),
|
|
submitTx(t, zapwire.SideBuy, false, 101.0, 2.0, "taker-x"),
|
|
}
|
|
fills, rows := applyAll(t, 10, ts, txs)
|
|
|
|
var parent [Size]byte
|
|
r1, b1, tr1, x1 := ExecutionRoot(parent, rows, fills, txs, [Size]byte{}, 10)
|
|
r2, b2, tr2, x2 := ExecutionRoot(parent, rows, fills, txs, [Size]byte{}, 10)
|
|
|
|
if r1 != r2 || b1 != b2 || tr1 != tr2 || x1 != x2 {
|
|
t.Fatal("ExecutionRoot not deterministic across two computations")
|
|
}
|
|
|
|
// The root chains the parent: a different parent -> different root.
|
|
parent2 := [Size]byte{0x01}
|
|
r3, _, _, _ := ExecutionRoot(parent2, rows, fills, txs, [Size]byte{}, 10)
|
|
if r3 == r1 {
|
|
t.Error("root did not change with a different parent (no history binding)")
|
|
}
|
|
|
|
// The root binds the height: a different height -> different root.
|
|
r4, _, _, _ := ExecutionRoot(parent, rows, fills, txs, [Size]byte{}, 11)
|
|
if r4 == r1 {
|
|
t.Error("root did not change with a different height")
|
|
}
|
|
}
|
|
|
|
// TestExecutionRootChangesWithState proves any change to committed state moves
|
|
// the root (it actually commits the book/fills, not a constant).
|
|
func TestExecutionRootChangesWithState(t *testing.T) {
|
|
ts := time.Unix(0, 1_700_000_000_000_000_000).UTC()
|
|
var parent [Size]byte
|
|
|
|
txsA := []*Tx{placeTx(t, zapwire.SideSell, 101.0, 5.0, "maker-a")}
|
|
_, rowsA := applyAll(t, 1, ts, txsA)
|
|
rootA, _, _, _ := ExecutionRoot(parent, rowsA, nil, txsA, [Size]byte{}, 1)
|
|
|
|
// Different resting price -> different book root -> different exec root.
|
|
txsB := []*Tx{placeTx(t, zapwire.SideSell, 102.0, 5.0, "maker-a")}
|
|
_, rowsB := applyAll(t, 1, ts, txsB)
|
|
rootB, _, _, _ := ExecutionRoot(parent, rowsB, nil, txsB, [Size]byte{}, 1)
|
|
|
|
if rootA == rootB {
|
|
t.Error("execution root identical for different book state")
|
|
}
|
|
}
|
|
|
|
// TestEmptyRootsAreStable pins the empty sub-root values to merkle.EmptyRoot via
|
|
// the public ExecutionRoot path (no rows/trades/txs).
|
|
func TestEmptyRootsAreStable(t *testing.T) {
|
|
var parent [Size]byte
|
|
r1, b1, tr1, x1 := ExecutionRoot(parent, nil, nil, nil, [Size]byte{}, 0)
|
|
r2, b2, tr2, x2 := ExecutionRoot(parent, nil, nil, nil, [Size]byte{}, 0)
|
|
if r1 != r2 || b1 != b2 || tr1 != tr2 || x1 != x2 {
|
|
t.Fatal("empty roots not stable")
|
|
}
|
|
// Sub-roots over empty leaf sets must equal each other (all merkle.EmptyRoot).
|
|
if b1 != tr1 || tr1 != x1 {
|
|
t.Error("empty sub-roots differ; expected all merkle.EmptyRoot")
|
|
}
|
|
}
|
|
|
|
// TestBookRootIgnoresInsertionOrder proves the book root is computed over the
|
|
// canonical row stream, so it is independent of how rows are presented (rows go
|
|
// through BookToRows which canonicalizes).
|
|
func TestBookRootIgnoresInsertionOrder(t *testing.T) {
|
|
ts := time.Unix(0, 1_700_000_000_000_000_000).UTC()
|
|
|
|
b1 := dex.NewOrderBook("X")
|
|
b1.ConsensusAddOrder(&dex.Order{ID: 1, Type: dex.Limit, Side: dex.Buy, Price: 100, Size: 1, User: "a", Timestamp: ts})
|
|
b1.ConsensusAddOrder(&dex.Order{ID: 2, Type: dex.Limit, Side: dex.Sell, Price: 101, Size: 1, User: "b", Timestamp: ts})
|
|
|
|
b2 := dex.NewOrderBook("X")
|
|
b2.ConsensusAddOrder(&dex.Order{ID: 2, Type: dex.Limit, Side: dex.Sell, Price: 101, Size: 1, User: "b", Timestamp: ts})
|
|
b2.ConsensusAddOrder(&dex.Order{ID: 1, Type: dex.Limit, Side: dex.Buy, Price: 100, Size: 1, User: "a", Timestamp: ts})
|
|
|
|
if bookRoot(dex.BookToRows(b1)) != bookRoot(dex.BookToRows(b2)) {
|
|
t.Error("book root depends on insertion order")
|
|
}
|
|
}
|