Skip to content
Open
Show file tree
Hide file tree
Changes from all 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.
77 changes: 77 additions & 0 deletions op-core/forks/forks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package forks

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestNext(t *testing.T) {
tests := []struct {
name string
fork Name
expected Name
}{
{"first fork", Bedrock, Regolith},
{"middle fork", Ecotone, Fjord},
{"second-to-last", Karst, Interop},
{"last fork returns None", Interop, None},
{"unknown fork returns None", Name("unknown"), None},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.expected, Next(tc.fork))
})
}
}

func TestPrev(t *testing.T) {
tests := []struct {
name string
fork Name
expected Name
}{
{"first fork returns None", Bedrock, None},
{"second fork", Regolith, Bedrock},
{"middle fork", Fjord, Ecotone},
{"last fork", Interop, Karst},
{"unknown fork returns None", Name("unknown"), None},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.expected, Prev(tc.fork))
})
}
}

func TestNextPrevInverse(t *testing.T) {
// For every mainline fork that is not the first, Prev(Next(prev)) == prev,
// and for every fork that is not the last, Next(Prev(next)) == next.
// This guards against the two maps drifting out of sync.
for i, f := range All {
if i < len(All)-1 {
require.Equal(t, f, Prev(Next(f)), "Prev(Next(%s)) must equal %s", f, f)
}
if i > 0 {
require.Equal(t, f, Next(Prev(f)), "Next(Prev(%s)) must equal %s", f, f)
}
}
}

func TestFrom(t *testing.T) {
t.Run("from first returns all", func(t *testing.T) {
require.Equal(t, All, From(Bedrock))
})
t.Run("from middle returns tail", func(t *testing.T) {
got := From(Ecotone)
require.Equal(t, Ecotone, got[0])
require.Equal(t, All[len(All)-1], got[len(got)-1])
require.Len(t, got, len(All)-4) // Bedrock, Regolith, Canyon, Delta excluded
})
t.Run("from last returns single", func(t *testing.T) {
require.Equal(t, []Name{Interop}, From(Interop))
})
t.Run("unknown fork panics", func(t *testing.T) {
require.Panics(t, func() { From(Name("unknown")) })
})
}
178 changes: 178 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,178 @@
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-optimism/optimism/op-service/bigs"
"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()
require.True(t,
env.Sd.RollupCfg.IsActivationBlockForFork(actHeader.Time, fork),
"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. No new
// L2 blocks are produced past the activation block, so the safe head should
// land exactly on it.
env.BatchMineAndSync(t)
l2SafeHead := env.Sequencer.L2Safe()
require.Equal(t, bigs.Uint64Strict(actHeader.Number), l2SafeHead.Number,
"safe head must be exactly 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
}
4 changes: 4 additions & 0 deletions rust/Cargo.lock

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

11 changes: 8 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,10 @@ 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
13 changes: 13 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,23 @@ alloy-primitives = { workspace = true, features = ["rlp"] }
# OP Alloy
op-alloy-consensus.workspace = true

# Caching for build-script-generated NUT bundle constructors. The `race`
# feature enables `once_cell::race::OnceBox`, which is lock-free and works
# on `no_std` targets without a critical-section implementation.
once_cell = { workspace = true, features = ["race"] }

[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
Loading
Loading