Skip to content
Open
86 changes: 61 additions & 25 deletions op-devstack/dsl/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,12 +296,12 @@ func (b *StandardBridge) forGamePublished(l2BlockNumber *big.Int) disputeGame {
bindings.WithClient(b.l1Client.EthClient()),
bindings.WithTo(game.Proxy),
bindings.WithTest(b.t))
seqNum, err := contractio.Read(gameContract.L2SequenceNumber(), b.ctx)
b.require.NoError(err, "Failed to read sequence number")
seqNum, err := contractio.Read(gameContract.L2BlockNumber(), b.ctx)
b.require.NoError(err, "Failed to read block number")
gameSeqNum = seqNum.Uint64()
b.log.Info("Found latest game", "index", gameIndex, "seqNum", gameSeqNum)
return gameSeqNum >= l2SequenceNumber
}, 90*time.Second, 100*time.Millisecond, "did not find a game of type %v at or after l2 sequence number %v", respectedGameType, l2SequenceNumber)
}, 40*time.Minute, 100*time.Millisecond, "did not find a game of type %v at or after l2 sequence number %v", respectedGameType, l2SequenceNumber)

gameBlockNum := gameSeqNum
if superRootsActive {
Expand Down Expand Up @@ -373,25 +373,34 @@ func (w *Withdrawal) Prove(user *EOA) {
var params ProvenWithdrawalParameters

w.t.Log("proveWithdrawal: proving withdrawal...")
params = w.proveWithdrawalParameters()
tx := bindings.WithdrawalTransaction{
Nonce: params.Nonce,
Sender: params.Sender,
Target: params.Target,
Value: params.Value,
GasLimit: params.GasLimit,
Data: params.Data,
}

var call bindings.TypedCall[any]
if params.SuperRootProof == nil {
call = w.bridge.l1Portal.ProveWithdrawalTransaction(tx, params.DisputeGameIndex, params.OutputRootProof, params.WithdrawalProof)
} else {
call = w.bridge.l1Portal.ProveWithdrawalTransactionSuperRoot(tx, params.DisputeGameAddress, params.OutputRootIndex, *params.SuperRootProof, params.OutputRootProof, params.WithdrawalProof)
}
// Retry as withdrawals can't be proven in the same block as the game is created.
// estimateGas works against the current head so we may need to retry until it has progressed enough.
// First, wait for at least one suitable game to exist (blocking wait).
// This ensures the proposer has created a game covering the withdrawal block.
w.bridge.forGamePublished(w.initReceipt.BlockNumber)

// Retry loop that re-fetches parameters on each attempt.
// A new game may be created during retries that includes the withdrawal in its L2 state.
// Re-computing parameters ensures we use the latest game data.
w.require.Eventually(func() bool {
// Re-fetch parameters on each attempt to get the latest game (non-blocking)
params = w.proveWithdrawalParametersLatestGame()

tx := bindings.WithdrawalTransaction{
Nonce: params.Nonce,
Sender: params.Sender,
Target: params.Target,
Value: params.Value,
GasLimit: params.GasLimit,
Data: params.Data,
}

var call bindings.TypedCall[any]
if params.SuperRootProof == nil {
call = w.bridge.l1Portal.ProveWithdrawalTransaction(tx, params.DisputeGameIndex, params.OutputRootProof, params.WithdrawalProof)
} else {
call = w.bridge.l1Portal.ProveWithdrawalTransactionSuperRoot(tx, params.DisputeGameAddress, params.OutputRootIndex, *params.SuperRootProof, params.OutputRootProof, params.WithdrawalProof)
}

proveReceipt, err := contractio.Write(call, w.ctx, user.Plan())
if err != nil {
w.log.Error("Failed to send prove transaction", "err", err)
Expand All @@ -406,11 +415,38 @@ func (w *Withdrawal) Prove(user *EOA) {
}, 30*time.Second, 1*time.Second, "Sending prove transaction")
}

// ProveWithdrawalParameters calls ProveWithdrawalParametersForBlock with the most recent L2 output after the latest game.
// Ported from op-node/withdrawals/utils.go to fit in the op-devstack
func (w *Withdrawal) proveWithdrawalParameters() ProvenWithdrawalParameters {
// Wait for a suitable game to be published
latestGame := w.bridge.forGamePublished(w.initReceipt.BlockNumber)
// proveWithdrawalParametersLatestGame fetches the latest game (non-blocking) and computes withdrawal parameters.
// This should only be called after forGamePublished has confirmed a suitable game exists.
func (w *Withdrawal) proveWithdrawalParametersLatestGame() ProvenWithdrawalParameters {
respectedGameType := w.bridge.RespectedGameType()
superRootsActive := w.bridge.UsesSuperRoots()

// Get the latest game (non-blocking)
game, gameIndex, err := w.bridge.findLatestGame(respectedGameType)
w.require.NoError(err, "failed to find latest game")

gameContract := bindings.NewBindings[bindings.FaultDisputeGame](
bindings.WithClient(w.bridge.l1Client.EthClient()),
bindings.WithTo(game.Proxy),
bindings.WithTest(w.t))
seqNum, err := contractio.Read(gameContract.L2BlockNumber(), w.ctx)
w.require.NoError(err, "Failed to read block number")
gameSeqNum := seqNum.Uint64()

gameBlockNum := gameSeqNum
if superRootsActive {
blockNum, err := w.bridge.rollupCfg.TargetBlockNumber(gameSeqNum)
w.require.NoError(err, "Failed to convert game timestamp to block number")
gameBlockNum = blockNum
}

latestGame := disputeGame{
Index: gameIndex,
Address: game.Proxy,
L2BlockNumber: gameBlockNum,
SequenceNumber: gameSeqNum,
UsesSuperRoots: superRootsActive,
}

// Fetch the block header from the L2 node
l2Header, err := w.bridge.l2Client.InfoByNumber(w.ctx, latestGame.L2BlockNumber)
Expand Down
88 changes: 88 additions & 0 deletions op-devstack/sysgo/deployer_succinct.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,18 @@ import (
"github.com/ethereum-optimism/optimism/op-devstack/devtest"
"github.com/ethereum-optimism/optimism/op-devstack/stack"
"github.com/ethereum-optimism/optimism/op-e2e/e2eutils/geth"
"github.com/ethereum-optimism/optimism/op-e2e/e2eutils/transactions"
"github.com/ethereum-optimism/optimism/op-service/eth"
"github.com/ethereum-optimism/optimism/op-service/txmgr"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/rpc"
"github.com/lmittmann/w3"
w3eth "github.com/lmittmann/w3/module/eth"
)

// =============================================================
Expand Down Expand Up @@ -451,6 +457,20 @@ func WithDeployOPSuccinctFaultDisputeGamePostDeploy(o *Orchestrator,
l2Net.deployment.sp1Verifier = addrs.Sp1Verifier
l2Net.deployment.anchorStateRegistry = addrs.AnchorStateRegistry
l2Net.deployment.disputeGameFactoryProxy = addrs.FactoryProxy

// Update the original AnchorStateRegistry's respectedGameType to match OP Succinct (42).
// The original ASR (deployed with the chain) defaults to respectedGameType=1, but
// OP Succinct creates games with type 42. Without this, withdrawals via the original
// OptimismPortal2 won't recognize OP Succinct games as valid.
l1EL, ok := o.GetL1EL(l1ELID)
require.True(ok, "l1 EL node required for setRespectedGameType")

originalPortalAddr := l2Net.rollupCfg.DepositContractAddress
l1ChainID := l1CLID.ChainID().ToBig()
logger := o.P().Logger().New("component", "succinct-deployer", "chain", l2CLID.ChainID().String())

err = setRespectedGameType(o.P(), l1EL.UserRPC(), originalPortalAddr, l1ChainID, o.GetKeys(), logger)
require.NoError(err, "failed to set respected game type on original AnchorStateRegistry")
}

// deployOpSuccinctFaultDisputeGame deploys an OPSuccinctFaultDisputeGame contract
Expand Down Expand Up @@ -756,6 +776,74 @@ func parseNamedAddresses(stdoutStr string, names ...string) (map[string]string,
return result, nil
}

// opSuccinctGameType is the game type used by OP Succinct FaultDisputeGame contracts.
const opSuccinctGameType = 42

// setRespectedGameType sets the respected game type on the original AnchorStateRegistry
// to match the OP Succinct game type (42). This is needed because the original
// AnchorStateRegistry (deployed with the chain) defaults to respectedGameType=1,
// but OP Succinct creates games with type 42.
func setRespectedGameType(
p devtest.P,
l1ELRpc string,
originalPortalAddr common.Address,
l1ChainID *big.Int,
keys devkeys.Keys,
logger log.Logger,
) error {
// Connect to L1
rpcClient, err := rpc.DialContext(p.Ctx(), l1ELRpc)
if err != nil {
return fmt.Errorf("failed to dial L1 RPC: %w", err)
}
defer rpcClient.Close()

client := ethclient.NewClient(rpcClient)
w3Client := w3.NewClient(rpcClient)

// Get the original AnchorStateRegistry from the portal
var originalASR common.Address
err = w3Client.Call(w3eth.CallFunc(originalPortalAddr, anchorStateRegistryFn).Returns(&originalASR))
if err != nil {
return fmt.Errorf("failed to get AnchorStateRegistry from portal: %w", err)
}

logger.Info("Setting respected game type on original AnchorStateRegistry",
"portal", originalPortalAddr.Hex(),
"asr", originalASR.Hex(),
"gameType", opSuccinctGameType,
)

// Get the Guardian key (SuperchainConfigGuardianKey is the Guardian of AnchorStateRegistry)
guardianKey, err := keys.Secret(devkeys.SuperchainConfigGuardianKey.Key(l1ChainID))
if err != nil {
return fmt.Errorf("failed to get Guardian key: %w", err)
}

// Encode the call data
data, err := setRespectedGameTypeFn.EncodeArgs(uint32(opSuccinctGameType))
if err != nil {
return fmt.Errorf("failed to encode setRespectedGameType args: %w", err)
}

// Send the transaction
candidate := txmgr.TxCandidate{
To: &originalASR,
TxData: data,
GasLimit: 100_000,
}
_, receipt, err := transactions.SendTx(p.Ctx(), client, candidate, guardianKey)
if err != nil {
return fmt.Errorf("failed to send setRespectedGameType tx: %w", err)
}
if receipt.Status != types.ReceiptStatusSuccessful {
return fmt.Errorf("setRespectedGameType tx failed: status=%d", receipt.Status)
}

logger.Info("Successfully set respected game type", "txHash", receipt.TxHash.Hex())
return nil
}

// WriteEnvFile writes key-value pairs to a file in .env format.
func WriteEnvFile(path string, kv map[string]string) error {
var keys []string
Expand Down
23 changes: 12 additions & 11 deletions op-devstack/sysgo/superroot.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,17 +279,18 @@ const (
)

var (
optimismPortalFn = w3.MustNewFunc("optimismPortal()", "address")
disputeGameFactoryFn = w3.MustNewFunc("disputeGameFactory()", "address")
gameImplsFn = w3.MustNewFunc("gameImpls(uint32)", "address")
gameArgsFn = w3.MustNewFunc("gameArgs(uint32)", "bytes")
ownerFn = w3.MustNewFunc("owner()", "address")
proxyAdminFn = w3.MustNewFunc("proxyAdmin()", "address")
adminFn = w3.MustNewFunc("admin()", "address")
proxyAdminOwnerFn = w3.MustNewFunc("proxyAdminOwner()", "address")
ethLockboxFn = w3.MustNewFunc("ethLockbox()", "address")
anchorStateRegistryFn = w3.MustNewFunc("anchorStateRegistry()", "address")
transferOwnershipFn = w3.MustNewFunc("transferOwnership(address)", "")
optimismPortalFn = w3.MustNewFunc("optimismPortal()", "address")
disputeGameFactoryFn = w3.MustNewFunc("disputeGameFactory()", "address")
gameImplsFn = w3.MustNewFunc("gameImpls(uint32)", "address")
gameArgsFn = w3.MustNewFunc("gameArgs(uint32)", "bytes")
ownerFn = w3.MustNewFunc("owner()", "address")
proxyAdminFn = w3.MustNewFunc("proxyAdmin()", "address")
adminFn = w3.MustNewFunc("admin()", "address")
proxyAdminOwnerFn = w3.MustNewFunc("proxyAdminOwner()", "address")
ethLockboxFn = w3.MustNewFunc("ethLockbox()", "address")
anchorStateRegistryFn = w3.MustNewFunc("anchorStateRegistry()", "address")
transferOwnershipFn = w3.MustNewFunc("transferOwnership(address)", "")
setRespectedGameTypeFn = w3.MustNewFunc("setRespectedGameType(uint32)", "")
)

func getOptimismPortal(t devtest.CommonT, client *w3.Client, systemConfigProxy common.Address) common.Address {
Expand Down
1 change: 1 addition & 0 deletions op-service/txintent/bindings/FaultDisputeGame.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type FaultDisputeGame struct {
// IDisputeGame.sol read methods
L1Head func() TypedCall[common.Hash] `sol:"l1Head"`
L2SequenceNumber func() TypedCall[*big.Int] `sol:"l2SequenceNumber"`
L2BlockNumber func() TypedCall[*big.Int] `sol:"l2BlockNumber"`
Status func() TypedCall[uint8] `sol:"status"`
GameType func() TypedCall[uint32] `sol:"gameType"`

Expand Down