Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
768de0e
fix(discv4): scope pending requests to the destination peer
MysticRyuujin Jul 29, 2026
4d1f995
fix(discv5): pair the UDP port with its address family
MysticRyuujin Jul 29, 2026
8ccef33
fix(bootnode): gate admission counters on the layer entry, tear down …
MysticRyuujin Jul 29, 2026
e7dce7f
fix(enr): satisfy rlp.Encoder and enforce the size limit on ingest
MysticRyuujin Jul 29, 2026
f50f721
bootnode: add --serve-all to disable classification and serve every peer
Jul 29, 2026
49aac52
style(enr): fix import ordering in encoding test
MysticRyuujin Jul 29, 2026
6856adc
refactor: reuse and simplification cleanups
MysticRyuujin Jul 29, 2026
c3d9e16
fix(discovery): require endpoint proof before a response mutates state
MysticRyuujin Jul 29, 2026
6dd2f08
fix(discv4): bind response matching to the address actually queried
MysticRyuujin Jul 29, 2026
917fd8b
fix(bootnode): honour EnableIPDiscovery and never override an explici…
MysticRyuujin Jul 29, 2026
fa30b68
fix(discv5): stop unauthenticated packets moving, deleting or replaci…
MysticRyuujin Jul 29, 2026
01b9fbc
fix(discv5): bind requests to their destination and gate the IP vote …
MysticRyuujin Jul 29, 2026
3b1fd31
fix(discv4): promote a node's address only on a proven endpoint
MysticRyuujin Jul 29, 2026
ee7fd2c
fix(discv5,discv4): address review of the endpoint-proof commits
MysticRyuujin Jul 29, 2026
73aff56
refactor: cleanups from the review of the endpoint-proof work
MysticRyuujin Jul 29, 2026
5940355
fix(discv5): keep Zone in the session address comparison
MysticRyuujin Jul 29, 2026
165af25
fix(bootnode): refresh last-seen on both layers for a dual-layer peer
MysticRyuujin Jul 29, 2026
a3a31fa
test(bootnode): exercise the real dual-layer staleness path
MysticRyuujin Jul 29, 2026
f3ae9a7
fix(db): key nodes and bad_nodes on (nodeid, layer)
MysticRyuujin Jul 29, 2026
97adbbe
fix(nodes): persist admitted nodes and drain the write queue on close
MysticRyuujin Jul 29, 2026
3535cd1
fix(nodes): count active and inactive as sets, not a subtraction
MysticRyuujin Jul 29, 2026
b5b78dc
fix(transport): distinguish other-protocol packets from unrecognised …
MysticRyuujin Jul 29, 2026
f510adf
fix(discv4): coalesce the PONG-driven ENR refresh
MysticRyuujin Jul 29, 2026
aea3161
fix(bootnode): arm the fork ENR refresh at the next boundary
MysticRyuujin Jul 29, 2026
ac12e1c
fix: address review findings in the devnet fix set
MysticRyuujin Jul 29, 2026
df72c15
fix: close the residual windows from the second review round
MysticRyuujin Jul 29, 2026
179cbd7
fix: close the third-round review findings
MysticRyuujin Jul 29, 2026
5c45964
fix(nodes): never clear dirty flags for writes that did not land
MysticRyuujin Jul 29, 2026
9e32cdb
fix(nodes): back off between consecutive batch failures
MysticRyuujin Jul 29, 2026
f7edae0
fix(nodes): treat a partial batch as a failure for backoff purposes
MysticRyuujin Jul 29, 2026
7f7f12a
fix(bootnode): poll after a fork boundary instead of skipping to the …
MysticRyuujin Jul 29, 2026
65f3f34
test(bootnode): cover serve-all pooling one node ID into both layers
MysticRyuujin Jul 30, 2026
cade30c
fix: address review findings on lookup blocking, capability loss and …
MysticRyuujin Jul 30, 2026
16802ba
fix: close the residuals on the probe and capability merge
MysticRyuujin Jul 30, 2026
e82d88b
fix(nodes): make protocol adoption and probe application atomic
MysticRyuujin Jul 30, 2026
42367b2
fix(nodes): merge protocols and record in one operation
MysticRyuujin Jul 30, 2026
b2bf6fa
fix(nodes): let a newer record replace a protocol pointer
MysticRyuujin Jul 30, 2026
8cc3cc8
fix(nodes): label a protocol pointer with its own record sequence
MysticRyuujin Jul 30, 2026
7ca11af
fix(nodes): keep protocol adoption to filling empty slots
MysticRyuujin Jul 30, 2026
e0346b1
fix(services): gate protocol-support installs on record freshness
MysticRyuujin Jul 30, 2026
8dafd1e
docs: add the Kurtosis testing guide for bootnodoor
MysticRyuujin Jul 30, 2026
b19a416
discv4: always send NEIGHBORS and advertise the real TCP port
pk910 Jul 30, 2026
b0240b4
discv5: cap NODES response to <=15 nodes / <=5 packets
pk910 Jul 30, 2026
8bcac67
discv4: evict a stale node instead of dropping new peers when the map…
pk910 Jul 30, 2026
48385e9
fix(webui): gate pprof handler on the --pprof flag
MysticRyuujin Jul 30, 2026
754d791
docs: record the 2026-07-30 kurtosis sweep and BPO blob coverage
MysticRyuujin Jul 30, 2026
950d85a
Merge PR #45: discv4/discv5 remainders — NEIGHBORS, NODES cap, node-m…
MysticRyuujin Jul 30, 2026
9c2e56c
log: demote per-peer discovery events to debug
MysticRyuujin Jul 30, 2026
eafc979
docs: untrack the kurtosis testing guide
MysticRyuujin Jul 30, 2026
d08b6c7
chore: drop the ignore rule for the local testing notes
MysticRyuujin Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions bootnode/clconfig/admit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package clconfig

import (
"net"
"sync"
"testing"
"time"

"github.com/ethereum/go-ethereum/crypto"
"github.com/ethpandaops/bootnodoor/enr"
)

func admitTestFilter(t *testing.T) *ForkDigestFilter {
t.Helper()

cfg := &Config{
SecondsPerSlot: 12,
customSlotsPerEpoch: 32,
genesisForkVersion: [4]byte{0x00, 0x00, 0x00, 0x01},
forks: []forkDefinition{
{name: "Altair", epoch: 0, parsedVersion: [4]byte{0x01, 0x00, 0x00, 0x00}},
},
}
cfg.SetGenesisTime(uint64(time.Now().Unix()) - 60)
return NewForkDigestFilter(cfg, time.Hour)
}

func recordWithEth2(t *testing.T, eth2 []byte) *enr.Record {
t.Helper()

key, err := crypto.GenerateKey()
if err != nil {
t.Fatalf("generate key: %v", err)
}
rec := enr.New()
if err := rec.Set("ip", net.IPv4(1, 2, 3, 4)); err != nil {
t.Fatalf("set ip: %v", err)
}
if eth2 != nil {
if err := rec.Set("eth2", eth2); err != nil {
t.Fatalf("set eth2: %v", err)
}
}
rec.SetSeq(1)
if err := rec.Sign(key); err != nil {
t.Fatalf("sign: %v", err)
}
return rec
}

// Matches is the per-packet classification entry point, so it must decide
// without moving any counter — those numbers report admissions, and packet
// traffic dwarfs admissions by orders of magnitude.
func TestMatchesRecordsNoStats(t *testing.T) {
filter := admitTestFilter(t)
current := filter.GetCurrentForkDigest()

cases := []struct {
name string
eth2 []byte
want bool
}{
{"no eth2", nil, false},
{"malformed", []byte{0x01, 0x02}, false},
{"current digest", EncodeETH2Field(current, [4]byte{0x01, 0x00, 0x00, 0x00}, ^uint64(0)), true},
{"unknown digest", EncodeETH2Field(ForkDigest{0xde, 0xad, 0xbe, 0xef}, [4]byte{}, 0), false},
}

for _, tc := range cases {
rec := recordWithEth2(t, tc.eth2)
if got := filter.Matches(rec); got != tc.want {
t.Errorf("Matches(%s) = %v, want %v", tc.name, got, tc.want)
}
}

stats := filter.GetStats()
if stats.TotalChecks != 0 || stats.AcceptedCurrent != 0 || stats.AcceptedOld != 0 ||
stats.AcceptedHistorical != 0 || stats.RejectedInvalid != 0 {
t.Fatalf("Matches moved counters: %+v", stats)
}
}

// Admit is the only counting entry point, and TotalChecks must always equal the
// sum of the buckets — the web UI renders them in one table, so a reader must
// never see rows that do not add up.
func TestAdmitRecordsOneBucketPerCall(t *testing.T) {
filter := admitTestFilter(t)
current := filter.GetCurrentForkDigest()

cases := []struct {
name string
eth2 []byte
wantAccept bool
wantChecks int
wantCurrent int
wantInvalid int
}{
{"no eth2 is uncounted", nil, false, 0, 0, 0},
{"current digest", EncodeETH2Field(current, [4]byte{0x01, 0x00, 0x00, 0x00}, ^uint64(0)), true, 1, 1, 0},
{"unknown digest", EncodeETH2Field(ForkDigest{0xde, 0xad, 0xbe, 0xef}, [4]byte{}, 0), false, 2, 1, 1},
{"malformed", []byte{0x01, 0x02}, false, 3, 1, 2},
}

for _, tc := range cases {
rec := recordWithEth2(t, tc.eth2)
if got := filter.Admit(rec); got != tc.wantAccept {
t.Errorf("Admit(%s) = %v, want %v", tc.name, got, tc.wantAccept)
}

stats := filter.GetStats()
if stats.TotalChecks != tc.wantChecks {
t.Errorf("after %s: TotalChecks = %d, want %d", tc.name, stats.TotalChecks, tc.wantChecks)
}
if stats.AcceptedCurrent != tc.wantCurrent {
t.Errorf("after %s: AcceptedCurrent = %d, want %d", tc.name, stats.AcceptedCurrent, tc.wantCurrent)
}
if stats.RejectedInvalid != tc.wantInvalid {
t.Errorf("after %s: RejectedInvalid = %d, want %d", tc.name, stats.RejectedInvalid, tc.wantInvalid)
}

sum := stats.AcceptedCurrent + stats.AcceptedOld + stats.AcceptedHistorical + stats.RejectedInvalid
if stats.TotalChecks != sum {
t.Errorf("after %s: TotalChecks = %d but buckets sum to %d", tc.name, stats.TotalChecks, sum)
}
}
}

// Update mutates oldForkDigests while packets are being filtered, so the digest
// lookups must happen under the lock. Publishing the map reference and indexing
// it afterwards is a concurrent map read/write, which aborts the process.
func TestAdmitConcurrentWithUpdate(t *testing.T) {
filter := admitTestFilter(t)

rec := recordWithEth2(t, EncodeETH2Field(ForkDigest{0x11, 0x22, 0x33, 0x44}, [4]byte{}, 0))

var wg sync.WaitGroup
stop := make(chan struct{})

// Update only writes oldForkDigests on a fork activation or a grace expiry,
// so seed an already-expired entry each round to make its cleanup loop
// delete — the same map write, just at test frequency.
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
filter.mu.Lock()
filter.oldForkDigests[ForkDigest{0x11, 0x22, 0x33, 0x44}] = time.Now().Add(-2 * time.Hour)
filter.mu.Unlock()
filter.Update()
}
}
}()

for i := 0; i < 8; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < 200; j++ {
if i%2 == 0 {
filter.Admit(rec)
} else {
filter.Matches(rec)
}
}
}(i)
}

// The Update loop runs until the readers finish, then is joined separately.
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
time.Sleep(200 * time.Millisecond)
close(stop)
<-done

stats := filter.GetStats()
sum := stats.AcceptedCurrent + stats.AcceptedOld + stats.AcceptedHistorical + stats.RejectedInvalid
if stats.TotalChecks != sum {
t.Fatalf("TotalChecks = %d but buckets sum to %d under concurrency", stats.TotalChecks, sum)
}
}
58 changes: 39 additions & 19 deletions bootnode/clconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"fmt"
"math"
"os"
"slices"
"sort"
"strings"
"time"
Expand Down Expand Up @@ -76,6 +77,29 @@ func (c *Config) getForks() []forkDefinition {
return c.forks
}

// ForkEpochs returns every scheduled fork epoch in ascending order, including
// BPO entries and excluding far-future placeholders.
//
// Distinct from GetAllForkDigestInfos, which deduplicates by digest: the eth2
// next-fork tuple changes at a boundary even when the current digest does not,
// so a caller scheduling work per boundary needs the raw epochs.
func (c *Config) ForkEpochs() []uint64 {
forks := c.getForks()

epochs := make([]uint64, 0, len(forks))
for i := range forks {
epoch := forks[i].epoch
if epoch == math.MaxUint64 {
continue
}
if len(epochs) > 0 && epochs[len(epochs)-1] == epoch {
continue
}
epochs = append(epochs, epoch)
}
return epochs
}

// GetForkEpoch returns the epoch for a given fork name.
// Returns nil if the fork is not defined.
func (c *Config) GetForkEpoch(forkName string) *uint64 {
Expand Down Expand Up @@ -549,22 +573,19 @@ func (c *Config) currentEpochNow() (uint64, bool) {
// genesis, each registered fork (including BPO pseudo-forks), and each blob
// schedule boundary. Sorted ascending, deduplicated.
func (c *Config) forkBoundaryEpochs() []uint64 {
seen := map[uint64]bool{0: true}
epochs := []uint64{0}
add := func(epoch uint64) {
if epoch != math.MaxUint64 && !seen[epoch] {
seen[epoch] = true
epochs = append(epochs, epoch)
}
}
for _, fork := range c.getForks() {
add(fork.epoch)
if fork.epoch != math.MaxUint64 {
epochs = append(epochs, fork.epoch)
}
}
for _, entry := range c.BlobSchedule {
add(entry.Epoch)
if entry.Epoch != math.MaxUint64 {
epochs = append(epochs, entry.Epoch)
}
}
sort.Slice(epochs, func(i, j int) bool { return epochs[i] < epochs[j] })
return epochs
slices.Sort(epochs)
return slices.Compact(epochs)
}

// GetCurrentForkDigest returns the fork digest for the current epoch.
Expand Down Expand Up @@ -659,14 +680,13 @@ type ForkDigestInfo struct {
// for same-epoch intermediate forks (never current on the wire) are
// intentionally not included.
func (c *Config) GetAllForkDigests() []ForkDigest {
var digests []ForkDigest
seen := make(map[ForkDigest]bool)
for _, epoch := range c.forkBoundaryEpochs() {
digest := c.GetForkDigestForEpoch(epoch)
if !seen[digest] {
seen[digest] = true
digests = append(digests, digest)
}
infos := c.GetAllForkDigestInfos()
if len(infos) == 0 {
return nil
}
digests := make([]ForkDigest, 0, len(infos))
for _, info := range infos {
digests = append(digests, info.Digest)
}
return digests
}
Expand Down
Loading