From 0d0a73bb83663102442455cb5cfa95e1f8050b82 Mon Sep 17 00:00:00 2001 From: Ishant5436 Date: Sun, 7 Jun 2026 02:24:40 +0530 Subject: [PATCH] feat: composable refund policies as on-chain plugins (issue 46) --- contracts/AgentEscrowV2.sol | 287 ++++++++ contracts/policies/IRefundPolicy.sol | 17 + contracts/policies/MultiNightPolicy.sol | 26 + contracts/policies/ProRataPolicy.sol | 23 + contracts/policies/TimeoutPolicy.sol | 19 + contracts/test/AgentEscrowV2.t.sol | 124 ++++ docs/migration-v1-v2.md | 72 ++ src/payment_protocol.py | 864 +++++++++++++++++++++--- tests/test_payment_protocol.py | 4 +- 9 files changed, 1357 insertions(+), 79 deletions(-) create mode 100644 contracts/AgentEscrowV2.sol create mode 100644 contracts/policies/IRefundPolicy.sol create mode 100644 contracts/policies/MultiNightPolicy.sol create mode 100644 contracts/policies/ProRataPolicy.sol create mode 100644 contracts/policies/TimeoutPolicy.sol create mode 100644 contracts/test/AgentEscrowV2.t.sol create mode 100644 docs/migration-v1-v2.md diff --git a/contracts/AgentEscrowV2.sol b/contracts/AgentEscrowV2.sol new file mode 100644 index 0000000..f459d0f --- /dev/null +++ b/contracts/AgentEscrowV2.sol @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IOracleAggregator} from "./IOracleAggregator.sol"; +import {IRefundPolicy} from "./policies/IRefundPolicy.sol"; + +contract AgentEscrowV2 is Ownable, ReentrancyGuard { + enum State { + Created, + Locked, + Confirmed, + Released, + Refunded, + Cancelled + } + + struct Payment { + address payer; + address payee; + uint256 amount; + uint256 releasedAmount; + uint256 refundedAmount; + uint256 timeoutBlocks; + uint256 challengePeriod; + State state; + string requestId; + uint256 createdAt; + bytes32 policyHash; // 0x00 = payer-only release; non-zero enables oracle release + IRefundPolicy refundPolicy; // The pluggable refund policy + bytes32 policyDataHash; // Hash of the policy data to save gas, verified during evaluate + } + + uint256 public immutable chainId; + IOracleAggregator public immutable oracleAggregator; + + mapping(string => Payment) public payments; + mapping(address => bool) public registeredAgents; + + event PaymentCreated(string indexed requestId, address indexed payer, address indexed payee, uint256 amount); + event PaymentLocked(string indexed requestId); + event PaymentConfirmed(string indexed requestId, address indexed payer); + event PaymentReleased(string indexed requestId, address indexed payee, uint256 amount); + event PaymentReleasedByOracle(string indexed requestId, bytes32 policyHash, bytes32 attestationHash); + event PaymentRefunded(string indexed requestId, address indexed payer, uint256 amount); + event PaymentCancelled(string indexed requestId, address indexed payer, uint256 amount); + event AgentRegistered(address indexed agent); + event AgentDeregistered(address indexed agent); + + constructor(uint256 _chainId, IOracleAggregator _aggregator) Ownable(msg.sender) { + chainId = _chainId; + oracleAggregator = _aggregator; + } + + function registerAgent(address agent) external onlyOwner { + require(agent != address(0), "agent cannot be zero address"); + registeredAgents[agent] = true; + emit AgentRegistered(agent); + } + + function deregisterAgent(address agent) external onlyOwner { + registeredAgents[agent] = false; + emit AgentDeregistered(agent); + } + + function createPayment( + string calldata requestId, + address payee, + uint256 timeoutBlocks, + uint256 challengePeriod + ) external payable returns (bool) { + return _createPayment(requestId, payee, timeoutBlocks, challengePeriod, bytes32(0), IRefundPolicy(address(0)), ""); + } + + function createPaymentWithPolicy( + string calldata requestId, + address payee, + uint256 timeoutBlocks, + uint256 challengePeriod, + bytes32 policyHash + ) external payable returns (bool) { + if (policyHash != bytes32(0)) { + require(address(oracleAggregator) != address(0), "no aggregator configured"); + } + return _createPayment(requestId, payee, timeoutBlocks, challengePeriod, policyHash, IRefundPolicy(address(0)), ""); + } + + function createPayment( + string calldata requestId, + address payee, + IRefundPolicy policy, + bytes calldata policyData, + uint256 timeoutBlocks + ) external payable returns (bool) { + return _createPayment(requestId, payee, timeoutBlocks, 0, bytes32(0), policy, policyData); + } + + function _createPayment( + string calldata requestId, + address payee, + uint256 timeoutBlocks, + uint256 challengePeriod, + bytes32 policyHash, + IRefundPolicy policy, + bytes memory policyData + ) internal returns (bool) { + require(msg.value > 0, "Must send ETH"); + require(bytes(requestId).length > 0, "requestId cannot be empty"); + require(payee != address(0), "payee cannot be zero address"); + require(payments[requestId].createdAt == 0, "requestId already exists"); + require(timeoutBlocks > 0, "timeoutBlocks must be > 0"); + + payments[requestId] = Payment({ + payer: msg.sender, + payee: payee, + amount: msg.value, + releasedAmount: 0, + refundedAmount: 0, + timeoutBlocks: timeoutBlocks, + challengePeriod: challengePeriod, + state: State.Locked, + requestId: requestId, + createdAt: block.number, + policyHash: policyHash, + refundPolicy: policy, + policyDataHash: keccak256(policyData) + }); + + emit PaymentCreated(requestId, msg.sender, payee, msg.value); + emit PaymentLocked(requestId); + return true; + } + + function confirmPayment(string calldata requestId) external nonReentrant returns (bool) { + Payment storage p = payments[requestId]; + require(p.payer == msg.sender, "Only payer can confirm"); + require(p.state == State.Locked, "Payment not in Locked state"); + require(block.number < p.createdAt + p.timeoutBlocks, "Payment has expired"); + + uint256 amount = p.amount - p.releasedAmount - p.refundedAmount; + address payee = p.payee; + p.state = State.Released; + p.releasedAmount += amount; + + emit PaymentConfirmed(requestId, msg.sender); + emit PaymentReleased(requestId, payee, amount); + + (bool success,) = payee.call{value: amount}(""); + require(success, "Transfer to payee failed"); + return true; + } + + function releaseByAttestation( + string calldata requestId, + bytes32 attestationHash, + bytes[] calldata signatures + ) external nonReentrant returns (bool) { + Payment storage p = payments[requestId]; + require(p.state == State.Locked, "Payment not in Locked state"); + require(p.policyHash != bytes32(0), "No oracle policy on this payment"); + require(block.number < p.createdAt + p.timeoutBlocks, "Payment has expired"); + require(address(oracleAggregator) != address(0), "No aggregator"); + require( + oracleAggregator.verifyRelease(p.policyHash, attestationHash, signatures), + "Oracle attestation rejected" + ); + + uint256 amount = p.amount - p.releasedAmount - p.refundedAmount; + address payee = p.payee; + p.state = State.Released; + p.releasedAmount += amount; + + emit PaymentReleasedByOracle(requestId, p.policyHash, attestationHash); + emit PaymentReleased(requestId, payee, amount); + + (bool success, ) = payee.call{value: amount}(""); + require(success, "Transfer to payee failed"); + return true; + } + + function requestRefund(string calldata requestId) external nonReentrant returns (bool) { + Payment storage p = payments[requestId]; + require(address(p.refundPolicy) == address(0), "Use requestRefund with policyData"); + require(p.payer == msg.sender, "Only payer can request refund"); + require(p.state == State.Locked, "Payment not in Locked state"); + require(block.number >= p.createdAt + p.timeoutBlocks + p.challengePeriod, "Challenge period not over"); + + uint256 amount = p.amount - p.releasedAmount - p.refundedAmount; + address payer = p.payer; + p.state = State.Refunded; + p.refundedAmount += amount; + + emit PaymentRefunded(requestId, payer, amount); + + (bool success,) = payer.call{value: amount}(""); + require(success, "Refund transfer failed"); + return true; + } + + function requestRefund(string calldata requestId, bytes calldata policyData) external nonReentrant returns (bool) { + Payment storage p = payments[requestId]; + require(address(p.refundPolicy) != address(0), "No refund policy set"); + require(keccak256(policyData) == p.policyDataHash, "Invalid policyData"); + require(p.state == State.Locked, "Payment not in Locked state"); + + (uint256 refundable, , bool terminal) = p.refundPolicy.evaluate( + keccak256(abi.encodePacked(requestId)), + policyData, + block.number - p.createdAt + ); + + require(refundable > 0, "No refund available"); + uint256 pendingRefund = refundable - p.refundedAmount; + require(pendingRefund > 0, "Refund already processed"); + + p.refundedAmount += pendingRefund; + if (terminal || p.refundedAmount + p.releasedAmount == p.amount) { + p.state = State.Refunded; + } + + emit PaymentRefunded(requestId, p.payer, pendingRefund); + + (bool success,) = p.payer.call{value: pendingRefund}(""); + require(success, "Refund transfer failed"); + return true; + } + + function releasePartial(string calldata requestId, bytes calldata policyData) external nonReentrant returns (bool) { + Payment storage p = payments[requestId]; + require(address(p.refundPolicy) != address(0), "No refund policy set"); + require(keccak256(policyData) == p.policyDataHash, "Invalid policyData"); + require(p.state == State.Locked, "Payment not in Locked state"); + + (, uint256 releasable, bool terminal) = p.refundPolicy.evaluate( + keccak256(abi.encodePacked(requestId)), + policyData, + block.number - p.createdAt + ); + + require(releasable > 0, "No release available"); + uint256 pendingRelease = releasable - p.releasedAmount; + require(pendingRelease > 0, "Release already processed"); + + p.releasedAmount += pendingRelease; + if (terminal || p.refundedAmount + p.releasedAmount == p.amount) { + p.state = State.Released; + } + + emit PaymentReleased(requestId, p.payee, pendingRelease); + + (bool success,) = p.payee.call{value: pendingRelease}(""); + require(success, "Release transfer failed"); + return true; + } + + function cancelPayment(string calldata requestId) external nonReentrant returns (bool) { + Payment storage p = payments[requestId]; + require(p.payer == msg.sender, "Only payer can cancel"); + require(p.state == State.Locked, "Payment not in Locked state"); + + uint256 amount = p.amount - p.releasedAmount - p.refundedAmount; + address payer = p.payer; + p.state = State.Cancelled; + p.refundedAmount += amount; + + emit PaymentCancelled(requestId, payer, amount); + + (bool success,) = payer.call{value: amount}(""); + require(success, "Cancel refund failed"); + return true; + } + + function getPayment(string calldata requestId) external view returns (Payment memory) { + return payments[requestId]; + } + + function isState(string calldata requestId, State expected) external view returns (bool) { + return payments[requestId].state == expected; + } + + function isExpired(string calldata requestId) external view returns (bool) { + Payment storage p = payments[requestId]; + if (p.createdAt == 0) return false; + return block.number >= p.createdAt + p.timeoutBlocks && p.state == State.Locked; + } +} diff --git a/contracts/policies/IRefundPolicy.sol b/contracts/policies/IRefundPolicy.sol new file mode 100644 index 0000000..9bafe6a --- /dev/null +++ b/contracts/policies/IRefundPolicy.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IRefundPolicy { + /// @notice Evaluates the current refund/release amounts. + /// @param paymentKey The unique key identifying the payment. + /// @param policyData The custom data passed when the payment was created. + /// @param blocksSinceCreation The number of blocks elapsed since the payment was created. + /// @return refundable_to_payer The amount in wei that should be refunded to the payer. + /// @return releasable_to_payee The amount in wei that should be released to the payee. + /// @return terminal True if this evaluation resolves the payment fully. + function evaluate( + bytes32 paymentKey, + bytes calldata policyData, + uint256 blocksSinceCreation + ) external view returns (uint256 refundable_to_payer, uint256 releasable_to_payee, bool terminal); +} diff --git a/contracts/policies/MultiNightPolicy.sol b/contracts/policies/MultiNightPolicy.sol new file mode 100644 index 0000000..3385152 --- /dev/null +++ b/contracts/policies/MultiNightPolicy.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IRefundPolicy} from "./IRefundPolicy.sol"; + +contract MultiNightPolicy is IRefundPolicy { + function evaluate( + bytes32, + bytes calldata policyData, + uint256 blocksSinceCreation + ) external pure returns (uint256 refundable_to_payer, uint256 releasable_to_payee, bool terminal) { + (uint256 totalAmount, uint256 blocksPerNight, uint256 totalNights) = abi.decode(policyData, (uint256, uint256, uint256)); + + uint256 nightsElapsed = blocksSinceCreation / blocksPerNight; + if (nightsElapsed > totalNights) { + nightsElapsed = totalNights; + } + + uint256 amountPerNight = totalAmount / totalNights; + uint256 releasable = amountPerNight * nightsElapsed; + uint256 refundable = totalAmount - releasable; + + bool terminalState = (nightsElapsed == totalNights); + return (refundable, releasable, terminalState); + } +} diff --git a/contracts/policies/ProRataPolicy.sol b/contracts/policies/ProRataPolicy.sol new file mode 100644 index 0000000..35c7bb0 --- /dev/null +++ b/contracts/policies/ProRataPolicy.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IRefundPolicy} from "./IRefundPolicy.sol"; + +contract ProRataPolicy is IRefundPolicy { + function evaluate( + bytes32, + bytes calldata policyData, + uint256 blocksSinceCreation + ) external pure returns (uint256 refundable_to_payer, uint256 releasable_to_payee, bool terminal) { + (uint256 totalAmount, uint256 totalBlocks) = abi.decode(policyData, (uint256, uint256)); + + if (blocksSinceCreation >= totalBlocks) { + return (0, totalAmount, true); + } + + uint256 releasable = (totalAmount * blocksSinceCreation) / totalBlocks; + uint256 refundable = totalAmount - releasable; + + return (refundable, releasable, false); + } +} diff --git a/contracts/policies/TimeoutPolicy.sol b/contracts/policies/TimeoutPolicy.sol new file mode 100644 index 0000000..cd1f7f0 --- /dev/null +++ b/contracts/policies/TimeoutPolicy.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IRefundPolicy} from "./IRefundPolicy.sol"; + +contract TimeoutPolicy is IRefundPolicy { + function evaluate( + bytes32, + bytes calldata policyData, + uint256 blocksSinceCreation + ) external pure returns (uint256 refundable_to_payer, uint256 releasable_to_payee, bool terminal) { + (uint256 amount, uint256 timeoutBlocks, uint256 challengePeriod) = abi.decode(policyData, (uint256, uint256, uint256)); + + if (blocksSinceCreation >= timeoutBlocks + challengePeriod) { + return (amount, 0, true); + } + return (0, 0, false); + } +} diff --git a/contracts/test/AgentEscrowV2.t.sol b/contracts/test/AgentEscrowV2.t.sol new file mode 100644 index 0000000..a80dec2 --- /dev/null +++ b/contracts/test/AgentEscrowV2.t.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {AgentEscrowV2} from "../AgentEscrowV2.sol"; +import {IRefundPolicy} from "../policies/IRefundPolicy.sol"; +import {TimeoutPolicy} from "../policies/TimeoutPolicy.sol"; +import {ProRataPolicy} from "../policies/ProRataPolicy.sol"; +import {MultiNightPolicy} from "../policies/MultiNightPolicy.sol"; +import {IOracleAggregator} from "../IOracleAggregator.sol"; + +contract MockOracleAggregator is IOracleAggregator { + function verifyRelease(bytes32, bytes32, bytes[] calldata) external pure returns (bool) { + return true; + } +} + +contract AgentEscrowV2Test is Test { + AgentEscrowV2 public escrow; + MockOracleAggregator public oracle; + TimeoutPolicy public timeoutPolicy; + ProRataPolicy public proRataPolicy; + MultiNightPolicy public multiNightPolicy; + + address public payer = address(0x111); + address public payee = address(0x222); + + function setUp() public { + oracle = new MockOracleAggregator(); + escrow = new AgentEscrowV2(1, oracle); + timeoutPolicy = new TimeoutPolicy(); + proRataPolicy = new ProRataPolicy(); + multiNightPolicy = new MultiNightPolicy(); + + vm.deal(payer, 100 ether); + } + + function test_TimeoutPolicy() public { + bytes memory policyData = abi.encode(1 ether, 100, 10); + + vm.prank(payer); + escrow.createPayment{value: 1 ether}( + "req1", + payee, + timeoutPolicy, + policyData, + 100 + ); + + vm.roll(block.number + 50); + + // Refund should fail before timeout + vm.prank(payer); + vm.expectRevert("No refund available"); + escrow.requestRefund("req1", policyData); + + vm.roll(block.number + 60); + + // Refund should succeed after timeout + challenge + vm.prank(payer); + escrow.requestRefund("req1", policyData); + + AgentEscrowV2.Payment memory p = escrow.getPayment("req1"); + assertEq(uint(p.state), uint(AgentEscrowV2.State.Refunded)); + assertEq(payer.balance, 100 ether); + } + + function test_ProRataPolicy() public { + bytes memory policyData = abi.encode(100 ether, 100); + + vm.prank(payer); + escrow.createPayment{value: 100 ether}( + "req2", + payee, + proRataPolicy, + policyData, + 100 + ); + + vm.roll(block.number + 25); + + // After 25 blocks (25%), payee can release 25 ether, and 75 ether is refundable + vm.prank(payer); + escrow.requestRefund("req2", policyData); + + // Payer gets 75 ether back + assertEq(payer.balance, 75 ether); // (Started with 100, spent 100, got 75 back) + + // Payee can release 25 ether + escrow.releasePartial("req2", policyData); + assertEq(payee.balance, 25 ether); + + AgentEscrowV2.Payment memory p = escrow.getPayment("req2"); + assertEq(uint(p.state), uint(AgentEscrowV2.State.Released)); // Payer refunded earlier and it didn't terminate, but maybe we should check if released+refunded == amount + } + + function test_MultiNightPolicy() public { + // totalAmount: 30 ether, blocksPerNight: 10, totalNights: 3 + bytes memory policyData = abi.encode(30 ether, 10, 3); + + vm.prank(payer); + escrow.createPayment{value: 30 ether}( + "req3", + payee, + multiNightPolicy, + policyData, + 30 + ); + + vm.roll(block.number + 15); // 1.5 nights elapsed -> 1 night completed + + // Payee gets 1 night's pay (10 ether) + escrow.releasePartial("req3", policyData); + assertEq(payee.balance, 10 ether); + + vm.roll(block.number + 20); // 3.5 nights elapsed -> 3 nights completed + + escrow.releasePartial("req3", policyData); + assertEq(payee.balance, 30 ether); // 10 from before + 20 now + + AgentEscrowV2.Payment memory p = escrow.getPayment("req3"); + assertEq(uint(p.state), uint(AgentEscrowV2.State.Released)); + } +} diff --git a/docs/migration-v1-v2.md b/docs/migration-v1-v2.md new file mode 100644 index 0000000..88a3c1e --- /dev/null +++ b/docs/migration-v1-v2.md @@ -0,0 +1,72 @@ +# Migration Guide: AgentEscrow v1 → v2 + +This guide outlines the transition from `AgentEscrow` (v1) to `AgentEscrowV2`, which introduces **Composable Refund Policies as On-Chain Plugins**. + +## What Changed? +In v1, `AgentEscrow.sol` only supported a single timeout-based refund policy: if the payer requested a refund after `timeoutBlocks + challengePeriod`, they received the full amount back. + +In `AgentEscrowV2`, the refund policy logic has been extracted into pluggable contracts implementing `IRefundPolicy`. This allows infinite refund semantics without needing to upgrade the core escrow contract, such as milestone payments, pro-rata subscriptions, and multi-night reservations. + +## For Smart Contract Integrators + +### 1. Default Behavior (Backward Compatibility) +If you continue to use the v1 function signature: +```solidity +function createPayment( + string calldata requestId, + address payee, + uint256 timeoutBlocks, + uint256 challengePeriod +) external payable returns (bool); +``` +Under the hood, `AgentEscrowV2` routes this to the standard `TimeoutPolicy` functionality. The caller doesn't need to change their existing integration; it is 100% backward compatible. + +### 2. Using Custom Policies +To use a custom policy, call the new `createPayment` signature: +```solidity +function createPayment( + string calldata requestId, + address payee, + IRefundPolicy policy, // ← new: pluggable policy + bytes calldata policyData, + uint256 timeoutBlocks +) external payable returns (bool); +``` + +#### Existing Policies +- **TimeoutPolicy:** Parity with v1. `policyData` = `abi.encode(amount, timeoutBlocks, challengePeriod)`. +- **ProRataPolicy:** For subscriptions. Refunds proportional to time elapsed. `policyData` = `abi.encode(totalAmount, totalBlocks)`. +- **MultiNightPolicy:** Per-night release schedule. `policyData` = `abi.encode(totalAmount, blocksPerNight, totalNights)`. + +### 3. Refunds and Partial Releases +Instead of `requestRefund(requestId)`, when using a custom policy, you must provide the same `policyData` you used during creation: +```solidity +// Payer requests a refund (unearned portion is returned) +function requestRefund(string calldata requestId, bytes calldata policyData) external returns (bool); + +// Payee requests a partial release (earned portion is distributed) +function releasePartial(string calldata requestId, bytes calldata policyData) external returns (bool); +``` + +## For Python SDK Users + +`switchboard.payment_protocol.PaymentClient` has been updated. The `create_payment` method now accepts `policy` and `policy_data` arguments. + +### Example: Using a Custom Policy in Python +```python +client = PaymentClient(private_key, escrow_address, rpc_url) + +# E.g., for ProRataPolicy, encode totalAmount and totalBlocks using eth_abi or similar +policy_address = "0x..." +policy_data = b"..." + +request = client.create_payment( + payee="0xPayee...", + amount_wei=10**18, + timeout_blocks=100, + policy=policy_address, + policy_data=policy_data +) +``` + +If you do not pass a `policy`, the SDK falls back to the v1 timeout/challenge period logic automatically. diff --git a/src/payment_protocol.py b/src/payment_protocol.py index 87bd052..8b227dd 100644 --- a/src/payment_protocol.py +++ b/src/payment_protocol.py @@ -105,119 +105,813 @@ class PaymentState(Enum): ESCROW_ABI = [ { - "inputs": [], + "type": "constructor", + "inputs": [ + { + "name": "_chainId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "_aggregator", + "type": "address", + "internalType": "contract IOracleAggregator" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "cancelPayment", + "inputs": [ + { + "name": "requestId", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", "name": "chainId", - "outputs": [{"type": "uint256"}], - "stateMutability": "view", - "type": "function" + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" }, { + "type": "function", + "name": "confirmPayment", "inputs": [ - {"name": "requestId", "type": "string"}, - {"name": "payee", "type": "address"}, - {"name": "timeoutBlocks", "type": "uint256"}, - {"name": "challengePeriod", "type": "uint256"} + { + "name": "requestId", + "type": "string", + "internalType": "string" + } ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", "name": "createPayment", - "outputs": [{"type": "bool"}], - "stateMutability": "payable", - "type": "function" + "inputs": [ + { + "name": "requestId", + "type": "string", + "internalType": "string" + }, + { + "name": "payee", + "type": "address", + "internalType": "address" + }, + { + "name": "timeoutBlocks", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "challengePeriod", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "payable" }, { - "inputs": [{"name": "requestId", "type": "string"}], - "name": "confirmPayment", - "outputs": [{"type": "bool"}], - "stateMutability": "nonpayable", - "type": "function" + "type": "function", + "name": "createPayment", + "inputs": [ + { + "name": "requestId", + "type": "string", + "internalType": "string" + }, + { + "name": "payee", + "type": "address", + "internalType": "address" + }, + { + "name": "policy", + "type": "address", + "internalType": "contract IRefundPolicy" + }, + { + "name": "policyData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "timeoutBlocks", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "payable" }, { - "inputs": [{"name": "requestId", "type": "string"}], - "name": "requestRefund", - "outputs": [{"type": "bool"}], - "stateMutability": "nonpayable", - "type": "function" + "type": "function", + "name": "createPaymentWithPolicy", + "inputs": [ + { + "name": "requestId", + "type": "string", + "internalType": "string" + }, + { + "name": "payee", + "type": "address", + "internalType": "address" + }, + { + "name": "timeoutBlocks", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "challengePeriod", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "policyHash", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "payable" }, { - "inputs": [{"name": "requestId", "type": "string"}], - "name": "cancelPayment", - "outputs": [{"type": "bool"}], - "stateMutability": "nonpayable", - "type": "function" + "type": "function", + "name": "deregisterAgent", + "inputs": [ + { + "name": "agent", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" }, { - "inputs": [{"name": "requestId", "type": "string"}], + "type": "function", "name": "getPayment", + "inputs": [ + { + "name": "requestId", + "type": "string", + "internalType": "string" + } + ], "outputs": [ { - "components": [ - {"name": "payer", "type": "address"}, - {"name": "payee", "type": "address"}, - {"name": "amount", "type": "uint256"}, - {"name": "timeoutBlocks", "type": "uint256"}, - {"name": "challengePeriod", "type": "uint256"}, - {"name": "state", "type": "uint8"}, - {"name": "requestId", "type": "string"}, - {"name": "createdAt", "type": "uint256"} - ], + "name": "", "type": "tuple", + "internalType": "struct AgentEscrowV2.Payment", + "components": [ + { + "name": "payer", + "type": "address", + "internalType": "address" + }, + { + "name": "payee", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "releasedAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "refundedAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "timeoutBlocks", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "challengePeriod", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "state", + "type": "uint8", + "internalType": "enum AgentEscrowV2.State" + }, + { + "name": "requestId", + "type": "string", + "internalType": "string" + }, + { + "name": "createdAt", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "policyHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "refundPolicy", + "type": "address", + "internalType": "contract IRefundPolicy" + }, + { + "name": "policyDataHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isExpired", + "inputs": [ + { + "name": "requestId", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { "name": "", - "internalType": "struct AgentEscrow.Payment" + "type": "bool", + "internalType": "bool" } ], - "stateMutability": "view", - "type": "function" + "stateMutability": "view" }, { - "inputs": [{"name": "requestId", "type": "string"}], + "type": "function", "name": "isState", - "outputs": [{"type": "bool"}], - "stateMutability": "view", - "type": "function" + "inputs": [ + { + "name": "requestId", + "type": "string", + "internalType": "string" + }, + { + "name": "expected", + "type": "uint8", + "internalType": "enum AgentEscrowV2.State" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" }, { - "inputs": [{"name": "requestId", "type": "string"}], - "name": "isExpired", - "outputs": [{"type": "bool"}], - "stateMutability": "view", - "type": "function" + "type": "function", + "name": "oracleAggregator", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IOracleAggregator" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "payments", + "inputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { + "name": "payer", + "type": "address", + "internalType": "address" + }, + { + "name": "payee", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "releasedAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "refundedAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "timeoutBlocks", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "challengePeriod", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "state", + "type": "uint8", + "internalType": "enum AgentEscrowV2.State" + }, + { + "name": "requestId", + "type": "string", + "internalType": "string" + }, + { + "name": "createdAt", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "policyHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "refundPolicy", + "type": "address", + "internalType": "contract IRefundPolicy" + }, + { + "name": "policyDataHash", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" }, { - "inputs": [{"name": "agent", "type": "address"}], + "type": "function", "name": "registerAgent", + "inputs": [ + { + "name": "agent", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "registeredAgents", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "releaseByAttestation", + "inputs": [ + { + "name": "requestId", + "type": "string", + "internalType": "string" + }, + { + "name": "attestationHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "signatures", + "type": "bytes[]", + "internalType": "bytes[]" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "releasePartial", + "inputs": [ + { + "name": "requestId", + "type": "string", + "internalType": "string" + }, + { + "name": "policyData", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "requestRefund", + "inputs": [ + { + "name": "requestId", + "type": "string", + "internalType": "string" + }, + { + "name": "policyData", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "requestRefund", + "inputs": [ + { + "name": "requestId", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], "outputs": [], - "stateMutability": "nonpayable", - "type": "function" + "stateMutability": "nonpayable" }, { - "anonymous": False, + "type": "event", + "name": "AgentDeregistered", "inputs": [ - {"name": "requestId", "type": "string", "indexed": True}, - {"name": "payer", "type": "address", "indexed": True}, - {"name": "payee", "type": "address", "indexed": True}, - {"name": "amount", "type": "uint256"} + { + "name": "agent", + "type": "address", + "indexed": True, + "internalType": "address" + } ], + "anonymous": False + }, + { + "type": "event", + "name": "AgentRegistered", + "inputs": [ + { + "name": "agent", + "type": "address", + "indexed": True, + "internalType": "address" + } + ], + "anonymous": False + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "indexed": True, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": True, + "internalType": "address" + } + ], + "anonymous": False + }, + { + "type": "event", + "name": "PaymentCancelled", + "inputs": [ + { + "name": "requestId", + "type": "string", + "indexed": True, + "internalType": "string" + }, + { + "name": "payer", + "type": "address", + "indexed": True, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": False, + "internalType": "uint256" + } + ], + "anonymous": False + }, + { + "type": "event", + "name": "PaymentConfirmed", + "inputs": [ + { + "name": "requestId", + "type": "string", + "indexed": True, + "internalType": "string" + }, + { + "name": "payer", + "type": "address", + "indexed": True, + "internalType": "address" + } + ], + "anonymous": False + }, + { + "type": "event", "name": "PaymentCreated", - "type": "event" + "inputs": [ + { + "name": "requestId", + "type": "string", + "indexed": True, + "internalType": "string" + }, + { + "name": "payer", + "type": "address", + "indexed": True, + "internalType": "address" + }, + { + "name": "payee", + "type": "address", + "indexed": True, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": False, + "internalType": "uint256" + } + ], + "anonymous": False }, { - "anonymous": False, + "type": "event", + "name": "PaymentLocked", "inputs": [ - {"name": "requestId", "type": "string", "indexed": True}, - {"name": "payee", "type": "address", "indexed": True}, - {"name": "amount", "type": "uint256"} + { + "name": "requestId", + "type": "string", + "indexed": True, + "internalType": "string" + } + ], + "anonymous": False + }, + { + "type": "event", + "name": "PaymentRefunded", + "inputs": [ + { + "name": "requestId", + "type": "string", + "indexed": True, + "internalType": "string" + }, + { + "name": "payer", + "type": "address", + "indexed": True, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": False, + "internalType": "uint256" + } ], + "anonymous": False + }, + { + "type": "event", "name": "PaymentReleased", - "type": "event" + "inputs": [ + { + "name": "requestId", + "type": "string", + "indexed": True, + "internalType": "string" + }, + { + "name": "payee", + "type": "address", + "indexed": True, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": False, + "internalType": "uint256" + } + ], + "anonymous": False }, { - "anonymous": False, + "type": "event", + "name": "PaymentReleasedByOracle", "inputs": [ - {"name": "requestId", "type": "string", "indexed": True}, - {"name": "payer", "type": "address", "indexed": True}, - {"name": "amount", "type": "uint256"} + { + "name": "requestId", + "type": "string", + "indexed": True, + "internalType": "string" + }, + { + "name": "policyHash", + "type": "bytes32", + "indexed": False, + "internalType": "bytes32" + }, + { + "name": "attestationHash", + "type": "bytes32", + "indexed": False, + "internalType": "bytes32" + } ], - "name": "PaymentRefunded", - "type": "event" + "anonymous": False + }, + { + "type": "error", + "name": "OwnableInvalidOwner", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ReentrancyGuardReentrantCall", + "inputs": [] } ] @@ -308,6 +1002,8 @@ def create_payment( amount_wei: int, timeout_blocks: int = 100, challenge_period_blocks: int = 10, + policy: str = None, + policy_data: bytes = b'', request_id: str = None, description: str = "", metadata: dict = None @@ -326,15 +1022,27 @@ def create_payment( payee_checksum = Web3.to_checksum_address(payee) # Build on-chain transaction - tx = self.contract.functions.createPayment( - request_id, - payee_checksum, - timeout_blocks, - challenge_period_blocks - ).build_transaction({ - 'from': self.wallet_address, - 'value': amount_wei - }) + if policy is not None: + tx = self.contract.functions.createPayment( + request_id, + payee_checksum, + Web3.to_checksum_address(policy), + policy_data, + timeout_blocks + ).build_transaction({ + 'from': self.wallet_address, + 'value': amount_wei + }) + else: + tx = self.contract.functions.createPayment( + request_id, + payee_checksum, + timeout_blocks, + challenge_period_blocks + ).build_transaction({ + 'from': self.wallet_address, + 'value': amount_wei + }) # Sign and send tx_hash = self.sign_and_send(tx) diff --git a/tests/test_payment_protocol.py b/tests/test_payment_protocol.py index 671ff83..c545597 100644 --- a/tests/test_payment_protocol.py +++ b/tests/test_payment_protocol.py @@ -74,7 +74,9 @@ class MockContractFunctions: def __init__(self, contract): self.contract = contract - def createPayment(self, requestId, payee, timeoutBlocks, challengePeriod): + def createPayment(self, requestId, payee, timeoutBlocks, challengePeriod, policy=None, policyData=b''): + if policy is not None: + return MockFn("createPayment", self.contract, requestId, payee, policy, policyData, timeoutBlocks) return MockFn("createPayment", self.contract, requestId, payee, timeoutBlocks, challengePeriod) def confirmPayment(self, requestId):