Skip to content

caps/eth: define the pooled transaction encoding and fix the txsize definition - #281

Open
qu0b wants to merge 1 commit into
ethereum:masterfrom
qu0b:qu0b/spec/txsize-announced-encoding
Open

qu0b wants to merge 1 commit into
ethereum:masterfrom
qu0b:qu0b/spec/txsize-announced-encoding

Conversation

@qu0b

@qu0b qu0b commented Aug 31, 2026

Copy link
Copy Markdown

Problem

The txsize definition 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

`txsizeₙ` refers to the length of the 'consensus encoding' of a typed transaction, i.e.
the byte size of `tx-type || tx-data` for typed transactions, and the size of the
RLP-encoded `legacy-tx` for non-typed legacy transactions.

For legacy, and for typed transactions of types 0x01, 0x02, 0x04, there is exactly one candidate byte string, everyone agrees, and go-ethereum's Transaction.MarshalBinary implements precisely what the sentence says. No change is proposed to that behaviour.

The problem is that for type 3, tx-data is not a single well-defined byte string. EIP-4844 defines a networking wrapper which is also of the form 0x03 || rlp(...), and go-ethereum's own MarshalBinary emits it whenever a sidecar is attached — which, in the pool, it always is:

// core/types/tx_blob.go:342
func (tx *BlobTx) encode(b *bytes.Buffer) error {
	switch {
	case tx.Sidecar == nil:
		return rlp.Encode(b, tx)                          // ~157 B  (bare body)
	case tx.Sidecar.Version == BlobSidecarVersion0:
		return rlp.Encode(b, &blobTxWithBlobsV0{...})      // ~131 KB (4-element wrapper)
	case tx.Sidecar.Version == BlobSidecarVersion1:
		return rlp.Encode(b, &blobTxWithBlobsV1{...})      // ~137 KB (5-element wrapper)
	}
}

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 one txsize means, because it was written before there was more than one.

What go-ethereum actually announces is not MarshalBinary at all, but a size carried on the pool metadata (eth/protocols/eth/broadcast.go:140-144), and its own field comments name the quantity:

// core/txpool/subpool.go:89
type TxMetadata struct {
	Type            uint8
	Size            uint64 // The length of the 'rlp encoding' of a transaction (including blobs)
	SizeWithoutBlob uint64 // The length without blob data (for ETH/72 announcements)
}

Size — used on eth/68eth/71 — is documented as including blobs, which is by definition not the consensus encoding. SizeWithoutBlob — used from eth/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:

value what it is who announces it
157 bare consensus body, Sidecar == nil Nethermind (reading the sentence literally)
6,487 eth/72 blob-elided wrapper go-ethereum on eth/72; what it expects from an eth/72 peer
137,567 full v1 network wrapper go-ethereum on eth/68eth/71; what it expects from a pre-eth/72 peer

Every one of those is tx-type || tx-data for some meaning of tx-data. That is the ambiguity this PR closes.

The document also contradicts itself 35 lines later: PooledTransactions says 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

For blob transactions (type 3), the blob data is elided from the response.
<!-- TODO: define encoding in tx section -->

This is not hypothetical

On a 7-client devnet over four days, one client implemented :524 literally and announced the consensus size on eth/72. go-ethereum expected the blob-elided size, saw a 6,330-byte discrepancy, and called dropPeer() 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

  • Add a Pooled Encoding section defining the wrapped form. It is written generically ("types which define a wrapped form") rather than for type 0x03 specifically, so that a further blob-carrying type — e.g. EIP-8141, which reuses the EIP-7594 wrapper unchanged — does not invalidate it.
  • Describe both the EIP-4844 version-0 and EIP-7594 version-1 wrappers. These are two independent axes that are easy to conflate: the wrapper shape follows the active fork, while blob elision follows the negotiated protocol version. go-ethereum still models both (blobTxWithBlobsV0/V1, discriminated by RLP list arity).
  • Redefine txsize as the length of that encoding, stating the stable rule once and the per-type detail separately.
  • State explicitly that the RLP string header framing a typed transaction inside the response list is not counted — this matches what implementations compute (Size() adds only +1 for the type byte) and would otherwise be ambiguous.
  • Express the announce/serve requirement as a property of the announcer's computation at the announcement's protocol version, mirroring go-ethereum's sizeForVersion, rather than as an equality between two observed byte counts. A raw equality would make legitimate wrapper-upgrade behaviour a protocol violation.
  • Fill the TODO: define encoding in tx section.
  • Incidental, while in the file: disambiguate the "128 kB" rule now that two encodings are named, and record the EIP-4844 requirement that blob transactions are never broadcast via 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:

  • EIP-8094 (sidecars removed from PooledTransactions entirely) — txsize collapses back to the plain consensus size; the invariant needs zero edits.
  • EIP-8141 (FRAME_TX_TYPE = 0x06 carrying blobs) — covered by the generic wording.
  • SSZ transactions (EIP-6404 et al.) — no SSZ EIP currently defines an announcement size or a pooled representation, so there is nothing to conflict with. Serialized length remains well-defined and cheap to compute, and EIP-7495's rename to ProgressiveContainer removed 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:

#281 the normative fix — txsize definition and the pooled encoding (this is the load-bearing one)
ethereum/EIPs#12275 non-normative signpost in EIP-8070
NethermindEth/nethermind#13049 client fix — announces the consensus size on eth/72 (61.5% of observed disconnects)
besu-eth/besu#11203 client fix — announces the pre-upgrade v0 size while serving v1 on eth/71 (21.2%). Independent of the spec question; inconsistent under any reading
lambdaclass/ethrex#7235 client fix — announces an empty-bundle size when the sidecar is gone (16.7%)
ethereum/go-ethereum#35620 corrects a comment in tx_fetcher.go claiming geth "only warn[s], but doesn't drop" while calling dropPeer — the sentence that makes this behaviour easy to misread

Suggested 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:

client behaviour verdict
erigon announces len(txBytes) where txBytes is the same buffer it later serves ✅ conformant by construction — effectively the reference implementation of this wording
nimbus-eth1 encodes exactly what it will serve ✅ conformant by construction
go-ethereum self-consistent; its Size() is a shortcut that coincides with the true length at blob scale ✅ conformant
reth announces the un-elided size on eth/72 ❌ — already being fixed in paradigmxyz/reth#26574
nethermind, besu, ethrex see the table above ❌ — PRs linked

Two points that the wording above tries to make unambiguous, because they are exactly where implementations diverged:

  1. The quantity is the true length of the pooled encoding, not any particular client's helper. go-ethereum's Size() computes 1 + P + S + hdr(S) where the true length is 1 + 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.
  2. Under eth/72 the elided length still includes the wrapper-version element and the empty blobs list (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.

…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).
@qu0b
qu0b force-pushed the qu0b/spec/txsize-announced-encoding branch from 7118eda to 7d66aef Compare August 31, 2026 12:31
@healthykim healthykim self-assigned this Aug 31, 2026
lightclient pushed a commit to ethereum/go-ethereum that referenced this pull request Aug 31, 2026
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
ilitteri pushed a commit to NikhilSharmaWe/ethrex that referenced this pull request Aug 31, 2026
…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
Comment thread caps/eth.md
Comment on lines +206 to +207
[PooledTransactions]. In definitions across this specification, we refer to transactions
in this encoding using the identifier `pooled-txₙ`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread caps/eth.md
Comment on lines 152 to 156
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be a good idea to use the encoding we defined here.

Suggested change
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.

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