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.
Recommended Fix
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.