Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Secure Ether Transfers with `call`

Hello! Welcome back.

In our last lesson, we focused on getting Ether into your smart contracts by making functions payable. Now, we'll tackle the other side of the equation: getting Ether back out. This is a critical function for almost any financial application on the blockchain, from processing withdrawals and refunds to distributing funds from a token sale.

Your learning goal for today is to send Ether from a contract securely using the call method. The word "securely" is key here, as sending value is one of the most high-stakes operations in smart contract development.


1. The Three Ways to Send Ether

In modern Solidity, there are three main low-level functions for sending Ether from a contract: transfer, send, and call.

Let's start with a short video that introduces and compares these three methods.

Send ETH | Solidity 0.8

This video from Smart Contract Programmer gives a quick overview of transfer, send, and call, highlighting their main differences.

Pay close attention to these parts: Introduction (00:00 - 00:36): Get a quick definition of each method. The call method (03:01 - 04:02): Focus on the syntax for call and the fact that it returns a success boolean. Recap and Recommendation (05:44 - 06:10): Listen for the final recommendation on which method to use.

As the video explained:

  • transfer() and send(): These methods are simple to use, but they forward a fixed, small amount of gas (2300 gas) to the recipient. If the recipient is a contract, this is only enough for very basic operations, like emitting an event.
  • call(): This method forwards all available gas by default. It's more flexible but requires more careful handling.

Why is call() the recommended method?

The fixed 2300 gas limit of transfer and send was initially seen as a security feature to prevent a class of attacks called reentrancy. However, it makes contracts brittle. Gas costs in Ethereum can change with network upgrades. A recipient contract's logic might become more complex. If the recipient's receive function ever requires more than 2300 gas, any contract trying to pay it with transfer or send will fail.

Because of this, the current best practice is to always use call for sending Ether, as it is more robust and future-proof.


2. The Syntax and Mechanics of call

Using call to send Ether has a unique syntax. It's a low-level function, which gives you more control.

(bool success, bytes memory data) = payable(recipient_address).call{value: amount}("");

Let's break this down:

  • payable(recipient_address): The address you are sending Ether to. It must be explicitly cast to a payable address type.
  • .call{...}: Invokes the call function.
  • value: amount: This is how you specify the amount of Ether to send with the call. amount should be a uint256 value in wei.
  • (""): This is the "calldata." An empty string means you aren't trying to execute any specific function on the recipient contract; you are just sending Ether. This will trigger the recipient's receive() function (or fallback() if receive() doesn't exist).
  • (bool success, bytes memory data): call returns two values.
    • success: A boolean that is true if the call succeeded and false otherwise. Crucially, call does not automatically revert the transaction on failure.
    • data: Any data returned by the recipient function. For simple Ether transfers, this is usually empty.

Since call does not revert on failure, you must handle the outcome yourself.

Steps to Send Value from Within a Smart Contract

This article from Alchemy provides a clear, step-by-step guide to using call. We will focus on the final implementation step.

Read 'Step 4: Use call Function to Send Value'. Pay close attention to the code example. Notice how the boolean success is captured and then checked using a require statement. This is a non-negotiable part of using call securely.

As the article shows, the correct pattern is:

(bool success, ) = recipient.call{value: amount}("");
require(success, "Payment failed."); // ALWAYS do this!

3. The Reentrancy Attack: The Greatest Danger

The flexibility of call (forwarding all gas) opens the door to a major security vulnerability if not handled correctly: the reentrancy attack.

Here’s how it works:

  1. A victim contract has a withdraw function that sends Ether to the caller.
  2. An attacker creates a malicious contract.
  3. The attacker's contract calls the victim's withdraw function.
  4. The victim contract uses call to send Ether back to the attacker's contract.
  5. When the attacker's contract receives the Ether, its receive() or fallback() function is triggered. This function immediately calls the victim's withdraw function again, before the first withdrawal has finished executing.
  6. If the victim contract hasn't updated the attacker's balance before sending the Ether, it will think the attacker still has funds and will send them again.
  7. This loop continues until the victim contract is drained of its funds.
Re-entrancy Attack Diagram
This diagram shows the malicious loop of a reentrancy attack. The attacker's contract repeatedly calls the `withdraw` function before the victim DAO contract can update its balance, leading to a complete drain of funds.

4. The Defense: The Checks-Effects-Interactions Pattern

To prevent reentrancy attacks, the Solidity community has established a critical design pattern: Checks-Effects-Interactions (CEI).

This is arguably the most important security pattern to learn.

Security Considerations

The official Solidity documentation provides the definitive explanation of reentrancy and the CEI pattern. This is essential reading.

Please read two sections from this document: Start at the heading 'Reentrancy' and read until the heading 'Gas Limit and Loops'. This section explains the attack with code examples showing vulnerable and corrected contracts. Read the section titled 'Use the Checks-Effects-Interactions Pattern'. This section formally defines the pattern. Focus on understanding the order of operations in the secure example.

The CEI pattern dictates a strict order of operations within your function:

  1. Checks: First, perform all validation checks.

    • Is msg.sender authorized? (require(msg.sender == owner))
    • Does the user have a sufficient balance? (require(balances[msg.sender] > 0))
  2. Effects: Second, update the contract's state variables. This is the most important step for preventing reentrancy.

    • Set the user's balance to zero (balances[msg.sender] = 0;) or decrease it before you send any Ether. If the attacker calls back, their balance will already be zero, and the "Check" will fail.
  3. Interactions: Last, interact with any external contracts.

    • This is where you make the external call to send the Ether.

Incorrect (Vulnerable) Order:

// DO NOT DO THIS
function withdraw(uint amount) public {
    // Check
    require(balances[msg.sender] >= amount, "Insufficient balance.");
    
    // Interaction (!!! DANGER !!!)
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed.");

    // Effect (Too late!)
    balances[msg.sender] -= amount; 
}

Correct (Secure) Order - Applying CEI:

// THIS IS THE SECURE PATTERN
function withdraw(uint amount) public {
    // 1. Checks
    require(balances[msg.sender] >= amount, "Insufficient balance.");

    // 2. Effects (Update state BEFORE the external call)
    balances[msg.sender] -= amount;

    // 3. Interactions
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed."); // Still need to check for failure
}
Test your understanding!

You have a simple "PiggyBank" contract where users can deposit Ether. Your task is to implement the smashPiggyBank() function to allow a user to withdraw their entire deposited amount securely.

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

contract PiggyBank {
    mapping(address => uint) public balances;

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

    // Users can withdraw their entire balance
    function smashPiggyBank() public {
        // Your code goes here.
        // Implement the Checks-Effects-Interactions pattern.
    }
}

Your Task:
Write the code for smashPiggyBank() following all the security best practices we've discussed.

Show answer
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract PiggyBank {
    mapping(address => uint) public balances;

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

    function smashPiggyBank() public {
        // 1. Checks: Get the user's balance and ensure it's greater than 0.
        uint userBalance = balances[msg.sender];
        require(userBalance > 0, "You have no Ether in the piggy bank.");

        // 2. Effects: Set the user's balance to 0 BEFORE sending the Ether.
        // This is the critical step to prevent reentrancy.
        balances[msg.sender] = 0;

        // 3. Interactions: Send the Ether and check for success.
        (bool success, ) = msg.sender.call{value: userBalance}("");
        require(success, "Withdrawal failed.");
    }
}

This solution correctly applies the CEI pattern. It first reads the balance, then updates the state (sets balance to 0), and only then performs the external call to send the Ether.


Conclusion

You've now learned the modern, secure way to send Ether from a smart contract. This skill is fundamental to building robust and trustworthy decentralized applications.

Key Takeaways:

  • Use call: It is the industry-standard method for sending Ether due to its flexibility with gas.
  • Check the Return Value: call returns a bool indicating success. You must always use require() to check this value and revert the transaction on failure.
  • Beware of Reentrancy: The flexibility of call creates a risk of reentrancy attacks if used carelessly.
  • Master Checks-Effects-Interactions: This pattern is your primary defense against reentrancy. Always update your contract's state before you make an external call (like sending Ether).

Preview of the Next Lesson:
We've been using require() extensively to guard our functions. In the next lesson, we will dive deeper into error handling. You will learn to handle contract errors and revert transactions using require, assert, and revert, giving you a complete toolkit for managing state and control flow in your smart contracts.

Can't find a good explanation? Sign up and we'll make it for you

Sign up