mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
An AllocateTx's nonce was covered by the owner's signature and compared to nothing. Every gate it passed the first time passed again for the identical bytes: the epoch was still current, the fingerprint still matched, the signer was still the range owner, the ML-DSA signature still verified. Anyone who saw an owner's allocate could resubmit it and consume the range's id space again, for free, as often as they liked. A range now carries the nonce of the allocation that last moved it, in the same key as its cursor, and takes only a strictly greater one. The state root covers it, so a chain cannot disagree about what has been spent and share a root. Accept committed whatever was staged. Every apply starts by dropping the version layer, so the layer belongs to whichever block was processed LAST — verify a sibling between a block's Verify and its Accept and the commit carried the sibling's writes under this block's id. Accept re-applies its own transactions from committed state, commits, and only then advances the chain in memory, under the lock it was mutating without. Block position is now checked before block content: the parent must be a block this node holds, the height must be parent+1, and the timestamp must not rewind or run past a skew ceiling floored at the parent. None of it was checked, so a block could claim any height — and the height it claimed became the chain's height and part of every state root computed after it. A proposer now leaves out what it cannot build rather than failing the whole build: one unauthorized allocate anyone could submit used to drain the mempool and destroy every manifest queued behind it. A verifier still refuses the whole block, which is the half that carries the safety property. Also: the height lookup reads an index instead of scanning the block map and returning whichever entry map iteration reached first; abandoned blocks are released; a rejected block leaves the index; boot no longer panics on a nil logger when the manager supplies no runtime; and the empty-set check in the owner gate is gone — pinning.Owner already answers that question, and answers the wider one the length check missed. Coverage 69.9% -> 97.9%, with 32 guards mutation-checked. The stale premise in SECURITY_review.md is recorded at its head: it reviewed a checkout that did not contain this VM.
223 lines
7.1 KiB
Go
223 lines
7.1 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package pinning
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"testing"
|
|
|
|
"github.com/luxfi/ids"
|
|
)
|
|
|
|
// node builds a deterministic NodeID from a small int so tests are readable.
|
|
func node(i byte) ids.NodeID {
|
|
var n ids.NodeID
|
|
n[0] = i
|
|
return n
|
|
}
|
|
|
|
func members(weights ...uint64) []Member {
|
|
ms := make([]Member, len(weights))
|
|
for i, w := range weights {
|
|
ms[i] = Member{NodeID: node(byte(i + 1)), Weight: w}
|
|
}
|
|
return ms
|
|
}
|
|
|
|
// TestEmptySet: no members → no owner. The VM must treat this as "cannot pin"
|
|
// (fail closed), never as "I am the owner".
|
|
func TestEmptySet(t *testing.T) {
|
|
if _, ok := Owner([]byte("bucket/0"), nil); ok {
|
|
t.Fatal("empty set must yield no owner")
|
|
}
|
|
if IsOwner([]byte("k"), node(1), nil) {
|
|
t.Fatal("empty set: nobody is owner")
|
|
}
|
|
}
|
|
|
|
// TestDeterministic: the SAME (key, set) yields the SAME owner across repeated
|
|
// and reordered evaluations. This is the property that lets ownership be
|
|
// resolved inside deterministic block Verify on every node.
|
|
func TestDeterministic(t *testing.T) {
|
|
ms := members(10, 10, 10, 10, 10)
|
|
key := []byte("bucket-A/object-Q")
|
|
want, ok := Owner(key, ms)
|
|
if !ok {
|
|
t.Fatal("expected an owner")
|
|
}
|
|
for i := 0; i < 100; i++ {
|
|
got, _ := Owner(key, ms)
|
|
if got != want {
|
|
t.Fatalf("non-deterministic owner: %s != %s", got, want)
|
|
}
|
|
}
|
|
// Reorder the slice; the winner must not change (Owner is order-independent).
|
|
rev := make([]Member, len(ms))
|
|
for i := range ms {
|
|
rev[len(ms)-1-i] = ms[i]
|
|
}
|
|
if got, _ := Owner(key, rev); got != want {
|
|
t.Fatalf("owner changed under reordering: %s != %s", got, want)
|
|
}
|
|
}
|
|
|
|
// TestSingleWriterPerRange: every key resolves to exactly one owner. There is no
|
|
// (key, set) input for which two distinct nodes both believe they own it — the
|
|
// safety core: two writes to the SAME object are impossible because only one
|
|
// node is ever the owner.
|
|
func TestSingleWriterPerRange(t *testing.T) {
|
|
ms := members(7, 3, 11, 5, 9, 2, 8)
|
|
for i := 0; i < 5000; i++ {
|
|
key := []byte(fmt.Sprintf("bkt-%d/obj-%d", i%13, i))
|
|
owner, ok := Owner(key, ms)
|
|
if !ok {
|
|
t.Fatalf("key %q: no owner", key)
|
|
}
|
|
ownerCount := 0
|
|
for _, m := range ms {
|
|
if IsOwner(key, m.NodeID, ms) {
|
|
ownerCount++
|
|
}
|
|
}
|
|
if ownerCount != 1 {
|
|
t.Fatalf("key %q: %d owners, want exactly 1 (owner=%s)", key, ownerCount, owner)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestParallelDifferentRanges: two DIFFERENT keys frequently resolve to two
|
|
// DIFFERENT owners — the win over raft. Under raft both writes serialize through
|
|
// one leader; under HRW pinning distinct ranges have distinct writers that
|
|
// proceed in parallel. We assert that across many key pairs a healthy fraction
|
|
// land on different owners (it must not collapse to one global writer).
|
|
func TestParallelDifferentRanges(t *testing.T) {
|
|
ms := members(10, 10, 10, 10, 10)
|
|
distinct := 0
|
|
const trials = 2000
|
|
for i := 0; i < trials; i++ {
|
|
a, _ := Owner([]byte(fmt.Sprintf("range-a-%d", i)), ms)
|
|
b, _ := Owner([]byte(fmt.Sprintf("range-b-%d", i)), ms)
|
|
if a != b {
|
|
distinct++
|
|
}
|
|
}
|
|
// With 5 equal validators, P(two random keys differ) = 4/5 = 0.8. Allow slack.
|
|
if distinct < trials*7/10 {
|
|
t.Fatalf("only %d/%d key pairs had distinct owners — writes are not parallelizing across ranges", distinct, trials)
|
|
}
|
|
}
|
|
|
|
// TestWeightedDistribution: ownership share tracks stake weight. A validator
|
|
// with 2x the weight should own ~2x the ranges. This is the economic alignment
|
|
// raft's single leader never had.
|
|
func TestWeightedDistribution(t *testing.T) {
|
|
// weights 1,2,3,4 → total 10. Expected shares 0.1,0.2,0.3,0.4.
|
|
ms := members(1, 2, 3, 4)
|
|
counts := map[ids.NodeID]int{}
|
|
const trials = 200000
|
|
for i := 0; i < trials; i++ {
|
|
owner, _ := Owner([]byte(fmt.Sprintf("o/%d", i)), ms)
|
|
counts[owner]++
|
|
}
|
|
totalWeight := uint64(0)
|
|
for _, m := range ms {
|
|
totalWeight += m.Weight
|
|
}
|
|
for _, m := range ms {
|
|
expected := float64(m.Weight) / float64(totalWeight)
|
|
actual := float64(counts[m.NodeID]) / float64(trials)
|
|
if math.Abs(actual-expected) > 0.02 {
|
|
t.Fatalf("node %s: share %.4f, expected %.4f (weight %d/%d)",
|
|
m.NodeID, actual, expected, m.Weight, totalWeight)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestZeroWeightNeverOwns: a zero-weight member (e.g. a validator being removed,
|
|
// reported with 0 stake) can never be pinned as a writer.
|
|
func TestZeroWeightNeverOwns(t *testing.T) {
|
|
ms := []Member{
|
|
{NodeID: node(1), Weight: 0},
|
|
{NodeID: node(2), Weight: 5},
|
|
{NodeID: node(3), Weight: 5},
|
|
}
|
|
for i := 0; i < 2000; i++ {
|
|
owner, ok := Owner([]byte(fmt.Sprintf("k/%d", i)), ms)
|
|
if !ok {
|
|
t.Fatalf("k/%d: expected an owner among non-zero members", i)
|
|
}
|
|
if owner == node(1) {
|
|
t.Fatalf("zero-weight node owned k/%d", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMinimalDisruption: removing ONE validator moves only the ranges that node
|
|
// owned; every other range keeps its owner. This bounds epoch-boundary re-pin
|
|
// churn — the property that makes validator-set change cheap rather than a full
|
|
// reshuffle. Adding back is symmetric.
|
|
func TestMinimalDisruption(t *testing.T) {
|
|
full := members(10, 10, 10, 10, 10) // nodes 1..5
|
|
removed := full[len(full)-1].NodeID // drop node 5
|
|
reduced := full[:len(full)-1]
|
|
|
|
const trials = 20000
|
|
moved, ownedByRemoved := 0, 0
|
|
for i := 0; i < trials; i++ {
|
|
key := []byte(fmt.Sprintf("obj/%d", i))
|
|
before, _ := Owner(key, full)
|
|
after, _ := Owner(key, reduced)
|
|
if before == removed {
|
|
ownedByRemoved++
|
|
continue // expected to move; it has to.
|
|
}
|
|
if before != after {
|
|
moved++ // a range NOT owned by the removed node should not move.
|
|
}
|
|
}
|
|
if moved != 0 {
|
|
t.Fatalf("%d ranges not owned by the removed node changed owner — HRW should keep them put", moved)
|
|
}
|
|
if ownedByRemoved == 0 {
|
|
t.Fatal("removed node owned zero ranges in the sample — test set too small to be meaningful")
|
|
}
|
|
t.Logf("removed node owned %d/%d ranges (all correctly re-pinned); 0 others moved", ownedByRemoved, trials)
|
|
}
|
|
|
|
// TestEpochFingerprintAgreement: the same validator set at the same height
|
|
// fingerprints identically regardless of input order; a different set or height
|
|
// fingerprints differently. This is the divergence detector a proposer stamps
|
|
// into its Allocate tx so peers reject a block pinned against the wrong epoch.
|
|
func TestEpochFingerprintAgreement(t *testing.T) {
|
|
ms := members(3, 7, 2, 9)
|
|
rev := make([]Member, len(ms))
|
|
for i := range ms {
|
|
rev[len(ms)-1-i] = ms[i]
|
|
}
|
|
if EpochFingerprint(100, ms) != EpochFingerprint(100, rev) {
|
|
t.Fatal("fingerprint must be order-independent")
|
|
}
|
|
if EpochFingerprint(100, ms) == EpochFingerprint(101, ms) {
|
|
t.Fatal("different height must fingerprint differently")
|
|
}
|
|
diff := append([]Member{}, ms...)
|
|
diff[0].Weight++
|
|
if EpochFingerprint(100, ms) == EpochFingerprint(100, diff) {
|
|
t.Fatal("different weight must fingerprint differently")
|
|
}
|
|
}
|
|
|
|
// TestSingleMember: a one-validator set always owns every range (the degenerate
|
|
// case — equivalent to a single-master deployment, but with no raft).
|
|
func TestSingleMember(t *testing.T) {
|
|
ms := members(1)
|
|
for i := 0; i < 100; i++ {
|
|
owner, ok := Owner([]byte(fmt.Sprintf("k/%d", i)), ms)
|
|
if !ok || owner != node(1) {
|
|
t.Fatalf("single member must own everything; got ok=%v owner=%s", ok, owner)
|
|
}
|
|
}
|
|
}
|