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 abytes
type variable. Whatever specificcatch
clause has its own return variable type.
- If the
- Remember that the low-level
catch (bytes memory returnData)
clause is able to catch all exceptions while specificcatch
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 eventualout of gas
error is caught by the low-levelcatch
clause. This is not true in general - if the transaction executing the code has not enough gas, theout of gas
error is not caught.