// SPDX-License-Identifier: MIT pragma solidity 0.8.24; import { Test } from "forge-std/Test.sol"; import { DisputeRegistryV3 } from "../../contracts/disputes/DisputeRegistryV3.sol"; import { IDisputeSubjectV3 } from "../../contracts/disputes/IDisputeSubjectV3.sol"; contract DisputeSubjectMock is IDisputeSubjectV3 { address internal immutable challengerA; address internal immutable challengerB; uint64 internal immutable settlementRequestedAt; uint64 internal immutable challengeDeadline; constructor(address challengerA_, address challengerB_) { challengerA = challengerA_; challengerB = challengerB_; settlementRequestedAt = uint64(block.timestamp); challengeDeadline = uint64(block.timestamp + 1 days); } function disputeSubject(uint256 subjectId) external view returns (bytes32) { return keccak256(abi.encode(address(this), subjectId)); } function isAuthorizedChallenger(uint256, address account) external view returns (bool) { return account == challengerA || account == challengerB; } function authorizedRespondent(uint256, address account) external view returns (address) { return account == challengerA ? address(0xBEEF) : account == challengerB ? challengerA : address(0); } function disputePayoutSource(uint256) external view returns (address) { return address(this); } function disputePayoutCap(uint256) external pure returns (uint256) { return 0; } function settlementChallengeContext(uint256 subjectId) external view returns (bytes32, uint64, uint64) { return (keccak256(abi.encode("settlement", subjectId)), settlementRequestedAt, challengeDeadline); } } contract DisputeRegistryV3Test is Test { address internal constant OWNER = address(0xA11CE); address internal constant CHALLENGER = address(0xC0FFEE); address internal constant OPPOSITE_CHALLENGER = address(0xC0FFEF); address internal constant ADJUDICATOR_A = address(0xA); address internal constant ADJUDICATOR_B = address(0xB); address internal constant ADJUDICATOR_C = address(0xC); address internal constant ADJUDICATOR_D = address(0xD); address internal constant ADJUDICATOR_E = address(0xE); DisputeRegistryV3 internal registry; DisputeSubjectMock internal subject; function setUp() external { registry = new DisputeRegistryV3(OWNER); subject = new DisputeSubjectMock(CHALLENGER, OPPOSITE_CHALLENGER); vm.startPrank(OWNER); registry.setAdjudicator(ADJUDICATOR_A, true); registry.setAdjudicator(ADJUDICATOR_B, true); registry.setAdjudicator(ADJUDICATOR_C, true); registry.setAdjudicator(ADJUDICATOR_D, true); registry.setAdjudicator(ADJUDICATOR_E, true); registry.setAdjudicatorThreshold(3); vm.stopPrank(); } function testFuzz_MembershipRotationPreservesFiledEligibilitySnapshot(uint64 responseDelay, uint64 decisionDelay) external { // The mock's settlement challenge deadline is one day after filing. // Keep fuzzed deadlines inside the protocol's actionable window. responseDelay = uint64(bound(responseDelay, 1 hours, 12 hours)); decisionDelay = uint64(bound(decisionDelay, responseDelay + 1, 23 hours)); vm.prank(CHALLENGER); uint256 disputeId = registry.fileDispute( address(subject), 1, keccak256("evidence"), uint64(block.timestamp) + responseDelay, uint64(block.timestamp) + decisionDelay, address(subject), 0 ); vm.prank(address(0xBEEF)); registry.respond(disputeId); vm.prank(ADJUDICATOR_A); registry.vote(disputeId, true); vm.prank(OWNER); registry.setAdjudicator(ADJUDICATOR_A, false); // Removal after filing cannot erase A's already-cast vote or prevent // the other filing-time adjudicators from completing the dispute. vm.prank(ADJUDICATOR_B); registry.vote(disputeId, true); vm.prank(ADJUDICATOR_C); registry.vote(disputeId, true); DisputeRegistryV3.Dispute memory dispute = registry.disputeOf(disputeId); assertTrue(dispute.finalized); assertEq(dispute.approvals, 3); } function test_rotationCannotAddEligibleVoterToOpenDispute() external { uint256 disputeId = _fileDispute(); vm.startPrank(OWNER); registry.setAdjudicator(ADJUDICATOR_E, false); address replacement = address(0xF); registry.setAdjudicator(replacement, true); vm.stopPrank(); assertFalse(registry.eligibleAdjudicator(disputeId, replacement)); vm.prank(replacement); vm.expectRevert(DisputeRegistryV3.NotAdjudicator.selector); registry.vote(disputeId, true); } function test_removedFiledAdjudicatorCanStillVoteOpenDispute() external { uint256 disputeId = _fileDispute(); _respond(disputeId); vm.prank(OWNER); registry.setAdjudicator(ADJUDICATOR_A, false); assertFalse(registry.adjudicator(ADJUDICATOR_A)); assertTrue(registry.eligibleAdjudicator(disputeId, ADJUDICATOR_A)); vm.prank(ADJUDICATOR_A); registry.vote(disputeId, true); assertEq(registry.disputeOf(disputeId).approvals, 1); } function test_refundableFilingBondReturnedExactlyOnceWhenUpheld() external { vm.deal(CHALLENGER, 1 ether); vm.prank(CHALLENGER); uint256 disputeId = registry.fileDispute{ value: 1 ether }( address(subject), 1, keccak256("refundable-bond"), uint64(block.timestamp + 1 hours), uint64(block.timestamp + 2 hours), address(subject), 0 ); assertEq(registry.disputeOf(disputeId).refundableBondWei, 1 ether); _respond(disputeId); vm.prank(ADJUDICATOR_A); registry.vote(disputeId, true); vm.prank(ADJUDICATOR_B); registry.vote(disputeId, true); vm.prank(ADJUDICATOR_C); registry.vote(disputeId, true); assertEq(registry.creditOf(address(0), CHALLENGER), 1 ether); vm.prank(ADJUDICATOR_D); vm.expectRevert(DisputeRegistryV3.AlreadyFinalized.selector); registry.vote(disputeId, true); assertEq(registry.creditOf(address(0), CHALLENGER), 1 ether); } function test_votesCannotPrematurelyFinalizeBeforeResponsePeriod() external { uint256 disputeId = _fileDispute(); vm.prank(ADJUDICATOR_A); vm.expectRevert(DisputeRegistryV3.ResponsePending.selector); registry.vote(disputeId, true); assertEq(registry.disputeOf(disputeId).approvals, 0); _respond(disputeId); vm.prank(ADJUDICATOR_A); registry.vote(disputeId, true); assertEq(registry.disputeOf(disputeId).approvals, 1); assertFalse(registry.disputeOf(disputeId).finalized); } function test_cannotFileDuplicateDisputeForSettlementAttempt() external { _fileDispute(); vm.prank(CHALLENGER); vm.expectRevert(DisputeRegistryV3.DuplicateDispute.selector); registry.fileDispute( address(subject), 1, keccak256("different-evidence-same-attempt"), uint64(block.timestamp + 1 hours), uint64(block.timestamp + 2 hours), address(subject), 0 ); } function test_oppositeAuthorizedChallengerCanFileForSameSettlementAttempt() external { uint256 firstDisputeId = _fileDispute(); vm.prank(OPPOSITE_CHALLENGER); uint256 secondDisputeId = registry.fileDispute( address(subject), 1, keccak256("opposite-authorized-challenger"), uint64(block.timestamp + 1 hours), uint64(block.timestamp + 2 hours), address(subject), 0 ); assertEq(registry.disputeCount(), 2); assertEq(registry.disputeOf(firstDisputeId).challenger, CHALLENGER); assertEq(registry.disputeOf(secondDisputeId).challenger, OPPOSITE_CHALLENGER); assertEq( registry.disputeOf(firstDisputeId).settlementSnapshot, registry.disputeOf(secondDisputeId).settlementSnapshot ); assertEq( registry.disputeOf(firstDisputeId).settlementRequestedAt, registry.disputeOf(secondDisputeId).settlementRequestedAt ); } function test_timeoutForOnePartyDoesNotBlockOppositePartysMeaningfulChallengeOrReplayFinalization() external { vm.deal(CHALLENGER, 1 ether); vm.prank(CHALLENGER); uint256 timeoutDisputeId = registry.fileDispute{ value: 1 ether }( address(subject), 1, keccak256("short-lived-timeout"), uint64(block.timestamp + 30 minutes), uint64(block.timestamp + 1 hours), address(subject), 0 ); vm.warp(block.timestamp + 1 hours); registry.finalizeByTimeout(timeoutDisputeId); assertEq(registry.creditOf(address(0), CHALLENGER), 1 ether); assertEq(uint256(registry.disputeOf(timeoutDisputeId).outcome), uint256(DisputeRegistryV3.Outcome.Rejected)); vm.expectRevert(DisputeRegistryV3.AlreadyFinalized.selector); registry.finalizeByTimeout(timeoutDisputeId); vm.deal(OPPOSITE_CHALLENGER, 1 ether); vm.prank(OPPOSITE_CHALLENGER); uint256 meaningfulDisputeId = registry.fileDispute{ value: 1 ether }( address(subject), 1, keccak256("opposite-party-meaningful-challenge"), uint64(block.timestamp + 1 hours), uint64(block.timestamp + 2 hours), address(subject), 0 ); vm.prank(CHALLENGER); registry.respond(meaningfulDisputeId); vm.prank(ADJUDICATOR_A); registry.vote(meaningfulDisputeId, true); vm.prank(ADJUDICATOR_B); registry.vote(meaningfulDisputeId, true); vm.prank(ADJUDICATOR_C); registry.vote(meaningfulDisputeId, true); assertEq(registry.creditOf(address(0), OPPOSITE_CHALLENGER), 1 ether); assertTrue(registry.disputeOf(meaningfulDisputeId).finalized); assertEq(uint256(registry.disputeOf(meaningfulDisputeId).outcome), uint256(DisputeRegistryV3.Outcome.Upheld)); vm.expectRevert(DisputeRegistryV3.AlreadyFinalized.selector); registry.finalizeByTimeout(meaningfulDisputeId); vm.prank(ADJUDICATOR_D); vm.expectRevert(DisputeRegistryV3.AlreadyFinalized.selector); registry.vote(meaningfulDisputeId, true); } function test_refundableFilingBondReturnedExactlyOnceWhenRejected() external { vm.deal(CHALLENGER, 1 ether); vm.prank(CHALLENGER); uint256 disputeId = registry.fileDispute{ value: 1 ether }( address(subject), 1, keccak256("refundable-rejected-bond"), uint64(block.timestamp + 1 hours), uint64(block.timestamp + 2 hours), address(subject), 0 ); vm.warp(block.timestamp + 2 hours); registry.finalizeByTimeout(disputeId); assertEq(registry.creditOf(address(0), CHALLENGER), 1 ether); assertEq(uint256(registry.disputeOf(disputeId).outcome), uint256(DisputeRegistryV3.Outcome.Rejected)); vm.expectRevert(DisputeRegistryV3.AlreadyFinalized.selector); registry.finalizeByTimeout(disputeId); assertEq(registry.creditOf(address(0), CHALLENGER), 1 ether); } function test_rejectsDecisionDeadlineAtOrAfterSettlementChallengeDeadline() external { vm.prank(CHALLENGER); vm.expectRevert(DisputeRegistryV3.BadDeadlines.selector); registry.fileDispute( address(subject), 1, keccak256("deadline-at-cutoff"), uint64(block.timestamp + 1 hours), uint64(block.timestamp + 1 days), address(subject), 0 ); vm.prank(CHALLENGER); vm.expectRevert(DisputeRegistryV3.BadDeadlines.selector); registry.fileDispute( address(subject), 1, keccak256("deadline-after-cutoff"), uint64(block.timestamp + 1 hours), uint64(block.timestamp + 2 days), address(subject), 0 ); } function test_acceptsDecisionDeadlineStrictlyBeforeSettlementChallengeDeadline() external { vm.prank(CHALLENGER); uint256 disputeId = registry.fileDispute( address(subject), 1, keccak256("deadline-before-cutoff"), uint64(block.timestamp + 1 hours), uint64(block.timestamp + 1 days - 1), address(subject), 0 ); DisputeRegistryV3.Dispute memory dispute = registry.disputeOf(disputeId); assertLt(dispute.decisionDeadline, dispute.challengeDeadline); } function test_legacyThresholdOneAttemptCannotLowerGovernanceConfiguredThreeOfFive() external { vm.prank(CHALLENGER); uint256 disputeId = registry.fileDispute( address(subject), 1, keccak256("threshold-one-attempt"), uint64(block.timestamp + 1 hours), uint64(block.timestamp + 2 hours), 1, address(subject), 0 ); DisputeRegistryV3.Dispute memory dispute = registry.disputeOf(disputeId); assertEq(dispute.adjudicatorThreshold, 3, "challenger lowered the configured threshold"); _respond(disputeId); vm.prank(ADJUDICATOR_A); registry.vote(disputeId, true); dispute = registry.disputeOf(disputeId); assertEq(dispute.approvals, 1); assertFalse(dispute.finalized, "one of five adjudicators upheld the dispute"); } function test_filingFailsClosedUntilGovernanceConfiguresThreshold() external { DisputeRegistryV3 unconfiguredRegistry = new DisputeRegistryV3(OWNER); vm.prank(OWNER); unconfiguredRegistry.setAdjudicator(ADJUDICATOR_A, true); vm.prank(CHALLENGER); vm.expectRevert(DisputeRegistryV3.BadThreshold.selector); unconfiguredRegistry.fileDispute( address(subject), 1, keccak256("unconfigured-threshold"), uint64(block.timestamp + 1 hours), uint64(block.timestamp + 2 hours), address(subject), 0 ); } function test_thresholdChangeAfterFilingDoesNotChangeSnapshot() external { uint256 disputeId = _fileDispute(); vm.prank(OWNER); registry.setAdjudicatorThreshold(1); assertEq(registry.requiredAdjudicatorThreshold(), 1); assertEq(registry.disputeOf(disputeId).adjudicatorThreshold, 3, "open dispute threshold was not snapshotted"); _respond(disputeId); vm.prank(ADJUDICATOR_A); registry.vote(disputeId, true); assertFalse(registry.disputeOf(disputeId).finalized, "post-filing configuration lowered threshold"); } function test_replayedVoteAndInsufficientApprovalsCannotFinalizeThreeOfFiveDispute() external { uint256 disputeId = _fileDispute(); _respond(disputeId); vm.prank(ADJUDICATOR_A); registry.vote(disputeId, true); vm.prank(ADJUDICATOR_A); vm.expectRevert(DisputeRegistryV3.AlreadyVoted.selector); registry.vote(disputeId, true); vm.prank(ADJUDICATOR_B); registry.vote(disputeId, true); DisputeRegistryV3.Dispute memory dispute = registry.disputeOf(disputeId); assertEq(dispute.approvals, 2); assertFalse(dispute.finalized, "insufficient approvals upheld the dispute"); } function test_validGovernanceThresholdApprovalsFinalizeDispute() external { uint256 disputeId = _fileDispute(); _respond(disputeId); vm.prank(ADJUDICATOR_A); registry.vote(disputeId, true); vm.prank(ADJUDICATOR_B); registry.vote(disputeId, true); vm.prank(ADJUDICATOR_C); registry.vote(disputeId, true); DisputeRegistryV3.Dispute memory dispute = registry.disputeOf(disputeId); assertEq(dispute.approvals, 3); assertTrue(dispute.finalized); assertEq(uint256(dispute.outcome), uint256(DisputeRegistryV3.Outcome.Upheld)); } function testFuzz_legacyThresholdInputCannotAffectSnapshot(uint8 attemptedThreshold) external { vm.prank(CHALLENGER); uint256 disputeId = registry.fileDispute( address(subject), 1, keccak256(abi.encode("fuzzed-threshold-input", attemptedThreshold)), uint64(block.timestamp + 1 hours), uint64(block.timestamp + 2 hours), attemptedThreshold, address(subject), 0 ); assertEq(registry.disputeOf(disputeId).adjudicatorThreshold, 3); } function _fileDispute() internal returns (uint256) { vm.prank(CHALLENGER); return registry.fileDispute( address(subject), 1, keccak256("governance-snapshotted-threshold"), uint64(block.timestamp + 1 hours), uint64(block.timestamp + 2 hours), address(subject), 0 ); } function _respond(uint256 disputeId) internal { vm.prank(address(0xBEEF)); registry.respond(disputeId); } }