solidity language logo

Solidity ETH Transfers: Why You Should Always Use call() Over transfer()

The Shift in Solidity Payment Patterns

For years, the standard advice for sending Ether in Solidity was to use the transfer() function. It was simple, readable, and had a built-in safety feature: it only forwarded 2300 gas. This gas limit was designed to prevent reentrancy attacks, as 2300 gas is barely enough to log an event, let alone execute a malicious contract callback.

However, the Ethereum landscape changed. With the implementation of EIP-1884, the cost of certain opcodes increased, meaning that 2300 gas is no longer a guaranteed constant for safe execution. This made transfer() and send() unreliable. Today, the call() method is the industry standard. Here is how to use it correctly and safely.

The Problem with transfer()

When you use payable(recipient).transfer(amount), the transaction will automatically fail and revert if the recipient is a contract that requires more than 2300 gas to receive funds. If a user uses a multi-sig wallet or a programmable smart contract wallet, your transfer() call might break their ability to interact with your DApp. This creates a "denial of service" vulnerability where your contract becomes unusable because it cannot send funds to modern wallets.

The Modern Standard: call()

The call() method is a low-level function that forwards all remaining gas by default (though you can specify a limit). It returns a boolean indicating success or failure, allowing the developer to handle errors gracefully.

// The recommended way to send Ether
(bool success, ) = _to.call{value: _amount}("");
require(success, "Transfer failed.");

Mitigating the Risk of Reentrancy

Because call() forwards most of the gas, it re-opens the door to reentrancy attacks. If the recipient is a malicious contract, it can call back into your contract before the first execution finishes. To use call() safely, you must follow two rules:

  1. Checks-Effects-Interactions: Always update your internal state (like decreasing a user's balance) before calling the external address.
  2. Reentrancy Guards: Use a mutex or OpenZeppelin's ReentrancyGuard modifier.

Practical Code Example

Below is a secure implementation of a withdrawal function using the modern pattern.

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

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

contract SecureVault is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

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

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

        // 2. Interaction: Send ETH using call
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "ETH Transfer failed");
    }
}

Conclusion

While transfer() seems safer because of its gas limit, it is too rigid for the evolving Ethereum protocol. By using call() combined with the Checks-Effects-Interactions pattern and reentrancy guards, you ensure your smart contracts are future-proof, compatible with smart wallets, and secure against exploits.