Skip to content

Lagoon upgrade gas: op-node and kona strip a different amount than they add (150k divergence) #22166

Description

@sebastianst

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 sidenot 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

  1. 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.
  2. 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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions