In the previous Road to Neo3 article, we gave a general overview of the importance of exception handling in Neo smart contracts and studied some of the preliminary development discussion. Before we continue more in depth on the implementation, we will follow the lifecycle of a Neo smart contract in order to better understand how the new mechanism for exception handling is applied.

Source code to bytecode

On its own, NeoVM does not have the ability to understand high-level programming languages such as Python or JavaScript. Once a developer has written a smart contract in their choice of language, it needs to be converted to a set of opcodes that NeoVM can read and execute.

Opcodes are instructions that can be understood by NeoVM and correspond to a specific operation to be performed. For example, to perform a simple addition calculation in NeoVM, we might use the “PUSH1” opcode to push the number 1 onto the stack, then “PUSH2” to put the number 2 on top of the stack, and then finally use the “ADD” opcode to add the top two values on the stack together.

Compiling Python source code to Neo2 opcodes (Note that due to the design of NeoVM and optimizations during compilation with neo-boa, the resulting bytecode is not neccessarily as simple as our PUSH1, PUSH2, ADD example).

This conversion process is handled by a compiler, which takes the contract’s source code and converts it into the bytecode ready to be executed by NeoVM. In the Neo ecosystem, there are compilers available for multiple languages, such as the core C# compiler Neon, neo-boa for Python, and neo-go for Go.

As we noted in the last article, creation of the new exception handling system required several new opcodes, bringing with them the new logic needed to catch and handle exceptions in NeoVM. These include the three instructions required for the mechanism itself; TRY, ENDTRY, and ENDFINALLY, along with three instructions for throwing errors or faulting the VM as required; ABORT, ASSERT, and THROW.

Bytecode into the VM

After producing the bytecode version of a smart contract, the next step is to load it into NeoVM, usually achieved by deploying the contract to the Neo blockchain using a transaction. This stores the contract on each node on the network, where it can be invoked as required.

Invocation is handled by the appropriately named InvocationStack, a key component of the NeoVM execution engine. When a smart contract is invoked, the contract bytecode and any other relevant parameters are loaded into the VM, creating a running execution context. This execution context can be thought of as an isolated environment for carrying out the relevant operations.

General architecture of NeoVM. Note: Somewhat outdated (Source: Neo3 Developer Guide)

Starting from the initial “NONE” state, NeoVM will then begin working its way through one operation at a time, stopping only when the InvocationStack is empty (entering the “HALT” state) or after an error occurs (resulting in the “FAULT” state).

Once the VM has reached FAULT, the state cannot be reverted. This is the case whether the FAULT was encountered due to an error in the contract, caused by an invalid transaction, or intentionally thrown in the code for another reason.

As a result, to add exception handling capabilities to Neo smart contracts, it was necessary to devise a solution that would allow code segments to be executed (and fail) without faulting the whole invocation.

Handling exceptions during execution

To explain the design, Chuan Lu, protocol group leader at Neo Global Development Shanghai and initial author of the mechanism, provided a written overview of the initial version in a Neo Column article. Though not fully up to date with the finished implementation, Chuan Lu introduces two important parts of the system; the TryStack and ExceptionHandlingContext.

An ExceptionHandlingContext is created whenever NeoVM executes the TRY opcode, with the new context being added to the TryStack. The context includes important information such as pointers to the instructions to be performed when either catching an exception or to be executed last as part of the optional finally segment. The other two opcodes, ENDTRY and ENDFINALLY, are used to guide NeoVM to the next instructions to follow, ensuring that code is executed as intended.

Stack and context design for try-catch. Note: Image partially outdated (Source: Neo Column)

Whenever an exception is encountered, the TryStack can be searched for a try-catch that can handle the exception. Essentially this means that execution of a contract can be paused while different conditions are checked in the catch segment, allowing smart contracts on Neo to define their own catchable exceptions to preemptively avoid invocation faults (or possibly to force a fault in the event that unwanted behaviour is detected).

For a Neo contract developer, the arrangement of these various opcodes is handled by the compiler, meaning they can focus on adding exception handling to their contracts at the source code level. As an example, a Python developer would be able to use a try and except code block in their code, which would be interpreted by the neo-boa compiler to add the same catch conditions to the final smart contract.

One notable benefit to the design of this mechanism is its support for try-catch nesting, allowing a try-catch to be performed within another try-catch. If the first try-catch does not catch an exception, it is thrown upward until caught or a fault is triggered, allowing for greater flexibility.

The implementation of exception handling for Neo3 smart contracts provides yet another powerful tool for dApp developers, continuing in Neo’s wider vision to be the most developer friendly platform.