diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol b/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol index 7bd0ccebe..fa8548a6b 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol @@ -204,7 +204,7 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi uint256 _numberOfChoices, bytes calldata _extraData, uint256 /*_nbVotes*/ - ) external override onlyByCore { + ) public virtual override onlyByCore { uint256 localDisputeID; Dispute storage dispute; Active storage active = coreDisputeIDToActive[_coreDisputeID]; diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol b/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol index 309f5c566..2b5131d81 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol @@ -29,6 +29,14 @@ interface IBalanceHolderERC1155 { contract DisputeKitGated is DisputeKitClassicBase { string public constant override version = "2.0.0"; + address private constant NO_TOKEN_GATE = address(0); + + // ************************************* // + // * Storage * // + // ************************************* // + + mapping(address token => bool supported) public supportedTokens; // Whether the token is supported or not. + // ************************************* // // * Constructor * // // ************************************* // @@ -50,6 +58,7 @@ contract DisputeKitGated is DisputeKitClassicBase { uint256 _jumpDisputeKitID ) external initializer { __DisputeKitClassicBase_initialize(_owner, _core, _wNative, _jumpDisputeKitID); + supportedTokens[NO_TOKEN_GATE] = true; // Allows disputes without token gating } // ************************ // @@ -62,6 +71,34 @@ contract DisputeKitGated is DisputeKitClassicBase { // NOP } + /// @notice Changes the supported tokens. + /// @param _tokens The tokens to support. + /// @param _supported Whether the tokens are supported or not. + function changeSupportedTokens(address[] memory _tokens, bool _supported) external onlyByOwner { + for (uint256 i = 0; i < _tokens.length; i++) { + supportedTokens[_tokens[i]] = _supported; + } + } + + // ************************************* // + // * State Modifiers * // + // ************************************* // + + /// @inheritdoc DisputeKitClassicBase + function createDispute( + uint256 _coreDisputeID, + uint256 _coreRoundID, + uint256 _numberOfChoices, + bytes calldata _extraData, + uint256 _nbVotes + ) public override { + (address tokenGate, , ) = _extraDataToTokenInfo(_extraData); + if (!supportedTokens[tokenGate]) revert TokenNotSupported(tokenGate); + + // super.createDispute() ensures access control onlyByCore. + super.createDispute(_coreDisputeID, _coreRoundID, _numberOfChoices, _extraData, _nbVotes); + } + // ************************************* // // * Internal * // // ************************************* // @@ -78,7 +115,7 @@ contract DisputeKitGated is DisputeKitClassicBase { /// @return tokenId The token ID for ERC-1155 tokens (ignored for ERC-20/ERC-721). function _extraDataToTokenInfo( bytes memory _extraData - ) public pure returns (address tokenGate, bool isERC1155, uint256 tokenId) { + ) internal pure returns (address tokenGate, bool isERC1155, uint256 tokenId) { // Need at least 160 bytes to safely read the parameters if (_extraData.length < 160) return (address(0), false, 0); @@ -107,7 +144,7 @@ contract DisputeKitGated is DisputeKitClassicBase { (address tokenGate, bool isERC1155, uint256 tokenId) = _extraDataToTokenInfo(dispute.extraData); // If no token gate is specified, allow all jurors - if (tokenGate == address(0)) return true; + if (tokenGate == NO_TOKEN_GATE) return true; // Check juror's token balance if (isERC1155) { @@ -116,4 +153,10 @@ contract DisputeKitGated is DisputeKitClassicBase { return IBalanceHolder(tokenGate).balanceOf(_juror) > 0; } } + + // ************************************* // + // * Errors * // + // ************************************* // + + error TokenNotSupported(address tokenGate); } diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol b/contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol index a4ec2f8dc..f5835e2a5 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol @@ -30,10 +30,13 @@ interface IBalanceHolderERC1155 { contract DisputeKitGatedShutter is DisputeKitClassicBase { string public constant override version = "2.0.0"; + address private constant NO_TOKEN_GATE = address(0); + // ************************************* // // * Storage * // // ************************************* // + mapping(address token => bool supported) public supportedTokens; // Whether the token is supported or not. mapping(uint256 localDisputeID => mapping(uint256 localRoundID => mapping(uint256 voteID => bytes32 recoveryCommitment))) public recoveryCommitments; @@ -84,6 +87,7 @@ contract DisputeKitGatedShutter is DisputeKitClassicBase { uint256 _jumpDisputeKitID ) external initializer { __DisputeKitClassicBase_initialize(_owner, _core, _wNative, _jumpDisputeKitID); + supportedTokens[NO_TOKEN_GATE] = true; // Allows disputes without token gating } // ************************ // @@ -96,10 +100,34 @@ contract DisputeKitGatedShutter is DisputeKitClassicBase { // NOP } + /// @notice Changes the supported tokens. + /// @param _tokens The tokens to support. + /// @param _supported Whether the tokens are supported or not. + function changeSupportedTokens(address[] memory _tokens, bool _supported) external onlyByOwner { + for (uint256 i = 0; i < _tokens.length; i++) { + supportedTokens[_tokens[i]] = _supported; + } + } + // ************************************* // // * State Modifiers * // // ************************************* // + /// @inheritdoc DisputeKitClassicBase + function createDispute( + uint256 _coreDisputeID, + uint256 _coreRoundID, + uint256 _numberOfChoices, + bytes calldata _extraData, + uint256 _nbVotes + ) public override { + (address tokenGate, , ) = _extraDataToTokenInfo(_extraData); + if (!supportedTokens[tokenGate]) revert TokenNotSupported(tokenGate); + + // super.createDispute() ensures access control onlyByCore. + super.createDispute(_coreDisputeID, _coreRoundID, _numberOfChoices, _extraData, _nbVotes); + } + /// @notice Sets the caller's commit for the specified votes. /// /// @dev It can be called multiple times during the commit period, each call overrides the commits of the previous one. @@ -209,7 +237,7 @@ contract DisputeKitGatedShutter is DisputeKitClassicBase { /// @return tokenGate The address of the token contract used for gating access. /// @return isERC1155 True if the token is an ERC-1155, false for ERC-20/ERC-721. /// @return tokenId The token ID for ERC-1155 tokens (ignored for ERC-20/ERC-721). - function __extraDataToTokenInfo( + function _extraDataToTokenInfo( bytes memory _extraData ) internal pure returns (address tokenGate, bool isERC1155, uint256 tokenId) { // Need at least 160 bytes to safely read the parameters @@ -237,10 +265,10 @@ contract DisputeKitGatedShutter is DisputeKitClassicBase { // Get the local dispute and extract token info from extraData uint256 localDisputeID = coreDisputeIDToLocal[_coreDisputeID]; Dispute storage dispute = disputes[localDisputeID]; - (address tokenGate, bool isERC1155, uint256 tokenId) = __extraDataToTokenInfo(dispute.extraData); + (address tokenGate, bool isERC1155, uint256 tokenId) = _extraDataToTokenInfo(dispute.extraData); // If no token gate is specified, allow all jurors - if (tokenGate == address(0)) return true; + if (tokenGate == NO_TOKEN_GATE) return true; // Check juror's token balance if (isERC1155) { @@ -254,5 +282,6 @@ contract DisputeKitGatedShutter is DisputeKitClassicBase { // * Errors * // // ************************************* // + error TokenNotSupported(address tokenGate); error EmptyRecoveryCommit(); } diff --git a/contracts/src/test/DisputeKitGatedMock.sol b/contracts/src/test/DisputeKitGatedMock.sol new file mode 100644 index 000000000..cc778c916 --- /dev/null +++ b/contracts/src/test/DisputeKitGatedMock.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import "../arbitration/dispute-kits/DisputeKitGated.sol"; + +/// @title DisputeKitGatedMock +/// DisputeKitGated with view functions to use in the tests. +contract DisputeKitGatedMock is DisputeKitGated { + function extraDataToTokenInfo( + bytes memory _extraData + ) public pure returns (address tokenGate, bool isERC1155, uint256 tokenId) { + (tokenGate, isERC1155, tokenId) = _extraDataToTokenInfo(_extraData); + } +} diff --git a/contracts/src/test/DisputeKitGatedShutterMock.sol b/contracts/src/test/DisputeKitGatedShutterMock.sol new file mode 100644 index 000000000..4ed4c59b2 --- /dev/null +++ b/contracts/src/test/DisputeKitGatedShutterMock.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import "../arbitration/dispute-kits/DisputeKitGatedShutter.sol"; + +/// @title DisputeKitGatedShutterMock +/// DisputeKitGatedShutter with view functions to use in the tests. +contract DisputeKitGatedShutterMock is DisputeKitGatedShutter { + function extraDataToTokenInfo( + bytes memory _extraData + ) public pure returns (address tokenGate, bool isERC1155, uint256 tokenId) { + (tokenGate, isERC1155, tokenId) = _extraDataToTokenInfo(_extraData); + } +} diff --git a/contracts/test/arbitration/dispute-kit-gated-shutter.ts b/contracts/test/arbitration/dispute-kit-gated-shutter.ts new file mode 100644 index 000000000..3078c2319 --- /dev/null +++ b/contracts/test/arbitration/dispute-kit-gated-shutter.ts @@ -0,0 +1,74 @@ +import { + setupTokenGatedTest, + testTokenWhitelistManagement, + testAccessControl, + testUnsupportedTokenErrors, + testERC20Gating, + testERC721Gating, + testERC1155Gating, + testWhitelistIntegration, + testNoTokenGateAddress, + TokenGatedTestContext, +} from "./helpers/dispute-kit-gated-common"; +import { + setupShutterTest, + testCommitPhase, + testNormalFlowBotReveals, + testRecoveryFlowJurorReveals, + testHashFunctionBehavior, + testEdgeCasesAndSecurity, + ShutterTestContext, +} from "./helpers/dispute-kit-shutter-common"; + +/* eslint-disable no-unused-vars */ +/* eslint-disable no-unused-expressions */ + +/** + * Test suite for DisputeKitGatedShutter - a dispute kit that requires jurors to hold + * specific tokens (ERC20, ERC721, or ERC1155) to participate in disputes, with additional + * Shutter functionality for commit-reveal voting. + * + * Tests cover: + * - All DisputeKitGated functionality (via shared tests) + * - Shutter-specific commit/reveal mechanism + * - Recovery commits for juror vote recovery + * - Integration between token gating and Shutter features + */ +describe("DisputeKitGatedShutter", async () => { + describe("Token Gating Features", async () => { + let tokenContext: TokenGatedTestContext; + + beforeEach("Setup", async () => { + tokenContext = await setupTokenGatedTest({ contractName: "DisputeKitGatedShutterMock" }); + }); + + // Run all shared token gating tests + testTokenWhitelistManagement(() => tokenContext); + testAccessControl(() => tokenContext); + testUnsupportedTokenErrors(() => tokenContext); + testERC20Gating(() => tokenContext); + testERC721Gating(() => tokenContext); + testERC1155Gating(() => tokenContext); + testWhitelistIntegration(() => tokenContext); + testNoTokenGateAddress(() => tokenContext); + }); + + describe("Shutter Features", async () => { + let shutterContext: ShutterTestContext; + + beforeEach("Setup", async () => { + // Setup DisputeKitGatedShutter with token gating enabled + shutterContext = await setupShutterTest({ + contractName: "DisputeKitGatedShutter", + isGated: true, // Enable token gating for DAI + }); + }); + + // Run all shared Shutter tests + testCommitPhase(() => shutterContext); + testNormalFlowBotReveals(() => shutterContext); + testRecoveryFlowJurorReveals(() => shutterContext); + testHashFunctionBehavior(() => shutterContext); + testEdgeCasesAndSecurity(() => shutterContext); + }); +}); diff --git a/contracts/test/arbitration/dispute-kit-gated.ts b/contracts/test/arbitration/dispute-kit-gated.ts index a6db6e473..83637f988 100644 --- a/contracts/test/arbitration/dispute-kit-gated.ts +++ b/contracts/test/arbitration/dispute-kit-gated.ts @@ -1,288 +1,43 @@ -import { deployments, ethers, getNamedAccounts, network } from "hardhat"; -import { toBigInt, BigNumberish, Addressable } from "ethers"; import { - PNK, - KlerosCore, - SortitionModule, - IncrementalNG, - DisputeKitGated, - TestERC20, - TestERC721, - TestERC1155, -} from "../../typechain-types"; -import { expect } from "chai"; -import { Courts } from "../../deploy/utils"; -import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { deployERC1155, deployERC721 } from "../../deploy/utils/deployTokens"; + setupTokenGatedTest, + testTokenWhitelistManagement, + testAccessControl, + testUnsupportedTokenErrors, + testERC20Gating, + testERC721Gating, + testERC1155Gating, + testWhitelistIntegration, + testNoTokenGateAddress, + TokenGatedTestContext, +} from "./helpers/dispute-kit-gated-common"; /* eslint-disable no-unused-vars */ -/* eslint-disable no-unused-expressions */ // https://github.com/standard/standard/issues/690#issuecomment-278533482 - +/* eslint-disable no-unused-expressions */ + +/** + * Test suite for DisputeKitGated - a dispute kit that requires jurors to hold + * specific tokens (ERC20, ERC721, or ERC1155) to participate in disputes. + * + * Tests cover: + * - Token whitelist management and access control + * - Error handling for unsupported tokens + * - Token gating functionality for different token types + * - Integration between whitelist and dispute creation + */ describe("DisputeKitGated", async () => { - const ONE_THOUSAND_PNK = 10n ** 21n; - const thousandPNK = (amount: BigNumberish) => toBigInt(amount) * ONE_THOUSAND_PNK; - const PNK = (amount: BigNumberish) => toBigInt(amount) * 10n ** 18n; - - let deployer: string; - let juror1: HardhatEthersSigner; - let juror2: HardhatEthersSigner; - let disputeKitGated: DisputeKitGated; - let pnk: PNK; - let dai: TestERC20; - let core: KlerosCore; - let sortitionModule: SortitionModule; - let rng: IncrementalNG; - let nft721: TestERC721; - let nft1155: TestERC1155; - const RANDOM = 424242n; - const GATED_DK_ID = 3; - const TOKEN_ID = 888; - const minStake = PNK(200); + let context: TokenGatedTestContext; beforeEach("Setup", async () => { - ({ deployer } = await getNamedAccounts()); - [, juror1, juror2] = await ethers.getSigners(); - - await deployments.fixture(["Arbitration", "VeaMock"], { - fallbackToGlobal: true, - keepExistingDeployments: false, - }); - disputeKitGated = await ethers.getContract("DisputeKitGated"); - pnk = await ethers.getContract("PNK"); - dai = await ethers.getContract("DAI"); - 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()); - - const hre = require("hardhat"); - await deployERC721(hre, deployer, "TestERC721", "Nft721"); - nft721 = await ethers.getContract("Nft721"); - - await deployERC1155(hre, deployer, "TestERC1155", "Nft1155"); - nft1155 = await ethers.getContract("Nft1155"); - await nft1155.mint(deployer, TOKEN_ID, 1, "0x00"); + context = await setupTokenGatedTest({ contractName: "DisputeKitGatedMock" }); }); - const encodeExtraData = ( - courtId: number, - minJurors: BigNumberish, - disputeKitId: number, - tokenGate: string | Addressable, - isERC1155: boolean, - tokenId: BigNumberish - ) => { - // Packing of tokenGate and isERC1155 - // uint88 (padding 11 bytes) + bool (1 byte) + address (20 bytes) = 32 bytes - const packed = ethers.solidityPacked(["uint88", "bool", "address"], [0, isERC1155, tokenGate]); - return ethers.AbiCoder.defaultAbiCoder().encode( - ["uint256", "uint256", "uint256", "bytes32", "uint256"], - [courtId, minJurors, disputeKitId, packed, tokenId] - ); - }; - - const stakeAndDraw = async ( - courtId: number, - minJurors: BigNumberish, - disputeKitId: number, - tokenGate: string | Addressable, - isERC1155: boolean, - tokenId: BigNumberish - ) => { - // 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(Courts.GENERAL, thousandPNK(10), { gasLimit: 500000 }) - .then((tx) => tx.wait()); - - expect(await sortitionModule.getJurorBalance(juror.address, 1)).to.deep.equal([ - thousandPNK(10), // totalStaked - 0, // totalLocked - thousandPNK(10), // stakedInCourt - 1, // nbOfCourts - ]); - } - - const extraData = encodeExtraData(courtId, minJurors, disputeKitId, tokenGate, isERC1155, tokenId); - // console.log("extraData", extraData); - - const tokenInfo = await disputeKitGated._extraDataToTokenInfo(extraData); - expect(tokenInfo[0]).to.equal(tokenGate); - expect(tokenInfo[1]).to.equal(isERC1155); - expect(tokenInfo[2]).to.equal(tokenId); - - const arbitrationCost = await core["arbitrationCost(bytes)"](extraData); - - // Warning: this dispute cannot be executed, in reality it should be created by an arbitrable contract, not an EOA. - const tx = await core["createDispute(uint256,bytes)"](2, extraData, { value: arbitrationCost }).then((tx) => - tx.wait() - ); - const disputeId = 0; - // console.log(tx?.logs); - - 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 - return core.draw(disputeId, 70, { gasLimit: 10000000 }); - }; - - describe("When gating with DAI token", async () => { - it("Should draw no juror if they don't have any DAI balance", async () => { - const nbOfJurors = 15n; - const tx = await stakeAndDraw(Courts.GENERAL, nbOfJurors, GATED_DK_ID, dai.target, false, 0).then((tx) => - tx.wait() - ); - - // Ensure that no juror is drawn - const drawLogs = - tx?.logs.filter((log: any) => log.fragment?.name === "Draw" && log.address === core.target) || []; - expect(drawLogs).to.have.length(0); - }); - - it("Should draw only the jurors who have some DAI balance", async () => { - dai.transfer(juror1.address, 1); - - const nbOfJurors = 15n; - const tx = await stakeAndDraw(Courts.GENERAL, nbOfJurors, GATED_DK_ID, dai.target, false, 0).then((tx) => - tx.wait() - ); - - // Ensure that only juror1 is drawn - const drawLogs = - tx?.logs.filter((log: any) => log.fragment?.name === "Draw" && log.address === core.target) || []; - expect(drawLogs).to.have.length(nbOfJurors); - drawLogs.forEach((log: any) => { - expect(log.args[0]).to.equal(juror1.address); - }); - - // Ensure that juror1 has PNK locked - expect(await sortitionModule.getJurorBalance(juror1.address, Courts.GENERAL)).to.deep.equal([ - thousandPNK(10), // totalStaked - minStake * nbOfJurors, // totalLocked - thousandPNK(10), // stakedInCourt - 1, // nbOfCourts - ]); - - // Ensure that juror2 has no PNK locked - expect(await sortitionModule.getJurorBalance(juror2.address, Courts.GENERAL)).to.deep.equal([ - thousandPNK(10), // totalStaked - 0, // totalLocked - thousandPNK(10), // stakedInCourt - 1, // nbOfCourts - ]); - }); - }); - - describe("When gating with ERC721 token", async () => { - it("Should draw no juror if they don't own the ERC721 token", async () => { - const nbOfJurors = 15n; - const tx = await stakeAndDraw(Courts.GENERAL, nbOfJurors, GATED_DK_ID, nft721.target, false, 0).then((tx) => - tx.wait() - ); - - // Ensure that no juror is drawn - const drawLogs = - tx?.logs.filter((log: any) => log.fragment?.name === "Draw" && log.address === core.target) || []; - expect(drawLogs).to.have.length(0); - }); - - it("Should draw only the jurors owning the ERC721 token", async () => { - await nft721.safeMint(juror2.address); - - const nbOfJurors = 15n; - const tx = await stakeAndDraw(Courts.GENERAL, nbOfJurors, GATED_DK_ID, nft721.target, false, 0).then((tx) => - tx.wait() - ); - - // Ensure that only juror2 is drawn - const drawLogs = - tx?.logs.filter((log: any) => log.fragment?.name === "Draw" && log.address === core.target) || []; - expect(drawLogs).to.have.length(nbOfJurors); - drawLogs.forEach((log: any) => { - expect(log.args[0]).to.equal(juror2.address); - }); - - // Ensure that juror1 is has no PNK locked - expect(await sortitionModule.getJurorBalance(juror1.address, Courts.GENERAL)).to.deep.equal([ - thousandPNK(10), // totalStaked - 0, // totalLocked - thousandPNK(10), // stakedInCourt - 1, // nbOfCourts - ]); - - // Ensure that juror2 has PNK locked - expect(await sortitionModule.getJurorBalance(juror2.address, Courts.GENERAL)).to.deep.equal([ - thousandPNK(10), // totalStaked - minStake * nbOfJurors, // totalLocked - thousandPNK(10), // stakedInCourt - 1, // nbOfCourts - ]); - }); - }); - - describe("When gating with ERC1155 token", async () => { - it("Should draw no juror if they don't own the ERC1155 token", async () => { - const nbOfJurors = 15n; - const tx = await stakeAndDraw(Courts.GENERAL, nbOfJurors, GATED_DK_ID, nft1155.target, true, TOKEN_ID).then( - (tx) => tx.wait() - ); - - // Ensure that no juror is drawn - const drawLogs = - tx?.logs.filter((log: any) => log.fragment?.name === "Draw" && log.address === core.target) || []; - expect(drawLogs).to.have.length(0); - }); - - it("Should draw only the jurors owning the ERC1155 token", async () => { - await nft1155.mint(juror2.address, TOKEN_ID, 1, "0x00"); - - const nbOfJurors = 15n; - const tx = await stakeAndDraw(Courts.GENERAL, nbOfJurors, GATED_DK_ID, nft1155.target, true, TOKEN_ID).then( - (tx) => tx.wait() - ); - - // Ensure that only juror2 is drawn - const drawLogs = - tx?.logs.filter((log: any) => log.fragment?.name === "Draw" && log.address === core.target) || []; - expect(drawLogs).to.have.length(nbOfJurors); - drawLogs.forEach((log: any) => { - expect(log.args[0]).to.equal(juror2.address); - }); - - // Ensure that juror1 is has no PNK locked - expect(await sortitionModule.getJurorBalance(juror1.address, Courts.GENERAL)).to.deep.equal([ - thousandPNK(10), // totalStaked - 0, // totalLocked - thousandPNK(10), // stakedInCourt - 1, // nbOfCourts - ]); - - // Ensure that juror2 has PNK locked - expect(await sortitionModule.getJurorBalance(juror2.address, Courts.GENERAL)).to.deep.equal([ - thousandPNK(10), // totalStaked - minStake * nbOfJurors, // totalLocked - thousandPNK(10), // stakedInCourt - 1, // nbOfCourts - ]); - }); - }); + // Run all shared tests with the context + testTokenWhitelistManagement(() => context); + testAccessControl(() => context); + testUnsupportedTokenErrors(() => context); + testERC20Gating(() => context); + testERC721Gating(() => context); + testERC1155Gating(() => context); + testWhitelistIntegration(() => context); + testNoTokenGateAddress(() => context); }); diff --git a/contracts/test/arbitration/dispute-kit-shutter.ts b/contracts/test/arbitration/dispute-kit-shutter.ts index aab7efa58..facf932e3 100644 --- a/contracts/test/arbitration/dispute-kit-shutter.ts +++ b/contracts/test/arbitration/dispute-kit-shutter.ts @@ -1,733 +1,38 @@ -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"; +import { + setupShutterTest, + testCommitPhase, + testNormalFlowBotReveals, + testRecoveryFlowJurorReveals, + testHashFunctionBehavior, + testEdgeCasesAndSecurity, + ShutterTestContext, +} from "./helpers/dispute-kit-shutter-common"; /* eslint-disable no-unused-vars */ -/* eslint-disable no-unused-expressions */ // https://github.com/standard/standard/issues/690#issuecomment-278533482 - +/* eslint-disable no-unused-expressions */ + +/** + * Test suite for DisputeKitShutter - implements shielded voting with commit-reveal mechanism + * using the Shutter protocol for encrypted vote storage and decryption. + * + * Tests cover: + * - Commit phase with recovery commitments + * - Normal flow where bot reveals votes + * - Recovery flow where jurors reveal their own votes + * - Hash function behavior for different callers + * - Edge cases and security considerations + */ 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"); + let context: ShutterTestContext; 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) + context = await setupShutterTest({ contractName: "DisputeKitShutter" }); }); - // ************************************* // - // * 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"); - }); - }); + // Run all shared Shutter tests with the context + testCommitPhase(() => context); + testNormalFlowBotReveals(() => context); + testRecoveryFlowJurorReveals(() => context); + testHashFunctionBehavior(() => context); + testEdgeCasesAndSecurity(() => context); }); diff --git a/contracts/test/arbitration/helpers/dispute-kit-gated-common.ts b/contracts/test/arbitration/helpers/dispute-kit-gated-common.ts new file mode 100644 index 000000000..4876093b3 --- /dev/null +++ b/contracts/test/arbitration/helpers/dispute-kit-gated-common.ts @@ -0,0 +1,704 @@ +import { deployments, ethers, getNamedAccounts, network } from "hardhat"; +import { toBigInt, BigNumberish, Addressable } from "ethers"; +import { + PNK, + KlerosCore, + SortitionModule, + IncrementalNG, + DisputeKitGatedMock, + DisputeKitGatedShutterMock, + TestERC20, + TestERC721, + TestERC1155, +} from "../../../typechain-types"; +import { expect } from "chai"; +import { Courts } from "../../../deploy/utils"; +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; +import { deployERC1155, deployERC721 } from "../../../deploy/utils/deployTokens"; +import { deployUpgradable } from "../../../deploy/utils/deployUpgradable"; + +/* eslint-disable no-unused-vars */ +/* eslint-disable no-unused-expressions */ // https://github.com/standard/standard/issues/690#issuecomment-278533482 + +// Type for the dispute kit (either DisputeKitGated or DisputeKitGatedShutter) +export type DisputeKitGatedType = DisputeKitGatedMock | DisputeKitGatedShutterMock; + +// Test context interface that holds all the test state +export interface TokenGatedTestContext { + deployer: string; + juror1: HardhatEthersSigner; + juror2: HardhatEthersSigner; + disputeKit: DisputeKitGatedType; + pnk: PNK; + dai: TestERC20; + core: KlerosCore; + sortitionModule: SortitionModule; + rng: IncrementalNG; + nft721: TestERC721; + nft1155: TestERC1155; + gatedDisputeKitID: number; + minStake: bigint; + RANDOM: bigint; + TOKEN_ID: number; + ONE_THOUSAND_PNK: bigint; + thousandPNK: (amount: BigNumberish) => bigint; + PNK: (amount: BigNumberish) => bigint; +} + +// Configuration for setting up a token gated test +export interface TokenGatedTestConfig { + contractName: string; // "DisputeKitGatedMock" or "DisputeKitGatedShutterMock" +} + +// Constants for token amounts +const ONE_THOUSAND_PNK = 10n ** 21n; +const thousandPNK = (amount: BigNumberish) => toBigInt(amount) * ONE_THOUSAND_PNK; +const PNK_AMOUNT = (amount: BigNumberish) => toBigInt(amount) * 10n ** 18n; + +// Helper function to encode extra data for dispute creation with token gating parameters +export const encodeExtraData = ( + courtId: BigNumberish, + minJurors: BigNumberish, + disputeKitId: number, + tokenGate: string | Addressable, + isERC1155: boolean, + tokenId: BigNumberish +) => { + // Packing of tokenGate and isERC1155 + // uint88 (padding 11 bytes) + bool (1 byte) + address (20 bytes) = 32 bytes + const packed = ethers.solidityPacked(["uint88", "bool", "address"], [0, isERC1155, tokenGate]); + return ethers.AbiCoder.defaultAbiCoder().encode( + ["uint256", "uint256", "uint256", "bytes32", "uint256"], + [courtId, minJurors, disputeKitId, packed, tokenId] + ); +}; + +// Helper function to add or remove tokens from the whitelist +export const whitelistTokens = async ( + context: TokenGatedTestContext, + tokens: (string | Addressable)[], + supported: boolean = true +) => { + const tokenAddresses = tokens.map((token) => (typeof token === "string" ? token : token.toString())); + return context.disputeKit.changeSupportedTokens(tokenAddresses, supported); +}; + +// Helper function to create a dispute with the specified token gate +export const createDisputeWithToken = async ( + context: TokenGatedTestContext, + token: string | Addressable, + isERC1155: boolean = false, + tokenId: BigNumberish = 0 +) => { + const extraData = encodeExtraData(Courts.GENERAL, 3, context.gatedDisputeKitID, token, isERC1155, tokenId); + const arbitrationCost = await context.core["arbitrationCost(bytes)"](extraData); + return context.core["createDispute(uint256,bytes)"](2, extraData, { value: arbitrationCost }); +}; + +// Helper function to assert whether a token is supported or not +export const expectTokenSupported = async ( + context: TokenGatedTestContext, + token: string | Addressable, + supported: boolean +) => { + const tokenAddress = typeof token === "string" ? token : token.toString(); + expect(await context.disputeKit.supportedTokens(tokenAddress)).to.equal(supported); +}; + +// Helper function to stake and draw jurors +export const stakeAndDraw = async ( + context: TokenGatedTestContext, + courtId: number, + minJurors: BigNumberish, + disputeKitId: number, + tokenGate: string | Addressable, + isERC1155: boolean, + tokenId: BigNumberish +) => { + // Stake jurors + for (const juror of [context.juror1, context.juror2]) { + await context.pnk.transfer(juror.address, context.thousandPNK(10)).then((tx) => tx.wait()); + expect(await context.pnk.balanceOf(juror.address)).to.equal(context.thousandPNK(10)); + + await context.pnk + .connect(juror) + .approve(context.core.target, context.thousandPNK(10), { gasLimit: 300000 }) + .then((tx) => tx.wait()); + + await context.core + .connect(juror) + .setStake(Courts.GENERAL, context.thousandPNK(10), { gasLimit: 500000 }) + .then((tx) => tx.wait()); + + expect(await context.sortitionModule.getJurorBalance(juror.address, 1)).to.deep.equal([ + context.thousandPNK(10), // totalStaked + 0, // totalLocked + context.thousandPNK(10), // stakedInCourt + 1, // nbOfCourts + ]); + } + + const extraData = encodeExtraData(courtId, minJurors, disputeKitId, tokenGate, isERC1155, tokenId); + + const tokenInfo = await context.disputeKit.extraDataToTokenInfo(extraData); + expect(tokenInfo[0]).to.equal(tokenGate); + expect(tokenInfo[1]).to.equal(isERC1155); + expect(tokenInfo[2]).to.equal(tokenId); + + const arbitrationCost = await context.core["arbitrationCost(bytes)"](extraData); + + // Warning: this dispute cannot be executed, in reality it should be created by an arbitrable contract, not an EOA. + const tx = await context.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 context.sortitionModule.passPhase().then((tx) => tx.wait()); // Staking -> Generating + + await context.sortitionModule.passPhase().then((tx) => tx.wait()); // Generating -> Drawing + return context.core.draw(disputeId, 70, { gasLimit: 10000000 }); +}; + +// Setup function that creates the test context +export async function setupTokenGatedTest(config: TokenGatedTestConfig): Promise { + const { deployer } = await getNamedAccounts(); + const [, juror1, juror2] = await ethers.getSigners(); + + await deployments.fixture(["Arbitration", "VeaMock"], { + fallbackToGlobal: true, + keepExistingDeployments: false, + }); + + const pnk = await ethers.getContract("PNK"); + const dai = await ethers.getContract("DAI"); + const weth = await ethers.getContract("WETH"); + const core = await ethers.getContract("KlerosCore"); + const sortitionModule = await ethers.getContract("SortitionModule"); + + const deploymentResult = await deployUpgradable(deployments, config.contractName, { + from: deployer, + proxyAlias: "UUPSProxy", + args: [deployer, core.target, weth.target, 1], + log: true, + }); + await core.addNewDisputeKit(deploymentResult.address); + const gatedDisputeKitID = Number((await core.getDisputeKitsLength()) - 1n); + await core.enableDisputeKits(Courts.GENERAL, [gatedDisputeKitID], true); + + const disputeKit = await ethers.getContract(config.contractName); + + // Make the tests more deterministic with this dummy RNG + await deployments.deploy("IncrementalNG", { + from: deployer, + args: [424242n], + log: true, + }); + const rng = await ethers.getContract("IncrementalNG"); + + await sortitionModule.changeRandomNumberGenerator(rng.target).then((tx) => tx.wait()); + + const hre = require("hardhat"); + await deployERC721(hre, deployer, "TestERC721", "Nft721"); + const nft721 = await ethers.getContract("Nft721"); + + await deployERC1155(hre, deployer, "TestERC1155", "Nft1155"); + const nft1155 = await ethers.getContract("Nft1155"); + const TOKEN_ID = 888; + await nft1155.mint(deployer, TOKEN_ID, 1, "0x00"); + + const context: TokenGatedTestContext = { + deployer, + juror1, + juror2, + disputeKit, + pnk, + dai, + core, + sortitionModule, + rng, + nft721, + nft1155, + gatedDisputeKitID, + minStake: PNK_AMOUNT(200), + RANDOM: 424242n, + TOKEN_ID, + ONE_THOUSAND_PNK, + thousandPNK, + PNK: PNK_AMOUNT, + }; + + // Whitelist all tokens by default + await whitelistTokens(context, [dai.target, nft721.target, nft1155.target], true); + + return context; +} + +// Test suites as functions that accept context + +export function testTokenWhitelistManagement(context: () => TokenGatedTestContext) { + describe("Token Whitelist Management", async () => { + describe("changeSupportedTokens function", async () => { + it("Should allow owner to whitelist single token", async () => { + const ctx = context(); + await whitelistTokens(ctx, [ctx.dai.target], true); + await expectTokenSupported(ctx, ctx.dai.target, true); + }); + + it("Should allow owner to whitelist multiple tokens", async () => { + const ctx = context(); + await whitelistTokens(ctx, [ctx.dai.target, ctx.nft721.target, ctx.nft1155.target], true); + await expectTokenSupported(ctx, ctx.dai.target, true); + await expectTokenSupported(ctx, ctx.nft721.target, true); + await expectTokenSupported(ctx, ctx.nft1155.target, true); + }); + + it("Should allow owner to remove single token from whitelist", async () => { + const ctx = context(); + await whitelistTokens(ctx, [ctx.dai.target], true); + await expectTokenSupported(ctx, ctx.dai.target, true); + + await whitelistTokens(ctx, [ctx.dai.target], false); + await expectTokenSupported(ctx, ctx.dai.target, false); + }); + + it("Should allow owner to remove multiple tokens from whitelist", async () => { + const ctx = context(); + await whitelistTokens(ctx, [ctx.dai.target, ctx.nft721.target], true); + await expectTokenSupported(ctx, ctx.dai.target, true); + await expectTokenSupported(ctx, ctx.nft721.target, true); + + await whitelistTokens(ctx, [ctx.dai.target, ctx.nft721.target], false); + await expectTokenSupported(ctx, ctx.dai.target, false); + await expectTokenSupported(ctx, ctx.nft721.target, false); + }); + + it("Should handle mixed operations (add some, remove some)", async () => { + const ctx = context(); + await whitelistTokens(ctx, [ctx.dai.target, ctx.nft721.target], true); + await expectTokenSupported(ctx, ctx.dai.target, true); + await expectTokenSupported(ctx, ctx.nft721.target, true); + + await whitelistTokens(ctx, [ctx.dai.target], false); + await whitelistTokens(ctx, [ctx.nft1155.target], true); + + await expectTokenSupported(ctx, ctx.dai.target, false); + await expectTokenSupported(ctx, ctx.nft721.target, true); + await expectTokenSupported(ctx, ctx.nft1155.target, true); + }); + + it("Should handle duplicate operations correctly", async () => { + const ctx = context(); + // Whitelist token twice - should not revert + await whitelistTokens(ctx, [ctx.dai.target], true); + await whitelistTokens(ctx, [ctx.dai.target], true); + await expectTokenSupported(ctx, ctx.dai.target, true); + + // Remove token twice - should not revert + await whitelistTokens(ctx, [ctx.dai.target], false); + await whitelistTokens(ctx, [ctx.dai.target], false); + await expectTokenSupported(ctx, ctx.dai.target, false); + }); + }); + }); +} + +export function testAccessControl(context: () => TokenGatedTestContext) { + describe("Access Control", async () => { + it("Should revert when non-owner tries to change supported tokens", async () => { + const ctx = context(); + await expect(ctx.disputeKit.connect(ctx.juror1).changeSupportedTokens([ctx.dai.target], true)).to.be.reverted; + }); + + it("Should revert when non-owner tries to remove supported tokens", async () => { + const ctx = context(); + // First whitelist as owner + await whitelistTokens(ctx, [ctx.dai.target], true); + + // Then try to remove as non-owner + await expect(ctx.disputeKit.connect(ctx.juror1).changeSupportedTokens([ctx.dai.target], false)).to.be.reverted; + }); + }); +} + +export function testUnsupportedTokenErrors(context: () => TokenGatedTestContext) { + describe("Error Handling - Unsupported Tokens", async () => { + it("Should revert with TokenNotSupported when creating dispute with unsupported ERC20", async () => { + const ctx = context(); + await whitelistTokens(ctx, [ctx.dai.target], false); + + await expect(createDisputeWithToken(ctx, ctx.dai.target)) + .to.be.revertedWithCustomError(ctx.disputeKit, "TokenNotSupported") + .withArgs(ctx.dai.target); + }); + + it("Should revert with TokenNotSupported when creating dispute with unsupported ERC721", async () => { + const ctx = context(); + await whitelistTokens(ctx, [ctx.nft721.target], false); + + await expect(createDisputeWithToken(ctx, ctx.nft721.target)) + .to.be.revertedWithCustomError(ctx.disputeKit, "TokenNotSupported") + .withArgs(ctx.nft721.target); + }); + + it("Should revert with TokenNotSupported when creating dispute with unsupported ERC1155", async () => { + const ctx = context(); + await whitelistTokens(ctx, [ctx.nft1155.target], false); + + await expect(createDisputeWithToken(ctx, ctx.nft1155.target, true, ctx.TOKEN_ID)) + .to.be.revertedWithCustomError(ctx.disputeKit, "TokenNotSupported") + .withArgs(ctx.nft1155.target); + }); + + it("Should allow dispute creation after token is whitelisted", async () => { + const ctx = context(); + await whitelistTokens(ctx, [ctx.dai.target], false); + + await expect(createDisputeWithToken(ctx, ctx.dai.target)).to.be.revertedWithCustomError( + ctx.disputeKit, + "TokenNotSupported" + ); + + await whitelistTokens(ctx, [ctx.dai.target], true); + + await expect(createDisputeWithToken(ctx, ctx.dai.target)).to.not.be.reverted; + }); + }); +} + +export function testERC20Gating(context: () => TokenGatedTestContext) { + describe("When gating with DAI token", async () => { + it("Should draw no juror if they don't have any DAI balance", async () => { + const ctx = context(); + const nbOfJurors = 15n; + const tx = await stakeAndDraw( + ctx, + Courts.GENERAL, + nbOfJurors, + ctx.gatedDisputeKitID, + ctx.dai.target, + false, + 0 + ).then((tx) => tx.wait()); + + // Ensure that no juror is drawn + const drawLogs = + tx?.logs.filter((log: any) => log.fragment?.name === "Draw" && log.address === ctx.core.target) || []; + expect(drawLogs).to.have.length(0); + }); + + it("Should draw only the jurors who have some DAI balance", async () => { + const ctx = context(); + await ctx.dai.transfer(ctx.juror1.address, 1); + + const nbOfJurors = 15n; + const tx = await stakeAndDraw( + ctx, + Courts.GENERAL, + nbOfJurors, + ctx.gatedDisputeKitID, + ctx.dai.target, + false, + 0 + ).then((tx) => tx.wait()); + + // Ensure that only juror1 is drawn + const drawLogs = + tx?.logs.filter((log: any) => log.fragment?.name === "Draw" && log.address === ctx.core.target) || []; + expect(drawLogs).to.have.length(nbOfJurors); + drawLogs.forEach((log: any) => { + expect(log.args[0]).to.equal(ctx.juror1.address); + }); + + // Ensure that juror1 has PNK locked + expect(await ctx.sortitionModule.getJurorBalance(ctx.juror1.address, Courts.GENERAL)).to.deep.equal([ + ctx.thousandPNK(10), // totalStaked + ctx.minStake * nbOfJurors, // totalLocked + ctx.thousandPNK(10), // stakedInCourt + 1, // nbOfCourts + ]); + + // Ensure that juror2 has no PNK locked + expect(await ctx.sortitionModule.getJurorBalance(ctx.juror2.address, Courts.GENERAL)).to.deep.equal([ + ctx.thousandPNK(10), // totalStaked + 0, // totalLocked + ctx.thousandPNK(10), // stakedInCourt + 1, // nbOfCourts + ]); + }); + }); +} + +export function testERC721Gating(context: () => TokenGatedTestContext) { + describe("When gating with ERC721 token", async () => { + it("Should draw no juror if they don't own the ERC721 token", async () => { + const ctx = context(); + const nbOfJurors = 15n; + const tx = await stakeAndDraw( + ctx, + Courts.GENERAL, + nbOfJurors, + ctx.gatedDisputeKitID, + ctx.nft721.target, + false, + 0 + ).then((tx) => tx.wait()); + + // Ensure that no juror is drawn + const drawLogs = + tx?.logs.filter((log: any) => log.fragment?.name === "Draw" && log.address === ctx.core.target) || []; + expect(drawLogs).to.have.length(0); + }); + + it("Should draw only the jurors owning the ERC721 token", async () => { + const ctx = context(); + await ctx.nft721.safeMint(ctx.juror2.address); + + const nbOfJurors = 15n; + const tx = await stakeAndDraw( + ctx, + Courts.GENERAL, + nbOfJurors, + ctx.gatedDisputeKitID, + ctx.nft721.target, + false, + 0 + ).then((tx) => tx.wait()); + + // Ensure that only juror2 is drawn + const drawLogs = + tx?.logs.filter((log: any) => log.fragment?.name === "Draw" && log.address === ctx.core.target) || []; + expect(drawLogs).to.have.length(nbOfJurors); + drawLogs.forEach((log: any) => { + expect(log.args[0]).to.equal(ctx.juror2.address); + }); + + // Ensure that juror1 has no PNK locked + expect(await ctx.sortitionModule.getJurorBalance(ctx.juror1.address, Courts.GENERAL)).to.deep.equal([ + ctx.thousandPNK(10), // totalStaked + 0, // totalLocked + ctx.thousandPNK(10), // stakedInCourt + 1, // nbOfCourts + ]); + + // Ensure that juror2 has PNK locked + expect(await ctx.sortitionModule.getJurorBalance(ctx.juror2.address, Courts.GENERAL)).to.deep.equal([ + ctx.thousandPNK(10), // totalStaked + ctx.minStake * nbOfJurors, // totalLocked + ctx.thousandPNK(10), // stakedInCourt + 1, // nbOfCourts + ]); + }); + }); +} + +export function testERC1155Gating(context: () => TokenGatedTestContext) { + describe("When gating with ERC1155 token", async () => { + it("Should draw no juror if they don't own the ERC1155 token", async () => { + const ctx = context(); + const nbOfJurors = 15n; + const tx = await stakeAndDraw( + ctx, + Courts.GENERAL, + nbOfJurors, + ctx.gatedDisputeKitID, + ctx.nft1155.target, + true, + ctx.TOKEN_ID + ).then((tx) => tx.wait()); + + // Ensure that no juror is drawn + const drawLogs = + tx?.logs.filter((log: any) => log.fragment?.name === "Draw" && log.address === ctx.core.target) || []; + expect(drawLogs).to.have.length(0); + }); + + it("Should draw only the jurors owning the ERC1155 token", async () => { + const ctx = context(); + await ctx.nft1155.mint(ctx.juror2.address, ctx.TOKEN_ID, 1, "0x00"); + + const nbOfJurors = 15n; + const tx = await stakeAndDraw( + ctx, + Courts.GENERAL, + nbOfJurors, + ctx.gatedDisputeKitID, + ctx.nft1155.target, + true, + ctx.TOKEN_ID + ).then((tx) => tx.wait()); + + // Ensure that only juror2 is drawn + const drawLogs = + tx?.logs.filter((log: any) => log.fragment?.name === "Draw" && log.address === ctx.core.target) || []; + expect(drawLogs).to.have.length(nbOfJurors); + drawLogs.forEach((log: any) => { + expect(log.args[0]).to.equal(ctx.juror2.address); + }); + + // Ensure that juror1 has no PNK locked + expect(await ctx.sortitionModule.getJurorBalance(ctx.juror1.address, Courts.GENERAL)).to.deep.equal([ + ctx.thousandPNK(10), // totalStaked + 0, // totalLocked + ctx.thousandPNK(10), // stakedInCourt + 1, // nbOfCourts + ]); + + // Ensure that juror2 has PNK locked + expect(await ctx.sortitionModule.getJurorBalance(ctx.juror2.address, Courts.GENERAL)).to.deep.equal([ + ctx.thousandPNK(10), // totalStaked + ctx.minStake * nbOfJurors, // totalLocked + ctx.thousandPNK(10), // stakedInCourt + 1, // nbOfCourts + ]); + }); + }); +} + +export function testWhitelistIntegration(context: () => TokenGatedTestContext) { + describe("Whitelist Integration Tests", async () => { + it("Should allow new disputes after whitelisting a token", async () => { + const ctx = context(); + // Whitelist DAI token + await whitelistTokens(ctx, [ctx.dai.target], true); + + // Transfer DAI to juror1 for token gating + await ctx.dai.transfer(ctx.juror1.address, 1); + + const nbOfJurors = 3n; + const tx = await stakeAndDraw( + ctx, + Courts.GENERAL, + nbOfJurors, + ctx.gatedDisputeKitID, + ctx.dai.target, + false, + 0 + ).then((tx) => tx.wait()); + + // Verify dispute was created and juror drawn + const drawLogs = + tx?.logs.filter((log: any) => log.fragment?.name === "Draw" && log.address === ctx.core.target) || []; + expect(drawLogs).to.have.length(nbOfJurors); + }); + + it("Should prevent new disputes after removing token from whitelist", async () => { + const ctx = context(); + await whitelistTokens(ctx, [ctx.dai.target], true); + await ctx.dai.transfer(ctx.juror1.address, 1); + + // Create first dispute (should work) + await expect(createDisputeWithToken(ctx, ctx.dai.target)).to.not.be.reverted; + + // Remove token from whitelist + await whitelistTokens(ctx, [ctx.dai.target], false); + + // Try to create another dispute (should fail) + await expect(createDisputeWithToken(ctx, ctx.dai.target)) + .to.be.revertedWithCustomError(ctx.disputeKit, "TokenNotSupported") + .withArgs(ctx.dai.target); + }); + + it("Should maintain whitelist state correctly across multiple operations", async () => { + const ctx = context(); + const tokens = [ctx.dai.target, ctx.nft721.target, ctx.nft1155.target]; + + // All tokens should already be supported from the main setup + for (const token of tokens) { + await expectTokenSupported(ctx, token, true); + } + + // Remove middle token + await whitelistTokens(ctx, [ctx.nft721.target], false); + await expectTokenSupported(ctx, ctx.dai.target, true); + await expectTokenSupported(ctx, ctx.nft721.target, false); + await expectTokenSupported(ctx, ctx.nft1155.target, true); + + // Re-add middle token + await whitelistTokens(ctx, [ctx.nft721.target], true); + for (const token of tokens) { + await expectTokenSupported(ctx, token, true); + } + }); + }); +} + +export function testNoTokenGateAddress(context: () => TokenGatedTestContext) { + describe("No Token Gate Edge Case (address(0))", async () => { + it("Should verify that address(0) is supported by default", async () => { + const ctx = context(); + await expectTokenSupported(ctx, ethers.ZeroAddress, true); + }); + + it("Should allow dispute creation with address(0) as tokenGate", async () => { + const ctx = context(); + // Create dispute with address(0) as tokenGate - should not revert + await expect(createDisputeWithToken(ctx, ethers.ZeroAddress, false, 0)).to.not.be.reverted; + }); + + it("Should draw all staked jurors when tokenGate is address(0)", async () => { + const ctx = context(); + // Neither juror has any special tokens, but both are staked + const nbOfJurors = 15n; + const tx = await stakeAndDraw( + ctx, + Courts.GENERAL, + nbOfJurors, + ctx.gatedDisputeKitID, + ethers.ZeroAddress, + false, + 0 + ).then((tx) => tx.wait()); + + // Both jurors should be eligible for drawing since there's no token gate + const drawLogs = + tx?.logs.filter((log: any) => log.fragment?.name === "Draw" && log.address === ctx.core.target) || []; + expect(drawLogs.length).to.equal(nbOfJurors); + + // Verify that draws include both jurors (not just one) + const drawnJurors = new Set(drawLogs.map((log: any) => log.args[0])); + expect(drawnJurors.size).to.be.greaterThan(1, "Should draw from multiple jurors"); + }); + + it("Should behave like non-gated dispute kit when tokenGate is address(0)", async () => { + const ctx = context(); + // Verify that with address(0), jurors don't need any token balance + const nbOfJurors = 3n; + + // Ensure jurors have no DAI tokens + expect(await ctx.dai.balanceOf(ctx.juror1.address)).to.equal(0); + expect(await ctx.dai.balanceOf(ctx.juror2.address)).to.equal(0); + + const tx = await stakeAndDraw( + ctx, + Courts.GENERAL, + nbOfJurors, + ctx.gatedDisputeKitID, + ethers.ZeroAddress, + false, + 0 + ).then((tx) => tx.wait()); + + // Jurors should still be drawn despite having no tokens + const drawLogs = + tx?.logs.filter((log: any) => log.fragment?.name === "Draw" && log.address === ctx.core.target) || []; + expect(drawLogs).to.have.length(nbOfJurors); + }); + + it("Should parse address(0) correctly from insufficient extraData", async () => { + const ctx = context(); + // Create extraData that's too short (less than 160 bytes) + // This should return address(0) from _extraDataToTokenInfo + const shortExtraData = ethers.AbiCoder.defaultAbiCoder().encode( + ["uint256", "uint256", "uint256"], + [Courts.GENERAL, 3, ctx.gatedDisputeKitID] + ); + + const tokenInfo = await ctx.disputeKit.extraDataToTokenInfo(shortExtraData); + expect(tokenInfo[0]).to.equal(ethers.ZeroAddress); + expect(tokenInfo[1]).to.equal(false); + expect(tokenInfo[2]).to.equal(0); + }); + }); +} diff --git a/contracts/test/arbitration/helpers/dispute-kit-shutter-common.ts b/contracts/test/arbitration/helpers/dispute-kit-shutter-common.ts new file mode 100644 index 000000000..5bb98e204 --- /dev/null +++ b/contracts/test/arbitration/helpers/dispute-kit-shutter-common.ts @@ -0,0 +1,880 @@ +import { deployments, ethers, getNamedAccounts, network } from "hardhat"; +import { toBigInt, BigNumberish } from "ethers"; +import { + PNK, + KlerosCore, + SortitionModule, + IncrementalNG, + DisputeKitShutter, + DisputeKitGatedShutterMock, + TestERC20, +} from "../../../typechain-types"; +import { expect } from "chai"; +import { Courts } from "../../../deploy/utils"; +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; +import { deployUpgradable } from "../../../deploy/utils/deployUpgradable"; +import { encodeExtraData as encodeGatedExtraData } from "./dispute-kit-gated-common"; + +/* eslint-disable no-unused-vars */ +/* eslint-disable no-unused-expressions */ + +// Type for the dispute kit (either DisputeKitShutter or DisputeKitGatedShutter) +export type DisputeKitShutterType = DisputeKitShutter | DisputeKitGatedShutterMock; + +// Test context interface that holds all the test state +export interface ShutterTestContext { + deployer: string; + juror1: HardhatEthersSigner; + juror2: HardhatEthersSigner; + bot: HardhatEthersSigner; + attacker: HardhatEthersSigner; + disputeKit: DisputeKitShutterType; + pnk: PNK; + core: KlerosCore; + sortitionModule: SortitionModule; + rng: IncrementalNG; + shutterDKID: number; + shutterCourtID: number; + RANDOM: bigint; + ONE_THOUSAND_PNK: bigint; + thousandPNK: (amount: BigNumberish) => bigint; + // Shutter test data + choice: bigint; + salt: bigint; + justification: string; + identity: string; + encryptedVote: Uint8Array; + // Token gating support (optional) + dai?: TestERC20; + isGated?: boolean; +} + +// Configuration for setting up a Shutter test +export interface ShutterTestConfig { + contractName: string; // "DisputeKitShutter" or "DisputeKitGatedShutter" + isGated?: boolean; // Whether to setup token gating +} + +// Constants +export const enum Period { + evidence = 0, + commit = 1, + vote = 2, + appeal = 3, + execution = 4, +} + +const ONE_THOUSAND_PNK = 10n ** 21n; +const thousandPNK = (amount: BigNumberish) => toBigInt(amount) * ONE_THOUSAND_PNK; + +// Helper function to encode extra data for dispute creation +export const encodeExtraData = (courtId: BigNumberish, minJurors: BigNumberish, disputeKitId: number) => + ethers.AbiCoder.defaultAbiCoder().encode(["uint256", "uint256", "uint256"], [courtId, minJurors, disputeKitId]); + +// Helper function to generate full and recovery commitments +export 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 }; +}; + +// Helper to create dispute and draw jurors +export const createDisputeAndDraw = async ( + context: ShutterTestContext, + courtId: BigNumberish, + minJurors: BigNumberish, + disputeKitId: number +) => { + // Stake jurors + for (const juror of [context.juror1, context.juror2]) { + await context.pnk.transfer(juror.address, context.thousandPNK(10)).then((tx) => tx.wait()); + expect(await context.pnk.balanceOf(juror.address)).to.equal(context.thousandPNK(10)); + + await context.pnk + .connect(juror) + .approve(context.core.target, context.thousandPNK(10), { gasLimit: 300000 }) + .then((tx) => tx.wait()); + + await context.core + .connect(juror) + .setStake(context.shutterCourtID, context.thousandPNK(10), { gasLimit: 500000 }) + .then((tx) => tx.wait()); + + expect(await context.sortitionModule.getJurorBalance(juror.address, context.shutterCourtID)).to.deep.equal([ + context.thousandPNK(10), // totalStaked + 0, // totalLocked + context.thousandPNK(10), // stakedInCourt + 1, // nbOfCourts + ]); + + // If gated, give tokens to jurors + if (context.isGated && context.dai) { + await context.dai.transfer(juror.address, 1); + } + } + + // Use gated extra data if this is a gated dispute kit with DAI token + let extraData: string; + if (context.isGated && context.dai) { + extraData = encodeGatedExtraData(courtId, minJurors, disputeKitId, context.dai.target, false, 0); + } else { + extraData = encodeExtraData(courtId, minJurors, disputeKitId); + } + + const arbitrationCost = await context.core["arbitrationCost(bytes)"](extraData); + + // Create dispute via core contract + await context.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 context.sortitionModule.passPhase().then((tx) => tx.wait()); // Staking -> Generating + + await context.sortitionModule.passPhase().then((tx) => tx.wait()); // Generating -> Drawing + await context.core.draw(disputeId, 70, { gasLimit: 10000000 }); + + return disputeId; +}; + +// Helper to advance to commit period +export const advanceToCommitPeriod = async (context: ShutterTestContext, disputeId: number) => { + // Advance from evidence to commit period + await context.core.passPeriod(disputeId).then((tx) => tx.wait()); + + // Verify we're in commit period + const dispute = await context.core.disputes(disputeId); + expect(dispute[2]).to.equal(Period.commit); // period is at index 2 +}; + +// Helper to advance to vote period +export const advanceToVotePeriod = async (context: ShutterTestContext, disputeId: number) => { + // Advance from commit to vote period + 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 context.core.passPeriod(disputeId).then((tx) => tx.wait()); + + // Verify we're in vote period + const updatedDispute = await context.core.disputes(disputeId); + expect(updatedDispute[2]).to.equal(Period.vote); // period is at index 2 +}; + +// Helper to get vote IDs for a juror +export const getVoteIDsForJuror = async ( + context: ShutterTestContext, + disputeId: number, + juror: HardhatEthersSigner +) => { + const localDisputeId = await context.disputeKit.coreDisputeIDToLocal(disputeId); + const nbRounds = await context.disputeKit.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 context.disputeKit.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; +}; + +// Setup function that creates the test context +export async function setupShutterTest(config: ShutterTestConfig): Promise { + const { deployer } = await getNamedAccounts(); + const [, juror1, juror2, bot, attacker] = await ethers.getSigners(); + + await deployments.fixture(["Arbitration", "VeaMock"], { + fallbackToGlobal: true, + keepExistingDeployments: false, + }); + + const pnk = await ethers.getContract("PNK"); + const core = await ethers.getContract("KlerosCore"); + const sortitionModule = await ethers.getContract("SortitionModule"); + + let disputeKit: DisputeKitShutterType; + let shutterDKID: number; + let shutterCourtID: number; + let dai: TestERC20 | undefined; + + if (config.contractName === "DisputeKitShutter") { + disputeKit = await ethers.getContract("DisputeKitShutter"); + shutterDKID = 2; + shutterCourtID = 2; // Court with hidden votes + + // Create a court with hidden votes enabled for testing DisputeKitShutter + 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, shutterDKID] // supportedDisputeKits - must include Classic (1) and Shutter (2) + ); + } else if (config.contractName === "DisputeKitGatedShutter") { + // For gated shutter, we need to deploy it if not already deployed + const weth = await ethers.getContract("WETH"); + dai = await ethers.getContract("DAI"); + + const deploymentResult = await deployUpgradable(deployments, "DisputeKitGatedShutterMock", { + from: deployer, + proxyAlias: "UUPSProxy", + args: [deployer, core.target, weth.target, 1], + log: true, + }); + await core.addNewDisputeKit(deploymentResult.address); + shutterDKID = Number((await core.getDisputeKitsLength()) - 1n); + + // For gated shutter, we use the General Court but with hidden votes enabled + shutterCourtID = Courts.GENERAL; + + // Enable hidden votes on the General Court + await core.changeCourtParameters( + Courts.GENERAL, + true, // hiddenVotes + ethers.parseEther("200"), // minStake + 10000, // alpha + ethers.parseEther("0.1"), // feeForJuror + 16, // jurorsForCourtJump + [300, 300, 300, 300] // timesPerPeriod + ); + + await core.enableDisputeKits(Courts.GENERAL, [shutterDKID], true); + + disputeKit = await ethers.getContract("DisputeKitGatedShutterMock"); + + // If gated, whitelist DAI token + if (config.isGated) { + const gatedKit = disputeKit as DisputeKitGatedShutterMock; + await gatedKit.changeSupportedTokens([dai.target], true); + } + } else { + throw new Error(`Unknown contract name: ${config.contractName}`); + } + + // Make the tests more deterministic with this dummy RNG + const RANDOM = 424242n; + await deployments.deploy("IncrementalNG", { + from: deployer, + args: [RANDOM], + log: true, + }); + const rng = await ethers.getContract("IncrementalNG"); + await sortitionModule.changeRandomNumberGenerator(rng.target).then((tx) => tx.wait()); + + // 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"); + + const context: ShutterTestContext = { + deployer, + juror1, + juror2, + bot, + attacker, + disputeKit, + pnk, + core, + sortitionModule, + rng, + shutterDKID, + shutterCourtID, + RANDOM, + ONE_THOUSAND_PNK, + thousandPNK, + choice, + salt, + justification, + identity, + encryptedVote, + dai, + isGated: config.isGated, + }; + + return context; +} + +// Test suites as functions that accept context + +export function testCommitPhase(context: () => ShutterTestContext) { + describe("Commit Phase - castCommitShutter()", () => { + describe("Successful commits", () => { + it("Should allow juror to commit vote with recovery commitment", async () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDs = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + expect(voteIDs.length).to.be.greaterThan(0); + + const { fullCommit, recoveryCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + + await expect( + ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, ctx.identity, ctx.encryptedVote) + ) + .to.emit(ctx.disputeKit, "CommitCastShutter") + .withArgs(disputeId, ctx.juror1.address, fullCommit, recoveryCommit, ctx.identity, ctx.encryptedVote); + + // Verify recovery commitment was stored + const localDisputeId = await ctx.disputeKit.coreDisputeIDToLocal(disputeId); + const storedRecoveryCommit = await ctx.disputeKit.recoveryCommitments(localDisputeId, 0, voteIDs[0]); + expect(storedRecoveryCommit).to.equal(recoveryCommit); + }); + + it("Should allow juror to update commitment multiple times", async () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDs = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + + // First commitment + const { fullCommit: commit1, recoveryCommit: recovery1 } = generateCommitments(1n, 111n, "First justification"); + await ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDs, commit1, recovery1, ctx.identity, ctx.encryptedVote); + + // Second commitment (overwrites first) + const { fullCommit: commit2, recoveryCommit: recovery2 } = generateCommitments( + 2n, + 222n, + "Second justification" + ); + await ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDs, commit2, recovery2, ctx.identity, ctx.encryptedVote); + + // Verify only the second commitment is stored + const localDisputeId = await ctx.disputeKit.coreDisputeIDToLocal(disputeId); + const storedRecoveryCommit = await ctx.disputeKit.recoveryCommitments(localDisputeId, 0, voteIDs[0]); + expect(storedRecoveryCommit).to.equal(recovery2); + }); + }); + + describe("Failed commits", () => { + it("Should revert if recovery commitment is empty", async () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDs = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + const { fullCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + + await expect( + ctx.disputeKit.connect(ctx.juror1).castCommitShutter( + disputeId, + voteIDs, + fullCommit, + ethers.ZeroHash, // Empty recovery commit + ctx.identity, + ctx.encryptedVote + ) + ).to.be.revertedWithCustomError(ctx.disputeKit, "EmptyRecoveryCommit"); + }); + + it("Should revert if not in commit period", async () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + // Still in evidence period + + const voteIDs = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + const { fullCommit, recoveryCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + + await expect( + ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, ctx.identity, ctx.encryptedVote) + ).to.be.revertedWithCustomError(ctx.disputeKit, "NotCommitPeriod"); + }); + + it("Should revert if juror doesn't own the vote", async () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDs = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + const { fullCommit, recoveryCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + + await expect( + ctx.disputeKit.connect(ctx.juror2).castCommitShutter( + disputeId, + voteIDs, // Using juror1's vote IDs + fullCommit, + recoveryCommit, + ctx.identity, + ctx.encryptedVote + ) + ).to.be.revertedWithCustomError(ctx.disputeKit, "JurorHasToOwnTheVote"); + }); + }); + }); +} + +export function testNormalFlowBotReveals(context: () => ShutterTestContext) { + describe("Normal Flow - Bot Reveals", () => { + describe("Successful reveals", () => { + it("Should allow bot to reveal vote with full justification", async () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDs = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + const { fullCommit, recoveryCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + + // Juror commits + await ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, ctx.identity, ctx.encryptedVote); + + await advanceToVotePeriod(ctx, disputeId); + + // Bot reveals vote + await expect( + ctx.disputeKit.connect(ctx.bot).castVoteShutter(disputeId, voteIDs, ctx.choice, ctx.salt, ctx.justification) + ) + .to.emit(ctx.disputeKit, "VoteCast") + .withArgs(disputeId, ctx.juror1.address, voteIDs, ctx.choice, ctx.justification); + + // Verify vote was counted + const voteInfo = await ctx.disputeKit.getVoteInfo(disputeId, 0, Number(voteIDs[0])); + expect(voteInfo[3]).to.be.true; // voted is at index 3 + expect(voteInfo[2]).to.equal(ctx.choice); // choice is at index 2 + }); + }); + + describe("Failed reveals", () => { + it("Should revert if wrong choice provided", async () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDs = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + const { fullCommit, recoveryCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + + await ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, ctx.identity, ctx.encryptedVote); + + await advanceToVotePeriod(ctx, disputeId); + + const wrongChoice = 2n; + await expect( + ctx.disputeKit.connect(ctx.bot).castVoteShutter( + disputeId, + voteIDs, + wrongChoice, // Wrong choice + ctx.salt, + ctx.justification + ) + ).to.be.revertedWithCustomError(ctx.disputeKit, "HashDoesNotMatchHiddenVoteCommitment"); + }); + + it("Should revert if wrong salt provided", async () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDs = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + const { fullCommit, recoveryCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + + await ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, ctx.identity, ctx.encryptedVote); + + await advanceToVotePeriod(ctx, disputeId); + + const wrongSalt = 99999n; + await expect( + ctx.disputeKit.connect(ctx.bot).castVoteShutter( + disputeId, + voteIDs, + ctx.choice, + wrongSalt, // Wrong salt + ctx.justification + ) + ).to.be.revertedWithCustomError(ctx.disputeKit, "HashDoesNotMatchHiddenVoteCommitment"); + }); + + it("Should revert if wrong justification provided", async () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDs = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + const { fullCommit, recoveryCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + + await ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, ctx.identity, ctx.encryptedVote); + + await advanceToVotePeriod(ctx, disputeId); + + const wrongJustification = "Wrong justification"; + await expect( + ctx.disputeKit.connect(ctx.bot).castVoteShutter( + disputeId, + voteIDs, + ctx.choice, + ctx.salt, + wrongJustification // Wrong justification + ) + ).to.be.revertedWithCustomError(ctx.disputeKit, "HashDoesNotMatchHiddenVoteCommitment"); + }); + + it("Should revert if vote already cast", async () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDs = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + const { fullCommit, recoveryCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + + await ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, ctx.identity, ctx.encryptedVote); + + await advanceToVotePeriod(ctx, disputeId); + + // First vote succeeds + await ctx.disputeKit + .connect(ctx.bot) + .castVoteShutter(disputeId, voteIDs, ctx.choice, ctx.salt, ctx.justification); + + // Second vote fails + await expect( + ctx.disputeKit.connect(ctx.bot).castVoteShutter(disputeId, voteIDs, ctx.choice, ctx.salt, ctx.justification) + ).to.be.revertedWithCustomError(ctx.disputeKit, "VoteAlreadyCast"); + }); + }); + }); +} + +export function testRecoveryFlowJurorReveals(context: () => ShutterTestContext) { + describe("Recovery Flow - Juror Reveals", () => { + describe("Successful recovery reveals", () => { + it("Should allow juror to recover vote without justification", async () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDs = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + const { fullCommit, recoveryCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + + // Juror commits + await ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, ctx.identity, ctx.encryptedVote); + + await advanceToVotePeriod(ctx, disputeId); + + // Juror reveals vote (Shutter failed, so juror must reveal) + // Note: justification can be anything as it won't be validated + await expect( + ctx.disputeKit.connect(ctx.juror1).castVoteShutter( + disputeId, + voteIDs, + ctx.choice, + ctx.salt, + "" // Empty justification is fine for recovery + ) + ) + .to.emit(ctx.disputeKit, "VoteCast") + .withArgs(disputeId, ctx.juror1.address, voteIDs, ctx.choice, ""); + + // Verify vote was counted + const voteInfo = await ctx.disputeKit.getVoteInfo(disputeId, 0, Number(voteIDs[0])); + expect(voteInfo[3]).to.be.true; // voted is at index 3 + expect(voteInfo[2]).to.equal(ctx.choice); // choice is at index 2 + }); + + it("Should validate against recovery commitment when juror reveals", async () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDs = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + const { fullCommit, recoveryCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + + await ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, ctx.identity, ctx.encryptedVote); + + await advanceToVotePeriod(ctx, 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( + ctx.disputeKit.connect(ctx.juror1).castVoteShutter( + disputeId, + voteIDs, + ctx.choice, + ctx.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 () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDs = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + const { fullCommit, recoveryCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + + await ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, ctx.identity, ctx.encryptedVote); + + await advanceToVotePeriod(ctx, disputeId); + + const wrongChoice = 2n; + await expect( + ctx.disputeKit.connect(ctx.juror1).castVoteShutter( + disputeId, + voteIDs, + wrongChoice, // Wrong choice + ctx.salt, + "" + ) + ).to.be.revertedWithCustomError(ctx.disputeKit, "HashDoesNotMatchHiddenVoteCommitment"); + }); + + it("Should revert if wrong salt in recovery", async () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDs = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + const { fullCommit, recoveryCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + + await ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, ctx.identity, ctx.encryptedVote); + + await advanceToVotePeriod(ctx, disputeId); + + const wrongSalt = 99999n; + await expect( + ctx.disputeKit.connect(ctx.juror1).castVoteShutter( + disputeId, + voteIDs, + ctx.choice, + wrongSalt, // Wrong salt + "" + ) + ).to.be.revertedWithCustomError(ctx.disputeKit, "HashDoesNotMatchHiddenVoteCommitment"); + }); + + it("Should revert if non-juror tries to reveal without correct full commitment", async () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDs = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + const { fullCommit, recoveryCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + + await ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDs, fullCommit, recoveryCommit, ctx.identity, ctx.encryptedVote); + + await advanceToVotePeriod(ctx, disputeId); + + // Attacker tries to reveal with only choice and salt (no justification) + await expect( + ctx.disputeKit.connect(ctx.attacker).castVoteShutter( + disputeId, + voteIDs, + ctx.choice, + ctx.salt, + "" // No justification - would work for juror but not for others + ) + ).to.be.revertedWithCustomError(ctx.disputeKit, "HashDoesNotMatchHiddenVoteCommitment"); + }); + }); + }); +} + +export function testHashFunctionBehavior(context: () => ShutterTestContext) { + describe("Hash Function Behavior", () => { + it("Should compute different hashes for juror recovery vs non-juror normal flow", async () => { + const ctx = context(); + + // Test 1: Verify hashVote matches generateCommitments for non-juror case + const { fullCommit, recoveryCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + const nonJurorHash = await ctx.disputeKit.hashVote(ctx.choice, ctx.salt, ctx.justification); + expect(nonJurorHash).to.equal(fullCommit, "Non-juror hash should match full commitment"); + + // Test 2: Verify the two commitment types are different + expect(fullCommit).to.not.equal(recoveryCommit, "Full and recovery commitments should differ"); + + // Test 3: Calculate what the juror hash would be and verify it matches recovery commitment + const jurorExpectedHash = ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode(["uint256", "uint256"], [ctx.choice, ctx.salt]) + ); + expect(jurorExpectedHash).to.equal(recoveryCommit, "Juror hash calculation should match recovery commitment"); + + // Test 4: Verify that changing justification affects non-juror hash but not juror hash + const differentJustification = "Different justification"; + const { fullCommit: newFullCommit } = generateCommitments(ctx.choice, ctx.salt, differentJustification); + const newNonJurorHash = await ctx.disputeKit.hashVote(ctx.choice, ctx.salt, differentJustification); + + expect(newNonJurorHash).to.equal(newFullCommit, "New non-juror hash should match new full commitment"); + expect(newNonJurorHash).to.not.equal(nonJurorHash, "Non-juror hash should change with justification"); + // Note: juror hash would remain the same (recoveryCommit) regardless of justification + }); + + it("Should correctly compute hash for normal flow", async () => { + const ctx = context(); + // Test hashVote function directly + const justificationHash = ethers.keccak256(ethers.toUtf8Bytes(ctx.justification)); + const expectedHash = ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode( + ["uint256", "uint256", "bytes32"], + [ctx.choice, ctx.salt, justificationHash] + ) + ); + + // When called by non-juror (normal case), should include justification + const computedHash = await ctx.disputeKit.hashVote(ctx.choice, ctx.salt, ctx.justification); + expect(computedHash).to.equal(expectedHash); + }); + }); +} + +export function testEdgeCasesAndSecurity(context: () => ShutterTestContext) { + describe("Edge Cases and Security", () => { + it("Should handle mixed normal and recovery reveals in same dispute", async () => { + const ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDsJuror1 = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + const voteIDsJuror2 = await getVoteIDsForJuror(ctx, disputeId, ctx.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 ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDsJuror1, commit1, recovery1, ctx.identity, ctx.encryptedVote); + + await ctx.disputeKit + .connect(ctx.juror2) + .castCommitShutter(disputeId, voteIDsJuror2, commit2, recovery2, ctx.identity, ctx.encryptedVote); + + await advanceToVotePeriod(ctx, disputeId); + + // Juror1 uses recovery flow (Shutter failed for them) + await ctx.disputeKit.connect(ctx.juror1).castVoteShutter( + disputeId, + voteIDsJuror1, + 1n, + 111n, + "Different justification" // Recovery doesn't check this + ); + + // Bot reveals juror2's vote normally + await ctx.disputeKit.connect(ctx.bot).castVoteShutter( + disputeId, + voteIDsJuror2, + 2n, + 222n, + "Juror 2 justification" // Must match exactly + ); + + // Verify both votes were counted + const vote1Info = await ctx.disputeKit.getVoteInfo(disputeId, 0, Number(voteIDsJuror1[0])); + const vote2Info = await ctx.disputeKit.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 ctx = context(); + const disputeId = await createDisputeAndDraw(ctx, ctx.shutterCourtID, 3, ctx.shutterDKID); + await advanceToCommitPeriod(ctx, disputeId); + + const voteIDsJuror1 = await getVoteIDsForJuror(ctx, disputeId, ctx.juror1); + const { fullCommit, recoveryCommit } = generateCommitments(ctx.choice, ctx.salt, ctx.justification); + + await ctx.disputeKit + .connect(ctx.juror1) + .castCommitShutter(disputeId, voteIDsJuror1, fullCommit, recoveryCommit, ctx.identity, ctx.encryptedVote); + + // Juror2 commits with a different choice + const differentChoice = 2n; + const voteIDsJuror2 = await getVoteIDsForJuror(ctx, disputeId, ctx.juror2); + const { fullCommit: commit2, recoveryCommit: recovery2 } = generateCommitments( + differentChoice, + ctx.salt, + ctx.justification + ); + + await ctx.disputeKit + .connect(ctx.juror2) + .castCommitShutter(disputeId, voteIDsJuror2, commit2, recovery2, ctx.identity, ctx.encryptedVote); + + await advanceToVotePeriod(ctx, 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( + ctx.disputeKit + .connect(ctx.attacker) + .castVoteShutter(disputeId, voteIDsJuror1, ctx.choice, ctx.salt, ctx.justification) + ) + .to.emit(ctx.disputeKit, "VoteCast") + .withArgs(disputeId, ctx.juror1.address, voteIDsJuror1, ctx.choice, ctx.justification); + + // Attacker cannot change juror2's vote to a different choice + await expect( + ctx.disputeKit.connect(ctx.attacker).castVoteShutter( + disputeId, + voteIDsJuror2, + 1n, // Wrong choice + ctx.salt, + ctx.justification + ) + ).to.be.revertedWithCustomError(ctx.disputeKit, "HashDoesNotMatchHiddenVoteCommitment"); + }); + }); +}