
Solidity Pull Payments: Designing Safer Ether Transfers with Withdrawal Patterns
What is the pull payment pattern?
In a push-based design, your contract sends Ether during the same transaction that creates the obligation:
- a marketplace pays sellers after a sale
- a DAO refunds voters
- a protocol distributes rewards to stakers
In a pull-based design, the contract only updates accounting state. The recipient later calls a withdrawal function to collect funds.
This separation has two major benefits:
- Failure isolation: one recipient’s bad fallback logic does not block everyone else.
- Safer execution flow: the contract can update state before any external call happens, reducing reentrancy risk.
Pull payments are especially useful when payouts are frequent, batched, or distributed to many addresses.
Why push payments are risky
A direct Ether transfer can fail for reasons that are not obvious at the application level:
- the recipient is a contract with a reverting
receive()orfallback() - the recipient needs more gas than the transfer mechanism allows
- the recipient is malicious and attempts reentrancy
- a batch payout reverts because one address is incompatible
Even if you use low-level call, the design still couples business logic to external execution. That coupling makes error handling more complex and can create partial-completion edge cases.
Typical failure scenario
Imagine a reward contract that loops over 200 users and pays each one immediately. If the 137th recipient reverts, the entire transaction reverts. No one gets paid, and gas is wasted. With pull payments, each user claims independently.
Core design of a withdrawal-based payout system
A pull payment system usually has three parts:
- Accrual: record how much each account can withdraw.
- Withdrawal: let the account claim its balance.
- Accounting reset: zero out the claim before sending Ether.
The key rule is simple:
Update internal state before making the external call.
This is the standard checks-effects-interactions pattern applied to payouts.
Minimal example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract PullPaymentVault {
mapping(address => uint256) public pendingWithdrawals;
event PaymentQueued(address indexed payee, uint256 amount);
event PaymentWithdrawn(address indexed payee, uint256 amount);
function queuePayment(address payee) external payable {
require(msg.value > 0, "No Ether sent");
pendingWithdrawals[payee] += msg.value;
emit PaymentQueued(payee, msg.value);
}
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingWithdrawals[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "Withdrawal failed");
emit PaymentWithdrawn(msg.sender, amount);
}
}This contract does not attempt to pay recipients automatically. It only records balances and lets users withdraw them later.
How the pattern prevents reentrancy
The most important security property of pull payments is that the contract clears the balance before the external call. If the recipient contract tries to reenter withdraw(), the second call sees a zero balance.
Why the order matters
Bad order:
- read balance
- send Ether
- set balance to zero
If the recipient reenters between steps 2 and 3, it can withdraw again.
Safe order:
- read balance
- set balance to zero
- send Ether
Even if the external call reenters, there is nothing left to steal from that balance entry.
Additional hardening
A withdrawal function should still follow standard defensive practices:
- use
callinstead oftransferorsend - keep the external call at the end of the function
- emit events after successful withdrawal
- consider a reentrancy guard if the function does more than simple payout logic
Pull payments reduce reentrancy risk, but they do not eliminate the need for careful design.
When pull payments are the right choice
Pull payments are ideal when the contract owes funds to many independent recipients.
Common use cases
- Marketplaces: sellers withdraw proceeds after a purchase
- Auction contracts: outbid users withdraw refunds
- Revenue sharing: contributors claim their share of protocol income
- Reward systems: users claim accumulated incentives
- DAO distributions: members withdraw allocated treasury funds
Less suitable cases
Pull payments are not always the best option:
- Immediate settlement is required: some flows need atomic payment completion
- User experience must be instant: a withdrawal step may be undesirable for small payouts
- Funds are time-sensitive: if a recipient must be paid during the same transaction, deferred claims may not fit the business logic
In those cases, you may still use a hybrid model: record a claimable balance, but optionally allow a trusted operator to trigger withdrawal on behalf of the recipient.
Comparing push and pull models
| Aspect | Push payments | Pull payments |
|---|---|---|
| Failure isolation | Poor | one failed transfer can revert a batch | Strong | each recipient claims independently |
| Reentrancy exposure | Higher | external calls happen during business logic | Lower | external call is isolated in withdrawal |
| Gas predictability | Less predictable | More predictable per claim |
| User experience | Immediate | Requires a separate claim transaction |
| Best for | Single, trusted recipients | Many recipients, refunds, rewards |
The trade-off is usually between convenience and robustness. For most multi-recipient payout systems, pull payments are the safer default.
Designing a production-ready withdrawal flow
A real payout system needs more than a mapping and a withdraw function. Consider the following improvements.
1. Separate accrual from payment logic
Keep the function that calculates entitlement distinct from the function that transfers Ether. This makes the accounting easier to audit and test.
For example:
creditSeller(address seller, uint256 amount)withdraw()
This separation helps prevent accidental Ether transfers during complex business operations.
2. Support partial withdrawals only if needed
Most systems should withdraw the full balance and reset it to zero. Partial withdrawals add complexity and increase the chance of accounting bugs.
If partial withdrawals are required, track the remaining balance carefully and update it before the external call.
3. Emit events for off-chain tracking
Withdrawal systems are easier to monitor when they emit events for both accrual and payout. Indexing these events allows frontends and analytics tools to show claimable balances without reading storage repeatedly.
4. Handle failed withdrawals gracefully
If a recipient’s withdrawal fails, do not burn their balance. Leave it intact so they can try again later. This is one of the main advantages of pull payments: a failed claim does not affect other users.
5. Consider recipient contracts
Some contracts intentionally reject Ether or require custom handling. If your system must support them, you may need:
- a recipient-initiated claim function
- a tokenized claim instead of native Ether
- a rescue or migration path for stuck balances
A more realistic marketplace example
The following example shows a simple marketplace escrow flow where buyers pay the contract, and sellers withdraw proceeds later.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract MarketplaceEscrow {
struct Listing {
address seller;
uint256 price;
bool sold;
}
uint256 public nextListingId;
mapping(uint256 => Listing) public listings;
mapping(address => uint256) public pendingProceeds;
event Listed(uint256 indexed listingId, address indexed seller, uint256 price);
event Purchased(uint256 indexed listingId, address indexed buyer, uint256 price);
event ProceedsWithdrawn(address indexed seller, uint256 amount);
function list(uint256 price) external returns (uint256 listingId) {
require(price > 0, "Invalid price");
listingId = nextListingId++;
listings[listingId] = Listing({
seller: msg.sender,
price: price,
sold: false
});
emit Listed(listingId, msg.sender, price);
}
function buy(uint256 listingId) external payable {
Listing storage item = listings[listingId];
require(!item.sold, "Already sold");
require(msg.value == item.price, "Incorrect payment");
item.sold = true;
pendingProceeds[item.seller] += msg.value;
emit Purchased(listingId, msg.sender, msg.value);
}
function withdrawProceeds() external {
uint256 amount = pendingProceeds[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingProceeds[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
emit ProceedsWithdrawn(msg.sender, amount);
}
}This design avoids sending Ether inside buy(). If a seller is a contract with a complex fallback, the purchase still succeeds. The seller can claim proceeds later using withdrawProceeds().
Best practices for secure pull payments
Use call for Ether transfers
Modern Solidity recommends call{value: amount}("") for sending Ether. It forwards all remaining gas, which is necessary for many recipient contracts. Do not rely on transfer, which can break due to gas cost changes.
Zero balances before external calls
This is non-negotiable. If you do not clear the balance first, you reintroduce reentrancy risk.
Keep withdrawal functions small
A withdrawal function should do only a few things:
- read the balance
- clear the balance
- send Ether
- emit an event
The smaller the function, the easier it is to audit.
Avoid loops in withdrawal paths
Each user should withdraw independently. Do not build a single withdrawAll() function that iterates over many recipients unless the recipient set is bounded and carefully controlled.
Make claims idempotent
If a withdrawal transaction is retried, it should not double-pay. Clearing the balance before the call makes the function naturally idempotent.
Common mistakes to avoid
Sending Ether during state updates
Do not mix accounting and payout in the same step unless you fully understand the implications. A failed transfer can revert your whole transaction and block unrelated state changes.
Using transfer for “safety”
transfer is not a security feature. It can create brittle contracts that fail when recipient gas usage changes. Prefer explicit checks around call.
Forgetting to handle stuck balances
If a recipient cannot receive Ether, your contract should still preserve their claim. Consider adding an administrative recovery or migration mechanism if the business model requires it.
Overcomplicating the withdrawal API
A simple withdraw() function is often better than multiple specialized payout functions. Complexity in payout logic tends to create edge cases.
Summary
Pull payments are a practical and security-focused way to manage Ether transfers in Solidity. Instead of pushing funds immediately, your contract records what each recipient can claim and lets them withdraw later. This pattern improves failure isolation, reduces reentrancy exposure, and scales better for multi-recipient systems.
Use pull payments when your contract distributes refunds, rewards, marketplace proceeds, or revenue shares. Keep the withdrawal logic minimal, clear balances before external calls, and emit events for observability. In most production systems, pull payments are the safer default for Ether distribution.
