// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; // ============================================================================ // Foundry test vectors for DemandEscrow (spec §6 / Section B). // Run: forge test -vvv (see contracts/README.md for setup) // These vectors are the executable specification the state machine must satisfy // before an auditor signs off. They mirror server/src/demand/escrow.ts, which is // the in-process dev simulation of this exact machine. // ============================================================================ import {Test} from "forge-std/Test.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {DemandEscrow} from "../DemandEscrow.sol"; contract MockUSDC is ERC20 { constructor() ERC20("USD Coin", "USDC") {} function decimals() public pure override returns (uint8) { return 6; } function mint(address to, uint256 amount) external { _mint(to, amount); } } contract DemandEscrowTest is Test { DemandEscrow internal escrow; MockUSDC internal usdc; address internal arbiter = makeAddr("arbiter"); address internal buyer = makeAddr("buyer"); address internal seller = makeAddr("seller"); address internal stranger = makeAddr("stranger"); uint96 internal constant AMOUNT = 1_000e6; // 1,000 USDC (6 decimals) bytes32 internal constant TERMS = keccak256("terms-v1"); bytes32 internal constant DELIVERY_REF = keccak256("ipfs://delivery"); uint256 internal constant NONCE = 42; uint40 internal fundDeadline; uint40 internal deliverDeadline; uint40 internal constant REVIEW = 3 days; function setUp() public { usdc = new MockUSDC(); // this test contract is the deployer → owner() (fee recipient). escrow = new DemandEscrow(usdc, arbiter); usdc.mint(buyer, 1_000_000e6); fundDeadline = uint40(block.timestamp + 3 days); deliverDeadline = uint40(block.timestamp + 14 days); } // ---- helpers ---- function _create() internal returns (bytes32 id) { vm.prank(buyer); id = escrow.create(seller, AMOUNT, TERMS, fundDeadline, deliverDeadline, REVIEW, NONCE); } function _fund(bytes32 id) internal { vm.startPrank(buyer); usdc.approve(address(escrow), AMOUNT); escrow.fund(id); vm.stopPrank(); } function _deliver(bytes32 id) internal { vm.prank(seller); escrow.deliver(id, DELIVERY_REF); } function _fee(uint256 amount) internal view returns (uint256) { return (amount * escrow.protocolFeeBps()) / 10_000; } // ---- happy path ---- function test_HappyPath_CreateFundDeliverRelease() public { bytes32 id = _create(); _fund(id); assertEq(escrow.totalLocked(), AMOUNT, "locked after fund"); assertEq(usdc.balanceOf(address(escrow)), AMOUNT, "escrow holds funds"); _deliver(id); vm.prank(buyer); escrow.release(id); uint256 fee = _fee(AMOUNT); assertEq(escrow.withdrawable(seller), AMOUNT - fee, "seller net"); assertEq(escrow.withdrawable(escrow.owner()), fee, "protocol fee"); assertEq(escrow.totalLocked(), 0, "unlocked after release"); // pull-payment uint256 before = usdc.balanceOf(seller); vm.prank(seller); escrow.withdraw(); assertEq(usdc.balanceOf(seller) - before, AMOUNT - fee, "seller withdrew net"); } function test_Release_ByTimeout_AnyoneMayTrigger() public { bytes32 id = _create(); _fund(id); _deliver(id); // before the review window closes a stranger cannot release vm.prank(stranger); vm.expectRevert(DemandEscrow.BadState.selector); escrow.release(id); // after it closes, anyone may push the auto-release vm.warp(block.timestamp + REVIEW + 1); vm.prank(stranger); escrow.release(id); assertEq(escrow.withdrawable(seller), AMOUNT - _fee(AMOUNT), "auto-released to seller"); } // ---- refund path (seller never delivered) ---- function test_RefundExpired_ReturnsFullAmountToBuyer() public { bytes32 id = _create(); _fund(id); vm.warp(block.timestamp + 15 days); // past deliverDeadline escrow.refundExpired(id); assertEq(escrow.withdrawable(buyer), AMOUNT, "buyer fully refunded, no fee"); assertEq(escrow.totalLocked(), 0, "unlocked after refund"); } function test_RefundExpired_BeforeDeadline_Reverts() public { bytes32 id = _create(); _fund(id); vm.expectRevert(DemandEscrow.TooEarly.selector); escrow.refundExpired(id); } // ---- dispute + resolve ---- function test_Dispute_Then_Resolve_SplitsWithFeeOnSellerPortion() public { bytes32 id = _create(); _fund(id); _deliver(id); vm.prank(buyer); escrow.dispute(id, "ipfs://reason"); uint96 toSeller = 600e6; vm.prank(arbiter); escrow.resolve(id, toSeller); uint256 fee = _fee(toSeller); assertEq(escrow.withdrawable(seller), toSeller - fee, "seller portion net of fee"); assertEq(escrow.withdrawable(buyer), AMOUNT - toSeller, "buyer portion, no fee"); assertEq(escrow.withdrawable(escrow.owner()), fee, "fee only on seller portion"); assertEq(escrow.totalLocked(), 0, "unlocked after resolve"); } function test_Dispute_ByNonParty_Reverts() public { bytes32 id = _create(); _fund(id); _deliver(id); vm.prank(stranger); vm.expectRevert(DemandEscrow.NotParty.selector); escrow.dispute(id, "x"); } function test_Dispute_AfterReviewWindow_Reverts() public { bytes32 id = _create(); _fund(id); _deliver(id); vm.warp(block.timestamp + REVIEW + 1); vm.prank(buyer); vm.expectRevert(DemandEscrow.TooLate.selector); escrow.dispute(id, "x"); } function test_Resolve_ByNonArbiter_Reverts() public { bytes32 id = _create(); _fund(id); _deliver(id); vm.prank(buyer); escrow.dispute(id, "x"); vm.prank(stranger); vm.expectRevert(DemandEscrow.BadState.selector); escrow.resolve(id, 1); } function test_Resolve_OverAmount_Reverts() public { bytes32 id = _create(); _fund(id); _deliver(id); vm.prank(buyer); escrow.dispute(id, "x"); vm.prank(arbiter); vm.expectRevert(DemandEscrow.TooLarge.selector); escrow.resolve(id, AMOUNT + 1); } // ---- caps ---- function test_Create_ZeroAmount_Reverts() public { vm.prank(buyer); vm.expectRevert(DemandEscrow.TooLarge.selector); escrow.create(seller, 0, TERMS, fundDeadline, deliverDeadline, REVIEW, NONCE); } function test_Create_OverPerEscrowCap_Reverts() public { vm.prank(buyer); vm.expectRevert(DemandEscrow.TooLarge.selector); escrow.create( seller, uint96(escrow.perEscrowCap() + 1), TERMS, fundDeadline, deliverDeadline, REVIEW, NONCE ); } function test_Fund_OverTotalLockedCap_Reverts() public { // shrink the global cap below a single escrow, then funding must revert escrow.setCaps(escrow.perEscrowCap(), AMOUNT - 1); bytes32 id = _create(); vm.startPrank(buyer); usdc.approve(address(escrow), AMOUNT); vm.expectRevert(DemandEscrow.TooLarge.selector); escrow.fund(id); vm.stopPrank(); } // ---- access / state guards ---- function test_Fund_NotBuyer_Reverts() public { bytes32 id = _create(); vm.prank(stranger); vm.expectRevert(DemandEscrow.BadState.selector); escrow.fund(id); } function test_Fund_PastDeadline_Reverts() public { bytes32 id = _create(); vm.warp(block.timestamp + 4 days); vm.startPrank(buyer); usdc.approve(address(escrow), AMOUNT); vm.expectRevert(DemandEscrow.TooLate.selector); escrow.fund(id); vm.stopPrank(); } function test_DoubleFund_Reverts() public { bytes32 id = _create(); _fund(id); vm.startPrank(buyer); usdc.approve(address(escrow), AMOUNT); vm.expectRevert(DemandEscrow.BadState.selector); escrow.fund(id); vm.stopPrank(); } function test_Deliver_NotSeller_Reverts() public { bytes32 id = _create(); _fund(id); vm.prank(stranger); vm.expectRevert(DemandEscrow.BadState.selector); escrow.deliver(id, DELIVERY_REF); } function test_Deliver_PastDeadline_Reverts() public { bytes32 id = _create(); _fund(id); vm.warp(block.timestamp + 15 days); vm.prank(seller); vm.expectRevert(DemandEscrow.TooLate.selector); escrow.deliver(id, DELIVERY_REF); } // ---- owner ops ---- function test_SetFee_OverMax_Reverts() public { vm.expectRevert(DemandEscrow.TooLarge.selector); escrow.setFee(escrow.MAX_FEE_BPS() + 1); } function test_SetFee_AtMax_Works() public { escrow.setFee(escrow.MAX_FEE_BPS()); assertEq(escrow.protocolFeeBps(), escrow.MAX_FEE_BPS()); } function test_OnlyOwner_SetFee() public { vm.prank(stranger); vm.expectRevert(); escrow.setFee(50); } function test_Pause_BlocksCreateAndFund() public { escrow.pause(); vm.prank(buyer); vm.expectRevert(); escrow.create(seller, AMOUNT, TERMS, fundDeadline, deliverDeadline, REVIEW, NONCE); } // ---- fee math (fuzz) ---- function testFuzz_ReleaseConservesValue(uint96 amount, uint16 bps) public { amount = uint96(bound(amount, 1, escrow.perEscrowCap())); bps = uint16(bound(bps, 0, escrow.MAX_FEE_BPS())); escrow.setFee(bps); usdc.mint(buyer, amount); vm.prank(buyer); bytes32 id = escrow.create(seller, amount, TERMS, fundDeadline, deliverDeadline, REVIEW, NONCE + amount); vm.startPrank(buyer); usdc.approve(address(escrow), amount); escrow.fund(id); vm.stopPrank(); _deliver(id); vm.prank(buyer); escrow.release(id); uint256 fee = (uint256(amount) * bps) / 10_000; // value is conserved: nothing is created or destroyed, only split assertEq(escrow.withdrawable(seller) + escrow.withdrawable(escrow.owner()), amount, "seller + fee == amount"); assertEq(escrow.withdrawable(escrow.owner()), fee, "fee exact"); } }