Implementing the Pull Payment Pattern in Solidity to Prevent DoS Attacks
Learn how to implement the Pull Payment pattern in Solidity to prevent Denial of Service (DoS) attacks and ensure secure fund distribution by shifting transfer risks to the recipient.
28 Oct 2025, 02:15 UTC

The Problem: The 'Push' Payment Vulnerability
In Solidity, a common mistake is using a loop to distribute funds to a list of addresses. This is known as a 'push' payment. The critical failure occurs when one recipient is a smart contract that intentionally reverts or consumes all available gas during the transfer. Because the entire transaction is atomic, a single failing recipient causes the entire distribution loop to fail, effectively locking funds for all other users.
The takeaway: Never assume a recipient can or will accept a transfer. To ensure reliability, shift the responsibility of receiving funds from the sender to the recipient.
The Smallest Suitable Design
The Pull Payment pattern replaces the direct transfer loop with a ledger system. Instead of sending Ether, the contract records the amount owed to each address in a mapping. Users then call a dedicated function to 'pull' their own funds.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract PullPayment {
// Ledger tracking balances owed to each address
mapping(address => uint256) public balances;
// Function to credit a user (called by the contract logic)
function credit(address account) external payable {
balances[account] += msg.value;
}
// Function for users to withdraw their own funds
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No funds to withdraw");
// Checks-Effects-Interactions: Zero the balance BEFORE the transfer
balances[msg.sender] = 0;
// Use call() instead of transfer() to avoid fixed gas limits
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Trust and Data Boundaries
The primary shift in this architecture is the execution risk boundary. In a push model, the contract administrator (or the contract's internal logic) bears the gas cost and the risk of failure. In a pull model, the risk is isolated to the individual recipient. If a recipient's contract is broken or malicious, only their specific withdraw() call fails; it has no impact on the contract's state or other users' funds.
Gas Distribution
While pull payments increase the total number of transactions on the network, they prevent the "Gas Limit DoS." In a push loop, as the number of recipients grows, the transaction may eventually exceed the block gas limit, making it impossible to distribute funds regardless of the recipient's behavior.
Operational Checks and Security
When implementing pull payments, the most critical vulnerability is the Reentrancy Attack. Because call() hands over control to the recipient contract, a malicious user could call withdraw() again before the first call finishes.
To mitigate this, apply the Checks-Effects-Interactions pattern:
- Checks: Verify the user has a balance (
require(amount > 0)). - Effects: Update the state to reflect the withdrawal (
balances[msg.sender] = 0). - Interactions: Perform the external Ether transfer (
msg.sender.call).
Failure Modes and Design Pivots
Comparison: Push vs. Pull
| Feature | Push Payment (Loop) | Pull Payment (Mapping) |
|---|---|---|
| Failure Impact | Global (All recipients blocked) | Local (Only failing user blocked) |
| Gas Cost | Paid by Sender | Paid by Recipient |
| Reliability | Low (Dependent on recipients) | High (Independent) |
When to Pivot
The pull pattern is not universal. You should reconsider this design if:
- Immediate Delivery is Required: If the contract is a time-locked escrow that must release funds at a specific second, a pull pattern requires the user to be active. If the user never calls
withdraw(), the funds sit idle. - Automated Distribution: If the business logic requires funds to move without user intervention, you may need an external "Keeper" bot to trigger withdrawals, though this re-introduces some push-style risks.
Verification and Testing
To verify the implementation, deploy the contract to a testnet and perform the following checks:
- Malicious Recipient Test: Create a contract that reverts in its
receive()function. Credit this contract with funds. Verify that other users can still withdraw their funds while the malicious contract'swithdraw()call fails. - Reentrancy Check: Attempt to call
withdraw()from a contract that callswithdraw()again inside itsreceive()function. The transaction should fail because the balance was zeroed before the first transfer. - State Check: Ensure that the
balancesmapping for a user is exactly 0 immediately after a successfulwithdraw()call.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.