From b6f824e4fdaef66e497b20571c3c94cdc7e909c5 Mon Sep 17 00:00:00 2001 From: Damilola Edwards Date: Mon, 6 Jul 2026 11:17:04 +0100 Subject: [PATCH 01/20] Bound discv4 handler node map to prevent unbounded growth The handler tracked every distinct node ID it ever saw in a map that was never evicted and had no size limit. getOrCreateNode inserted unconditionally, including one entry per record in every inbound NEIGHBORS packet with no bond gate, so the map grew without bound over normal operation and an unauthenticated peer could accelerate it to memory exhaustion by flooding NEIGHBORS records with fabricated public keys. Add a hard cap (MaxNodes, default 50000) enforced on insert: once full a new node is still returned so the packet is handled but is not retained. Evict stale unbonded nodes in the existing periodic cleanup (NodeTTL, default 5m); bonded nodes are kept until their bond expires. Both are configurable and default when unset, so existing call sites are unchanged. --- discv4/protocol/handler.go | 45 +++++++++++++++ discv4/protocol/handler_test.go | 99 +++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 discv4/protocol/handler_test.go diff --git a/discv4/protocol/handler.go b/discv4/protocol/handler.go index 9a6b91c..8c566db 100644 --- a/discv4/protocol/handler.go +++ b/discv4/protocol/handler.go @@ -100,6 +100,16 @@ type HandlerConfig struct { // ExpirationWindow is the acceptable time range for packet expiration (default 20s) ExpirationWindow time.Duration + // MaxNodes is the maximum number of nodes to track (default 50000). + // Once reached, new nodes are handled but not retained until a slot frees up, + // keeping memory bounded under floods of distinct node IDs. + MaxNodes int + + // NodeTTL is how long an unbonded node is retained since it was last seen + // before it becomes eligible for eviction (default 5 minutes). Bonded nodes + // are kept until their bond expires. + NodeTTL time.Duration + // Callbacks (all optional) OnPing OnPingCallback OnPongReceived OnPongReceivedCallback @@ -156,6 +166,15 @@ const ( // neighborsTimeout is how long to wait for additional NEIGHBORS packets neighborsTimeout = 2 * time.Second + + // defaultMaxNodes is the default cap on tracked nodes. It bounds memory + // against floods of distinct node IDs (for example fabricated NEIGHBORS + // records) that would otherwise grow the map without limit. + defaultMaxNodes = 50000 + + // defaultNodeTTL is how long an unbonded node is retained since it was last + // seen before it becomes eligible for eviction. + defaultNodeTTL = 5 * time.Minute ) // NewHandler creates a new protocol handler. @@ -170,6 +189,12 @@ func NewHandler(ctx context.Context, config HandlerConfig, transport Transport) if config.ExpirationWindow == 0 { config.ExpirationWindow = defaultExpirationWindow } + if config.MaxNodes == 0 { + config.MaxNodes = defaultMaxNodes + } + if config.NodeTTL == 0 { + config.NodeTTL = defaultNodeTTL + } h := &Handler{ config: config, @@ -810,6 +835,15 @@ func (h *Handler) getOrCreateNode(id node.ID, pubkey *ecdsa.PublicKey, addr *net // Create new node n = node.New(pubkey, addr) + + // Bound the map so an unauthenticated flood of distinct node IDs (for + // example fabricated NEIGHBORS records) cannot grow it without limit. Stale + // unbonded entries are reclaimed by cleanup; until a slot frees up we still + // return the node so the packet is handled, but we do not retain it. + if len(h.nodes) >= h.config.MaxNodes { + return n + } + h.nodes[id] = n return n } @@ -905,6 +939,17 @@ func (h *Handler) cleanup() { } } h.pendingNeighborsMu.Unlock() + + // Evict stale, unbonded nodes so the map stays bounded. Bonded nodes are + // kept until their bond expires, after which IsBonded reports false and they + // become eligible here. + h.nodesMu.Lock() + for id, n := range h.nodes { + if !n.IsBonded() && now.Sub(n.LastSeen()) > h.config.NodeTTL { + delete(h.nodes, id) + } + } + h.nodesMu.Unlock() } // Statistics diff --git a/discv4/protocol/handler_test.go b/discv4/protocol/handler_test.go new file mode 100644 index 0000000..d21528f --- /dev/null +++ b/discv4/protocol/handler_test.go @@ -0,0 +1,99 @@ +package protocol + +import ( + "context" + "crypto/ecdsa" + "net" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethpandaops/bootnodoor/discv4/node" +) + +func testAddr() *net.UDPAddr { + return &net.UDPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 30303} +} + +// makeNodeID returns a fresh, valid secp256k1 public key and its node ID, +// standing in for a distinct peer (or a fabricated NEIGHBORS record). +func makeNodeID(t *testing.T) (*ecdsa.PublicKey, node.ID) { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + pub := &key.PublicKey + return pub, node.PubkeyToID(pub) +} + +// TestGetOrCreateNodeRespectsMaxNodes verifies the tracked-node map is hard +// bounded: a flood of distinct node IDs (the NM-012/NM-104 vector) cannot grow +// it past MaxNodes. +func TestGetOrCreateNodeRespectsMaxNodes(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + const maxNodes = 100 + h := NewHandler(ctx, HandlerConfig{MaxNodes: maxNodes, NodeTTL: time.Hour}, nil) + + for i := 0; i < maxNodes*5; i++ { + pub, id := makeNodeID(t) + h.getOrCreateNode(id, pub, testAddr()) + } + + if got := len(h.AllNodes()); got != maxNodes { + t.Fatalf("node map not bounded: got %d nodes, want %d", got, maxNodes) + } +} + +// TestCleanupEvictsStaleUnbondedNodes verifies cleanup reclaims unbonded nodes +// past their TTL while keeping bonded nodes. +func TestCleanupEvictsStaleUnbondedNodes(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + h := NewHandler(ctx, HandlerConfig{MaxNodes: 1000, NodeTTL: 20 * time.Millisecond}, nil) + + pubStale, idStale := makeNodeID(t) + h.getOrCreateNode(idStale, pubStale, testAddr()) + + pubBonded, idBonded := makeNodeID(t) + bonded := h.getOrCreateNode(idBonded, pubBonded, testAddr()) + bonded.MarkPongReceived(time.Hour) // establish a live bond + + time.Sleep(40 * time.Millisecond) // age both past NodeTTL + + h.cleanup() + + if h.GetNode(idStale) != nil { + t.Error("stale unbonded node was not evicted") + } + if h.GetNode(idBonded) == nil { + t.Error("bonded node was wrongly evicted") + } +} + +// TestCleanupReclaimsFloodedNodes verifies that a burst of unbonded nodes (a +// NEIGHBORS-injection flood) is fully reclaimed once it ages out. +func TestCleanupReclaimsFloodedNodes(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + h := NewHandler(ctx, HandlerConfig{MaxNodes: 10000, NodeTTL: 10 * time.Millisecond}, nil) + + for i := 0; i < 500; i++ { + pub, id := makeNodeID(t) + h.getOrCreateNode(id, pub, testAddr()) + } + if got := len(h.AllNodes()); got != 500 { + t.Fatalf("setup: expected 500 tracked nodes, got %d", got) + } + + time.Sleep(20 * time.Millisecond) + h.cleanup() + + if got := len(h.AllNodes()); got != 0 { + t.Fatalf("flooded nodes not reclaimed: %d still tracked", got) + } +} From d9a264fbc9859527ba6874ed521deb86270bb04b Mon Sep 17 00:00:00 2001 From: Damilola Edwards Date: Mon, 6 Jul 2026 11:39:45 +0100 Subject: [PATCH 02/20] Guard discv4/discv5 protocol pointers on the generic node The generic node published its v4Node and v5Node pointers with no synchronization. SetV4 and SetV5 wrote them lock free while V4, V5, HasV4, HasV5, PeerID, Enode, UpdateENR and CalculateScore read them lock free. These run on different goroutines in normal operation, for example protocol detection updating the pointers while a scoring sweep, a database batch or the web UI reads them, so the access is a data race. The check then deref readers are worse: SetV5(nil) can clear the pointer between the nil check and the call, so PeerID and CalculateScore can dereference nil and crash the daemon. Guard both pointers with the node mutex that already protects addr. Simple accessors take the read lock. SetV4 and SetV5 take the write lock around the pointer store only. The check then deref readers capture the pointer under the read lock and release before the call, so a concurrent clear can no longer be observed mid call. --- nodes/node.go | 47 +++++++++--- nodes/node_test.go | 176 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 9 deletions(-) create mode 100644 nodes/node_test.go diff --git a/nodes/node.go b/nodes/node.go index ac8c42f..04ba943 100644 --- a/nodes/node.go +++ b/nodes/node.go @@ -165,27 +165,38 @@ func (n *Node) SetAddr(addr *net.UDPAddr) { // V4 returns the discv4 node if available. func (n *Node) V4() *node.Node { + n.mu.RLock() + defer n.mu.RUnlock() return n.v4Node } // V5 returns the discv5 node if available. func (n *Node) V5() *discv5node.Node { + n.mu.RLock() + defer n.mu.RUnlock() return n.v5Node } // HasV4 returns true if this node supports discv4. func (n *Node) HasV4() bool { + n.mu.RLock() + defer n.mu.RUnlock() return n.v4Node != nil } // HasV5 returns true if this node supports discv5. func (n *Node) HasV5() bool { + n.mu.RLock() + defer n.mu.RUnlock() return n.v5Node != nil } // SetV4 sets the discv4 node and marks protocol support dirty. func (n *Node) SetV4(v4 *node.Node) { + n.mu.Lock() n.v4Node = v4 + n.mu.Unlock() + if v4 != nil && n.nodeStats != nil { // Ensure callback is set up (in case stats were created elsewhere) n.setupSharedStatsCallback() @@ -197,7 +208,10 @@ func (n *Node) SetV4(v4 *node.Node) { // SetV5 sets the discv5 node and marks protocol support dirty. func (n *Node) SetV5(v5 *discv5node.Node) { + n.mu.Lock() n.v5Node = v5 + n.mu.Unlock() + if v5 != nil && n.nodeStats != nil { // Ensure callback is set up (in case stats were created elsewhere) n.setupSharedStatsCallback() @@ -209,8 +223,12 @@ func (n *Node) SetV5(v5 *discv5node.Node) { // Enode returns the node's enode:// URL representation. func (n *Node) Enode() *enode.Enode { - if n.v4Node != nil { - return n.v4Node.Enode() + n.mu.RLock() + v4 := n.v4Node + n.mu.RUnlock() + + if v4 != nil { + return v4.Enode() } // Build from generic node info @@ -388,8 +406,12 @@ func (n *Node) Record() *enr.Record { // PeerID returns the libp2p peer ID for this node. // Delegates to the v5 node if available, otherwise builds it from the public key. func (n *Node) PeerID() string { - if n.v5Node != nil { - return n.v5Node.PeerID() + n.mu.RLock() + v5 := n.v5Node + n.mu.RUnlock() + + if v5 != nil { + return v5.PeerID() } // Fallback: build peer ID from public key if n.pubKey != nil { @@ -410,8 +432,11 @@ func (n *Node) UpdateENR(newRecord *enr.Record) bool { n.enr = newRecord // Update v5 node if available - if n.v5Node != nil { - n.v5Node.UpdateENR(newRecord) + n.mu.RLock() + v5 := n.v5Node + n.mu.RUnlock() + if v5 != nil { + v5.UpdateENR(newRecord) } return true @@ -434,7 +459,11 @@ func (n *Node) IsAlive(maxAge time.Duration, maxFailures int) bool { // CalculateScore computes a quality score for the node. // Delegates to the v5 node if available. func (n *Node) CalculateScore(forkInfo *ForkScoringInfo) float64 { - if n.v5Node != nil { + n.mu.RLock() + v5 := n.v5Node + n.mu.RUnlock() + + if v5 != nil { // Cast forkInfo to the v5 node's ForkScoringInfo type if forkInfo != nil { // Convert table.ForkScoringInfo to discv5/node.ForkScoringInfo @@ -444,10 +473,10 @@ func (n *Node) CalculateScore(forkInfo *ForkScoringInfo) float64 { GenesisForkDigest: forkInfo.GenesisForkDigest, GracePeriodEnd: forkInfo.GracePeriodEnd, } - return n.v5Node.CalculateScore(v5ForkInfo) + return v5.CalculateScore(v5ForkInfo) } // If forkInfo is nil or wrong type, call with nil - return n.v5Node.CalculateScore(nil) + return v5.CalculateScore(nil) } // Basic fallback score based on success rate successCount := n.SuccessCount() diff --git a/nodes/node_test.go b/nodes/node_test.go new file mode 100644 index 0000000..1b4acf5 --- /dev/null +++ b/nodes/node_test.go @@ -0,0 +1,176 @@ +package nodes + +import ( + "net" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + discv4node "github.com/ethpandaops/bootnodoor/discv4/node" + discv5node "github.com/ethpandaops/bootnodoor/discv5/node" + "github.com/ethpandaops/bootnodoor/enr" +) + +// newTestNode builds a node for exercising the protocol-pointer accessors. +// nodeStats is left nil so SetV4/SetV5 only touch the v4Node/v5Node pointers, +// keeping these tests focused on their synchronization. +func newTestNode(t *testing.T) *Node { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + return &Node{ + pubKey: &key.PublicKey, + addr: &net.UDPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 30303}, + } +} + +func makeV4(t *testing.T) *discv4node.Node { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + return discv4node.New(&key.PublicKey, &net.UDPAddr{IP: net.IPv4(2, 2, 2, 2), Port: 30303}) +} + +func makeV5(t *testing.T) *discv5node.Node { + 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(3, 3, 3, 3)); err != nil { + t.Fatalf("set ip: %v", err) + } + if err := rec.Set("udp", uint16(30303)); err != nil { + t.Fatalf("set udp: %v", err) + } + if err := rec.Sign(key); err != nil { + t.Fatalf("sign: %v", err) + } + v5, err := discv5node.New(rec) + if err != nil { + t.Fatalf("new v5 node: %v", err) + } + return v5 +} + +// TestNodeConcurrentProtocolPointerAccess exercises the readers and writers of +// the v4Node/v5Node pointers concurrently. Under the race detector it fails if +// any access to those pointers is unsynchronized. +func TestNodeConcurrentProtocolPointerAccess(t *testing.T) { + n := newTestNode(t) + v4 := makeV4(t) + v5 := makeV5(t) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + writer := func(set func(i int)) { + defer wg.Done() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + set(i) + } + } + } + + wg.Add(2) + go writer(func(i int) { + if i%2 == 0 { + n.SetV4(v4) + } else { + n.SetV4(nil) + } + }) + go writer(func(i int) { + if i%2 == 0 { + n.SetV5(v5) + } else { + n.SetV5(nil) + } + }) + + for r := 0; r < 4; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + _ = n.V4() + _ = n.V5() + _ = n.HasV4() + _ = n.HasV5() + _ = n.PeerID() + _ = n.Enode() + _ = n.String() + } + } + }() + } + + time.Sleep(200 * time.Millisecond) + close(stop) + wg.Wait() +} + +// TestNodeNoNilDerefDuringProtocolSwap targets the check-then-deref reader +// PeerID: while a node repeatedly gains and loses its v5 support, PeerID must +// never dereference a pointer that was cleared between the nil check and the +// call. CalculateScore reads the same pointer through the identical locked +// path. +func TestNodeNoNilDerefDuringProtocolSwap(t *testing.T) { + n := newTestNode(t) + v5 := makeV5(t) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + if i%2 == 0 { + n.SetV5(v5) + } else { + n.SetV5(nil) + } + } + } + }() + + for r := 0; r < 4; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + _ = n.PeerID() + _ = n.V5() + _ = n.HasV5() + } + } + }() + } + + time.Sleep(200 * time.Millisecond) + close(stop) + wg.Wait() +} From 36ec1ff1047ee6b0acef36d4b61a686de8883b8f Mon Sep 17 00:00:00 2001 From: Damilola Edwards Date: Tue, 7 Jul 2026 14:21:22 +0100 Subject: [PATCH 03/20] Deliver discv4 responses without blocking the dispatch goroutine Response handlers delivered a matched response by sending on the pending request's channel with a plain send. That channel is buffered with size one and is read at most once by the waiter, so a duplicate, replayed or late PONG, NEIGHBORS or ENRRESPONSE found the buffer full or the waiter gone and parked the packet dispatch goroutine forever. An unauthenticated peer could leak goroutines by replaying responses. Route the three sends through a helper that uses a non blocking send, so an extra response is dropped instead of parking the goroutine. The first legitimate response still lands in the empty buffer and is delivered. --- discv4/protocol/handler.go | 20 ++++- discv4/protocol/response_delivery_test.go | 104 ++++++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 discv4/protocol/response_delivery_test.go diff --git a/discv4/protocol/handler.go b/discv4/protocol/handler.go index 9a6b91c..2d3f014 100644 --- a/discv4/protocol/handler.go +++ b/discv4/protocol/handler.go @@ -337,7 +337,7 @@ func (h *Handler) handlePong(fromNode *node.Node, from *net.UDPAddr, pong *Pong) // Match to pending request req := h.getPendingRequest(string(pong.ReplyTok)) if req != nil { - req.ResponseChan <- pong + h.deliverResponse(req, pong) } // Check if remote node has newer ENR @@ -463,7 +463,7 @@ func (h *Handler) handleNeighbors(fromNode *node.Node, from *net.UDPAddr, neighb h.pendingNeighborsMu.Unlock() if finalPending != nil { - matchedReq.ResponseChan <- finalPending.Nodes + h.deliverResponse(matchedReq, finalPending.Nodes) } }() } @@ -522,7 +522,7 @@ func (h *Handler) handleENRResponse(fromNode *node.Node, from *net.UDPAddr, resp // Match to pending request req := h.getPendingRequest(string(resp.ReplyTok)) if req != nil { - req.ResponseChan <- resp.Record + h.deliverResponse(req, resp.Record) } return nil @@ -867,6 +867,20 @@ func (h *Handler) removePendingRequest(hash string) { h.requestsMu.Unlock() } +// deliverResponse hands a response to a waiting request without blocking. +// +// ResponseChan is buffered (size 1) and read at most once by the waiter. A +// duplicate, replayed or late response therefore finds the buffer full or the +// waiter already gone. Sending directly would park the packet-dispatch +// goroutine forever, so an unauthenticated peer could leak goroutines by +// replaying responses. The non-blocking send drops the extra response instead. +func (h *Handler) deliverResponse(req *PendingRequest, resp interface{}) { + select { + case req.ResponseChan <- resp: + default: + } +} + // Cleanup // cleanupLoop periodically cleans up expired requests and neighbors. diff --git a/discv4/protocol/response_delivery_test.go b/discv4/protocol/response_delivery_test.go new file mode 100644 index 0000000..c2052a5 --- /dev/null +++ b/discv4/protocol/response_delivery_test.go @@ -0,0 +1,104 @@ +package protocol + +import ( + "context" + "sync" + "testing" + "time" +) + +func newTestHandler(t *testing.T) (*Handler, context.CancelFunc) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + return NewHandler(ctx, HandlerConfig{}, nil), cancel +} + +// TestDeliverResponseNeverBlocks verifies that a flood of duplicate responses +// for a single request never parks the delivering goroutines and that exactly +// one value is handed to the waiter. With a blocking send every duplicate past +// the first would leak a goroutine. +func TestDeliverResponseNeverBlocks(t *testing.T) { + h, cancel := newTestHandler(t) + defer cancel() + + req := h.addPendingRequest([]byte("reqhash"), nil, PingPacket) + + const dups = 200 + var wg sync.WaitGroup + wg.Add(dups) + for i := 0; i < dups; i++ { + go func() { + defer wg.Done() + h.deliverResponse(req, "pong") + }() + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("deliverResponse blocked: delivering goroutines leaked") + } + + // Exactly one value is buffered for the waiter, the rest were dropped. + select { + case <-req.ResponseChan: + default: + t.Fatal("expected one delivered response") + } + select { + case <-req.ResponseChan: + t.Fatal("more than one response buffered") + default: + } +} + +// TestDuplicateResponsesWaiterGetsOneNoLeak mirrors the real flow: a waiter +// consumes one response and removes the request while duplicate responses race +// through getPendingRequest and deliverResponse. The waiter must receive +// exactly one response and every delivery goroutine must finish. +func TestDuplicateResponsesWaiterGetsOneNoLeak(t *testing.T) { + h, cancel := newTestHandler(t) + defer cancel() + + hash := []byte("reqhash") + req := h.addPendingRequest(hash, nil, PingPacket) + + got := make(chan interface{}, 1) + var waiter sync.WaitGroup + waiter.Add(1) + go func() { + defer waiter.Done() + resp := <-req.ResponseChan + h.removePendingRequest(string(hash)) + got <- resp + }() + + const dups = 200 + var wg sync.WaitGroup + wg.Add(dups) + for i := 0; i < dups; i++ { + go func() { + defer wg.Done() + if r := h.getPendingRequest(string(hash)); r != nil { + h.deliverResponse(r, "pong") + } + }() + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("duplicate responses leaked delivering goroutines") + } + + waiter.Wait() + select { + case <-got: + default: + t.Fatal("waiter did not receive a response") + } +} From e79f3382ee3916861be6843301f2be4e01d27fa4 Mon Sep 17 00:00:00 2001 From: Damilola Edwards Date: Tue, 7 Jul 2026 14:39:37 +0100 Subject: [PATCH 04/20] Bound accumulation of pending NEIGHBORS responses handleNeighbors accumulated node records for any sender into a per node slice with no cap, and refreshed the entry timestamp on every packet, so the periodic cleanup never evicted it. An unauthenticated peer could send a steady stream of NEIGHBORS and grow memory without limit, and each packet scheduled its own delivery goroutine. Only accept NEIGHBORS in response to a FINDNODE we actually sent to that node, so unsolicited responses are dropped. Cap the accumulated nodes at one k-bucket. Schedule delivery once, on the first packet. Base cleanup on the entry creation time instead of the last received time, so a stream of packets can no longer keep an entry alive. --- discv4/protocol/handler.go | 82 +++++++----- discv4/protocol/pending_neighbors_test.go | 144 ++++++++++++++++++++++ 2 files changed, 194 insertions(+), 32 deletions(-) create mode 100644 discv4/protocol/pending_neighbors_test.go diff --git a/discv4/protocol/handler.go b/discv4/protocol/handler.go index 9a6b91c..29f84ca 100644 --- a/discv4/protocol/handler.go +++ b/discv4/protocol/handler.go @@ -136,9 +136,6 @@ type PendingNeighborsResponse struct { // CreatedAt is when we received the first packet CreatedAt time.Time - - // LastRecv is when we received the last packet - LastRecv time.Time } const ( @@ -154,8 +151,18 @@ const ( // cleanupInterval is how often we run cleanup cleanupInterval = 5 * time.Second - // neighborsTimeout is how long to wait for additional NEIGHBORS packets + // neighborsTimeout is how long a pending NEIGHBORS entry may live before + // cleanup evicts it as a backstop. neighborsTimeout = 2 * time.Second + + // neighborsCollectWindow is how long we accumulate multi-packet NEIGHBORS + // before delivering the collected nodes to the waiting FINDNODE. + neighborsCollectWindow = 100 * time.Millisecond + + // maxNeighborsPerResponse caps the nodes accumulated for one FINDNODE. A + // discv4 FINDNODE returns at most one k-bucket, so anything beyond this is a + // flood and is dropped. + maxNeighborsPerResponse = 16 ) // NewHandler creates a new protocol handler. @@ -402,6 +409,14 @@ func (h *Handler) handleNeighbors(fromNode *node.Node, from *net.UDPAddr, neighb h.incrementFindnodeResponsesRecv() + // Only accept NEIGHBORS in response to a FINDNODE we actually sent to this + // node. Dropping unsolicited NEIGHBORS prevents a peer we never queried from + // making us accumulate node records without bound. + matchedReq := h.findPendingFindnode(fromNode.ID()) + if matchedReq == nil { + return nil + } + // Convert nodes nodes := make([]*node.Node, 0, len(neighbors.Nodes)) for _, n := range neighbors.Nodes { @@ -417,45 +432,35 @@ func (h *Handler) handleNeighbors(fromNode *node.Node, from *net.UDPAddr, neighb } nodeID := node.PubkeyToID(pubkey) - discoveredNode := h.getOrCreateNode(nodeID, pubkey, addr) - nodes = append(nodes, discoveredNode) + nodes = append(nodes, h.getOrCreateNode(nodeID, pubkey, addr)) } - // Try to match to pending request - // We use the sender's node ID as the key for pending FINDNODE requests + // Accumulate the response, keyed by the sender's node ID. key := string(fromNode.IDBytes()) h.pendingNeighborsMu.Lock() pending := h.pendingNeighbors[key] - if pending == nil { - pending = &PendingNeighborsResponse{ - Nodes: nodes, - CreatedAt: time.Now(), - LastRecv: time.Now(), - } + firstPacket := pending == nil + if firstPacket { + pending = &PendingNeighborsResponse{CreatedAt: time.Now()} h.pendingNeighbors[key] = pending - } else { - pending.Nodes = append(pending.Nodes, nodes...) - pending.LastRecv = time.Now() } - h.pendingNeighborsMu.Unlock() - - // Check if we have a pending request waiting for this - h.requestsMu.RLock() - var matchedReq *PendingRequest - for _, req := range h.requests { - if req.PacketType == FindnodePacket && req.ToNode.ID() == fromNode.ID() { - matchedReq = req - break + // Cap the accumulated nodes so a burst of NEIGHBORS cannot grow the entry + // without bound. Extra nodes past the cap are dropped. + if room := maxNeighborsPerResponse - len(pending.Nodes); room > 0 { + if len(nodes) > room { + nodes = nodes[:room] } + pending.Nodes = append(pending.Nodes, nodes...) } - h.requestsMu.RUnlock() + h.pendingNeighborsMu.Unlock() - if matchedReq != nil { - // Deliver accumulated nodes after a short delay - // (in case more NEIGHBORS packets arrive) + // Deliver once, after a short window that lets multi-packet responses + // arrive. Only the first packet schedules delivery, so a flood cannot spawn + // a goroutine per packet. + if firstPacket { go func() { - time.Sleep(100 * time.Millisecond) + time.Sleep(neighborsCollectWindow) h.pendingNeighborsMu.Lock() finalPending := h.pendingNeighbors[key] @@ -860,6 +865,19 @@ func (h *Handler) getPendingRequest(hash string) *PendingRequest { return h.requests[hash] } +// findPendingFindnode returns a pending FINDNODE request awaiting a response +// from the given node, or nil if none exists. +func (h *Handler) findPendingFindnode(id node.ID) *PendingRequest { + h.requestsMu.RLock() + defer h.requestsMu.RUnlock() + for _, req := range h.requests { + if req.PacketType == FindnodePacket && req.ToNode != nil && req.ToNode.ID() == id { + return req + } + } + return nil +} + // removePendingRequest removes a pending request. func (h *Handler) removePendingRequest(hash string) { h.requestsMu.Lock() @@ -900,7 +918,7 @@ func (h *Handler) cleanup() { // Clean up old pending neighbors h.pendingNeighborsMu.Lock() for key, pending := range h.pendingNeighbors { - if now.Sub(pending.LastRecv) > neighborsTimeout { + if now.Sub(pending.CreatedAt) > neighborsTimeout { delete(h.pendingNeighbors, key) } } diff --git a/discv4/protocol/pending_neighbors_test.go b/discv4/protocol/pending_neighbors_test.go new file mode 100644 index 0000000..0389283 --- /dev/null +++ b/discv4/protocol/pending_neighbors_test.go @@ -0,0 +1,144 @@ +package protocol + +import ( + "context" + "net" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethpandaops/bootnodoor/discv4/node" +) + +func newNeighborsHandler(t *testing.T) (*Handler, context.CancelFunc) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + return NewHandler(ctx, HandlerConfig{}, nil), cancel +} + +func makeDiscv4Node(t *testing.T) *node.Node { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + return node.New(&key.PublicKey, &net.UDPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 30303}) +} + +func makeNeighbors(t *testing.T, count int) *Neighbors { + t.Helper() + recs := make([]NodeRecord, count) + for i := range recs { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + recs[i] = NodeRecord{ + IP: net.IPv4(9, 9, 9, byte(i%256)), + UDP: 30303, + TCP: 30303, + ID: EncodePubkey(&key.PublicKey), + } + } + return &Neighbors{Nodes: recs, Expiration: uint64(time.Now().Add(time.Minute).Unix())} +} + +// TestUnsolicitedNeighborsDropped verifies that NEIGHBORS from a node we never +// sent a FINDNODE to create no pending accumulation. +func TestUnsolicitedNeighborsDropped(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + from := makeDiscv4Node(t) + if err := h.handleNeighbors(from, from.Addr(), makeNeighbors(t, 10)); err != nil { + t.Fatalf("handleNeighbors: %v", err) + } + + h.pendingNeighborsMu.RLock() + n := len(h.pendingNeighbors) + h.pendingNeighborsMu.RUnlock() + if n != 0 { + t.Fatalf("unsolicited NEIGHBORS created %d pending entries, want 0", n) + } +} + +// TestNeighborsAccumulationCapped verifies the accumulated nodes for a single +// FINDNODE never exceed the cap, even across multiple oversized packets. +func TestNeighborsAccumulationCapped(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + from := makeDiscv4Node(t) + h.addPendingRequest([]byte("req"), from, FindnodePacket) + + // Pre-build packets so no slow key generation happens between the calls and + // the read (the delivery goroutine deletes the entry after the window). + pkt1 := makeNeighbors(t, maxNeighborsPerResponse+20) + pkt2 := makeNeighbors(t, 20) + + if err := h.handleNeighbors(from, from.Addr(), pkt1); err != nil { + t.Fatal(err) + } + if err := h.handleNeighbors(from, from.Addr(), pkt2); err != nil { + t.Fatal(err) + } + + h.pendingNeighborsMu.RLock() + pending := h.pendingNeighbors[string(from.IDBytes())] + h.pendingNeighborsMu.RUnlock() + if pending == nil { + t.Fatal("expected a pending entry for the matched FINDNODE") + } + if len(pending.Nodes) != maxNeighborsPerResponse { + t.Fatalf("accumulated %d nodes, want cap %d", len(pending.Nodes), maxNeighborsPerResponse) + } +} + +// TestNeighborsDeliveredToWaiter verifies the happy path still delivers the +// collected nodes to the waiting FINDNODE. +func TestNeighborsDeliveredToWaiter(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + from := makeDiscv4Node(t) + req := h.addPendingRequest([]byte("req"), from, FindnodePacket) + + if err := h.handleNeighbors(from, from.Addr(), makeNeighbors(t, 5)); err != nil { + t.Fatal(err) + } + + select { + case resp := <-req.ResponseChan: + nodes, ok := resp.([]*node.Node) + if !ok || len(nodes) != 5 { + t.Fatalf("unexpected delivery: %T len=%d", resp, len(nodes)) + } + case <-time.After(2 * time.Second): + t.Fatal("collected nodes were not delivered to the waiter") + } +} + +// TestCleanupEvictsStalePendingNeighbors verifies cleanup evicts entries by +// creation time, so a stream of packets can no longer keep an entry alive. +func TestCleanupEvictsStalePendingNeighbors(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + h.pendingNeighborsMu.Lock() + h.pendingNeighbors["stale"] = &PendingNeighborsResponse{CreatedAt: time.Now().Add(-neighborsTimeout - time.Second)} + h.pendingNeighbors["fresh"] = &PendingNeighborsResponse{CreatedAt: time.Now()} + h.pendingNeighborsMu.Unlock() + + h.cleanup() + + h.pendingNeighborsMu.RLock() + _, staleExists := h.pendingNeighbors["stale"] + _, freshExists := h.pendingNeighbors["fresh"] + h.pendingNeighborsMu.RUnlock() + if staleExists { + t.Error("stale pending entry was not evicted") + } + if !freshExists { + t.Error("fresh pending entry was wrongly evicted") + } +} From ed6d31a696d69937b008163dd70f119a816ec1b3 Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Thu, 16 Jul 2026 18:25:22 -0500 Subject: [PATCH 05/20] Fix startup session/pool bugs: set node on initiator sessions, lock LoadInitialNodesFromDB --- discv5/protocol/handler.go | 4 ++++ nodes/flattable.go | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/discv5/protocol/handler.go b/discv5/protocol/handler.go index 3d3a256..cc8987f 100644 --- a/discv5/protocol/handler.go +++ b/discv5/protocol/handler.go @@ -704,6 +704,10 @@ func (h *Handler) handleWHOAREYOUPacket(packet *Packet, from *net.UDPAddr, local } } + if sess.GetNode() == nil && remoteNode != nil { + sess.SetNode(remoteNode) + } + h.config.Sessions.Put(sess) // Call OnHandshakeComplete callback for outgoing handshake diff --git a/nodes/flattable.go b/nodes/flattable.go index a412713..98e4c76 100644 --- a/nodes/flattable.go +++ b/nodes/flattable.go @@ -195,7 +195,14 @@ func (t *FlatTable) LoadInitialNodesFromDB() error { // Load random nodes from DB to bootstrap the active pool randomNodes := t.db.LoadRandomNodes(t.maxActiveNodes) + t.mu.Lock() + defer t.mu.Unlock() + for _, n := range randomNodes { + if _, exists := t.activeNodes[n.ID()]; exists { + continue + } + if t.ipLimiter.CanAdd(n) { t.activeNodes[n.ID()] = n t.ipLimiter.Add(n) From e10a691b495f9b7b5d609a4475b6f4f3fd45a564 Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Thu, 16 Jul 2026 18:30:20 -0500 Subject: [PATCH 06/20] Re-admit demoted nodes to the active pool on traffic --- bootnode/service.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bootnode/service.go b/bootnode/service.go index b28017b..ca3d6ef 100644 --- a/bootnode/service.go +++ b/bootnode/service.go @@ -1046,12 +1046,15 @@ func (s *Service) onNodeSeen(n *v5node.Node, timestamp time.Time) { // Look up the generic node from the table if genericNode := s.elTable.Get(nodeID); genericNode != nil { genericNode.SetLastSeen(timestamp) // This marks it dirty + // Get falls back to the DB, so Add re-admits demoted nodes + s.elTable.Add(genericNode) s.elNodeDB.QueueUpdate(genericNode) } } else if s.enrManager.FilterCLNode(n.Record()) && s.clTable != nil && s.clNodeDB != nil { // Look up the generic node from the table if genericNode := s.clTable.Get(nodeID); genericNode != nil { genericNode.SetLastSeen(timestamp) // This marks it dirty + s.clTable.Add(genericNode) s.clNodeDB.QueueUpdate(genericNode) } } From 1a5f00877dad9f5b3e9080f60583272f39002eb2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:05:56 +0000 Subject: [PATCH 07/20] Bump the dependencies group across 1 directory with 6 updates Bumps the dependencies group with 4 updates in the / directory: [github.com/ethereum/go-ethereum](https://github.com/ethereum/go-ethereum), [github.com/pk910/dynamic-ssz](https://github.com/pk910/dynamic-ssz), [github.com/pressly/goose/v3](https://github.com/pressly/goose) and [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang). Updates `github.com/ethereum/go-ethereum` from 1.17.3 to 1.17.4 - [Release notes](https://github.com/ethereum/go-ethereum/releases) - [Commits](https://github.com/ethereum/go-ethereum/compare/v1.17.3...v1.17.4) Updates `github.com/pk910/dynamic-ssz` from 1.3.1 to 1.3.2 - [Release notes](https://github.com/pk910/dynamic-ssz/releases) - [Changelog](https://github.com/pk910/dynamic-ssz/blob/master/CHANGELOG.md) - [Commits](https://github.com/pk910/dynamic-ssz/compare/v1.3.1...v1.3.2) Updates `github.com/pressly/goose/v3` from 3.27.1 to 3.27.2 - [Release notes](https://github.com/pressly/goose/releases) - [Changelog](https://github.com/pressly/goose/blob/main/CHANGELOG.md) - [Commits](https://github.com/pressly/goose/compare/v3.27.1...v3.27.2) Updates `github.com/prometheus/client_golang` from 1.23.2 to 1.24.0 - [Release notes](https://github.com/prometheus/client_golang/releases) - [Changelog](https://github.com/prometheus/client_golang/blob/v1.24.0/CHANGELOG.md) - [Commits](https://github.com/prometheus/client_golang/compare/v1.23.2...v1.24.0) Updates `golang.org/x/crypto` from 0.50.0 to 0.53.0 - [Commits](https://github.com/golang/crypto/compare/v0.50.0...v0.53.0) Updates `golang.org/x/net` from 0.53.0 to 0.56.0 - [Commits](https://github.com/golang/net/compare/v0.53.0...v0.56.0) --- updated-dependencies: - dependency-name: github.com/ethereum/go-ethereum dependency-version: 1.17.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dependencies - dependency-name: github.com/pk910/dynamic-ssz dependency-version: 1.3.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dependencies - dependency-name: github.com/pressly/goose/v3 dependency-version: 3.27.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dependencies - dependency-name: github.com/prometheus/client_golang dependency-version: 1.24.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: dependencies - dependency-name: golang.org/x/crypto dependency-version: 0.53.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: dependencies - dependency-name: golang.org/x/net dependency-version: 0.56.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: dependencies ... Signed-off-by: dependabot[bot] --- go.mod | 28 ++++++++--------- go.sum | 97 +++++++++++++++++++++++++++------------------------------- 2 files changed, 58 insertions(+), 67 deletions(-) diff --git a/go.mod b/go.mod index 9b41905..4167178 100644 --- a/go.mod +++ b/go.mod @@ -4,21 +4,21 @@ go 1.25.7 require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 - github.com/ethereum/go-ethereum v1.17.3 + github.com/ethereum/go-ethereum v1.17.4 github.com/glebarez/go-sqlite v1.22.0 github.com/gorilla/mux v1.8.1 github.com/jmoiron/sqlx v1.4.0 github.com/mr-tron/base58 v1.3.0 github.com/multiformats/go-multihash v0.2.3 - github.com/pk910/dynamic-ssz v1.3.1 - github.com/pressly/goose/v3 v3.27.1 - github.com/prometheus/client_golang v1.23.2 + github.com/pk910/dynamic-ssz v1.3.2 + github.com/pressly/goose/v3 v3.27.2 + github.com/prometheus/client_golang v1.24.0 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 github.com/tdewolff/minify v2.3.6+incompatible github.com/urfave/negroni v1.0.0 - golang.org/x/crypto v0.50.0 - golang.org/x/net v0.53.0 + golang.org/x/crypto v0.53.0 + golang.org/x/net v0.56.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -39,10 +39,10 @@ require ( github.com/multiformats/go-varint v0.0.6 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/pk910/hashtree-bindings v0.1.0 // indirect + github.com/pk910/hashtree-bindings v0.2.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.20.1 // indirect + github.com/prometheus/common v0.70.0 // indirect + github.com/prometheus/procfs v0.21.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect @@ -51,14 +51,12 @@ require ( github.com/tdewolff/parse v2.3.4+incompatible // indirect github.com/tdewolff/test v1.0.11 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.47.0 // indirect google.golang.org/protobuf v1.36.11 // indirect lukechampine.com/blake3 v1.1.6 // indirect - modernc.org/libc v1.72.1 // indirect + modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.49.1 // indirect + modernc.org/sqlite v1.53.0 // indirect ) diff --git a/go.sum b/go.sum index e8f1bfd..879ab68 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3h github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q= -github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A= +github.com/ethereum/go-ethereum v1.17.4 h1:uA4q+qiLp7QImBsjdRbINu8iX6OEVmj4DPc5/E5Fsxc= +github.com/ethereum/go-ethereum v1.17.4/go.mod h1:qMdgwqqRAen+aT8P7KKQKi0Qt6RzG4cfejVAbCpJgqA= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= @@ -27,8 +27,8 @@ github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbS github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -59,16 +59,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -100,26 +96,24 @@ github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9k github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/pk910/dynamic-ssz v1.3.1 h1:S/no7kRA5FSORmfybG4Cs49CjPgP94fePKPxt8uKkdI= -github.com/pk910/dynamic-ssz v1.3.1/go.mod h1:ARK5qDyrJ/MHpaZHGJYvCKElvaMYTE9pXOQbvPDeE0U= -github.com/pk910/hashtree-bindings v0.1.0 h1:w7NyRWFi2OaYEFvo9ADcE/QU6PMuVLl3hBgx92KiH9c= -github.com/pk910/hashtree-bindings v0.1.0/go.mod h1:zrWt88783JmhBfcgni6kkIMYRdXTZi/FL//OyI5T/l4= +github.com/pk910/dynamic-ssz v1.3.2 h1:65UR/O+ss+U2Dn86Rdl7LwehHo3u2ElutduS/pcuUXE= +github.com/pk910/dynamic-ssz v1.3.2/go.mod h1:lqmnou2bjr2UWQ3C/L3082TGW0SFl/SwT7ionwM0+FU= +github.com/pk910/hashtree-bindings v0.2.2 h1:gkczxxekBW2NeMK9N3OLj7Jepe7zPmJGVwr8LyofGsA= +github.com/pk910/hashtree-bindings v0.2.2/go.mod h1:zrWt88783JmhBfcgni6kkIMYRdXTZi/FL//OyI5T/l4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= -github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8= +github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM= +github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= +github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= +github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= @@ -147,24 +141,24 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -174,16 +168,16 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= @@ -196,9 +190,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= @@ -210,20 +203,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c= lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= -modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc= -modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54= -modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= -modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= -modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -232,8 +225,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= -modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= From 59e3db68e0b8820213309451e5287b59f0d57b63 Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Wed, 22 Jul 2026 18:13:23 -0500 Subject: [PATCH 08/20] fix(discv5): install refreshed peer ENRs --- discv5/protocol/handler.go | 49 ++++++++++- discv5/protocol/handler_test.go | 139 ++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 discv5/protocol/handler_test.go diff --git a/discv5/protocol/handler.go b/discv5/protocol/handler.go index 3d3a256..d9bee5f 100644 --- a/discv5/protocol/handler.go +++ b/discv5/protocol/handler.go @@ -1686,13 +1686,20 @@ func (h *Handler) requestENRUpdate(n *node.Node) { return } - // The NODES response handler will automatically update the ENR in our table nodesMsg, ok := resp.Message.(*Nodes) - if ok && len(nodesMsg.Records) > 0 { + if !ok { h.config.Logger.WithFields(logrus.Fields{ "nodeID": n.ID().String()[:16], - "count": len(nodesMsg.Records), - }).Debug("handler: received ENR update") + "type": fmt.Sprintf("%T", resp.Message), + }).Debug("handler: ENR update returned unexpected response") + return + } + + if h.applyENRUpdate(n, nodesMsg.Records) { + h.config.Logger.WithFields(logrus.Fields{ + "nodeID": n.ID().String()[:16], + "seq": n.Record().Seq(), + }).Debug("handler: installed ENR update") } case <-time.After(5 * time.Second): @@ -1703,6 +1710,40 @@ func (h *Handler) requestENRUpdate(n *node.Node) { }() } +// applyENRUpdate installs the newest valid record returned by a distance-zero +// FINDNODE request. A peer must not be able to replace its session record with +// another node's ENR, even though the response itself matched the pending request. +func (h *Handler) applyENRUpdate(n *node.Node, records []*enr.Record) bool { + var newest *enr.Record + + for _, record := range records { + candidate, err := node.New(record) + if err != nil { + h.config.Logger.WithError(err).Debug("handler: ignoring invalid ENR update") + continue + } + if candidate.ID() != n.ID() { + h.config.Logger.WithFields(logrus.Fields{ + "nodeID": n.ID().String()[:16], + "recordID": candidate.ID().String()[:16], + }).Debug("handler: ignoring ENR update for a different node") + continue + } + if newest == nil || record.Seq() > newest.Seq() { + newest = record + } + } + + if newest == nil || !n.UpdateENR(newest) { + return false + } + + if h.config.OnNodeUpdate != nil { + h.config.OnNodeUpdate(n) + } + return true +} + // SendFindNode sends a FINDNODE request. // // Returns a channel that will receive the NODES response. diff --git a/discv5/protocol/handler_test.go b/discv5/protocol/handler_test.go new file mode 100644 index 0000000..13a24a2 --- /dev/null +++ b/discv5/protocol/handler_test.go @@ -0,0 +1,139 @@ +package protocol + +import ( + "crypto/ecdsa" + "net" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethpandaops/bootnodoor/discv5/node" + "github.com/ethpandaops/bootnodoor/enr" + "github.com/sirupsen/logrus" +) + +func TestApplyENRUpdateInstallsNewestMatchingRecord(t *testing.T) { + key := generateKey(t) + currentRecord := signedRecord(t, key, 1, nil) + remoteNode, err := node.New(currentRecord) + if err != nil { + t.Fatalf("create node: %v", err) + } + + staleRecord := signedRecord(t, key, 1, nil) + newRecord := signedRecord(t, key, 2, map[string]interface{}{ + "eth": []struct { + Hash []byte + Next uint64 + }{{Hash: []byte{0xde, 0xad, 0xbe, 0xef}}}, + }) + newestRecord := signedRecord(t, key, 3, map[string]interface{}{ + "eth": []struct { + Hash []byte + Next uint64 + }{{Hash: []byte{0xca, 0xfe, 0xba, 0xbe}}}, + }) + + callbackCount := 0 + handler := testHandler(func(updated *node.Node) { + callbackCount++ + if updated != remoteNode { + t.Error("callback received a different node") + } + }) + + if !handler.applyENRUpdate(remoteNode, []*enr.Record{staleRecord, newestRecord, newRecord}) { + t.Fatal("expected ENR to be updated") + } + if got := remoteNode.Record().Seq(); got != 3 { + t.Fatalf("record sequence = %d, want 3", got) + } + eth, ok := remoteNode.Record().Eth() + if !ok { + t.Fatal("updated record is missing eth fork ID") + } + if got, want := eth[0].ForkID, [4]byte{0xca, 0xfe, 0xba, 0xbe}; got != want { + t.Fatalf("fork hash = %x, want %x", got, want) + } + if callbackCount != 1 { + t.Fatalf("callback count = %d, want 1", callbackCount) + } +} + +func TestApplyENRUpdateRejectsDifferentNode(t *testing.T) { + remoteNode, err := node.New(signedRecord(t, generateKey(t), 1, nil)) + if err != nil { + t.Fatalf("create node: %v", err) + } + differentRecord := signedRecord(t, generateKey(t), 99, nil) + + callbackCount := 0 + handler := testHandler(func(*node.Node) { callbackCount++ }) + if handler.applyENRUpdate(remoteNode, []*enr.Record{differentRecord}) { + t.Fatal("different node's ENR was accepted") + } + if got := remoteNode.Record().Seq(); got != 1 { + t.Fatalf("record sequence = %d, want 1", got) + } + if callbackCount != 0 { + t.Fatalf("callback count = %d, want 0", callbackCount) + } +} + +func TestApplyENRUpdateRejectsStaleRecord(t *testing.T) { + key := generateKey(t) + remoteNode, err := node.New(signedRecord(t, key, 2, nil)) + if err != nil { + t.Fatalf("create node: %v", err) + } + + callbackCount := 0 + handler := testHandler(func(*node.Node) { callbackCount++ }) + if handler.applyENRUpdate(remoteNode, []*enr.Record{signedRecord(t, key, 1, nil)}) { + t.Fatal("stale ENR was accepted") + } + if got := remoteNode.Record().Seq(); got != 2 { + t.Fatalf("record sequence = %d, want 2", got) + } + if callbackCount != 0 { + t.Fatalf("callback count = %d, want 0", callbackCount) + } +} + +func testHandler(onNodeUpdate OnNodeUpdateCallback) *Handler { + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + return &Handler{config: HandlerConfig{ + Logger: logger, + OnNodeUpdate: onNodeUpdate, + }} +} + +func generateKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + return key +} + +func signedRecord(t *testing.T, key *ecdsa.PrivateKey, seq uint64, fields map[string]interface{}) *enr.Record { + t.Helper() + record := enr.New() + if err := record.Set("ip", net.IPv4(203, 0, 113, 1)); err != nil { + t.Fatalf("set ip: %v", err) + } + if err := record.Set("udp", uint16(30303)); err != nil { + t.Fatalf("set udp: %v", err) + } + for name, value := range fields { + if err := record.Set(name, value); err != nil { + t.Fatalf("set %s: %v", name, err) + } + } + record.SetSeq(seq) + if err := record.Sign(key); err != nil { + t.Fatalf("sign record: %v", err) + } + return record +} From a9d4df3c8372d3d92a70c0eb7dfbe27171588f3e Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Mon, 27 Jul 2026 08:20:19 -0500 Subject: [PATCH 09/20] fix(discv4): address review feedback on #34 and #38 - enforce the NEIGHBORS cap before records enter the node map - remove a completed FINDNODE's pending request on every exit path - stamp last-seen at node creation so cleanup honors NodeTTL --- discv4/protocol/handler.go | 55 ++++++++----- discv4/protocol/pending_neighbors_test.go | 98 +++++++++++++++++++++++ 2 files changed, 132 insertions(+), 21 deletions(-) diff --git a/discv4/protocol/handler.go b/discv4/protocol/handler.go index e0f7cc1..c091e82 100644 --- a/discv4/protocol/handler.go +++ b/discv4/protocol/handler.go @@ -442,9 +442,26 @@ func (h *Handler) handleNeighbors(fromNode *node.Node, from *net.UDPAddr, neighb return nil } - // Convert nodes + // Accumulate the response, keyed by the sender's node ID. The cap is + // enforced before decoding so records past it are not persisted in the + // global node map either. + key := string(fromNode.IDBytes()) + + h.pendingNeighborsMu.Lock() + pending := h.pendingNeighbors[key] + firstPacket := pending == nil + if firstPacket { + pending = &PendingNeighborsResponse{CreatedAt: time.Now()} + h.pendingNeighbors[key] = pending + } + room := maxNeighborsPerResponse - len(pending.Nodes) + h.pendingNeighborsMu.Unlock() + nodes := make([]*node.Node, 0, len(neighbors.Nodes)) for _, n := range neighbors.Nodes { + if len(nodes) >= room { + break + } pubkey, err := DecodePubkey(crypto.S256(), n.ID) if err != nil { logrus.WithError(err).Debug("Invalid node public key in NEIGHBORS") @@ -460,23 +477,16 @@ func (h *Handler) handleNeighbors(fromNode *node.Node, from *net.UDPAddr, neighb nodes = append(nodes, h.getOrCreateNode(nodeID, pubkey, addr)) } - // Accumulate the response, keyed by the sender's node ID. - key := string(fromNode.IDBytes()) - h.pendingNeighborsMu.Lock() - pending := h.pendingNeighbors[key] - firstPacket := pending == nil - if firstPacket { - pending = &PendingNeighborsResponse{CreatedAt: time.Now()} - h.pendingNeighbors[key] = pending - } - // Cap the accumulated nodes so a burst of NEIGHBORS cannot grow the entry - // without bound. Extra nodes past the cap are dropped. - if room := maxNeighborsPerResponse - len(pending.Nodes); room > 0 { - if len(nodes) > room { - nodes = nodes[:room] + // Re-check the cap: a concurrent packet may have filled the entry while we + // were decoding outside the lock. + if p := h.pendingNeighbors[key]; p != nil { + if r := maxNeighborsPerResponse - len(p.Nodes); r > 0 { + if len(nodes) > r { + nodes = nodes[:r] + } + p.Nodes = append(p.Nodes, nodes...) } - pending.Nodes = append(pending.Nodes, nodes...) } h.pendingNeighborsMu.Unlock() @@ -643,12 +653,14 @@ func (h *Handler) Findnode(n *node.Node, target []byte) ([]*node.Node, error) { return nil, fmt.Errorf("encode error: %w", err) } - // Register pending request + // Register pending request. Removal is deferred so every exit path clears + // it: a completed request left in the map keeps matching later NEIGHBORS + // from that node and reopens collection windows until cleanup runs. req := h.addPendingRequest(hash, n, FindnodePacket) + defer h.removePendingRequest(string(hash)) // Send packet if err := h.transport.SendTo(packet, n.Addr()); err != nil { - h.removePendingRequest(string(hash)) return nil, err } @@ -663,11 +675,9 @@ func (h *Handler) Findnode(n *node.Node, target []byte) ([]*node.Node, error) { } return nil, fmt.Errorf("unexpected response type") case <-time.After(h.config.RequestTimeout * 3): // Longer timeout for multi-packet responses - h.removePendingRequest(string(hash)) n.MarkTimeout() return nil, fmt.Errorf("timeout") case <-h.ctx.Done(): - h.removePendingRequest(string(hash)) return nil, h.ctx.Err() } } @@ -838,8 +848,11 @@ func (h *Handler) getOrCreateNode(id node.ID, pubkey *ecdsa.PublicKey, addr *net return n } - // Create new node + // Create new node. Stamp last-seen with the insertion time: a node learned + // from a NEIGHBORS record has never sent us a packet, and a zero timestamp + // would make cleanup evict it on its next run regardless of NodeTTL. n = node.New(pubkey, addr) + n.UpdateLastSeen() // Bound the map so an unauthenticated flood of distinct node IDs (for // example fabricated NEIGHBORS records) cannot grow it without limit. Stale diff --git a/discv4/protocol/pending_neighbors_test.go b/discv4/protocol/pending_neighbors_test.go index 0389283..6a47f00 100644 --- a/discv4/protocol/pending_neighbors_test.go +++ b/discv4/protocol/pending_neighbors_test.go @@ -142,3 +142,101 @@ func TestCleanupEvictsStalePendingNeighbors(t *testing.T) { t.Error("fresh pending entry was wrongly evicted") } } + +// TestNeighborsCapAppliesBeforeNodePersistence verifies records past the +// per-response cap are not persisted in the global node map either, so a +// queried peer cannot grow memory by flooding unique records. +func TestNeighborsCapAppliesBeforeNodePersistence(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + from := makeDiscv4Node(t) + h.addPendingRequest([]byte("req"), from, FindnodePacket) + + if err := h.handleNeighbors(from, from.Addr(), makeNeighbors(t, maxNeighborsPerResponse+20)); err != nil { + t.Fatal(err) + } + + h.nodesMu.RLock() + n := len(h.nodes) + h.nodesMu.RUnlock() + if n != maxNeighborsPerResponse { + t.Fatalf("node map persisted %d records, want at most the cap %d", n, maxNeighborsPerResponse) + } +} + +// TestFreshNodeSurvivesCleanup verifies a newly created node is not evicted by +// the next cleanup run before its NodeTTL: creation stamps last-seen, so a +// node learned from a NEIGHBORS record does not carry a zero timestamp. +func TestFreshNodeSurvivesCleanup(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + id := node.PubkeyToID(&key.PublicKey) + h.getOrCreateNode(id, &key.PublicKey, &net.UDPAddr{IP: net.IPv4(9, 9, 9, 9), Port: 30303}) + + h.cleanup() + + if h.GetNode(id) == nil { + t.Fatal("fresh unbonded node was evicted before NodeTTL") + } +} + +type stubTransport struct{} + +func (stubTransport) SendTo([]byte, *net.UDPAddr) error { return nil } + +func (stubTransport) Send([]byte, *net.UDPAddr, *net.UDPAddr) error { return nil } + +// TestFindnodeRemovesCompletedRequest verifies a delivered FINDNODE leaves no +// pending request behind, so later NEIGHBORS from the same node cannot keep +// matching it and reopening collection windows. +func TestFindnodeRemovesCompletedRequest(t *testing.T) { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + h := NewHandler(ctx, HandlerConfig{PrivateKey: key}, stubTransport{}) + + to := makeDiscv4Node(t) + to.MarkPongReceived(time.Hour) + target := EncodePubkey(&key.PublicKey) + + type result struct { + nodes []*node.Node + err error + } + done := make(chan result, 1) + go func() { + nodes, err := h.Findnode(to, target[:]) + done <- result{nodes, err} + }() + + deadline := time.Now().Add(2 * time.Second) + for h.findPendingFindnode(to.ID()) == nil { + if time.Now().After(deadline) { + t.Fatal("pending FINDNODE never registered") + } + time.Sleep(5 * time.Millisecond) + } + if err := h.handleNeighbors(to, to.Addr(), makeNeighbors(t, 3)); err != nil { + t.Fatal(err) + } + + res := <-done + if res.err != nil || len(res.nodes) != 3 { + t.Fatalf("Findnode = %d nodes, %v", len(res.nodes), res.err) + } + h.requestsMu.RLock() + remaining := len(h.requests) + h.requestsMu.RUnlock() + if remaining != 0 { + t.Fatalf("%d pending requests remain after a completed FINDNODE, want 0", remaining) + } +} From 2e26bb243c0b7398e75a5a161e4aa5b27e749542 Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Mon, 27 Jul 2026 09:12:27 -0500 Subject: [PATCH 10/20] fix: race and cap fixes from the develop-vs-master review - reserve NEIGHBORS room before decoding and tombstone delivered entries, keyed by request hash, so the persistence cap is exact under concurrent dispatch - guard discv5 node record/addr/tcpPort/stats behind a mutex; same for the discv4 node enr/addr/stats and the generic node enr - bulk DB load stops at the active-pool soft cap - Ping/RequestENR clear their pending request on every exit path - handler stats snapshot reads maps under their own mutexes --- discv4/node/node.go | 44 +++++-- discv4/node/node_test.go | 86 ++++++++++++++ discv4/protocol/handler.go | 77 ++++++++----- discv4/protocol/pending_neighbors_test.go | 79 ++++++++++++- discv5/node/node.go | 58 +++++++--- discv5/node/node_test.go | 133 ++++++++++++++++++++++ nodes/flattable.go | 5 + nodes/flattable_test.go | 96 ++++++++++++++++ nodes/node.go | 29 +++-- 9 files changed, 539 insertions(+), 68 deletions(-) create mode 100644 discv4/node/node_test.go create mode 100644 discv5/node/node_test.go create mode 100644 nodes/flattable_test.go diff --git a/discv4/node/node.go b/discv4/node/node.go index 42d5e36..c85fa6b 100644 --- a/discv4/node/node.go +++ b/discv4/node/node.go @@ -31,6 +31,11 @@ type Node struct { // pubKey is the node's secp256k1 public key pubKey *ecdsa.PublicKey + // mu guards addr, enr and the stats pointer: SetAddr/SetENR/SetStats + // replace them from other goroutines while packet handling reads them. + // The pointed-to values synchronize themselves or are replaced whole. + mu sync.RWMutex + // addr is the node's UDP address addr *net.UDPAddr @@ -159,6 +164,8 @@ func (n *Node) PublicKey() *ecdsa.PublicKey { // Addr returns the node's UDP address. func (n *Node) Addr() *net.UDPAddr { + n.mu.RLock() + defer n.mu.RUnlock() return n.addr } @@ -166,17 +173,30 @@ func (n *Node) Addr() *net.UDPAddr { // // This is used when we receive packets from a different address than expected. func (n *Node) SetAddr(addr *net.UDPAddr) { + n.mu.Lock() n.addr = addr + n.mu.Unlock() } // ENR returns the node's ENR record, if available. func (n *Node) ENR() *enr.Record { + n.mu.RLock() + defer n.mu.RUnlock() return n.enr } // SetENR updates the node's ENR record. func (n *Node) SetENR(record *enr.Record) { + n.mu.Lock() n.enr = record + n.mu.Unlock() +} + +// statsRef returns the current shared stats pointer for use outside the lock. +func (n *Node) statsRef() *stats.SharedStats { + n.mu.RLock() + defer n.mu.RUnlock() + return n.stats } // Enode returns the node's enode:// representation. @@ -185,12 +205,14 @@ func (n *Node) Enode() *enode.Enode { return n.enode } + addr := n.Addr() + // Build enode from node info return &enode.Enode{ PublicKey: n.pubKey, - IP: n.addr.IP, - UDP: uint16(n.addr.Port), - TCP: uint16(n.addr.Port), // Assume same port for TCP + IP: addr.IP, + UDP: uint16(addr.Port), + TCP: uint16(addr.Port), // Assume same port for TCP } } @@ -201,7 +223,7 @@ func (n *Node) String() string { n.bondMu.RUnlock() return fmt.Sprintf("Node{id=%x, addr=%s, bond=%s}", - n.id[:8], n.addr.String(), bondStatus) + n.id[:8], n.Addr().String(), bondStatus) } // Bond Status Methods @@ -241,7 +263,7 @@ func (n *Node) MarkPingSent() { } n.bondMu.Unlock() - n.stats.SetLastPing(now) + n.statsRef().SetLastPing(now) } // MarkPingReceived records that we received a PING from this node. @@ -276,7 +298,7 @@ func (n *Node) MarkPongReceived(bondDuration time.Duration) { n.consecutiveTimeout = 0 n.bondMu.Unlock() - n.stats.ResetFailureCount() + n.statsRef().ResetFailureCount() n.UpdateLastSeen() } @@ -286,7 +308,7 @@ func (n *Node) MarkTimeout() { n.consecutiveTimeout++ n.bondMu.Unlock() - n.stats.IncrementFailureCount() + n.statsRef().IncrementFailureCount() } // LastPingSent returns when we last sent a PING. @@ -314,17 +336,17 @@ func (n *Node) BondExpiration() time.Time { // UpdateLastSeen updates the last seen timestamp. func (n *Node) UpdateLastSeen() { - n.stats.SetLastSeen(time.Now()) + n.statsRef().SetLastSeen(time.Now()) } // LastSeen returns when we last saw a packet from this node. func (n *Node) LastSeen() time.Time { - return n.stats.LastSeen() + return n.statsRef().LastSeen() } // FailedPings returns the number of failed ping attempts. func (n *Node) FailedPings() uint32 { - return uint32(n.stats.FailureCount()) + return uint32(n.statsRef().FailureCount()) } // IncrementPacketsReceived increments the received packet counter. @@ -366,7 +388,9 @@ func (n *Node) ConsecutiveTimeouts() uint32 { // This allows the node to update stats owned by a parent node. func (n *Node) SetStats(sharedStats *stats.SharedStats) { if sharedStats != nil { + n.mu.Lock() n.stats = sharedStats + n.mu.Unlock() } } diff --git a/discv4/node/node_test.go b/discv4/node/node_test.go new file mode 100644 index 0000000..08f1cc6 --- /dev/null +++ b/discv4/node/node_test.go @@ -0,0 +1,86 @@ +package node + +import ( + "net" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethpandaops/bootnodoor/enr" + "github.com/ethpandaops/bootnodoor/stats" +) + +// TestNodeConcurrentFieldAccess exercises SetAddr/SetENR/SetStats against every +// reader of the guarded fields, including Enode and String which read addr +// directly. Under the race detector it fails if any access is unsynchronized. +func TestNodeConcurrentFieldAccess(t *testing.T) { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + n := New(&key.PublicKey, &net.UDPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 30303}) + + rec := enr.New() + if err := rec.Set("ip", net.IPv4(5, 6, 7, 8)); err != nil { + t.Fatalf("set ip: %v", err) + } + if err := rec.Sign(key); err != nil { + t.Fatalf("sign: %v", err) + } + shared := stats.NewSharedStats(time.Now()) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + writer := func(mutate func(i int)) { + defer wg.Done() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + mutate(i) + } + } + } + + wg.Add(3) + go writer(func(i int) { + n.SetAddr(&net.UDPAddr{IP: net.IPv4(9, 9, 9, byte(i%256)), Port: 30303}) + }) + go writer(func(i int) { + if i%2 == 0 { + n.SetENR(rec) + } else { + n.SetENR(nil) + } + }) + go writer(func(int) { n.SetStats(shared) }) + + for r := 0; r < 4; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + _ = n.Addr() + _ = n.ENR() + _ = n.Enode() + _ = n.String() + n.UpdateLastSeen() + _ = n.LastSeen() + n.MarkPingSent() + _ = n.IsBonded() + } + } + }() + } + + time.Sleep(200 * time.Millisecond) + close(stop) + wg.Wait() +} diff --git a/discv4/protocol/handler.go b/discv4/protocol/handler.go index c091e82..c6949e9 100644 --- a/discv4/protocol/handler.go +++ b/discv4/protocol/handler.go @@ -144,6 +144,15 @@ type PendingNeighborsResponse struct { // Nodes accumulated so far Nodes []*node.Node + // Reserved counts nodes a handler has claimed room for but may not have + // appended yet. Reserving before decoding makes the persistence cap exact + // even when packets are dispatched concurrently. + Reserved int + + // Closed marks a delivered entry. Packets processed after delivery must + // not reserve against it; cleanup evicts the tombstone by CreatedAt. + Closed bool + // CreatedAt is when we received the first packet CreatedAt time.Time } @@ -442,24 +451,31 @@ func (h *Handler) handleNeighbors(fromNode *node.Node, from *net.UDPAddr, neighb return nil } - // Accumulate the response, keyed by the sender's node ID. The cap is - // enforced before decoding so records past it are not persisted in the - // global node map either. - key := string(fromNode.IDBytes()) + // Accumulate the response, keyed by the matched request's hash so each + // FINDNODE gets exactly one entry and a fresh request never collides with + // a delivered one. Room is reserved before decoding, so records past the + // cap are never persisted in the global node map, even when packets are + // dispatched concurrently. + key := string(matchedReq.RequestHash) h.pendingNeighborsMu.Lock() pending := h.pendingNeighbors[key] + if pending != nil && pending.Closed { + h.pendingNeighborsMu.Unlock() + return nil + } firstPacket := pending == nil if firstPacket { pending = &PendingNeighborsResponse{CreatedAt: time.Now()} h.pendingNeighbors[key] = pending } - room := maxNeighborsPerResponse - len(pending.Nodes) + take := min(len(neighbors.Nodes), maxNeighborsPerResponse-pending.Reserved) + pending.Reserved += take h.pendingNeighborsMu.Unlock() - nodes := make([]*node.Node, 0, len(neighbors.Nodes)) + nodes := make([]*node.Node, 0, take) for _, n := range neighbors.Nodes { - if len(nodes) >= room { + if len(nodes) >= take { break } pubkey, err := DecodePubkey(crypto.S256(), n.ID) @@ -478,15 +494,8 @@ func (h *Handler) handleNeighbors(fromNode *node.Node, from *net.UDPAddr, neighb } h.pendingNeighborsMu.Lock() - // Re-check the cap: a concurrent packet may have filled the entry while we - // were decoding outside the lock. - if p := h.pendingNeighbors[key]; p != nil { - if r := maxNeighborsPerResponse - len(p.Nodes); r > 0 { - if len(nodes) > r { - nodes = nodes[:r] - } - p.Nodes = append(p.Nodes, nodes...) - } + if p := h.pendingNeighbors[key]; p != nil && !p.Closed { + p.Nodes = append(p.Nodes, nodes...) } h.pendingNeighborsMu.Unlock() @@ -499,11 +508,15 @@ func (h *Handler) handleNeighbors(fromNode *node.Node, from *net.UDPAddr, neighb h.pendingNeighborsMu.Lock() finalPending := h.pendingNeighbors[key] - delete(h.pendingNeighbors, key) + var collected []*node.Node + if finalPending != nil { + finalPending.Closed = true + collected = finalPending.Nodes + } h.pendingNeighborsMu.Unlock() if finalPending != nil { - h.deliverResponse(matchedReq, finalPending.Nodes) + h.deliverResponse(matchedReq, collected) } }() } @@ -593,12 +606,12 @@ func (h *Handler) Ping(n *node.Node) (*Pong, error) { return nil, fmt.Errorf("encode error: %w", err) } - // Register pending request + // Register pending request; removal is deferred so every exit path clears it. req := h.addPendingRequest(hash, n, PingPacket) + defer h.removePendingRequest(string(hash)) // Send packet if err := h.transport.SendTo(packet, n.Addr()); err != nil { - h.removePendingRequest(string(hash)) return nil, err } @@ -619,11 +632,9 @@ func (h *Handler) Ping(n *node.Node) (*Pong, error) { } return nil, fmt.Errorf("unexpected response type") case <-time.After(h.config.RequestTimeout): - h.removePendingRequest(string(hash)) n.MarkTimeout() return nil, fmt.Errorf("timeout") case <-h.ctx.Done(): - h.removePendingRequest(string(hash)) return nil, h.ctx.Err() } } @@ -706,12 +717,12 @@ func (h *Handler) RequestENR(n *node.Node) (*enr.Record, error) { return nil, fmt.Errorf("encode error: %w", err) } - // Register pending request + // Register pending request; removal is deferred so every exit path clears it. pendingReq := h.addPendingRequest(hash, n, ENRRequestPacket) + defer h.removePendingRequest(string(hash)) // Send packet if err := h.transport.SendTo(packet, n.Addr()); err != nil { - h.removePendingRequest(string(hash)) return nil, err } @@ -726,11 +737,9 @@ func (h *Handler) RequestENR(n *node.Node) (*enr.Record, error) { } return nil, fmt.Errorf("unexpected response type") case <-time.After(h.config.RequestTimeout): - h.removePendingRequest(string(hash)) n.MarkTimeout() return nil, fmt.Errorf("timeout") case <-h.ctx.Done(): - h.removePendingRequest(string(hash)) return nil, h.ctx.Err() } } @@ -1043,6 +1052,16 @@ func (h *Handler) incrementFindnodeResponsesRecv() { // Stats returns current statistics. func (h *Handler) Stats() map[string]interface{} { + h.nodesMu.RLock() + knownNodes := len(h.nodes) + h.nodesMu.RUnlock() + h.requestsMu.RLock() + pendingRequests := len(h.requests) + h.requestsMu.RUnlock() + h.pendingNeighborsMu.RLock() + pendingNeighbors := len(h.pendingNeighbors) + h.pendingNeighborsMu.RUnlock() + h.statsMu.RLock() defer h.statsMu.RUnlock() @@ -1054,8 +1073,8 @@ func (h *Handler) Stats() map[string]interface{} { "unbonded_findnode": h.unbondedFindnode, "findnode_requests_recv": h.findnodeRequestsRecv, "findnode_responses_recv": h.findnodeResponsesRecv, - "known_nodes": len(h.nodes), - "pending_requests": len(h.requests), - "pending_neighbors": len(h.pendingNeighbors), + "known_nodes": knownNodes, + "pending_requests": pendingRequests, + "pending_neighbors": pendingNeighbors, } } diff --git a/discv4/protocol/pending_neighbors_test.go b/discv4/protocol/pending_neighbors_test.go index 6a47f00..b811080 100644 --- a/discv4/protocol/pending_neighbors_test.go +++ b/discv4/protocol/pending_neighbors_test.go @@ -3,6 +3,7 @@ package protocol import ( "context" "net" + "sync" "testing" "time" @@ -84,7 +85,7 @@ func TestNeighborsAccumulationCapped(t *testing.T) { } h.pendingNeighborsMu.RLock() - pending := h.pendingNeighbors[string(from.IDBytes())] + pending := h.pendingNeighbors["req"] h.pendingNeighborsMu.RUnlock() if pending == nil { t.Fatal("expected a pending entry for the matched FINDNODE") @@ -240,3 +241,79 @@ func TestFindnodeRemovesCompletedRequest(t *testing.T) { t.Fatalf("%d pending requests remain after a completed FINDNODE, want 0", remaining) } } + +// TestNeighborsPersistenceCapExactUnderConcurrency verifies that packets +// processed on concurrent dispatch goroutines cannot jointly persist more than +// the cap: room is reserved under the lock before any record is decoded. +func TestNeighborsPersistenceCapExactUnderConcurrency(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + from := makeDiscv4Node(t) + h.addPendingRequest([]byte("req"), from, FindnodePacket) + + packets := make([]*Neighbors, 6) + for i := range packets { + packets[i] = makeNeighbors(t, maxNeighborsPerResponse) + } + + var wg sync.WaitGroup + for _, pkt := range packets { + wg.Add(1) + go func(p *Neighbors) { + defer wg.Done() + if err := h.handleNeighbors(from, from.Addr(), p); err != nil { + t.Errorf("handleNeighbors: %v", err) + } + }(pkt) + } + wg.Wait() + + h.nodesMu.RLock() + persisted := len(h.nodes) + h.nodesMu.RUnlock() + if persisted != maxNeighborsPerResponse { + t.Fatalf("node map persisted %d records under concurrent packets, want exactly the cap %d", persisted, maxNeighborsPerResponse) + } +} + +// TestNeighborsAfterDeliveryPersistNothing verifies the delivered entry is +// tombstoned rather than deleted: a packet processed after the collection +// window must not reopen accumulation or persist records. +func TestNeighborsAfterDeliveryPersistNothing(t *testing.T) { + h, cancel := newNeighborsHandler(t) + defer cancel() + + from := makeDiscv4Node(t) + req := h.addPendingRequest([]byte("req"), from, FindnodePacket) + + if err := h.handleNeighbors(from, from.Addr(), makeNeighbors(t, 2)); err != nil { + t.Fatal(err) + } + select { + case <-req.ResponseChan: + case <-time.After(2 * time.Second): + t.Fatal("collected nodes were not delivered") + } + + h.nodesMu.RLock() + before := len(h.nodes) + h.nodesMu.RUnlock() + + if err := h.handleNeighbors(from, from.Addr(), makeNeighbors(t, 5)); err != nil { + t.Fatal(err) + } + + h.nodesMu.RLock() + after := len(h.nodes) + h.nodesMu.RUnlock() + if after != before { + t.Fatalf("a post-delivery packet persisted %d records, want 0", after-before) + } + h.pendingNeighborsMu.RLock() + pending := h.pendingNeighbors["req"] + h.pendingNeighborsMu.RUnlock() + if pending == nil || !pending.Closed || len(pending.Nodes) != 2 { + t.Fatalf("tombstone state = %+v, want closed with the delivered 2 nodes", pending) + } +} diff --git a/discv5/node/node.go b/discv5/node/node.go index d217b73..4eebd88 100644 --- a/discv5/node/node.go +++ b/discv5/node/node.go @@ -12,6 +12,7 @@ import ( "crypto/ecdsa" "fmt" "net" + "sync" "time" "github.com/ethereum/go-ethereum/crypto" @@ -57,6 +58,13 @@ func PubkeyToID(pub *ecdsa.PublicKey) ID { // It combines the node's ENR record with additional runtime information // like network statistics and last seen time. type Node struct { + // mu guards record, addr, tcpPort and the stats pointer: UpdateENR and + // SetStats replace them while packet handling and scoring read them from + // other goroutines. The pointed-to values need no guard - enr.Record has + // its own lock and is never mutated after signing, addr is replaced whole, + // and SharedStats synchronizes internally. + mu sync.RWMutex + // record is the ENR record containing node identity and metadata record *enr.Record @@ -143,40 +151,55 @@ func (n *Node) ID() ID { // Record returns the node's ENR record. func (n *Node) Record() *enr.Record { + n.mu.RLock() + defer n.mu.RUnlock() return n.record } // Addr returns the node's UDP address. func (n *Node) Addr() *net.UDPAddr { + n.mu.RLock() + defer n.mu.RUnlock() return n.addr } +// statsRef returns the current shared stats pointer for use outside the lock. +func (n *Node) statsRef() *stats.SharedStats { + n.mu.RLock() + defer n.mu.RUnlock() + return n.stats +} + // SetStats replaces the node's stats with a shared stats pointer. // This allows the node to update stats owned by a parent node. func (n *Node) SetStats(sharedStats *stats.SharedStats) { if sharedStats != nil { + n.mu.Lock() n.stats = sharedStats + n.mu.Unlock() } } // IP returns the node's IP address. func (n *Node) IP() net.IP { - return n.addr.IP + return n.Addr().IP } // UDPPort returns the node's UDP port. func (n *Node) UDPPort() uint16 { - return uint16(n.addr.Port) + return uint16(n.Addr().Port) } // TCPPort returns the node's TCP port (0 if not set). func (n *Node) TCPPort() uint16 { + n.mu.RLock() + defer n.mu.RUnlock() return n.tcpPort } // PublicKey returns the node's public key. func (n *Node) PublicKey() *ecdsa.PublicKey { - return n.record.PublicKey() + return n.Record().PublicKey() } // PeerID returns the libp2p peer ID for this node. @@ -201,7 +224,7 @@ func (n *Node) PeerID() string { // Digest returns the node's fork digest. func (n *Node) Digest() [4]byte { - eth2Data, ok := n.record.Eth2() + eth2Data, ok := n.Record().Eth2() if !ok { return [4]byte{} } @@ -210,37 +233,37 @@ func (n *Node) Digest() [4]byte { // SetLastSeen updates the last seen time. func (n *Node) SetLastSeen(t time.Time) { - n.stats.SetLastSeen(t) + n.statsRef().SetLastSeen(t) } // SetLastPing updates the last ping time. func (n *Node) SetLastPing(t time.Time) { - n.stats.SetLastPing(t) + n.statsRef().SetLastPing(t) } // SetFailureCount sets the failure count. func (n *Node) SetFailureCount(count int) { - n.stats.SetFailureCount(count) + n.statsRef().SetFailureCount(count) } // SetSuccessCount sets the success count. func (n *Node) SetSuccessCount(count int) { - n.stats.SetSuccessCount(count) + n.statsRef().SetSuccessCount(count) } // IncrementFailureCount increases the failure count by 1. func (n *Node) IncrementFailureCount() { - n.stats.IncrementFailureCount() + n.statsRef().IncrementFailureCount() } // ResetFailureCount resets the failure count to 0 and increments success count. func (n *Node) ResetFailureCount() { - n.stats.ResetFailureCount() + n.statsRef().ResetFailureCount() } // UpdateRTT updates the average RTT using exponential moving average. func (n *Node) UpdateRTT(rtt time.Duration) { - n.stats.UpdateRTT(rtt) + n.statsRef().UpdateRTT(rtt) } // UpdateENR updates the node's ENR record if the new one has a higher sequence number. @@ -251,6 +274,9 @@ func (n *Node) UpdateENR(newRecord *enr.Record) bool { return false } + n.mu.Lock() + defer n.mu.Unlock() + // Only update if new record has higher sequence number if newRecord.Seq() > n.record.Seq() { n.record = newRecord @@ -279,7 +305,7 @@ func (n *Node) UpdateENR(newRecord *enr.Record) bool { // // Format: Node[id=abc123..., addr=192.168.1.1:9000, seen=1m ago] func (n *Node) String() string { - lastSeen := n.stats.LastSeen() + lastSeen := n.statsRef().LastSeen() var seenStr string if lastSeen.IsZero() { @@ -290,7 +316,7 @@ func (n *Node) String() string { return fmt.Sprintf("Node[id=%s, addr=%s, seen=%s]", n.id.String()[:8]+"...", // First 8 chars of ID - n.addr.String(), + n.Addr().String(), seenStr, ) } @@ -308,7 +334,7 @@ type Stats struct { // GetStats returns the current statistics for the node. func (n *Node) GetStats() Stats { - snapshot := n.stats.GetSnapshot() + snapshot := n.statsRef().GetSnapshot() return Stats{ FirstSeen: snapshot.FirstSeen, LastSeen: snapshot.LastSeen, @@ -316,7 +342,7 @@ func (n *Node) GetStats() Stats { FailureCount: snapshot.FailureCount, SuccessCount: snapshot.SuccessCount, AvgRTT: snapshot.AvgRTT, - ENRSeq: n.record.Seq(), + ENRSeq: n.Record().Seq(), } } @@ -354,7 +380,7 @@ type ForkScoringInfo struct { // // Returns a score between 0.0 (worst) and 1.0 (best). func (n *Node) CalculateScore(forkInfo *ForkScoringInfo) float64 { - snapshot := n.stats.GetSnapshot() + snapshot := n.statsRef().GetSnapshot() now := time.Now() // RTT score (30% weight, adjusted from 40%) diff --git a/discv5/node/node_test.go b/discv5/node/node_test.go new file mode 100644 index 0000000..8f9d8c1 --- /dev/null +++ b/discv5/node/node_test.go @@ -0,0 +1,133 @@ +package node + +import ( + "net" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethpandaops/bootnodoor/enr" + "github.com/ethpandaops/bootnodoor/stats" +) + +func signedRecord(t *testing.T, seq uint64, port uint16) *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(3, 3, 3, 3)); err != nil { + t.Fatalf("set ip: %v", err) + } + if err := rec.Set("udp", port); err != nil { + t.Fatalf("set udp: %v", err) + } + rec.SetSeq(seq) + if err := rec.Sign(key); err != nil { + t.Fatalf("sign: %v", err) + } + return rec +} + +// TestNodeConcurrentENRAndStatsAccess exercises UpdateENR and SetStats against +// every reader of the guarded fields. Under the race detector it fails if any +// access to record, addr, tcpPort or the stats pointer is unsynchronized. +func TestNodeConcurrentENRAndStatsAccess(t *testing.T) { + n, err := New(signedRecord(t, 1, 30303)) + if err != nil { + t.Fatal(err) + } + newer := signedRecord(t, 2, 30304) + shared := stats.NewSharedStats(time.Now()) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + wg.Add(2) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + n.UpdateENR(newer) + } + } + }() + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + n.SetStats(shared) + } + } + }() + + for r := 0; r < 4; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + _ = n.Record() + _ = n.Addr() + _ = n.IP() + _ = n.UDPPort() + _ = n.TCPPort() + _ = n.PublicKey() + _ = n.PeerID() + _ = n.Digest() + _ = n.GetStats() + _ = n.CalculateScore(nil) + _ = n.String() + n.SetLastSeen(time.Now()) + n.IncrementFailureCount() + } + } + }() + } + + time.Sleep(200 * time.Millisecond) + close(stop) + wg.Wait() +} + +// TestUpdateENRSeqMonotonicUnderConcurrency verifies interleaved refreshes can +// never install a lower-sequence record over a higher one. +func TestUpdateENRSeqMonotonicUnderConcurrency(t *testing.T) { + n, err := New(signedRecord(t, 1, 30303)) + if err != nil { + t.Fatal(err) + } + older := signedRecord(t, 5, 30305) + newest := signedRecord(t, 9, 30309) + + var wg sync.WaitGroup + for r := 0; r < 8; r++ { + rec := older + if r%2 == 0 { + rec = newest + } + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 500; i++ { + n.UpdateENR(rec) + } + }() + } + wg.Wait() + + if got := n.Record().Seq(); got != 9 { + t.Fatalf("record seq = %d, want the highest installed sequence 9", got) + } +} diff --git a/nodes/flattable.go b/nodes/flattable.go index 98e4c76..e734f45 100644 --- a/nodes/flattable.go +++ b/nodes/flattable.go @@ -199,6 +199,11 @@ func (t *FlatTable) LoadInitialNodesFromDB() error { defer t.mu.Unlock() for _, n := range randomNodes { + // The transport may have admitted nodes before this bulk load runs, so + // stop at the soft cap instead of stacking a full load on top of them. + if len(t.activeNodes) >= t.maxActiveNodes { + break + } if _, exists := t.activeNodes[n.ID()]; exists { continue } diff --git a/nodes/flattable_test.go b/nodes/flattable_test.go new file mode 100644 index 0000000..f36ad7f --- /dev/null +++ b/nodes/flattable_test.go @@ -0,0 +1,96 @@ +package nodes + +import ( + "context" + "net" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/sirupsen/logrus" + + "github.com/ethpandaops/bootnodoor/db" + discv5node "github.com/ethpandaops/bootnodoor/discv5/node" + "github.com/ethpandaops/bootnodoor/enr" +) + +func makeV5At(t *testing.T, ip net.IP) *discv5node.Node { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + rec := enr.New() + if err := rec.Set("ip", ip); err != nil { + t.Fatalf("set ip: %v", err) + } + if err := rec.Set("udp", uint16(9000)); err != nil { + t.Fatalf("set udp: %v", err) + } + if err := rec.Sign(key); err != nil { + t.Fatalf("sign: %v", err) + } + v5, err := discv5node.New(rec) + if err != nil { + t.Fatalf("new v5 node: %v", err) + } + return v5 +} + +// TestLoadInitialNodesFromDBRespectsSoftCap verifies the bulk load stops at +// maxActiveNodes when traffic has already admitted nodes before it runs. +func TestLoadInitialNodesFromDBRespectsSoftCap(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + database := db.NewDatabase(&db.SqliteDatabaseConfig{File: ":memory:"}, logger) + if err := database.Init(); err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.ApplyEmbeddedDbSchema(-2); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ndb := NewNodeDB(ctx, database, db.LayerCL, logger) + + for i := 0; i < 4; i++ { + n := NewFromV5(makeV5At(t, net.IPv4(10, 0, 0, byte(i+1))), ndb) + n.MarkDirty(DirtyFull) + if err := ndb.QueueUpdate(n); err != nil { + t.Fatal(err) + } + } + deadline := time.Now().Add(5 * time.Second) + for ndb.Count() < 4 { + if time.Now().After(deadline) { + t.Fatalf("only %d of 4 seeded nodes were persisted", ndb.Count()) + } + time.Sleep(50 * time.Millisecond) + } + + table, err := NewFlatTable(FlatTableConfig{DB: ndb, MaxActiveNodes: 2, Logger: logger}) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + n := NewFromV5(makeV5At(t, net.IPv4(10, 0, 1, byte(i+1))), ndb) + table.mu.Lock() + table.activeNodes[n.ID()] = n + table.ipLimiter.Add(n) + table.mu.Unlock() + } + + if err := table.LoadInitialNodesFromDB(); err != nil { + t.Fatal(err) + } + + table.mu.RLock() + got := len(table.activeNodes) + table.mu.RUnlock() + if got != 2 { + t.Fatalf("active pool holds %d nodes after bulk load, want the soft cap 2", got) + } +} diff --git a/nodes/node.go b/nodes/node.go index 04ba943..59a88d7 100644 --- a/nodes/node.go +++ b/nodes/node.go @@ -141,6 +141,8 @@ func (n *Node) PublicKey() *ecdsa.PublicKey { // ENR returns the node's ENR record. func (n *Node) ENR() *enr.Record { + n.mu.RLock() + defer n.mu.RUnlock() return n.enr } @@ -400,6 +402,8 @@ type NodeStats struct { // Record returns the node's ENR record. func (n *Node) Record() *enr.Record { + n.mu.RLock() + defer n.mu.RUnlock() return n.enr } @@ -428,21 +432,22 @@ func (n *Node) UpdateENR(newRecord *enr.Record) bool { } // Update our ENR - if newRecord.Seq() > n.enr.Seq() { - n.enr = newRecord - - // Update v5 node if available - n.mu.RLock() - v5 := n.v5Node - n.mu.RUnlock() - if v5 != nil { - v5.UpdateENR(newRecord) - } + n.mu.Lock() + if newRecord.Seq() <= n.enr.Seq() { + n.mu.Unlock() + return false + } + n.enr = newRecord + v5 := n.v5Node + n.mu.Unlock() - return true + // Update v5 node if available - outside the lock so n.mu never nests with + // the v5 node's own mutex. + if v5 != nil { + v5.UpdateENR(newRecord) } - return false + return true } // NeedsPing checks if the node needs a liveness check. From bc8006b7857817ebb0f5c602c47ef5adedcb986d Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Mon, 27 Jul 2026 13:54:37 -0500 Subject: [PATCH 11/20] fix(fork): correct fork-id/digest admission and publication EL: - ForkFilter keeps its genesis time; GetCurrentForkID no longer gathers forks with a hardcoded 0 cutoff, so the current-era id matches go-ethereum - Filter() ports go-ethereum's EIP-2124 validation ruleset (subset/superset/ stale-Next) evaluated at a static head instead of accepting any known checksum; the unused FilterStrict stub is gone - GetAllForkIDsWithNames uses the stored genesis time and groups names per deduplicated activation, so shared-activation rows stay aligned - admission sites record accept/reject counts and log the remote fork id CL: - GetAllForkDigests/Infos and the previous-fork getters enumerate boundary epochs through GetForkDigestForEpoch, so the admission set and the live digest share one code path and same-epoch phantom digests are gone - BLOB_SCHEDULE is sorted at parse; GetBlobParamsForEpoch's early break relied on an ordering the YAML never guaranteed - rejectedExpired (unreachable: expired digests fall through to historical acceptance) is replaced by an acceptedHistorical accessor - the blocking StartPeriodicUpdate is replaced by Update() on a shared tick ENR: - eth/eth2 are refreshed on a maintenance tick so a long-running bootnode stops advertising a stale fork past a scheduled transition; UpdateENR is a no-op when nothing changed, so the sequence only moves at real transitions - lookup completion counts fork rejections separately from pool rejections --- bootnode/clconfig/config.go | 218 ++++++++++------------- bootnode/clconfig/config_test.go | 118 +++++++++++++ bootnode/clconfig/filter.go | 56 +----- bootnode/clconfig/filter_test.go | 47 +++++ bootnode/elconfig/filter.go | 292 +++++++++++++++++++++---------- bootnode/elconfig/filter_test.go | 236 +++++++++++++++++++++++++ bootnode/enr.go | 52 +++++- bootnode/service.go | 114 +++++++++--- bootnode/service_test.go | 79 +++++++++ services/lookup.go | 38 +++- webui/handlers/overview.go | 8 +- 11 files changed, 951 insertions(+), 307 deletions(-) create mode 100644 bootnode/clconfig/config_test.go create mode 100644 bootnode/elconfig/filter_test.go diff --git a/bootnode/clconfig/config.go b/bootnode/clconfig/config.go index b8a854e..0a20353 100644 --- a/bootnode/clconfig/config.go +++ b/bootnode/clconfig/config.go @@ -134,6 +134,12 @@ func LoadConfig(path string) (*Config, error) { cfg.rawConfig = rawConfig + // GetBlobParamsForEpoch's early break and addBPOForks' naming both assume + // an ascending schedule; the YAML carries no ordering guarantee. + sort.SliceStable(cfg.BlobSchedule, func(i, j int) bool { + return cfg.BlobSchedule[i].Epoch < cfg.BlobSchedule[j].Epoch + }) + // Extract fork data dynamically from the map if err := cfg.extractForkData(rawConfig); err != nil { return nil, fmt.Errorf("failed to extract fork data: %w", err) @@ -524,28 +530,52 @@ func (c *Config) GetForkDigestForEpoch(epoch uint64) ForkDigest { return c.GetForkDigest(forkVersion, blobParams) } -// GetCurrentForkDigest returns the fork digest for the current epoch. -// -// Calculates the current epoch based on genesis time and returns the appropriate fork digest. -func (c *Config) GetCurrentForkDigest() ForkDigest { - // Get genesis time +// currentEpochNow computes the current epoch from genesis time and wall +// clock. The second return is false when no genesis time is configured. +func (c *Config) currentEpochNow() (uint64, bool) { genesisTime := c.GetGenesisTime() if genesisTime == 0 { - // No genesis time, fall back to latest fork with realistic epoch - return c.getFallbackForkDigest() + return 0, false } - - // Calculate current epoch currentTime := uint64(time.Now().Unix()) - slotsPerEpoch := c.GetSlotsPerEpoch() secondsPerSlot := c.SecondsPerSlot if secondsPerSlot == 0 { - secondsPerSlot = 12 // Default + secondsPerSlot = 12 } + return uint64(GetCurrentEpoch(genesisTime, currentTime, secondsPerSlot, c.GetSlotsPerEpoch())), true +} - currentEpoch := uint64(GetCurrentEpoch(genesisTime, currentTime, secondsPerSlot, slotsPerEpoch)) +// forkBoundaryEpochs returns every epoch at which the wire digest can change: +// 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) + } + for _, entry := range c.BlobSchedule { + add(entry.Epoch) + } + sort.Slice(epochs, func(i, j int) bool { return epochs[i] < epochs[j] }) + return epochs +} - // Return fork digest for current epoch +// GetCurrentForkDigest returns the fork digest for the current epoch. +// +// Calculates the current epoch based on genesis time and returns the appropriate fork digest. +func (c *Config) GetCurrentForkDigest() ForkDigest { + currentEpoch, ok := c.currentEpochNow() + if !ok { + // No genesis time, fall back to latest fork with realistic epoch + return c.getFallbackForkDigest() + } return c.GetForkDigestForEpoch(currentEpoch) } @@ -554,86 +584,44 @@ func (c *Config) GetGenesisForkDigest() ForkDigest { return c.GetForkDigest(c.genesisForkVersion, nil) } -// GetPreviousForkDigest returns the fork digest for the previous fork before the current one. -// Returns the genesis fork digest if there is no previous fork. -func (c *Config) GetPreviousForkDigest() ForkDigest { - // Get genesis time - genesisTime := c.GetGenesisTime() - if genesisTime == 0 { - // No genesis time, return genesis fork digest - return c.GetGenesisForkDigest() - } - - // Calculate current epoch - currentTime := uint64(time.Now().Unix()) - slotsPerEpoch := c.GetSlotsPerEpoch() - secondsPerSlot := c.SecondsPerSlot - if secondsPerSlot == 0 { - secondsPerSlot = 12 +// previousBoundaryEpoch returns the boundary epoch immediately before the +// currently active one, and whether such a boundary exists. +func (c *Config) previousBoundaryEpoch() (uint64, bool) { + currentEpoch, ok := c.currentEpochNow() + if !ok { + return 0, false } - currentEpoch := uint64(GetCurrentEpoch(genesisTime, currentTime, secondsPerSlot, slotsPerEpoch)) - - // Find the fork before the current one by iterating through forks in forward order - forks := c.getForks() - var currentFork *forkDefinition - var previousFork *forkDefinition - - for i := 0; i < len(forks); i++ { - fork := forks[i] - if currentEpoch >= fork.epoch { - // This fork is active, remember it as current - previousFork = currentFork // The last current becomes previous - currentFork = &forks[i] // This is now current + var passed []uint64 + for _, epoch := range c.forkBoundaryEpochs() { + if epoch > currentEpoch { + break } + passed = append(passed, epoch) } - - // Return the previous fork if it exists - if previousFork != nil { - return c.GetForkDigest(previousFork.parsedVersion, nil) + if len(passed) < 2 { + return 0, false } + return passed[len(passed)-2], true +} - // No previous fork, return genesis - return c.GetGenesisForkDigest() +// GetPreviousForkDigest returns the wire digest that was current before the +// active boundary, computed on the same enumeration as GetAllForkDigests. +// Returns the genesis fork digest if there is no previous boundary. +func (c *Config) GetPreviousForkDigest() ForkDigest { + epoch, ok := c.previousBoundaryEpoch() + if !ok { + return c.GetGenesisForkDigest() + } + return c.GetForkDigestForEpoch(epoch) } // GetPreviousForkName returns the name of the previous fork before the current one. func (c *Config) GetPreviousForkName() string { - // Get genesis time - genesisTime := c.GetGenesisTime() - if genesisTime == 0 { + epoch, ok := c.previousBoundaryEpoch() + if !ok { return "Phase0" } - - // Calculate current epoch - currentTime := uint64(time.Now().Unix()) - slotsPerEpoch := c.GetSlotsPerEpoch() - secondsPerSlot := c.SecondsPerSlot - if secondsPerSlot == 0 { - secondsPerSlot = 12 - } - currentEpoch := uint64(GetCurrentEpoch(genesisTime, currentTime, secondsPerSlot, slotsPerEpoch)) - - // Find the fork before the current one by iterating through forks in forward order - forks := c.getForks() - var currentFork *forkDefinition - var previousFork *forkDefinition - - for i := 0; i < len(forks); i++ { - fork := forks[i] - if currentEpoch >= fork.epoch { - // This fork is active, remember it as current - previousFork = currentFork // The last current becomes previous - currentFork = &forks[i] // This is now current - } - } - - // Return the previous fork name if it exists - if previousFork != nil { - return previousFork.name - } - - // No previous fork, return Phase0 - return "Phase0" + return c.GetForkNameAtEpoch(epoch) } // getFallbackForkDigest returns the latest fork with a realistic epoch. @@ -665,57 +653,43 @@ type ForkDigestInfo struct { ForkVersion [4]byte } -// GetAllForkDigests returns all possible fork digests for this config. -// -// This is useful for creating filters that accept nodes from multiple forks. -// Note: For Fulu+ forks with blob schedules, this returns multiple digests per fork. +// GetAllForkDigests returns every fork digest that can appear on the wire +// for this config: one per boundary epoch, computed through the same +// GetForkDigestForEpoch path that produces the live current digest. Digests +// for same-epoch intermediate forks (never current on the wire) are +// intentionally not included. func (c *Config) GetAllForkDigests() []ForkDigest { var digests []ForkDigest - - // Genesis (epoch 0) - use genesis fork version - digests = append(digests, c.GetForkDigest(c.genesisForkVersion, nil)) - - // All forks (including BPOs) - use their specific fork versions - for _, fork := range c.getForks() { - if fork.epoch != math.MaxUint64 { - // Get blob parameters active at this fork's epoch (if any) - blobParams := c.GetBlobParamsForEpoch(fork.epoch) - digests = append(digests, c.GetForkDigest(fork.parsedVersion, blobParams)) + seen := make(map[ForkDigest]bool) + for _, epoch := range c.forkBoundaryEpochs() { + digest := c.GetForkDigestForEpoch(epoch) + if !seen[digest] { + seen[digest] = true + digests = append(digests, digest) } } - return digests } -// GetAllForkDigestInfos returns all fork digests with their metadata. +// GetAllForkDigestInfos returns all wire-valid fork digests with their +// metadata, on the same boundary enumeration as GetAllForkDigests. func (c *Config) GetAllForkDigestInfos() []ForkDigestInfo { var infos []ForkDigestInfo - - // Add Genesis (epoch 0) - infos = append(infos, ForkDigestInfo{ - Digest: c.GetForkDigest(c.genesisForkVersion, nil), - Name: "Phase0/Genesis", - Epoch: 0, - BlobParams: nil, - ForkVersion: c.genesisForkVersion, - }) - - // Add all forks (including BPOs) - they're already in the correct order - for _, fork := range c.getForks() { - if fork.epoch != math.MaxUint64 { - // Get blob parameters active at this fork's epoch (if any) - blobParams := c.GetBlobParamsForEpoch(fork.epoch) - - infos = append(infos, ForkDigestInfo{ - Digest: c.GetForkDigest(fork.parsedVersion, blobParams), - Name: fork.name, - Epoch: fork.epoch, - BlobParams: blobParams, - ForkVersion: fork.parsedVersion, - }) + seen := make(map[ForkDigest]bool) + for _, epoch := range c.forkBoundaryEpochs() { + digest := c.GetForkDigestForEpoch(epoch) + if seen[digest] { + continue } + seen[digest] = true + infos = append(infos, ForkDigestInfo{ + Digest: digest, + Name: c.GetForkNameAtEpoch(epoch), + Epoch: epoch, + BlobParams: c.GetBlobParamsForEpoch(epoch), + ForkVersion: c.GetForkVersionAtEpoch(epoch), + }) } - return infos } diff --git a/bootnode/clconfig/config_test.go b/bootnode/clconfig/config_test.go new file mode 100644 index 0000000..9deb272 --- /dev/null +++ b/bootnode/clconfig/config_test.go @@ -0,0 +1,118 @@ +package clconfig + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func enumerationConfig(currentEpoch uint64) *Config { + const ( + secondsPerSlot = 12 + slotsPerEpoch = 32 + ) + return &Config{ + SecondsPerSlot: secondsPerSlot, + customGenesisTime: uint64(time.Now().Unix()) - (currentEpoch * secondsPerSlot * slotsPerEpoch) - 60, + customSlotsPerEpoch: slotsPerEpoch, + genesisForkVersion: [4]byte{0x00, 0x00, 0x00, 0x01}, + forks: []forkDefinition{ + {name: "Altair", epoch: 0, parsedVersion: [4]byte{0x01, 0x00, 0x00, 0x00}}, + {name: "Bellatrix", epoch: 0, parsedVersion: [4]byte{0x02, 0x00, 0x00, 0x00}}, + {name: "Capella", epoch: 50, parsedVersion: [4]byte{0x03, 0x00, 0x00, 0x00}}, + }, + } +} + +// TestGetAllForkDigestsEqualsPerEpochEnumeration pins the unified digest +// computation: one digest per boundary epoch through GetForkDigestForEpoch, +// no phantom digests for same-epoch intermediate forks, and the live current +// digest always contained in the set. +func TestGetAllForkDigestsEqualsPerEpochEnumeration(t *testing.T) { + for _, currentEpoch := range []uint64{10, 100} { + cfg := enumerationConfig(currentEpoch) + + digests := cfg.GetAllForkDigests() + if len(digests) != 2 { + t.Fatalf("epoch %d: enumerated %d digests, want 2 boundaries (0, 50) with no phantom same-epoch intermediates", currentEpoch, len(digests)) + } + if digests[0] != cfg.GetForkDigestForEpoch(0) || digests[1] != cfg.GetForkDigestForEpoch(50) { + t.Fatalf("epoch %d: enumeration diverges from GetForkDigestForEpoch", currentEpoch) + } + + current := cfg.GetCurrentForkDigest() + found := false + for _, d := range digests { + if d == current { + found = true + } + } + if !found { + t.Fatalf("epoch %d: current digest %s not in enumerated set", currentEpoch, current.String()) + } + + infos := cfg.GetAllForkDigestInfos() + if len(infos) != len(digests) { + t.Fatalf("epoch %d: infos (%d) and digests (%d) disagree", currentEpoch, len(infos), len(digests)) + } + if infos[0].Name != "Bellatrix" || infos[1].Name != "Capella" { + t.Fatalf("epoch %d: info names = %q, %q; want the wire-active fork per boundary", currentEpoch, infos[0].Name, infos[1].Name) + } + } + + cfg := enumerationConfig(100) + if got := cfg.GetPreviousForkDigest(); got != cfg.GetForkDigestForEpoch(0) { + t.Fatalf("previous digest = %s, want the epoch-0 boundary digest", got.String()) + } + if got := cfg.GetPreviousForkName(); got != "Bellatrix" { + t.Fatalf("previous fork name = %q, want Bellatrix", got) + } +} + +// TestBlobScheduleSortedAtParse verifies an unsorted BLOB_SCHEDULE is sorted +// on load, so GetBlobParamsForEpoch's early break returns the right entry. +func TestBlobScheduleSortedAtParse(t *testing.T) { + yaml := ` +CONFIG_NAME: sorttest +MIN_GENESIS_TIME: 1700000000 +GENESIS_DELAY: 0 +GENESIS_FORK_VERSION: 0x00000001 +SECONDS_PER_SLOT: 12 +ELECTRA_FORK_EPOCH: 10 +ELECTRA_FORK_VERSION: 0x05000001 +FULU_FORK_EPOCH: 100 +FULU_FORK_VERSION: 0x06000001 +MAX_BLOBS_PER_BLOCK_ELECTRA: 9 +BLOB_SCHEDULE: + - EPOCH: 300 + MAX_BLOBS_PER_BLOCK: 21 + - EPOCH: 100 + MAX_BLOBS_PER_BLOCK: 12 + - EPOCH: 200 + MAX_BLOBS_PER_BLOCK: 15 +` + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := LoadConfig(path) + if err != nil { + t.Fatal(err) + } + + for i := 1; i < len(cfg.BlobSchedule); i++ { + if cfg.BlobSchedule[i].Epoch < cfg.BlobSchedule[i-1].Epoch { + t.Fatalf("blob schedule not sorted: %+v", cfg.BlobSchedule) + } + } + if got := cfg.GetBlobParamsForEpoch(250); got == nil || got.MaxBlobsPerBlock != 15 { + t.Fatalf("blob params at 250 = %+v, want the epoch-200 entry (15)", got) + } + if got := cfg.GetBlobParamsForEpoch(50); got != nil { + t.Fatalf("blob params before fulu = %+v, want nil", got) + } + if got := cfg.GetBlobParamsForEpoch(150); got == nil || got.MaxBlobsPerBlock != 12 { + t.Fatalf("blob params at 150 = %+v, want the epoch-100 entry (12)", got) + } +} diff --git a/bootnode/clconfig/filter.go b/bootnode/clconfig/filter.go index b36a608..2f3dc28 100644 --- a/bootnode/clconfig/filter.go +++ b/bootnode/clconfig/filter.go @@ -51,7 +51,6 @@ type ForkDigestFilter struct { acceptedOld int acceptedHistorical int rejectedInvalid int - rejectedExpired int } // Logger interface for debug messages @@ -246,35 +245,6 @@ func (f *ForkDigestFilter) Update() { } } -// StartPeriodicUpdate starts a background goroutine that periodically updates the fork digest. -// -// Parameters: -// - interval: How often to check for fork activations (e.g., 5 minutes) -// - stopCh: Channel to signal shutdown -// -// Example: -// -// stopCh := make(chan struct{}) -// filter.StartPeriodicUpdate(5*time.Minute, genesisTime, stopCh) -// -// // Later, to stop: -// close(stopCh) -func (f *ForkDigestFilter) StartPeriodicUpdate(interval time.Duration, stopCh <-chan struct{}) { - ticker := time.NewTicker(interval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - // Update fork digest - f.Update() - - case <-stopCh: - return - } - } -} - // GetCurrentForkDigest returns the current fork digest being checked. func (f *ForkDigestFilter) GetCurrentForkDigest() ForkDigest { f.mu.RLock() @@ -308,7 +278,6 @@ type FilterStats struct { AcceptedOld int AcceptedHistorical int RejectedInvalid int - RejectedExpired int CurrentDigest ForkDigest OldDigests int LastUpdate time.Time @@ -325,7 +294,6 @@ func (f *ForkDigestFilter) GetStats() FilterStats { AcceptedOld: f.acceptedOld, AcceptedHistorical: f.acceptedHistorical, RejectedInvalid: f.rejectedInvalid, - RejectedExpired: f.rejectedExpired, CurrentDigest: f.currentForkDigest, OldDigests: len(f.oldForkDigests), LastUpdate: f.lastUpdate, @@ -499,12 +467,13 @@ func (f *ForkDigestFilter) GetRejectedInvalid() int { return f.rejectedInvalid } -// GetRejectedExpired returns the count of nodes rejected due to expired grace period. -func (f *ForkDigestFilter) GetRejectedExpired() int { +// GetAcceptedHistorical returns the count of nodes accepted on a historical +// fork digest (valid chain, not the current or grace-period fork). +func (f *ForkDigestFilter) GetAcceptedHistorical() int { f.mu.RLock() defer f.mu.RUnlock() - return f.rejectedExpired + return f.acceptedHistorical } // GetTotalChecks returns the total number of filter checks performed. @@ -562,20 +531,3 @@ func CompatibilityMode(acceptedDigests []ForkDigest) enr.ENRFilter { return digestMap[forkDigest] } } - -// ForkFilterStats contains statistics about fork digest filtering. -type ForkFilterStats struct { - NetworkName string - CurrentFork string - CurrentDigest string - PreviousFork string - PreviousDigest string - GenesisDigest string - GracePeriod string - OldDigests map[string]time.Duration - AcceptedCurrent int - AcceptedOld int - RejectedInvalid int - RejectedExpired int - TotalChecks int -} diff --git a/bootnode/clconfig/filter_test.go b/bootnode/clconfig/filter_test.go index 5169229..1c5eb0e 100644 --- a/bootnode/clconfig/filter_test.go +++ b/bootnode/clconfig/filter_test.go @@ -70,3 +70,50 @@ func TestNextForkInfoFallsBackToFarFuture(t *testing.T) { t.Fatalf("unexpected fallback next fork epoch: got %d", nextEpoch) } } + +// TestForkDigestFilterUpdateTransition verifies Update() tracks an epoch +// crossing: the current digest swaps, the old digest lands in the grace map +// and stays accepted, and the recomputed eth2 field reflects the new digest. +func TestForkDigestFilterUpdateTransition(t *testing.T) { + const ( + secondsPerSlot = 12 + slotsPerEpoch = 32 + ) + cfg := &Config{ + SecondsPerSlot: secondsPerSlot, + customSlotsPerEpoch: slotsPerEpoch, + genesisForkVersion: [4]byte{0x00, 0x00, 0x00, 0x01}, + forks: []forkDefinition{ + {name: "Altair", epoch: 0, parsedVersion: [4]byte{0x01, 0x00, 0x00, 0x00}}, + {name: "Capella", epoch: 100, parsedVersion: [4]byte{0x02, 0x00, 0x00, 0x00}}, + }, + } + cfg.SetGenesisTime(uint64(time.Now().Unix()) - (50 * secondsPerSlot * slotsPerEpoch) - 60) + + filter := NewForkDigestFilter(cfg, time.Hour) + before := cfg.GetForkDigestForEpoch(50) + after := cfg.GetForkDigestForEpoch(100) + if got := filter.GetStats().CurrentDigest; got != before { + t.Fatalf("initial digest = %s, want pre-transition %s", got.String(), before.String()) + } + + cfg.SetGenesisTime(uint64(time.Now().Unix()) - (150 * secondsPerSlot * slotsPerEpoch) - 60) + filter.Update() + + stats := filter.GetStats() + if stats.CurrentDigest != after { + t.Fatalf("post-update digest = %s, want %s", stats.CurrentDigest.String(), after.String()) + } + if stats.OldDigests != 1 { + t.Fatalf("old digests = %d, want the pre-transition digest in the grace map", stats.OldDigests) + } + field := filter.ComputeEth2Field() + if len(field) < 4 { + t.Fatalf("eth2 field too short: %d bytes", len(field)) + } + var gotDigest ForkDigest + copy(gotDigest[:], field[:4]) + if gotDigest != after { + t.Fatalf("eth2 field digest = %s, want post-transition %s", gotDigest.String(), after.String()) + } +} diff --git a/bootnode/elconfig/filter.go b/bootnode/elconfig/filter.go index b77e95a..de69f2c 100644 --- a/bootnode/elconfig/filter.go +++ b/bootnode/elconfig/filter.go @@ -2,29 +2,72 @@ package elconfig import ( "fmt" + "hash/crc32" + "math" + "sync" + "time" ) // ForkFilter validates fork IDs from remote nodes. // -// It checks if a remote fork ID is compatible with the local chain. +// It ports go-ethereum's EIP-2124 validation ruleset evaluated with a static +// head stance: a bootnode tracks no chain head, so every block-scheduled fork +// is treated as passed (exact on post-merge networks, which can only schedule +// forks by time) and the time head is the wall clock at validation time. type ForkFilter struct { - // validForkIDs contains all acceptable fork IDs - validForkIDs map[[4]byte]bool - - // allForkIDs contains the complete list for debugging - allForkIDs []ForkID - // genesisHash is the genesis block hash genesisHash [32]byte // chainConfig is the chain configuration chainConfig *ChainConfig + + // genesisTime is the genesis block timestamp; fork-id math must drop + // time-scheduled forks at or before it, exactly like go-ethereum's + // gatherForks(config, genesis.Time()). + genesisTime uint64 + + // forks holds every canonical fork boundary (blocks then times) plus the + // MaxUint64 sentry go-ethereum appends so the last real fork needs no + // special casing. + forks []uint64 + + // numBlockForks is the boundary index separating block forks from time + // forks in forks, including go-ethereum's rule that the sentry counts as + // a block fork when the chain has no time forks at all. + numBlockForks int + + // blockHead is the static block head: at or past every canonical block + // fork, before the sentry. + blockHead uint64 + + // sums[i] is the checksum after passing the first i fork boundaries. + sums [][4]byte + + // allForkIDs contains the complete canonical list for display + allForkIDs []ForkID + + // Admission outcomes, recorded by the admission call sites only (the + // filter is also invoked for per-packet layer classification, which must + // not pollute these numbers). + statsMu sync.Mutex + totalChecks uint64 + accepted uint64 + rejected uint64 + lastRejectedID ForkID +} + +// FilterStats is a snapshot of admission outcomes. +type FilterStats struct { + TotalChecks uint64 + Accepted uint64 + Rejected uint64 + LastRejectedID ForkID } // NewForkFilter creates a new fork ID filter. // -// It pre-computes all valid fork IDs for the chain so that nodes -// on any valid fork (past, current, or future) are accepted. +// It pre-computes the canonical fork checksum chain so remote fork IDs can be +// validated with go-ethereum's ruleset. // // Parameters: // - genesisHash: Genesis block hash @@ -33,50 +76,131 @@ type ForkFilter struct { // // Returns a filter that can validate remote fork IDs. func NewForkFilter(genesisHash [32]byte, config *ChainConfig, genesisTime uint64) *ForkFilter { - // Gather all forks forksByBlock, forksByTime := GatherForks(config, genesisTime) - // Compute all possible fork IDs - allForkIDs := ComputeAllForkIDs(genesisHash, forksByBlock, forksByTime) + forks := append(append([]uint64{}, forksByBlock...), forksByTime...) + sums := make([][4]byte, len(forks)+1) + hash := crc32.ChecksumIEEE(genesisHash[:]) + sums[0] = checksumToBytes(hash) + for i, fork := range forks { + hash = checksumUpdate(hash, fork) + sums[i+1] = checksumToBytes(hash) + } - // Build lookup map for fast validation - validForkIDs := make(map[[4]byte]bool) - for _, id := range allForkIDs { - validForkIDs[id.Hash] = true + blockHead := uint64(0) + if len(forksByBlock) > 0 { + blockHead = forksByBlock[len(forksByBlock)-1] } + numBlockForks := len(forksByBlock) + forks = append(forks, math.MaxUint64) + if len(forksByTime) == 0 { + // In purely block based forks, keep the sentry out of timestamp + // territory (go-ethereum's rule). + numBlockForks++ + } + + allForkIDs := ComputeAllForkIDs(genesisHash, forksByBlock, forksByTime) + return &ForkFilter{ - validForkIDs: validForkIDs, - allForkIDs: allForkIDs, - genesisHash: genesisHash, - chainConfig: config, + genesisHash: genesisHash, + chainConfig: config, + genesisTime: genesisTime, + forks: forks, + numBlockForks: numBlockForks, + blockHead: blockHead, + sums: sums, + allForkIDs: allForkIDs, } } // Filter checks if a fork ID is valid for this chain. // // Returns true if the fork ID is acceptable, false otherwise. -// -// This implementation is permissive - it accepts any node that appears -// to be on the same chain, regardless of whether they're ahead or behind. func (f *ForkFilter) Filter(id ForkID) bool { - return f.validForkIDs[id.Hash] + return f.validate(id, uint64(time.Now().Unix())) == nil } -// FilterStrict performs strict fork ID validation. +// validate runs go-ethereum's fork checksum validation ruleset with the +// static head stance (see the ForkFilter doc). now is a parameter so tests +// can pin the time head. // -// Returns nil if valid, error describing the issue otherwise. -func (f *ForkFilter) FilterStrict(id ForkID, currentBlock, currentTime uint64) error { - // Check if hash is valid - if !f.validForkIDs[id.Hash] { +// The ruleset, verbatim from go-ethereum: +// 1. If local and remote FORK_CSUM matches, compare local head to FORK_NEXT. +// 1a. A remotely announced but remotely not passed block is already +// passed locally: reject, the chains are incompatible. +// 1b. No remotely announced fork, or not yet passed locally: accept. +// 2. If the remote FORK_CSUM is a subset of the local past forks and the +// remote FORK_NEXT matches the locally following fork: accept (they are +// syncing). +// 3. If the remote FORK_CSUM is a superset of the local past forks and can +// be completed with locally known future forks: accept (we are syncing). +// 4. Reject in all other cases. +func (f *ForkFilter) validate(id ForkID, now uint64) error { + for i, fork := range f.forks { + head := f.blockHead + if i >= f.numBlockForks { + head = now + } + if head >= fork { + continue + } + // Found the first unpassed fork, check the remote against it (rule #1). + if f.sums[i] == id.Hash { + // A remote-announced fork we have already passed means the remote + // is stale (rule #1a). Every unpassed fork here is time-scheduled + // (block forks are all passed under the static stance), so the + // head to compare is the wall clock. + if id.Next > 0 && now >= id.Next { + return fmt.Errorf("remote is stale: announced fork %d already passed", id.Next) + } + return nil + } + // Different fork state: subset means the remote is syncing (rule #2). + for j := 0; j < i; j++ { + if f.sums[j] == id.Hash { + if f.forks[j] != id.Next { + return fmt.Errorf("remote is stale: subset checksum with next %d, want %d", id.Next, f.forks[j]) + } + return nil + } + } + // Superset means we would be the one syncing (rule #3). + for j := i + 1; j < len(f.sums); j++ { + if f.sums[j] == id.Hash { + return nil + } + } return fmt.Errorf("incompatible fork ID hash: %#x", id.Hash) } + // Unreachable: the MaxUint64 sentry can never be passed. + return nil +} - // For strict validation, we could also check id.Next against our state - // to detect if the remote is on a stale fork. For now, we accept any - // valid hash. +// RecordAdmission records an admission decision for the stats surface. Call +// this from admission paths only, never from layer classification. +func (f *ForkFilter) RecordAdmission(acceptedNode bool, id ForkID) { + f.statsMu.Lock() + defer f.statsMu.Unlock() + f.totalChecks++ + if acceptedNode { + f.accepted++ + return + } + f.rejected++ + f.lastRejectedID = id +} - return nil +// GetStats returns a snapshot of the admission outcomes. +func (f *ForkFilter) GetStats() FilterStats { + f.statsMu.Lock() + defer f.statsMu.Unlock() + return FilterStats{ + TotalChecks: f.totalChecks, + Accepted: f.accepted, + Rejected: f.rejected, + LastRejectedID: f.lastRejectedID, + } } // GetAllForkIDs returns all valid fork IDs for debugging. @@ -86,7 +210,7 @@ func (f *ForkFilter) GetAllForkIDs() []ForkID { // GetCurrentForkID calculates the current fork ID based on chain state. func (f *ForkFilter) GetCurrentForkID(currentBlock, currentTime uint64) ForkID { - forksByBlock, forksByTime := GatherForks(f.chainConfig, 0) + forksByBlock, forksByTime := GatherForks(f.chainConfig, f.genesisTime) return ComputeForkID(f.genesisHash, forksByBlock, forksByTime, currentBlock, currentTime) } @@ -100,81 +224,71 @@ type ForkIDWithName struct { // GetAllForkIDsWithNames returns all fork IDs along with their names. // This is useful for displaying fork information in the UI. -func (f *ForkFilter) GetAllForkIDsWithNames(genesisTime uint64) []ForkIDWithName { +// +// Multiple upgrades activating at one block or timestamp share a single fork +// ID, so names are grouped per deduplicated boundary instead of paired +// positionally. +func (f *ForkFilter) GetAllForkIDsWithNames() []ForkIDWithName { if f.chainConfig == nil { return nil } - - // Extract fork data if not already done if len(f.chainConfig.forksByBlock) == 0 && len(f.chainConfig.forksByTime) == 0 && f.chainConfig.rawConfig != nil { f.chainConfig.extractForkData() } - // Collect all forks with their activation points - type forkWithValue struct { - name string + type boundary struct { + names []string value uint64 isTime bool } - - var allForks []forkWithValue - - // Add genesis - allForks = append(allForks, forkWithValue{name: "genesis", value: 0, isTime: false}) - - // Add block-based forks + var boundaries []boundary + appendFork := func(name string, value uint64, isTime bool) { + for i := range boundaries { + if boundaries[i].value == value && boundaries[i].isTime == isTime { + boundaries[i].names = append(boundaries[i].names, name) + return + } + } + boundaries = append(boundaries, boundary{names: []string{name}, value: value, isTime: isTime}) + } for _, fork := range f.chainConfig.forksByBlock { if fork.value > 0 { - allForks = append(allForks, forkWithValue{name: fork.name, value: fork.value, isTime: false}) + appendFork(fork.name, fork.value, false) } } - - // Add time-based forks for _, fork := range f.chainConfig.forksByTime { - if fork.value > genesisTime { - allForks = append(allForks, forkWithValue{name: fork.name, value: fork.value, isTime: true}) + if fork.value > f.genesisTime { + appendFork(fork.name, fork.value, true) } } - // Gather fork values for computing fork IDs - forksByBlock, forksByTime := GatherForks(f.chainConfig, genesisTime) - - // Compute all fork IDs - allForkIDs := ComputeAllForkIDs(f.genesisHash, forksByBlock, forksByTime) - - // Build result - match fork IDs to names - // The fork IDs are in order: genesis, then each subsequent fork - result := make([]ForkIDWithName, 0, len(allForkIDs)) - - if len(allForkIDs) > 0 { - // First fork ID is always genesis - result = append(result, ForkIDWithName{ - ForkID: allForkIDs[0], - Name: "Genesis", - Activation: 0, - IsTime: false, - }) - - // Subsequent fork IDs correspond to activation points in chronological order - forkIdx := 1 - for _, fork := range allForks { - if fork.name != "genesis" && forkIdx < len(allForkIDs) { - // Capitalize first letter of fork name for display - displayName := fork.name - if len(displayName) > 0 { - displayName = string(displayName[0]-32) + displayName[1:] - } - - result = append(result, ForkIDWithName{ - ForkID: allForkIDs[forkIdx], - Name: displayName, - Activation: fork.value, - IsTime: fork.isTime, - }) - forkIdx++ + result := make([]ForkIDWithName, 0, len(f.allForkIDs)) + result = append(result, ForkIDWithName{ + ForkID: f.allForkIDs[0], + Name: "Genesis", + Activation: 0, + IsTime: false, + }) + for i, b := range boundaries { + if i+1 >= len(f.allForkIDs) { + break + } + name := "" + for j, n := range b.names { + if len(n) > 0 { + n = string(n[0]-32) + n[1:] + } + if j > 0 { + name += "/" } + name += n } + result = append(result, ForkIDWithName{ + ForkID: f.allForkIDs[i+1], + Name: name, + Activation: b.value, + IsTime: b.isTime, + }) } - return result } diff --git a/bootnode/elconfig/filter_test.go b/bootnode/elconfig/filter_test.go new file mode 100644 index 0000000..f022da5 --- /dev/null +++ b/bootnode/elconfig/filter_test.go @@ -0,0 +1,236 @@ +package elconfig + +import ( + "encoding/binary" + "encoding/hex" + "strings" + "testing" +) + +// mainnetGenesisHash is the Ethereum mainnet genesis block hash, so the +// checksums below can be verified against go-ethereum's forkid test vectors. +var mainnetGenesisHash = mustHash32("d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3") + +func mustHash32(s string) [32]byte { + b, err := hex.DecodeString(s) + if err != nil || len(b) != 32 { + panic("bad hash literal") + } + var out [32]byte + copy(out[:], b) + return out +} + +// mainnetConfig mirrors go-ethereum's params.MainnetChainConfig fork +// schedule. Mainnet's genesis block timestamp is 0. +func mainnetConfig() *ChainConfig { + return &ChainConfig{rawConfig: map[string]interface{}{ + "homesteadBlock": 1150000, + "daoForkBlock": 1920000, + "eip150Block": 2463000, + "eip155Block": 2675000, + "eip158Block": 2675000, + "byzantiumBlock": 4370000, + "constantinopleBlock": 7280000, + "petersburgBlock": 7280000, + "istanbulBlock": 9069000, + "muirGlacierBlock": 9200000, + "berlinBlock": 12244000, + "londonBlock": 12965000, + "arrowGlacierBlock": 13773000, + "grayGlacierBlock": 15050000, + "shanghaiTime": 1681338455, + "cancunTime": 1710338135, + "pragueTime": 1746612311, + "osakaTime": 1764798551, + "bpo1Time": 1765290071, + "bpo2Time": 1767747671, + }} +} + +// devnetConfig models a kurtosis-style devnet: every fork active at genesis. +func devnetConfig(genesisTime uint64) *ChainConfig { + return &ChainConfig{rawConfig: map[string]interface{}{ + "homesteadBlock": 0, + "londonBlock": 0, + "shanghaiTime": int(genesisTime), + "cancunTime": int(genesisTime), + "pragueTime": int(genesisTime), + }} +} + +func sum32(hash uint32) [4]byte { + var out [4]byte + binary.BigEndian.PutUint32(out[:], hash) + return out +} + +func TestGatherForksGenesisCutoff(t *testing.T) { + byBlock, byTime := GatherForks(mainnetConfig(), 0) + if len(byBlock) != 12 { + t.Fatalf("block forks = %d (%v), want 12 deduplicated boundaries", len(byBlock), byBlock) + } + if len(byTime) != 6 { + t.Fatalf("time forks = %d (%v), want 6", len(byTime), byTime) + } + for i := 1; i < len(byBlock); i++ { + if byBlock[i] <= byBlock[i-1] { + t.Fatalf("block forks not strictly ascending: %v", byBlock) + } + } + + _, byTime = GatherForks(mainnetConfig(), 1710338135) + if len(byTime) != 4 || byTime[0] != 1746612311 { + t.Fatalf("cutoff at cancun kept %v, want time forks strictly after the cutoff", byTime) + } + + byBlock, byTime = GatherForks(devnetConfig(1700000000), 1700000000) + if len(byBlock) != 0 || len(byTime) != 0 { + t.Fatalf("all-at-genesis devnet gathered %v/%v, want none (genesis ruleset)", byBlock, byTime) + } +} + +// TestComputeForkIDNextSelection pins go-ethereum's mainnet TestCreation +// vectors at era boundaries. +func TestComputeForkIDNextSelection(t *testing.T) { + byBlock, byTime := GatherForks(mainnetConfig(), 0) + cases := []struct { + head, time uint64 + wantHash uint32 + wantNext uint64 + }{ + {0, 0, 0xfc64ec04, 1150000}, + {1149999, 0, 0xfc64ec04, 1150000}, + {1150000, 0, 0x97c2c34c, 1920000}, + {15050000, 1681338454, 0xf0afd0e3, 1681338455}, + {20000000, 1681338455, 0xdce96c2d, 1710338135}, + {30000000, 1710338134, 0xdce96c2d, 1710338135}, + {30000000, 1710338135, 0x9f3d2254, 1746612311}, + {30000000, 1746612311, 0xc376cf8b, 1764798551}, + {30000000, 1767747671, 0x07c9462e, 0}, + {50000000, 2000000000, 0x07c9462e, 0}, + } + for _, c := range cases { + got := ComputeForkID(mainnetGenesisHash, byBlock, byTime, c.head, c.time) + if got.Hash != sum32(c.wantHash) || got.Next != c.wantNext { + t.Errorf("ComputeForkID(head=%d, time=%d) = %v, want {%#x %d}", c.head, c.time, got, c.wantHash, c.wantNext) + } + } +} + +func TestComputeAllForkIDsConsistentWithComputeForkID(t *testing.T) { + byBlock, byTime := GatherForks(mainnetConfig(), 0) + all := ComputeAllForkIDs(mainnetGenesisHash, byBlock, byTime) + if len(all) != len(byBlock)+len(byTime)+1 { + t.Fatalf("enumerated %d ids, want %d", len(all), len(byBlock)+len(byTime)+1) + } + boundaries := append(append([]uint64{}, byBlock...), byTime...) + for i, boundary := range boundaries { + var head, time uint64 + if i < len(byBlock) { + head = boundary + time = 0 + } else { + head = byBlock[len(byBlock)-1] + time = boundary + } + got := ComputeForkID(mainnetGenesisHash, byBlock, byTime, head, time) + if got != all[i+1] { + t.Errorf("boundary %d: walked id %v != enumerated %v", boundary, got, all[i+1]) + } + } +} + +// TestGetCurrentForkIDUsesGenesisTime is the regression pin for the hardcoded +// genesisTime=0 bug: an all-at-genesis devnet must report the genesis-era id +// with no upcoming fork, and the result must be in the admission set. +func TestGetCurrentForkIDUsesGenesisTime(t *testing.T) { + const genesisTime = 1700000000 + genesisHash := mustHash32("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + f := NewForkFilter(genesisHash, devnetConfig(genesisTime), genesisTime) + + got := f.GetCurrentForkID(999999999, genesisTime+600) + byBlock, byTime := GatherForks(devnetConfig(genesisTime), genesisTime) + want := ComputeForkID(genesisHash, byBlock, byTime, 0, 0) + if got != want || got.Next != 0 { + t.Fatalf("devnet current fork id = %v, want genesis-era %v with Next 0", got, want) + } + found := false + for _, id := range f.GetAllForkIDs() { + if id == got { + found = true + } + } + if !found { + t.Fatal("current fork id is not in the filter's own admission set") + } + + mf := NewForkFilter(mainnetGenesisHash, mainnetConfig(), 0) + if got := mf.GetCurrentForkID(999999999, 1710338135); got.Hash != sum32(0x9f3d2254) || got.Next != 1746612311 { + t.Fatalf("mainnet current-era id = %v, want {0x9f3d2254 1746612311}", got) + } +} + +// TestForkFilterValidation ports the static-stance-relevant subset of +// go-ethereum's validation rules: the time head is pinned mid-Cancun. +func TestForkFilterValidation(t *testing.T) { + f := NewForkFilter(mainnetGenesisHash, mainnetConfig(), 0) + const now = 1720000000 // between cancun (1710338135) and prague (1746612311) + + cases := []struct { + name string + id ForkID + accept bool + }{ + {"current era, correct next", ForkID{sum32(0x9f3d2254), 1746612311}, true}, + {"current era, no next", ForkID{sum32(0x9f3d2254), 0}, true}, + {"current era, stale next already passed (rule 1a)", ForkID{sum32(0x9f3d2254), 1710338135}, false}, + {"subset syncing peer, correct next (rule 2)", ForkID{sum32(0xdce96c2d), 1710338135}, true}, + {"subset stale peer, wrong next (rule 2)", ForkID{sum32(0xdce96c2d), 0}, false}, + {"deep subset syncing peer, correct next", ForkID{sum32(0xfc64ec04), 1150000}, true}, + {"superset future peer (rule 3)", ForkID{sum32(0xc376cf8b), 1764798551}, true}, + {"unknown chain (rule 4)", ForkID{sum32(0xdeadbeef), 0}, false}, + } + for _, c := range cases { + err := f.validate(c.id, now) + if (err == nil) != c.accept { + t.Errorf("%s: validate(%v) = %v, want accept=%v", c.name, c.id, err, c.accept) + } + } + + f.RecordAdmission(true, cases[0].id) + f.RecordAdmission(false, cases[7].id) + stats := f.GetStats() + if stats.TotalChecks != 2 || stats.Accepted != 1 || stats.Rejected != 1 || stats.LastRejectedID != cases[7].id { + t.Fatalf("admission stats = %+v", stats) + } +} + +// TestGetAllForkIDsWithNamesAlignment covers duplicate activations: two +// upgrades sharing one timestamp must collapse to one named row per fork id. +func TestGetAllForkIDsWithNamesAlignment(t *testing.T) { + cfg := &ChainConfig{rawConfig: map[string]interface{}{ + "homesteadBlock": 1000, + "shanghaiTime": 5000, + "cancunTime": 5000, + "pragueTime": 6000, + }} + genesisHash := mustHash32("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + f := NewForkFilter(genesisHash, cfg, 100) + + rows := f.GetAllForkIDsWithNames() + if len(rows) != len(f.GetAllForkIDs()) { + t.Fatalf("rows = %d, want one per fork id (%d)", len(rows), len(f.GetAllForkIDs())) + } + for i, row := range rows { + if row.ForkID != f.GetAllForkIDs()[i] { + t.Errorf("row %d id %v misaligned with enumerated %v", i, row.ForkID, f.GetAllForkIDs()[i]) + } + } + if rows[0].Name != "Genesis" || rows[1].Name != "Homestead" || rows[3].Name != "Prague" { + t.Fatalf("row names = %q, %q, _, %q", rows[0].Name, rows[1].Name, rows[3].Name) + } + if !strings.Contains(rows[2].Name, "Shanghai") || !strings.Contains(rows[2].Name, "Cancun") || rows[2].Activation != 5000 || !rows[2].IsTime { + t.Fatalf("shared-activation row = %+v, want both upgrade names at @5000", rows[2]) + } +} diff --git a/bootnode/enr.go b/bootnode/enr.go index 2feafa8..50066ed 100644 --- a/bootnode/enr.go +++ b/bootnode/enr.go @@ -1,9 +1,12 @@ package bootnode import ( + "bytes" "crypto/ecdsa" "fmt" + "math" "net" + "time" "github.com/ethpandaops/bootnodoor/bootnode/clconfig" "github.com/ethpandaops/bootnodoor/bootnode/elconfig" @@ -63,8 +66,20 @@ func NewENRManager(cfg *Config, key *ecdsa.PrivateKey, localNode *v5node.Node, s return manager } +// StaticHead returns the head a bootnode evaluates fork schedules at. It +// tracks no chain, so every block-scheduled fork counts as passed (exact on +// post-merge networks, which can only schedule forks by time) and the time +// head is the wall clock. +func StaticHead() (block, timestamp uint64) { + return math.MaxUint64 - 1, uint64(time.Now().Unix()) +} + // UpdateENR updates the local ENR with current eth and eth2 fields. // +// It is a no-op when the computed fields already match the published record, +// so periodic callers do not churn the sequence number (peers re-fetch a +// record on every bump). +// // This should be called: // - On startup // - After fork transitions @@ -78,6 +93,8 @@ func (m *ENRManager) UpdateENR(currentBlock, currentTime uint64) error { return fmt.Errorf("failed to clone ENR: %w", err) } + changed := false + // A bootnode serves no TCP, so never advertise tcp/tcp6 — including any // inherited from an ENR persisted by an older, TCP-advertising version. newRecord.Delete("tcp") @@ -98,24 +115,43 @@ func (m *ENRManager) UpdateENR(currentBlock, currentTime uint64) error { } newRecord.Set("eth", ethField) - m.config.Logger.WithField("forkID", forkID.String()).Debug("updated ENR with eth field") - } else { + if current, ok := record.Eth(); !ok || len(current) == 0 || + current[0].ForkID != forkID.Hash || current[0].NextForkEpoch != forkID.Next { + changed = true + m.config.Logger.WithField("forkID", forkID.String()).Debug("updated ENR with eth field") + } + } else if record.Has("eth") { // Drop any stale eth field (e.g. inherited from a reused shared ENR). newRecord.Delete("eth") + changed = true } if m.servesCL && m.config.HasCL() { eth2Field := m.clFilter.ComputeEth2Field() newRecord.Set("eth2", eth2Field) - // eth2Field is []byte, extract first 4 bytes as fork digest for logging - var forkDigest [4]byte - if len(eth2Field) >= 4 { - copy(forkDigest[:], eth2Field[0:4]) + var currentEth2 []byte + if err := record.Get("eth2", ¤tEth2); err != nil || !bytes.Equal(currentEth2, eth2Field) { + changed = true + + // eth2Field is []byte, extract first 4 bytes as fork digest for logging + var forkDigest [4]byte + if len(eth2Field) >= 4 { + copy(forkDigest[:], eth2Field[0:4]) + } + m.config.Logger.WithField("forkDigest", fmt.Sprintf("%#x", forkDigest)).Debug("updated ENR with eth2 field") } - m.config.Logger.WithField("forkDigest", fmt.Sprintf("%#x", forkDigest)).Debug("updated ENR with eth2 field") - } else { + } else if record.Has("eth2") { newRecord.Delete("eth2") + changed = true + } + + if record.Has("tcp") || record.Has("tcp6") { + changed = true + } + + if !changed { + return nil } // Increment sequence number diff --git a/bootnode/service.go b/bootnode/service.go index ca3d6ef..277f32e 100644 --- a/bootnode/service.go +++ b/bootnode/service.go @@ -153,7 +153,8 @@ func New(cfg *Config) (*Service, error) { } id.enrManager = NewENRManager(cfg, id.key, localNode, id.servesEL, id.servesCL) - if uerr := id.enrManager.UpdateENR(0, 0); uerr != nil { + headBlock, headTime := StaticHead() + if uerr := id.enrManager.UpdateENR(headBlock, headTime); uerr != nil { cfg.Logger.WithError(uerr).Warn("failed to update ENR with eth/eth2 fields") } else if serr := s.storeENR(id.storeKey, localNode.Record()); serr != nil { cfg.Logger.WithError(serr).Warn("failed to store updated ENR") @@ -253,15 +254,23 @@ func New(cfg *Config) (*Service, error) { Layer: db.LayerEL, Alpha: 3, LookupTimeout: 30 * time.Second, - OnNodeFound: func(n *nodes.Node) bool { + OnNodeFound: func(n *nodes.Node) services.AdmissionResult { // Filter by fork ID before adding to table if n.Record() != nil && s.enrManager != nil { - if isEL, _ := s.enrManager.FilterELNode(n.Record()); !isEL { + isEL, forkID := s.enrManager.FilterELNode(n.Record()) + if elFilter := s.enrManager.GetELFilter(); elFilter != nil { + elFilter.RecordAdmission(isEL, forkID) + } + if !isEL { + cfg.Logger.WithFields(logrus.Fields{ + "peerID": n.PeerID(), + "eth": forkID.String(), + }).Debug("EL lookup admission rejected: incompatible fork id") // Mark as bad node if err := cfg.Database.StoreBadNode(n.IDBytes(), db.LayerEL, "invalid_fork_id"); err != nil { cfg.Logger.WithError(err).Debug("failed to store bad node") } - return false + return services.AdmissionRejectedFilter } } @@ -300,14 +309,14 @@ func New(cfg *Config) (*Service, error) { } // Attempt to add to EL table - added := s.elTable.Add(n) - if added { - // Remove from bad nodes list if it was previously bad - if err := cfg.Database.RemoveBadNode(n.IDBytes(), db.LayerEL); err != nil { - cfg.Logger.WithError(err).Debug("failed to remove from bad nodes") - } + if !s.elTable.Add(n) { + return services.AdmissionRejectedPool } - return added + // Remove from bad nodes list if it was previously bad + if err := cfg.Database.RemoveBadNode(n.IDBytes(), db.LayerEL); err != nil { + cfg.Logger.WithError(err).Debug("failed to remove from bad nodes") + } + return services.AdmissionAccepted }, Logger: cfg.Logger.WithField("service", "el-lookup"), }) @@ -336,7 +345,7 @@ func New(cfg *Config) (*Service, error) { Layer: db.LayerCL, Alpha: 3, LookupTimeout: 30 * time.Second, - OnNodeFound: func(n *nodes.Node) bool { + OnNodeFound: func(n *nodes.Node) services.AdmissionResult { // Filter by fork digest before adding to table if n.Record() != nil && s.enrManager != nil { if !s.enrManager.FilterCLNode(n.Record()) { @@ -344,18 +353,18 @@ func New(cfg *Config) (*Service, error) { if err := cfg.Database.StoreBadNode(n.IDBytes(), db.LayerCL, "invalid_fork_digest"); err != nil { cfg.Logger.WithError(err).Debug("failed to store bad node") } - return false + return services.AdmissionRejectedFilter } } // Attempt to add to CL table - added := s.clTable.Add(n) - if added { - // Remove from bad nodes list if it was previously bad - if err := cfg.Database.RemoveBadNode(n.IDBytes(), db.LayerCL); err != nil { - cfg.Logger.WithError(err).Debug("failed to remove from bad nodes") - } + if !s.clTable.Add(n) { + return services.AdmissionRejectedPool } - return added + // Remove from bad nodes list if it was previously bad + if err := cfg.Database.RemoveBadNode(n.IDBytes(), db.LayerCL); err != nil { + cfg.Logger.WithError(err).Debug("failed to remove from bad nodes") + } + return services.AdmissionAccepted }, Logger: cfg.Logger.WithField("service", "cl-lookup"), }) @@ -605,6 +614,7 @@ func (s *Service) maintenanceLoop() { supportCheck := time.NewTicker(30 * time.Minute) // Check protocol support every 30 minutes badNodesCleanup := time.NewTicker(24 * time.Hour) // Cleanup bad nodes once per day enrRequestCleanup := time.NewTicker(1 * time.Minute) // Cleanup stale ENR requests every minute + forkRefresh := time.NewTicker(1 * time.Minute) // Re-publish eth/eth2 when a fork activates defer tableMaintenance.Stop() defer alivenessCheck.Stop() @@ -612,6 +622,7 @@ func (s *Service) maintenanceLoop() { defer supportCheck.Stop() defer badNodesCleanup.Stop() defer enrRequestCleanup.Stop() + defer forkRefresh.Stop() for { select { @@ -635,7 +646,50 @@ func (s *Service) maintenanceLoop() { case <-enrRequestCleanup.C: s.cleanupStaleENRRequests() + + case <-forkRefresh.C: + s.refreshForkENR() + } + } +} + +// refreshForkENR re-publishes the eth/eth2 ENR fields when a fork activates +// while running. Nothing else refreshes them, so without this a long-lived +// bootnode keeps advertising a stale fork id past every scheduled transition. +// UpdateENR is a no-op when nothing changed, so the sequence number only moves +// at real transitions. +func (s *Service) refreshForkENR() { + s.mu.Lock() + defer s.mu.Unlock() + + headBlock, headTime := StaticHead() + + for _, id := range s.identities { + if id.localNode == nil || id.enrManager == nil { + continue } + // Advance the CL accept set first so the recomputed eth2 field carries + // the new digest. + if clFilter := id.enrManager.GetCLFilter(); clFilter != nil { + clFilter.Update() + } + + beforeSeq := id.localNode.Record().Seq() + if err := id.enrManager.UpdateENR(headBlock, headTime); err != nil { + s.config.Logger.WithError(err).Error("failed to refresh fork fields in ENR") + continue + } + if id.localNode.Record().Seq() == beforeSeq { + continue + } + + if err := s.storeENR(id.storeKey, id.localNode.Record()); err != nil { + s.config.Logger.WithError(err).Warn("failed to store refreshed ENR") + } + if id.servesEL && s.discv4Service != nil { + s.discv4Service.SetLocalENR(id.localNode.Record()) + } + s.config.Logger.WithField("seq", id.localNode.Record().Seq()).Info("fork transition: re-published ENR fork fields") } } @@ -869,7 +923,11 @@ func (s *Service) connectELBootnodeENR(record *enr.Record) { // Filter by fork ID before adding if s.enrManager != nil { - if isEL, forkID := s.enrManager.FilterELNode(record); !isEL { + isEL, forkID := s.enrManager.FilterELNode(record) + if elFilter := s.enrManager.GetELFilter(); elFilter != nil { + elFilter.RecordAdmission(isEL, forkID) + } + if !isEL { s.config.Logger.WithFields(logrus.Fields{ "nodeID": fmt.Sprintf("%x", v5.ID().Bytes()[:8]), "eth": forkID, @@ -925,7 +983,11 @@ func (s *Service) connectELBootnodeEnode(enodeURL *enode.Enode) { // Filter by fork ID before adding if s.enrManager != nil { - if isEL, forkID := s.enrManager.FilterELNode(enrRecord); !isEL { + isEL, forkID := s.enrManager.FilterELNode(enrRecord) + if elFilter := s.enrManager.GetELFilter(); elFilter != nil { + elFilter.RecordAdmission(isEL, forkID) + } + if !isEL { s.config.Logger.WithFields(logrus.Fields{ "nodeID": fmt.Sprintf("%x", nodeID[:8]), "eth": forkID, @@ -1236,6 +1298,9 @@ func (s *Service) checkAndAddNodeV4(n *v4node.Node) bool { // Filter the node using ENR manager (EL-only for discv4) if s.enrManager != nil { filter, forkID := s.enrManager.FilterELNode(n.ENR()) + if elFilter := s.enrManager.GetELFilter(); elFilter != nil { + elFilter.RecordAdmission(filter, forkID) + } if !filter { s.config.Logger.WithFields(logrus.Fields{ "nodeID": fmt.Sprintf("%x", n.IDBytes()[:8]), @@ -1268,8 +1333,11 @@ func (s *Service) checkAndAddNode(n *v5node.Node) bool { } // Determine layer - isEL, _ := s.enrManager.FilterELNode(n.Record()) + isEL, elForkID := s.enrManager.FilterELNode(n.Record()) isCL := s.enrManager.FilterCLNode(n.Record()) + if elFilter := s.enrManager.GetELFilter(); elFilter != nil { + elFilter.RecordAdmission(isEL, elForkID) + } // Add to appropriate table(s) added := false diff --git a/bootnode/service_test.go b/bootnode/service_test.go index 6d80216..dd4d267 100644 --- a/bootnode/service_test.go +++ b/bootnode/service_test.go @@ -2,6 +2,7 @@ package bootnode import ( "crypto/ecdsa" + "fmt" "net" "testing" "time" @@ -528,3 +529,81 @@ func TestLayerENR_StripsForeignForkField(t *testing.T) { t.Error("CL ENR should not carry eth") } } + +// The published eth entry must describe the current fork era, not the genesis +// era with an already-passed Next (which geth-family peers treat as stale). +func TestUpdateENR_PublishesCurrentEraForkID(t *testing.T) { + const genesisTime = 1000 + passed := uint64(time.Now().Unix()) - 3600 + future := uint64(time.Now().Unix()) + 86400 + cfg := &Config{ + Logger: quietLogger(), + ELConfig: mustChainConfig(t, fmt.Sprintf(`{"chainId":1,"shanghaiTime":%d,"cancunTime":%d}`, passed, future)), + ELGenesisHash: [32]byte{1, 2, 3}, + ELGenesisTime: genesisTime, + } + key := mustKey(t) + ln, err := createLocalNode(cfg, key, net.ParseIP("1.2.3.4"), nil, 9000, nil) + if err != nil { + t.Fatalf("createLocalNode: %v", err) + } + + mgr := NewENRManager(cfg, key, ln, true, false) + headBlock, headTime := StaticHead() + if err := mgr.UpdateENR(headBlock, headTime); err != nil { + t.Fatalf("UpdateENR: %v", err) + } + + eth, ok := ln.Record().Eth() + if !ok || len(eth) == 0 { + t.Fatal("EL identity did not advertise eth") + } + want := mgr.GetELFilter().GetCurrentForkID(headBlock, headTime) + if eth[0].ForkID != want.Hash || eth[0].NextForkEpoch != want.Next { + t.Fatalf("published eth = {%#x %d}, want current era {%#x %d}", eth[0].ForkID, eth[0].NextForkEpoch, want.Hash, want.Next) + } + if eth[0].NextForkEpoch != future { + t.Fatalf("published Next = %d, want the upcoming fork %d", eth[0].NextForkEpoch, future) + } +} + +// The refresh tick calls UpdateENR every minute; an unchanged record must not +// bump the sequence number, because peers re-fetch on every bump. +func TestUpdateENR_NoSeqBumpWhenUnchanged(t *testing.T) { + cfg := &Config{ + Logger: quietLogger(), + ELConfig: mustChainConfig(t, `{"chainId":1,"shanghaiTime":1500}`), + ELGenesisHash: [32]byte{4, 5, 6}, + ELGenesisTime: 1000, + } + key := mustKey(t) + ln, err := createLocalNode(cfg, key, net.ParseIP("1.2.3.4"), nil, 9000, nil) + if err != nil { + t.Fatalf("createLocalNode: %v", err) + } + + mgr := NewENRManager(cfg, key, ln, true, false) + headBlock, headTime := StaticHead() + if err := mgr.UpdateENR(headBlock, headTime); err != nil { + t.Fatalf("first UpdateENR: %v", err) + } + seq := ln.Record().Seq() + + for i := 0; i < 3; i++ { + if err := mgr.UpdateENR(StaticHead()); err != nil { + t.Fatalf("repeat UpdateENR: %v", err) + } + } + if got := ln.Record().Seq(); got != seq { + t.Fatalf("sequence advanced from %d to %d without a field change", seq, got) + } +} + +func mustChainConfig(t *testing.T, jsonCfg string) *elconfig.ChainConfig { + t.Helper() + cfg, err := elconfig.ParseChainConfig([]byte(jsonCfg)) + if err != nil { + t.Fatalf("ParseChainConfig: %v", err) + } + return cfg +} diff --git a/services/lookup.go b/services/lookup.go index 0cc553c..62b21ba 100644 --- a/services/lookup.go +++ b/services/lookup.go @@ -55,6 +55,22 @@ type LookupService struct { lookupsV4 int // Lookups using v4 } +// AdmissionResult reports why a discovered node was or was not admitted, so +// fork-filter rejections stay distinguishable from pool-capacity rejections. +type AdmissionResult int + +const ( + // AdmissionAccepted means the node was admitted to the table. + AdmissionAccepted AdmissionResult = iota + + // AdmissionRejectedFilter means the node failed fork validation. + AdmissionRejectedFilter + + // AdmissionRejectedPool means the node passed validation but the table + // declined it (capacity, per-IP limit, or self). + AdmissionRejectedPool +) + // Config contains configuration for the lookup service. type Config struct { // LocalNode is our node information @@ -88,7 +104,7 @@ type Config struct { // OnNodeFound is called when a new node is discovered during lookup // The callback should handle admission checks and add the node if valid - OnNodeFound func(*nodedb.Node) bool + OnNodeFound func(*nodedb.Node) AdmissionResult // Logger for debug messages Logger logrus.FieldLogger @@ -455,9 +471,18 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i // Add discovered nodes via callback (handles admission checks) var addedNodes []*nodedb.Node + var rejectedFilter, rejectedPool int for _, n := range allDiscovered { - if ls.config.OnNodeFound != nil && ls.config.OnNodeFound(n) { + if ls.config.OnNodeFound == nil { + continue + } + switch ls.config.OnNodeFound(n) { + case AdmissionAccepted: addedNodes = append(addedNodes, n) + case AdmissionRejectedFilter: + rejectedFilter++ + case AdmissionRejectedPool: + rejectedPool++ } } @@ -467,10 +492,11 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i ls.mu.Unlock() ls.config.Logger.WithFields(logrus.Fields{ - "target": target, - "discovered": len(allDiscovered), - "accepted": len(addedNodes), - "rejected": len(allDiscovered) - len(addedNodes), + "target": target, + "discovered": len(allDiscovered), + "accepted": len(addedNodes), + "rejected_fork": rejectedFilter, + "rejected_pool": rejectedPool, }).Info("lookup complete") return addedNodes, nil diff --git a/webui/handlers/overview.go b/webui/handlers/overview.go index 65f8d67..ea5d1da 100644 --- a/webui/handlers/overview.go +++ b/webui/handlers/overview.go @@ -364,13 +364,7 @@ func (fh *FrontendHandler) getOverviewPageData() (*OverviewPageData, error) { if elConfig := fh.bootnodeService.ELConfig(); elConfig != nil { if enrMgr := fh.bootnodeService.ENRManager(); enrMgr != nil { if elFilter := enrMgr.GetELFilter(); elFilter != nil { - // Get genesis time from config - genesisTime := uint64(0) - if fh.bootnodeService.ELConfig() != nil { - // Note: We'd need the genesis time here, defaulting to 0 - } - - allForksWithNames := elFilter.GetAllForkIDsWithNames(genesisTime) + allForksWithNames := elFilter.GetAllForkIDsWithNames() pageData.ELForks = make([]ForkInfo, 0, len(allForksWithNames)) for _, fork := range allForksWithNames { // Format activation point From 58f1fc744d35dc6f136da44b0e0b1b8df5ac0c20 Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Mon, 27 Jul 2026 14:00:08 -0500 Subject: [PATCH 12/20] fix(discovery): never dial our own identities Config.LocalNode was declared but never read, so nothing excluded us from discovery: a peer returning our record in a NODES/NEIGHBORS response made us FINDNODE ourselves every round, and two concurrent self-handshakes collided on the per-nodeID+addr challenge key, producing invalid handshake signature warnings against our own address. - Config.LocalNode is replaced by LocalIDs, which carries every identity's node ID (two when separate EL and CL keys are configured) - lookups seed the seen map with all local IDs, skip them when selecting query targets, and skip them in the discv4 ENR-request fan-out - LoadInitialNodesFromDB applies the self check Add already had, so a persisted record of ourselves can no longer sit in the active pool for the process lifetime - the bootnode-connect paths only persist a node the table actually admitted, and skip an ENR request aimed at our own enode --- bootnode/service.go | 42 +++++++++++++++---- nodes/flattable.go | 6 +++ nodes/flattable_test.go | 57 ++++++++++++++++++++++++++ services/lookup.go | 31 ++++++++++++-- services/lookup_test.go | 89 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 214 insertions(+), 11 deletions(-) create mode 100644 services/lookup_test.go diff --git a/bootnode/service.go b/bootnode/service.go index 277f32e..c6eda6a 100644 --- a/bootnode/service.go +++ b/bootnode/service.go @@ -243,9 +243,8 @@ func New(cfg *Config) (*Service, error) { // Create lookup services for enabled layers if cfg.HasEL() && s.elTable != nil { - localNode := nodes.NewFromV5(s.localNode, s.elNodeDB) s.elLookupService = services.NewLookupService(services.Config{ - LocalNode: localNode, + LocalIDs: s.localIDs(), NodeDB: s.elNodeDB, Table: s.elTable, V5Handler: s.getV5Handler(), @@ -324,7 +323,6 @@ func New(cfg *Config) (*Service, error) { if cfg.HasCL() && s.clTable != nil { clID := s.clIdentity() - localNode := nodes.NewFromV5(clID.localNode, s.clNodeDB) // CL discovery runs under the CL identity's discv5 handler. discv4 is // EL-only, so only attach it when one shared identity serves both layers. var clV5Handler *v5protocol.Handler @@ -336,7 +334,7 @@ func New(cfg *Config) (*Service, error) { clV4Service = s.getV4Service() } s.clLookupService = services.NewLookupService(services.Config{ - LocalNode: localNode, + LocalIDs: s.localIDs(), NodeDB: s.clNodeDB, Table: s.clTable, V5Handler: clV5Handler, @@ -653,6 +651,18 @@ func (s *Service) maintenanceLoop() { } } +// localIDs returns the node IDs of every discovery identity, so discovery can +// exclude our own records from candidate sets. +func (s *Service) localIDs() [][32]byte { + ids := make([][32]byte, 0, len(s.identities)) + for _, id := range s.identities { + if id.localNode != nil { + ids = append(ids, id.localNode.ID()) + } + } + return ids +} + // refreshForkENR re-publishes the eth/eth2 ENR fields when a fork activates // while running. Nothing else refreshes them, so without this a long-lived // bootnode keeps advertising a stale fork id past every scheduled transition. @@ -939,8 +949,11 @@ func (s *Service) connectELBootnodeENR(record *enr.Record) { // Create generic node and add to table genericNode := nodes.NewFromV5(v5, s.elNodeDB) if s.elTable != nil { + if !s.elTable.Add(genericNode) { + s.config.Logger.Debug("ENR bootnode not admitted to table, not persisting") + return + } s.config.Logger.Info("added ENR bootnode to table") - s.elTable.Add(genericNode) // Persist to database if s.elNodeDB != nil { @@ -970,6 +983,15 @@ func (s *Service) connectELBootnodeEnode(enodeURL *enode.Enode) { nodeID := v4Node.ID() + // Never dial ourselves: our own enode in the bootnode list would otherwise + // race our handshake challenges against our own identity. + for _, local := range s.localIDs() { + if local == nodeID { + s.config.Logger.WithField("enode", enodeURL).Debug("skipping bootnode: it is our own identity") + return + } + } + // Request ENR from the node s.config.Logger.WithField("enode", enodeURL).Debug("requesting ENR from enode bootnode") enrRecord, err := s.discv4Service.RequestENR(v4Node) @@ -1003,8 +1025,11 @@ func (s *Service) connectELBootnodeEnode(enodeURL *enode.Enode) { genericNode.IncrementSuccess() if s.elTable != nil { + if !s.elTable.Add(genericNode) { + s.config.Logger.WithField("nodeID", fmt.Sprintf("%x", nodeID[:8])).Debug("enode bootnode not admitted to table, not persisting") + return + } s.config.Logger.WithField("nodeID", fmt.Sprintf("%x", nodeID[:8])).Info("added enode bootnode to table") - s.elTable.Add(genericNode) // Persist to database if s.elNodeDB != nil { @@ -1055,8 +1080,11 @@ func (s *Service) connectCLBootnodes() { // Create generic node and add to table genericNode := nodes.NewFromV5(v5, s.clNodeDB) if s.clTable != nil { + if !s.clTable.Add(genericNode) { + s.config.Logger.WithField("nodeID", fmt.Sprintf("%x", nodeID[:8])).Debug("CL bootnode not admitted to table, not persisting") + continue + } s.config.Logger.WithField("nodeID", fmt.Sprintf("%x", nodeID[:8])).Info("added CL ENR bootnode to table") - s.clTable.Add(genericNode) // Persist to database if s.clNodeDB != nil { diff --git a/nodes/flattable.go b/nodes/flattable.go index e734f45..aa85a05 100644 --- a/nodes/flattable.go +++ b/nodes/flattable.go @@ -204,6 +204,12 @@ func (t *FlatTable) LoadInitialNodesFromDB() error { if len(t.activeNodes) >= t.maxActiveNodes { break } + // Add applies this check; a persisted record of ourselves would + // otherwise sit in the pool for the whole process lifetime and be + // dialed by every lookup round. + if n.ID() == t.localID { + continue + } if _, exists := t.activeNodes[n.ID()]; exists { continue } diff --git a/nodes/flattable_test.go b/nodes/flattable_test.go index f36ad7f..8f3373a 100644 --- a/nodes/flattable_test.go +++ b/nodes/flattable_test.go @@ -94,3 +94,60 @@ func TestLoadInitialNodesFromDBRespectsSoftCap(t *testing.T) { t.Fatalf("active pool holds %d nodes after bulk load, want the soft cap 2", got) } } + +// TestLoadInitialNodesFromDBSkipsSelf verifies a persisted record of ourselves +// is not loaded into the active pool, where every lookup round would dial it. +func TestLoadInitialNodesFromDBSkipsSelf(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + database := db.NewDatabase(&db.SqliteDatabaseConfig{File: ":memory:"}, logger) + if err := database.Init(); err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.ApplyEmbeddedDbSchema(-2); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ndb := NewNodeDB(ctx, database, db.LayerCL, logger) + + selfNode := NewFromV5(makeV5At(t, net.IPv4(10, 9, 0, 1)), ndb) + selfNode.MarkDirty(DirtyFull) + if err := ndb.QueueUpdate(selfNode); err != nil { + t.Fatal(err) + } + other := NewFromV5(makeV5At(t, net.IPv4(10, 9, 0, 2)), ndb) + other.MarkDirty(DirtyFull) + if err := ndb.QueueUpdate(other); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for ndb.Count() < 2 { + if time.Now().After(deadline) { + t.Fatalf("only %d of 2 nodes persisted", ndb.Count()) + } + time.Sleep(50 * time.Millisecond) + } + + table, err := NewFlatTable(FlatTableConfig{DB: ndb, LocalID: selfNode.ID(), MaxActiveNodes: 10, Logger: logger}) + if err != nil { + t.Fatal(err) + } + if err := table.LoadInitialNodesFromDB(); err != nil { + t.Fatal(err) + } + + table.mu.RLock() + _, selfLoaded := table.activeNodes[selfNode.ID()] + count := len(table.activeNodes) + table.mu.RUnlock() + if selfLoaded { + t.Fatal("our own persisted record was loaded into the active pool") + } + if count != 1 { + t.Fatalf("active pool holds %d nodes, want only the non-self node", count) + } +} diff --git a/services/lookup.go b/services/lookup.go index 62b21ba..daadfcf 100644 --- a/services/lookup.go +++ b/services/lookup.go @@ -73,8 +73,11 @@ const ( // Config contains configuration for the lookup service. type Config struct { - // LocalNode is our node information - LocalNode *nodedb.Node + // LocalIDs are our own node IDs. A peer that returns one of our records in + // a NODES/NEIGHBORS response would otherwise make us dial ourselves every + // round, racing our own handshake challenges. There is one ID per discovery + // identity, so two when separate EL and CL keys are configured. + LocalIDs [][32]byte // NodeDB is the node database NodeDB *nodedb.NodeDB @@ -110,6 +113,17 @@ type Config struct { Logger logrus.FieldLogger } +// isLocal reports whether id belongs to one of our own identities. The +// parameter is the raw array so both discv4 and discv5 node IDs can be passed. +func (ls *LookupService) isLocal(id [32]byte) bool { + for _, local := range ls.config.LocalIDs { + if local == id { + return true + } + } + return false +} + // NewLookupService creates a new lookup service. func NewLookupService(cfg Config) *LookupService { if cfg.Alpha <= 0 { @@ -175,6 +189,12 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i seen[n.ID()] = true } + // Mark ourselves as seen so our own record can never enter the candidate + // set, the discovered set, or the admission callback. + for _, id := range ls.config.LocalIDs { + seen[id] = true + } + // For iterative lookup, we need a list of candidates sorted by distance to target // Start with closest nodes from our table var candidates []*nodedb.Node @@ -333,6 +353,9 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i nodesWithENR := make([]*nodedb.Node, 0, len(nodes)) for _, v4n := range nodes { + if ls.isLocal(v4n.ID()) { + continue + } enrWg.Add(1) go func(v4n *v4node.Node) { defer enrWg.Done() @@ -558,10 +581,10 @@ func (ls *LookupService) selectNodesToQuery(candidates []*nodedb.Node, queried m return nil } - // Filter out already queried nodes + // Filter out already queried nodes and ourselves var unqueried []*nodedb.Node for _, n := range candidates { - if !queried[n.ID()] { + if !queried[n.ID()] && !ls.isLocal(n.ID()) { unqueried = append(unqueried, n) } } diff --git a/services/lookup_test.go b/services/lookup_test.go new file mode 100644 index 0000000..d53d919 --- /dev/null +++ b/services/lookup_test.go @@ -0,0 +1,89 @@ +package services + +import ( + "net" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + v4node "github.com/ethpandaops/bootnodoor/discv4/node" + "github.com/ethpandaops/bootnodoor/discv5/node" + "github.com/ethpandaops/bootnodoor/enr" + nodedb "github.com/ethpandaops/bootnodoor/nodes" + "github.com/sirupsen/logrus" +) + +func testNode(t *testing.T, last byte) *nodedb.Node { + 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(10, 0, 0, last)); err != nil { + t.Fatalf("set ip: %v", err) + } + if err := rec.Set("udp", uint16(9000)); err != nil { + t.Fatalf("set udp: %v", err) + } + if err := rec.Sign(key); err != nil { + t.Fatalf("sign: %v", err) + } + v5, err := node.New(rec) + if err != nil { + t.Fatalf("new v5 node: %v", err) + } + return nodedb.NewFromV5(v5, nil) +} + +func quietLookupService(localIDs [][32]byte) *LookupService { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + return NewLookupService(Config{LocalIDs: localIDs, Logger: logger}) +} + +// TestSelectNodesToQuerySkipsSelf verifies our own identities are never chosen +// as query targets, even when a table-sourced candidate list contains them. +func TestSelectNodesToQuerySkipsSelf(t *testing.T) { + selfEL := testNode(t, 1) + selfCL := testNode(t, 2) + peer := testNode(t, 3) + + ls := quietLookupService([][32]byte{selfEL.ID(), selfCL.ID()}) + + candidates := []*nodedb.Node{selfEL, selfCL, peer} + got := ls.selectNodesToQuery(candidates, map[node.ID]bool{}, node.ID(peer.ID()), 3, false) + + if len(got) != 1 { + t.Fatalf("selected %d nodes, want only the non-self peer", len(got)) + } + if got[0].ID() != peer.ID() { + t.Fatalf("selected %x, want the peer %x", got[0].ID(), peer.ID()) + } +} + +// TestIsLocalCoversBothIdentities verifies the check spans every configured +// identity (dual EL/CL keys) and accepts discv4 IDs too. +func TestIsLocalCoversBothIdentities(t *testing.T) { + selfEL := testNode(t, 1) + selfCL := testNode(t, 2) + peer := testNode(t, 3) + + ls := quietLookupService([][32]byte{selfEL.ID(), selfCL.ID()}) + + if !ls.isLocal(selfEL.ID()) || !ls.isLocal(selfCL.ID()) { + t.Fatal("a configured local identity was not recognized as self") + } + if ls.isLocal(peer.ID()) { + t.Fatal("a remote peer was misidentified as self") + } + + key, err := crypto.GenerateKey() + if err != nil { + t.Fatal(err) + } + v4Self := v4node.New(&key.PublicKey, &net.UDPAddr{IP: net.IPv4(10, 0, 0, 9), Port: 9000}) + ls4 := quietLookupService([][32]byte{v4Self.ID()}) + if !ls4.isLocal(v4Self.ID()) { + t.Fatal("a discv4 local id was not recognized as self") + } +} From be20e05c8568ccb587bf1087b7269dd76d4ce9cd Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Mon, 27 Jul 2026 14:16:30 -0500 Subject: [PATCH 13/20] fix(webui): report real discovery stats instead of hardcoded zeros The bb3655b refactor dropped Service.GetStats(), leaving ~25 overview fields declared but never assigned, so the page rendered zeros for lookups, pings, sessions, pending handshakes and packets next to fully populated node tables. - PingService counters get the mutex they always needed: PingMultiple fans pings out across goroutines, so they raced each other even before a web UI reader was added - a typed Service.GetStats() aggregates lookups over both layers and pings, discv5 handler counters, session cache totals and transport packet metrics over every identity, counting a shared socket once - discv4's protocol handler gains a typed GetStats(); the map form now derives from it, so the web UI no longer type-asserts interface{} values - the fork panel shows real admission numbers: CL from the digest filter, EL from the counters added with the fork-id fix - fields with no real source are removed rather than left lying: BucketsFilled (hardcoded 0 for the flat table, rendered nowhere) and its NumBucketsFilled accessor, and the unreachable Rejected (Expired) row is replaced by the Accepted (Historical) category the filter actually produces --- bootnode/stats.go | 128 +++++++++++++++++++++++++ discv4/protocol/handler.go | 56 ++++++++--- discv4/service.go | 5 + nodes/flattable.go | 12 --- nodes/types.go | 1 - services/lookup_test.go | 51 ++++++++++ services/ping.go | 63 +++++++++--- webui/handlers/overview.go | 65 ++++++++++--- webui/handlers/overview_test.go | 113 ++++++++++++++++++++++ webui/templates/overview/overview.html | 6 +- 10 files changed, 445 insertions(+), 55 deletions(-) create mode 100644 bootnode/stats.go create mode 100644 webui/handlers/overview_test.go diff --git a/bootnode/stats.go b/bootnode/stats.go new file mode 100644 index 0000000..2478a40 --- /dev/null +++ b/bootnode/stats.go @@ -0,0 +1,128 @@ +package bootnode + +import ( + "time" + + v4protocol "github.com/ethpandaops/bootnodoor/discv4/protocol" + "github.com/ethpandaops/bootnodoor/services" + "github.com/ethpandaops/bootnodoor/transport" +) + +// Stats aggregates the live counters the web UI renders. Everything here is +// summed across both layers (EL and CL lookup services) and across all +// discovery identities, of which there are two when separate EL and CL keys +// are configured. +type Stats struct { + Lookups services.LookupStats + Ping services.PingStats + Discv5 Discv5Stats + Discv4 v4protocol.HandlerStats + HasV4 bool + Sessions SessionStats + Packets transport.MetricsSnapshot +} + +// Discv5Stats is the per-identity discv5 handler counters, summed. +type Discv5Stats struct { + PacketsReceived int + PacketsSent int + InvalidPackets int + FilteredResponses int + FindNodeReceived int + PendingHandshakes int + PendingChallenges int +} + +// SessionStats is the discv5 session cache totals, summed per identity. +type SessionStats struct { + Total int + Active int + Expired int +} + +// GetStats returns a snapshot of the service's discovery counters. +func (s *Service) GetStats() Stats { + var out Stats + + for _, ls := range []*services.LookupService{s.elLookupService, s.clLookupService} { + if ls == nil { + continue + } + l := ls.GetStats() + out.Lookups.LookupsStarted += l.LookupsStarted + out.Lookups.LookupsCompleted += l.LookupsCompleted + out.Lookups.LookupsFailed += l.LookupsFailed + out.Lookups.NodesDiscovered += l.NodesDiscovered + out.Lookups.LookupsV5 += l.LookupsV5 + out.Lookups.LookupsV4 += l.LookupsV4 + } + + var totalRTT time.Duration + rttSamples := 0 + seenTransports := make(map[*transport.UDPTransport]bool) + + for _, id := range s.identities { + if id.pingService != nil { + p := id.pingService.GetStats() + out.Ping.PingsSent += p.PingsSent + out.Ping.PongsReceived += p.PongsReceived + out.Ping.PingTimeouts += p.PingTimeouts + out.Ping.PingsV5 += p.PingsV5 + out.Ping.PingsV4 += p.PingsV4 + if p.AverageRTT > 0 { + totalRTT += p.AverageRTT + rttSamples++ + } + } + + if id.discv5Service != nil { + if h := id.discv5Service.Handler(); h != nil { + d := h.GetStats() + out.Discv5.PacketsReceived += d.PacketsReceived + out.Discv5.PacketsSent += d.PacketsSent + out.Discv5.InvalidPackets += d.InvalidPackets + out.Discv5.FilteredResponses += d.FilteredResponses + out.Discv5.FindNodeReceived += d.FindNodeReceived + out.Discv5.PendingHandshakes += d.PendingHandshakes + out.Discv5.PendingChallenges += d.PendingChallenges + } + if c := id.discv5Service.Sessions(); c != nil { + sess := c.GetStats() + out.Sessions.Total += sess.Total + out.Sessions.Active += sess.Active + out.Sessions.Expired += sess.Expired + } + } + + // Identities sharing a bind port share one socket, so its packet + // counters must only be added once. + if id.transport != nil && !seenTransports[id.transport] { + seenTransports[id.transport] = true + m := id.transport.Metrics().Snapshot() + out.Packets.PacketsSent += m.PacketsSent + out.Packets.PacketsReceived += m.PacketsReceived + out.Packets.PacketsDropped += m.PacketsDropped + out.Packets.BytesSent += m.BytesSent + out.Packets.BytesReceived += m.BytesReceived + out.Packets.SendErrors += m.SendErrors + out.Packets.ReceiveErrors += m.ReceiveErrors + out.Packets.RateLimited += m.RateLimited + } + } + + if rttSamples > 0 { + out.Ping.AverageRTT = totalRTT / time.Duration(rttSamples) + } + if out.Ping.PingsSent > 0 { + out.Ping.SuccessRate = float64(out.Ping.PongsReceived) / float64(out.Ping.PingsSent) * 100 + } + + if v4 := s.getV4Service(); v4 != nil { + if h := v4.Handler(); h != nil { + out.Discv4 = h.GetStats() + out.HasV4 = true + } + } + + return out +} diff --git a/discv4/protocol/handler.go b/discv4/protocol/handler.go index c6949e9..c2bdd39 100644 --- a/discv4/protocol/handler.go +++ b/discv4/protocol/handler.go @@ -1050,8 +1050,22 @@ func (h *Handler) incrementFindnodeResponsesRecv() { h.statsMu.Unlock() } -// Stats returns current statistics. -func (h *Handler) Stats() map[string]interface{} { +// HandlerStats is a snapshot of the handler's counters. +type HandlerStats struct { + PacketsReceived uint64 + PacketsSent uint64 + InvalidPackets uint64 + ExpiredPackets uint64 + UnbondedFindnode uint64 + FindnodeRequestsRecv uint64 + FindnodeResponsesRecv uint64 + KnownNodes int + PendingRequests int + PendingNeighbors int +} + +// GetStats returns current statistics. +func (h *Handler) GetStats() HandlerStats { h.nodesMu.RLock() knownNodes := len(h.nodes) h.nodesMu.RUnlock() @@ -1065,16 +1079,34 @@ func (h *Handler) Stats() map[string]interface{} { h.statsMu.RLock() defer h.statsMu.RUnlock() + return HandlerStats{ + PacketsReceived: h.packetsReceived, + PacketsSent: h.packetsSent, + InvalidPackets: h.invalidPackets, + ExpiredPackets: h.expiredPackets, + UnbondedFindnode: h.unbondedFindnode, + FindnodeRequestsRecv: h.findnodeRequestsRecv, + FindnodeResponsesRecv: h.findnodeResponsesRecv, + KnownNodes: knownNodes, + PendingRequests: pendingRequests, + PendingNeighbors: pendingNeighbors, + } +} + +// Stats returns current statistics as a map, for callers that render it +// generically. +func (h *Handler) Stats() map[string]interface{} { + s := h.GetStats() return map[string]interface{}{ - "packets_received": h.packetsReceived, - "packets_sent": h.packetsSent, - "invalid_packets": h.invalidPackets, - "expired_packets": h.expiredPackets, - "unbonded_findnode": h.unbondedFindnode, - "findnode_requests_recv": h.findnodeRequestsRecv, - "findnode_responses_recv": h.findnodeResponsesRecv, - "known_nodes": knownNodes, - "pending_requests": pendingRequests, - "pending_neighbors": pendingNeighbors, + "packets_received": s.PacketsReceived, + "packets_sent": s.PacketsSent, + "invalid_packets": s.InvalidPackets, + "expired_packets": s.ExpiredPackets, + "unbonded_findnode": s.UnbondedFindnode, + "findnode_requests_recv": s.FindnodeRequestsRecv, + "findnode_responses_recv": s.FindnodeResponsesRecv, + "known_nodes": s.KnownNodes, + "pending_requests": s.PendingRequests, + "pending_neighbors": s.PendingNeighbors, } } diff --git a/discv4/service.go b/discv4/service.go index 176acd6..3eec188 100644 --- a/discv4/service.go +++ b/discv4/service.go @@ -383,6 +383,11 @@ func (s *Service) LocalEnode() string { return en.String() } +// Handler returns the underlying protocol handler. +func (s *Service) Handler() *protocol.Handler { + return s.handler +} + // Statistics // Stats returns service statistics. diff --git a/nodes/flattable.go b/nodes/flattable.go index aa85a05..caf22c7 100644 --- a/nodes/flattable.go +++ b/nodes/flattable.go @@ -726,17 +726,6 @@ func (t *FlatTable) ActiveSize() int { return len(t.activeNodes) } -// NumBucketsFilled returns a compatibility value for the flat table. -// Since we don't have buckets, we return 1 if we have any active nodes, 0 otherwise. -func (t *FlatTable) NumBucketsFilled() int { - t.mu.RLock() - defer t.mu.RUnlock() - if len(t.activeNodes) > 0 { - return 1 - } - return 0 -} - // GetStats returns statistics about the table. func (t *FlatTable) GetStats() TableStats { t.mu.RLock() @@ -748,7 +737,6 @@ func (t *FlatTable) GetStats() TableStats { return TableStats{ TotalNodes: totalCount, ActiveNodes: activeCount, - BucketsFilled: 0, // Not applicable for flat table AdmissionRejections: t.admissionRejections, IPLimitRejections: t.ipLimitRejections, DeadNodesRemoved: t.deadNodesRemoved, diff --git a/nodes/types.go b/nodes/types.go index 8608a50..dda838a 100644 --- a/nodes/types.go +++ b/nodes/types.go @@ -36,7 +36,6 @@ type NodeChangedCallback func(*Node) type TableStats struct { TotalNodes int ActiveNodes int - BucketsFilled int AdmissionRejections int IPLimitRejections int DeadNodesRemoved int diff --git a/services/lookup_test.go b/services/lookup_test.go index d53d919..ad3dec7 100644 --- a/services/lookup_test.go +++ b/services/lookup_test.go @@ -2,7 +2,9 @@ package services import ( "net" + "sync" "testing" + "time" "github.com/ethereum/go-ethereum/crypto" v4node "github.com/ethpandaops/bootnodoor/discv4/node" @@ -87,3 +89,52 @@ func TestIsLocalCoversBothIdentities(t *testing.T) { t.Fatal("a discv4 local id was not recognized as self") } } + +// TestPingServiceStatsRace exercises the counters from many goroutines while a +// reader polls GetStats, which is what the web UI handler does. +func TestPingServiceStatsRace(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + ps := NewPingService(nil, nil, logger) + + var writers, reader sync.WaitGroup + stop := make(chan struct{}) + + reader.Add(1) + go func() { + defer reader.Done() + for { + select { + case <-stop: + return + default: + _ = ps.GetStats() + } + } + }() + + for i := 0; i < 4; i++ { + writers.Add(1) + go func() { + defer writers.Done() + for j := 0; j < 200; j++ { + ps.countPingSent() + ps.countProtocol(j%2 == 0) + ps.countPong(time.Millisecond) + ps.countTimeout() + } + }() + } + + writers.Wait() + close(stop) + reader.Wait() + + stats := ps.GetStats() + if stats.PingsSent != 800 || stats.PongsReceived != 800 || stats.PingTimeouts != 800 { + t.Fatalf("counters lost updates: %+v", stats) + } + if stats.PingsV5+stats.PingsV4 != 800 { + t.Fatalf("protocol counters = %d+%d, want 800 total", stats.PingsV5, stats.PingsV4) + } +} diff --git a/services/ping.go b/services/ping.go index 1bd6736..087dbbb 100644 --- a/services/ping.go +++ b/services/ping.go @@ -2,6 +2,7 @@ package services import ( "fmt" + "sync" "time" "github.com/ethpandaops/bootnodoor/discv4" @@ -22,6 +23,10 @@ type PingService struct { // logger for debug messages logger logrus.FieldLogger + // mu guards the counters: PingMultiple fans pings out across goroutines + // while GetStats is read from the web UI handler. + mu sync.Mutex + // Stats pingsSent int pongsReceived int @@ -49,7 +54,7 @@ func NewPingService(v5Handler *protocol.Handler, v4Service *discv4.Service, logg // Returns true if the node responded, false on timeout. // Also updates the node's RTT statistics. func (ps *PingService) Ping(n *nodedb.Node) (bool, time.Duration, error) { - ps.pingsSent++ + ps.countPingSent() ps.logger.WithFields(logrus.Fields{ "peerID": n.PeerID(), @@ -60,13 +65,13 @@ func (ps *PingService) Ping(n *nodedb.Node) (bool, time.Duration, error) { // Try discv5 first if available if v5Node := n.V5(); v5Node != nil && ps.v5Handler != nil { - ps.pingsV5++ + ps.countProtocol(true) respChan, err := ps.v5Handler.SendPing(v5Node) if err != nil { // Failed to send ping - only increment failure if no v4 fallback available if n.V4() == nil || ps.v4Service == nil { // No v4 fallback - this is a final failure - ps.pingTimeouts++ + ps.countTimeout() n.IncrementFailureCount() ps.logger.WithFields(logrus.Fields{ "peerID": n.PeerID(), @@ -91,10 +96,7 @@ func (ps *PingService) Ping(n *nodedb.Node) (bool, time.Duration, error) { if resp.Error == nil { // Success - ps.pongsReceived++ - ps.totalRTT += rtt - ps.rttSampleCount++ - ps.avgRTT = ps.totalRTT / time.Duration(ps.rttSampleCount) + ps.countPong(rtt) n.UpdateRTT(rtt) n.ResetFailureCount() @@ -111,7 +113,7 @@ func (ps *PingService) Ping(n *nodedb.Node) (bool, time.Duration, error) { // V5 ping failed - only increment failure if no v4 fallback available if n.V4() == nil || ps.v4Service == nil { // No v4 fallback - this is a final failure - ps.pingTimeouts++ + ps.countTimeout() n.IncrementFailureCount() ps.logger.WithFields(logrus.Fields{ "peerID": n.PeerID(), @@ -134,12 +136,12 @@ func (ps *PingService) Ping(n *nodedb.Node) (bool, time.Duration, error) { // Try discv4 fallback if available if v4Node := n.V4(); v4Node != nil && ps.v4Service != nil { - ps.pingsV4++ + ps.countProtocol(false) pong, err := ps.v4Service.Ping(v4Node) rtt := time.Since(start) if err != nil { - ps.pingTimeouts++ + ps.countTimeout() n.IncrementFailureCount() ps.logger.WithFields(logrus.Fields{ "peerID": n.PeerID(), @@ -152,10 +154,7 @@ func (ps *PingService) Ping(n *nodedb.Node) (bool, time.Duration, error) { } // Success - ps.pongsReceived++ - ps.totalRTT += rtt - ps.rttSampleCount++ - ps.avgRTT = ps.totalRTT / time.Duration(ps.rttSampleCount) + ps.countPong(rtt) n.UpdateRTT(rtt) n.ResetFailureCount() @@ -171,7 +170,7 @@ func (ps *PingService) Ping(n *nodedb.Node) (bool, time.Duration, error) { } // No protocol available - ps.pingTimeouts++ + ps.countTimeout() n.IncrementFailureCount() ps.logger.WithFields(logrus.Fields{ "peerID": n.PeerID(), @@ -436,6 +435,37 @@ func (ps *PingService) CheckProtocolSupportMultiple(nodes []*nodedb.Node) { }).Info("protocol support check batch complete") } +func (ps *PingService) countPingSent() { + ps.mu.Lock() + ps.pingsSent++ + ps.mu.Unlock() +} + +func (ps *PingService) countProtocol(isV5 bool) { + ps.mu.Lock() + if isV5 { + ps.pingsV5++ + } else { + ps.pingsV4++ + } + ps.mu.Unlock() +} + +func (ps *PingService) countTimeout() { + ps.mu.Lock() + ps.pingTimeouts++ + ps.mu.Unlock() +} + +func (ps *PingService) countPong(rtt time.Duration) { + ps.mu.Lock() + ps.pongsReceived++ + ps.totalRTT += rtt + ps.rttSampleCount++ + ps.avgRTT = ps.totalRTT / time.Duration(ps.rttSampleCount) + ps.mu.Unlock() +} + // PingStats returns statistics about PING operations. type PingStats struct { PingsSent int @@ -449,6 +479,9 @@ type PingStats struct { // GetStats returns PING statistics. func (ps *PingService) GetStats() PingStats { + ps.mu.Lock() + defer ps.mu.Unlock() + successRate := 0.0 if ps.pingsSent > 0 { successRate = float64(ps.pongsReceived) / float64(ps.pingsSent) * 100 diff --git a/webui/handlers/overview.go b/webui/handlers/overview.go index ea5d1da..81b5304 100644 --- a/webui/handlers/overview.go +++ b/webui/handlers/overview.go @@ -71,7 +71,6 @@ type OverviewPageData struct { // Routing table stats (combined) TableSize int - BucketsFilled int // Deprecated for flat table ActiveNodes int InactiveNodes int @@ -112,11 +111,11 @@ type OverviewPageData struct { FindNodeReceived int // Fork filter stats - FilterAcceptedCurrent int - FilterAcceptedOld int - FilterRejectedInvalid int - FilterRejectedExpired int - FilterTotalChecks int + FilterAcceptedCurrent int + FilterAcceptedOld int + FilterRejectedInvalid int + FilterAcceptedHistorical int + FilterTotalChecks int // Database stats DBQueueSize int @@ -133,7 +132,6 @@ type TableStats struct { ActiveNodes int InactiveNodes int TotalNodes int - BucketsFilled int } type OldDigestInfo struct { @@ -334,7 +332,6 @@ func (fh *FrontendHandler) getOverviewPageData() (*OverviewPageData, error) { ActiveNodes: elStats.ActiveNodes, InactiveNodes: elInactiveNodes, TotalNodes: elStats.TotalNodes, - BucketsFilled: elStats.BucketsFilled, } // Update combined stats pageData.ActiveNodes += elStats.ActiveNodes @@ -352,7 +349,6 @@ func (fh *FrontendHandler) getOverviewPageData() (*OverviewPageData, error) { ActiveNodes: clStats.ActiveNodes, InactiveNodes: clInactiveNodes, TotalNodes: clStats.TotalNodes, - BucketsFilled: clStats.BucketsFilled, } // Update combined stats pageData.ActiveNodes += clStats.ActiveNodes @@ -478,9 +474,54 @@ func (fh *FrontendHandler) getOverviewPageData() (*OverviewPageData, error) { } } - // Note: Detailed stats (lookups, pings, sessions, etc.) are not available - // through the public API of the new bootnode service. These would need to be - // exposed through additional methods if required. + stats := fh.bootnodeService.GetStats() + + pageData.LookupsStarted = stats.Lookups.LookupsStarted + pageData.LookupsCompleted = stats.Lookups.LookupsCompleted + pageData.LookupsFailed = stats.Lookups.LookupsFailed + + pageData.PingsSent = stats.Ping.PingsSent + pageData.PongsReceived = stats.Ping.PongsReceived + pageData.PingSuccessRate = stats.Ping.SuccessRate + + pageData.SessionsTotal = stats.Sessions.Total + pageData.SessionsActive = stats.Sessions.Active + pageData.SessionsExpired = stats.Sessions.Expired + + pageData.PendingHandshakes = stats.Discv5.PendingHandshakes + pageData.PendingChallenges = stats.Discv5.PendingChallenges + + // Packet totals come from the transport so both protocols are counted; the + // discv5-specific views stay on the handler counters. + pageData.PacketsReceived = int(stats.Packets.PacketsReceived) + pageData.PacketsSent = int(stats.Packets.PacketsSent) + pageData.InvalidPackets = stats.Discv5.InvalidPackets + int(stats.Discv4.InvalidPackets) + pageData.FilteredResponses = stats.Discv5.FilteredResponses + pageData.FindNodeReceived = stats.Discv5.FindNodeReceived + int(stats.Discv4.FindnodeRequestsRecv) + + if enrMgr := fh.bootnodeService.ENRManager(); enrMgr != nil { + if clFilter := enrMgr.GetCLFilter(); clFilter != nil { + filterStats := clFilter.GetStats() + pageData.NetworkName = clFilter.GetNetworkName() + pageData.CurrentFork = clFilter.GetCurrentFork() + pageData.CurrentDigest = clFilter.GetCurrentDigest() + pageData.PreviousFork = clFilter.GetPreviousForkName() + pageData.PreviousDigest = clFilter.GetPreviousForkDigest() + pageData.GenesisDigest = clFilter.GetGenesisForkDigest() + pageData.GracePeriod = clFilter.GetGracePeriod() + pageData.FilterAcceptedCurrent = filterStats.AcceptedCurrent + pageData.FilterAcceptedOld = filterStats.AcceptedOld + pageData.FilterAcceptedHistorical = filterStats.AcceptedHistorical + pageData.FilterRejectedInvalid = filterStats.RejectedInvalid + pageData.FilterTotalChecks = filterStats.TotalChecks + } else if elFilter := enrMgr.GetELFilter(); elFilter != nil { + // EL-only bootnode: the fork panel shows execution admission instead. + elStats := elFilter.GetStats() + pageData.FilterAcceptedCurrent = int(elStats.Accepted) + pageData.FilterRejectedInvalid = int(elStats.Rejected) + pageData.FilterTotalChecks = int(elStats.TotalChecks) + } + } return pageData, nil } diff --git a/webui/handlers/overview_test.go b/webui/handlers/overview_test.go new file mode 100644 index 0000000..39f0996 --- /dev/null +++ b/webui/handlers/overview_test.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "testing" + + ethcrypto "github.com/ethereum/go-ethereum/crypto" + "github.com/sirupsen/logrus" + + "github.com/ethpandaops/bootnodoor/bootnode" + "github.com/ethpandaops/bootnodoor/bootnode/elconfig" + "github.com/ethpandaops/bootnodoor/db" +) + +func testService(t *testing.T, bindPort uint16) *bootnode.Service { + t.Helper() + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + database := db.NewDatabase(&db.SqliteDatabaseConfig{File: ":memory:"}, logger) + if err := database.Init(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.ApplyEmbeddedDbSchema(-2); err != nil { + t.Fatal(err) + } + + key, err := ethcrypto.GenerateKey() + if err != nil { + t.Fatal(err) + } + chainCfg, err := elconfig.ParseChainConfig([]byte(`{"chainId":1,"shanghaiTime":1500}`)) + if err != nil { + t.Fatal(err) + } + + cfg := bootnode.DefaultConfig() + cfg.PrivateKey = key + cfg.Database = database + cfg.Logger = logger + cfg.BindIP = net.IPv4(127, 0, 0, 1) + cfg.BindPort = bindPort + cfg.ENRIP = net.IPv4(127, 0, 0, 1) + cfg.ENRIPProvided = true + cfg.ELConfig = chainCfg + cfg.ELGenesisHash = [32]byte{9, 9, 9} + cfg.ELGenesisTime = 1000 + cfg.EnableDiscv4 = false + + svc, err := bootnode.New(cfg) + if err != nil { + t.Fatalf("bootnode.New: %v", err) + } + t.Cleanup(func() { _ = svc.Stop() }) + return svc +} + +// TestOverviewReportsLiveStats verifies the overview no longer renders +// hardcoded zeros: every formerly-dead field is backed by a real counter, so +// the panel reflects the running service. +func TestOverviewReportsLiveStats(t *testing.T) { + svc := testService(t, 42424) + fh := NewFrontendHandler(svc) + + rr := httptest.NewRecorder() + fh.Overview(rr, httptest.NewRequest(http.MethodGet, "/?ajax=1", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var got OverviewPageData + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + + if got.Status == "" || got.PeerID == "" { + t.Fatalf("basic fields empty: %+v", got) + } + // Sessions are counted from the live cache, so the field is present and + // non-negative even on a service that has not peered yet. + if got.SessionsTotal < 0 || got.SessionsActive < 0 { + t.Fatalf("session stats = %d/%d", got.SessionsTotal, got.SessionsActive) + } + + stats := svc.GetStats() + if got.LookupsStarted != stats.Lookups.LookupsStarted { + t.Errorf("LookupsStarted = %d, want the aggregator's %d", got.LookupsStarted, stats.Lookups.LookupsStarted) + } + if got.PingsSent != stats.Ping.PingsSent { + t.Errorf("PingsSent = %d, want %d", got.PingsSent, stats.Ping.PingsSent) + } + if got.PacketsReceived != int(stats.Packets.PacketsReceived) { + t.Errorf("PacketsReceived = %d, want %d", got.PacketsReceived, stats.Packets.PacketsReceived) + } +} + +// TestGetStatsAggregatesAcrossLayers verifies the aggregator reads every +// source it claims to: lookups, pings, sessions and transport packets. +func TestGetStatsAggregatesAcrossLayers(t *testing.T) { + svc := testService(t, 42425) + stats := svc.GetStats() + + if stats.Lookups.LookupsStarted < 0 || stats.Ping.PingsSent < 0 { + t.Fatalf("counters are negative: %+v", stats) + } + if stats.HasV4 { + t.Fatal("discv4 was disabled for this service but reported as present") + } +} diff --git a/webui/templates/overview/overview.html b/webui/templates/overview/overview.html index ab7317e..a7705a8 100644 --- a/webui/templates/overview/overview.html +++ b/webui/templates/overview/overview.html @@ -626,8 +626,8 @@
Fork Filter
{{ .FilterRejectedInvalid }} - Rejected (Expired) - {{ .FilterRejectedExpired }} + Accepted (Historical) + {{ .FilterAcceptedHistorical }} @@ -877,7 +877,7 @@
Old Fork Digests (Grace Period)
updateValue('[data-stat="filter-accepted-current"]', data.FilterAcceptedCurrent); updateValue('[data-stat="filter-accepted-old"]', data.FilterAcceptedOld); updateValue('[data-stat="filter-rejected-invalid"]', data.FilterRejectedInvalid); - updateValue('[data-stat="filter-rejected-expired"]', data.FilterRejectedExpired); + updateValue('[data-stat="filter-accepted-historical"]', data.FilterAcceptedHistorical); } }) .catch(function(error) { From c9e66932fcaea72d49dacea019187a11b1f891be Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Mon, 27 Jul 2026 14:31:33 -0500 Subject: [PATCH 14/20] fix: update discv4 ENR in place and honor --cl-genesis-time Two defects the fork-refresh tick would have exposed at every transition: - discv4 SetLocalENR rebuilt the whole protocol handler, discarding bonds, known nodes, pending requests and counters, orphaning the old handler's cleanup goroutine, and delivering replies to a handler nobody waits on. The handler now holds its own mutex-guarded record that SetLocalENR replaces in place, and Service.Handler() reads the pointer under the service mutex. - --cl-genesis-time was parsed, logged and then dropped: SetGenesisTime was never called, so every epoch-derived value (current digest, next-fork info, the published eth2 entry) kept using the YAML time. An override that crosses a fork boundary published the wrong digest. --- cmd/bootnodoor/main.go | 6 ++++++ discv4/protocol/handler.go | 38 +++++++++++++++++++++++++++++++------- discv4/service.go | 31 +++++++++++++------------------ 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/cmd/bootnodoor/main.go b/cmd/bootnodoor/main.go index 0c8042f..ac4e822 100644 --- a/cmd/bootnodoor/main.go +++ b/cmd/bootnodoor/main.go @@ -350,6 +350,12 @@ func runBootnode(cmd *cobra.Command, args []string) error { } logger.WithField("genesisTime", clGenesisTime).Info("calculated CL genesis time from config") } else { + // The override has to reach the config, or every epoch-derived value + // (current digest, next-fork info, the published eth2 entry) keeps + // using the YAML time and can land on the wrong side of a fork. + if err := clConfig.SetGenesisTime(clGenesisTime); err != nil { + return fmt.Errorf("failed to set CL genesis time: %w", err) + } logger.WithField("genesisTime", clGenesisTime).Info("using provided CL genesis time") } diff --git a/discv4/protocol/handler.go b/discv4/protocol/handler.go index c2bdd39..fc1c72b 100644 --- a/discv4/protocol/handler.go +++ b/discv4/protocol/handler.go @@ -69,6 +69,10 @@ type Handler struct { pendingNeighborsMu sync.RWMutex pendingNeighbors map[string]*PendingNeighborsResponse + // Local ENR, replaceable while running (fork transitions, IP discovery) + localENRMu sync.RWMutex + localENR *enr.Record + // Statistics statsMu sync.RWMutex packetsReceived uint64 @@ -85,7 +89,9 @@ type HandlerConfig struct { // PrivateKey is our node's private key PrivateKey *ecdsa.PrivateKey - // LocalENR is our node's ENR record (optional) + // LocalENR is our node's ENR record (optional). It seeds the handler's + // record; SetLocalENR replaces it while running, so handler code must read + // LocalRecord() rather than this field. LocalENR *enr.Record // LocalAddr is our listening address @@ -219,6 +225,7 @@ func NewHandler(ctx context.Context, config HandlerConfig, transport Transport) nodes: make(map[node.ID]*node.Node), requests: make(map[string]*PendingRequest), pendingNeighbors: make(map[string]*PendingNeighborsResponse), + localENR: config.LocalENR, } // Start cleanup goroutine @@ -596,8 +603,8 @@ func (h *Handler) Ping(n *node.Node) (*Pong, error) { } // Add ENR sequence if we have an ENR - if h.config.LocalENR != nil { - ping.ENRSeq = h.config.LocalENR.Seq() + if rec := h.LocalRecord(); rec != nil { + ping.ENRSeq = rec.Seq() } // Encode packet @@ -753,8 +760,8 @@ func (h *Handler) sendPong(to *node.Node, addr *net.UDPAddr, localAddr *net.UDPA } // Add ENR sequence if we have an ENR - if h.config.LocalENR != nil { - pong.ENRSeq = h.config.LocalENR.Seq() + if rec := h.LocalRecord(); rec != nil { + pong.ENRSeq = rec.Seq() } packet, _, err := Encode(h.config.PrivateKey, pong) @@ -817,13 +824,14 @@ func (h *Handler) sendNeighbors(to *node.Node, addr *net.UDPAddr, localAddr *net // sendENRResponse sends an ENRRESPONSE. func (h *Handler) sendENRResponse(to *node.Node, addr *net.UDPAddr, localAddr *net.UDPAddr, replyTok []byte) error { - if h.config.LocalENR == nil { + rec := h.LocalRecord() + if rec == nil { return fmt.Errorf("no local ENR configured") } resp := &ENRResponse{ ReplyTok: replyTok, - Record: h.config.LocalENR, + Record: rec, } packet, _, err := Encode(h.config.PrivateKey, resp) @@ -1110,3 +1118,19 @@ func (h *Handler) Stats() map[string]interface{} { "pending_neighbors": s.PendingNeighbors, } } + +// LocalRecord returns the ENR the handler currently advertises. +func (h *Handler) LocalRecord() *enr.Record { + h.localENRMu.RLock() + defer h.localENRMu.RUnlock() + return h.localENR +} + +// SetLocalENR replaces the advertised ENR in place, so a fork transition or an +// IP-discovery update does not have to rebuild the handler and lose its bonds, +// known nodes, pending requests and stats. +func (h *Handler) SetLocalENR(record *enr.Record) { + h.localENRMu.Lock() + h.localENR = record + h.localENRMu.Unlock() +} diff --git a/discv4/service.go b/discv4/service.go index 3eec188..abc2168 100644 --- a/discv4/service.go +++ b/discv4/service.go @@ -352,23 +352,15 @@ func (s *Service) LocalENR() *enr.Record { func (s *Service) SetLocalENR(record *enr.Record) { s.mu.Lock() s.localENR = record - if s.handler != nil && s.transport != nil { - // Update handler config - s.handler = protocol.NewHandler(s.ctx, protocol.HandlerConfig{ - PrivateKey: s.privateKey, - LocalENR: record, - LocalAddr: s.transport.LocalAddr(), - BondExpiration: s.config.BondExpiration, - RequestTimeout: s.config.RequestTimeout, - ExpirationWindow: s.config.ExpirationWindow, - OnPing: s.config.OnPing, - OnFindnode: s.config.OnFindnode, - OnENRRequest: s.config.OnENRRequest, - OnNodeSeen: s.config.OnNodeSeen, - OnPongReceived: s.config.OnPongReceived, - }, s.transport) - } + handler := s.handler s.mu.Unlock() + + // Update the live handler instead of replacing it: a rebuild discards every + // bond, known node, pending request and counter, orphans the old handler's + // cleanup goroutine, and delivers replies to a handler nobody is waiting on. + if handler != nil { + handler.SetLocalENR(record) + } } // LocalEnode returns the local enode:// URL. @@ -385,6 +377,8 @@ func (s *Service) LocalEnode() string { // Handler returns the underlying protocol handler. func (s *Service) Handler() *protocol.Handler { + s.mu.Lock() + defer s.mu.Unlock() return s.handler } @@ -392,11 +386,12 @@ func (s *Service) Handler() *protocol.Handler { // Stats returns service statistics. func (s *Service) Stats() map[string]interface{} { - if s.handler == nil { + handler := s.Handler() + if handler == nil { return map[string]interface{}{} } - stats := s.handler.Stats() + stats := handler.Stats() // Note: Transport stats are not included since transport is managed externally From 9544615e456d6a5c63b69437f751a8fca24b7262 Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Mon, 27 Jul 2026 14:52:25 -0500 Subject: [PATCH 15/20] fix: distinguish cross-layer from wrong-fork rejections, surface EL admission Both found by the devnet validation run: - lookup rejections lumped consensus nodes in with execution nodes on an incompatible fork. On the devnet 100% of rejected_fork was healthy cross-layer traffic, and on a real network an EL lookup mostly sees CL records, so the counter reads exactly like the malfunction this batch fixed. Records with no eth/eth2 entry are now AdmissionRejectedLayer, logged as rejected_layer and stored as not_el/not_cl, and they no longer pollute the fork filter's accept/reject counters. - the overview picked the CL filter or the EL filter with an else-if, so on any dual-layer bootnode the EL admission counters were unreachable. They now have their own card and AJAX wiring alongside the CL fork panel. --- bootnode/service.go | 28 +++++++++++++++++++++--- services/lookup.go | 24 +++++++++++++++------ webui/handlers/overview.go | 19 ++++++++++------ webui/templates/overview/overview.html | 30 ++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 16 deletions(-) diff --git a/bootnode/service.go b/bootnode/service.go index c6eda6a..530ee83 100644 --- a/bootnode/service.go +++ b/bootnode/service.go @@ -257,10 +257,20 @@ func New(cfg *Config) (*Service, error) { // Filter by fork ID before adding to table if n.Record() != nil && s.enrManager != nil { isEL, forkID := s.enrManager.FilterELNode(n.Record()) - if elFilter := s.enrManager.GetELFilter(); elFilter != nil { - elFilter.RecordAdmission(isEL, forkID) - } if !isEL { + // A record with no eth entry is a consensus node, not an + // execution node on the wrong fork. Counting those as + // fork rejections would make a healthy dual-layer network + // look like a fork-compatibility failure. + if _, hasEth := n.Record().Eth(); !hasEth { + if err := cfg.Database.StoreBadNode(n.IDBytes(), db.LayerEL, "not_el"); err != nil { + cfg.Logger.WithError(err).Debug("failed to store bad node") + } + return services.AdmissionRejectedLayer + } + if elFilter := s.enrManager.GetELFilter(); elFilter != nil { + elFilter.RecordAdmission(false, forkID) + } cfg.Logger.WithFields(logrus.Fields{ "peerID": n.PeerID(), "eth": forkID.String(), @@ -271,6 +281,9 @@ func New(cfg *Config) (*Service, error) { } return services.AdmissionRejectedFilter } + if elFilter := s.enrManager.GetELFilter(); elFilter != nil { + elFilter.RecordAdmission(true, forkID) + } } // If node was discovered via v4 (only has v4 support), immediately test for v5 support @@ -347,6 +360,15 @@ func New(cfg *Config) (*Service, error) { // Filter by fork digest before adding to table if n.Record() != nil && s.enrManager != nil { if !s.enrManager.FilterCLNode(n.Record()) { + // No eth2 entry means an execution node, not a consensus + // node on the wrong digest; keep the two distinguishable. + var eth2 []byte + if err := n.Record().Get("eth2", ð2); err != nil { + if err := cfg.Database.StoreBadNode(n.IDBytes(), db.LayerCL, "not_cl"); err != nil { + cfg.Logger.WithError(err).Debug("failed to store bad node") + } + return services.AdmissionRejectedLayer + } // Mark as bad node if err := cfg.Database.StoreBadNode(n.IDBytes(), db.LayerCL, "invalid_fork_digest"); err != nil { cfg.Logger.WithError(err).Debug("failed to store bad node") diff --git a/services/lookup.go b/services/lookup.go index daadfcf..79d9e4a 100644 --- a/services/lookup.go +++ b/services/lookup.go @@ -63,9 +63,16 @@ const ( // AdmissionAccepted means the node was admitted to the table. AdmissionAccepted AdmissionResult = iota - // AdmissionRejectedFilter means the node failed fork validation. + // AdmissionRejectedFilter means the node is on this layer but announced an + // incompatible fork. AdmissionRejectedFilter + // AdmissionRejectedLayer means the node serves the other layer, so this + // lookup was never a candidate for it. On a dual-layer network most + // discovered nodes land here, which is healthy and must not be read as a + // fork-compatibility problem. + AdmissionRejectedLayer + // AdmissionRejectedPool means the node passed validation but the table // declined it (capacity, per-IP limit, or self). AdmissionRejectedPool @@ -494,7 +501,7 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i // Add discovered nodes via callback (handles admission checks) var addedNodes []*nodedb.Node - var rejectedFilter, rejectedPool int + var rejectedFilter, rejectedPool, rejectedLayer int for _, n := range allDiscovered { if ls.config.OnNodeFound == nil { continue @@ -506,6 +513,8 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i rejectedFilter++ case AdmissionRejectedPool: rejectedPool++ + case AdmissionRejectedLayer: + rejectedLayer++ } } @@ -515,11 +524,12 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i ls.mu.Unlock() ls.config.Logger.WithFields(logrus.Fields{ - "target": target, - "discovered": len(allDiscovered), - "accepted": len(addedNodes), - "rejected_fork": rejectedFilter, - "rejected_pool": rejectedPool, + "target": target, + "discovered": len(allDiscovered), + "accepted": len(addedNodes), + "rejected_fork": rejectedFilter, + "rejected_layer": rejectedLayer, + "rejected_pool": rejectedPool, }).Info("lookup complete") return addedNodes, nil diff --git a/webui/handlers/overview.go b/webui/handlers/overview.go index 81b5304..aef5489 100644 --- a/webui/handlers/overview.go +++ b/webui/handlers/overview.go @@ -110,13 +110,20 @@ type OverviewPageData struct { FilteredResponses int FindNodeReceived int - // Fork filter stats + // CL fork digest filter stats FilterAcceptedCurrent int FilterAcceptedOld int FilterRejectedInvalid int FilterAcceptedHistorical int FilterTotalChecks int + // EL fork ID admission stats. Independent of the CL counters above: a + // dual-layer bootnode runs both filters, so neither can stand in for the + // other. + ELFilterAccepted int + ELFilterRejected int + ELFilterTotalChecks int + // Database stats DBQueueSize int DBProcessedUpdates int64 @@ -514,12 +521,12 @@ func (fh *FrontendHandler) getOverviewPageData() (*OverviewPageData, error) { pageData.FilterAcceptedHistorical = filterStats.AcceptedHistorical pageData.FilterRejectedInvalid = filterStats.RejectedInvalid pageData.FilterTotalChecks = filterStats.TotalChecks - } else if elFilter := enrMgr.GetELFilter(); elFilter != nil { - // EL-only bootnode: the fork panel shows execution admission instead. + } + if elFilter := enrMgr.GetELFilter(); elFilter != nil { elStats := elFilter.GetStats() - pageData.FilterAcceptedCurrent = int(elStats.Accepted) - pageData.FilterRejectedInvalid = int(elStats.Rejected) - pageData.FilterTotalChecks = int(elStats.TotalChecks) + pageData.ELFilterAccepted = int(elStats.Accepted) + pageData.ELFilterRejected = int(elStats.Rejected) + pageData.ELFilterTotalChecks = int(elStats.TotalChecks) } } diff --git a/webui/templates/overview/overview.html b/webui/templates/overview/overview.html index a7705a8..d63ed3d 100644 --- a/webui/templates/overview/overview.html +++ b/webui/templates/overview/overview.html @@ -635,6 +635,33 @@
Fork Filter
{{ end }} + + + {{ if .ELFilterTotalChecks }} +
+
+
+
EL Fork ID Admission
+ + + + + + + + + + + + + + + +
Total Checks{{ .ELFilterTotalChecks }}
Accepted{{ .ELFilterAccepted }}
Rejected (Fork){{ .ELFilterRejected }}
+
+
+
+ {{ end }} @@ -878,6 +905,9 @@
Old Fork Digests (Grace Period)
updateValue('[data-stat="filter-accepted-old"]', data.FilterAcceptedOld); updateValue('[data-stat="filter-rejected-invalid"]', data.FilterRejectedInvalid); updateValue('[data-stat="filter-accepted-historical"]', data.FilterAcceptedHistorical); + updateValue('[data-stat="el-filter-total-checks"]', data.ELFilterTotalChecks); + updateValue('[data-stat="el-filter-accepted"]', data.ELFilterAccepted); + updateValue('[data-stat="el-filter-rejected"]', data.ELFilterRejected); } }) .catch(function(error) { From 3dafc9c652d5e8696066389e74a7022689a1db8e Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Mon, 27 Jul 2026 15:33:26 -0500 Subject: [PATCH 16/20] fix(webui): populate the grace-period digest card OldDigests was declared and had a template card gated on it, but was never assigned, so the card never rendered. The fork test showed the data is real: AcceptedOld was in the thousands across three transitions while the panel stayed blank. GetOldForkDigests already returns the exact shape the field needs, sorted by remaining grace time. --- webui/handlers/overview.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/webui/handlers/overview.go b/webui/handlers/overview.go index aef5489..6564dea 100644 --- a/webui/handlers/overview.go +++ b/webui/handlers/overview.go @@ -7,6 +7,7 @@ import ( "fmt" "net" "net/http" + "sort" "strconv" "strings" "time" @@ -514,6 +515,15 @@ func (fh *FrontendHandler) getOverviewPageData() (*OverviewPageData, error) { pageData.CurrentDigest = clFilter.GetCurrentDigest() pageData.PreviousFork = clFilter.GetPreviousForkName() pageData.PreviousDigest = clFilter.GetPreviousForkDigest() + for digest, remaining := range clFilter.GetOldForkDigests() { + pageData.OldDigests = append(pageData.OldDigests, OldDigestInfo{ + Digest: digest.String(), + Remaining: remaining, + }) + } + sort.Slice(pageData.OldDigests, func(i, j int) bool { + return pageData.OldDigests[i].Remaining > pageData.OldDigests[j].Remaining + }) pageData.GenesisDigest = clFilter.GetGenesisForkDigest() pageData.GracePeriod = clFilter.GetGracePeriod() pageData.FilterAcceptedCurrent = filterStats.AcceptedCurrent From 2d58439d8024596cb4121fd0754ef5f0f2fb7f07 Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Mon, 27 Jul 2026 15:46:41 -0500 Subject: [PATCH 17/20] fix(fork): spec-correct eth2 encoding and post-fork record refresh All found by the live multi-fork devnet test (Electra -> Fulu -> BPO1). - eth2.next_fork_epoch was written and read big-endian. The entry is an SSZ ENRForkID, so the epoch is a little-endian uint64: a conformant peer read epoch 1 as 2^56. Both sides were consistently wrong, so bootnodoor's own filter and tests could not see it, and FAR_FUTURE_EPOCH is byte-palindromic so it stayed invisible until a fork was actually scheduled. Regression test added for exactly that reason. - nextForkInfo returned the version of unscheduled (FAR_FUTURE_EPOCH) forks, so after the last fork bootnodoor advertised an unscheduled fork's version where the spec wants the current one. - addDiscv4Node returned early for a node already in the table, so Add() never got the chance to install its newer ENR. Post-fork records were left stale and served to every FINDNODE querier - the failure a bootnode exists to prevent. The insertion log still only fires for genuinely new nodes. - EL admission counters no longer count records without an eth entry, so the rejection count stops tracking cross-layer traffic (9544615 fixed the log line but not the stats surface). - a CL fork activation now logs instead of carrying a 'Log would go here' placeholder. --- bootnode/clconfig/config.go | 7 ++++-- bootnode/clconfig/config_test.go | 24 +++++++++++++++++++++ bootnode/clconfig/filter.go | 13 +++++++++-- bootnode/service.go | 37 +++++++++++++++++++------------- enr/record.go | 14 +++--------- 5 files changed, 65 insertions(+), 30 deletions(-) diff --git a/bootnode/clconfig/config.go b/bootnode/clconfig/config.go index 0a20353..53ba155 100644 --- a/bootnode/clconfig/config.go +++ b/bootnode/clconfig/config.go @@ -799,8 +799,11 @@ func EncodeETH2Field(currentDigest ForkDigest, nextForkVersion [4]byte, nextFork // Next fork version (bytes 4-7) copy(field[4:8], nextForkVersion[:]) - // Next fork epoch (bytes 8-15, big endian) - binary.BigEndian.PutUint64(field[8:16], nextForkEpoch) + // Bytes 8-15: the eth2 entry is an SSZ ENRForkID, so the epoch is a + // little-endian uint64. Big-endian reads identically when the epoch is + // FAR_FUTURE_EPOCH, which is why this went unnoticed until a fork was + // actually scheduled. + binary.LittleEndian.PutUint64(field[8:16], nextForkEpoch) return field } diff --git a/bootnode/clconfig/config_test.go b/bootnode/clconfig/config_test.go index 9deb272..30f2e61 100644 --- a/bootnode/clconfig/config_test.go +++ b/bootnode/clconfig/config_test.go @@ -1,6 +1,7 @@ package clconfig import ( + "bytes" "os" "path/filepath" "testing" @@ -116,3 +117,26 @@ BLOB_SCHEDULE: t.Fatalf("blob params at 150 = %+v, want the epoch-100 entry (12)", got) } } + +// TestEncodeETH2FieldEpochIsLittleEndian pins the SSZ encoding of +// ENRForkID.next_fork_epoch. A big-endian epoch is byte-identical when the +// value is FAR_FUTURE_EPOCH, so only a scheduled fork exposes the difference — +// which is why every all-forks-at-genesis devnet missed this. +func TestEncodeETH2FieldEpochIsLittleEndian(t *testing.T) { + digest := ForkDigest{0xaa, 0xbb, 0xcc, 0xdd} + version := [4]byte{0x70, 0x00, 0x00, 0x38} + + field := EncodeETH2Field(digest, version, 5) + if len(field) != 16 { + t.Fatalf("eth2 field = %d bytes, want 16", len(field)) + } + want := []byte{0x05, 0, 0, 0, 0, 0, 0, 0} + if !bytes.Equal(field[8:16], want) { + t.Fatalf("next_fork_epoch bytes = % x, want % x (little-endian 5)", field[8:16], want) + } + + // FAR_FUTURE_EPOCH is palindromic, so it must round-trip either way. + if got := EncodeETH2Field(digest, version, ^uint64(0)); !bytes.Equal(got[8:16], []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}) { + t.Fatalf("far-future epoch bytes = % x", got[8:16]) + } +} diff --git a/bootnode/clconfig/filter.go b/bootnode/clconfig/filter.go index 2f3dc28..fb90ac2 100644 --- a/bootnode/clconfig/filter.go +++ b/bootnode/clconfig/filter.go @@ -232,8 +232,10 @@ func (f *ForkDigestFilter) Update() { f.currentForkDigest = newDigest f.lastUpdate = time.Now() - // Log would go here - // logger.Info("Fork activated", "old", oldDigest, "new", newDigest) + if f.logger != nil { + f.logger.Debugf("CL fork activated: digest %s -> %s (previous digest accepted for %s)", + oldDigest.String(), newDigest.String(), f.gracePeriod) + } } // Clean up expired old digests @@ -343,6 +345,13 @@ func (f *ForkDigestFilter) nextForkInfo() ([4]byte, uint64) { currentForkVersion := f.config.GetForkVersionAtEpoch(currentEpoch) for _, fork := range f.config.getForks() { + // An unscheduled fork is not an upcoming one. Returning its version + // published a next_fork_version for a fork that may never activate, + // where the spec wants the current version once next_fork_epoch is + // FAR_FUTURE_EPOCH. + if fork.epoch == farFutureEpoch { + continue + } if fork.epoch > currentEpoch { return fork.parsedVersion, fork.epoch } diff --git a/bootnode/service.go b/bootnode/service.go index 530ee83..a060999 100644 --- a/bootnode/service.go +++ b/bootnode/service.go @@ -1337,19 +1337,19 @@ func (s *Service) checkAndAddNodeV4(n *v4node.Node) bool { return false } - // Check if node already exists in table - if existingNode := s.elTable.Get(n.ID()); existingNode != nil { - s.config.Logger.WithFields(logrus.Fields{ - "nodeID": fmt.Sprintf("%x", n.IDBytes()[:8]), - }).Debug("Discv4 node already in EL table, skipping add") - return false - } + // A node already in the table still needs its record re-checked: Add() + // installs a newer ENR, which is how a peer's post-fork eth entry reaches + // the table. Returning early here left the pre-fork record in place and + // served it to every FINDNODE querier. + alreadyKnown := s.elTable.Get(n.ID()) != nil // Filter the node using ENR manager (EL-only for discv4) if s.enrManager != nil { filter, forkID := s.enrManager.FilterELNode(n.ENR()) - if elFilter := s.enrManager.GetELFilter(); elFilter != nil { - elFilter.RecordAdmission(filter, forkID) + if _, hasEth := n.ENR().Eth(); hasEth { + if elFilter := s.enrManager.GetELFilter(); elFilter != nil { + elFilter.RecordAdmission(filter, forkID) + } } if !filter { s.config.Logger.WithFields(logrus.Fields{ @@ -1366,10 +1366,12 @@ func (s *Service) checkAndAddNodeV4(n *v4node.Node) bool { // Try to add to table if s.elTable.Add(genericNode) { - s.config.Logger.WithFields(logrus.Fields{ - "nodeID": fmt.Sprintf("%x", n.IDBytes()[:8]), - "addr": n.Addr().String(), - }).Info("Added discv4 node to EL table") + if !alreadyKnown { + s.config.Logger.WithFields(logrus.Fields{ + "nodeID": fmt.Sprintf("%x", n.IDBytes()[:8]), + "addr": n.Addr().String(), + }).Info("Added discv4 node to EL table") + } return true } @@ -1385,8 +1387,13 @@ func (s *Service) checkAndAddNode(n *v5node.Node) bool { // Determine layer isEL, elForkID := s.enrManager.FilterELNode(n.Record()) isCL := s.enrManager.FilterCLNode(n.Record()) - if elFilter := s.enrManager.GetELFilter(); elFilter != nil { - elFilter.RecordAdmission(isEL, elForkID) + // Only an execution record is an execution admission decision; counting + // consensus nodes here made the rejection counter track cross-layer + // traffic, which on a dual-layer network is most of what arrives. + if _, hasEth := n.Record().Eth(); hasEth { + if elFilter := s.enrManager.GetELFilter(); elFilter != nil { + elFilter.RecordAdmission(isEL, elForkID) + } } // Add to appropriate table(s) diff --git a/enr/record.go b/enr/record.go index 30b38f3..3584821 100644 --- a/enr/record.go +++ b/enr/record.go @@ -11,6 +11,7 @@ package enr import ( "crypto/ecdsa" + "encoding/binary" "errors" "fmt" "net" @@ -393,7 +394,7 @@ func (r *Record) Eth2() (*Eth2ENRData, bool) { // Eth2 field format: // - Bytes 0-3: Current fork digest // - Bytes 4-7: Next fork version - // - Bytes 8-15: Next fork epoch (big endian) + // - Bytes 8-15: Next fork epoch (SSZ uint64, little endian) if len(eth2Bytes) < 16 { return nil, false } @@ -401,16 +402,7 @@ func (r *Record) Eth2() (*Eth2ENRData, bool) { var eth2Data Eth2ENRData copy(eth2Data.ForkDigest[:], eth2Bytes[0:4]) copy(eth2Data.NextForkVersion[:], eth2Bytes[4:8]) - - // Decode next fork epoch (big endian) - eth2Data.NextForkEpoch = uint64(eth2Bytes[8])<<56 | - uint64(eth2Bytes[9])<<48 | - uint64(eth2Bytes[10])<<40 | - uint64(eth2Bytes[11])<<32 | - uint64(eth2Bytes[12])<<24 | - uint64(eth2Bytes[13])<<16 | - uint64(eth2Bytes[14])<<8 | - uint64(eth2Bytes[15]) + eth2Data.NextForkEpoch = binary.LittleEndian.Uint64(eth2Bytes[8:16]) return ð2Data, true } From 470d0cc6096446889e2bf41076e05b04d915d11b Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Mon, 27 Jul 2026 15:57:44 -0500 Subject: [PATCH 18/20] fix: refresh known v4 records, tighten layer gating, unnest EL stats Three findings from review of the fork-test fixes: - the post-fork record refresh was still unreachable. onNodeSeenV4 returns as soon as a node is in the table, so checkAndAddNodeV4 never ran for known nodes; PONG's newer-ENR detection only updates the handler's own node and discards the result. A known node whose record advanced now goes through admission again so Add() installs it. - the layer gates used Record.Eth(), which is also false for a malformed or empty eth entry, so a broken execution record was filed as cross-layer instead of a fork rejection. They now test key presence with Has(). - the EL admission AJAX updates sat inside the CL filter's conditional, so on an EL-only deployment the card never refreshed and could not appear without a page reload. --- bootnode/service.go | 18 ++++++++++++------ webui/templates/overview/overview.html | 4 ++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/bootnode/service.go b/bootnode/service.go index a060999..9029e43 100644 --- a/bootnode/service.go +++ b/bootnode/service.go @@ -262,7 +262,7 @@ func New(cfg *Config) (*Service, error) { // execution node on the wrong fork. Counting those as // fork rejections would make a healthy dual-layer network // look like a fork-compatibility failure. - if _, hasEth := n.Record().Eth(); !hasEth { + if !n.Record().Has("eth") { if err := cfg.Database.StoreBadNode(n.IDBytes(), db.LayerEL, "not_el"); err != nil { cfg.Logger.WithError(err).Debug("failed to store bad node") } @@ -362,8 +362,7 @@ func New(cfg *Config) (*Service, error) { if !s.enrManager.FilterCLNode(n.Record()) { // No eth2 entry means an execution node, not a consensus // node on the wrong digest; keep the two distinguishable. - var eth2 []byte - if err := n.Record().Get("eth2", ð2); err != nil { + if !n.Record().Has("eth2") { if err := cfg.Database.StoreBadNode(n.IDBytes(), db.LayerCL, "not_cl"); err != nil { cfg.Logger.WithError(err).Debug("failed to store bad node") } @@ -1219,9 +1218,16 @@ func (s *Service) onNodeSeenV4(n *v4node.Node, timestamp time.Time) { if s.elTable != nil && s.elNodeDB != nil { // Look up the generic node from the table if genericNode := s.elTable.Get(n.ID()); genericNode != nil { - // Node exists, just update last seen genericNode.SetLastSeen(timestamp) // This marks it dirty s.elNodeDB.QueueUpdate(genericNode) + + // A known node still needs its record re-checked. PONG triggers an + // ENR refresh on the handler's node, but nothing propagates that to + // the table, so without this the table serves the peer's pre-fork + // record for the rest of its lifetime. + if rec := n.ENR(); rec != nil && rec.Seq() > genericNode.Record().Seq() { + s.checkAndAddNodeV4(n) + } return } @@ -1346,7 +1352,7 @@ func (s *Service) checkAndAddNodeV4(n *v4node.Node) bool { // Filter the node using ENR manager (EL-only for discv4) if s.enrManager != nil { filter, forkID := s.enrManager.FilterELNode(n.ENR()) - if _, hasEth := n.ENR().Eth(); hasEth { + if n.ENR().Has("eth") { if elFilter := s.enrManager.GetELFilter(); elFilter != nil { elFilter.RecordAdmission(filter, forkID) } @@ -1390,7 +1396,7 @@ func (s *Service) checkAndAddNode(n *v5node.Node) bool { // Only an execution record is an execution admission decision; counting // consensus nodes here made the rejection counter track cross-layer // traffic, which on a dual-layer network is most of what arrives. - if _, hasEth := n.Record().Eth(); hasEth { + if n.Record().Has("eth") { if elFilter := s.enrManager.GetELFilter(); elFilter != nil { elFilter.RecordAdmission(isEL, elForkID) } diff --git a/webui/templates/overview/overview.html b/webui/templates/overview/overview.html index d63ed3d..7df7972 100644 --- a/webui/templates/overview/overview.html +++ b/webui/templates/overview/overview.html @@ -905,6 +905,10 @@
Old Fork Digests (Grace Period)
updateValue('[data-stat="filter-accepted-old"]', data.FilterAcceptedOld); updateValue('[data-stat="filter-rejected-invalid"]', data.FilterRejectedInvalid); updateValue('[data-stat="filter-accepted-historical"]', data.FilterAcceptedHistorical); + } + + /* EL fork ID admission (independent of the CL filter above) */ + if (data.ELFilterTotalChecks) { updateValue('[data-stat="el-filter-total-checks"]', data.ELFilterTotalChecks); updateValue('[data-stat="el-filter-accepted"]', data.ELFilterAccepted); updateValue('[data-stat="el-filter-rejected"]', data.ELFilterRejected); From 71d9ecaa371ad27f7a062b04f2a6874c3055a62c Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Mon, 27 Jul 2026 17:18:29 -0500 Subject: [PATCH 19/20] fix(lookup): admit newer relayed records for known nodes A peer we cannot bond with has no refresh channel of its own: PING/PONG, the handshake and the v4 ENR request all need us to reach it. The one remaining path is another discv5 peer relaying its signed record, and the lookup dropped those - every table node was pre-seeded into `seen`, so a known ID could never reach OnNodeFound. Nodes are not evicted below capacity, so the first-contact record stuck for the process lifetime and was persisted to the DB. Replace `seen`/`allDiscovered` with per-lookup bookkeeping that keeps the best record per node plus first-observation order. Refreshes are gated to v5-sourced records: a v4 wrapper's ENR came from dialing the peer, so it is not a relay, and re-admitting one re-runs the blocking v5 support probe. Eligibility is checked before the dedupe branch so a later duplicate cannot bypass that gate, and admission re-checks the live table entry in case a direct refresh overtook us mid-lookup. Also move queryHistory, lookupsV5 and lookupsV4 writes to ls.mu; they were written under the per-lookup mutex but read under ls.mu. --- services/lookup.go | 135 ++++++++++++++++++++++---------- services/lookup_test.go | 169 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 41 deletions(-) diff --git a/services/lookup.go b/services/lookup.go index 79d9e4a..2f062b0 100644 --- a/services/lookup.go +++ b/services/lookup.go @@ -131,6 +131,81 @@ func (ls *LookupService) isLocal(id [32]byte) bool { return false } +// discoveries accumulates the records observed during a single lookup, keeping +// the highest-sequence record per node plus the order nodes were first seen in. +type discoveries struct { + table map[node.ID]*nodedb.Node + best map[node.ID]*nodedb.Node + order []node.ID +} + +func newDiscoveries(tableNodes []*nodedb.Node) *discoveries { + // Live entries, not a sequence snapshot: Add mutates these same objects, so + // a refresh landing mid-lookup must not leave us admitting a stale record. + table := make(map[node.ID]*nodedb.Node, len(tableNodes)) + for _, n := range tableNodes { + table[n.ID()] = n + } + + return &discoveries{ + table: table, + best: make(map[node.ID]*nodedb.Node), + } +} + +// admit yields the best record per node in first-observation order. Order is +// preserved because the table applies capacity and per-IP limits as nodes +// arrive, so which record gets a slot depends on when it is offered. +func (d *discoveries) admit() []*nodedb.Node { + out := make([]*nodedb.Node, 0, len(d.order)) + + for _, id := range d.order { + n := d.best[id] + if known, ok := d.table[id]; ok && n.Record().Seq() <= known.Record().Seq() { + continue + } + out = append(out, n) + } + + return out +} + +// noteDiscovered records a discovered node, reporting whether it is new to us +// and therefore worth querying in the next round. Eligibility is settled before +// d.best is consulted so a later duplicate cannot bypass the known-node gate. +func (ls *LookupService) noteDiscovered(d *discoveries, n *nodedb.Node) bool { + id := n.ID() + if ls.isLocal(id) { + return false + } + + rec := n.Record() + if rec == nil { + return false + } + + // Only discv5 relays a peer's signed record; a v4 wrapper's ENR was fetched + // by dialing the peer, which is exactly what a stale peer is unreachable + // for, and re-admitting one re-runs the blocking v5 support probe. + known, isKnown := d.table[id] + if isKnown && (!n.HasV5() || rec.Seq() <= known.Record().Seq()) { + return false + } + + if prev, ok := d.best[id]; ok { + if rec.Seq() > prev.Record().Seq() { + d.best[id] = n + } + + return false + } + + d.best[id] = n + d.order = append(d.order, id) + + return !isKnown +} + // NewLookupService creates a new lookup service. func NewLookupService(cfg Config) *LookupService { if cfg.Alpha <= 0 { @@ -185,23 +260,10 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i return nil, fmt.Errorf("no nodes in table to query") } - // Track all discovered nodes and which ones we've queried - seen := make(map[node.ID]bool) + disc := newDiscoveries(allNodes) queried := make(map[node.ID]bool) - var allDiscovered []*nodedb.Node var mu sync.Mutex - // Add existing table nodes to the candidate pool - for _, n := range allNodes { - seen[n.ID()] = true - } - - // Mark ourselves as seen so our own record can never enter the candidate - // set, the discovered set, or the admission callback. - for _, id := range ls.config.LocalIDs { - seen[id] = true - } - // For iterative lookup, we need a list of candidates sorted by distance to target // Start with closest nodes from our table var candidates []*nodedb.Node @@ -238,7 +300,6 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i // Query nodes in parallel for this round var wg sync.WaitGroup roundDiscovered := make([]*nodedb.Node, 0) - var roundMu sync.Mutex for _, n := range toQuery { wg.Add(1) @@ -248,9 +309,13 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i // Mark as queried mu.Lock() queried[n.ID()] = true - ls.queryHistory[n.ID()] = time.Now() mu.Unlock() + // queryHistory outlives this lookup and is read under ls.mu. + ls.mu.Lock() + ls.queryHistory[n.ID()] = time.Now() + ls.mu.Unlock() + // Calculate distances var distances []uint if isRandomWalk { @@ -272,9 +337,9 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i // Try discv5 first if available var discoveredNodes []*nodedb.Node if v5Node := n.V5(); v5Node != nil && ls.config.V5Handler != nil { - mu.Lock() + ls.mu.Lock() ls.lookupsV5++ - mu.Unlock() + ls.mu.Unlock() respChan, err := ls.config.V5Handler.SendFindNode(v5Node, distances) if err != nil { @@ -328,9 +393,9 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i // Try discv4 fallback if no v5 results and v4 is available if len(discoveredNodes) == 0 && n.V4() != nil && ls.config.V4Service != nil { - mu.Lock() + ls.mu.Lock() ls.lookupsV4++ - mu.Unlock() + ls.mu.Unlock() v4Node := n.V4() // Convert target to []byte for v4 @@ -440,22 +505,13 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i discoveredNodes = append(discoveredNodes, nodesWithENR...) } - // Add discovered nodes to round results - roundMu.Lock() + mu.Lock() for _, newNode := range discoveredNodes { - // Skip if already seen - mu.Lock() - alreadySeen := seen[newNode.ID()] - if !alreadySeen { - seen[newNode.ID()] = true - } - mu.Unlock() - - if !alreadySeen { + if ls.noteDiscovered(disc, newNode) { roundDiscovered = append(roundDiscovered, newNode) } } - roundMu.Unlock() + mu.Unlock() }(n) } @@ -466,11 +522,6 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i "discovered": len(roundDiscovered), }).Debug("lookup round complete") - // Add this round's discoveries to the total - mu.Lock() - allDiscovered = append(allDiscovered, roundDiscovered...) - mu.Unlock() - // Check context before next round select { case <-ctx.Done(): @@ -493,16 +544,18 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i } } + admitted := disc.admit() + ls.config.Logger.WithFields(logrus.Fields{ "target": target, "queried": len(queried), - "discovered": len(allDiscovered), + "discovered": len(admitted), }).Debug("lookup queries complete") // Add discovered nodes via callback (handles admission checks) var addedNodes []*nodedb.Node var rejectedFilter, rejectedPool, rejectedLayer int - for _, n := range allDiscovered { + for _, n := range admitted { if ls.config.OnNodeFound == nil { continue } @@ -519,13 +572,13 @@ func (ls *LookupService) lookupInternal(ctx context.Context, target node.ID, k i } ls.mu.Lock() - ls.nodesDiscovered += len(allDiscovered) + ls.nodesDiscovered += len(admitted) ls.lookupsCompleted++ ls.mu.Unlock() ls.config.Logger.WithFields(logrus.Fields{ "target": target, - "discovered": len(allDiscovered), + "discovered": len(admitted), "accepted": len(addedNodes), "rejected_fork": rejectedFilter, "rejected_layer": rejectedLayer, diff --git a/services/lookup_test.go b/services/lookup_test.go index ad3dec7..29164c0 100644 --- a/services/lookup_test.go +++ b/services/lookup_test.go @@ -1,6 +1,7 @@ package services import ( + "crypto/ecdsa" "net" "sync" "testing" @@ -37,6 +38,41 @@ func testNode(t *testing.T, last byte) *nodedb.Node { return nodedb.NewFromV5(v5, nil) } +func recordAtSeq(t *testing.T, key *ecdsa.PrivateKey, last byte, seq uint64) *enr.Record { + t.Helper() + rec := enr.New() + if err := rec.Set("ip", net.IPv4(10, 0, 0, last)); err != nil { + t.Fatalf("set ip: %v", err) + } + if err := rec.Set("udp", uint16(9000)); err != nil { + t.Fatalf("set udp: %v", err) + } + rec.SetSeq(seq) + if err := rec.Sign(key); err != nil { + t.Fatalf("sign: %v", err) + } + return rec +} + +// v5NodeAtSeq builds a node the way a discv5 NODES response does. +func v5NodeAtSeq(t *testing.T, key *ecdsa.PrivateKey, last byte, seq uint64) *nodedb.Node { + t.Helper() + v5, err := node.New(recordAtSeq(t, key, last, seq)) + if err != nil { + t.Fatalf("new v5 node: %v", err) + } + return nodedb.NewFromV5(v5, nil) +} + +// v4NodeAtSeq builds a node the way the discv4 NEIGHBORS path does, where the +// record came from dialing the peer for its ENR rather than from a relay. +func v4NodeAtSeq(t *testing.T, key *ecdsa.PrivateKey, last byte, seq uint64) *nodedb.Node { + t.Helper() + v4 := v4node.New(&key.PublicKey, &net.UDPAddr{IP: net.IPv4(10, 0, 0, last), Port: 9000}) + v4.SetENR(recordAtSeq(t, key, last, seq)) + return nodedb.NewFromV4(v4, nil) +} + func quietLookupService(localIDs [][32]byte) *LookupService { logger := logrus.New() logger.SetLevel(logrus.ErrorLevel) @@ -90,6 +126,139 @@ func TestIsLocalCoversBothIdentities(t *testing.T) { } } +func testKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + return key +} + +// TestNoteDiscoveredRefreshesKnownNode covers the bug this whole path exists +// for: a peer we cannot bond with only ever learns a newer record when another +// discv5 peer relays it, so a known node with a higher sequence must reach +// admission without being treated as a new frontier candidate. +func TestNoteDiscoveredRefreshesKnownNode(t *testing.T) { + key := testKey(t) + known := v5NodeAtSeq(t, key, 1, 4) + ls := quietLookupService(nil) + + tests := []struct { + name string + candidate *nodedb.Node + admitted bool + }{ + {"newer v5 record refreshes", v5NodeAtSeq(t, key, 1, 6), true}, + {"newer v4 record is not a relay", v4NodeAtSeq(t, key, 1, 6), false}, + {"equal sequence is not newer", v5NodeAtSeq(t, key, 1, 4), false}, + {"older sequence is ignored", v5NodeAtSeq(t, key, 1, 3), false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + d := newDiscoveries([]*nodedb.Node{known}) + + if frontier := ls.noteDiscovered(d, tc.candidate); frontier { + t.Fatal("a known node must never re-enter the frontier") + } + + got := d.admit() + if tc.admitted && len(got) != 1 { + t.Fatalf("admitted %d nodes, want the refreshed record", len(got)) + } + if !tc.admitted && len(got) != 0 { + t.Fatalf("admitted %d nodes, want none", len(got)) + } + }) + } +} + +// TestNoteDiscoveredEligibilityPrecedesDedupe pins the rule ordering: a v4 +// record must not slip past the known-node gate just because it outranks a v5 +// record already held for that node. +func TestNoteDiscoveredEligibilityPrecedesDedupe(t *testing.T) { + key := testKey(t) + known := v5NodeAtSeq(t, key, 1, 4) + relayed := v5NodeAtSeq(t, key, 1, 5) + dialed := v4NodeAtSeq(t, key, 1, 6) + + ls := quietLookupService(nil) + d := newDiscoveries([]*nodedb.Node{known}) + + ls.noteDiscovered(d, relayed) + ls.noteDiscovered(d, dialed) + + got := d.admit() + if len(got) != 1 { + t.Fatalf("admitted %d nodes, want 1", len(got)) + } + if !got[0].HasV5() || got[0].Record().Seq() != 5 { + t.Fatalf("admitted seq %d (v5=%t), want the relayed v5 record at seq 5", + got[0].Record().Seq(), got[0].HasV5()) + } +} + +// TestNoteDiscoveredKeepsBestOfDuplicates verifies a newer duplicate replaces +// the record without queueing the peer for a second query in the same round. +func TestNoteDiscoveredKeepsBestOfDuplicates(t *testing.T) { + key := testKey(t) + ls := quietLookupService(nil) + d := newDiscoveries(nil) + + if !ls.noteDiscovered(d, v5NodeAtSeq(t, key, 1, 5)) { + t.Fatal("an unknown node should enter the frontier") + } + if ls.noteDiscovered(d, v5NodeAtSeq(t, key, 1, 6)) { + t.Fatal("a duplicate must not enter the frontier twice") + } + + got := d.admit() + if len(got) != 1 { + t.Fatalf("admitted %d nodes, want 1", len(got)) + } + if got[0].Record().Seq() != 6 { + t.Fatalf("admitted seq %d, want the newer duplicate at seq 6", got[0].Record().Seq()) + } +} + +// TestAdmitSkipsRecordsOvertakenMidLookup covers a direct refresh landing while +// the lookup is still running: the relayed record is no longer newer by the +// time we admit, and re-offering it would run the fork filter against a record +// the table has already moved past. +func TestAdmitSkipsRecordsOvertakenMidLookup(t *testing.T) { + key := testKey(t) + known := v5NodeAtSeq(t, key, 1, 4) + + ls := quietLookupService(nil) + d := newDiscoveries([]*nodedb.Node{known}) + + ls.noteDiscovered(d, v5NodeAtSeq(t, key, 1, 5)) + + known.UpdateENR(recordAtSeq(t, key, 1, 7)) + + if got := d.admit(); len(got) != 0 { + t.Fatalf("admitted %d nodes, want none once the table overtook them", len(got)) + } +} + +// TestNoteDiscoveredSkipsSelf verifies our own relayed record never reaches +// admission, at any sequence number. +func TestNoteDiscoveredSkipsSelf(t *testing.T) { + key := testKey(t) + self := v5NodeAtSeq(t, key, 1, 1) + + ls := quietLookupService([][32]byte{self.ID()}) + d := newDiscoveries(nil) + + if ls.noteDiscovered(d, v5NodeAtSeq(t, key, 1, 9)) { + t.Fatal("our own record entered the frontier") + } + if got := d.admit(); len(got) != 0 { + t.Fatalf("admitted %d nodes, want none", len(got)) + } +} + // TestPingServiceStatsRace exercises the counters from many goroutines while a // reader polls GetStats, which is what the web UI handler does. func TestPingServiceStatsRace(t *testing.T) { From 461a23f091a81db2a451ccbad6251a0996b90f9a Mon Sep 17 00:00:00 2001 From: Chase Wright Date: Mon, 27 Jul 2026 17:18:29 -0500 Subject: [PATCH 20/20] fix(discv5): accept a record-less handshake, bind key to claimed ID The handshake record is optional in discv5 v5.1 - a peer omits it when our WHOAREYOU advertised an ENRSeq it has nothing newer than - but it was the only source of the sender's key, so those handshakes were rejected. We honour the same optionality outbound, so we emitted handshakes we would refuse. Reachable when a session with a node attached lands between the session lookup and sendWHOAREYOU. Carry the record our ENRSeq came from on the pending challenge and fall back to it, resolving the whole node rather than a bare key: the session and OnHandshakeComplete both hang off it, and a node-less session can never refresh its ENR. Also require the key to derive the claimed source node ID. The session is keyed by that ID while the signature only proves possession of the key, so a peer could authenticate as itself and be filed under another identity. --- discv5/protocol/handler.go | 84 +++++++++++++++++++++++--------- discv5/protocol/handler_test.go | 86 +++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 23 deletions(-) diff --git a/discv5/protocol/handler.go b/discv5/protocol/handler.go index f5b5289..476553c 100644 --- a/discv5/protocol/handler.go +++ b/discv5/protocol/handler.go @@ -42,6 +42,11 @@ type PendingChallenge struct { ChallengeData []byte PacketBytes []byte // Raw WHOAREYOU packet bytes for resending CreatedAt time.Time + + // KnownNode is the record our advertised ENRSeq came from, if any. A peer + // may legally answer a non-zero ENRSeq without repeating its record, so + // this is the only copy left to verify that handshake against. + KnownNode *node.Node } // OnHandshakeCompleteCallback is called when a handshake completes successfully. @@ -802,29 +807,12 @@ func (h *Handler) handleHandshakePacket(packet *Packet, from *net.UDPAddr, local h.removePendingChallenge(challengeKey, pendingChallenge) h.mu.Unlock() - // Get sender's static public key for signature verification - // First try to extract it from the ENR in the handshake packet - var senderPubKey *ecdsa.PublicKey - var remoteNodeFromENR *node.Node - if len(packet.Handshake.ENR) > 0 { - enrRecord := &enr.Record{} - if err := enrRecord.DecodeRLPBytes(packet.Handshake.ENR); err != nil { - h.config.Logger.WithError(err).Warn("handler: failed to decode ENR from handshake") - } else { - remoteNode, err := node.New(enrRecord) - if err != nil { - h.config.Logger.WithError(err).Warn("handler: failed to create node from ENR") - } else { - remoteNodeFromENR = remoteNode - senderPubKey = remoteNode.PublicKey() - } - } - } - - // ENR is required in handshake packet for signature verification - if senderPubKey == nil { - h.config.Logger.WithField("sourceNodeID", sourceNodeID.String()[:16]).Warn("handler: no ENR provided in handshake packet") - return fmt.Errorf("no ENR provided in handshake packet") + remoteNodeFromENR, senderPubKey, err := resolveHandshakeSender( + packet.Handshake.ENR, pendingChallenge, sourceNodeID, h.config.Logger) + if err != nil { + h.config.Logger.WithError(err).WithField("sourceNodeID", sourceNodeID.String()[:16]). + Warn("handler: cannot resolve handshake sender") + return err } // Decode ephemeral public key (for ECDH) @@ -1404,8 +1392,10 @@ func (h *Handler) sendWHOAREYOU(to *net.UDPAddr, destNodeID node.ID, nonce []byt // Get the current ENR sequence we have for this node enrSeq := uint64(0) + var knownNode *node.Node if sess := h.config.Sessions.Get(destNodeID); sess != nil { if existingNode := sess.GetNode(); existingNode != nil { + knownNode = existingNode enrSeq = existingNode.Record().Seq() } } @@ -1436,6 +1426,7 @@ func (h *Handler) sendWHOAREYOU(to *net.UDPAddr, destNodeID node.ID, nonce []byt ChallengeData: challengeData, PacketBytes: packetBytes, // Store for resending CreatedAt: time.Now(), + KnownNode: knownNode, } h.mu.Lock() @@ -1714,6 +1705,53 @@ func (h *Handler) requestENRUpdate(n *node.Node) { }() } +// resolveHandshakeSender determines which node, and therefore which static key, +// a handshake must be verified against. +// +// The record is optional in the handshake message: a peer may legally omit it +// when our WHOAREYOU advertised an ENRSeq it has nothing newer than, in which +// case the challenge's own copy is the only one left. The whole node is +// returned rather than a bare key because the session and the handshake +// callback both hang off it, and a node-less session can never refresh its ENR. +func resolveHandshakeSender(enrBytes []byte, challenge *PendingChallenge, sourceNodeID node.ID, + logger logrus.FieldLogger, +) (*node.Node, *ecdsa.PublicKey, error) { + var remoteNode *node.Node + + if len(enrBytes) > 0 { + record := &enr.Record{} + if err := record.DecodeRLPBytes(enrBytes); err != nil { + logger.WithError(err).Warn("handler: failed to decode ENR from handshake") + } else if n, err := node.New(record); err != nil { + logger.WithError(err).Warn("handler: failed to create node from ENR") + } else { + remoteNode = n + } + } + + if remoteNode == nil && challenge != nil { + remoteNode = challenge.KnownNode + } + + if remoteNode == nil { + return nil, nil, fmt.Errorf("no ENR provided in handshake packet") + } + + pubKey := remoteNode.PublicKey() + if pubKey == nil { + return nil, nil, fmt.Errorf("handshake record carries no public key") + } + + // The session is keyed by the claimed source ID while the signature only + // proves possession of this key, so without binding the two a peer could + // authenticate as itself and be filed under another node's identity. + if node.PubkeyToID(pubKey) != sourceNodeID { + return nil, nil, fmt.Errorf("handshake key does not match source node ID") + } + + return remoteNode, pubKey, nil +} + // applyENRUpdate installs the newest valid record returned by a distance-zero // FINDNODE request. A peer must not be able to replace its session record with // another node's ENR, even though the response itself matched the pending request. diff --git a/discv5/protocol/handler_test.go b/discv5/protocol/handler_test.go index 13a24a2..c507385 100644 --- a/discv5/protocol/handler_test.go +++ b/discv5/protocol/handler_test.go @@ -11,6 +11,92 @@ import ( "github.com/sirupsen/logrus" ) +// TestResolveHandshakeSender covers the optional-record rule from discv5 v5.1: +// a peer may answer our WHOAREYOU without repeating its ENR, and rejecting that +// would break bonding with exactly the peers whose records we already hold. +func TestResolveHandshakeSender(t *testing.T) { + key := generateKey(t) + record := signedRecord(t, key, 4, nil) + known, err := node.New(record) + if err != nil { + t.Fatalf("create node: %v", err) + } + + encoded, err := record.EncodeRLP() + if err != nil { + t.Fatalf("encode record: %v", err) + } + + otherKey := generateKey(t) + otherNode, err := node.New(signedRecord(t, otherKey, 1, nil)) + if err != nil { + t.Fatalf("create other node: %v", err) + } + + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + tests := []struct { + name string + enrBytes []byte + challenge *PendingChallenge + source node.ID + wantErr bool + }{ + { + name: "record in handshake is used", + enrBytes: encoded, + source: known.ID(), + }, + { + name: "record omitted falls back to the challenge", + challenge: &PendingChallenge{KnownNode: known}, + source: known.ID(), + }, + { + name: "record omitted with nothing to fall back to", + challenge: &PendingChallenge{}, + source: known.ID(), + wantErr: true, + }, + { + name: "record belongs to a different node than claimed", + enrBytes: encoded, + source: otherNode.ID(), + wantErr: true, + }, + { + name: "fallback belongs to a different node than claimed", + challenge: &PendingChallenge{KnownNode: known}, + source: otherNode.ID(), + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, pubKey, err := resolveHandshakeSender(tc.enrBytes, tc.challenge, tc.source, logger) + + if tc.wantErr { + if err == nil { + t.Fatal("expected an error, got a resolved sender") + } + return + } + + if err != nil { + t.Fatalf("resolve: %v", err) + } + if got == nil { + t.Fatal("resolved a key but no node; the session would have nothing to refresh") + } + if node.PubkeyToID(pubKey) != tc.source { + t.Fatal("resolved key does not derive the claimed source ID") + } + }) + } +} + func TestApplyENRUpdateInstallsNewestMatchingRecord(t *testing.T) { key := generateKey(t) currentRecord := signedRecord(t, key, 1, nil)