
Designing Pull-Based Payment Flows in Solidity
What is a pull-based payment flow?
In a pull-based design, the contract does not push ETH or tokens to recipients during the main business operation. Instead, it:
- Calculates the amount owed.
- Stores that amount in contract state.
- Lets the recipient call a withdrawal function later.
This is especially useful when the recipient may be a smart contract, when the payout is optional, or when the main transaction should not depend on the success of a transfer.
Why this pattern matters
Immediate transfers can fail for reasons unrelated to your core logic:
- The recipient contract reverts in its fallback or receive function.
- The recipient requires more gas than a transfer method provides.
- The recipient is temporarily unable to accept funds.
- A batch payout fails because one recipient misbehaves.
With pull payments, the contract only needs to update accounting. The recipient can claim funds when convenient, and failures are isolated to the withdrawal call.
When to use pull payments
Pull-based flows are a strong fit for:
- Refund systems
- Revenue sharing
- Marketplace seller payouts
- Staking rewards
- Airdrops and claimable distributions
- Prize or bounty claims
- Any scenario with many recipients
They are less useful when the recipient must receive funds synchronously as part of a protocol invariant. Even then, it is often worth asking whether the business logic can be restructured to record entitlement first and withdraw later.
Push vs pull at a glance
| Approach | How it works | Main risk | Best use case |
|---|---|---|---|
| Push payment | Contract sends funds immediately | External call failure blocks the operation | Small, trusted recipient set |
| Pull payment | Contract records entitlement; recipient withdraws later | Requires a second transaction | Refunds, rewards, and scalable payouts |
Core design principles
A robust pull-payment implementation should follow a few rules.
1. Separate accounting from transfer
The function that creates the entitlement should only update state. It should not depend on the success of an external transfer.
2. Use withdrawal as the only external call
The withdrawal function should be the single place where ETH or tokens leave the contract. This keeps the surface area small and easier to audit.
3. Update state before transferring
When funds are withdrawn, reduce the recorded balance before making the external call. This prevents reentrancy from draining the same balance twice.
4. Make withdrawals idempotent
If a user has already withdrawn their full balance, calling the function again should simply return or revert cleanly.
5. Keep recipient logic simple
Avoid complex recipient-side hooks unless absolutely necessary. The more logic you execute during withdrawal, the more likely you are to introduce failure modes.
A practical ETH payout example
The following contract records ETH owed to recipients and lets them withdraw later.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract PullPaymentVault {
mapping(address => uint256) private _payments;
event PaymentQueued(address indexed payee, uint256 amount);
event PaymentWithdrawn(address indexed payee, uint256 amount);
function queuePayment(address payee) external payable {
require(payee != address(0), "invalid payee");
require(msg.value > 0, "no value");
_payments[payee] += msg.value;
emit PaymentQueued(payee, msg.value);
}
function paymentsOf(address payee) external view returns (uint256) {
return _payments[payee];
}
function withdraw() external {
uint256 amount = _payments[msg.sender];
require(amount > 0, "nothing to withdraw");
_payments[msg.sender] = 0;
(bool ok, ) = payable(msg.sender).call{value: amount}("");
require(ok, "withdraw failed");
emit PaymentWithdrawn(msg.sender, amount);
}
}What this example gets right
queuePaymentonly updates accounting.withdrawzeroes out the balance before calling out.- The contract uses
call, which is the recommended low-level ETH transfer mechanism in modern Solidity. - Events make it easy to track queued and withdrawn payments off-chain.
Why not use transfer?
transfer forwards a fixed 2300 gas stipend, which is often too restrictive for modern recipient contracts. It can break unexpectedly as gas costs evolve. Using call is more flexible, but it also means you must handle success explicitly and protect against reentrancy by following the checks-effects-interactions pattern.
Handling token payouts
Pull-based flows are equally useful for ERC-20 distributions. Instead of sending tokens immediately, you can record claimable balances and let users withdraw them.
A common pattern is to keep a token balance per recipient and transfer on demand.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
}
contract TokenRewardVault {
IERC20 public immutable rewardToken;
mapping(address => uint256) private _rewards;
event RewardAccrued(address indexed account, uint256 amount);
event RewardClaimed(address indexed account, uint256 amount);
constructor(IERC20 token) {
rewardToken = token;
}
function accrueReward(address account, uint256 amount) external {
require(account != address(0), "invalid account");
require(amount > 0, "zero amount");
_rewards[account] += amount;
emit RewardAccrued(account, amount);
}
function claim() external {
uint256 amount = _rewards[msg.sender];
require(amount > 0, "nothing to claim");
_rewards[msg.sender] = 0;
bool ok = rewardToken.transfer(msg.sender, amount);
require(ok, "token transfer failed");
emit RewardClaimed(msg.sender, amount);
}
}Token-specific considerations
ERC-20 implementations are not perfectly uniform. Some return bool, some revert on failure, and some are non-standard. In production systems, it is often safer to use a well-tested token handling library that supports these variations.
Also consider whether the token itself may be fee-on-transfer or rebasing. If so, the amount recorded in your contract may not match the amount the recipient ultimately receives. For reward systems, that mismatch can be unacceptable unless explicitly accounted for.
Preventing reentrancy in withdrawal flows
Any function that sends ETH or calls an external token contract can be a reentrancy target. Pull-based design reduces the blast radius, but it does not eliminate the risk.
Recommended defenses
- Zero out balances before external calls.
- Keep withdrawal functions small.
- Avoid calling arbitrary recipient code during accounting updates.
- Use a reentrancy guard if the contract has multiple state-changing entry points that interact with withdrawals.
A simple mental model helps: the contract should be in a safe state before control leaves it.
Example of the safe sequence
- Read the owed amount.
- Set the owed amount to zero.
- Transfer funds.
- Emit the event.
That order ensures that if the recipient reenters, the balance is already cleared.
Designing for partial failures
One advantage of pull payments is that one recipient’s failure does not affect others. This is especially important for batch distributions.
Common anti-pattern
A loop that sends funds to every recipient in one transaction can fail entirely if one transfer reverts. That means a single problematic recipient blocks the whole batch.
Better approach
Record each recipient’s entitlement individually and let each one withdraw independently. If you still need batch processing for operational reasons, use it only to queue balances, not to transfer value.
This pattern is common in:
- Crowdsale refunds
- DAO distributions
- Affiliate commissions
- Marketplace settlement systems
Managing gas and storage growth
Pull-based systems trade synchronous transfer complexity for state growth. Every unpaid balance occupies storage until it is withdrawn.
Practical guidance
- Keep the entitlement mapping compact.
- Avoid storing unnecessary metadata alongside balances.
- Encourage withdrawals through clear UI and event indexing.
- Consider expiration or sweeping rules only if your business model supports them and legal/compliance requirements are understood.
Storage lifecycle table
| Concern | Recommendation | Reason |
|---|---|---|
| Many small balances | Use a single mapping(address => uint256) | Minimizes storage overhead |
| Long-lived claims | Expose paymentsOf or claimable views | Improves UX and transparency |
| Forgotten balances | Emit events and provide a dashboard | Helps users discover funds |
| Batch distributions | Queue balances, do not transfer in loops | Avoids partial failure |
Improving user experience
A pull-payment system is technically safer, but users may not appreciate having to make a second transaction unless the interface makes it obvious.
Good UX practices
- Show claimable balances prominently in the frontend.
- Provide a one-click “Withdraw” or “Claim” action.
- Display estimated gas cost before the user submits the transaction.
- Emit events for queued and withdrawn amounts so indexers can surface balances.
- If possible, allow third parties to trigger withdrawals to the recipient, while ensuring the recipient is still the beneficiary.
That last point can be useful for gas sponsorship or automation. If you support it, make sure the funds always go to the intended recipient, not the caller.
Common mistakes to avoid
1. Mixing accounting and payout logic
If a function both updates balances and transfers funds, you lose the main benefit of pull payments.
2. Forgetting to clear balances
If you transfer before setting the balance to zero, a reentrant call may withdraw again.
3. Assuming every token behaves like a standard ERC-20
Token transfer semantics vary. Test against the actual token implementation you plan to support.
4. Hiding claimable funds
If users cannot easily discover what they are owed, balances may remain stuck indefinitely.
5. Using loops for direct payouts
Loops are fine for queueing entitlements, but not for sending value to many recipients in one transaction.
A practical checklist for production contracts
Before shipping a pull-based payout system, verify the following:
- Entitlements are stored separately from transfer logic.
- Withdrawal functions clear state before external calls.
- ETH transfers use
call, nottransfer. - Token transfers are checked for success.
- Events are emitted for both accrual and withdrawal.
- The contract exposes a read function for claimable amounts.
- Reentrancy is considered across all state-changing paths.
- Recipient edge cases are tested, including reverting contracts and non-standard tokens.
Conclusion
Pull-based payment flows are one of the most reliable patterns for Solidity contracts that need to distribute value. They reduce coupling between business logic and external transfers, make failures easier to isolate, and scale better than direct payout loops. The core idea is simple: record what is owed, then let recipients claim it later.
When implemented carefully, this pattern improves both security and operational resilience. It is especially valuable for refunds, rewards, and any system where many recipients may need funds over time.
