diff --git a/.github/workflows/contracts-testing.yml b/.github/workflows/contracts-testing.yml index 47646906c..26e1fb313 100644 --- a/.github/workflows/contracts-testing.yml +++ b/.github/workflows/contracts-testing.yml @@ -15,73 +15,129 @@ on: pull_request: branches: - "*" - -permissions: # added using https://github.com/step-security/secure-workflows + +permissions: # added using https://github.com/step-security/secure-workflows contents: read jobs: - contracts-testing: + # *********************************************************************************** # + # ******************************* Hardhat Tests ************************************* # + # *********************************************************************************** # + hardhat-tests: runs-on: ubuntu-latest steps: - - name: Harden Runner - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 - with: - disable-sudo: false - egress-policy: block - allowed-endpoints: > - binaries.soliditylang.org:443 - classic.yarnpkg.com:443 - github.com:443 - nightly.yarnpkg.com:443 - nodejs.org:443 - objects.githubusercontent.com:443 - registry.yarnpkg.com:443 - registry.npmjs.org:443 - 54.185.253.63:443 - - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - submodules: recursive - - - name: Set up corepack (for yarn) - run: | - corepack enable - corepack prepare yarn@4.9.2 --activate - yarn set version 4.9.2 - - - name: Setup Node.js environment - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 - with: - node-version: 20.x - cache: yarn - - - name: Cache node modules - uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 - env: - cache-name: cache-node-modules - with: - path: | - ~/.npm - **/node_modules - key: ${{ runner.os }}-build-${{ secrets.CACHE_VERSION }}-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-build-${{ secrets.CACHE_VERSION }}-${{ env.cache-name }}- - - - name: Install contracts dependencies - run: yarn workspace @kleros/kleros-v2-contracts install - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@de808b1eea699e761c404bda44ba8f21aba30b2c # v1.3.1 - - - name: Install lcov - run: sudo apt-get install -y lcov - - - name: Run Hardhat and Foundry tests with coverage - run: yarn coverage - working-directory: contracts - - - name: Upload a build artifact - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: code-coverage-report - path: contracts/coverage + - name: Harden Runner + uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + with: + disable-sudo: false + egress-policy: block + allowed-endpoints: > + binaries.soliditylang.org:443 + classic.yarnpkg.com:443 + github.com:443 + nightly.yarnpkg.com:443 + nodejs.org:443 + objects.githubusercontent.com:443 + registry.yarnpkg.com:443 + registry.npmjs.org:443 + 54.185.253.63:443 + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + submodules: recursive + + - name: Set up corepack (for yarn) + run: | + corepack enable + corepack prepare yarn@4.9.2 --activate + yarn set version 4.9.2 + + - name: Setup Node.js environment + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 + with: + node-version: 20.x + cache: yarn + + - name: Cache node modules + uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 + env: + cache-name: cache-node-modules + with: + path: | + ~/.npm + **/node_modules + key: ${{ runner.os }}-build-${{ secrets.CACHE_VERSION }}-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-build-${{ secrets.CACHE_VERSION }}-${{ env.cache-name }}- + + - name: Install contracts dependencies + run: yarn workspace @kleros/kleros-v2-contracts install + + - name: Run Hardhat tests + run: yarn test + working-directory: contracts + + # *********************************************************************************** # + # ******************************* Foundry Tests ************************************* # + # *********************************************************************************** # + # COMPILATION FAILS 🤬 + # foundry-tests: + # runs-on: ubuntu-latest + # steps: + # - name: Harden Runner + # uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + # with: + # disable-sudo: false + # egress-policy: block + # allowed-endpoints: > + # binaries.soliditylang.org:443 + # classic.yarnpkg.com:443 + # github.com:443 + # nightly.yarnpkg.com:443 + # nodejs.org:443 + # objects.githubusercontent.com:443 + # registry.yarnpkg.com:443 + # registry.npmjs.org:443 + # 54.185.253.63:443 + + # - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + # with: + # submodules: recursive + + # - name: Set up corepack (for yarn) + # run: | + # corepack enable + # corepack prepare yarn@4.9.2 --activate + # yarn set version 4.9.2 + + # - name: Setup Node.js environment + # uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 + # with: + # node-version: 20.x + # cache: yarn + + # - name: Cache node modules + # uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 + # env: + # cache-name: cache-node-modules + # with: + # path: | + # ~/.npm + # **/node_modules + # key: ${{ runner.os }}-build-${{ secrets.CACHE_VERSION }}-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }} + # restore-keys: | + # ${{ runner.os }}-build-${{ secrets.CACHE_VERSION }}-${{ env.cache-name }}- + + # # - name: Install contracts dependencies + # # run: yarn workspace @kleros/kleros-v2-contracts install + + # - name: Install Foundry + # uses: foundry-rs/foundry-toolchain@82dee4ba654bd2146511f85f0d013af94670c4de # v1.4.0 + + # - name: Run Foundry tests + # run: forge test --config-path ./foundry.toml + # working-directory: contracts + + # - name: Run snapshot + # run: NO_COLOR=1 forge snapshot >> $GITHUB_STEP_SUMMARY + # working-directory: contracts diff --git a/contracts/CHANGELOG.md b/contracts/CHANGELOG.md index e6db23764..2e61786fb 100644 --- a/contracts/CHANGELOG.md +++ b/contracts/CHANGELOG.md @@ -15,28 +15,31 @@ The format is based on [Common Changelog](https://common-changelog.org/). - **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)) -- **Breaking:** Add a new state variable `jumpDisputeKitID` to the `DisputeKitClassicBase` contract ([#2114](https://github.com/kleros/kleros-v2/issues/2114)) - 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)) - Replace the `bytes32 _key` parameter in `SortitionTrees.createTree()` and `SortitionTrees.draw()` by `uint96 courtID` ([#2113](https://github.com/kleros/kleros-v2/issues/2113)) - Extract the sortition sum trees logic into a library `SortitionTrees` ([#2113](https://github.com/kleros/kleros-v2/issues/2113)) +- Make `IDisputeKit.getDegreeOfCoherenceReward()` multi-dimensional so different calculations may be applied to PNK rewards, fee rewards and PNK penalties (future-proofing) ([#2090](https://github.com/kleros/kleros-v2/issues/2090)) +- Consolidate the constant `ALPHA_DIVISOR` with `ONE_BASIS_POINTS` ([#2090](https://github.com/kleros/kleros-v2/issues/2090)) - Set the Hardhat Solidity version to v0.8.30 and enable the IR pipeline ([#2069](https://github.com/kleros/kleros-v2/issues/2069)) - Set the Foundry Solidity version to v0.8.30 and enable the IR pipeline ([#2073](https://github.com/kleros/kleros-v2/issues/2073)) - Widen the allowed solc version to any v0.8.x for the interfaces only ([#2083](https://github.com/kleros/kleros-v2/issues/2083)) -- Make `IDisputeKit.getDegreeOfCoherenceReward()` multi-dimensional so different calculations may be applied to PNK rewards, fee rewards and PNK penalties (future-proofing) ([#2090](https://github.com/kleros/kleros-v2/issues/2090)) -- Consolidate the constant `ALPHA_DIVISOR` with `ONE_BASIS_POINTS` ([#2090](https://github.com/kleros/kleros-v2/issues/2090)) - Bump `hardhat` to v2.26.2 ([#2069](https://github.com/kleros/kleros-v2/issues/2069)) - Bump `@kleros/vea-contracts` to v0.7.0 ([#2073](https://github.com/kleros/kleros-v2/issues/2073)) ### Added +- **Breaking:** Add a new field `drawnJurorFromCourtIDs` to the `Round` struct in `KlerosCoreBase` and `KlerosCoreUniversity` ([#2107](https://github.com/kleros/kleros-v2/issues/2107)) +- **Breaking:** Add a new state variable `jumpDisputeKitID` to the `DisputeKitClassicBase` contract ([#2114](https://github.com/kleros/kleros-v2/issues/2114)) +- **Breaking:** Add a parameter `_recoveryCommit` to the event `DisputeKitShutter.CommitCastShutter` ([#2100](https://github.com/kleros/kleros-v2/issues/2100)) +- **Breaking:** Add a storage variable `recoveryCommitments` to `DisputeKitShutter` ([#2100](https://github.com/kleros/kleros-v2/issues/2100)) +- Allow the Shutter commitment to be recovered by the juror using only the salt and the choice, without having to provide the justification ([#2100](https://github.com/kleros/kleros-v2/issues/2100)) - Allow the dispute kits to force an early court jump and to override the number of votes after an appeal (future-proofing) ([#2110](https://github.com/kleros/kleros-v2/issues/2110)) - Allow the dispute kits to specify which new dispute kit to use when a court jump occurs ([#2114](https://github.com/kleros/kleros-v2/issues/2114)) +- 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)) ### Fixed diff --git a/contracts/foundry.toml b/contracts/foundry.toml index 00b0c68b8..2dcdd2711 100644 --- a/contracts/foundry.toml +++ b/contracts/foundry.toml @@ -1,16 +1,11 @@ [profile.default] solc = "0.8.30" +evm_version = "cancun" via_ir = true optimizer = true -optimizer_runs = 500 -optimizer_details = { yulDetails = { stackAllocation = true } } -additional_compiler_profiles = [ - { name = "tests", via_ir = false } -] -compilation_restrictions = [ - { paths = "test/foundry/KlerosCore.t.sol", via_ir = false }, -] +optimizer_runs = 10000 + src = 'src' out = 'out' libs = ['../node_modules', 'lib'] diff --git a/contracts/hardhat.config.ts b/contracts/hardhat.config.ts index d451ef05f..9d8e9c49d 100644 --- a/contracts/hardhat.config.ts +++ b/contracts/hardhat.config.ts @@ -28,6 +28,7 @@ const config: HardhatUserConfig = { { version: "0.8.30", settings: { + evmVersion: "cancun", viaIR: process.env.VIA_IR !== "false", // Defaults to true optimizer: { enabled: true, diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol b/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol index 577364cde..369789ab1 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol @@ -318,19 +318,21 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi if (_voteIDs.length == 0) revert EmptyVoteIDs(); if (!coreDisputeIDToActive[_coreDisputeID]) revert NotActiveForCoreDisputeID(); - Dispute storage dispute = disputes[coreDisputeIDToLocal[_coreDisputeID]]; + uint256 localDisputeID = coreDisputeIDToLocal[_coreDisputeID]; + Dispute storage dispute = disputes[localDisputeID]; if (_choice > dispute.numberOfChoices) revert ChoiceOutOfBounds(); - Round storage round = dispute.rounds[dispute.rounds.length - 1]; + uint256 localRoundID = dispute.rounds.length - 1; + Round storage round = dispute.rounds[localRoundID]; { (uint96 courtID, , , , ) = core.disputes(_coreDisputeID); (, bool hiddenVotes, , , , , ) = core.courts(courtID); - bytes32 voteHash = hashVote(_choice, _salt, _justification); + bytes32 actualVoteHash = hashVote(_choice, _salt, _justification); // Save the votes. for (uint256 i = 0; i < _voteIDs.length; i++) { if (round.votes[_voteIDs[i]].account != _juror) revert JurorHasToOwnTheVote(); - if (hiddenVotes && round.votes[_voteIDs[i]].commit != voteHash) + if (hiddenVotes && _getExpectedVoteHash(localDisputeID, localRoundID, _voteIDs[i]) != actualVoteHash) revert HashDoesNotMatchHiddenVoteCommitment(); if (round.votes[_voteIDs[i]].voted) revert VoteAlreadyCast(); round.votes[_voteIDs[i]].choice = _choice; @@ -484,15 +486,14 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi * @dev Computes the hash of a vote using ABI encoding * @dev The unused parameters may be used by overriding contracts. * @param _choice The choice being voted for - * @param _justification The justification for the vote * @param _salt A random salt for commitment * @return bytes32 The hash of the encoded vote parameters */ function hashVote( uint256 _choice, uint256 _salt, - string memory _justification - ) public pure virtual returns (bytes32) { + string memory /*_justification*/ + ) public view virtual returns (bytes32) { return keccak256(abi.encodePacked(_choice, _salt)); } @@ -738,17 +739,29 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi // * Internal * // // ************************************* // + /// @dev Returns the expected vote hash for a given vote. + /// @param _localDisputeID The ID of the dispute in the Dispute Kit. + /// @param _localRoundID The ID of the round in the Dispute Kit. + /// @param _voteID The ID of the vote. + /// @return The expected vote hash. + function _getExpectedVoteHash( + uint256 _localDisputeID, + uint256 _localRoundID, + uint256 _voteID + ) internal view virtual returns (bytes32) { + return disputes[_localDisputeID].rounds[_localRoundID].votes[_voteID].commit; + } + /// @dev Checks that the chosen address satisfies certain conditions for being drawn. /// Note that we don't check the minStake requirement here because of the implicit staking in parent courts. /// minStake is checked directly during staking process however it's possible for the juror to get drawn /// while having < minStake if it is later increased by governance. /// This issue is expected and harmless. - /// @param _round The round in which the juror is being drawn. /// @param _coreDisputeID ID of the dispute in the core contract. /// @param _juror Chosen address. /// @return result Whether the address passes the check or not. function _postDrawCheck( - Round storage _round, + Round storage /*_round*/, uint256 _coreDisputeID, address _juror ) internal view virtual returns (bool result) { diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol b/contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol index 158bfc5a6..67d9ddbea 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol @@ -30,6 +30,19 @@ interface IBalanceHolderERC1155 { contract DisputeKitGatedShutter is DisputeKitClassicBase { string public constant override version = "0.13.0"; + // ************************************* // + // * Storage * // + // ************************************* // + + mapping(uint256 localDisputeID => mapping(uint256 localRoundID => mapping(uint256 voteID => bytes32 recoveryCommitment))) + public recoveryCommitments; + + // ************************************* // + // * Transient Storage * // + // ************************************* // + + bool transient callerIsJuror; + // ************************************* // // * Events * // // ************************************* // @@ -38,12 +51,14 @@ contract DisputeKitGatedShutter is DisputeKitClassicBase { /// @param _coreDisputeID The identifier of the dispute in the Arbitrator contract. /// @param _juror The address of the juror casting the vote commitment. /// @param _commit The commitment hash. + /// @param _recoveryCommit The commitment hash without the justification. /// @param _identity The Shutter identity used for encryption. /// @param _encryptedVote The Shutter encrypted vote. event CommitCastShutter( uint256 indexed _coreDisputeID, address indexed _juror, bytes32 indexed _commit, + bytes32 _recoveryCommit, bytes32 _identity, bytes _encryptedVote ); @@ -96,17 +111,29 @@ contract DisputeKitGatedShutter is DisputeKitClassicBase { /// @param _coreDisputeID The ID of the dispute in Kleros Core. /// @param _voteIDs The IDs of the votes. /// @param _commit The commitment hash including the justification. + /// @param _recoveryCommit The commitment hash without the justification. /// @param _identity The Shutter identity used for encryption. /// @param _encryptedVote The Shutter encrypted vote. function castCommitShutter( uint256 _coreDisputeID, uint256[] calldata _voteIDs, bytes32 _commit, + bytes32 _recoveryCommit, bytes32 _identity, bytes calldata _encryptedVote ) external notJumped(_coreDisputeID) { + if (_recoveryCommit == bytes32(0)) revert EmptyRecoveryCommit(); + + uint256 localDisputeID = coreDisputeIDToLocal[_coreDisputeID]; + Dispute storage dispute = disputes[localDisputeID]; + uint256 localRoundID = dispute.rounds.length - 1; + for (uint256 i = 0; i < _voteIDs.length; i++) { + recoveryCommitments[localDisputeID][localRoundID][_voteIDs[i]] = _recoveryCommit; + } + + // `_castCommit()` ensures that the caller owns the vote _castCommit(_coreDisputeID, _voteIDs, _commit); - emit CommitCastShutter(_coreDisputeID, msg.sender, _commit, _identity, _encryptedVote); + emit CommitCastShutter(_coreDisputeID, msg.sender, _commit, _recoveryCommit, _identity, _encryptedVote); } function castVoteShutter( @@ -119,8 +146,12 @@ contract DisputeKitGatedShutter is DisputeKitClassicBase { Dispute storage dispute = disputes[coreDisputeIDToLocal[_coreDisputeID]]; address juror = dispute.rounds[dispute.rounds.length - 1].votes[_voteIDs[0]].account; - // _castVote() ensures that all the _voteIDs do belong to `juror` + callerIsJuror = juror == msg.sender; + + // `_castVote()` ensures that all the `_voteIDs` do belong to `juror` _castVote(_coreDisputeID, _voteIDs, _choice, _salt, _justification, juror); + + callerIsJuror = false; } // ************************************* // @@ -138,15 +169,38 @@ contract DisputeKitGatedShutter is DisputeKitClassicBase { uint256 _choice, uint256 _salt, string memory _justification - ) public pure override returns (bytes32) { - bytes32 justificationHash = keccak256(bytes(_justification)); - return keccak256(abi.encode(_choice, _salt, justificationHash)); + ) public view override returns (bytes32) { + if (callerIsJuror) { + // Caller is the juror, hash without `_justification` to facilitate recovery. + return keccak256(abi.encodePacked(_choice, _salt)); + } else { + // Caller is not the juror, hash with `_justification`. + bytes32 justificationHash = keccak256(bytes(_justification)); + return keccak256(abi.encode(_choice, _salt, justificationHash)); + } } // ************************************* // // * Internal * // // ************************************* // + /// @dev Returns the expected vote hash for a given vote. + /// @param _localDisputeID The ID of the dispute in the Dispute Kit. + /// @param _localRoundID The ID of the round in the Dispute Kit. + /// @param _voteID The ID of the vote. + /// @return The expected vote hash. + function _getExpectedVoteHash( + uint256 _localDisputeID, + uint256 _localRoundID, + uint256 _voteID + ) internal view override returns (bytes32) { + if (callerIsJuror) { + return recoveryCommitments[_localDisputeID][_localRoundID][_voteID]; + } else { + return disputes[_localDisputeID].rounds[_localRoundID].votes[_voteID].commit; + } + } + /// @dev Extracts token gating information from the extra data. /// @param _extraData The extra data bytes array with the following encoding: /// - bytes 0-31: uint96 courtID, not used here @@ -197,4 +251,10 @@ contract DisputeKitGatedShutter is DisputeKitClassicBase { return IBalanceHolder(tokenGate).balanceOf(_juror) > 0; } } + + // ************************************* // + // * Errors * // + // ************************************* // + + error EmptyRecoveryCommit(); } diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitShutter.sol b/contracts/src/arbitration/dispute-kits/DisputeKitShutter.sol index 0dfbf6985..b8bef461f 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitShutter.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitShutter.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity ^0.8.28; import {DisputeKitClassicBase, KlerosCore} from "./DisputeKitClassicBase.sol"; @@ -14,6 +14,19 @@ import {DisputeKitClassicBase, KlerosCore} from "./DisputeKitClassicBase.sol"; contract DisputeKitShutter is DisputeKitClassicBase { string public constant override version = "0.13.0"; + // ************************************* // + // * Storage * // + // ************************************* // + + mapping(uint256 localDisputeID => mapping(uint256 localRoundID => mapping(uint256 voteID => bytes32 recoveryCommitment))) + public recoveryCommitments; + + // ************************************* // + // * Transient Storage * // + // ************************************* // + + bool transient callerIsJuror; + // ************************************* // // * Events * // // ************************************* // @@ -22,12 +35,14 @@ contract DisputeKitShutter is DisputeKitClassicBase { /// @param _coreDisputeID The identifier of the dispute in the Arbitrator contract. /// @param _juror The address of the juror casting the vote commitment. /// @param _commit The commitment hash. + /// @param _recoveryCommit The commitment hash without the justification. /// @param _identity The Shutter identity used for encryption. /// @param _encryptedVote The Shutter encrypted vote. event CommitCastShutter( uint256 indexed _coreDisputeID, address indexed _juror, bytes32 indexed _commit, + bytes32 _recoveryCommit, bytes32 _identity, bytes _encryptedVote ); @@ -80,17 +95,29 @@ contract DisputeKitShutter is DisputeKitClassicBase { /// @param _coreDisputeID The ID of the dispute in Kleros Core. /// @param _voteIDs The IDs of the votes. /// @param _commit The commitment hash including the justification. + /// @param _recoveryCommit The commitment hash without the justification. /// @param _identity The Shutter identity used for encryption. /// @param _encryptedVote The Shutter encrypted vote. function castCommitShutter( uint256 _coreDisputeID, uint256[] calldata _voteIDs, bytes32 _commit, + bytes32 _recoveryCommit, bytes32 _identity, bytes calldata _encryptedVote ) external notJumped(_coreDisputeID) { + if (_recoveryCommit == bytes32(0)) revert EmptyRecoveryCommit(); + + uint256 localDisputeID = coreDisputeIDToLocal[_coreDisputeID]; + Dispute storage dispute = disputes[localDisputeID]; + uint256 localRoundID = dispute.rounds.length - 1; + for (uint256 i = 0; i < _voteIDs.length; i++) { + recoveryCommitments[localDisputeID][localRoundID][_voteIDs[i]] = _recoveryCommit; + } + + // `_castCommit()` ensures that the caller owns the vote _castCommit(_coreDisputeID, _voteIDs, _commit); - emit CommitCastShutter(_coreDisputeID, msg.sender, _commit, _identity, _encryptedVote); + emit CommitCastShutter(_coreDisputeID, msg.sender, _commit, _recoveryCommit, _identity, _encryptedVote); } function castVoteShutter( @@ -103,8 +130,12 @@ contract DisputeKitShutter is DisputeKitClassicBase { Dispute storage dispute = disputes[coreDisputeIDToLocal[_coreDisputeID]]; address juror = dispute.rounds[dispute.rounds.length - 1].votes[_voteIDs[0]].account; - // _castVote() ensures that all the _voteIDs do belong to `juror` + callerIsJuror = juror == msg.sender; + + // `_castVote()` ensures that all the `_voteIDs` do belong to `juror` _castVote(_coreDisputeID, _voteIDs, _choice, _salt, _justification, juror); + + callerIsJuror = false; } // ************************************* // @@ -122,8 +153,41 @@ contract DisputeKitShutter is DisputeKitClassicBase { uint256 _choice, uint256 _salt, string memory _justification - ) public pure override returns (bytes32) { - bytes32 justificationHash = keccak256(bytes(_justification)); - return keccak256(abi.encode(_choice, _salt, justificationHash)); + ) public view override returns (bytes32) { + if (callerIsJuror) { + // Caller is the juror, hash without `_justification` to facilitate recovery. + return keccak256(abi.encodePacked(_choice, _salt)); + } else { + // Caller is not the juror, hash with `_justification`. + bytes32 justificationHash = keccak256(bytes(_justification)); + return keccak256(abi.encode(_choice, _salt, justificationHash)); + } + } + + // ************************************* // + // * Internal * // + // ************************************* // + + /// @dev Returns the expected vote hash for a given vote. + /// @param _localDisputeID The ID of the dispute in the Dispute Kit. + /// @param _localRoundID The ID of the round in the Dispute Kit. + /// @param _voteID The ID of the vote. + /// @return The expected vote hash. + function _getExpectedVoteHash( + uint256 _localDisputeID, + uint256 _localRoundID, + uint256 _voteID + ) internal view override returns (bytes32) { + if (callerIsJuror) { + return recoveryCommitments[_localDisputeID][_localRoundID][_voteID]; + } else { + return disputes[_localDisputeID].rounds[_localRoundID].votes[_voteID].commit; + } } + + // ************************************* // + // * Errors * // + // ************************************* // + + error EmptyRecoveryCommit(); } diff --git a/contracts/test/arbitration/dispute-kit-shutter.ts b/contracts/test/arbitration/dispute-kit-shutter.ts new file mode 100644 index 000000000..aab7efa58 --- /dev/null +++ b/contracts/test/arbitration/dispute-kit-shutter.ts @@ -0,0 +1,733 @@ +import { deployments, ethers, getNamedAccounts, network } from "hardhat"; +import { toBigInt, BigNumberish } from "ethers"; +import { PNK, KlerosCore, SortitionModule, IncrementalNG, DisputeKitShutter } from "../../typechain-types"; +import { expect } from "chai"; +import { Courts } from "../../deploy/utils"; +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +/* eslint-disable no-unused-vars */ +/* eslint-disable no-unused-expressions */ // https://github.com/standard/standard/issues/690#issuecomment-278533482 + +describe("DisputeKitShutter", async () => { + const ONE_THOUSAND_PNK = 10n ** 21n; + const thousandPNK = (amount: BigNumberish) => toBigInt(amount) * ONE_THOUSAND_PNK; + + let deployer: string; + let juror1: HardhatEthersSigner; + let juror2: HardhatEthersSigner; + let bot: HardhatEthersSigner; + let attacker: HardhatEthersSigner; + let disputeKitShutter: DisputeKitShutter; + let pnk: PNK; + let core: KlerosCore; + let sortitionModule: SortitionModule; + let rng: IncrementalNG; + const RANDOM = 424242n; + const SHUTTER_DK_ID = 2; + const SHUTTER_COURT_ID = 2; // Court with hidden votes for testing + + // Test data + const choice = 1n; + const salt = 12345n; + const justification = "This is my justification for the vote"; + const identity = ethers.keccak256(ethers.toUtf8Bytes("shutter-identity")); + const encryptedVote = ethers.toUtf8Bytes("encrypted-vote-data"); + + beforeEach("Setup", async () => { + ({ deployer } = await getNamedAccounts()); + [, juror1, juror2, bot, attacker] = await ethers.getSigners(); + + await deployments.fixture(["Arbitration", "VeaMock"], { + fallbackToGlobal: true, + keepExistingDeployments: false, + }); + disputeKitShutter = await ethers.getContract("DisputeKitShutter"); + pnk = await ethers.getContract("PNK"); + core = await ethers.getContract("KlerosCore"); + sortitionModule = await ethers.getContract("SortitionModule"); + + // Make the tests more deterministic with this dummy RNG + await deployments.deploy("IncrementalNG", { + from: deployer, + args: [RANDOM], + log: true, + }); + rng = await ethers.getContract("IncrementalNG"); + + await sortitionModule.changeRandomNumberGenerator(rng.target).then((tx) => tx.wait()); + + // Create a court with hidden votes enabled for testing DisputeKitShutter + // Parameters: parent, hiddenVotes, minStake, alpha, feeForJuror, jurorsForCourtJump, timesPerPeriod, sortitionExtraData, supportedDisputeKits + await core.createCourt( + Courts.GENERAL, // parent + true, // hiddenVotes - MUST be true for DisputeKitShutter + ethers.parseEther("200"), // minStake + 10000, // alpha + ethers.parseEther("0.1"), // feeForJuror + 16, // jurorsForCourtJump + [300, 300, 300, 300], // timesPerPeriod for evidence, commit, vote, appeal + ethers.toBeHex(5), // sortitionExtraData + [1, SHUTTER_DK_ID] // supportedDisputeKits - must include Classic (1) and Shutter (2) + ); + + // The new court ID should be 2 (after GENERAL court which is 1) + }); + + // ************************************* // + // * Constants * // + // ************************************* // + + const enum Period { + evidence = 0, + commit = 1, + vote = 2, + appeal = 3, + execution = 4, + } + + // ************************************* // + // * Helper Functions * // + // ************************************* // + + const encodeExtraData = (courtId: BigNumberish, minJurors: BigNumberish, disputeKitId: number) => + ethers.AbiCoder.defaultAbiCoder().encode(["uint256", "uint256", "uint256"], [courtId, minJurors, disputeKitId]); + + const generateCommitments = (choice: bigint, salt: bigint, justification: string) => { + // Recovery commitment: hash(choice, salt) - no justification + const recoveryCommit = ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode(["uint256", "uint256"], [choice, salt]) + ); + + // Full commitment: hash(choice, salt, justificationHash) + const justificationHash = ethers.keccak256(ethers.toUtf8Bytes(justification)); + const fullCommit = ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode(["uint256", "uint256", "bytes32"], [choice, salt, justificationHash]) + ); + + return { fullCommit, recoveryCommit }; + }; + + const createDisputeAndDraw = async (courtId: BigNumberish, minJurors: BigNumberish, disputeKitId: number) => { + // Stake jurors + for (const juror of [juror1, juror2]) { + await pnk.transfer(juror.address, thousandPNK(10)).then((tx) => tx.wait()); + expect(await pnk.balanceOf(juror.address)).to.equal(thousandPNK(10)); + + await pnk + .connect(juror) + .approve(core.target, thousandPNK(10), { gasLimit: 300000 }) + .then((tx) => tx.wait()); + + await core + .connect(juror) + .setStake(SHUTTER_COURT_ID, thousandPNK(10), { gasLimit: 500000 }) + .then((tx) => tx.wait()); + + expect(await sortitionModule.getJurorBalance(juror.address, SHUTTER_COURT_ID)).to.deep.equal([ + thousandPNK(10), // totalStaked + 0, // totalLocked + thousandPNK(10), // stakedInCourt + 1, // nbOfCourts + ]); + } + + const extraData = encodeExtraData(courtId, minJurors, disputeKitId); + const arbitrationCost = await core["arbitrationCost(bytes)"](extraData); + + // Create dispute via core contract + await core["createDispute(uint256,bytes)"](2, extraData, { value: arbitrationCost }).then((tx) => tx.wait()); + const disputeId = 0; + + await network.provider.send("evm_increaseTime", [2000]); // Wait for minStakingTime + await network.provider.send("evm_mine"); + await sortitionModule.passPhase().then((tx) => tx.wait()); // Staking -> Generating + + await sortitionModule.passPhase().then((tx) => tx.wait()); // Generating -> Drawing + await core.draw(disputeId, 70, { gasLimit: 10000000 }); + + return disputeId; + }; + + const advanceToCommitPeriod = async (disputeId: number) => { + // Advance from evidence to commit period + await core.passPeriod(disputeId).then((tx) => tx.wait()); + + // Verify we're in commit period + const dispute = await core.disputes(disputeId); + expect(dispute[2]).to.equal(Period.commit); // period is at index 2 + }; + + const advanceToVotePeriod = async (disputeId: number) => { + // Advance from commit to vote period + const dispute = await core.disputes(disputeId); + const courtId = dispute[0]; // courtID is at index 0 + const court = await core.courts(courtId); + // Court struct: parent, hiddenVotes, children[], minStake, alpha, feeForJuror, jurorsForCourtJump, disabled, timesPerPeriod[] + // timesPerPeriod is a mapping, we need to check the actual structure + const timesPerPeriod = [300, 300, 300, 300]; // Default times from deployment + const commitPeriod = timesPerPeriod[Period.commit]; + + await network.provider.send("evm_increaseTime", [Number(commitPeriod)]); + await network.provider.send("evm_mine"); + + await core.passPeriod(disputeId).then((tx) => tx.wait()); + + // Verify we're in vote period + const updatedDispute = await core.disputes(disputeId); + expect(updatedDispute[2]).to.equal(Period.vote); // period is at index 2 + }; + + const getVoteIDsForJuror = async (disputeId: number, juror: HardhatEthersSigner) => { + const localDisputeId = await disputeKitShutter.coreDisputeIDToLocal(disputeId); + const nbRounds = await disputeKitShutter.getNumberOfRounds(localDisputeId); + const roundIndex = Number(nbRounds) - 1; + + // Get all votes for this round and filter by juror + const voteIDs: bigint[] = []; + const maxVotes = 10; // Reasonable limit for testing + + for (let i = 0; i < maxVotes; i++) { + try { + const voteInfo = await disputeKitShutter.getVoteInfo(disputeId, roundIndex, i); + if (voteInfo[0] === juror.address) { + // account is at index 0 + voteIDs.push(BigInt(i)); + } + } catch { + // No more votes + break; + } + } + + return voteIDs; + }; + + // ************************************* // + // * Tests * // + // ************************************* // + + describe("Commit Phase - castCommitShutter()", () => { + describe("Successful commits", () => { + it("Should allow juror to commit vote with recovery commitment", async () => { + // Use the court with hidden votes (court ID 2) + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + expect(voteIDs.length).to.be.greaterThan(0); + + const { fullCommit, recoveryCommit } = generateCommitments(choice, salt, justification); + + await expect( + disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, identity, encryptedVote) + ) + .to.emit(disputeKitShutter, "CommitCastShutter") + .withArgs(disputeId, juror1.address, fullCommit, recoveryCommit, identity, encryptedVote); + + // Verify recovery commitment was stored + const localDisputeId = await disputeKitShutter.coreDisputeIDToLocal(disputeId); + const storedRecoveryCommit = await disputeKitShutter.recoveryCommitments(localDisputeId, 0, voteIDs[0]); + expect(storedRecoveryCommit).to.equal(recoveryCommit); + }); + + it("Should allow juror to update commitment multiple times", async () => { + // Use the court with hidden votes (court ID 2) + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + + // First commitment + const { fullCommit: commit1, recoveryCommit: recovery1 } = generateCommitments(1n, 111n, "First justification"); + await disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDs, commit1, recovery1, identity, encryptedVote); + + // Second commitment (overwrites first) + const { fullCommit: commit2, recoveryCommit: recovery2 } = generateCommitments( + 2n, + 222n, + "Second justification" + ); + await disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDs, commit2, recovery2, identity, encryptedVote); + + // Verify only the second commitment is stored + const localDisputeId = await disputeKitShutter.coreDisputeIDToLocal(disputeId); + const storedRecoveryCommit = await disputeKitShutter.recoveryCommitments(localDisputeId, 0, voteIDs[0]); + expect(storedRecoveryCommit).to.equal(recovery2); + }); + }); + + describe("Failed commits", () => { + it("Should revert if recovery commitment is empty", async () => { + // Use the court with hidden votes (court ID 2) + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + const { fullCommit } = generateCommitments(choice, salt, justification); + + await expect( + disputeKitShutter.connect(juror1).castCommitShutter( + disputeId, + voteIDs, + fullCommit, + ethers.ZeroHash, // Empty recovery commit + identity, + encryptedVote + ) + ).to.be.revertedWithCustomError(disputeKitShutter, "EmptyRecoveryCommit"); + }); + + it("Should revert if not in commit period", async () => { + // Use the court with hidden votes (court ID 2) + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + // Still in evidence period + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + const { fullCommit, recoveryCommit } = generateCommitments(choice, salt, justification); + + await expect( + disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, identity, encryptedVote) + ).to.be.revertedWithCustomError(disputeKitShutter, "NotCommitPeriod"); + }); + + it("Should revert if juror doesn't own the vote", async () => { + // Use the court with hidden votes (court ID 2) + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + const { fullCommit, recoveryCommit } = generateCommitments(choice, salt, justification); + + await expect( + disputeKitShutter.connect(juror2).castCommitShutter( + disputeId, + voteIDs, // Using juror1's vote IDs + fullCommit, + recoveryCommit, + identity, + encryptedVote + ) + ).to.be.revertedWithCustomError(disputeKitShutter, "JurorHasToOwnTheVote"); + }); + }); + }); + + describe("Normal Flow - Bot Reveals", () => { + describe("Successful reveals", () => { + it("Should allow bot to reveal vote with full justification", async () => { + // Use the court with hidden votes (court ID 2) + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + const { fullCommit, recoveryCommit } = generateCommitments(choice, salt, justification); + + // Juror commits + await disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, identity, encryptedVote); + + await advanceToVotePeriod(disputeId); + + // Bot reveals vote + await expect(disputeKitShutter.connect(bot).castVoteShutter(disputeId, voteIDs, choice, salt, justification)) + .to.emit(disputeKitShutter, "VoteCast") + .withArgs(disputeId, juror1.address, voteIDs, choice, justification); + + // Verify vote was counted + const voteInfo = await disputeKitShutter.getVoteInfo(disputeId, 0, Number(voteIDs[0])); + expect(voteInfo[3]).to.be.true; // voted is at index 3 + expect(voteInfo[2]).to.equal(choice); // choice is at index 2 + }); + }); + + describe("Failed reveals", () => { + it("Should revert if wrong choice provided", async () => { + // Use the court with hidden votes (court ID 2) + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + const { fullCommit, recoveryCommit } = generateCommitments(choice, salt, justification); + + await disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, identity, encryptedVote); + + await advanceToVotePeriod(disputeId); + + const wrongChoice = 2n; + await expect( + disputeKitShutter.connect(bot).castVoteShutter( + disputeId, + voteIDs, + wrongChoice, // Wrong choice + salt, + justification + ) + ).to.be.revertedWithCustomError(disputeKitShutter, "HashDoesNotMatchHiddenVoteCommitment"); + }); + + it("Should revert if wrong salt provided", async () => { + // Use the court with hidden votes (court ID 2) + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + const { fullCommit, recoveryCommit } = generateCommitments(choice, salt, justification); + + await disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, identity, encryptedVote); + + await advanceToVotePeriod(disputeId); + + const wrongSalt = 99999n; + await expect( + disputeKitShutter.connect(bot).castVoteShutter( + disputeId, + voteIDs, + choice, + wrongSalt, // Wrong salt + justification + ) + ).to.be.revertedWithCustomError(disputeKitShutter, "HashDoesNotMatchHiddenVoteCommitment"); + }); + + it("Should revert if wrong justification provided", async () => { + // Use the court with hidden votes (court ID 2) + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + const { fullCommit, recoveryCommit } = generateCommitments(choice, salt, justification); + + await disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, identity, encryptedVote); + + await advanceToVotePeriod(disputeId); + + const wrongJustification = "Wrong justification"; + await expect( + disputeKitShutter.connect(bot).castVoteShutter( + disputeId, + voteIDs, + choice, + salt, + wrongJustification // Wrong justification + ) + ).to.be.revertedWithCustomError(disputeKitShutter, "HashDoesNotMatchHiddenVoteCommitment"); + }); + + it("Should revert if vote already cast", async () => { + // Use the court with hidden votes (court ID 2) + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + const { fullCommit, recoveryCommit } = generateCommitments(choice, salt, justification); + + await disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, identity, encryptedVote); + + await advanceToVotePeriod(disputeId); + + // First vote succeeds + await disputeKitShutter.connect(bot).castVoteShutter(disputeId, voteIDs, choice, salt, justification); + + // Second vote fails + await expect( + disputeKitShutter.connect(bot).castVoteShutter(disputeId, voteIDs, choice, salt, justification) + ).to.be.revertedWithCustomError(disputeKitShutter, "VoteAlreadyCast"); + }); + }); + }); + + describe("Recovery Flow - Juror Reveals", () => { + describe("Successful recovery reveals", () => { + it("Should allow juror to recover vote without justification", async () => { + // Use the court with hidden votes (court ID 2) + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + const { fullCommit, recoveryCommit } = generateCommitments(choice, salt, justification); + + // Juror commits + await disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, identity, encryptedVote); + + await advanceToVotePeriod(disputeId); + + // Juror reveals vote (Shutter failed, so juror must reveal) + // Note: justification can be anything as it won't be validated + await expect( + disputeKitShutter.connect(juror1).castVoteShutter( + disputeId, + voteIDs, + choice, + salt, + "" // Empty justification is fine for recovery + ) + ) + .to.emit(disputeKitShutter, "VoteCast") + .withArgs(disputeId, juror1.address, voteIDs, choice, ""); + + // Verify vote was counted + const voteInfo = await disputeKitShutter.getVoteInfo(disputeId, 0, Number(voteIDs[0])); + expect(voteInfo[3]).to.be.true; // voted is at index 3 + expect(voteInfo[2]).to.equal(choice); // choice is at index 2 + }); + + it("Should validate against recovery commitment when juror reveals", async () => { + // Use the court with hidden votes (court ID 2) + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + const { fullCommit, recoveryCommit } = generateCommitments(choice, salt, justification); + + await disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, identity, encryptedVote); + + await advanceToVotePeriod(disputeId); + + // Juror can provide any justification - it won't be validated + const differentJustification = "This is a different justification that won't be checked"; + await expect( + disputeKitShutter.connect(juror1).castVoteShutter( + disputeId, + voteIDs, + choice, + salt, + differentJustification // Different justification is OK for recovery + ) + ).to.not.be.reverted; + }); + }); + + describe("Failed recovery reveals", () => { + it("Should revert if wrong choice in recovery", async () => { + // Use the court with hidden votes (court ID 2) + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + const { fullCommit, recoveryCommit } = generateCommitments(choice, salt, justification); + + await disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, identity, encryptedVote); + + await advanceToVotePeriod(disputeId); + + const wrongChoice = 2n; + await expect( + disputeKitShutter.connect(juror1).castVoteShutter( + disputeId, + voteIDs, + wrongChoice, // Wrong choice + salt, + "" + ) + ).to.be.revertedWithCustomError(disputeKitShutter, "HashDoesNotMatchHiddenVoteCommitment"); + }); + + it("Should revert if wrong salt in recovery", async () => { + // Use the court with hidden votes (court ID 2) + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + const { fullCommit, recoveryCommit } = generateCommitments(choice, salt, justification); + + await disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, identity, encryptedVote); + + await advanceToVotePeriod(disputeId); + + const wrongSalt = 99999n; + await expect( + disputeKitShutter.connect(juror1).castVoteShutter( + disputeId, + voteIDs, + choice, + wrongSalt, // Wrong salt + "" + ) + ).to.be.revertedWithCustomError(disputeKitShutter, "HashDoesNotMatchHiddenVoteCommitment"); + }); + + it("Should revert if non-juror tries to reveal without correct full commitment", async () => { + // Use the court with hidden votes (court ID 2) + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + const { fullCommit, recoveryCommit } = generateCommitments(choice, salt, justification); + + await disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, identity, encryptedVote); + + await advanceToVotePeriod(disputeId); + + // Attacker tries to reveal with only choice and salt (no justification) + await expect( + disputeKitShutter.connect(attacker).castVoteShutter( + disputeId, + voteIDs, + choice, + salt, + "" // No justification - would work for juror but not for others + ) + ).to.be.revertedWithCustomError(disputeKitShutter, "HashDoesNotMatchHiddenVoteCommitment"); + }); + }); + }); + + describe("Hash Function Behavior", () => { + it("Should return different hashes for juror vs non-juror callers", async () => { + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDs = await getVoteIDsForJuror(disputeId, juror1); + const { fullCommit, recoveryCommit } = generateCommitments(choice, salt, justification); + + await disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, identity, encryptedVote); + + await advanceToVotePeriod(disputeId); + + // During castVoteShutter, the contract should use different hash logic + // For juror: hash(choice, salt) + // For non-juror: hash(choice, salt, justificationHash) + + // This is tested implicitly by the recovery flow tests above + // The juror can reveal with any justification, while non-juror must provide exact justification + }); + + it("Should correctly compute hash for normal flow", async () => { + // Test hashVote function directly + const justificationHash = ethers.keccak256(ethers.toUtf8Bytes(justification)); + const expectedHash = ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode(["uint256", "uint256", "bytes32"], [choice, salt, justificationHash]) + ); + + // When called by non-juror (normal case), should include justification + const computedHash = await disputeKitShutter.hashVote(choice, salt, justification); + expect(computedHash).to.equal(expectedHash); + }); + }); + + describe("Edge Cases and Security", () => { + it("Should handle mixed normal and recovery reveals in same dispute", async () => { + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDsJuror1 = await getVoteIDsForJuror(disputeId, juror1); + const voteIDsJuror2 = await getVoteIDsForJuror(disputeId, juror2); + + const { fullCommit: commit1, recoveryCommit: recovery1 } = generateCommitments(1n, 111n, "Juror 1 justification"); + const { fullCommit: commit2, recoveryCommit: recovery2 } = generateCommitments(2n, 222n, "Juror 2 justification"); + + // Both jurors commit + await disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDsJuror1, commit1, recovery1, identity, encryptedVote); + + await disputeKitShutter + .connect(juror2) + .castCommitShutter(disputeId, voteIDsJuror2, commit2, recovery2, identity, encryptedVote); + + await advanceToVotePeriod(disputeId); + + // Juror1 uses recovery flow (Shutter failed for them) + await disputeKitShutter.connect(juror1).castVoteShutter( + disputeId, + voteIDsJuror1, + 1n, + 111n, + "Different justification" // Recovery doesn't check this + ); + + // Bot reveals juror2's vote normally + await disputeKitShutter.connect(bot).castVoteShutter( + disputeId, + voteIDsJuror2, + 2n, + 222n, + "Juror 2 justification" // Must match exactly + ); + + // Verify both votes were counted + const vote1Info = await disputeKitShutter.getVoteInfo(disputeId, 0, Number(voteIDsJuror1[0])); + const vote2Info = await disputeKitShutter.getVoteInfo(disputeId, 0, Number(voteIDsJuror2[0])); + + expect(vote1Info[3]).to.be.true; // voted is at index 3 + expect(vote1Info[2]).to.equal(1n); // choice is at index 2 + expect(vote2Info[3]).to.be.true; + expect(vote2Info[2]).to.equal(2n); + }); + + it("Should allow anyone to reveal vote with correct data only", async () => { + const disputeId = await createDisputeAndDraw(2, 3, SHUTTER_DK_ID); + await advanceToCommitPeriod(disputeId); + + const voteIDsJuror1 = await getVoteIDsForJuror(disputeId, juror1); + const { fullCommit, recoveryCommit } = generateCommitments(choice, salt, justification); + + await disputeKitShutter + .connect(juror1) + .castCommitShutter(disputeId, voteIDsJuror1, fullCommit, recoveryCommit, identity, encryptedVote); + + // Juror2 commits with a different choice + const differentChoice = 2n; + const voteIDsJuror2 = await getVoteIDsForJuror(disputeId, juror2); + const { fullCommit: commit2, recoveryCommit: recovery2 } = generateCommitments( + differentChoice, + salt, + justification + ); + + await disputeKitShutter + .connect(juror2) + .castCommitShutter(disputeId, voteIDsJuror2, commit2, recovery2, identity, encryptedVote); + + await advanceToVotePeriod(disputeId); + + // In normal Shutter operation, anyone (bot/attacker) can reveal the vote if they have the correct data + // This is by design - the security comes from the fact that only Shutter knows the decryption key + await expect( + disputeKitShutter.connect(attacker).castVoteShutter(disputeId, voteIDsJuror1, choice, salt, justification) + ) + .to.emit(disputeKitShutter, "VoteCast") + .withArgs(disputeId, juror1.address, voteIDsJuror1, choice, justification); + + // Attacker cannot change juror2's vote to a different choice + await expect( + disputeKitShutter.connect(attacker).castVoteShutter( + disputeId, + voteIDsJuror2, + 1n, // Wrong choice + salt, + justification + ) + ).to.be.revertedWithCustomError(disputeKitShutter, "HashDoesNotMatchHiddenVoteCommitment"); + }); + }); +});