For years, Solidity tutorials and best practice guides taught developers to send ETH using recipient.transfer(amount). It was considered the "safe" way because it automatically limited the gas stipend for the receiving address to a mere 2300 gas.
This hard limit was intended to prevent reentrancy attacks, as 2300 gas is insufficient to execute complex logic to call back into the sending contract.
Today, using .transfer() (and its cousin .send()) is considered bad practice and a significant usability bug.
The Gas Limit Trap
The Ethereum ecosystem has evolved. Users no longer just use Externally Owned Accounts (EOAs) like MetaMask. They use smart contract wallets: Gnosis Safe, Argent, and various multi-sig implementations.
When a smart wallet receives ETH, it executes code. It might need to emit an event, update an internal balance tracking system, or forward the funds elsewhere. Often, this logic requires more than 2300 gas.
If your protocol attempts to send rewards or withdraw funds to a Gnosis Safe user using .transfer(), the transaction will fail instantly due to "Out of Gas." Your protocol becomes incompatible with the most secure user wallets in the space.
Furthermore, Ethereum opcode gas costs are not fixed. They change via hardforks (e.g., EIP-1884 changed the cost of SLOAD, breaking many contracts that relied on fixed gas assumptions). Hardcoding a 2300 gas limit makes your contract fragile to future protocol updates.
The Modern Standard: .call()
The only acceptable way to send ETH in modern Solidity is using the low-level .call method:
// THE MODERN WAY
(bool success, ) = recipient.call{value: amount}("");
require(success, "ETH transfer failed");// THE MODERN WAY
(bool success, ) = recipient.call{value: amount}("");
require(success, "ETH transfer failed");
This method forwards all available gas (unless specified otherwise) to the recipient, allowing smart wallets to execute their necessary logic.
Addressing the Risk
Critics argue that .call() reintroduces reentrancy risks. This is true, but reentrancy should no longer be managed by relying on a fragile gas stipend.
Security must be handled explicitly at the architectural level:
- Checks-Effects-Interactions Pattern: Always update internal state before making an external call.
- Reentrancy Guards: Use modifiers like OpenZeppelin's nonReentrant to place a lock on critical functions.
Stop using outdated safeguards that break usability. Abandon .transfer() and embrace .call() with proper security patterns.