Hello everyone!
We’d like to ask for opinions on the way the try
/catch
syntax should evolve. We are aware of the problems with how the feature works currently, and we have several ideas on how to solve them. We’re looking for your feedback to decide which one to focus on. In this post I’ll go over the issues and describe the solutions we’re thinking of.
If you’re not familiar with how try
/catch
works, I recommend first checking out these materials first:
- Solidity 0.6.x features: try/catch statement - blog post from back when the feature was introduced
- Solidity documentation on
try
/catch
Current issues with try
/catch
Wrong expectations created by the syntax
The main problem is really not what the feature does, but what it makes people expect. try
/catch
was modeled after a similar feature that many programming languages provide for handling exceptions. Despite the similarities, reverts are not exceptions and do not behave the same way. This is especially evident in internal function calls, where the lack of any stack unwinding mechanism means that reverting just ends the contract execution, without any mechanism to intercept it. Any such mechanism introduced by the compiler is not likely to be a good trade-off in the resource-constrained environment such as EVM.
The current syntax does not necessarily make it obvious that this is the case. A common expectation of users familiar with other languages is that catch
will handle all reverts, both those from external functions and those issued by the current contract. Consider the following example:
try token.transfer(recipient, currentBalance() - 100) returns (bool) {
newBalance = IERC20.balanceOf(recipient)
}
catch {
...
}
There are two things that often trip up users:
- First, one might expect that the
try <expression> {}
part also covers local reverts caused by the<expression>
. It does not. IfcurrentBalance()
returns a value lower than100
and the expression underflows, the control will not pass to thecatch
block. Instead the contract will revert. - Second, the block where the
balanceOf()
call is performed is not covered. A revert in that call will not transfer control tocatch
, even though it’s an external call. Only reverts in the try expression are caught.
Lack of mechanism to handle reverts in compiler-generated checks
The compiler includes extra checks when generating code for high-level calls. One of them is the “extcodesize check”, which fails if the target is not a contract. The other is a whole class of validation errors that can happen in the code that decodes the raw returndata into the high-level types that the function returns. A decoding error may also happen during decoding of the error itself.
Currently any such failure cannot be caught. The contract unconditionally reverts if that happens.
Missing features
It’s currently not possible to explicitly catch custom errors. Only Error
and Panic
can be caught. Other error types can only be handled in catch
or catch(bytes memory)
.
It’s also not possible to rethrow an error without the use of inline assembly.
Our ideas
Now let’s look at what we could do to address these issues.
Extended try
/catch
syntax
The most obvious idea is to just make try
/catch
more powerful.
catch internal
and catch external
We could extend the current syntax to explicitly distinguish between different kinds of reverts. Let’s look at an example:
import {ErrorCode, PanicCode} from "std/errors";
error MyError(string);
try token.transfer(someAddress, 123) returns (bool transferSuccessful) {
...
}
catch external MyError(string memory) {
...
}
catch internal Error(ErrorCode reason) {
if (reason == ErrorCode.DecodingFailure) {
...
}
else if (reason == ErrorCode.NotAContract)
...
}
else {
...
}
}
catch external Panic(PanicCode reason) {
if (reason == PanicCode.DivisionByZero) {
...
}
}
catch internal (bytes memory returndata) {
...
}
catch {
...
}
It introduces several new elements:
- It splits the
catch
clauses into two kinds:internal
andexternal
. The former would only match the local reverts triggered by the extra checks added by the compiler (it would still not allow catching reverts from internal calls). The latter would work the same waycatch
works now and handle only the reverts bubbled up from external calls.- Eventually the plain
catch
could be repurposed to handle either kind, as is assumed in the example, but that would be a breaking change. Initially we would introduce the new syntax in a non-breaking release by keeping the plaincatch
behavior unchanged.
- Eventually the plain
- It introduces error codes for the extra checks and validations performed on external calls. Currently the reverts generated by the compiler for such cases are just
Error(string)
, and not distinguishable when revert strings are disabled (which is the default). - It introduces the ability to catch custom errors, which is not yet implemented.
- The
ErrorCode
andPanicCode
enums could be eventually provided by the standard library, which is not available yet. Until that time they would be plain integers.
The best thing about this proposal is that it builds on the current syntax and can even be implemented in a non-breaking way. The downside is that it requires users to understand the distinction between internal and external catch
. It could be argued that users already need to understand that distinction to use try
/catch
properly, and that not understanding it is what helps perpetuate the wrong expectations, but it’s still a new syntax element that would now need to explicitly considered.
Another downside is that it is likely that in the long-term we’ll decide to go for one of the other proposals presented below. This proposal may turn out to be only a short-term solution. Therefore the main question here is is it enough of an improvement to solve issues with try
/catch
in the short term and not be a wasted effort? Another is do you think it’s also a good long-term solution?
Extra catch clauses for local reverts
An alternative and more minimalistic variant of the proposal above, from @chriseth, is to just introduce a few new variants of catch
clause to handle local reverts
catch NoContract {}
catch DecodingFailure {}
catch Other {}
One potential downside is that this proposal does not provide a way to access the undecoded return data. It could possibly be refined to account for that though, if necessary. Its biggest advantage is that it’s relatively easy to implement and introduces just enough new elements to handle the local reverts, which we see as the most important problem of the current try
/catch
.
Rethrowing reverts
A small enhancement that could be added to the current try
/catch
in a non-breaking way is an ability to rethrow errors:
try token.transfer(someAddress, 123) returns (bool transferSuccessful) {
...
}
catch internal (bytes memory returndata) {
revert(returndata);
}
catch {
revert;
}
There are at least two ways to do this:
- Using
revert
with no arguments. It’s similar to how some programming languages allow rethrowing exceptions with athrow;
orraise;
statement without arguments. The downside is that we already haverevert()
in Solidity and it reverts with an empty message. This may lead to mistakes. - Explicitly passing a
bytes
array torevert
, making the compiler use that as revert content (as opposed to passing in astring
, which is wrapped in anError(string)
by the compiler). This unfortunately also has potential to be confusing and cause mistakes.
try
/catch
/else
The fact that the block in try <expression> { ... }
is not covered could be remedied by changing the syntax. This could be solved by adopting this part of syntax from Python, which allows adding an else
block to its try
/except
statement. Such a block is executed only when there are no errors. Unlike in the try
block, exceptions in it are not caught. In Solidity it could look like this:
try token.transfer(someAddress, 123)
catch {
...
}
else (bool transferSuccessful) {
...
}
Disallowing the block after try
would be a breaking change, but it could initially be made optional and only disallowed in the next breaking release.
Warning about internal reverts not covered by try
/catch
We don’t really have a great solution to the problem of local reverts in the try expression. The ones that come from checks inserted by the compiler can be easily redirected to the catch
block, but it’s not the case for internal operations included in the expression by the user. These can potentially be deeply nested in internal functions.
One idea we had would be to instead try to prevent wrong expectations by issuing a warning about it:
try token.transfer(recipient, currentBalance() - 100) // WARNING
uint value = currentBalance() - 100;
try token.transfer(recipient, value) // OK
Do you think such a warning would be an improvement over the current situation, or would it just be an unnecessary annoyance?
abi.tryDecode()
Here’s a completely different idea, proposed by @hrkrshnn. Maybe, what we should really focus on is providing a better low-level mechanism instead. It can be argued that handling reverts is inherently a low-level thing and many users already prefer the low-level call()
combined with explicit abi.decode()
to have greater control, and work around the deficiencies of try
/catch
mentioned above. This is currently hindered by the fact that abi.decode()
always reverts on decoding errors.
The proposal would be to allow for usage such as this:
(bool callSuccess, bytes memory returndata) = someAddress.call{value: 42}(data);
if (callSuccess) {
...
}
else {
...
}
(bool decodingSuccess, uint value) = abi.tryDecode(returndata, (uint));
if (decodingSuccess) {
...
}
else {
...
}
This would be meant as a pragmatic solution matching usage patterns in the wild today, letting users ignore try
/catch
and wait for a better long-term solution. It’s also generally something that’s often requested and could coexist with try
/catch
.
Question: Do you think it’s better to provide this feature instead of focusing on try
/catch
?
tryCall()
and match
This is the most long-term solution, proposed by @ekpyron.
We’re currently in the process of a general type system redesign aimed at making it more uniform. This will allow us to introduce generics and eventually also make the language simpler, with many features that are currently hard-coded into the compiler made implementable with language primitives and provided by the standard library instead. try
/catch
is one of the constructs that might be replaced this way.
The main problem here is that we still can’t say for sure how this will look like. We have some early ideas and here’s one of them, but it may also end up being completely different.
import {tryCall} from "std/errors";
error MyError(string);
match tryCall(token.transfer, (someAddress, 123)) {
CallSuccess(transferSuccessful) => {
...
}
CallFailure(MyError(reason)) => {
...
}
NotAContract => {
...
}
DecodingFailure(errorCode) => {
...
}
}
How it would work:
-
tryCall()
would be an ordinary function anyone could implement using standard language constructs, without resorting to inline assembly. It would be provided as a part of the standard library. It would accept the pointer to the external function to call and a tuple of arguments. -
tryCall()
would be fully type-safe and generic. You would be able to specialize it with any external function type. -
tryCall()
would return a Rust-like enum with data, that could be inspected to check if the operation was successful, and, if not, get the decoded error from the external call or an error code of the local revert. -
match
would be just a general pattern matching statement, not tied specifically to errors.
The example above is incomplete and has several problems:
- Given that users can define arbitrary error, the value returned from
tryCall()
would not have a static type. It’s not yet clear how we would handle this. - Syntax for any of this is not yet decided.
- It relies on quite a few features, which are not yet available: generics, proper tuple types, enums with data, standard library, match statement. It will only be possible when we have all of them.
Question: Do you think this is a good long-term replacement or would you still prefer some kind of try/catch statement? If so why exactly would you prefer it?