Skip to content

Discovery hardening, persistence, fork behavior and counter fixes - #46

Merged
pk910 merged 50 commits into
masterfrom
develop
Jul 30, 2026
Merged

Discovery hardening, persistence, fork behavior and counter fixes#46
pk910 merged 50 commits into
masterfrom
develop

Conversation

@MysticRyuujin

Copy link
Copy Markdown
Collaborator

Summary

This pull request has 50 commits. It changes 71 files. It adds 6725 lines and
it removes 1586 lines. Approximately 4000 of the new lines are tests.

The work has five parts:

  • It makes the discovery protocols safe against packets with a false source.
  • It stops a loop that sent a very large quantity of packets.
  • It makes the fork ENR refresh start at the fork boundary.
  • It keeps the node data correct in the database.
  • It corrects the counters and adds the --serve-all option.

Discovery: a peer must first prove its address

Before this change, a packet with a false source address could change the
state of bootnodoor. An attacker could use this to make bootnodoor send data
to a third party.

The new rules are:

  • A response changes the state only after the peer proves its endpoint.
  • bootnodoor matches a response against the address that it sent the request
    to.
  • A PONG gives a bond only for the address that received the PING.
  • bootnodoor promotes the address of a node only for a proven endpoint.
  • An unauthenticated packet cannot move a session, delete a session or
    replace a session. A WHOAREYOU packet with no pending request no longer
    deletes the session.
  • The Zone value is part of the comparison of a session address.
  • The UDP port stays together with its address family.
  • bootnodoor keys a pending request on the hash and on the destination node.
    Before this change, two requests in the same second were the same bytes.
    Then a response from one peer could install the record of a different peer.

Discovery: one refresh at a time

A PONG with a higher ENR sequence started an ENR refresh. Each refresh sent a
PING, and that PING caused a new PONG. The new PONG started the same refresh
again.

The devnet test showed a maximum of 980 packets in one second. bootnodoor sent
2575 PINGs to one peer in 51 seconds. After the fix, the same peer receives a
maximum of 14 PINGs in 60 seconds.

Fork behavior

  • bootnodoor arms the ENR refresh at the next fork boundary. It does not wait
    for a timer of 60 seconds.
  • If a boundary is in the past, bootnodoor polls. Before this change it went
    to the next boundary, and the refresh was late.
  • A peer with two layers gets a new last-seen time on both layers.

Database

  • The nodes table and the bad_nodes table use the key (nodeid, layer).
    Before this change the key was nodeid only. Then a peer in two layers lost
    one layer at each restart. The file
    db/schema/20260729200000_node_layer_key.sql makes this change. bootnodoor
    runs the migration at start-up. A down migration is available, and a test
    covers it.
  • bootnodoor writes the nodes that it admits. Before this change it marked
    them, but it did not put them in the write queue.
  • bootnodoor empties the write queue when it stops.
  • bootnodoor waits between two batch failures.
  • bootnodoor keeps the dirty flags when a write does not complete.

Counters, the web UI and the responses

  • bootnodoor counts the active nodes and the inactive nodes as two sets. A
    subtraction gave a negative count of inactive nodes.
  • Each packet handler has a protocol label. Invalid Packets counts only the
    packets that bootnodoor cannot read. The packets of a different protocol go
    to the new Other Protocol counter, which the overview page shows.
  • The admission counters count a record only if the record has an entry for
    the layer.
  • The /debug/pprof/ pages are available only with the --pprof option.
    Before this change the pages were always available.
  • bootnodoor always sends a minimum of one NEIGHBORS packet. An empty table
    gave no packet, and the peer waited for its full timeout.
  • A NEIGHBORS record shows the TCP port from the ENR of the node. Before this
    change it showed a copy of the UDP port.
  • A NODES response has a maximum of 15 nodes in a maximum of 5 packets.
    go-ethereum reads 5 packets, and it removes the packets after these.
  • If the node map is full, bootnodoor removes one unbonded node. Then a new
    peer can make a bond. bootnodoor never removes a bonded node in this way.

New option

--serve-all stops the classification of the layers and the filter on the
fork ID. bootnodoor then puts each peer in all the tables that are available.

Log

Five messages for each peer are now at the debug level. The info level shows
the messages at start-up, the fork transition messages and the
lookup complete message. One of the five messages was a risk: a remote peer
could send packets that cause the message. To see the events for each peer,
use --log-level debug, or read the /metrics page.

Structure

The IP discovery code moves from discv5/ipdiscovery.go to
services/ipdiscovery.go. The --enable-ip-discovery option now controls it.
An explicit --enr-ip value always has precedence.

Tests

  • go build ./... gives no errors. go test ./... passes for all packages.
  • This pull request adds 25 files of tests. They cover the endpoint proof, the
    pending requests, the ENR refresh, the layer key, the migration in both
    directions, the fork boundary, the two layers, the dispatch counters and the
    --pprof option.

Devnet results

Two devnet series ran with 7 pairs of clients. The results are:

  • The three fork transitions each wrote one message. The ENR sequence went
    from 2 to 5. It made one step for each boundary.
  • The delay between the boundary and the new ENR was 2 seconds, 3 seconds and
    6 seconds.
  • The eth field and the eth2 field were the same bytes as the fields of
    geth and of Lighthouse, at Fulu and after BPO-1.
  • The blob count went from 9 to 12. The value of blobGasUsed went from
    0x120000 to 0x180000.
  • The packet rate stayed flat at each boundary. The captures lost no packets.
  • Invalid Packets stayed at 4. Inactive Nodes stayed at 0.
  • A test sent 55000 PINGs from 55000 different keys. The build before the last
    fix did not answer the FINDNODE of a new peer. This build answers it.
  • With --serve-all, 25 node IDs were in both layers. After a restart,
    bootnodoor read 27 rows and 26 rows from the database.
  • The log had no error message and no warning message.

MysticRyuujin and others added 30 commits July 29, 2026 08:06
Packet hashes alias across peers (deterministic signatures, 1s Expiration
granularity), so a response from one node could resolve a request sent to
another. Key pending requests by hash + destination node ID and hold a slice
per key so concurrent waiters don't orphan each other.

Reject a second in-flight FINDNODE to the same peer: NEIGHBORS carries no
reply token, so two cannot be told apart.

Harden ENRRESPONSE: require a matching pending request, require the record's
key to derive the sender's node ID, and install via seq-monotonic UpdateENR
so a replay cannot roll a node back to an older record.

Drop the unused map-returning Stats methods.
An IPv6-only record advertising udp6 but no udp was rejected outright, and
UpdateENR silently kept a stale address when the new record's family differed.
Extract udpEndpoint so both paths pair ip with udp and ip6 with udp6, falling
back to udp for dual-stack records.
…on New failure

Records without an eth entry are consensus nodes, not wrong-fork execution
nodes; counting them made the EL rejection counter track cross-layer traffic,
which on a dual-layer network is most of what arrives. Centralize the gate in
ENRManager.RecordELAdmission and mirror it in the CL filter, where every EL
node was inflating totalChecks.

New() now unwinds via a single deferred guard that also cancels the context:
a late failure previously leaked the NodeDB queue processors and handler
cleanup goroutines hanging off s.ctx.

Extract the two lookup admission closures and the repeated bootnode
table-add/persist block; reuse discv5/session.Stats instead of redeclaring it.
EncodeRLP had the signature ([]byte, error) instead of (io.Writer) error, so it
never satisfied rlp.Encoder. Every field of Record is unexported, so a nested
Record fell through to struct encoding and serialized as c0 -- go-ethereum
rejected our discv4 ENRRESPONSE with "record contains less than two list
elements". discv5 was unaffected because Nodes.Encode calls the byte-slice
encoder by hand.

Rename the byte-slice form to EncodeRLPBytes (symmetric with DecodeRLPBytes)
and add a real EncodeRLP. The receiver stays a pointer, unlike go-ethereum's
value receiver, because Record holds a mutex.

DecodeRLPBytes also now rejects payloads over 300 bytes, mirroring geth's
decodeRecord. encode() already enforced this outbound, but ingest wrote
straight to the raw cache, so an oversized wire record could be stored and
re-served -- reachable since relayed records became admissible.
Adds an opt-in rendezvous mode. With --serve-all the bootnode skips EL/CL
classification and fork-ID/digest filtering entirely: every discovered node is
pooled into every enabled table (regardless of eth/eth2 fields) and served to
every requester. Turns bootnodoor into a plain discv5 rendezvous, like a stock
geth bootnode. Default off; classification + fork filtering unchanged.

(cherry picked from commit c9aed7e)

Adapted to this branch, which refactored the paths the original patched:

- The two OnNodeFound closures are now admitELLookupNode/admitCLLookupNode and
  return services.AdmissionResult, so the guard moved into those methods.
- Gate checkAndAddNodeV4 too. The original missed the discv4 path, so
  discv4-discovered nodes stayed fork-filtered and were recorded as bad nodes,
  contradicting "every discovered node is pooled".
- Gate the three configured-seed paths. Fork-filtering a seed under serve-all
  can reject the only seed, leaving the table empty so discovery never starts.
- Skip the filters under serve-all instead of overriding their results. This
  branch records admission stats inside the filters, so calling them would move
  the counters for decisions never made and report rejections of nodes that
  were in fact admitted.
- Keep onNodeSeen's EL-xor-CL short circuit: computing both up front made the
  CL filter run for every EL node, drifting its counters in classified mode.

Also dedupe onFindNodeV5 results when serving both layers. A node can sit in
both tables, so the concatenated response returned it twice. Serve-all makes
that systematic, but it already affected dual-stack peers on a shared identity,
so the fix is not gated on the flag.

Known limitation: a lookup result is still admitted only to its own layer's
table. With separate EL and CL identities a peer found via one identity is not
served to the other's requesters until it is seen directly.
- elconfig: drop the duplicate crc32 walk (sums) and read checksums off
  allForkIDs; remove blockHead and the sentry special case, which cannot
  affect any validate() outcome under the static head stance. Verified
  equivalent by differential test across every schedule shape, time head
  and perturbed fork ID. Copy the slice out of GetAllForkIDs now that
  validate depends on it.
- clconfig: slices.Sort/Compact for the boundary walk; GetAllForkDigests
  projects from GetAllForkDigestInfos instead of duplicating it.
- stats: drop Discv5Stats.PacketsReceived/PacketsSent, summed but never
  read (the UI takes packet totals from transport metrics).
- lookup: slices.Contains for the local-identity check.
- Fork names: strings.ToUpper/Join instead of hand-rolled byte arithmetic
  that corrupts any name not starting a-z.
An off-path attacker could move the ENR this bootnode publishes, and a
bonded peer could reflect amplified NEIGHBORS traffic at a third party.
Both follow from the same gap: nothing proved a peer was reachable at the
address it claimed.

discv4 PONG: side effects (bond, external-IP vote, ENR refresh) ran before
any request match, so any well-formed PONG applied them. They now require a
match that is
  - source-bound: the PONG source IP must equal the address the PING was
    sent to. PendingRequest snapshots that IP at send time, because
    getOrCreateNode rewrites ToNode.Addr() from every inbound packet,
    including the spoofed one this check exists to catch.
  - PING-typed: getPendingRequests ignored PacketType, and peers know the
    hashes of packets we sent them, so an ENRREQUEST hash matched as a
    reply token.
  - consumed once: the entry was removed by the caller, so for the 500ms
    Ping() wait every replayed PONG re-cast the IP vote. Varying the
    spoofed source reached MinReports/MinDistinctIPs off one exchange.

discv4 bonds: tracked per proven IP rather than per node ID, so a bond
earned at one address no longer serves requests from another. Ports are
excluded, matching go-ethereum (checkBond passes ip.Addr(), dropping the
port) and avoiding a false negative when a NAT mapping rotates inside the
24h bond. Per-IP rather than a single address so dual-stack peers keep both
bonds. handlePing no longer grants a bond for merely receiving a PING: we
pong whatever source the packet claimed, which proves nothing, and that
was the remaining route to bonding a victim's address.

discv5: MatchResponse validated only requestID and node ID, so a PONG could
match and consume a pending FINDNODE, firing the IP-vote path and stranding
the lookup whose channel it closed. It now checks the response against the
stored request; handlePong returns early when unmatched.

IP discovery: consensus counted repeat reports from one peer as independent,
measuring independence only by spoofable source IP, so a single node ID with
three spoofed sources met both thresholds. Distinct reporter node IDs are now
required too, and the reporter key is the full ID rather than an 8-byte
prefix that let two peers count as one.

CL fork filter: Filter was both predicate and stats recorder, and ran on
per-packet classification paths, so the UI's Fork Filter card counted
packets rather than admissions. Split into a pure Matches and a counting
Admit over a shared classify, mirrored as ClassifyCLNode/AdmitCLNode and
ClassifyELNode/AdmitELNode; RecordELAdmission and its five-site paired-call
contract are gone. Renaming made every call site a compile error rather
than a silent behaviour change. classify holds the lock once, which fixes
a concurrent map read/write against Update() on oldForkDigests (an
unrecoverable abort, not an error) and makes TotalChecks always equal the
sum of the buckets.

Also: count FINDNODE responses after the unsolicited gate, not before.
Follow-up from review of c3d9e16. The endpoint proof was incomplete: the
recorded destination could differ from where the packet went, and the proven
address did not reach the code that consumes it.

- Ping/Findnode/RequestENR read Node.Addr() separately for the To endpoint,
  the pending request and the send. Concurrent receive workers rewrite that
  address between those reads, so DestIP could name an endpoint the request
  never went to: legitimate PONGs rejected, or a bond granted for an address
  that never received a PING. Each sender now captures the address once and
  addPendingRequest takes it explicitly, so the recorded and actual
  destinations cannot diverge.
- OnPongReceived now carries the proven address. The bootnode was re-reading
  from.Addr() for the IP-discovery source, which any later spoofed packet
  from the same identity rewrites, letting an unproven address count toward
  the distinct-source threshold.
- ENRRESPONSE and NEIGHBORS apply the same destination check as PONG.
  ENRRESPONSE additionally requires an ENRREQUEST: it matched on packet hash
  alone, so a peer could answer with the hash of some other packet we sent
  it and resolve the wrong waiter.

pendingFindnodeLocked stays address-agnostic; it enforces one in-flight
FINDNODE per peer, which is not an endpoint question.
…t ENR IP

EnableIPDiscovery defaulted to false and was read in exactly one place —
reconcileStoredENR's startup ip6-stripping branch. Nothing in the runtime
path consulted it: New built the IPDiscovery service unconditionally, both
OnPongReceived callbacks were wired unconditionally, and there was no CLI
flag at all. So the feature ran for everyone while claiming to be off.

updateENRWithDiscoveredIP also checked neither the flag nor ENRIPProvided,
so a bootnode started with an explicit --enr-ip had that address (and, on a
shared socket, its port) overwritten in the live ENR and republished once
peer reports reached consensus. reconcileStoredENR restored it on restart,
making the symptom a flip-flop rather than a permanent change.

- Default EnableIPDiscovery to true so current behaviour is preserved, and
  add --enable-ip-discovery to turn it off. Skipping construction is the
  whole enforcement: onPongReceived already returns early on a nil service.
- Refuse to move an address the operator set explicitly, per the contract
  Config.ENRIPProvided already documents and only startup honoured.

Behaviour change: with the default now true, reconcileStoredENR's
!EnableIPDiscovery branch stops firing, so a stored ip6 is retained at
startup instead of stripped. That is coherent — with discovery on, let it
correct the address rather than discard it — but it is a startup change,
not only a flag default.
…ng sessions

handleOrdinaryPacket located the session by the unauthenticated src-id header,
migrated its address before decrypting, and on decrypt failure deleted it and
challenged the packet source. An attacker who knew only a peer's public node ID
could therefore migrate then destroy that peer's session from any address, and
repeat it — permanent session denial, with in-flight requests dying on timeout.

Four routes closed:

- Address migration now happens only after DecryptMessage succeeds. AES-GCM over
  the header proves possession of the session key, and the source address is not
  in the AAD, so a NAT-rebound peer still migrates from its new address.
- Decrypt failure keeps the session. Recovery is a handshake and Cache.Put
  replaces the entry by node ID when it lands, so deleting bought nothing and was
  the whole DoS. The challenge still goes to the packet source: a peer that
  genuinely lost its keys sends a random packet, which by definition fails to
  decrypt, and its source is the only address it can be reached at. Answering at
  the session address would be a reflection primitive against the real peer.
- The GetByAddr fallback is gone. Sessions are keyed by sender node ID and every
  creation site uses the correct ID, so it never helped a correct peer — but it
  reached the same deletion without knowing the victim's node ID at all, by
  spoofing its IP:port with a random src-id, and was an O(n) scan over up to 1000
  sessions driven by unauthenticated traffic.
- A forged WHOAREYOU no longer replaces a live session. Not deleting was
  insufficient: with a request in flight, recovery derives fresh keys and Put
  overwrites the session before the remote proves anything, leaving a session
  present but unreadable by the real peer. Sessions now remember the nonces of
  recent ordinary packets they sent (bounded, 16) and a challenge quoting a nonce
  we never sent is dropped.

Also: Session.RemoteAddr becomes remoteAddr behind an Addr() accessor, fixing an
unlocked read racing UpdateAddr; and Session.String() no longer holds the read
lock across Age()/IdleTime(), which take it again.
…on it

PendingRequest recorded no address, and all three AddRequest callers re-read
n.Addr() for the send, so the recorded request and the transmitted destination
came from two separate reads of state that a newer ENR can move — including one
supplied by the peer being verified. This is the divergence already fixed on the
discv4 side in 6dd2f08.

AddRequest now takes the destination explicitly and each caller captures n.Addr()
once for both the record and the send. MatchResponse returns the matched request
instead of a bool so callers can reach DestAddr.

handlePong gates the OnPongReceived IP-discovery vote on the PONG's source
matching that destination by IP. A session peer can send a correctly typed,
correctly encrypted PONG from a forged source, and that source is what feeds the
distinct-reporter threshold. Response delivery stays source-agnostic so NAT
rebinding and mobile peers keep working — only the vote needs the endpoint
proven, which is why the check sits at the side effect and not in the match.

handleNodes and handleTalkResp continue to ignore the result; their delivery
semantics are unchanged.
getOrCreateNode rewrote a known node's canonical address from every inbound
packet's source, before any handler checked expiration or solicitation. That
address is what every sender reads and what sendNeighbors republishes, and
nodes.NewFromV4 stores the handler's own object — so a peer could name a third
party in NEIGHBORS at an address of its choosing and we would republish it to
every FINDNODE querier. HandlePacket also refreshed liveness and fired
OnNodeSeen before validation, and OnNodeSeen can emit PING/ENRREQUEST traffic.

- lookupOrCreateNode uses the address only when creating an unknown node, and
  is now read-locked on the hit path. handleNeighbors uses it too, so a claimed
  record can no longer move a third party's address.
- promoteAddr installs the address, called only from handlePong for the
  destination a matched PING was sent to. It promotes req.ToNode as well as
  fromNode: ping/lookup build v4 nodes ad hoc, so the object a sender read can
  differ from the one the handler resolved.
- noteSeen/noteProven split the pre-dispatch work. noteSeen runs in every
  handler right after the expiration check — liveness is identity-scoped and the
  signature authenticates the identity, and withholding it would evict a peer
  that is actively signing packets but whose bond has lapsed, which then returns
  with no proven addresses at all. noteProven adds OnNodeSeen and sits behind
  each handler's proof gate.

handlePing's reciprocal PING now targets the source it just ponged rather than
the canonical address. Without this the split blackholes any peer that moves: it
would be pinged only at its old address, never answer, never bond, and be
refused service permanently. This is not a new lever — PINGs are signed, so an
attacker can only ping as themselves, and a spoofed source yields one PONG plus
one rate-limited PING at the victim, neither larger than the trigger.

Scope: this migrates the handler's node, not the routing table's copy.
nodes.Node stores addr as a snapshot taken at admission and has no production
SetAddr caller, so table-driven pings keep using the frozen value exactly as
before. Fixing that needs nodes/iplimit.go re-keyed, since it counts per IP at
Add time; tracked separately.

Adds discv4/protocol/handle_packet_test.go — the first coverage HandlePacket has
ever had, including the moved-peer regression test.
- Cache.Put evicted whenever the map was full, without checking whether the key
  already existed. fa30b68 made that reachable: handshake recovery now retains
  the stale session instead of deleting it first, so replacing it at capacity
  evicted an unrelated live peer and left the map one short. Only evict when the
  Put actually grows the map. Adds the first tests for discv5/session.
- handleENRRequest called neither noteSeen nor noteProven, so accepted
  ENRREQUEST traffic stopped refreshing LastSeen and never re-admitted the peer,
  letting an actively communicating node age out. It now matches every other
  handler: noteSeen after the expiration check, noteProven after the bond gate.
- Widen the sent-nonce window from 16 to 64. Too small silently drops a
  legitimate peer's restart recovery until its next packet; the window only has
  to cover what we can send to one peer within a request lifetime.

Noted, not changed: GetPendingRequestForNode picks an arbitrary pending request
for the peer, so with concurrent requests in flight recovery can replay one the
challenge did not refer to. That predates these commits — the nonce check gates
whether recovery runs at all, not which request it carries.
- discv4 noteProven called noteSeen, but every call site already did, so
  IncrementPacketsReceived double-counted on most packets. It now only fires
  the callback, which is the decision it actually owns.
- discv5 SendMessage and SendMessageFrom were 122 near-identical lines, and the
  RecordSentNonce fix had to be pasted into both. SendTo(data, to) is literally
  Send(data, to, nil), so SendMessage delegates. ~120 lines gone and the sent-
  nonce invariant has one home.
- Delete discv5/ipdiscovery.go: an unreferenced, diverged copy of
  services/ipdiscovery.go, missing the distinct-reporter hardening and taking no
  reporter IP at all. The spoofable version of a function just hardened.
- Delete getPendingRequests: no production caller, and the one lookup helper
  with no type or destination binding, i.e. the shape the rest of this work
  exists to remove. The two tests use pendingRequestsFrom.
- Delete four callerless ForkDigestFilter accessors and an unreachable
  outcomeNotCL switch arm; drop the packetHandler alias for proofHandler; make
  nodes.ENR delegate to Record rather than repeat it.

Efficiency, both measured on the paths they sit on:
- enr.EncodeRLPBytes took the write lock to read its cache, costing 8.6x under
  contention at 10 cores. A bootnode serves the same records to every requester,
  so those are exactly the contended ones. Read path now takes RLock.
- discv5 compared session and packet addresses via String() on every
  authenticated packet: 77ns/6 allocs against a 271ns decrypt. Now compares
  IP and port directly, 1.9ns/0 allocs.

Also trims comments: the "unauthenticated source must not become canonical"
invariant was written out four times; it now lives on lookupOrCreateNode with
the others pointing at it.
73aff56 replaced sess.Addr().String() != from.String() with an explicit IP and
port comparison to avoid two allocations per authenticated packet, but dropped
UDPAddr.Zone. Cache.GetByAddr still matches on the full address string, so for
scoped IPv6 the two could disagree: a peer that changed interface would not
migrate here, then miss GetByAddr on the WHOAREYOU path and fail to recover.

Comparing Zone restores exact equivalence with the old String() form and is
still allocation-free.
checkAndAddNode admits EL and CL independently, and each admission builds its
own nodes.Node wrapping the same v5 node — two objects, two last-seen fields.
onNodeSeen classified EL-xor-CL, so for a record carrying both eth and eth2 only
the EL copy was ever refreshed. The CL copy's last-seen stayed at the zero time
from admission onwards, making it look infinitely old to the CL table's
age-based sweep while the peer was actively talking to us.

The xor existed to keep the counting filter off the per-packet path. Classify is
pure since the Classify/Admit split, so both layers can be evaluated with no
counter effect and ~19ns for the second call.

Test asserts both tables' last-seen advance; with the xor restored the CL side
reads 0001-01-01.
Review of 165af25 was right that the test proved less than claimed, and that
the commit message overstated the bug.

NewFromV5 ends in v5.SetStats, so the v5 node shares stats with whichever
wrapper was constructed last — CL, since checkAndAddNode builds it second — and
handleMessage calls SetLastSeen on the v5 node before OnNodeSeen. So on a freshly
admitted peer the handler already refreshes the CL copy for free, and the "CL
last-seen stays at the zero value" result came from the test calling onNodeSeen
without the handler's preceding SetLastSeen.

The staleness is real but narrower: a re-admission (an ENR refresh, say) builds a
fresh wrapper, FlatTable.Add keeps the entry it already has and discards the new
one, but SetStats has already repointed the v5 node at the discarded copy. From
then on the table's own object is refreshed only by onNodeSeen, so classifying
one layer and not the other strands the other.

The test now re-admits, then applies SetLastSeen and onNodeSeen in the order the
handler produces them, and seeds a known admission timestamp so a regression
shows a stale time rather than a zero one. With the xor restored the CL entry
reads its admission time while the refresh is an hour later.

The fix in 165af25 is unchanged and still correct.
Rows in both tables are per node and layer, but nodeid was the sole primary
key, so a dual-layer peer's second write replaced its first while every read
filtered on (nodeid, layer). Nodes lost a layer on restart; bad_nodes lost the
suppression that stops repeated ENR requests.
FlatTable.Add marked nodes dirty but never enqueued them, so organically
discovered nodes lived only in memory and were lost on restart. The queue
consumer also abandoned its channel backlog on context cancellation, and Stop
cancels before closing, so Close now refuses new work and flushes what is left.

The full upsert wrote last_active as NULL while its branch cleared the
DirtyLastActive that admission had just set, leaving configured bootnodes
sorting as the most inactive rows.
TotalNodes came from the database and ActiveNodes from memory, so the six
consumers subtracting them reported more active than total and a negative
inactive count whenever a write had not landed yet. GetStats now derives the
union and the difference directly.
…ones

discv5 registers first and rejected anything it could not decode, so every
ordinary discv4 packet on the shared socket was counted invalid before the
transport re-dispatched it: 17749 of 21140 received in a devnet run. Only the
dispatcher knows the final outcome, so it counts fallthrough and unhandled
separately and the UI reports both.
A PONG advertising a newer sequence spawned an unguarded RequestENR, which
always PINGs and sleeps 500ms before the ENRREQUEST. Its PONG re-entered
handlePong with the cached sequence still stale, so refreshes multiplied at RTT
speed: a scripted peer saw 25433 PINGs and 18662 ENRREQUESTs in two seconds,
matching a devnet capture of ~4000 cycles across two peers during a fork.

The claim is taken at the trigger, not inside RequestENR, because a goroutine
descheduled past the winner's release would otherwise become a new winner. A
sequence observed mid-refresh still earns one more round; failures retry twice
with backoff. RequestENR itself is unchanged, so the lookup path is unaffected.

Also makes the service-level guard atomic: Load-then-Store let two callers
through, and delete-on-completion could drop a later claimant's entry.
The refresh ran on a free-running one-minute ticker, so the advertised eth/eth2
fields kept the previous fork for up to a full period after activation —
8s, 20s, 22s and 51s across six devnet transitions. It now waits for the next
scheduled boundary, with a one-minute reconciliation tick as the backstop for a
missing schedule, a clock jump, or a boundary passed during startup.

Boundaries come from the raw fork epochs rather than GetAllForkDigestInfos,
which deduplicates by digest: the eth2 next-fork tuple changes at a boundary
even when the current digest does not.
- batchUpdate cleared every dirty flag after writing, discarding any flag marked
  while the write was in flight: that caller saw the node already queued and did
  not enqueue it again. Clear only the observed flags and requeue the remainder.
- The full upsert's conflict clause never assigned last_active, so an existing
  row kept a stale or NULL timestamp. COALESCE so a caller without one cannot
  blank a stored value.
- A peer advertising a higher sequence in every PONG could re-arm the ENR
  refresh indefinitely, holding a goroutine and its traffic until shutdown.
  Bound the rounds one claim may run; a later PONG opens a fresh claim.
- The fork overflow guard divided by a product that could itself wrap to zero,
  panicking the maintenance goroutine. Check each multiplication first.
- Default the refresh backoff when ExpirationWindow is unset, so a retry is
  delayed rather than immediate.
- Clearing the observed flag bits could still drop a same-bit re-mark made while
  the write was in flight, since the new mark is indistinguishable from the one
  written. Snapshot a generation alongside the flags and skip clearing entirely
  when it moved.
- The refresh round bound was per claim, so a peer could send one PING, take the
  reciprocal PING's higher-sequence PONG, and open a fresh four-round claim
  immediately. Exhausting the rounds now starts a cooldown.
- The signed-range check subtracted an unsigned offset from MaxInt64, which wraps
  for an offset past MaxInt64 and let an unrepresentable timestamp through.
- The queue-set entry was removed after the flags were cleared, so a caller that
  marked the node in between had its QueueUpdate swallowed as already-queued and
  then the entry deleted underneath it. Clear after removal instead.
- Bounding rounds per claim did not bound the rate: a peer could advertise one
  increment per claim, finish in a single round, and reopen on the next PONG.
  Every completed claim now starts a minimum interval, with the longer cooldown
  reserved for an exhausted one.
- A bump observed during a cooldown was remembered but never fetched unless
  another PONG arrived later. The cleanup tick now resumes those.
A failed per-field update, and a transaction that failed at commit, both still
had their snapshots cleared as if persisted, so the ENR, stats or timestamp was
silently lost until an unrelated change happened to mark the node again. Track
per-node write failures, discard the processed set entirely when the commit
fails, and requeue anything not confirmed written.
Requeueing unwritten nodes keeps data from being lost, but on a persistent
database error the requeue refills the batch and the next pass runs immediately.
batchUpdate now reports whether it committed, and the consumer sleeps
proportionally to the run of failures, capped at five seconds.
Row-level write errors leave the transaction callback returning nil, so a
committed transaction was reported as success and reset the backoff even though
the failed nodes had been requeued — retrying every 10ms. A batch now counts as
successful only when every node in it was persisted.
MysticRyuujin and others added 20 commits July 29, 2026 18:28
…next

nextForkBoundary only considers activations strictly after now, so a refresh
firing on the boundary before the new digest is computable found no change and
then armed for the following boundary — a full backstop minute away. A devnet
BPO transition took 75s that way, worse than the ticker it replaced.

The refresh now polls every second for 90s after any boundary, so the lag is
bounded by the poll interval rather than by where the backstop chain happened to
land.
The composite-key change had no end-to-end coverage: no real peer advertises eth
and eth2 together, since EL and CL run as separate identities with separate keys,
so no devnet topology produces one. Serve-all is the reachable path — it pools
every discovered peer into every enabled table, so under it an ordinary CL-only
node occupies both layers and every peer hit the collision.

Verified against the old key: only one row persists and the reload fails.
…metrics

- probeV5Support ran inline in the lookup admission path and each probe waits a
  request timeout, so a 16-node NEIGHBORS response stalled the maintenance loop
  for over a minute. It now runs after admission, bounded to 8 in flight, and
  resolves the table entry so the result lands on the object the table kept.
- FlatTable.Add refreshed only a newer ENR for a known node, so a peer admitted
  as v5-only by a completing handshake lost its v4 pointer when the v4 admission
  followed. Protocols are now merged into the existing entry.
- Two discv5 identities share a socket and each rejects the other's packets. That
  is identity demultiplexing, but it was counted and displayed as other-protocol
  traffic. Handlers now carry a protocol label and only a real protocol change
  counts.
- The aggregate ping RTT averaged the per-identity averages; it is now weighted by
  each identity's pong count.
- Reset left the two dispatch counters untouched.
- The probe decision read the incoming v4-only wrapper, so a node the table
  already knew to be v5 was re-probed on every rediscovery and could occupy all
  the slots that genuinely v4-only nodes needed. The table entry decides now, and
  one probe per node is in flight at a time.
- Resolving the entry before the ping did not close the wrong-object window: the
  entry can be swept or replaced during the round trip. The result is applied to
  whichever wrapper the table holds when the answer arrives.
- The merge adopted a protocol pointer without checking record freshness. Senders
  use that pointer's own address, so an older record would aim the protocol at an
  endpoint the peer has left; adoption now requires a record at least as new.
Adoption only ever fills an empty slot, so a pointer installed from a stale
record is permanent. The freshness check and the install ran as two separate
operations, letting a concurrent admission advance the entry in between and the
older pointer land anyway. AdoptProtocolsFrom now does both under one hold of the
node's lock.

The v5 probe had the same shape: the probed node was built before the round trip
and applied after it, so an ENR that advanced while the ping was outstanding left
v5 traffic aimed at the address that happened to answer — and HasV5 then
suppressed any corrective probe. SetV5AtSeq discards a result whose record is no
longer current.
Splitting adoption from ENR advancement left them interleavable however each was
guarded individually: a newer admission could adopt, an older one install its
pointer against the still-old record, and the newer one then advance the record,
stranding an endpoint UpdateENR does not refresh for v4. AdoptProtocolsFrom now
does both under one hold and reports each outcome.

Self-merge is also a no-op now. Re-admitting a table entry passes it to itself,
and snapshotting its own pointers before taking the lock could reinstall a
protocol a concurrent clear had just removed.
Adoption only filled empty slots, so a pointer taken from an early record was
permanent: a later record advanced the ENR but could not replace it, and the
table went on serving that endpoint. UpdateENR refreshes the v5 node, but nothing
refreshes a v4 node's address, so the stale one persisted.

Each pointer now records the sequence it came from, and adoption replaces one
sourced from an older record. Writing the record's claimed address into the v4
node instead would undo the endpoint-proof work: the table shares that object
with the discv4 handler, where only a matched PONG may move an address.
The pointer was stamped with its carrier's sequence, so a pointer built from an
older snapshot was marked current and no later admission could replace it. The
label now comes from the record the pointer itself carries.

A discv4 node created from an enode has no record, so it falls back to the
carrier's sequence — without that every such pointer would be labelled 0 and
become unreplaceable, which a regression test caught.
An ENR sequence orders signed records. It does not order endpoint proofs, and the
per-pointer sequence labels added here treated them as one thing: a discv4
pointer's ENR can advance while its proven address deliberately stays put, so
labelling it by record left a proven-old address marked current and unreplaceable.
Each attempt to patch that produced another provenance edge case.

The table does not arbitrate endpoint freshness. checkAndAddNodeV4 hands the
handler's own node to the table, promoteAddr moves that object's address on a
matched PONG, and onFindNodeV4 serves the pointer — so the served address already
follows proof without adoption doing anything. Adoption fills an empty slot, which
is all the original v4-capability loss ever needed.

Also here: ApplyProbeResult, so an asynchronous probe applies both protocol
outcomes under one lock hold and only while the record it measured is still
current; nil-safety in UpdateENR; SetV5AtSeq validating the pointer's record as
well as the carrier's; the sequence reads hoisted out of n.mu so it never nests
with a protocol node's mutex; and the removal of SetAddr, which had no callers.

Part of this reached the branch as an unreviewed tool-generated commit. That has
been folded in here, reviewed, and mostly reverted.
CheckProtocolSupport snapshotted the record before probing and then installed
pointers rebuilt from that snapshot. It needed no concurrency to go wrong: on a
successful v4 ping it fetched a fresh ENR and advanced the node's record itself,
then discarded the refreshed probe object and rebuilt from the record it had just
superseded. The two clear paths were unguarded as well, so a probe that started
before a newer admission could remove the pointer that admission installed.

The probe objects themselves are now installed, and the fetched ENR is applied
only after ApplyProbeResult so the sequence the install is gated on cannot move
underneath it. It is attached to the object that was probed rather than to
whatever V4() returns by then, which a concurrent admission may have replaced.
Covers what only a devnet reaches — a fork activating under load, real peers,
wire evidence, and restart against a populated database — since every serious
defect found in this component came from one of those rather than the unit suite.

Includes the discv4 packet-size table and the shared-netns tcpdump recipe for
telling who originated traffic, the --serve-all standalone setup that is the only
way to exercise the composite key against real peers, and the harness traps:
WAL sidecar files, Kurtosis reassigning host ports on restart, hyphenated fork
names breaking naive scraping, and no extra-args hook for the packaged service.
An empty routing table previously produced a silent (zero-packet) response, so
go-ethereum's querier waited out its full request timeout instead of returning
early on the first reply. Always send >=1 NEIGHBORS packet. Also advertise the
node's real TCP port from its ENR instead of copying the UDP port.
go-ethereum honours only the first NODES packet's total and reads at most 5
packets; sigp/discv5 caps at 16 nodes. Cap the served set at 15 nodes / <=5
packets so no served node is silently dropped by a requester.
… is full

lookupOrCreateNode previously returned a non-retained node once the map was full,
so under a flood of distinct signed IDs a genuinely new peer's inbound PING marked
bond state on a discarded object and could never bond (memory-growth DoS turned
into a bonding-lockout DoS). When full, evict one unbonded entry to admit the new
node; bonded, endpoint-proven peers are never evicted this way.

Adds regression tests for this and the two preceding NEIGHBORS fixes.
…ap flood eviction

discv4/discv5 remainders on develop: NEIGHBORS, NODES cap, node-map flood eviction
At mainnet scale these scale with client population rather than with time,
and the unexpected-WHOAREYOU line is remotely triggerable. info now carries
startup, fork transitions and periodic aggregates only.
Kept as local notes; not repo content.
@pk910
pk910 merged commit bc7b750 into master Jul 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants