Estimated time: 6 minutes read
Safely sending Ether between contracts requires understanding all the possibilities offered by Solidity and applying appropriate design patterns to prevent vulnerabilities and attacks.
How to send Ether from a smart contract?
Currently, there are three Solidity language functions for sending Ether from a smart contract:
- send: sends the supplied amount of Ether as a parameter and returns a bool to report the result of the transfer.
- transfer: sends the supplied amount of Ether as a parameter and throws an exception in case of transfer failure.
- call: sends the supplied amount of Ether as a parameter and returns a bool to report the result of the transfer.
If the Ether balance is sent to another smart contract, it will invoke the “receive” or “fallback” function of the destination contract, depending on which one exists and whether the “msg.data” part of the transaction is empty or not.
Apparently the three functions could be used interchangeably; however, there are nuances in their use:
- send and transfer limit the execution of the code in the “receive” and “fallback” functions to 2300 gas units.
- call, on the other hand, does not set any default limit, allowing the consumption of the remaining available gas in the transaction, although a limit could optionally be set in the call.
You can delve deeper into sending Ether in Solidity at this link.
In the case of sending Ether to an externally owned account (EOA), there would be no difference in usage among the three functions.
What attacks should be prevented when sending Ether from a smart contract?
Sending Ether from one smart contract to another is susceptible to a reentrancy attack.
Since the destination contract, the one receiving the Ether, will execute the code programmed in its “receive” or “fallback” functions (as applicable), logic could be developed to exploit a possible vulnerability in the calling contract, as illustrated below:
contract Vulnerable {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount, "not enough balance");
payable(msg.sender).transfer(amount);
balances[msg.sender] -= amount;
}
}
If the caller of the ‘withdraw’ function is an EOA, there will be no risk. However, let’s suppose it is another smart contract, which we will call the attacker, that invokes the following function:
function attack() {
Vulnerable.deposit({value: 1 ether});
Vulnerable.withdraw(1 ether);
}
The call to ‘withdraw’ will pass the balance check and execute a ‘transfer’ to the attacking smart contract. In this case, if the following ‘fallback’ function were to be executed, we can see how the reentrancy attack would succeed, decrementing the balance of the vulnerable contract recursively:
fallback() external payable {
Vulnerable.withdraw(1 ether);
}
To prevent these types of attacks, the amount of gas available for the execution of ‘receive’ or ‘fallback’ calls has been limited to 2300 units. The idea is to allow simple operations like emitting an event but to prevent more complex ones, such as those resulting from a reentrancy attack.
Which way of sending Ether is safer?
At first glance, limiting the gas to 2300 units would be sufficient to safely use ‘transfer’ or ‘send’ calls to send Ether to another contract.
However, with the implementation of various Ethereum forks, it has become apparent that relying on constant gas consumption to limit or guarantee the execution of certain functions in Solidity is not advisable.
In early 2019, the Constantinople fork had to be delayed because the reduction in gas costs could make the 2300 unit limitation insufficient to prevent certain reentrancy attacks.
On the other hand, the Istanbul fork brought an increase in gas consumption for the SLOAD operation, which could break the execution of some contracts whose ‘receive’ and ‘fallback’ functions relied on consuming less than 2300 units. Since then, it has been recommended to avoid using ‘send’ and ‘transfer’ to send Ether.
The current consensus is to use the ‘call’ function (without a predetermined gas limit for the execution of the destination contract) and prevent reentrancy attacks using the ‘Checks-Effects-Interactions’ pattern and/or the use of reentrancy guards.
In our example, the attack would be prevented with a simple modification that decrements the balance before transferring it, and also changes the ‘transfer’ function to ‘call’:
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount, "not enough balance");
balances[msg.sender] -= amount;
payable(msg.sender).call({value: amount});
}
Conclusion: Sending Ether securely
Throughout the history of the Solidity language, various guidelines have been established for safely sending Ether between contracts.
Based on recent real-world usage experiences, the current consensus is to use the ‘call’ function and prevent potential reentrancy attacks using programming patterns that modify the state before making calls to external contracts, as well as using reentrancy guards.
Furthermore, it is highly advisable not to rely on practices that trust the correct execution of certain functions of a smart contract to predetermined gas consumption limits, as these limits can vary across successive code forks of the Ethereum network.
Do you know of any notable cases of contract exploitation through a reentrancy attack when sending Ether? I invite you to share your knowledge.
Contact me if you need to develop a decentralized application: I can help you prevent this and other Solidity language vulnerabilities. Thank you very much!