Devnet engineering teardown

Four client bugs, taken apart

How each was found in 2.7 billion log lines, why each one actually matters, and why the fix is safe — from a weekend on glamsterdam-devnet-8, 28–31 August 2026.

Companion to the weekend incident report · post-Gloas / ePBS · 94 nodes · 12 s slots · 85,265 validators

The thesis of this document: none of these four bugs was found by reading code. Every one was found by noticing that two independent sources disagreed about reality — and then refusing to let the disagreement go. The code reading came afterwards, to explain a fact already in hand.

That ordering matters, because three of the four were invisible from inside the client that had the bug. A client cannot log a mistake it does not know it is making.

Contents
  1. The discovery pipeline — how you get from 2.7B log lines to four bugs
  2. geth — a panic that costs 11,000 blocks
  3. nimbus-eth2 — one bad answer, poisoned forever
  4. nethermind & besu — announce what you will serve
  5. Ground truth: what the spec should say
  6. Why each fix is safe
  7. What generalises

1 · The discovery pipeline

The fleet emitted 2.70 billion log lines over the window. You cannot read that, and you cannot grep it usefully either — 83 % of it is one client on a debug build, and 49 % of all execution-layer lines are two clients logging user RPC errors at WARN. The signal came from a narrower path:

Chain data missed & orphaned slots 2.7B log lines otel_logs Prometheus 92 targets Contradiction A up=1, but zero blocks 20 h Contradiction B peers drop, discovery healthy Contradiction C process dies, no bad block Read the source at the DEPLOYED commit, not HEAD 4 bugs
Every one of the four started as a contradiction between two sources that should have agreed — not as a suspicious-looking function.

Three traps that would have produced wrong answers

Worth stating plainly, because each one silently returns a plausible result:

TrapWhat it doesWhy it matters
docker inspect .State.OOMKilled Reports false for real OOM kills whenever memlimit=0 Those are global, not cgroup, OOMs — Docker never sets the flag. Two real kills read as clean exits
Log severity fields 8,807 of 8,807 rows tagged SeverityText='error' for one client were actually DBG lines The collector matched the word “error” in the message text, not the level
Analytics payload_present NULL on every post-Gloas row, so countIf(payload_present = 0) returns 0 Reads as “no missed payloads”. There were 53

The general shape: a check that cannot fail is not a passing check. Before trusting a zero, confirm the field can ever be non-zero.

2 · geth — a panic that costs 11,000 blocks

core/filtermaps · process-fatal

How it was found

A crash census across the fleet turned up 13 process-fatal aborts, and 9 of them were the same panic on one node out of fifteen — the one running a block builder under sustained transaction load. A bug that hits 1 of 15 identical nodes is almost always about timing, not configuration.

The lead-in was the giveaway. Every abort was preceded within tens of milliseconds by head churn, and RawReceipts was then called with exactly head + 1 — the same off-by-one relationship all four times:

10:38:09.102  Shorten chain  del=2 number=97,824
10:38:09.121  Extend chain   add=2 number=97,826
10:38:09.130  Shorten chain  del=1 number=97,825   <-- head lands on 97,825
10:38:09.146  panic: invalid block number          <-- +16 ms, asked for 97,826

What is actually wrong

The intuitive guess — “the chain view went stale” — is wrong, and worth discarding explicitly: ChainView is immutable in its head number. The stale thing is a flag.

iterator state the moment it breaks
1 · renderer at block N head is N+1, so delimiter = true “next() will step to N+1” 2 · reorg Shorten chain del=1 head drops to N exactly onto the rendered block 3 · view swapped in matchViews → true block N is unchanged …but delimiter stays set 4 · next() RawReceipts(N+1) one past the new head panic — process dies A deeper reorg is handled correctly — matchViews returns false and the renderer re-snapshots. Only the exact-equality boundary escapes. That is why it needs load to appear at all.
matchViews has a dedicated branch accepting a view whose head is the iterator's block — but it never re-derives the delimiter flag that was computed from the previous head.

Why it is worth fixing

A panic here is not a dropped request — the process dies with a dirty trie. On restart geth logs Head state missing, repairing and rewinds to the last persisted state. Measured cost across the four aborts analysed:

AbortBlocks rewoundNote
08-28 05:572,112Depth tracks time since the last state flush, not reorg depth — two aborts rewound to the same block (109,675). Plus ~8 min of log-index re-rendering each time.
08-29 18:394,749
08-27 10:385,264
08-30 16:5011,357

✓ The fix — 10 lines

if l.delimiter && l.blockNumber >= cv.HeadNumber() {
    return false
}

Refuse the view update instead of accepting it. That surfaces as errChainUpdate — the signal the renderer already emits for every other kind of chain update, and which indexerLoop already tolerates. The machinery to abort and re-snapshot a render cycle was always there; this interleaving was simply escaping it.

3 · nimbus-eth2 — one bad answer, poisoned forever

fork choice · still unfixed upstream

How it was found

A node reported up = 1 on both scrape targets and had produced zero blocks in 20 hours. That contradiction is the entire discovery. Everything else followed from asking why two sources disagreed.

The health endpoint did know — /eth/v1/node/health was returning 206, not 200. Nobody was watching it.

What is actually wrong

An INVALID response to one engine_forkchoiceUpdatedV4 — for a payload that engine_newPayloadV5 had returned VALID for seconds earlier — put the node into a state it could never leave. Four defects compound:

① The recovery loop cannot make progress “Mark the FULL variant invalid and let head selection fall back to EMPTY” — but the marker only ever indexes fullBlockIndices and swallows the miss. Once it falls back to EMPTY there is nothing left to mark. 118,034 spins, head == nextHead. ② The EMPTY fallback is a no-op at the Engine API layer The forkchoice state is built from the bid’s block hash and never consults head.full. EMPTY and FULL produce an identical call, which hits a per-slot cache and replays the same INVALID without ever contacting the EL — hence ~192 spins/s with zero engine traffic. ③ Invalidity is inherited — and can never be reset (THIS is the terminal step) invalid: parentNode.isSome and parentNode.unsafeGet.invalid — every block built on the poisoned one is born invalid. The network had already built on it, so the entire canonical chain became unusable in this node’s fork choice. ④ The resulting error is handled nowhere All three call sites log and give up. One of them literally logs "report bug". No fallback to finalized, no rebuild, no self-heal.
The node still had the live chain — slot 128,768 was sitting in its block tree while its head stayed pinned at 122,924. It had the data and refused to select it.

The transition to the permanent state is exact, not approximate. A viability check falls back to justified.epoch + 2 >= currentSlot.epoch; 3840 + 2 = 3842 holds through epoch 3842 and fails the instant epoch 3843 begins — slot 122,976 = 13:55:12 UTC, matching the logged current_slot to the second.

▲ Why no patch was written

Four interacting defects, and the right repair is a genuine design question under EIP-7732 semantics: should an EMPTY variant be invalidatable? Should invalid be resettable, or should the forkchoice call honour full first? A confidently-wrong patch to fork choice is worse than no patch. The issue enumerates five options and lets the maintainers choose. Knowing when not to ship a fix is part of the job.

The operational lesson, learned the hard way

The first diagnosis said this node needed a resync — reasoning that it wedged immediately on reloading its persisted state, so a restart would reload the same state. That was wrong, and reading the source is what caught it: fork choice is rebuilt in memory from the finalized head at startup, and a fresh node only inherits invalid from a parent. So a plain restart starts clean.

It was then tested on the live node. A single docker restart beacon:

SignalBeforeAfter (~90 s)
Head slot122,924 — frozen 20 htracking live
Finalized epoch38394027
Invalid-payload recovery lines118,0340

Full recovery in 25 minutes, versus hours for a resync. The cheaper action was also the correct one, and only reading the code revealed that.

4 · nethermind & besu — announce what you will serve

devp2p transaction announcements · two clients, one invariant

How they were found

geth logs a warning and disconnects a peer whose announced transaction size disagrees with the transaction it then delivers. Those warnings were running at 1,392 per geth node per day — about ten times the rate at which two already-known versions of this bug had been filed. So the question was not “is there a bug” but “who is the new offender”.

The log line names a peer by node ID, not by client. Attribution was rebuilt from first principles — take each node's self-announced enode public key, hash it, join on the ID — and cross-checked against admin_peers. That produced two different offenders with two different signatures, which is what revealed these were separate bugs rather than one.

The invariant both violate

The size you announce must be the size of the bytes you will serve.

Receivers validate the delivery against the announcement and use it to budget response sizes. Both clients announced one encoding and served another.

announced actually served
nethermind · eth/72 announces the bare consensus size 157 B 6,487 B 41× understated · Δ 6,330 geth expects the blob-elided size on eth/72
besu · eth/71 announces the pre-upgrade v0 size 131,341 B 137,567 B Δ 6,226 — 778× past the 8-byte tolerance
Two panels, two independent scales — the magnitudes differ by three orders of magnitude, so a shared axis would render the left panel invisible. Values are labelled directly.

Same symptom, completely different causes

nethermindbesu
Protocoleth/72eth/71 — it does not implement eth/72 at all
What geth expectsblob-elided sizefull wire size
The mistakeEncodes without the network wrapper, announcing a bare consensus length Broadcasts the submitted v0 object while pooling and serving the upgraded v1
Δ composition+1 version, +1 empty blob list, +50 commitment, +6,275 cell proofs, +3 header +1 version, +6,225 (128 cell proofs vs 1 blob proof)
ScopeAll eth/72 blob announcementsOnly locally RPC-submitted v0 blob txs
Share of all drops61.5 %21.2 %

◆ Why both were invisible from inside

And these two compound with a third, already-known bug: besu never dials out. Every force-drop is a connection besu cannot re-establish — it can only wait to be re-dialled. Neither bug alone would be crippling; together they turn a recoverable disconnect into sustained isolation. Three of the eth/72-capable pairings ended up effectively partitioned:

PairLinks / peer slotsEffect
reth ↔ nethermind0 / 177No connection at all, both directions
reth ↔ geth1 / 216Partitioned
nethermind ↔ geth1 / 225A link survives ~20 minutes before being dropped

An honest complication

The devp2p spec still says the announced size is the “consensus encoding” — pre-blob wording, in a section the spec itself marks as unfinished. Read literally, nethermind's code comment is quoting the spec correctly. So the filing does not argue “the spec says elided”. It argues that the announced size must match the delivered bytes, because that is what every receiver validates against — and that nethermind already demands exactly that of its own peers on eth/68. A filing that overstates its authority gets dismissed; one that names the ambiguity and argues around it does not.

5 · Ground truth: what the spec should say

Patching two clients treats the symptom. The actual defect is upstream of all of them — the specification does not unambiguously define what the announced size is, and where it does define it, it contradicts what every implementation does.

What the four documents actually say

SourceWhat it says about the announced sizeVerdict
devp2p caps/eth.md
line 524 — and line 514 is already the eth/72 schema, it includes cells
txsizeₙ refers to the length of the ‘consensus encoding’ of a typed transaction, i.e. the byte size of tx-type || tx-data ◆ Contradicts every client
devp2p caps/eth.md
line 558, PooledTransactions
“For blob transactions (type 3), the blob data is elided from the response.”
followed literally by <!-- TODO: define encoding in tx section -->
▲ Self-marked unfinished
EIP-8070 (eth/72) Keeps size_i in the schema unchanged; adds only cell_mask. Redefines the served encoding (blobs[]) but never says what the announced size becomes. ▲ Silent on the key point
EIP-5793 (introduced the field) One sentence: “the transaction sizes of the announced hashes”. The abstract says “as defined in EIP-2718” — the consensus envelope. But the stated purpose is bandwidth throttling and preventing “128KB+” blind broadcast. ▲ Letter and purpose disagree

Read literally, nethermind is correct and geth is wrong. nethermind announces the consensus encoding, which is exactly what caps/eth.md:524 specifies — its in-code comment is quoting the spec accurately. geth announces the network encoding on every protocol version, which matches no literal reading of the text.

What is actually against nethermind is the field's purpose, the de-facto behaviour of the other four clients, and nethermind's own eth/68 code — which announces the network form and accepts only network-form sizes from its peers. So this is a spec-conformance dispute, not a plain bug. besu's is a plain bug: it announces one encoding and serves a different one of the same kind, which no reading rescues.

The rule that resolves it

The announced size must be the byte length of the transaction exactly as that peer will return it in PooledTransactions, on the negotiated protocol version.

This is the only definition that satisfies what the field is for. EIP-5793 introduced it so nodes could “load balance or throttle peers” and avoid blindly broadcasting large transactions — which requires the number to be the bandwidth the fetch will actually cost. A consensus-encoding size is off by three orders of magnitude for a blob transaction and is useless for that job.

what a peer serves — and must announce not the announced size
One blob transaction has four different lengths. Only two are ever the right answer. consensus 0x03 || rlp([body]) 157 B in a block eth/72 elided …, [], commitments, cell_proofs 6,487 B announce this v0 network wrapper (pre-Osaka, 1 blob proof) 131,341 B superseded v1 network wrapper (128 cell proofs) 137,567 B announce this on eth/68–71 nethermind announced the 157 · besu announced the 131,341 while serving the 137,567
The two correct answers are version-dependent — which is fine, and is already what geth implements. The mistake is that the spec never says so. Bars are to scale for the two network encodings; the consensus and elided forms are drawn at minimum visible width — at true scale they would be 0.1 px and 5.9 px respectively, which is itself the point.

Proposed replacement text for caps/eth.md

An earlier draft of this section proposed wording that an adversarial review then took apart. Five defects are worth recording, because each is a trap anyone writing this text would fall into:

Defect in the first draftWhy it mattered
Hardcoded type 0x03 EIP-8141 adds FRAME_TX_TYPE = 0x06 and states the networking wrapper is “reused from EIP-7594 unchanged” — a second blob-carrying type. The text would have been stale on arrival, and the same person co-authors that EIP and owns this file.
“exactly as the peer would return it” was false A typed transaction is an RLP byte string inside the response list, so the returned element includes a 1–3 byte header. geth's Size() adds only +1 for the type byte. The invariant contradicted its own bullets.
The EIP-4844 v0 wrapper was erased v0 is a four-element list with no wrapper_version; geth still models both (blobTxWithBlobsV0/V1, discriminated by list arity). The draft also conflated two independent axes: wrapper shape follows the fork, elision follows the protocol version.
pooled-tx = {legacy-tx, typed-tx, pooled-blob-tx} typed-tx already ranges over type 0x03, so the union formally permitted serving a consensus-encoded blob transaction — the exact thing the change exists to forbid.
A byte-count-equality MUST Not what any client checks. geth stores the announcer's protocol version and recomputes “what would an announcer at version V have said”, which is immune to cross-version and wrapper-upgrade cases that a raw equality would criminalise.

The revised text states the rule once, generically, and puts the per-type detail where a future fork can extend rather than rewrite it:

#### Pooled Encoding

Certain transaction types carry auxiliary data which is required to validate the
transaction in the pool, but which is not part of the transaction as it appears in a
block. Transactions of such a type therefore have a second, 'pooled' encoding, used by
[PooledTransactions]. ... we refer to transactions in this encoding using `pooled-tx_n`.

For a transaction whose `tx-type` defines no wrapped form, the pooled encoding is
identical to `tx`. Types which do define one - currently only type `0x03`, introduced by
[EIP-4844] - are encoded as:

    wrapped-tx = tx-type || rlp([
        tx-payload-body, wrapper-version: P,
        blobs: [blob_1: B_131072, ...],
        commitments: [commitment_1: B_48, ...],
        proofs: [proof_1: B_48, ...],
    ])

... On chains where [EIP-7594] is not active, the version-0 wrapper defined by [EIP-4844]
is used instead: it omits `wrapper-version` and carries one blob proof per blob. The two
forms are distinguished by the number of elements in the RLP list.

--- and, under NewPooledTransactionHashes ---

`txsize_n` is the byte length of the announced transaction in the [pooled encoding], on
the protocol version negotiated for this connection. It is the length of:

- the RLP encoding of `legacy-tx`, for legacy transactions;
- `tx-type || tx-data`, for typed transactions whose type defines no wrapped form;
- the whole `wrapped-tx`, for types which do.

The RLP string header which frames a typed transaction as an element of the enclosing
[PooledTransactions] list is not counted.

A peer must announce the size of the transaction it holds. A receiver may recompute
`txsize_n` from a transaction it has been served - evaluating it at the protocol version
on which the announcement was made, which need not be the version it was delivered on -
and may disconnect a peer whose announcement disagrees.

The structural test is not SSZ but EIP-8094, which proposes stripping sidecars from PooledTransactions entirely. Under it txsize collapses back to the plain consensus size — and the invariant sentence above survives with zero edits while the old per-type prose would need a rewrite. Three live drafts (8094, 8141, SSZ), one structure that absorbs all three.

Does this survive the SSZ migration?

This was the main worry, and the answer is cleaner than expected. No SSZ EIP conflicts. EIP-6404/6465/6466/7495/7688 contain zero references to NewPooledTransactionHashes, PooledTransactions, or any announcement size; EIP-6404 explicitly defers the channel question (“RLP and SSZ transactions may clash when encoded… use only a single format within one channel”).

The hazard one would brace for — a container whose serialized length is not unique — is also gone: EIP-7495 was retitled ProgressiveContainer, serialization is now identical to Container, and active_fields is a static type property that is never serialized. One value, one length. Under SSZ the invariant actually becomes more valuable, because EIP-6404's Transaction has no sidecar concept at all — “the byte length of what this peer would serve” is the only definition that still means something when the type system has no answer.

Why this shape, and not the alternatives

OptionAssessment
Keep “consensus encoding” and make clients conform ◆ Rejected. It would break the field's only purpose — a 157-byte number tells you nothing about the cost of fetching 137 KB — and would require changing four clients to match one.
Define it purely behaviourally: “whatever you serve” ▲ Insufficient alone. Self-consistent but untestable: a client could serve a wrong encoding consistently and still conform. Hence the text above does both — names the encoding per version and states the announce-equals-serve invariant.
Announce both consensus and wire size ▲ Rejected. More expressive — block-space impact and bandwidth cost are genuinely different things — but it needs a schema change and extra bytes per announcement, to solve a problem nobody has. The field is for bandwidth; one number suffices.
Specify it in EIP-8070 instead ✓ Complementary. EIP-8070 should state the eth/72 announced size explicitly, since it is the document that made the served encoding version-dependent. But caps/eth.md is the normative home of the field and is where the wrong sentence lives.

✓ A tolerance that could then be deleted

geth currently permits an 8-byte discrepancy before disconnecting, with the comment “due to the RLP vs consensus format messyness, allow a few bytes wiggle-room” and a TODO: Get rid of this relaxation when clients are proven stable. That tolerance exists precisely because of this ambiguity. Under an unambiguous rule the comparison is exact and the fudge factor can go — which also removes the risk of a genuinely malformed announcement hiding inside it. (Separately: that comment says “we only warn, but don't drop” while the next statement calls dropPeer. Stale, and worth a one-line upstream fix.)

What this means for the two filings

They should not be filed the same way, which is the practical payoff of doing this analysis before pressing send:

6 · Why each fix is safe

A fix that trades a crash for silent corruption is not a fix. Each patch was checked against the same three questions: does it change behaviour on the paths that were already working, does it use an existing error route rather than inventing one, and does the test actually pin the bug?

FixBlast radiusWhy no regressionTest rigour
geth
10 lines
One extra early-return in one function Routes into errChainUpdate, the path already taken for every other chain update and already tolerated by the caller. Indexing semantics unchanged — the only difference is a clean re-snapshot where the process previously died. Deliberately does not change BlockHash's signature: an upstream PR proposing exactly that was closed, so maintainers keep the assertion and fix the invariant. Test asserts the buggy path would be accepted without the fix, so it pins the bug rather than passing incidentally. Also covers the two cases that must keep working. Race detector clean.
nethermind
6 files
Announce path + one persisted field Delegates to the same encoder the serve path already uses, so announced and served sizes cannot drift again by construction. Receive-side leniency left in place on purpose so a mixed-version network keeps propagating blobs during rollout. The subtle part: the value is persisted to disk. The fix bumps the storage format marker so records written by an affected build decode as unknown rather than being replayed with the wrong size.
besu
2 files
Return value of one internal method The pool-relay path was already correct and is untouched. The remote-transaction path is updated the same way — a no-op today, but it keeps the invariant expressed in one place rather than two. Verified by reintroducing the bug with the test in place and confirming it fails (expected: KZG_CELL_PROOFS but was: KZG_PROOF), then restoring the fix.
nimbus-eth2 No patch written. Four interacting defects; the correct repair is a maintainer design decision. Five options enumerated in the issue instead.

▲ Stated plainly, because reviewers deserve it

besu's full build was not run — artifact-download throttling killed two attempts. Only compilation, formatting and the targeted test task completed. That caveat is written into the PR body rather than left for a reviewer to discover.

The strongest test technique here

Two of the three patches were validated by putting the bug back and confirming the new test fails. A test that passes both with and without your fix is testing nothing, and you cannot tell the difference without trying it. It costs one minute.

7 · What generalises

  1. up = 1 does not mean “working”. A node reported healthy through 20 hours of producing nothing. Alert on function — “has this validator proposed or attested recently” — not on liveness. The signal that would have caught it already existed and was unwatched.
  2. A check that cannot fail is not a passing check. A NULL column made “no missed payloads” the answer to a question that was never actually asked. Before trusting a zero, confirm the field can ever be non-zero.
  3. Rebuild attribution from first principles when a conclusion rests on it. The peer logs name a node ID, not a client. Deriving the mapping from public keys — rather than reusing a prior mapping — is what separated two distinct bugs that shared one symptom.
  4. Read the source at the deployed commit. Mutable image tags meant the fleet moved 42 times in four days. Reading HEAD would have explained the wrong binary.
  5. Be willing to be wrong out loud. Six intermediate conclusions in this investigation were wrong and were caught by verification: an assumed root cause, a miscounted crash total, a wrong protocol version, a “one-line fix” that was six files, a misread ramp that was a step — and a resync recommendation that would have cost hours of unnecessary work. Every one was caught by checking rather than by intuition.

The through-line: three of these four bugs were invisible to the client that had them — nethermind accepts the malformed announcements it sends, besu cannot distinguish the two objects it confuses, and nimbus logs "report bug" and gives up. They were only visible from outside, in what a different implementation observed.

That is the real argument for multi-client devnets. Not that clients disagree — but that disagreement is often the only place the bug is observable at all.

Teardown of four bugs found on glamsterdam-devnet-8, 2026-08-28 → 08-31. Companion to the weekend incident report. Fix branches are pushed with issue and PR text drafted; filing upstream is the maintainers' and operators' call. Line numbers and evidence are in the filing documents.