Ever wondered how to make payments safe in decentralized marketplaces?
Today, weβre building a Secure Escrow System β a smart contract that holds funds until both parties fulfill agreed conditions.
Itβs like a digital middleman, ensuring funds are only released when everything goes right β or refunded safely in disputes.
π What Youβll Learn
β
How to build a secure payment escrow using Solidity
β
Handle ETH & ERC-20 tokens safely
β
Manage disputes with an arbiter
β
Protect funds with ReentrancyGuard and SafeERC20
β
Implement deadline-based resolution
π‘ Concept Overview
In traditional systems, platforms like Upwork or PayPal hold funds until both sides agree.
On-chain, we can do the same β without trusting a central authority.
Roles involved:
- Buyer β Deposits funds for the purchase.
- Seller β Delivers goods/services.
- Arbiter β Neutral party that resolves disputes.
Flow:
- Buyer creates and funds an escrow.
- Seller delivers work.
- Buyer approves β funds go to seller.
- Dispute? β Arbiter decides release or refund.
π§± Smart Contract: Escrow.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/*
Day 24 β Secure Conditional Payments (Escrow)
A system that holds funds until both parties agree,
with support for ETH, ERC20, and dispute resolution.
*/
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract Escrow is ReentrancyGuard, Ownable {
using SafeERC20 for IERC20;
enum Status { AWAITING_PAYMENT, AWAITING_DELIVERY, COMPLETE, DISPUTED, REFUNDED, CANCELLED }
struct Agreement {
address buyer;
address seller;
address arbiter;
address token;
uint256 amount;
uint256 deadline;
Status status;
bool buyerApproved;
bool sellerMarkedDelivered;
uint256 createdAt;
}
uint256 public nextId;
mapping(uint256 => Agreement) public agreements;
event EscrowCreated(uint256 indexed id, address indexed buyer, address indexed seller, address arbiter, address token, uint256 amount, uint256 deadline);
event Funded(uint256 indexed id, address payer, uint256 amount);
event BuyerApproved(uint256 indexed id, address indexed buyer);
event DisputeRaised(uint256 indexed id, address indexed by);
event Resolved(uint256 indexed id, address indexed arbiter, address to, uint256 amount);
event Refunded(uint256 indexed id, address indexed to, uint256 amount);
modifier onlyBuyer(uint256 id) {
require(msg.sender == agreements[id].buyer, "only buyer");
_;
}
modifier onlySeller(uint256 id) {
require(msg.sender == agreements[id].seller, "only seller");
_;
}
modifier onlyArbiter(uint256 id) {
require(msg.sender == agreements[id].arbiter, "only arbiter");
_;
}
modifier inStatus(uint256 id, Status s) {
require(agreements[id].status == s, "invalid status");
_;
}
constructor() {
nextId = 1;
}
function createEscrow(address seller, address arbiter, address token, uint256 amount, uint256 deadline) external returns (uint256) {
require(seller != address(0), "invalid seller");
require(amount > 0, "amount>0");
uint256 id = nextId++;
agreements[id] = Agreement({
buyer: msg.sender,
seller: seller,
arbiter: arbiter,
token: token,
amount: amount,
deadline: deadline,
status: Status.AWAITING_PAYMENT,
buyerApproved: false,
sellerMarkedDelivered: false,
createdAt: block.timestamp
});
emit EscrowCreated(id, msg.sender, seller, arbiter, token, amount, deadline);
return id;
}
function fundEscrowETH(uint256 id) external payable nonReentrant inStatus(id, Status.AWAITING_PAYMENT) {
Agreement storage a = agreements[id];
require(a.token == address(0), "not ETH");
require(msg.sender == a.buyer, "only buyer");
require(msg.value == a.amount, "wrong amount");
a.status = Status.AWAITING_DELIVERY;
emit Funded(id, msg.sender, msg.value);
}
function fundEscrowERC20(uint256 id) external nonReentrant inStatus(id, Status.AWAITING_PAYMENT) {
Agreement storage a = agreements[id];
require(a.token != address(0), "not ERC20");
require(msg.sender == a.buyer, "only buyer");
IERC20(a.token).safeTransferFrom(msg.sender, address(this), a.amount);
a.status = Status.AWAITING_DELIVERY;
emit Funded(id, msg.sender, a.amount);
}
function buyerApprove(uint256 id) external onlyBuyer(id) inStatus(id, Status.AWAITING_DELIVERY) nonReentrant {
Agreement storage a = agreements[id];
a.buyerApproved = true;
a.status = Status.COMPLETE;
_payout(a.token, a.seller, a.amount);
emit BuyerApproved(id, msg.sender);
}
function raiseDispute(uint256 id) external onlyBuyer(id) inStatus(id, Status.AWAITING_DELIVERY) {
agreements[id].status = Status.DISPUTED;
emit DisputeRaised(id, msg.sender);
}
function arbiterResolve(uint256 id, bool releaseToSeller) external onlyArbiter(id) inStatus(id, Status.DISPUTED) nonReentrant {
Agreement storage a = agreements[id];
if (releaseToSeller) {
a.status = Status.COMPLETE;
_payout(a.token, a.seller, a.amount);
emit Resolved(id, msg.sender, a.seller, a.amount);
} else {
a.status = Status.REFUNDED;
_payout(a.token, a.buyer, a.amount);
emit Refunded(id, a.buyer, a.amount);
}
}
function claimAfterDeadline(uint256 id) external nonReentrant inStatus(id, Status.AWAITING_DELIVERY) {
Agreement storage a = agreements[id];
require(a.deadline != 0 && block.timestamp > a.deadline, "deadline not passed");
require(msg.sender == a.seller, "only seller");
a.status = Status.COMPLETE;
_payout(a.token, a.seller, a.amount);
emit Resolved(id, address(0), a.seller, a.amount);
}
function _payout(address token, address to, uint256 amount) internal {
if (token == address(0)) {
(bool ok, ) = payable(to).call{value: amount}("");
require(ok, "ETH transfer failed");
} else {
IERC20(token).safeTransfer(to, amount);
}
}
receive() external payable {}
}
π§ͺ Testing with Foundry
π Folder Structure
day-24-escrow/
βββ src/
β βββ Escrow.sol
βββ test/
β βββ Escrow.t.sol
βββ foundry.toml
π§Ύ Test File (Escrow.t.sol)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/Escrow.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract DummyToken is ERC20 {
constructor() ERC20("Dummy", "DUM") { _mint(msg.sender, 1_000_000e18); }
}
contract EscrowTest is Test {
Escrow escrow;
DummyToken token;
address buyer = address(0xBEEF);
address seller = address(0xCAFE);
address arbiter = address(0xABCD);
function setUp() public {
escrow = new Escrow();
token = new DummyToken();
vm.deal(buyer, 2 ether);
}
function testEthEscrowFlow() public {
vm.startPrank(buyer);
uint256 id = escrow.createEscrow(seller, arbiter, address(0), 1 ether, 0);
escrow.fundEscrowETH{value: 1 ether}(id);
escrow.buyerApprove(id);
vm.stopPrank();
}
function testERC20DisputeRefund() public {
vm.startPrank(buyer);
uint256 id = escrow.createEscrow(seller, arbiter, address(token), 100e18, 0);
token.approve(address(escrow), 100e18);
escrow.fundEscrowERC20(id);
escrow.raiseDispute(id);
vm.stopPrank();
vm.prank(arbiter);
escrow.arbiterResolve(id, false); // refund to buyer
}
}
β‘ Run Locally
forge init day-24-escrow
cd day-24-escrow
forge install OpenZeppelin/openzeppelin-contracts
forge build
forge test -vv
π Security Practices
- β Protected by ReentrancyGuard
- β Safe token transfers via SafeERC20
- β Proper state checks with modifiers
- β Arbiter-based dispute resolution
- β Deadline system for inactive buyers
π§© Possible Enhancements
- πΈ Platform fees for escrow service
- β±οΈ Multi-stage milestone releases
- ποΈ DAO-based arbitration
- π Chainlink oracle for automated verification
π§ Key Takeaways
- You built a trustless payment system using Solidity.
- Learned how to handle disputes safely.
- Applied security best practices for real-world DeFi apps.
- Understood how conditional payments power Web3 marketplaces.
π Final Thoughts
This escrow contract is a cornerstone for decentralized marketplaces, freelance platforms, and P2P trades.
It teaches how to hold and release funds securely using smart contracts.
You now know how to implement secure, conditional payments on-chain πͺ
Top comments (0)