
Building a Safe Pull-Based Ether Refund Pattern in Solidity
Why pull-based refunds are safer
In a push-based design, a contract immediately transfers Ether back to the sender or a third party. That seems convenient, but it couples business logic with external calls. If the recipient is a contract, the transfer may fail or trigger unexpected behavior. If the recipient reenters the contract before state is finalized, funds can be drained or accounting can be corrupted.
A pull-based pattern separates these concerns:
- The contract calculates the refund.
- The contract records the refundable amount.
- The user calls a dedicated withdrawal function to claim it.
This design is especially useful for:
- Overpayment refunds in sales contracts
- Auction bid refunds
- Canceled order reimbursements
- Fee rebates
- Batch settlement systems
Benefits at a glance
| Approach | Main advantage | Main risk |
|---|---|---|
| Push refund | Immediate user experience | Reentrancy, failed transfers, gas griefing |
| Pull refund | Safer and more reliable accounting | Requires an extra user transaction |
The extra withdrawal step is usually a good tradeoff for security and operational robustness.
Core design principles
A secure refund vault should follow a few rules:
- Record before interaction: update internal accounting before sending Ether.
- Use
callfor transfers: it forwards all remaining gas and is the recommended low-level Ether transfer method. - Protect withdrawal logic: prevent reentrancy in the claim function.
- Keep balances isolated: track each user’s refundable amount independently.
- Emit events: make refunds observable off-chain.
You should also decide whether refunds are:
- User-initiated: the user explicitly deposits or overpays and later withdraws.
- Admin-initiated: the contract owner credits refunds after a cancellation or dispute.
- Automatic but deferred: the contract computes a refund at execution time and stores it for later claim.
The implementation below supports the third model and can be adapted to the others.
Example: a pull-based Ether refund vault
The contract below models a simple purchase flow where users send Ether to reserve a service. If the service is canceled, the contract credits each buyer with a refund amount. Buyers then withdraw their refunds themselves.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract RefundVault {
address public immutable owner;
mapping(address => uint256) private refundableBalances;
uint256 public totalRefundable;
bool public refundsEnabled;
bool public finalized;
event Purchased(address indexed buyer, uint256 amount);
event RefundCredited(address indexed user, uint256 amount);
event RefundsEnabled();
event Refunded(address indexed user, uint256 amount);
event Finalized();
error NotOwner();
error AlreadyFinalized();
error RefundsNotEnabled();
error NothingToWithdraw();
error InvalidAmount();
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
constructor() {
owner = msg.sender;
}
function purchase() external payable {
if (finalized) revert AlreadyFinalized();
if (msg.value == 0) revert InvalidAmount();
emit Purchased(msg.sender, msg.value);
}
function creditRefund(address user, uint256 amount) external onlyOwner {
if (finalized) revert AlreadyFinalized();
if (amount == 0) revert InvalidAmount();
refundableBalances[user] += amount;
totalRefundable += amount;
emit RefundCredited(user, amount);
}
function enableRefunds() external onlyOwner {
if (finalized) revert AlreadyFinalized();
refundsEnabled = true;
emit RefundsEnabled();
}
function withdrawRefund() external {
if (!refundsEnabled) revert RefundsNotEnabled();
uint256 amount = refundableBalances[msg.sender];
if (amount == 0) revert NothingToWithdraw();
// Effects
refundableBalances[msg.sender] = 0;
totalRefundable -= amount;
// Interaction
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "ETH_TRANSFER_FAILED");
emit Refunded(msg.sender, amount);
}
function finalize() external onlyOwner {
if (finalized) revert AlreadyFinalized();
finalized = true;
emit Finalized();
}
function refundableBalanceOf(address user) external view returns (uint256) {
return refundableBalances[user];
}
receive() external payable {
revert("DIRECT_PAYMENTS_DISABLED");
}
}What this contract demonstrates
- Refunds are credited first, then withdrawn later.
- The withdrawal function uses the checks-effects-interactions pattern.
- The contract rejects direct Ether transfers to avoid accidental accounting mismatches.
- Refund state is observable through events and view functions.
How the flow works
1. Users purchase or deposit
The purchase() function accepts Ether and records the action. In a real application, you might also store order IDs, product selections, or auction bids.
2. The contract credits refunds
If the sale is canceled or partially fulfilled, the owner can call creditRefund(user, amount) to assign a refundable balance.
This step is intentionally separate from the withdrawal. That separation is what makes the pattern safer.
3. Refunds are enabled
The owner calls enableRefunds() once the refund window opens. This prevents premature withdrawals before the contract is ready.
4. Users withdraw their refunds
Each user calls withdrawRefund() to claim their Ether. The contract zeroes out the balance before sending funds, which prevents a reentrant double-withdraw.
Why call is preferred over transfer
Older Solidity examples often used transfer(). That approach is no longer recommended because it forwards only 2300 gas, which can break when the recipient is a contract with a more expensive fallback or receive function.
Using call{value: amount}("") is more flexible and future-proof. However, it also means you must be careful to protect the withdrawal function with proper state updates and, ideally, a reentrancy guard.
Recommended transfer strategy
| Method | Status | Notes |
|---|---|---|
transfer() | Discouraged | Gas stipend can cause unexpected failures |
send() | Discouraged | Returns false instead of reverting; easy to misuse |
call{value: ...}("") | Recommended | Must be paired with safe state handling |
Adding a reentrancy guard
The example above is already safe because it zeroes the balance before the external call. Still, many production contracts benefit from an explicit reentrancy guard, especially if they contain multiple withdrawal paths or more complex state transitions.
A minimal guard looks like this:
bool private locked;
modifier nonReentrant() {
require(!locked, "REENTRANCY");
locked = true;
_;
locked = false;
}You can then apply it to withdrawRefund():
function withdrawRefund() external nonReentrant {
...
}This is not a substitute for correct state ordering, but it adds defense in depth.
Handling partial refunds and batch operations
Real-world refund systems often need more than a simple one-to-one reimbursement. Common variants include:
- Partial refunds: a user gets back only the unused portion of a payment.
- Batch credits: an operator credits many users after a canceled event.
- Proportional refunds: each participant receives a share based on contribution size.
For batch operations, avoid looping over unbounded user lists on-chain. Instead, use an off-chain process to compute refund amounts and submit them in manageable chunks.
Practical batching advice
- Keep each transaction under a safe gas budget.
- Store a cursor for progress if you must process many users.
- Prefer multiple smaller transactions over one large loop.
- Emit events for each credited refund so off-chain systems can reconcile state.
Common mistakes to avoid
1. Sending Ether before updating state
This is the classic reentrancy bug. Always update balances first.
2. Mixing refund logic with business logic
Do not make the refund function also finalize orders, update inventory, or change unrelated state. Keep it focused.
3. Using a single global refund flag without per-user balances
A global “refund available” switch is not enough. Each user needs an independent claimable amount.
4. Forgetting to handle failed external calls
Even with call, the transfer can fail. Revert the withdrawal if it does, so funds remain claimable.
5. Allowing direct Ether transfers without accounting
If your contract can receive Ether via receive() or fallback(), make sure those funds are either accounted for or explicitly rejected.
Extending the pattern for production use
The basic vault can be adapted in several useful ways.
Add deadline-based refunds
You may want refunds to be claimable only after a cancellation deadline or only until a cutoff date. That can be implemented with a timestamp check:
uint256 public refundStart;
function enableRefunds() external onlyOwner {
refundStart = block.timestamp;
refundsEnabled = true;
}Support ERC20-denominated refunds
If the original payment was in an ERC20 token, the same pull-based idea applies. Instead of sending Ether, the contract records token balances and lets users claim tokens later. The main difference is that token transfers use IERC20.transfer() and require allowance management.
Add pause controls
A pause mechanism can be useful if a bug is discovered in the refund logic. Pausing should not trap user funds permanently; it should only stop new credits or withdrawals until the issue is resolved.
Use events for reconciliation
Events are essential for off-chain accounting, analytics, and support tooling. At minimum, emit:
- deposit or purchase events
- refund credit events
- withdrawal events
- lifecycle events such as enabled, paused, or finalized
Testing the refund flow
A secure refund contract should be tested against both normal and adversarial behavior.
Test cases to include
- User withdraws after being credited
- User with zero balance cannot withdraw
- Withdrawal fails if refunds are not enabled
- Double withdrawal is impossible
- Owner cannot credit refunds after finalization
- Direct Ether transfers are rejected
- A contract recipient can withdraw successfully
- Reentrancy attempt does not drain funds
Example test focus
If you are using Foundry or Hardhat, write a malicious recipient contract that attempts to call withdrawRefund() again from its fallback function. The second call should fail because the balance was already zeroed out.
That test is more valuable than a simple happy-path test because it proves the safety property you care about most.
When this pattern is the right choice
Use a pull-based refund design when:
- refunds are not needed immediately in the same transaction
- recipients may be smart contracts
- you want to reduce reentrancy exposure
- failed external transfers would be unacceptable
- refund logic must remain auditable and deterministic
Avoid it only when immediate synchronous payment is truly required and the recipient is guaranteed to be safe, which is rare in open blockchain systems.
Summary
A pull-based Ether refund pattern is one of the simplest ways to make refund logic safer in Solidity. By recording claimable balances and letting users withdraw them later, you reduce coupling, avoid brittle external calls, and make your contract easier to reason about.
The key implementation rules are straightforward:
- store refundable balances per user
- zero balances before sending Ether
- use
callinstead oftransfer - protect withdrawal paths with reentrancy defenses
- emit events for every state transition
This pattern scales well from small sales contracts to more complex financial workflows, and it should be a default choice whenever refunds are part of your design.
