Skip to main content
Sigvex

Remediating DoS with Failed Call

How to prevent denial of service from unchecked external calls in loops using error handling, pull patterns, and try/catch.

Remediating DoS with Failed Call

Overview

Related Detector: DoS with Failed Call

DoS with failed call occurs when a loop makes external calls without handling individual failures. A single failed call reverts the entire transaction, blocking all recipients. The primary fix is to catch errors per iteration and continue processing, or to use a pull-over-push withdrawal pattern.

Handle Failures Per Iteration

// BEFORE: One failure blocks all
function distribute(address[] calldata recipients, uint256[] calldata amounts) external {
    for (uint256 i = 0; i < recipients.length; i++) {
        payable(recipients[i]).transfer(amounts[i]);  // Reverts on failure
    }
}

// AFTER: Handle each failure independently
function distribute(address[] calldata recipients, uint256[] calldata amounts) external {
    for (uint256 i = 0; i < recipients.length; i++) {
        (bool success, ) = payable(recipients[i]).call{value: amounts[i]}("");
        if (!success) {
            pendingPayments[recipients[i]] += amounts[i];
            emit PaymentFailed(recipients[i], amounts[i]);
        }
    }
}

Alternative Mitigations

Pull-Over-Push Pattern

Let recipients withdraw their own funds instead of pushing payments:

mapping(address => uint256) public claimable;

function recordPayments(address[] calldata recipients, uint256[] calldata amounts)
    external onlyOwner
{
    for (uint256 i = 0; i < recipients.length; i++) {
        claimable[recipients[i]] += amounts[i];
        emit PaymentRecorded(recipients[i], amounts[i]);
    }
}

function claim() external {
    uint256 amount = claimable[msg.sender];
    require(amount > 0, "Nothing to claim");
    claimable[msg.sender] = 0;
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "Claim failed");
}

Try/Catch for Token Transfers

function distributeTokens(address[] calldata recipients, uint256[] calldata amounts) external {
    for (uint256 i = 0; i < recipients.length; i++) {
        try token.transfer(recipients[i], amounts[i]) returns (bool success) {
            if (!success) {
                pendingTokens[recipients[i]] += amounts[i];
            }
        } catch {
            pendingTokens[recipients[i]] += amounts[i];
            emit TransferFailed(recipients[i], amounts[i]);
        }
    }
}

Common Mistakes

Mistake: Using transfer() or send() Instead of call()

// WRONG: transfer() forwards only 2300 gas and reverts on failure
payable(recipient).transfer(amount);

// WRONG: send() forwards 2300 gas and returns false on failure
// but is often used without checking the return value
payable(recipient).send(amount);

Use call{value: amount}("") and check the return value. transfer() and send() are deprecated due to the 2300 gas limit.

Mistake: Catching the Error but Not Accounting for It

(bool success, ) = recipient.call{value: amount}("");
if (!success) {
    // Error is silently swallowed -- funds are lost
    continue;
}

Failed payments must be tracked (in a mapping or event) so recipients can claim them later.

Mistake: Only Protecting One Entry Point

function batchSend(address[] calldata to, uint256[] calldata amounts) external {
    // Protected with try/catch
}

function emergencyDistribute(address[] calldata to) external onlyOwner {
    // Same loop pattern WITHOUT error handling
    for (uint256 i = 0; i < to.length; i++) {
        payable(to[i]).transfer(emergencyAmounts[i]);  // Vulnerable
    }
}

Apply consistent error handling across all batch operation entry points.

References