Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions op-acceptance-tests/acceptance-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
16 changes: 16 additions & 0 deletions op-acceptance-tests/tests/isthmus/l2blob/init_test.go
Original file line number Diff line number Diff line change
@@ -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)),
)
}
151 changes: 151 additions & 0 deletions op-acceptance-tests/tests/isthmus/l2blob/l2blob_test.go
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions rust/alloy-op-hardforks/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions rust/op-reth/crates/chainspec/src/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ pub static BASE_MAINNET: LazyLock<Arc<OpChainSpec>> = LazyLock::new(|| {
},
sgt_activation_timestamp: None,
sgt_is_native_backed: true,
l2_blob_activation_timestamp: None,
}
.into()
});
1 change: 1 addition & 0 deletions rust/op-reth/crates/chainspec/src/base_sepolia.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub static BASE_SEPOLIA: LazyLock<Arc<OpChainSpec>> = LazyLock::new(|| {
},
sgt_activation_timestamp: None,
sgt_is_native_backed: true,
l2_blob_activation_timestamp: None,
}
.into()
});
1 change: 1 addition & 0 deletions rust/op-reth/crates/chainspec/src/basefee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ mod tests {
},
sgt_activation_timestamp: None,
sgt_is_native_backed: true,
l2_blob_activation_timestamp: None,
})
}

Expand Down
1 change: 1 addition & 0 deletions rust/op-reth/crates/chainspec/src/dev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub static OP_DEV: LazyLock<Arc<OpChainSpec>> = LazyLock::new(|| {
},
sgt_activation_timestamp: None,
sgt_is_native_backed: true,
l2_blob_activation_timestamp: None,
}
.into()
});
49 changes: 38 additions & 11 deletions rust/op-reth/crates/chainspec/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}
Expand All @@ -239,30 +240,41 @@ pub struct OpChainSpec {
pub sgt_activation_timestamp: Option<u64>,
/// 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<u64>,
}

/// Parse SGT config from genesis extra fields (config.optimism.soulGasTokenTime / isSoulBackedByNative).
fn parse_sgt_config(genesis: &Genesis) -> (Option<u64>, 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<u64>, bool, Option<u64>) {
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`].
Expand Down Expand Up @@ -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<Genesis> for OpChainSpec {
Expand Down Expand Up @@ -471,8 +491,9 @@ impl From<Genesis> 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 {
Expand All @@ -488,13 +509,19 @@ impl From<Genesis> for OpChainSpec {
},
sgt_activation_timestamp,
sgt_is_native_backed,
l2_blob_activation_timestamp,
}
}
}

impl From<ChainSpec> 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,
}
}
}

Expand Down
1 change: 1 addition & 0 deletions rust/op-reth/crates/chainspec/src/op.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub static OP_MAINNET: LazyLock<Arc<OpChainSpec>> = LazyLock::new(|| {
},
sgt_activation_timestamp: None,
sgt_is_native_backed: true,
l2_blob_activation_timestamp: None,
}
.into()
});
1 change: 1 addition & 0 deletions rust/op-reth/crates/chainspec/src/op_sepolia.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ pub static OP_SEPOLIA: LazyLock<Arc<OpChainSpec>> = LazyLock::new(|| {
},
sgt_activation_timestamp: None,
sgt_is_native_backed: true,
l2_blob_activation_timestamp: None,
}
.into()
});
16 changes: 13 additions & 3 deletions rust/op-reth/crates/consensus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down Expand Up @@ -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 {
Expand All @@ -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),
Expand Down
Loading