glamsterdam-devnet-9 · incident report

Gossip degradation is CPU exhaustion, not peer-table poisoning

P1 — Network-wide degradation
Component p2p gossip / all consensus clients Observed 2026-09-03 09:00–09:40 UTC Network glamsterdam-devnet-9 (chain 7013099983) Fork Gloas @ epoch 225

We tested the hypothesis that the 312 nodes stuck on the pre-Gloas chain are poisoning healthy nodes' peer tables and causing the fleet's attestation-publish failures. It is false. Peer scoring is working across every client: against a 30.9% fleet baseline of stuck nodes, healthy nodes hold 0.6–9.2% stuck peers. The host with the worst peer table in our sample (teku-geth-3, 39.8% stuck) has zero attestation failures, while the host with the most failures (teku-geth-1, 780) has a clean 4.6%. What actually predicts attestation-publish failure is CPU saturation (r = +0.93). Separately, we resolved the fork-digest question: the stuck nodes advertise the correct Gloas digest because compute_fork_digest is derived from the wall clock, not from the node's chain head — so no fork-digest configuration could ever have partitioned them.

What we observe

Peer-table composition — 18 hosts, 4 consensus clients

Every connected peer's IP resolved back to the devnet inventory via /eth/v1/node/peers?state=connected. A node performing no pruning would sit near the fleet baseline of 30.9% (312 stuck of 1009 hosts).

Consensus clientHostsPeer slots Held by stuck nodesShareMedian connected
teku6521489.2%90
lodestar4795698.7%198
nimbus4594203.4%155
lighthouse481250.6%203
fleet baseline (no pruning) 30.9%

Every client is far below the no-pruning baseline. Peer scoring is successfully purging nodes that never relay. Lighthouse is the most aggressive — lighthouse-besu-1 reports 201 connected against 498 disconnected, i.e. active rejection.

Per-host detail

HostCLConnectedStuck SharePublish failures (2 h)
teku-geth-3teku933739.8%0
teku-reth-2teku9955.1%86
teku-geth-1teku6534.6%780
teku-besu-11teku10022.0%281
teku-nethermind-1teku7611.3%0
teku-geth-12teku8800.0%81
lodestar-geth-2lodestar1982311.6%0
lodestar-geth-1lodestar1992010.1%0
lodestar-reth-1lodestar196157.7%0
lodestar-besu-1lodestar202115.4%0
nimbus-geth-5nimbus15485.2%6
nimbus-besu-1nimbus15763.8%28
nimbus-geth-1nimbus15642.6%299
nimbus-reth-1nimbus12721.6%271
lighthouse-besu-1lighthouse20531.5%0
lighthouse-nethermind-1lighthouse20421.0%0
lighthouse-geth-1lighthouse20200.0%0
lighthouse-reth-1lighthouse20100.0%0

Attestation-publish failure rate by client, last 2 h

ClientFailedSucceeded Failure rateHosts affected
teku7,68536,09117.56%37
nimbus25,9821,271,0412.00%71
lighthouse0516,7050.00%0
lodestar014,6580.00%0

Root cause analysis

The fork-digest question — resolved, and it is not a misconfiguration

Both stuck and healthy nodes advertise fork digest 0x6ebb965e. We derived what that digest actually is. The devnet's fork versions are correctly distinct (FULU_FORK_VERSION: 0x70124218, GLOAS_FORK_VERSION: 0x80124218), and with genesis_validators_root = 0x064e977b… the base digests are:

FULU   0x70124218 -> base fork_digest 0xae929e67
GLOAS  0x80124218 -> base fork_digest 0x20523e64

EIP-7892 blob-parameter digest, BLOB_SCHEDULE {EPOCH: 0, MAX_BLOBS_PER_BLOCK: 21}:
  sha256(serialize(BlobParameters(0, 21)))[0:4] = 0x4ee9a83a

  0x20523e64 XOR 0x4ee9a83a = 0x6ebb965e   <-- exactly the digest seen on the wire

So 0x6ebb965e is the Gloas digest. The stuck nodes — whose chain head is slot 7199, three slots before the fork — are correctly advertising the post-fork digest. That is not a bug and not a devnet misconfiguration: per the p2p specification, compute_fork_digest takes the fork version for the current wall-clock epoch from the fork schedule, not the fork version of the node's own head. A node whose clock reads epoch 380 computes the Gloas digest whether or not it managed to process the fork.

Structural consequence

Fork-digest isolation partitions nodes running a different fork schedule — a stale binary or a wrong config. It cannot partition nodes that share the schedule and fail to execute the transition. No configuration change to this devnet would have automatically fenced off the stuck nodes. Application-layer peer scoring is the only mechanism that can, and — per the table above — it is doing its job.

The poisoning hypothesis is falsified

If stuck peers occupying gossip slots caused publish failures, stuck-peer share should predict failures. It does not. Across six Teku hosts:

r(stuck-peer share, publish failures) = -0.271   (Spearman +0.143)
r(CPU%,             publish failures) = +0.931   (Spearman +0.943)
r(CPU%,             stuck-peer share) =  -0.486   (Spearman -0.086)

The single cleanest refutation: teku-geth-3 holds 39.8% stuck peers — by far the worst peer table we measured — and logs zero attestation-publish failures. teku-geth-1 holds a healthy 4.6% and logs 780. All four Lodestar hosts carry 5–12% stuck peers and log zero failures.

We also tested the coordinating hypothesis that overload prevents peer scoring from running, making a poisoned table a symptom rather than a cause. That is not supported either: the worst-pruning host (teku-geth-3, 39.8%) has the lowest CPU of the six Teku hosts at 84%, and r(CPU, stuck-peer share) is negative. teku-geth-3's peer table is anomalous, and on this evidence it is also harmless. We could not explain it and we are not going to guess.

What actually drives the failures: CPU saturation

HostBeacon CPUStuck-peer share Publish failures
teku-geth-1761%4.6%780
teku-besu-11584%2.0%281
teku-reth-2281%5.1%86
teku-geth-12263%0.0%81
teku-nethermind-1209%1.3%0
teku-geth-384%39.8%0

The ordering is monotonic in CPU and unrelated to peer composition. This is consistent with the sibling Teku report's finding of heap pressure and a wedged tick scheduler: a beacon node that cannot keep up with the slot clock misses the publish deadline. The gossip mesh is a victim here, not the cause. Peer count is a plausible secondary factor — every host with ≥196 connected peers logs zero failures, and Teku's 65–100 peers must cover 64 attestation subnets — but our sample cannot separate low peer count from high CPU, since both track the same client.

Nimbus, investigated independently

Nimbus's failures are real but were mis-scaled in the original framing. The exact string count over 2 h is 25,982 Attestation not sent across 71 of 80 hosts — but against 1,271,041 successful Attestation sent, a 2.00% failure rate. The error is named explicitly and is not the same shape as Teku's:

NTC 2026-09-03 07:22:16.601+00:00 Attestation not sent topics="message_router"
  attestation="(committee_index: 4, attester_index: 2863700, data: (slot: 11811, index: 1,
  beacon_block_root: 35ca5a1f, source: 224:a8ce3d31, target: 369:35ca5a1f), signature: b0d8e79c)"
  error="No peers on libp2p topic"

Nimbus peer tables are clean (1.6–5.2% stuck) and Nimbus CPU is low (3.6–12%), so neither of the two mechanisms above explains it. Nimbus runs a median of 155 connected peers against Lighthouse's 203; with 64 attestation subnets, a thinner peer set raises the probability that some subnet momentarily has no mesh peer. We flag this as the leading hypothesis but did not confirm it — confirming it needs per-subnet mesh-size sampling over time, which we did not run. The 2.00% rate makes this a low-priority item regardless.

Impact, and a correction to the published participation arithmetic

The incident report stated: 343,000 validators offline = 34.3%, a 65.7% ceiling against observed 55–63%, with "the residual gap is Teku's sick hosts". The first half stands; the attribution of the residual was wrong and is only partly resolved.

Quantifying the gossip contribution from the measured rates:

SourceValidators affectedShare of active set
Verified offline (stuck / down / OOM-looping)343,00034.3%
Teku publish failures (17.56% of ~126,000 live Teku validators)~22,000~2.2%
Nimbus publish failures (2.00% of 80,000)~1,600~0.16%

That revises the ceiling from 65.7% to roughly 63.3%. Observed participation over 16 sampled epochs averages 56.4% (range 41–63%), so the top of the observed range now matches the model but the mean does not. Testing the remaining gap against on-chain inclusion capacity — only 17.5 of 32 slots produce a block on average, and participation is measured from attestations included in blocks — gives:

Pearson r(blocks proposed, participation%) = +0.546   over n=16 epochs
blocks proposed: mean 17.5/32, range 11-23
participation:   mean 56.4%,   range 41-63%

A moderate positive correlation, explaining roughly 30% of the variance. So reduced block production is a real contributor but not a complete explanation. The residual is partially unresolved and we say so rather than assigning it. Attestation loss from the missing 40% of slots, inclusion-distance effects during the leak, and per-epoch committee-shuffling variance are all plausible and untested.

The remediation implication changes too. Because peer scoring is already isolating the stuck nodes effectively, stopping them would not measurably improve the health of the 370 nodes that crossed the fork. The original argument for stopping them early does not survive this measurement. Fixing Prysm remains the only action that restores finality.

How to verify / reproduce

# 1. Peer-table composition on any node: resolve peer IPs against the inventory
curl -s localhost:5052/eth/v1/node/peers?state=connected \
  | jq -r '.data[].last_seen_p2p_address' \
  | grep -oE '/ip4/[0-9.]+|/ip6/[0-9a-f:]+'
# cross-reference against ansible/inventories/devnet-9/inventory.ini
# a node with no pruning sits near 30.9%; all measured clients sit at 0.6-9.2%

# 2. Confirm the digest is Gloas, not a stale pre-fork digest
python3 -c "
import hashlib,struct
g=bytes.fromhex('064e977bb09f5fe8ef114bdca09d8ccec07c412c4d41b6879c4c7207d9780073')
base=lambda v: hashlib.sha256(bytes.fromhex(v)+b'\x00'*28+g).digest()[:4]
blob=hashlib.sha256(struct.pack(' 6ebb965e

# 3. Failure rate per client (not raw counts - the raw counts mislead)
#    external.otel_logs, position(Body,'Attestation not sent')>0
#                    vs position(Body,'Attestation sent')>0

# 4. The falsifying pair
#    teku-geth-3: 39.8% stuck peers, 0 failures
#    teku-geth-1:  4.6% stuck peers, 780 failures

Methodological warning. A substring search for not sent or a regex alternation containing subnet matches Nimbus's successful Attestation sent lines, because the full log line contains the word subnet further along. That inflates the apparent failure count by roughly 50×. Match the literal string and always compute a rate against the success count.

Recommended action

  1. Do not stop the stuck nodes on gossip-health grounds. Peer scoring is already isolating them and we measured no harm to healthy nodes. Fix Prysm; that is what restores finality.
  2. Treat Teku's attestation failures as the CPU/heap problem they are — see the Teku report. They are not a p2p problem and a peer-level fix will not help.
  3. Do not expect a fork-digest change to fence off clients that fail a transition. The digest is clock-derived by design. If operators want automatic isolation of nodes that fail a fork, that needs a new mechanism, not a config change.
  4. Consider raising Teku's and Nimbus's peer targets on large-subnet-count networks. Every host we measured with ≥196 peers logged zero publish failures; Teku defaults to ~90 and Nimbus to ~155 here, against 64 attestation subnets. This is a hypothesis worth a controlled test, not a confirmed fix.
  5. Investigate Nimbus's 2.00% No peers on libp2p topic separately and at low priority, with per-subnet mesh sampling.

Evidence appendix

Stuck-node population

312 nodes at head slot 7199 (last pre-Gloas slot; fork epoch 225 = slot 7200)
  prysm     307
  bootnode    4   (all four bootnodes run Prysm)
  grandine    1
fleet: 1009 hosts -> 30.9% baseline

On-the-wire digest, from a stuck Grandine node's Status exchange

sending  Status ... fork_digest: 0x6ebb965e, head_slot: 7199,  earliest_available_slot: 7136
received Status ... fork_digest: 0x6ebb965e, head_slot: 12209, earliest_available_slot: 0
      identical digest; the handshake cannot distinguish them

Nodes observed emitting the Gloas digest, last 90 min

teku       290,630 lines  41 hosts
grandine    39,922 lines  10 hosts   <- stuck pre-Gloas
prysm        1,574 lines  22 hosts   <- stuck pre-Gloas

Fork schedule (network-configs/devnet-9/metadata/config.yaml)

FULU_FORK_VERSION:  0x70124218   FULU_FORK_EPOCH:  0
GLOAS_FORK_VERSION: 0x80124218   GLOAS_FORK_EPOCH: 225
BLOB_SCHEDULE:
  - EPOCH: 0
    MAX_BLOBS_PER_BLOCK: 21