Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
9 changes: 9 additions & 0 deletions docker-bake.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,9 @@ target "op-rbuilder" {
target "kona-node" {
dockerfile = "kona/docker/apps/kona_app_generic.dockerfile"
context = "rust"
contexts = {
nuts-bundles = "op-core/nuts/bundles"
}
args = {
REPO_LOCATION = "local"
BIN_TARGET = "kona-node"
Expand All @@ -358,6 +361,9 @@ target "kona-node" {
target "kona-host" {
dockerfile = "kona/docker/apps/kona_app_generic.dockerfile"
context = "rust"
contexts = {
nuts-bundles = "op-core/nuts/bundles"
}
args = {
REPO_LOCATION = "local"
BIN_TARGET = "kona-host"
Expand All @@ -370,6 +376,9 @@ target "kona-host" {
target "kona-client" {
dockerfile = "kona/docker/apps/kona_app_generic.dockerfile"
context = "rust"
contexts = {
nuts-bundles = "op-core/nuts/bundles"
}
args = {
REPO_LOCATION = "local"
BIN_TARGET = "kona-client"
Expand Down
21 changes: 15 additions & 6 deletions op-core/forks/forks.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,22 @@ func From(start Name) []Name {
panic(fmt.Sprintf("invalid fork: %s", start))
}

var next = func() map[Name]Name {
m := make(map[Name]Name, len(All))
var next, prev = func() (map[Name]Name, map[Name]Name) {
n := make(map[Name]Name, len(All))
p := make(map[Name]Name, len(All))
for i, f := range All {
if i == len(All)-1 {
m[f] = None
break
n[f] = None
} else {
n[f] = All[i+1]
}
if i == 0 {
p[f] = None
} else {
p[f] = All[i-1]
}
m[f] = All[i+1]
}
return m
return n, p
}()

// IsValid returns true if the provided fork is a known fork.
Expand All @@ -82,3 +88,6 @@ func IsValid(f Name) bool {

// Next returns the fork that follows the provided fork, or None if it is the last.
func Next(f Name) Name { return next[f] }

// Prev returns the fork that precedes the provided fork, or None if it is the first.
func Prev(f Name) Name { return prev[f] }
Comment thread
maurelian marked this conversation as resolved.
176 changes: 176 additions & 0 deletions op-e2e/actions/proofs/nut_bundle_activation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package proofs

import (
"context"
"math/big"
"testing"

"github.com/ethereum-optimism/optimism/op-chain-ops/genesis"
"github.com/ethereum-optimism/optimism/op-core/forks"
"github.com/ethereum-optimism/optimism/op-core/predeploys"
actionsHelpers "github.com/ethereum-optimism/optimism/op-e2e/actions/helpers"
"github.com/ethereum-optimism/optimism/op-e2e/actions/proofs/helpers"
"github.com/ethereum-optimism/optimism/op-node/rollup/derive"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/core/types"
"github.com/stretchr/testify/require"
)

// TestActivationBlockNUTBundle verifies that, for every fork from Karst onward,
// the fork's activation block contains exactly the bundle's deposit transactions
// in order, every upgrade tx executes successfully, and the fault-proof program
// can prove the result.
//
// Discovery runs through [forks.From]([forks.Karst]), so any future fork is
// covered automatically — and required to have a NUT bundle registered with
// [derive.UpgradeTransactions]. The only per-fork requirement beyond that is
// that the fork immediately preceding it is registered in [helpers.Hardforks]
// — a one-line entry needed for any fork-parametrized test in this package.
//
// Fork-specific state assertions (e.g. Karst's proxy implementation swap) are
// dispatched via the switch in [testActivationBlockNUTBundle]. Future forks
// with their own post-activation invariants register a case there.
func TestActivationBlockNUTBundle(gt *testing.T) {
matrix := helpers.NewMatrix[forks.Name]()

for _, fork := range forks.From(forks.Karst) {
_, _, err := derive.UpgradeTransactions(fork)
require.NoError(gt, err, "fork %s from Karst onward must have a NUT bundle", fork)

preFork := forks.Prev(fork)
require.NotEqual(gt, forks.None, preFork, "fork %s has no preceding fork in forks.All", fork)
preHelper := lookupHardforkHelper(preFork)
require.NotNil(gt, preHelper,
"no pre-fork helper registered for NUT-bundle fork %s (prior fork: %s); add %s to helpers.Hardforks",
fork, preFork, preFork)

matrix.AddDefaultTestCasesWithName(
string(fork),
fork,
helpers.NewForkMatrix(preHelper),
testActivationBlockNUTBundle,
)
}

matrix.Run(gt)
}

func testActivationBlockNUTBundle(gt *testing.T, testCfg *helpers.TestCfg[forks.Name]) {
fork := testCfg.Custom
t := actionsHelpers.NewDefaultTesting(gt)

offset := uint64(4)
testSetup := func(dc *genesis.DeployConfig) {
dc.L1PragueTimeOffset = ptr(hexutil.Uint64(0))
dc.SetForkTimeOffset(fork, &offset)
}
env := helpers.NewL2FaultProofEnv(t, testCfg, helpers.NewTestParams(), helpers.NewBatcherCfg(), testSetup)

expectedTxs, expectedGas, err := derive.UpgradeTransactions(fork)
require.NoError(t, err, "load NUT bundle for %s", fork)
require.NotEmpty(t, expectedTxs, "bundle for %s must contain at least one upgrade tx", fork)

env.Miner.ActEmptyBlock(t)
env.Sequencer.ActL1HeadSignal(t)
for i := 0; i < int(offset); i++ {
env.Sequencer.ActL2EmptyBlock(t)
}

engine := env.Engine
actHeader := engine.L2Chain().CurrentHeader()
blockTime := env.Sd.RollupCfg.BlockTime
require.Equal(t, fork,
env.Sd.RollupCfg.IsActivationBlock(actHeader.Time-blockTime, actHeader.Time),
Comment thread
maurelian marked this conversation as resolved.
Outdated
"expected activation block for %s at time %d", fork, actHeader.Time)

actBlock := engine.L2Chain().GetBlockByHash(actHeader.Hash())
txs := actBlock.Transactions()
// Index 0 is the L1 info deposit; indices 1.. are the NUT upgrade deposits.
require.Len(t, txs, 1+len(expectedTxs),
"activation block should have 1 L1 info deposit + %d NUT upgrade txs", len(expectedTxs))

var totalUpgradeGas uint64
for i, rawExpected := range expectedTxs {
actualBytes, err := txs[1+i].MarshalBinary()
require.NoError(t, err)
require.Equal(t, []byte(rawExpected), actualBytes, "NUT tx %d byte mismatch", i)

var expected types.Transaction
require.NoError(t, expected.UnmarshalBinary(rawExpected))
totalUpgradeGas += expected.Gas()
}
require.Equal(t, expectedGas, totalUpgradeGas, "total NUT gas must equal bundle total")
Comment thread
maurelian marked this conversation as resolved.
Comment thread
maurelian marked this conversation as resolved.

// Every tx in the activation block — the L1 info deposit and all NUT upgrade
// deposits — must execute successfully. A reverted upgrade tx would leave the
// chain in a broken fork-activation state.
receipts := engine.L2Chain().GetReceiptsByHash(actHeader.Hash())
require.Len(t, receipts, len(txs), "receipt count must match tx count")
for i, r := range receipts {
require.Equal(t, types.ReceiptStatusSuccessful, r.Status,
"activation-block tx %d reverted", i)
}
Comment thread
maurelian marked this conversation as resolved.

// Fork-specific post-activation assertions. Future forks register cases here.
switch fork {
case forks.Karst:
assertKarstActivation(t, env, actHeader)
}

// Advance the safe head across the activation boundary so the fault-proof
// program verifies a non-trivial span including the upgrade block.
env.BatchMineAndSync(t)
l2SafeHead := env.Sequencer.L2Safe()
require.GreaterOrEqual(t, l2SafeHead.Number, actHeader.Number.Uint64(),
Comment thread
maurelian marked this conversation as resolved.
Outdated
"safe head must have progressed past the %s activation block", fork)

env.RunFaultProofProgram(t, l2SafeHead.Number, testCfg.CheckResult, testCfg.InputParams...)
}

// assertKarstActivation verifies Karst-specific state changes: representative
// predeploy proxies' EIP-1967 implementation slots must change across the
// activation block and the new implementations must have code. This is a
// smoke test that the bundle's upgrade transactions actually rewrote proxy
// implementation pointers, not a check of what the new implementations do.
func assertKarstActivation(t actionsHelpers.StatefulTesting, env *helpers.L2FaultProofEnv, actHeader *types.Header) {
ethCl := env.Engine.EthClient()
postBlock := actHeader.Number
preBlock := new(big.Int).Sub(postBlock, big.NewInt(1))

// L1Block and GasPriceOracle mirror the proxies asserted by earlier fork
// tests (ecotone, isthmus); covering them keeps vocabulary consistent
// across fork tests.
proxies := []struct {
name string
addr common.Address
}{
{"L1Block", predeploys.L1BlockAddr},
{"GasPriceOracle", predeploys.GasPriceOracleAddr},
}
for _, p := range proxies {
preImpl, err := ethCl.StorageAt(context.Background(), p.addr, genesis.ImplementationSlot, preBlock)
require.NoError(t, err, "read %s impl slot pre-activation", p.name)
postImpl, err := ethCl.StorageAt(context.Background(), p.addr, genesis.ImplementationSlot, postBlock)
require.NoError(t, err, "read %s impl slot post-activation", p.name)

require.NotEqualf(t, preImpl, postImpl,
"%s (%s) implementation slot must change across Karst activation", p.name, p.addr)

newImplAddr := common.BytesToAddress(postImpl)
code, err := ethCl.CodeAt(context.Background(), newImplAddr, postBlock)
require.NoError(t, err, "read code at new %s impl", p.name)
require.NotEmptyf(t, code, "new %s impl %s must have code", p.name, newImplAddr)
}
}

// lookupHardforkHelper resolves a fork name to its [helpers.Hardfork] entry by
// scanning [helpers.Hardforks]. Returns nil when the fork isn't registered.
func lookupHardforkHelper(name forks.Name) *helpers.Hardfork {
for _, hf := range helpers.Hardforks {
if forks.Name(hf.Name) == name {
return hf
}
}
return nil
}
3 changes: 3 additions & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 9 additions & 3 deletions rust/kona/crates/protocol/derive/src/attributes/stateful.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,14 @@ where
{
upgrade_transactions.append(&mut Hardforks::JOVIAN.txs().collect());
}
// Starting with Karst, upgrade transactions carry their own gas budget that is
// added to the block gas limit at the fork activation block.
let mut upgrade_gas: u64 = 0;
if self.rollup_cfg.is_karst_active(next_l2_time) &&
!self.rollup_cfg.is_karst_active(l2_parent.block_info.timestamp)
{
upgrade_transactions.append(&mut Hardforks::KARST.txs().collect());
upgrade_gas += Hardforks::KARST.upgrade_gas();
}
if self.rollup_cfg.is_interop_active(next_l2_time) &&
!self.rollup_cfg.is_interop_active(l2_parent.block_info.timestamp)
Expand Down Expand Up @@ -214,9 +218,11 @@ where
},
transactions: Some(txs),
no_tx_pool: Some(true),
gas_limit: Some(u64::from_be_bytes(
alloy_primitives::U64::from(sys_config.gas_limit).to_be_bytes(),
)),
gas_limit: Some(
u64::from_be_bytes(
alloy_primitives::U64::from(sys_config.gas_limit).to_be_bytes(),
) + upgrade_gas,
),
eip_1559_params: sys_config.eip_1559_params(
&self.rollup_cfg,
l2_parent.block_info.timestamp,
Expand Down
8 changes: 8 additions & 0 deletions rust/kona/crates/protocol/hardforks/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,18 @@ alloy-primitives = { workspace = true, features = ["rlp"] }
# OP Alloy
op-alloy-consensus.workspace = true

[build-dependencies]
anyhow.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true

[dev-dependencies]
alloy-primitives = { workspace = true, features = ["rand", "arbitrary"] }
anyhow.workspace = true
revm.workspace = true
op-revm.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true

[features]
default = []
Expand Down
55 changes: 55 additions & 0 deletions rust/kona/crates/protocol/hardforks/build.rs
Comment thread
maurelian marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! Build script for `kona-hardforks`.
//!
//! Reads NUT bundle JSON files from `op-core/nuts/bundles/` and generates Rust source
//! that constructs [`op_alloy_consensus::NutBundle`] values at runtime without serde.
//!
//! The parsing and codegen logic lives in [`build_helpers`] so it can be shared
//! with integration tests under `tests/`.

use std::{env, fs, path::PathBuf};

use anyhow::{anyhow, Context, Result};

#[path = "build_helpers.rs"]
mod build_helpers;

use build_helpers::{capitalize, format_bundle, parse_bundle};

/// Read the bundle JSON, generate Rust source, and write it to `out_dir`.
fn generate(name: &str, json_path: &PathBuf, out_dir: &str) -> Result<()> {
let json = fs::read_to_string(json_path)
.with_context(|| format!("read {}", json_path.display()))?;
let bundle = parse_bundle(&json).with_context(|| format!("parse {}", json_path.display()))?;
let code = format_bundle(name, &capitalize(name), &bundle);
let out_path = PathBuf::from(out_dir).join(format!("{name}_nut_bundle.rs"));
fs::write(&out_path, code).with_context(|| format!("write {}", out_path.display()))?;
Ok(())
}

fn run() -> Result<()> {
let out_dir = env::var("OUT_DIR").context("OUT_DIR not set")?;
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").context("CARGO_MANIFEST_DIR not set")?);

let monorepo_root = manifest_dir
.ancestors()
.find(|p| p.join("op-core").is_dir())
.ok_or_else(|| {
anyhow!(
"could not find op-core/ in any ancestor of {}",
manifest_dir.display()
)
})?
.to_path_buf();

let karst_bundle = monorepo_root.join("op-core/nuts/bundles/karst_nut_bundle.json");
println!("cargo::rerun-if-changed={}", karst_bundle.display());

generate("karst", &karst_bundle, &out_dir).context("generate karst bundle")
Comment thread
maurelian marked this conversation as resolved.
}

fn main() {
if let Err(e) = run() {
panic!("{e:?}");
}
}
Loading
Loading