Default Functions

Calling Contracts w/o Calling Any Functions

In Contract Interactions, we were introduced to the idea of calling other smart contracts and in particular, the call function. Recall if we just want to send ether to an account, we can use the following code:

account.call{value: 1}("");

When talking about the call function, we examined it from the perspective of the sender, but not from the perspective of the recipient. And as it turns out, there is lots to talk about if the recipient of a call function is a smart contract. In this section, we focus on default functions - functions that are automatically run whenever a target function is not specified.

How Default Functions are Activated

Although we will not get into the specifics of how the calldata of the call function is organized, note that if we are calling a specific function, said function's id is contained within the calldata. Therefore, it is evident that if we pass in the empty string as the calldata for the call function, we are not specifying a specific function to call.

receive and fallback

In the case that we do not specify a function to call (and we are calling a smart contract), the following two scenarios can occur:

  • The transaction reverts (occurs when a default function is not implemented)

  • Execution is handled by a default function

What is a default function, you might ask? The following is the syntax for the two types of default functions that can be implemented in a smart contract:

receieve() external payable {}
fallback() external {}

The first thing to notice about default functions is that they lack the function keyword; default functions are a special type of function and therefore, they are declared via the receive and fallback keywords.

Now that we are aware of the two types of fallback functions, the next logical question to ask is how are the two functions different? The difference between receive and fallback lies in when they are called. Assume we are sending ether to a contract; the following logical chart explains what function is called (credit goes to Solidity-by-Example for this):

  • If we are passing in the empty string as calldata

    • receive is called if it exists

    • fallback is called only if it is marked as payable

  • If we are not passing in the empty string as calldata

    • fallback is called if it exists

As for what default functions can do, recall that default functions are... functions and so anything that functions can do, default functions can do as well.

The Security Nightmare of Default Functions

Although default functions appear mundane, their existence is the cause for one of the most common security vulnerabilities for smart contracts - the reentrancy attack. We will investigate the reentrancy attack in-depth when discussing smart contract security, but for now, we will discuss reentrancy attacks from a high level.

Assume we have a smart contract Bank which acts like a bank. Users can deposit and withdraw ether as they wish via the deposit and withdraw functions, respectively. Furthermore, the amount of ether a user has in the bank is tracked via a mapping variable.

Bank.sol
contract Bank {

    mapping(address => uint256) balances;

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

    function withdraw(uint256 amount) public {
        require(balances[msg.sender] >= amount, "Not enough funds!");
        (bool success, ) = address(msg.sender).call{value: amount}("");
        require(success, "Sending ether failed");
        balances[msg.sender] -= amount;
    }

}

Now consider Eve, a malicious smart contract who wants to drain the bank of all its funds. Eve cannot change the logic of the smart contract, but she can dictate her own logic. Furthermore, Eve notices the following logical path of the withdraw function:

  • The bank first checks if the user has enough funds to withdraw the desired amount

    • If not, the transaction reverts

  • The bank sends the caller their desired funds

  • The bank checks if the funds were able to be sent

    • If not, the transaction reverts

  • The bank updates the balance of the caller to account for the withdraw

The most important thing to note here is that the balance of the caller is updated after the funds are sent. And once again, because Eve can dictate her own logic, she can dictate the logic of her fallback function. Therefore, if Eve is able to repeatedly call withdraw prior to her balance being updated by the bank, she is able to drain the bank of all its funds. This, in essence, is the reentrancy attack - looping through the execution of a function multiple times through default functions.

Eve.sol
contract Eve {

    Bank bank;

    function attack() public {
        bank.withdraw(1 ether);
    }

    fallback() external payable {
        if (address(bank).balance >= 1 ether) {
            bank.withdraw(1 ether);
        }
    }

}

Above is the logic that Eve would implement to conduct the reentrancy attack. In particular, there are two functions to consider here:

  • attack: starts the reentrancy attack by calling withdraw

  • fallback: executed whenever the bank sends the funds to Eve. It first checks if the bank has enough funds to be siphoned. If so, the function calls withdraw

The logical path of the entire reentrancy attack is described below:

  • attack is called, which starts the reentrancy attack by calling withdraw

  • withdraw executes until it calls Eve

  • fallback is executed, and calls withdraw

  • withdraw executes until it calls Eve

  • fallback is executed, and calls withdraw

  • ...

  • fallback is executed; Eve sees that the bank no longer has enough funds to be siphoned and ends the reentrancy attack

  • withdraw finishes the rest of its execution

Resources

https://solidity-by-example.org/sending-ether/

Last updated