diff --git a/docker-bake.hcl b/docker-bake.hcl index ffb29e0f5dd..70557412a89 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -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" @@ -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" @@ -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" diff --git a/op-core/forks/forks.go b/op-core/forks/forks.go index c80902b577e..546360ac1c1 100644 --- a/op-core/forks/forks.go +++ b/op-core/forks/forks.go @@ -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. @@ -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] } diff --git a/op-core/forks/forks_test.go b/op-core/forks/forks_test.go new file mode 100644 index 00000000000..e49b1574f34 --- /dev/null +++ b/op-core/forks/forks_test.go @@ -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")) }) + }) +} diff --git a/op-e2e/actions/proofs/nut_bundle_activation_test.go b/op-e2e/actions/proofs/nut_bundle_activation_test.go new file mode 100644 index 00000000000..8d4421055e8 --- /dev/null +++ b/op-e2e/actions/proofs/nut_bundle_activation_test.go @@ -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") + + // 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) + } + + // 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 +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 3e5bbcee3ba..dc230cc65bc 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -5736,10 +5736,14 @@ version = "0.4.5" dependencies = [ "alloy-eips", "alloy-primitives", + "anyhow", "kona-protocol", + "once_cell", "op-alloy-consensus", "op-revm", "revm", + "serde", + "serde_json", ] [[package]] diff --git a/rust/kona/crates/protocol/derive/src/attributes/stateful.rs b/rust/kona/crates/protocol/derive/src/attributes/stateful.rs index 157f1f34856..c2f2997cad2 100644 --- a/rust/kona/crates/protocol/derive/src/attributes/stateful.rs +++ b/rust/kona/crates/protocol/derive/src/attributes/stateful.rs @@ -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) @@ -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, diff --git a/rust/kona/crates/protocol/hardforks/Cargo.toml b/rust/kona/crates/protocol/hardforks/Cargo.toml index 4547c63b004..7e7457534af 100644 --- a/rust/kona/crates/protocol/hardforks/Cargo.toml +++ b/rust/kona/crates/protocol/hardforks/Cargo.toml @@ -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 = [] diff --git a/rust/kona/crates/protocol/hardforks/build.rs b/rust/kona/crates/protocol/hardforks/build.rs new file mode 100644 index 00000000000..f6c9e8f57e9 --- /dev/null +++ b/rust/kona/crates/protocol/hardforks/build.rs @@ -0,0 +1,52 @@ +//! 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::{Context, Result, anyhow}; + +#[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") +} + +fn main() { + if let Err(e) = run() { + panic!("{e:?}"); + } +} diff --git a/rust/kona/crates/protocol/hardforks/build_helpers.rs b/rust/kona/crates/protocol/hardforks/build_helpers.rs new file mode 100644 index 00000000000..e4838163d59 --- /dev/null +++ b/rust/kona/crates/protocol/hardforks/build_helpers.rs @@ -0,0 +1,115 @@ +//! Shared NUT bundle parsing and codegen. +//! +//! Included by `build.rs` at build-script compile time and by +//! `tests/build_codegen.rs` at integration-test compile time. That dual role +//! is the reason these helpers live at the crate root instead of under `src/`. + +// This module is included via `#[path = ...]` from two different compilation +// units. Each unit uses a subset of the public items; suppress the +// per-compilation-unit dead-code warnings rather than gating every item. +#![allow(dead_code, unreachable_pub)] + +use anyhow::{Context, Result, anyhow}; +use serde::Deserialize; + +/// Supported NUT bundle schema version. +pub const SUPPORTED_VERSION: &str = "1.0.0"; + +#[derive(Deserialize)] +pub struct BundleMetadata { + pub version: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BundleTransaction { + pub intent: String, + pub from: String, + pub to: Option, + pub data: String, + pub gas_limit: u64, +} + +#[derive(Deserialize)] +pub struct BundleFile { + pub metadata: BundleMetadata, + pub transactions: Vec, +} + +/// Parse a NUT bundle from JSON, validating its schema version. +pub fn parse_bundle(json: &str) -> Result { + let bundle: BundleFile = serde_json::from_str(json).context("parse NUT bundle JSON")?; + if bundle.metadata.version != SUPPORTED_VERSION { + return Err(anyhow!( + "unsupported NUT bundle version: got {:?}, want {:?}", + bundle.metadata.version, + SUPPORTED_VERSION + )); + } + Ok(bundle) +} + +/// Capitalize the first character of `name` — `"karst"` becomes `"Karst"`. +/// +/// The NUT bundle's `fork_name` feeds into the source-hash derivation and must +/// match the capitalized form used by op-node. +pub fn capitalize(name: &str) -> String { + let mut c = name.chars(); + c.next().map_or_else(String::new, |first| first.to_uppercase().collect::() + c.as_str()) +} + +/// Generate the Rust source for a `fn {name}_nut_bundle() -> &'static NutBundle` constructor. +/// +/// The generated function wraps the bundle construction in a function-scoped +/// `once_cell::race::OnceBox` so the bundle is allocated at most once per +/// process, with subsequent calls returning a reference to the cached value. +/// `OnceBox` is lock-free (AtomicPtr-based) and works on `no_std` targets +/// without requiring a `critical-section` implementation — critical for the +/// cannon/asterisc fault-proof VM targets. +/// +/// `fork_display` is the capitalized form of `name` (see [`capitalize`]). +pub fn format_bundle(name: &str, fork_display: &str, bundle: &BundleFile) -> String { + let mut code = String::new(); + code.push_str("// Auto-generated by build.rs — do not edit.\n\n"); + code.push_str(&format!( + "pub(crate) fn {name}_nut_bundle() -> &'static op_alloy_consensus::NutBundle {{\n" + )); + code.push_str( + " static BUNDLE: once_cell::race::OnceBox\n = once_cell::race::OnceBox::new();\n", + ); + code.push_str(&format!( + " BUNDLE.get_or_init(|| alloc::boxed::Box::new(op_alloy_consensus::NutBundle {{\n fork_name: alloc::string::String::from({fork_display:?}),\n transactions: alloc::vec![\n" + )); + + for tx in &bundle.transactions { + let to_expr = tx.to.as_ref().map_or_else( + || "None".to_string(), + |addr| { + format!( + "Some(alloy_primitives::address!({:?}))", + addr.strip_prefix("0x").unwrap_or(addr) + ) + }, + ); + let data_hex = tx.data.strip_prefix("0x").unwrap_or(&tx.data); + let from_hex = tx.from.strip_prefix("0x").unwrap_or(&tx.from); + + code.push_str(" op_alloy_consensus::NetworkUpgradeTransaction {\n"); + code.push_str(&format!( + " intent: alloc::string::String::from({:?}),\n", + tx.intent + )); + code.push_str(&format!( + " from: alloy_primitives::address!({from_hex:?}),\n" + )); + code.push_str(&format!(" to: {to_expr},\n")); + code.push_str(&format!( + " data: alloy_primitives::Bytes::from_static(&alloy_primitives::hex!({data_hex:?})),\n" + )); + code.push_str(&format!(" gas_limit: {},\n", tx.gas_limit)); + code.push_str(" },\n"); + } + + code.push_str(" ],\n }))\n}\n"); + code +} diff --git a/rust/kona/crates/protocol/hardforks/src/forks.rs b/rust/kona/crates/protocol/hardforks/src/forks.rs index eaad53f63bc..fde3d021323 100644 --- a/rust/kona/crates/protocol/hardforks/src/forks.rs +++ b/rust/kona/crates/protocol/hardforks/src/forks.rs @@ -82,7 +82,7 @@ mod tests { assert_eq!(jovian_upgrade_tx.collect::>().len(), 5); let karst_upgrade_tx = Hardforks::KARST.txs(); - assert_eq!(karst_upgrade_tx.collect::>().len(), 0); + assert_eq!(karst_upgrade_tx.collect::>().len(), 32); let interop_upgrade_tx = Hardforks::INTEROP.txs(); assert_eq!(interop_upgrade_tx.collect::>().len(), 9); diff --git a/rust/kona/crates/protocol/hardforks/src/karst.rs b/rust/kona/crates/protocol/hardforks/src/karst.rs index 7b4c1cbc70e..ca0a89548d4 100644 --- a/rust/kona/crates/protocol/hardforks/src/karst.rs +++ b/rust/kona/crates/protocol/hardforks/src/karst.rs @@ -1,34 +1,53 @@ //! Module containing a `TxDeposit` builder for the Karst network upgrade transactions. //! //! Karst network upgrade transactions are defined in the [OP Stack Specs][specs]. +//! The transactions are loaded from a JSON NUT bundle at compile time via `build.rs`. //! //! [specs]: https://github.com/ethereum-optimism/specs/tree/main/specs/protocol/karst -use alloy_primitives::Bytes; - -use crate::Hardfork; +// Include the build-script-generated NUT bundle constructor. +include!(concat!(env!("OUT_DIR"), "/karst_nut_bundle.rs")); /// The Karst network upgrade transactions. #[derive(Debug, Default, Clone, Copy)] pub struct Karst; -impl Hardfork for Karst { - /// Constructs the network upgrade transactions. - /// Karst has no upgrade transactions (empty NUT bundle). - fn txs(&self) -> impl Iterator + '_ { - core::iter::empty() - } -} +impl_hardfork_from_bundle!(Karst, karst_nut_bundle); #[cfg(test)] mod tests { use super::*; + use crate::Hardfork; use alloc::vec::Vec; + use alloy_primitives::Bytes; + + #[test] + fn test_karst_upgrade_txs() { + let karst = Karst; + let txs: Vec = karst.txs().collect(); + assert_eq!(txs.len(), 32); + + // All encoded deposit txs start with the deposit type byte (0x7e). + for tx in &txs { + assert_eq!(tx[0], 0x7e); + } + } #[test] - fn test_karst_no_upgrade_txs() { + fn test_karst_upgrade_gas() { let karst = Karst; - let txs: Vec<_> = karst.txs().collect(); - assert!(txs.is_empty()); + assert_eq!(karst.upgrade_gas(), 51_600_000); + } + + #[test] + fn test_karst_bundle_valid() { + let bundle = karst_nut_bundle(); + assert_eq!(bundle.fork_name, "Karst"); + assert_eq!(bundle.transactions.len(), 32); + + // Verify all transactions have non-empty intents. + for tx in &bundle.transactions { + assert!(!tx.intent.is_empty()); + } } } diff --git a/rust/kona/crates/protocol/hardforks/src/lib.rs b/rust/kona/crates/protocol/hardforks/src/lib.rs index 4e1bc4c7b7e..a6a2f334c85 100644 --- a/rust/kona/crates/protocol/hardforks/src/lib.rs +++ b/rust/kona/crates/protocol/hardforks/src/lib.rs @@ -9,6 +9,9 @@ extern crate alloc; +#[macro_use] +mod nut_bundle; + mod traits; pub use traits::Hardfork; diff --git a/rust/kona/crates/protocol/hardforks/src/nut_bundle.rs b/rust/kona/crates/protocol/hardforks/src/nut_bundle.rs new file mode 100644 index 00000000000..86590f2c836 --- /dev/null +++ b/rust/kona/crates/protocol/hardforks/src/nut_bundle.rs @@ -0,0 +1,38 @@ +//! Shared pattern for implementing [`crate::Hardfork`] from a NUT bundle constructor. + +/// Implements [`crate::Hardfork`] for a fork type backed by a NUT bundle constructor +/// generated by `build.rs`. +/// +/// Given a type and a `fn() -> NutBundle` returning the bundle, this generates +/// `txs` and `upgrade_gas` — EIP-2718 encoding each deposit transaction and +/// summing per-tx gas. +/// +/// Usage at a fork site: +/// +/// ```ignore +/// include!(concat!(env!("OUT_DIR"), "/karst_nut_bundle.rs")); +/// pub struct Karst; +/// impl_hardfork_from_bundle!(Karst, karst_nut_bundle); +/// ``` +macro_rules! impl_hardfork_from_bundle { + ($ty:ident, $bundle_fn:ident) => { + impl $crate::Hardfork for $ty { + fn txs(&self) -> impl ::core::iter::Iterator + '_ { + use ::alloy_eips::eip2718::Encodable2718; + let bundle = $bundle_fn(); + let deposits = bundle.to_deposit_transactions().unwrap_or_else(|e| { + panic!("{} NUT bundle is invalid: {:?}", ::core::stringify!($ty), e) + }); + deposits.into_iter().map(|tx| { + let mut encoded = ::alloc::vec::Vec::new(); + tx.encode_2718(&mut encoded); + ::alloy_primitives::Bytes::from(encoded) + }) + } + + fn upgrade_gas(&self) -> u64 { + $bundle_fn().total_gas() + } + } + }; +} diff --git a/rust/kona/crates/protocol/hardforks/src/traits.rs b/rust/kona/crates/protocol/hardforks/src/traits.rs index 5d59631de14..588f497e5d9 100644 --- a/rust/kona/crates/protocol/hardforks/src/traits.rs +++ b/rust/kona/crates/protocol/hardforks/src/traits.rs @@ -6,4 +6,13 @@ use alloy_primitives::Bytes; pub trait Hardfork { /// Returns the hardfork upgrade transactions as [`Bytes`]. fn txs(&self) -> impl Iterator + '_; + + /// Returns the additional gas required by upgrade transactions. + /// + /// Starting with Karst, upgrade transactions carry their own gas budget that is + /// added to the block gas limit at the fork activation block. Pre-Karst forks + /// return 0 (upgrade txs ran within the system tx gas allowance). + fn upgrade_gas(&self) -> u64 { + 0 + } } diff --git a/rust/kona/crates/protocol/hardforks/tests/build_codegen.rs b/rust/kona/crates/protocol/hardforks/tests/build_codegen.rs new file mode 100644 index 00000000000..0da4b928c58 --- /dev/null +++ b/rust/kona/crates/protocol/hardforks/tests/build_codegen.rs @@ -0,0 +1,35 @@ +//! Regression test for the build-script bundle code generator. +//! +//! Runs [`format_bundle`] on a committed fixture JSON and asserts the +//! generated Rust source matches the committed expected output byte-for-byte. +//! Any change to the code-generator's output format will break this test. +//! +//! To regenerate the expected fixture after an intentional codegen change: +//! `cargo test -p kona-hardforks --test build_codegen -- --ignored regenerate_expected` + +#[path = "../build_helpers.rs"] +mod build_helpers; + +use build_helpers::{capitalize, format_bundle, parse_bundle}; + +const INPUT_JSON: &str = include_str!("fixtures/test_bundle.json"); +const EXPECTED_OUTPUT: &str = include_str!("fixtures/test_bundle_expected.rs"); + +#[test] +fn generates_expected_rust_source() { + let bundle = parse_bundle(INPUT_JSON).expect("parse fixture bundle"); + let generated = format_bundle("test", &capitalize("test"), &bundle); + assert_eq!(generated, EXPECTED_OUTPUT, "generated source does not match expected fixture"); +} + +/// Regenerate `tests/fixtures/test_bundle_expected.rs` from the current +/// generator output. Run manually after an intentional codegen change: +/// `cargo test -p kona-hardforks --test build_codegen -- --ignored regenerate_expected` +#[test] +#[ignore] +fn regenerate_expected() { + let bundle = parse_bundle(INPUT_JSON).expect("parse fixture bundle"); + let generated = format_bundle("test", &capitalize("test"), &bundle); + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/test_bundle_expected.rs"); + std::fs::write(path, generated).expect("write expected fixture"); +} diff --git a/rust/kona/crates/protocol/hardforks/tests/fixtures/test_bundle.json b/rust/kona/crates/protocol/hardforks/tests/fixtures/test_bundle.json new file mode 100644 index 00000000000..03604498976 --- /dev/null +++ b/rust/kona/crates/protocol/hardforks/tests/fixtures/test_bundle.json @@ -0,0 +1,21 @@ +{ + "metadata": { + "version": "1.0.0" + }, + "transactions": [ + { + "intent": "Deploy FooProxy", + "from": "0x0000000000000000000000000000000000000001", + "to": null, + "data": "0xdeadbeef", + "gasLimit": 100000 + }, + { + "intent": "Upgrade Bar", + "from": "0x0000000000000000000000000000000000000002", + "to": "0x1234567890123456789012345678901234567890", + "data": "0xcafef00d", + "gasLimit": 200000 + } + ] +} diff --git a/rust/kona/crates/protocol/hardforks/tests/fixtures/test_bundle_expected.rs b/rust/kona/crates/protocol/hardforks/tests/fixtures/test_bundle_expected.rs new file mode 100644 index 00000000000..3df26c1e3ab --- /dev/null +++ b/rust/kona/crates/protocol/hardforks/tests/fixtures/test_bundle_expected.rs @@ -0,0 +1,25 @@ +// Auto-generated by build.rs — do not edit. + +pub(crate) fn test_nut_bundle() -> &'static op_alloy_consensus::NutBundle { + static BUNDLE: once_cell::race::OnceBox + = once_cell::race::OnceBox::new(); + BUNDLE.get_or_init(|| alloc::boxed::Box::new(op_alloy_consensus::NutBundle { + fork_name: alloc::string::String::from("Test"), + transactions: alloc::vec![ + op_alloy_consensus::NetworkUpgradeTransaction { + intent: alloc::string::String::from("Deploy FooProxy"), + from: alloy_primitives::address!("0000000000000000000000000000000000000001"), + to: None, + data: alloy_primitives::Bytes::from_static(&alloy_primitives::hex!("deadbeef")), + gas_limit: 100000, + }, + op_alloy_consensus::NetworkUpgradeTransaction { + intent: alloc::string::String::from("Upgrade Bar"), + from: alloy_primitives::address!("0000000000000000000000000000000000000002"), + to: Some(alloy_primitives::address!("1234567890123456789012345678901234567890")), + data: alloy_primitives::Bytes::from_static(&alloy_primitives::hex!("cafef00d")), + gas_limit: 200000, + }, + ], + })) +} diff --git a/rust/kona/docker/apps/kona_app_generic.dockerfile b/rust/kona/docker/apps/kona_app_generic.dockerfile index edae3a25e30..2b0a584fff0 100644 --- a/rust/kona/docker/apps/kona_app_generic.dockerfile +++ b/rust/kona/docker/apps/kona_app_generic.dockerfile @@ -35,6 +35,12 @@ FROM dep-setup-stage AS app-local-setup-stage # Copy in the local workspace repository COPY . /workspace +# Pull in the NUT bundle JSONs from an additional named build context. The +# kona-hardforks build.rs walks ancestors of CARGO_MANIFEST_DIR looking for an +# op-core/ sibling; placing the bundles at /workspace/op-core/nuts/bundles +# satisfies that walk without widening the primary rust/ context. +COPY --from=nuts-bundles / /workspace/op-core/nuts/bundles + ################################ # Remote Repo Setup Stage # ################################ @@ -44,11 +50,13 @@ SHELL ["/bin/bash", "-c"] ARG TAG ARG REPOSITORY -# Clone kona at the specified tag +# Clone kona at the specified tag. op-core is preserved alongside rust so the +# kona-hardforks build.rs ancestor walk finds the NUT bundles. RUN git clone https://github.com/${REPOSITORY} repo && \ cd repo && \ git checkout "${TAG}" && \ - mv rust /workspace + mv rust /workspace && \ + mv op-core /workspace/op-core ################################ # App Build Stage # diff --git a/rust/kona/docker/fpvm-prestates/cannon-repro.dockerfile b/rust/kona/docker/fpvm-prestates/cannon-repro.dockerfile index 78115cffb0d..c949b954ec7 100644 --- a/rust/kona/docker/fpvm-prestates/cannon-repro.dockerfile +++ b/rust/kona/docker/fpvm-prestates/cannon-repro.dockerfile @@ -33,6 +33,10 @@ COPY --from=custom_configs / /usr/local/kona-custom-configs # Copy kona source from build context COPY . /kona +# Pull in the NUT bundle JSONs from the monorepo context so the kona-hardforks +# build.rs ancestor walk finds op-core/ as a parent of the crate directory. +COPY --from=monorepo op-core/nuts/bundles /kona/op-core/nuts/bundles + ENV KONA_CUSTOM_CONFIGS=$KONA_CUSTOM_CONFIGS ENV KONA_CUSTOM_CONFIGS_DIR=/usr/local/kona-custom-configs