[call for feedback] The future of `try`/`catch` in Solidity

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:

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:

  1. First, one might expect that the try <expression> {} part also covers local reverts caused by the <expression>. It does not. If currentBalance() returns a value lower than 100 and the expression underflows, the control will not pass to the catch block. Instead the contract will revert.
  2. Second, the block where the balanceOf() call is performed is not covered. A revert in that call will not transfer control to catch, 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:

  1. It splits the catch clauses into two kinds: internal and external. 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 way catch 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 plain catch behavior unchanged.
  2. 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).
  3. It introduces the ability to catch custom errors, which is not yet implemented.
  4. The ErrorCode and PanicCode 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:

  1. Using revert with no arguments. It’s similar to how some programming languages allow rethrowing exceptions with a throw; or raise; statement without arguments. The downside is that we already have revert() in Solidity and it reverts with an empty message. This may lead to mistakes.
  2. Explicitly passing a bytes array to revert, making the compiler use that as revert content (as opposed to passing in a string, which is wrapped in an Error(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:

  1. 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.
  2. tryCall() would be fully type-safe and generic. You would be able to specialize it with any external function type.
  3. 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.
  4. 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?

6 Likes

It’d be great if we could if we could discern between different types of reverts and even better if we could access the revert message.

I used try-catch only once in prod
My 2cents on the debate :
I don’t think solidity needs new syntax to handle local reverts as the developer already have all the tools in hand to control the logic flow of its contract.
The feature is only needed for external calls. As try-catch has a different, non-transposable meaning in other languages I would be in favor of using an all new syntax.
Being able to handle reverts in external calls without low-level mechanism is nice and seems legitimate to me.
When it comes to handling the different causes of a revert (and decoding returned data) I would like to have a simple syntax that executes code whatever the reason of the revert, as I feel like I would want to see a big chunk of code only if it’s to finely define the response behavior, and a catch-all situation should be a one-liner.

1 Like

I’ve never used try/catch in Solidity so I might be misunderstanding some stuff.

Personally I don’t like it when we use syntax similar to “traditional” languages to denote something with a different meaning. Therefore, personally I lean towards the tryCall() proposal, which to me seems to make the most sense.

The main question I would have is — does this cover the actual usage of try/catch in the wild?
Are there any deployed contracts using try/catch that we can look at? Or other resources that explore how people use try/catch today in practice?

Arrived at this forum post after following the rabbit hole of issues related to custom revert codes not having try catch support currently. Our company uses custom revert codes, and likes that language feature a lot, and would like to see try catch or its successor support it.

My opinionated take is that the language should keep the current try catch syntax because it’s comfortable and kind of analogous to what programmers expect from other languages, but completely prevent using the try statement on an internal call.

The compiler should be able to tell whether a given call is internal or external and then refuse to compile internal try statements. If you’re calling an internal function then you have control over the function that’s reverting and you can change the revert code to return a tuple of (bool success, data). I’ve never used a try catch with an internal call, and it’s hard for me to see why it would be a useful thing for people. Trying to call out externally to another contract outside of your control, and being able to react differently based on whether that contract call succeeded or not is an essential language feature by comparison.

The tryCall syntax you describe makes logistical sense from a compiler engineering standpoint, and in general I’m fine with it as an idea, but I would urge the solidity language spec designers to think a little bit more about how to format the syntax. That specific match example above seems very verbose and kind of hard to read (what are the arrow functions, anonymous javascript functions? Why the use of the word match, this whole block looks like a case switch statement), I would think about how to specify the language so the syntax is clean and simple and short.

I actually like the try/catch since I sort of expect some kind of functionality like this to exist, but I agree that its definitely not intuitive that it can’t catch the internal reverts, specifically if you expect some return type and then the decoder fails bc the calling contract doesn’t actually return the expected type. Afaik there isn’t really a way around this unless you handling it in assembly. Even a low level call wouldn’t handle this bc the decode would break… so I kind of like the abi.tryDecode() option as an interim solution but maybe that is also just because its very specific to an issue I’m running into :slight_smile: