← Back to Blog

Withdrawal Pattern Security: Push vs. Pull Payments and Forced ETH Acceptance

2026-04-18 withdrawal pattern pull payment push payment solidity security dos forced ether 2026

An English auction contract keeps track of the highest bidder and automatically refunds the previous leader when someone outbids them. It looks clean. It works perfectly in tests. Then a winning bidder deploys a contract with a receive() function that reverts on every incoming ETH transfer. The auction locks up. No new bids are accepted. The griefing attacker secures the item at their chosen price — or simply holds the contract hostage until the protocol owner pays them to leave.

This is the push payment DoS attack. It is one of the most common payment-logic mistakes in Solidity, and the withdrawal pattern exists specifically to prevent it.


Push vs. Pull: The Core Distinction

Push payment: the contract sends ETH to a recipient in the same transaction as the state change. Bids, refunds, reward distributions — ETH flows out immediately.

Pull payment: the contract records a claimable balance in a mapping. The recipient calls a separate withdraw() function to pull their own funds when they are ready.

The push model feels natural. The pull model requires more code. The pull model is correct.

Every vulnerability in this post stems from a push payment design. The fix is the same in every case: switch to pull.


Vulnerability 1: Push Payment DoS

When a contract pushes ETH to an address in the middle of a loop or a critical state-transition function, a malicious recipient can block the entire flow.

// VULNERABLE: pushes ETH to previous bidder on every new bid
contract VulnerableAuction {
    address public highestBidder;
    uint256 public highestBid;

    function bid() external payable {
        require(msg.value > highestBid, "Bid too low");

        // Refund the previous leader immediately
        if (highestBidder != address(0)) {
            // If highestBidder is a contract that reverts on ETH,
            // this line reverts — and so does the entire bid() call
            payable(highestBidder).transfer(highestBid);
        }

        highestBidder = msg.sender;
        highestBid = msg.value;
    }
}

The attacker deploys a griefing contract and places a bid:

contract GriefingBidder {
    VulnerableAuction public auction;

    constructor(address _auction) {
        auction = VulnerableAuction(_auction);
    }

    function attack() external payable {
        auction.bid{value: msg.value}();
    }

    // Reverts on every incoming ETH transfer
    receive() external payable {
        revert("I will not accept a refund");
    }
}

Once GriefingBidder holds the highest bid, every subsequent bid() call reverts when it tries to refund the attacker. The auction is permanently frozen. No legitimate bidder can replace the attacker.


Vulnerability 2: Reentrancy via Push with call()

The deprecation of transfer() in favor of call{value: amount}("") is correct guidance — gas schedule changes have made transfer()'s 2300 gas stipend unreliable for forwarding to smart contract wallets. But call() forwards all remaining gas by default. Combined with a push pattern that updates state after the send, this opens a reentrancy window.

// VULNERABLE: state updated after the external call
contract VulnerableBank {
    mapping(address => uint256) public balances;

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "Nothing to withdraw");

        // Sends ETH before updating state
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        // Too late — attacker has already re-entered
        balances[msg.sender] = 0;
    }
}

An attacker's contract re-enters withdraw() inside the receive() callback. Because balances[msg.sender] is still non-zero at that point, each reentrant call passes the require check and drains another amount from the contract.

transfer() prevented this class of attack by limiting the gas forwarded to the recipient. But relying on a gas stipend for security is fragile. The correct fix is the Checks-Effects-Interactions (CEI) pattern — update state before making the external call, so there is nothing to exploit even if reentrancy occurs.

// SAFE: CEI pattern — effects before interactions
function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "Nothing to withdraw");

    balances[msg.sender] = 0;  // Effect: state updated first

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

Vulnerability 3: Forced ETH via selfdestruct

A contract that has no receive() or fallback() function cannot receive ETH through a normal call. But a contract can always receive ETH via selfdestruct. When a contract self-destructs, it forwards its entire balance to a target address — and that transfer bypasses all receive() and fallback() logic unconditionally.

// VULNERABLE: assumes address(this).balance only grows from msg.value
contract VulnerableStakingPool {
    uint256 public totalStaked;

    function stake() external payable {
        require(msg.value == 1 ether, "Must stake exactly 1 ETH");
        // This check breaks after a selfdestruct-based ETH injection
        require(
            address(this).balance == totalStaked + msg.value,
            "Balance mismatch"
        );
        totalStaked += msg.value;
    }
}

An attacker sends 0.001 ETH via selfdestruct:

contract EthForcer {
    constructor(address target) payable {
        selfdestruct(payable(target));
    }
}

Now address(target).balance is permanently 0.001 ETH above totalStaked. The require inside stake() will never pass again. The function is bricked.

Fix: never use address(this).balance in conditional logic. Track legitimate deposits with an internal state variable.

// SAFE: internal accounting, ignores forced ETH
contract SafeStakingPool {
    uint256 public totalStaked;
    mapping(address => uint256) public stakes;

    function stake() external payable {
        require(msg.value == 1 ether, "Must stake exactly 1 ETH");
        totalStaked += msg.value;
        stakes[msg.sender] += msg.value;
    }
}

Any ETH injected via selfdestruct is ignored because the logic never reads address(this).balance.


Vulnerability 4: Forced ETH via Pre-Deployment Funding

CREATE2 lets you compute a contract's deployment address before it exists. An attacker can send ETH to that address before the contract is deployed. When the contract is later deployed, address(this).balance is already non-zero — even though no legitimate deposit has ever occurred.

// VULNERABLE: deploys and immediately checks balance
contract TokenSale {
    bool public initialized;

    function initialize() external onlyOwner {
        require(!initialized, "Already initialized");
        // Assumes 0 balance on fresh deployment
        require(address(this).balance == 0, "Unexpected pre-funding");
        initialized = true;
    }
}

If the attacker pre-funded the CREATE2 address with even 1 wei, initialize() reverts permanently. The contract can never be initialized.

Fix: remove balance checks from initialization logic. If you need to detect legitimate deposits, track them through event-driven accounting, not raw balance checks.


Vulnerability 5: Incorrect Invariants on Contract Balance

Similar to the pre-deployment attack, contracts sometimes encode invariants of the form:

require(address(this).balance >= totalDeposits, "Accounting error");

This looks like a sanity check but it can fail unexpectedly. ETH can arrive from three sources that bypass all contract functions:

  1. selfdestruct from another contract
  2. Mining coinbase rewards (if the contract address is set as a miner's fee recipient)
  3. Pre-deployment funding via CREATE2

Any of these adds to address(this).balance without touching totalDeposits. The invariant breaks, and the contract stops working — even though no funds were stolen and no legitimate error occurred.

Replace balance-based invariants with internal accounting:

// SAFE: invariant uses internal state only
uint256 public totalDeposits;

function deposit() external payable {
    totalDeposits += msg.value;
}

function checkInvariant() internal view {
    // Only checks internally tracked values
    assert(totalDeposits == expectedAmount);
}

If you genuinely need to track forced ETH, use address(this).balance - totalDeposits to calculate the "excess" separately, and treat it as out-of-band funds that the protocol ignores or sweeps.


The Withdrawal Pattern: Vulnerable Push vs. Safe Pull

Here is the auction example rewritten correctly using the withdrawal pattern:

Vulnerable push pattern:

contract PushAuction {
    address public highestBidder;
    uint256 public highestBid;

    function bid() external payable {
        require(msg.value > highestBid, "Bid too low");
        if (highestBidder != address(0)) {
            // Pushes ETH — vulnerable to DoS and reentrancy
            payable(highestBidder).transfer(highestBid);
        }
        highestBidder = msg.sender;
        highestBid = msg.value;
    }
}

Safe pull pattern:

contract PullAuction {
    address public highestBidder;
    uint256 public highestBid;

    // Pending refunds stored per address
    mapping(address => uint256) public pendingReturns;

    function bid() external payable {
        require(msg.value > highestBid, "Bid too low");

        if (highestBidder != address(0)) {
            // Record refund — do NOT send immediately
            pendingReturns[highestBidder] += highestBid;
        }

        highestBidder = msg.sender;
        highestBid = msg.value;
    }

    function withdraw() external {
        uint256 amount = pendingReturns[msg.sender];
        require(amount > 0, "Nothing to withdraw");

        // CEI: clear state before sending
        pendingReturns[msg.sender] = 0;

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

The pull pattern eliminates the push DoS vector entirely: bid() never sends ETH, so a griefing receive() has no effect. The CEI pattern inside withdraw() prevents reentrancy. Each user is responsible for claiming their own refund.


When to Use transfer() vs. call()

transfer() forwards exactly 2300 gas and reverts if the recipient fails. This was considered safe for reentrancy prevention when EIP-1884 introduced higher SLOAD costs, the 2300 gas stipend became insufficient for some legitimate contract wallets. A contract wallet that stores state on receive() now fails silently when called via transfer().

The current recommendation:

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SafeWithdrawal is ReentrancyGuard {
    mapping(address => uint256) public pendingReturns;

    function withdraw() external nonReentrant {
        uint256 amount = pendingReturns[msg.sender];
        require(amount > 0, "Nothing to withdraw");

        pendingReturns[msg.sender] = 0;  // CEI: effect first

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

CEI plus nonReentrant is belt-and-suspenders. Either alone is generally sufficient, but both together are zero cost and eliminate the entire reentrancy surface.


What ContractScan Detects

Vulnerability Pattern Detected
Push ETH in loop transfer()/call() inside a for/while loop over addresses
Reentrancy via push External call before state update (CEI violation)
address(this).balance in conditional logic Balance used in require, if, or assert without internal accounting
selfdestruct-based forced ETH Contracts with balance invariants exposed to forced sends
Pre-deployment funding risk CREATE2 deployments with balance-dependent initialization
transfer() usage Flagged for gas-assumption fragility; suggests migration to call()
Missing nonReentrant guard Withdrawal functions without reentrancy protection

Quick Checklist

Before deploying any contract that holds or distributes ETH:


Summary

Push payments couple ETH delivery to state transitions — and that coupling is the source of every vulnerability in this post. A reverting recipient kills the push. An eager attacker exploits the window before state updates. A selfdestruct call injects ETH that breaks balance-based invariants.

The withdrawal pattern decouples delivery from state: record the owed amount, let the recipient claim it separately. That structural separation eliminates the push DoS, removes the reentrancy opportunity from distribution logic, and makes balance-based invariants irrelevant because no legitimate code depends on them.

Check your payment logic at https://contract-scanner.raccoonworld.xyz — ContractScan detects CEI violations, push patterns in loops, and address(this).balance dependencies across your entire contract.


Related: Denial of Service Attacks in Solidity: Gas Griefing and Unbounded Loops — broader DoS patterns beyond payment logic.

Related: Reentrancy: From The DAO to Euler Finance — how reentrancy exploits evolved and the full defense stack.


Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →