diff --git a/contracts/deploy/00-home-chain-arbitration-mainnet.ts b/contracts/deploy/00-home-chain-arbitration-mainnet.ts index a3d87f51f..62d6f7614 100644 --- a/contracts/deploy/00-home-chain-arbitration-mainnet.ts +++ b/contracts/deploy/00-home-chain-arbitration-mainnet.ts @@ -31,7 +31,7 @@ const deployArbitration: DeployFunction = async (hre: HardhatRuntimeEnvironment) const classicDisputeKitID = 1; // Classic DK const disputeKit = await deployUpgradable(deployments, "DisputeKitClassic", { from: deployer, - args: [deployer, ZeroAddress, weth.target, classicDisputeKitID], + args: [deployer, ZeroAddress, weth.target], log: true, }); @@ -125,7 +125,7 @@ const deployArbitration: DeployFunction = async (hre: HardhatRuntimeEnvironment) // Extra dispute kits const disputeKitShutter = await deployUpgradable(deployments, "DisputeKitShutter", { from: deployer, - args: [deployer, core.target, weth.target, classicDisputeKitID], + args: [deployer, core.target, weth.target], log: true, }); await core.addNewDisputeKit(disputeKitShutter.address); @@ -133,7 +133,7 @@ const deployArbitration: DeployFunction = async (hre: HardhatRuntimeEnvironment) const disputeKitGated = await deployUpgradable(deployments, "DisputeKitGated", { from: deployer, - args: [deployer, core.target, weth.target, classicDisputeKitID], + args: [deployer, core.target, weth.target], log: true, }); await core.addNewDisputeKit(disputeKitGated.address); @@ -141,7 +141,7 @@ const deployArbitration: DeployFunction = async (hre: HardhatRuntimeEnvironment) const disputeKitGatedShutter = await deployUpgradable(deployments, "DisputeKitGatedShutter", { from: deployer, - args: [deployer, core.target, weth.target, disputeKitShutterID], // Does not jump to DKClassic + args: [deployer, core.target, weth.target], // TODO: jump to a Shutter DK instead of a Classic one? log: true, }); await core.addNewDisputeKit(disputeKitGatedShutter.address); diff --git a/contracts/deploy/00-home-chain-arbitration-university.ts b/contracts/deploy/00-home-chain-arbitration-university.ts index 057e69cfe..855d41b22 100644 --- a/contracts/deploy/00-home-chain-arbitration-university.ts +++ b/contracts/deploy/00-home-chain-arbitration-university.ts @@ -34,7 +34,7 @@ const deployArbitration: DeployFunction = async (hre: HardhatRuntimeEnvironment) const disputeKit = await deployUpgradable(deployments, "DisputeKitClassicUniversity", { from: deployer, contract: "DisputeKitClassic", - args: [deployer, ZeroAddress, weth.target, 1], + args: [deployer, ZeroAddress, weth.target], log: true, }); diff --git a/contracts/deploy/00-home-chain-arbitration.ts b/contracts/deploy/00-home-chain-arbitration.ts index 1c4c29695..eaea77031 100644 --- a/contracts/deploy/00-home-chain-arbitration.ts +++ b/contracts/deploy/00-home-chain-arbitration.ts @@ -37,7 +37,7 @@ const deployArbitration: DeployFunction = async (hre: HardhatRuntimeEnvironment) const classicDisputeKitID = 1; // Classic DK const disputeKit = await deployUpgradable(deployments, "DisputeKitClassic", { from: deployer, - args: [deployer, ZeroAddress, weth.target, classicDisputeKitID], + args: [deployer, ZeroAddress, weth.target], log: true, }); @@ -115,7 +115,7 @@ const deployArbitration: DeployFunction = async (hre: HardhatRuntimeEnvironment) // Extra dispute kits const disputeKitShutter = await deployUpgradable(deployments, "DisputeKitShutter", { from: deployer, - args: [deployer, core.target, weth.target, classicDisputeKitID], + args: [deployer, core.target, weth.target], log: true, }); await core.addNewDisputeKit(disputeKitShutter.address); @@ -124,7 +124,7 @@ const deployArbitration: DeployFunction = async (hre: HardhatRuntimeEnvironment) const disputeKitGated = await deployUpgradable(deployments, "DisputeKitGated", { from: deployer, - args: [deployer, core.target, weth.target, classicDisputeKitID], + args: [deployer, core.target, weth.target], log: true, }); await core.addNewDisputeKit(disputeKitGated.address); @@ -133,7 +133,7 @@ const deployArbitration: DeployFunction = async (hre: HardhatRuntimeEnvironment) const disputeKitGatedShutter = await deployUpgradable(deployments, "DisputeKitGatedShutter", { from: deployer, - args: [deployer, core.target, weth.target, disputeKitShutterID], // Does not jump to DKClassic + args: [deployer, core.target, weth.target], // TODO: jump to a Shutter DK instead of a Classic one? log: true, }); await core.addNewDisputeKit(disputeKitGatedShutter.address); diff --git a/contracts/hardhat.config.ts b/contracts/hardhat.config.ts index 21a62a310..8b77b80ac 100644 --- a/contracts/hardhat.config.ts +++ b/contracts/hardhat.config.ts @@ -32,7 +32,7 @@ const config: HardhatUserConfig = { viaIR: process.env.VIA_IR !== "false", // Defaults to true optimizer: { enabled: true, - runs: 800, // Constrained by the size of the KlerosCore contract + runs: 1000, // Constrained by the size of the KlerosCore contract }, outputSelection: { "*": { diff --git a/contracts/src/arbitration/KlerosCore.sol b/contracts/src/arbitration/KlerosCore.sol index deb46afd8..d59debcbc 100644 --- a/contracts/src/arbitration/KlerosCore.sol +++ b/contracts/src/arbitration/KlerosCore.sol @@ -785,13 +785,13 @@ contract KlerosCore is IArbitratorV2, Initializable, UUPSProxiable { Round storage extraRound = dispute.rounds.push(); uint256 extraRoundID = dispute.rounds.length - 1; - (uint96 newCourtID, uint256 newDisputeKitID, bool courtJump, ) = _getCourtAndDisputeKitJumps( + (uint96 newCourtID, uint256 newDisputeKitID, ) = _getCompatibleNextRoundSettings( dispute, round, courts[dispute.courtID], _disputeID ); - if (courtJump) { + if (newCourtID != dispute.courtID) { emit CourtJump(_disputeID, extraRoundID, dispute.courtID, newCourtID); } @@ -1080,31 +1080,26 @@ contract KlerosCore is IArbitratorV2, Initializable, UUPSProxiable { /// @notice Gets the cost of appealing a specified dispute. /// @param _disputeID The ID of the dispute. - /// @return cost The appeal cost. - function appealCost(uint256 _disputeID) public view returns (uint256 cost) { + /// @return The appeal cost. + function appealCost(uint256 _disputeID) public view returns (uint256) { Dispute storage dispute = disputes[_disputeID]; Round storage round = dispute.rounds[dispute.rounds.length - 1]; Court storage court = courts[dispute.courtID]; - - (, uint256 newDisputeKitID, bool courtJump, ) = _getCourtAndDisputeKitJumps(dispute, round, court, _disputeID); - - uint256 nbVotesAfterAppeal = disputeKits[newDisputeKitID].getNbVotesAfterAppeal( - disputeKits[round.disputeKitID], - round.nbVotes + (uint96 newCourtID, , uint256 nbVotesAfterAppeal) = _getCompatibleNextRoundSettings( + dispute, + round, + court, + _disputeID ); - - if (courtJump) { - // Jump to parent court. - if (dispute.courtID == GENERAL_COURT) { - // TODO: Handle the forking when appealed in General court. - cost = NON_PAYABLE_AMOUNT; // Get the cost of the parent court. - } else { - cost = courts[court.parent].feeForJuror * nbVotesAfterAppeal; - } - } else { - // Stay in current court. - cost = court.feeForJuror * nbVotesAfterAppeal; + if (newCourtID == dispute.courtID) { + // No court jump + return court.feeForJuror * nbVotesAfterAppeal; + } + if (dispute.courtID != GENERAL_COURT && newCourtID != FORKING_COURT) { + // Court jump but not to the Forking court + return courts[newCourtID].feeForJuror * nbVotesAfterAppeal; } + return NON_PAYABLE_AMOUNT; // Jumping to the Forking Court is not supported yet. } /// @notice Gets the start and the end of a specified dispute's current appeal period. @@ -1180,20 +1175,38 @@ contract KlerosCore is IArbitratorV2, Initializable, UUPSProxiable { return dispute.rounds[dispute.rounds.length - 1].nbVotes; } - /// @notice Returns true if the dispute kit will be switched to a parent DK. - /// @param _disputeID The ID of the dispute. - /// @return Whether DK will be switched or not. - function isDisputeKitJumping(uint256 _disputeID) external view returns (bool) { + /// @notice Checks whether a dispute will jump to new court/DK and enforces a compatibility check. + /// @param _disputeID Dispute ID. + /// @return newCourtID Court ID after jump. + /// @return newDisputeKitID Dispute kit ID after jump. + /// @return newRoundNbVotes The number of votes in the new round. + /// @return courtJump Whether the dispute jumps to a new court or not. + /// @return disputeKitJump Whether the dispute jumps to a new dispute kit or not. + function getCourtAndDisputeKitJumps( + uint256 _disputeID + ) + external + view + returns ( + uint96 newCourtID, + uint256 newDisputeKitID, + uint256 newRoundNbVotes, + bool courtJump, + bool disputeKitJump + ) + { Dispute storage dispute = disputes[_disputeID]; Round storage round = dispute.rounds[dispute.rounds.length - 1]; Court storage court = courts[dispute.courtID]; - if (!_isCourtJumping(round, court, _disputeID)) { - return false; - } - - // Jump if the parent court doesn't support the current DK. - return !courts[court.parent].supportedDisputeKits[round.disputeKitID]; + (newCourtID, newDisputeKitID, newRoundNbVotes) = _getCompatibleNextRoundSettings( + dispute, + round, + court, + _disputeID + ); + courtJump = (newCourtID != dispute.courtID); + disputeKitJump = (newDisputeKitID != round.disputeKitID); } /// @notice Returns the length of disputeKits array. @@ -1214,51 +1227,47 @@ contract KlerosCore is IArbitratorV2, Initializable, UUPSProxiable { // * Internal * // // ************************************* // - /// @notice Returns true if the round is jumping to a parent court. - /// @param _round The round to check. - /// @param _court The court to check. - /// @return Whether the round is jumping to a parent court or not. - function _isCourtJumping( - Round storage _round, - Court storage _court, - uint256 _disputeID - ) internal view returns (bool) { - return - disputeKits[_round.disputeKitID].earlyCourtJump(_disputeID) || _round.nbVotes >= _court.jurorsForCourtJump; - } - - /// @notice Checks whether a dispute will jump to new court/DK, and returns new court and DK. + /// @notice Get the next round settings for a given dispute + /// @dev Enforces a compatibility check between the next round's court and dispute kit. /// @param _dispute Dispute data. /// @param _round Round ID. /// @param _court Current court ID. /// @param _disputeID Dispute ID. /// @return newCourtID Court ID after jump. /// @return newDisputeKitID Dispute kit ID after jump. - /// @return courtJump Whether the dispute jumps to a new court or not. - /// @return disputeKitJump Whether the dispute jumps to a new dispute kit or not. - function _getCourtAndDisputeKitJumps( + /// @return newRoundNbVotes The number of votes in the new round. + function _getCompatibleNextRoundSettings( Dispute storage _dispute, Round storage _round, Court storage _court, uint256 _disputeID - ) internal view returns (uint96 newCourtID, uint256 newDisputeKitID, bool courtJump, bool disputeKitJump) { - newCourtID = _dispute.courtID; - newDisputeKitID = _round.disputeKitID; - - if (!_isCourtJumping(_round, _court, _disputeID)) return (newCourtID, newDisputeKitID, false, false); - - // Jump to parent court. - newCourtID = courts[newCourtID].parent; - courtJump = true; - + ) internal view returns (uint96 newCourtID, uint256 newDisputeKitID, uint256 newRoundNbVotes) { + uint256 disputeKitID = _round.disputeKitID; + (newCourtID, newDisputeKitID, newRoundNbVotes) = disputeKits[disputeKitID].getNextRoundSettings( + _disputeID, + _dispute.courtID, + _court.parent, + _court.jurorsForCourtJump, + disputeKitID, + _round.nbVotes + ); + if ( + newCourtID == FORKING_COURT || + newCourtID >= courts.length || + newDisputeKitID == NULL_DISPUTE_KIT || + newDisputeKitID >= disputeKits.length || + newRoundNbVotes == 0 + ) { + // Falling back to the current court and dispute kit with default nbVotes. + newCourtID = _dispute.courtID; + newDisputeKitID = disputeKitID; + newRoundNbVotes = (_round.nbVotes * 2) + 1; + } + // Ensure compatibility between the next round's court and dispute kit. if (!courts[newCourtID].supportedDisputeKits[newDisputeKitID]) { - // The current Dispute Kit is not compatible with the new court, jump to another Dispute Kit. - newDisputeKitID = disputeKits[_round.disputeKitID].getJumpDisputeKitID(); - if (newDisputeKitID == NULL_DISPUTE_KIT || !courts[newCourtID].supportedDisputeKits[newDisputeKitID]) { - // The new Dispute Kit is not defined or still not compatible, fall back to `DisputeKitClassic` which is always supported. - newDisputeKitID = DISPUTE_KIT_CLASSIC; - } - disputeKitJump = true; + // Falling back to `DisputeKitClassic` which is always supported and with default nbVotes. + newDisputeKitID = DISPUTE_KIT_CLASSIC; + newRoundNbVotes = (_round.nbVotes * 2) + 1; } } diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol b/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol index 95f551a48..5c7ac7bf3 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol @@ -26,14 +26,8 @@ contract DisputeKitClassic is DisputeKitClassicBase { /// @param _owner The owner's address. /// @param _core The KlerosCore arbitrator. /// @param _wNative The wrapped native token address, typically wETH. - /// @param _jumpDisputeKitID The ID of the dispute kit to switch to after the court jump. - function initialize( - address _owner, - KlerosCore _core, - address _wNative, - uint256 _jumpDisputeKitID - ) external initializer { - __DisputeKitClassicBase_initialize(_owner, _core, _wNative, _jumpDisputeKitID); + function initialize(address _owner, KlerosCore _core, address _wNative) external initializer { + __DisputeKitClassicBase_initialize(_owner, _core, _wNative); } // ************************ // diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol b/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol index 9ed66cdfe..4061db5e6 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol @@ -58,6 +58,14 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi bool currentRound; // True if the dispute's current round is active on this Dispute Kit. False if the dispute has jumped to another Dispute Kit. } + struct NextRoundSettings { + bool enabled; // True if the settings are enabled, false otherwise. + uint96 jumpCourtID; // A non-zero value makes the next round use this court ID. Zero is considered as undefined. + uint256 jumpDisputeKitID; // A non-zero value makes the next round use this dispute kit ID. Zero is considered as undefined. + uint256 jumpDisputeKitIDOnCourtJump; // A non-zero value makes the next round use this dispute kit ID ONLY IF the court jumps and `jumpDisputeKitID` is undefined. Zero is considered as undefined. + uint256 nbVotes; // A non-zero value makes the next round use this number of votes. Zero is considered as undefined. + } + // ************************************* // // * Storage * // // ************************************* // @@ -73,7 +81,7 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi bool public singleDrawPerJuror; // Whether each juror can only draw once per dispute, false by default. mapping(uint256 coreDisputeID => Active) public coreDisputeIDToActive; // Active status of the dispute and the current round. address public wNative; // The wrapped native token for safeSend(). - uint256 public jumpDisputeKitID; // The ID of the dispute kit in Kleros Core disputeKits array that the dispute should switch to after the court jump, in case the new court doesn't support this dispute kit. + mapping(uint96 currentCourtID => NextRoundSettings) public courtIDToNextRoundSettings; // The settings for the next round. uint256[50] private __gap; // Reserved slots for future upgrades. @@ -121,6 +129,11 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi /// @param _choice The choice that is being funded. event ChoiceFunded(uint256 indexed _coreDisputeID, uint256 indexed _coreRoundID, uint256 indexed _choice); + /// @notice To be emitted when the next round settings are changed. + /// @param _courtID The ID of the court that the settings are changed for. + /// @param _nextRoundSettings The settings for the next round. + event NextRoundSettingsChanged(uint96 indexed _courtID, NextRoundSettings _nextRoundSettings); + // ************************************* // // * Modifiers * // // ************************************* // @@ -149,17 +162,14 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi /// @param _owner The owner's address. /// @param _core The KlerosCore arbitrator. /// @param _wNative The wrapped native token address, typically wETH. - /// @param _jumpDisputeKitID The ID of the dispute kit to switch to after the court jump. function __DisputeKitClassicBase_initialize( address _owner, KlerosCore _core, - address _wNative, - uint256 _jumpDisputeKitID + address _wNative ) internal onlyInitializing { owner = _owner; core = _core; wNative = _wNative; - jumpDisputeKitID = _jumpDisputeKitID; } // ************************ // @@ -187,10 +197,15 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi core = KlerosCore(_core); } - /// @notice Changes the dispute kit ID used for the jump. - /// @param _jumpDisputeKitID The new value for the `jumpDisputeKitID` storage variable. - function changeJumpDisputeKitID(uint256 _jumpDisputeKitID) external onlyByOwner { - jumpDisputeKitID = _jumpDisputeKitID; + /// @notice Changes the settings for the next round. + /// @param _courtID The ID of the court that the settings are changed for. + /// @param _nextRoundSettings The settings for the next round. + function changeNextRoundSettings( + uint96 _courtID, + NextRoundSettings memory _nextRoundSettings + ) external onlyByOwner { + courtIDToNextRoundSettings[_courtID] = _nextRoundSettings; + emit NextRoundSettingsChanged(_courtID, _nextRoundSettings); } // ************************************* // @@ -420,7 +435,8 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi // At least two sides are fully funded. round.feeRewards = round.feeRewards - appealCost; - if (core.isDisputeKitJumping(_coreDisputeID)) { + (, , , , bool isDisputeKitJumping) = core.getCourtAndDisputeKitJumps(_coreDisputeID); + if (isDisputeKitJumping) { // Don't create a new round in case of a jump, and remove local dispute from the flow. coreDisputeIDToActive[_coreDisputeID].currentRound = false; } else { @@ -617,22 +633,40 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi } /// @inheritdoc IDisputeKit - function earlyCourtJump(uint256 /* _coreDisputeID */) external pure override returns (bool) { - return false; - } - - /// @inheritdoc IDisputeKit - function getNbVotesAfterAppeal( - IDisputeKit /* _previousDisputeKit */, - uint256 _currentNbVotes - ) external pure override returns (uint256) { - return (_currentNbVotes * 2) + 1; - } - - /// @inheritdoc IDisputeKit - function getJumpDisputeKitID() external view override returns (uint256) { - // Fall back to classic DK in case the jump ID is not defined. - return jumpDisputeKitID == 0 ? DISPUTE_KIT_CLASSIC : jumpDisputeKitID; + function getNextRoundSettings( + uint256 /* _coreDisputeID */, + uint96 _currentCourtID, + uint96 _parentCourtID, + uint256 _currentCourtJurorsForJump, + uint256 _currentDisputeKitID, + uint256 _currentRoundNbVotes + ) public view virtual override returns (uint96 newCourtID, uint256 newDisputeKitID, uint256 newRoundNbVotes) { + NextRoundSettings storage nextRoundSettings = courtIDToNextRoundSettings[_currentCourtID]; + uint256 jumpDisputeKitIDOnCourtJump; + if (nextRoundSettings.enabled) { + newRoundNbVotes = nextRoundSettings.nbVotes; + newCourtID = nextRoundSettings.jumpCourtID; + newDisputeKitID = nextRoundSettings.jumpDisputeKitID; // Takes precedence over jumpDisputeKitIDOnCourtJump + jumpDisputeKitIDOnCourtJump = nextRoundSettings.jumpDisputeKitIDOnCourtJump; + } + if (newCourtID == 0) { + // Default court jump logic, unaffected by the newRoundNbVotes override + newCourtID = _currentRoundNbVotes >= _currentCourtJurorsForJump ? _parentCourtID : _currentCourtID; + } + if (newDisputeKitID == 0) { + // jumpDisputeKitID is undefined for next round + if (newCourtID != _currentCourtID && jumpDisputeKitIDOnCourtJump != 0) { + // Override on court jump + newDisputeKitID = jumpDisputeKitIDOnCourtJump; + } else { + // Default dispute kit jump logic + newDisputeKitID = _currentDisputeKitID; + } + } + if (newRoundNbVotes == 0) { + // Default nbVotes logic + newRoundNbVotes = (_currentRoundNbVotes * 2) + 1; + } } /// @inheritdoc IDisputeKit diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol b/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol index 2b5131d81..64190f774 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol @@ -50,14 +50,8 @@ contract DisputeKitGated is DisputeKitClassicBase { /// @param _owner The owner's address. /// @param _core The KlerosCore arbitrator. /// @param _wNative The wrapped native token address, typically wETH. - /// @param _jumpDisputeKitID The ID of the dispute kit to switch to after the court jump. - function initialize( - address _owner, - KlerosCore _core, - address _wNative, - uint256 _jumpDisputeKitID - ) external initializer { - __DisputeKitClassicBase_initialize(_owner, _core, _wNative, _jumpDisputeKitID); + function initialize(address _owner, KlerosCore _core, address _wNative) external initializer { + __DisputeKitClassicBase_initialize(_owner, _core, _wNative); supportedTokens[NO_TOKEN_GATE] = true; // Allows disputes without token gating } diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol b/contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol index 38b3a66e3..e3f1f4906 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol @@ -79,14 +79,8 @@ contract DisputeKitGatedShutter is DisputeKitClassicBase { /// @param _owner The owner's address. /// @param _core The KlerosCore arbitrator. /// @param _wNative The wrapped native token address, typically wETH. - /// @param _jumpDisputeKitID The ID of the dispute kit to switch to after the court jump. - function initialize( - address _owner, - KlerosCore _core, - address _wNative, - uint256 _jumpDisputeKitID - ) external initializer { - __DisputeKitClassicBase_initialize(_owner, _core, _wNative, _jumpDisputeKitID); + function initialize(address _owner, KlerosCore _core, address _wNative) external initializer { + __DisputeKitClassicBase_initialize(_owner, _core, _wNative); supportedTokens[NO_TOKEN_GATE] = true; // Allows disputes without token gating } diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitShutter.sol b/contracts/src/arbitration/dispute-kits/DisputeKitShutter.sol index 53f89d895..6599999a6 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitShutter.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitShutter.sol @@ -60,14 +60,8 @@ contract DisputeKitShutter is DisputeKitClassicBase { /// @param _owner The owner's address. /// @param _core The KlerosCore arbitrator. /// @param _wNative The wrapped native token address, typically wETH. - /// @param _jumpDisputeKitID The ID of the dispute kit to switch to after the court jump. - function initialize( - address _owner, - KlerosCore _core, - address _wNative, - uint256 _jumpDisputeKitID - ) external initializer { - __DisputeKitClassicBase_initialize(_owner, _core, _wNative, _jumpDisputeKitID); + function initialize(address _owner, KlerosCore _core, address _wNative) external initializer { + __DisputeKitClassicBase_initialize(_owner, _core, _wNative); } // ************************ // diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitSybilResistant.sol b/contracts/src/arbitration/dispute-kits/DisputeKitSybilResistant.sol index 983fb63df..644212130 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitSybilResistant.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitSybilResistant.sol @@ -40,15 +40,13 @@ contract DisputeKitSybilResistant is DisputeKitClassicBase { /// @param _core The KlerosCore arbitrator. /// @param _poh The Proof of Humanity registry. /// @param _wNative The wrapped native token address, typically wETH. - /// @param _jumpDisputeKitID The ID of the dispute kit to switch to after the court jump. function initialize( address _owner, KlerosCore _core, IProofOfHumanity _poh, - address _wNative, - uint256 _jumpDisputeKitID + address _wNative ) external initializer { - __DisputeKitClassicBase_initialize(_owner, _core, _wNative, _jumpDisputeKitID); + __DisputeKitClassicBase_initialize(_owner, _core, _wNative); poh = _poh; singleDrawPerJuror = true; } diff --git a/contracts/src/arbitration/interfaces/IDisputeKit.sol b/contracts/src/arbitration/interfaces/IDisputeKit.sol index f5de3d8ec..f5c2efe8e 100644 --- a/contracts/src/arbitration/interfaces/IDisputeKit.sol +++ b/contracts/src/arbitration/interfaces/IDisputeKit.sol @@ -51,6 +51,7 @@ interface IDisputeKit { /// @param _coreDisputeID The ID of the dispute in Kleros Core, not in the Dispute Kit. /// @param _nonce Nonce. /// @return drawnAddress The drawn address. + /// @return fromSubcourtID The subcourt ID from which the juror was drawn. function draw( uint256 _coreDisputeID, uint256 _nonce @@ -123,23 +124,25 @@ interface IDisputeKit { /// @return Whether the appeal funding is finished. function isAppealFunded(uint256 _coreDisputeID) external view returns (bool); - /// @dev Returns true if the dispute is jumping to a parent court. + /// @notice Returns the next round settings for a given dispute. + /// @dev This function does not check for compatibility between `newDisputeKitID` and `newCourtID`, this is the Core's responsibility. /// @param _coreDisputeID The ID of the dispute in Kleros Core, not in the Dispute Kit. - /// @return Whether the dispute is jumping to a parent court or not. - function earlyCourtJump(uint256 _coreDisputeID) external view returns (bool); - - /// @notice Returns the number of votes after the appeal. - /// @param _previousDisputeKit The previous Dispute Kit. - /// @param _currentNbVotes The number of votes before the appeal. - /// @return The number of votes after the appeal. - function getNbVotesAfterAppeal( - IDisputeKit _previousDisputeKit, - uint256 _currentNbVotes - ) external view returns (uint256); - - /// @notice Returns the dispute kit ID to be used after court jump by Kleros Core. - /// @return The ID of the dispute kit in Kleros Core disputeKits array. - function getJumpDisputeKitID() external view returns (uint256); + /// @param _currentCourtID The ID of the current court. + /// @param _parentCourtID The ID of the parent court. + /// @param _currentCourtJurorsForJump The court jump threshold defined by the current court. + /// @param _currentDisputeKitID The ID of the current dispute kit. + /// @param _currentRoundNbVotes The number of votes in the current round. + /// @return newCourtID Court ID after jump. + /// @return newDisputeKitID Dispute kit ID after jump. + /// @return newRoundNbVotes The number of votes in the new round. + function getNextRoundSettings( + uint256 _coreDisputeID, + uint96 _currentCourtID, + uint96 _parentCourtID, + uint256 _currentCourtJurorsForJump, + uint256 _currentDisputeKitID, + uint256 _currentRoundNbVotes + ) external view returns (uint96 newCourtID, uint256 newDisputeKitID, uint256 newRoundNbVotes); /// @notice Returns true if the specified voter was active in this round. /// @param _coreDisputeID The ID of the dispute in Kleros Core, not in the Dispute Kit. diff --git a/contracts/src/test/DisputeKitClassicMockUncheckedNextRoundSettings.sol b/contracts/src/test/DisputeKitClassicMockUncheckedNextRoundSettings.sol new file mode 100644 index 000000000..75a71a4f3 --- /dev/null +++ b/contracts/src/test/DisputeKitClassicMockUncheckedNextRoundSettings.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import "../arbitration/dispute-kits/DisputeKitClassic.sol"; + +/// @title DisputeKitClassicMockUncheckedNextRoundSettings +/// DisputeKitClassic with unchecked next round settings to test `KlerosCore._getCompatibleNextRoundSettings()` fallback logic. +contract DisputeKitClassicMockUncheckedNextRoundSettings is DisputeKitClassic { + function getNextRoundSettings( + uint256 /* _disputeID */, + uint96 _currentCourtID, + uint96 /* _parentCourtID */, + uint256 /* _currentCourtJurorsForJump */, + uint256 /* _currentDisputeKitID */, + uint256 /* _currentRoundNbVotes */ + ) public view override returns (uint96 newCourtID, uint256 newDisputeKitID, uint256 newRoundNbVotes) { + NextRoundSettings storage nextRoundSettings = courtIDToNextRoundSettings[_currentCourtID]; + newRoundNbVotes = nextRoundSettings.nbVotes; + newCourtID = nextRoundSettings.jumpCourtID; + newDisputeKitID = nextRoundSettings.jumpDisputeKitID; + } +} diff --git a/contracts/test/arbitration/helpers/dispute-kit-gated-common.ts b/contracts/test/arbitration/helpers/dispute-kit-gated-common.ts index 4876093b3..ffd4cf0ca 100644 --- a/contracts/test/arbitration/helpers/dispute-kit-gated-common.ts +++ b/contracts/test/arbitration/helpers/dispute-kit-gated-common.ts @@ -180,7 +180,7 @@ export async function setupTokenGatedTest(config: TokenGatedTestConfig): Promise const deploymentResult = await deployUpgradable(deployments, config.contractName, { from: deployer, proxyAlias: "UUPSProxy", - args: [deployer, core.target, weth.target, 1], + args: [deployer, core.target, weth.target], log: true, }); await core.addNewDisputeKit(deploymentResult.address); diff --git a/contracts/test/arbitration/helpers/dispute-kit-shutter-common.ts b/contracts/test/arbitration/helpers/dispute-kit-shutter-common.ts index 699968f9c..133f9c7ac 100644 --- a/contracts/test/arbitration/helpers/dispute-kit-shutter-common.ts +++ b/contracts/test/arbitration/helpers/dispute-kit-shutter-common.ts @@ -246,7 +246,7 @@ export async function setupShutterTest(config: ShutterTestConfig): Promise Court2 -> Court3 -> Court4 + vm.prank(owner); + core.createCourt( + court3ID, // parent + hiddenVotes, + minStake, + alpha, + 0.06 ether, + 3, // Low threshold to ensure jump + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // Configure Court4 to jump directly to GENERAL_COURT (skipping Court3 and Court2) + vm.prank(owner); + disputeKit.changeNextRoundSettings( + court4ID, + DisputeKitClassicBase.NextRoundSettings({ + enabled: true, + jumpCourtID: GENERAL_COURT, // Jump to grandparent, skipping Court3 and Court2 + jumpDisputeKitID: DISPUTE_KIT_CLASSIC, + jumpDisputeKitIDOnCourtJump: 0, + nbVotes: 0 + }) + ); + + // Stake in Court4 and GENERAL_COURT + vm.prank(staker1); + core.setStake(court4ID, 20000); + vm.prank(staker1); + core.setStake(GENERAL_COURT, 20000); + + // Create dispute in Court4 + bytes memory court4ExtraData = abi.encodePacked(uint256(court4ID), DEFAULT_NB_OF_JURORS, DISPUTE_KIT_CLASSIC); + arbitrable.changeArbitratorExtraData(court4ExtraData); + vm.prank(disputer); + arbitrable.createDispute{value: 0.06 ether * DEFAULT_NB_OF_JURORS}("Action"); + + vm.warp(block.timestamp + minStakingTime); + sortitionModule.passPhase(); // Generating + vm.warp(block.timestamp + rngLookahead); + sortitionModule.passPhase(); // Drawing + + core.draw(disputeID, DEFAULT_NB_OF_JURORS); + vm.warp(block.timestamp + timesPerPeriod[0]); + core.passPeriod(disputeID); // Vote + + uint256[] memory voteIDs = new uint256[](3); + voteIDs[0] = 0; + voteIDs[1] = 1; + voteIDs[2] = 2; + vm.prank(staker1); + disputeKit.castVote(disputeID, voteIDs, 2, 0, "XYZ"); + + core.passPeriod(disputeID); // Appeal + + // Verify appealCost uses GENERAL_COURT's feeForJuror (0.03 ether from base setup) + uint256 expectedCost = feeForJuror * 7; // 0.03 * 7 = 0.21 ether + assertEq(core.appealCost(disputeID), expectedCost, "appealCost should use GENERAL_COURT's feeForJuror"); + + // Verify jump prediction + (uint96 nextCourtID, , , bool isCourtJumping, ) = core.getCourtAndDisputeKitJumps(disputeID); + assertEq(nextCourtID, GENERAL_COURT, "Should predict jump to GENERAL_COURT"); + assertEq(isCourtJumping, true, "Should be court jumping"); + + // Fund and execute appeal + vm.prank(crowdfunder1); + disputeKit.fundAppeal{value: 0.63 ether}(disputeID, 1); + + vm.expectEmit(true, true, true, true); + emit KlerosCore.CourtJump(disputeID, 1, court4ID, GENERAL_COURT); + vm.prank(crowdfunder2); + disputeKit.fundAppeal{value: 0.42 ether}(disputeID, 2); + + // Verify dispute jumped directly to GENERAL_COURT (not to Court3 or Court2) + (uint96 courtID, , , , ) = core.disputes(disputeID); + assertEq(courtID, GENERAL_COURT, "Dispute should have jumped directly to GENERAL_COURT"); + + KlerosCore.Round memory round = core.getRoundInfo(disputeID, 1); + assertEq(round.nbVotes, 7, "New round should have 7 jurors"); + } + + /// @dev Test that when NextRoundSettings.enabled = false, default parent court jump logic is used + function test_appeal_disabledNextRoundSettingsFallbackToParent() public { + uint256 disputeID = 0; + uint96 court2ID = 2; + uint96 court3ID = 3; + + // Create Court2 (child of GENERAL_COURT) + uint256[] memory supportedDK = new uint256[](1); + supportedDK[0] = DISPUTE_KIT_CLASSIC; + vm.prank(owner); + core.createCourt( + GENERAL_COURT, // parent + hiddenVotes, + minStake, + alpha, + 0.05 ether, + 3, + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // Create Court3 (sibling of Court2) + vm.prank(owner); + core.createCourt( + GENERAL_COURT, + hiddenVotes, + minStake, + alpha, + 0.07 ether, + 5, + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // Configure Court2's NextRoundSettings with enabled = FALSE + // This should cause the settings to be ignored and default parent jump logic to apply + vm.prank(owner); + disputeKit.changeNextRoundSettings( + court2ID, + DisputeKitClassicBase.NextRoundSettings({ + enabled: false, // DISABLED - settings should be ignored + jumpCourtID: court3ID, // This should be IGNORED + jumpDisputeKitID: DISPUTE_KIT_CLASSIC, + jumpDisputeKitIDOnCourtJump: 0, + nbVotes: 9 // This should also be IGNORED + }) + ); + + // Stake in courts + vm.prank(staker1); + core.setStake(court2ID, 20000); + vm.prank(staker1); + core.setStake(GENERAL_COURT, 20000); + + // Create dispute in Court2 + bytes memory court2ExtraData = abi.encodePacked(uint256(court2ID), DEFAULT_NB_OF_JURORS, DISPUTE_KIT_CLASSIC); + arbitrable.changeArbitratorExtraData(court2ExtraData); + vm.prank(disputer); + arbitrable.createDispute{value: 0.05 ether * DEFAULT_NB_OF_JURORS}("Action"); + + vm.warp(block.timestamp + minStakingTime); + sortitionModule.passPhase(); // Generating + vm.warp(block.timestamp + rngLookahead); + sortitionModule.passPhase(); // Drawing + + core.draw(disputeID, DEFAULT_NB_OF_JURORS); + vm.warp(block.timestamp + timesPerPeriod[0]); + core.passPeriod(disputeID); // Vote + + uint256[] memory voteIDs = new uint256[](3); + voteIDs[0] = 0; + voteIDs[1] = 1; + voteIDs[2] = 2; + vm.prank(staker1); + disputeKit.castVote(disputeID, voteIDs, 2, 0, "XYZ"); + + core.passPeriod(disputeID); // Appeal + + // Verify jump prediction shows parent court (GENERAL_COURT), NOT Court3 + (uint96 nextCourtID, , uint256 nextNbVotes, bool isCourtJumping, ) = core.getCourtAndDisputeKitJumps(disputeID); + assertEq(nextCourtID, GENERAL_COURT, "Should jump to parent GENERAL_COURT, not Court3"); + assertEq(isCourtJumping, true, "Should be court jumping"); + assertEq(nextNbVotes, 7, "Should use default formula (3*2+1=7), not custom nbVotes (9)"); + + // Verify appealCost uses GENERAL_COURT's feeForJuror (0.03), not Court3's (0.07) + uint256 expectedCost = feeForJuror * 7; // 0.03 * 7 = 0.21 ether + assertEq(core.appealCost(disputeID), expectedCost, "appealCost should use parent court's fee"); + + // Fund and execute appeal + vm.prank(crowdfunder1); + disputeKit.fundAppeal{value: 0.63 ether}(disputeID, 1); + + vm.expectEmit(true, true, true, true); + emit KlerosCore.CourtJump(disputeID, 1, court2ID, GENERAL_COURT); // Jump to GENERAL_COURT, not Court3 + vm.prank(crowdfunder2); + disputeKit.fundAppeal{value: 0.42 ether}(disputeID, 2); + + // Verify dispute jumped to GENERAL_COURT (parent), not Court3 + (uint96 courtID, , , , ) = core.disputes(disputeID); + assertEq(courtID, GENERAL_COURT, "Dispute should be in GENERAL_COURT (parent), not Court3"); + + // Verify nbVotes used default formula, not custom value + KlerosCore.Round memory round = core.getRoundInfo(disputeID, 1); + assertEq(round.nbVotes, 7, "Should use default nbVotes (7), not custom (9)"); + } + + /// @dev Test that when jumpCourtID is invalid, the system falls back to staying in current court + function test_appeal_invalidJumpCourtIDFallback() public { + uint256 disputeID = 0; + uint96 court2ID = 2; + uint96 invalidCourtID = 999; // Non-existent court + + // Create Court2 (child of GENERAL_COURT) + uint256[] memory supportedDK = new uint256[](1); + supportedDK[0] = DISPUTE_KIT_CLASSIC; + vm.prank(owner); + core.createCourt( + GENERAL_COURT, // parent + hiddenVotes, + minStake, + alpha, + 0.05 ether, + 3, + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // Configure Court2's NextRoundSettings with invalid jumpCourtID + vm.prank(owner); + disputeKit.changeNextRoundSettings( + court2ID, + DisputeKitClassicBase.NextRoundSettings({ + enabled: true, + jumpCourtID: invalidCourtID, // Invalid court ID - should cause fallback + jumpDisputeKitID: DISPUTE_KIT_CLASSIC, + jumpDisputeKitIDOnCourtJump: 0, + nbVotes: 0 + }) + ); + + // Stake in Court2 + vm.prank(staker1); + core.setStake(court2ID, 20000); + + // Create dispute in Court2 + bytes memory court2ExtraData = abi.encodePacked(uint256(court2ID), DEFAULT_NB_OF_JURORS, DISPUTE_KIT_CLASSIC); + arbitrable.changeArbitratorExtraData(court2ExtraData); + vm.prank(disputer); + arbitrable.createDispute{value: 0.05 ether * DEFAULT_NB_OF_JURORS}("Action"); + + vm.warp(block.timestamp + minStakingTime); + sortitionModule.passPhase(); // Generating + vm.warp(block.timestamp + rngLookahead); + sortitionModule.passPhase(); // Drawing + + core.draw(disputeID, DEFAULT_NB_OF_JURORS); + vm.warp(block.timestamp + timesPerPeriod[0]); + core.passPeriod(disputeID); // Vote + + uint256[] memory voteIDs = new uint256[](3); + voteIDs[0] = 0; + voteIDs[1] = 1; + voteIDs[2] = 2; + vm.prank(staker1); + disputeKit.castVote(disputeID, voteIDs, 2, 0, "XYZ"); + + core.passPeriod(disputeID); // Appeal + + // Verify jump prediction shows current court (fallback because invalid jumpCourtID) + (uint96 nextCourtID, , , bool isCourtJumping, ) = core.getCourtAndDisputeKitJumps(disputeID); + assertEq(nextCourtID, court2ID, "Should fallback to current court when jumpCourtID is invalid"); + assertEq(isCourtJumping, false, "Should NOT be court jumping"); + + // Verify appealCost uses Court2's feeForJuror since staying in Court2 + uint256 expectedCost = 0.05 ether * 7; // 0.35 ether + assertEq(core.appealCost(disputeID), expectedCost, "appealCost should use current court's fee"); + + // Fund and execute appeal + vm.prank(crowdfunder1); + disputeKit.fundAppeal{value: 1.05 ether}(disputeID, 1); // 0.35 + (0.35 * 20000/10000) + + // No CourtJump event should be emitted since staying in same court + vm.expectEmit(true, true, true, true); + emit KlerosCore.AppealDecision(disputeID, arbitrable); + vm.expectEmit(true, true, true, true); + emit KlerosCore.NewPeriod(disputeID, KlerosCore.Period.evidence); + vm.prank(crowdfunder2); + disputeKit.fundAppeal{value: 0.7 ether}(disputeID, 2); // 0.35 + (0.35 * 10000/10000) + + // Verify dispute stayed in Court2 + (uint96 courtID, , , , ) = core.disputes(disputeID); + assertEq(courtID, court2ID, "Dispute should still be in Court2"); + + // Verify new round has correct number of jurors + KlerosCore.Round memory round = core.getRoundInfo(disputeID, 1); + assertEq(round.nbVotes, 7, "New round should have 7 jurors (3 * 2 + 1)"); + } + + /// @dev Test that custom nbVotes in NextRoundSettings is respected and appealCost() calculates correctly + function test_appeal_jumpCourtWithCustomNbVotes() public { + uint256 disputeID = 0; + uint96 court2ID = 2; + uint96 court3ID = 3; + uint256 customNbVotes = 11; // Custom vote count (not the default 3*2+1=7) + + // Create Court2 (child of GENERAL_COURT) + uint256[] memory supportedDK = new uint256[](1); + supportedDK[0] = DISPUTE_KIT_CLASSIC; + vm.prank(owner); + core.createCourt( + GENERAL_COURT, // parent + hiddenVotes, + minStake, + alpha, + 0.05 ether, + 3, + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // Create Court3 (sibling of Court2) + vm.prank(owner); + core.createCourt( + GENERAL_COURT, + hiddenVotes, + minStake, + alpha, + 0.08 ether, // Different feeForJuror for Court3 + 5, + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // Configure Court2 to jump to Court3 with custom nbVotes + vm.prank(owner); + disputeKit.changeNextRoundSettings( + court2ID, + DisputeKitClassicBase.NextRoundSettings({ + enabled: true, + jumpCourtID: court3ID, + jumpDisputeKitID: DISPUTE_KIT_CLASSIC, + jumpDisputeKitIDOnCourtJump: 0, + nbVotes: customNbVotes // Custom vote count + }) + ); + + // Stake in courts + vm.prank(staker1); + core.setStake(court2ID, 20000); + vm.prank(staker1); + core.setStake(court3ID, 20000); + + // Create dispute in Court2 + bytes memory court2ExtraData = abi.encodePacked(uint256(court2ID), DEFAULT_NB_OF_JURORS, DISPUTE_KIT_CLASSIC); + arbitrable.changeArbitratorExtraData(court2ExtraData); + vm.prank(disputer); + arbitrable.createDispute{value: 0.05 ether * DEFAULT_NB_OF_JURORS}("Action"); + + vm.warp(block.timestamp + minStakingTime); + sortitionModule.passPhase(); // Generating + vm.warp(block.timestamp + rngLookahead); + sortitionModule.passPhase(); // Drawing + + core.draw(disputeID, DEFAULT_NB_OF_JURORS); + vm.warp(block.timestamp + timesPerPeriod[0]); + core.passPeriod(disputeID); // Vote + + uint256[] memory voteIDs = new uint256[](3); + voteIDs[0] = 0; + voteIDs[1] = 1; + voteIDs[2] = 2; + vm.prank(staker1); + disputeKit.castVote(disputeID, voteIDs, 2, 0, "XYZ"); + + core.passPeriod(disputeID); // Appeal + + // CRITICAL: Verify appealCost uses Court3's feeForJuror AND custom nbVotes + // Expected: 0.08 ether * 11 = 0.88 ether + uint256 expectedCost = 0.08 ether * customNbVotes; + assertEq(core.appealCost(disputeID), expectedCost, "appealCost should use Court3's fee with custom nbVotes"); + + // Verify it's NOT using the default formula (which would be 7 votes) + uint256 defaultFormulaVotes = 3 * 2 + 1; // 7 + uint256 defaultCost = 0.08 ether * defaultFormulaVotes; // 0.56 ether + assertTrue(expectedCost != defaultCost, "Custom cost should differ from default formula cost"); + + // Verify jump prediction + (uint96 nextCourtID, , uint256 nextNbVotes, bool isCourtJumping, ) = core.getCourtAndDisputeKitJumps(disputeID); + assertEq(nextCourtID, court3ID, "Should jump to Court3"); + assertEq(nextNbVotes, customNbVotes, "Should use custom nbVotes"); + assertEq(isCourtJumping, true, "Should be court jumping"); + + // Fund and execute appeal with custom vote count cost + vm.prank(crowdfunder1); + // Total: 0.88 + (0.88 * 20000/10000) = 0.88 + 1.76 = 2.64 ether + disputeKit.fundAppeal{value: 2.64 ether}(disputeID, 1); + + vm.expectEmit(true, true, true, true); + emit KlerosCore.CourtJump(disputeID, 1, court2ID, court3ID); + vm.prank(crowdfunder2); + // Total: 0.88 + (0.88 * 10000/10000) = 0.88 + 0.88 = 1.76 ether + disputeKit.fundAppeal{value: 1.76 ether}(disputeID, 2); + + // Verify dispute jumped to Court3 + (uint96 courtID, , , , ) = core.disputes(disputeID); + assertEq(courtID, court3ID, "Dispute should be in Court3"); + + // CRITICAL: Verify new round has custom nbVotes, NOT default (7) + KlerosCore.Round memory round = core.getRoundInfo(disputeID, 1); + assertEq(round.nbVotes, customNbVotes, "New round should have custom nbVotes (11)"); + assertEq(round.disputeKitID, DISPUTE_KIT_CLASSIC, "DK should still be DISPUTE_KIT_CLASSIC"); + + // Verify we can draw the custom number of jurors + core.draw(disputeID, customNbVotes); + round = core.getRoundInfo(disputeID, 1); + assertEq(round.drawnJurors.length, customNbVotes, "Should have drawn custom number of jurors"); + } + + /// @dev Test that custom nbVotes in NextRoundSettings does NOT trigger a court jump + /// Only the current round's actual drawn jurors should determine court jump, not custom nbVotes + function test_appeal_customNbVotesDoesNotTriggerCourtJump() public { + uint256 disputeID = 0; + uint96 court2ID = 2; + uint256 customNbVotes = 9; // Custom vote count that exceeds jurorsForCourtJump + + // Create Court2 with HIGH jurorsForCourtJump threshold + uint256[] memory supportedDK = new uint256[](1); + supportedDK[0] = DISPUTE_KIT_CLASSIC; + vm.prank(owner); + core.createCourt( + GENERAL_COURT, // parent + hiddenVotes, + minStake, + alpha, + 0.06 ether, + 7, // HIGH jurorsForCourtJump threshold + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // Configure NextRoundSettings with custom nbVotes > jurorsForCourtJump + // But NO explicit jumpCourtID (let default logic decide) + vm.prank(owner); + disputeKit.changeNextRoundSettings( + court2ID, + DisputeKitClassicBase.NextRoundSettings({ + enabled: true, + jumpCourtID: 0, // UNDEFINED - use default jump logic + jumpDisputeKitID: DISPUTE_KIT_CLASSIC, + jumpDisputeKitIDOnCourtJump: 0, + nbVotes: customNbVotes // 9 votes, which is > 7 threshold + }) + ); + + // Stake in courts + vm.prank(staker1); + core.setStake(court2ID, 20000); + vm.prank(staker1); + core.setStake(GENERAL_COURT, 20000); + + // Create dispute in Court2 with only 3 jurors (well below threshold of 7) + bytes memory court2ExtraData = abi.encodePacked(uint256(court2ID), DEFAULT_NB_OF_JURORS, DISPUTE_KIT_CLASSIC); + arbitrable.changeArbitratorExtraData(court2ExtraData); + vm.prank(disputer); + arbitrable.createDispute{value: 0.06 ether * DEFAULT_NB_OF_JURORS}("Action"); + + vm.warp(block.timestamp + minStakingTime); + sortitionModule.passPhase(); // Generating + vm.warp(block.timestamp + rngLookahead); + sortitionModule.passPhase(); // Drawing + + core.draw(disputeID, DEFAULT_NB_OF_JURORS); // Draw 3 jurors + vm.warp(block.timestamp + timesPerPeriod[0]); + core.passPeriod(disputeID); // Vote + + uint256[] memory voteIDs = new uint256[](3); + voteIDs[0] = 0; + voteIDs[1] = 1; + voteIDs[2] = 2; + vm.prank(staker1); + disputeKit.castVote(disputeID, voteIDs, 2, 0, "XYZ"); + + core.passPeriod(disputeID); // Appeal + + // CRITICAL TEST: Verify NO court jump occurs + // Current round has 3 jurors (< 7 threshold) -> should NOT jump + // Custom nbVotes = 9 (> 7 threshold) -> should NOT affect jump decision + (uint96 nextCourtID, , uint256 nextNbVotes, bool isCourtJumping, ) = core.getCourtAndDisputeKitJumps(disputeID); + assertEq(nextCourtID, court2ID, "Should stay in Court2 (no jump)"); + assertEq(isCourtJumping, false, "Should NOT be court jumping despite custom nbVotes > threshold"); + assertEq(nextNbVotes, customNbVotes, "Should use custom nbVotes for next round"); + + // Verify appealCost uses Court2's feeForJuror with custom nbVotes + // Expected: 0.06 ether * 9 = 0.54 ether + uint256 expectedCost = 0.06 ether * customNbVotes; + assertEq(core.appealCost(disputeID), expectedCost, "appealCost should use Court2's fee with custom nbVotes"); + + // Fund and execute appeal + vm.prank(crowdfunder1); + // Total: 0.54 + (0.54 * 20000/10000) = 0.54 + 1.08 = 1.62 ether + disputeKit.fundAppeal{value: 1.62 ether}(disputeID, 1); + + // NO CourtJump event should be emitted (staying in same court) + vm.expectEmit(true, true, true, true); + emit KlerosCore.AppealDecision(disputeID, arbitrable); + vm.expectEmit(true, true, true, true); + emit KlerosCore.NewPeriod(disputeID, KlerosCore.Period.evidence); + vm.prank(crowdfunder2); + // Total: 0.54 + (0.54 * 10000/10000) = 0.54 + 0.54 = 1.08 ether + disputeKit.fundAppeal{value: 1.08 ether}(disputeID, 2); + + // Verify dispute stayed in Court2 + (uint96 courtID, , , , ) = core.disputes(disputeID); + assertEq(courtID, court2ID, "Dispute should still be in Court2 (no jump occurred)"); + + // CRITICAL: Verify new round has custom nbVotes (9), not default (3*2+1=7) + KlerosCore.Round memory round = core.getRoundInfo(disputeID, 1); + assertEq(round.nbVotes, customNbVotes, "New round should have custom nbVotes (9)"); + assertEq(round.disputeKitID, DISPUTE_KIT_CLASSIC, "DK should still be DISPUTE_KIT_CLASSIC"); + + // Verify we can draw the custom number of jurors in the same court + core.draw(disputeID, customNbVotes); + round = core.getRoundInfo(disputeID, 1); + assertEq(round.drawnJurors.length, customNbVotes, "Should have drawn 9 jurors in Court2"); + } + + /// @dev Test that jumpDisputeKitID takes precedence over jumpDisputeKitIDOnCourtJump when both are set + function test_appeal_jumpDisputeKitIDTakesPrecedenceOverOnCourtJump() public { + uint256 disputeID = 0; + uint96 court2ID = 2; + uint96 court3ID = 3; + uint256 dkID2 = 2; + uint256 dkID3 = 3; + + // Create DisputeKit2 and DisputeKit3 + DisputeKitClassic dkLogic = new DisputeKitClassic(); + + bytes memory initDataDk2 = abi.encodeWithSignature( + "initialize(address,address,address)", + owner, + address(core), + address(wNative) + ); + UUPSProxy proxyDk2 = new UUPSProxy(address(dkLogic), initDataDk2); + DisputeKitClassic disputeKit2 = DisputeKitClassic(address(proxyDk2)); + + bytes memory initDataDk3 = abi.encodeWithSignature( + "initialize(address,address,address)", + owner, + address(core), + address(wNative) + ); + UUPSProxy proxyDk3 = new UUPSProxy(address(dkLogic), initDataDk3); + DisputeKitClassic disputeKit3 = DisputeKitClassic(address(proxyDk3)); + + vm.prank(owner); + core.addNewDisputeKit(disputeKit2); + vm.prank(owner); + core.addNewDisputeKit(disputeKit3); + + // Create Court2 supporting all 3 DKs + uint256[] memory supportedDK = new uint256[](3); + supportedDK[0] = DISPUTE_KIT_CLASSIC; + supportedDK[1] = dkID2; + supportedDK[2] = dkID3; + vm.prank(owner); + core.createCourt( + GENERAL_COURT, // parent + hiddenVotes, + minStake, + alpha, + 0.05 ether, + 3, // Low jurorsForCourtJump to ensure jump + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // Create Court3 (sibling of Court2) also supporting all 3 DKs + vm.prank(owner); + core.createCourt( + GENERAL_COURT, // Same parent as Court2 (siblings) + hiddenVotes, + minStake, + alpha, + 0.07 ether, + 5, + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // CRITICAL: Configure NextRoundSettings with BOTH jumpDisputeKitID and jumpDisputeKitIDOnCourtJump set + // jumpDisputeKitID should take precedence + vm.prank(owner); + disputeKit3.changeNextRoundSettings( + court2ID, + DisputeKitClassicBase.NextRoundSettings({ + enabled: true, + jumpCourtID: court3ID, // Force court jump to Court3 + jumpDisputeKitID: dkID2, // Should be used (takes precedence) + jumpDisputeKitIDOnCourtJump: dkID3, // Should be IGNORED despite being set + nbVotes: 0 + }) + ); + + // Stake in courts + vm.prank(staker1); + core.setStake(court2ID, 20000); + vm.prank(staker1); + core.setStake(court3ID, 20000); + + // Create dispute in Court2 with DisputeKit3 + bytes memory extraData = abi.encodePacked(uint256(court2ID), DEFAULT_NB_OF_JURORS, dkID3); + arbitrable.changeArbitratorExtraData(extraData); + vm.prank(disputer); + arbitrable.createDispute{value: 0.05 ether * DEFAULT_NB_OF_JURORS}("Action"); + + vm.warp(block.timestamp + minStakingTime); + sortitionModule.passPhase(); // Generating + vm.warp(block.timestamp + rngLookahead); + sortitionModule.passPhase(); // Drawing + + // Verify initial round uses DisputeKit3 + KlerosCore.Round memory round = core.getRoundInfo(disputeID, 0); + assertEq(round.disputeKitID, dkID3, "Initial round should use DisputeKit3"); + + core.draw(disputeID, DEFAULT_NB_OF_JURORS); + vm.warp(block.timestamp + timesPerPeriod[0]); + core.passPeriod(disputeID); // Vote + + uint256[] memory voteIDs = new uint256[](3); + voteIDs[0] = 0; + voteIDs[1] = 1; + voteIDs[2] = 2; + vm.prank(staker1); + disputeKit3.castVote(disputeID, voteIDs, 2, 0, "XYZ"); + + core.passPeriod(disputeID); // Appeal + + // CRITICAL TEST: Verify jump prediction shows DK2, NOT DK3 + (uint96 nextCourtID, uint256 nextDisputeKitID, , bool isCourtJumping, bool isDisputeKitJumping) = core + .getCourtAndDisputeKitJumps(disputeID); + assertEq(nextCourtID, court3ID, "Should jump to Court3"); + assertEq(nextDisputeKitID, dkID2, "Should jump to DisputeKit2 (jumpDisputeKitID), NOT DisputeKit3"); + assertEq(isCourtJumping, true, "Should be court jumping"); + assertEq(isDisputeKitJumping, true, "Should be DK jumping"); + + // Verify appealCost uses Court3's feeForJuror + uint256 expectedCost = 0.07 ether * 7; // 0.49 ether + assertEq(core.appealCost(disputeID), expectedCost, "appealCost should use Court3's fee"); + + // Fund and execute appeal + vm.prank(crowdfunder1); + disputeKit3.fundAppeal{value: 1.47 ether}(disputeID, 1); + + // Verify events show jump to Court3 and DisputeKit2 (NOT DisputeKit3) + vm.expectEmit(true, true, true, true); + emit KlerosCore.CourtJump(disputeID, 1, court2ID, court3ID); + vm.expectEmit(true, true, true, true); + emit KlerosCore.DisputeKitJump(disputeID, 1, dkID3, dkID2); // DK3 -> DK2 (NOT DK3) + vm.expectEmit(true, true, true, true); + emit DisputeKitClassicBase.DisputeCreation(disputeID, 2, extraData); + vm.expectEmit(true, true, true, true); + emit KlerosCore.AppealDecision(disputeID, arbitrable); + vm.expectEmit(true, true, true, true); + emit KlerosCore.NewPeriod(disputeID, KlerosCore.Period.evidence); + vm.prank(crowdfunder2); + disputeKit3.fundAppeal{value: 0.98 ether}(disputeID, 2); + + // Verify dispute is now in Court3 with DisputeKit2 + (uint96 courtID, , , , ) = core.disputes(disputeID); + assertEq(courtID, court3ID, "Dispute should be in Court3"); + + round = core.getRoundInfo(disputeID, 1); + assertEq(round.disputeKitID, dkID2, "New round should use DisputeKit2 (NOT DisputeKit3)"); + assertEq(round.nbVotes, 7, "New round should have 7 jurors"); + + // Verify DisputeKit3 is no longer active for this dispute + (, bool currentRound) = disputeKit3.coreDisputeIDToActive(disputeID); + assertEq(currentRound, false, "DisputeKit3 should no longer be active"); + + // Verify DisputeKit2 is now active + (, currentRound) = disputeKit2.coreDisputeIDToActive(disputeID); + assertEq(currentRound, true, "DisputeKit2 should be active"); + + // Verify we can draw jurors in the new DK + vm.expectEmit(true, true, true, true); + emit KlerosCore.Draw(staker1, disputeID, 1, 0); + core.draw(disputeID, 1); + + (address account, , , ) = disputeKit2.getVoteInfo(disputeID, 1, 0); + assertEq(account, staker1, "Should have drawn juror in DisputeKit2"); + } + + /// @dev Test that invalid jumpDisputeKitID triggers complete fallback of ALL THREE parameters + /// Tests KlerosCore._getCompatibleNextRoundSettings() Scenario 1: + /// if jumpDisputeKitID >= disputeKits.length, then ALL parameters (court, DK, nbVotes) + /// fallback to current settings, even if other params are valid/custom. + /// Verifies safety check: newDisputeKitID >= disputeKits.length + function test_appeal_invalidDisputeKitIDCompleteTripleFallback() public { + uint256 disputeID = 0; + uint96 court2ID = 2; + uint96 court3ID = 3; + uint256 dkID2 = 2; + uint256 invalidDKID = 999; // Invalid - exceeds disputeKits.length + uint256 customNbVotes = 11; + + // Create DisputeKit2 + DisputeKitClassic dkLogic = new DisputeKitClassic(); + bytes memory initDataDk2 = abi.encodeWithSignature( + "initialize(address,address,address)", + owner, + address(core), + address(wNative) + ); + UUPSProxy proxyDk2 = new UUPSProxy(address(dkLogic), initDataDk2); + DisputeKitClassic disputeKit2 = DisputeKitClassic(address(proxyDk2)); + + vm.prank(owner); + core.addNewDisputeKit(disputeKit2); + + // Create Court2 supporting both DISPUTE_KIT_CLASSIC and DK2 + uint256[] memory supportedDK = new uint256[](2); + supportedDK[0] = DISPUTE_KIT_CLASSIC; + supportedDK[1] = dkID2; + vm.prank(owner); + core.createCourt( + GENERAL_COURT, + hiddenVotes, + minStake, + alpha, + 0.05 ether, + 5, + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // Create Court3 (valid target court) + vm.prank(owner); + core.createCourt( + GENERAL_COURT, + hiddenVotes, + minStake, + alpha, + 0.07 ether, // Different fee to verify fallback + 5, + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // Configure NextRoundSettings with INVALID jumpDisputeKitID + // Even though jumpCourtID is VALID and nbVotes is CUSTOM, all should fallback + vm.prank(owner); + disputeKit2.changeNextRoundSettings( + court2ID, + DisputeKitClassicBase.NextRoundSettings({ + enabled: true, + jumpCourtID: court3ID, // VALID court (should be ignored due to invalid DK) + jumpDisputeKitID: invalidDKID, // INVALID DK - triggers complete fallback + jumpDisputeKitIDOnCourtJump: 0, + nbVotes: customNbVotes // CUSTOM nbVotes (should be ignored) + }) + ); + + // Stake in courts + vm.prank(staker1); + core.setStake(court2ID, 20000); + + // Create dispute in Court2 with DisputeKit2 + bytes memory extraData = abi.encodePacked(uint256(court2ID), DEFAULT_NB_OF_JURORS, dkID2); + arbitrable.changeArbitratorExtraData(extraData); + vm.prank(disputer); + arbitrable.createDispute{value: 0.05 ether * DEFAULT_NB_OF_JURORS}("Action"); + + vm.warp(block.timestamp + minStakingTime); + sortitionModule.passPhase(); // Generating + vm.warp(block.timestamp + rngLookahead); + sortitionModule.passPhase(); // Drawing + + core.draw(disputeID, DEFAULT_NB_OF_JURORS); // 3 jurors + vm.warp(block.timestamp + timesPerPeriod[0]); + core.passPeriod(disputeID); // Vote + + uint256[] memory voteIDs = new uint256[](3); + voteIDs[0] = 0; + voteIDs[1] = 1; + voteIDs[2] = 2; + vm.prank(staker1); + disputeKit2.castVote(disputeID, voteIDs, 2, 0, "XYZ"); + + core.passPeriod(disputeID); // Appeal + + // CRITICAL TEST: Verify COMPLETE TRIPLE FALLBACK + // Despite jumpCourtID being valid (Court3) and nbVotes being custom (11), + // the invalid jumpDisputeKitID should cause ALL THREE to fallback + ( + uint96 nextCourtID, + uint256 nextDisputeKitID, + uint256 nextNbVotes, + bool isCourtJumping, + bool isDisputeKitJumping + ) = core.getCourtAndDisputeKitJumps(disputeID); + + assertEq(nextCourtID, court2ID, "Court should fallback to current (Court2), not jump to Court3"); + assertEq(nextDisputeKitID, dkID2, "DK should fallback to current (DK2)"); + assertEq(nextNbVotes, 7, "nbVotes should fallback to default (7), not custom (11)"); + assertEq(isCourtJumping, false, "Should NOT be court jumping"); + assertEq(isDisputeKitJumping, false, "Should NOT be DK jumping"); + + // Verify appealCost uses Court2's fee (not Court3's), with default nbVotes (not custom) + uint256 expectedCost = 0.05 ether * 7; // Court2's fee × 7 + assertEq(core.appealCost(disputeID), expectedCost, "appealCost should use Court2's fee with default nbVotes"); + + // Fund and execute appeal + vm.prank(crowdfunder1); + disputeKit2.fundAppeal{value: 1.05 ether}(disputeID, 1); + + // NO CourtJump or DisputeKitJump events should be emitted + vm.expectEmit(true, true, true, true); + emit KlerosCore.AppealDecision(disputeID, arbitrable); + vm.expectEmit(true, true, true, true); + emit KlerosCore.NewPeriod(disputeID, KlerosCore.Period.evidence); + vm.prank(crowdfunder2); + disputeKit2.fundAppeal{value: 0.7 ether}(disputeID, 2); + + // Verify dispute stayed in Court2 with DK2 and default nbVotes + (uint96 courtID, , , , ) = core.disputes(disputeID); + assertEq(courtID, court2ID, "Dispute should still be in Court2"); + + KlerosCore.Round memory round = core.getRoundInfo(disputeID, 1); + assertEq(round.disputeKitID, dkID2, "Should still use DK2"); + assertEq(round.nbVotes, 7, "Should use default nbVotes (7), not custom (11)"); + + // Verify we can draw jurors in the same court and DK + core.draw(disputeID, 7); + round = core.getRoundInfo(disputeID, 1); + assertEq(round.drawnJurors.length, 7, "Should have drawn 7 jurors"); + } + + /// @dev Test that incompatible DK with target court falls back to DISPUTE_KIT_CLASSIC + /// Tests KlerosCore._getCompatibleNextRoundSettings(): + /// if target court doesn't support target DK, fallback to DISPUTE_KIT_CLASSIC. + /// Court jump still happens, but DK and nbVotes fallback. + /// Verifies compatibility check: !courts[newCourtID].supportedDisputeKits[newDisputeKitID] + function test_appeal_incompatibleDisputeKitFallbackToClassic() public { + uint256 disputeID = 0; + uint96 court2ID = 2; + uint96 court3ID = 3; + uint256 dkID2 = 2; + uint256 customNbVotes = 11; + + // Create DisputeKit2 + DisputeKitClassic dkLogic = new DisputeKitClassic(); + bytes memory initDataDk2 = abi.encodeWithSignature( + "initialize(address,address,address)", + owner, + address(core), + address(wNative) + ); + UUPSProxy proxyDk2 = new UUPSProxy(address(dkLogic), initDataDk2); + DisputeKitClassic disputeKit2 = DisputeKitClassic(address(proxyDk2)); + + vm.prank(owner); + core.addNewDisputeKit(disputeKit2); + + // Create Court2 supporting BOTH DISPUTE_KIT_CLASSIC and DK2 + uint256[] memory supportedDK = new uint256[](2); + supportedDK[0] = DISPUTE_KIT_CLASSIC; + supportedDK[1] = dkID2; + vm.prank(owner); + core.createCourt( + GENERAL_COURT, + hiddenVotes, + minStake, + alpha, + 0.05 ether, + 3, // Low threshold to ensure jump + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // Create Court3 supporting ONLY DISPUTE_KIT_CLASSIC (NOT DK2) + supportedDK = new uint256[](1); + supportedDK[0] = DISPUTE_KIT_CLASSIC; // Only Classic, no DK2! + vm.prank(owner); + core.createCourt( + GENERAL_COURT, + hiddenVotes, + minStake, + alpha, + 0.08 ether, // Different fee to verify correct court is used + 5, + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // Verify Court3 does NOT support DK2 + assertEq(core.isSupported(court3ID, dkID2), false, "Court3 should NOT support DK2"); + assertEq(core.isSupported(court3ID, DISPUTE_KIT_CLASSIC), true, "Court3 should support Classic"); + + // Configure NextRoundSettings to jump to Court3 with DK2 + // DK2 is valid but incompatible with Court3 + vm.prank(owner); + disputeKit2.changeNextRoundSettings( + court2ID, + DisputeKitClassicBase.NextRoundSettings({ + enabled: true, + jumpCourtID: court3ID, // Valid court + jumpDisputeKitID: dkID2, // Valid DK BUT Court3 doesn't support it + jumpDisputeKitIDOnCourtJump: 0, + nbVotes: customNbVotes // Custom nbVotes (should be overridden) + }) + ); + + // Stake in courts + vm.prank(staker1); + core.setStake(court2ID, 20000); + vm.prank(staker1); + core.setStake(court3ID, 20000); + + // Create dispute in Court2 with DisputeKit2 + bytes memory extraData = abi.encodePacked(uint256(court2ID), DEFAULT_NB_OF_JURORS, dkID2); + arbitrable.changeArbitratorExtraData(extraData); + vm.prank(disputer); + arbitrable.createDispute{value: 0.05 ether * DEFAULT_NB_OF_JURORS}("Action"); + + vm.warp(block.timestamp + minStakingTime); + sortitionModule.passPhase(); // Generating + vm.warp(block.timestamp + rngLookahead); + sortitionModule.passPhase(); // Drawing + + // Verify initial round uses DisputeKit2 + KlerosCore.Round memory round = core.getRoundInfo(disputeID, 0); + assertEq(round.disputeKitID, dkID2, "Initial round should use DK2"); + + core.draw(disputeID, DEFAULT_NB_OF_JURORS); + vm.warp(block.timestamp + timesPerPeriod[0]); + core.passPeriod(disputeID); // Vote + + uint256[] memory voteIDs = new uint256[](3); + voteIDs[0] = 0; + voteIDs[1] = 1; + voteIDs[2] = 2; + vm.prank(staker1); + disputeKit2.castVote(disputeID, voteIDs, 2, 0, "XYZ"); + + core.passPeriod(disputeID); // Appeal + + // CRITICAL TEST: Verify PARTIAL FALLBACK + // Court jump should succeed to Court3 + // But DK should fallback to DISPUTE_KIT_CLASSIC (incompatible) + // And nbVotes should fallback to default + ( + uint96 nextCourtID, + uint256 nextDisputeKitID, + uint256 nextNbVotes, + bool isCourtJumping, + bool isDisputeKitJumping + ) = core.getCourtAndDisputeKitJumps(disputeID); + + assertEq(nextCourtID, court3ID, "Court should jump to Court3"); + assertEq(nextDisputeKitID, DISPUTE_KIT_CLASSIC, "DK should fallback to DISPUTE_KIT_CLASSIC (not DK2)"); + assertEq(nextNbVotes, 7, "nbVotes should fallback to default (7), not custom (11)"); + assertEq(isCourtJumping, true, "Should be court jumping"); + assertEq(isDisputeKitJumping, true, "Should be DK jumping (DK2 -> Classic)"); + + // Verify appealCost uses Court3's fee (court jump succeeds) with default nbVotes + uint256 expectedCost = 0.08 ether * 7; // Court3's fee × 7 + assertEq(core.appealCost(disputeID), expectedCost, "appealCost should use Court3's fee with default nbVotes"); + + // Fund and execute appeal + vm.prank(crowdfunder1); + disputeKit2.fundAppeal{value: 2.4 ether}(disputeID, 1); // 0.56 + (0.56 * 20000/10000) + + // Verify CourtJump AND DisputeKitJump events + vm.expectEmit(true, true, true, true); + emit KlerosCore.CourtJump(disputeID, 1, court2ID, court3ID); + vm.expectEmit(true, true, true, true); + emit KlerosCore.DisputeKitJump(disputeID, 1, dkID2, DISPUTE_KIT_CLASSIC); + vm.expectEmit(true, true, true, true); + emit DisputeKitClassicBase.DisputeCreation(disputeID, 2, extraData); + vm.expectEmit(true, true, true, true); + emit KlerosCore.AppealDecision(disputeID, arbitrable); + vm.expectEmit(true, true, true, true); + emit KlerosCore.NewPeriod(disputeID, KlerosCore.Period.evidence); + vm.prank(crowdfunder2); + disputeKit2.fundAppeal{value: 1.6 ether}(disputeID, 2); // 0.56 + (0.56 * 10000/10000) + + // Verify dispute jumped to Court3 with DISPUTE_KIT_CLASSIC + (uint96 courtID, , , , ) = core.disputes(disputeID); + assertEq(courtID, court3ID, "Dispute should be in Court3"); + + round = core.getRoundInfo(disputeID, 1); + assertEq(round.disputeKitID, DISPUTE_KIT_CLASSIC, "New round should use DISPUTE_KIT_CLASSIC (not DK2)"); + assertEq(round.nbVotes, 7, "Should use default nbVotes (7), not custom (11)"); + + // Verify DK2 is no longer active + (, bool currentRound) = disputeKit2.coreDisputeIDToActive(disputeID); + assertEq(currentRound, false, "DK2 should no longer be active"); + + // Verify DISPUTE_KIT_CLASSIC is active + (, currentRound) = disputeKit.coreDisputeIDToActive(disputeID); + assertEq(currentRound, true, "DISPUTE_KIT_CLASSIC should be active"); + + // Verify we can draw jurors in the new court with Classic DK + core.draw(disputeID, 7); + round = core.getRoundInfo(disputeID, 1); + assertEq(round.drawnJurors.length, 7, "Should have drawn 7 jurors in Court3"); + } + + /// @dev Test that jumpCourtID = FORKING_COURT triggers KlerosCore fallback + /// Uses DisputeKitClassicMockUncheckedNextRoundSettings which returns raw values without + /// DisputeKitClassicBase's safety logic. This allows testing KlerosCore._getCompatibleNextRoundSettings() + /// sanity check: if newCourtID == FORKING_COURT, trigger complete fallback. + /// Verifies complete triple fallback of all three parameters (court, DK, nbVotes). + function test_appeal_forkingCourtTriggersKlerosCoreFallback() public { + uint256 disputeID = 0; + uint96 court2ID = 2; + uint256 mockDKID = 2; + uint256 customNbVotes = 11; + + // Create mock DK that bypasses DisputeKitClassicBase safety logic + DisputeKitClassicMockUncheckedNextRoundSettings mockDKLogic = new DisputeKitClassicMockUncheckedNextRoundSettings(); + bytes memory initDataMockDK = abi.encodeWithSignature( + "initialize(address,address,address)", + owner, + address(core), + address(wNative) + ); + UUPSProxy proxyMockDK = new UUPSProxy(address(mockDKLogic), initDataMockDK); + DisputeKitClassicMockUncheckedNextRoundSettings mockDK = DisputeKitClassicMockUncheckedNextRoundSettings( + address(proxyMockDK) + ); + + vm.prank(owner); + core.addNewDisputeKit(mockDK); + + // Create Court2 supporting both DISPUTE_KIT_CLASSIC and mockDK + uint256[] memory supportedDK = new uint256[](2); + supportedDK[0] = DISPUTE_KIT_CLASSIC; + supportedDK[1] = mockDKID; + vm.prank(owner); + core.createCourt( + GENERAL_COURT, + hiddenVotes, + minStake, + alpha, + 0.06 ether, + 5, + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // Configure NextRoundSettings with jumpCourtID = FORKING_COURT (0) + // Mock will pass this raw value to KlerosCore (no interception) + vm.prank(owner); + mockDK.changeNextRoundSettings( + court2ID, + DisputeKitClassicBase.NextRoundSettings({ + enabled: true, + jumpCourtID: FORKING_COURT, // 0 - will be passed raw to KlerosCore + jumpDisputeKitID: mockDKID, // Valid DK (non-zero) + jumpDisputeKitIDOnCourtJump: 0, + nbVotes: customNbVotes // Custom nbVotes (non-zero) + }) + ); + + // Stake in Court2 + vm.prank(staker1); + core.setStake(court2ID, 20000); + + // Create dispute in Court2 with mockDK + bytes memory extraData = abi.encodePacked(uint256(court2ID), DEFAULT_NB_OF_JURORS, mockDKID); + arbitrable.changeArbitratorExtraData(extraData); + vm.prank(disputer); + arbitrable.createDispute{value: 0.06 ether * DEFAULT_NB_OF_JURORS}("Action"); + + vm.warp(block.timestamp + minStakingTime); + sortitionModule.passPhase(); // Generating + vm.warp(block.timestamp + rngLookahead); + sortitionModule.passPhase(); // Drawing + + core.draw(disputeID, DEFAULT_NB_OF_JURORS); // 3 jurors + vm.warp(block.timestamp + timesPerPeriod[0]); + core.passPeriod(disputeID); // Vote + + uint256[] memory voteIDs = new uint256[](3); + voteIDs[0] = 0; + voteIDs[1] = 1; + voteIDs[2] = 2; + vm.prank(staker1); + mockDK.castVote(disputeID, voteIDs, 2, 0, "XYZ"); + + core.passPeriod(disputeID); // Appeal + + // CRITICAL TEST: KlerosCore should detect FORKING_COURT (0) and trigger complete fallback + // Despite jumpDisputeKitID=mockDKID and nbVotes=11 being valid/custom + ( + uint96 nextCourtID, + uint256 nextDisputeKitID, + uint256 nextNbVotes, + bool isCourtJumping, + bool isDisputeKitJumping + ) = core.getCourtAndDisputeKitJumps(disputeID); + + assertEq(nextCourtID, court2ID, "Court should fallback to current (Court2)"); + assertEq(nextDisputeKitID, mockDKID, "DK should fallback to current (mockDK)"); + assertEq(nextNbVotes, 7, "nbVotes should fallback to default (7), not custom (11)"); + assertEq(isCourtJumping, false, "Should NOT be court jumping"); + assertEq(isDisputeKitJumping, false, "Should NOT be DK jumping"); + + // Verify appealCost uses Court2's fee with default nbVotes + uint256 expectedCost = 0.06 ether * 7; // Court2's fee × 7 (default) + assertEq(core.appealCost(disputeID), expectedCost, "appealCost should use Court2 fee with default nbVotes"); + + // Fund and execute appeal + vm.prank(crowdfunder1); + mockDK.fundAppeal{value: 1.26 ether}(disputeID, 1); + + // NO CourtJump or DisputeKitJump events should be emitted + vm.expectEmit(true, true, true, true); + emit KlerosCore.AppealDecision(disputeID, arbitrable); + vm.expectEmit(true, true, true, true); + emit KlerosCore.NewPeriod(disputeID, KlerosCore.Period.evidence); + vm.prank(crowdfunder2); + mockDK.fundAppeal{value: 0.84 ether}(disputeID, 2); + + // Verify dispute stayed in Court2 with mockDK and default nbVotes + (uint96 courtID, , , , ) = core.disputes(disputeID); + assertEq(courtID, court2ID, "Dispute should still be in Court2"); + + KlerosCore.Round memory round = core.getRoundInfo(disputeID, 1); + assertEq(round.disputeKitID, mockDKID, "Should still use mockDK"); + assertEq(round.nbVotes, 7, "Should use default nbVotes (7), not custom (11)"); + + // Verify we can draw jurors + core.draw(disputeID, 7); + round = core.getRoundInfo(disputeID, 1); + assertEq(round.drawnJurors.length, 7, "Should have drawn 7 jurors"); + } + + /// @dev Test that jumpDisputeKitID = NULL_DISPUTE_KIT triggers KlerosCore fallback + /// Uses mock DK to bypass DisputeKitClassicBase safety logic and test KlerosCore sanity check: + /// if newDisputeKitID == NULL_DISPUTE_KIT, trigger complete fallback. + /// Verifies complete triple fallback of all three parameters. + function test_appeal_nullDisputeKitTriggersKlerosCoreFallback() public { + uint256 disputeID = 0; + uint96 court2ID = 2; + uint96 court3ID = 3; + uint256 mockDKID = 2; + uint256 customNbVotes = 11; + + // Create mock DK + DisputeKitClassicMockUncheckedNextRoundSettings mockDKLogic = new DisputeKitClassicMockUncheckedNextRoundSettings(); + bytes memory initDataMockDK = abi.encodeWithSignature( + "initialize(address,address,address)", + owner, + address(core), + address(wNative) + ); + UUPSProxy proxyMockDK = new UUPSProxy(address(mockDKLogic), initDataMockDK); + DisputeKitClassicMockUncheckedNextRoundSettings mockDK = DisputeKitClassicMockUncheckedNextRoundSettings( + address(proxyMockDK) + ); + + vm.prank(owner); + core.addNewDisputeKit(mockDK); + + // Create Court2 and Court3 + uint256[] memory supportedDK = new uint256[](2); + supportedDK[0] = DISPUTE_KIT_CLASSIC; + supportedDK[1] = mockDKID; + vm.prank(owner); + core.createCourt( + GENERAL_COURT, + hiddenVotes, + minStake, + alpha, + 0.05 ether, + 5, + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + vm.prank(owner); + core.createCourt( + GENERAL_COURT, + hiddenVotes, + minStake, + alpha, + 0.08 ether, // Different fee + 5, + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // Configure NextRoundSettings with jumpDisputeKitID = NULL_DISPUTE_KIT (0) + vm.prank(owner); + mockDK.changeNextRoundSettings( + court2ID, + DisputeKitClassicBase.NextRoundSettings({ + enabled: true, + jumpCourtID: court3ID, // Valid court (non-zero) + jumpDisputeKitID: NULL_DISPUTE_KIT, // 0 - will be passed raw to KlerosCore + jumpDisputeKitIDOnCourtJump: 0, + nbVotes: customNbVotes // Custom nbVotes (non-zero) + }) + ); + + // Stake in courts + vm.prank(staker1); + core.setStake(court2ID, 20000); + + // Create dispute + bytes memory extraData = abi.encodePacked(uint256(court2ID), DEFAULT_NB_OF_JURORS, mockDKID); + arbitrable.changeArbitratorExtraData(extraData); + vm.prank(disputer); + arbitrable.createDispute{value: 0.05 ether * DEFAULT_NB_OF_JURORS}("Action"); + + vm.warp(block.timestamp + minStakingTime); + sortitionModule.passPhase(); + vm.warp(block.timestamp + rngLookahead); + sortitionModule.passPhase(); + + core.draw(disputeID, DEFAULT_NB_OF_JURORS); + vm.warp(block.timestamp + timesPerPeriod[0]); + core.passPeriod(disputeID); + + uint256[] memory voteIDs = new uint256[](3); + voteIDs[0] = 0; + voteIDs[1] = 1; + voteIDs[2] = 2; + vm.prank(staker1); + mockDK.castVote(disputeID, voteIDs, 2, 0, "XYZ"); + + core.passPeriod(disputeID); + + // CRITICAL TEST: KlerosCore should detect NULL_DISPUTE_KIT (0) and trigger complete fallback + ( + uint96 nextCourtID, + uint256 nextDisputeKitID, + uint256 nextNbVotes, + bool isCourtJumping, + bool isDisputeKitJumping + ) = core.getCourtAndDisputeKitJumps(disputeID); + + assertEq(nextCourtID, court2ID, "Court should fallback to current (Court2), not Court3"); + assertEq(nextDisputeKitID, mockDKID, "DK should fallback to current (mockDK)"); + assertEq(nextNbVotes, 7, "nbVotes should fallback to default (7), not custom (11)"); + assertEq(isCourtJumping, false, "Should NOT be court jumping"); + assertEq(isDisputeKitJumping, false, "Should NOT be DK jumping"); + + // Verify appealCost uses Court2's fee with default nbVotes + uint256 expectedCost = 0.05 ether * 7; // Court2's fee × 7 (not Court3's 0.08) + assertEq(core.appealCost(disputeID), expectedCost, "appealCost should use Court2 fee"); + + // Fund and execute appeal + vm.prank(crowdfunder1); + mockDK.fundAppeal{value: 1.05 ether}(disputeID, 1); + + vm.expectEmit(true, true, true, true); + emit KlerosCore.AppealDecision(disputeID, arbitrable); + vm.expectEmit(true, true, true, true); + emit KlerosCore.NewPeriod(disputeID, KlerosCore.Period.evidence); + vm.prank(crowdfunder2); + mockDK.fundAppeal{value: 0.7 ether}(disputeID, 2); + + // Verify complete fallback + (uint96 courtID, , , , ) = core.disputes(disputeID); + assertEq(courtID, court2ID, "Should still be in Court2"); + + KlerosCore.Round memory round = core.getRoundInfo(disputeID, 1); + assertEq(round.disputeKitID, mockDKID, "Should still use mockDK"); + assertEq(round.nbVotes, 7, "Should use default nbVotes (7)"); + } + + /// @dev Test that nbVotes = 0 triggers KlerosCore fallback + /// Uses mock DK to test KlerosCore sanity check: if newRoundNbVotes == 0, trigger complete fallback. + /// Verifies complete triple fallback of all three parameters. + function test_appeal_zeroNbVotesTriggersKlerosCoreFallback() public { + uint256 disputeID = 0; + uint96 court2ID = 2; + uint96 court3ID = 3; + uint256 mockDKID = 2; + + // Create mock DK + DisputeKitClassicMockUncheckedNextRoundSettings mockDKLogic = new DisputeKitClassicMockUncheckedNextRoundSettings(); + bytes memory initDataMockDK = abi.encodeWithSignature( + "initialize(address,address,address)", + owner, + address(core), + address(wNative) + ); + UUPSProxy proxyMockDK = new UUPSProxy(address(mockDKLogic), initDataMockDK); + DisputeKitClassicMockUncheckedNextRoundSettings mockDK = DisputeKitClassicMockUncheckedNextRoundSettings( + address(proxyMockDK) + ); + + vm.prank(owner); + core.addNewDisputeKit(mockDK); + + // Create Court2 and Court3 + uint256[] memory supportedDK = new uint256[](2); + supportedDK[0] = DISPUTE_KIT_CLASSIC; + supportedDK[1] = mockDKID; + vm.prank(owner); + core.createCourt( + GENERAL_COURT, + hiddenVotes, + minStake, + alpha, + 0.05 ether, + 5, + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + vm.prank(owner); + core.createCourt( + GENERAL_COURT, + hiddenVotes, + minStake, + alpha, + 0.08 ether, + 5, + [uint256(60), uint256(120), uint256(180), uint256(240)], + sortitionExtraData, + supportedDK + ); + + // Configure NextRoundSettings with nbVotes = 0 + vm.prank(owner); + mockDK.changeNextRoundSettings( + court2ID, + DisputeKitClassicBase.NextRoundSettings({ + enabled: true, + jumpCourtID: court3ID, // Valid court (non-zero) + jumpDisputeKitID: mockDKID, // Valid DK (non-zero) + jumpDisputeKitIDOnCourtJump: 0, + nbVotes: 0 // 0 - will be passed raw to KlerosCore + }) + ); + + // Stake in courts + vm.prank(staker1); + core.setStake(court2ID, 20000); + + // Create dispute + bytes memory extraData = abi.encodePacked(uint256(court2ID), DEFAULT_NB_OF_JURORS, mockDKID); + arbitrable.changeArbitratorExtraData(extraData); + vm.prank(disputer); + arbitrable.createDispute{value: 0.05 ether * DEFAULT_NB_OF_JURORS}("Action"); + + vm.warp(block.timestamp + minStakingTime); + sortitionModule.passPhase(); + vm.warp(block.timestamp + rngLookahead); + sortitionModule.passPhase(); + + core.draw(disputeID, DEFAULT_NB_OF_JURORS); + vm.warp(block.timestamp + timesPerPeriod[0]); + core.passPeriod(disputeID); + + uint256[] memory voteIDs = new uint256[](3); + voteIDs[0] = 0; + voteIDs[1] = 1; + voteIDs[2] = 2; + vm.prank(staker1); + mockDK.castVote(disputeID, voteIDs, 2, 0, "XYZ"); + + core.passPeriod(disputeID); + + // CRITICAL TEST: KlerosCore should detect nbVotes == 0 and trigger complete fallback + ( + uint96 nextCourtID, + uint256 nextDisputeKitID, + uint256 nextNbVotes, + bool isCourtJumping, + bool isDisputeKitJumping + ) = core.getCourtAndDisputeKitJumps(disputeID); + + assertEq(nextCourtID, court2ID, "Court should fallback to current (Court2), not Court3"); + assertEq(nextDisputeKitID, mockDKID, "DK should fallback to current (mockDK)"); + assertEq(nextNbVotes, 7, "nbVotes should fallback to default (7)"); + assertEq(isCourtJumping, false, "Should NOT be court jumping"); + assertEq(isDisputeKitJumping, false, "Should NOT be DK jumping"); + + // Verify appealCost uses Court2's fee + uint256 expectedCost = 0.05 ether * 7; + assertEq(core.appealCost(disputeID), expectedCost, "appealCost should use Court2 fee"); + + // Fund and execute appeal + vm.prank(crowdfunder1); + mockDK.fundAppeal{value: 1.05 ether}(disputeID, 1); + + vm.expectEmit(true, true, true, true); + emit KlerosCore.AppealDecision(disputeID, arbitrable); + vm.expectEmit(true, true, true, true); + emit KlerosCore.NewPeriod(disputeID, KlerosCore.Period.evidence); + vm.prank(crowdfunder2); + mockDK.fundAppeal{value: 0.7 ether}(disputeID, 2); + + // Verify complete fallback + (uint96 courtID, , , , ) = core.disputes(disputeID); + assertEq(courtID, court2ID, "Should still be in Court2"); + + KlerosCore.Round memory round = core.getRoundInfo(disputeID, 1); + assertEq(round.disputeKitID, mockDKID, "Should still use mockDK"); + assertEq(round.nbVotes, 7, "Should use default nbVotes (7)"); + } } diff --git a/contracts/test/foundry/KlerosCore_Initialization.t.sol b/contracts/test/foundry/KlerosCore_Initialization.t.sol index 3aa316c0d..6dba2ec0f 100644 --- a/contracts/test/foundry/KlerosCore_Initialization.t.sol +++ b/contracts/test/foundry/KlerosCore_Initialization.t.sol @@ -60,8 +60,6 @@ contract KlerosCore_InitializationTest is KlerosCore_TestBase { assertEq(pinakion.allowance(staker2, address(core)), 1 ether, "Wrong allowance for staker2"); assertEq(disputeKit.owner(), msg.sender, "Wrong DK owner"); - assertEq(disputeKit.getJumpDisputeKitID(), DISPUTE_KIT_CLASSIC, "Wrong jump DK"); - assertEq(disputeKit.jumpDisputeKitID(), DISPUTE_KIT_CLASSIC, "Wrong jump DK storage var"); assertEq(address(disputeKit.core()), address(core), "Wrong core in DK"); assertEq(sortitionModule.owner(), msg.sender, "Wrong SM owner"); @@ -118,11 +116,10 @@ contract KlerosCore_InitializationTest is KlerosCore_TestBase { UUPSProxy proxyCore = new UUPSProxy(address(coreLogic), ""); bytes memory initDataDk = abi.encodeWithSignature( - "initialize(address,address,address,uint256)", + "initialize(address,address,address)", newOwner, address(proxyCore), - address(wNative), - DISPUTE_KIT_CLASSIC + address(wNative) ); UUPSProxy proxyDk = new UUPSProxy(address(dkLogic), initDataDk); diff --git a/contracts/test/foundry/KlerosCore_TestBase.sol b/contracts/test/foundry/KlerosCore_TestBase.sol index 991cd7469..a63a46418 100644 --- a/contracts/test/foundry/KlerosCore_TestBase.sol +++ b/contracts/test/foundry/KlerosCore_TestBase.sol @@ -116,11 +116,10 @@ abstract contract KlerosCore_TestBase is Test { UUPSProxy proxyCore = new UUPSProxy(address(coreLogic), ""); bytes memory initDataDk = abi.encodeWithSignature( - "initialize(address,address,address,uint256)", + "initialize(address,address,address)", owner, address(proxyCore), - address(wNative), - DISPUTE_KIT_CLASSIC + address(wNative) ); UUPSProxy proxyDk = new UUPSProxy(address(dkLogic), initDataDk); diff --git a/contracts/test/foundry/KlerosCore_Voting.t.sol b/contracts/test/foundry/KlerosCore_Voting.t.sol index 5a7b4c091..398ff4b95 100644 --- a/contracts/test/foundry/KlerosCore_Voting.t.sol +++ b/contracts/test/foundry/KlerosCore_Voting.t.sol @@ -399,11 +399,10 @@ contract KlerosCore_VotingTest is KlerosCore_TestBase { DisputeKitClassic dkLogic = new DisputeKitClassic(); // Create a new DK to check castVote. bytes memory initDataDk = abi.encodeWithSignature( - "initialize(address,address,address,uint256)", + "initialize(address,address,address)", owner, address(core), - address(wNative), - DISPUTE_KIT_CLASSIC + address(wNative) ); UUPSProxy proxyDk = new UUPSProxy(address(dkLogic), initDataDk);