From 483a7cb989c17566cbb631002b1948c3830a3c51 Mon Sep 17 00:00:00 2001 From: Predge <307976007+predge-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:21:16 +0300 Subject: [PATCH] feat(v2): Circle Refund Protocol adapter for PredgeRefundArbiterV2 PredgeRefundArbiterV2 cannot drive Circle's RefundProtocol directly: Circle has no paymentInfo (it exposes payments(id) with to/refundTo), amounts are in USDC 6dp while the bond is sized in native wei, and Circle's arbiter is fixed in its constructor. CircleRefundProtocolAdapter is set as Circle's arbiter, accepts writes only from the bound Predge arbiter, maps refundTo->payer, to->payee, scales the amount, and treats an already-refunded payment as done. Adds our own ICircleRefundProtocol (signatures only, read from the public source), a local MockCircleRefundProtocol mirroring its behaviour, MockUSDC, 14 Foundry tests (release, refund on verdict, dispute overturned/upheld, edge cases) and an Arc testnet deploy script (simulation only, nothing broadcast). The deployed arbiter and the v2 plan are unchanged. --- .../adapters/CircleRefundProtocolAdapter.sol | 113 +++++++ .../v2/interfaces/ICircleRefundProtocol.sol | 54 ++++ .../v2/mocks/MockCircleRefundProtocol.sol | 137 +++++++++ contracts/v2/mocks/MockUSDC.sol | 45 +++ scripts/foundry/DeployCircleRefundDemo.s.sol | 43 +++ test/foundry-v2/CircleRefundAdapter.t.sol | 291 ++++++++++++++++++ 6 files changed, 683 insertions(+) create mode 100644 contracts/v2/adapters/CircleRefundProtocolAdapter.sol create mode 100644 contracts/v2/interfaces/ICircleRefundProtocol.sol create mode 100644 contracts/v2/mocks/MockCircleRefundProtocol.sol create mode 100644 contracts/v2/mocks/MockUSDC.sol create mode 100644 scripts/foundry/DeployCircleRefundDemo.s.sol create mode 100644 test/foundry-v2/CircleRefundAdapter.t.sol 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)); + } +}