Zokyo Security Research
Push vs Pull Pattern in EVM
Understanding push and pull design patterns in Solidity, their impact on gas consumption, and how to avoid denial of service in your smart contracts.
Contract PUSH PULL

Blockchain developers and auditors must thoroughly understand the architectural implications of smart contracts. This includes an in-depth analysis of gas consumption, operational efficiency, and the correct functionality in alignment with the Ethereum Virtual Machine (EVM) capabilities and constraints.

Understanding transaction gas consumption and the phenomenon of transactions running out of gas is crucial, as this topic is directly correlated with the architecture of smart contracts and the design of their functions. This article explores and analyzes two distinct Solidity design patterns - the push and pull patterns - and explains why one is overwhelmingly preferred over the other.

Understanding Gas in the EVM

Every operation executed within the EVM consumes a certain amount of gas. Gas is the mechanism that prevents infinite loops and ensures that the computational resources of the network are compensated. Users pay for gas when they submit transactions, and both individual transactions and blocks have a maximum gas limit.

When the gas consumed during a transaction exceeds the block gas limit, the transaction will fail. This is not merely an inconvenience - in the context of smart contracts, it can lead to a complete denial of service for the protocol. An improper architectural design could result in critical transactions consistently failing because they consume more gas than the limit allows.

This is precisely where the choice between push and pull patterns becomes critical.

The Push Pattern

The push architecture for a smart contract function involves executing an action for a user without requiring direct interaction from that user with the smart contract. This can be illustrated using the example of airdrops.

Push Architecture Example: Airdrop

Consider the following contract. The claimPush function is utilized to transfer the airdropAmount to each user eligible for the airdrop. If there are 10,000 eligible users, this function will execute 10,000 transfers of the airdropAmount:

Solidity AirdropPush.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract AirdropPush {
    using SafeERC20 for IERC20;

    IERC20 public token;
    address[] public eligibleUsers;
    uint256 public airdropAmount;

    constructor(address _token, uint256 _airdropAmount) {
        token = IERC20(_token);
        airdropAmount = _airdropAmount;
    }

    function addEligibleUser(address user) external {
        eligibleUsers.push(user);
    }

    function claimPush() external {
        for (uint256 i = 0; i < eligibleUsers.length; i++) {
            token.safeTransfer(eligibleUsers[i], airdropAmount);
        }
    }
}

This exemplifies push architecture: none of the 10,000 users need to manually interact with the contract to receive the airdrop. Instead, a single caller can initiate the token transfers on their behalf.

Problems with Push Architecture

While the push approach seems convenient, it introduces several serious issues:

  • Unbounded gas consumption: The claimPush function involves a growing array that cannot be reduced in size. As more users are added, the loop iterates over an ever-larger array. Eventually, the gas required to execute the entire loop will exceed the block gas limit, making the transaction impossible to execute.
  • Denial of service: Once the array grows large enough, the claimPush function becomes permanently unusable. No user receives their airdrop, and the tokens are effectively locked in the contract. This is a denial of service scenario.
  • Single point of failure: Because all transfers happen within a single transaction, if even one transfer fails (for example, to a contract that reverts in its receive() function), the entire transaction reverts and no users receive their tokens.
Warning

Push architecture should be avoided for operations that iterate over unbounded arrays. A growing array that cannot be reduced in size is likely to expand to the point where it consumes more gas than the block gas limit allows, resulting in a permanent denial of service.

Push Pattern Risk with Ether Transfers

The problems with push architecture extend beyond token airdrops. When push architecture is used for transferring ether across an array of addresses, additional attack vectors emerge.

When ether is transferred to a smart contract, the receive() function is automatically triggered. Consider a scenario where even just two addresses are in the array. If one of those addresses belongs to a contract that implements a revert() inside its receive() function, the entire transaction will revert. This means no users receive their ether.

Solidity EtherPush.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract EtherPush {
    address[] public recipients;

    function addRecipient(address recipient) external {
        recipients.push(recipient);
    }

    function distributeEther() external payable {
        uint256 amount = msg.value / recipients.length;
        for (uint256 i = 0; i < recipients.length; i++) {
            // If any recipient reverts, entire distribution fails
            (bool success, ) = recipients[i].call{value: amount}("");
            require(success, "Transfer failed");
        }
    }
}

// Malicious contract that blocks the distribution
contract MaliciousReceiver {
    receive() external payable {
        revert("Blocking all transfers");
    }
}

Intentionally triggering a revert inside a receive() function is not the only concern. Implementing complex logic - such as a gas-consuming loop - can inadvertently lead to a revert by exhausting the forwarded gas. A single malicious or poorly implemented contract among the recipients can hold all other users hostage.

Furthermore, the user who invokes the distribution function pays all the gas for sending to every recipient. This creates a perverse incentive where no rational user would want to call the function first, since they bear the full gas cost while others benefit for free.

The Pull Pattern

Instead of utilizing a push architecture, which can result in transactions running out of gas and lead to denial of service, an alternative approach should be employed: the pull pattern.

In this new architecture, each user is responsible for executing their own claim transaction without depending on a large array. The key change is replacing the array with a mapping that indicates whether a user is whitelisted or not:

Solidity AirdropPull.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract AirdropPull {
    using SafeERC20 for IERC20;

    IERC20 public token;
    uint256 public airdropAmount;

    mapping(address => bool) public whitelisted;
    mapping(address => bool) public claimed;

    constructor(address _token, uint256 _airdropAmount) {
        token = IERC20(_token);
        airdropAmount = _airdropAmount;
    }

    function addToWhitelist(address user) external {
        whitelisted[user] = true;
    }

    function claimPull() external {
        require(whitelisted[msg.sender], "Not whitelisted");
        require(!claimed[msg.sender], "Already claimed");

        claimed[msg.sender] = true;
        token.safeTransfer(msg.sender, airdropAmount);
    }
}

With this architecture, thousands of users can be whitelisted, and thousands of airdrop transactions can be distributed. Each user will be responsible for executing their own airdrop claim transaction. The claimPull function verifies if the caller is whitelisted and, if so, transfers the tokens.

Advantages of Pull Architecture

  • No unbounded loops: The function performs a constant number of operations regardless of how many users are eligible. Gas cost is predictable and will never exceed the block gas limit.
  • Isolated failures: If a user's address is a contract that reverts in its receive() function, it only affects that user's claim. The rest of the users can still receive their tokens or ether, preventing any denial of service.
  • Fair gas distribution: Each user pays the gas cost for their own claim transaction, rather than one user bearing the entire cost for all recipients.
  • Scalability: A mapping-based approach can handle millions of eligible users without any increase in per-transaction gas cost.

Pull Pattern for Ether Transfers

The pull pattern also addresses the previously mentioned scenario involving ether transfers. If a user implements a revert() inside a receive() function, the rest of the users can still receive their ether, preventing any denial of service:

Solidity EtherPull.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract EtherPull {
    mapping(address => uint256) public credits;

    function allowForPull(address receiver, uint256 amount) private {
        credits[receiver] += amount;
    }

    function withdrawCredits() external {
        uint256 amount = credits[msg.sender];
        require(amount != 0, "No credits");
        require(address(this).balance >= amount, "Insufficient balance");

        credits[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

In this design, the contract records how much ether each user is owed via a credits mapping. Users then call withdrawCredits individually to claim their ether. If one user's withdrawal fails, it has no effect on any other user.

Push vs Pull: Side-by-Side Comparison

The following table summarizes the key differences between the two patterns:

  • Gas scaling: Push grows linearly with the number of recipients. Pull remains constant per claim.
  • Failure isolation: Push reverts the entire batch on a single failure. Pull isolates failures to individual users.
  • DoS resistance: Push is vulnerable to denial of service via unbounded loops or malicious receivers. Pull is resilient.
  • Gas payer: In push, one caller pays for all transfers. In pull, each user pays for their own claim.
  • User experience: Push requires no action from recipients. Pull requires each user to submit a transaction.
  • Scalability: Push hits the block gas limit at large recipient counts. Pull scales to any number of users.

Industry Recommendation

The "Pull over Push" pattern is advocated by reputable blockchain security organizations such as OpenZeppelin and ConsenSys. Their security guidelines consistently recommend that contracts should favor pull over push for payments and token distributions.

OpenZeppelin provides a PullPayment base contract as part of their library, which implements the pull pattern for ether transfers out of the box. This is a well-tested, audited implementation that can serve as a starting point for any contract that needs to distribute funds.

Note

Interacting with a contract that uses pull instead of push payments requires users to send one additional transaction - the one requesting the withdrawal. This is a minor tradeoff in user experience, but it eliminates the risk of denial of service and ensures that the contract remains functional regardless of the number of users or the behavior of individual recipients.

When Is Push Acceptable?

Push architecture is not always wrong. There are narrow scenarios where it can be safely used:

  • Fixed, small recipient lists: If the number of recipients is known at compile time and is small enough that gas consumption will never approach the block limit, push can be acceptable.
  • Batched push with pagination: Some implementations split the push operation into batches, processing a fixed number of recipients per transaction. This mitigates the gas limit issue but adds complexity and does not solve the malicious receiver problem for ether transfers.
  • Trusted recipients only: If all recipients are known, trusted contracts (such as internal protocol components), the risk of a malicious receive() function is eliminated.

In all other cases - particularly when dealing with user-facing contracts, airdrops, or any distribution to external addresses - the pull pattern should be the default choice.

Conclusion

Smart contract architecture choices have direct security implications. The push pattern, while intuitive, creates unbounded gas consumption risks and single-point-of-failure vulnerabilities that can permanently lock funds or disable protocol functionality. The pull pattern resolves these issues by shifting the responsibility of claiming to individual users, isolating failures, and ensuring predictable gas costs.

When designing token distributions, airdrop mechanisms, or any form of payment distribution in Solidity, always default to the pull pattern. The minor tradeoff in user experience - requiring users to submit their own claim transaction - is a small price to pay for the security guarantees it provides.

At Zokyo, we consistently identify push-over-pull vulnerabilities during our smart contract audits. Understanding these design patterns is fundamental to building secure, gas-efficient protocols on the EVM. If you are building a protocol and want a comprehensive security review, reach out to our team.