Summary
If for some reason the two parties agree to cancel the agreement, eg. Illness, time constraints, family responsibility.
There is no way to cancel the agreement and refund the funds to the BUYER, without either raising a dispute and loosing fees or needing to trust the SELLER to refund the funds to the BUYER after confirmReceipt
is called.
Vulnerability Details
Impact
The BUYER would either loose the fees portion of their funds, or need to trust the SELLER to refund their funds.
Tools Used
Manual Audit
Recommendations
You could consider adding a cancellation function that requires mutual approval.
CODE and Foundry test below example of function
function cancelAgreement() external onlyBuyerOrSeller inState(State.Created) {
if(msg.sender == i_seller)
{
sellerCancelled = true;
}
if(msg.sender == i_buyer)
{
buyerCancelled = true;
}
if(buyerCancelled && sellerCancelled)
{
s_state = State.Cancelled;
uint256 contractBalance = i_tokenContract.balanceOf(address(this));
if (contractBalance > 0) {
i_tokenContract.safeTransfer(i_buyer, contractBalance);
}
}
emit Cancelled(i_buyer, i_seller);
}
IEscrow contract
pragma solidity 0.8.18;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IEscrow {
error Escrow__FeeExceedsPrice(uint256 price, uint256 fee);
error Escrow__OnlyBuyer();
error Escrow__OnlyBuyerOrSeller();
error Escrow__OnlyArbiter();
error Escrow__InWrongState(State currentState, State expectedState);
error Escrow__MustDeployWithTokenBalance();
error Escrow__TotalFeeExceedsBalance(uint256 balance, uint256 totalFee);
error Escrow__DisputeRequiresArbiter();
error Escrow__TokenZeroAddress();
error Escrow__BuyerZeroAddress();
error Escrow__SellerZeroAddress();
event Confirmed(address indexed seller);
event Disputed(address indexed disputer);
event Resolved(address indexed buyer, address indexed seller);
event Cancelled(address indexed buyer, address indexed seller);
enum State {
Created,
Confirmed,
Disputed,
Resolved,
Cancelled
}
function confirmReceipt() external;
function initiateDispute() external;
function cancelAgreement() external;
function resolveDispute(uint256 buyerAward) external;
function getPrice() external view returns (uint256);
function getTokenContract() external view returns (IERC20);
function getBuyer() external view returns (address);
function getSeller() external view returns (address);
function getArbiter() external view returns (address);
function getArbiterFee() external view returns (uint256);
function getState() external view returns (State);
}
Escrow contract
pragma solidity 0.8.18;
import {IEscrow} from "./IEscrow.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Escrow is IEscrow, ReentrancyGuard {
using SafeERC20 for IERC20;
uint256 private immutable i_price;
IERC20 private immutable i_tokenContract;
address private immutable i_buyer;
address private immutable i_seller;
address private immutable i_arbiter;
uint256 private immutable i_arbiterFee;
bool private buyerCancelled;
bool private sellerCancelled;
State private s_state;
constructor(
uint256 price,
IERC20 tokenContract,
address buyer,
address seller,
address arbiter,
uint256 arbiterFee
) {
if (address(tokenContract) == address(0)) revert Escrow__TokenZeroAddress();
if (buyer == address(0)) revert Escrow__BuyerZeroAddress();
if (seller == address(0)) revert Escrow__SellerZeroAddress();
if (arbiterFee >= price) revert Escrow__FeeExceedsPrice(price, arbiterFee);
if (tokenContract.balanceOf(address(this)) < price) revert Escrow__MustDeployWithTokenBalance();
i_price = price;
i_tokenContract = tokenContract;
i_buyer = buyer;
i_seller = seller;
i_arbiter = arbiter;
i_arbiterFee = arbiterFee;
}
modifier onlyBuyer() {
if (msg.sender != i_buyer) {
revert Escrow__OnlyBuyer();
}
_;
}
modifier onlyBuyerOrSeller() {
if (msg.sender != i_buyer && msg.sender != i_seller) {
revert Escrow__OnlyBuyerOrSeller();
}
_;
}
modifier onlyArbiter() {
if (msg.sender != i_arbiter) {
revert Escrow__OnlyArbiter();
}
_;
}
modifier inState(State expectedState) {
if (s_state != expectedState) {
revert Escrow__InWrongState(s_state, expectedState);
}
_;
}
function confirmReceipt() external onlyBuyer inState(State.Created) {
s_state = State.Confirmed;
emit Confirmed(i_seller);
i_tokenContract.safeTransfer(i_seller, i_tokenContract.balanceOf(address(this)));
}
function initiateDispute() external onlyBuyerOrSeller inState(State.Created) {
if (i_arbiter == address(0)) revert Escrow__DisputeRequiresArbiter();
s_state = State.Disputed;
emit Disputed(msg.sender);
}
function cancelAgreement() external onlyBuyerOrSeller inState(State.Created) {
if(msg.sender == i_seller)
{
sellerCancelled = true;
}
if(msg.sender == i_buyer)
{
buyerCancelled = true;
}
if(buyerCancelled && sellerCancelled)
{
s_state = State.Cancelled;
uint256 contractBalance = i_tokenContract.balanceOf(address(this));
if (contractBalance > 0) {
i_tokenContract.safeTransfer(i_buyer, contractBalance);
}
}
emit Cancelled(i_buyer, i_seller);
}
function resolveDispute(uint256 buyerAward) external onlyArbiter nonReentrant inState(State.Disputed) {
uint256 tokenBalance = i_tokenContract.balanceOf(address(this));
uint256 totalFee = buyerAward + i_arbiterFee;
if (totalFee > tokenBalance) {
revert Escrow__TotalFeeExceedsBalance(tokenBalance, totalFee);
}
s_state = State.Resolved;
emit Resolved(i_buyer, i_seller);
if (buyerAward > 0) {
i_tokenContract.safeTransfer(i_buyer, buyerAward);
}
if (i_arbiterFee > 0) {
i_tokenContract.safeTransfer(i_arbiter, i_arbiterFee);
}
tokenBalance = i_tokenContract.balanceOf(address(this));
if (tokenBalance > 0) {
i_tokenContract.safeTransfer(i_seller, tokenBalance);
}
}
function getPrice() external view returns (uint256) {
return i_price;
}
function getTokenContract() external view returns (IERC20) {
return i_tokenContract;
}
function getBuyer() external view returns (address) {
return i_buyer;
}
function getSeller() external view returns (address) {
return i_seller;
}
function getArbiter() external view returns (address) {
return i_arbiter;
}
function getArbiterFee() external view returns (uint256) {
return i_arbiterFee;
}
function getState() external view returns (State) {
return s_state;
}
}
Foundry test
token contract below this contract
(copy/past to file EscrowTokenTest.sol
)
pragma solidity 0.8.18;
import "forge-std/Test.sol";
import {IEscrow,Escrow} from "../src/Escrow.sol";
import {EscrowFactory} from "../src/EscrowFactory.sol";
import "../src/TestToken.sol";
contract EscrowTokenTest is Test {
bytes32 public constant SALT1 = bytes32(uint256(keccak256(abi.encodePacked("test"))));
EscrowFactory public escrowFactory;
address public DEPLOYER = makeAddr("DEPLOYER");
address public BUYER = makeAddr("BUYER");
address public SELLER= makeAddr("SELLER");
address public ARBITER = makeAddr("ARBITER");
uint256 public constant ARBITER_FEE = 1e16;
IEscrow public escrow;
uint256 public buyerAward = 0;
TestToken testToken;
event Confirmed(address indexed seller);
event Disputed(address indexed disputer);
event Resolved(address indexed buyer, address indexed seller);
event Cancelled(address indexed buyer, address indexed seller);
function setUp() external {
vm.startPrank(DEPLOYER);
testToken = new TestToken();
testToken.mint(BUYER,100 ether);
testToken.mint(SELLER,10 ether);
escrowFactory = new EscrowFactory();
vm.stopPrank();
}
function testHappyPath() public {
vm.startPrank(BUYER);
testToken.approve(address(escrowFactory), 10 ether);
escrow = escrowFactory.newEscrow(10 ether, IERC20(testToken), SELLER, ARBITER, ARBITER_FEE, SALT1);
vm.stopPrank();
vm.startPrank(BUYER);
uint256 balanceBefore = testToken.balanceOf(SELLER);
escrow.confirmReceipt();
uint256 balanceAfter = testToken.balanceOf(SELLER);
vm.stopPrank();
assertGt(balanceAfter,balanceBefore);
assertEq(escrow.getPrice(), 10 ether);
assertEq(address(escrow.getTokenContract()), address(testToken));
assertEq(escrow.getBuyer(), BUYER);
assertEq(escrow.getSeller(), SELLER);
assertEq(escrow.getArbiter(), ARBITER);
assertEq(escrow.getArbiterFee(), ARBITER_FEE);
}
function testCancelledPath() public {
vm.startPrank(BUYER);
testToken.approve(address(escrowFactory), 10 ether);
escrow = escrowFactory.newEscrow(10 ether, IERC20(testToken), SELLER, ARBITER, ARBITER_FEE, SALT1);
vm.stopPrank();
uint256 balanceBefore = testToken.balanceOf(BUYER);
vm.startPrank(BUYER);
escrow.cancelAgreement();
vm.stopPrank();
vm.startPrank(SELLER);
vm.expectEmit(address(escrow));
emit Cancelled(BUYER,SELLER);
escrow.cancelAgreement();
vm.stopPrank();
uint256 balanceAfter = testToken.balanceOf(BUYER);
assertGt(balanceAfter,balanceBefore);
}
}
TestToken contract (copy/past to file TestToken.sol
)
pragma solidity ^0.8.17;
import "../lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
contract TestToken is ERC20 {
uint256 public constant INITIAL_SUPPLY = 1_000_000_000e18;
address _owner;
constructor() ERC20("TToken", "TTT") {
_owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender==_owner,"Notthe Owner!");
_;
}
function mint(address account, uint256 amount) external onlyOwner {
_mint(account, amount);
}
}