// SPDX-License-Identifier: MIT pragma solidity 0.8.24; import { TestBase } from "./TestBase.sol"; import { TaskMilestoneEscrowV3 } from "../src/TaskMilestoneEscrowV3.sol"; import { DeterministicCoverageV3, IObjectiveTriggerV3 } from "../src/DeterministicCoverageV3.sol"; import { VoluntaryMutualAidV3 } from "../src/VoluntaryMutualAidV3.sol"; import { RewardVaultV3 } from "../src/RewardVaultV3.sol"; import { NativeAssetLedgerV3 } from "../src/common/NativeAssetLedgerV3.sol"; import { EmergencyControlsV3 } from "../src/common/EmergencyControlsV3.sol"; contract TriggerMock is IObjectiveTriggerV3 { mapping(bytes32 => bool) public value; function set(bytes32 key, bool result) external { value[key] = result; } function triggered(bytes32 key, bytes32, bytes32) external view returns (bool) { return value[key]; } } contract MalformedTrigger { fallback() external { assembly { mstore(0, 1) return(0, 31) } } } contract RevertingTrigger { fallback() external { revert("objective source unavailable"); } } contract WithdrawalRecipient { VoluntaryMutualAidV3 public immutable aid; bool public rejectTransfer; bool public attemptedReentry; constructor(VoluntaryMutualAidV3 aid_) { aid = aid_; } function create(bytes32 purposeHash, uint96 cap, uint64 deadline) external returns (uint256) { return aid.createCampaign(purposeHash, cap, deadline); } function withdraw() external { aid.withdrawCredit(); } function setRejectTransfer(bool value) external { rejectTransfer = value; } receive() external payable { if (rejectTransfer) revert("reject"); attemptedReentry = true; (bool ok,) = address(aid).call(abi.encodeCall(aid.withdrawCredit, ())); require(!ok, "reentry unexpectedly succeeded"); } } contract EconomicSuccessorsTest is TestBase { address internal constant GOVERNANCE = address(0x1001); address internal constant GUARDIAN = address(0x1002); address internal constant CREATOR = address(0x2001); address internal constant WORKER = address(0x2002); address internal constant INSURED = address(0x2003); address internal constant DONOR = address(0x2004); address internal constant ISSUER = address(0x2005); TaskMilestoneEscrowV3 internal escrow; DeterministicCoverageV3 internal coverage; VoluntaryMutualAidV3 internal aid; RewardVaultV3 internal rewards; TriggerMock internal trigger; function setUp() public { escrow = new TaskMilestoneEscrowV3(GOVERNANCE, GUARDIAN, 1 days, 10 ether, 30 ether, 8); coverage = new DeterministicCoverageV3(GOVERNANCE, GUARDIAN, 1 days, 10 ether, 30 ether); aid = new VoluntaryMutualAidV3(GOVERNANCE, GUARDIAN, 1 days, 10 ether, 5 ether, 30 ether); rewards = new RewardVaultV3(GOVERNANCE, GUARDIAN, 1 days, 10 ether, 2 ether, 30 ether); trigger = new TriggerMock(); vm.deal(CREATOR, 100 ether); vm.deal(WORKER, 100 ether); vm.deal(INSURED, 100 ether); vm.deal(DONOR, 100 ether); } function testTaskMilestonesAndDefaultAtExactBoundary() public { uint96[] memory amounts = new uint96[](2); amounts[0] = 1 ether; amounts[1] = 2 ether; uint64[] memory submitPeriods = new uint64[](2); submitPeriods[0] = 1 days; submitPeriods[1] = 2 days; uint64[] memory reviewPeriods = new uint64[](2); reviewPeriods[0] = 3 days; reviewPeriods[1] = 4 days; vm.prank(CREATOR); uint256 id = escrow.openTask{ value: 3 ether }( WORKER, keccak256("criteria"), uint64(block.timestamp + 1 days), amounts, submitPeriods, reviewPeriods ); assertAccounting( address(escrow), escrow.assets(), escrow.totalCredits(), escrow.totalLocked(), escrow.totalReserved(), escrow.surplus() ); vm.prank(WORKER); escrow.acceptTask(id); vm.prank(WORKER); escrow.submitMilestone(id, keccak256("work-1")); TaskMilestoneEscrowV3.Task memory task = escrow.taskOf(id); vm.warp(task.actionDeadline); vm.expectRevert(TaskMilestoneEscrowV3.DeadlineNotPassed.selector); escrow.defaultApprove(id); vm.warp(task.actionDeadline + 1); escrow.defaultApprove(id); assertEq(escrow.totalLocked(), 2 ether); assertEq(escrow.totalCredits(), 1 ether); vm.prank(WORKER); escrow.submitMilestone(id, keccak256("work-2")); vm.prank(CREATOR); escrow.approveMilestone(id); assertEq(escrow.totalLocked(), 0); assertEq(escrow.totalCredits(), 3 ether); } function testPauseDoesNotBlockTaskRecoveryOrWithdrawal() public { uint96[] memory amounts = new uint96[](1); amounts[0] = 2 ether; uint64[] memory periods = new uint64[](1); periods[0] = 1 days; vm.prank(CREATOR); uint256 id = escrow.openTask{ value: 2 ether }( WORKER, keccak256("criteria"), uint64(block.timestamp + 1 days), amounts, periods, periods ); vm.prank(GUARDIAN); escrow.pause(); vm.warp(block.timestamp + 1 days + 1); escrow.recoverTimedOut(id); uint256 beforeBalance = CREATOR.balance; vm.prank(CREATOR); escrow.withdrawCredit(); assertEq(CREATOR.balance, beforeBalance + 2 ether); assertTrue(escrow.isSolvent()); } function testCreatorRejectionRefundsCurrentAndRemainingAndIsTerminal() public { uint96[] memory amounts = new uint96[](2); amounts[0] = 1 ether; amounts[1] = 2 ether; uint64[] memory periods = new uint64[](2); periods[0] = 1 days; periods[1] = 1 days; vm.prank(CREATOR); uint256 id = escrow.openTask{ value: 3 ether }( WORKER, keccak256("criteria"), uint64(block.timestamp + 1 days), amounts, periods, periods ); vm.prank(WORKER); escrow.acceptTask(id); vm.prank(WORKER); escrow.submitMilestone(id, keccak256("rejected-work")); vm.expectRevert(TaskMilestoneEscrowV3.Unauthorized.selector); vm.prank(WORKER); escrow.rejectMilestone(id, keccak256("self-reject")); vm.prank(CREATOR); escrow.rejectMilestone(id, keccak256("objective-rejection-reason")); TaskMilestoneEscrowV3.Task memory task = escrow.taskOf(id); assertEq(uint256(task.state), uint256(TaskMilestoneEscrowV3.TaskState.Rejected)); assertEq(task.remaining, 0); assertEq(escrow.creditOf(CREATOR), 3 ether); assertEq(escrow.creditOf(WORKER), 0); assertEq(escrow.totalLocked(), 0); assertTrue(escrow.isSolvent()); vm.expectRevert(TaskMilestoneEscrowV3.BadState.selector); vm.prank(CREATOR); escrow.rejectMilestone(id, keccak256("replay")); vm.expectRevert(TaskMilestoneEscrowV3.BadState.selector); vm.prank(CREATOR); escrow.approveMilestone(id); vm.expectRevert(TaskMilestoneEscrowV3.BadState.selector); escrow.defaultApprove(id); } function testPriorApprovedMilestonesStayPaidWhenLaterMilestoneRejected() public { uint96[] memory amounts = new uint96[](2); amounts[0] = 1 ether; amounts[1] = 2 ether; uint64[] memory periods = new uint64[](2); periods[0] = 1 days; periods[1] = 1 days; vm.prank(CREATOR); uint256 id = escrow.openTask{ value: 3 ether }( WORKER, keccak256("criteria"), uint64(block.timestamp + 1 days), amounts, periods, periods ); vm.prank(WORKER); escrow.acceptTask(id); vm.prank(WORKER); escrow.submitMilestone(id, keccak256("approved-work")); vm.prank(CREATOR); escrow.approveMilestone(id); vm.prank(WORKER); escrow.submitMilestone(id, keccak256("later-rejected-work")); vm.prank(CREATOR); escrow.rejectMilestone(id, keccak256("later-rejection")); assertEq(escrow.creditOf(WORKER), 1 ether); assertEq(escrow.creditOf(CREATOR), 2 ether); assertEq(escrow.totalCredits(), 3 ether); assertEq(escrow.totalLocked(), 0); assertTrue(escrow.isSolvent()); } function testFuzzRejectionPreservesSolvency(uint96 rawFirst, uint96 rawSecond) public { uint96 first = uint96(bound(rawFirst, 1, 5 ether)); uint96 second = uint96(bound(rawSecond, 1, 5 ether)); uint96[] memory amounts = new uint96[](2); amounts[0] = first; amounts[1] = second; uint64[] memory periods = new uint64[](2); periods[0] = 1; periods[1] = 1; vm.prank(CREATOR); uint256 id = escrow.openTask{ value: uint256(first) + second }( WORKER, keccak256("fuzz-criteria"), uint64(block.timestamp + 1), amounts, periods, periods ); vm.prank(WORKER); escrow.acceptTask(id); vm.prank(WORKER); escrow.submitMilestone(id, keccak256("fuzz-work")); vm.prank(CREATOR); escrow.rejectMilestone(id, keccak256("fuzz-reason")); assertEq(escrow.creditOf(CREATOR), uint256(first) + second); assertEq(escrow.creditOf(WORKER), 0); assertTrue(escrow.isSolvent()); } function testCoverageIsFullyReservedAndDeterministic() public { bytes32 fact = keccak256("objective-fact"); vm.prank(CREATOR); uint256 id = coverage.offerCoverage{ value: 5 ether }( INSURED, address(trigger), address(trigger).codehash, keccak256("covered-bond-or-task"), fact, keccak256("terms"), 1 ether, uint64(block.timestamp + 1 days), uint64(block.timestamp + 10 days) ); assertEq(coverage.totalReserved(), 5 ether); vm.prank(INSURED); coverage.acceptCoverage{ value: 1 ether }(id); assertEq(coverage.totalCredits(), 1 ether); vm.expectRevert(DeterministicCoverageV3.TriggerFalse.selector); coverage.claim(id); trigger.set(fact, true); coverage.claim(id); assertEq(coverage.totalReserved(), 0); assertEq(coverage.totalCredits(), 6 ether); assertTrue(coverage.isSolvent()); } function testCoverageOfferCancellationAndExpiredOfferRecoveryBoundaries() public { bytes32 fact = keccak256("cancel-fact"); vm.prank(CREATOR); uint256 cancelled = coverage.offerCoverage{ value: 2 ether }( INSURED, address(trigger), address(trigger).codehash, keccak256("cancel-object"), fact, keccak256("cancel-terms"), 1 ether, uint64(block.timestamp + 1 days), uint64(block.timestamp + 2 days) ); vm.expectRevert(DeterministicCoverageV3.Unauthorized.selector); vm.prank(INSURED); coverage.cancelOffer(cancelled); vm.prank(CREATOR); coverage.cancelOffer(cancelled); assertEq(coverage.creditOf(CREATOR), 2 ether); vm.prank(CREATOR); uint256 expired = coverage.offerCoverage{ value: 3 ether }( INSURED, address(trigger), address(trigger).codehash, keccak256("expired-object"), fact, keccak256("expired-terms"), 1 ether, uint64(block.timestamp + 1 days), uint64(block.timestamp + 2 days) ); DeterministicCoverageV3.Policy memory policy = coverage.policyOf(expired); vm.warp(policy.offerDeadline); vm.expectRevert(DeterministicCoverageV3.DeadlineNotPassed.selector); coverage.recoverExpiredOffer(expired); vm.warp(policy.offerDeadline + 1); coverage.recoverExpiredOffer(expired); assertEq(coverage.creditOf(CREATOR), 5 ether); assertEq(coverage.totalReserved(), 0); assertTrue(coverage.isSolvent()); } function testCoverageReleaseUntriggeredAtExactBoundaryAndWhilePaused() public { bytes32 fact = keccak256("untriggered-fact"); vm.prank(CREATOR); uint256 id = coverage.offerCoverage{ value: 2 ether }( INSURED, address(trigger), address(trigger).codehash, keccak256("release-object"), fact, keccak256("release-terms"), 1 ether, uint64(block.timestamp + 1 days), uint64(block.timestamp + 2 days) ); vm.prank(INSURED); coverage.acceptCoverage{ value: 1 ether }(id); DeterministicCoverageV3.Policy memory policy = coverage.policyOf(id); vm.prank(GUARDIAN); coverage.pause(); vm.warp(policy.coverageDeadline); vm.expectRevert(DeterministicCoverageV3.DeadlineNotPassed.selector); coverage.releaseUntriggered(id); vm.warp(policy.coverageDeadline + 1); coverage.releaseUntriggered(id); assertEq(coverage.creditOf(CREATOR), 3 ether); assertEq(coverage.totalReserved(), 0); } function testCoverageRejectsChangedCodeMalformedFalseAndRevertingVerifier() public { bytes32 fact = keccak256("verifier-adversarial-fact"); vm.prank(CREATOR); uint256 changed = coverage.offerCoverage{ value: 1 ether }( INSURED, address(trigger), address(trigger).codehash, keccak256("changed-code-object"), fact, keccak256("changed-code-terms"), 1 ether, uint64(block.timestamp + 1 days), uint64(block.timestamp + 2 days) ); vm.prank(INSURED); coverage.acceptCoverage{ value: 1 ether }(changed); vm.etch(address(trigger), hex"60006000fd"); vm.expectRevert(DeterministicCoverageV3.InvalidVerifier.selector); coverage.claim(changed); MalformedTrigger malformed = new MalformedTrigger(); uint256 malformedId = _activePolicy(address(malformed), fact, "malformed"); vm.expectRevert(DeterministicCoverageV3.TriggerFalse.selector); coverage.claim(malformedId); RevertingTrigger revertingTarget = new RevertingTrigger(); uint256 revertingId = _activePolicy(address(revertingTarget), fact, "reverting"); vm.expectRevert(DeterministicCoverageV3.TriggerFalse.selector); coverage.claim(revertingId); TriggerMock falseTarget = new TriggerMock(); uint256 falseId = _activePolicy(address(falseTarget), fact, "false"); vm.expectRevert(DeterministicCoverageV3.TriggerFalse.selector); coverage.claim(falseId); } function testFuzzMutualAidNeverExceedsCampaignOrAggregate(uint96 rawA, uint96 rawB) public { uint256 a = bound(rawA, 1, 5 ether); uint256 b = bound(rawB, 1, 5 ether); uint96 cap = uint96(a + b); vm.assume(cap <= 10 ether); vm.prank(INSURED); uint256 id = aid.createCampaign(keccak256("aid"), cap, uint64(block.timestamp + 7 days)); vm.prank(DONOR); aid.contribute{ value: a }(id); vm.prank(CREATOR); aid.contribute{ value: b }(id); assertEq(aid.totalCredits(), a + b); assertEq(aid.assets(), a + b); assertEq(aid.surplus(), 0); assertTrue(aid.isSolvent()); } function testRewardsCanOnlyUseSeparateProgramReserve() public { vm.prank(CREATOR); uint256 id = rewards.createProgram{ value: 4 ether }( ISSUER, keccak256("objective reward rules"), uint64(block.timestamp + 7 days) ); vm.prank(ISSUER); rewards.issueAtEpoch(id, 1, keccak256("issuance-1"), WORKER, 2 ether); vm.expectRevert(RewardVaultV3.DuplicateIssuance.selector); vm.prank(ISSUER); rewards.issueAtEpoch(id, 1, keccak256("issuance-1"), WORKER, 1 ether); assertEq(rewards.totalReserved(), 2 ether); assertEq(rewards.totalCredits(), 2 ether); vm.warp(block.timestamp + 7 days + 1); rewards.recoverProgram(id); assertEq(rewards.totalReserved(), 0); assertEq(rewards.totalCredits(), 4 ether); } function testMutualCancellationRequiresBothParticipants() public { uint96[] memory amounts = new uint96[](1); amounts[0] = 2 ether; uint64[] memory periods = new uint64[](1); periods[0] = 1 days; vm.prank(CREATOR); uint256 id = escrow.openTask{ value: 2 ether }( WORKER, keccak256("mutual-cancel"), uint64(block.timestamp + 1 days), amounts, periods, periods ); vm.prank(WORKER); escrow.acceptTask(id); vm.prank(CREATOR); escrow.requestMutualCancellation(id); assertEq(escrow.totalLocked(), 2 ether); vm.prank(CREATOR); escrow.requestMutualCancellation(id); assertEq(escrow.totalLocked(), 2 ether); vm.prank(WORKER); escrow.requestMutualCancellation(id); assertEq(escrow.totalLocked(), 0); assertEq(escrow.creditOf(CREATOR), 2 ether); } function testCampaignCloseByRecipientOrPermissionlesslyAfterDeadline() public { vm.prank(INSURED); uint256 byRecipient = aid.createCampaign(keccak256("recipient-close"), 1 ether, uint64(block.timestamp + 1 days)); vm.prank(INSURED); aid.closeCampaign(byRecipient); assertTrue(aid.campaignOf(byRecipient).closed); vm.prank(INSURED); uint256 afterDeadline = aid.createCampaign(keccak256("deadline-close"), 1 ether, uint64(block.timestamp + 1 days)); vm.expectRevert(VoluntaryMutualAidV3.DeadlineNotPassed.selector); aid.closeCampaign(afterDeadline); vm.warp(block.timestamp + 1 days + 1); aid.closeCampaign(afterDeadline); assertTrue(aid.campaignOf(afterDeadline).closed); } function testPullPaymentFailurePreservesCreditAndReentryCannotDoubleWithdraw() public { WithdrawalRecipient recipient = new WithdrawalRecipient(aid); uint256 campaign = recipient.create(keccak256("withdrawal-recipient"), 2 ether, uint64(block.timestamp + 1 days)); vm.prank(DONOR); aid.contribute{ value: 2 ether }(campaign); recipient.setRejectTransfer(true); vm.expectRevert(NativeAssetLedgerV3.NativeTransferFailed.selector); recipient.withdraw(); assertEq(aid.creditOf(address(recipient)), 2 ether); assertEq(aid.totalCredits(), 2 ether); recipient.setRejectTransfer(false); recipient.withdraw(); assertTrue(recipient.attemptedReentry()); assertEq(address(recipient).balance, 2 ether); assertEq(aid.creditOf(address(recipient)), 0); assertEq(aid.totalCredits(), 0); assertTrue(aid.isSolvent()); } function testPauseUnpauseBoundariesAcrossEconomicModules() public { vm.prank(GUARDIAN); escrow.pause(); vm.prank(GUARDIAN); coverage.pause(); vm.prank(GUARDIAN); aid.pause(); vm.prank(GUARDIAN); rewards.pause(); vm.expectRevert(EmergencyControlsV3.UnpauseTooEarly.selector); vm.prank(GOVERNANCE); escrow.unpause(); vm.expectRevert(EmergencyControlsV3.EmergencyUnauthorized.selector); vm.prank(GUARDIAN); coverage.unpause(); vm.warp(block.timestamp + 1 days); vm.prank(GOVERNANCE); escrow.unpause(); vm.prank(GOVERNANCE); coverage.unpause(); vm.prank(GOVERNANCE); aid.unpause(); vm.prank(GOVERNANCE); rewards.unpause(); assertFalse(escrow.paused()); assertFalse(coverage.paused()); assertFalse(aid.paused()); assertFalse(rewards.paused()); } function testForcedEthIsOnlySurplus() public { vm.deal(address(aid), 3 ether); assertEq(aid.assets(), 3 ether); assertEq(aid.accountedLiabilities(), 0); assertEq(aid.surplus(), 3 ether); } function assertAccounting( address target, uint256 assets_, uint256 credits, uint256 locked, uint256 reserved, uint256 surplus_ ) internal pure { target; assertEq(assets_, credits + locked + reserved + surplus_); } function _activePolicy(address verifier, bytes32 fact, string memory salt) internal returns (uint256 id) { vm.prank(CREATOR); id = coverage.offerCoverage{ value: 1 ether }( INSURED, verifier, verifier.codehash, keccak256(abi.encodePacked(salt, "-object")), fact, keccak256(abi.encodePacked(salt, "-terms")), 1 ether, uint64(block.timestamp + 1 days), uint64(block.timestamp + 2 days) ); vm.prank(INSURED); coverage.acceptCoverage{ value: 1 ether }(id); } }