Low Level Call vs High Level Call in Solidity

A contract in Solidity can call other contracts via two methods: through the contract interface, which is considered a high-level call, or by using the call method, which is a low-level approach.

Despite both methods using the CALL opcode, Solidity handles them differently.

In this article, we’ll compare the two: why a low-level call never reverts, whereas a high-level call can revert, and why a low-level call to an empty address is considered successful, while a high-level call reverts when calling a non-existent contract.

Why does a low-level call (or a delegatecall) never reverts but a call via the contract interface can revert

Before explaining why, let me quote the Solidity documentation that addresses this issue.

When exceptions happen in a sub-call, they “bubble up” (i.e., exceptions are rethrown) automatically unless they are caught in a try/catch statement. Exceptions to this rule are send and the low-level functions call, delegatecalll and staticcall: they return false as their first return value in case of an exception instead of “bubbling up”.

Below we show both a high-level call and a low-level call to compare the behavior. I will employ the call method in the example below, but the same principles can be extended to delegatecall.

Caller can call ops() in Called in two ways. Note that ops() always reverts:

pragma solidity ^0.8.0;

contract Caller {

    // first call to ops()
    function callByCall(address _address) public returns (bool success) {
        (success, ) = _address.call(abi.encodeWithSignature("ops()"));
    }

    // second call to ops()
    function callByInterface(address _address) public {
        Called called = Called(_address);
        called.ops();
    }
}
contract Called {

    // ops() always reverts
    function ops() public {
        revert();
    }
}

Despite both methods being used to call the same function, and both methods using the opcode CALL, the solidity compiler generates bytecode to handle the failure cases in different ways. Executing both functions within the Caller contract will reveal that Caller.callByInterface will revert, while Caller.callByCall will not.

At the EVM level, the CALL opcode returns a boolean indicating whether the call was successful or not, and places this return on the stack. The opcode itself doesn’t trigger the revert.

When the call is made via the contract’s interface, Solidity handles this return value for us. It explicitly checks whether the return value is false and initiates a revert unless the call was made within a try/catch block.

However, when using low-level calls, we need to manually handle this return boolean and explicitly trigger a revert if desired.

contract Caller {

      //...
      function callByCall(address address) public returns (bool success) {
        (success, ) = address.call(abi.encodeWithSignature("ops()"));
        if (!success) {
            revert("Something went wront");
        }
    }
    //...
}

This difference between a high level call and a low level call is illustrated in the figure below.

Low level call handling a revert vs a high level call handling a revert

The difference between call and call by interface when calling an empty address

Solidity’s low level call method doesn’t perform a prior check to verify whether the called address corresponds to a contract. The contract can check if the address is a smart contract using EXTCODESIZE, which is the opcode behind the scenes for address.code.length. If the size is zero, it indicates that there’s no contract deployed at that address. However, the call method doesn’t incorporate this check; it directly executes the CALL opcode regardless.

When using the interface, checks the target’s code size. In other words, in the bytecode generated for the callByInterface function, the EXTCODESIZE opcode is executed at the specified address before executing the CALL opcode. If the size returned by EXTCODESIZE is zero, indicating that there’s no contract at that address, the function reverts before executing the CALL opcode. This explains why the callByInterface function reverts if executed with a non-existent contract address, while callByCall does not.

This difference between how a low level call and a high level call interacts with an empty contract is illustrated below.

low level call calling an empty contract vs a high level call calling an empty contract

Fundamentally, an execution can revert if it encounters a REVERT opcode, runs out of gas, or attempts something prohibited, such as dividing by zero. When a call is made to an empty address, none of the above conditions can occur.

Learn More with RareSkills

See our free Solidity course if you are new to Solidity. If you are a more experienced Solidity programmer, please see our advanced Solidity bootcamp.

Authorship

This article was written by João Paulo Morais in collaboration with RareSkills.

Originally Published May 1, 2024

20 Common Solidity Beginner Mistakes

20 Common Solidity Beginner Mistakes Our intent is not to be patronizing towards developers early in their journey with this article. Having reviewed code from numerous Solidity developers, we’ve seen some mistakes occur more frequently and we list those here. By no means is this an exhaustive list of mistakes a Solidity developer can make. […]

Smart Contract Foundry Upgrades with the OpenZeppelin Plugin

Smart Contract Foundry Upgrades with the OpenZeppelin Plugin Upgrading a smart contract is a multistep and error-prone process, so to minimize the chances of human error, it is desirable to use a tool that automates the procedure as much as possible. Therefore, the OpenZeppelin Upgrade Plugin streamlines deploying, upgrading and managing smart contracts built with Foundry or […]

UUPS: Universal Upgradeable Proxy Standard (ERC-1822)

UUPS: Universal Upgradeable Proxy Standard (ERC-1822) The UUPS pattern is a proxy pattern where the upgrade function resides in the implementation contract, but changes the implementation address stored in the proxy contract via a delegatecall from the proxy. The high level mechanism is shown in the animation below: Similar to the Transparent Upgradeable Proxy, the […]

Try Catch and all the ways Solidity can revert

Try Catch and all the ways Solidity can revert This article describes all the kinds of errors that can happen when a smart contract is called, and how the Solidity Try / Catch block responds (or fails to respond) to each of them. To understand how Try / Catch works in Solidity, we must understand […]