Summary
Since Karst, a NUT-bundle fork adds one-time upgrade gas to its activation block's gas limit so the upgrade deposits don't have to fit inside the normal block budget. The system-config reconstruction subtracts that same amount again when reconstructing from the activation block, so the steady-state gas limit is restored for the next block.
The invariant is: the amount added to the activation block must equal the amount stripped afterwards.
For Lagoon this invariant is violated in both clients, in opposite directions.
The added amount depends on whether the chain has a multi-chain dependency set — a multi-chain activation wraps the NUT bundle in two extra deposits, setFeature(INTEROP) (100,000 gas) and ETHLiquidity.fund() (50,000 gas), for 150,000 gas of wrappers. The strip amount, however, is computed from the rollup config and a timestamp alone, and neither carries dependency-set information — so each client hardcodes one branch, and they picked opposite ones:
- op-node always strips the bundle-only amount.
- kona always strips the bundle + wrappers amount.
Measured
Both measured at eaef4492c89d99e00e266b15f7475b9952b6108c:
|
added at activation block |
stripped for next block |
net |
| op-node, single-chain |
60,503,172 |
60,503,172 |
✅ 0 |
| op-node, multi-chain |
60,653,172 |
60,503,172 |
❌ +150,000 leaked onto every later block |
| kona, single-chain |
60,503,172 |
60,653,172 |
❌ −150,000 over-stripped |
| kona, multi-chain |
60,653,172 |
60,653,172 |
✅ 0 |
So op-node and kona disagree about the reconstructed system-config gas limit by exactly 150,000 gas in both configurations. The divergence starts at the first block after Lagoon activation and persists until the next setGasLimit. Exactly one of the two clients is wrong in each configuration.
Lagoon is not activated on any production chain, so nothing is live-affected — this is fixable before it matters.
Where
Add side — dependency-set aware in both clients:
Strip side — not dependency-set aware in either client:
Karst is unaffected: its bundle has no conditional wrappers, so add and strip agree in both clients.
Repro
kona (rust/kona/crates/protocol/protocol/src/utils.rs, tests module):
#[test]
fn lagoon_add_vs_strip() {
assert_eq!(
Hardforks::LAGOON.upgrade_gas_for_activation(false), // what the add side uses, single-chain
upgrade_gas(OpHardfork::Lagoon), // what the strip side uses
);
}
// left: 60503172, right: 60653172
op-node (op-node/rollup/derive):
_, multiAdd, _ := InteropActivationUpgradeTransactions(true)
strip, _ := UpgradeGas(forks.Lagoon)
// multiAdd = 60653172, strip = 60503172 — 150000 leaked
Why no existing test catches it
- The unit tests are tautological. Both
TestUpgradeGasToStrip (Go) and test_upgrade_gas_to_strip (Rust) compute their expected value with the same call the implementation makes, so they pin the implementation to itself rather than to the add side.
- The cross-client action test skips Lagoon.
testActivationBlockNUTBundle in rust/kona/tests/proofs/nut_bundle_activation_test.go is the only test that runs op-node's block building and kona-client's reconstruction against each other across the activation boundary — and it early-returns for Lagoon before both the gas assertions and the fault-proof runs.
There is also no spec to arbitrate: specs.optimism.io/interop/derivation.html#network-upgrade-transactions still describes the pre-NUT-bundle Interop upgrade (4 deposits, 3,270,000 gas) under the old "upgrade must fit inside the existing limit" model — MaxResourceLimit + SystemTxMaxGas + UpgradeGasUsage ≤ L2GasLimit, requiring L2GasLimit > 24_320_000. The activation-block gas bump and its reversal are not specified anywhere. That should be fixed too.
Suggested fix
Option A (preferred): make the upgrade-gas bump independent of the dependency set. Always add bundleGas + 150_000 at the Lagoon activation block regardless of the dependency set, and always strip the same. The single-chain case then carries 150,000 gas of unused headroom in exactly one block, ever — negligible. Both strip functions stay pure functions of (rollup config, timestamp), which is what every caller can supply. Only the add side changes, and kona's current strip becomes correct as-is.
Option B: thread the dependency set into the reconstruction. PayloadToSystemConfig / to_system_config would both need it. Larger blast radius — many call sites, and kona's single-chain proof path has no dependency set at all (which is separately why the action test can't prove Lagoon spans in single mode).
Whichever is chosen, the tests should assert add-equals-strip rather than re-deriving the expected value from the implementation.
Follow-ups
- Restore the Lagoon gas-limit assertions in
testActivationBlockNUTBundle (skipping only the fault-proof runs). Filed separately / included in the accompanying PR.
- Wire a dependency set into kona-host single so Lagoon activation spans can be proven in the single-chain action test too, closing the cross-client gap properly.
🤖 Co-created with Claude Opus 5
Summary
Since Karst, a NUT-bundle fork adds one-time upgrade gas to its activation block's gas limit so the upgrade deposits don't have to fit inside the normal block budget. The system-config reconstruction subtracts that same amount again when reconstructing from the activation block, so the steady-state gas limit is restored for the next block.
The invariant is: the amount added to the activation block must equal the amount stripped afterwards.
For Lagoon this invariant is violated in both clients, in opposite directions.
The added amount depends on whether the chain has a multi-chain dependency set — a multi-chain activation wraps the NUT bundle in two extra deposits,
setFeature(INTEROP)(100,000 gas) andETHLiquidity.fund()(50,000 gas), for 150,000 gas of wrappers. The strip amount, however, is computed from the rollup config and a timestamp alone, and neither carries dependency-set information — so each client hardcodes one branch, and they picked opposite ones:Measured
Both measured at
eaef4492c89d99e00e266b15f7475b9952b6108c:So op-node and kona disagree about the reconstructed system-config gas limit by exactly 150,000 gas in both configurations. The divergence starts at the first block after Lagoon activation and persists until the next
setGasLimit. Exactly one of the two clients is wrong in each configuration.Lagoon is not activated on any production chain, so nothing is live-affected — this is fixable before it matters.
Where
Add side — dependency-set aware in both clients:
attributes.go#L170-L181→InteropActivationUpgradeTransactionsgated onlen(ba.depSet.Chains()) > 1stateful.rs#L198-L216→upgrade_gas_for_activation(activate_interop_contracts), gated ondependency_set.dependencies.len() > 1Strip side — not dependency-set aware in either client:
upgradeGasToStripcallsUpgradeGas(fork), which returns the bundle total only — the single-chain amount.upgrade_gasmapsLagoontoHardforks::LAGOON.upgrade_gas(), and theHardforkimpl hardcodesupgrade_gas_for_activation(true)— the multi-chain amount.Karst is unaffected: its bundle has no conditional wrappers, so add and strip agree in both clients.
Repro
kona (
rust/kona/crates/protocol/protocol/src/utils.rs, tests module):op-node (
op-node/rollup/derive):Why no existing test catches it
TestUpgradeGasToStrip(Go) andtest_upgrade_gas_to_strip(Rust) compute their expected value with the same call the implementation makes, so they pin the implementation to itself rather than to the add side.testActivationBlockNUTBundleinrust/kona/tests/proofs/nut_bundle_activation_test.gois the only test that runs op-node's block building and kona-client's reconstruction against each other across the activation boundary — and it early-returns for Lagoon before both the gas assertions and the fault-proof runs.There is also no spec to arbitrate: specs.optimism.io/interop/derivation.html#network-upgrade-transactions still describes the pre-NUT-bundle Interop upgrade (4 deposits, 3,270,000 gas) under the old "upgrade must fit inside the existing limit" model —
MaxResourceLimit + SystemTxMaxGas + UpgradeGasUsage ≤ L2GasLimit, requiringL2GasLimit > 24_320_000. The activation-block gas bump and its reversal are not specified anywhere. That should be fixed too.Suggested fix
Option A (preferred): make the upgrade-gas bump independent of the dependency set. Always add
bundleGas + 150_000at the Lagoon activation block regardless of the dependency set, and always strip the same. The single-chain case then carries 150,000 gas of unused headroom in exactly one block, ever — negligible. Both strip functions stay pure functions of(rollup config, timestamp), which is what every caller can supply. Only the add side changes, and kona's current strip becomes correct as-is.Option B: thread the dependency set into the reconstruction.
PayloadToSystemConfig/to_system_configwould both need it. Larger blast radius — many call sites, and kona's single-chain proof path has no dependency set at all (which is separately why the action test can't prove Lagoon spans in single mode).Whichever is chosen, the tests should assert add-equals-strip rather than re-deriving the expected value from the implementation.
Follow-ups
testActivationBlockNUTBundle(skipping only the fault-proof runs). Filed separately / included in the accompanying PR.🤖 Co-created with Claude Opus 5