
Building a Safe and Flexible Ether Refund Escrow in Solidity
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
- Checks-effects-interactions
- Single-use refund records
- Explicit authorization
- Event logging
- Graceful failure
Update internal state before sending Ether.
Mark a refund as completed before transferring funds.
Only approved accounts can confirm refunds.
Emit events for deposits, approvals, and payouts.
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:
- validate the request
- update
claimed = true - 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:
transferimposes a fixed gas stipend that can break with changing gas costscallis more flexible and future-proofcallrequires 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:
- customer calls
requestRefund()and sends 1 ETH - support team calls
approveRefund(requestId) - 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:
amountDepositedamountRefunded
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
| Pitfall | Why it is risky | Better approach |
|---|---|---|
| Sending Ether before updating state | Enables reentrancy | Mark the request claimed first |
Using tx.origin for authorization | Breaks composability and can be phished | Use msg.sender and explicit roles |
| Allowing zero-address beneficiaries | Funds may become unrecoverable | Reject address(0) |
| Ignoring failed transfers | Creates inconsistent state or lost claims | Revert on failed call |
| Reusing request IDs | Can overwrite existing escrow data | Use monotonic IDs |
| Mixing approval and payout in one function | Harder to audit and integrate | Separate 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.
