A brief analysis of the new try/catch functionality in Solidity 0.6

Programming smart contracts in Ethereum is quite different from what regular developers are used to, and the lack of tools to handle the basic errors has always been an issue, often leading to broken smart contract logics.

When a transaction execution in the EVM fires a revert, all the state changes are rolled back and execution is halted. So whoever approached Solidity coming from modern programming languages probably ended up googling about “how to try/catch in Solidity” to handle these reverts.

One of the new coolest features of Solidity 0.6 is error handling, with the use of try/catch statements.

Why we need try / catch

A try/catch structure can be useful in many scenarios:

  • We don’t want to stop the execution even if a call reverted.
  • We want to retry the call in the same transaction, store the error status, react to the failed call, etc.

Before Solidity 0.6, the only way of simulating a try/catch was to use low level calls such as call, delegatecall and staticcall. Here’s a simple example on how you’d implement some sort of try/catch in a function that internally calls another function of the same contract:

pragma solidity <0.6.0;

contract OldTryCatch {

    function execute(uint256 amount) external {

        // the low level call will return `false` if its execution reverts
        (bool success, bytes memory returnData) = address(this).call(
            abi.encodeWithSignature(
                "onlyEven(uint256)",
                amount
            )
        );

        if (success) {
            // handle success            
        } else {
            // handle exception
        }
    }

    function onlyEven(uint256 a) public {
        // Code that can revert
        require(a % 2 == 0, "Ups! Reverting");
        // ...
    }
}

When calling execute(uint256 amount), the input parameter amount is passed in a low-level call to the onlyEven(uint256) function, returning a bool value as a first argument that indicates the success or failure of the sub-call, without actually halting the whole execution of the transaction on failure.

Notice that, in the case that the low-level call to onlyEven(uint256) returned a false value, it must have reverted somewhere inside and state changes done within the low-level call execution are reverted, but changes applied before and/or after the call are not.

This custom, and somewhat fragile, implementation of try/catch can be used both for calling functions from the same contract (as in this case) or, perhaps more interestingly, from an external one.

It can be a useful way of controlling errors coming from external calls, but we should always remember that the use of low-level calls is discouraged due to the security issues that can arise when executing code that we don’t trust or know.

That’s why the new try/catch feature has been introduced for external calls. With the newest compiler version, we can now write:

pragma solidity <0.7.0;

contract CalledContract {    
    function someFunction() external {
        // Code that reverts
        revert();
    }
}


contract TryCatcher {
    
    event CatchEvent();
    event SuccessEvent();
    
    CalledContract public externalContract;

    constructor() public {
        externalContract = new CalledContract();
    }
    
    function execute() external {
        
        try externalContract.someFunction() {
            // Do something if the call succeeds
            emit SuccessEvent();
        } catch {
            // Do something in any other case
            emit CatchEvent();
        }
    }
}

Overview

First of all, as mentioned, the new feature is exclusively available for external calls. If we want to use the try / catch pattern with internal calls within a contract (as in the first example), we can still use the method described previously using low-level calls or we can make use of this global variable to call an internal function as if it was an external call.

If you attempt making a low-level call with the new syntax, the compiler will raise an error:


The compiler returns a TypeError message whenever we attempt a low-level call with the try / catch syntax.

If you’ve read the compiler error carefully, the TypeError message says that the try/catch can be used even for contract creations. Let’s try it now.

pragma solidity <0.7.0;

contract CalledContract {
    
    constructor() public {
        // Code that reverts
        revert();
    }
    
    // ...
}

contract TryCatcher {
    
    // ...

    function execute() public {
        
        try new CalledContract() {
            emit SuccessEvent();
        } catch {
            emit CatchEvent();
        }
    }
}

It’s important to note that anything within the try block can still stop the execution. The try is applied exclusively on the call. For instance, the following revert inside the try success block will revert the transaction:


function execute() public {

    try externalContract.someFunction() {
        // this will revert the execution of the transaction
        // even if the external call succeeded
        revert();
    } catch {
       ...
    }

So be aware of the fact that reverts inside the try block are not caught by the catch itself. The try block must be treated as the success code block execution.

Return values and in-scope variables

Try / catch allows using returned variables from the external call or in-scope variables.

For constructor calls:

contract TryCatcher {
    
    // ...

    function execute() public {
        
        try new CalledContract() returns(CalledContract returnedInstance) {
            // returnedInstance can be used to obtain the address of the newly deployed contract
            emit SuccessEvent();
        } catch {
            emit CatchEvent();
        }
    }
}

For external calls:

contract CalledContract {    
    function getTwo() public returns (uint256) {
        return 2;
    }
}

contract TryCatcher {

    CalledContract public externalContract;
    
    // ...

    function execute() public returns (uint256, bool) {

        try externalContract.getTwo() returns (uint256 v) {
            uint256 newValue = v + 2;
            return (newValue, true);
        } catch {
            emit CatchEvent();
        }
        
        // ...
    }
}

Notice that the local variable newValue and return variables are limited to the try block. The same applies to any variable declared inside a catch block.

To use the returned data inside the catch statement, we can use the following syntax:


contract TryCatcher {
    
    event ReturnDataEvent(bytes someData);
    
    // ...

    function execute() public returns (uint256, bool) {

        try externalContract.someFunction() {
            // ...
        } catch (bytes memory returnData) {            
            emit ReturnDataEvent(returnData);
        }
    }
}

Data returned by the external call is converted into bytes and can be accessed inside the catch block. Notice that all kinds of revert reasons are taken into account from this catch and if, for any reason, decoding the return data fails, the failure would be produced in the context of the calling contract - so the transaction executing the try/catch will fail too.

Special catch clauses

Solidity’s new try / catch also includes special catch clauses. The first specific catch clause that we can already use is:


contract TryCatcher {
    
    event ReturnDataEvent(bytes someData);
    event CatchStringEvent(string someString);
    event SuccessEvent();
    
    // ...

    function execute() public {

        try externalContract.someFunction() {
            emit SuccessEvent();
        } catch Error(string memory revertReason) {
            emit CatchStringEvent(revertReason);
        } catch (bytes memory returnData) {
            emit ReturnDataEvent(returnData);
        }
    }
}

Here, if the revert was caused by a require(condition, "reason string") or a revert("reason string"), the error signature matches the catch Error(string memory revertReason) clause and then its block gets executed. In any other case, (e.g., a failed assert) the more general catch (bytes memory returnData) clause is executed.

Notice that catch Error(string memory revertReason) is not catching anything apart from the two cases just described. If we used it exclusively (without the other clause), we’d end up missing some errors. In general, catch or catch (bytes memory returnData) must be used along with catch Error(string memory revertReason) to ensure that we are covering all possible revert causes.

In the unique case that the decoding process of catch Error(string memory revertReason) returned string fails, the catch(bytes memory returnData), if present, would be able to catch it.

More special catch clauses are planned to come in future Solidity versions.

Gas failures

If the transaction doesn’t have enough gas to execute, the out of gas error is not caught.

In some cases, we may need to specify the gas for the external call so, even if the transaction has enough gas, if the execution of the external call requires more gas than what we have set, an internal out of gas error can be caught by a low level catch clause.

pragma solidity <0.7.0;

contract CalledContract {
    
    function someFunction() public returns (uint256) {
        require(true, "This time not reverting");
    }
}

contract TryCatcher {
    
    event ReturnDataEvent(bytes someData);
    event SuccessEvent();

    CalledContract public externalContract;

    constructor() public {
        externalContract = new CalledContract();
    }
    
    function execute() public {

        try externalContract.someFunction.gas(20)() { // Setting gas to 20
            // ...
        } catch Error(string memory revertReason) {
            // ...
        } catch (bytes memory returnData) {
            emit ReturnDataEvent(returnData);
        }
    }
}

When setting the gas to 20, the execution of the try call will run out of gas and the exception will be caught by the last catch statement: catch (bytes memory returnData). Conversely, setting the gas to a greater amount (e.g., 2000) will effectively execute the try success block.

Conclusions

To just summarize everything, here’s a list of things to remember when using Solidity’s new try/catch:

  • It is a feature exclusively available for external calls as described in detail above. Deploying a new contract is also considered an external call.
  • The feature is able to catch exceptions produced exclusively inside the call. The try block following the call is considered to be the success block. Whatever exception inside this block is not caught.
  • If the function call returns some variables they can be used in the following execution blocks (as described in the examples above).
    • If the try success block is executed, the return variables used must be declared of the same type of the ones actually returned by the function call
    • If the low-level catch block is executed, the return variable is a bytes type variable. Whatever specific catch clause has its own return variable type.
  • Remember that the low-level catch (bytes memory returnData) clause is able to catch all exceptions while specific catch clauses are limited in scope. Consider using both when dealing with all kind of exceptions.
  • When setting specific gas usage for the try external call, an eventual out of gas error is caught by the low-level catch clause. This is not true in general - if the transaction executing the code has not enough gas, the out of gas error is not caught.
6 Likes

Note that it’s also possible to use try/catch with an external call to this!

try this.onlyEven(3) {
    ...
} catch {
    ...
}

This is equivalent to the assembly shown at the beginning.

What is not possible is to catch an internal call such as try onlyEven(3) ....

2 Likes

@frangio you’re right I didn’t notice it was possible also with the use of this. Great finding.
Thank you so much for pointing that out !
I’ve updated the article

1 Like