Files
node/chains/red_pendingcontext_dos_test.go
zeekayandHanzo Dev b1cc108c9f gofmt
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 01:51:51 -07:00

146 lines
5.4 KiB
Go

// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// red_pendingcontext_dos_test.go — the catch-up path must stay bounded against a
// Byzantine peer.
//
// The frontier-sync wiring connects the AcceptedFrontier handler to
// requestContext(), which records each requested blockID in b.pendingContext.
// With nothing evicting from that map, a peer streaming AcceptedFrontier frames
// each naming a distinct random tip grows it without bound → OOM, and a peer that
// takes a request then withholds Context strands the asker forever.
// requestContext reaps entries past pendingContextTTL and hard-caps the map at
// maxPendingContext. These tests pin both properties.
package chains
import (
"context"
"testing"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/math/set"
"github.com/luxfi/node/mesh"
"github.com/luxfi/node/message"
"github.com/luxfi/node/proto/p2p"
)
// redStubNet implements the one Network method requestContext uses (Send); every
// other method is inherited from the embedded nil interface and is never called.
type redStubNet struct {
mesh.Network
sends int
}
func (s *redStubNet) Send(_ message.OutboundMessage, nodeIDs set.Set[ids.NodeID], _ ids.ID, _ uint32) set.Set[ids.NodeID] {
s.sends++
return nodeIDs
}
// redStubMsg implements the one OutboundMsgBuilder method requestContext uses.
type redStubMsg struct {
message.OutboundMsgBuilder
}
func (redStubMsg) GetAncestors(_ ids.ID, _ uint32, _ time.Duration, _ ids.ID, _ p2p.EngineType) (message.OutboundMessage, error) {
return nil, nil
}
func newRedTestHandler(net mesh.Network) *blockHandler {
return &blockHandler{
logger: log.NewNoOpLogger(),
net: net,
msgCreator: redStubMsg{},
chainID: ids.GenerateTestID(),
pendingContext: make(map[ids.ID]contextRequest),
}
}
// TestPendingContext_BoundedUnderFlood: a peer streaming distinct fake tips into
// requestContext can NEVER grow pendingContext past maxPendingContext.
func TestPendingContext_BoundedUnderFlood(t *testing.T) {
stubNet := &redStubNet{}
bh := newRedTestHandler(stubNet)
const N = 10_000 // ≫ the maxPendingContext cap — enough to prove "stays bounded"
from := ids.GenerateTestNodeID()
for i := 0; i < N; i++ {
bh.requestContext(context.Background(), from, ids.GenerateTestID())
}
if got := len(bh.pendingContext); got > maxPendingContext {
t.Fatalf("pendingContext unbounded: %d entries exceeds cap %d", got, maxPendingContext)
}
t.Logf("bounded: %d entries after a %d-distinct-tip flood (cap %d, sends=%d)",
len(bh.pendingContext), N, maxPendingContext, stubNet.sends)
}
// TestPendingContext_StaleEntriesReaped: a request whose Context is withheld past
// its TTL is reaped on the next requestContext, so the block is re-requestable from
// an honest peer rather than stranding the asker.
func TestPendingContext_StaleEntriesReaped(t *testing.T) {
bh := newRedTestHandler(&redStubNet{})
from := ids.GenerateTestNodeID()
stale := ids.GenerateTestID()
bh.pendingContext[stale] = contextRequest{
nodeID: from,
requestID: 1,
blockID: stale,
timestamp: time.Now().Add(-2 * pendingContextTTL),
}
// Any later request runs the reaper before recording its own entry.
bh.requestContext(context.Background(), from, ids.GenerateTestID())
if _, stillThere := bh.pendingContext[stale]; stillThere {
t.Fatalf("stale pendingContext entry (%v old) not reaped → re-strand persists", 2*pendingContextTTL)
}
}
// TestPendingContext_StaleEntryForTheSameBlockIsReRequested: the case the test
// above CANNOT see. It reaps by asking for a *different* block, which reaches the
// sweep. A node that is behind asks for exactly one block -- the one it is missing
// -- so it never takes that path: the dedup early-return fires first, every time,
// and the sweep is unreachable for the only blockID that matters.
//
// So the suppression is unbounded in time: the slot for that block sits expired
// indefinitely past pendingContextTTL, since only a request for some OTHER block
// would sweep it. It is also self-sustaining -- the wedge keeps the node asking
// for the same block, and asking for the same block is what keeps it wedged.
//
// Without the same-block reap, sends stays 1: the expired slot suppresses
// forever.
func TestPendingContext_StaleEntryForTheSameBlockIsReRequested(t *testing.T) {
stubNet := &redStubNet{}
bh := newRedTestHandler(stubNet)
from := ids.GenerateTestNodeID()
missing := ids.GenerateTestID() // the ONE block this node needs
bh.requestContext(context.Background(), from, missing)
if stubNet.sends != 1 {
t.Fatalf("setup: want 1 send, got %d", stubNet.sends)
}
// Positive control: while genuinely in flight, a re-ask MUST still be
// suppressed. Expiry must not become "no dedup at all".
bh.requestContext(context.Background(), from, missing)
if stubNet.sends != 1 {
t.Fatalf("live in-flight request was not deduped: sends=%d, want 1", stubNet.sends)
}
// The peer took the request and never answered. Age the slot past its TTL.
req := bh.pendingContext[missing]
req.timestamp = time.Now().Add(-2 * pendingContextTTL)
bh.pendingContext[missing] = req
// Same block, asked again -- the only thing a behind node ever does.
bh.requestContext(context.Background(), from, missing)
if stubNet.sends != 2 {
t.Fatalf("expired request still suppressed its own block: sends=%d, want 2 "+
"-- the block a behind node needs is the block it can never ask for again",
stubNet.sends)
}
}