diff --git a/contracts/CHANGELOG.md b/contracts/CHANGELOG.md index 79845b880..086502e21 100644 --- a/contracts/CHANGELOG.md +++ b/contracts/CHANGELOG.md @@ -13,6 +13,12 @@ The format is based on [Common Changelog](https://common-changelog.org/). - **Breaking:** Rename the interface from `RNG` to `IRNG` ([#2054](https://github.com/kleros/kleros-v2/issues/2054)) - **Breaking:** Remove the `_block` parameter from `IRNG.requestRandomness()` and `IRNG.receiveRandomness()`, not needed for the primary VRF-based RNG ([#2054](https://github.com/kleros/kleros-v2/issues/2054)) - **Breaking:** Rename `governor` to `owner` in order to comply with the lightweight ownership standard [ERC-5313](https://eipsinsight.com/ercs/erc-5313) ([#2112](https://github.com/kleros/kleros-v2/issues/2112)) +- **Breaking:** Apply the penalties to the stakes in the Sortition Tree ([#2107](https://github.com/kleros/kleros-v2/issues/2107)) +- **Breaking:** Make `SortitionModule.getJurorBalance().stakedInCourt` include the penalties ([#2107](https://github.com/kleros/kleros-v2/issues/2107)) +- **Breaking:** Add a new field `drawnJurorFromCourtIDs` to the `Round` struct in `KlerosCoreBase` and `KlerosCoreUniversity` ([#2107](https://github.com/kleros/kleros-v2/issues/2107)) +- Make `IDisputeKit.draw()` and `ISortitionModule.draw()` return the court ID from which the juror was drawn ([#2107](https://github.com/kleros/kleros-v2/issues/2107)) +- Rename `SortitionModule.setJurorInactive()` to `SortitionModule.forcedUnstakeAllCourts()` ([#2107](https://github.com/kleros/kleros-v2/issues/2107)) +- Allow stake changes to by-pass delayed stakes when initiated by the SortitionModule by setting the `_noDelay` parameter to `true` in `SortitionModule.validateStake()` ([#2107](https://github.com/kleros/kleros-v2/issues/2107)) - Make the primary VRF-based RNG fall back to `BlockhashRNG` if the VRF request is not fulfilled within a timeout ([#2054](https://github.com/kleros/kleros-v2/issues/2054)) - Authenticate the calls to the RNGs to prevent 3rd parties from depleting the Chainlink VRF subscription funds ([#2054](https://github.com/kleros/kleros-v2/issues/2054)) - Use `block.timestamp` rather than `block.number` for `BlockhashRNG` for better reliability on Arbitrum as block production is sporadic depending on network conditions. ([#2054](https://github.com/kleros/kleros-v2/issues/2054)) diff --git a/contracts/src/arbitration/KlerosCoreBase.sol b/contracts/src/arbitration/KlerosCoreBase.sol index 0a9946bbc..4efac9477 100644 --- a/contracts/src/arbitration/KlerosCoreBase.sol +++ b/contracts/src/arbitration/KlerosCoreBase.sol @@ -60,6 +60,7 @@ abstract contract KlerosCoreBase is IArbitratorV2, Initializable, UUPSProxiable uint256 repartitions; // A counter of reward repartitions made in this round. uint256 pnkPenalties; // The amount of PNKs collected from penalties in this round. address[] drawnJurors; // Addresses of the jurors that were drawn in this round. + uint96[] drawnJurorFromCourtIDs; // The courtIDs where the juror was drawn from, possibly their stake in a subcourt. uint256 sumFeeRewardPaid; // Total sum of arbitration fees paid to coherent jurors as a reward in this round. uint256 sumPnkRewardPaid; // Total sum of PNK paid to coherent jurors as a reward in this round. IERC20 feeToken; // The token used for paying fees in this round. @@ -463,16 +464,16 @@ abstract contract KlerosCoreBase is IArbitratorV2, Initializable, UUPSProxiable /// @param _newStake The new stake. /// Note that the existing delayed stake will be nullified as non-relevant. function setStake(uint96 _courtID, uint256 _newStake) external virtual whenNotPaused { - _setStake(msg.sender, _courtID, _newStake, OnError.Revert); + _setStake(msg.sender, _courtID, _newStake, false, OnError.Revert); } - /// @dev Sets the stake of a specified account in a court, typically to apply a delayed stake or unstake inactive jurors. + /// @dev Sets the stake of a specified account in a court without delaying stake changes, typically to apply a delayed stake or unstake inactive jurors. /// @param _account The account whose stake is being set. /// @param _courtID The ID of the court. /// @param _newStake The new stake. function setStakeBySortitionModule(address _account, uint96 _courtID, uint256 _newStake) external { if (msg.sender != address(sortitionModule)) revert SortitionModuleOnly(); - _setStake(_account, _courtID, _newStake, OnError.Return); + _setStake(_account, _courtID, _newStake, true, OnError.Return); } /// @dev Transfers PNK to the juror by SortitionModule. @@ -606,13 +607,14 @@ abstract contract KlerosCoreBase is IArbitratorV2, Initializable, UUPSProxiable uint256 startIndex = round.drawIterations; // for gas: less storage reads uint256 i; while (i < _iterations && round.drawnJurors.length < round.nbVotes) { - address drawnAddress = disputeKit.draw(_disputeID, startIndex + i++); + (address drawnAddress, uint96 fromSubcourtID) = disputeKit.draw(_disputeID, startIndex + i++); if (drawnAddress == address(0)) { continue; } sortitionModule.lockStake(drawnAddress, round.pnkAtStakePerJuror); emit Draw(drawnAddress, _disputeID, currentRound, round.drawnJurors.length); round.drawnJurors.push(drawnAddress); + round.drawnJurorFromCourtIDs.push(fromSubcourtID != 0 ? fromSubcourtID : dispute.courtID); if (round.drawnJurors.length == round.nbVotes) { sortitionModule.postDrawHook(_disputeID, currentRound); } @@ -775,7 +777,12 @@ abstract contract KlerosCoreBase is IArbitratorV2, Initializable, UUPSProxiable sortitionModule.unlockStake(account, penalty); // Apply the penalty to the staked PNKs. - (uint256 pnkBalance, uint256 availablePenalty) = sortitionModule.penalizeStake(account, penalty); + uint96 penalizedInCourtID = round.drawnJurorFromCourtIDs[_params.repartition]; + (uint256 pnkBalance, uint256 newCourtStake, uint256 availablePenalty) = sortitionModule.setStakePenalty( + account, + penalizedInCourtID, + penalty + ); _params.pnkPenaltiesInRound += availablePenalty; emit TokenAndETHShift( account, @@ -786,10 +793,15 @@ abstract contract KlerosCoreBase is IArbitratorV2, Initializable, UUPSProxiable 0, round.feeToken ); - // Unstake the juror from all courts if he was inactive or his balance can't cover penalties anymore. + if (pnkBalance == 0 || !disputeKit.isVoteActive(_params.disputeID, _params.round, _params.repartition)) { - sortitionModule.setJurorInactive(account); + // The juror is inactive or their balance is can't cover penalties anymore, unstake them from all courts. + sortitionModule.forcedUnstakeAllCourts(account); + } else if (newCourtStake < courts[penalizedInCourtID].minStake) { + // The juror's balance fell below the court minStake, unstake them from the court. + sortitionModule.forcedUnstake(account, penalizedInCourtID); } + if (_params.repartition == _params.numberOfVotesInRound - 1 && _params.coherentCount == 0) { // No one was coherent, send the rewards to the owner. _transferFeeToken(round.feeToken, payable(owner), round.totalFeesForJurors); @@ -1126,9 +1138,16 @@ abstract contract KlerosCoreBase is IArbitratorV2, Initializable, UUPSProxiable /// @param _account The account to set the stake for. /// @param _courtID The ID of the court to set the stake for. /// @param _newStake The new stake. + /// @param _noDelay True if the stake change should not be delayed. /// @param _onError Whether to revert or return false on error. /// @return Whether the stake was successfully set or not. - function _setStake(address _account, uint96 _courtID, uint256 _newStake, OnError _onError) internal returns (bool) { + function _setStake( + address _account, + uint96 _courtID, + uint256 _newStake, + bool _noDelay, + OnError _onError + ) internal returns (bool) { if (_courtID == FORKING_COURT || _courtID >= courts.length) { _stakingFailed(_onError, StakingResult.CannotStakeInThisCourt); // Staking directly into the forking court is not allowed. return false; @@ -1140,7 +1159,8 @@ abstract contract KlerosCoreBase is IArbitratorV2, Initializable, UUPSProxiable (uint256 pnkDeposit, uint256 pnkWithdrawal, StakingResult stakingResult) = sortitionModule.validateStake( _account, _courtID, - _newStake + _newStake, + _noDelay ); if (stakingResult != StakingResult.Successful && stakingResult != StakingResult.Delayed) { _stakingFailed(_onError, stakingResult); diff --git a/contracts/src/arbitration/KlerosCoreNeo.sol b/contracts/src/arbitration/KlerosCoreNeo.sol index a72319fc5..ebfd1bb72 100644 --- a/contracts/src/arbitration/KlerosCoreNeo.sol +++ b/contracts/src/arbitration/KlerosCoreNeo.sol @@ -108,7 +108,7 @@ contract KlerosCoreNeo is KlerosCoreBase { /// Note that the existing delayed stake will be nullified as non-relevant. function setStake(uint96 _courtID, uint256 _newStake) external override whenNotPaused { if (jurorNft.balanceOf(msg.sender) == 0) revert NotEligibleForStaking(); - super._setStake(msg.sender, _courtID, _newStake, OnError.Revert); + super._setStake(msg.sender, _courtID, _newStake, false, OnError.Revert); } // ************************************* // diff --git a/contracts/src/arbitration/SortitionModuleBase.sol b/contracts/src/arbitration/SortitionModuleBase.sol index ae749fb85..cf48c624c 100644 --- a/contracts/src/arbitration/SortitionModuleBase.sol +++ b/contracts/src/arbitration/SortitionModuleBase.sol @@ -19,12 +19,11 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr struct SortitionSumTree { uint256 K; // The maximum number of children per node. - // We use this to keep track of vacant positions in the tree after removing a leaf. This is for keeping the tree as balanced as possible without spending gas on moving nodes around. - uint256[] stack; - uint256[] nodes; + uint256[] stack; // We use this to keep track of vacant positions in the tree after removing a leaf. This is for keeping the tree as balanced as possible without spending gas on moving nodes around. + uint256[] nodes; // The tree nodes. // Two-way mapping of IDs to node indexes. Note that node index 0 is reserved for the root node, and means the ID does not have a node. - mapping(bytes32 => uint256) IDsToNodeIndexes; - mapping(uint256 => bytes32) nodeIndexesToIDs; + mapping(bytes32 stakePathID => uint256 nodeIndex) IDsToNodeIndexes; + mapping(uint256 nodeIndex => bytes32 stakePathID) nodeIndexesToIDs; } struct DelayedStake { @@ -36,7 +35,7 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr struct Juror { uint96[] courtIDs; // The IDs of courts where the juror's stake path ends. A stake path is a path from the general court to a court the juror directly staked in using `_setStake`. - uint256 stakedPnk; // The juror's total amount of tokens staked in subcourts. Reflects actual pnk balance. + uint256 stakedPnk; // The juror's total amount of tokens staked in subcourts. PNK balance including locked PNK and penalty deductions. uint256 lockedPnk; // The juror's total amount of tokens locked in disputes. } @@ -233,21 +232,24 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr /// @param _account The address of the juror. /// @param _courtID The ID of the court. /// @param _newStake The new stake. + /// @param _noDelay True if the stake change should not be delayed. /// @return pnkDeposit The amount of PNK to be deposited. /// @return pnkWithdrawal The amount of PNK to be withdrawn. /// @return stakingResult The result of the staking operation. function validateStake( address _account, uint96 _courtID, - uint256 _newStake + uint256 _newStake, + bool _noDelay ) external override onlyByCore returns (uint256 pnkDeposit, uint256 pnkWithdrawal, StakingResult stakingResult) { - (pnkDeposit, pnkWithdrawal, stakingResult) = _validateStake(_account, _courtID, _newStake); + (pnkDeposit, pnkWithdrawal, stakingResult) = _validateStake(_account, _courtID, _newStake, _noDelay); } function _validateStake( address _account, uint96 _courtID, - uint256 _newStake + uint256 _newStake, + bool _noDelay ) internal virtual returns (uint256 pnkDeposit, uint256 pnkWithdrawal, StakingResult stakingResult) { Juror storage juror = jurors[_account]; uint256 currentStake = stakeOf(_account, _courtID); @@ -261,7 +263,7 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr return (0, 0, StakingResult.CannotStakeZeroWhenNoStake); // Forbid staking 0 amount when current stake is 0 to avoid flaky behaviour. } - if (phase != Phase.staking) { + if (phase != Phase.staking && !_noDelay) { // Store the stake change as delayed, to be applied when the phase switches back to Staking. DelayedStake storage delayedStake = delayedStakes[++delayedStakeWriteIndex]; delayedStake.account = _account; @@ -306,6 +308,42 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr _setStake(_account, _courtID, _pnkDeposit, _pnkWithdrawal, _newStake); } + /// @dev Update the state of the stakes with a PNK reward deposit, called by KC during rewards execution. + /// `O(n + p * log_k(j))` where + /// `n` is the number of courts the juror has staked in, + /// `p` is the depth of the court tree, + /// `k` is the minimum number of children per node of one of these courts' sortition sum tree, + /// and `j` is the maximum number of jurors that ever staked in one of these courts simultaneously. + /// @param _account The address of the juror. + /// @param _courtID The ID of the court. + /// @param _penalty The amount of PNK to be deducted. + /// @return pnkBalance The updated total PNK balance of the juror, including the penalty. + /// @return newCourtStake The updated stake of the juror in the court. + /// @return availablePenalty The amount of PNK that was actually deducted. + function setStakePenalty( + address _account, + uint96 _courtID, + uint256 _penalty + ) external override onlyByCore returns (uint256 pnkBalance, uint256 newCourtStake, uint256 availablePenalty) { + Juror storage juror = jurors[_account]; + availablePenalty = _penalty; + newCourtStake = stakeOf(_account, _courtID); + if (juror.stakedPnk < _penalty) { + availablePenalty = juror.stakedPnk; + } + + if (availablePenalty == 0) return (juror.stakedPnk, newCourtStake, 0); // No penalty to apply. + + uint256 currentStake = stakeOf(_account, _courtID); + uint256 newStake = 0; + if (currentStake >= availablePenalty) { + newStake = currentStake - availablePenalty; + } + _setStake(_account, _courtID, 0, availablePenalty, newStake); + pnkBalance = juror.stakedPnk; // updated by _setStake() + newCourtStake = stakeOf(_account, _courtID); // updated by _setStake() + } + /// @dev Update the state of the stakes with a PNK reward deposit, called by KC during rewards execution. /// `O(n + p * log_k(j))` where /// `n` is the number of courts the juror has staked in, @@ -391,25 +429,6 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr } } - function penalizeStake( - address _account, - uint256 _relativeAmount - ) external override onlyByCore returns (uint256 pnkBalance, uint256 availablePenalty) { - Juror storage juror = jurors[_account]; - uint256 stakedPnk = juror.stakedPnk; - - if (stakedPnk >= _relativeAmount) { - availablePenalty = _relativeAmount; - juror.stakedPnk -= _relativeAmount; - } else { - availablePenalty = stakedPnk; - juror.stakedPnk = 0; - } - - pnkBalance = juror.stakedPnk; - return (pnkBalance, availablePenalty); - } - /// @dev Unstakes the inactive juror from all courts. /// `O(n * (p * log_k(j)) )` where /// `n` is the number of courts the juror has staked in, @@ -417,13 +436,25 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr /// `k` is the minimum number of children per node of one of these courts' sortition sum tree, /// and `j` is the maximum number of jurors that ever staked in one of these courts simultaneously. /// @param _account The juror to unstake. - function setJurorInactive(address _account) external override onlyByCore { + function forcedUnstakeAllCourts(address _account) external override onlyByCore { uint96[] memory courtIDs = getJurorCourtIDs(_account); for (uint256 j = courtIDs.length; j > 0; j--) { core.setStakeBySortitionModule(_account, courtIDs[j - 1], 0); } } + /// @dev Unstakes the inactive juror from a specific court. + /// `O(n * (p * log_k(j)) )` where + /// `n` is the number of courts the juror has staked in, + /// `p` is the depth of the court tree, + /// `k` is the minimum number of children per node of one of these courts' sortition sum tree, + /// and `j` is the maximum number of jurors that ever staked in one of these courts simultaneously. + /// @param _account The juror to unstake. + /// @param _courtID The ID of the court. + function forcedUnstake(address _account, uint96 _courtID) external override onlyByCore { + core.setStakeBySortitionModule(_account, _courtID, 0); + } + /// @dev Gives back the locked PNKs in case the juror fully unstaked earlier. /// Note that since locked and staked PNK are async it is possible for the juror to have positive staked PNK balance /// while having 0 stake in courts and 0 locked tokens (eg. when the juror fully unstaked during dispute and later got his tokens unlocked). @@ -457,12 +488,12 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr bytes32 _key, uint256 _coreDisputeID, uint256 _nonce - ) public view override returns (address drawnAddress) { + ) public view override returns (address drawnAddress, uint96 fromSubcourtID) { if (phase != Phase.drawing) revert NotDrawingPhase(); SortitionSumTree storage tree = sortitionSumTrees[_key]; if (tree.nodes[0] == 0) { - return address(0); // No jurors staked. + return (address(0), 0); // No jurors staked. } uint256 currentDrawnNumber = uint256(keccak256(abi.encodePacked(randomNumber, _coreDisputeID, _nonce))) % @@ -486,7 +517,9 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr } } } - drawnAddress = _stakePathIDToAccount(tree.nodeIndexesToIDs[treeIndex]); + + bytes32 stakePathID = tree.nodeIndexesToIDs[treeIndex]; + (drawnAddress, fromSubcourtID) = _stakePathIDToAccountAndCourtID(stakePathID); } /// @dev Get the stake of a juror in a court. @@ -500,17 +533,24 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr /// @dev Get the stake of a juror in a court. /// @param _key The key of the tree, corresponding to a court. - /// @param _ID The stake path ID, corresponding to a juror. + /// @param _stakePathID The stake path ID, corresponding to a juror. /// @return The stake of the juror in the court. - function stakeOf(bytes32 _key, bytes32 _ID) public view returns (uint256) { + function stakeOf(bytes32 _key, bytes32 _stakePathID) public view returns (uint256) { SortitionSumTree storage tree = sortitionSumTrees[_key]; - uint treeIndex = tree.IDsToNodeIndexes[_ID]; + uint treeIndex = tree.IDsToNodeIndexes[_stakePathID]; if (treeIndex == 0) { return 0; } return tree.nodes[treeIndex]; } + /// @dev Gets the balance of a juror in a court. + /// @param _juror The address of the juror. + /// @param _courtID The ID of the court. + /// @return totalStaked The total amount of tokens staked including locked tokens and penalty deductions. Equivalent to the effective stake in the General court. + /// @return totalLocked The total amount of tokens locked in disputes. + /// @return stakedInCourt The amount of tokens staked in the specified court including locked tokens and penalty deductions. + /// @return nbCourts The number of courts the juror has directly staked in. function getJurorBalance( address _juror, uint96 _courtID @@ -570,24 +610,6 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr } } - /// @dev Retrieves a juror's address from the stake path ID. - /// @param _stakePathID The stake path ID to unpack. - /// @return account The account. - function _stakePathIDToAccount(bytes32 _stakePathID) internal pure returns (address account) { - assembly { - // solium-disable-line security/no-inline-assembly - let ptr := mload(0x40) - for { - let i := 0x00 - } lt(i, 0x14) { - i := add(i, 0x01) - } { - mstore8(add(add(ptr, 0x0c), i), byte(i, _stakePathID)) - } - account := mload(ptr) - } - } - function _extraDataToTreeK(bytes memory _extraData) internal pure returns (uint256 K) { if (_extraData.length >= 32) { assembly { @@ -602,13 +624,13 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr /// @dev Set a value in a tree. /// @param _key The key of the tree. /// @param _value The new value. - /// @param _ID The ID of the value. + /// @param _stakePathID The ID of the value. /// `O(log_k(n))` where /// `k` is the maximum number of children per node in the tree, /// and `n` is the maximum number of nodes ever appended. - function _set(bytes32 _key, uint256 _value, bytes32 _ID) internal { + function _set(bytes32 _key, uint256 _value, bytes32 _stakePathID) internal { SortitionSumTree storage tree = sortitionSumTrees[_key]; - uint256 treeIndex = tree.IDsToNodeIndexes[_ID]; + uint256 treeIndex = tree.IDsToNodeIndexes[_stakePathID]; if (treeIndex == 0) { // No existing node. @@ -642,8 +664,8 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr } // Add label. - tree.IDsToNodeIndexes[_ID] = treeIndex; - tree.nodeIndexesToIDs[treeIndex] = _ID; + tree.IDsToNodeIndexes[_stakePathID] = treeIndex; + tree.nodeIndexesToIDs[treeIndex] = _stakePathID; _updateParents(_key, treeIndex, true, _value); } @@ -660,7 +682,7 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr tree.stack.push(treeIndex); // Clear label. - delete tree.IDsToNodeIndexes[_ID]; + delete tree.IDsToNodeIndexes[_stakePathID]; delete tree.nodeIndexesToIDs[treeIndex]; _updateParents(_key, treeIndex, false, value); @@ -678,7 +700,7 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr } } - /// @dev Packs an account and a court ID into a stake path ID. + /// @dev Packs an account and a court ID into a stake path ID: [20 bytes of address][12 bytes of courtID] = 32 bytes total. /// @param _account The address of the juror to pack. /// @param _courtID The court ID to pack. /// @return stakePathID The stake path ID. @@ -689,6 +711,8 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr assembly { // solium-disable-line security/no-inline-assembly let ptr := mload(0x40) + + // Write account address (first 20 bytes) for { let i := 0x00 } lt(i, 0x14) { @@ -696,6 +720,8 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr } { mstore8(add(ptr, i), byte(add(0x0c, i), _account)) } + + // Write court ID (last 12 bytes) for { let i := 0x14 } lt(i, 0x20) { @@ -707,6 +733,39 @@ abstract contract SortitionModuleBase is ISortitionModule, Initializable, UUPSPr } } + /// @dev Retrieves both juror's address and court ID from the stake path ID. + /// @param _stakePathID The stake path ID to unpack. + /// @return account The account. + /// @return courtID The court ID. + function _stakePathIDToAccountAndCourtID( + bytes32 _stakePathID + ) internal pure returns (address account, uint96 courtID) { + assembly { + // solium-disable-line security/no-inline-assembly + let ptr := mload(0x40) + + // Read account address (first 20 bytes) + for { + let i := 0x00 + } lt(i, 0x14) { + i := add(i, 0x01) + } { + mstore8(add(add(ptr, 0x0c), i), byte(i, _stakePathID)) + } + account := mload(ptr) + + // Read court ID (last 12 bytes) + for { + let i := 0x00 + } lt(i, 0x0c) { + i := add(i, 0x01) + } { + mstore8(add(add(ptr, 0x14), i), byte(add(i, 0x14), _stakePathID)) + } + courtID := mload(ptr) + } + } + // ************************************* // // * Errors * // // ************************************* // diff --git a/contracts/src/arbitration/SortitionModuleNeo.sol b/contracts/src/arbitration/SortitionModuleNeo.sol index d106d5d9b..7c2a3b53b 100644 --- a/contracts/src/arbitration/SortitionModuleNeo.sol +++ b/contracts/src/arbitration/SortitionModuleNeo.sol @@ -77,7 +77,8 @@ contract SortitionModuleNeo is SortitionModuleBase { function _validateStake( address _account, uint96 _courtID, - uint256 _newStake + uint256 _newStake, + bool _noDelay ) internal override onlyByCore returns (uint256 pnkDeposit, uint256 pnkWithdrawal, StakingResult stakingResult) { uint256 currentStake = stakeOf(_account, _courtID); bool stakeIncrease = _newStake > currentStake; @@ -98,6 +99,6 @@ contract SortitionModuleNeo is SortitionModuleBase { totalStaked -= stakeChange; } } - (pnkDeposit, pnkWithdrawal, stakingResult) = super._validateStake(_account, _courtID, _newStake); + (pnkDeposit, pnkWithdrawal, stakingResult) = super._validateStake(_account, _courtID, _newStake, _noDelay); } } diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol b/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol index f2d7a154c..03a284878 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol @@ -224,7 +224,7 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi function draw( uint256 _coreDisputeID, uint256 _nonce - ) external override onlyByCore notJumped(_coreDisputeID) returns (address drawnAddress) { + ) external override onlyByCore notJumped(_coreDisputeID) returns (address drawnAddress, uint96 fromSubcourtID) { uint256 localDisputeID = coreDisputeIDToLocal[_coreDisputeID]; Dispute storage dispute = disputes[localDisputeID]; uint256 localRoundID = dispute.rounds.length - 1; @@ -234,10 +234,10 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi (uint96 courtID, , , , ) = core.disputes(_coreDisputeID); bytes32 key = bytes32(uint256(courtID)); // Get the ID of the tree. - drawnAddress = sortitionModule.draw(key, _coreDisputeID, _nonce); + (drawnAddress, fromSubcourtID) = sortitionModule.draw(key, _coreDisputeID, _nonce); if (drawnAddress == address(0)) { // Sortition can return 0 address if no one has staked yet. - return drawnAddress; + return (drawnAddress, fromSubcourtID); } if (_postDrawCheck(round, _coreDisputeID, drawnAddress)) { diff --git a/contracts/src/arbitration/interfaces/IDisputeKit.sol b/contracts/src/arbitration/interfaces/IDisputeKit.sol index 0aab45b9c..50e533203 100644 --- a/contracts/src/arbitration/interfaces/IDisputeKit.sol +++ b/contracts/src/arbitration/interfaces/IDisputeKit.sol @@ -48,7 +48,10 @@ 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. - function draw(uint256 _coreDisputeID, uint256 _nonce) external returns (address drawnAddress); + function draw( + uint256 _coreDisputeID, + uint256 _nonce + ) external returns (address drawnAddress, uint96 fromSubcourtID); // ************************************* // // * Public Views * // diff --git a/contracts/src/arbitration/interfaces/ISortitionModule.sol b/contracts/src/arbitration/interfaces/ISortitionModule.sol index a51fadae9..e830891df 100644 --- a/contracts/src/arbitration/interfaces/ISortitionModule.sol +++ b/contracts/src/arbitration/interfaces/ISortitionModule.sol @@ -18,7 +18,8 @@ interface ISortitionModule { function validateStake( address _account, uint96 _courtID, - uint256 _newStake + uint256 _newStake, + bool _noDelay ) external returns (uint256 pnkDeposit, uint256 pnkWithdrawal, StakingResult stakingResult); function setStake( @@ -29,22 +30,29 @@ interface ISortitionModule { uint256 _newStake ) external; + function setStakePenalty( + address _account, + uint96 _courtID, + uint256 _penalty + ) external returns (uint256 pnkBalance, uint256 newCourtStake, uint256 availablePenalty); + function setStakeReward(address _account, uint96 _courtID, uint256 _reward) external returns (bool success); - function setJurorInactive(address _account) external; + function forcedUnstakeAllCourts(address _account) external; + + function forcedUnstake(address _account, uint96 _courtID) external; function lockStake(address _account, uint256 _relativeAmount) external; function unlockStake(address _account, uint256 _relativeAmount) external; - function penalizeStake( - address _account, - uint256 _relativeAmount - ) external returns (uint256 pnkBalance, uint256 availablePenalty); - function notifyRandomNumber(uint256 _drawnNumber) external; - function draw(bytes32 _court, uint256 _coreDisputeID, uint256 _nonce) external view returns (address); + function draw( + bytes32 _court, + uint256 _coreDisputeID, + uint256 _nonce + ) external view returns (address drawnAddress, uint96 fromSubcourtID); function getJurorBalance( address _juror, diff --git a/contracts/src/arbitration/university/KlerosCoreUniversity.sol b/contracts/src/arbitration/university/KlerosCoreUniversity.sol index d6366753a..b2519fdd7 100644 --- a/contracts/src/arbitration/university/KlerosCoreUniversity.sol +++ b/contracts/src/arbitration/university/KlerosCoreUniversity.sol @@ -59,6 +59,7 @@ contract KlerosCoreUniversity is IArbitratorV2, UUPSProxiable, Initializable { uint256 repartitions; // A counter of reward repartitions made in this round. uint256 pnkPenalties; // The amount of PNKs collected from penalties in this round. address[] drawnJurors; // Addresses of the jurors that were drawn in this round. + uint96[] drawnJurorFromCourtIDs; // The courtIDs where the juror was drawn from, possibly their stake in a subcourt. uint256 sumFeeRewardPaid; // Total sum of arbitration fees paid to coherent jurors as a reward in this round. uint256 sumPnkRewardPaid; // Total sum of PNK paid to coherent jurors as a reward in this round. IERC20 feeToken; // The token used for paying fees in this round. @@ -451,7 +452,7 @@ contract KlerosCoreUniversity is IArbitratorV2, UUPSProxiable, Initializable { /// @param _newStake The new stake. /// Note that the existing delayed stake will be nullified as non-relevant. function setStake(uint96 _courtID, uint256 _newStake) external { - _setStake(msg.sender, _courtID, _newStake, OnError.Revert); + _setStake(msg.sender, _courtID, _newStake, false, OnError.Revert); } /// @dev Sets the stake of a specified account in a court, typically to apply a delayed stake or unstake inactive jurors. @@ -460,7 +461,7 @@ contract KlerosCoreUniversity is IArbitratorV2, UUPSProxiable, Initializable { /// @param _newStake The new stake. function setStakeBySortitionModule(address _account, uint96 _courtID, uint256 _newStake) external { if (msg.sender != address(sortitionModule)) revert SortitionModuleOnly(); - _setStake(_account, _courtID, _newStake, OnError.Return); + _setStake(_account, _courtID, _newStake, true, OnError.Return); } /// @dev Transfers PNK to the juror by SortitionModule. @@ -595,13 +596,14 @@ contract KlerosCoreUniversity is IArbitratorV2, UUPSProxiable, Initializable { { IDisputeKit disputeKit = disputeKits[round.disputeKitID]; uint256 iteration = round.drawIterations + 1; - address drawnAddress = disputeKit.draw(_disputeID, iteration); + (address drawnAddress, uint96 fromSubcourtID) = disputeKit.draw(_disputeID, iteration); if (drawnAddress == address(0)) { revert NoJurorDrawn(); } sortitionModule.lockStake(drawnAddress, round.pnkAtStakePerJuror); emit Draw(drawnAddress, _disputeID, currentRound, round.drawnJurors.length); round.drawnJurors.push(drawnAddress); + round.drawnJurorFromCourtIDs.push(fromSubcourtID != 0 ? fromSubcourtID : dispute.courtID); if (round.drawnJurors.length == round.nbVotes) { sortitionModule.postDrawHook(_disputeID, currentRound); } @@ -770,7 +772,12 @@ contract KlerosCoreUniversity is IArbitratorV2, UUPSProxiable, Initializable { sortitionModule.unlockStake(account, penalty); // Apply the penalty to the staked PNKs. - (uint256 pnkBalance, uint256 availablePenalty) = sortitionModule.penalizeStake(account, penalty); + uint96 penalizedInCourtID = round.drawnJurorFromCourtIDs[_params.repartition]; + (uint256 pnkBalance, uint256 newCourtStake, uint256 availablePenalty) = sortitionModule.setStakePenalty( + account, + penalizedInCourtID, + penalty + ); _params.pnkPenaltiesInRound += availablePenalty; emit TokenAndETHShift( account, @@ -781,10 +788,15 @@ contract KlerosCoreUniversity is IArbitratorV2, UUPSProxiable, Initializable { 0, round.feeToken ); - // Unstake the juror from all courts if he was inactive or his balance can't cover penalties anymore. + if (pnkBalance == 0 || !disputeKit.isVoteActive(_params.disputeID, _params.round, _params.repartition)) { - sortitionModule.setJurorInactive(account); + // The juror is inactive or their balance is can't cover penalties anymore, unstake them from all courts. + sortitionModule.forcedUnstakeAllCourts(account); + } else if (newCourtStake < courts[penalizedInCourtID].minStake) { + // The juror's balance fell below the court minStake, unstake them from the court. + sortitionModule.forcedUnstake(account, penalizedInCourtID); } + if (_params.repartition == _params.numberOfVotesInRound - 1 && _params.coherentCount == 0) { // No one was coherent, send the rewards to the owner. if (round.feeToken == NATIVE_CURRENCY) { @@ -1068,9 +1080,16 @@ contract KlerosCoreUniversity is IArbitratorV2, UUPSProxiable, Initializable { /// @param _account The account to set the stake for. /// @param _courtID The ID of the court to set the stake for. /// @param _newStake The new stake. + /// @param _noDelay True if the stake change should not be delayed. /// @param _onError Whether to revert or return false on error. /// @return Whether the stake was successfully set or not. - function _setStake(address _account, uint96 _courtID, uint256 _newStake, OnError _onError) internal returns (bool) { + function _setStake( + address _account, + uint96 _courtID, + uint256 _newStake, + bool _noDelay, + OnError _onError + ) internal returns (bool) { if (_courtID == FORKING_COURT || _courtID >= courts.length) { _stakingFailed(_onError, StakingResult.CannotStakeInThisCourt); // Staking directly into the forking court is not allowed. return false; @@ -1082,7 +1101,8 @@ contract KlerosCoreUniversity is IArbitratorV2, UUPSProxiable, Initializable { (uint256 pnkDeposit, uint256 pnkWithdrawal, StakingResult stakingResult) = sortitionModule.validateStake( _account, _courtID, - _newStake + _newStake, + _noDelay ); if (stakingResult != StakingResult.Successful) { _stakingFailed(_onError, stakingResult); diff --git a/contracts/src/arbitration/university/SortitionModuleUniversity.sol b/contracts/src/arbitration/university/SortitionModuleUniversity.sol index a7a049bb0..dd1caa832 100644 --- a/contracts/src/arbitration/university/SortitionModuleUniversity.sol +++ b/contracts/src/arbitration/university/SortitionModuleUniversity.sol @@ -139,7 +139,8 @@ contract SortitionModuleUniversity is ISortitionModuleUniversity, UUPSProxiable, function validateStake( address _account, uint96 _courtID, - uint256 _newStake + uint256 _newStake, + bool /*_noDelay*/ ) external view @@ -193,6 +194,30 @@ contract SortitionModuleUniversity is ISortitionModuleUniversity, UUPSProxiable, _setStake(_account, _courtID, _pnkDeposit, _pnkWithdrawal, _newStake); } + function setStakePenalty( + address _account, + uint96 _courtID, + uint256 _penalty + ) external override onlyByCore returns (uint256 pnkBalance, uint256 newCourtStake, uint256 availablePenalty) { + Juror storage juror = jurors[_account]; + availablePenalty = _penalty; + newCourtStake = _stakeOf(_account, _courtID); + if (juror.stakedPnk < _penalty) { + availablePenalty = juror.stakedPnk; + } + + if (availablePenalty == 0) return (juror.stakedPnk, newCourtStake, 0); // No penalty to apply. + + uint256 currentStake = _stakeOf(_account, _courtID); + uint256 newStake = 0; + if (currentStake >= availablePenalty) { + newStake = currentStake - availablePenalty; + } + _setStake(_account, _courtID, 0, availablePenalty, newStake); + pnkBalance = juror.stakedPnk; // updated by _setStake() + newCourtStake = _stakeOf(_account, _courtID); // updated by _setStake() + } + /// @dev Update the state of the stakes with a PNK reward deposit, called by KC during rewards execution. /// `O(n + p * log_k(j))` where /// `n` is the number of courts the juror has staked in, @@ -271,25 +296,6 @@ contract SortitionModuleUniversity is ISortitionModuleUniversity, UUPSProxiable, emit StakeLocked(_account, _relativeAmount, true); } - function penalizeStake( - address _account, - uint256 _relativeAmount - ) external override onlyByCore returns (uint256 pnkBalance, uint256 availablePenalty) { - Juror storage juror = jurors[_account]; - uint256 stakedPnk = juror.stakedPnk; - - if (stakedPnk >= _relativeAmount) { - availablePenalty = _relativeAmount; - juror.stakedPnk -= _relativeAmount; - } else { - availablePenalty = stakedPnk; - juror.stakedPnk = 0; - } - - pnkBalance = juror.stakedPnk; - return (pnkBalance, availablePenalty); - } - /// @dev Unstakes the inactive juror from all courts. /// `O(n * (p * log_k(j)) )` where /// `n` is the number of courts the juror has staked in, @@ -297,13 +303,25 @@ contract SortitionModuleUniversity is ISortitionModuleUniversity, UUPSProxiable, /// `k` is the minimum number of children per node of one of these courts' sortition sum tree, /// and `j` is the maximum number of jurors that ever staked in one of these courts simultaneously. /// @param _account The juror to unstake. - function setJurorInactive(address _account) external override onlyByCore { + function forcedUnstakeAllCourts(address _account) external override onlyByCore { uint96[] memory courtIDs = getJurorCourtIDs(_account); for (uint256 j = courtIDs.length; j > 0; j--) { core.setStakeBySortitionModule(_account, courtIDs[j - 1], 0); } } + /// @dev Unstakes the inactive juror from a specific court. + /// `O(n * (p * log_k(j)) )` where + /// `n` is the number of courts the juror has staked in, + /// `p` is the depth of the court tree, + /// `k` is the minimum number of children per node of one of these courts' sortition sum tree, + /// and `j` is the maximum number of jurors that ever staked in one of these courts simultaneously. + /// @param _account The juror to unstake. + /// @param _courtID The ID of the court. + function forcedUnstake(address _account, uint96 _courtID) external override onlyByCore { + core.setStakeBySortitionModule(_account, _courtID, 0); + } + /// @dev Gives back the locked PNKs in case the juror fully unstaked earlier. /// Note that since locked and staked PNK are async it is possible for the juror to have positive staked PNK balance /// while having 0 stake in courts and 0 locked tokens (eg. when the juror fully unstaked during dispute and later got his tokens unlocked). @@ -327,7 +345,11 @@ contract SortitionModuleUniversity is ISortitionModuleUniversity, UUPSProxiable, /// @dev Draw an ID from a tree using a number. /// Note that this function reverts if the sum of all values in the tree is 0. /// @return drawnAddress The drawn address. - function draw(bytes32, uint256, uint256) public view override returns (address drawnAddress) { + function draw( + bytes32, + uint256, + uint256 + ) public view override returns (address drawnAddress, uint96 fromSubcourtID) { drawnAddress = transientJuror; } diff --git a/contracts/test/arbitration/dispute-kit-gated.ts b/contracts/test/arbitration/dispute-kit-gated.ts index 4c3d26052..b7336087c 100644 --- a/contracts/test/arbitration/dispute-kit-gated.ts +++ b/contracts/test/arbitration/dispute-kit-gated.ts @@ -109,7 +109,7 @@ describe("DisputeKitGated", async () => { await core .connect(juror) - .setStake(Courts.GENERAL, thousandPNK(10), { gasLimit: 300000 }) + .setStake(Courts.GENERAL, thousandPNK(10), { gasLimit: 500000 }) .then((tx) => tx.wait()); expect(await sortitionModule.getJurorBalance(juror.address, 1)).to.deep.equal([ diff --git a/contracts/test/foundry/KlerosCore_Execution.t.sol b/contracts/test/foundry/KlerosCore_Execution.t.sol index 21e9b7ab0..c5763964e 100644 --- a/contracts/test/foundry/KlerosCore_Execution.t.sol +++ b/contracts/test/foundry/KlerosCore_Execution.t.sol @@ -16,7 +16,7 @@ contract KlerosCore_ExecutionTest is KlerosCore_TestBase { uint256 disputeID = 0; vm.prank(staker1); - core.setStake(GENERAL_COURT, 1500); + core.setStake(GENERAL_COURT, 2000); vm.prank(disputer); arbitrable.createDispute{value: feeForJuror * DEFAULT_NB_OF_JURORS}("Action"); vm.warp(block.timestamp + minStakingTime); @@ -111,7 +111,7 @@ contract KlerosCore_ExecutionTest is KlerosCore_TestBase { core.execute(disputeID, 0, 3); // Do 3 iterations to check penalties first (uint256 totalStaked, uint256 totalLocked, , ) = sortitionModule.getJurorBalance(staker1, GENERAL_COURT); - assertEq(totalStaked, 500, "totalStaked should be penalized"); // 1500 - 1000 + assertEq(totalStaked, 1000, "totalStaked should be penalized"); // 2000 - 1000 assertEq(totalLocked, 0, "Tokens should be released for staker1"); (, totalLocked, , ) = sortitionModule.getJurorBalance(staker2, GENERAL_COURT); assertEq(totalLocked, 2000, "Tokens should still be locked for staker2"); @@ -148,8 +148,8 @@ contract KlerosCore_ExecutionTest is KlerosCore_TestBase { assertEq(staker1.balance, 0, "Wrong balance of the staker1"); assertEq(staker2.balance, 0.09 ether, "Wrong balance of the staker2"); - assertEq(pinakion.balanceOf(address(core)), 21500, "Wrong token balance of the core"); // Was 21500. 1000 was transferred to staker2 - assertEq(pinakion.balanceOf(staker1), 999999999999998500, "Wrong token balance of staker1"); + assertEq(pinakion.balanceOf(address(core)), 22000, "Wrong token balance of the core"); // Was 21500. 1000 was transferred to staker2 + assertEq(pinakion.balanceOf(staker1), 999999999999998000, "Wrong token balance of staker1"); assertEq(pinakion.balanceOf(staker2), 999999999999980000, "Wrong token balance of staker2"); // 20k stake and 1k added as a reward, thus -19k from the default } @@ -213,8 +213,8 @@ contract KlerosCore_ExecutionTest is KlerosCore_TestBase { assertEq(staker1.balance, 0, "Wrong balance of the staker1"); assertEq(owner.balance, ownerBalance + 0.09 ether, "Wrong balance of the owner"); - assertEq(pinakion.balanceOf(address(core)), 17000, "Wrong token balance of the core"); - assertEq(pinakion.balanceOf(staker1), 999999999999980000, "Wrong token balance of staker1"); + assertEq(pinakion.balanceOf(address(core)), 0, "Wrong token balance of the core"); // The inactive juror got unstaked regardless of the phase (`noDelay` is true) + assertEq(pinakion.balanceOf(staker1), 999999999999997000, "Wrong token balance of staker1"); assertEq(pinakion.balanceOf(owner), ownerTokenBalance + 3000, "Wrong token balance of owner"); } @@ -272,7 +272,9 @@ contract KlerosCore_ExecutionTest is KlerosCore_TestBase { // Note that these events are emitted only after the first iteration of execute() therefore the juror has been penalized only for 1000 PNK her. vm.expectEmit(true, true, true, true); - emit SortitionModuleBase.StakeSet(staker1, newCourtID, 0, 19000); // Starting with 40000 we first nullify the stake and remove 20000 and then remove penalty once since there was only first iteration (40000 - 20000 - 1000) + emit SortitionModuleBase.StakeSet(staker1, newCourtID, 19000, 39000); // 1000 PNK penalty for voteID 0 + vm.expectEmit(true, true, true, true); + emit SortitionModuleBase.StakeSet(staker1, newCourtID, 0, 20000); // Starting with 40000 we first nullify the stake and remove 19000 and then remove penalty once since there was only first iteration (40000 - 20000 - 1000) vm.expectEmit(true, true, true, true); emit SortitionModuleBase.StakeSet(staker1, GENERAL_COURT, 0, 2000); // 2000 PNK should remain in balance to cover penalties since the first 1000 of locked pnk was already unlocked core.execute(disputeID, 0, 3); @@ -343,6 +345,76 @@ contract KlerosCore_ExecutionTest is KlerosCore_TestBase { assertEq(nbCourts, 0, "Should unstake from all courts"); } + function test_execute_UnstakeBelowMinStake() public { + uint256 disputeID = 0; + + vm.prank(staker1); + core.setStake(GENERAL_COURT, 1200); + + vm.prank(staker2); + core.setStake(GENERAL_COURT, 10000); + + assertEq(pinakion.balanceOf(address(core)), 11200, "Wrong token balance of the core"); + assertEq(pinakion.balanceOf(staker1), 999999999999998800, "Wrong token balance of staker1"); + assertEq(pinakion.balanceOf(staker2), 999999999999990000, "Wrong token balance of staker2"); + + vm.prank(disputer); + arbitrable.createDispute{value: feeForJuror * DEFAULT_NB_OF_JURORS}("Action"); + vm.warp(block.timestamp + minStakingTime); + sortitionModule.passPhase(); // Generating + vm.warp(block.timestamp + rngLookahead); + sortitionModule.passPhase(); // Drawing phase + + core.draw(disputeID, DEFAULT_NB_OF_JURORS); + + (uint256 totalStaked, uint256 totalLocked, , uint256 nbCourts) = sortitionModule.getJurorBalance( + staker1, + GENERAL_COURT + ); + assertEq(totalStaked, 1200, "Wrong totalStaked"); + assertEq(totalLocked, 1000, "Wrong totalLocked"); // Juror only staked 1000 but will fall below minStake with a bad vote + assertEq(nbCourts, 1, "Wrong number of courts"); + + sortitionModule.passPhase(); // Staking phase. Change to staking so we don't have to deal with delayed stakes. + + vm.warp(block.timestamp + timesPerPeriod[0]); + core.passPeriod(disputeID); // Vote + + uint256[] memory voteIDs = new uint256[](1); + voteIDs[0] = 0; + vm.prank(staker1); + disputeKit.castVote(disputeID, voteIDs, 1, 0, "XYZ"); // 1 incoherent vote should make the juror's stake below minStake + + voteIDs = new uint256[](2); + voteIDs[0] = 1; + voteIDs[1] = 2; + vm.prank(staker2); + disputeKit.castVote(disputeID, voteIDs, 2, 0, "XYZ"); + + core.passPeriod(disputeID); // Appeal + + vm.warp(block.timestamp + timesPerPeriod[3]); + core.passPeriod(disputeID); // Execution + + vm.expectEmit(true, true, true, true); + emit SortitionModuleBase.StakeSet(staker1, GENERAL_COURT, 0, 0); // Juror balance should be below minStake and should be unstaked from the court automatically. + core.execute(disputeID, 0, 6); + + assertEq(pinakion.balanceOf(address(core)), 11000, "Wrong token balance of the core"); + assertEq(pinakion.balanceOf(staker1), 1 ether - 1000, "Wrong token balance of staker1"); // The juror should have his penalty back as a reward + assertEq(pinakion.balanceOf(staker2), 999999999999990000, "Wrong token balance of staker2"); // No change + + (totalStaked, totalLocked, , nbCourts) = sortitionModule.getJurorBalance(staker1, GENERAL_COURT); + assertEq(totalStaked, 0, "Wrong staker1 totalStaked"); + assertEq(totalLocked, 0, "Wrong staker1 totalLocked"); + assertEq(nbCourts, 0, "Wrong staker1 nbCourts"); + + (totalStaked, totalLocked, , nbCourts) = sortitionModule.getJurorBalance(staker2, GENERAL_COURT); + assertEq(totalStaked, 11000, "Wrong staker2 totalStaked"); + assertEq(totalLocked, 0, "Wrong staker2 totalLocked"); + assertEq(nbCourts, 1, "Wrong staker2 nbCourts"); + } + function test_execute_withdrawLeftoverPNK() public { // Return the previously locked tokens uint256 disputeID = 0;