diff --git a/contracts/ExpertCommitteeArbitrator.sol b/contracts/ExpertCommitteeArbitrator.sol new file mode 100644 index 00000000..1c44899b --- /dev/null +++ b/contracts/ExpertCommitteeArbitrator.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// Disclaimer https://github.com/hats-finance/hats-contracts/blob/main/DISCLAIMER.md + +pragma solidity 0.8.16; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "./interfaces/IHATClaimsManager.sol"; + +contract ExpertCommitteeArbitrator is Ownable { + address public expertCommittee; + + error OnlyExpertCommittee(); + + modifier onlyExpertCommittee() { + if (msg.sender != expertCommittee) { + revert OnlyExpertCommittee(); + } + _; + } + + constructor(address _expertCommittee) { + expertCommittee = _expertCommittee; + } + + function setExpertCommittee(address _expertCommittee) external onlyOwner { + expertCommittee = _expertCommittee; + } + + function challengeClaim(IHATClaimsManager _vault, bytes32 _claimId, uint16 _bountyPercentage, address _beneficiary) external onlyExpertCommittee { + _vault.challengeClaim(_claimId, _bountyPercentage, _beneficiary); + } + + function approveClaim(IHATClaimsManager _vault, bytes32 _claimId) external onlyOwner { + _vault.approveClaim(_claimId, 0, address(0)); + } + + function dismissClaim(IHATClaimsManager _vault, bytes32 _claimId) external onlyOwner { + _vault.dismissClaim(_claimId); + } +} diff --git a/contracts/HATClaimsManager.sol b/contracts/HATClaimsManager.sol index 02dc7276..6eb5a795 100644 --- a/contracts/HATClaimsManager.sol +++ b/contracts/HATClaimsManager.sol @@ -82,6 +82,7 @@ contract HATClaimsManager is IHATClaimsManager, OwnableUpgradeable, ReentrancyGu bool public arbitratorCanSubmitClaims; // Can the committee revoke the token lock bool public isTokenLockRevocable; + mapping(bytes32 => ArbitratorChangeProposal) public arbitratorChangeProposals; modifier onlyRegistryOwner() { if (registry.owner() != msg.sender) revert OnlyRegistryOwner(); @@ -196,7 +197,16 @@ contract HATClaimsManager is IHATClaimsManager, OwnableUpgradeable, ReentrancyGu ); } - function challengeClaim(bytes32 _claimId) external isActiveClaim(_claimId) { + function challengeClaim(bytes32 _claimId, uint16 _bountyPercentage, address _beneficiary) external { + Claim memory _claim = activeClaim; + arbitratorChangeProposals[_claimId] = ArbitratorChangeProposal({ + beneficiary: _claim.arbitratorCanChangeBeneficiary ? _beneficiary : address(0), + bountyPercentage: _claim.arbitratorCanChangeBounty ? _bountyPercentage : 0 + }); + challengeClaim(_claimId); + } + + function challengeClaim(bytes32 _claimId) public isActiveClaim(_claimId) { if (msg.sender != activeClaim.arbitrator && msg.sender != registry.owner()) revert OnlyArbitratorOrRegistryOwner(); // solhint-disable-next-line not-rely-on-time @@ -214,28 +224,47 @@ contract HATClaimsManager is IHATClaimsManager, OwnableUpgradeable, ReentrancyGu function approveClaim(bytes32 _claimId, uint16 _bountyPercentage, address _beneficiary) external nonReentrant isActiveClaim(_claimId) { Claim memory _claim = activeClaim; delete activeClaim; - + + ArbitratorChangeProposal memory arbitratorChangeProposal = arbitratorChangeProposals[_claimId]; + bool hasArbitratorProposal = arbitratorChangeProposal.beneficiary != address(0) || arbitratorChangeProposal.bountyPercentage != 0; // solhint-disable-next-line not-rely-on-time if (block.timestamp >= _claim.createdAt + _claim.challengePeriod + _claim.challengeTimeOutPeriod) { - // cannot approve an expired claim - revert ClaimExpired(); + // cannot approve an expired claim unless there's an arbitrator proposal + if (!hasArbitratorProposal) { + revert ClaimExpired(); + } } if (_claim.challengedAt != 0) { - // the claim was challenged, and only the arbitrator can approve it, within the timeout period - if ( - msg.sender != _claim.arbitrator || - // solhint-disable-next-line not-rely-on-time - block.timestamp >= _claim.challengedAt + _claim.challengeTimeOutPeriod - ) + bool isArbitrator = msg.sender == _claim.arbitrator; + bool afterChallengeTimeout = block.timestamp >= _claim.challengedAt + _claim.challengeTimeOutPeriod; + + // the claim was challenged + if (afterChallengeTimeout) { + // after challenge timeout, can only approve if there's an arbitrator proposal + if (!hasArbitratorProposal) { + revert ChallengedClaimCanOnlyBeApprovedByArbitratorUntilChallengeTimeoutPeriod(); + } + } else if (!isArbitrator) { + // during challenge timeout, only arbitrator can approve revert ChallengedClaimCanOnlyBeApprovedByArbitratorUntilChallengeTimeoutPeriod(); + } + // the arbitrator can update the bounty if needed - if (_claim.arbitratorCanChangeBounty && _bountyPercentage != 0) { - _claim.bountyPercentage = _bountyPercentage; + if (_claim.arbitratorCanChangeBounty) { + if (isArbitrator && _bountyPercentage != 0 && !afterChallengeTimeout) { + _claim.bountyPercentage = _bountyPercentage; + } else if (arbitratorChangeProposal.bountyPercentage != 0) { + _claim.bountyPercentage = arbitratorChangeProposal.bountyPercentage; + } } - if (_claim.arbitratorCanChangeBeneficiary && _beneficiary != address(0)) { - _claim.beneficiary = _beneficiary; + if (_claim.arbitratorCanChangeBeneficiary) { + if (isArbitrator && _beneficiary != address(0) && !afterChallengeTimeout) { + _claim.beneficiary = _beneficiary; + } else if (arbitratorChangeProposal.beneficiary != address(0)) { + _claim.beneficiary = arbitratorChangeProposal.beneficiary; + } } } else { // the claim can be approved by anyone if the challengePeriod passed without a challenge @@ -321,19 +350,35 @@ contract HATClaimsManager is IHATClaimsManager, OwnableUpgradeable, ReentrancyGu function dismissClaim(bytes32 _claimId) external isActiveClaim(_claimId) { uint256 _challengeTimeOutPeriod = activeClaim.challengeTimeOutPeriod; uint256 _challengedAt = activeClaim.challengedAt; + ArbitratorChangeProposal memory arbitratorChangeProposal = arbitratorChangeProposals[_claimId]; + bool hasArbitratorProposal = arbitratorChangeProposal.beneficiary != address(0) || arbitratorChangeProposal.bountyPercentage != 0; + // solhint-disable-next-line not-rely-on-time if (block.timestamp <= activeClaim.createdAt + activeClaim.challengePeriod + _challengeTimeOutPeriod) { - if (_challengedAt == 0) revert OnlyCallableIfChallenged(); - if ( - // solhint-disable-next-line not-rely-on-time - block.timestamp <= _challengedAt + _challengeTimeOutPeriod && - msg.sender != activeClaim.arbitrator - ) revert OnlyCallableByArbitratorOrAfterChallengeTimeOutPeriod(); - } // else the claim is expired and should be dismissed + // During total timeout period + if (_challengedAt != 0) { + // Challenged claim + bool isInTimeoutPeriod = block.timestamp <= _challengedAt + _challengeTimeOutPeriod; + bool isArbitrator = msg.sender == activeClaim.arbitrator; + + if (!isArbitrator) { + if (hasArbitratorProposal || isInTimeoutPeriod) { + revert OnlyCallableByArbitratorOrAfterChallengeTimeOutPeriod(); + } + } else if (hasArbitratorProposal && !isInTimeoutPeriod) { + revert CannotDismissArbitratorProposalAfterTimoutPeriodOrIfNotAbitrator(); + } + } else { + // Unchallenged claim cannot be dismissed during timeout period + revert OnlyCallableIfChallenged(); + } + } else if (hasArbitratorProposal) { + // After timeout, if there's an arbitrator proposal it can only be approved + revert CannotDismissArbitratorProposalAfterTimoutPeriodOrIfNotAbitrator(); + } // else expired claim with no arbitrator proposal can be dismissed by anyone + delete activeClaim; - vault.setWithdrawPaused(false); - emit DismissClaim(_claimId); } /* -------------------------------------------------------------------------------- */ diff --git a/contracts/interfaces/IHATClaimsManager.sol b/contracts/interfaces/IHATClaimsManager.sol index 3ea309df..bedb5a5c 100644 --- a/contracts/interfaces/IHATClaimsManager.sol +++ b/contracts/interfaces/IHATClaimsManager.sol @@ -93,6 +93,11 @@ interface IHATClaimsManager { uint32 timestamp; } + struct ArbitratorChangeProposal { + address beneficiary; + uint16 bountyPercentage; + } + /** * @notice Initialization parameters for the vault * @param name The vault's name (concatenated as "Hats Vault " + name) @@ -198,6 +203,8 @@ interface IHATClaimsManager { error CannotSetToPerviousRewardController(); // Payout must either be 100%, or up to the MAX_BOUNTY_LIMIT error PayoutMustBeUpToMaxBountyLimitOrHundredPercent(); + // Cannot dismiss an arbitrator proposal after claim has expired or during timeout period if not arbitrator + error CannotDismissArbitratorProposalAfterTimoutPeriodOrIfNotAbitrator(); event SubmitClaim( @@ -273,10 +280,21 @@ interface IHATClaimsManager { * payout that had been previously submitted by the committee. * Can only be called during the challenge period after submission of the * claim. - * @param _claimId The claim ID */ function challengeClaim(bytes32 _claimId) external; + /** + * @notice Called by the arbitrator or governance to challenge a claim for a bounty + * payout that had been previously submitted by the committee. + * Can only be called during the challenge period after submission of the + * claim. + * @param _claimId The claim ID + * * @param _bountyPercentage The percentage of the vault's balance that will + * be sent as a bounty. + * @param _beneficiary where the bounty will be sent to. + */ + function challengeClaim(bytes32 _claimId, uint16 _bountyPercentage, address _beneficiary) external; + /** * @notice Approve a claim for a bounty submitted by a committee, and * pay out bounty to hacker and committee. Also transfer to the diff --git a/docs/dodoc/ExpertCommitteeArbitrator.md b/docs/dodoc/ExpertCommitteeArbitrator.md new file mode 100644 index 00000000..1409b7cb --- /dev/null +++ b/docs/dodoc/ExpertCommitteeArbitrator.md @@ -0,0 +1,179 @@ +# ExpertCommitteeArbitrator + + + + + + + + + +## Methods + +### approveClaim + +```solidity +function approveClaim(contract IHATClaimsManager _vault, bytes32 _claimId) external nonpayable +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _vault | contract IHATClaimsManager | undefined | +| _claimId | bytes32 | undefined | + +### challengeClaim + +```solidity +function challengeClaim(contract IHATClaimsManager _vault, bytes32 _claimId, uint16 _bountyPercentage, address _beneficiary) external nonpayable +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _vault | contract IHATClaimsManager | undefined | +| _claimId | bytes32 | undefined | +| _bountyPercentage | uint16 | undefined | +| _beneficiary | address | undefined | + +### dismissClaim + +```solidity +function dismissClaim(contract IHATClaimsManager _vault, bytes32 _claimId) external nonpayable +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _vault | contract IHATClaimsManager | undefined | +| _claimId | bytes32 | undefined | + +### expertCommittee + +```solidity +function expertCommittee() external view returns (address) +``` + + + + + + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | address | undefined | + +### owner + +```solidity +function owner() external view returns (address) +``` + + + +*Returns the address of the current owner.* + + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | address | undefined | + +### renounceOwnership + +```solidity +function renounceOwnership() external nonpayable +``` + + + +*Leaves the contract without owner. It will not be possible to call `onlyOwner` functions anymore. Can only be called by the current owner. NOTE: Renouncing ownership will leave the contract without an owner, thereby removing any functionality that is only available to the owner.* + + +### setExpertCommittee + +```solidity +function setExpertCommittee(address _expertCommittee) external nonpayable +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _expertCommittee | address | undefined | + +### transferOwnership + +```solidity +function transferOwnership(address newOwner) external nonpayable +``` + + + +*Transfers ownership of the contract to a new account (`newOwner`). Can only be called by the current owner.* + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| newOwner | address | undefined | + + + +## Events + +### OwnershipTransferred + +```solidity +event OwnershipTransferred(address indexed previousOwner, address indexed newOwner) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| previousOwner `indexed` | address | undefined | +| newOwner `indexed` | address | undefined | + + + +## Errors + +### OnlyExpertCommittee + +```solidity +error OnlyExpertCommittee() +``` + + + + + + + diff --git a/docs/dodoc/HATClaimsManager.md b/docs/dodoc/HATClaimsManager.md index 65de80fa..c1f920d6 100644 --- a/docs/dodoc/HATClaimsManager.md +++ b/docs/dodoc/HATClaimsManager.md @@ -244,6 +244,29 @@ function arbitratorCanSubmitClaims() external view returns (bool) |---|---|---| | _0 | bool | undefined | +### arbitratorChangeProposals + +```solidity +function arbitratorChangeProposals(bytes32) external view returns (address beneficiary, uint16 bountyPercentage) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _0 | bytes32 | undefined | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| beneficiary | address | undefined | +| bountyPercentage | uint16 | undefined | + ### bountySplit ```solidity @@ -273,11 +296,29 @@ Called by the arbitrator or governance to challenge a claim for a bounty payout +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _claimId | bytes32 | undefined | + +### challengeClaim + +```solidity +function challengeClaim(bytes32 _claimId, uint16 _bountyPercentage, address _beneficiary) external nonpayable +``` + +Called by the arbitrator or governance to challenge a claim for a bounty payout that had been previously submitted by the committee. Can only be called during the challenge period after submission of the claim. + + + #### Parameters | Name | Type | Description | |---|---|---| | _claimId | bytes32 | The claim ID | +| _bountyPercentage | uint16 | The percentage of the vault's balance that will be sent as a bounty. | +| _beneficiary | address | where the bounty will be sent to. | ### committee @@ -1136,6 +1177,17 @@ error BountyPercentageHigherThanMaxBounty() +### CannotDismissArbitratorProposalAfterTimoutPeriodOrIfNotAbitrator + +```solidity +error CannotDismissArbitratorProposalAfterTimoutPeriodOrIfNotAbitrator() +``` + + + + + + ### CannotSetToPerviousRewardController ```solidity diff --git a/docs/dodoc/interfaces/IHATClaimsManager.md b/docs/dodoc/interfaces/IHATClaimsManager.md index 08794a35..bc8c2e2e 100644 --- a/docs/dodoc/interfaces/IHATClaimsManager.md +++ b/docs/dodoc/interfaces/IHATClaimsManager.md @@ -55,11 +55,29 @@ Called by the arbitrator or governance to challenge a claim for a bounty payout +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _claimId | bytes32 | undefined | + +### challengeClaim + +```solidity +function challengeClaim(bytes32 _claimId, uint16 _bountyPercentage, address _beneficiary) external nonpayable +``` + +Called by the arbitrator or governance to challenge a claim for a bounty payout that had been previously submitted by the committee. Can only be called during the challenge period after submission of the claim. + + + #### Parameters | Name | Type | Description | |---|---|---| | _claimId | bytes32 | The claim ID | +| _bountyPercentage | uint16 | The percentage of the vault's balance that will be sent as a bounty. | +| _beneficiary | address | where the bounty will be sent to. | ### committeeCheckIn @@ -721,6 +739,17 @@ error BountyPercentageHigherThanMaxBounty() +### CannotDismissArbitratorProposalAfterTimoutPeriodOrIfNotAbitrator + +```solidity +error CannotDismissArbitratorProposalAfterTimoutPeriodOrIfNotAbitrator() +``` + + + + + + ### CannotSetToPerviousRewardController ```solidity diff --git a/docs/dodoc/mocks/HATClaimsManagerV2Mock.md b/docs/dodoc/mocks/HATClaimsManagerV2Mock.md index ae4fe147..32c8ced5 100644 --- a/docs/dodoc/mocks/HATClaimsManagerV2Mock.md +++ b/docs/dodoc/mocks/HATClaimsManagerV2Mock.md @@ -244,6 +244,29 @@ function arbitratorCanSubmitClaims() external view returns (bool) |---|---|---| | _0 | bool | undefined | +### arbitratorChangeProposals + +```solidity +function arbitratorChangeProposals(bytes32) external view returns (address beneficiary, uint16 bountyPercentage) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _0 | bytes32 | undefined | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| beneficiary | address | undefined | +| bountyPercentage | uint16 | undefined | + ### bountySplit ```solidity @@ -273,11 +296,29 @@ Called by the arbitrator or governance to challenge a claim for a bounty payout +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _claimId | bytes32 | undefined | + +### challengeClaim + +```solidity +function challengeClaim(bytes32 _claimId, uint16 _bountyPercentage, address _beneficiary) external nonpayable +``` + +Called by the arbitrator or governance to challenge a claim for a bounty payout that had been previously submitted by the committee. Can only be called during the challenge period after submission of the claim. + + + #### Parameters | Name | Type | Description | |---|---|---| | _claimId | bytes32 | The claim ID | +| _bountyPercentage | uint16 | The percentage of the vault's balance that will be sent as a bounty. | +| _beneficiary | address | where the bounty will be sent to. | ### committee @@ -1153,6 +1194,17 @@ error BountyPercentageHigherThanMaxBounty() +### CannotDismissArbitratorProposalAfterTimoutPeriodOrIfNotAbitrator + +```solidity +error CannotDismissArbitratorProposalAfterTimoutPeriodOrIfNotAbitrator() +``` + + + + + + ### CannotSetToPerviousRewardController ```solidity diff --git a/test/arbitrator.js b/test/arbitrator.js index 222815bc..3a995377 100644 --- a/test/arbitrator.js +++ b/test/arbitrator.js @@ -1043,4 +1043,419 @@ contract("Registry Arbitrator", (accounts) => { "OnlyCallableIfChallenged" ); }); + + it("arbitrator change proposal - approve claim with proposal after timeout", async () => { + const { registry, claimsManager, arbitrator } = await setup(accounts); + const newBounty = 6000; + const newBeneficiary = accounts[5]; + const challengePeriod = 60*60*24*1; + const challengeTimeOutPeriod = 60*60*24*2; + await registry.setDefaultChallengePeriod(challengePeriod); + await registry.setDefaultChallengeTimeOutPeriod(challengeTimeOutPeriod); + await claimsManager.setArbitratorOptions(true, true, false); + await advanceToSafetyPeriod(registry); + + // Submit and challenge claim with proposal + let claimId = await submitClaim(claimsManager, { accounts }); + await claimsManager.methods["challengeClaim(bytes32,uint16,address)"](claimId, newBounty, newBeneficiary, { from: arbitrator }); + + // Pass challenge timeout + await utils.increaseTime(challengeTimeOutPeriod); + + // Claim with proposal can still be approved even after timeout + // new parameters arbitrator pass won't have effect + let tx = await claimsManager.approveClaim(claimId, 0, ZERO_ADDRESS, { from: arbitrator }); + assert.equal(tx.logs[0].event, "ApproveClaim"); + assert.equal(tx.logs[0].args._bountyPercentage, newBounty); + assert.equal(tx.logs[0].args._beneficiary, newBeneficiary); + }); + + // TODO: Do we want to allow the arbitrator to changed the proposed resolution after timeout or not allow that? (Anyone could front run it and approve before the arbitrator with the proposed parameters) + it("arbitrator change proposal - approve claim with proposal after timeout shound not change data (even with arbitrator)", async () => { + const { registry, claimsManager, arbitrator } = await setup(accounts); + const newBounty = 6000; + const newBeneficiary = accounts[5]; + const challengePeriod = 60*60*24*1; + const challengeTimeOutPeriod = 60*60*24*2; + await registry.setDefaultChallengePeriod(challengePeriod); + await registry.setDefaultChallengeTimeOutPeriod(challengeTimeOutPeriod); + await claimsManager.setArbitratorOptions(true, true, false); + await advanceToSafetyPeriod(registry); + + // Submit and challenge claim with proposal + let claimId = await submitClaim(claimsManager, { accounts }); + await claimsManager.methods["challengeClaim(bytes32,uint16,address)"](claimId, newBounty, newBeneficiary, { from: arbitrator }); + + // Pass challenge timeout + await utils.increaseTime(challengeTimeOutPeriod); + + // Claim with proposal can still be approved even after timeout + let finalBounty = 5000; + let finalBeneficiary = accounts[4]; + let tx = await claimsManager.approveClaim(claimId, finalBounty, finalBeneficiary, { from: arbitrator }); + assert.equal(tx.logs[0].event, "ApproveClaim"); + assert.equal(tx.logs[0].args._bountyPercentage, newBounty); + assert.equal(tx.logs[0].args._beneficiary, newBeneficiary); + }); + + it("arbitrator change proposal - approve claim with proposal after timeout cannot change claim data if not arbitrator", async () => { + const { registry, claimsManager, arbitrator } = await setup(accounts); + const newBounty = 6000; + const newBeneficiary = accounts[5]; + const challengePeriod = 60*60*24*1; + const challengeTimeOutPeriod = 60*60*24*2; + await registry.setDefaultChallengePeriod(challengePeriod); + await registry.setDefaultChallengeTimeOutPeriod(challengeTimeOutPeriod); + await claimsManager.setArbitratorOptions(true, true, false); + await advanceToSafetyPeriod(registry); + + // Submit and challenge claim with proposal + let claimId = await submitClaim(claimsManager, { accounts }); + await claimsManager.methods["challengeClaim(bytes32,uint16,address)"](claimId, newBounty, newBeneficiary, { from: arbitrator }); + + // Pass challenge timeout + await utils.increaseTime(challengeTimeOutPeriod); + + // Claim with proposal can still be approved even after timeout + // new parameters user pass won't have effect + let finalBounty = 5000; + let finalBeneficiary = accounts[4]; + let tx = await claimsManager.approveClaim(claimId, finalBounty, finalBeneficiary, { from: accounts[5] }); + assert.equal(tx.logs[0].event, "ApproveClaim"); + assert.equal(tx.logs[0].args._bountyPercentage, newBounty); + assert.equal(tx.logs[0].args._beneficiary, newBeneficiary); + }); + + it("arbitrator change proposal - only arbitrator can dismiss during timeout", async () => { + const { registry, claimsManager, arbitrator } = await setup(accounts); + const someAccount = accounts[5]; + await claimsManager.setArbitratorOptions(true, true, true); + await advanceToSafetyPeriod(registry); + + // Submit and challenge claim with proposal + let claimId = await submitClaim(claimsManager, { accounts }); + await claimsManager.methods["challengeClaim(bytes32,uint16,address)"](claimId, 6000, accounts[5], { from: arbitrator }); + + // During challenge timeout + await assertFunctionRaisesException( + claimsManager.dismissClaim(claimId, { from: someAccount }), + "OnlyCallableByArbitratorOrAfterChallengeTimeOutPeriod" + ); + + // Arbitrator can dismiss during timeout + await claimsManager.dismissClaim(claimId, { from: arbitrator }); + }); + + it("arbitrator change proposal - cannot dismiss after timeout", async () => { + const { registry, claimsManager, arbitrator } = await setup(accounts); + const someAccount = accounts[5]; + const challengePeriod = 60*60*24*1; + const challengeTimeOutPeriod = 60*60*24*2; + await registry.setDefaultChallengePeriod(challengePeriod); + await registry.setDefaultChallengeTimeOutPeriod(challengeTimeOutPeriod); + await claimsManager.setArbitratorOptions(true, true, true); + await advanceToSafetyPeriod(registry); + + // Submit and challenge claim with proposal + let claimId = await submitClaim(claimsManager, { accounts }); + await claimsManager.methods["challengeClaim(bytes32,uint16,address)"](claimId, 6000, accounts[5], { from: arbitrator }); + + // Pass challenge timeout + await utils.increaseTime(challengeTimeOutPeriod); + + // Nobody can dismiss after timeout if there's a proposal + await assertFunctionRaisesException( + claimsManager.dismissClaim(claimId, { from: someAccount }), + "OnlyCallableByArbitratorOrAfterChallengeTimeOutPeriod" + ); + await assertFunctionRaisesException( + claimsManager.dismissClaim(claimId, { from: arbitrator }), + "CannotDismissArbitratorProposalAfterTimoutPeriodOrIfNotAbitrator" + ); + }); + + it("arbitrator change proposal - approve claim with proposal before timeout", async () => { + const { registry, claimsManager, arbitrator } = await setup(accounts); + const newBounty = 6000; + const newBeneficiary = accounts[5]; + await claimsManager.setArbitratorOptions(true, true, true); + await advanceToSafetyPeriod(registry); + + // Submit and challenge claim with proposal + let claimId = await submitClaim(claimsManager, { accounts }); + await claimsManager.methods["challengeClaim(bytes32,uint16,address)"](claimId, newBounty, newBeneficiary, { from: arbitrator }); + + // Non-arbitrator cannot approve during timeout + await assertFunctionRaisesException( + claimsManager.approveClaim(claimId, 0, ZERO_ADDRESS, { from: accounts[3] }), + "ChallengedClaimCanOnlyBeApprovedByArbitratorUntilChallengeTimeoutPeriod" + ); + + let tx = await claimsManager.approveClaim(claimId, 0, ZERO_ADDRESS, { from: arbitrator }); + + // Should use the proposal parameters instead of the provided ones + assert.equal(tx.logs[0].event, "ApproveClaim"); + assert.equal(tx.logs[0].args._bountyPercentage, newBounty); + assert.equal(tx.logs[0].args._beneficiary, newBeneficiary); + }); + + it("arbitrator change proposal - approve claim with proposal before timeout and change final claim data", async () => { + const { registry, claimsManager, arbitrator } = await setup(accounts); + const newBounty = 6000; + const newBeneficiary = accounts[5]; + await claimsManager.setArbitratorOptions(true, true, true); + await advanceToSafetyPeriod(registry); + + // Submit and challenge claim with proposal + let claimId = await submitClaim(claimsManager, { accounts }); + await claimsManager.methods["challengeClaim(bytes32,uint16,address)"](claimId, newBounty, newBeneficiary, { from: arbitrator }); + + // Non-arbitrator cannot approve during timeout + await assertFunctionRaisesException( + claimsManager.approveClaim(claimId, 0, ZERO_ADDRESS, { from: accounts[3] }), + "ChallengedClaimCanOnlyBeApprovedByArbitratorUntilChallengeTimeoutPeriod" + ); + + let finalBounty = 5000; + let finalBeneficiary = accounts[4]; + // Arbitrator can approve during timeout with different parameters + let tx = await claimsManager.approveClaim(claimId, finalBounty, finalBeneficiary, { from: arbitrator }); + + // Should use the proposal parameters instead of the provided ones + assert.equal(tx.logs[0].event, "ApproveClaim"); + assert.equal(tx.logs[0].args._bountyPercentage, finalBounty); + assert.equal(tx.logs[0].args._beneficiary, finalBeneficiary); + }); + + it("no arbitrator change proposal - anyone can dismiss after timeout", async () => { + const { registry, claimsManager, arbitrator } = await setup(accounts); + const someAccount = accounts[5]; + const challengePeriod = 60*60*24*1; + const challengeTimeOutPeriod = 60*60*24*2; + await registry.setDefaultChallengePeriod(challengePeriod); + await registry.setDefaultChallengeTimeOutPeriod(challengeTimeOutPeriod); + await advanceToSafetyPeriod(registry); + + // Submit and challenge claim without proposal + let claimId = await submitClaim(claimsManager, { accounts }); + await claimsManager.challengeClaim(claimId, { from: arbitrator }); + + // During challenge timeout only arbitrator can dismiss + await assertFunctionRaisesException( + claimsManager.dismissClaim(claimId, { from: someAccount }), + "OnlyCallableByArbitratorOrAfterChallengeTimeOutPeriod" + ); + + // After challenge timeout anyone can dismiss + await utils.increaseTime(challengeTimeOutPeriod); + await claimsManager.dismissClaim(claimId, { from: someAccount }); + }); + + it("expired unchallenged claim can be dismissed by anyone", async () => { + const { registry, claimsManager } = await setup(accounts); + const someAccount = accounts[5]; + const challengePeriod = 60*60*24*1; + const challengeTimeOutPeriod = 60*60*24*2; + await registry.setDefaultChallengePeriod(challengePeriod); + await registry.setDefaultChallengeTimeOutPeriod(challengeTimeOutPeriod); + await advanceToSafetyPeriod(registry); + + // Submit claim but don't challenge it + let claimId = await submitClaim(claimsManager, { accounts }); + + // Cannot dismiss before expiration + await assertFunctionRaisesException( + claimsManager.dismissClaim(claimId, { from: someAccount }), + "OnlyCallableIfChallenged" + ); + + // After total timeout anyone can dismiss + await utils.increaseTime(challengePeriod + challengeTimeOutPeriod); + await claimsManager.dismissClaim(claimId, { from: someAccount }); + }); + + it("arbitrator change proposal with different arbitrator options", async () => { + const { registry, claimsManager, arbitrator } = await setup(accounts); + const newBounty = 6000; + const newBeneficiary = accounts[5]; + + // Test all combinations of options + const testCases = [ + { canChangeBounty: false, canChangeBeneficiary: false }, + { canChangeBounty: true, canChangeBeneficiary: false }, + { canChangeBounty: false, canChangeBeneficiary: true }, + { canChangeBounty: true, canChangeBeneficiary: true } + ]; + + for (const testCase of testCases) { + // Set arbitrator options + await claimsManager.setArbitratorOptions( + testCase.canChangeBounty, + testCase.canChangeBeneficiary, + true, + { from: await registry.owner() } + ); + await advanceToSafetyPeriod(registry); + + // Submit claim + let claimId = await submitClaim(claimsManager, { accounts }); + + // Challenge with proposal + await claimsManager.methods["challengeClaim(bytes32,uint16,address)"]( + claimId, + newBounty, + newBeneficiary, + { from: arbitrator } + ); + + // Check stored proposal + const proposal = await claimsManager.arbitratorChangeProposals(claimId); + + // Verify stored bounty + if (testCase.canChangeBounty) { + assert.equal(proposal.bountyPercentage, newBounty, + "Bounty should be stored when arbitratorCanChangeBounty is true"); + } else { + assert.equal(proposal.bountyPercentage, 0, + "Bounty should be 0 when arbitratorCanChangeBounty is false"); + } + + // Verify stored beneficiary + if (testCase.canChangeBeneficiary) { + assert.equal(proposal.beneficiary, newBeneficiary, + "Beneficiary should be stored when arbitratorCanChangeBeneficiary is true"); + } else { + assert.equal(proposal.beneficiary, ZERO_ADDRESS, + "Beneficiary should be zero address when arbitratorCanChangeBeneficiary is false"); + } + + // Clean up for next test + await claimsManager.dismissClaim(claimId, { from: arbitrator }); + } + }); + + it("cannot approve expired claim without arbitrator proposal", async () => { + const { registry, claimsManager, arbitrator } = await setup(accounts); + const challengePeriod = 60*60*24*1; + const challengeTimeOutPeriod = 60*60*24*2; + await registry.setDefaultChallengePeriod(challengePeriod); + await registry.setDefaultChallengeTimeOutPeriod(challengeTimeOutPeriod); + await advanceToSafetyPeriod(registry); + + // Submit claim without challenge + let claimId = await submitClaim(claimsManager, { accounts }); + + // Pass total timeout (challenge period + timeout period) + await utils.increaseTime(challengePeriod + challengeTimeOutPeriod); + + // Try to approve expired claim - should fail + await assertFunctionRaisesException( + claimsManager.approveClaim(claimId, 8000, ZERO_ADDRESS, { from: arbitrator }), + "ClaimExpired" + ); + + await claimsManager.dismissClaim(claimId); + + // Submit and challenge claim without proposal + let claimId2 = await submitClaim(claimsManager, { accounts }); + await claimsManager.challengeClaim(claimId2, { from: arbitrator }); + + // Pass total timeout + await utils.increaseTime(challengePeriod + challengeTimeOutPeriod); + + // Try to approve expired challenged claim without proposal - should fail + await assertFunctionRaisesException( + claimsManager.approveClaim(claimId2, 8000, ZERO_ADDRESS, { from: arbitrator }), + "ClaimExpired" + ); + }); + + it("arbitrator change proposal - cannot dismiss after timeout", async () => { + const { registry, claimsManager, arbitrator } = await setup(accounts); + const someAccount = accounts[5]; + const challengePeriod = 60*60*24*1; + const challengeTimeOutPeriod = 60*60*24*2; + await registry.setDefaultChallengePeriod(challengePeriod); + await registry.setDefaultChallengeTimeOutPeriod(challengeTimeOutPeriod); + await claimsManager.setArbitratorOptions(true, true, true); + await advanceToSafetyPeriod(registry); + + // Submit and challenge claim with proposal + let claimId = await submitClaim(claimsManager, { accounts }); + await claimsManager.methods["challengeClaim(bytes32,uint16,address)"](claimId, 6000, accounts[5], { from: arbitrator }); + + // Pass challenge timeout + await utils.increaseTime(challengeTimeOutPeriod); + + // Nobody can dismiss after timeout if there's a proposal + await assertFunctionRaisesException( + claimsManager.dismissClaim(claimId, { from: someAccount }), + "OnlyCallableByArbitratorOrAfterChallengeTimeOutPeriod" + ); + await assertFunctionRaisesException( + claimsManager.dismissClaim(claimId, { from: arbitrator }), + "CannotDismissArbitratorProposalAfterTimoutPeriodOrIfNotAbitrator" + ); + + // Pass total timeout + await utils.increaseTime(challengePeriod); + + // Still cannot dismiss after total timeout if there's a proposal + await assertFunctionRaisesException( + claimsManager.dismissClaim(claimId, { from: someAccount }), + "CannotDismissArbitratorProposalAfterTimoutPeriodOrIfNotAbitrator" + ); + await assertFunctionRaisesException( + claimsManager.dismissClaim(claimId, { from: arbitrator }), + "CannotDismissArbitratorProposalAfterTimoutPeriodOrIfNotAbitrator" + ); + }); + + it("can approve expired claim with arbitrator proposal", async () => { + const { registry, claimsManager, arbitrator } = await setup(accounts); + const challengePeriod = 60*60*24*1; + const challengeTimeOutPeriod = 60*60*24*2; + const newBounty = 6000; + const newBeneficiary = accounts[5]; + await registry.setDefaultChallengePeriod(challengePeriod); + await registry.setDefaultChallengeTimeOutPeriod(challengeTimeOutPeriod); + await claimsManager.setArbitratorOptions(true, true, true); + await advanceToSafetyPeriod(registry); + + // Submit and challenge claim with proposal + let claimId = await submitClaim(claimsManager, { accounts }); + await claimsManager.methods["challengeClaim(bytes32,uint16,address)"]( + claimId, + newBounty, + newBeneficiary, + { from: arbitrator } + ); + + // Pass total timeout (challenge period + timeout period) + await utils.increaseTime(challengePeriod + challengeTimeOutPeriod); + + // Should still be able to approve claim with proposal after timeout + let tx = await claimsManager.approveClaim(claimId, 0, ZERO_ADDRESS, { from: arbitrator }); + assert.equal(tx.logs[0].event, "ApproveClaim"); + assert.equal(tx.logs[0].args._bountyPercentage, newBounty); + assert.equal(tx.logs[0].args._beneficiary, newBeneficiary); + + // Try another claim to verify same behavior with non-arbitrator + let claimId2 = await submitClaim(claimsManager, { accounts }); + await claimsManager.methods["challengeClaim(bytes32,uint16,address)"]( + claimId2, + newBounty, + newBeneficiary, + { from: arbitrator } + ); + + // Pass total timeout again + await utils.increaseTime(challengePeriod + challengeTimeOutPeriod); + + // Non-arbitrator should also be able to approve with proposal parameters + tx = await claimsManager.approveClaim(claimId2, 1234, accounts[6], { from: accounts[3] }); + assert.equal(tx.logs[0].event, "ApproveClaim"); + assert.equal(tx.logs[0].args._bountyPercentage, newBounty); + assert.equal(tx.logs[0].args._beneficiary, newBeneficiary); + }); }); diff --git a/test/expertcommitteearbitrator.js b/test/expertcommitteearbitrator.js new file mode 100644 index 00000000..1d2785be --- /dev/null +++ b/test/expertcommitteearbitrator.js @@ -0,0 +1,377 @@ +const HATVaultsRegistry = artifacts.require("./HATVaultsRegistry.sol"); +const HATVault = artifacts.require("./HATVault.sol"); +const HATClaimsManager = artifacts.require("./HATClaimsManager.sol"); +const HATTimelockController = artifacts.require("./HATTimelockController.sol"); +const HATTokenMock = artifacts.require("./HATTokenMock.sol"); +const ERC20Mock = artifacts.require("./ERC20Mock.sol"); +const UniSwapV3RouterMock = artifacts.require("./UniSwapV3RouterMock.sol"); +const TokenLockFactory = artifacts.require("./TokenLockFactory.sol"); +const HATTokenLock = artifacts.require("./HATTokenLock.sol"); +const RewardController = artifacts.require("./RewardController.sol"); +const ExpertCommitteeArbitrator = artifacts.require("./ExpertCommitteeArbitrator.sol"); +const utils = require("./utils.js"); +const IExpertCommitteeArbitrator = new ethers.utils.Interface(ExpertCommitteeArbitrator.abi); +const IHATClaimsManager = new ethers.utils.Interface(HATClaimsManager.abi); + +const { deployHATVaults } = require("../scripts/deployments/hatvaultsregistry-deploy.js"); + +var hatVaultsRegistry; +var vault; +var claimsManager; +var rewardController; +var hatTimelockController; +var hatToken; +var router; +var stakingToken; +var tokenLockFactory; +var arbitratorContract; +var hatGovernanceDelay = 60 * 60 * 24 * 7; +const { + assertVMException, + epochRewardPerBlock, + advanceToSafetyPeriod, + advanceToNonSafetyPeriod, + submitClaim, + assertFunctionRaisesException, + MAX_UINT16, + ZERO_ADDRESS +} = require("./common.js"); + +const setup = async function( + accounts, + challengePeriod=60 * 60 * 24, + startBlock = 0, + maxBounty = 8000, + bountySplit = [7000, 2500, 500], + hatBountySplit = [1000, 500], + halvingAfterBlock = 10, + routerReturnType = 0, + allocPoint = 100, + weth = true, + rewardInVaults = 2500000 +) { + hatToken = await HATTokenMock.new(accounts[0]); + await hatToken.setTransferable({from: accounts[0]}); + stakingToken = await ERC20Mock.new("Staking", "STK"); + var wethAddress = utils.NULL_ADDRESS; + if (weth) { + wethAddress = stakingToken.address; + } + router = await UniSwapV3RouterMock.new(routerReturnType, wethAddress); + var tokenLock = await HATTokenLock.new(); + tokenLockFactory = await TokenLockFactory.new(tokenLock.address, accounts[0]); + let deployment = await deployHATVaults({ + governance: accounts[0], + hatToken: hatToken.address, + tokenLockFactory: tokenLockFactory.address, + rewardControllersConf: [{ + startBlock, + epochLength: halvingAfterBlock, + epochRewardPerBlock + }], + hatVaultsRegistryConf: { + bountyGovernanceHAT: hatBountySplit[0], + bountyHackerHATVested: hatBountySplit[1] + }, + silent: true + }); + hatVaultsRegistry = await HATVaultsRegistry.at(deployment.hatVaultsRegistry.address); + arbitratorContract = await ExpertCommitteeArbitrator.new(accounts[7]); + await hatVaultsRegistry.setDefaultArbitrator(arbitratorContract.address); + rewardController = await RewardController.at( + deployment.rewardControllers[0].address + ); + hatTimelockController = await HATTimelockController.new( + hatGovernanceDelay, + [accounts[0]], + [accounts[0]], + [accounts[1]] + ); + + await hatToken.setMinter( + accounts[0], + web3.utils.toWei((2500000 + rewardInVaults).toString()) + ); + await hatToken.mint(router.address, web3.utils.toWei("2500000")); + await hatToken.mint(accounts[0], web3.utils.toWei(rewardInVaults.toString())); + await hatToken.transfer( + rewardController.address, + web3.utils.toWei(rewardInVaults.toString()) + ); + + let tx = await hatVaultsRegistry.createVault( + { + asset: stakingToken.address, + name: "VAULT", + symbol: "VLT", + rewardControllers: [rewardController.address], + owner: hatTimelockController.address, + isPaused: false, + descriptionHash: "_descriptionHash", + }, + { + owner: hatTimelockController.address, + committee: accounts[1], + arbitrator: "0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF", + arbitratorCanChangeBounty: true, + arbitratorCanChangeBeneficiary: false, + arbitratorCanSubmitIssues: false, + isTokenLockRevocable: false, + maxBounty: maxBounty, + bountySplit: bountySplit, + bountyGovernanceHAT: MAX_UINT16, + bountyHackerHATVested: MAX_UINT16, + vestingDuration: 86400, + vestingPeriods: 10 + } + ); + + vault = await HATVault.at(tx.logs[2].args._vault); + claimsManager = await HATClaimsManager.at(tx.logs[2].args._claimsManager); + + await advanceToNonSafetyPeriod(hatVaultsRegistry); + + await hatVaultsRegistry.setDefaultChallengePeriod(challengePeriod); + + await hatVaultsRegistry.transferOwnership(hatTimelockController.address); + await rewardController.transferOwnership(hatTimelockController.address); + await arbitratorContract.transferOwnership(hatTimelockController.address); + + await hatTimelockController.setAllocPoint( + vault.address, + rewardController.address, + allocPoint + ); + + await claimsManager.committeeCheckIn({ from: accounts[1] }); +}; + +contract("ExpertCommitteeArbitrator", (accounts) => { + it("challenge - approve Claim ", async () => { + await setup(accounts); + const staker = accounts[1]; + await advanceToSafetyPeriod(hatVaultsRegistry); + + // we send some funds to the vault so we can pay out later when approveClaim is called + await stakingToken.mint(staker, web3.utils.toWei("2")); + await stakingToken.approve(vault.address, web3.utils.toWei("1"), { + from: staker, + }); + await vault.deposit(web3.utils.toWei("1"), staker, { from: staker }); + await rewardController.updateVault(vault.address); + + let claimId = await submitClaim(claimsManager, { accounts }); + + await arbitratorContract.challengeClaim(claimsManager.address, claimId, 0, ZERO_ADDRESS, {from: accounts[7]}); + + try { + await arbitratorContract.approveClaim(claimsManager.address, claimId, {from: accounts[7]}); + assert(false, "only governance"); + } catch (ex) { + assertVMException(ex); + } + + await hatTimelockController.approveClaim(arbitratorContract.address, claimsManager.address, claimId); + }); + + it("challenge - dismiss claim", async () => { + await setup(accounts); + // set challenge period to 1000 + await advanceToSafetyPeriod(hatVaultsRegistry); + let claimId = await submitClaim(claimsManager, { accounts }); + await arbitratorContract.challengeClaim(claimsManager.address, claimId, 0, ZERO_ADDRESS, {from: accounts[7]}); + try { + await arbitratorContract.dismissClaim(claimsManager.address, claimId); + assert(false, "only governance"); + } catch (ex) { + assertVMException(ex); + } + + try { + await hatTimelockController.dismissClaim(arbitratorContract.address, claimsManager.address, claimId, { + from: accounts[7], + }); + assert(false, "only governance"); + } catch (ex) { + assertVMException(ex); + } + + await hatTimelockController.dismissClaim(arbitratorContract.address, claimsManager.address, claimId); + }); + + it("expert committee can challenge with new values and arbitrator can approve", async () => { + await setup(accounts); + const newBounty = 6000; + const newBeneficiary = accounts[5]; + + // Schedule expert committee change through timelock + await hatTimelockController.schedule( + claimsManager.address, + 0, // value + IHATClaimsManager.encodeFunctionData("setArbitratorOptions", [true, true, true]), + "0x0000000000000000000000000000000000000000000000000000000000000000", // predecessor + "0x0000000000000000000000000000000000000000000000000000000000000000", // salt + 60 * 60 * 24 * 7 // delay + ); + + // Wait for timelock delay + await utils.increaseTime(60 * 60 * 24 * 7); + + // Execute the scheduled expert committee change + await hatTimelockController.execute( + claimsManager.address, + 0, + IHATClaimsManager.encodeFunctionData("setArbitratorOptions", [true, true, true]), + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ); + + await advanceToSafetyPeriod(hatVaultsRegistry); + + // Submit claim + let claimId = await submitClaim(claimsManager, { accounts }); + + // Non-expert committee cannot challenge + await assertFunctionRaisesException( + arbitratorContract.challengeClaim( + claimsManager.address, + claimId, + newBounty, + newBeneficiary, + { from: accounts[2] } + ), + "OnlyExpertCommittee" + ); + + // Expert committee can challenge with new values + await arbitratorContract.challengeClaim( + claimsManager.address, + claimId, + newBounty, + newBeneficiary, + { from: accounts[7] } // expert committee set in setup + ); + + // Verify the proposal was stored + const proposal = await claimsManager.arbitratorChangeProposals(claimId); + assert.equal(proposal.bountyPercentage, newBounty); + assert.equal(proposal.beneficiary, newBeneficiary); + + // Governance can approve through timelock + await hatTimelockController.approveClaim( + arbitratorContract.address, + claimsManager.address, + claimId + ); + }); + + it("only governance can set expert committee", async () => { + await setup(accounts); + const newExpertCommittee = accounts[8]; + + // Non-owner cannot set expert committee + await assertFunctionRaisesException( + arbitratorContract.setExpertCommittee(newExpertCommittee, { from: accounts[1] }), + "Ownable: caller is not the owner" + ); + + // Non-timelock cannot set expert committee + await assertFunctionRaisesException( + arbitratorContract.setExpertCommittee(newExpertCommittee, { from: accounts[0] }), + "Ownable: caller is not the owner" + ); + + // Verify current expert committee + assert.equal(await arbitratorContract.expertCommittee(), accounts[7]); + + // Schedule expert committee change through timelock + await hatTimelockController.schedule( + arbitratorContract.address, + 0, // value + IExpertCommitteeArbitrator.encodeFunctionData("setExpertCommittee", [newExpertCommittee]), + "0x0000000000000000000000000000000000000000000000000000000000000000", // predecessor + "0x0000000000000000000000000000000000000000000000000000000000000000", // salt + 60 * 60 * 24 * 7 // delay + ); + + // Wait for timelock delay + await utils.increaseTime(60 * 60 * 24 * 7); + + // Execute the scheduled expert committee change + await hatTimelockController.execute( + arbitratorContract.address, + 0, + IExpertCommitteeArbitrator.encodeFunctionData("setExpertCommittee", [newExpertCommittee]), + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ); + + // Verify expert committee was updated + assert.equal(await arbitratorContract.expertCommittee(), newExpertCommittee); + + // Old expert committee can no longer challenge + await assertFunctionRaisesException( + arbitratorContract.challengeClaim( + claimsManager.address, + web3.utils.randomHex(32), + 6000, + accounts[5], + { from: accounts[7] } + ), + "OnlyExpertCommittee" + ); + + // New expert committee can challenge + await advanceToSafetyPeriod(hatVaultsRegistry); + const claimId = await submitClaim(claimsManager, { accounts }); + await arbitratorContract.challengeClaim( + claimsManager.address, + claimId, + 6000, + accounts[5], + { from: newExpertCommittee } + ); + }); + + it("only expert committee can challenge", async () => { + await setup(accounts); + await advanceToSafetyPeriod(hatVaultsRegistry); + + const newBounty = 6000; + const newBeneficiary = accounts[5]; + let claimId = await submitClaim(claimsManager, { accounts }); + + // Owner cannot challenge + await assertFunctionRaisesException( + arbitratorContract.challengeClaim( + claimsManager.address, + claimId, + newBounty, + newBeneficiary, + { from: accounts[0] } + ), + "OnlyExpertCommittee" + ); + + // Random account cannot challenge + await assertFunctionRaisesException( + arbitratorContract.challengeClaim( + claimsManager.address, + claimId, + newBounty, + newBeneficiary, + { from: accounts[3] } + ), + "OnlyExpertCommittee" + ); + + // Expert committee can challenge + await arbitratorContract.challengeClaim( + claimsManager.address, + claimId, + newBounty, + newBeneficiary, + { from: accounts[7] } // expert committee + ); + }); +});