What DoS looks like in Solidity

A DoS vulnerability occurs when an attacker, or even an ordinary user with an unusual contract wallet, can prevent others from completing a function. In Solidity, this often happens when a contract:

  • iterates over a list of recipients and transfers to each one,
  • performs external calls inside a loop,
  • requires every recipient to accept funds immediately,
  • or depends on a third-party contract that may revert unexpectedly.

The problem is not limited to malicious behavior. A recipient contract may deliberately revert in its receive() or fallback() function, consume too much gas, or use logic that fails under certain conditions. If your contract processes recipients in a single transaction, one bad recipient can block the entire batch.

Common failure modes

PatternRiskExample impact
Push payments in a loopOne revert cancels the whole transactionA dividend distribution never completes
External calls before state updatesReentrancy and partial failureFunds or accounting become inconsistent
Unbounded recipient arraysGas exhaustionAdmin cannot finalize payouts
Hard dependency on third-party hooksUnexpected revertDeposits or withdrawals become unavailable

Why pull payments are safer

With pull payments, the contract does not send value as part of the main business operation. Instead, it records a credit in storage. The recipient later calls a separate withdrawal function to claim it.

This design improves resilience because:

  • one recipient cannot block others,
  • the main function stays simple and predictable,
  • failures are isolated to the claimant,
  • and the contract can use standard checks-effects-interactions ordering more easily.

Pull payments are especially useful for:

  • revenue sharing,
  • royalty distribution,
  • escrow release,
  • refunds,
  • reward claims,
  • and any system where many accounts are owed funds over time.

A vulnerable push-payment example

The following contract tries to distribute ETH to all payees in one transaction:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract BadDistributor {
    address[] public payees;
    mapping(address => uint256) public shares;

    constructor(address[] memory _payees, uint256[] memory _shares) payable {
        require(_payees.length == _shares.length, "length mismatch");
        for (uint256 i = 0; i < _payees.length; i++) {
            payees.push(_payees[i]);
            shares[_payees[i]] = _shares[i];
        }
    }

    function distribute() external {
        uint256 balance = address(this).balance;

        for (uint256 i = 0; i < payees.length; i++) {
            address payable recipient = payable(payees[i]);
            uint256 amount = (balance * shares[recipient]) / 100;

            (bool ok, ) = recipient.call{value: amount}("");
            require(ok, "payment failed");
        }
    }
}

What goes wrong

If any recipient is a contract that reverts on receiving ETH, the entire distribute() call reverts. That means:

  • no one gets paid,
  • the function can be retried, but it will fail again,
  • and the contract becomes effectively stuck until the problematic recipient is removed or replaced.

Even worse, if the list is large, the loop may run out of gas before finishing.


Refactoring to a pull-payment design

A safer design records each recipient’s entitlement and lets them withdraw individually.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract PullDistributor {
    mapping(address => uint256) public pending;
    address public owner;

    event PaymentAllocated(address indexed recipient, uint256 amount);
    event PaymentWithdrawn(address indexed recipient, uint256 amount);

    modifier onlyOwner() {
        require(msg.sender == owner, "not owner");
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function allocate(address recipient) external payable onlyOwner {
        require(recipient != address(0), "zero recipient");
        require(msg.value > 0, "no value");

        pending[recipient] += msg.value;
        emit PaymentAllocated(recipient, msg.value);
    }

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

        pending[msg.sender] = 0;

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

        emit PaymentWithdrawn(msg.sender, amount);
    }
}

Why this is better

  • allocate() only updates accounting.
  • withdraw() affects only one recipient.
  • A reverting recipient cannot block other users.
  • The contract zeroes out the balance before the external call, reducing the risk of reentrancy.

This pattern does not eliminate all risk, but it converts a global failure into a local one.


Design rules for DoS-resistant contracts

1. Avoid pushing funds in critical paths

If a function must succeed for the protocol to remain usable, it should not depend on multiple external transfers. Examples include:

  • settlement functions,
  • finalization steps,
  • reward snapshots,
  • and administrative cleanup.

Instead, record credits and let users claim them later.

2. Keep loops bounded and optional

Loops over user-controlled or unbounded arrays are a common source of DoS. If iteration is necessary:

  • cap the number of items,
  • process in chunks,
  • or let callers supply a bounded range.

For example, a batch claim function should process only a limited number of recipients per call.

3. Do not require third-party contracts to behave perfectly

Any external call can fail. This includes:

  • ETH transfers,
  • ERC-20 token transfers,
  • ERC-721 safe transfers,
  • and callback-based integrations.

Treat external calls as unreliable and isolate them from core state transitions.

4. Update state before external interaction

This is a broader safety rule, but it also helps with DoS. If a withdrawal fails after state has been updated, the user can often retry safely. If state changes happen after the call, a failure may leave the contract in an inconsistent or blocked state.

5. Provide recovery paths

If a withdrawal fails because a recipient contract is incompatible, consider offering:

  • an alternate withdrawal method,
  • a recipient override,
  • or a way to redirect funds to a new address after verification.

This is particularly useful for treasury systems and vesting contracts.


Handling ERC-20 payouts safely

ETH is not the only source of DoS risk. ERC-20 transfers can also fail if the token contract reverts, returns false, or behaves non-standardly.

A robust pattern is to separate accounting from token transfer and let recipients claim their own tokens.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
}

contract TokenRewards {
    IERC20 public immutable token;
    mapping(address => uint256) public claimable;

    constructor(IERC20 _token) {
        token = _token;
    }

    function addReward(address user, uint256 amount) external {
        claimable[user] += amount;
    }

    function claim() external {
        uint256 amount = claimable[msg.sender];
        require(amount > 0, "nothing to claim");

        claimable[msg.sender] = 0;

        require(token.transfer(msg.sender, amount), "token transfer failed");
    }
}

Notes

  • If the token is non-standard, use a well-tested safe transfer library.
  • Do not loop over many recipients and transfer tokens in one transaction.
  • If the reward source is external, consider recording entitlements first and funding claims separately.

Batch processing without global failure

Sometimes batch operations are unavoidable. In that case, design them to fail softly rather than catastrophically.

Good batch design principles

  • Process a limited number of items per call.
  • Track progress in storage.
  • Skip or isolate failing entries when business logic allows it.
  • Emit events for later reconciliation.

A pattern like this is safer than a single all-or-nothing payout loop:

function process(uint256 start, uint256 end) external {
    require(end <= payees.length, "out of range");
    require(end > start, "invalid range");

    for (uint256 i = start; i < end; i++) {
        address recipient = payees[i];
        uint256 amount = pending[recipient];

        if (amount == 0) continue;

        pending[recipient] = 0;

        (bool ok, ) = payable(recipient).call{value: amount}("");
        if (!ok) {
            pending[recipient] = amount;
            emit PaymentFailed(recipient, amount);
        }
    }
}

This approach allows the contract to continue processing other recipients even if one transfer fails. Whether you re-credit failed payments or mark them for manual recovery depends on your application.


Choosing the right pattern

Use caseRecommended approachWhy
One-off user withdrawalsPull paymentSimple and isolated
Revenue sharing among many usersPull payment with claim functionNo global blocking
Small trusted recipient setPush may be acceptable with cautionLower operational complexity
Large or user-controlled recipient listChunked processing or pull paymentsPrevents gas and revert cascades
External token distributionsClaim-based token withdrawalAvoids batch transfer failures

In practice, pull payments are the default safe choice unless you have a strong reason to push funds immediately.


Testing for DoS conditions

Security testing should include adversarial recipient behavior. Build test contracts that:

  • revert on receiving ETH,
  • consume excessive gas,
  • return malformed values,
  • or reenter when called.

Example malicious recipient

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract RevertingReceiver {
    receive() external payable {
        revert("no ETH accepted");
    }
}

Use this in tests to verify that:

  • one failing recipient does not block others,
  • withdrawals can be retried,
  • and state remains consistent after partial failures.

Also test large recipient sets to ensure loops do not exceed gas limits.


Operational best practices

  • Prefer claim-based flows for any recurring payout.
  • Keep recipient lists off critical execution paths.
  • Emit events for allocations, claims, and failures.
  • Document whether failed claims are retryable.
  • Use access control for allocation functions.
  • Review every external call for failure handling.
  • Simulate malicious recipients during audits and tests.

A good rule of thumb: if a function sends value to more than one untrusted address, it is a candidate for DoS hardening.


Conclusion

Denial-of-service bugs in Solidity often arise from a simple mistake: assuming external recipients will always cooperate. The safest way to avoid this class of failure is to separate accounting from transfer. Record what users are owed, then let them withdraw independently.

Pull payments are not just a convenience pattern; they are a resilience pattern. They reduce coupling, limit blast radius, and make smart contracts much harder to freeze through a single revert or gas-heavy recipient.

Learn more with useful resources