Solidity - Low-Level Call to Non-Existent Contract

June 17, 2022

We’ll investigate what happens when we call a contract using call , delegatecall, staticcall.

You can think “of course it should revert, there is no contract”. But this would be wrong. Those functions return a boolean. If you said, “Oh, okay! So, we’ll get a false”, you’ll be wrong. Because when you call a non-existent contract with those functions, you’ll get a true. This is how EVM works.

Let’s try it with hands-on experience. Here is a contract to try them:

// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.15;

contract DarkForest {
    function callWithCall() external returns (bool res) {
        (res, ) = address(1).call(abi.encodeWithSignature("transfer(address,uint256)", msg.sender, 5 ether));
    }

    function callWithDelegatecall() external returns(bool res) {
        (res, ) = address(1).delegatecall(abi.encodeWithSignature("transfer(address,uint256)", msg.sender, 5 ether));
    }

    function callWithStaticcall() external view returns(bool res) {
        (res, ) = address(1).staticcall(abi.encodeWithSignature("transfer(address,uint256)", msg.sender, 5 ether));
    }
}

You can use this contract in remix and you’ll see the results. It doesn’t matter which network you’ve tried (at least most of them). Because probably there is no contract with the 0x0000000000000000000000000000000000000001 address if they don’t have a pre-compiled contract with that address. So basically you can use this contract in your localhost.

You’ll see you are getting a true when you call those functions. But there is no contract with 0x0000000000000000000000000000000000000001 this address.

External calls are so important in Solidity. You have to use them very carefully. This is just an example of bad cases when you are using an external call.

Prevention Technique

If you have to call an external contract that you don’t trust then you have to check if there is a contract with the given address.

In Solidity, we can check an address’ code length. If the code length is greater than 0, it means that address is a contract. So, let’s add our new security method to our contract:

// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.15;

contract DarkForest {
    function callWithCall() external returns (bool res) {
        require(address(1).code.length > 0, "non-existent contract");

        (res, ) = address(1).call(abi.encodeWithSignature("transfer(address,uint256)", msg.sender, 5 ether));
    }

    function callWithDelegatecall() external returns(bool res) {
        require(address(1).code.length > 0, "non-existent contract");

        (res, ) = address(1).delegatecall(abi.encodeWithSignature("transfer(address,uint256)", msg.sender, 5 ether));
    }

    function callWithStaticcall() external view returns(bool res) {
        require(address(1).code.length > 0, "non-existent contract");

        (res, ) = address(1).staticcall(abi.encodeWithSignature("transfer(address,uint256)", msg.sender, 5 ether));
    }
}

With the require(address(1).code.length > 0, "non-existent contract"); line, we can determine if the address is a contract. Now you can try to call those functions. You’ll get an error. But are we totally safe right now?

In the constructor, contracts don’t have any code yet. So, a hacker can call our function in a constructor to get what he wants. We don’t want this to happen. We’ll add another layer for security:

// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.15;

contract DarkForest {
    function callWithCall() external returns (bool res) {
        require(msg.sender == tx.origin, "only eoas");
        require(address(1).code.length > 0, "non-existent contract");

        (res, ) = address(1).call(abi.encodeWithSignature("transfer(address,uint256)", msg.sender, 5 ether));
    }

    function callWithDelegatecall() external returns(bool res) {
        require(msg.sender == tx.origin, "only eoas");
        require(address(1).code.length > 0, "non-existent contract");

        (res, ) = address(1).delegatecall(abi.encodeWithSignature("transfer(address,uint256)", msg.sender, 5 ether));
    }

    function callWithStaticcall() external view returns(bool res) {
        require(msg.sender == tx.origin, "only eoas");
        require(address(1).code.length > 0, "non-existent contract");

        (res, ) = address(1).staticcall(abi.encodeWithSignature("transfer(address,uint256)", msg.sender, 5 ether));
    }
}

The require(msg.sender == tx.origin, "only eoas"); line be sure the caller is an EOA.

One last thing: Now we can safely make an external call safely. But, we don’t check the result. So, if there is an error in the external call our transaction doesn’t bubble up the error. Usually, we don’t want this to happen. We can protect ourselves by adding this line to the end of our functions: require(res, "failed external call"); .

Now we are safe. Here is the latest code:

// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.15;

contract DarkForest {
    function callWithCall() external returns (bool res) {
        require(msg.sender == tx.origin, "only eoas");
        require(address(1).code.length > 0, "non-existent contract");

        (res, ) = address(1).call(abi.encodeWithSignature("transfer(address,uint256)", msg.sender, 5 ether));
        require(res, "failed external call");
    }

    function callWithDelegatecall() external returns(bool res) {
        require(msg.sender == tx.origin, "only eoas");
        require(address(1).code.length > 0, "non-existent contract");

        (res, ) = address(1).delegatecall(abi.encodeWithSignature("transfer(address,uint256)", msg.sender, 5 ether));
        require(res, "failed external call");
    }

    function callWithStaticcall() external view returns(bool res) {
        require(msg.sender == tx.origin, "only eoas");
        require(address(1).code.length > 0, "non-existent contract");

        (res, ) = address(1).staticcall(abi.encodeWithSignature("transfer(address,uint256)", msg.sender, 5 ether));
        require(res, "failed external call");
    }
}