Conversation
…efinition txsize is currently specified as the length of the 'consensus encoding'. No implementation announces that: go-ethereum, reth, besu and ethrex all announce a network-encoding size, blob-elided from eth/72. The consensus size also defeats the field's stated purpose in EIP-5793 (throttling and load balancing), being off by three orders of magnitude for a blob transaction. One client implemented the sentence literally and was force-disconnected by geth peers tens of thousands of times on a devnet. The document also contradicted itself: PooledTransactions elides blob data from the response while NewPooledTransactionHashes announced a size that never included it, and the response encoding was left as a TODO. Add a 'Pooled Encoding' section defining the wrapped form generically, rather than for type 0x03 specifically, so that a further blob-carrying type (e.g. EIP-8141) does not invalidate it. Describe both the EIP-4844 version-0 and EIP-7594 version-1 wrappers, since the wrapper shape follows the active fork while blob elision follows the negotiated protocol version. Redefine txsize as the length of that encoding, stating the stable rule once and the per-type detail separately. Note explicitly that the RLP string header framing a typed transaction in the enclosing list is not counted, which matches what implementations compute. Also disambiguate the 128 kB rule, and record the EIP-4844 requirement that blob transactions are never broadcast via Transactions (0x02).
7118eda to
7d66aef
Compare
Comment-only, no behaviour change.
`eth/fetcher/tx_fetcher.go` (twice, at the `waitlist` and `announces`
branches) reads:
```go
if math.Abs(float64(size)-float64(meta.size)) > 8 {
log.Warn("Announced transaction size mismatch", ...)
// Normally we should drop a peer considering this is a protocol violation.
// However, due to the RLP vs consensus format messyness, allow a few bytes
// wiggle-room where we only warn, but don't drop.
//
// TODO(karalabe): Get rid of this relaxation when clients are proven stable.
f.dropPeer(peer)
}
```
The comment says the wiggle-room means "we only warn, but don't drop",
and then the next statement drops the peer. The tolerance is actually
the `> 8` guard on the enclosing branch: a difference of 8 bytes or less
is neither warned about nor dropped for, and anything larger is both.
I hit this while working out why several clients were being disconnected
on a devnet, and read it as "geth tolerates this" for longer than I
would like to admit. The `TODO` is left in place.
## Context
This came out of the same investigation as ethereum/devp2p#281, which
proposes fixing the `txsize` definition in the wire spec — currently
specified as the "consensus encoding", which no client actually
announces. Related client fixes: NethermindEth/nethermind#13049,
besu-eth/besu#11203.
Write-up:
https://panda-uploads-production.devops-539.workers.dev/panda/uploads/a7974b/teardown.html
…mpty-bundle size (lambdaclass#7235) ## Problem On the `glamsterdam-devnet-8` devnet, geth dropped ethrex peers **~14,970 times** between 2026-08-27 and 08-31 with: ``` Announced transaction size mismatch size=137,567 ann=162 ``` `NewPooledTransactionHashes::new` computes the announced size for a type-3 transaction as: ```rust let tx_blobs_bundle = blockchain.mempool.get_blobs_bundle(transaction_hash)?.unwrap_or_default(); ``` (`crates/networking/p2p/rlpx/eth/transactions.rs:136-137`, and identically `eth72/transactions.rs:104-105`) When the bundle is no longer in the pool — the transaction was pulled into a payload or evicted between the broadcaster's snapshot and the announcement — `unwrap_or_default()` yields an **empty** bundle, and we announce the size of a blobless wrapper. The window is real: `TxBroadcaster` announces from a snapshot (`tx_broadcaster.rs:309`), while `remove_transaction_with_lock` (`mempool.rs:290-296`) atomically drops the transaction *and* its bundle when it is pulled into a payload or evicted — exactly the race our own serve-path comment describes. ### Byte arithmetic For a single-blob v1 transaction with `L = 156` = `len(rlp(EIP-4844 body list))`: | | bytes | |---|---:| | announced: `1` (type) + `2` (list header) + `L` + `3` (three empty lists `0xc0`, version omitted) | **162** | | actually served: `L` + `ListSize(sidecar 137,406)` + `1` | **137,567** | Both figures reproduce the observed log line exactly. ## Why this is a bug rather than a tolerable approximation **It contradicts our own serve path.** `Blockchain::get_p2p_transaction_by_hash` (`crates/blockchain/blockchain.rs:3743-3747`) treats the very same condition as a hard error — *"Blob transaction present without its bundle"* — so `GetPooledTransactions` skips the transaction. We announce a transaction we then refuse to serve, with a size that matches neither outcome. **It contradicts our own receive-side validation.** `PooledTransactions::validate_requested` (`transactions.rs:372`, `eth72/transactions.rs:377`) rejects any peer whose delivered size differs from the announced size by more than `POOLED_TX_SIZE_TOLERANCE = 8`. **ethrex would disconnect a peer doing what ethrex does here.** **The penalty lands even though we never serve the transaction.** geth's cleanup loop (`eth/fetcher/tx_fetcher.go:793-826`) checks the one delivered transaction against *every* peer that announced that hash — so we are dropped for the announcement alone. This is the same class as lambdaclass#6255 (*"ethrex-to-ethrex peers stuck in connect/disconnect loop — Invalid pooled transaction size"*, fixed by lambdaclass#6256, which is in the build I measured) reached via a different route. ## Fix Skip the transaction rather than substituting an empty bundle, so an announcement can never describe something we cannot serve. `transaction_types` and `transaction_hashes` move below the size computation so that `continue` keeps the three parallel arrays in lockstep — they were previously pushed first, so an early `continue` would have desynced them. Applied to both `eth/transactions.rs` and `eth72/transactions.rs`. `cargo check -p ethrex-p2p` — clean. ## Context Four clients were dropped by geth for announced-size mismatches on the same devnet, for four different reasons. ethrex accounted for 16.7% of them. The others: - **nethermind** announces the bare consensus size on eth/72 — NethermindEth/nethermind#13049 - **besu** announces the pre-upgrade v0 size while serving v1 — besu-eth/besu#11203 - **reth** announces the un-elided size on eth/72 — already being fixed upstream in paradigmxyz/reth#26574, no action needed - the underlying **spec ambiguity** (`txsize` is specified as the "consensus encoding", which no client announces) — ethereum/devp2p#281, ethereum/EIPs#12275 Full write-up with the per-client breakdown: https://panda-uploads-production.devops-539.workers.dev/panda/uploads/a7974b/teardown.html
| [PooledTransactions]. In definitions across this specification, we refer to transactions | ||
| in this encoding using the identifier `pooled-txₙ`. |
There was a problem hiding this comment.
I am not sure whether it is a good idea to bind the encoding to a specific message type. For example, in EIP 8077, we are considering the idea of sending type 3 transactions directly via Transactions.
There was a problem hiding this comment.
yeah, I also am septical regarding this point. Do you have any example on how this might be done more clearly? maybe just revise the current spec wording to ammend it slightly to handle the blob transaction annoncement. Theres the "TODO" in there so instead of handling a more general case we can just handle the specific case.
There was a problem hiding this comment.
What about defining net-tx = { tx, wrapped-tx }? i.e. the wrapped encoding for transaction types that define one, and otherwise make it identical to tx` (the consensus encoding).
[PooledTransactions] would become [request-id: P, [net-tx₁, net-tx₂, ...]], and the same for[Transactions], but we could add an additional restriction there saying that type-3 transactions must not be sent using this message type. This could be removed after 8077
There was a problem hiding this comment.
Hi, are you still working on this ? What I meant was that we might use the pooled-tx type for Transactions after 8077, so handling the general case seems better with that in mind. I just wasn't sure whether the type name was somehow tied to a specific message type's name. But since it can also refer to transactions in the txpool, I think it's fine as is.
| Responses to [GetPooledTransactions] for blob transactions include the traditional | ||
| transaction payload and blob metadata. The blob data itself can be obtained only by | ||
| [GetCells]. Upon receiving the [NewPooledTransactionHashes] message with new blob | ||
| transaction hashes, the node begins fetching their cells. For each transaction, it first | ||
| makes a probabilistic decision between two strategies. |
There was a problem hiding this comment.
It might be a good idea to use the encoding we defined here.
| Responses to [GetPooledTransactions] serve blob transactions in their [Pooled encoding], | |
| with the blob data elided. The blob data itself can be obtained only by [GetCells]. Upon | |
| receiving the [NewPooledTransactionHashes] message with new blob transaction hashes, the | |
| node begins fetching their cells. For each transaction, it first makes a probabilistic | |
| decision between two strategies. |
Problem
The
txsizedefinition is unambiguous for every transaction type except type 3, and type 3 is the only one where the number is large enough to matter.devp2p/caps/eth.md
Lines 524 to 526 in 2c19a28
For legacy, and for typed transactions of types
0x01,0x02,0x04, there is exactly one candidate byte string, everyone agrees, and go-ethereum'sTransaction.MarshalBinaryimplements precisely what the sentence says. No change is proposed to that behaviour.The problem is that for type 3,
tx-datais not a single well-defined byte string. EIP-4844 defines a networking wrapper which is also of the form0x03 || rlp(...), and go-ethereum's ownMarshalBinaryemits it whenever a sidecar is attached — which, in the pool, it always is:So
MarshalBinary— the reference implementation of "the consensus encoding" — returns three different answers for one transaction depending on runtime state. The spec sentence does not say which onetxsizemeans, because it was written before there was more than one.What go-ethereum actually announces is not
MarshalBinaryat all, but a size carried on the pool metadata (eth/protocols/eth/broadcast.go:140-144), and its own field comments name the quantity:Size— used oneth/68–eth/71— is documented as including blobs, which is by definition not the consensus encoding.SizeWithoutBlob— used frometh/72— is a third quantity again, and is computed arithmetically rather than by serializing anything (core/txpool/blobpool/blobpool.go:202-213).So for a single-blob transaction there are three defensible readings of the current sentence, and clients have picked all three:
Sidecar == nileth/72; what it expects from aneth/72peereth/68–eth/71; what it expects from a pre-eth/72peerEvery one of those is
tx-type || tx-datafor some meaning oftx-data. That is the ambiguity this PR closes.The document also contradicts itself 35 lines later:
PooledTransactionssays blob data is elided from the response, and then declines to define the resulting encoding at all:devp2p/caps/eth.md
Lines 559 to 561 in 2c19a28
This is not hypothetical
On a 7-client devnet over four days, one client implemented
:524literally and announced the consensus size oneth/72. go-ethereum expected the blob-elided size, saw a 6,330-byte discrepancy, and calleddropPeer()55,130 times — 61.5% of all announced-size disconnects on the network. That pairing was reduced to roughly one surviving link per 225 peer slots.The client is not obviously at fault: it implemented the sentence in this document, and its in-code comment says so.
Changes
0x03specifically, so that a further blob-carrying type — e.g. EIP-8141, which reuses the EIP-7594 wrapper unchanged — does not invalidate it.blobTxWithBlobsV0/V1, discriminated by RLP list arity).txsizeas the length of that encoding, stating the stable rule once and the per-type detail separately.Size()adds only+1for the type byte) and would otherwise be ambiguous.sizeForVersion, rather than as an equality between two observed byte counts. A raw equality would make legitimate wrapper-upgrade behaviour a protocol violation.TODO: define encoding in tx section.Transactions (0x02)— a rule that appears nowhere in this document today.Forward compatibility
The structure deliberately separates a stable, encoding-agnostic invariant from per-version detail. Checked against three live drafts:
PooledTransactionsentirely) —txsizecollapses back to the plain consensus size; the invariant needs zero edits.FRAME_TX_TYPE = 0x06carrying blobs) — covered by the generic wording.ProgressiveContainerremoved the "same value, two lengths" hazard.Under an unambiguous definition, the 8-byte comparison tolerance in go-ethereum — commented "due to the RLP vs consensus format messyness" with
TODO(karalabe): Get rid of this relaxation when clients are proven stable— could eventually be removed.Background
Full write-up of how this was found, including the byte arithmetic and the per-client breakdown: https://panda-uploads-production.devops-539.workers.dev/panda/uploads/a7974b/teardown.html
The markdown linter (
.lint/lint.sh caps/eth.md) passes.Related PRs
This is one of four coordinated changes from the same investigation:
txsizedefinition and the pooled encoding (this is the load-bearing one)tx_fetcher.goclaiming geth "only warn[s], but doesn't drop" while callingdropPeer— the sentence that makes this behaviour easy to misreadSuggested order: the spec change first, then the client fixes, then the EIP signpost.
Client survey
All seven execution clients on the devnet were checked against the proposed definition. The clarification breaks nobody, and two clients are already exactly conformant:
len(txBytes)wheretxBytesis the same buffer it later servesSize()is a shortcut that coincides with the true length at blob scaleTwo points that the wording above tries to make unambiguous, because they are exactly where implementations diverged:
Size()computes1 + P + S + hdr(S)where the true length is1 + P + S + hdr(P+S); these agree only because both headers are 4 bytes at realistic blob counts. erigon's and nimbus-eth1's are structural identities and are the better model.eth/72the elided length still includes thewrapper-versionelement and the emptyblobslist (0xc0). "Serialize with empty blobs" and "full size minus blob bytes" then give the same answer — which is worth stating rather than leaving implementers to rediscover.