What an Ether refund escrow solves

A refund escrow holds Ether on behalf of a participant until a condition is met. Unlike a direct payment, the funds are not immediately transferred to the recipient. Instead, the contract records who paid, who may receive the refund, and whether the refund has been approved.

This design is useful when:

  • a buyer cancels an order before delivery
  • a service is not completed and the customer must be refunded
  • a deposit must be returned after a rental or reservation ends
  • an admin or arbitrator needs to approve refunds after review

A good refund escrow should be:

  • explicit about who can request and approve refunds
  • safe against repeated withdrawals
  • transparent through events and readable state
  • simple enough to audit and reason about

Design goals and security model

Before writing code, define the trust model. In this tutorial, the escrow has three roles:

  • Payer: deposits Ether into the contract
  • Beneficiary: the address that can receive a refund
  • Approver: an address allowed to approve the refund, such as an admin or arbitrator

The contract will support one refund per escrow record. That keeps the example focused and avoids the complexity of partial refunds or multi-stage disputes.

Key safety rules

  1. Checks-effects-interactions
  2. Update internal state before sending Ether.

  3. Single-use refund records
  4. Mark a refund as completed before transferring funds.

  5. Explicit authorization
  6. Only approved accounts can confirm refunds.

  7. Event logging
  8. Emit events for deposits, approvals, and payouts.

  9. Graceful failure
  10. Use call and revert on failure rather than silently ignoring it.


Contract overview

The contract stores each refund request in a struct. A request includes the payer, beneficiary, amount, approval status, and completion status. The approver can mark a request as approved, and then the beneficiary can claim the refund.

This two-step flow is practical because it separates policy from execution:

  • approval decides whether the refund is valid
  • claiming performs the actual Ether transfer

That separation makes the contract easier to integrate with off-chain dispute resolution or admin workflows.


Full Solidity example

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

contract RefundEscrow {
    address public owner;

    struct RefundRequest {
        address payer;
        address beneficiary;
        uint256 amount;
        bool approved;
        bool claimed;
    }

    uint256 public nextRequestId;
    mapping(uint256 => RefundRequest) public requests;
    mapping(address => bool) public approvers;

    event ApproverUpdated(address indexed approver, bool allowed);
    event RefundRequested(
        uint256 indexed requestId,
        address indexed payer,
        address indexed beneficiary,
        uint256 amount
    );
    event RefundApproved(uint256 indexed requestId, address indexed approver);
    event RefundClaimed(uint256 indexed requestId, address indexed beneficiary, uint256 amount);

    error NotOwner();
    error NotApprover();
    error InvalidRequest();
    error AlreadyApproved();
    error AlreadyClaimed();
    error NotBeneficiary();
    error NotApproved();
    error TransferFailed();
    error ZeroAddress();
    error ZeroAmount();

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;
    }

    modifier onlyApprover() {
        if (!approvers[msg.sender]) revert NotApprover();
        _;
    }

    constructor() {
        owner = msg.sender;
        approvers[msg.sender] = true;
    }

    function setApprover(address account, bool allowed) external onlyOwner {
        if (account == address(0)) revert ZeroAddress();
        approvers[account] = allowed;
        emit ApproverUpdated(account, allowed);
    }

    function requestRefund(address beneficiary) external payable returns (uint256 requestId) {
        if (beneficiary == address(0)) revert ZeroAddress();
        if (msg.value == 0) revert ZeroAmount();

        requestId = nextRequestId++;
        requests[requestId] = RefundRequest({
            payer: msg.sender,
            beneficiary: beneficiary,
            amount: msg.value,
            approved: false,
            claimed: false
        });

        emit RefundRequested(requestId, msg.sender, beneficiary, msg.value);
    }

    function approveRefund(uint256 requestId) external onlyApprover {
        RefundRequest storage r = requests[requestId];
        if (r.payer == address(0)) revert InvalidRequest();
        if (r.approved) revert AlreadyApproved();
        if (r.claimed) revert AlreadyClaimed();

        r.approved = true;
        emit RefundApproved(requestId, msg.sender);
    }

    function claimRefund(uint256 requestId) external {
        RefundRequest storage r = requests[requestId];
        if (r.payer == address(0)) revert InvalidRequest();
        if (msg.sender != r.beneficiary) revert NotBeneficiary();
        if (!r.approved) revert NotApproved();
        if (r.claimed) revert AlreadyClaimed();

        uint256 amount = r.amount;
        r.claimed = true;

        (bool success, ) = payable(r.beneficiary).call{value: amount}("");
        if (!success) revert TransferFailed();

        emit RefundClaimed(requestId, r.beneficiary, amount);
    }

    function getRequest(uint256 requestId)
        external
        view
        returns (
            address payer,
            address beneficiary,
            uint256 amount,
            bool approved,
            bool claimed
        )
    {
        RefundRequest storage r = requests[requestId];
        if (r.payer == address(0)) revert InvalidRequest();

        return (r.payer, r.beneficiary, r.amount, r.approved, r.claimed);
    }
}

How the contract works

1. Creating a refund request

The payer calls requestRefund() and sends Ether with the transaction. The contract stores the request in a mapping using a sequential ID.

Important details:

  • the beneficiary must be a valid non-zero address
  • the amount is taken from msg.value
  • the request is immutable after creation except for approval and claim status

This is a clean way to associate a specific amount with a specific refund workflow.

2. Approving the refund

An authorized approver calls approveRefund(requestId). The function checks that the request exists and has not already been approved or claimed.

This step is intentionally separate from the deposit. In real systems, approval may depend on:

  • customer support review
  • oracle input
  • off-chain arbitration
  • admin confirmation after a cancellation event

3. Claiming the refund

The beneficiary calls claimRefund(requestId). The function verifies that:

  • the request exists
  • the caller is the intended beneficiary
  • the refund was approved
  • the refund has not already been claimed

Then it marks the request as claimed before sending Ether. This prevents reentrancy from reusing the same request.


Why this pattern is safer than direct transfers

A common mistake is to send Ether immediately after a refund decision without recording completion first. That can create a reentrancy window if the recipient is a contract with a fallback function.

This escrow avoids that problem by following the checks-effects-interactions pattern:

  1. validate the request
  2. update claimed = true
  3. transfer Ether with call

Because the state is updated before the external call, a malicious recipient cannot claim the same refund twice through recursive calls.

transfer vs call

Modern Solidity development generally prefers call over transfer because:

  • transfer imposes a fixed gas stipend that can break with changing gas costs
  • call is more flexible and future-proof
  • call requires explicit success handling, which improves clarity

In this example, call is used with a revert on failure, so the transaction fails cleanly if the transfer cannot be completed.


Practical usage examples

Example 1: service cancellation refund

A customer pays a deposit for a service. If the service is canceled before work begins, an operator approves the refund and the customer claims it.

Flow:

  1. customer calls requestRefund() and sends 1 ETH
  2. support team calls approveRefund(requestId)
  3. customer calls claimRefund(requestId)

Example 2: marketplace dispute resolution

A buyer deposits funds for an item. If the seller fails to deliver, an arbitrator approves a refund to the buyer.

In this case:

  • payer = buyer
  • beneficiary = buyer
  • approver = arbitrator or platform admin

Example 3: reservation cancellation

A user books a room or event slot with a deposit. If the cancellation policy allows it, the operator approves the refund after the cancellation deadline is checked off-chain or by another contract.


Best practices for production use

The example is intentionally compact, but production escrow systems usually need more structure.

Add deadlines or cancellation windows

You may want refunds to be claimable only after a certain time, or only before a cutoff. That can be implemented with a createdAt timestamp and a refundDeadline.

Support partial refunds

Some workflows require returning only part of the deposit. In that case, store both:

  • amountDeposited
  • amountRefunded

and ensure the sum of refunds never exceeds the deposit.

Restrict approvers carefully

A single owner can be acceptable for prototypes, but production systems often use:

  • a multisig
  • role-based access control
  • a governance process
  • a trusted arbitration contract

Emit enough events

Events are essential for off-chain indexing and support tooling. At minimum, log:

  • request creation
  • approval
  • claim completion
  • approver changes

Consider pausing

If the contract will hold meaningful value, a pause mechanism can help during incident response. A paused state can block new requests and claims while preserving existing data.


Common pitfalls to avoid

PitfallWhy it is riskyBetter approach
Sending Ether before updating stateEnables reentrancyMark the request claimed first
Using tx.origin for authorizationBreaks composability and can be phishedUse msg.sender and explicit roles
Allowing zero-address beneficiariesFunds may become unrecoverableReject address(0)
Ignoring failed transfersCreates inconsistent state or lost claimsRevert on failed call
Reusing request IDsCan overwrite existing escrow dataUse monotonic IDs
Mixing approval and payout in one functionHarder to audit and integrateSeparate approval from claiming

Extending the example

If you want to evolve this contract, consider these enhancements:

Add a dispute resolver

Instead of a generic approver, you can assign a resolver per request. That makes the escrow suitable for peer-to-peer transactions where each deal has its own arbitrator.

Store metadata hashes

You can attach a bytes32 referenceId or IPFS hash to link the on-chain refund to an off-chain invoice, order, or support ticket.

Support ERC20 refunds

The same pattern works for tokens, but the payout logic changes to IERC20(token).transfer(...) or safeTransfer(...) from a trusted library.

Add batch operations

For operational efficiency, an admin may want to approve multiple refunds in one transaction. Batch functions should still validate each request individually.


Testing recommendations

A refund escrow should be tested with both happy paths and adversarial cases.

Test cases to include

  • request creation with valid and invalid beneficiary addresses
  • approval by authorized and unauthorized accounts
  • claim by the correct beneficiary
  • claim by the wrong address
  • double claim attempts
  • claim before approval
  • transfer failure scenarios
  • event emission for each state transition

Security-focused tests

Use a malicious receiver contract to verify that:

  • the refund cannot be claimed twice
  • reentrant calls do not bypass approval checks
  • state remains correct if the external transfer fails

These tests are especially important because escrow logic is often targeted by attackers looking for edge-case mistakes.


When to use this pattern

Use a refund escrow when you need a clear, auditable path for returning Ether after a decision is made. It is a strong fit for systems where money is temporarily held and later released based on policy, review, or dispute resolution.

Do not use this pattern if:

  • funds should be immediately withdrawable by the recipient without approval
  • you need complex multi-party settlement logic
  • the workflow requires streaming or continuous partial payouts

In those cases, a different architecture may be simpler and safer.


Learn more with useful resources