Skip to content

AVM Debugger

The AVM VSCode debugger enables inspection of blockchain logic through Simulate Traces - JSON files containing detailed transaction execution data without on-chain deployment. The extension requires both trace files and source maps that link original code (TEAL or Puya) to compiled instructions. While the extension works independently, projects created with algokit templates include utilities that automatically generate these debugging artifacts. For full list of available capabilities of debugger extension refer to this documentation.

This tutorial demonstrates the workflow using a Python-based Algorand project. We will walk through identifying and fixing a bug in an Algorand smart contract using the Algorand Virtual Machine (AVM) Debugger. We’ll start with a simple, smart contract containing a deliberate bug, and by using the AVM Debugger, we’ll pinpoint and fix the issue. This guide will walk you through setting up, debugging, and fixing a smart contract using this extension.

Prerequisites

Step 1: Setup the Debugging Environment

Install the Algokit AVM VSCode Debugger extension from the VSCode Marketplace by going to extensions in VSCode, then search for Algokit AVM Debugger and click install. You should see the output like the following:

AVM Debugger Extension

Step 2: Set Up the Example Smart Contract

We aim to debug smart contract code in a project generated via algokit init. Refer to set up Algokit. Here’s the Algorand Python code for an tictactoe smart contract. The bug is in the move method, where games_played is updated by 2 for guest and 1 for host (which should be updated by 1 for both guest and host).

Remove hello_world folder Create a new tic tac toe smart contract starter via algokit generate smart-contract -a contract_name "TicTacToe" Replace the content of contract.py with the code below.

# pyright: reportMissingModuleSource=false
from typing import Literal, Tuple, TypeAlias
from algopy import (
ARC4Contract,
BoxMap,
Global,
LocalState,
OnCompleteAction,
Txn,
UInt64,
arc4,
gtxn,
itxn,
op,
subroutine,
urange,
)
Board: TypeAlias = arc4.StaticArray[arc4.Byte, Literal[9]]
HOST_MARK = 1
GUEST_MARK = 2
class GameState(arc4.Struct, kw_only=True):
board: Board
host: arc4.Address
guest: arc4.Address
is_over: arc4.Bool
turns: arc4.UInt8
class TicTacToe(ARC4Contract):
def __init__(self) -> None:
self.id_counter = UInt64(0)
self.games_played = LocalState(UInt64)
self.games_won = LocalState(UInt64)
self.games = BoxMap(UInt64, GameState)
@subroutine
def opt_in(self) -> None:
self.games_played[Txn.sender] = UInt64(0)
self.games_won[Txn.sender] = UInt64(0)
@arc4.abimethod(allow_actions=[OnCompleteAction.NoOp, OnCompleteAction.OptIn])
def new_game(self, mbr: gtxn.PaymentTransaction) -> UInt64:
if Txn.on_completion == OnCompleteAction.OptIn:
self.opt_in()
self.id_counter += 1
assert mbr.receiver == Global.current_application_address
pre_new_game_box, exists = op.AcctParamsGet.acct_min_balance(
Global.current_application_address
)
assert exists
self.games[self.id_counter] = GameState(
board=arc4.StaticArray[arc4.Byte, Literal[9]].from_bytes(op.bzero(9)),
host=arc4.Address(Txn.sender),
guest=arc4.Address(),
is_over=arc4.Bool(False), # noqa: FBT003
turns=arc4.UInt8(),
)
post_new_game_box, exists = op.AcctParamsGet.acct_min_balance(
Global.current_application_address
)
assert exists
assert mbr.amount == (post_new_game_box - pre_new_game_box)
return self.id_counter
@arc4.abimethod
def delete_game(self, game_id: UInt64) -> None:
game = self.games[game_id].copy()
assert game.guest == arc4.Address() or game.is_over.native
assert Txn.sender == self.games[game_id].host.native
pre_del_box, exists = op.AcctParamsGet.acct_min_balance(
Global.current_application_address
)
assert exists
del self.games[game_id]
post_del_box, exists = op.AcctParamsGet.acct_min_balance(
Global.current_application_address
)
assert exists
itxn.Payment(
receiver=game.host.native, amount=pre_del_box - post_del_box
).submit()
@arc4.abimethod(allow_actions=[OnCompleteAction.NoOp, OnCompleteAction.OptIn])
def join(self, game_id: UInt64) -> None:
if Txn.on_completion == OnCompleteAction.OptIn:
self.opt_in()
assert self.games[game_id].host.native != Txn.sender
assert self.games[game_id].guest == arc4.Address()
self.games[game_id].guest = arc4.Address(Txn.sender)
@arc4.abimethod
def move(self, game_id: UInt64, x: UInt64, y: UInt64) -> None:
game = self.games[game_id].copy()
assert not game.is_over.native
assert game.board[self.coord_to_matrix_index(x, y)] == arc4.Byte()
assert Txn.sender == game.host.native or Txn.sender == game.guest.native
is_host = Txn.sender == game.host.native
if is_host:
assert game.turns.native % 2 == 0
self.games[game_id].board[self.coord_to_matrix_index(x, y)] = arc4.Byte(
HOST_MARK
)
else:
assert game.turns.native % 2 == 1
self.games[game_id].board[self.coord_to_matrix_index(x, y)] = arc4.Byte(
GUEST_MARK
)
self.games[game_id].turns = arc4.UInt8(
self.games[game_id].turns.native + UInt64(1)
)
is_over, is_draw = self.is_game_over(self.games[game_id].board.copy())
if is_over:
self.games[game_id].is_over = arc4.Bool(True)
self.games_played[game.host.native] += UInt64(1)
self.games_played[game.guest.native] += UInt64(2) # incorrect code here
if not is_draw:
winner = game.host if is_host else game.guest
self.games_won[winner.native] += UInt64(1)
@arc4.baremethod(allow_actions=[OnCompleteAction.CloseOut])
def close_out(self) -> None:
pass
@subroutine
def coord_to_matrix_index(self, x: UInt64, y: UInt64) -> UInt64:
return 3 * y + x
@subroutine
def is_game_over(self, board: Board) -> Tuple[bool, bool]:
for i in urange(3):
# Row check
if board[3 * i] == board[3 * i + 1] == board[3 * i + 2] != arc4.Byte():
return True, False
# Column check
if board[i] == board[i + 3] == board[i + 6] != arc4.Byte():
return True, False
# Diagonal check
if board[0] == board[4] == board[8] != arc4.Byte():
return True, False
if board[2] == board[4] == board[6] != arc4.Byte():
return True, False
# Draw check
if (
board[0]
== board[1]
== board[2]
== board[3]
== board[4]
== board[5]
== board[6]
== board[7]
== board[8]
!= arc4.Byte()
):
return True, True
return False, False

Add the below deployment code in deploy.config file:

import logging
import algokit_utils
from algosdk.v2client.algod import AlgodClient
from algosdk.v2client.indexer import IndexerClient
from algokit_utils import (
EnsureBalanceParameters,
TransactionParameters,
ensure_funded,
)
from algokit_utils.beta.algorand_client import AlgorandClient
import base64
import algosdk.abi
from algokit_utils import (
EnsureBalanceParameters,
TransactionParameters,
ensure_funded,
)
from algokit_utils.beta.algorand_client import AlgorandClient
from algokit_utils.beta.client_manager import AlgoSdkClients
from algokit_utils.beta.composer import PayParams
from algosdk.atomic_transaction_composer import TransactionWithSigner
from algosdk.util import algos_to_microalgos
from algosdk.v2client.algod import AlgodClient
from algosdk.v2client.indexer import IndexerClient
logger = logging.getLogger(__name__)
# define deployment behaviour based on supplied app spec
def deploy(
algod_client: AlgodClient,
indexer_client: IndexerClient,
app_spec: algokit_utils.ApplicationSpecification,
deployer: algokit_utils.Account,
) -> None:
from smart_contracts.artifacts.tictactoe.tic_tac_toe_client import (
TicTacToeClient,
)
app_client = TicTacToeClient(
algod_client,
creator=deployer,
indexer_client=indexer_client,
)
app_client.deploy(
on_schema_break=algokit_utils.OnSchemaBreak.AppendApp,
on_update=algokit_utils.OnUpdate.AppendApp,
)
last_game_id = app_client.get_global_state().id_counter
algorand = AlgorandClient.from_clients(AlgoSdkClients(algod_client, indexer_client))
algorand.set_suggested_params_timeout(0)
host = algorand.account.random()
ensure_funded(
algorand.client.algod,
EnsureBalanceParameters(
account_to_fund=host.address,
min_spending_balance_micro_algos=algos_to_microalgos(200_000),
),
)
print(f"balance of host address: ",algod_client.account_info(host.address)["amount"]);
print(f"host address: ",host.address);
ensure_funded(
algorand.client.algod,
EnsureBalanceParameters(
account_to_fund=app_client.app_address,
min_spending_balance_micro_algos=algos_to_microalgos(10_000),
),
)
print(f"app_client address: ",app_client.app_address);
game_id = app_client.opt_in_new_game(
mbr=TransactionWithSigner(
txn=algorand.transactions.payment(
PayParams(
sender=host.address,
receiver=app_client.app_address,
amount=2_500 + 400 * (5 + 8 + 75),
)
),
signer=host.signer,
),
transaction_parameters=TransactionParameters(
signer=host.signer,
sender=host.address,
boxes=[(0, b"games" + (last_game_id + 1).to_bytes(8, "big"))],
),
)
guest = algorand.account.random()
ensure_funded(
algorand.client.algod,
EnsureBalanceParameters(
account_to_fund=guest.address,
min_spending_balance_micro_algos=algos_to_microalgos(10),
),
)
app_client.opt_in_join(
game_id=game_id.return_value,
transaction_parameters=TransactionParameters(
signer=guest.signer,
sender=guest.address,
boxes=[(0, b"games" + game_id.return_value.to_bytes(8, "big"))],
),
)
moves = [
((0, 0), (2, 2)),
((1, 1), (2, 1)),
((0, 2), (2, 0)),
]
for host_move, guest_move in moves:
app_client.move(
game_id=game_id.return_value,
x=host_move[0],
y=host_move[1],
transaction_parameters=TransactionParameters(
signer=host.signer,
sender=host.address,
boxes=[(0, b"games" + game_id.return_value.to_bytes(8, "big"))],
accounts=[guest.address],
),
)
# app_client.join(game_id=game_id.return_value)
app_client.move(
game_id=game_id.return_value,
x=guest_move[0],
y=guest_move[1],
transaction_parameters=TransactionParameters(
signer=guest.signer,
sender=guest.address,
boxes=[(0, b"games" + game_id.return_value.to_bytes(8, "big"))],
accounts=[host.address],
),
)
game_state = algosdk.abi.TupleType(
[
algosdk.abi.ArrayStaticType(algosdk.abi.ByteType(), 9),
algosdk.abi.AddressType(),
algosdk.abi.AddressType(),
algosdk.abi.BoolType(),
algosdk.abi.UintType(8),
]
).decode(
base64.b64decode(
algorand.client.algod.application_box_by_name(
app_client.app_id, box_name=b"games" + game_id.return_value.to_bytes(8, "big")
)["value"]
)
)
assert game_state[3]

Step 3: Compile & Deploy the Smart Contract

To enable debugging mode and full tracing for each step in the execution, go to main.py file and add:

from algokit_utils.config import config
config.configure(debug=True, trace_all=True)

For more details, refer to Debugger:

Next compile the smart contract using AlgoKit:

Terminal window
algokit project run build

This will generate the following files in artifacts: approval.teal, clear.teal, clear.puya.map, approval.puya.map and arc32.json files. The .puya.map files are result of the execution of puyapy compiler (which project run build command orchestrated and invokes automatically). The compiler has an option called --output-source-maps which is enabled by default.

Deploy the smart contract on localnet:

Terminal window
algokit project deploy localnet

This will automatically generate *.appln.trace.avm.json files in debug_traces folder, .teal and .teal.map files in sources.

The .teal.map files are source maps for TEAL and those are automatically generated every time an app is deployed via algokit-utils. Even if the developer is only interested in debugging puya source maps, the teal source maps would also always be available as a backup in case there is a need to fall back to more lower level source map.

Expected Behavior

The expected behavior is that games_played should be updated by 1 for both guest and host

Bug

When move method is called, games_played will get updated incorrectly for guest player.

Step 4: Start the debugger

In the VSCode, go to run and debug on left side. This will load the compiled smart contract into the debugger. In the run and debug, select debug TEAL via Algokit AVM Debugger. It will ask to select the appropriate debug_traces file.

AVM Debugger Debug Traces

Figure: Load Debugger in VSCode

Next it will ask you to select the source map file. Select the approval.puya.map file. Which would indicate to the debug extension that you would like to debug the given trace file using Puya sourcemaps, allowing you to step through high level python code. If you need to change the debugger to use TEAL or puya sourcemaps for other frontends such as Typescript, remove the individual record from .algokit/sources/sources.avm.json file or run the debugger commands via VSCode command palette

AVM Debugger Map File

Step 5: Debugging the smart contract

Let’s now debug the issue:

AVM Debugger Started

Enter into the app_id of the transaction_group.json file. This opens the contract. Set a breakpoint in the move method. You can also add additional breakpoints.

AVM Debugger Smart Contract

On left side, you can see Program State which includes program counter, opcode, stack, scratch space. In On-chain State you will be able to see global, local and box storages for the application id deployed on localnet.

:::note: We have used localnet but the contracts can be deployed on any other network. A trace file is in a sense agnostic of the network in which the trace file was generated in. As long as its a complete simulate trace that contains state, stack and scratch states in the execution trace - debugger will work just fine with those as well. :::

Once you start step operations of debugging, it will get populated according to the contract. Now you can step-into the code.

Step 6: Analyze the Output

Observe the games_played variable for guest is increased by 2 (incorrectly) whereas for host is increased correctly.

AVM Debugger Bug

Step 7: Fix the Bug

Now that we’ve identified the bug, let’s fix it in our original smart contract in move method:

@arc4.abimethod
def move(self, game_id: UInt64, x: UInt64, y: UInt64) -> None:
game = self.games[game_id].copy()
assert not game.is_over.native
assert game.board[self.coord_to_matrix_index(x, y)] == arc4.Byte()
assert Txn.sender == game.host.native or Txn.sender == game.guest.native
is_host = Txn.sender == game.host.native
if is_host:
assert game.turns.native % 2 == 0
self.games[game_id].board[self.coord_to_matrix_index(x, y)] = arc4.Byte(
HOST_MARK
)
else:
assert game.turns.native % 2 == 1
self.games[game_id].board[self.coord_to_matrix_index(x, y)] = arc4.Byte(
GUEST_MARK
)
self.games[game_id].turns = arc4.UInt8(
self.games[game_id].turns.native + UInt64(1)
)
is_over, is_draw = self.is_game_over(self.games[game_id].board.copy())
if is_over:
self.games[game_id].is_over = arc4.Bool(True)
self.games_played[game.host.native] += UInt64(1)
self.games_played[game.guest.native] += UInt64(1) # changed here
if not is_draw:
winner = game.host if is_host else game.guest
self.games_won[winner.native] += UInt64(1)

Step 8: Re-deploy

Re-compile and re-deploy the contract using the step 3.

Step 9: Verify again using Debugger

Reset the sources.avm.json file, then restart the debugger selecting approval.puya.source.map file. Run through steps 4 to 6 to verify that the games_played now updates as expected, confirming the bug has been fixed as seen below.

AVM Debugger Correct Code

Summary

In this tutorial, we walked through the process of using the AVM debugger from AlgoKit Python utils to debug an Algorand Smart Contract. We set up a debugging environment, loaded a smart contract with a planted bug, stepped through the execution, and identified the issue. This process can be invaluable when developing and testing smart contracts on the Algorand blockchain. It’s highly recommended to thoroughly test your smart contracts to ensure they function as expected and prevent costly errors in production before deploying them to the main network.

Next steps

To learn more, refer to documentation of the debugger extension to learn more about Debugging session.