VM exception handling

How the TVM handles errors, the four exception categories, the call stack depth limit, and Solidity error-handling tools (assert, require, revert, custom errors).

📘

Prerequisites

This page covers two related topics: how the TVM handles errors during contract execution, and how to write Solidity that handles errors cleanly. Sections 1–3 are reference material for diagnosing failed transactions; sections 4–6 are guidance for writing better contracts.

TVM exception types

There are four categories of exceptions that may occur during contract execution. Each has different consequences for Energy consumption and on-chain visibility.

Runtime exceptions that exhaust the Energy allowance

When a contract terminates because of OUT_OF_ENERGY, OUT_OF_TIME, invalid opcode, a VM stack overflow, an out-of-memory condition, or certain other TVM runtime exceptions, the TVM calls spendAllEnergy() and accounts for Energy up to the limit calculated before execution. Both the Energy used before the failure and the unused allowance are recorded as consumed.

Common conditions include:

  1. Energy exhaustion — the next operation requires more Energy than remains available.
  2. Execution timeout — execution exceeds the per-transaction CPU time limit, controlled by getMaxCpuTimeOfOneTx (currently 80 ms on Mainnet).
  3. Invalid opcode — execution reaches an unsupported or explicitly triggered invalid opcode.
  4. VM stack overflow — the TVM raises JVMStackOverFlowException.
  5. Memory limit exceeded — a single call frame allocates more than 3 MB; nested call frames within the same transaction are each subject to this limit.

Whether Solidity language-level checks enter this path depends on the compiler version. Before Solidity 0.8.0, failed assert calls and some arithmetic or bounds checks could compile to invalid opcode, exhausting the execution's Energy allowance. Solidity 0.8+ normally returns Panic(uint256) and terminates through REVERT, using the accounting described in the next section.

REVERT

REVERT can be triggered by require, revert, Solidity 0.8+ Panic(uint256), and other conditions. It only charges the Energy used up to the point of failure; the unused Energy allowance is not billed. The typical error message is REVERT opcode executed.

The following conditions trigger REVERT:

  1. Calling require(expression) where expression evaluates to false.
  2. A function called via a message call that does not end correctly (for example, runs out of Energy or throws). If Energy is not specified at the call site, all available Energy is forwarded; from the outer result alone, the caller may be unable to tell whether the callee exhausted its Energy allowance or terminated through REVERT. This rule does not apply to low-level calls such as call, send, and delegatecall, which return false on failure rather than reverting automatically.
  3. Creating a contract with new where the constructor does not finish (you cannot specify Energy for new, so all available Energy appears consumed).
  4. Receiving TRX through a public function (constructor, fallback, or any public function) that lacks the payable modifier.
  5. A failed transfer() call.
  6. A revert() call.
  7. A Solidity 0.8+ Panic(uint256) caused by an assert failure, arithmetic overflow or underflow, division by zero, out-of-bounds array access, or another language-level check.
📘

State rollback and Energy accounting

After a contract enters TVM execution, state changes are rolled back whether execution terminates because of a runtime exception such as OUT_OF_TIME or invalid opcode, or through REVERT. This preserves transaction atomicity and data consistency.

Energy accounting depends on how execution terminates:

  • Runtime exceptions that call spendAllEnergy(): Energy is accounted for up to the limit calculated before execution, including the unused remainder.
  • REVERT: Only the Energy used before the error is charged; the unused allowance is not billed.

Energy already consumed is not refunded under either accounting path.

Validation-style exception

Validation-style exceptions are caught by the FullNode before the transaction reaches the TVM. The transaction is not recorded on-chain and no Energy or Bandwidth is consumed.

These exceptions occur when:

  1. The current TVM version does not support a feature the contract requires.
  2. A contract being created has a name longer than 32 bytes.
  3. A contract being created has a consume_user_resource_percent outside the valid range [0, 100].
  4. A contract being created hits a hash collision in the generated contract address.
  5. The call_value is non-zero and exceeds the caller's balance.
  6. The fee_limit is outside the valid range.
  7. A constant request is sent to a node that does not support constant calls.
  8. The triggered contract does not exist in the database.

Illegal VM operation exception

These exceptions are also not recorded on-chain, but the FullNode that broadcast the transaction is penalized at the network layer for a period of time (to prevent spam):

  1. OwnerAddress and OriginAddress mismatch during contract creation.
  2. Broadcasting a constant request (constant calls should not be broadcast).

Stack depth and recursive call limits

The TVM limits contract-call depth to 64 layers, covering cross-contract calls, nested calls, and recursive self-calls. At the limit, the TVM does not enter another call frame: a low-level CALL returns false, and CREATE returns the zero address, without directly forcing the outer execution to REVERT. Solidity high-level contract calls normally check the failure result and revert, while callers using low-level call can inspect the returned false and decide how to handle it.

A separate but related limit is the Solidity compiler's Stack too deep error, which is a compile-time limitation triggered by too many local or temporary variables. This is not directly related to recursion at runtime — but it serves as a reminder to decompose complex functions, reduce local variables, or manually manage stack frames.

When designing recursive logic:

  1. Cap the recursion depth explicitly to prevent runaway recursion.
  2. Estimate per-call Energy so the total stays within fee_limit.
  3. Prefer iteration (for/while loops) or transaction splitting where possible — they almost always cost less Energy than equivalent recursion.

Solidity error handling tools

Solidity provides four mechanisms for error handling: assert, require, revert, and (since 0.8.4) Custom Errors.

assert

assert is for internal invariants that should never be violated. Use it for internal bugs and invariant violations, not as a substitute for require when validating input or permissions. The error format and Energy accounting after an assert failure depend on the Solidity compiler version: older versions commonly emit invalid opcode, while Solidity 0.8+ returns Panic(uint256) and terminates through REVERT.

assert(x == y);

Since Solidity 0.8.0, the compiler inserts a Panic(uint256) error for assert failures.

require

require is for validating external inputs, access control, and other preconditions. A require failure reverts state changes, consumes only Energy used so far, and returns the remainder.

require(msg.sender == owner, "Not the owner");

The error message string is shown in transaction logs and helps users diagnose the failure.

revert

revert aborts execution at any point, with an optional reason string or custom error. It is the most flexible of the four — useful when a require would not naturally fit the control flow.

if (someCondition) {
    revert("Condition not met");
}

Custom errors (Solidity 0.8.4+)

Custom errors give you two advantages over string-based revert reasons:

  1. Lower Energy cost — error selectors are 4 bytes, much cheaper to encode than a long string.
  2. Structured parameters — callers receive typed data (an address, a uint256, etc.) they can use to build a specific error UI.
// Declare the error
error Unauthorized(address caller);

contract MyContract {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    function doSomething() external {
        if (msg.sender != owner) {
            revert Unauthorized(msg.sender);
        }
        // Other logic
    }
}

When Unauthorized(msg.sender) reverts, the TVM rolls back the transaction and returns the unused Energy. Off-chain callers (TronWeb, Trident) decode the selector and parameters automatically when an ABI is available.

Best practices

Energy cost matters in error handling

The termination path directly affects Energy accounting:

  • In Solidity 0.8+, an assert failure returns Panic(uint256) and terminates through REVERT, so only Energy used before the failure is charged. Compilers before 0.8.0 commonly emitted invalid opcode for failed assertions, which can exhaust the execution's Energy allowance.
  • require and explicit revert charge only the Energy used before failure and are appropriate for normal business validation.
  • Custom errors save Energy at the encoding/decoding level versus string-based reverts. Prefer them for any contract that reverts often.

In hot paths, validate cheap-to-check inputs first to avoid wasting Energy on later computation that would be reverted anyway.

Use assert only for invariants

If you find yourself using assert for input validation, switch to require instead. assert failures should indicate a bug in the contract — never a normal user-facing error.

Use require and custom errors for validation

require (and the equivalent revert with a reason or custom error) is the right tool for:

  • Access control (require(msg.sender == owner, ...))
  • Input bounds (require(amount > 0, ...))
  • Contract state preconditions (require(state == State.Open, ...))

Reduce recursion when you can

The TVM's 64-layer call stack and Energy cost both penalize deep recursion. Many recursive algorithms have iterative equivalents that cost less Energy and are easier to reason about. Reach for recursion only when the iterative form is significantly more complex.

Reentrancy considerations

External calls — including recursive self-calls and contract-to-contract calls — surrender execution control to the callee. The callee can re-enter your contract before your function finishes, leading to state-race vulnerabilities (the most famous being the DAO hack on Ethereum).

Use the checks-effects-interactions pattern: validate preconditions first, update state next, then make external calls last. For sensitive paths, consider OpenZeppelin's ReentrancyGuard or equivalent. See Best practices for more.


Related resources