Devnet engineering teardown
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.
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.
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:
Worth stating plainly, because each one silently returns a plausible result:
| Trap | What it does | Why 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.
core/filtermaps · process-fatal
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
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.
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.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:
| Abort | Blocks rewound | Note |
|---|---|---|
| 08-28 05:57 | 2,112 | Depth 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:39 | 4,749 | |
| 08-27 10:38 | 5,264 | |
| 08-30 16:50 | 11,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.
fork choice · still unfixed upstream
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.
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 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 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:
| Signal | Before | After (~90 s) |
|---|---|---|
| Head slot | 122,924 — frozen 20 h | tracking live |
| Finalized epoch | 3839 | 4027 |
| Invalid-payload recovery lines | 118,034 | 0 |
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.
devp2p transaction announcements · two clients, one invariant
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 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.
| nethermind | besu | |
|---|---|---|
| Protocol | eth/72 | eth/71 — it does not implement eth/72 at all |
| What geth expects | blob-elided size | full wire size |
| The mistake | Encodes 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) |
| Scope | All eth/72 blob announcements | Only locally RPC-submitted v0 blob txs |
| Share of all drops | 61.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:
| Pair | Links / peer slots | Effect |
|---|---|---|
| reth ↔ nethermind | 0 / 177 | No connection at all, both directions |
| reth ↔ geth | 1 / 216 | Partitioned |
| nethermind ↔ geth | 1 / 225 | A link survives ~20 minutes before being dropped |
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.
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.
| Source | What it says about the announced size | Verdict |
|---|---|---|
devp2p caps/eth.mdline 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.mdline 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 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.
caps/eth.mdAn 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 draft | Why 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.
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.
| Option | Assessment |
|---|---|
| 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.)
They should not be filed the same way, which is the practical payoff of doing this analysis before pressing send:
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?
| Fix | Blast radius | Why no regression | Test 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.
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.
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.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.