
Solidity Pull Payments: Designing Safer Ether Transfers with Withdrawal Patterns
What is the pull payment pattern?
A pull payment system stores balances owed to users and lets them claim funds themselves through a withdrawal function. The contract does not attempt to deliver Ether during the original business operation.
This is especially useful when:
- many recipients must be paid
- recipients are contracts with unknown behavior
- payment should not block the main workflow
- you want to minimize the impact of failed external calls
A common example is a marketplace. When a sale completes, the seller should receive proceeds, but the marketplace should not depend on the seller’s fallback logic. With pull payments, the marketplace records the amount owed, and the seller withdraws later.
Push vs. pull
| Approach | Behavior | Main risk | Best fit |
|---|---|---|---|
| Push payment | Contract sends Ether immediately | Recipient revert can break the flow | Simple one-off transfers to trusted addresses |
| Pull payment | Contract records credit for later withdrawal | User must claim funds manually | Marketplaces, auctions, reward systems, refunds |
The key idea is that payment delivery becomes a separate transaction initiated by the recipient.
Why pull payments are safer
Pull payments reduce the number of external calls made during sensitive state transitions. That matters because external calls can:
- revert and undo the entire transaction
- consume unexpected gas
- execute arbitrary code in the recipient contract
- create complex dependency chains
By recording a balance instead of sending Ether immediately, the contract can finish its internal logic first. The withdrawal function can then be designed with narrow scope and explicit failure handling.
This pattern also improves operational robustness. If a recipient is temporarily unavailable, the payment remains claimable. The contract does not need to retry or maintain a queue of failed transfers.
A practical implementation
Below is a minimal pull payment contract that credits users and lets them withdraw later.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract PullPaymentVault {
mapping(address => uint256) private _credits;
event PaymentQueued(address indexed payee, uint256 amount);
event PaymentWithdrawn(address indexed payee, uint256 amount);
error NoPaymentDue();
error ZeroAmount();
function queuePayment(address payee) external payable {
if (payee == address(0)) revert ZeroAmount();
if (msg.value == 0) revert ZeroAmount();
_credits[payee] += msg.value;
emit PaymentQueued(payee, msg.value);
}
function paymentsDue(address payee) external view returns (uint256) {
return _credits[payee];
}
function withdrawPayments() external {
uint256 amount = _credits[msg.sender];
if (amount == 0) revert NoPaymentDue();
_credits[msg.sender] = 0;
(bool ok, ) = payable(msg.sender).call{value: amount}("");
require(ok, "ETH_TRANSFER_FAILED");
emit PaymentWithdrawn(msg.sender, amount);
}
}What this example demonstrates
queuePaymentrecords funds owed to a recipient.withdrawPaymentslets the recipient claim their balance.- The balance is set to zero before the external call.
- Events provide a clear audit trail of queued and withdrawn funds.
This structure is intentionally simple, but it captures the core mechanics used in production systems.
The withdrawal flow in detail
A secure withdrawal function usually follows this sequence:
- Read the owed amount from storage.
- Ensure the amount is nonzero.
- Set the stored balance to zero.
- Transfer Ether to the caller.
- Emit an event.
The order matters. Clearing the balance before the transfer prevents the same funds from being withdrawn twice if the recipient tries to reenter the function.
Why call is preferred over transfer
Historically, many contracts used transfer because it forwarded a fixed 2300 gas stipend. That assumption is no longer reliable for modern Ethereum execution environments. A recipient may need more gas for logging, proxy logic, or other simple operations.
Using .call{value: amount}("") is more flexible and is now the standard approach. The tradeoff is that you must explicitly handle the return value and follow reentrancy-safe patterns.
Common use cases
Pull payments are a strong fit for workflows where the payer and payee are not the same actor.
1. Marketplaces
When a buyer purchases an item, the marketplace can credit the seller’s balance instead of sending Ether immediately. This prevents a seller contract from blocking the sale settlement.
2. Auctions
Auction contracts often need to refund outbid participants. Rather than pushing refunds during every bid, the contract can credit each displaced bidder and let them withdraw later.
3. Revenue sharing
A protocol that distributes fees among multiple stakeholders can record each stakeholder’s share and allow periodic claims. This avoids looping over many recipients during a single transaction.
4. Refund systems
If a deposit must be returned after a condition is met, pull payments make the refund path deterministic and less fragile.
Best practices for production contracts
Keep accounting and transfer logic separate
The contract that calculates who is owed money should not also manage complex recipient behavior. Separate concerns make audits easier and reduce the chance of hidden dependencies.
Use explicit withdrawal functions
A dedicated withdrawPayments() function is easier to test and monitor than automatic transfers scattered across multiple code paths.
Emit events for both crediting and withdrawal
Events are not the payment mechanism, but they are essential for off-chain indexing, support tooling, and operational visibility.
Prefer pull payments for untrusted recipients
If the recipient can be a contract, assume it may revert or attempt reentrancy. Pull payments reduce the blast radius of those behaviors.
Consider batching only at the user layer
If you need to withdraw many credits, let off-chain software coordinate multiple user withdrawals rather than building a large on-chain loop. On-chain loops can become gas-heavy and brittle.
Security considerations
Pull payments are safer than direct transfers, but they are not automatically secure. A few details still matter.
Reentrancy
Even though the balance is cleared before the transfer, the withdrawal function still makes an external call. That means it should remain minimal and avoid additional state changes after the transfer.
A good rule is to update state first, interact second, and keep the interaction isolated.
Denial of service by recipient behavior
A malicious recipient can still revert in its fallback function and prevent its own withdrawal. That is usually acceptable because the failure only affects the recipient’s claim, not the entire contract.
Forced Ether
A contract can receive Ether via selfdestruct or other mechanisms outside the normal payment flow. Do not assume that address(this).balance equals the sum of all credits. Track liabilities separately in storage.
Zero-address handling
Never credit the zero address unless that is a deliberate design choice. It is usually a sign of a bug in upstream logic.
Extending the pattern
In real systems, pull payments often need more structure than a single mapping.
Multiple assets
If your protocol handles ERC-20 tokens as well as Ether, maintain separate ledgers for each asset. Do not mix token balances and native currency balances in one variable.
Role-gated crediting
Only authorized components should be able to queue payments. For example, a marketplace settlement contract may be allowed to credit sellers, while random external callers are not.
Partial withdrawals
Some applications need users to withdraw only part of their balance. In that case, accept an amount parameter and decrement the stored credit accordingly.
function withdraw(uint256 amount) external {
uint256 owed = _credits[msg.sender];
if (amount == 0 || amount > owed) revert NoPaymentDue();
_credits[msg.sender] = owed - amount;
(bool ok, ) = payable(msg.sender).call{value: amount}("");
require(ok, "ETH_TRANSFER_FAILED");
}This is useful for large balances or user interfaces that want to manage cash flow gradually.
Design checklist
Before shipping a pull payment system, verify the following:
- credits are updated before any external call
- withdrawals are isolated in a single function
- failed withdrawals do not affect other users
- storage tracks liabilities independently of contract balance
- events are emitted for crediting and withdrawal
- access control prevents unauthorized credit creation
- tests cover recipient reverts and contract recipients
| Concern | Recommended approach |
|---|---|
| External call risk | Use pull-based withdrawal |
| Recipient revert | Fail only the recipient’s withdrawal |
| Reentrancy | Clear balance before transfer |
| Accounting accuracy | Store liabilities in dedicated mappings |
| Observability | Emit queue and withdrawal events |
Testing strategies
A pull payment contract should be tested against both normal and adversarial recipients.
Test cases to include
- withdrawal by an externally owned account
- withdrawal by a contract with a simple fallback
- withdrawal by a contract that reverts
- repeated withdrawal attempts after balance is cleared
- zero-balance withdrawal
- unauthorized payment crediting
A useful adversarial test is a recipient contract that tries to reenter during withdrawal. Even if the attack fails, the test confirms that state is updated safely before the external call.
When not to use pull payments
Pull payments are not always the right answer.
Avoid them when:
- the recipient is trusted and immediate settlement is required
- the payment is tiny and operational simplicity matters more than robustness
- the user experience cannot tolerate a separate withdrawal step
For example, a simple donation contract may be fine with direct transfers to a single trusted treasury. The pull pattern is most valuable when recipients are numerous, unknown, or potentially hostile.
Conclusion
Pull payments are a practical Solidity design pattern for safer Ether transfers. By recording credits and letting recipients withdraw later, you reduce the risk that a single recipient can disrupt your contract’s core logic. The pattern also improves compatibility with contract recipients, makes failures more isolated, and simplifies reasoning about state transitions.
For marketplaces, auctions, revenue sharing, and refund flows, pull payments are often the most robust default. When combined with careful accounting, minimal withdrawal logic, and clear events, they provide a strong foundation for production-grade value transfer.
