top of page
  • Writer's pictureTeam RareSkills

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, delegatecall 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.



840 views0 comments
bottom of page