From 92711d4d3bb89781e414cb96546157179b55dd32 Mon Sep 17 00:00:00 2001 From: blockchaindevsh Date: Thu, 26 Mar 2026 14:58:30 +0800 Subject: [PATCH] feat(op-reth): add L2 blob transaction support and acceptance tests Port L2 blob (EIP-4844) support from op-geth to op-reth, enabling blob transactions on L2 chains when the l2BlobTime config is set. op-reth changes: - Add is_l2_blob_active_at_timestamp() to OpHardforks trait - Add l2_blob_activation_timestamp field to OpChainSpec, parsed from genesis config.optimism.l2BlobTime (same pattern as SGT) - Conditionally allow EIP-4844 txs in txpool validator, payload builder, and node pool setup when L2 blob is active - Use actual blob_gas_used from execution in block assembly when L2 blob is active - Validate blob gas in consensus when L2 blob is active Acceptance tests: - Add l2blob test package under isthmus gate - TestSubmitL2BlobTransaction: sends 3-blob tx, verifies inclusion and blob gas usage in block header - TestSubmitL2BlobTransactionWithDAC: optional DAC server verification Co-Authored-By: Claude Opus 4.6 (1M context) --- op-acceptance-tests/acceptance-tests.yaml | 2 + .../tests/isthmus/l2blob/init_test.go | 16 ++ .../tests/isthmus/l2blob/l2blob_test.go | 151 ++++++++++++++++++ rust/alloy-op-hardforks/src/lib.rs | 9 ++ rust/op-reth/crates/chainspec/src/base.rs | 1 + .../crates/chainspec/src/base_sepolia.rs | 1 + rust/op-reth/crates/chainspec/src/basefee.rs | 1 + rust/op-reth/crates/chainspec/src/dev.rs | 1 + rust/op-reth/crates/chainspec/src/lib.rs | 49 ++++-- rust/op-reth/crates/chainspec/src/op.rs | 1 + .../crates/chainspec/src/op_sepolia.rs | 1 + rust/op-reth/crates/consensus/src/lib.rs | 16 +- .../crates/consensus/src/validation/mod.rs | 8 +- rust/op-reth/crates/evm/src/build.rs | 28 +++- rust/op-reth/crates/evm/src/lib.rs | 21 ++- rust/op-reth/crates/node/src/node.rs | 10 +- rust/op-reth/crates/payload/src/builder.rs | 18 ++- rust/op-reth/crates/txpool/src/validator.rs | 6 +- 18 files changed, 305 insertions(+), 35 deletions(-) create mode 100644 op-acceptance-tests/tests/isthmus/l2blob/init_test.go create mode 100644 op-acceptance-tests/tests/isthmus/l2blob/l2blob_test.go diff --git a/op-acceptance-tests/acceptance-tests.yaml b/op-acceptance-tests/acceptance-tests.yaml index 75b15c3f5c1..8975664b9bf 100644 --- a/op-acceptance-tests/acceptance-tests.yaml +++ b/op-acceptance-tests/acceptance-tests.yaml @@ -93,6 +93,8 @@ gates: timeout: 10m - package: github.com/ethereum-optimism/optimism/op-acceptance-tests/tests/isthmus/pectra timeout: 10m + - package: github.com/ethereum-optimism/optimism/op-acceptance-tests/tests/isthmus/l2blob + timeout: 10m - id: base description: "Sanity/smoke acceptance tests for all networks." diff --git a/op-acceptance-tests/tests/isthmus/l2blob/init_test.go b/op-acceptance-tests/tests/isthmus/l2blob/init_test.go new file mode 100644 index 00000000000..22973f51c16 --- /dev/null +++ b/op-acceptance-tests/tests/isthmus/l2blob/init_test.go @@ -0,0 +1,16 @@ +package l2blob + +import ( + "testing" + + "github.com/ethereum-optimism/optimism/op-devstack/presets" + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" +) + +func TestMain(m *testing.M) { + presets.DoMain(m, + presets.WithMinimal(), + stack.MakeCommon(sysgo.WithDeployerOptions(WithL2BlobAtGenesis)), + ) +} diff --git a/op-acceptance-tests/tests/isthmus/l2blob/l2blob_test.go b/op-acceptance-tests/tests/isthmus/l2blob/l2blob_test.go new file mode 100644 index 00000000000..fe89d6ce490 --- /dev/null +++ b/op-acceptance-tests/tests/isthmus/l2blob/l2blob_test.go @@ -0,0 +1,151 @@ +package l2blob + +import ( + "bytes" + "context" + "fmt" + mrand "math/rand" + "testing" + "time" + + "github.com/ethereum-optimism/optimism/op-chain-ops/devkeys" + opforks "github.com/ethereum-optimism/optimism/op-core/forks" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/presets" + "github.com/ethereum-optimism/optimism/op-e2e/e2eutils/intentbuilder" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/txplan" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + daclient "github.com/ethstorage/da-server/pkg/da/client" +) + +const ( + dacPort = 37777 +) + +var ( + dacUrl = fmt.Sprintf("http://127.0.0.1:%d", dacPort) +) + +// WithL2BlobAtGenesis enables L2 blob support at genesis for all L2 chains. +func WithL2BlobAtGenesis(_ devtest.P, _ devkeys.Keys, builder intentbuilder.Builder) { + offset := uint64(0) + for _, l2Cfg := range builder.L2s() { + l2Cfg.WithForkAtGenesis(opforks.Isthmus) + } + // Set L2GenesisBlobTimeOffset directly via global deploy overrides + // since l2BlobTime is not a standard fork. + builder.WithGlobalOverride("l2GenesisBlobTimeOffset", (*hexutil.Uint64)(&offset)) +} + +// TestSubmitL2BlobTransaction tests that blob transactions can be submitted and included on L2. +func TestSubmitL2BlobTransaction(gt *testing.T) { + t := devtest.SerialT(gt) + sys := presets.NewMinimal(t) + + t.Require().True(sys.L2Chain.IsForkActive(opforks.Isthmus), "Isthmus fork must be active") + + alice := sys.FunderL2.NewFundedEOA(eth.OneEther) + + // Create random blobs + numBlobs := 3 + blobs := make([]*eth.Blob, numBlobs) + for i := range blobs { + b := getRandBlob(t, int64(i)) + blobs[i] = &b + } + + // Send a blob transaction + chainConfig := sys.L2Chain.Escape().ChainConfig() + planned := alice.Transact( + txplan.WithBlobs(blobs, chainConfig), + txplan.WithTo(&common.Address{}), // blob tx requires a 'to' address + ) + + receipt, err := planned.Included.Eval(t.Ctx()) + t.Require().NoError(err, "blob transaction must be included") + t.Require().NotNil(receipt, "receipt must not be nil") + t.Require().Equal(uint64(1), receipt.Status, "blob transaction must succeed") + + // Verify the transaction has blob hashes + tx, err := planned.Signed.Eval(t.Ctx()) + t.Require().NoError(err, "must get signed transaction") + t.Require().Equal(numBlobs, len(tx.BlobHashes()), "transaction must have correct number of blob hashes") + + // Verify blob gas usage in the block + blockNum := receipt.BlockNumber + client := sys.L2EL.Escape().L2EthClient() + header, err := client.InfoByNumber(t.Ctx(), blockNum.Uint64()) + t.Require().NoError(err, "must get block header") + + blobGasUsed := header.BlobGasUsed() + t.Require().NotZero(blobGasUsed, "blob gas used must be non-zero for block with blob transactions") + + t.Logf("L2 blob transaction included: block=%d, blobGasUsed=%d, blobHashes=%d", + blockNum, blobGasUsed, len(tx.BlobHashes())) +} + +// TestSubmitL2BlobTransactionWithDAC tests blob submission and retrieval via DAC server. +func TestSubmitL2BlobTransactionWithDAC(gt *testing.T) { + t := devtest.SerialT(gt) + sys := presets.NewMinimal(t) + + t.Require().True(sys.L2Chain.IsForkActive(opforks.Isthmus), "Isthmus fork must be active") + + alice := sys.FunderL2.NewFundedEOA(eth.OneEther) + + // Create random blobs + numBlobs := 3 + blobs := make([]*eth.Blob, numBlobs) + for i := range blobs { + b := getRandBlob(t, int64(i)) + blobs[i] = &b + } + + // Send a blob transaction + chainConfig := sys.L2Chain.Escape().ChainConfig() + planned := alice.Transact( + txplan.WithBlobs(blobs, chainConfig), + txplan.WithTo(&common.Address{}), + ) + + receipt, err := planned.Included.Eval(t.Ctx()) + t.Require().NoError(err, "blob transaction must be included") + t.Require().Equal(uint64(1), receipt.Status, "blob transaction must succeed") + + tx, err := planned.Signed.Eval(t.Ctx()) + t.Require().NoError(err, "must get signed transaction") + blobHashes := tx.BlobHashes() + t.Require().Equal(numBlobs, len(blobHashes), "transaction must have correct number of blob hashes") + + // Try to download blobs from DAC server (if available) + ctx, cancel := context.WithTimeout(t.Ctx(), 5*time.Second) + defer cancel() + dacClient := daclient.New([]string{dacUrl}) + dblobs, err := dacClient.GetBlobs(ctx, blobHashes) + if err != nil { + t.Logf("DAC server not available at %s, skipping blob retrieval verification: %v", dacUrl, err) + return + } + + t.Require().Equal(len(blobHashes), len(dblobs), "downloaded blobs count must match blob hashes") + for i, blob := range dblobs { + t.Require().Equal(eth.BlobSize, len(blob), "downloaded blob %d must have correct size", i) + t.Require().True(bytes.Equal(blob, blobs[i][:]), + "blob %d content mismatch: got %s vs expected %s", + i, common.Bytes2Hex(blob[:32]), common.Bytes2Hex(blobs[i][:32])) + } +} + +// getRandBlob generates a random blob with the given seed. +func getRandBlob(t devtest.T, seed int64) eth.Blob { + r := mrand.New(mrand.NewSource(seed)) + bigData := eth.Data(make([]byte, eth.MaxBlobDataSize)) + _, err := r.Read(bigData) + t.Require().NoError(err) + var b eth.Blob + err = b.FromData(bigData) + t.Require().NoError(err) + return b +} diff --git a/rust/alloy-op-hardforks/src/lib.rs b/rust/alloy-op-hardforks/src/lib.rs index 9921cc58428..6eff21a57d9 100644 --- a/rust/alloy-op-hardforks/src/lib.rs +++ b/rust/alloy-op-hardforks/src/lib.rs @@ -267,6 +267,15 @@ pub trait OpHardforks: EthereumHardforks { fn is_sgt_native_backed(&self) -> bool { true } + + /// Returns `true` if L2 Blob support is active at given block timestamp. + /// + /// L2 Blob enables EIP-4844 blob transactions on L2 chains. + /// Default implementation returns `false`. Override in chain-specific implementations + /// (e.g., `OpChainSpec`) to enable L2 Blob based on configuration. + fn is_l2_blob_active_at_timestamp(&self, _timestamp: u64) -> bool { + false + } } /// A type allowing to configure activation [`ForkCondition`]s for a given list of diff --git a/rust/op-reth/crates/chainspec/src/base.rs b/rust/op-reth/crates/chainspec/src/base.rs index a9370752e33..86a5eb58c59 100644 --- a/rust/op-reth/crates/chainspec/src/base.rs +++ b/rust/op-reth/crates/chainspec/src/base.rs @@ -37,6 +37,7 @@ pub static BASE_MAINNET: LazyLock> = LazyLock::new(|| { }, sgt_activation_timestamp: None, sgt_is_native_backed: true, + l2_blob_activation_timestamp: None, } .into() }); diff --git a/rust/op-reth/crates/chainspec/src/base_sepolia.rs b/rust/op-reth/crates/chainspec/src/base_sepolia.rs index 672cfc8970c..628427b9f81 100644 --- a/rust/op-reth/crates/chainspec/src/base_sepolia.rs +++ b/rust/op-reth/crates/chainspec/src/base_sepolia.rs @@ -38,6 +38,7 @@ pub static BASE_SEPOLIA: LazyLock> = LazyLock::new(|| { }, sgt_activation_timestamp: None, sgt_is_native_backed: true, + l2_blob_activation_timestamp: None, } .into() }); diff --git a/rust/op-reth/crates/chainspec/src/basefee.rs b/rust/op-reth/crates/chainspec/src/basefee.rs index d4880abffa8..c79e4c29c6f 100644 --- a/rust/op-reth/crates/chainspec/src/basefee.rs +++ b/rust/op-reth/crates/chainspec/src/basefee.rs @@ -102,6 +102,7 @@ mod tests { }, sgt_activation_timestamp: None, sgt_is_native_backed: true, + l2_blob_activation_timestamp: None, }) } diff --git a/rust/op-reth/crates/chainspec/src/dev.rs b/rust/op-reth/crates/chainspec/src/dev.rs index 34c2df39083..1caa00c74db 100644 --- a/rust/op-reth/crates/chainspec/src/dev.rs +++ b/rust/op-reth/crates/chainspec/src/dev.rs @@ -31,6 +31,7 @@ pub static OP_DEV: LazyLock> = LazyLock::new(|| { }, sgt_activation_timestamp: None, sgt_is_native_backed: true, + l2_blob_activation_timestamp: None, } .into() }); diff --git a/rust/op-reth/crates/chainspec/src/lib.rs b/rust/op-reth/crates/chainspec/src/lib.rs index cf755cbb6f1..cc78b197a6e 100644 --- a/rust/op-reth/crates/chainspec/src/lib.rs +++ b/rust/op-reth/crates/chainspec/src/lib.rs @@ -218,13 +218,14 @@ impl OpChainSpecBuilder { inner.genesis_header = SealedHeader::seal_slow(make_op_genesis_header(&inner.genesis, &inner.hardforks)); - let (sgt_activation_timestamp, sgt_is_native_backed) = - parse_sgt_config(&inner.genesis); + let (sgt_activation_timestamp, sgt_is_native_backed, l2_blob_activation_timestamp) = + parse_optimism_genesis_config(&inner.genesis); OpChainSpec { inner, sgt_activation_timestamp, sgt_is_native_backed, + l2_blob_activation_timestamp, } } } @@ -239,30 +240,41 @@ pub struct OpChainSpec { pub sgt_activation_timestamp: Option, /// Whether SGT is backed by native (from config.optimism.isSoulBackedByNative) pub sgt_is_native_backed: bool, + /// L2 Blob activation timestamp from genesis config.optimism.l2BlobTime + /// Enables EIP-4844 blob transactions on L2. + pub l2_blob_activation_timestamp: Option, } -/// Parse SGT config from genesis extra fields (config.optimism.soulGasTokenTime / isSoulBackedByNative). -fn parse_sgt_config(genesis: &Genesis) -> (Option, bool) { +/// Parse custom OP config from genesis extra fields (config.optimism.*). +/// +/// Returns (sgt_activation_timestamp, sgt_is_native_backed, l2_blob_activation_timestamp). +fn parse_optimism_genesis_config(genesis: &Genesis) -> (Option, bool, Option) { genesis .config .extra_fields .get("optimism") .and_then(|v| v.as_object()) .map(|obj| { - let timestamp = obj.get("soulGasTokenTime").and_then(|v| v.as_u64()); + let sgt_timestamp = obj.get("soulGasTokenTime").and_then(|v| v.as_u64()); let native_backed = obj .get("isSoulBackedByNative") .and_then(|v| v.as_bool()) .unwrap_or(true); - (timestamp, native_backed) + let l2_blob_timestamp = obj.get("l2BlobTime").and_then(|v| v.as_u64()); + (sgt_timestamp, native_backed, l2_blob_timestamp) }) - .unwrap_or((None, true)) + .unwrap_or((None, true, None)) } impl OpChainSpec { /// Constructs a new [`OpChainSpec`] from the given inner [`ChainSpec`]. pub fn new(inner: ChainSpec) -> Self { - Self { inner, sgt_activation_timestamp: None, sgt_is_native_backed: true } + Self { + inner, + sgt_activation_timestamp: None, + sgt_is_native_backed: true, + l2_blob_activation_timestamp: None, + } } /// Converts the given [`Genesis`] into a [`OpChainSpec`]. @@ -384,6 +396,14 @@ impl OpHardforks for OpChainSpec { fn is_sgt_native_backed(&self) -> bool { self.sgt_is_native_backed } + + fn is_l2_blob_active_at_timestamp(&self, timestamp: u64) -> bool { + self.is_cancun_active_at_timestamp(timestamp) + && self + .l2_blob_activation_timestamp + .map(|activation| timestamp >= activation) + .unwrap_or(false) + } } impl From for OpChainSpec { @@ -471,8 +491,9 @@ impl From for OpChainSpec { let hardforks = ChainHardforks::new(ordered_hardforks); let genesis_header = SealedHeader::seal_slow(make_op_genesis_header(&genesis, &hardforks)); - // Parse SGT config from optimism extra field (same as op-geth genesis format) - let (sgt_activation_timestamp, sgt_is_native_backed) = parse_sgt_config(&genesis); + // Parse custom config from optimism extra field (same as op-geth genesis format) + let (sgt_activation_timestamp, sgt_is_native_backed, l2_blob_activation_timestamp) = + parse_optimism_genesis_config(&genesis); Self { inner: ChainSpec { @@ -488,13 +509,19 @@ impl From for OpChainSpec { }, sgt_activation_timestamp, sgt_is_native_backed, + l2_blob_activation_timestamp, } } } impl From for OpChainSpec { fn from(value: ChainSpec) -> Self { - Self { inner: value, sgt_activation_timestamp: None, sgt_is_native_backed: true } + Self { + inner: value, + sgt_activation_timestamp: None, + sgt_is_native_backed: true, + l2_blob_activation_timestamp: None, + } } } diff --git a/rust/op-reth/crates/chainspec/src/op.rs b/rust/op-reth/crates/chainspec/src/op.rs index 8b3b190f4f8..2d9421ee47a 100644 --- a/rust/op-reth/crates/chainspec/src/op.rs +++ b/rust/op-reth/crates/chainspec/src/op.rs @@ -38,6 +38,7 @@ pub static OP_MAINNET: LazyLock> = LazyLock::new(|| { }, sgt_activation_timestamp: None, sgt_is_native_backed: true, + l2_blob_activation_timestamp: None, } .into() }); diff --git a/rust/op-reth/crates/chainspec/src/op_sepolia.rs b/rust/op-reth/crates/chainspec/src/op_sepolia.rs index 5340330506b..fca4b56f2d0 100644 --- a/rust/op-reth/crates/chainspec/src/op_sepolia.rs +++ b/rust/op-reth/crates/chainspec/src/op_sepolia.rs @@ -36,6 +36,7 @@ pub static OP_SEPOLIA: LazyLock> = LazyLock::new(|| { }, sgt_activation_timestamp: None, sgt_is_native_backed: true, + l2_blob_activation_timestamp: None, } .into() }); diff --git a/rust/op-reth/crates/consensus/src/lib.rs b/rust/op-reth/crates/consensus/src/lib.rs index 7cf199f3099..11bb11f4c4a 100644 --- a/rust/op-reth/crates/consensus/src/lib.rs +++ b/rust/op-reth/crates/consensus/src/lib.rs @@ -129,7 +129,10 @@ where // In Jovian, the blob gas used computation has changed. We are moving the blob base fee // validation to post-execution since the DA footprint calculation is stateful. // Pre-execution we only validate that the blob gas used is present in the header. - if self.chain_spec.is_jovian_active_at_timestamp(block.timestamp()) { + // For L2 Blob, blob gas is validated post-execution (actual blob gas from transactions). + if self.chain_spec.is_l2_blob_active_at_timestamp(block.timestamp()) || + self.chain_spec.is_jovian_active_at_timestamp(block.timestamp()) + { block.blob_gas_used().ok_or(ConsensusError::BlobGasUsedMissing)?; } else if self.chain_spec.is_ecotone_active_at_timestamp(block.timestamp()) { validate_cancun_gas(block)?; @@ -206,11 +209,14 @@ where // In the op-stack, the excess blob gas is always 0 for all blocks after ecotone. // The blob gas used and the excess blob gas should both be set after ecotone. // After Jovian, the blob gas used contains the current DA footprint. + // After L2 Blob activation, excess_blob_gas is calculated using the EIP-4844 formula + // and blob_gas_used reflects actual blob gas consumption. if self.chain_spec.is_ecotone_active_at_timestamp(header.timestamp()) { let blob_gas_used = header.blob_gas_used().ok_or(ConsensusError::BlobGasUsedMissing)?; - // Before Jovian and after ecotone, the blob gas used should be 0. + // Before Jovian/L2Blob and after ecotone, the blob gas used should be 0. if !self.chain_spec.is_jovian_active_at_timestamp(header.timestamp()) && + !self.chain_spec.is_l2_blob_active_at_timestamp(header.timestamp()) && blob_gas_used != 0 { return Err(ConsensusError::BlobGasUsedDiff(GotExpected { @@ -221,7 +227,11 @@ where let excess_blob_gas = header.excess_blob_gas().ok_or(ConsensusError::ExcessBlobGasMissing)?; - if excess_blob_gas != 0 { + // When L2 Blob is active, excess_blob_gas is calculated from parent using + // the EIP-4844 formula (matching op-geth), so it may be non-zero. + if !self.chain_spec.is_l2_blob_active_at_timestamp(header.timestamp()) && + excess_blob_gas != 0 + { return Err(ConsensusError::ExcessBlobGasDiff { diff: GotExpected { got: excess_blob_gas, expected: 0 }, parent_excess_blob_gas: parent.excess_blob_gas().unwrap_or(0), diff --git a/rust/op-reth/crates/consensus/src/validation/mod.rs b/rust/op-reth/crates/consensus/src/validation/mod.rs index 7025066adc2..6c7aaf40bf2 100644 --- a/rust/op-reth/crates/consensus/src/validation/mod.rs +++ b/rust/op-reth/crates/consensus/src/validation/mod.rs @@ -94,8 +94,11 @@ pub fn validate_block_post_execution( result: &BlockExecutionResult, receipt_root_bloom: Option<(B256, Bloom)>, ) -> Result<(), ConsensusError> { - // Validate that the blob gas used is present and correctly computed if Jovian is active. - if chain_spec.is_jovian_active_at_timestamp(header.timestamp()) { + // Validate that the blob gas used is present and correctly computed if Jovian or L2 Blob is + // active. + if chain_spec.is_jovian_active_at_timestamp(header.timestamp()) + || chain_spec.is_l2_blob_active_at_timestamp(header.timestamp()) + { let computed_blob_gas_used = result.blob_gas_used; let header_blob_gas_used = header.blob_gas_used().ok_or(ConsensusError::BlobGasUsedMissing)?; @@ -239,6 +242,7 @@ mod tests { }, sgt_activation_timestamp: None, sgt_is_native_backed: true, + l2_blob_activation_timestamp: None, }) } diff --git a/rust/op-reth/crates/evm/src/build.rs b/rust/op-reth/crates/evm/src/build.rs index 336967499d2..6128b3730b4 100644 --- a/rust/op-reth/crates/evm/src/build.rs +++ b/rust/op-reth/crates/evm/src/build.rs @@ -1,7 +1,7 @@ use alloc::sync::Arc; use alloy_consensus::{ - Block, BlockBody, EMPTY_OMMER_ROOT_HASH, Header, TxReceipt, constants::EMPTY_WITHDRAWALS, - proofs, + Block, BlockBody, BlockHeader, EMPTY_OMMER_ROOT_HASH, Header, TxReceipt, + constants::EMPTY_WITHDRAWALS, proofs, }; use alloy_eips::{eip7685::EMPTY_REQUESTS_HASH, merge::BEACON_NONCE}; use alloy_evm::block::BlockExecutorFactory; @@ -11,6 +11,7 @@ use reth_evm::execute::{BlockAssembler, BlockAssemblerInput}; use reth_execution_errors::BlockExecutionError; use reth_execution_types::BlockExecutionResult; use reth_optimism_consensus::{calculate_receipt_root_no_memo_optimism, isthmus}; +use reth_chainspec::EthChainSpec; use reth_optimism_forks::OpHardforks; use reth_optimism_primitives::DepositReceipt; use reth_primitives_traits::{Receipt, SignedTransaction}; @@ -29,7 +30,7 @@ impl OpBlockAssembler { } } -impl OpBlockAssembler { +impl OpBlockAssembler { /// Builds a block for `input` without any bounds on header `H`. pub fn assemble_block< F: for<'a> BlockExecutorFactory< @@ -37,7 +38,7 @@ impl OpBlockAssembler { Transaction: SignedTransaction, Receipt: Receipt + DepositReceipt, >, - H, + H: BlockHeader, >( &self, input: BlockAssemblerInput<'_, '_, F, H>, @@ -45,6 +46,7 @@ impl OpBlockAssembler { let BlockAssemblerInput { evm_env, execution_ctx: ctx, + parent, transactions, output: BlockExecutionResult { receipts, gas_used, blob_gas_used, requests: _ }, bundle_state, @@ -80,7 +82,21 @@ impl OpBlockAssembler { }; let (excess_blob_gas, blob_gas_used) = - if self.chain_spec.is_jovian_active_at_timestamp(timestamp) { + if self.chain_spec.is_l2_blob_active_at_timestamp(timestamp) { + // When L2 blob is active, calculate excess_blob_gas using the EIP-4844 formula + // from parent header, matching op-geth behavior for blob base fee market on L2. + let blob_params = self.chain_spec.blob_params_at_timestamp(timestamp); + let excess = if parent.header().excess_blob_gas().is_some() { + parent + .header() + .maybe_next_block_excess_blob_gas(blob_params) + .unwrap_or(0) + } else { + // First block after L2 blob activation: parent has no blob gas fields + 0 + }; + (Some(excess), Some(*blob_gas_used)) + } else if self.chain_spec.is_jovian_active_at_timestamp(timestamp) { // In jovian, we're using the blob gas used field to store the current da // footprint's value. (Some(0), Some(*blob_gas_used)) @@ -136,7 +152,7 @@ impl Clone for OpBlockAssembler { impl BlockAssembler for OpBlockAssembler where - ChainSpec: OpHardforks, + ChainSpec: OpHardforks + EthChainSpec, F: for<'a> BlockExecutorFactory< ExecutionCtx<'a> = OpBlockExecutionCtx, Transaction: SignedTransaction, diff --git a/rust/op-reth/crates/evm/src/lib.rs b/rust/op-reth/crates/evm/src/lib.rs index b5d55bf32a8..2f326ab663b 100644 --- a/rust/op-reth/crates/evm/src/lib.rs +++ b/rust/op-reth/crates/evm/src/lib.rs @@ -259,10 +259,23 @@ where cfg_env.sgt_enabled = sgt_config.enabled; cfg_env.sgt_is_native_backed = sgt_config.is_native_backed; - let blob_excess_gas_and_price = spec - .into_eth_spec() - .is_enabled_in(SpecId::CANCUN) - .then_some(BlobExcessGasAndPrice { excess_blob_gas: 0, blob_gasprice: 1 }); + let blob_excess_gas_and_price = + if self.chain_spec().is_l2_blob_active_at_timestamp(timestamp) { + // When L2 blob is active, derive blob gas price from the payload's + // excess_blob_gas using the EIP-4844 formula, matching op-geth behavior. + let blob_params = self.chain_spec().blob_params_at_timestamp(timestamp); + payload.payload.excess_blob_gas().map(|excess_blob_gas| { + let blob_gasprice = blob_params + .map(|params| params.calc_blob_fee(excess_blob_gas)) + .unwrap_or(1); + BlobExcessGasAndPrice { excess_blob_gas, blob_gasprice } + }) + } else { + // Standard OP-stack: no blob base fee market, excess is always 0. + spec.into_eth_spec() + .is_enabled_in(SpecId::CANCUN) + .then_some(BlobExcessGasAndPrice { excess_blob_gas: 0, blob_gasprice: 1 }) + }; let block_env = BlockEnv { number: U256::from(block_number), diff --git a/rust/op-reth/crates/node/src/node.rs b/rust/op-reth/crates/node/src/node.rs index af71fe27384..541fec27a15 100644 --- a/rust/op-reth/crates/node/src/node.rs +++ b/rust/op-reth/crates/node/src/node.rs @@ -1077,9 +1077,13 @@ where .await; let blob_store = reth_node_builder::components::create_blob_store(ctx)?; - let validator = - TransactionValidationTaskExecutor::eth_builder(ctx.provider().clone(), evm_config) - .no_eip4844() + let mut eth_builder = + TransactionValidationTaskExecutor::eth_builder(ctx.provider().clone(), evm_config); + // Disable EIP-4844 blob transactions unless L2 blob is active + if !ctx.chain_spec().is_l2_blob_active_at_timestamp(ctx.head().timestamp) { + eth_builder = eth_builder.no_eip4844(); + } + let validator = eth_builder .with_max_tx_input_bytes(ctx.config().txpool.max_tx_input_bytes) .kzg_settings(ctx.kzg_settings()?) .set_tx_fee_cap(ctx.config().rpc.rpc_tx_fee_cap) diff --git a/rust/op-reth/crates/payload/src/builder.rs b/rust/op-reth/crates/payload/src/builder.rs index 2477a8bf9a9..e912d6d567b 100644 --- a/rust/op-reth/crates/payload/src/builder.rs +++ b/rust/op-reth/crates/payload/src/builder.rs @@ -625,8 +625,12 @@ where let mut info = ExecutionInfo::new(); for sequencer_tx in self.attributes().sequencer_transactions() { - // A sequencer's block should never contain blob transactions. - if sequencer_tx.value().is_eip4844() { + // A sequencer's block should never contain blob transactions unless L2 blob is active. + if sequencer_tx.value().is_eip4844() + && !self + .chain_spec + .is_l2_blob_active_at_timestamp(self.attributes().timestamp()) + { return Err(PayloadBuilderError::other( OpPayloadBuilderError::BlobTransactionRejected, )); @@ -716,8 +720,14 @@ where continue; } - // A sequencer's block should never contain blob or deposit transactions from the pool. - if tx.is_eip4844() || tx.is_deposit() { + // A sequencer's block should never contain deposit transactions from the pool. + // Blob transactions are also rejected unless L2 blob is active. + if tx.is_deposit() + || (tx.is_eip4844() + && !self + .chain_spec + .is_l2_blob_active_at_timestamp(self.attributes().timestamp())) + { best_txs.mark_invalid(tx.signer(), tx.nonce()); continue; } diff --git a/rust/op-reth/crates/txpool/src/validator.rs b/rust/op-reth/crates/txpool/src/validator.rs index 85e0882ec20..15709ceb402 100644 --- a/rust/op-reth/crates/txpool/src/validator.rs +++ b/rust/op-reth/crates/txpool/src/validator.rs @@ -174,7 +174,7 @@ where /// /// This behaves the same as [`EthTransactionValidator::validate_one_with_state`], but in /// addition applies OP validity checks: - /// - ensures tx is not eip4844 + /// - ensures tx is not eip4844 (unless L2 blob is active) /// - ensures cross chain transactions are valid wrt locally configured safety level /// - ensures that the account has enough balance to cover the L1 gas cost pub async fn validate_one_with_state( @@ -183,7 +183,9 @@ where transaction: Tx, state: &mut Option>, ) -> TransactionValidationOutcome { - if transaction.is_eip4844() { + if transaction.is_eip4844() + && !self.chain_spec().is_l2_blob_active_at_timestamp(self.block_timestamp()) + { return TransactionValidationOutcome::Invalid( transaction, InvalidTransactionError::TxTypeNotSupported.into(),