Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
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
21 changes: 21 additions & 0 deletions op-acceptance-tests/tests/proofs/zk/setup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package zk

import (
gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-core/devfeatures"
"github.com/ethereum-optimism/optimism/op-devstack/devtest"
"github.com/ethereum-optimism/optimism/op-devstack/presets"
"github.com/ethereum-optimism/optimism/op-devstack/sysgo"
)

func zkOpts() []presets.Option {
return []presets.Option{
presets.WithGameTypeAdded(gameTypes.ZKDisputeGameType),
presets.WithDeployerOptions(sysgo.WithDevFeatureEnabled(devfeatures.ZKDisputeGameFlag)),
presets.WithDeployerOptions(sysgo.WithJovianAtGenesis),
}
}

func newSystem(t devtest.T) *presets.Minimal {
return presets.NewMinimal(t, zkOpts()...)
}
23 changes: 23 additions & 0 deletions op-acceptance-tests/tests/proofs/zk/smoke_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package zk

import (
"testing"

"github.com/ethereum-optimism/optimism/op-devstack/devtest"
)

// TestSmoke verifies that op-deployer correctly deploys and registers the ZK
// dispute game when ZKDisputeGameFlag is enabled.
func TestSmoke(gt *testing.T) {
t := devtest.ParallelT(gt)
sys := newSystem(t)
require := t.Require()

zk := sys.DisputeGameFactory().ZKGameImpl()

require.NotEmpty(zk.Address, "ZK dispute game impl must be registered in DisputeGameFactory")
require.NotZero(zk.Args.MaxChallengeDuration, "maxChallengeDuration must be set")
require.NotZero(zk.Args.MaxProveDuration, "maxProveDuration must be set")
require.Positive(zk.Args.ChallengerBond.Sign(), "challengerBond must be non-zero")
require.Equal(sys.L2Chain.ChainID().ToBig(), zk.Args.L2ChainID, "l2ChainId must match deployed chain")
}
1 change: 1 addition & 0 deletions op-chain-ops/addresses/contracts.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type ImplementationsContracts struct {
AnchorStateRegistryImpl common.Address
FaultDisputeGameImpl common.Address
PermissionedDisputeGameImpl common.Address
ZkDisputeGameImpl common.Address
StorageSetterImpl common.Address
}

Expand Down
1 change: 1 addition & 0 deletions op-deployer/pkg/deployer/opcm/opchain.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ type ReadImplementationAddressesOutput struct {
PermissionedDisputeGame common.Address
SuperFaultDisputeGame common.Address
SuperPermissionedDisputeGame common.Address
ZkDisputeGame common.Address
OpcmStandardValidator common.Address
OpcmInteropMigrator common.Address
}
Expand Down
51 changes: 51 additions & 0 deletions op-deployer/pkg/deployer/pipeline/dispute_games.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import (
gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-deployer/pkg/deployer/opcm"
"github.com/ethereum-optimism/optimism/op-deployer/pkg/deployer/state"
"github.com/ethereum-optimism/optimism/op-deployer/pkg/deployer/upgrade/embedded"
"github.com/ethereum-optimism/optimism/op-service/eth"
"github.com/ethereum/go-ethereum/common"
"github.com/lmittmann/w3"
)

func DeployAdditionalDisputeGames(
Expand Down Expand Up @@ -121,6 +123,51 @@ func deployDisputeGame(
}
vmAddr = out.MipsSingleton
}
case state.VMTypeZK:
zkImpl := st.ImplementationsDeployment.ZkDisputeGameImpl
if zkImpl == (common.Address{}) {
return fmt.Errorf("ZkDisputeGameImpl is not deployed; ensure ZKDisputeGameFlag is set in devFeatureBitmap")
}
if game.ZKDisputeGame == nil {
return fmt.Errorf("ZKDisputeGame params must be set when VMType is ZK")
}
if game.DisputeGameType != uint32(embedded.GameTypeZKDisputeGame) {
return fmt.Errorf("DisputeGameType must be %d for ZK dispute game, got %d", embedded.GameTypeZKDisputeGame, game.DisputeGameType)
}
zk := game.ZKDisputeGame
if zk.ChallengerBond == nil || zk.ChallengerBond.ToInt().Sign() <= 0 {
return fmt.Errorf("ZKDisputeGame.ChallengerBond must be set to a positive value")
}
challengerBond := zk.ChallengerBond.ToInt()
encoded, err := zkGameArgEncoder.EncodeArgs(&embedded.ZKDisputeGameConfig{
AbsolutePrestate: zk.AbsolutePrestate,
Verifier: zk.Verifier,
MaxChallengeDuration: zk.MaxChallengeDuration,
MaxProveDuration: zk.MaxProveDuration,
ChallengerBond: challengerBond,
})
if err != nil {
return fmt.Errorf("failed to encode ZK game args: %w", err)
}
zkInput := opcm.SetDisputeGameImplInput{
Factory: thisState.OpChainContracts.DisputeGameFactoryProxy,
Impl: zkImpl,
AnchorStateRegistry: common.Address{},
GameType: game.DisputeGameType,
GameArgs: encoded[4:],
}
if game.MakeRespected {
zkInput.AnchorStateRegistry = thisState.OpChainContracts.AnchorStateRegistryProxy
}
if err := opcm.SetDisputeGameImpl(env.L1ScriptHost, zkInput); err != nil {
return fmt.Errorf("failed to set ZK dispute game impl: %w", err)
}
thisState.AdditionalDisputeGames = append(thisState.AdditionalDisputeGames, state.AdditionalDisputeGameState{
GameType: game.DisputeGameType,
VMType: game.VMType,
GameAddress: zkImpl,
})
return nil
default:
return fmt.Errorf("unsupported VM type: %v", game.VMType)
}
Expand Down Expand Up @@ -213,6 +260,10 @@ func deployDisputeGame(
return nil
}

// zkGameArgEncoder encodes the ZK dispute game args for SetDisputeGameImpl.
// Mirrors the zkEncoder in upgrade/embedded/upgrade.go (same ABI signature).
var zkGameArgEncoder = w3.MustNewFunc("dummy((bytes32 absolutePrestate,address verifier,uint64 maxChallengeDuration,uint64 maxProveDuration,uint256 challengerBond))", "")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might be wrong? ZK_ARGS_LENGTH is 172 but this encodes 160 bytes (32*5 bytes)? Am I missing something?

uint256 public constant ZK_ARGS_LENGTH = 172;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dug into this further, it is wrong, and it's not just a length mismatch. Walked through the full call chain and wrote a repro.

zkGameArgEncoder (dispute_games.go:265) is a w3 ABI tuple encoder, producing 5 × 32 = 160 bytes with per-field left-padding. The bytes flow through SetDisputeGameImpl.s.sol:75 into factory.setImplementation(gameType, impl, gameArgs) (DisputeGameFactory.sol:315-316), which stores them verbatim and appends them to every clone as CWIA immutable data (:210). ZKDisputeGame.sol:181-217 reads the packed layout at fixed byte offsets, and since LibGameArgs.sol:116 reverts with InvalidGameArgsLength on anything other than 172 bytes, any clone instantiated against this impl reverts on creation. Not silently broken, actively unusable.

The same tuple encoder works fine in upgrade/embedded/upgrade.go because OPCM re-abi.decodes and re-packs it on-chain via OPContractsManagerUtils.makeGameArgs (:455-467). The deployer path skips that step. The Cannon peer at dispute_games.go:189 uses gameargs.PackPermissionless(), which is the precedent to follow.

Also noticed the blob is missing three fields entirely: anchorStateRegistry, weth, and l2ChainId. Even if we switched to packed encoding without fixing that, we'd still be short, and those need to come from thisState.OpChainContracts and thisIntent.ID.

Wrote a quick repro: https://gist.github.com/wwared/75bf76f775a44410ade3fac80ce402d9

gh pr checkout 20219 --repo ethereum-optimism/optimism
curl -L https://gist.githubusercontent.com/wwared/75bf76f775a44410ade3fac80ce402d9/raw \
  -o op-deployer/pkg/deployer/pipeline/zk_gameargs_encoding_test.go
go test -run 'TestZKGameArgs_' -v ./op-deployer/pkg/deployer/pipeline/...

It encodes a known config via zkGameArgEncoder, strips the selector the same way dispute_games.go:157 does, and compares against the reference 172-byte packed layout. A PASS on TestZKGameArgs_DeployerEncoderProducesWrongLayout means the args are encoded incorrectly: each assertion pins one fact about the broken state (len == 160, verifier reads back as 0x000…0001111…1111 from the ABI pad, etc.). Once the fix lands the assertions flip, and at that point this should be replaced with a golden-byte positive-path test.

On why we didn't catch it earlier: the unit tests in dispute_games_test.go are all negative paths (…_ZeroImpl, …_NilParams, …_WrongDisputeGameType, …_NilChallengerBond, …_ZeroChallengerBond), which return before the encoder runs, so nothing inspects its output. TestEncodedUpgradeInputV2_GameArgsEncoding does pin the 160-byte ABI form byte-for-byte, but only for the upgrade path where OPCM re-packs. And the new smoke test in op-acceptance-tests/tests/proofs/zk/smoke_test.go routes through delegateCallWithSetCode on the OPCM impl (in sysgo/add_game_type.go), which is the upgrade path again. The new DeployAdditionalDisputeGames ZK branch has no coverage.

Fix-wise: swap the tuple encoder for a packed encoder matching OPContractsManagerUtils.makeGameArgs for ZK_DISPUTE_GAME (172 bytes, appending ASR/WETH/chainID), following the Cannon gameargs.PackPermissionless() pattern. Add a golden-byte positive-path test. And ideally a test that actually exercises the deployer path rather than only the upgrade flow.

Copy link
Copy Markdown
Contributor

@wwared wwared Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: it's entirely possible Claude got some stuff wrong in the above comment, but at the very least it does seem like the game args on the ZKDisputeGame.sol contract are different from the ones in this PR (missing the last three):

// - 0xAC gameArgs (absolutePrestate + verifier + durations + bond + registry + weth + l2ChainId)

Copy link
Copy Markdown
Contributor Author

@ashitakah ashitakah Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You were right, fixed everything you flagged.

Swapped the w3 ABI tuple encoder for a proper ZKGameArgs.Pack() in the gameargs package (same pattern as PackPermissionless() for Cannon), now produces the correct 172 packed bytes. Also added the three missing fields (anchorStateRegistry, weth, l2ChainId) from thisState.OpChainContracts and thisIntent.ID, plus a test pinning every offset.

The end-to-end acceptance test for the deployer path is a follow-up, agreed it's missing but I will leave it as a separate issue.


func shouldDeployAdditionalDisputeGames(thisIntent *state.ChainIntent, thisState *state.ChainState) bool {
if len(thisIntent.AdditionalDisputeGames) == 0 {
return false
Expand Down
185 changes: 185 additions & 0 deletions op-deployer/pkg/deployer/pipeline/dispute_games_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package pipeline

import (
"log/slog"
"math/big"
"testing"

"github.com/ethereum-optimism/optimism/op-chain-ops/addresses"
"github.com/ethereum-optimism/optimism/op-core/devfeatures"
"github.com/ethereum-optimism/optimism/op-deployer/pkg/deployer/state"
"github.com/ethereum-optimism/optimism/op-service/testlog"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/stretchr/testify/require"
)

func TestShouldDeployAdditionalDisputeGames(t *testing.T) {
dummyGame := state.AdditionalDisputeGame{VMType: state.VMTypeCannon}

tests := []struct {
name string
intent *state.ChainIntent
st *state.ChainState
expected bool
}{
{
name: "no_games_in_intent",
intent: &state.ChainIntent{},
st: &state.ChainState{},
expected: false,
},
{
name: "games_in_intent_empty_state",
intent: &state.ChainIntent{AdditionalDisputeGames: []state.AdditionalDisputeGame{dummyGame}},
st: &state.ChainState{},
expected: true,
},
{
name: "games_in_intent_already_deployed",
intent: &state.ChainIntent{AdditionalDisputeGames: []state.AdditionalDisputeGame{dummyGame}},
st: &state.ChainState{
AdditionalDisputeGames: []state.AdditionalDisputeGameState{
{GameType: 1, VMType: state.VMTypeCannon},
},
},
expected: false,
},
{
name: "zk_game_in_intent_empty_state",
intent: &state.ChainIntent{AdditionalDisputeGames: []state.AdditionalDisputeGame{{VMType: state.VMTypeZK}}},
st: &state.ChainState{},
expected: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldDeployAdditionalDisputeGames(tt.intent, tt.st)
require.Equal(t, tt.expected, got)
})
}
}

func TestDeployDisputeGame_ZK_ZeroImpl(t *testing.T) {
lgr := testlog.Logger(t, slog.LevelInfo)

env := &Env{Logger: lgr}
st := &state.State{
ImplementationsDeployment: &addresses.ImplementationsContracts{
ZkDisputeGameImpl: common.Address{}, // zero — flag was not active
},
}
game := state.AdditionalDisputeGame{
VMType: state.VMTypeZK,
ZKDisputeGame: &state.ZKDisputeGameParams{
Verifier: common.HexToAddress("0x1111111111111111111111111111111111111111"),
AbsolutePrestate: common.HexToHash("0xdeadbeef"),
ChallengerBond: (*hexutil.Big)(big.NewInt(1e18)),
},
}

err := deployDisputeGame(env, st, &state.ChainIntent{}, &state.ChainState{}, game)
require.ErrorContains(t, err, "ZkDisputeGameImpl is not deployed")
}

func TestDeployDisputeGame_ZK_NilParams(t *testing.T) {
lgr := testlog.Logger(t, slog.LevelInfo)

env := &Env{Logger: lgr}
st := &state.State{
ImplementationsDeployment: &addresses.ImplementationsContracts{
ZkDisputeGameImpl: common.HexToAddress("0x2222222222222222222222222222222222222222"),
},
}
game := state.AdditionalDisputeGame{
VMType: state.VMTypeZK,
ZKDisputeGame: nil, // params not set
}

err := deployDisputeGame(env, st, &state.ChainIntent{}, &state.ChainState{}, game)
require.ErrorContains(t, err, "ZKDisputeGame params must be set")
}

func TestDeployDisputeGame_ZK_WrongDisputeGameType(t *testing.T) {
lgr := testlog.Logger(t, slog.LevelInfo)

env := &Env{Logger: lgr}
st := &state.State{
ImplementationsDeployment: &addresses.ImplementationsContracts{
ZkDisputeGameImpl: common.HexToAddress("0x2222222222222222222222222222222222222222"),
},
}
game := state.AdditionalDisputeGame{
VMType: state.VMTypeZK,
ZKDisputeGame: &state.ZKDisputeGameParams{},
ChainProofParams: state.ChainProofParams{DisputeGameType: 0}, // wrong — must be GameTypeZKDisputeGame (10)
}

err := deployDisputeGame(env, st, &state.ChainIntent{}, &state.ChainState{}, game)
require.ErrorContains(t, err, "DisputeGameType must be")
}

func TestDeployDisputeGame_ZK_NilChallengerBond(t *testing.T) {
lgr := testlog.Logger(t, slog.LevelInfo)

env := &Env{Logger: lgr}
st := &state.State{
ImplementationsDeployment: &addresses.ImplementationsContracts{
ZkDisputeGameImpl: common.HexToAddress("0x2222222222222222222222222222222222222222"),
},
}
game := state.AdditionalDisputeGame{
VMType: state.VMTypeZK,
ZKDisputeGame: &state.ZKDisputeGameParams{
ChallengerBond: nil,
},
ChainProofParams: state.ChainProofParams{DisputeGameType: 10},
}

err := deployDisputeGame(env, st, &state.ChainIntent{}, &state.ChainState{}, game)
require.ErrorContains(t, err, "ChallengerBond must be set")
}

func TestDeployDisputeGame_ZK_ZeroChallengerBond(t *testing.T) {
lgr := testlog.Logger(t, slog.LevelInfo)

env := &Env{Logger: lgr}
st := &state.State{
ImplementationsDeployment: &addresses.ImplementationsContracts{
ZkDisputeGameImpl: common.HexToAddress("0x2222222222222222222222222222222222222222"),
},
}
game := state.AdditionalDisputeGame{
VMType: state.VMTypeZK,
ZKDisputeGame: &state.ZKDisputeGameParams{
ChallengerBond: (*hexutil.Big)(big.NewInt(0)),
},
ChainProofParams: state.ChainProofParams{DisputeGameType: 10},
}

err := deployDisputeGame(env, st, &state.ChainIntent{}, &state.ChainState{}, game)
require.ErrorContains(t, err, "ChallengerBond must be set")
}

func TestDeployDisputeGame_UnsupportedVMType(t *testing.T) {
lgr := testlog.Logger(t, slog.LevelInfo)

env := &Env{Logger: lgr}
st := &state.State{
ImplementationsDeployment: &addresses.ImplementationsContracts{},
}
game := state.AdditionalDisputeGame{
VMType: state.VMType("UNSUPPORTED"),
}

err := deployDisputeGame(env, st, &state.ChainIntent{}, &state.ChainState{}, game)
require.ErrorContains(t, err, "unsupported VM type")
}

// TestZKDisputeGameFlag validates that devfeatures.ZKDisputeGameFlag matches the expected value.
func TestZKDisputeGameFlag(t *testing.T) {
expected := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000001000000")
require.Equal(t, expected, devfeatures.ZKDisputeGameFlag,
"devfeatures.ZKDisputeGameFlag must match the expected value")
}
5 changes: 3 additions & 2 deletions op-deployer/pkg/deployer/pipeline/implementations.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import (
"fmt"
"math/big"

"github.com/ethereum-optimism/optimism/op-chain-ops/addresses"
"github.com/ethereum-optimism/optimism/op-service/jsonutil"
"github.com/ethereum/go-ethereum/common"

"github.com/ethereum-optimism/optimism/op-chain-ops/addresses"
"github.com/ethereum-optimism/optimism/op-deployer/pkg/deployer/opcm"
"github.com/ethereum-optimism/optimism/op-deployer/pkg/deployer/standard"
"github.com/ethereum-optimism/optimism/op-deployer/pkg/deployer/state"
"github.com/ethereum-optimism/optimism/op-service/jsonutil"
)

func DeployImplementations(env *Env, intent *state.Intent, st *state.State) error {
Expand Down Expand Up @@ -100,6 +100,7 @@ func DeployImplementations(env *Env, intent *state.Intent, st *state.State) erro
AnchorStateRegistryImpl: dio.AnchorStateRegistryImpl,
FaultDisputeGameImpl: dio.FaultDisputeGameImpl,
PermissionedDisputeGameImpl: dio.PermissionedDisputeGameImpl,
ZkDisputeGameImpl: dio.ZkDisputeGameImpl,
StorageSetterImpl: dio.StorageSetterImpl,
}

Expand Down
1 change: 1 addition & 0 deletions op-deployer/pkg/deployer/pipeline/opchain.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func DeployOPChain(env *Env, intent *state.Intent, st *state.State, chainID comm
st.ImplementationsDeployment.PreimageOracleImpl = impls.PreimageOracleSingleton
st.ImplementationsDeployment.FaultDisputeGameImpl = impls.FaultDisputeGame
st.ImplementationsDeployment.PermissionedDisputeGameImpl = impls.PermissionedDisputeGame
st.ImplementationsDeployment.ZkDisputeGameImpl = impls.ZkDisputeGame
st.ImplementationsDeployment.OpcmStandardValidatorImpl = impls.OpcmStandardValidator

return nil
Expand Down
Loading
Loading