Summary
A non-atomic state transition exists in BlockBuilder.addTransaction() in the @ethereumjs/vm@10.1.1 snapshot.
The issue can be triggered with a 7-blob blob transaction that is valid to construct under pre-Osaka rules, then manually wrapped as an EIP-7594 (wrapper_version = 1) network wrapper and decoded through createBlob4844TxFromSerializedNetworkWrapper(). When this decoded transaction is later passed into an Osaka BlockBuilder, addTransaction() executes the transaction and commits its effects before a later Osaka-specific revalidation step fails with:
7 blobs exceeds max 6 blobs per tx (EIP-7594)
After the exception, the builder is left in a partially updated state:
- the transaction is not added to
transactions
gasUsed remains 0
blobGasUsed is already updated to 917504
- the VM state is already modified
build() can still produce a block whose header reflects the poisoned state
In the reproduced test case, the resulting block has transactions.length = 0 and gasUsed = 0, but blobGasUsed = 917504 and a poisoned stateRoot. Replaying that block on a clean VM fails with invalid block stateRoot.
Root Cause Analysis
The issue is caused by two behaviors combining into a non-atomic failure path.
First, createBlob4844TxFromSerializedNetworkWrapper() constructs the base blob transaction from txValues before applying the decoded networkWrapperVersion. The decoded wrapper version and blob-sidecar fields are then assigned onto the already-constructed object afterward. As a result, a transaction can be decoded under a pre-Osaka Common, while later carrying networkWrapperVersion = 1 after decode, without the constructor's early wrapper/common consistency checks being re-run.
Second, BlockBuilder.addTransaction() is not atomic across its full processing pipeline. The method:
- performs initial builder-level checks
- creates a temporary block context
- calls
runTx()
- updates blob accounting
- reconstructs the minimal blob transaction form with
createMinimal4844TxFromNetworkWrapper()
- only after that appends the transaction/result and updates
gasUsed
The critical detail is that runTx() commits its own journal checkpoint on success. In the reproduced path, execution succeeds, so transaction side effects are already committed into the builder's enclosing state. After that, addTransaction() increments blobGasUsed, and only then re-runs Osaka validation indirectly by calling createMinimal4844TxFromNetworkWrapper() under the Osaka Common. That reconstruction re-enters Blob4844Tx validation and throws because Osaka limits blob transactions to 6 blobs.
At that point, addTransaction() exits by exception, but there is no rollback of the already-committed transaction effects and no rollback of the already-updated blobGasUsed. At the same time, the transaction was never appended to transactions, no receipt/result was recorded, and gasUsed was never increased. This leaves the builder in a split state where execution side effects and some accounting have been committed, but the transaction is absent from the block body.
Impact
This issue allows a malformed cross-hardfork blob input to poison BlockBuilder state.
In the reproduced scenario, a caller can cause the builder to retain state changes from a transaction that addTransaction() reported as rejected. The builder can then finalize a block whose header is inconsistent with its transaction list. Specifically, the reproduced block has:
transactions.length = 0
gasUsed = 0
blobGasUsed = 917504
- a poisoned
stateRoot
The observed poisoned stateRoot is:
0xe779f8d659ebd019455840826364ac8ceffcb55ec101a59ca86a12f5d77fbd26
The clean-state comparison root in the test is:
0x71b4f91151036b980ed5eaf7989738978ea37ef78a29bad777d26e912f2ca6be
When the built block is replayed on a clean VM, validation fails with invalid block stateRoot.
Based on the reproduced behavior, this is a correctness and safety issue in block construction. The demonstrated impact is local builder-state corruption and invalid block assembly. The PoC does not show consensus bypass, acceptance of an invalid block by honest re-execution, or unauthorized asset movement.
Reproduce Steps
-
Use the local ethereumjs-monorepo snapshot at commit:
a1d1a76d37d976bebae09ca2e4943f81253601ab
-
Use the added test file:
ethereumjs-monorepo/packages/vm/test/api/EIPs/ethjs-osaka-builder-state-poison.spec.ts
here: https://gist.github.com/N0zoM1z0/5dfb1c635be3fc2a95b063ca64f4621a
-
The test performs the following steps:
- creates a 7-blob blob transaction using a pre-Osaka
Common
- manually serializes it as a
wrapper_version = 1 EIP-7594 network wrapper
- decodes it with
createBlob4844TxFromSerializedNetworkWrapper()
- passes the decoded transaction into an Osaka
BlockBuilder.addTransaction()
- asserts that
addTransaction() throws 7 blobs exceeds max 6 blobs per tx (EIP-7594)
- verifies that the builder is already poisoned after the throw
- finalizes the builder and verifies that the produced block is externally invalid when replayed on a clean VM
-
Run:
cd /ethereumjs-monorepo/packages/vm
npx vitest run -c ./vitest.config.ts ./test/api/EIPs/ethjs-osaka-builder-state-poison.spec.ts
-
Expected observations from the reproduced run:
addTransaction() throws 7 blobs exceeds max 6 blobs per tx (EIP-7594)
builder.blobGasUsed === 917504
- the builder's
stateRoot differs from the clean-state copy
build() returns a block with transactions.length = 0 and gasUsed = 0
- that same block still has
blobGasUsed = 917504
- replaying the built block on a clean VM fails with
invalid block stateRoot
Recommended Fix
-
Make BlockBuilder.addTransaction() atomic.
The entire per-transaction flow should succeed or fail as a unit. In practice, this means wrapping the full sequence — execution, blob-specific post-processing, minimal-transaction reconstruction, accounting updates, and transaction/result insertion — in a builder-level rollback boundary, and reverting all builder-visible effects on any exception.
-
Move all blob-transaction revalidation that can still fail to a point before runTx().
In the reproduced path, the fatal Osaka revalidation happens after execution has already succeeded. Any validation that can reject the transaction should happen before execution begins.
-
Validate network-wrapper version consistency during decoding, not by post-construction mutation.
createBlob4844TxFromSerializedNetworkWrapper() should not be able to construct a transaction under one fork context and then assign a conflicting wrapper version afterward without re-running the relevant validation logic.
-
Only update blobGasUsed, transaction arrays, receipts, and gas accounting after all post-execution checks succeed.
This reduces the chance of partial commits even if future validation paths are added.
-
Keep a regression test covering this exact path.
The current PoC is a good regression target because it proves both the triggering condition and the externally visible invalid-block outcome.
Summary
A non-atomic state transition exists in
BlockBuilder.addTransaction()in the@ethereumjs/vm@10.1.1snapshot.The issue can be triggered with a 7-blob blob transaction that is valid to construct under pre-Osaka rules, then manually wrapped as an EIP-7594 (
wrapper_version = 1) network wrapper and decoded throughcreateBlob4844TxFromSerializedNetworkWrapper(). When this decoded transaction is later passed into an OsakaBlockBuilder,addTransaction()executes the transaction and commits its effects before a later Osaka-specific revalidation step fails with:7 blobs exceeds max 6 blobs per tx (EIP-7594)After the exception, the builder is left in a partially updated state:
transactionsgasUsedremains0blobGasUsedis already updated to917504build()can still produce a block whose header reflects the poisoned stateIn the reproduced test case, the resulting block has
transactions.length = 0andgasUsed = 0, butblobGasUsed = 917504and a poisonedstateRoot. Replaying that block on a clean VM fails withinvalid block stateRoot.Root Cause Analysis
The issue is caused by two behaviors combining into a non-atomic failure path.
First,
createBlob4844TxFromSerializedNetworkWrapper()constructs the base blob transaction fromtxValuesbefore applying the decodednetworkWrapperVersion. The decoded wrapper version and blob-sidecar fields are then assigned onto the already-constructed object afterward. As a result, a transaction can be decoded under a pre-OsakaCommon, while later carryingnetworkWrapperVersion = 1after decode, without the constructor's early wrapper/common consistency checks being re-run.Second,
BlockBuilder.addTransaction()is not atomic across its full processing pipeline. The method:runTx()createMinimal4844TxFromNetworkWrapper()gasUsedThe critical detail is that
runTx()commits its own journal checkpoint on success. In the reproduced path, execution succeeds, so transaction side effects are already committed into the builder's enclosing state. After that,addTransaction()incrementsblobGasUsed, and only then re-runs Osaka validation indirectly by callingcreateMinimal4844TxFromNetworkWrapper()under the OsakaCommon. That reconstruction re-entersBlob4844Txvalidation and throws because Osaka limits blob transactions to 6 blobs.At that point,
addTransaction()exits by exception, but there is no rollback of the already-committed transaction effects and no rollback of the already-updatedblobGasUsed. At the same time, the transaction was never appended totransactions, no receipt/result was recorded, andgasUsedwas never increased. This leaves the builder in a split state where execution side effects and some accounting have been committed, but the transaction is absent from the block body.Impact
This issue allows a malformed cross-hardfork blob input to poison
BlockBuilderstate.In the reproduced scenario, a caller can cause the builder to retain state changes from a transaction that
addTransaction()reported as rejected. The builder can then finalize a block whose header is inconsistent with its transaction list. Specifically, the reproduced block has:transactions.length = 0gasUsed = 0blobGasUsed = 917504stateRootThe observed poisoned
stateRootis:0xe779f8d659ebd019455840826364ac8ceffcb55ec101a59ca86a12f5d77fbd26The clean-state comparison root in the test is:
0x71b4f91151036b980ed5eaf7989738978ea37ef78a29bad777d26e912f2ca6beWhen the built block is replayed on a clean VM, validation fails with
invalid block stateRoot.Based on the reproduced behavior, this is a correctness and safety issue in block construction. The demonstrated impact is local builder-state corruption and invalid block assembly. The PoC does not show consensus bypass, acceptance of an invalid block by honest re-execution, or unauthorized asset movement.
Reproduce Steps
Use the local
ethereumjs-monoreposnapshot at commit:a1d1a76d37d976bebae09ca2e4943f81253601abUse the added test file:
ethereumjs-monorepo/packages/vm/test/api/EIPs/ethjs-osaka-builder-state-poison.spec.tshere: https://gist.github.com/N0zoM1z0/5dfb1c635be3fc2a95b063ca64f4621a
The test performs the following steps:
Commonwrapper_version = 1EIP-7594 network wrappercreateBlob4844TxFromSerializedNetworkWrapper()BlockBuilder.addTransaction()addTransaction()throws7 blobs exceeds max 6 blobs per tx (EIP-7594)Run:
cd /ethereumjs-monorepo/packages/vm npx vitest run -c ./vitest.config.ts ./test/api/EIPs/ethjs-osaka-builder-state-poison.spec.tsExpected observations from the reproduced run:
addTransaction()throws7 blobs exceeds max 6 blobs per tx (EIP-7594)builder.blobGasUsed === 917504stateRootdiffers from the clean-state copybuild()returns a block withtransactions.length = 0andgasUsed = 0blobGasUsed = 917504invalid block stateRootRecommended Fix
Make
BlockBuilder.addTransaction()atomic.The entire per-transaction flow should succeed or fail as a unit. In practice, this means wrapping the full sequence — execution, blob-specific post-processing, minimal-transaction reconstruction, accounting updates, and transaction/result insertion — in a builder-level rollback boundary, and reverting all builder-visible effects on any exception.
Move all blob-transaction revalidation that can still fail to a point before
runTx().In the reproduced path, the fatal Osaka revalidation happens after execution has already succeeded. Any validation that can reject the transaction should happen before execution begins.
Validate network-wrapper version consistency during decoding, not by post-construction mutation.
createBlob4844TxFromSerializedNetworkWrapper()should not be able to construct a transaction under one fork context and then assign a conflicting wrapper version afterward without re-running the relevant validation logic.Only update
blobGasUsed, transaction arrays, receipts, and gas accounting after all post-execution checks succeed.This reduces the chance of partial commits even if future validation paths are added.
Keep a regression test covering this exact path.
The current PoC is a good regression target because it proves both the triggering condition and the externally visible invalid-block outcome.