Skip to main content

Type a word from a question or an article title.

3 · Under the hood

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.

8 min readUpdated:
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:

text
createOffer ──> Offered ──acceptOffer──> Active ──confirmDelivery──────────> Settled
                   │                       │
                   │                       ├─ setCancelIntent (both) ───────> Settled
                   │                       ├─ claimExpired (after term) ────> Settled
                   │                       └─ openDispute ──> Disputed
                   │                                            ├─ resolveDispute ──────> Settled
                   └─ cancelOffer ──> Settled                   └─ claimArbiterTimeout ─> Settled

The 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.

solidity
// 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:

GapHow it is closed in the production contract
No third partyA guarantor, a dispute, a ruling with shares from 0 to 100%
The seller must acceptAn offer lives 7 days, only the other side can accept
No termTerm 1/7/30/90 days, refund after expiry
No fees1% to the platform, base and dispute rates for the guarantor
Only ETHERC-20 and native currency; the received amount is verified
A recipient can block the payout"Deferred payments" (credits), see pull payments
No reentrancy protectionReentrancyGuard on every function touching money
No stopPause: blocks only new deals, not exit from existing ones
Terms are invisibleA 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:

solidity
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:

solidity
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:

solidity
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 side

msg.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.

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.

Mentioned in

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.