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", 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 diff --git a/src/escrow/SafeBaseEscrowV1.sol b/src/escrow/SafeBaseEscrowV1.sol index f1615de..05da163 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; @@ -94,7 +112,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 +132,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 +190,32 @@ 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 (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(); + } escrow.state = EscrowState.Released; @@ -192,9 +231,22 @@ 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 (escrow.state == EscrowState.Disputed && !isMediator) { + 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; 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 {} +} diff --git a/test/SafeBaseEscrowV1.t.sol b/test/SafeBaseEscrowV1.t.sol index bb84c21..ee5fb52 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.approveBuyer(escrowId); + + vm.prank(buyer); + escrow.disputeEscrow(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 {} }