From e49152d458c3c5127270e185280d9208b77461ce Mon Sep 17 00:00:00 2001 From: Angel Soto Date: Mon, 7 Sep 2026 19:52:20 +0200 Subject: [PATCH] feat(contracts-rootstock): carry the dispute-game init bond as an ABI argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RSKDeployOPChain gains an RSK-owned entry point that takes the init bond for the enabled permissioned game as an explicit argument, so oprsk-deployer can supply a configured value — PAYROLLUP-191 ships 0 on regtest, which stops a permissioned network parking proposer capital. runSplitWithBond(input, bond) implementation runSplit(input) applies DEFAULT_INIT_BOND (upstream parity) runWithBytes(bytes) unchanged runSplitWithBytes(bytes) decodes (DeployOPChainInput, uint256) Upstream's Types.DeployOPChainInput is untouched: the extra element lives in this entry point's own tuple, so byte-compatibility with DeployOPChain.run is unaffected and the four parity tests pass unmodified. The script has no notion of "unset" — the caller resolves the effective value, matching how Go already owns every other dispute parameter. Disabled game types keep a literal 0 bond regardless of the argument, which the splitter's config validation requires. The bond tests are ordinary tests: no environment, no harness, no quarantined invocation, so CI stays a single forge test run. A new assertion pins DEFAULT_INIT_BOND to the Go-side constant so an upstream bump has to be reconciled deliberately instead of drifting apart on a merge. PAYROLLUP-191 --- .../script/RSKDeployOPChain.s.sol | 73 +++++++++++++++--- .../test/RSKDeployOPChainInitBond.t.sol | 77 +++++++++++++++++++ .../test/RSKOPCMSplitter.t.sol | 26 ++++++- 3 files changed, 163 insertions(+), 13 deletions(-) create mode 100644 packages/contracts-rootstock/test/RSKDeployOPChainInitBond.t.sol diff --git a/packages/contracts-rootstock/script/RSKDeployOPChain.s.sol b/packages/contracts-rootstock/script/RSKDeployOPChain.s.sol index 4fef281df72..a34affd0541 100644 --- a/packages/contracts-rootstock/script/RSKDeployOPChain.s.sol +++ b/packages/contracts-rootstock/script/RSKDeployOPChain.s.sol @@ -10,12 +10,23 @@ pragma solidity 0.8.15; // deployment + 5 stage calls — each guaranteed to fit under RSKj's RSKIP144 // 6.8M per-tx sublist gas cap. // -// Input/output shapes are byte-identical to upstream's `DeployOPChain.run(...)`, +// The `Output` shape is byte-identical to upstream's `DeployOPChain.run(...)`, // so the existing `cmd/deploy-rollup` Go side parses the broadcast JSON -// without modification. +// without modification. Upstream's `Types.DeployOPChainInput` is likewise +// untouched: the one RSK-owned entry point that carries an extra element (the +// dispute-game init bond) keeps it in its own tuple rather than in that struct. // // Sigs exposed: -// * runSplit((address,...)) → (address[15] tuple matching DeployOPChain.Output) +// * runSplit((address,...)) → upstream parity, applies +// DEFAULT_INIT_BOND +// * runSplitWithBond((address,...),uint256) → explicit init bond +// * runWithBytes(bytes) → upstream-shaped ABI-bytes +// wrapper over runSplit +// * runSplitWithBytes(bytes) → (DeployOPChainInput, uint256); +// the one oprsk-deployer calls +// +// All four return an ABI-encoded `Output` (address[15] tuple matching +// DeployOPChain.Output). // // Other entry points (upgradeSplit, migrateSplit) are deferred — they share // the same splitter contract but aren't on the critical path for the F1 @@ -83,10 +94,11 @@ contract RSKDeployOPChain is Script { } /// @notice ABI-bytes entry point mirroring upstream `DeployOPChain.runWithBytes`. - /// Lets op-deployer's `forge.NewScriptCaller` invoke `runSplit` - /// through the same `BytesScriptEncoder`/`BytesScriptDecoder` - /// pair it uses for the upstream script — no parallel broadcast - /// parser on the Go side. + /// Byte-for-byte the upstream shape: one ABI-encoded + /// `Types.DeployOPChainInput` in, one encoded `Output` out. Kept + /// so the upstream-parity path stays available and testable; + /// `oprsk-deployer` calls `runSplitWithBytes` instead, which also + /// carries the init bond. function runWithBytes(bytes memory _input) public returns (bytes memory) { require(_input.length > 0, "RSKDeployOPChain: input cannot be empty"); Types.DeployOPChainInput memory input = abi.decode(_input, (Types.DeployOPChainInput)); @@ -94,12 +106,45 @@ contract RSKDeployOPChain is Script { return abi.encode(output_); } + /// @notice ABI-bytes entry point used by rskdeployer.DeployOPChainSplit. + /// Same as `runWithBytes` plus an explicit init bond for the + /// enabled permissioned game, so the value travels as a typed + /// argument instead of out-of-band. Upstream's + /// `Types.DeployOPChainInput` is untouched — the extra element + /// lives in this RSK-owned entry point's tuple, which is why + /// upstream compatibility is unaffected. + /// @param _input `abi.encode(Types.DeployOPChainInput, uint256 initBond)`. + function runSplitWithBytes(bytes memory _input) public returns (bytes memory) { + require(_input.length > 0, "RSKDeployOPChain: input cannot be empty"); + (Types.DeployOPChainInput memory input, uint256 bond) = + abi.decode(_input, (Types.DeployOPChainInput, uint256)); + Output memory output_ = runSplitWithBond(input, bond); + return abi.encode(output_); + } + /// @notice Drop-in replacement for upstream `DeployOPChain.run(input)`, /// except the single `OPContractsManagerV2.deploy(config)` call - /// is replaced by a splitter deploy + 5 stage broadcasts. + /// is replaced by a splitter deploy + 5 stage broadcasts. Uses the + /// upstream default init bond; `runSplitWithBond` takes an + /// explicit one. /// @param _input Same `Types.DeployOPChainInput` upstream uses. /// @return output_ Same `Output` shape upstream emits. function runSplit(Types.DeployOPChainInput memory _input) public returns (Output memory output_) { + output_ = runSplitWithBond(_input, DEFAULT_INIT_BOND); + } + + /// @notice `runSplit` with the dispute-game init bond supplied explicitly. + /// Callers that read the bond from configuration (oprsk-deployer) + /// resolve the effective value on the Go side and pass it here, so + /// this script has no notion of "unset". + /// @param _input Same `Types.DeployOPChainInput` upstream uses. + /// @param _initBond Init bond in wei for the enabled permissioned game + /// type. Disabled game types always get 0 regardless. + /// @return output_ Same `Output` shape upstream emits. + function runSplitWithBond(Types.DeployOPChainInput memory _input, uint256 _initBond) + public + returns (Output memory output_) + { checkInput(_input); require(address(_input.opcm).code.length > 0, "RSKDeployOPChain: OPCM address has no code"); @@ -117,7 +162,7 @@ contract RSKDeployOPChain is Script { IOPContractsManagerMigrator migrator = opcmV2.opcmMigrator(); // Build the FullConfig identically to upstream `_toOPCMV2DeployInput`. - RSKOPCMSplitter.FullConfig memory cfg = _toFullConfig(_input); + RSKOPCMSplitter.FullConfig memory cfg = _toFullConfig(_input, _initBond); // ----------- BROADCAST 1 — deploy the splitter ----------- vm.broadcast(msg.sender); @@ -177,7 +222,7 @@ contract RSKDeployOPChain is Script { // — the field shapes are identical by construction. // --------------------------------------------------------------------- - function _toFullConfig(Types.DeployOPChainInput memory _input) + function _toFullConfig(Types.DeployOPChainInput memory _input, uint256 _initBond) internal view returns (RSKOPCMSplitter.FullConfig memory cfg_) @@ -194,6 +239,10 @@ contract RSKDeployOPChain is Script { challenger: _input.challenger }); + // Only the enabled permissioned game takes `_initBond`; every disabled + // entry below keeps a literal 0, which the splitter's config + // validation requires (RSKOPCMSplitter reverts + // `RSKOPCMSplitter_InvalidGameConfigs` on disabled + non-zero). IOPContractsManagerUtils.DisputeGameConfig[] memory disputeGameConfigs = new IOPContractsManagerUtils.DisputeGameConfig[](6); @@ -213,7 +262,7 @@ contract RSKDeployOPChain is Script { }) : IOPContractsManagerUtils.DisputeGameConfig({ enabled: true, - initBond: DEFAULT_INIT_BOND, + initBond: _initBond, gameType: GameTypes.PERMISSIONED_CANNON, gameArgs: abi.encode(pdgConfig) }); @@ -235,7 +284,7 @@ contract RSKDeployOPChain is Script { disputeGameConfigs[4] = isSuperRoot ? IOPContractsManagerUtils.DisputeGameConfig({ enabled: true, - initBond: DEFAULT_INIT_BOND, + initBond: _initBond, gameType: GameTypes.SUPER_PERMISSIONED_CANNON, gameArgs: abi.encode(pdgConfig) }) diff --git a/packages/contracts-rootstock/test/RSKDeployOPChainInitBond.t.sol b/packages/contracts-rootstock/test/RSKDeployOPChainInitBond.t.sol new file mode 100644 index 00000000000..d443c85ef67 --- /dev/null +++ b/packages/contracts-rootstock/test/RSKDeployOPChainInitBond.t.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {GameTypes} from "src/dispute/lib/Types.sol"; + +import {RSKDeployOPChain} from "../script/RSKDeployOPChain.s.sol"; +import {RSKOPCTestBase} from "./helpers/RSKOPCTestBase.sol"; + +/// @notice Tests for the configurable dispute-game init bond that +/// rskdeployer.DeployOPChainSplit supplies from +/// `[deployer].dispute_game_init_bond_wei`. +/// @dev The bond is an ordinary argument, so these are ordinary tests: no +/// process-global state, no harness, no quarantined invocation. The Go +/// side resolves the effective value (config or default) and passes it, +/// which is why the script itself has no notion of "unset". +contract RSKDeployOPChainInitBond_Test is RSKOPCTestBase { + function test_runSplitWithBond_zero_deploysZeroBond() public { + RSKDeployOPChain script = new RSKDeployOPChain(); + RSKDeployOPChain.Output memory output_ = script.runSplitWithBond(deployInput, 0); + + IDisputeGameFactory dgf = IDisputeGameFactory(address(output_.disputeGameFactoryProxy)); + assertEq(dgf.initBonds(GameTypes.PERMISSIONED_CANNON), 0); + assertEq(dgf.initBonds(GameTypes.SUPER_PERMISSIONED_CANNON), 0); + } + + function test_runSplitWithBond_custom_deploysCustomBond() public { + RSKDeployOPChain script = new RSKDeployOPChain(); + RSKDeployOPChain.Output memory output_ = script.runSplitWithBond(deployInput, 12345); + + IDisputeGameFactory dgf = IDisputeGameFactory(address(output_.disputeGameFactoryProxy)); + assertEq(dgf.initBonds(GameTypes.PERMISSIONED_CANNON), 12345); + // Disabled game types keep a zero bond regardless of the argument. + assertEq(dgf.initBonds(GameTypes.SUPER_PERMISSIONED_CANNON), 0); + } + + /// @notice `runSplit` is the upstream-parity path: no bond argument, so it + /// must apply the upstream default. This is what an absent + /// dispute_game_init_bond_wei has to preserve. + function test_runSplit_appliesUpstreamDefaultBond() public { + RSKDeployOPChain script = new RSKDeployOPChain(); + RSKDeployOPChain.Output memory output_ = script.runSplit(deployInput); + + IDisputeGameFactory dgf = IDisputeGameFactory(address(output_.disputeGameFactoryProxy)); + assertEq(dgf.initBonds(GameTypes.PERMISSIONED_CANNON), DEFAULT_INIT_BOND); + } + + /// @notice The RSK entry point carries (input, bond) in one ABI blob; + /// rskdeployer packs exactly this shape. A mismatch here is the + /// failure mode that would break a real deploy at decode time. + function test_runSplitWithBytes_decodesInputAndBond() public { + RSKDeployOPChain script = new RSKDeployOPChain(); + bytes memory encoded = script.runSplitWithBytes(abi.encode(deployInput, uint256(777))); + RSKDeployOPChain.Output memory output_ = abi.decode(encoded, (RSKDeployOPChain.Output)); + + IDisputeGameFactory dgf = IDisputeGameFactory(address(output_.disputeGameFactoryProxy)); + assertEq(dgf.initBonds(GameTypes.PERMISSIONED_CANNON), 777); + // Output must round-trip through the same 15-field shape as runWithBytes. + assertEq(address(output_.disputeGameFactoryProxy), address(dgf)); + assertGt(address(output_.systemConfigProxy).code.length, 0); + } + + function test_runSplitWithBytes_emptyInput_reverts() public { + RSKDeployOPChain script = new RSKDeployOPChain(); + vm.expectRevert("RSKDeployOPChain: input cannot be empty"); + script.runSplitWithBytes(bytes("")); + } + + /// @notice Guards the Go-side default against an upstream bump: rskdeployer + /// hard-codes 0.08 ether as DefaultDisputeGameInitBondWei, so if + /// upstream ever changes this constant the two must be reconciled + /// deliberately rather than drifting apart on a merge. + function test_defaultInitBond_matchesGoSideConstant() public { + RSKDeployOPChain script = new RSKDeployOPChain(); + assertEq(script.DEFAULT_INIT_BOND(), 80_000_000_000_000_000); + } +} diff --git a/packages/contracts-rootstock/test/RSKOPCMSplitter.t.sol b/packages/contracts-rootstock/test/RSKOPCMSplitter.t.sol index b3249bcfed0..395f3b50743 100644 --- a/packages/contracts-rootstock/test/RSKOPCMSplitter.t.sol +++ b/packages/contracts-rootstock/test/RSKOPCMSplitter.t.sol @@ -2,9 +2,11 @@ pragma solidity 0.8.15; import {IProxyAdmin} from "interfaces/universal/IProxyAdmin.sol"; +import {IDisputeGame} from "interfaces/dispute/IDisputeGame.sol"; import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {IFaultDisputeGame} from "interfaces/dispute/IFaultDisputeGame.sol"; import {Features} from "src/libraries/Features.sol"; -import {GameTypes} from "src/dispute/lib/Types.sol"; +import {Claim, GameStatus, GameTypes} from "src/dispute/lib/Types.sol"; import {RSKOPCMSplitter} from "../contracts/RSKOPCMSplitter.sol"; import {RSKOPCTestBase} from "./helpers/RSKOPCTestBase.sol"; @@ -174,6 +176,28 @@ contract RSKOPCMSplitter_Test is RSKOPCTestBase { assertEq(splitter.phase(), 2); } + function test_bondZero_gameCreatesAndResolves() public { + fullConfig.disputeGameConfigs[1].initBond = 0; + RSKOPCMSplitter.ChainContracts memory cts = _runSplitter(); + + IDisputeGameFactory dgf = IDisputeGameFactory(cts.disputeGameFactory); + assertEq(dgf.initBonds(GameTypes.PERMISSIONED_CANNON), 0); + + // With a non-zero bond this zero-value create would revert with + // IncorrectBondAmount; PermissionedDisputeGame requires tx.origin to + // be the proposer, hence the two-argument prank. + vm.prank(proposer, proposer); + IDisputeGame game = + dgf.create{value: 0}(GameTypes.PERMISSIONED_CANNON, Claim.wrap(bytes32(uint256(1))), abi.encode(uint256(1))); + + vm.warp(block.timestamp + maxClockDuration.raw() + 1); + IFaultDisputeGame(address(game)).resolveClaim(0, 0); + game.resolve(); + + assertEq(uint8(game.status()), uint8(GameStatus.DEFENDER_WINS)); + assertEq(IFaultDisputeGame(address(game)).credit(proposer), 0); + } + function test_lateCollaboratorFailure_isAtomicAndRetryable() public { _startAtStep2();