mirror of
https://github.com/luxfi/node.git
synced 2026-10-30 15:24:05 +00:00
The module already called itself github.com/luxfi/node while containing no node in Go: cmd/luxd was twenty-one lines that printed the size of a few types, and the Go column of the matrix measured a VM plugin. A repo that fetches the thing it claims to be has not replaced it, so the Go sources move in — 1515 files, the daemon and everything under it — and the build reaches outside this module for nothing. Rust and C++ sit beside it unchanged and are still measured against it by the differential. That is the point of one repo: the reference implementation and the two ports are the same checkout, so parity is a thing you can run rather than a thing you assert. Verified: builds, and the closure carries no lux-private — checked at build time rather than believed. The daemon starts twelve chains on a local network (P X C Q Z A B G K M F, plus D through its plugin) and reports healthy.
389 lines
14 KiB
Go
389 lines
14 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package chains
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/luxfi/ids"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// --- 1. an Ancestry response is a trust boundary, verified as a unit ---
|
|
|
|
// Each case is a response a peer can send that is not one contiguous ascending chain
|
|
// ENDING at the block asked about. Every one of them was believed before, because the
|
|
// walk indexed refs first and checked nothing.
|
|
//
|
|
// The wire order is OLDEST-FIRST (GetAncestors frames blocks ending at the requested
|
|
// one), so the requested block is last and its deepest ancestor is first.
|
|
func TestVerifyAncestryChunkRefusesMalformedResponses(t *testing.T) {
|
|
refs, _ := refChain(5) // heights 0..5, refs[i].Height == i
|
|
top := refs[5]
|
|
|
|
// The honest response for top, oldest-first: 3, 4, 5.
|
|
honest := []BlockRef{refs[3], refs[4], refs[5]}
|
|
|
|
other, _ := refChain(5)
|
|
|
|
cases := []struct {
|
|
name string
|
|
root ids.ID
|
|
refs []BlockRef
|
|
want error
|
|
}{
|
|
{"empty", top.ID, nil, ErrAncestryEmpty},
|
|
{
|
|
// Answers about a block we did not ask about, so the anchor's stake would be
|
|
// credited to a chain of the peer's choosing.
|
|
"wrong root", top.ID, []BlockRef{other[4], other[5]}, ErrAncestryWrongRoot,
|
|
},
|
|
{
|
|
// Descending instead of ascending: the requested block is not last.
|
|
"reversed order", top.ID,
|
|
[]BlockRef{refs[5], refs[4], refs[3]}, ErrAncestryWrongRoot,
|
|
},
|
|
{
|
|
// Two chains interleaved: not one path, so both would be credited.
|
|
"not contiguous", top.ID,
|
|
[]BlockRef{other[4], refs[5]}, ErrAncestryNotContiguous,
|
|
},
|
|
{
|
|
// A parent-linked pair whose heights skip, so the descent is not what it claims.
|
|
"height skips", top.ID,
|
|
[]BlockRef{{ID: refs[4].ID, Height: 2, Parent: refs[3].ID}, refs[5]}, ErrAncestryHeightStep,
|
|
},
|
|
{
|
|
// An id repeated inside one response.
|
|
"cycle", top.ID,
|
|
[]BlockRef{refs[3], refs[3], refs[4], refs[5]}, ErrAncestryCycle,
|
|
},
|
|
{
|
|
// Height 0 is genesis; a parent there is spurious.
|
|
"genesis claims a parent", refs[0].ID,
|
|
[]BlockRef{{ID: refs[0].ID, Height: 0, Parent: ids.GenerateTestID()}},
|
|
ErrAncestryHeightStep,
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
_, err := verifyAncestryChunk(c.root, c.refs, nil)
|
|
require.ErrorIs(t, err, c.want)
|
|
})
|
|
}
|
|
|
|
// The honest response passes, or the rules above are simply rejecting everything.
|
|
chunk, err := verifyAncestryChunk(top.ID, honest, nil)
|
|
require.NoError(t, err)
|
|
require.Equal(t, top.ID, chunk.Root)
|
|
require.Len(t, chunk.Blocks, 3)
|
|
require.Equal(t, refs[2].ID, chunk.Next, "Next is the parent of the DEEPEST block served")
|
|
require.False(t, chunk.Complete)
|
|
}
|
|
|
|
// One id is one block. A second, different answer for an id already believed means a
|
|
// peer is lying and we cannot tell which, so neither answer is taken.
|
|
func TestVerifyAncestryChunkRefusesConflictingMetadata(t *testing.T) {
|
|
refs, _ := refChain(5)
|
|
index := map[ids.ID]BlockRef{refs[4].ID: refs[4]}
|
|
known := func(id ids.ID) (BlockRef, bool) {
|
|
ref, ok := index[id]
|
|
return ref, ok
|
|
}
|
|
|
|
// Same id, different parent than the one already indexed. refs[4] is the parent of
|
|
// refs[5], so a forged refs[4] cannot also be contiguous — assert on the whole chain
|
|
// with only the metadata changed, which is exactly the lie a peer would tell.
|
|
lying := []BlockRef{{ID: refs[4].ID, Height: 4, Parent: ids.GenerateTestID()}, refs[5]}
|
|
_, err := verifyAncestryChunk(refs[5].ID, lying, known)
|
|
require.ErrorIs(t, err, ErrAncestryConflict)
|
|
|
|
// The consistent version of the same response is accepted.
|
|
_, err = verifyAncestryChunk(refs[5].ID, []BlockRef{refs[4], refs[5]}, known)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Genesis terminates the descent: Next empty, Complete true.
|
|
func TestVerifyAncestryChunkMarksGenesisComplete(t *testing.T) {
|
|
refs, _ := refChain(2)
|
|
chunk, err := verifyAncestryChunk(refs[2].ID, []BlockRef{refs[0], refs[1], refs[2]}, nil)
|
|
require.NoError(t, err)
|
|
require.True(t, chunk.Complete)
|
|
require.Equal(t, ids.Empty, chunk.Next)
|
|
}
|
|
|
|
// hostileAncestry serves a response that fails one rule, to prove the refusal reaches
|
|
// the naming decision rather than only the unit under test.
|
|
type hostileAncestry struct {
|
|
honest *stubAncestry
|
|
forge func(tip ids.ID, refs []BlockRef) []BlockRef
|
|
}
|
|
|
|
func (h *hostileAncestry) Ancestry(ctx context.Context, tip ids.ID, max int) ([]BlockRef, error) {
|
|
refs, err := h.honest.Ancestry(ctx, tip, max)
|
|
if err != nil || len(refs) == 0 {
|
|
return refs, err
|
|
}
|
|
return h.forge(tip, refs), nil
|
|
}
|
|
|
|
// A peer splicing a foreign block into an otherwise honest ancestry must not get that
|
|
// block — or anything after it — credited. The whole chunk is refused, so the walk
|
|
// cannot be steered onto a chain the peer chose.
|
|
func TestNameFrontierRefusesSplicedAncestry(t *testing.T) {
|
|
const w uint64 = 100
|
|
refs, byID := refChain(10)
|
|
common := refs[10]
|
|
prev := common
|
|
for i := 0; i < 6; i++ {
|
|
c := childRef(prev)
|
|
byID[c.ID] = c
|
|
prev = c
|
|
}
|
|
ahead := prev
|
|
|
|
foreign, _ := refChain(3)
|
|
beacons := nodeIDs(3)
|
|
policy := &BootstrapPolicy{
|
|
TrustedBeacons: equalBeacons(beacons, w),
|
|
MinResponses: 3,
|
|
NamingWindow: 4,
|
|
Source: &hostileAncestry{
|
|
honest: &stubAncestry{byID: byID},
|
|
forge: func(_ ids.ID, got []BlockRef) []BlockRef {
|
|
// Keep the LAST ref honest so the root check passes, then splice foreign
|
|
// blocks in front of it — the shape that gets a chain of the peer's
|
|
// choosing credited with the anchor's stake.
|
|
return []BlockRef{foreign[1], foreign[2], got[len(got)-1]}
|
|
},
|
|
},
|
|
}
|
|
_, err := policy.AcceptsFrontier(context.Background(), []BeaconReply{
|
|
reply(beacons[0], ahead.ID, w),
|
|
reply(beacons[1], common.ID, w),
|
|
reply(beacons[2], common.ID, w),
|
|
})
|
|
require.Error(t, err, "a spliced ancestry must never name a frontier")
|
|
require.ErrorIs(t, err, ErrNoBootstrapQuorum,
|
|
"nothing was credited, so this is a genuine disagreement rather than a spent budget")
|
|
}
|
|
|
|
// --- 2. a child request never outlives the operation containing it ---
|
|
|
|
func TestNamingBudgetClampsRequestInsideAttempt(t *testing.T) {
|
|
// The shipped defaults, which were inverted: 12s per request inside a 3s walk.
|
|
def := (&BootstrapPolicy{}).namingBudget()
|
|
require.Less(t, def.Request, def.Attempt,
|
|
"a request that may outlive its walk is a bound in name only")
|
|
require.Equal(t, bootstrapRequestTimeout, def.Request)
|
|
require.Equal(t, bootstrapAttemptTimeout, def.Attempt)
|
|
|
|
// A misconfiguration is clamped rather than allowed or fatal: refusing to bootstrap
|
|
// over a bad duration is worse than honouring the smaller of the two.
|
|
bad := (&BootstrapPolicy{Budget: NamingBudget{Attempt: time.Second, Request: time.Minute}}).namingBudget()
|
|
require.Equal(t, time.Second, bad.Request)
|
|
|
|
// NamingTimeout is the older name for the attempt bound; existing config keeps working.
|
|
old := (&BootstrapPolicy{NamingTimeout: 5 * time.Second}).namingBudget()
|
|
require.Equal(t, 5*time.Second, old.Attempt)
|
|
require.LessOrEqual(t, old.Request, old.Attempt)
|
|
}
|
|
|
|
// slowAncestry blocks until its context is done, so one silent peer would consume the
|
|
// whole walk if the per-request bound were not enforced.
|
|
type slowAncestry struct{ calls int }
|
|
|
|
func (s *slowAncestry) Ancestry(ctx context.Context, _ ids.ID, _ int) ([]BlockRef, error) {
|
|
s.calls++
|
|
<-ctx.Done()
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
func TestOneSilentPeerDoesNotConsumeTheWholeAttempt(t *testing.T) {
|
|
const w uint64 = 100
|
|
refs, _ := refChain(4)
|
|
beacons := nodeIDs(3)
|
|
src := &slowAncestry{}
|
|
policy := &BootstrapPolicy{
|
|
TrustedBeacons: equalBeacons(beacons, w),
|
|
MinResponses: 3,
|
|
Source: src,
|
|
Budget: NamingBudget{
|
|
Attempt: 900 * time.Millisecond,
|
|
Request: 100 * time.Millisecond,
|
|
},
|
|
}
|
|
start := time.Now()
|
|
_, err := policy.AcceptsFrontier(context.Background(), []BeaconReply{
|
|
reply(beacons[0], refs[4].ID, w),
|
|
reply(beacons[1], refs[3].ID, w),
|
|
reply(beacons[2], refs[2].ID, w),
|
|
})
|
|
require.Error(t, err)
|
|
require.Less(t, time.Since(start), 3*time.Second, "the attempt must stay bounded")
|
|
require.Greater(t, src.calls, 1,
|
|
"a silent peer costs ONE request timeout, so the walk still reaches other anchors")
|
|
}
|
|
|
|
// --- 3. the budget bounds one attempt; the cursor persists ---
|
|
|
|
// The recoverability property, stated directly: a gap far wider than one attempt's
|
|
// budget is crossed by repeated attempts, because each resumes where the last stopped.
|
|
// With a per-attempt ceiling and no cursor this loops forever at the same depth,
|
|
// and the node never crosses the gap.
|
|
func TestDescentResumesAcrossAttemptsUntilTheGapIsCrossed(t *testing.T) {
|
|
const w uint64 = 100
|
|
refs, byID := refChain(10)
|
|
common := refs[10]
|
|
prev := common
|
|
for i := 0; i < 120; i++ { // a gap 120 deep
|
|
c := childRef(prev)
|
|
byID[c.ID] = c
|
|
prev = c
|
|
}
|
|
ahead := prev
|
|
|
|
beacons := nodeIDs(3)
|
|
replies := []BeaconReply{
|
|
reply(beacons[0], ahead.ID, w),
|
|
reply(beacons[1], common.ID, w),
|
|
reply(beacons[2], common.ID, w),
|
|
}
|
|
// The cursor lives HERE, outliving every policy built below — the whole point.
|
|
progress := map[ids.ID]*NamingProgress{}
|
|
src := &stubAncestry{byID: byID}
|
|
|
|
newAttempt := func() *BootstrapPolicy {
|
|
return &BootstrapPolicy{
|
|
TrustedBeacons: equalBeacons(beacons, w),
|
|
MinResponses: 3,
|
|
NamingWindow: 8,
|
|
Source: src,
|
|
Progress: progress,
|
|
// One attempt can see 16 blocks; the gap is 120.
|
|
Budget: NamingBudget{MaxBlocks: 16, MaxRequests: 4},
|
|
}
|
|
}
|
|
|
|
// The first attempt cannot possibly reach the common ancestor.
|
|
_, err := newAttempt().AcceptsFrontier(context.Background(), replies)
|
|
require.ErrorIs(t, err, ErrNamingIncomplete, "out of budget, not out of agreement")
|
|
firstDepth := progress[ahead.ID].Traversed
|
|
require.Positive(t, firstDepth, "the first attempt must record the ground it covered")
|
|
require.NotEqual(t, ids.Empty, progress[ahead.ID].Cursor, "and where it stopped")
|
|
|
|
// Each further attempt must get STRICTLY deeper, or retrying is repetition.
|
|
var named *Frontier
|
|
depth := firstDepth
|
|
for i := 0; i < 40 && named == nil; i++ {
|
|
f, err := newAttempt().AcceptsFrontier(context.Background(), replies)
|
|
if err == nil {
|
|
named = f
|
|
break
|
|
}
|
|
require.ErrorIs(t, err, ErrNamingIncomplete)
|
|
got := progress[ahead.ID].Traversed
|
|
require.Greater(t, got, depth,
|
|
"attempt %d made no progress: a budget that resets with a cursor that does not "+
|
|
"is the same permanent ceiling it replaced", i+2)
|
|
depth = got
|
|
}
|
|
require.NotNil(t, named, "the gap must eventually be crossed")
|
|
require.Equal(t, common.ID, named.ID, "and the block named is the ⅔-backed common ancestor")
|
|
require.Equal(t, common.Height, named.Height)
|
|
}
|
|
|
|
// A request budget bounds round trips independently, so a peer dribbling one block per
|
|
// response cannot turn a block budget into unbounded requests.
|
|
func TestRequestBudgetBoundsDribbledResponses(t *testing.T) {
|
|
const w uint64 = 100
|
|
refs, byID := refChain(10)
|
|
prev := refs[10]
|
|
for i := 0; i < 60; i++ {
|
|
c := childRef(prev)
|
|
byID[c.ID] = c
|
|
prev = c
|
|
}
|
|
beacons := nodeIDs(3)
|
|
counted := &countingAncestry{inner: &stubAncestry{byID: byID}}
|
|
policy := &BootstrapPolicy{
|
|
TrustedBeacons: equalBeacons(beacons, w),
|
|
MinResponses: 3,
|
|
NamingWindow: 1, // one block per response
|
|
Source: counted,
|
|
Budget: NamingBudget{MaxBlocks: 10_000, MaxRequests: 5},
|
|
}
|
|
_, err := policy.AcceptsFrontier(context.Background(), []BeaconReply{
|
|
reply(beacons[0], prev.ID, w),
|
|
reply(beacons[1], refs[10].ID, w),
|
|
reply(beacons[2], refs[10].ID, w),
|
|
})
|
|
require.Error(t, err)
|
|
require.LessOrEqual(t, counted.calls, 5, "the request budget must bind independently")
|
|
}
|
|
|
|
type countingAncestry struct {
|
|
inner *stubAncestry
|
|
calls int
|
|
}
|
|
|
|
func (c *countingAncestry) Ancestry(ctx context.Context, tip ids.ID, max int) ([]BlockRef, error) {
|
|
c.calls++
|
|
return c.inner.Ancestry(ctx, tip, max)
|
|
}
|
|
|
|
// Retained descent state is bounded: anchors nobody reports any more are dropped, and
|
|
// what remains is capped by keeping the descents that got furthest.
|
|
func TestPruneNamingProgress(t *testing.T) {
|
|
stale, live := ids.GenerateTestID(), ids.GenerateTestID()
|
|
progress := map[ids.ID]*NamingProgress{
|
|
stale: {Anchor: stale, Traversed: 100, VerifiedRefs: make([]BlockRef, 100)},
|
|
live: {Anchor: live, Traversed: 10, VerifiedRefs: make([]BlockRef, 10)},
|
|
}
|
|
PruneNamingProgress(progress, map[ids.ID]StakeWeight{live: 1}, 1_000)
|
|
require.NotContains(t, progress, stale, "an unreported anchor can never be credited again")
|
|
require.Contains(t, progress, live)
|
|
|
|
// Over the cap, the least-progressed reported anchor goes first.
|
|
a, b := ids.GenerateTestID(), ids.GenerateTestID()
|
|
progress = map[ids.ID]*NamingProgress{
|
|
a: {Anchor: a, Traversed: 5, VerifiedRefs: make([]BlockRef, 5)},
|
|
b: {Anchor: b, Traversed: 90, VerifiedRefs: make([]BlockRef, 90)},
|
|
}
|
|
PruneNamingProgress(progress, map[ids.ID]StakeWeight{a: 1, b: 1}, 90)
|
|
require.NotContains(t, progress, a, "the shallowest descent loses the least progress")
|
|
require.Contains(t, progress, b)
|
|
}
|
|
|
|
// A policy with no Progress map still works — per-attempt descent, which is correct but
|
|
// cannot cross a gap wider than one attempt. Pinned so the nil path is not a panic.
|
|
func TestNilProgressStillNamesWithinOneAttempt(t *testing.T) {
|
|
const w uint64 = 100
|
|
refs, byID := refChain(10)
|
|
common := refs[10]
|
|
prev := common
|
|
for i := 0; i < 6; i++ {
|
|
c := childRef(prev)
|
|
byID[c.ID] = c
|
|
prev = c
|
|
}
|
|
beacons := nodeIDs(3)
|
|
policy := &BootstrapPolicy{
|
|
TrustedBeacons: equalBeacons(beacons, w),
|
|
MinResponses: 3,
|
|
NamingWindow: 4,
|
|
Source: &stubAncestry{byID: byID},
|
|
Progress: nil,
|
|
}
|
|
f, err := policy.AcceptsFrontier(context.Background(), []BeaconReply{
|
|
reply(beacons[0], prev.ID, w),
|
|
reply(beacons[1], common.ID, w),
|
|
reply(beacons[2], common.ID, w),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, common.ID, f.ID)
|
|
}
|