diff --git a/contracts/v2/adapters/CircleRefundProtocolAdapter.sol b/contracts/v2/adapters/CircleRefundProtocolAdapter.sol new file mode 100644 index 0000000..c34ae14 --- /dev/null +++ b/contracts/v2/adapters/CircleRefundProtocolAdapter.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {IRefundProtocolV2} from "../PredgeRefundArbiterV2.sol"; +import {ICircleRefundProtocol} from "../interfaces/ICircleRefundProtocol.sol"; + +interface IPredgeRefundArbiterView { + function refundProtocol() external view returns (address); +} + +/// @title CircleRefundProtocolAdapter +/// @notice Lets an unchanged `PredgeRefundArbiterV2` act as the arbiter of Circle's Refund Protocol. +/// The adapter is the address set as `arbiter` in Circle's constructor; it accepts write calls +/// only from the one Predge arbiter bound to it, and it translates the read side: +/// +/// Predge `paymentInfo` <- Circle `payments(id)` +/// payer <- refundTo (who a refund is paid to) +/// payee <- to +/// amount <- amount * amountScale (USDC 6 decimals -> native wei) +/// releaseTimestamp <- releaseTimestamp +/// +/// A payment that does not exist, is already refunded, or has been partly withdrawn reads as +/// all-zero, so no evidence can be filed and no ruling can be made on it. +/// +/// Wiring is a three-way cycle (Circle needs the adapter address, the Predge arbiter needs the +/// adapter address, the adapter needs both), so the adapter is deployed first and `bind` is +/// called once by its deployer after the other two exist. `bind` checks both directions of the +/// wiring and can never be called again. +/// +/// @dev Holds no funds and has no owner. It does not expose Circle's `earlyWithdrawByArbiter`, +/// `depositArbiterFunds` or `withdrawArbiterFunds`: a refund is paid only from the recipient's +/// balance inside Circle's contract. If that balance cannot cover it, Circle reverts, the +/// Predge `finalize`/`resolve` reverts with it, and the ruling can only be voided by `expire`. +contract CircleRefundProtocolAdapter is IRefundProtocolV2 { + address public immutable deployer; + /// @notice Multiplier from the token's units to the native wei the Predge bond is sized in + /// (1e12 on Arc: USDC ERC-20 has 6 decimals, native USDC gas has 18). + uint256 public immutable amountScale; + + ICircleRefundProtocol public circle; + address public predgeArbiter; + + event Bound(address indexed circle, address indexed predgeArbiter); + event RefundForwarded(uint256 indexed paymentID); + event RefundAlreadyDone(uint256 indexed paymentID); + event LockupForwarded(address indexed recipient, uint256 lockupSeconds); + + error ZeroAddress(); + error ZeroScale(); + error NotDeployer(); + error AlreadyBound(); + error NotBound(); + error NotCircleArbiter(); + error ArbiterNotWired(); + error NotPredgeArbiter(); + + constructor(uint256 amountScale_) { + if (amountScale_ == 0) revert ZeroScale(); + deployer = msg.sender; + amountScale = amountScale_; + } + + /// @notice One-time wiring, deployer only. Requires `circle_.arbiter() == this` and + /// `predgeArbiter_.refundProtocol() == this`. + function bind(address circle_, address predgeArbiter_) external { + if (msg.sender != deployer) revert NotDeployer(); + if (address(circle) != address(0)) revert AlreadyBound(); + if (circle_ == address(0) || predgeArbiter_ == address(0)) revert ZeroAddress(); + if (ICircleRefundProtocol(circle_).arbiter() != address(this)) revert NotCircleArbiter(); + if (IPredgeRefundArbiterView(predgeArbiter_).refundProtocol() != address(this)) revert ArbiterNotWired(); + circle = ICircleRefundProtocol(circle_); + predgeArbiter = predgeArbiter_; + emit Bound(circle_, predgeArbiter_); + } + + modifier onlyPredgeArbiter() { + if (msg.sender != predgeArbiter || predgeArbiter == address(0)) revert NotPredgeArbiter(); + _; + } + + /// @inheritdoc IRefundProtocolV2 + function paymentInfo(uint256 paymentID) + external + view + returns (address payer, address payee, uint256 amount, uint256 releaseTimestamp) + { + if (address(circle) == address(0)) revert NotBound(); + (address to, uint256 amt, uint256 release, address refundTo, uint256 withdrawn, bool refunded) = + circle.payments(paymentID); + if (to == address(0) || refunded || withdrawn != 0) return (address(0), address(0), 0, 0); + return (refundTo, to, amt * amountScale, release); + } + + /// @notice Forward a Predge refund to Circle. If the payment was already refunded (for example the + /// recipient called `refundByRecipient` during the window), the outcome the ruling asks for + /// already holds, so this returns instead of reverting. + function refundByArbiter(uint256 paymentID) external onlyPredgeArbiter { + (,,,,, bool refunded) = circle.payments(paymentID); + if (refunded) { + emit RefundAlreadyDone(paymentID); + return; + } + emit RefundForwarded(paymentID); + circle.refundByArbiter(paymentID); + } + + /// @notice Forward a lockup change. Predge's own bound is (window + 14 days, 365 days]; Circle's + /// cap of 180 days also applies and reverts above it. + function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external onlyPredgeArbiter { + emit LockupForwarded(recipient, recipientLockupSeconds); + circle.setLockupSeconds(recipient, recipientLockupSeconds); + } +} diff --git a/contracts/v2/interfaces/ICircleRefundProtocol.sol b/contracts/v2/interfaces/ICircleRefundProtocol.sol new file mode 100644 index 0000000..aef900f --- /dev/null +++ b/contracts/v2/interfaces/ICircleRefundProtocol.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @notice The subset of Circle's Refund Protocol (github.com/circlefin/refund-protocol, +/// src/RefundProtocol.sol, read 01.11.2026) that Predge touches. Written by Predge from the +/// public function signatures; no Circle code is copied or imported. Only signatures are +/// declared, so this file carries no Circle implementation. +/// +/// Facts about the real contract that shape the adapter (all from reading its source): +/// - `arbiter` is set once in the constructor; there is no setter. Every arbiter-only call +/// (`refundByArbiter`, `setLockupSeconds`, `earlyWithdrawByArbiter`, `depositArbiterFunds`, +/// `withdrawArbiterFunds`) checks `msg.sender == arbiter`. +/// - There is no `paymentInfo`; the public `payments(id)` getter returns the struct fields +/// `(to, amount, releaseTimestamp, refundTo, withdrawnAmount, refunded)`. The address that +/// called `pay` is NOT stored: the party a refund goes to is `refundTo`, which the current +/// `refundTo` may change at any time with `updateRefundTo`. +/// - `amount` is in the ERC-20's own units (USDC: 6 decimals). +/// - `refundByArbiter` does not check `releaseTimestamp`; it reverts `PaymentRefunded` if the +/// payment was already refunded, and `InsufficientFunds` if neither the recipient's balance +/// nor the arbiter's own deposited balance covers the amount. +/// - `setLockupSeconds` reverts above `MAX_LOCKUP_SECONDS` (180 days). +/// - Payment ids start at 0 (`nonce` starts at 0). +interface ICircleRefundProtocol { + function arbiter() external view returns (address); + function fiatToken() external view returns (address); + function nonce() external view returns (uint256); + function MAX_LOCKUP_SECONDS() external view returns (uint256); + function lockupSeconds(address recipient) external view returns (uint256); + function balances(address account) external view returns (uint256); + function debts(address recipient) external view returns (uint256); + + function payments(uint256 paymentID) + external + view + returns ( + address to, + uint256 amount, + uint256 releaseTimestamp, + address refundTo, + uint256 withdrawnAmount, + bool refunded + ); + + function pay(address to, uint256 amount, address refundTo) external; + function withdraw(uint256[] calldata paymentIDs) external; + function refundByRecipient(uint256 paymentID) external; + function updateRefundTo(uint256 paymentID, address newRefundTo) external; + + // arbiter-only + function refundByArbiter(uint256 paymentID) external; + function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external; + function depositArbiterFunds(uint256 amount) external; + function withdrawArbiterFunds(uint256 amount) external; +} diff --git a/contracts/v2/mocks/MockCircleRefundProtocol.sol b/contracts/v2/mocks/MockCircleRefundProtocol.sol new file mode 100644 index 0000000..7c5fd80 --- /dev/null +++ b/contracts/v2/mocks/MockCircleRefundProtocol.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @notice TEST / DEMO ONLY. Predge's own re-implementation of the behaviour of Circle's Refund +/// Protocol that the adapter depends on, written from the public interface and behaviour +/// described in `ICircleRefundProtocol`. It is NOT Circle's code and is not audited. +/// Same function names, argument order, getter layout and error names as Circle's contract, +/// so the adapter and tests exercise the real call shapes. Left out: EIP-712 +/// `earlyWithdrawByArbiter` (not used by Predge) and recipient debt bookkeeping beyond the +/// arbiter-funds fallback. +interface IERC20Min { + function transfer(address to, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); +} + +contract MockCircleRefundProtocol { + struct Payment { + address to; + uint256 amount; + uint256 releaseTimestamp; + address refundTo; + uint256 withdrawnAmount; + bool refunded; + } + + uint256 public constant MAX_LOCKUP_SECONDS = 180 days; + + address public immutable arbiter; + address public immutable fiatToken; + uint256 public nonce; + + mapping(address => uint256) public lockupSeconds; + mapping(uint256 => Payment) public payments; + mapping(address => uint256) public balances; + mapping(address => uint256) public debts; + + event PaymentCreated(uint256 indexed paymentID, address indexed to, uint256 amount, uint256 releaseTimestamp, address indexed refundTo); + event Refund(uint256 indexed paymentID, address indexed refundTo, uint256 amount); + event RefundToUpdated(uint256 indexed paymentID, address indexed oldRefundTo, address indexed newRefundTo); + event Withdrawal(address indexed to, uint256 amount); + + error CallerNotAllowed(); + error PaymentIsStillLocked(uint256 paymentID); + error RefundToIsZeroAddress(); + error InsufficientFunds(); + error PaymentRefunded(uint256 paymentID); + error LockupSecondsExceedsMax(); + + modifier onlyArbiter() { + if (msg.sender != arbiter) revert CallerNotAllowed(); + _; + } + + constructor(address arbiter_, address token_) { + arbiter = arbiter_; + fiatToken = token_; + } + + function pay(address to, uint256 amount, address refundTo) external { + if (refundTo == address(0)) revert RefundToIsZeroAddress(); + uint256 release = block.timestamp + lockupSeconds[to]; + require(IERC20Min(fiatToken).transferFrom(msg.sender, address(this), amount), "transferFrom"); + uint256 id = nonce++; + payments[id] = Payment(to, amount, release, refundTo, 0, false); + balances[to] += amount; + emit PaymentCreated(id, to, amount, release, refundTo); + } + + function refundByRecipient(uint256 paymentID) external { + Payment storage p = payments[paymentID]; + if (msg.sender != p.to) revert CallerNotAllowed(); + if (balances[p.to] < p.amount) revert InsufficientFunds(); + balances[p.to] -= p.amount; + _refund(paymentID, p); + } + + /// @dev Like Circle's: no release-time check; paid from the recipient's balance, else from the + /// arbiter's deposited balance (which then becomes the recipient's debt). + function refundByArbiter(uint256 paymentID) external onlyArbiter { + Payment storage p = payments[paymentID]; + if (balances[p.to] >= p.amount) { + balances[p.to] -= p.amount; + } else { + if (balances[arbiter] < p.amount) revert InsufficientFunds(); + balances[arbiter] -= p.amount; + debts[p.to] += p.amount; + } + _refund(paymentID, p); + } + + function setLockupSeconds(address recipient, uint256 recipientLockupSeconds) external onlyArbiter { + if (recipientLockupSeconds > MAX_LOCKUP_SECONDS) revert LockupSecondsExceedsMax(); + lockupSeconds[recipient] = recipientLockupSeconds; + } + + function depositArbiterFunds(uint256 amount) external onlyArbiter { + require(IERC20Min(fiatToken).transferFrom(msg.sender, address(this), amount), "transferFrom"); + balances[arbiter] += amount; + } + + function withdrawArbiterFunds(uint256 amount) external onlyArbiter { + if (balances[arbiter] < amount) revert InsufficientFunds(); + balances[arbiter] -= amount; + require(IERC20Min(fiatToken).transfer(arbiter, amount), "transfer"); + } + + function withdraw(uint256[] calldata paymentIDs) external { + uint256 total; + for (uint256 i; i < paymentIDs.length; ++i) { + Payment storage p = payments[paymentIDs[i]]; + if (p.to != msg.sender) revert CallerNotAllowed(); + if (block.timestamp < p.releaseTimestamp) revert PaymentIsStillLocked(paymentIDs[i]); + if (p.refunded) revert PaymentRefunded(paymentIDs[i]); + total += p.amount - p.withdrawnAmount; + p.withdrawnAmount = p.amount; + } + if (balances[msg.sender] < total) revert InsufficientFunds(); + balances[msg.sender] -= total; + require(IERC20Min(fiatToken).transfer(msg.sender, total), "transfer"); + emit Withdrawal(msg.sender, total); + } + + function updateRefundTo(uint256 paymentID, address newRefundTo) external { + if (newRefundTo == address(0)) revert RefundToIsZeroAddress(); + Payment storage p = payments[paymentID]; + if (msg.sender != p.refundTo) revert CallerNotAllowed(); + emit RefundToUpdated(paymentID, p.refundTo, newRefundTo); + p.refundTo = newRefundTo; + } + + function _refund(uint256 paymentID, Payment storage p) private { + if (p.refunded) revert PaymentRefunded(paymentID); + p.refunded = true; + require(IERC20Min(fiatToken).transfer(p.refundTo, p.amount), "transfer"); + emit Refund(paymentID, p.refundTo, p.amount); + } +} diff --git a/contracts/v2/mocks/MockUSDC.sol b/contracts/v2/mocks/MockUSDC.sol new file mode 100644 index 0000000..28aeb8b --- /dev/null +++ b/contracts/v2/mocks/MockUSDC.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @notice TEST ONLY 6-decimal ERC-20 with open minting. Never deploy outside a local test. +contract MockUSDC { + string public constant name = "Mock USDC"; + string public constant symbol = "mUSDC"; + uint8 public constant decimals = 6; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function mint(address to, uint256 amount) external { + totalSupply += amount; + balanceOf[to] += amount; + emit Transfer(address(0), to, amount); + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transfer(address to, uint256 amount) external returns (bool) { + _move(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 a = allowance[from][msg.sender]; + if (a != type(uint256).max) allowance[from][msg.sender] = a - amount; + _move(from, to, amount); + return true; + } + + function _move(address from, address to, uint256 amount) private { + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + } +} diff --git a/scripts/foundry/DeployCircleRefundDemo.s.sol b/scripts/foundry/DeployCircleRefundDemo.s.sol new file mode 100644 index 0000000..07fb425 --- /dev/null +++ b/scripts/foundry/DeployCircleRefundDemo.s.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Script, console2} from "forge-std/Script.sol"; +import {PredgeRefundArbiterV2} from "../../contracts/v2/PredgeRefundArbiterV2.sol"; +import {CircleRefundProtocolAdapter} from "../../contracts/v2/adapters/CircleRefundProtocolAdapter.sol"; +import {MockCircleRefundProtocol} from "../../contracts/v2/mocks/MockCircleRefundProtocol.sol"; + +/// @notice Demo deploy for Arc TESTNET: adapter -> Refund Protocol -> PredgeRefundArbiterV2 -> bind. +/// Simulate first (no --broadcast). Env: +/// OWNER, OPERATOR required; Predge arbiter owner (Safe or EOA) and operator key +/// USDC ERC-20 the Refund Protocol escrows (Arc testnet USDC by default) +/// CIRCLE_RP optional: an existing Circle RefundProtocol whose `arbiter` is the +/// adapter address this script will create. If unset, Predge's own +/// MockCircleRefundProtocol is deployed instead. +/// AMOUNT_SCALE default 1e12 (USDC 6dp -> Arc native 18dp) +/// WINDOW, MIN_BOND, BPS default 86400, 5e18, 0 (the live Arc v2.1 config) +contract DeployCircleRefundDemo is Script { + function run() external { + address owner = vm.envAddress("OWNER"); + address operator = vm.envAddress("OPERATOR"); + address usdc = vm.envOr("USDC", address(0x3600000000000000000000000000000000000000)); + address circleRp = vm.envOr("CIRCLE_RP", address(0)); + uint256 scale = vm.envOr("AMOUNT_SCALE", uint256(1e12)); + uint64 window = uint64(vm.envOr("WINDOW", uint256(86400))); + uint256 minBond = vm.envOr("MIN_BOND", uint256(5e18)); + uint16 bps = uint16(vm.envOr("BPS", uint256(0))); + + vm.startBroadcast(); + CircleRefundProtocolAdapter adapter = new CircleRefundProtocolAdapter(scale); + if (circleRp == address(0)) { + circleRp = address(new MockCircleRefundProtocol(address(adapter), usdc)); + } + PredgeRefundArbiterV2 arb = new PredgeRefundArbiterV2(owner, operator, address(adapter), window, minBond, bps); + adapter.bind(circleRp, address(arb)); + vm.stopBroadcast(); + + console2.log("adapter ", address(adapter)); + console2.log("refundProtocol ", circleRp); + console2.log("predgeArbiter ", address(arb)); + console2.log("next: OWNER calls predgeArbiter.setLockupSeconds(seller, >15 days, <=180 days)"); + } +} diff --git a/test/foundry-v2/CircleRefundAdapter.t.sol b/test/foundry-v2/CircleRefundAdapter.t.sol new file mode 100644 index 0000000..dc6c776 --- /dev/null +++ b/test/foundry-v2/CircleRefundAdapter.t.sol @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test, console2} from "forge-std/Test.sol"; +import {PredgeRefundArbiterV2} from "../../contracts/v2/PredgeRefundArbiterV2.sol"; +import {CircleRefundProtocolAdapter} from "../../contracts/v2/adapters/CircleRefundProtocolAdapter.sol"; +import {MockCircleRefundProtocol} from "../../contracts/v2/mocks/MockCircleRefundProtocol.sol"; +import {MockUSDC} from "../../contracts/v2/mocks/MockUSDC.sol"; + +/// @notice PredgeRefundArbiterV2 (unchanged, as deployed) driving a Circle-shaped Refund Protocol +/// through CircleRefundProtocolAdapter. Parameters mirror the live Arc config: 1-day window, +/// 5 USDC native bond floor, 0 bps. +contract CircleRefundAdapterTest is Test { + address internal safe = makeAddr("safe"); + address internal op = makeAddr("operator"); + address internal buyer = makeAddr("buyerAgent"); + address internal seller = makeAddr("sellerAgent"); + address internal stranger = makeAddr("stranger"); + + uint64 internal constant WINDOW = 1 days; + uint256 internal constant MIN_BOND = 5e18; // 5 USDC in Arc native wei + uint16 internal constant BPS = 0; + uint256 internal constant SCALE = 1e12; // USDC 6 decimals -> 18 + uint256 internal constant LOCKUP = 20 days; // > WINDOW + RESOLUTION_TIMEOUT (15 days) + uint256 internal constant PRICE = 10e6; // 10 USDC + + bytes32 internal constant EV_BUYER = keccak256("terms+delivery hash, buyer"); + bytes32 internal constant EV_SELLER = keccak256("terms+delivery hash, seller"); + bytes32 internal constant EV_COUNTER = keccak256("counter evidence"); + + MockUSDC internal usdc; + MockCircleRefundProtocol internal circle; + CircleRefundProtocolAdapter internal adapter; + PredgeRefundArbiterV2 internal arb; + + function setUp() public { + vm.warp(1_762_000_000); + usdc = new MockUSDC(); + adapter = new CircleRefundProtocolAdapter(SCALE); + circle = new MockCircleRefundProtocol(address(adapter), address(usdc)); + arb = new PredgeRefundArbiterV2(safe, op, address(adapter), WINDOW, MIN_BOND, BPS); + adapter.bind(address(circle), address(arb)); + + vm.prank(safe); + arb.setLockupSeconds(seller, LOCKUP); // Safe -> Predge arbiter -> adapter -> Circle + + vm.deal(op, 100 ether); + usdc.mint(buyer, 100e6); + vm.prank(buyer); + usdc.approve(address(circle), type(uint256).max); + } + + // ---------- helpers ---------- + + function _pay() internal returns (uint256 id) { + id = circle.nonce(); + vm.prank(buyer); + circle.pay(seller, PRICE, buyer); + } + + function _fileBoth(uint256 id) internal { + vm.prank(buyer); + arb.fileEvidence(id, EV_BUYER); + vm.prank(seller); + arb.fileEvidence(id, EV_SELLER); + } + + function _rule(uint256 id, address from, bytes32 ev, PredgeRefundArbiterV2.Direction d) internal { + vm.prank(op); + arb.rule{value: MIN_BOND}(id, from, ev, d); + } + + function _refunded(uint256 id) internal view returns (bool r) { + (,,,,, r) = circle.payments(id); + } + + // ---------- wiring ---------- + + function test_wiring() public view { + assertEq(circle.arbiter(), address(adapter)); + assertEq(address(arb.refundProtocol()), address(adapter)); + assertEq(address(adapter.circle()), address(circle)); + assertEq(adapter.predgeArbiter(), address(arb)); + assertEq(circle.lockupSeconds(seller), LOCKUP); + } + + function test_bind_onceOnly_deployerOnly() public { + vm.expectRevert(CircleRefundProtocolAdapter.AlreadyBound.selector); + adapter.bind(address(circle), address(arb)); + + CircleRefundProtocolAdapter a2 = new CircleRefundProtocolAdapter(SCALE); + vm.prank(stranger); + vm.expectRevert(CircleRefundProtocolAdapter.NotDeployer.selector); + a2.bind(address(circle), address(arb)); + // Circle's arbiter is the first adapter, not a2 + vm.expectRevert(CircleRefundProtocolAdapter.NotCircleArbiter.selector); + a2.bind(address(circle), address(arb)); + // Predge arbiter points to the first adapter, not a2 + MockCircleRefundProtocol c2 = new MockCircleRefundProtocol(address(a2), address(usdc)); + vm.expectRevert(CircleRefundProtocolAdapter.ArbiterNotWired.selector); + a2.bind(address(c2), address(arb)); + vm.expectRevert(CircleRefundProtocolAdapter.NotBound.selector); + a2.paymentInfo(0); + } + + function test_paymentInfo_mapsCircleFields() public { + uint256 id = _pay(); + assertEq(id, 0, "Circle ids start at 0"); + (address payer, address payee, uint256 amount, uint256 release) = adapter.paymentInfo(id); + assertEq(payer, buyer, "payer = refundTo"); + assertEq(payee, seller, "payee = to"); + assertEq(amount, PRICE * SCALE, "6 -> 18 decimals"); + assertEq(release, block.timestamp + LOCKUP); + (payer, payee, amount, release) = adapter.paymentInfo(99); + assertEq(payee, address(0), "unknown payment reads as zero"); + } + + function test_onlyPredgeArbiterCanDriveCircle() public { + uint256 id = _pay(); + vm.prank(stranger); + vm.expectRevert(CircleRefundProtocolAdapter.NotPredgeArbiter.selector); + adapter.refundByArbiter(id); + vm.prank(op); + vm.expectRevert(CircleRefundProtocolAdapter.NotPredgeArbiter.selector); + adapter.refundByArbiter(id); + vm.prank(stranger); + vm.expectRevert(MockCircleRefundProtocol.CallerNotAllowed.selector); + circle.refundByArbiter(id); + vm.prank(stranger); + vm.expectRevert(CircleRefundProtocolAdapter.NotPredgeArbiter.selector); + adapter.setLockupSeconds(seller, 1); + } + + // ---------- 1. happy path: release ---------- + + function test_release_sellerPaidAfterLockup() public { + uint256 id = _pay(); + _fileBoth(id); + _rule(id, seller, EV_SELLER, PredgeRefundArbiterV2.Direction.Release); + + vm.warp(block.timestamp + WINDOW); + arb.finalize(id); + assertEq(uint8(arb.rulings(id).status), uint8(PredgeRefundArbiterV2.Status.Finalized)); + assertFalse(_refunded(id)); + assertEq(arb.credit(op), MIN_BOND, "bond back to operator"); + + vm.warp(block.timestamp + LOCKUP); + uint256[] memory ids = new uint256[](1); + ids[0] = id; + vm.prank(seller); + circle.withdraw(ids); + assertEq(usdc.balanceOf(seller), PRICE); + assertEq(usdc.balanceOf(address(circle)), 0); + + uint256 before = op.balance; + vm.prank(op); + arb.withdraw(); + assertEq(op.balance - before, MIN_BOND); + } + + // ---------- 2. refund on verdict ---------- + + function test_refundOnVerdict_buyerGetsUsdcBack() public { + uint256 id = _pay(); + _fileBoth(id); + _rule(id, buyer, EV_BUYER, PredgeRefundArbiterV2.Direction.Refund); + + vm.warp(block.timestamp + WINDOW); + arb.finalize(id); + assertTrue(_refunded(id)); + assertEq(usdc.balanceOf(buyer), 100e6, "buyer whole again"); + assertEq(circle.balances(seller), 0); + + vm.warp(block.timestamp + LOCKUP); + uint256[] memory ids = new uint256[](1); + ids[0] = id; + vm.prank(seller); + vm.expectRevert(abi.encodeWithSelector(MockCircleRefundProtocol.PaymentRefunded.selector, id)); + circle.withdraw(ids); + } + + // ---------- 3. dispute ---------- + + function test_dispute_releaseOverturnedToRefund() public { + uint256 id = _pay(); + _fileBoth(id); + _rule(id, seller, EV_SELLER, PredgeRefundArbiterV2.Direction.Release); + + vm.prank(seller); + vm.expectRevert(PredgeRefundArbiterV2.NotHarmedParty.selector); + arb.dispute(id, EV_COUNTER); + + vm.prank(buyer); + arb.dispute(id, EV_COUNTER); + vm.warp(block.timestamp + WINDOW); + vm.expectRevert(PredgeRefundArbiterV2.BadStatus.selector); + arb.finalize(id); // frozen + + vm.prank(safe); + arb.resolve(id, PredgeRefundArbiterV2.Direction.Refund); + assertEq(uint8(arb.rulings(id).status), uint8(PredgeRefundArbiterV2.Status.Overturned)); + assertTrue(_refunded(id)); + assertEq(usdc.balanceOf(buyer), 100e6); + assertEq(arb.credit(buyer), MIN_BOND, "operator bond to the harmed buyer"); + assertEq(arb.overturnCount(), 1); + } + + function test_dispute_refundUpheld() public { + uint256 id = _pay(); + _fileBoth(id); + _rule(id, buyer, EV_BUYER, PredgeRefundArbiterV2.Direction.Refund); + vm.prank(seller); + arb.dispute(id, EV_COUNTER); + vm.prank(safe); + arb.resolve(id, PredgeRefundArbiterV2.Direction.Refund); + assertEq(uint8(arb.rulings(id).status), uint8(PredgeRefundArbiterV2.Status.Finalized)); + assertTrue(_refunded(id)); + assertEq(arb.credit(op), MIN_BOND); + } + + // ---------- edge cases from the gap analysis ---------- + + function test_sellerRefundsFirst_finalizeStillSucceeds() public { + uint256 id = _pay(); + _fileBoth(id); + _rule(id, buyer, EV_BUYER, PredgeRefundArbiterV2.Direction.Refund); + vm.prank(seller); + circle.refundByRecipient(id); + vm.warp(block.timestamp + WINDOW); + arb.finalize(id); // adapter treats "already refunded" as done + assertEq(uint8(arb.rulings(id).status), uint8(PredgeRefundArbiterV2.Status.Finalized)); + assertEq(usdc.balanceOf(buyer), 100e6); + } + + function test_refundedPayment_notArbitrable() public { + uint256 id = _pay(); + vm.prank(seller); + circle.refundByRecipient(id); + vm.prank(buyer); + vm.expectRevert(PredgeRefundArbiterV2.NotAParty.selector); + arb.fileEvidence(id, EV_BUYER); + } + + function test_zeroLockup_ruleRefused() public { + address other = makeAddr("noLockupSeller"); + uint256 id = circle.nonce(); + vm.prank(buyer); + circle.pay(other, PRICE, buyer); + vm.prank(buyer); + arb.fileEvidence(id, EV_BUYER); + vm.prank(op); + vm.expectRevert(PredgeRefundArbiterV2.LockupTooShort.selector); + arb.rule{value: MIN_BOND}(id, buyer, EV_BUYER, PredgeRefundArbiterV2.Direction.Refund); + } + + function test_lockupCap_circle180dBelowPredge365d() public { + vm.prank(safe); + vm.expectRevert(MockCircleRefundProtocol.LockupSecondsExceedsMax.selector); + arb.setLockupSeconds(seller, 200 days); + } + + function test_refundToChangedAfterRuling_refundGoesToNewAddress() public { + address newTo = makeAddr("buyerTreasury"); + uint256 id = _pay(); + _fileBoth(id); + _rule(id, buyer, EV_BUYER, PredgeRefundArbiterV2.Direction.Refund); + vm.prank(buyer); + circle.updateRefundTo(id, newTo); + vm.warp(block.timestamp + WINDOW); + arb.finalize(id); + assertEq(usdc.balanceOf(newTo), PRICE, "Circle pays the current refundTo"); + assertEq(arb.rulings(id).payer, buyer, "Predge keeps the payer snapshotted at ruling"); + } + + // ---------- the 6-line demo log ---------- + + function test_demoLog() public { + uint256 id = _pay(); + console2.log("[1/6] PAY buyer-agent -> RefundProtocol (Circle interface): 10.00 USDC to seller-agent, id", id, "lockup 20d"); + _fileBoth(id); + console2.log("[2/6] EVIDENCE buyer + seller filed hashes of terms and delivery with Predge"); + _rule(id, buyer, EV_BUYER, PredgeRefundArbiterV2.Direction.Refund); + console2.log("[3/6] VERDICT Predge operator: REFUND (delivery did not meet the fixed terms), 5 USDC bond posted"); + console2.log("[4/6] WINDOW 24h challenge window open; seller may dispute to the owner Safe"); + vm.warp(block.timestamp + WINDOW); + arb.finalize(id); + console2.log("[5/6] FINALIZE no dispute -> Predge arbiter -> adapter -> RefundProtocol.refundByArbiter"); + console2.log("[6/6] RESULT buyer USDC back (6dp):", usdc.balanceOf(buyer) - 90e6, "refunded:", _refunded(id)); + assertTrue(_refunded(id)); + } +}