Skip to content

Conversation

i582
Copy link

@i582 i582 commented Sep 13, 2025

Why?

Like any development, writing smart contracts is error prone. What if you read more than you need from a slice? You'll get a VM crash with code 9. But where did the crash occur? What was on the stack just before the crash? How did we get to that branch? A debugger can answer all these questions.

The debugger allows us to go through the program step by step and analyze what went wrong, get the state at any time and gives the ability to analyze this data outside the debugger to understand what went wrong.

Step-by-step emulation is the first step in creating a runtime debugger.

Challenges

The current implementation of the virtual machine and transaction emulator is designed to execute all instructions one by one without interruptions, which makes it impossible to make a runtime debugger in its current form. This PR solves this problem by dividing the execution into several separate parts, which in normal mode follow each other and implement exactly the same behavior as before, but also makes it possible to assemble an API for step-by-step execution from these parts.

The main difficulty of changes for the debugger implementation is returning control after each step.

Let's look at a normal execution. User in this case can be a debugger or another tool:

normal-mode

When we want to call a get method, we send a request and get the result. In the process, TVM executes the code of the given get method, executing each instruction one after another.

Now let's look at what happens during the step-by-step execution that is necessary for debugging:

step-mode

As you can see, in this case we do not get the execution result right away, in the first step we only prepared the emulator and TVM for executing the get method, but no code has been executed yet. At the same time, we return control back to the user and wait until it sends the next request to execute the first instruction. And so step by step the user can go through the entire code of the get method and at each step request additional information, such as the current stack or the value in the control registers.

Thus, the main difficulty in step-by-step execution comes down to the fact that if in normal execution the entire execution state was local, since all the code was executed at once, in step-by-step execution the state must be saved between each execution step so that when the user requests the next step, we restore this state and execute the next step.

Implementation

Step-by-step execution is not needed in production, so the main user and interface from the outside is a transaction emulator that is later built to WASM and then used in the sandbox for local testing and transaction emulation.

TvmEmulator vs TransactionEmulator

In the current implementation of the emulator, the execution of get methods and the emulation of transactions are separated. TvmEmulator is responsible for emulating get methods, and TransactionEmulator is responsible for transactions. This duality leads to the fact that the following functions described that are exported and can be called from WASM have two forms, one for TvmEmulator and one for TransactionEmulator.

transaction_emulator_sbs_emulate_transaction & tvm_emulator_sbs_run_get_method

Prepare a transaction or get method to be emulated (as shown in the previous diagram in the first step).

transaction_emulator_sbs_step & tvm_emulator_sbs_step

Perform the next step of the emulation.

transaction_emulator_sbs_result && tvm_emulator_sbs_get_method_result

Complete the emulation and return the result.

Data getters

The functions for getting the current state (stack, c7 and current position) are also duplicated.

For a more complete description, see the documentation in the code.

Shared state

As described earlier, step-by-step execution adds the need to store the state not locally, but somewhere where it will be preserved between calls. Such places became the emulators themselves: TvmEmulator and TransactionEmulator. Both emulators now contain the state of the virtual machine and a logger. These two fields retain their values from the beginning of emulation in step-by-step mode until the moment of calling transaction_emulator_sbs_result and tvm_emulator_sbs_get_method_result, where they are reset.

TransactionEmulator also has a number of other fields:

std::vector<block::StoragePrices> storage_prices;
block::StoragePhaseConfig storage_phase_cfg{&storage_prices};
block::ComputePhaseConfig compute_phase_cfg{};
block::ActionPhaseConfig action_phase_cfg{};
std::unique_ptr<block::transaction::Transaction> trans{};
block::Account account_{};
bool external{false};
block::SerializeConfig serialize_config{};

Which save the execution state that was previously also local. After calling transaction_emulator_sbs_result these fields are reset so that next transactions will not use the old values.

Unresolved issues

  • What to do with precompiled contracts in step by step mode? Now we execute it if an implementation is found at the preparation stage

TODO

  • vm_loaded_cells in step by step mode
  • Better overall naming
  • Extensive testing

Thanks


void *setup_sbs_get_method(const char *params, const char* stack, const char* config) {
// we need to allocate logger on the heap since it outlive this function unlike `run_get_method`
StringLog* logger = new StringLog();
Copy link
Member

@dungeon-master-666 dungeon-master-666 Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see usage logger->get_string() so apparently it's not needed and can be replaced with NoopLog? Otherwise to prevent memory leak I'd suggest to create a function create_string_logger and destroy_string_logger and add parameter logger to setup_sbs_get_method and emulate_sbs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants