Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions contracts/v2/adapters/CircleRefundProtocolAdapter.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
54 changes: 54 additions & 0 deletions contracts/v2/interfaces/ICircleRefundProtocol.sol
Original file line number Diff line number Diff line change
@@ -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;
}
137 changes: 137 additions & 0 deletions contracts/v2/mocks/MockCircleRefundProtocol.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
45 changes: 45 additions & 0 deletions contracts/v2/mocks/MockUSDC.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
43 changes: 43 additions & 0 deletions scripts/foundry/DeployCircleRefundDemo.s.sol
Original file line number Diff line number Diff line change
@@ -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)");
}
}
Loading