// SPDX-License-Identifier: MIT pragma solidity 0.8.24; import { PullPaymentsV3 } from "../bonds/PullPaymentsV3.sol"; import { IDisputeSubjectV3, IDisputePayoutSourceV3 } from "./IDisputeSubjectV3.sol"; /// @title DisputeRegistryV3 /// @notice Subject-bound, threshold-adjudicated dispute lifecycle. contract DisputeRegistryV3 is PullPaymentsV3 { uint256 public constant MAX_ADJUDICATORS = 32; enum Outcome { None, Upheld, Rejected } struct Dispute { address subject; uint256 subjectId; bytes32 subjectHash; address challenger; address respondent; /// @notice Refundable filing bond. It is returned exactly once on /// either upheld or rejected finalization and is never slashed. uint256 refundableBondWei; bytes32 evidenceCommitment; /// @notice Exact settlement attempt captured from the subject at filing. bytes32 settlementSnapshot; uint64 settlementRequestedAt; uint64 challengeDeadline; uint64 filedAt; uint64 responseDeadline; uint64 decisionDeadline; uint8 adjudicatorThreshold; uint256 adjudicatorConfigVersion; uint8 approvals; bool responded; Outcome outcome; address payoutSource; uint256 payoutCap; bool finalized; } Dispute[] public disputes; mapping(address => bool) public adjudicator; address[] public adjudicators; mapping(address => uint256) private _adjudicatorIndexPlusOne; mapping(uint256 => mapping(address => bool)) public eligibleAdjudicator; mapping(uint256 => mapping(address => bool)) public voted; mapping(bytes32 => bool) public disputeFiledForSettlement; address public owner; uint8 public adjudicatorCount; /// @notice Governance-configured approvals required to uphold newly filed /// disputes. A zero value intentionally leaves filing disabled. uint8 public requiredAdjudicatorThreshold; /// @notice Bumps whenever adjudicator membership changes for auditability. /// Open disputes use an explicit filing-time eligibility snapshot /// and are not changed or invalidated by later rotation. uint256 public adjudicatorConfigVersion; // The legacy settlement-wide getter above remains an aggregate observation // for ABI compatibility. Duplicate enforcement is challenger-scoped so one // authorized party cannot consume the other party's challenge opportunity. mapping(bytes32 => mapping(address => bool)) private _disputeFiledForSettlementByChallenger; error NotOwner(); error NotAdjudicator(); error NotAuthorizedChallenger(); error NotRespondent(); error AlreadyVoted(); error BadDeadlines(); error BadThreshold(); error BadPayoutSource(); error ResponseDeadlinePassed(); error ResponsePending(); error VoteDeadlinePassed(); error DecisionDeadlineNotReached(); error AlreadyFinalized(); error ZeroAddress(); error InvalidSettlementContext(); error DuplicateDispute(); event DisputeFiledV3( uint256 indexed disputeId, bytes32 indexed subjectHash, address indexed challenger, address respondent, uint256 refundableBond, bytes32 settlementSnapshot, uint64 settlementRequestedAt, uint64 challengeDeadline ); event AdjudicatorSetV3(address indexed a, bool ok); event AdjudicatorThresholdUpdatedV3(uint8 requiredAdjudicatorThreshold); event AdjudicatorConfigVersionUpdatedV3(uint256 indexed configVersion); event RespondentRespondedV3(uint256 indexed disputeId, address indexed respondent); event VoteCastV3(uint256 indexed disputeId, address indexed adj, bool inFavor, uint256 approvals); event DisputeFinalizedV3(uint256 indexed disputeId, Outcome outcome); modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; } constructor(address _owner) { if (_owner == address(0)) revert ZeroAddress(); owner = _owner; } function setAdjudicator(address a, bool v) external onlyOwner { if (a == address(0)) revert ZeroAddress(); if (adjudicator[a] != v) { if (v) { if (adjudicators.length >= MAX_ADJUDICATORS) revert BadThreshold(); adjudicator[a] = true; adjudicators.push(a); _adjudicatorIndexPlusOne[a] = adjudicators.length; adjudicatorCount += 1; } else { // Do not permit a membership update that would make the // governance-configured threshold unattainable for future // filings. Existing disputes retain their filing-time set. if (requiredAdjudicatorThreshold > adjudicatorCount - 1) revert BadThreshold(); adjudicator[a] = false; uint256 index = _adjudicatorIndexPlusOne[a] - 1; uint256 lastIndex = adjudicators.length - 1; if (index != lastIndex) { address moved = adjudicators[lastIndex]; adjudicators[index] = moved; _adjudicatorIndexPlusOne[moved] = index + 1; } adjudicators.pop(); delete _adjudicatorIndexPlusOne[a]; adjudicatorCount -= 1; } unchecked { ++adjudicatorConfigVersion; } emit AdjudicatorConfigVersionUpdatedV3(adjudicatorConfigVersion); } emit AdjudicatorSetV3(a, v); } /// @notice Sets the approval threshold used for subsequently filed disputes. /// @dev Existing disputes retain their threshold snapshot. Until governance /// configures a non-zero attainable value, filing is fail-closed. function setAdjudicatorThreshold(uint8 newThreshold) external onlyOwner { if (newThreshold == 0 || newThreshold > adjudicatorCount) revert BadThreshold(); requiredAdjudicatorThreshold = newThreshold; emit AdjudicatorThresholdUpdatedV3(newThreshold); } function fileDispute( address subject, uint256 subjectId, bytes32 evidenceCommitment, uint64 responseDeadline, uint64 decisionDeadline, address payoutSource, uint256 payoutCap ) external payable returns (uint256 id) { return _fileDispute( subject, subjectId, evidenceCommitment, responseDeadline, decisionDeadline, payoutSource, payoutCap ); } /// @notice Legacy ABI compatibility entrypoint. /// @dev The legacy caller-supplied threshold is intentionally ignored. It /// cannot weaken the threshold configured by governance. function fileDispute( address subject, uint256 subjectId, bytes32 evidenceCommitment, uint64 responseDeadline, uint64 decisionDeadline, uint8, address payoutSource, uint256 payoutCap ) external payable returns (uint256 id) { return _fileDispute( subject, subjectId, evidenceCommitment, responseDeadline, decisionDeadline, payoutSource, payoutCap ); } function _fileDispute( address subject, uint256 subjectId, bytes32 evidenceCommitment, uint64 responseDeadline, uint64 decisionDeadline, address payoutSource, uint256 payoutCap ) private returns (uint256 id) { if (subject == address(0) || payoutSource == address(0)) revert ZeroAddress(); if (responseDeadline <= block.timestamp || decisionDeadline <= responseDeadline) revert BadDeadlines(); uint8 adjudicatorThreshold = requiredAdjudicatorThreshold; if (adjudicatorThreshold == 0 || adjudicatorThreshold > adjudicatorCount) revert BadThreshold(); IDisputeSubjectV3 boundSubject = IDisputeSubjectV3(subject); if (!boundSubject.isAuthorizedChallenger(subjectId, msg.sender)) revert NotAuthorizedChallenger(); // The subject supplies this only while the exact settlement attempt is // challengeable. Capturing it here prevents old/unrelated disputes from // later stopping a different settlement attempt. (bytes32 settlementSnapshot, uint64 settlementRequestedAt, uint64 challengeDeadline) = boundSubject.settlementChallengeContext(subjectId); if ( settlementSnapshot == bytes32(0) || settlementRequestedAt == 0 || settlementRequestedAt > block.timestamp || challengeDeadline <= block.timestamp || challengeDeadline <= settlementRequestedAt ) { revert InvalidSettlementContext(); } // The subject accepts an upheld dispute only before its challenge deadline. // A decision deadline at or after that cutoff could create a timely-filed // dispute that can never affect the captured settlement attempt. if (decisionDeadline >= challengeDeadline) revert BadDeadlines(); address respondent = boundSubject.authorizedRespondent(subjectId, msg.sender); if ( respondent == address(0) || payoutSource != boundSubject.disputePayoutSource(subjectId) || payoutCap > boundSubject.disputePayoutCap(subjectId) ) revert BadPayoutSource(); // Each authorized challenger gets one dispute process for one exact // settlement attempt. This prevents duplicate filings by that party // without allowing one party's timeout or rejection to consume the // other authorized party's challenge opportunity. bytes32 filingKey = keccak256(abi.encode(subject, subjectId, settlementSnapshot, settlementRequestedAt)); if (_disputeFiledForSettlementByChallenger[filingKey][msg.sender]) revert DuplicateDispute(); _disputeFiledForSettlementByChallenger[filingKey][msg.sender] = true; disputeFiledForSettlement[filingKey] = true; id = disputes.length; disputes.push( Dispute({ subject: subject, subjectId: subjectId, subjectHash: boundSubject.disputeSubject(subjectId), challenger: msg.sender, respondent: respondent, refundableBondWei: msg.value, evidenceCommitment: evidenceCommitment, settlementSnapshot: settlementSnapshot, settlementRequestedAt: settlementRequestedAt, challengeDeadline: challengeDeadline, filedAt: uint64(block.timestamp), responseDeadline: responseDeadline, decisionDeadline: decisionDeadline, adjudicatorThreshold: adjudicatorThreshold, adjudicatorConfigVersion: adjudicatorConfigVersion, approvals: 0, responded: false, outcome: Outcome.None, payoutSource: payoutSource, payoutCap: payoutCap, finalized: false }) ); // MAX_ADJUDICATORS bounds this filing-time snapshot. Later additions // cannot vote on this dispute, and later removals cannot strip an // originally eligible adjudicator of the ability to finish it. uint256 eligibleCount = adjudicators.length; for (uint256 i; i < eligibleCount; ++i) { eligibleAdjudicator[id][adjudicators[i]] = true; } emit DisputeFiledV3( id, boundSubject.disputeSubject(subjectId), msg.sender, respondent, msg.value, settlementSnapshot, settlementRequestedAt, challengeDeadline ); } function respond(uint256 id) external { Dispute storage d = disputes[id]; if (d.finalized) revert AlreadyFinalized(); if (msg.sender != d.respondent) revert NotRespondent(); if (block.timestamp >= d.responseDeadline) revert ResponseDeadlinePassed(); d.responded = true; emit RespondentRespondedV3(id, msg.sender); } function vote(uint256 id, bool inFavor) external { Dispute storage d = disputes[id]; if (d.finalized) revert AlreadyFinalized(); if (!eligibleAdjudicator[id][msg.sender]) revert NotAdjudicator(); if (!d.responded && block.timestamp < d.responseDeadline) revert ResponsePending(); if (block.timestamp >= d.decisionDeadline) revert VoteDeadlinePassed(); if (voted[id][msg.sender]) revert AlreadyVoted(); voted[id][msg.sender] = true; if (inFavor) d.approvals += 1; emit VoteCastV3(id, msg.sender, inFavor, d.approvals); if (d.approvals >= d.adjudicatorThreshold) _finalizeUpheld(id, d); } /// @notice Permissionless explicit default: a deadline without threshold /// finalizes permanently as Rejected and refunds the filing bond. function finalizeByTimeout(uint256 id) external { Dispute storage d = disputes[id]; if (d.finalized) revert AlreadyFinalized(); if (block.timestamp < d.decisionDeadline) revert DecisionDeadlineNotReached(); d.outcome = Outcome.Rejected; d.finalized = true; if (d.refundableBondWei > 0) _credit(address(0), d.challenger, d.refundableBondWei); emit DisputeFinalizedV3(id, Outcome.Rejected); } /// @notice Returns upheld status only for the settlement attempt captured at filing. /// @dev A plain subject/challenger match is deliberately insufficient: the /// settlement snapshot and request time make each attempt one-shot. function isFinalizedUpheldForSettlement( uint256 id, address subject, uint256 subjectId, address challenger, bytes32 settlementSnapshot, uint64 settlementRequestedAt ) external view returns (bool upheld, uint64 filedAt) { Dispute storage d = disputes[id]; upheld = d.finalized && d.outcome == Outcome.Upheld && d.subject == subject && d.subjectId == subjectId && d.challenger == challenger && d.settlementSnapshot == settlementSnapshot && d.settlementRequestedAt == settlementRequestedAt && d.filedAt >= settlementRequestedAt && d.filedAt < d.challengeDeadline; filedAt = d.filedAt; } function disputeCount() external view returns (uint256) { return disputes.length; } function disputeOf(uint256 id) external view returns (Dispute memory) { return disputes[id]; } function _finalizeUpheld(uint256 id, Dispute storage d) internal { d.outcome = Outcome.Upheld; d.finalized = true; if (d.refundableBondWei > 0) _credit(address(0), d.challenger, d.refundableBondWei); if (d.payoutCap > 0) { IDisputePayoutSourceV3(d.payoutSource).releaseDisputePayout(id, d.challenger, d.payoutCap); } emit DisputeFinalizedV3(id, Outcome.Upheld); } }