Understanding the Risks of External Calls

When a Solidity contract calls an external function, control is temporarily transferred to the target contract. This can lead to unexpected behaviors such as reentrancy, denial of service (DoS), and unexpected return values. Because the target contract's logic is not under the caller’s control, developers must validate assumptions and structure code defensively.

A key best practice is to avoid relying on the return values of external calls unless you control the contract being called. Even then, unexpected upgrades or reentrancy can cause issues. To mitigate these risks, use techniques like checks-effects-interactions, pull over push patterns, and gas limits.

Example: Secure Token Transfer Using Pull Instead of Push

Instead of pushing tokens directly to an external address (which may trigger a reentrancy or DoS), the contract should allow users to withdraw funds. This is known as the pull pattern.

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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract SecureWithdrawal {
    mapping(address => uint256) public balances;
    IERC20 public token;

    constructor(address _tokenAddress) {
        token = IERC20(_tokenAddress);
    }

    function deposit() external {
        uint256 amount = token.allowance(msg.sender, address(this));
        require(amount > 0, "Allowance must be > 0");
        token.transferFrom(msg.sender, address(this), amount);
        balances[msg.sender] += amount;
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance to withdraw");
        balances[msg.sender] = 0;
        token.transfer(msg.sender, amount);
    }
}

In the above example, the user deposits tokens through a transferFrom call, and the contract stores the balance. To withdraw, the contract uses transfer after zeroing the balance, preventing reentrancy.

Example: Safe External Contract Call with Gas Limit

When calling an external function, ensure that a gas limit is specified to prevent the entire transaction from reverting due to a failure in the called contract.

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

interface IOracle {
    function getPrice() external view returns (uint256);
}

contract PriceConsumer {
    IOracle public oracle;

    constructor(address _oracleAddress) {
        oracle = IOracle(_oracleAddress);
    }

    function fetchPrice() external view returns (uint256) {
        (bool success, bytes memory data) = address(oracle).staticcall{gas: 10000}(abi.encodeWithSelector(oracle.getPrice.selector));
        require(success, "Oracle call failed");
        return abi.decode(data, (uint256));
    }
}

This example uses a staticcall with a specified gas limit to fetch a price from an oracle. This prevents a gas-intensive or malicious oracle from causing the transaction to fail.

Using Checks-Effects-Interactions Pattern

The checks-effects-interactions pattern is a recommended practice to reduce the risk of reentrancy and unexpected state changes. The pattern dictates:

  1. Checks: Validate all inputs and conditions first.
  2. Effects: Update the contract state next.
  3. Interactions: Make external calls last.

Here’s an example of this pattern in action:

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

contract Bank {
    mapping(address => uint256) public balances;

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

        // Update state before external call
        balances[msg.sender] -= amount;

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

By updating the state before making the external call, the contract ensures that a reentrant call cannot withdraw funds more than once.

Safe Use of Low-Level Calls

Low-level calls such as call, delegatecall, and staticcall offer flexibility but come with risks. Always validate return values and use them only when necessary.

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

contract LowLevelExample {
    function callExternal(address _contract, bytes memory _data) external returns (bool) {
        (bool success, ) = _contract.call(_data);
        return success;
    }
}

Use low-level calls with caution and ensure proper validation and error handling.

Summary of Best Practices

PracticeDescription
Pull over pushAllow users to withdraw funds instead of pushing funds to them
Checks-effects-interactionsValidate, update state, call external contracts
Gas limitsSpecify gas limits for external calls to prevent DoS
Avoid relying on return valuesTreat return values from external calls as optional
Use staticcall for view/pure callsPrevent state changes during read-only calls

Learn more with useful resources