Writing escrow in Solidity: the state machine and a real contract walkthrough
How an escrow contract is built in Solidity: statuses, transitions, access checks, events and payouts - a minimal version and the production contract.
In this article
An escrow contract is a great teaching example: small, yet it has everything that matters - state, access rights, money, events and protection against mistakes. We will assemble a minimal version from scratch and then see how the production one differs.
The idea: a finite state machine
At any moment a deal is in one of its states, and you can move between them only by known rules. Such a model is called a finite state machine. For escrow it looks like this:
createOffer ──> Offered ──acceptOffer──> Active ──confirmDelivery──────────> Settled
│ │
│ ├─ setCancelIntent (both) ───────> Settled
│ ├─ claimExpired (after term) ────> Settled
│ └─ openDispute ──> Disputed
│ ├─ resolveDispute ──────> Settled
└─ cancelOffer ──> Settled └─ claimArbiterTimeout ─> SettledThe Settled state is the only final one: there is no exit from it. The payout happens exactly once, at the moment of transition into Settled. That is the most important property for security: if the payout is tied to an irreversible transition it cannot be repeated.
A minimal version
Let us start with the simplest deal: the buyer puts in ETH, the seller is defined by the offer, the buyer confirms - the money goes to the seller.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
contract MiniEscrow {
enum Status { None, Funded, Settled }
struct Deal {
address buyer;
uint96 amount; // 20 + 12 bytes = one storage slot
address seller;
Status status;
}
mapping(uint256 => Deal) private _deals;
uint256 public dealCount;
event Created(uint256 indexed id, address indexed buyer, address indexed seller, uint256 amount);
event Released(uint256 indexed id, uint256 amount);
error ZeroAmount();
error AmountTooLarge();
error NotAuthorized();
error WrongStatus(Status expected, Status actual);
error TransferFailed();
function create(address seller) external payable returns (uint256 id) {
if (msg.value == 0) revert ZeroAmount();
if (msg.value > type(uint96).max) revert AmountTooLarge();
id = dealCount++;
_deals[id] = Deal(msg.sender, uint96(msg.value), seller, Status.Funded);
emit Created(id, msg.sender, seller, msg.value);
}
function release(uint256 id) external {
Deal storage d = _deals[id];
if (d.status != Status.Funded) revert WrongStatus(Status.Funded, d.status);
if (msg.sender != d.buyer) revert NotAuthorized();
d.status = Status.Settled; // 1. change state first
uint256 amount = d.amount;
(bool ok, ) = d.seller.call{value: amount}(""); // 2. then send the money
if (!ok) revert TransferFailed();
emit Released(id, amount);
}
}Let us see what matters here.
The structure and the enum. Status is an enumeration: None (the default, meaning "no deal"), Funded, Settled. The zero value is deliberately "empty": accessing a non-existent deal returns status None and is rejected by a check.
Checks at the start of the function. First the status, then rights: the function reverts if the deal is not in the right state or the caller is not the buyer. This order - "checks, effects, interactions" - is covered in the article on reentrancy.
Custom errors instead of require("...") strings: more compact, cheaper in gas and unambiguously encoded (ABI).
Overflow protection. uint96(msg.value) would silently truncate a large number, so before the cast we explicitly check AmountTooLarge. Since version 0.8 arithmetic is checked automatically, but explicit type casts remain a source of silent errors.
The dealCount++ counter hands out deal numbers in order: 0, 1, 2… The first deal gets number 0.
How the production contract differs
The minimal version works, but has many gaps. The production contract closes them:
| Gap | How it is closed in the production contract |
|---|---|
| No third party | A guarantor, a dispute, a ruling with shares from 0 to 100% |
| The seller must accept | An offer lives 7 days, only the other side can accept |
| No term | Term 1/7/30/90 days, refund after expiry |
| No fees | 1% to the platform, base and dispute rates for the guarantor |
| Only ETH | ERC-20 and native currency; the received amount is verified |
| A recipient can block the payout | "Deferred payments" (credits), see pull payments |
| No reentrancy protection | ReentrancyGuard on every function touching money |
| No stop | Pause: blocks only new deals, not exit from existing ones |
| Terms are invisible | A terms hash in the deal, checked on accepting |
Payout calculation - the most careful spot
The split calculation is pure arithmetic, so it is moved into a separate function that is easy to test:
function _split(uint256 amount, uint16 arbiterFeeBps, uint16 buyerShareBps)
private pure
returns (uint256 buyerAmount, uint256 sellerAmount, uint256 arbiterFee, uint256 platformFee)
{
platformFee = (amount * PLATFORM_FEE_BPS) / BPS; // 1% of the amount
uint256 net = amount - platformFee;
arbiterFee = (net * arbiterFeeBps) / BPS; // guarantor's rate on the remainder
uint256 pot = net - arbiterFee;
buyerAmount = (pot * buyerShareBps) / BPS;
sellerAmount = pot - buyerAmount; // the remainder - by subtraction
}Money is measured in basis points (bps): 1% = 100 bps, 100% = 10,000. Integer division rounds down, so "pennies" are inevitably lost in division. The key detail is the last line: the seller's share is computed by subtraction, not multiplication. If both shares were computed by division, the sum of parts could end up less than the whole (or more), and the contract would owe money or give too much. Subtraction guarantees that the parts always sum to the whole.
A single payout point
In the production contract all outcomes pass through one function, _settle. It first records the result and then sends the money:
function _settle(uint256 dealId, Deal storage d, uint16 buyerShareBps,
uint16 arbiterFeeBps, Outcome outcome) private {
// ... compute the shares via _split ...
// Effects
d.status = Status.Settled;
d.outcome = outcome;
platformFees[token] += platformFee;
emit DealSettled(dealId, outcome, buyerAmount, sellerAmount, arbiterFee, platformFee);
// Interactions
if (arbiterFee != 0) _payout(token, arbiter, arbiterFee);
if (buyerAmount != 0) _payout(token, buyer, buyerAmount);
if (sellerAmount != 0) _payout(token, seller, sellerAmount);
}The advantage of a single point: any payout can be checked in one place, and every outcome - confirmation, cancellation, term, dispute, timeout - picks the parameters and calls the same code. The difference between them is only which guarantor rate and which buyer share to pass. So an "outcome" becomes plain data rather than branching logic.
Access rights
The contract knows who is who because roles are recorded in the deal. The checks look like this:
if (msg.sender != d.buyer) revert NotAuthorized(); // buyer only
if (msg.sender != d.arbiter) revert NotAuthorized(); // guarantor only
if (msg.sender != d.buyer && msg.sender != d.seller) revert NotAuthorized(); // either sidemsg.sender is whoever signed the call. It cannot be forged: it is the address determined by the transaction signature.
Events - the deal's chronicle
Each transition writes an event: OfferCreated, OfferAccepted, DisputeOpened, DealSettled. The interface builds the deal history from them, and observers learn what happened without reading state.
What to read next
How the contract calculates shares without loss - in the article integer math and rounding, and how a deal's data is packed - in the analysis of storage. Then, who may call what: access-control patterns. And the most dangerous spot of any contract with money is handing control to an outside address: reentrancy and pull payments.
Keep reading
- 3 · Under the hood
Reentrancy, checks-effects-interactions and pull payments
How the reentrancy attack that hacked The DAO works and how to defend against it: operation order, ReentrancyGuard and deferred payments, using escrow.
6 min read - 3 · Under the hood
Access-control patterns in smart contracts - owner, roles and deal parties
How contracts decide who may do what: Ownable and Ownable2Step, roles, checking deal parties, pauses and least privilege. A walkthrough using the escrow.
4 min read
Mentioned in
- Bitcoin vs Ethereum - the difference and why deals are built on Ethereum
- Public-key cryptography - how a signature proves it is you
- What is a smart contract and why you can trust it with money
- The deal lifecycle - from offer to payout, step by step
- How to verify a smart contract before using it - a step-by-step guide
- A tour of the Solidity language - types, functions, errors and events by example
- Events and logs: eth_getLogs, bloom filters and public RPC limits
- Access-control patterns in smart contracts - owner, roles and deal parties
- A catalogue of smart contract vulnerabilities: 12 classic mistakes and defences
- Foundry for beginners - compiling, tests, fuzzing and a local network
Ready to try it?
Create your first deal in a couple of minutes. The money is locked in a smart contract and paid out by rules you can see in advance.

