Introduction
Today, they play a key role in the development of decentralised finance, voting systems, supply chain tracking, and, the subject of today's article, escrow services.
What are Smart Contracts?
Smart Contract is a program that runs over a blockchain network.
The term smart contract was first proposed in 1994 by Nick Szabo as a "transaction protocols that execute the terms of a contract". His goal was to extend the functionality of electronic transaction methods, enabling them to do more than just transfer assets.
Unlike traditional contracts where is required a third-party enforcement, a smart contract automatically validate the pre-defined conditions to execute a sequence of actions whenever fulfils the condition. Being them:
- Autonomous: Once deployed, users can run without requiring manual intervention.
- Transparent: Users can see the contract code and verify it’s logics.
- Immutable: After deployment, the code can not be changed.
- Distributed: The contract runs on all nodes over the blockchain’s network, providing redundancy and eliminating single points of failure.
However, it was only in 2013, with the introduction of Ethereum, that the concept gained popularity and a solid foundation with the creation of Solidity, a programming language for implementing smart contracts on the Ethereum Virtual Machine.
Understanding Escrow Marketplaces
Before diving into the code, let's clarify what an escrow marketplace does:
1. A seller lists an item for sale
2. A buyer places funds in escrow (held by the contract)
3. After receiving the item, the buyer releases the funds to the seller
4. If there's a dispute, the marketplace owner can resolve it
This process protects both buyers and sellers by ensuring that funds are released only when both parties are satisfied.
Time to code
In the development we’re gonna use Foundry as our toolchain. To get started, you’ll need to install it (instructions can be found here).
Once installed, we can create a new project with forge init escrow-marketplace
Foundry will create the following directory structure:
.
├── foundry.toml # Configuration file for the Foundry project
├── lib/ # External dependencies and libraries
├── script/ # Deployment scripts and automation tasks
├── src/ # Smart contract source files (.sol files)
└── test/ # Test files written in Solidity
Setting Up the Contract Foundation
After creating the EscrowMarketplace.sol
file, we insert the license identifier and Solidity version:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
The SPDX license identifier is a standard way to declare the contract's license. The pragma line specifies which Solidity compiler version to use. This is the default starting point for all our contracts in Solidity development.
Now, we define our contract:
contract EscrowMarketplace {
// State variables
address public owner;
uint256 public feePercentage;
uint256 public nextItemId;
// Constructor
constructor(uint256 _feePercentage) {
owner = msg.sender;
feePercentage = _feePercentage;
nextItemId = 1;
}
}
Here, we declare:
- owner: The address of the marketplace administrator. It is set to msg.sender
that is the address that has called the function. Using in the constructor means that the deployer will be the owner.
- feePercentage: The fee charged by the marketplace (in basis points, where 100 = 1%)
- nextItemId: A counter for assigning unique IDs to items
The constructor is executed during contract deployment and sets the default values for our variables.
Defining Data Structures
We need to define what an "item" is in our marketplace:
contract EscrowMarketplace {
// Struct to store item details
struct Item {
uint256 id;
string name;
string description;
uint256 price;
address payable seller;
address payable buyer;
ItemStatus status;
uint256 createdAt;
uint256 completedAt;
}
// Enum for item status
enum ItemStatus {
Listed,
InEscrow,
Completed,
Cancelled,
Disputed,
Refunded
}
// Mapping to store items
mapping(uint256 => Item) public items;
address public owner;
...
Here, we create:
1. An `Item` struct containing all information about a marketplace listing
2. An `ItemStatus` enum to track the state of each item
3. A mapping that connects item IDs to their data
The `enum` type is particularly useful for state management, as it makes our code more readable than using raw numbers.
Events
Events are a way for smart contracts communicate with the outside world.
It can be used to notify applications external to the blockchain, such as a frontend, so they can execute some action based on this state.
Once an event is emitted, it's available on the blockchain for anyone to consult. Events can have relevant data attached to it, this data is declared in the `()`.
We're gonna use it to communicate changes in the item selling state:
event ItemListed(uint256 indexed itemId, address indexed seller, uint256 price);
event ItemPurchased(uint256 indexed itemId, address indexed buyer, uint256 amount);
event ItemDeliveryConfirmed(uint256 indexed itemId);
event ItemRefunded(uint256 indexed itemId);
event ItemDisputed(uint256 indexed itemId, address disputeInitiator);
event DisputeResolved(uint256 indexed itemId, address winner);
Modifiers and constraints
Modifiers are a way to add code to your function in a declarative way, reducing code duplicate. The placeholder statement (_) is where the body of the function being modified is inserted.
Here we're gonna declare some modifiers to restrict function access and validate inputs. This code will be reused throughout our development, so with modifiers, we also reinforce DRY (Don't Repeat Yourself).
Solidity enables more than one modifiers per function, if needed.
modifier onlyOwner() {
require(msg.sender == owner, "Only owner can call this function");
_;
}
modifier onlySeller(uint256 _itemId) {
require(msg.sender == items[_itemId].seller, "Only seller can call this function");
_;
}
modifier onlyBuyer(uint256 _itemId) {
require(msg.sender == items[_itemId].buyer, "Only buyer can call this function");
_;
}
modifier itemExists(uint256 _itemId) {
require(_itemId < nextItemId, "Item does not exist");
_;
}
The require
statement is used to check a condition. If the condition is not met, it triggers a revert, which undoes all changes made to the state up to that point in the code. The second parameter is a message that is attached to the failed transaction, so the user can know what went wrong.
function incrementCounter(uint256 value) public {
// Add some value to the counter
counter += 2;
/*
Check if the value is not 10 before proceeding.
If it is, undoes above changes
*/
require(value != 10, "Value cannot be 10");
// If the check passes, increment the counter
counter += value;
}
In this example, the require statement ensures that the value is not 10. If value is 10, the transaction will revert, and the state of counter will remain unchanged. If the check passes, the counter is incremented by 2 and value.
Now let's examine the core functions:
Listing an Item
function listItem(
string memory _name,
string memory _description,
uint256 _price
) external returns (uint256) {
require(_price > 0, "Price must be greater than zero");
uint256 itemId = nextItemId;
items[itemId] = Item({
id: itemId,
name: _name,
description: _description,
price: _price,
seller: payable(msg.sender),
buyer: payable(address(0)),
status: ItemStatus.Listed,
createdAt: block.timestamp,
completedAt: 0
});
nextItemId++;
emit ItemListed(itemId, msg.sender, _price);
return itemId;
}
The listItem
function allows sellers to list an item for sale. Here’s what it does step by step:
It uses
require
to check if the price is greater than zero, preventing free listings.A unique
itemId
is assigned, and a newItem
struct is stored in theitems
mapping.The seller's address (
msg.sender
) is recorded to track ownership.The item’s creation time is stored using
block.timestamp
.The
ItemListed
event is emitted, allowing external applications to track new listings.Returns the new
itemId
, which can be used to reference the item later.
Purchasing an item
function purchaseItem(uint256 _itemId) external payable itemExists(_itemId) {
Item storage item = items[_itemId];
require(
item.status == ItemStatus.Listed,
"Item is not available for purchase"
);
require(msg.sender != item.seller, "Seller cannot buy their own item");
require(msg.value >= item.price, "Insufficient funds sent");
item.buyer = payable(msg.sender);
item.status = ItemStatus.InEscrow;
// Refund excess payment
if (msg.value > item.price) {
payable(msg.sender).transfer(msg.value - item.price);
}
emit ItemPurchased(_itemId, msg.sender, item.price);
}
The purchaseItem
function allows buyers to purchase a listed item in our marketplace.
The function is marked as
payable
, allowing it to receive and handle funds.It will revert with the matching error message if any of the following constraints were not met:
The item is available for purchase (
ItemStatus.Listed
).The buyer is not the seller (
msg.sender != item.seller
).The buyer has sent enough Ether (
msg.value >= item.price
).
The buyer's address (
msg.sender
) is recorded, and the item status changes toInEscrow
.If the buyer sends more than the required amount, the surplus is refunded.
Trigger
ItemPurchased
event.
Confirming Delivery
function confirmDelivery(
uint256 _itemId
) external onlyBuyer(_itemId) itemExists(_itemId) {
Item storage item = items[_itemId];
require(item.status == ItemStatus.InEscrow, "Item is not in escrow");
// Calculate fee
uint256 fee = (item.price * feePercentage) / 10000;
uint256 sellerAmount = item.price - fee;
// Update item status
item.status = ItemStatus.Completed;
item.completedAt = block.timestamp;
// Transfer funds
payable(owner).transfer(fee);
item.seller.transfer(sellerAmount);
emit ItemDeliveryConfirmed(_itemId);
}
The confirmDelivery
function finalizes the transaction when the buyer confirms receiving the item.
It checks if:
The caller is the buyer (
onlyBuyer
modifier).The item exists (
itemExists
modifier).The item is currently in escrow (
require(item.status == ItemStatus.InEscrow)
).
Calculates
placeFee. The fee is determined as a percentage of the item's price, divided by 10,000 since fees are in basis points.
Mark the item as
Completed
, and the completion timestamp is recorded.Sent marketplace fee to the contract owner.
Transfer the remaining to the seller.
Trigger
ItemDeliveryConfirmed
event.
Solving disputes
If the buyer don’t receive the asset being traded (which is a operation outside smart contract scope) he can request a refund, putting the item on dispute.
function requestRefund(uint256 _itemId) external onlyBuyer(_itemId) itemExists(_itemId) {
Item storage item = items[_itemId];
require(item.status == ItemStatus.InEscrow, "Item is not in escrow");
// Update status to disputed
item.status = ItemStatus.Disputed;
emit ItemDisputed(_itemId, msg.sender);
}
Once an item is disputed the seller can agree to the refund triggering the function agreeToRefund
. If so, the value locked in the smart contract is transferred back to the buyer and the item is market as refunded.
function agreeToRefund(uint256 _itemId) external onlySeller(_itemId) itemExists(_itemId) {
Item storage item = items[_itemId];
require(item.status == ItemStatus.InEscrow || item.status == ItemStatus.Disputed,
"Item must be in escrow or disputed");
// Update item status
item.status = ItemStatus.Refunded;
// Return funds to buyer
item.buyer.transfer(item.price);
emit ItemRefunded(_itemId);
}
If buyer and seller don’t reach an agreement the owner can solve the dispute using the solveDispute
function
function resolveDispute(uint256 _itemId, bool _refundToBuyer) external onlyOwner itemExists(_itemId) {
Item storage item = items[_itemId];
require(item.status == ItemStatus.Disputed, "Item is not disputed");
if (_refundToBuyer) {
// Refund to buyer
item.status = ItemStatus.Refunded;
item.buyer.transfer(item.price);
emit DisputeResolved(_itemId, item.buyer);
} else {
// Release to seller
uint256 fee = (item.price * feePercentage) / 10000;
uint256 sellerAmount = item.price - fee;
item.status = ItemStatus.Completed;
item.completedAt = block.timestamp;
payable(owner).transfer(fee);
item.seller.transfer(sellerAmount);
emit DisputeResolved(_itemId, item.seller);
}
}
Administrative Functions
Finally, we include functions for marketplace management:
function updateFee(uint256 _newFeePercentage) external onlyOwner {
require(_newFeePercentage <= 1000, "Fee cannot exceed 10%");
feePercentage = _newFeePercentage;
}
function getItem(
uint256 _itemId
)
external
view
itemExists(_itemId)
returns (
uint256 id,
string memory name,
string memory description,
uint256 price,
address seller,
address buyer,
ItemStatus status,
uint256 createdAt,
uint256 completedAt
)
{
Item storage item = items[_itemId];
return (
item.id,
item.name,
item.description,
item.price,
item.seller,
item.buyer,
item.status,
item.createdAt,
item.completedAt
);
}
These functions allow the owner to adjust the fee percentage (with a 10% maximum) and provide a way to query an item, returning a tuple with item details.
Conclusion
That’s it! Our implementation includes all the essential components of an escrow system. I hope this article has demonstrated the power of smart contracts and their potential in building autonomous and transparent software.
The complete source code for this project is available in our Github repository