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
- Visual Studio Code (version 1.80.0 or higher)
- Node.js (version 18.x or higher)
- algokit-cli installed
- Algokit AVM VSCode Debugger extension installed
- Basic understanding of Algorand smart contracts using Python
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:

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=falsefrom 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 = 1GUEST_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_utilsfrom algosdk.v2client.algod import AlgodClientfrom algosdk.v2client.indexer import IndexerClientfrom algokit_utils import ( EnsureBalanceParameters, TransactionParameters, ensure_funded,)from algokit_utils.beta.algorand_client import AlgorandClientimport base64
import algosdk.abifrom algokit_utils import ( EnsureBalanceParameters, TransactionParameters, ensure_funded,)from algokit_utils.beta.algorand_client import AlgorandClientfrom algokit_utils.beta.client_manager import AlgoSdkClientsfrom algokit_utils.beta.composer import PayParamsfrom algosdk.atomic_transaction_composer import TransactionWithSignerfrom algosdk.util import algos_to_microalgosfrom algosdk.v2client.algod import AlgodClientfrom algosdk.v2client.indexer import IndexerClient
logger = logging.getLogger(__name__)
# define deployment behaviour based on supplied app specdef 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 configconfig.configure(debug=True, trace_all=True)
For more details, refer to Debugger:
Next compile the smart contract using AlgoKit:
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:
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.

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

Step 5: Debugging the smart contract
Let’s now debug the issue:

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.

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.

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.

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.