kleros/kleros-v2

View on GitHub
contracts/src/arbitration/evidence/ModeratedEvidenceModule.sol

Summary

Maintainability
Test Coverage
// SPDX-License-Identifier: MIT

/// @custom:authors: [@fnanni-0]
/// @custom:reviewers: []
/// @custom:auditors: []
/// @custom:bounties: []
/// @custom:deployments: []
/// @custom:tools: []

pragma solidity 0.8.24;

// TODO: standard interfaces should be placed in a separated repo (?)
import {IArbitrableV2, IArbitratorV2} from "../interfaces/IArbitrableV2.sol";
import "../interfaces/IDisputeTemplateRegistry.sol";
import "../../libraries/CappedMath.sol";

/// @title Implementation of the Evidence Standard with Moderated Submissions
contract ModeratedEvidenceModule is IArbitrableV2 {
    using CappedMath for uint256;

    // ************************************* //
    // *         Enums / Structs           * //
    // ************************************* //

    enum Party {
        None,
        Submitter,
        Moderator
    }

    struct EvidenceData {
        address payable submitter; // Address that submitted the evidence.
        bool disputed; // Whether the evidence submission has been disputed.
        Party ruling; // Final status of the evidence. If not disputed, can be changed by opening another round of moderation.
        uint256 disputeID; // The ID of the dispute. An evidence submission can only be disputed once.
        Moderation[] moderations; // Stores the data of each moderation event. An evidence submission can be moderated many times.
    }

    struct Moderation {
        uint256[3] paidFees; // Tracks the fees paid by each side in this moderation.
        uint256 feeRewards; // Sum of reimbursable fees and stake rewards available to the parties that made contributions to the side that ultimately wins a dispute.
        mapping(address contributor => uint256[3]) contributions; // Maps contributors to their contributions for each side.
        bool closed; // Moderation happens over a bounded period of time after which it is considered closed. If so, a new moderation round should be opened.
        Party currentWinner; // The current winner of this moderation round.
        uint256 bondDeadline; // The deadline until which the loser party can stake to overturn the current status.
        uint256 arbitratorDataID; // The index of the relevant arbitratorData struct.
    }

    struct ArbitratorData {
        uint256 disputeTemplateId; // The ID of the dispute template used by the arbitrator.
        bytes arbitratorExtraData; // Extra data for the arbitrator.
    }

    // ************************************* //
    // *             Storage               * //
    // ************************************* //

    uint256 public constant AMOUNT_OF_CHOICES = 2;
    uint256 public constant MULTIPLIER_DIVISOR = 10000; // Divisor parameter for multipliers.
    mapping(bytes32 evidenceId => EvidenceData) evidences; // Maps the evidence ID to its data. evidences[evidenceID].
    mapping(uint256 => bytes32) public disputeIDtoEvidenceID; // One-to-one relationship between the dispute and the evidence.
    ArbitratorData[] public arbitratorDataList; // Stores the arbitrator data of the contract. Updated each time the data is changed.
    IArbitratorV2 public immutable arbitrator; // The trusted arbitrator to resolve potential disputes. If it needs to be changed, a new contract can be deployed.
    address public governor; // The address that can make governance changes to the parameters of the contract.
    IDisputeTemplateRegistry public templateRegistry; // The dispute template registry.
    uint256 public bondTimeout; // The time in seconds during which the last moderation status can be challenged.
    uint256 public totalCostMultiplier; // Multiplier of arbitration fees that must be ultimately paid as fee stake. In basis points.
    uint256 public initialDepositMultiplier; // Multiplier of arbitration fees that must be paid as initial stake for submitting evidence. In basis points.

    // ************************************* //
    // *        Function Modifiers         * //
    // ************************************* //

    modifier onlyGovernor() {
        require(msg.sender == governor, "The caller must be the governor");
        _;
    }

    // ************************************* //
    // *              Events               * //
    // ************************************* //

    /// @dev To be raised when a moderated evidence is submitted. Should point to the resource (evidences are not to be stored on chain due to gas considerations).
    /// @param _arbitrator The arbitrator of the contract.
    /// @param _externalDisputeID Unique identifier for this dispute outside Kleros. It's the submitter responsability to submit the right evidence group ID.
    /// @param _party The address of the party submiting the evidence. Note that 0x0 refers to evidence not submitted by any party.
    /// @param _evidence Stringified evidence object, example: '{"name" : "Justification", "description" : "Description", "fileURI" : "/ipfs/QmWQV5ZFFhEJiW8Lm7ay2zLxC2XS4wx1b2W7FfdrLMyQQc"}'.
    event ModeratedEvidence(
        IArbitratorV2 indexed _arbitrator,
        uint256 indexed _externalDisputeID,
        address indexed _party,
        string _evidence
    );

    /// @dev Indicate that a party has to pay a fee or would otherwise be considered as losing.
    /// @param _evidenceID The ID of the evidence being moderated.
    /// @param _currentWinner The party who is currently winning.
    event ModerationStatusChanged(bytes32 indexed _evidenceID, Party _currentWinner);

    // ************************************* //
    // *            Constructor            * //
    // ************************************* //

    /// @dev Constructor.
    /// @param _arbitrator The trusted arbitrator to resolve potential disputes.
    /// @param _governor The trusted governor of the contract.
    /// @param _totalCostMultiplier Multiplier of arbitration fees that must be ultimately paid as fee stake. In basis points.
    /// @param _initialDepositMultiplier Multiplier of arbitration fees that must be paid as initial stake for submitting evidence. In basis points.
    /// @param _bondTimeout The time in seconds during which the last moderation status can be challenged.
    /// @param _arbitratorExtraData Extra data for the trusted arbitrator contract.
    /// @param _templateData The dispute template data.
    /// @param _templateDataMappings The dispute template data mappings.
    constructor(
        IArbitratorV2 _arbitrator,
        address _governor,
        IDisputeTemplateRegistry _templateRegistry,
        uint256 _totalCostMultiplier,
        uint256 _initialDepositMultiplier,
        uint256 _bondTimeout,
        bytes memory _arbitratorExtraData,
        string memory _templateData,
        string memory _templateDataMappings
    ) {
        arbitrator = _arbitrator;
        governor = _governor;
        templateRegistry = _templateRegistry;

        totalCostMultiplier = _totalCostMultiplier; // For example 15000, which would provide a 100% reward to the dispute winner.
        initialDepositMultiplier = _initialDepositMultiplier; // For example 63, which equals 1/16.
        bondTimeout = _bondTimeout; // For example 24 hs.

        ArbitratorData storage arbitratorData = arbitratorDataList.push();
        arbitratorData.arbitratorExtraData = _arbitratorExtraData;
        arbitratorData.disputeTemplateId = templateRegistry.setDisputeTemplate(
            "",
            _templateData,
            _templateDataMappings
        );
    }

    // ************************************* //
    // *             Governance            * //
    // ************************************* //

    /// @dev Change the governor of the contract.
    /// @param _governor The address of the new governor.
    function changeGovernor(address _governor) external onlyGovernor {
        governor = _governor;
    }

    /// @dev Change the proportion of arbitration fees that must be paid as fee stake by parties when there is no winner or loser (e.g. when the arbitrator refused to rule).
    /// @param _initialDepositMultiplier Multiplier of arbitration fees that must be paid as fee stake. In basis points.
    function changeInitialDepositMultiplier(uint256 _initialDepositMultiplier) external onlyGovernor {
        initialDepositMultiplier = _initialDepositMultiplier;
    }

    /// @dev Change the proportion of arbitration fees that must be paid as fee stake by the winner of the previous round.
    /// @param _totalCostMultiplier Multiplier of arbitration fees that must be paid as fee stake. In basis points.
    function changeTotalCostMultiplier(uint256 _totalCostMultiplier) external onlyGovernor {
        totalCostMultiplier = _totalCostMultiplier;
    }

    /// @dev Change the the time window within which evidence submissions and removals can be contested.
    /// Ongoing moderations will start using the latest bondTimeout available after calling moderate() again.
    /// @param _bondTimeout Multiplier of arbitration fees that must be paid as fee stake. In basis points.
    function changeBondTimeout(uint256 _bondTimeout) external onlyGovernor {
        bondTimeout = _bondTimeout;
    }

    /// @dev Update the dispute template data.
    /// @param _templateData The new dispute template data.
    function changeDisputeTemplate(
        string calldata _templateData,
        string memory _templateDataMappings
    ) external onlyGovernor {
        ArbitratorData storage arbitratorData = arbitratorDataList[arbitratorDataList.length - 1];
        uint256 newDisputeTemplateId = templateRegistry.setDisputeTemplate("", _templateData, _templateDataMappings);
        arbitratorDataList.push(
            ArbitratorData({
                disputeTemplateId: newDisputeTemplateId,
                arbitratorExtraData: arbitratorData.arbitratorExtraData
            })
        );
    }

    /// @dev Change the arbitrator to be used for disputes that may be raised in the next requests. The arbitrator is trusted to support appeal period and not reenter.
    /// @param _arbitratorExtraData The extra data used by the new arbitrator.
    function changeArbitratorExtraData(bytes calldata _arbitratorExtraData) external onlyGovernor {
        ArbitratorData storage arbitratorData = arbitratorDataList[arbitratorDataList.length - 1];
        arbitratorDataList.push(
            ArbitratorData({
                disputeTemplateId: arbitratorData.disputeTemplateId,
                arbitratorExtraData: _arbitratorExtraData
            })
        );
    }

    // ************************************* //
    // *         State Modifiers           * //
    // ************************************* //

    /// @dev Submits evidence.
    /// @param _evidenceGroupID Unique identifier of the evidence group the evidence belongs to. It's the submitter responsability to submit the right evidence group ID.
    /// @param _evidence Stringified evidence object, example: '{"name" : "Justification", "description" : "Description", "fileURI" : "/ipfs/QmWQV5ZFFhEJiW8Lm7ay2zLxC2XS4wx1b2W7FfdrLMyQQc"}'.
    function submitEvidence(uint256 _evidenceGroupID, string calldata _evidence) external payable {
        // Optimization opportunity: map evidenceID to an incremental index that can be safely assumed to be less than a small uint.
        bytes32 evidenceID = keccak256(abi.encodePacked(_evidenceGroupID, _evidence));
        EvidenceData storage evidenceData = evidences[evidenceID];
        require(evidenceData.submitter == address(0x0), "Evidence already submitted.");
        evidenceData.submitter = payable(msg.sender);

        ArbitratorData storage arbitratorData = arbitratorDataList[arbitratorDataList.length - 1];

        uint256 arbitrationCost = arbitrator.arbitrationCost(arbitratorData.arbitratorExtraData);
        uint256 totalCost = arbitrationCost.mulCap(totalCostMultiplier) / MULTIPLIER_DIVISOR;
        uint256 depositRequired = totalCost.mulCap(initialDepositMultiplier) / MULTIPLIER_DIVISOR;

        Moderation storage moderation = evidenceData.moderations.push();
        // Overpaying is allowed.
        contribute(moderation, Party.Submitter, payable(msg.sender), msg.value, totalCost);
        require(moderation.paidFees[uint256(Party.Submitter)] >= depositRequired, "Insufficient funding.");
        moderation.bondDeadline = block.timestamp + bondTimeout;
        moderation.currentWinner = Party.Submitter;
        moderation.arbitratorDataID = arbitratorDataList.length - 1;

        // When evidence is submitted for a foreign arbitrable, the arbitrator field of Evidence is ignored.
        emit ModeratedEvidence(arbitrator, _evidenceGroupID, msg.sender, _evidence);
    }

    /// @dev Moderates an evidence submission. Requires the contester to at least double the accumulated stake of the oposing party.
    /// Optimization opportunity: use `bytes calldata args` and compress _evidenceID and _side (only for optimistic rollups).
    /// @param _evidenceID Unique identifier of the evidence submission.
    /// @param _side The side to contribute to.
    function moderate(bytes32 _evidenceID, Party _side) external payable {
        EvidenceData storage evidenceData = evidences[_evidenceID];
        require(evidenceData.submitter != address(0x0), "Evidence does not exist.");
        require(!evidenceData.disputed, "Evidence already disputed.");
        require(_side != Party.None, "Invalid side.");

        Moderation storage moderation = evidenceData.moderations[evidenceData.moderations.length - 1];
        if (moderation.closed) {
            // Start another round of moderation.
            moderation = evidenceData.moderations.push();
            moderation.arbitratorDataID = arbitratorDataList.length - 1;
        }
        require(_side != moderation.currentWinner, "Only the current loser can fund.");
        require(
            block.timestamp < moderation.bondDeadline || moderation.bondDeadline == 0,
            "Moderation market is closed."
        );

        ArbitratorData storage arbitratorData = arbitratorDataList[moderation.arbitratorDataID];

        uint256 arbitrationCost = arbitrator.arbitrationCost(arbitratorData.arbitratorExtraData);
        uint256 totalCost = arbitrationCost.mulCap(totalCostMultiplier) / MULTIPLIER_DIVISOR;

        uint256 opposition = 3 - uint256(_side);
        uint256 depositRequired = moderation.paidFees[opposition] * 2;
        if (depositRequired == 0) {
            depositRequired = totalCost.mulCap(initialDepositMultiplier) / MULTIPLIER_DIVISOR;
        } else if (depositRequired > totalCost) {
            depositRequired = totalCost;
        }

        // Overpaying is allowed.
        contribute(moderation, _side, payable(msg.sender), msg.value, totalCost);
        require(moderation.paidFees[uint256(_side)] >= depositRequired, "Insufficient funding.");

        if (moderation.paidFees[uint256(_side)] >= totalCost && moderation.paidFees[opposition] >= totalCost) {
            moderation.feeRewards = moderation.feeRewards - arbitrationCost;

            evidenceData.disputeID = arbitrator.createDispute{value: arbitrationCost}(
                AMOUNT_OF_CHOICES,
                arbitratorData.arbitratorExtraData
            );
            disputeIDtoEvidenceID[evidenceData.disputeID] = _evidenceID;

            emit DisputeRequest(
                arbitrator,
                evidenceData.disputeID,
                uint256(_evidenceID),
                arbitratorData.disputeTemplateId,
                ""
            );
            evidenceData.disputed = true;
            moderation.bondDeadline = 0;
            moderation.currentWinner = Party.None;
        } else {
            moderation.bondDeadline = block.timestamp + bondTimeout;
            moderation.currentWinner = _side;
        }
        emit ModerationStatusChanged(_evidenceID, moderation.currentWinner);
    }

    /// @dev Resolves a moderation event once the timeout has passed.
    /// @param _evidenceID Unique identifier of the evidence submission.
    function resolveModerationMarket(bytes32 _evidenceID) external {
        // Moderation market resolutions are not final.
        // Evidence can be reported/accepted again in the future.
        // Only an arbitrator's ruling after a dispute is final.
        EvidenceData storage evidenceData = evidences[_evidenceID];
        Moderation storage moderation = evidenceData.moderations[evidenceData.moderations.length - 1];

        require(!evidenceData.disputed, "Evidence already disputed.");
        require(block.timestamp > moderation.bondDeadline, "Moderation still ongoing.");

        moderation.closed = true;
        evidenceData.ruling = moderation.currentWinner;
    }

    /// @dev Make a fee contribution.
    /// @param _moderation The moderation to contribute to.
    /// @param _side The side to contribute to.
    /// @param _contributor The contributor.
    /// @param _amount The amount contributed.
    /// @param _totalRequired The total amount required for this side.
    /// @return The amount of fees contributed.
    function contribute(
        Moderation storage _moderation,
        Party _side,
        address payable _contributor,
        uint256 _amount,
        uint256 _totalRequired
    ) internal returns (uint256) {
        uint256 contribution;
        uint256 remainingETH;
        uint256 requiredAmount = _totalRequired.subCap(_moderation.paidFees[uint256(_side)]);
        (contribution, remainingETH) = calculateContribution(_amount, requiredAmount);
        _moderation.contributions[_contributor][uint256(_side)] += contribution;
        _moderation.paidFees[uint256(_side)] += contribution;
        _moderation.feeRewards += contribution;

        if (remainingETH != 0) _contributor.send(remainingETH);

        return contribution;
    }

    /// @dev Returns the contribution value and remainder from available ETH and required amount.
    /// @param _available The amount of ETH available for the contribution.
    /// @param _requiredAmount The amount of ETH required for the contribution.
    /// @return taken The amount of ETH taken.
    /// @return remainder The amount of ETH left from the contribution.
    function calculateContribution(
        uint256 _available,
        uint256 _requiredAmount
    ) internal pure returns (uint256 taken, uint256 remainder) {
        if (_requiredAmount > _available) return (_available, 0); // Take whatever is available, return 0 as leftover ETH.

        remainder = _available - _requiredAmount;
        return (_requiredAmount, remainder);
    }

    /// @dev Withdraws contributions of moderations. Reimburses contributions if the appeal was not fully funded.
    /// If the appeal was fully funded, sends the fee stake rewards and reimbursements proportional to the contributions made to the winner of a dispute.
    /// Optimization opportunity: use `bytes calldata args` and compress _evidenceID and _moderationID (only for optimistic rollups).
    /// @param _beneficiary The address that made contributions.
    /// @param _evidenceID The ID of the associated evidence submission.
    /// @param _moderationID The ID of the moderatino occurence.
    function withdrawFeesAndRewards(
        address payable _beneficiary,
        bytes32 _evidenceID,
        uint256 _moderationID
    ) external returns (uint256 reward) {
        EvidenceData storage evidenceData = evidences[_evidenceID];
        Moderation storage moderation = evidenceData.moderations[_moderationID];
        require(moderation.closed, "Moderation must be closed.");

        uint256[3] storage contributionTo = moderation.contributions[_beneficiary];

        if (evidenceData.ruling == Party.None) {
            // Reimburse unspent fees proportionally if there is no winner and loser.
            uint256 totalFeesPaid = moderation.paidFees[uint256(Party.Submitter)] +
                moderation.paidFees[uint256(Party.Moderator)];
            uint256 totalBeneficiaryContributions = contributionTo[uint256(Party.Submitter)] +
                contributionTo[uint256(Party.Moderator)];
            reward = totalFeesPaid > 0 ? (totalBeneficiaryContributions * moderation.feeRewards) / totalFeesPaid : 0;
        } else {
            // Reward the winner.
            uint256 paidFees = moderation.paidFees[uint256(evidenceData.ruling)];
            reward = paidFees > 0
                ? (contributionTo[uint256(evidenceData.ruling)] * moderation.feeRewards) / paidFees
                : 0;
        }
        contributionTo[uint256(Party.Submitter)] = 0;
        contributionTo[uint256(Party.Moderator)] = 0;

        _beneficiary.send(reward); // It is the user responsibility to accept ETH.
    }

    /// @dev Give a ruling for a dispute. Must be called by the arbitrator to enforce the final ruling.
    /// The purpose of this function is to ensure that the address calling it has the right to rule on the contract.
    /// @param _disputeID ID of the dispute in the Arbitrator contract.
    /// @param _ruling Ruling given by the arbitrator. Note that 0 is reserved for "Not able/wanting to make a decision".
    function rule(uint256 _disputeID, uint256 _ruling) public override {
        bytes32 evidenceID = disputeIDtoEvidenceID[_disputeID];
        EvidenceData storage evidenceData = evidences[evidenceID];
        Moderation storage moderation = evidenceData.moderations[evidenceData.moderations.length - 1];
        require(
            evidenceData.disputed &&
                !moderation.closed &&
                msg.sender == address(arbitrator) &&
                _ruling <= AMOUNT_OF_CHOICES,
            "Ruling can't be processed."
        );

        evidenceData.ruling = Party(_ruling);
        moderation.closed = true;

        emit Ruling(arbitrator, _disputeID, _ruling);
    }

    // ************************************* //
    // *           Public Views            * //
    // ************************************* //

    /// @dev Gets the number of moderation events of the specific evidence submission.
    /// @param _evidenceID The ID of the evidence submission.
    /// @return The number of moderations.
    function getNumberOfModerations(bytes32 _evidenceID) external view returns (uint256) {
        EvidenceData storage evidenceData = evidences[_evidenceID];
        return evidenceData.moderations.length;
    }

    /// @dev Gets the contributions made by a party for a given moderation.
    /// @param _evidenceID The ID of the evidence submission.
    /// @param _moderationID The ID of the moderation occurence.
    /// @param _contributor The address of the contributor.
    /// @return contributions The contributions.
    function getContributions(
        bytes32 _evidenceID,
        uint256 _moderationID,
        address _contributor
    ) external view returns (uint256[3] memory contributions) {
        EvidenceData storage evidenceData = evidences[_evidenceID];
        Moderation storage moderation = evidenceData.moderations[_moderationID];
        contributions = moderation.contributions[_contributor];
    }

    /// @dev Gets the information of a moderation event.
    /// @param _evidenceID The ID of the evidence submission.
    /// @param _moderationID The ID of the moderation occurence.
    /// @return paidFees currentWinner feeRewards The moderation information.
    function getModerationInfo(
        bytes32 _evidenceID,
        uint256 _moderationID
    ) external view returns (uint256[3] memory paidFees, Party currentWinner, uint256 feeRewards) {
        EvidenceData storage evidenceData = evidences[_evidenceID];
        Moderation storage moderation = evidenceData.moderations[_moderationID];
        return (moderation.paidFees, moderation.currentWinner, moderation.feeRewards);
    }

    /// @dev Gets the last arbitrator data index, which is used for current new submissions.
    /// @return The last arbitrator data index.
    function getCurrentArbitratorIndex() external view returns (uint256) {
        return arbitratorDataList.length - 1;
    }
}