What a token swap escrow does

A swap escrow coordinates an exchange between two parties:

  • Party A deposits token X
  • Party B deposits token Y
  • Once both deposits are present, either party can finalize the swap
  • If the trade is not completed by a deadline, each party can reclaim their own deposit

This pattern is useful when:

  • two traders do not fully trust each other
  • a marketplace needs atomic settlement for digital goods
  • a project wants a simple OTC trade flow without a full order book

Unlike a simple transfer, escrow gives you a controlled state machine. That state machine is the core of the design.

Design goals

A safe escrow should have these properties:

GoalWhy it matters
Explicit trade statePrevents double settlement and ambiguous outcomes
Deadline-based cancellationLets users recover funds if the counterparty disappears
Pull-based withdrawalsReduces external call complexity during state changes
Safe ERC20 interactionHandles tokens that return false or revert
Single-use trade IDsPrevents replay and accidental reuse

The contract below is intentionally focused on one trade per escrow instance. That keeps the logic easy to audit and reduces the risk of state collisions.

Contract overview

We will build a contract where:

  • the creator defines the two tokens, amounts, counterparty, and expiry
  • each participant deposits their token
  • once both deposits are in, either participant can call finalize()
  • after expiry, each participant can call cancel() to reclaim their own deposit

This is a bilateral escrow, not a generalized exchange engine. That makes it suitable for direct trades and settlement workflows.

Implementation

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

library SafeERC20Lite {
    function safeTransferFrom(IERC20 token, address from, address to, uint256 amount) internal {
        (bool ok, bytes memory data) =
            address(token).call(abi.encodeWithSelector(token.transferFrom.selector, from, to, amount));
        require(ok && (data.length == 0 || abi.decode(data, (bool))), "TRANSFER_FROM_FAILED");
    }

    function safeTransfer(IERC20 token, address to, uint256 amount) internal {
        (bool ok, bytes memory data) =
            address(token).call(abi.encodeWithSelector(token.transfer.selector, to, amount));
        require(ok && (data.length == 0 || abi.decode(data, (bool))), "TRANSFER_FAILED");
    }
}

contract TokenSwapEscrow {
    using SafeERC20Lite for IERC20;

    enum Status {
        Open,
        Funded,
        Finalized,
        Cancelled
    }

    address public immutable partyA;
    address public immutable partyB;
    IERC20 public immutable tokenA;
    IERC20 public immutable tokenB;
    uint256 public immutable amountA;
    uint256 public immutable amountB;
    uint256 public immutable deadline;

    Status public status;
    bool public depositedA;
    bool public depositedB;

    event Deposited(address indexed party, address indexed token, uint256 amount);
    event Finalized(address indexed caller);
    event Cancelled(address indexed caller);

    modifier onlyParty() {
        require(msg.sender == partyA || msg.sender == partyB, "NOT_PARTY");
        _;
    }

    modifier inStatus(Status expected) {
        require(status == expected, "BAD_STATUS");
        _;
    }

    constructor(
        address _partyA,
        address _partyB,
        address _tokenA,
        address _tokenB,
        uint256 _amountA,
        uint256 _amountB,
        uint256 _deadline
    ) {
        require(_partyA != address(0) && _partyB != address(0), "ZERO_ADDRESS");
        require(_partyA != _partyB, "SAME_PARTY");
        require(_tokenA != address(0) && _tokenB != address(0), "ZERO_TOKEN");
        require(_amountA > 0 && _amountB > 0, "ZERO_AMOUNT");
        require(_deadline > block.timestamp, "BAD_DEADLINE");

        partyA = _partyA;
        partyB = _partyB;
        tokenA = IERC20(_tokenA);
        tokenB = IERC20(_tokenB);
        amountA = _amountA;
        amountB = _amountB;
        deadline = _deadline;
        status = Status.Open;
    }

    function depositA() external onlyParty inStatus(Status.Open) {
        require(msg.sender == partyA, "NOT_A");
        require(!depositedA, "ALREADY_DEPOSITED");

        depositedA = true;
        tokenA.safeTransferFrom(msg.sender, address(this), amountA);

        emit Deposited(msg.sender, address(tokenA), amountA);
        _updateStatus();
    }

    function depositB() external onlyParty inStatus(Status.Open) {
        require(msg.sender == partyB, "NOT_B");
        require(!depositedB, "ALREADY_DEPOSITED");

        depositedB = true;
        tokenB.safeTransferFrom(msg.sender, address(this), amountB);

        emit Deposited(msg.sender, address(tokenB), amountB);
        _updateStatus();
    }

    function finalize() external onlyParty inStatus(Status.Funded) {
        status = Status.Finalized;

        tokenA.safeTransfer(partyB, amountA);
        tokenB.safeTransfer(partyA, amountB);

        emit Finalized(msg.sender);
    }

    function cancel() external onlyParty {
        require(block.timestamp >= deadline, "NOT_EXPIRED");
        require(status != Status.Finalized, "ALREADY_FINALIZED");
        require(status != Status.Cancelled, "ALREADY_CANCELLED");

        status = Status.Cancelled;

        if (depositedA) {
            tokenA.safeTransfer(partyA, amountA);
        }
        if (depositedB) {
            tokenB.safeTransfer(partyB, amountB);
        }

        emit Cancelled(msg.sender);
    }

    function _updateStatus() internal {
        if (depositedA && depositedB) {
            status = Status.Funded;
        }
    }
}

How the contract works

1. Construction defines the trade terms

The constructor locks in the participants, token addresses, amounts, and deadline. That means the escrow cannot be repurposed for a different trade later.

This is important because mutable trade parameters are a common source of disputes. If the terms can change after one party deposits, the contract becomes harder to reason about and easier to abuse.

2. Each party deposits only their side

depositA() can only be called by partyA, and depositB() only by partyB. This prevents a third party from forcing the escrow into a funded state.

The contract marks the deposit flag before calling transferFrom. That is a standard defensive pattern against reentrancy-style surprises in token callbacks or nonstandard token behavior.

3. Finalization is atomic

Once both deposits are present, the escrow enters Funded. Either party can call finalize(), which transfers token A to party B and token B to party A.

The state is set to Finalized before any external token transfers occur. That way, even if one token behaves unexpectedly, the contract cannot be finalized twice.

4. Cancellation protects users from dead trades

If one side never deposits, or if the trade stalls, either party can call cancel() after the deadline. Each party gets back only the asset they deposited.

This is safer than a single “refund all” function because it preserves ownership boundaries and avoids accidental release of the counterparty’s funds.

Why the state machine matters

A small escrow contract can still fail if its state transitions are unclear. The table below summarizes the intended lifecycle:

StatusMeaningAllowed actions
OpenWaiting for one or both depositsdepositA(), depositB()
FundedBoth deposits receivedfinalize()
FinalizedTrade completedNone
CancelledTrade expired and refundedNone

This structure prevents common bugs such as:

  • finalizing before both deposits arrive
  • refunding after settlement
  • accepting duplicate deposits
  • mixing trade completion with refund logic

Security considerations

Use safe token calls

Not all ERC20 tokens behave identically. Some return false instead of reverting, and some older tokens do not return a value at all. The SafeERC20Lite helper handles both cases by checking the low-level call result and the decoded return value when present.

For production systems, you may prefer OpenZeppelin’s SafeERC20, but the principle is the same: never assume transfer or transferFrom succeeded just because the call did not revert.

Keep external calls after state updates

The contract updates its internal status before transferring out funds during finalization and cancellation. This reduces the risk of reentrancy or repeated execution if a token contract behaves unexpectedly.

Even though ERC20 tokens are less commonly reentrant than ERC777-style assets, defensive ordering is still a best practice.

Avoid partial settlement ambiguity

A trade escrow should not silently accept only one side and then attempt to “guess” what to do later. The contract above makes the rules explicit:

  • if both deposits arrive, finalize
  • if the deadline passes, cancel
  • otherwise, remain open

That clarity is valuable for both users and integrators.

Consider token compatibility

Some tokens charge transfer fees or rebalance balances in nonstandard ways. This tutorial assumes standard ERC20 behavior where the transferred amount matches the requested amount.

If you need fee-on-transfer support, you must redesign the accounting to measure actual received balances rather than trusting nominal amounts.

Practical usage flow

A typical integration sequence looks like this:

  1. One party deploys the escrow with agreed terms.
  2. Both parties approve the escrow contract to spend their tokens.
  3. Party A calls depositA().
  4. Party B calls depositB().
  5. Either party calls finalize().
  6. If the trade fails, either party waits until deadline and calls cancel().

In a frontend, you should surface the current status, deposit flags, and time remaining. Users should never have to infer the contract state from events alone.

Common extensions

Depending on your application, you may want to extend the basic escrow with one or more of these features:

  • Mutual cancellation before expiry: both parties sign off to abort early
  • Arbitration role: a trusted resolver can finalize disputes
  • Partial fills: support multiple settlement rounds
  • Permit-based deposits: let users deposit without a separate approval transaction
  • Metadata fields: store an order reference, invoice ID, or off-chain agreement hash

The right extension depends on whether you are building a simple bilateral swap or a more complex settlement workflow.

Testing recommendations

A good test suite should cover:

  • constructor validation
  • successful deposits by the correct party
  • rejection of deposits by the wrong party
  • finalization only after both deposits
  • cancellation only after the deadline
  • prevention of double deposits
  • prevention of double finalization
  • refund behavior when only one side deposited

If you use Foundry or Hardhat, also test with a mock token that returns false on transfer and one that reverts. Those edge cases often reveal whether your token handling is truly safe.

Conclusion

A token swap escrow is a compact but powerful Solidity pattern for trust-minimized bilateral trades. The key to making it safe is not cleverness; it is discipline: explicit state, strict participant checks, deadline-based recovery, and careful ERC20 handling.

If you keep the lifecycle simple and make every transition intentional, you get a contract that is easier to audit, easier to integrate, and much safer for real users.

Learn more with useful resources