From 8313d62b7e4a8a8a00fa285ea9acc35b8715083b Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Wed, 3 Dec 2025 13:54:31 +0000 Subject: [PATCH 01/38] test: add MockERC20 and MockVerifier --- test/mocks/MockERC20.sol | 53 +++++++++++++++++++++++++++++++++++++ test/mocks/MockVerifier.sol | 14 ++++++++++ 2 files changed, 67 insertions(+) create mode 100644 test/mocks/MockERC20.sol create mode 100644 test/mocks/MockVerifier.sol diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol new file mode 100644 index 0000000..23da12a --- /dev/null +++ b/test/mocks/MockERC20.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +contract MockERC20 { + string public name; + string public symbol; + uint8 public decimals; + 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); + + constructor(string memory _name, string memory _symbol, uint8 _decimals) { + name = _name; + symbol = _symbol; + decimals = _decimals; + } + + function mint(address to, uint256 amount) public { + totalSupply += amount; + balanceOf[to] += amount; + emit Transfer(address(0), to, amount); + } + + function transfer(address to, uint256 amount) public returns (bool) { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + + emit Transfer(from, to, amount); + return true; + } +} diff --git a/test/mocks/MockVerifier.sol b/test/mocks/MockVerifier.sol new file mode 100644 index 0000000..17a9762 --- /dev/null +++ b/test/mocks/MockVerifier.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +contract MockVerifier { + mapping(uint256 => bool) public shouldVerify; + + function setVerificationResult(uint256 escrowId, bool result) external { + shouldVerify[escrowId] = result; + } + + function verify(uint256 escrowId, bytes calldata) external view returns (bool) { + return shouldVerify[escrowId]; + } +} From 92bfa40627bbc8399a9373d3e4273ddcf23d077b Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Wed, 3 Dec 2025 13:54:46 +0000 Subject: [PATCH 02/38] test: SafeBaseEscrowV1 initialization and escrow creation Add comprehensive tests for SafeBaseEscrowV1 covering: - Proxy initialization - Escrow creation with validation - State transitions - Pause/unpause functionality - Upgrade authorization --- test/SafeBaseEscrowV1.t.sol | 624 ++++++++++++++++++++++++++++++++++++ 1 file changed, 624 insertions(+) create mode 100644 test/SafeBaseEscrowV1.t.sol diff --git a/test/SafeBaseEscrowV1.t.sol b/test/SafeBaseEscrowV1.t.sol new file mode 100644 index 0000000..bb84c21 --- /dev/null +++ b/test/SafeBaseEscrowV1.t.sol @@ -0,0 +1,624 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {SafeBaseEscrowV1} from "../src/escrow/SafeBaseEscrowV1.sol"; +import {Treasury} from "../src/Treasury.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; + +contract SafeBaseEscrowV1Test is Test { + SafeBaseEscrowV1 public escrow; + Treasury public treasury; + MockERC20 public token; + + address public owner = address(1); + address public buyer = address(2); + address public seller = address(3); + address public mediator = address(4); + + event EscrowCreated( + uint256 indexed escrowId, + address indexed buyer, + address indexed seller, + address token, + uint256 amount, + uint256 deadline + ); + + event EscrowFunded(uint256 indexed escrowId, uint256 amount); + event EscrowReleased(uint256 indexed escrowId, address indexed recipient); + event EscrowRefunded(uint256 indexed escrowId, address indexed recipient); + event EscrowDisputed(uint256 indexed escrowId, address indexed initiator); + event EscrowCancelled(uint256 indexed escrowId); + event ApprovalGranted(uint256 indexed escrowId, address indexed party); + event BasePayFundingReceived(uint256 indexed escrowId, bytes32 indexed paymentId); + + function setUp() public { + token = new MockERC20("Test Token", "TEST", 18); + + Treasury treasuryImpl = new Treasury(); + bytes memory treasuryData = abi.encodeWithSelector( + Treasury.initialize.selector, + owner, + 1 + ); + ERC1967Proxy treasuryProxy = new ERC1967Proxy(address(treasuryImpl), treasuryData); + treasury = Treasury(payable(address(treasuryProxy))); + + SafeBaseEscrowV1 escrowImpl = new SafeBaseEscrowV1(); + bytes memory escrowData = abi.encodeWithSelector( + SafeBaseEscrowV1.initialize.selector, + owner, + address(treasury) + ); + ERC1967Proxy escrowProxy = new ERC1967Proxy(address(escrowImpl), escrowData); + escrow = SafeBaseEscrowV1(address(escrowProxy)); + + vm.startPrank(owner); + treasury.addAdmin(address(escrow)); + treasury.addExecutor(address(escrow)); + vm.stopPrank(); + + vm.deal(buyer, 100 ether); + token.mint(buyer, 1000 ether); + } + + function testInitialize() public view { + assertEq(address(escrow.treasury()), address(treasury)); + assertEq(escrow.owner(), owner); + } + + function testCreateEscrow() public { + vm.prank(buyer); + + vm.expectEmit(true, true, true, true); + emit EscrowCreated(1, buyer, seller, address(0), 1 ether, block.timestamp + 1 days); + + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + assertEq(escrowId, 1); + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + + assertEq(escrowData.buyer, buyer); + assertEq(escrowData.seller, seller); + assertEq(escrowData.mediator, mediator); + assertEq(escrowData.token, address(0)); + assertEq(escrowData.amount, 1 ether); + assertEq(escrowData.deadline, block.timestamp + 1 days); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Created); + } + + function testCreateEscrowZeroSeller() public { + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.InvalidAddress.selector); + escrow.createEscrow( + address(0), + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + } + + function testCreateEscrowZeroAmount() public { + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.InvalidAmount.selector); + escrow.createEscrow( + seller, + mediator, + address(0), + 0, + block.timestamp + 1 days + ); + } + + function testCreateEscrowPastDeadline() public { + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.DeadlineExpired.selector); + escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp - 1 + ); + } + + function testFundEscrowETH() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.expectEmit(true, false, false, true); + emit EscrowFunded(escrowId, 1 ether); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Funded); + } + + function testFundEscrowNotBuyer() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.deal(seller, 1 ether); + vm.prank(seller); + vm.expectRevert(SafeBaseEscrowV1.Unauthorized.selector); + escrow.fundEscrow{value: 1 ether}(escrowId); + } + + function testFundEscrowWrongState() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.InvalidState.selector); + escrow.fundEscrow{value: 1 ether}(escrowId); + } + + function testFundEscrowWrongAmount() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.InvalidAmount.selector); + escrow.fundEscrow{value: 0.5 ether}(escrowId); + } + + function testFundEscrowWithBasePay() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + bytes32 paymentId = keccak256("payment1"); + + vm.expectEmit(true, false, false, true); + emit EscrowFunded(escrowId, 1 ether); + + escrow.fundEscrowWithBasePay(escrowId, paymentId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Funded); + assertEq(escrowData.paymentId, paymentId); + assertEq(escrow.paymentIdToEscrow(paymentId), escrowId); + } + + function testApproveBuyer() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.expectEmit(true, true, false, false); + emit ApprovalGranted(escrowId, buyer); + + vm.prank(buyer); + escrow.approveBuyer(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.buyerApproved); + } + + function testApproveBuyerUnauthorized() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(seller); + vm.expectRevert(SafeBaseEscrowV1.Unauthorized.selector); + escrow.approveBuyer(escrowId); + } + + function testApproveBuyerAlreadyApproved() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + escrow.approveBuyer(escrowId); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.AlreadyApproved.selector); + escrow.approveBuyer(escrowId); + } + + function testApproveSeller() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.expectEmit(true, true, false, false); + emit ApprovalGranted(escrowId, seller); + + vm.prank(seller); + escrow.approveSeller(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.sellerApproved); + } + + function testReleaseToSeller() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + escrow.approveBuyer(escrowId); + + uint256 sellerBalanceBefore = seller.balance; + + vm.expectEmit(true, true, false, false); + emit EscrowReleased(escrowId, seller); + + vm.prank(buyer); + escrow.releaseToSeller(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Released); + assertEq(seller.balance, sellerBalanceBefore + 1 ether); + } + + function testReleaseToSellerMediator() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + uint256 sellerBalanceBefore = seller.balance; + + vm.prank(mediator); + escrow.releaseToSeller(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Released); + assertEq(seller.balance, sellerBalanceBefore + 1 ether); + } + + function testReleaseToSellerUnauthorized() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(seller); + vm.expectRevert(SafeBaseEscrowV1.Unauthorized.selector); + escrow.releaseToSeller(escrowId); + } + + function testRefundToBuyer() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.warp(block.timestamp + 2 days); + + uint256 buyerBalanceBefore = buyer.balance; + + vm.expectEmit(true, true, false, false); + emit EscrowRefunded(escrowId, buyer); + + vm.prank(buyer); + escrow.refundToBuyer(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Refunded); + assertEq(buyer.balance, buyerBalanceBefore + 1 ether); + } + + function testRefundToBuyerMediator() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + escrow.disputeEscrow(escrowId); + + uint256 buyerBalanceBefore = buyer.balance; + + vm.prank(mediator); + escrow.refundToBuyer(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Refunded); + assertEq(buyer.balance, buyerBalanceBefore + 1 ether); + } + + function testRefundToBuyerBeforeDeadline() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.Unauthorized.selector); + escrow.refundToBuyer(escrowId); + } + + function testDisputeEscrow() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.expectEmit(true, true, false, false); + emit EscrowDisputed(escrowId, buyer); + + vm.prank(buyer); + escrow.disputeEscrow(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Disputed); + } + + function testDisputeEscrowNoMediator() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + address(0), + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.Unauthorized.selector); + escrow.disputeEscrow(escrowId); + } + + function testCancelEscrow() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.expectEmit(true, false, false, false); + emit EscrowCancelled(escrowId); + + vm.prank(buyer); + escrow.cancelEscrow(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Cancelled); + } + + function testCancelEscrowWrongState() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.InvalidState.selector); + escrow.cancelEscrow(escrowId); + } + + function testPauseUnpause() public { + vm.prank(owner); + escrow.pause(); + + vm.prank(buyer); + vm.expectRevert(); + escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(owner); + escrow.unpause(); + + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + assertEq(escrowId, 1); + } + + function testUpgradeAuthorization() public { + SafeBaseEscrowV1 newImpl = new SafeBaseEscrowV1(); + + vm.prank(buyer); + vm.expectRevert(); + escrow.upgradeToAndCall(address(newImpl), ""); + + vm.prank(owner); + escrow.upgradeToAndCall(address(newImpl), ""); + } + + function testStateTransitions() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Created); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Funded); + + vm.prank(buyer); + escrow.approveBuyer(escrowId); + + vm.prank(buyer); + escrow.releaseToSeller(escrowId); + + escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Released); + } + + function testInvalidStateTransitions() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days + ); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.InvalidState.selector); + escrow.approveBuyer(escrowId); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.InvalidState.selector); + escrow.fundEscrow{value: 1 ether}(escrowId); + } + + receive() external payable {} +} From 95b908c4472241f516a52618e157a37c0d01030e Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Wed, 3 Dec 2025 13:55:09 +0000 Subject: [PATCH 03/38] test: add comprehensive tests for RulesEngine, Registry, Executor, and Treasury - RulesEngineV1: test rule creation, validation, and conditional logic - RegistryV1: test escrow indexing, pagination, and party queries - ExecutorV1: test automation, deadline checks, and access control - Treasury: test multi-sig withdrawal flow, admin management, and ERC20 support - Update foundry.toml with gas reports and fuzz configuration --- foundry.toml | 7 + test/ExecutorV1.t.sol | 243 ++++++++++++++++++++++++++++ test/RegistryV1.t.sol | 164 +++++++++++++++++++ test/RulesEngineV1.t.sol | 333 +++++++++++++++++++++++++++++++++++++++ test/Treasury.t.sol | 279 +++++++++++++++++++++++++++++++- 5 files changed, 1025 insertions(+), 1 deletion(-) create mode 100644 test/ExecutorV1.t.sol create mode 100644 test/RegistryV1.t.sol create mode 100644 test/RulesEngineV1.t.sol diff --git a/foundry.toml b/foundry.toml index b3dbd4a..5993eac 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,6 +6,7 @@ solc_version = "0.8.28" optimizer = true optimizer_runs = 200 fs_permissions = [{ access = "read", path = "./deployments" }] +gas_reports = ["SafeBaseEscrowV1", "RulesEngineV1", "RegistryV1", "ExecutorV1"] [rpc_endpoints] base = "https://mainnet.base.org" @@ -14,3 +15,9 @@ base-sepolia = "https://sepolia.base.org" [etherscan] base-sepolia = { key = "${BASESCAN_API_KEY}", url = "https://api-sepolia.basescan.org/api" } base = { key = "${BASESCAN_API_KEY}", url = "https://api.basescan.org/api" } + +[fuzz] +runs = 256 + +[invariant] +runs = 256 diff --git a/test/ExecutorV1.t.sol b/test/ExecutorV1.t.sol new file mode 100644 index 0000000..f33407b --- /dev/null +++ b/test/ExecutorV1.t.sol @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {ExecutorV1} from "../src/escrow/ExecutorV1.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract MockEscrow { + struct EscrowData { + address buyer; + address seller; + address mediator; + address token; + uint256 amount; + uint256 deadline; + uint8 state; + bool buyerApproved; + bool sellerApproved; + bytes32 paymentId; + uint256 createdAt; + } + + EscrowData public escrowData; + + function setEscrow( + address buyer, + uint256 deadline, + uint8 state, + bool buyerApproved, + bool sellerApproved + ) external { + escrowData.buyer = buyer; + escrowData.deadline = deadline; + escrowData.state = state; + escrowData.buyerApproved = buyerApproved; + escrowData.sellerApproved = sellerApproved; + } + + function getEscrow(uint256) external view returns ( + address, address, address, address, uint256, uint256, uint8, bool, bool, bytes32, uint256 + ) { + return ( + escrowData.buyer, + escrowData.seller, + escrowData.mediator, + escrowData.token, + escrowData.amount, + escrowData.deadline, + escrowData.state, + escrowData.buyerApproved, + escrowData.sellerApproved, + escrowData.paymentId, + escrowData.createdAt + ); + } + + function refundToBuyer(uint256) external {} + function releaseToSeller(uint256) external {} +} + +contract MockRulesEngine { + bool public shouldAllow; + + function setCanRelease(bool _shouldAllow) external { + shouldAllow = _shouldAllow; + } + + function canRelease( + uint256, + bool, + bool, + bool, + uint256, + bytes calldata + ) external view returns (bool) { + return shouldAllow; + } + + function canRefund(uint256, uint256, bool) external view returns (bool) { + return shouldAllow; + } +} + +contract ExecutorV1Test is Test { + ExecutorV1 public executor; + MockEscrow public escrowContract; + MockRulesEngine public rulesEngine; + + address public owner = address(1); + address public automator = address(2); + + event DeadlineCheckScheduled(uint256 indexed escrowId, uint256 deadline); + event AutoRefundExecuted(uint256 indexed escrowId); + event AutoReleaseExecuted(uint256 indexed escrowId); + event AutomatorAdded(address indexed automator); + event AutomatorRemoved(address indexed automator); + + function setUp() public { + escrowContract = new MockEscrow(); + rulesEngine = new MockRulesEngine(); + + ExecutorV1 executorImpl = new ExecutorV1(); + bytes memory executorData = abi.encodeWithSelector( + ExecutorV1.initialize.selector, + owner, + address(escrowContract), + address(rulesEngine) + ); + ERC1967Proxy executorProxy = new ERC1967Proxy(address(executorImpl), executorData); + executor = ExecutorV1(address(executorProxy)); + } + + function testInitialize() public view { + assertEq(executor.owner(), owner); + assertEq(address(executor.escrowContract()), address(escrowContract)); + assertEq(address(executor.rulesEngine()), address(rulesEngine)); + } + + function testAddAutomator() public { + vm.expectEmit(true, false, false, false); + emit AutomatorAdded(automator); + + vm.prank(owner); + executor.addAutomator(automator); + + assertTrue(executor.automators(automator)); + } + + function testAddAutomatorOnlyOwner() public { + vm.prank(address(99)); + vm.expectRevert(); + executor.addAutomator(automator); + } + + function testRemoveAutomator() public { + vm.prank(owner); + executor.addAutomator(automator); + + vm.expectEmit(true, false, false, false); + emit AutomatorRemoved(automator); + + vm.prank(owner); + executor.removeAutomator(automator); + + assertFalse(executor.automators(automator)); + } + + function testScheduleDeadlineCheck() public { + vm.prank(owner); + executor.addAutomator(automator); + + uint256 deadline = block.timestamp + 1 days; + + vm.expectEmit(true, false, false, true); + emit DeadlineCheckScheduled(1, deadline); + + vm.prank(automator); + executor.scheduleDeadlineCheck(1, deadline); + + assertTrue(executor.scheduledForRefund(1)); + } + + function testScheduleDeadlineCheckOnlyAutomator() public { + uint256 deadline = block.timestamp + 1 days; + + vm.prank(address(99)); + vm.expectRevert(ExecutorV1.Unauthorized.selector); + executor.scheduleDeadlineCheck(1, deadline); + } + + function testExecuteAutoRefund() public { + vm.prank(owner); + executor.addAutomator(automator); + + escrowContract.setEscrow(address(3), block.timestamp - 1, 1, false, false); + rulesEngine.setCanRelease(true); + + vm.expectEmit(true, false, false, false); + emit AutoRefundExecuted(1); + + vm.prank(automator); + executor.executeAutoRefund(1, 1); + + assertFalse(executor.scheduledForRefund(1)); + } + + function testExecuteAutoRefundBeforeDeadline() public { + vm.prank(owner); + executor.addAutomator(automator); + + escrowContract.setEscrow(address(3), block.timestamp + 1 days, 1, false, false); + rulesEngine.setCanRelease(false); + + vm.prank(automator); + vm.expectRevert(ExecutorV1.DeadlineNotReached.selector); + executor.executeAutoRefund(1, 1); + } + + function testExecuteAutoRelease() public { + vm.prank(owner); + executor.addAutomator(automator); + + escrowContract.setEscrow(address(3), block.timestamp + 1 days, 1, true, true); + rulesEngine.setCanRelease(true); + + vm.expectEmit(true, false, false, false); + emit AutoReleaseExecuted(1); + + vm.prank(automator); + executor.executeAutoRelease(1, 1); + + assertFalse(executor.scheduledForRelease(1)); + } + + function testCheckAndExecuteDeadlines() public { + vm.prank(owner); + executor.addAutomator(automator); + + escrowContract.setEscrow(address(3), block.timestamp - 1, 1, false, false); + rulesEngine.setCanRelease(true); + + uint256[] memory escrowIds = new uint256[](1); + escrowIds[0] = 1; + + vm.prank(automator); + executor.checkAndExecuteDeadlines(escrowIds, 1); + } + + function testOnlyAutomatorModifier() public { + vm.prank(address(99)); + vm.expectRevert(ExecutorV1.Unauthorized.selector); + executor.scheduleDeadlineCheck(1, block.timestamp + 1 days); + } + + function testOwnerCanExecuteAutomatorFunctions() public { + uint256 deadline = block.timestamp + 1 days; + + vm.prank(owner); + executor.scheduleDeadlineCheck(1, deadline); + + assertTrue(executor.scheduledForRefund(1)); + } +} diff --git a/test/RegistryV1.t.sol b/test/RegistryV1.t.sol new file mode 100644 index 0000000..0715054 --- /dev/null +++ b/test/RegistryV1.t.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {RegistryV1} from "../src/escrow/RegistryV1.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract RegistryV1Test is Test { + RegistryV1 public registry; + + address public owner = address(1); + address public escrowContract = address(2); + address public buyer = address(3); + address public seller = address(4); + + event EscrowIndexed(uint256 indexed escrowId, address indexed buyer, address indexed seller); + event EscrowUpdated(uint256 indexed escrowId, uint8 state); + + function setUp() public { + RegistryV1 registryImpl = new RegistryV1(); + bytes memory registryData = abi.encodeWithSelector( + RegistryV1.initialize.selector, + owner + ); + ERC1967Proxy registryProxy = new ERC1967Proxy(address(registryImpl), registryData); + registry = RegistryV1(address(registryProxy)); + + vm.prank(owner); + registry.setEscrowContract(escrowContract); + } + + function testInitialize() public view { + assertEq(registry.owner(), owner); + } + + function testSetEscrowContract() public { + RegistryV1 newRegistry = RegistryV1(address(new ERC1967Proxy( + address(new RegistryV1()), + abi.encodeWithSelector(RegistryV1.initialize.selector, owner) + ))); + + vm.prank(owner); + newRegistry.setEscrowContract(address(999)); + + assertEq(newRegistry.escrowContract(), address(999)); + } + + function testSetEscrowContractOnlyOwner() public { + vm.prank(address(99)); + vm.expectRevert(); + registry.setEscrowContract(address(999)); + } + + function testIndexEscrow() public { + vm.expectEmit(true, true, true, false); + emit EscrowIndexed(1, buyer, seller); + + vm.prank(escrowContract); + registry.indexEscrow(1, buyer, seller, 1 ether, block.timestamp); + + RegistryV1.EscrowMetadata memory metadata = registry.getEscrowMetadata(1); + assertEq(metadata.buyer, buyer); + assertEq(metadata.seller, seller); + assertEq(metadata.amount, 1 ether); + assertEq(metadata.createdAt, block.timestamp); + assertEq(metadata.state, 0); + } + + function testIndexEscrowUnauthorized() public { + vm.prank(address(99)); + vm.expectRevert(RegistryV1.Unauthorized.selector); + registry.indexEscrow(1, buyer, seller, 1 ether, block.timestamp); + } + + function testUpdateEscrowState() public { + vm.prank(escrowContract); + registry.indexEscrow(1, buyer, seller, 1 ether, block.timestamp); + + vm.expectEmit(true, false, false, true); + emit EscrowUpdated(1, 1); + + vm.prank(escrowContract); + registry.updateEscrowState(1, 1); + + RegistryV1.EscrowMetadata memory metadata = registry.getEscrowMetadata(1); + assertEq(metadata.state, 1); + } + + function testUpdateEscrowStateUnauthorized() public { + vm.prank(escrowContract); + registry.indexEscrow(1, buyer, seller, 1 ether, block.timestamp); + + vm.prank(address(99)); + vm.expectRevert(RegistryV1.Unauthorized.selector); + registry.updateEscrowState(1, 1); + } + + function testGetPartyEscrows() public { + vm.startPrank(escrowContract); + registry.indexEscrow(1, buyer, seller, 1 ether, block.timestamp); + registry.indexEscrow(2, buyer, address(5), 2 ether, block.timestamp); + registry.indexEscrow(3, address(6), seller, 3 ether, block.timestamp); + vm.stopPrank(); + + uint256[] memory buyerEscrows = registry.getPartyEscrows(buyer); + assertEq(buyerEscrows.length, 2); + assertEq(buyerEscrows[0], 1); + assertEq(buyerEscrows[1], 2); + + uint256[] memory sellerEscrows = registry.getPartyEscrows(seller); + assertEq(sellerEscrows.length, 2); + assertEq(sellerEscrows[0], 1); + assertEq(sellerEscrows[1], 3); + } + + function testGetEscrowsPaginated() public { + vm.startPrank(escrowContract); + for (uint256 i = 1; i <= 10; i++) { + registry.indexEscrow(i, buyer, seller, i * 1 ether, block.timestamp); + } + vm.stopPrank(); + + uint256[] memory page1 = registry.getEscrowsPaginated(0, 5); + assertEq(page1.length, 5); + assertEq(page1[0], 1); + assertEq(page1[4], 5); + + uint256[] memory page2 = registry.getEscrowsPaginated(5, 5); + assertEq(page2.length, 5); + assertEq(page2[0], 6); + assertEq(page2[4], 10); + + uint256[] memory page3 = registry.getEscrowsPaginated(8, 5); + assertEq(page3.length, 2); + assertEq(page3[0], 9); + assertEq(page3[1], 10); + + uint256[] memory page4 = registry.getEscrowsPaginated(100, 5); + assertEq(page4.length, 0); + } + + function testGetTotalEscrowCount() public { + vm.startPrank(escrowContract); + registry.indexEscrow(1, buyer, seller, 1 ether, block.timestamp); + registry.indexEscrow(2, buyer, seller, 2 ether, block.timestamp); + registry.indexEscrow(3, buyer, seller, 3 ether, block.timestamp); + vm.stopPrank(); + + assertEq(registry.getTotalEscrowCount(), 3); + } + + function testGetPartyEscrowCount() public { + vm.startPrank(escrowContract); + registry.indexEscrow(1, buyer, seller, 1 ether, block.timestamp); + registry.indexEscrow(2, buyer, address(5), 2 ether, block.timestamp); + registry.indexEscrow(3, address(6), seller, 3 ether, block.timestamp); + vm.stopPrank(); + + assertEq(registry.getPartyEscrowCount(buyer), 2); + assertEq(registry.getPartyEscrowCount(seller), 2); + assertEq(registry.getPartyEscrowCount(address(5)), 1); + assertEq(registry.getPartyEscrowCount(address(6)), 1); + } +} diff --git a/test/RulesEngineV1.t.sol b/test/RulesEngineV1.t.sol new file mode 100644 index 0000000..19d537c --- /dev/null +++ b/test/RulesEngineV1.t.sol @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {RulesEngineV1} from "../src/escrow/RulesEngineV1.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {MockVerifier} from "./mocks/MockVerifier.sol"; + +contract RulesEngineV1Test is Test { + RulesEngineV1 public rulesEngine; + MockVerifier public verifier; + + address public owner = address(1); + + event RuleSetCreated(uint256 indexed ruleSetId); + event RuleSetUpdated(uint256 indexed ruleSetId); + event DefaultRuleSetChanged(uint256 indexed ruleSetId); + + function setUp() public { + verifier = new MockVerifier(); + + RulesEngineV1 rulesEngineImpl = new RulesEngineV1(); + bytes memory rulesEngineData = abi.encodeWithSelector( + RulesEngineV1.initialize.selector, + owner + ); + ERC1967Proxy rulesEngineProxy = new ERC1967Proxy(address(rulesEngineImpl), rulesEngineData); + rulesEngine = RulesEngineV1(address(rulesEngineProxy)); + } + + function testInitialize() public view { + assertEq(rulesEngine.owner(), owner); + } + + function testCreateRuleSet() public { + vm.prank(owner); + + uint256 ruleSetId = rulesEngine.createRuleSet( + true, + true, + true, + true, + true, + false, + address(0) + ); + + assertTrue(ruleSetId != 0); + + RulesEngineV1.RuleSet memory ruleSet = rulesEngine.getRuleSet(ruleSetId); + assertTrue(ruleSet.requireBuyerApproval); + assertTrue(ruleSet.requireSellerApproval); + assertTrue(ruleSet.autoRefundAfterDeadline); + assertTrue(ruleSet.autoReleaseOnFullApproval); + assertTrue(ruleSet.mediatorOverrideEnabled); + assertFalse(ruleSet.externalVerifierEnabled); + assertEq(ruleSet.externalVerifier, address(0)); + } + + function testCreateRuleSetOnlyOwner() public { + vm.prank(address(99)); + vm.expectRevert(); + rulesEngine.createRuleSet( + true, + true, + true, + true, + true, + false, + address(0) + ); + } + + function testSetDefaultRuleSet() public { + vm.prank(owner); + uint256 ruleSetId = rulesEngine.createRuleSet( + true, + true, + true, + true, + true, + false, + address(0) + ); + + vm.expectEmit(true, false, false, false); + emit DefaultRuleSetChanged(ruleSetId); + + vm.prank(owner); + rulesEngine.setDefaultRuleSet(ruleSetId); + + assertEq(rulesEngine.defaultRuleSetId(), ruleSetId); + } + + function testSetDefaultRuleSetInvalid() public { + vm.prank(owner); + uint256 ruleSetId = rulesEngine.createRuleSet( + false, + false, + true, + true, + true, + false, + address(0) + ); + + vm.prank(owner); + vm.expectRevert(RulesEngineV1.InvalidRuleSet.selector); + rulesEngine.setDefaultRuleSet(ruleSetId); + } + + function testCanReleaseWithBuyerApproval() public { + vm.prank(owner); + uint256 ruleSetId = rulesEngine.createRuleSet( + true, + false, + false, + false, + false, + false, + address(0) + ); + + bool canRelease = rulesEngine.canRelease( + ruleSetId, + true, + false, + false, + 1, + "" + ); + + assertTrue(canRelease); + + canRelease = rulesEngine.canRelease( + ruleSetId, + false, + false, + false, + 1, + "" + ); + + assertFalse(canRelease); + } + + function testCanReleaseWithSellerApproval() public { + vm.prank(owner); + uint256 ruleSetId = rulesEngine.createRuleSet( + false, + true, + false, + false, + false, + false, + address(0) + ); + + bool canRelease = rulesEngine.canRelease( + ruleSetId, + false, + true, + false, + 1, + "" + ); + + assertTrue(canRelease); + + canRelease = rulesEngine.canRelease( + ruleSetId, + false, + false, + false, + 1, + "" + ); + + assertFalse(canRelease); + } + + function testCanReleaseMediatorOverride() public { + vm.prank(owner); + uint256 ruleSetId = rulesEngine.createRuleSet( + true, + true, + false, + false, + true, + false, + address(0) + ); + + bool canRelease = rulesEngine.canRelease( + ruleSetId, + false, + false, + true, + 1, + "" + ); + + assertTrue(canRelease); + } + + function testCanReleaseExternalVerifier() public { + vm.prank(owner); + uint256 ruleSetId = rulesEngine.createRuleSet( + false, + false, + false, + false, + false, + true, + address(verifier) + ); + + verifier.setVerificationResult(1, true); + + bool canRelease = rulesEngine.canRelease( + ruleSetId, + false, + false, + false, + 1, + "" + ); + + assertTrue(canRelease); + + verifier.setVerificationResult(1, false); + + canRelease = rulesEngine.canRelease( + ruleSetId, + false, + false, + false, + 1, + "" + ); + + assertFalse(canRelease); + } + + function testCanReleaseAutoRelease() public { + vm.prank(owner); + uint256 ruleSetId = rulesEngine.createRuleSet( + false, + false, + false, + true, + false, + false, + address(0) + ); + + bool canRelease = rulesEngine.canRelease( + ruleSetId, + true, + true, + false, + 1, + "" + ); + + assertTrue(canRelease); + + canRelease = rulesEngine.canRelease( + ruleSetId, + false, + false, + false, + 1, + "" + ); + + assertFalse(canRelease); + } + + function testCanRefundAfterDeadline() public { + vm.prank(owner); + uint256 ruleSetId = rulesEngine.createRuleSet( + false, + false, + true, + false, + false, + false, + address(0) + ); + + uint256 deadline = block.timestamp + 1 days; + + bool canRefund = rulesEngine.canRefund( + ruleSetId, + deadline, + false + ); + + assertFalse(canRefund); + + vm.warp(block.timestamp + 2 days); + + canRefund = rulesEngine.canRefund( + ruleSetId, + deadline, + false + ); + + assertTrue(canRefund); + } + + function testCanRefundMediatorOverride() public { + vm.prank(owner); + uint256 ruleSetId = rulesEngine.createRuleSet( + false, + false, + false, + false, + true, + false, + address(0) + ); + + bool canRefund = rulesEngine.canRefund( + ruleSetId, + block.timestamp + 1 days, + true + ); + + assertTrue(canRefund); + } +} diff --git a/test/Treasury.t.sol b/test/Treasury.t.sol index 6bbfa03..6606171 100644 --- a/test/Treasury.t.sol +++ b/test/Treasury.t.sol @@ -1,4 +1,281 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; -contract Treasury {} +import {Test} from "forge-std/Test.sol"; +import {Treasury} from "../src/Treasury.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; + +contract TreasuryTest is Test { + Treasury public treasury; + MockERC20 public token; + + address public owner = address(1); + address public admin1 = address(2); + address public admin2 = address(3); + address public executor = address(4); + address public recipient = address(5); + + event AdminAdded(address indexed admin); + event AdminRemoved(address indexed admin); + event ExecutorAdded(address indexed executor); + event ExecutorRemoved(address indexed executor); + event Deposited(address indexed from, uint256 amount); + event WithdrawalRequested(uint256 indexed requestId, address token, address to, uint256 amount); + event WithdrawalApproved(uint256 indexed requestId, address indexed admin); + event WithdrawalExecuted(uint256 indexed requestId); + event WithdrawalCancelled(uint256 indexed requestId); + + function setUp() public { + token = new MockERC20("Test Token", "TEST", 18); + + Treasury treasuryImpl = new Treasury(); + bytes memory treasuryData = abi.encodeWithSelector( + Treasury.initialize.selector, + owner, + 2 + ); + ERC1967Proxy treasuryProxy = new ERC1967Proxy(address(treasuryImpl), treasuryData); + treasury = Treasury(payable(address(treasuryProxy))); + + vm.startPrank(owner); + treasury.addAdmin(admin1); + treasury.addAdmin(admin2); + treasury.addExecutor(executor); + vm.stopPrank(); + + token.mint(address(treasury), 1000 ether); + vm.deal(address(treasury), 100 ether); + } + + function testInitialize() public view { + assertEq(treasury.owner(), owner); + assertEq(treasury.requiredApprovals(), 2); + } + + function testAddAdmin() public { + address newAdmin = address(6); + + vm.expectEmit(true, false, false, false); + emit AdminAdded(newAdmin); + + vm.prank(owner); + treasury.addAdmin(newAdmin); + + assertTrue(treasury.admins(newAdmin)); + assertEq(treasury.adminCount(), 3); + } + + function testAddAdminOnlyOwner() public { + vm.prank(address(99)); + vm.expectRevert(); + treasury.addAdmin(address(6)); + } + + function testRemoveAdmin() public { + vm.expectEmit(true, false, false, false); + emit AdminRemoved(admin1); + + vm.prank(owner); + treasury.removeAdmin(admin1); + + assertFalse(treasury.admins(admin1)); + assertEq(treasury.adminCount(), 1); + } + + function testAddExecutor() public { + address newExecutor = address(7); + + vm.expectEmit(true, false, false, false); + emit ExecutorAdded(newExecutor); + + vm.prank(owner); + treasury.addExecutor(newExecutor); + + assertTrue(treasury.executors(newExecutor)); + } + + function testRemoveExecutor() public { + vm.expectEmit(true, false, false, false); + emit ExecutorRemoved(executor); + + vm.prank(owner); + treasury.removeExecutor(executor); + + assertFalse(treasury.executors(executor)); + } + + function testRequestWithdrawal() public { + vm.expectEmit(true, false, false, true); + emit WithdrawalRequested(0, address(0), recipient, 1 ether); + + vm.prank(admin1); + uint256 requestId = treasury.requestWithdrawal(address(0), recipient, 1 ether); + + assertEq(requestId, 0); + assertEq(treasury.requestCount(), 1); + } + + function testRequestWithdrawalOnlyAdmin() public { + vm.prank(address(99)); + vm.expectRevert(Treasury.NotAdmin.selector); + treasury.requestWithdrawal(address(0), recipient, 1 ether); + } + + function testRequestWithdrawalZeroAddress() public { + vm.prank(admin1); + vm.expectRevert(Treasury.ZeroAddress.selector); + treasury.requestWithdrawal(address(0), address(0), 1 ether); + } + + function testRequestWithdrawalZeroAmount() public { + vm.prank(admin1); + vm.expectRevert(Treasury.ZeroAmount.selector); + treasury.requestWithdrawal(address(0), recipient, 0); + } + + function testRequestWithdrawalInsufficientBalance() public { + vm.prank(admin1); + vm.expectRevert(Treasury.InsufficientBalance.selector); + treasury.requestWithdrawal(address(0), recipient, 1000 ether); + } + + function testApproveWithdrawal() public { + vm.prank(admin1); + uint256 requestId = treasury.requestWithdrawal(address(0), recipient, 1 ether); + + vm.expectEmit(true, true, false, false); + emit WithdrawalApproved(requestId, admin2); + + vm.prank(admin2); + treasury.approveWithdrawal(requestId); + } + + function testApproveWithdrawalOnlyAdmin() public { + vm.prank(admin1); + uint256 requestId = treasury.requestWithdrawal(address(0), recipient, 1 ether); + + vm.prank(address(99)); + vm.expectRevert(Treasury.NotAdmin.selector); + treasury.approveWithdrawal(requestId); + } + + function testApproveWithdrawalAlreadyApproved() public { + vm.prank(admin1); + uint256 requestId = treasury.requestWithdrawal(address(0), recipient, 1 ether); + + vm.prank(admin1); + treasury.approveWithdrawal(requestId); + + vm.prank(admin1); + vm.expectRevert(Treasury.AlreadyApproved.selector); + treasury.approveWithdrawal(requestId); + } + + function testExecuteWithdrawal() public { + vm.prank(admin1); + uint256 requestId = treasury.requestWithdrawal(address(0), recipient, 1 ether); + + vm.prank(admin1); + treasury.approveWithdrawal(requestId); + + vm.prank(admin2); + treasury.approveWithdrawal(requestId); + + uint256 recipientBalanceBefore = recipient.balance; + + vm.expectEmit(true, false, false, false); + emit WithdrawalExecuted(requestId); + + vm.prank(executor); + treasury.executeWithdrawal(requestId); + + assertEq(recipient.balance, recipientBalanceBefore + 1 ether); + } + + function testExecuteWithdrawalOnlyExecutor() public { + vm.prank(admin1); + uint256 requestId = treasury.requestWithdrawal(address(0), recipient, 1 ether); + + vm.prank(admin1); + treasury.approveWithdrawal(requestId); + + vm.prank(admin2); + treasury.approveWithdrawal(requestId); + + vm.prank(address(99)); + vm.expectRevert(Treasury.NotExecutor.selector); + treasury.executeWithdrawal(requestId); + } + + function testExecuteWithdrawalInsufficientApprovals() public { + vm.prank(admin1); + uint256 requestId = treasury.requestWithdrawal(address(0), recipient, 1 ether); + + vm.prank(admin1); + treasury.approveWithdrawal(requestId); + + vm.prank(executor); + vm.expectRevert(Treasury.InvalidStatus.selector); + treasury.executeWithdrawal(requestId); + } + + function testExecuteWithdrawalERC20() public { + vm.prank(admin1); + uint256 requestId = treasury.requestWithdrawal(address(token), recipient, 10 ether); + + vm.prank(admin1); + treasury.approveWithdrawal(requestId); + + vm.prank(admin2); + treasury.approveWithdrawal(requestId); + + uint256 recipientBalanceBefore = token.balanceOf(recipient); + + vm.prank(executor); + treasury.executeWithdrawal(requestId); + + assertEq(token.balanceOf(recipient), recipientBalanceBefore + 10 ether); + } + + function testCancelWithdrawal() public { + vm.prank(admin1); + uint256 requestId = treasury.requestWithdrawal(address(0), recipient, 1 ether); + + vm.expectEmit(true, false, false, false); + emit WithdrawalCancelled(requestId); + + vm.prank(admin1); + treasury.cancelWithdrawal(requestId); + } + + function testCancelWithdrawalOnlyAdmin() public { + vm.prank(admin1); + uint256 requestId = treasury.requestWithdrawal(address(0), recipient, 1 ether); + + vm.prank(address(99)); + vm.expectRevert(Treasury.NotAdmin.selector); + treasury.cancelWithdrawal(requestId); + } + + function testSetRequiredApprovals() public { + vm.prank(owner); + treasury.setRequiredApprovals(3); + + assertEq(treasury.requiredApprovals(), 3); + } + + function testReceiveETH() public { + uint256 balanceBefore = address(treasury).balance; + + vm.expectEmit(true, false, false, true); + emit Deposited(address(this), 5 ether); + + (bool success,) = address(treasury).call{value: 5 ether}(""); + assertTrue(success); + + assertEq(address(treasury).balance, balanceBefore + 5 ether); + } + + receive() external payable {} +} From 520d8327bc1dd77b86872f705a31282411c54f93 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Wed, 3 Dec 2025 14:43:36 +0000 Subject: [PATCH 04/38] ci: improve GitHub Actions workflows - Remove incomplete workflow files (deps.yml, slither-analysis.yml) - Update CI to run on both main and develop branches - Add gas snapshot tracking with 5% tolerance - Add contract size reporting in build step - Add coverage report with lcov format - Add Codecov integration for coverage tracking --- .github/workflows/ci.yml | 21 +++++++++++++++++---- .github/workflows/deps.yml | 9 --------- .github/workflows/slither-analysis.yml | 9 --------- 3 files changed, 17 insertions(+), 22 deletions(-) delete mode 100644 .github/workflows/deps.yml delete mode 100644 .github/workflows/slither-analysis.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee363e0..5432847 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: + branches: [main, develop] jobs: test: @@ -17,10 +18,22 @@ jobs: uses: foundry-rs/foundry-toolchain@v1 - name: Build contracts - run: forge build + run: forge build --sizes - name: Run tests run: forge test -vvv - - name: Coverage - run: forge coverage --report summary + - name: Gas Snapshot + run: forge snapshot --check --tolerance 5 + + - name: Coverage Report + run: | + forge coverage --report summary + forge coverage --report lcov + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./lcov.info + flags: foundry + fail_ci_if_error: false diff --git a/.github/workflows/deps.yml b/.github/workflows/deps.yml deleted file mode 100644 index 9026d33..0000000 --- a/.github/workflows/deps.yml +++ /dev/null @@ -1,9 +0,0 @@ -name: ci: add dependency updates - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 diff --git a/.github/workflows/slither-analysis.yml b/.github/workflows/slither-analysis.yml deleted file mode 100644 index 28d4d93..0000000 --- a/.github/workflows/slither-analysis.yml +++ /dev/null @@ -1,9 +0,0 @@ -name: ci: add security scanning - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 From 8f5934ecdfb9fd7439d12f14107d6fe217f08c52 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Wed, 3 Dec 2025 15:10:05 +0000 Subject: [PATCH 05/38] fix: replace transfer() with call() for UUPS proxy compatibility - Change ETH transfer from transfer() to call{value}() - transfer() has 2300 gas limit which fails with UUPS proxies - call() forwards all available gas allowing proxy fallback to execute - Add error handling for failed transfers - Fixes 17 failing tests in SafeBaseEscrowV1Test Resolves OutOfGas errors when funding escrow with ETH to Treasury proxy. --- src/escrow/SafeBaseEscrowV1.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/escrow/SafeBaseEscrowV1.sol b/src/escrow/SafeBaseEscrowV1.sol index 875fd65..7dcaa5d 100644 --- a/src/escrow/SafeBaseEscrowV1.sol +++ b/src/escrow/SafeBaseEscrowV1.sol @@ -128,7 +128,8 @@ contract SafeBaseEscrowV1 is if (escrow.token == address(0)) { if (msg.value != escrow.amount) revert InvalidAmount(); - payable(address(treasury)).transfer(msg.value); + (bool success, ) = payable(address(treasury)).call{value: msg.value}(""); + require(success, "ETH transfer failed"); } escrow.state = EscrowState.Funded; From 52c6f04046e56909482c642afa53675f86ee9ded Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Wed, 3 Dec 2025 15:15:26 +0000 Subject: [PATCH 06/38] fix: add executeWithdrawal calls in release and refund functions - Add treasury.executeWithdrawal() after approveWithdrawal() - Ensures funds are immediately transferred to recipient - releaseToSeller now completes full withdrawal flow - refundToBuyer now completes full withdrawal flow - Fixes 4 failing balance assertion tests This completes the withdrawal process instead of just approving it, since SafeBaseEscrow has both admin and executor roles in Treasury. --- src/escrow/SafeBaseEscrowV1.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/escrow/SafeBaseEscrowV1.sol b/src/escrow/SafeBaseEscrowV1.sol index 7dcaa5d..f1615de 100644 --- a/src/escrow/SafeBaseEscrowV1.sol +++ b/src/escrow/SafeBaseEscrowV1.sol @@ -182,6 +182,7 @@ contract SafeBaseEscrowV1 is uint256 requestId = treasury.requestWithdrawal(escrow.token, escrow.seller, escrow.amount); treasury.approveWithdrawal(requestId); + treasury.executeWithdrawal(requestId); emit EscrowReleased(_escrowId, escrow.seller); } @@ -199,6 +200,7 @@ contract SafeBaseEscrowV1 is uint256 requestId = treasury.requestWithdrawal(escrow.token, escrow.buyer, escrow.amount); treasury.approveWithdrawal(requestId); + treasury.executeWithdrawal(requestId); emit EscrowRefunded(_escrowId, escrow.buyer); } From 2ad93e832f4d2e4b8fdebdc6bb978a512a17dbb5 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Wed, 3 Dec 2025 15:17:37 +0000 Subject: [PATCH 07/38] ci: fix gas snapshot step for first run - Add conditional check for .gas-snapshot file existence - Create snapshot on first run instead of checking - Check snapshot with 5% tolerance on subsequent runs - Prevents CI failure when snapshot file doesn't exist Fixes "No such file or directory" error in GitHub Actions. --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5432847..03583b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,12 @@ jobs: run: forge test -vvv - name: Gas Snapshot - run: forge snapshot --check --tolerance 5 + run: | + if [ -f .gas-snapshot ]; then + forge snapshot --check --tolerance 5 + else + forge snapshot + fi - name: Coverage Report run: | From 732da59318b748fc1c89b59543f8ebbcc000d143 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Thu, 4 Dec 2025 06:24:24 +0000 Subject: [PATCH 08/38] feat: add UUPS upgrade workflow and script - Add UpgradeContract.s.sol for upgrading any UUPS contract - Add GitHub Actions workflow for manual contract upgrades - Supports all contracts: Treasury, SafeBaseEscrow, RulesEngine, etc. - Choose network (sepolia/mainnet) and contract via UI - Includes dry run simulation before actual upgrade - Auto-verifies new implementation on Basescan Usage: GitHub Actions -> Upgrade Contract -> Run workflow --- .github/workflows/upgrade.yml | 95 ++++++++++++++++++++++ script/UpgradeContract.s.sol | 147 ++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 .github/workflows/upgrade.yml create mode 100644 script/UpgradeContract.s.sol diff --git a/.github/workflows/upgrade.yml b/.github/workflows/upgrade.yml new file mode 100644 index 0000000..2339ce3 --- /dev/null +++ b/.github/workflows/upgrade.yml @@ -0,0 +1,95 @@ +name: Upgrade Contract + +on: + workflow_dispatch: + inputs: + network: + description: 'Network to upgrade on' + required: true + type: choice + options: + - sepolia + - mainnet + contract: + description: 'Contract to upgrade' + required: true + type: choice + options: + - Treasury + - SafeBaseEscrow + - RulesEngine + - Registry + - Executor + - AccessController + - Verifier + - BasePay + - PaymentTracker + +jobs: + upgrade: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Set RPC URL + id: set-rpc + run: | + if [ "${{ inputs.network }}" = "mainnet" ]; then + echo "rpc_url=https://mainnet.base.org" >> $GITHUB_OUTPUT + echo "chain_name=Base Mainnet" >> $GITHUB_OUTPUT + else + echo "rpc_url=https://sepolia.base.org" >> $GITHUB_OUTPUT + echo "chain_name=Base Sepolia" >> $GITHUB_OUTPUT + fi + + - name: Dry Run - Simulate Upgrade + run: | + echo "=== Simulating Upgrade ===" + echo "Network: ${{ steps.set-rpc.outputs.chain_name }}" + echo "Contract: ${{ inputs.contract }}" + forge script script/UpgradeContract.s.sol \ + --rpc-url ${{ steps.set-rpc.outputs.rpc_url }} \ + -vvv + env: + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + UPGRADE_CONTRACT: ${{ inputs.contract }} + + - name: Upgrade Contract + run: | + echo "=== Executing Upgrade ===" + forge script script/UpgradeContract.s.sol \ + --rpc-url ${{ steps.set-rpc.outputs.rpc_url }} \ + --broadcast \ + --verify \ + --verifier etherscan \ + --etherscan-api-key ${{ secrets.BASESCAN_API_KEY }} \ + -vvvv + env: + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + UPGRADE_CONTRACT: ${{ inputs.contract }} + BASESCAN_API_KEY: ${{ secrets.BASESCAN_API_KEY }} + + - name: Save upgrade artifacts + uses: actions/upload-artifact@v4 + with: + name: upgrade-${{ inputs.contract }}-${{ inputs.network }}-${{ github.run_number }} + path: | + broadcast/ + cache/ + out/ + + - name: Upgrade Summary + run: | + echo "## ✅ Upgrade Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Contract:** ${{ inputs.contract }}" >> $GITHUB_STEP_SUMMARY + echo "**Network:** ${{ steps.set-rpc.outputs.chain_name }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Check the logs above for proxy and implementation addresses." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "⚠️ **Important:** Update the deployment JSON file with the new implementation address!" >> $GITHUB_STEP_SUMMARY diff --git a/script/UpgradeContract.s.sol b/script/UpgradeContract.s.sol new file mode 100644 index 0000000..67bc262 --- /dev/null +++ b/script/UpgradeContract.s.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Script.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +import {Treasury} from "../src/Treasury.sol"; +import {SafeBaseEscrowV1} from "../src/escrow/SafeBaseEscrowV1.sol"; +import {RulesEngineV1} from "../src/escrow/RulesEngineV1.sol"; +import {RegistryV1} from "../src/escrow/RegistryV1.sol"; +import {ExecutorV1} from "../src/escrow/ExecutorV1.sol"; +import {AccessController} from "../src/access/AccessController.sol"; +import {Verifier} from "../src/integrations/Verifier.sol"; +import {BasePay} from "../src/integrations/BasePay.sol"; +import {PaymentTracker} from "../src/integrations/PaymentTracker.sol"; + +/** + * @title UpgradeContractScript + * @notice Universal UUPS upgrade script for SafeBase contracts + * @dev Usage: + * forge script script/UpgradeContract.s.sol --rpc-url --broadcast + * Environment variables: + * - PRIVATE_KEY: Deployer private key (must be contract owner) + * - UPGRADE_CONTRACT: Contract to upgrade (Treasury, SafeBaseEscrow, etc.) + */ +contract UpgradeContractScript is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + string memory contractToUpgrade = vm.envString("UPGRADE_CONTRACT"); + + address proxyAddress = loadProxyAddress(contractToUpgrade); + + console.log("=== UUPS Contract Upgrade ==="); + console.log("Chain ID:", block.chainid); + console.log("Contract:", contractToUpgrade); + console.log("Proxy:", proxyAddress); + + vm.startBroadcast(deployerPrivateKey); + + address newImplementation = deployNewImplementation(contractToUpgrade); + + console.log("New Implementation:", newImplementation); + console.log("Upgrading proxy..."); + + // Call upgradeToAndCall on the proxy + UUPSUpgradeable(proxyAddress).upgradeToAndCall(newImplementation, ""); + + vm.stopBroadcast(); + + console.log("=== Upgrade Complete ==="); + console.log("Proxy:", proxyAddress); + console.log("New Implementation:", newImplementation); + + // Save to deployment file + saveDeployment(contractToUpgrade, proxyAddress, newImplementation); + } + + function deployNewImplementation(string memory contractName) internal returns (address) { + bytes32 nameHash = keccak256(bytes(contractName)); + + if (nameHash == keccak256("Treasury")) { + Treasury impl = new Treasury(); + return address(impl); + } else if (nameHash == keccak256("SafeBaseEscrow")) { + SafeBaseEscrowV1 impl = new SafeBaseEscrowV1(); + return address(impl); + } else if (nameHash == keccak256("RulesEngine")) { + RulesEngineV1 impl = new RulesEngineV1(); + return address(impl); + } else if (nameHash == keccak256("Registry")) { + RegistryV1 impl = new RegistryV1(); + return address(impl); + } else if (nameHash == keccak256("Executor")) { + ExecutorV1 impl = new ExecutorV1(); + return address(impl); + } else if (nameHash == keccak256("AccessController")) { + AccessController impl = new AccessController(); + return address(impl); + } else if (nameHash == keccak256("Verifier")) { + Verifier impl = new Verifier(); + return address(impl); + } else if (nameHash == keccak256("BasePay")) { + BasePay impl = new BasePay(); + return address(impl); + } else if (nameHash == keccak256("PaymentTracker")) { + PaymentTracker impl = new PaymentTracker(); + return address(impl); + } else { + revert(string.concat("Unknown contract: ", contractName)); + } + } + + function loadProxyAddress(string memory contractName) internal view returns (address) { + uint256 chainId = block.chainid; + string memory root = vm.projectRoot(); + string memory path; + + if (chainId == 8453) { + path = string.concat(root, "/deployments/8453.json"); + } else if (chainId == 84532) { + path = string.concat(root, "/deployments/84532.json"); + } else { + revert("Unsupported chain"); + } + + string memory json = vm.readFile(path); + string memory jsonPath = string.concat(".contracts.", contractName, ".proxy"); + + address proxyAddr = vm.parseJsonAddress(json, jsonPath); + require(proxyAddr != address(0), "Proxy address not found"); + + return proxyAddr; + } + + function saveDeployment( + string memory contractName, + address proxyAddress, + address implementationAddress + ) internal { + uint256 chainId = block.chainid; + string memory root = vm.projectRoot(); + string memory path; + + if (chainId == 8453) { + path = string.concat(root, "/deployments/8453.json"); + } else if (chainId == 84532) { + path = string.concat(root, "/deployments/84532.json"); + } else { + return; // Don't save for unsupported chains + } + + // Read existing JSON + string memory existingJson = vm.readFile(path); + + // Create new contract entry + string memory contractJson = string.concat( + '{"proxy":"', vm.toString(proxyAddress), + '","implementation":"', vm.toString(implementationAddress), '"}' + ); + + // Log the update (actual file writing would need additional tooling) + console.log("\n=== Update deployment file manually ==="); + console.log("File:", path); + console.log("Contract:", contractName); + console.log("New implementation:", implementationAddress); + } +} From 53f47c15ef088a4b2e010d03b85555f89f54d6af Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Thu, 4 Dec 2025 06:49:40 +0000 Subject: [PATCH 09/38] chore: update SafeBaseEscrow implementation addresses after upgrade Base Mainnet (8453): - Old: 0x4b015dF938d053388796078FA3F5145C550C4EE8 - New: 0x57741EE5bAc991D43Cf71207781fCB0eE4b9e9a8 - Verified: https://basescan.org/address/0x57741ee5bac991d43cf71207781fcb0ee4b9e9a8 Base Sepolia (84532): - Old: 0x9b0C3872234e403aa62F7315122019c22D9e4F11 - New: 0xF2a7fbffD5760721C99104A7C1f500797F3D1314 - Verified: https://sepolia.basescan.org/address/0xf2a7fbffd5760721c99104a7c1f500797f3d1314 Changes in upgraded implementation: - Fixed OutOfGas issue with ETH transfers to Treasury proxy - Added executeWithdrawal calls for immediate fund transfers - All 85 tests passing --- deployments/8453.json | 2 +- deployments/84532.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deployments/8453.json b/deployments/8453.json index 3fd9508..e818ee2 100644 --- a/deployments/8453.json +++ b/deployments/8453.json @@ -33,7 +33,7 @@ }, "SafeBaseEscrow": { "proxy": "0x1B079e9519CF110b491a231d7AA67c9a597F13B2", - "implementation": "0x4b015dF938d053388796078FA3F5145C550C4EE8" + "implementation": "0x57741EE5bAc991D43Cf71207781fCB0eE4b9e9a8" }, "Executor": { "proxy": "0xdBa335d18751944b46f205F32F03Fa4F1BEf1a94", diff --git a/deployments/84532.json b/deployments/84532.json index b2c3fdf..960871a 100644 --- a/deployments/84532.json +++ b/deployments/84532.json @@ -33,7 +33,7 @@ }, "SafeBaseEscrow": { "proxy": "0xA1e13a0E7E54bC71ee4173D74773b455A86816aB", - "implementation": "0x9b0C3872234e403aa62F7315122019c22D9e4F11" + "implementation": "0xF2a7fbffD5760721C99104A7C1f500797F3D1314" }, "Executor": { "proxy": "0xB49e7b4cCB76B3aE9439798eb980434CBCF8c428", From 6013f40f3a2d6a1f3f3e6bb7a0a111eaff401fc8 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Thu, 11 Dec 2025 11:52:42 +0000 Subject: [PATCH 10/38] fix: correct __Ownable_init for OpenZeppelin v4.9.6 compatibility In OZ v4.9.6, __Ownable_init() takes no parameters. Use _transferOwnership() to set custom owner after initialization. Fixes initialization in: - SafeBaseEscrowV1 - RulesEngineV1 --- src/escrow/RulesEngineV1.sol | 5 ++- src/escrow/SafeBaseEscrowV1.sol | 62 +++++++++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/escrow/RulesEngineV1.sol b/src/escrow/RulesEngineV1.sol index 7677d09..d649f38 100644 --- a/src/escrow/RulesEngineV1.sol +++ b/src/escrow/RulesEngineV1.sol @@ -31,7 +31,10 @@ contract RulesEngineV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { error InvalidVerifier(); function initialize(address _owner) public initializer { - __Ownable_init(_owner); + __Ownable_init(); + if (_owner != msg.sender) { + _transferOwnership(_owner); + } } function createRuleSet( diff --git a/src/escrow/SafeBaseEscrowV1.sol b/src/escrow/SafeBaseEscrowV1.sol index f1615de..9564b51 100644 --- a/src/escrow/SafeBaseEscrowV1.sol +++ b/src/escrow/SafeBaseEscrowV1.sol @@ -7,6 +7,23 @@ import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Own import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {Treasury} from "../Treasury.sol"; +interface IRulesEngine { + function canRelease( + uint256 ruleSetId, + bool buyerApproved, + bool sellerApproved, + bool isMediatorOverride, + uint256 escrowId, + bytes calldata verifierData + ) external view returns (bool); + + function canRefund( + uint256 ruleSetId, + uint256 deadline, + bool isMediatorOverride + ) external view returns (bool); +} + contract SafeBaseEscrowV1 is Initializable, UUPSUpgradeable, @@ -28,6 +45,7 @@ contract SafeBaseEscrowV1 is bool sellerApproved; bytes32 paymentId; uint256 createdAt; + uint256 ruleSetId; } Treasury public treasury; @@ -71,12 +89,16 @@ contract SafeBaseEscrowV1 is } function initialize(address _owner, address _treasury) public initializer { - __Ownable_init(_owner); + __Ownable_init(); __Pausable_init(); _reentrancyStatus = 1; if (_treasury == address(0)) revert InvalidAddress(); treasury = Treasury(payable(_treasury)); + + if (_owner != msg.sender) { + _transferOwnership(_owner); + } } function setRulesEngine(address _rulesEngine) external onlyOwner { @@ -94,7 +116,8 @@ contract SafeBaseEscrowV1 is address _mediator, address _token, uint256 _amount, - uint256 _deadline + uint256 _deadline, + uint256 _ruleSetId ) external whenNotPaused returns (uint256) { if (_seller == address(0)) revert InvalidAddress(); if (_amount == 0) revert InvalidAmount(); @@ -113,7 +136,8 @@ contract SafeBaseEscrowV1 is buyerApproved: false, sellerApproved: false, paymentId: bytes32(0), - createdAt: block.timestamp + createdAt: block.timestamp, + ruleSetId: _ruleSetId }); emit EscrowCreated(escrowId, msg.sender, _seller, _token, _amount, _deadline); @@ -170,13 +194,28 @@ contract SafeBaseEscrowV1 is function releaseToSeller(uint256 _escrowId) external nonReentrant whenNotPaused { EscrowData storage escrow = escrows[_escrowId]; - if (escrow.state != EscrowState.Funded) revert InvalidState(); + if (escrow.state != EscrowState.Funded && escrow.state != EscrowState.Disputed) { + revert InvalidState(); + } bool isMediator = msg.sender == escrow.mediator && escrow.mediator != address(0); bool isBuyer = msg.sender == escrow.buyer; if (!isMediator && !isBuyer) revert Unauthorized(); - if (!isMediator && !escrow.buyerApproved) revert Unauthorized(); + + if (rulesEngine != address(0) && escrow.ruleSetId != 0) { + bool canRelease = IRulesEngine(rulesEngine).canRelease( + escrow.ruleSetId, + escrow.buyerApproved, + escrow.sellerApproved, + isMediator, + _escrowId, + "" + ); + if (!canRelease) revert Unauthorized(); + } else { + if (!isMediator && !escrow.buyerApproved) revert Unauthorized(); + } escrow.state = EscrowState.Released; @@ -192,9 +231,18 @@ contract SafeBaseEscrowV1 is if (escrow.state != EscrowState.Funded && escrow.state != EscrowState.Disputed) revert InvalidState(); bool isMediator = msg.sender == escrow.mediator && escrow.mediator != address(0); - bool canRefund = isMediator || block.timestamp > escrow.deadline; - if (!canRefund) revert Unauthorized(); + if (rulesEngine != address(0) && escrow.ruleSetId != 0) { + bool canRefund = IRulesEngine(rulesEngine).canRefund( + escrow.ruleSetId, + escrow.deadline, + isMediator + ); + if (!canRefund) revert Unauthorized(); + } else { + bool canRefund = isMediator || block.timestamp > escrow.deadline; + if (!canRefund) revert Unauthorized(); + } escrow.state = EscrowState.Refunded; From ad64fd48cd9e6ce93bfb5568dbaecc8d39af784e Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Thu, 11 Dec 2025 11:52:58 +0000 Subject: [PATCH 11/38] =?UTF-8?q?test:=20add=20Disputed=20=E2=86=92=20Rele?= =?UTF-8?q?ased=20transition=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added comprehensive tests for mediator-driven resolution of disputed escrows: - testDisputedToReleased: mediator releases funds after dispute - testDisputedToReleasedRequiresMediator: buyer cannot release from Disputed - testMultipleDisputeAttempts: prevents double-dispute - testReleaseAfterDeadlineWithApproval: approval works post-deadline - testCannotReleaseFromCancelledState: terminal state enforcement - testCannotRefundFromCancelledState: terminal state enforcement Covers edge cases identified in lifecycle hardening audit. --- test/SafeBaseEscrowV1.t.sol | 234 +++++++++++++++++++++++++++++++----- 1 file changed, 207 insertions(+), 27 deletions(-) diff --git a/test/SafeBaseEscrowV1.t.sol b/test/SafeBaseEscrowV1.t.sol index bb84c21..68217f6 100644 --- a/test/SafeBaseEscrowV1.t.sol +++ b/test/SafeBaseEscrowV1.t.sol @@ -80,7 +80,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); assertEq(escrowId, 1); @@ -93,6 +94,7 @@ contract SafeBaseEscrowV1Test is Test { assertEq(escrowData.amount, 1 ether); assertEq(escrowData.deadline, block.timestamp + 1 days); assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Created); + assertEq(escrowData.ruleSetId, 0); } function testCreateEscrowZeroSeller() public { @@ -103,7 +105,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); } @@ -115,7 +118,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 0, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); } @@ -127,7 +131,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp - 1 + block.timestamp - 1, + 0 ); } @@ -138,7 +143,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.expectEmit(true, false, false, true); @@ -158,7 +164,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.deal(seller, 1 ether); @@ -174,7 +181,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -192,7 +200,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -207,7 +216,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); bytes32 paymentId = keccak256("payment1"); @@ -230,7 +240,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -253,7 +264,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -271,7 +283,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -292,7 +305,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -315,7 +329,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -344,7 +359,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -367,7 +383,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -385,7 +402,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -413,7 +431,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -439,7 +458,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -457,7 +477,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -480,7 +501,8 @@ contract SafeBaseEscrowV1Test is Test { address(0), address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -498,7 +520,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.expectEmit(true, false, false, false); @@ -518,7 +541,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -540,7 +564,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(owner); @@ -552,7 +577,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); assertEq(escrowId, 1); @@ -576,7 +602,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); @@ -605,7 +632,8 @@ contract SafeBaseEscrowV1Test is Test { mediator, address(0), 1 ether, - block.timestamp + 1 days + block.timestamp + 1 days, + 0 ); vm.prank(buyer); @@ -620,5 +648,157 @@ contract SafeBaseEscrowV1Test is Test { escrow.fundEscrow{value: 1 ether}(escrowId); } + function testDisputedToReleased() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + escrow.disputeEscrow(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Disputed); + + uint256 sellerBalanceBefore = seller.balance; + + vm.expectEmit(true, true, false, false); + emit EscrowReleased(escrowId, seller); + + vm.prank(mediator); + escrow.releaseToSeller(escrowId); + + escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Released); + assertEq(seller.balance, sellerBalanceBefore + 1 ether); + } + + function testDisputedToReleasedRequiresMediator() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + escrow.disputeEscrow(escrowId); + + vm.prank(buyer); + escrow.approveBuyer(escrowId); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.Unauthorized.selector); + escrow.releaseToSeller(escrowId); + } + + function testMultipleDisputeAttempts() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + escrow.disputeEscrow(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Disputed); + + vm.prank(seller); + vm.expectRevert(SafeBaseEscrowV1.InvalidState.selector); + escrow.disputeEscrow(escrowId); + } + + function testReleaseAfterDeadlineWithApproval() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.warp(block.timestamp + 2 days); + + vm.prank(buyer); + escrow.approveBuyer(escrowId); + + uint256 sellerBalanceBefore = seller.balance; + + vm.prank(buyer); + escrow.releaseToSeller(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Released); + assertEq(seller.balance, sellerBalanceBefore + 1 ether); + } + + function testCannotReleaseFromCancelledState() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + escrow.cancelEscrow(escrowId); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.InvalidState.selector); + escrow.releaseToSeller(escrowId); + } + + function testCannotRefundFromCancelledState() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + escrow.cancelEscrow(escrowId); + + vm.warp(block.timestamp + 2 days); + + vm.prank(mediator); + vm.expectRevert(SafeBaseEscrowV1.InvalidState.selector); + escrow.refundToBuyer(escrowId); + } + receive() external payable {} } From 7da0046b418566fb1d58bf14696e375d22470018 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Thu, 11 Dec 2025 11:53:10 +0000 Subject: [PATCH 12/38] test: add RulesEngine integration test suite Created SafeBaseEscrowRulesIntegration.t.sol covering: - Release with rules-based approval logic - Refund with deadline-based rules - Mediator override capabilities - Disputed state resolution with rules - Backward compatibility (ruleSetId = 0) Validates complete integration between SafeBaseEscrowV1 and RulesEngineV1 across all state transitions. --- test/SafeBaseEscrowRulesIntegration.t.sol | 306 ++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 test/SafeBaseEscrowRulesIntegration.t.sol diff --git a/test/SafeBaseEscrowRulesIntegration.t.sol b/test/SafeBaseEscrowRulesIntegration.t.sol new file mode 100644 index 0000000..2e509d1 --- /dev/null +++ b/test/SafeBaseEscrowRulesIntegration.t.sol @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {SafeBaseEscrowV1} from "../src/escrow/SafeBaseEscrowV1.sol"; +import {RulesEngineV1} from "../src/escrow/RulesEngineV1.sol"; +import {Treasury} from "../src/Treasury.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract SafeBaseEscrowRulesIntegrationTest is Test { + SafeBaseEscrowV1 public escrow; + RulesEngineV1 public rulesEngine; + Treasury public treasury; + + address public owner = address(1); + address public buyer = address(2); + address public seller = address(3); + address public mediator = address(4); + + uint256 public ruleSetId; + + event EscrowCreated( + uint256 indexed escrowId, + address indexed buyer, + address indexed seller, + address token, + uint256 amount, + uint256 deadline + ); + event EscrowReleased(uint256 indexed escrowId, address indexed recipient); + event EscrowRefunded(uint256 indexed escrowId, address indexed recipient); + + function setUp() public { + Treasury treasuryImpl = new Treasury(); + bytes memory treasuryData = abi.encodeWithSelector( + Treasury.initialize.selector, + owner, + 1 + ); + ERC1967Proxy treasuryProxy = new ERC1967Proxy(address(treasuryImpl), treasuryData); + treasury = Treasury(payable(address(treasuryProxy))); + + SafeBaseEscrowV1 escrowImpl = new SafeBaseEscrowV1(); + bytes memory escrowData = abi.encodeWithSelector( + SafeBaseEscrowV1.initialize.selector, + owner, + address(treasury) + ); + ERC1967Proxy escrowProxy = new ERC1967Proxy(address(escrowImpl), escrowData); + escrow = SafeBaseEscrowV1(address(escrowProxy)); + + RulesEngineV1 rulesEngineImpl = new RulesEngineV1(); + bytes memory rulesEngineData = abi.encodeWithSelector( + RulesEngineV1.initialize.selector, + owner + ); + ERC1967Proxy rulesEngineProxy = new ERC1967Proxy(address(rulesEngineImpl), rulesEngineData); + rulesEngine = RulesEngineV1(address(rulesEngineProxy)); + + vm.startPrank(owner); + treasury.addAdmin(address(escrow)); + treasury.addExecutor(address(escrow)); + escrow.setRulesEngine(address(rulesEngine)); + + ruleSetId = rulesEngine.createRuleSet( + true, + false, + true, + false, + true, + false, + address(0) + ); + vm.stopPrank(); + + vm.deal(buyer, 100 ether); + } + + function testReleaseWithRulesEngineApproval() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + ruleSetId + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + escrow.approveBuyer(escrowId); + + uint256 sellerBalanceBefore = seller.balance; + + vm.prank(buyer); + escrow.releaseToSeller(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Released); + assertEq(seller.balance, sellerBalanceBefore + 1 ether); + } + + function testReleaseWithRulesEngineRejection() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + ruleSetId + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.Unauthorized.selector); + escrow.releaseToSeller(escrowId); + } + + function testReleaseWithMediatorOverride() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + ruleSetId + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + uint256 sellerBalanceBefore = seller.balance; + + vm.prank(mediator); + escrow.releaseToSeller(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Released); + assertEq(seller.balance, sellerBalanceBefore + 1 ether); + } + + function testRefundWithRulesEngineAfterDeadline() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + ruleSetId + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.warp(block.timestamp + 2 days); + + uint256 buyerBalanceBefore = buyer.balance; + + vm.prank(buyer); + escrow.refundToBuyer(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Refunded); + assertEq(buyer.balance, buyerBalanceBefore + 1 ether); + } + + function testRefundWithRulesEngineBeforeDeadline() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + ruleSetId + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.Unauthorized.selector); + escrow.refundToBuyer(escrowId); + } + + function testRefundWithMediatorOverride() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + ruleSetId + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + uint256 buyerBalanceBefore = buyer.balance; + + vm.prank(mediator); + escrow.refundToBuyer(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Refunded); + assertEq(buyer.balance, buyerBalanceBefore + 1 ether); + } + + function testDisputedToReleasedWithRulesEngine() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + ruleSetId + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + escrow.disputeEscrow(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Disputed); + + uint256 sellerBalanceBefore = seller.balance; + + vm.prank(mediator); + escrow.releaseToSeller(escrowId); + + escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Released); + assertEq(seller.balance, sellerBalanceBefore + 1 ether); + } + + function testDisputedToRefundedWithRulesEngine() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + ruleSetId + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + escrow.disputeEscrow(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Disputed); + + uint256 buyerBalanceBefore = buyer.balance; + + vm.prank(mediator); + escrow.refundToBuyer(escrowId); + + escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Refunded); + assertEq(buyer.balance, buyerBalanceBefore + 1 ether); + } + + function testEscrowWithoutRulesEngineWorks() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + vm.prank(buyer); + escrow.approveBuyer(escrowId); + + uint256 sellerBalanceBefore = seller.balance; + + vm.prank(buyer); + escrow.releaseToSeller(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Released); + assertEq(seller.balance, sellerBalanceBefore + 1 ether); + } + + receive() external payable {} +} From f64b600be11839d5bbb2c552ae26c496ce072754 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Thu, 11 Dec 2025 11:53:25 +0000 Subject: [PATCH 13/38] docs: add escrow lifecycle specification Comprehensive documentation for SafeBase escrow state machine: - 6 states with complete transition diagram - Authorization matrix for each role - RulesEngine integration patterns - Invariants (state, financial, temporal) - Edge cases and race conditions - Security model and attack vectors - Integration examples and upgrade guide Serves as reference for developers integrating with SafeBase and auditors reviewing lifecycle logic. --- docs/escrow-lifecycle.md | 245 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docs/escrow-lifecycle.md diff --git a/docs/escrow-lifecycle.md b/docs/escrow-lifecycle.md new file mode 100644 index 0000000..828df23 --- /dev/null +++ b/docs/escrow-lifecycle.md @@ -0,0 +1,245 @@ +# SafeBase Escrow Lifecycle Specification + +## Overview + +SafeBase escrow system is a production-grade, modular escrow and conditional payment layer for Base L2. The system consists of two core components: + +- **SafeBaseEscrowV1**: State machine managing escrow lifecycle and fund custody +- **RulesEngineV1**: Programmable condition evaluation for release/refund decisions + +## State Machine + +### States (6 total) + +1. **Created** - Initial state after escrow creation +2. **Funded** - Funds deposited (via direct funding or Base Pay) +3. **Released** - Funds released to seller (terminal state) +4. **Refunded** - Funds refunded to buyer (terminal state) +5. **Disputed** - Dispute raised, requires mediator intervention +6. **Cancelled** - Escrow cancelled before funding (terminal state) + +### State Transition Diagram + +``` +Created ──fundEscrow()──────────> Funded ──releaseToSeller()──> Released ✓ + │ │ + │ ├──refundToBuyer()────────> Refunded ✓ + │ │ + └──cancelEscrow()──> Cancelled ✓ └──disputeEscrow()────────> Disputed + │ + ├──releaseToSeller()──> Released ✓ + │ + └──refundToBuyer()────> Refunded ✓ +``` + +### Allowed Transitions + +| From | To | Trigger | Authorization | +|------------|------------|----------------------|--------------------------------------------------| +| Created | Funded | fundEscrow() | Buyer only | +| Created | Funded | fundEscrowWithBasePay() | Anyone (off-chain verified) | +| Created | Cancelled | cancelEscrow() | Buyer only | +| Funded | Released | releaseToSeller() | Buyer (with approval) OR Mediator | +| Funded | Refunded | refundToBuyer() | Mediator OR Anyone after deadline | +| Funded | Disputed | disputeEscrow() | Buyer OR Seller (requires mediator set) | +| Disputed | Released | releaseToSeller() | Mediator only | +| Disputed | Refunded | refundToBuyer() | Mediator only | + +**Terminal states**: Released, Refunded, Cancelled - no transitions out. + +## Roles + +### Buyer +- Creates escrow +- Funds escrow +- Can approve release (buyerApproved flag) +- Can raise dispute +- Receives refund + +### Seller +- Receives funds on release +- Can approve delivery (sellerApproved flag - future use) +- Can raise dispute + +### Mediator (optional) +- Can override any approval requirements +- Can release or refund from any Funded/Disputed state +- Required for dispute resolution +- Escrows without mediator cannot be disputed + +## Rules Engine Integration + +### Without Rules Engine (ruleSetId = 0) +Default behavior: +- **Release**: Requires buyer approval (buyerApproved) OR mediator override +- **Refund**: Requires mediator action OR deadline expiration + +### With Rules Engine (ruleSetId > 0) +Delegates decision logic to RulesEngineV1: +- **canRelease()**: Evaluates approval requirements, mediator override, external verifiers +- **canRefund()**: Evaluates deadline, mediator override, auto-refund rules + +### Rule Set Structure +```solidity +struct RuleSet { + bool requireBuyerApproval; // Buyer must approve before release + bool requireSellerApproval; // Seller must approve (future) + bool autoRefundAfterDeadline; // Auto-refund when deadline expires + bool autoReleaseOnFullApproval; // Auto-release when both parties approve + bool mediatorOverrideEnabled; // Mediator can bypass all rules + bool externalVerifierEnabled; // Use external contract for verification + address externalVerifier; // External verifier contract address +} +``` + +## Invariants + +### State Invariants +1. **Monotonic state progression**: Once in terminal state (Released/Refunded/Cancelled), state cannot change +2. **Single terminal state**: An escrow can only reach one terminal state +3. **Dispute requires mediator**: Cannot transition to Disputed if mediator == address(0) +4. **Funding immutability**: Once Funded, amount and parties cannot change + +### Financial Invariants +1. **Conservation**: Funds held in Treasury equal sum of all Funded/Disputed escrows +2. **Single release**: Each escrow can only trigger one withdrawal (release OR refund, never both) +3. **Amount consistency**: Withdrawal amount always equals escrow.amount + +### Authorization Invariants +1. **Buyer exclusivity**: Only buyer can fund and cancel +2. **Mediator authority**: Mediator can always release/refund from Funded/Disputed states +3. **Approval requirement**: Non-mediator releases require buyerApproved (unless rules override) + +### Temporal Invariants +1. **Deadline in future**: createEscrow requires deadline > block.timestamp +2. **Auto-refund**: After deadline, anyone can trigger refund (if no mediator) +3. **No deadline bypass**: Release does not check deadline (allows late fulfillment) + +## Edge Cases + +### Race Conditions +1. **Approve + Deadline**: Buyer approves just as deadline expires + - **Result**: Both release and refund are valid; first transaction wins + +2. **Dispute + Release**: Buyer disputes while mediator releases + - **Result**: Dispute fails (state already Released) OR Release fails (state Disputed) + +3. **Dual approval**: Buyer and seller approve simultaneously + - **Result**: Both approvals succeed; state unchanged until release called + +### Failure Modes +1. **Missing verifier**: If externalVerifierEnabled but verifier == address(0) + - **Result**: canRelease() returns false, blocking release + +2. **Treasury withdrawal fails**: Network congestion or recipient revert + - **Result**: State changes to Released/Refunded but withdrawal reverts entire transaction + +3. **Mediator unavailable**: Escrow disputed but mediator address compromised + - **Result**: Funds locked until mediator acts; no fallback mechanism + +### Boundary Conditions +1. **Zero deadline**: Prevented by createEscrow validation +2. **Zero amount**: Prevented by createEscrow validation +3. **Missing seller**: Prevented by createEscrow validation +4. **Missing mediator**: Allowed; disputes disabled for this escrow + +## Integration Patterns + +### Standard Escrow Flow +```solidity +// 1. Create escrow +uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), // ETH + 1 ether, + block.timestamp + 7 days, + ruleSetId +); + +// 2. Buyer funds +escrow.fundEscrow{value: 1 ether}(escrowId); + +// 3. Buyer approves after delivery +escrow.approveBuyer(escrowId); + +// 4. Release to seller +escrow.releaseToSeller(escrowId); +``` + +### Base Pay Integration +```solidity +// Off-chain: User pays via Base Pay +// On-chain: Verifier calls fundEscrowWithBasePay +escrow.fundEscrowWithBasePay(escrowId, paymentId); +``` + +### Dispute Resolution +```solidity +// Buyer raises dispute +escrow.disputeEscrow(escrowId); + +// Mediator investigates and decides +// Option A: Release to seller +escrow.releaseToSeller(escrowId); + +// Option B: Refund to buyer +escrow.refundToBuyer(escrowId); +``` + +## Upgrade Considerations + +### Storage Layout +SafeBaseEscrowV1 uses UUPS proxy pattern: +- `EscrowData` struct can be extended (append-only) +- New fields added in Block 1: `ruleSetId` +- Gap: `uint256[50] __gap` reserved for future storage + +### Breaking Changes from V0 (if exists) +1. `createEscrow()` now requires `ruleSetId` parameter +2. `releaseToSeller()` now accepts Disputed state +3. RulesEngine integration changes authorization logic + +### Migration Strategy +- Existing escrows (without ruleSetId) continue with legacy logic +- New escrows can use Rules Engine by setting ruleSetId > 0 +- No state migration required; backward compatible + +## Security Model + +### Trust Assumptions +1. **Mediator honesty**: Mediators have full control over disputed escrows +2. **Treasury security**: Multi-sig treasury protects all funds +3. **Rules immutability**: Once created, RuleSets cannot be modified (future: add updateRuleSet) + +### Attack Vectors +1. **Mediator collusion**: Mediator + Buyer/Seller collude to steal funds + - **Mitigation**: Reputation system, EAS attestations (future blocks) + +2. **Denial of service**: Attacker creates many escrows to bloat state + - **Mitigation**: Registry indexing, off-chain filtering (Block 6) + +3. **Reentrancy**: Withdrawal triggers external call to recipient + - **Mitigation**: ReentrancyGuard on releaseToSeller/refundToBuyer + +### Access Control +- **Owner**: Can set rulesEngine, registry, pause contract +- **Buyer**: Can create, fund, approve, dispute, cancel +- **Seller**: Can approve, dispute +- **Mediator**: Can release, refund from Funded/Disputed +- **Anyone**: Can refund after deadline (if no mediator) + +## Future Enhancements (Beyond Block 1) + +1. **Partial releases**: Split payments for milestone-based escrows +2. **Multi-token support**: ERC20 token escrows (currently ETH only) +3. **Time-locked releases**: Automatic release after deadline + approval +4. **Appeal mechanism**: Secondary mediator for disputed cases +5. **Escrow templates**: Pre-configured rule sets for common use cases +6. **Event-driven automation**: Executor integration for auto-release/refund + +--- + +**Version**: 1.0 (Block 1 - Lifecycle Hardening) +**Last Updated**: 2025-12-11 +**Maintainer**: SafeBase Core Team From 728162c491d868a3f22c521e4f14fcab74671a7b Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Thu, 11 Dec 2025 12:02:41 +0000 Subject: [PATCH 14/38] fix: use correct __Ownable_init signature for OpenZeppelin v5.5 OpenZeppelin v5.x requires initialOwner parameter in __Ownable_init(). Updated initialization to use __Ownable_init(_owner) instead of parameterless init followed by transferOwnership. Fixes compilation errors in SafeBaseEscrowV1 and RulesEngineV1. --- src/escrow/RulesEngineV1.sol | 5 +---- src/escrow/SafeBaseEscrowV1.sol | 6 +----- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/escrow/RulesEngineV1.sol b/src/escrow/RulesEngineV1.sol index d649f38..7677d09 100644 --- a/src/escrow/RulesEngineV1.sol +++ b/src/escrow/RulesEngineV1.sol @@ -31,10 +31,7 @@ contract RulesEngineV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { error InvalidVerifier(); function initialize(address _owner) public initializer { - __Ownable_init(); - if (_owner != msg.sender) { - _transferOwnership(_owner); - } + __Ownable_init(_owner); } function createRuleSet( diff --git a/src/escrow/SafeBaseEscrowV1.sol b/src/escrow/SafeBaseEscrowV1.sol index 9564b51..595d2c2 100644 --- a/src/escrow/SafeBaseEscrowV1.sol +++ b/src/escrow/SafeBaseEscrowV1.sol @@ -89,16 +89,12 @@ contract SafeBaseEscrowV1 is } function initialize(address _owner, address _treasury) public initializer { - __Ownable_init(); + __Ownable_init(_owner); __Pausable_init(); _reentrancyStatus = 1; if (_treasury == address(0)) revert InvalidAddress(); treasury = Treasury(payable(_treasury)); - - if (_owner != msg.sender) { - _transferOwnership(_owner); - } } function setRulesEngine(address _rulesEngine) external onlyOwner { From ac44c63b50e12778d39e2508135852322ecdcb75 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Thu, 11 Dec 2025 12:11:34 +0000 Subject: [PATCH 15/38] fix: correct test order in testDisputedToReleasedRequiresMediator Moved approveBuyer() call before disputeEscrow() since approvals are only allowed in Funded state. Test now correctly verifies that buyer cannot release from Disputed state even with prior approval. --- test/SafeBaseEscrowV1.t.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/SafeBaseEscrowV1.t.sol b/test/SafeBaseEscrowV1.t.sol index 68217f6..ee5fb52 100644 --- a/test/SafeBaseEscrowV1.t.sol +++ b/test/SafeBaseEscrowV1.t.sol @@ -696,10 +696,10 @@ contract SafeBaseEscrowV1Test is Test { escrow.fundEscrow{value: 1 ether}(escrowId); vm.prank(buyer); - escrow.disputeEscrow(escrowId); + escrow.approveBuyer(escrowId); vm.prank(buyer); - escrow.approveBuyer(escrowId); + escrow.disputeEscrow(escrowId); vm.prank(buyer); vm.expectRevert(SafeBaseEscrowV1.Unauthorized.selector); From 0c123f06246966d8f04212cc3bbd1a4cd84148b2 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Thu, 11 Dec 2025 12:14:31 +0000 Subject: [PATCH 16/38] fix: enforce mediator-only actions in Disputed state Added explicit check that when escrow.state == Disputed, only the mediator can call releaseToSeller() or refundToBuyer(). This prevents buyers/sellers from bypassing dispute resolution using prior approvals. Critical invariant: Disputed escrows require mediator intervention. --- src/escrow/SafeBaseEscrowV1.sol | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/escrow/SafeBaseEscrowV1.sol b/src/escrow/SafeBaseEscrowV1.sol index 595d2c2..05da163 100644 --- a/src/escrow/SafeBaseEscrowV1.sol +++ b/src/escrow/SafeBaseEscrowV1.sol @@ -199,6 +199,10 @@ contract SafeBaseEscrowV1 is if (!isMediator && !isBuyer) revert Unauthorized(); + if (escrow.state == EscrowState.Disputed && !isMediator) { + revert Unauthorized(); + } + if (rulesEngine != address(0) && escrow.ruleSetId != 0) { bool canRelease = IRulesEngine(rulesEngine).canRelease( escrow.ruleSetId, @@ -228,6 +232,10 @@ contract SafeBaseEscrowV1 is bool isMediator = msg.sender == escrow.mediator && escrow.mediator != address(0); + if (escrow.state == EscrowState.Disputed && !isMediator) { + revert Unauthorized(); + } + if (rulesEngine != address(0) && escrow.ruleSetId != 0) { bool canRefund = IRulesEngine(rulesEngine).canRefund( escrow.ruleSetId, From aeece923c14952a227e0e35a9f9e0a5b28644607 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Thu, 11 Dec 2025 12:35:12 +0000 Subject: [PATCH 17/38] chore: update deployment addresses after contract upgrades Update implementation addresses for SafeBaseEscrow and RulesEngine following successful UUPS upgrades on Base Sepolia and Mainnet. Base Sepolia (84532): - SafeBaseEscrow: 0x682026827839A367252Ec80a0bbaaA47AFA3d870 - RulesEngine: 0xFA439194fe9B624AD51f7ecccf9FbcdB4350Bc70 Base Mainnet (8453): - SafeBaseEscrow: 0xA1e13a0E7E54bC71ee4173D74773b455A86816aB - RulesEngine: 0xB49e7b4cCB76B3aE9439798eb980434CBCF8c428 All contracts verified on Basescan. --- deployments/8453.json | 4 ++-- deployments/84532.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deployments/8453.json b/deployments/8453.json index e818ee2..4ab1b2e 100644 --- a/deployments/8453.json +++ b/deployments/8453.json @@ -25,7 +25,7 @@ }, "RulesEngine": { "proxy": "0x7bFA481f050AC09d676A7Ba61397b3f4dac6E558", - "implementation": "0xb25dcb1488247Fa0e29e6813fb9BAb630E375517" + "implementation": "0xB49e7b4cCB76B3aE9439798eb980434CBCF8c428" }, "Registry": { "proxy": "0x273930106653461A2F4f33Ea2821652283dcAE11", @@ -33,7 +33,7 @@ }, "SafeBaseEscrow": { "proxy": "0x1B079e9519CF110b491a231d7AA67c9a597F13B2", - "implementation": "0x57741EE5bAc991D43Cf71207781fCB0eE4b9e9a8" + "implementation": "0xA1e13a0E7E54bC71ee4173D74773b455A86816aB" }, "Executor": { "proxy": "0xdBa335d18751944b46f205F32F03Fa4F1BEf1a94", diff --git a/deployments/84532.json b/deployments/84532.json index 960871a..4494b25 100644 --- a/deployments/84532.json +++ b/deployments/84532.json @@ -25,7 +25,7 @@ }, "RulesEngine": { "proxy": "0xDb1855c6C8ADd51eE4B7e132173cA9833B1DAf07", - "implementation": "0xf3377563303A664141c98643f98a9918456f2216" + "implementation": "0xFA439194fe9B624AD51f7ecccf9FbcdB4350Bc70" }, "Registry": { "proxy": "0x57741EE5bAc991D43Cf71207781fCB0eE4b9e9a8", @@ -33,7 +33,7 @@ }, "SafeBaseEscrow": { "proxy": "0xA1e13a0E7E54bC71ee4173D74773b455A86816aB", - "implementation": "0xF2a7fbffD5760721C99104A7C1f500797F3D1314" + "implementation": "0x682026827839A367252Ec80a0bbaaA47AFA3d870" }, "Executor": { "proxy": "0xB49e7b4cCB76B3aE9439798eb980434CBCF8c428", From 43b3209f8c55468b0752a263e49deec751c250a7 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sat, 13 Dec 2025 21:07:20 +0300 Subject: [PATCH 18/38] docs: update specs --- docs/ARCHITECTURE.md | 37 +++++++++++++++++++++++++++++++++++-- docs/BASEPAY.md | 39 +++++++++++++++++++++++++++++++++++++-- docs/SECURITY_NOTES.md | 31 +++++++++++++++++++++++++++++-- docs/SMARTWALLET.md | 31 +++++++++++++++++++++++++++++-- 4 files changed, 130 insertions(+), 8 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1f77a65..8f7a48e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,3 +1,36 @@ -# docs: add architecture docs +# SafeBase Architecture -Documentation placeholder. +## Modules +- **SafeBaseEscrowV1** - 6-state FSM, stores deal params, routes calls to Treasury, RulesEngine, Registry. +- **RulesEngineV1** - evaluates release/refund (approvals, deadlines, external verifier, auto-release/auto-refund). +- **RegistryV1** - escrow index (id -> metadata), emits events for frontend/indexer. +- **ExecutorV1** - automated execution (auto-refund/auto-release) based on rules/deadlines. +- **Treasury / TreasuryV2** - custody, multi-step approvals, Base Pay transaction idempotency. +- **BasePay / PaymentTracker / Verifier** - off-chain payment bridge, paymentId -> escrow mapping, verification. +- **Wallet layer (SmartWallet, SubAccountManager, BatchCaller, WalletFactory, NameService)** - B2B roles/limits, batching. +- **AccessController** - roles/admins, used by Treasury/Registry/Executor. +- **Webhook / onchain Utils** - helper calls and notifications. + +## Flows (high-level) +1) **Create**: buyer calls `createEscrow` (seller, mediator?, token, amount, deadline, ruleSetId) -> write to Registry. +2) **Funding**: + - `fundEscrow` (ETH) -> Treasury, state Funded. + - `fundEscrowWithBasePay(paymentId)` for off-chain payment -> PaymentTracker/Verifier confirm -> Funded. +3) **Approvals**: buyer/seller may approve; mediator can override when enabled. +4) **Release/Refund**: + - `releaseToSeller` or `refundToBuyer` check RulesEngine (approvals/deadline/verifier/mediator override) -> Treasury transfers. + - In Disputed state mediator decides. +5) **Automation**: Executor polls rules/deadlines and triggers release/refund without manual clicks. +6) **Upgrades**: UUPS, proxy owner = admin; verify storage layout before upgrade. + +## Roles +- Buyer: create/fund, approve, dispute/cancel, may trigger release when fully approved. +- Seller: receives funds, may dispute. +- Mediator: override rules, release/refund from Funded/Disputed. +- Admin/Owner: manages addresses for RulesEngine/Registry/Treasury/Executor, pause, upgrades. +- Executor bot: service that calls Executor for automated actions. + +## Integrations +- **Base Pay**: off-chain payment -> Verifier -> `fundEscrowWithBasePay`, `paymentIdToEscrow` in Registry/PaymentTracker. +- **Indexer**: consume Escrow/Treasury/Rules/Executor/BasePay events; build timelines and SLA metrics. +- **Frontend (OnchainKit + Base SDK)**: wallet connect, tx launch, status rendering from indexer. diff --git a/docs/BASEPAY.md b/docs/BASEPAY.md index 5ad7350..8e28c42 100644 --- a/docs/BASEPAY.md +++ b/docs/BASEPAY.md @@ -1,3 +1,38 @@ -# docs: add Base Pay guide +# Base Pay - flow and integration -Documentation placeholder. +## Goals +- Map off-chain payment to on-chain escrow. +- Idempotency: each `paymentId` processed once (`paymentIdToEscrow`, `TreasuryV2.basePayTransactions`). +- Transparency: events for indexer and dashboards. + +## Flow +1) User pays via Base Pay off-chain. +2) Backend listens to Base Pay notification -> validates -> triggers Verifier/PaymentTracker. +3) On-chain call `fundEscrowWithBasePay(escrowId, paymentId)`: + - Sets state to Funded. + - Stores `paymentIdToEscrow[paymentId] = escrowId`. + - Emits `BasePayFundingReceived`. +4) (Optional) `TreasuryV2.processBasePayTransaction(txId, token, to, amount)`: + - Marks `basePayTransactions[txId] = true` (idempotent). + - Emits `BasePayTransactionProcessed`. + +## Backend requirements +- Verify `paymentId` authenticity (Base Pay signature/webhook). +- Guarantee once-only processing per `paymentId` (retry with same id must be no-op). +- Log escrowId/paymentId/tx hash; retry on network errors. +- Respect proxy settings from environment. + +## Errors / edge cases +- Duplicate `paymentId`: should safely no-op/fail. +- Escrow not in Created: `InvalidState`. +- Invalid `escrowId`: `EscrowNotFound`. +- Verifier unavailable: backend must not call contract until verified. + +## Metrics/events for indexer +- `BasePayFundingReceived(escrowId, paymentId)` +- `EscrowFunded(escrowId, amount)` +- `BasePayTransactionProcessed(txId)` + +## Next steps +- Add full ERC20 handling in `fundEscrowWithBasePay` (currently only marks funding). +- Add worker service for reliable retries and dead-letter handling. diff --git a/docs/SECURITY_NOTES.md b/docs/SECURITY_NOTES.md index fbb9853..d0273ff 100644 --- a/docs/SECURITY_NOTES.md +++ b/docs/SECURITY_NOTES.md @@ -1,3 +1,30 @@ -# docs: add security notes +# Security Notes -Documentation placeholder. +## Invariants +- Escrow: terminal states are final; exactly one payout (release or refund) per escrow. +- Deadline: refund after deadline is open to anyone if no mediator; dispute requires mediator. +- Treasury: funds balance >= sum of active Funded/Disputed escrows; approvals before withdrawal. +- Rules: external verifier required when enabled; missing address blocks release. + +## Risks and mitigations +- **Mediator collusion**: mediator can release/refund from Funded/Disputed. Mitigate with trusted selection, log review, rule templates/limits. +- **Reentrancy / external calls**: withdrawals via call; keep reentrancy guard, ensure safe tokens when ERC20 path is added. +- **Deadline DoS**: many escrows with short deadlines stress Executor. Mitigate with batching, rate-limits in off-chain worker. +- **Verifier downtime**: blocks release; backend should retry and avoid calling contract until verified. +- **Upgrade risk**: UUPS - check storage layout, proxy owner, test on testnet before prod. +- **Key/role compromise**: AccessController/Treasury admins in multisig, enforce limits/approvals. + +## Upgrades +- Ensure proxy owner is nonzero; never renounce on upgradeable infra. +- Review storage layout diff before release; adjust gap carefully. +- Upgrade scripts must use `upgradeTo`/`upgradeToAndCall` and log tx. + +## Tests / QA (minimum) +- Invariant tests: single payout, terminal states irreversible, deadline rules, no release without buyerApproved when required by RuleSet. +- Fuzz: dispute/approve racing deadline; duplicate `paymentId`; mass fund/refund. +- Negative: missing verifier, zero mediator dispute, release from Created, double refund. + +## Operations +- Monitor events: `EscrowFunded/Released/Refunded/Disputed`, `BasePayFundingReceived`, `BasePayTransactionProcessed`, Treasury approvals/executions. +- Runbooks: manual withdrawal if Executor fails; actions when Verifier/Base Pay is down. +- RPC: prefer reliable RPC (mainnet.base.org/Alchemy/Infura/Ankr); avoid unstable endpoints. diff --git a/docs/SMARTWALLET.md b/docs/SMARTWALLET.md index 7c9b9cb..8f84a16 100644 --- a/docs/SMARTWALLET.md +++ b/docs/SMARTWALLET.md @@ -1,3 +1,30 @@ -# docs: add smart wallet guide +# Smart Wallet layer (B2B) -Documentation placeholder. +## Components +- **SmartWallet** - base smart account with roles. +- **SubAccountManager** - hierarchy of org subaccounts/budgets. +- **BatchCaller** - batch operations (approve, release, refund, registry calls). +- **WalletFactory** - issues wallets for orgs. +- **NameService** - readable names/aliases. + +## Roles / limits (example) +- Org Admin - creates subaccounts, sets limits, assigns operators. +- Operator - performs payments/escrow actions within limits. +- Viewer/Auditor - read-only/off-chain sign-offs. +- Limits: per-tx, daily, by op type (fund, release/refund, treasury withdraw). + +## Flows +1) Org deploys SmartWallet via Factory, registers in NameService. +2) Creates subaccounts with limits (e.g., procurement with daily cap). +3) Operators interact with Escrow/Treasury via BatchCaller (gas and UX reduction). +4) Events include org/subaccount/actor/opType/amount for audit and indexing. + +## UX / integrations +- OnchainKit + Base SDK: select subaccount, view limits, submit batches. +- Multisig/role approvals on wallet level (extra layer beyond escrow approvals). +- AA/4337 compatibility priority: avoid breaking standard smart wallet interfaces. + +## Next steps +- Implement limits/roles fully if placeholders remain. +- Emit detailed audit events (org, subaccount, actor, opType, amount). +- E2E tests: batches with limits, limit violations, operator without admin rights. From 8cced458692793312173b4fad9877a2fbd76070d Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 15:42:07 +0300 Subject: [PATCH 19/38] escrow: add erc20 funding and partial releases --- src/escrow/SafeBaseEscrowV1.sol | 59 ++++++++++++- test/SafeBaseEscrowV1.t.sol | 146 ++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 2 deletions(-) diff --git a/src/escrow/SafeBaseEscrowV1.sol b/src/escrow/SafeBaseEscrowV1.sol index 05da163..acf6300 100644 --- a/src/escrow/SafeBaseEscrowV1.sol +++ b/src/escrow/SafeBaseEscrowV1.sol @@ -6,6 +6,7 @@ import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/U import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {Treasury} from "../Treasury.sol"; +import {IERC20} from "../IERC20.sol"; interface IRulesEngine { function canRelease( @@ -39,6 +40,7 @@ contract SafeBaseEscrowV1 is address mediator; address token; uint256 amount; + uint256 releasedAmount; uint256 deadline; EscrowState state; bool buyerApproved; @@ -67,6 +69,7 @@ contract SafeBaseEscrowV1 is event EscrowFunded(uint256 indexed escrowId, uint256 amount); event EscrowReleased(uint256 indexed escrowId, address indexed recipient); + event EscrowPartialReleased(uint256 indexed escrowId, address indexed recipient, uint256 amount); event EscrowRefunded(uint256 indexed escrowId, address indexed recipient); event EscrowDisputed(uint256 indexed escrowId, address indexed initiator); event EscrowCancelled(uint256 indexed escrowId); @@ -80,6 +83,7 @@ contract SafeBaseEscrowV1 is error InvalidAddress(); error EscrowNotFound(); error AlreadyApproved(); + error AmountExceeded(); modifier nonReentrant() { require(_reentrancyStatus != 2, "ReentrancyGuard: reentrant call"); @@ -127,6 +131,7 @@ contract SafeBaseEscrowV1 is mediator: _mediator, token: _token, amount: _amount, + releasedAmount: 0, deadline: _deadline, state: EscrowState.Created, buyerApproved: false, @@ -150,6 +155,10 @@ contract SafeBaseEscrowV1 is if (msg.value != escrow.amount) revert InvalidAmount(); (bool success, ) = payable(address(treasury)).call{value: msg.value}(""); require(success, "ETH transfer failed"); + } else { + if (msg.value != 0) revert InvalidAmount(); + bool ok = IERC20(escrow.token).transferFrom(msg.sender, address(treasury), escrow.amount); + require(ok, "ERC20 transfer failed"); } escrow.state = EscrowState.Funded; @@ -217,15 +226,59 @@ contract SafeBaseEscrowV1 is if (!isMediator && !escrow.buyerApproved) revert Unauthorized(); } + uint256 remaining = escrow.amount - escrow.releasedAmount; + if (remaining == 0) revert AmountExceeded(); + escrow.releasedAmount = escrow.amount; escrow.state = EscrowState.Released; - uint256 requestId = treasury.requestWithdrawal(escrow.token, escrow.seller, escrow.amount); + uint256 requestId = treasury.requestWithdrawal(escrow.token, escrow.seller, remaining); treasury.approveWithdrawal(requestId); treasury.executeWithdrawal(requestId); emit EscrowReleased(_escrowId, escrow.seller); } + function releasePartialToSeller(uint256 _escrowId, uint256 _amount) external nonReentrant whenNotPaused { + EscrowData storage escrow = escrows[_escrowId]; + if (escrow.state != EscrowState.Funded && escrow.state != EscrowState.Disputed) { + revert InvalidState(); + } + if (_amount == 0) revert InvalidAmount(); + + bool isMediator = msg.sender == escrow.mediator && escrow.mediator != address(0); + bool isBuyer = msg.sender == escrow.buyer; + if (!isMediator && !isBuyer) revert Unauthorized(); + if (escrow.state == EscrowState.Disputed && !isMediator) revert Unauthorized(); + + if (rulesEngine != address(0) && escrow.ruleSetId != 0) { + bool canRelease = IRulesEngine(rulesEngine).canRelease( + escrow.ruleSetId, + escrow.buyerApproved, + escrow.sellerApproved, + isMediator, + _escrowId, + "" + ); + if (!canRelease) revert Unauthorized(); + } else { + if (!isMediator && !escrow.buyerApproved) revert Unauthorized(); + } + + uint256 remaining = escrow.amount - escrow.releasedAmount; + if (_amount > remaining) revert AmountExceeded(); + + escrow.releasedAmount += _amount; + if (escrow.releasedAmount == escrow.amount) { + escrow.state = EscrowState.Released; + } + + uint256 requestId = treasury.requestWithdrawal(escrow.token, escrow.seller, _amount); + treasury.approveWithdrawal(requestId); + treasury.executeWithdrawal(requestId); + + emit EscrowPartialReleased(_escrowId, escrow.seller, _amount); + } + function refundToBuyer(uint256 _escrowId) external nonReentrant whenNotPaused { EscrowData storage escrow = escrows[_escrowId]; if (escrow.state != EscrowState.Funded && escrow.state != EscrowState.Disputed) revert InvalidState(); @@ -248,9 +301,11 @@ contract SafeBaseEscrowV1 is if (!canRefund) revert Unauthorized(); } + uint256 remaining = escrow.amount - escrow.releasedAmount; + if (remaining == 0) revert AmountExceeded(); escrow.state = EscrowState.Refunded; - uint256 requestId = treasury.requestWithdrawal(escrow.token, escrow.buyer, escrow.amount); + uint256 requestId = treasury.requestWithdrawal(escrow.token, escrow.buyer, remaining); treasury.approveWithdrawal(requestId); treasury.executeWithdrawal(requestId); diff --git a/test/SafeBaseEscrowV1.t.sol b/test/SafeBaseEscrowV1.t.sol index ee5fb52..9107c20 100644 --- a/test/SafeBaseEscrowV1.t.sol +++ b/test/SafeBaseEscrowV1.t.sol @@ -209,6 +209,51 @@ contract SafeBaseEscrowV1Test is Test { escrow.fundEscrow{value: 0.5 ether}(escrowId); } + function testFundEscrowERC20() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(token), + 100 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + token.approve(address(escrow), 100 ether); + + vm.expectEmit(true, false, false, true); + emit EscrowFunded(escrowId, 100 ether); + + vm.prank(buyer); + escrow.fundEscrow{value: 0}(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Funded); + assertEq(token.balanceOf(address(treasury)), 100 ether); + assertEq(token.balanceOf(buyer), 900 ether); + } + + function testFundEscrowERC20RejectsValue() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(token), + 1 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + token.approve(address(escrow), 1 ether); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.InvalidAmount.selector); + escrow.fundEscrow{value: 1 wei}(escrowId); + } + function testFundEscrowWithBasePay() public { vm.prank(buyer); uint256 escrowId = escrow.createEscrow( @@ -352,6 +397,107 @@ contract SafeBaseEscrowV1Test is Test { assertEq(seller.balance, sellerBalanceBefore + 1 ether); } + function testPartialReleaseThenRefundRemainingERC20() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(token), + 100 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + token.approve(address(escrow), 100 ether); + + vm.prank(buyer); + escrow.fundEscrow{value: 0}(escrowId); + + vm.prank(buyer); + escrow.approveBuyer(escrowId); + + uint256 sellerBalanceBefore = token.balanceOf(seller); + + vm.prank(buyer); + escrow.releasePartialToSeller(escrowId, 40 ether); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Funded); + assertEq(escrowData.releasedAmount, 40 ether); + assertEq(token.balanceOf(seller), sellerBalanceBefore + 40 ether); + + vm.warp(block.timestamp + 2 days); + + uint256 buyerBalanceBefore = token.balanceOf(buyer); + + vm.prank(buyer); + escrow.refundToBuyer(escrowId); + + escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Refunded); + assertEq(token.balanceOf(buyer), buyerBalanceBefore + 60 ether); + } + + function testPartialReleaseFullAmountSetsReleased() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(token), + 50 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + token.approve(address(escrow), 50 ether); + + vm.prank(buyer); + escrow.fundEscrow{value: 0}(escrowId); + + vm.prank(buyer); + escrow.approveBuyer(escrowId); + + vm.prank(buyer); + escrow.releasePartialToSeller(escrowId, 20 ether); + + vm.prank(buyer); + escrow.releasePartialToSeller(escrowId, 30 ether); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Released); + assertEq(escrowData.releasedAmount, 50 ether); + } + + function testPartialReleaseExceedsAmount() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(token), + 10 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + token.approve(address(escrow), 10 ether); + + vm.prank(buyer); + escrow.fundEscrow{value: 0}(escrowId); + + vm.prank(buyer); + escrow.approveBuyer(escrowId); + + vm.prank(buyer); + escrow.releasePartialToSeller(escrowId, 9 ether); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.AmountExceeded.selector); + escrow.releasePartialToSeller(escrowId, 2 ether); + } + function testReleaseToSellerMediator() public { vm.prank(buyer); uint256 escrowId = escrow.createEscrow( From b0333d7d9f99b528e56095d623f6b3245213f796 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 15:53:15 +0300 Subject: [PATCH 20/38] config: enable via_ir for coverage --- foundry.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/foundry.toml b/foundry.toml index 5993eac..5dda090 100644 --- a/foundry.toml +++ b/foundry.toml @@ -8,6 +8,11 @@ optimizer_runs = 200 fs_permissions = [{ access = "read", path = "./deployments" }] gas_reports = ["SafeBaseEscrowV1", "RulesEngineV1", "RegistryV1", "ExecutorV1"] +[profile.coverage] +via_ir = true +optimizer = true +optimizer_runs = 200 + [rpc_endpoints] base = "https://mainnet.base.org" base-sepolia = "https://sepolia.base.org" From 7b3208373e46ca2a9cc2d16a64d87c622b82f620 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 16:04:24 +0300 Subject: [PATCH 21/38] ci: run coverage with coverage profile --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03583b9..acc1fbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,8 @@ jobs: fi - name: Coverage Report + env: + FOUNDRY_PROFILE: coverage run: | forge coverage --report summary forge coverage --report lcov From 1088e3c0940c5ecc5a1b32963feefb6a14dc7619 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 16:07:20 +0300 Subject: [PATCH 22/38] ci: run coverage with ir-minimum --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acc1fbf..9c75888 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,8 +35,8 @@ jobs: env: FOUNDRY_PROFILE: coverage run: | - forge coverage --report summary - forge coverage --report lcov + forge coverage --report summary --ir-minimum + forge coverage --report lcov --ir-minimum - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 From 93733afb2d230625a65da1546df9c2291ef4a76d Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 16:11:44 +0300 Subject: [PATCH 23/38] rules: add updateable rule sets and tests --- src/escrow/RulesEngineV1.sol | 59 +++++++++++++++++++++++++-- test/RulesEngineV1.t.sol | 78 ++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/src/escrow/RulesEngineV1.sol b/src/escrow/RulesEngineV1.sol index 7677d09..8c6327c 100644 --- a/src/escrow/RulesEngineV1.sol +++ b/src/escrow/RulesEngineV1.sol @@ -20,7 +20,18 @@ contract RulesEngineV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { address externalVerifier; } + struct RuleSetInput { + bool requireBuyerApproval; + bool requireSellerApproval; + bool autoRefundAfterDeadline; + bool autoReleaseOnFullApproval; + bool mediatorOverrideEnabled; + bool externalVerifierEnabled; + address externalVerifier; + } + mapping(uint256 => RuleSet) public ruleSets; + mapping(uint256 => bool) public ruleSetExists; uint256 public defaultRuleSetId; event RuleSetCreated(uint256 indexed ruleSetId); @@ -56,7 +67,7 @@ contract RulesEngineV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { ) ); - ruleSets[ruleSetId] = RuleSet({ + _writeRuleSet(ruleSetId, RuleSetInput({ requireBuyerApproval: _requireBuyerApproval, requireSellerApproval: _requireSellerApproval, autoRefundAfterDeadline: _autoRefundAfterDeadline, @@ -64,15 +75,39 @@ contract RulesEngineV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { mediatorOverrideEnabled: _mediatorOverrideEnabled, externalVerifierEnabled: _externalVerifierEnabled, externalVerifier: _externalVerifier - }); + })); emit RuleSetCreated(ruleSetId); return ruleSetId; } + function updateRuleSet( + uint256 _ruleSetId, + bool _requireBuyerApproval, + bool _requireSellerApproval, + bool _autoRefundAfterDeadline, + bool _autoReleaseOnFullApproval, + bool _mediatorOverrideEnabled, + bool _externalVerifierEnabled, + address _externalVerifier + ) external onlyOwner { + if (!ruleSetExists[_ruleSetId]) revert InvalidRuleSet(); + + _writeRuleSet(_ruleSetId, RuleSetInput({ + requireBuyerApproval: _requireBuyerApproval, + requireSellerApproval: _requireSellerApproval, + autoRefundAfterDeadline: _autoRefundAfterDeadline, + autoReleaseOnFullApproval: _autoReleaseOnFullApproval, + mediatorOverrideEnabled: _mediatorOverrideEnabled, + externalVerifierEnabled: _externalVerifierEnabled, + externalVerifier: _externalVerifier + })); + + emit RuleSetUpdated(_ruleSetId); + } + function setDefaultRuleSet(uint256 _ruleSetId) external onlyOwner { - if (ruleSets[_ruleSetId].requireBuyerApproval == false && - ruleSets[_ruleSetId].requireSellerApproval == false) { + if (!ruleSetExists[_ruleSetId]) { revert InvalidRuleSet(); } defaultRuleSetId = _ruleSetId; @@ -138,4 +173,20 @@ contract RulesEngineV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} uint256[50] private __gap; + + function _writeRuleSet(uint256 _ruleSetId, RuleSetInput memory input) internal { + if (input.externalVerifierEnabled && input.externalVerifier == address(0)) { + revert InvalidVerifier(); + } + ruleSets[_ruleSetId] = RuleSet({ + requireBuyerApproval: input.requireBuyerApproval, + requireSellerApproval: input.requireSellerApproval, + autoRefundAfterDeadline: input.autoRefundAfterDeadline, + autoReleaseOnFullApproval: input.autoReleaseOnFullApproval, + mediatorOverrideEnabled: input.mediatorOverrideEnabled, + externalVerifierEnabled: input.externalVerifierEnabled, + externalVerifier: input.externalVerifier + }); + ruleSetExists[_ruleSetId] = true; + } } diff --git a/test/RulesEngineV1.t.sol b/test/RulesEngineV1.t.sol index 19d537c..44e8e29 100644 --- a/test/RulesEngineV1.t.sol +++ b/test/RulesEngineV1.t.sol @@ -109,6 +109,84 @@ contract RulesEngineV1Test is Test { rulesEngine.setDefaultRuleSet(ruleSetId); } + function testUpdateRuleSet() public { + vm.prank(owner); + uint256 ruleSetId = rulesEngine.createRuleSet( + true, + false, + false, + false, + false, + false, + address(0) + ); + + vm.expectEmit(true, false, false, false); + emit RuleSetUpdated(ruleSetId); + + vm.prank(owner); + rulesEngine.updateRuleSet( + ruleSetId, + false, + true, + true, + true, + true, + true, + address(verifier) + ); + + RulesEngineV1.RuleSet memory ruleSet = rulesEngine.getRuleSet(ruleSetId); + assertFalse(ruleSet.requireBuyerApproval); + assertTrue(ruleSet.requireSellerApproval); + assertTrue(ruleSet.autoRefundAfterDeadline); + assertTrue(ruleSet.autoReleaseOnFullApproval); + assertTrue(ruleSet.mediatorOverrideEnabled); + assertTrue(ruleSet.externalVerifierEnabled); + assertEq(ruleSet.externalVerifier, address(verifier)); + } + + function testUpdateRuleSetRequiresExisting() public { + vm.prank(owner); + vm.expectRevert(RulesEngineV1.InvalidRuleSet.selector); + rulesEngine.updateRuleSet( + 123, + true, + true, + false, + false, + false, + false, + address(0) + ); + } + + function testUpdateRuleSetOnlyOwner() public { + vm.prank(owner); + uint256 ruleSetId = rulesEngine.createRuleSet( + true, + false, + false, + false, + false, + false, + address(0) + ); + + vm.prank(address(99)); + vm.expectRevert(); + rulesEngine.updateRuleSet( + ruleSetId, + false, + true, + false, + false, + false, + false, + address(0) + ); + } + function testCanReleaseWithBuyerApproval() public { vm.prank(owner); uint256 ruleSetId = rulesEngine.createRuleSet( From 413ff4b252ff7e2a1691c3eddc4aaec73e082a89 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 16:15:43 +0300 Subject: [PATCH 24/38] rules: validate default rule set; ignore ds_store --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 57cda07..acc4cd5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ .env broadcast/ lib/ +.DS_Store From 40c7cc10416499ff5430a943f671db2bdd710510 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 16:16:03 +0300 Subject: [PATCH 25/38] rules: enforce default rule requires approvals --- src/escrow/RulesEngineV1.sol | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/escrow/RulesEngineV1.sol b/src/escrow/RulesEngineV1.sol index 8c6327c..e996e5e 100644 --- a/src/escrow/RulesEngineV1.sol +++ b/src/escrow/RulesEngineV1.sol @@ -107,9 +107,13 @@ contract RulesEngineV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { } function setDefaultRuleSet(uint256 _ruleSetId) external onlyOwner { + RuleSet memory rules = ruleSets[_ruleSetId]; if (!ruleSetExists[_ruleSetId]) { revert InvalidRuleSet(); } + if (!rules.requireBuyerApproval && !rules.requireSellerApproval) { + revert InvalidRuleSet(); + } defaultRuleSetId = _ruleSetId; emit DefaultRuleSetChanged(_ruleSetId); } From b0cf7943fa688b07c917d36a3ecfaddb131983d9 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 16:22:26 +0300 Subject: [PATCH 26/38] registry: emit index/update from escrow --- src/escrow/SafeBaseEscrowV1.sol | 29 +++++++++++++++++++++++++++++ test/SafeBaseEscrowV1.t.sol | 30 ++++++++++++++++++++++++++++++ test/mocks/MockRegistry.sol | 31 +++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 test/mocks/MockRegistry.sol diff --git a/src/escrow/SafeBaseEscrowV1.sol b/src/escrow/SafeBaseEscrowV1.sol index acf6300..29be600 100644 --- a/src/escrow/SafeBaseEscrowV1.sol +++ b/src/escrow/SafeBaseEscrowV1.sol @@ -8,6 +8,18 @@ import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/Pau import {Treasury} from "../Treasury.sol"; import {IERC20} from "../IERC20.sol"; +interface IRegistry { + function indexEscrow( + uint256 escrowId, + address buyer, + address seller, + uint256 amount, + uint256 createdAt + ) external; + + function updateEscrowState(uint256 escrowId, uint8 state) external; +} + interface IRulesEngine { function canRelease( uint256 ruleSetId, @@ -143,6 +155,10 @@ contract SafeBaseEscrowV1 is emit EscrowCreated(escrowId, msg.sender, _seller, _token, _amount, _deadline); + if (registry != address(0)) { + IRegistry(registry).indexEscrow(escrowId, msg.sender, _seller, _amount, block.timestamp); + } + return escrowId; } @@ -162,6 +178,7 @@ contract SafeBaseEscrowV1 is } escrow.state = EscrowState.Funded; + _updateRegistryState(_escrowId); emit EscrowFunded(_escrowId, escrow.amount); } @@ -173,6 +190,7 @@ contract SafeBaseEscrowV1 is escrow.paymentId = _paymentId; paymentIdToEscrow[_paymentId] = _escrowId; + _updateRegistryState(_escrowId); emit EscrowFunded(_escrowId, escrow.amount); emit BasePayFundingReceived(_escrowId, _paymentId); } @@ -235,6 +253,7 @@ contract SafeBaseEscrowV1 is treasury.approveWithdrawal(requestId); treasury.executeWithdrawal(requestId); + _updateRegistryState(_escrowId); emit EscrowReleased(_escrowId, escrow.seller); } @@ -276,6 +295,7 @@ contract SafeBaseEscrowV1 is treasury.approveWithdrawal(requestId); treasury.executeWithdrawal(requestId); + _updateRegistryState(_escrowId); emit EscrowPartialReleased(_escrowId, escrow.seller, _amount); } @@ -309,6 +329,7 @@ contract SafeBaseEscrowV1 is treasury.approveWithdrawal(requestId); treasury.executeWithdrawal(requestId); + _updateRegistryState(_escrowId); emit EscrowRefunded(_escrowId, escrow.buyer); } @@ -319,6 +340,7 @@ contract SafeBaseEscrowV1 is if (escrow.mediator == address(0)) revert Unauthorized(); escrow.state = EscrowState.Disputed; + _updateRegistryState(_escrowId); emit EscrowDisputed(_escrowId, msg.sender); } @@ -328,6 +350,7 @@ contract SafeBaseEscrowV1 is if (msg.sender != escrow.buyer) revert Unauthorized(); escrow.state = EscrowState.Cancelled; + _updateRegistryState(_escrowId); emit EscrowCancelled(_escrowId); } @@ -345,5 +368,11 @@ contract SafeBaseEscrowV1 is function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + function _updateRegistryState(uint256 _escrowId) internal { + if (registry != address(0)) { + IRegistry(registry).updateEscrowState(_escrowId, uint8(escrows[_escrowId].state)); + } + } + uint256[50] private __gap; } diff --git a/test/SafeBaseEscrowV1.t.sol b/test/SafeBaseEscrowV1.t.sol index 9107c20..fcaf24b 100644 --- a/test/SafeBaseEscrowV1.t.sol +++ b/test/SafeBaseEscrowV1.t.sol @@ -6,11 +6,13 @@ import {SafeBaseEscrowV1} from "../src/escrow/SafeBaseEscrowV1.sol"; import {Treasury} from "../src/Treasury.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; +import {MockRegistry} from "./mocks/MockRegistry.sol"; contract SafeBaseEscrowV1Test is Test { SafeBaseEscrowV1 public escrow; Treasury public treasury; MockERC20 public token; + MockRegistry public registry; address public owner = address(1); address public buyer = address(2); @@ -36,6 +38,7 @@ contract SafeBaseEscrowV1Test is Test { function setUp() public { token = new MockERC20("Test Token", "TEST", 18); + registry = new MockRegistry(); Treasury treasuryImpl = new Treasury(); bytes memory treasuryData = abi.encodeWithSelector( @@ -58,6 +61,7 @@ contract SafeBaseEscrowV1Test is Test { vm.startPrank(owner); treasury.addAdmin(address(escrow)); treasury.addExecutor(address(escrow)); + escrow.setRegistry(address(registry)); vm.stopPrank(); vm.deal(buyer, 100 ether); @@ -209,6 +213,30 @@ contract SafeBaseEscrowV1Test is Test { escrow.fundEscrow{value: 0.5 ether}(escrowId); } + function testRegistryIndexAndUpdateOnFund() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + 0 + ); + + MockRegistry.Indexed memory meta = registry.indexed(escrowId); + assertEq(meta.buyer, buyer); + assertEq(meta.seller, seller); + assertEq(meta.amount, 1 ether); + assertEq(meta.createdAt, block.timestamp); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + assertEq(registry.lastEscrowId(), escrowId); + assertEq(registry.lastState(), uint8(SafeBaseEscrowV1.EscrowState.Funded)); + } + function testFundEscrowERC20() public { vm.prank(buyer); uint256 escrowId = escrow.createEscrow( @@ -568,6 +596,8 @@ contract SafeBaseEscrowV1Test is Test { SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Refunded); assertEq(buyer.balance, buyerBalanceBefore + 1 ether); + assertEq(registry.lastEscrowId(), escrowId); + assertEq(registry.lastState(), uint8(SafeBaseEscrowV1.EscrowState.Refunded)); } function testRefundToBuyerMediator() public { diff --git a/test/mocks/MockRegistry.sol b/test/mocks/MockRegistry.sol new file mode 100644 index 0000000..3eb710c --- /dev/null +++ b/test/mocks/MockRegistry.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +contract MockRegistry { + uint256 public lastEscrowId; + uint8 public lastState; + struct Indexed { + address buyer; + address seller; + uint256 amount; + uint256 createdAt; + } + mapping(uint256 => Indexed) public indexed; + + function indexEscrow( + uint256 escrowId, + address buyer, + address seller, + uint256 amount, + uint256 createdAt + ) external { + indexed[escrowId] = Indexed(buyer, seller, amount, createdAt); + lastEscrowId = escrowId; + } + + function updateEscrowState(uint256 escrowId, uint8 state) external { + lastEscrowId = escrowId; + lastState = state; + } +} + From f5d99b5eb9e067f47c5e6d1f82c504a5a382bc59 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 16:26:08 +0300 Subject: [PATCH 27/38] registry: fix mock naming; update escrow lifecycle doc --- docs/escrow-lifecycle.md | 46 ++++++++++++++++++++++++++----------- test/SafeBaseEscrowV1.t.sol | 2 +- test/mocks/MockRegistry.sol | 4 ++-- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/docs/escrow-lifecycle.md b/docs/escrow-lifecycle.md index 828df23..6858192 100644 --- a/docs/escrow-lifecycle.md +++ b/docs/escrow-lifecycle.md @@ -12,9 +12,9 @@ SafeBase escrow system is a production-grade, modular escrow and conditional pay ### States (6 total) 1. **Created** - Initial state after escrow creation -2. **Funded** - Funds deposited (via direct funding or Base Pay) -3. **Released** - Funds released to seller (terminal state) -4. **Refunded** - Funds refunded to buyer (terminal state) +2. **Funded** - Funds deposited (via direct funding or Base Pay; ETH or ERC20) +3. **Released** - Funds released to seller (terminal state; may reach via partial releases) +4. **Refunded** - Funds refunded to buyer (terminal state; remaining amount after partial releases) 5. **Disputed** - Dispute raised, requires mediator intervention 6. **Cancelled** - Escrow cancelled before funding (terminal state) @@ -39,9 +39,9 @@ Created ──fundEscrow()──────────> Funded ──releaseTo | Created | Funded | fundEscrow() | Buyer only | | Created | Funded | fundEscrowWithBasePay() | Anyone (off-chain verified) | | Created | Cancelled | cancelEscrow() | Buyer only | -| Funded | Released | releaseToSeller() | Buyer (with approval) OR Mediator | -| Funded | Refunded | refundToBuyer() | Mediator OR Anyone after deadline | -| Funded | Disputed | disputeEscrow() | Buyer OR Seller (requires mediator set) | +| Funded | Released | releaseToSeller() | Buyer (with approval) OR Mediator | +| Funded | Refunded | refundToBuyer() | Mediator OR Anyone after deadline | +| Funded | Disputed | disputeEscrow() | Buyer OR Seller (requires mediator set) | | Disputed | Released | releaseToSeller() | Mediator only | | Disputed | Refunded | refundToBuyer() | Mediator only | @@ -145,7 +145,7 @@ struct RuleSet { ## Integration Patterns -### Standard Escrow Flow +### Standard Escrow Flow (ETH) ```solidity // 1. Create escrow uint256 escrowId = escrow.createEscrow( @@ -175,6 +175,28 @@ escrow.fundEscrowWithBasePay(escrowId, paymentId); ``` ### Dispute Resolution + +### ERC20 Funding +```solidity +escrow.createEscrow( + seller, + mediator, + address(token), // ERC20 + 100 ether, + block.timestamp + 7 days, + ruleSetId +); + +token.approve(address(escrow), 100 ether); +escrow.fundEscrow(escrowId); // msg.value must be 0 +``` + +### Partial Releases +```solidity +// After approvals / rules allow +escrow.releasePartialToSeller(escrowId, 40 ether); // updates state, Registry, Treasury withdrawal +// Remaining amount stays in escrow until released or refunded +``` ```solidity // Buyer raises dispute escrow.disputeEscrow(escrowId); @@ -231,12 +253,10 @@ SafeBaseEscrowV1 uses UUPS proxy pattern: ## Future Enhancements (Beyond Block 1) -1. **Partial releases**: Split payments for milestone-based escrows -2. **Multi-token support**: ERC20 token escrows (currently ETH only) -3. **Time-locked releases**: Automatic release after deadline + approval -4. **Appeal mechanism**: Secondary mediator for disputed cases -5. **Escrow templates**: Pre-configured rule sets for common use cases -6. **Event-driven automation**: Executor integration for auto-release/refund +1. **Time-locked releases**: Automatic release after deadline + approval +2. **Appeal mechanism**: Secondary mediator for disputed cases +3. **Escrow templates**: Pre-configured rule sets for common use cases +4. **Event-driven automation**: Executor integration for auto-release/refund --- diff --git a/test/SafeBaseEscrowV1.t.sol b/test/SafeBaseEscrowV1.t.sol index fcaf24b..a4722f8 100644 --- a/test/SafeBaseEscrowV1.t.sol +++ b/test/SafeBaseEscrowV1.t.sol @@ -224,7 +224,7 @@ contract SafeBaseEscrowV1Test is Test { 0 ); - MockRegistry.Indexed memory meta = registry.indexed(escrowId); + MockRegistry.Indexed memory meta = registry.indexedEscrows(escrowId); assertEq(meta.buyer, buyer); assertEq(meta.seller, seller); assertEq(meta.amount, 1 ether); diff --git a/test/mocks/MockRegistry.sol b/test/mocks/MockRegistry.sol index 3eb710c..1a33275 100644 --- a/test/mocks/MockRegistry.sol +++ b/test/mocks/MockRegistry.sol @@ -10,7 +10,7 @@ contract MockRegistry { uint256 amount; uint256 createdAt; } - mapping(uint256 => Indexed) public indexed; + mapping(uint256 => Indexed) public indexedEscrows; function indexEscrow( uint256 escrowId, @@ -19,7 +19,7 @@ contract MockRegistry { uint256 amount, uint256 createdAt ) external { - indexed[escrowId] = Indexed(buyer, seller, amount, createdAt); + indexedEscrows[escrowId] = Indexed(buyer, seller, amount, createdAt); lastEscrowId = escrowId; } From 33f238db4b35ba7a217041da3f8469776d63b88e Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 16:27:49 +0300 Subject: [PATCH 28/38] test: fix registry getter destructuring --- test/SafeBaseEscrowV1.t.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/SafeBaseEscrowV1.t.sol b/test/SafeBaseEscrowV1.t.sol index a4722f8..0baef68 100644 --- a/test/SafeBaseEscrowV1.t.sol +++ b/test/SafeBaseEscrowV1.t.sol @@ -224,11 +224,11 @@ contract SafeBaseEscrowV1Test is Test { 0 ); - MockRegistry.Indexed memory meta = registry.indexedEscrows(escrowId); - assertEq(meta.buyer, buyer); - assertEq(meta.seller, seller); - assertEq(meta.amount, 1 ether); - assertEq(meta.createdAt, block.timestamp); + (address b, address s, uint256 amt, uint256 ts) = registry.indexedEscrows(escrowId); + assertEq(b, buyer); + assertEq(s, seller); + assertEq(amt, 1 ether); + assertEq(ts, block.timestamp); vm.prank(buyer); escrow.fundEscrow{value: 1 ether}(escrowId); From 0c2347d82b0446f9782f0627d1213e3f130a206a Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 16:37:36 +0300 Subject: [PATCH 29/38] executor: use escrow struct and ruleSetId --- src/escrow/ExecutorV1.sol | 97 +++++++++++++-------------------------- test/ExecutorV1.t.sol | 32 ++++--------- 2 files changed, 43 insertions(+), 86 deletions(-) diff --git a/src/escrow/ExecutorV1.sol b/src/escrow/ExecutorV1.sol index a961b9f..408b048 100644 --- a/src/escrow/ExecutorV1.sol +++ b/src/escrow/ExecutorV1.sol @@ -6,19 +6,23 @@ import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/U import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; interface ISafeBaseEscrow { - function getEscrow(uint256 escrowId) external view returns ( - address buyer, - address seller, - address mediator, - address token, - uint256 amount, - uint256 deadline, - uint8 state, - bool buyerApproved, - bool sellerApproved, - bytes32 paymentId, - uint256 createdAt - ); + struct EscrowData { + address buyer; + address seller; + address mediator; + address token; + uint256 amount; + uint256 releasedAmount; + uint256 deadline; + uint8 state; + bool buyerApproved; + bool sellerApproved; + bytes32 paymentId; + uint256 createdAt; + uint256 ruleSetId; + } + + function getEscrow(uint256 escrowId) external view returns (EscrowData memory); function refundToBuyer(uint256 escrowId) external; function releaseToSeller(uint256 escrowId) external; } @@ -88,23 +92,11 @@ contract ExecutorV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { emit DeadlineCheckScheduled(_escrowId, _deadline); } - function executeAutoRefund(uint256 _escrowId, uint256 _ruleSetId) external onlyAutomator { - ( - , - , - , - , - , - uint256 deadline, - uint8 state, - , - , - , - ) = escrowContract.getEscrow(_escrowId); - - if (state != 1) revert InvalidState(); - - bool canRefund = rulesEngine.canRefund(_ruleSetId, deadline, false); + function executeAutoRefund(uint256 _escrowId) external onlyAutomator { + ISafeBaseEscrow.EscrowData memory e = escrowContract.getEscrow(_escrowId); + if (e.state != 1) revert InvalidState(); + + bool canRefund = rulesEngine.canRefund(e.ruleSetId, e.deadline, false); if (!canRefund) revert DeadlineNotReached(); escrowContract.refundToBuyer(_escrowId); @@ -113,26 +105,14 @@ contract ExecutorV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { emit AutoRefundExecuted(_escrowId); } - function executeAutoRelease(uint256 _escrowId, uint256 _ruleSetId) external onlyAutomator { - ( - , - , - , - , - , - , - uint8 state, - bool buyerApproved, - bool sellerApproved, - , - ) = escrowContract.getEscrow(_escrowId); - - if (state != 1) revert InvalidState(); + function executeAutoRelease(uint256 _escrowId) external onlyAutomator { + ISafeBaseEscrow.EscrowData memory e = escrowContract.getEscrow(_escrowId); + if (e.state != 1) revert InvalidState(); bool canRelease = rulesEngine.canRelease( - _ruleSetId, - buyerApproved, - sellerApproved, + e.ruleSetId, + e.buyerApproved, + e.sellerApproved, false, _escrowId, "" @@ -146,25 +126,14 @@ contract ExecutorV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { emit AutoReleaseExecuted(_escrowId); } - function checkAndExecuteDeadlines(uint256[] calldata _escrowIds, uint256 _ruleSetId) external onlyAutomator { + function checkAndExecuteDeadlines(uint256[] calldata _escrowIds) external onlyAutomator { for (uint256 i = 0; i < _escrowIds.length; i++) { uint256 escrowId = _escrowIds[i]; - ( - , - , - , - , - , - uint256 deadline, - uint8 state, - , - , - , - ) = escrowContract.getEscrow(escrowId); - - if (state == 1 && block.timestamp > deadline) { - bool canRefund = rulesEngine.canRefund(_ruleSetId, deadline, false); + ISafeBaseEscrow.EscrowData memory e = escrowContract.getEscrow(escrowId); + + if (e.state == 1 && block.timestamp > e.deadline) { + bool canRefund = rulesEngine.canRefund(e.ruleSetId, e.deadline, false); if (canRefund) { try escrowContract.refundToBuyer(escrowId) { emit AutoRefundExecuted(escrowId); diff --git a/test/ExecutorV1.t.sol b/test/ExecutorV1.t.sol index f33407b..44ec640 100644 --- a/test/ExecutorV1.t.sol +++ b/test/ExecutorV1.t.sol @@ -27,31 +27,19 @@ contract MockEscrow { uint256 deadline, uint8 state, bool buyerApproved, - bool sellerApproved + bool sellerApproved, + uint256 ruleSetId ) external { escrowData.buyer = buyer; escrowData.deadline = deadline; escrowData.state = state; escrowData.buyerApproved = buyerApproved; escrowData.sellerApproved = sellerApproved; + escrowData.ruleSetId = ruleSetId; } - function getEscrow(uint256) external view returns ( - address, address, address, address, uint256, uint256, uint8, bool, bool, bytes32, uint256 - ) { - return ( - escrowData.buyer, - escrowData.seller, - escrowData.mediator, - escrowData.token, - escrowData.amount, - escrowData.deadline, - escrowData.state, - escrowData.buyerApproved, - escrowData.sellerApproved, - escrowData.paymentId, - escrowData.createdAt - ); + function getEscrow(uint256) external view returns (ISafeBaseEscrow.EscrowData memory) { + return escrowData; } function refundToBuyer(uint256) external {} @@ -172,7 +160,7 @@ contract ExecutorV1Test is Test { vm.prank(owner); executor.addAutomator(automator); - escrowContract.setEscrow(address(3), block.timestamp - 1, 1, false, false); + escrowContract.setEscrow(address(3), block.timestamp - 1, 1, false, false, 1); rulesEngine.setCanRelease(true); vm.expectEmit(true, false, false, false); @@ -188,7 +176,7 @@ contract ExecutorV1Test is Test { vm.prank(owner); executor.addAutomator(automator); - escrowContract.setEscrow(address(3), block.timestamp + 1 days, 1, false, false); + escrowContract.setEscrow(address(3), block.timestamp + 1 days, 1, false, false, 1); rulesEngine.setCanRelease(false); vm.prank(automator); @@ -200,7 +188,7 @@ contract ExecutorV1Test is Test { vm.prank(owner); executor.addAutomator(automator); - escrowContract.setEscrow(address(3), block.timestamp + 1 days, 1, true, true); + escrowContract.setEscrow(address(3), block.timestamp + 1 days, 1, true, true, 1); rulesEngine.setCanRelease(true); vm.expectEmit(true, false, false, false); @@ -216,14 +204,14 @@ contract ExecutorV1Test is Test { vm.prank(owner); executor.addAutomator(automator); - escrowContract.setEscrow(address(3), block.timestamp - 1, 1, false, false); + escrowContract.setEscrow(address(3), block.timestamp - 1, 1, false, false, 1); rulesEngine.setCanRelease(true); uint256[] memory escrowIds = new uint256[](1); escrowIds[0] = 1; vm.prank(automator); - executor.checkAndExecuteDeadlines(escrowIds, 1); + executor.checkAndExecuteDeadlines(escrowIds); } function testOnlyAutomatorModifier() public { From 78d0bb7d61f16de30eb9aa2f7e962350bffe17b0 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 16:43:14 +0300 Subject: [PATCH 30/38] test: import escrow interface for executor --- test/ExecutorV1.t.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/test/ExecutorV1.t.sol b/test/ExecutorV1.t.sol index 44ec640..bdef120 100644 --- a/test/ExecutorV1.t.sol +++ b/test/ExecutorV1.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.28; import {Test} from "forge-std/Test.sol"; import {ExecutorV1} from "../src/escrow/ExecutorV1.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ISafeBaseEscrow} from "../src/escrow/ExecutorV1.sol"; contract MockEscrow { struct EscrowData { From 763c0986aa6527894d5b2802dc4b18efb731162b Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 16:46:08 +0300 Subject: [PATCH 31/38] test: include ruleSetId in mock escrow data --- test/ExecutorV1.t.sol | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/test/ExecutorV1.t.sol b/test/ExecutorV1.t.sol index bdef120..b19e120 100644 --- a/test/ExecutorV1.t.sol +++ b/test/ExecutorV1.t.sol @@ -7,21 +7,7 @@ import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.s import {ISafeBaseEscrow} from "../src/escrow/ExecutorV1.sol"; contract MockEscrow { - struct EscrowData { - address buyer; - address seller; - address mediator; - address token; - uint256 amount; - uint256 deadline; - uint8 state; - bool buyerApproved; - bool sellerApproved; - bytes32 paymentId; - uint256 createdAt; - } - - EscrowData public escrowData; + ISafeBaseEscrow.EscrowData public escrowData; function setEscrow( address buyer, From 84feedbc84fe9c6e011197570dfed8134ee02eb9 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 16:47:26 +0300 Subject: [PATCH 32/38] test: update executor calls to new signatures --- test/ExecutorV1.t.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/ExecutorV1.t.sol b/test/ExecutorV1.t.sol index b19e120..c9fccca 100644 --- a/test/ExecutorV1.t.sol +++ b/test/ExecutorV1.t.sol @@ -154,7 +154,7 @@ contract ExecutorV1Test is Test { emit AutoRefundExecuted(1); vm.prank(automator); - executor.executeAutoRefund(1, 1); + executor.executeAutoRefund(1); assertFalse(executor.scheduledForRefund(1)); } @@ -168,7 +168,7 @@ contract ExecutorV1Test is Test { vm.prank(automator); vm.expectRevert(ExecutorV1.DeadlineNotReached.selector); - executor.executeAutoRefund(1, 1); + executor.executeAutoRefund(1); } function testExecuteAutoRelease() public { @@ -182,7 +182,7 @@ contract ExecutorV1Test is Test { emit AutoReleaseExecuted(1); vm.prank(automator); - executor.executeAutoRelease(1, 1); + executor.executeAutoRelease(1); assertFalse(executor.scheduledForRelease(1)); } From a7d581310a8cc8fdacb368b978a9125545c42e99 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 16:51:45 +0300 Subject: [PATCH 33/38] executor: add batch auto releases; adjust mocks --- src/escrow/ExecutorV1.sol | 23 +++++++++++++++++++++++ test/ExecutorV1.t.sol | 29 +++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/escrow/ExecutorV1.sol b/src/escrow/ExecutorV1.sol index 408b048..206c0d7 100644 --- a/src/escrow/ExecutorV1.sol +++ b/src/escrow/ExecutorV1.sol @@ -143,6 +143,29 @@ contract ExecutorV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { } } + function checkAndExecuteReleases(uint256[] calldata _escrowIds) external onlyAutomator { + for (uint256 i = 0; i < _escrowIds.length; i++) { + uint256 escrowId = _escrowIds[i]; + ISafeBaseEscrow.EscrowData memory e = escrowContract.getEscrow(escrowId); + + if (e.state == 1) { + bool canRelease = rulesEngine.canRelease( + e.ruleSetId, + e.buyerApproved, + e.sellerApproved, + false, + escrowId, + "" + ); + if (canRelease) { + try escrowContract.releaseToSeller(escrowId) { + emit AutoReleaseExecuted(escrowId); + } catch {} + } + } + } + } + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} uint256[50] private __gap; diff --git a/test/ExecutorV1.t.sol b/test/ExecutorV1.t.sol index c9fccca..bd0ac69 100644 --- a/test/ExecutorV1.t.sol +++ b/test/ExecutorV1.t.sol @@ -8,6 +8,8 @@ import {ISafeBaseEscrow} from "../src/escrow/ExecutorV1.sol"; contract MockEscrow { ISafeBaseEscrow.EscrowData public escrowData; + bool public releasedCalled; + bool public refundedCalled; function setEscrow( address buyer, @@ -29,8 +31,13 @@ contract MockEscrow { return escrowData; } - function refundToBuyer(uint256) external {} - function releaseToSeller(uint256) external {} + function refundToBuyer(uint256) external { + refundedCalled = true; + } + + function releaseToSeller(uint256) external { + releasedCalled = true; + } } contract MockRulesEngine { @@ -199,6 +206,24 @@ contract ExecutorV1Test is Test { vm.prank(automator); executor.checkAndExecuteDeadlines(escrowIds); + + assertTrue(escrowContract.refundedCalled()); + } + + function testCheckAndExecuteReleases() public { + vm.prank(owner); + executor.addAutomator(automator); + + escrowContract.setEscrow(address(3), block.timestamp + 1 days, 1, true, true, 1); + rulesEngine.setCanRelease(true); + + uint256[] memory escrowIds = new uint256[](1); + escrowIds[0] = 1; + + vm.prank(automator); + executor.checkAndExecuteReleases(escrowIds); + + assertTrue(escrowContract.releasedCalled()); } function testOnlyAutomatorModifier() public { From b78dc21858d2905d0fe8bccc8219a767cc3d9e9f Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 17:07:33 +0300 Subject: [PATCH 34/38] deploy: allow force_new and update executor impl (84532) --- deployments/84532.json | 2 +- script/DeployModular.s.sol | 41 +++++++++++++++++++------------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/deployments/84532.json b/deployments/84532.json index 4494b25..85590de 100644 --- a/deployments/84532.json +++ b/deployments/84532.json @@ -37,7 +37,7 @@ }, "Executor": { "proxy": "0xB49e7b4cCB76B3aE9439798eb980434CBCF8c428", - "implementation": "0x6112146dea7A81D2F3F189084608a99BCAA2F388" + "implementation": "0x9Bc334daCcB224cac61BDbeB97669BB1C67dBe1E" } } } diff --git a/script/DeployModular.s.sol b/script/DeployModular.s.sol index 4520cd6..dfa38b8 100644 --- a/script/DeployModular.s.sol +++ b/script/DeployModular.s.sol @@ -80,11 +80,11 @@ contract DeployModularScript is Script { return addrs; } - function deployContract(string memory contractName, address owner, ExistingAddresses memory existing) internal returns (address) { + function deployContract(string memory contractName, address owner, ExistingAddresses memory existing, bool forceNew) internal returns (address) { bytes32 nameHash = keccak256(bytes(contractName)); if (nameHash == keccak256("Treasury")) { - if (existing.treasury != address(0)) { + if (!forceNew && existing.treasury != address(0)) { console.log("Treasury already deployed:", existing.treasury); return existing.treasury; } @@ -96,7 +96,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("AccessController")) { - if (existing.accessController != address(0)) { + if (!forceNew && existing.accessController != address(0)) { console.log("AccessController already deployed:", existing.accessController); return existing.accessController; } @@ -108,7 +108,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("Verifier")) { - if (existing.verifier != address(0)) { + if (!forceNew && existing.verifier != address(0)) { console.log("Verifier already deployed:", existing.verifier); return existing.verifier; } @@ -120,7 +120,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("PaymentTracker")) { - if (existing.paymentTracker != address(0)) { + if (!forceNew && existing.paymentTracker != address(0)) { console.log("PaymentTracker already deployed:", existing.paymentTracker); return existing.paymentTracker; } @@ -132,7 +132,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("BasePay")) { - if (existing.basePay != address(0)) { + if (!forceNew && existing.basePay != address(0)) { console.log("BasePay already deployed:", existing.basePay); return existing.basePay; } @@ -144,7 +144,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("RulesEngine")) { - if (existing.rulesEngine != address(0)) { + if (!forceNew && existing.rulesEngine != address(0)) { console.log("RulesEngine already deployed:", existing.rulesEngine); return existing.rulesEngine; } @@ -156,7 +156,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("Registry")) { - if (existing.registry != address(0)) { + if (!forceNew && existing.registry != address(0)) { console.log("Registry already deployed:", existing.registry); return existing.registry; } @@ -168,7 +168,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("SafeBaseEscrow")) { - if (existing.escrow != address(0)) { + if (!forceNew && existing.escrow != address(0)) { console.log("SafeBaseEscrow already deployed:", existing.escrow); return existing.escrow; } @@ -181,7 +181,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("Executor")) { - if (existing.executor != address(0)) { + if (!forceNew && existing.executor != address(0)) { console.log("Executor already deployed:", existing.executor); return existing.executor; } @@ -201,6 +201,7 @@ contract DeployModularScript is Script { uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); address owner = vm.envAddress("OWNER_ADDRESS"); string memory targetContract = vm.envOr("DEPLOY_CONTRACT", string("all")); + bool forceNew = vm.envOr("FORCE_NEW", false); console.log("=== SafeBase Modular Deployment ==="); console.log("Chain ID:", block.chainid); @@ -214,15 +215,15 @@ contract DeployModularScript is Script { if (keccak256(bytes(targetContract)) == keccak256("all")) { console.log("\n--- Deploying all contracts ---"); - existing.treasury = deployContract("Treasury", owner, existing); - existing.accessController = deployContract("AccessController", owner, existing); - existing.verifier = deployContract("Verifier", owner, existing); - existing.paymentTracker = deployContract("PaymentTracker", owner, existing); - existing.basePay = deployContract("BasePay", owner, existing); - existing.rulesEngine = deployContract("RulesEngine", owner, existing); - existing.registry = deployContract("Registry", owner, existing); - existing.escrow = deployContract("SafeBaseEscrow", owner, existing); - existing.executor = deployContract("Executor", owner, existing); + existing.treasury = deployContract("Treasury", owner, existing, forceNew); + existing.accessController = deployContract("AccessController", owner, existing, forceNew); + existing.verifier = deployContract("Verifier", owner, existing, forceNew); + existing.paymentTracker = deployContract("PaymentTracker", owner, existing, forceNew); + existing.basePay = deployContract("BasePay", owner, existing, forceNew); + existing.rulesEngine = deployContract("RulesEngine", owner, existing, forceNew); + existing.registry = deployContract("Registry", owner, existing, forceNew); + existing.escrow = deployContract("SafeBaseEscrow", owner, existing, forceNew); + existing.executor = deployContract("Executor", owner, existing, forceNew); if (existing.escrow != address(0) && existing.rulesEngine != address(0) && existing.registry != address(0)) { SafeBaseEscrowV1(existing.escrow).setRulesEngine(existing.rulesEngine); @@ -242,7 +243,7 @@ contract DeployModularScript is Script { } } else { console.log("\n--- Deploying single contract ---"); - deployContract(targetContract, owner, existing); + deployContract(targetContract, owner, existing, forceNew); } vm.stopBroadcast(); From 2e2a88904b40a6caed2c8db9fa9a7e3d1c9053a1 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 17:12:21 +0300 Subject: [PATCH 35/38] deploy: add force_new option and log --- .github/workflows/deploy.yml | 6 ++++++ script/DeployModular.s.sol | 1 + 2 files changed, 7 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c53439c..c012ce5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -25,6 +25,11 @@ on: - Registry - SafeBaseEscrow - Executor + force_new: + description: 'Force new proxy (ignore existing addresses)' + required: false + type: boolean + default: false jobs: deploy: @@ -51,6 +56,7 @@ jobs: OWNER_ADDRESS: ${{ secrets.OWNER_ADDRESS }} BASESCAN_API_KEY: ${{ secrets.BASESCAN_API_KEY }} DEPLOY_CONTRACT: ${{ inputs.contract }} + FORCE_NEW: ${{ inputs.force_new }} - name: Save deployment artifacts uses: actions/upload-artifact@v4 diff --git a/script/DeployModular.s.sol b/script/DeployModular.s.sol index dfa38b8..f1ea6cb 100644 --- a/script/DeployModular.s.sol +++ b/script/DeployModular.s.sol @@ -207,6 +207,7 @@ contract DeployModularScript is Script { console.log("Chain ID:", block.chainid); console.log("Owner:", owner); console.log("Target:", targetContract); + console.log("Force new:", forceNew ? "true" : "false"); ExistingAddresses memory existing = loadExistingAddresses(); From ed4b3040d9edaa434e662bb9bca851a05dc26d51 Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 17:24:59 +0300 Subject: [PATCH 36/38] deploy: record new proxies for escrow and rules engine --- deployments/8453.json | 8 ++++---- deployments/84532.json | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/deployments/8453.json b/deployments/8453.json index 4ab1b2e..8d7a6b9 100644 --- a/deployments/8453.json +++ b/deployments/8453.json @@ -24,16 +24,16 @@ "implementation": "0x546bD44cE5576A6e90cC7150aD93aAD1B06291BE" }, "RulesEngine": { - "proxy": "0x7bFA481f050AC09d676A7Ba61397b3f4dac6E558", - "implementation": "0xB49e7b4cCB76B3aE9439798eb980434CBCF8c428" + "proxy": "0x02267434995220548CCc3171263229a2aa54e1a4", + "implementation": "0xF2a7fbffD5760721C99104A7C1f500797F3D1314" }, "Registry": { "proxy": "0x273930106653461A2F4f33Ea2821652283dcAE11", "implementation": "0xC1E06BfBBe9b812BFc5feFBe4efDFb7B9A4E64cB" }, "SafeBaseEscrow": { - "proxy": "0x1B079e9519CF110b491a231d7AA67c9a597F13B2", - "implementation": "0xA1e13a0E7E54bC71ee4173D74773b455A86816aB" + "proxy": "0xec0c6F43b9064cE1C33E9343671c0e67cB19594c", + "implementation": "0x9e2B522F357711CF4Cd24D781ED2a553E08cCC59" }, "Executor": { "proxy": "0xdBa335d18751944b46f205F32F03Fa4F1BEf1a94", diff --git a/deployments/84532.json b/deployments/84532.json index 85590de..a2cf9b7 100644 --- a/deployments/84532.json +++ b/deployments/84532.json @@ -24,16 +24,16 @@ "implementation": "0xCB66CBF0A09c3Bf336f1290DFB6e6CfB213132Ff" }, "RulesEngine": { - "proxy": "0xDb1855c6C8ADd51eE4B7e132173cA9833B1DAf07", - "implementation": "0xFA439194fe9B624AD51f7ecccf9FbcdB4350Bc70" + "proxy": "0xFA194dd94Fb7E8253fb6717eF4C6C23D4b2Cc7A2", + "implementation": "0x1d4036bbc2960a6b572D07eab33a3744FbF354D6" }, "Registry": { "proxy": "0x57741EE5bAc991D43Cf71207781fCB0eE4b9e9a8", "implementation": "0x509014Ac3d57ee08B2A47639aCDeEaE1B700960f" }, "SafeBaseEscrow": { - "proxy": "0xA1e13a0E7E54bC71ee4173D74773b455A86816aB", - "implementation": "0x682026827839A367252Ec80a0bbaaA47AFA3d870" + "proxy": "0x2a441dD6a9B81013D49C1d553e8c42Cb32679652", + "implementation": "0x675c3F20aC7E6C7BF64D07F079EC84785610c12e" }, "Executor": { "proxy": "0xB49e7b4cCB76B3aE9439798eb980434CBCF8c428", From 7d25824708d732eaf338693f6d6f01d2d635473e Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 17:30:41 +0300 Subject: [PATCH 37/38] docs: update mainnet executor impl; deployments synced --- README.md | 10 +++++----- deployments/8453.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ff6ec4e..14eb1b4 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,9 @@ forge script script/DeployAndInteract.s.sol \ | Verifier | `0x1B079e9519CF110b491a231d7AA67c9a597F13B2` | ✅ | | PaymentTracker | `0xdBa335d18751944b46f205F32F03Fa4F1BEf1a94` | ✅ | | BasePay | `0x062d3a45862a32BF5D1e35404aaA55e7027c4F4B` | ✅ | -| RulesEngine | `0xDb1855c6C8ADd51eE4B7e132173cA9833B1DAf07` | ✅ | +| RulesEngine | `0xFA194dd94Fb7E8253fb6717eF4C6C23D4b2Cc7A2` | ✅ | | Registry | `0x57741EE5bAc991D43Cf71207781fCB0eE4b9e9a8` | ✅ | -| SafeBaseEscrow | `0xA1e13a0E7E54bC71ee4173D74773b455A86816aB` | ✅ | +| SafeBaseEscrow | `0x2a441dD6a9B81013D49C1d553e8c42Cb32679652` | ✅ | | Executor | `0xB49e7b4cCB76B3aE9439798eb980434CBCF8c428` | ✅ | ### Base Mainnet @@ -86,10 +86,10 @@ forge script script/DeployAndInteract.s.sol \ | Verifier | `0xb06d4414B479eb425f6E7d38226d0194C595c7CF` | ✅ | | PaymentTracker | `0xAA1be2099208db011dFbEa7174114D69982cFcef` | ✅ | | BasePay | `0xD47991043dA73bdfcF6c399e5Ed26e5C8D6c3D27` | ✅ | -| RulesEngine | `0x7bFA481f050AC09d676A7Ba61397b3f4dac6E558` | ✅ | +| RulesEngine | `0x02267434995220548CCc3171263229a2aa54e1a4` | ✅ | | Registry | `0x273930106653461A2F4f33Ea2821652283dcAE11` | ✅ | -| SafeBaseEscrow | `0x1B079e9519CF110b491a231d7AA67c9a597F13B2` | ✅ | -| Executor | `0xdBa335d18751944b46f205F32F03Fa4F1BEf1a94` | ✅ | +| SafeBaseEscrow | `0xec0c6F43b9064cE1C33E9343671c0e67cB19594c` | ✅ | +| Executor | `0xdBa335d18751944b46f205F32F03Fa4F1BEf1a94` | ✅ | Impl: `0xBb744584644c5956353bC8E382EC8E1dAA4286BF` | **Network Details:** - **Base Sepolia RPC**: `https://sepolia.base.org` diff --git a/deployments/8453.json b/deployments/8453.json index 8d7a6b9..82ad962 100644 --- a/deployments/8453.json +++ b/deployments/8453.json @@ -37,7 +37,7 @@ }, "Executor": { "proxy": "0xdBa335d18751944b46f205F32F03Fa4F1BEf1a94", - "implementation": "0xD7fd8D4026123B1c4135cbF6ba91b7A0b3a5C748" + "implementation": "0xBb744584644c5956353bC8E382EC8E1dAA4286BF" } } } From 84eb9dd784f92bacf1f55665b6a67f4c0f7ce60e Mon Sep 17 00:00:00 2001 From: natalya-bbr Date: Sun, 14 Dec 2025 17:57:16 +0300 Subject: [PATCH 38/38] Revert "Merge branch 'main' into develop" This reverts commit 7691f5e49d6f801f29ae98f61eb6b3af1718b92b, reversing changes made to 7d25824708d732eaf338693f6d6f01d2d635473e. --- src/escrow/SafeBaseEscrowV1.sol | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/escrow/SafeBaseEscrowV1.sol b/src/escrow/SafeBaseEscrowV1.sol index 6e383a0..29be600 100644 --- a/src/escrow/SafeBaseEscrowV1.sol +++ b/src/escrow/SafeBaseEscrowV1.sol @@ -37,23 +37,6 @@ interface IRulesEngine { ) external view returns (bool); } -interface IRulesEngine { - function canRelease( - uint256 ruleSetId, - bool buyerApproved, - bool sellerApproved, - bool isMediatorOverride, - uint256 escrowId, - bytes calldata verifierData - ) external view returns (bool); - - function canRefund( - uint256 ruleSetId, - uint256 deadline, - bool isMediatorOverride - ) external view returns (bool); -} - contract SafeBaseEscrowV1 is Initializable, UUPSUpgradeable,