Improved Contract Debugging
The Algorand team continues to add features that will improve the diagnosing and debugging of smart contracts. This article summarizes some of the latest changes added to Algorand to the dryrun endpoint.
As covered in the developer documentation, currently developers can debug issues using two different methods. The first is the ability to use a GUI (tealdbg) to debug contracts line by line. The second way is to use the dryrun REST endpoint, which is incorporated in all of the SDKs. Given some state context, the dryrun endpoint runs a transaction or a group of transactions and returns results from the AVM evaluation. This article details some of the changes to the SDKs for diagnosing issues.
SDK Changes
The Algorand SDKs have been improved to better support diagnosing code issues using a dryrun response from the REST endpoint. Dryrun is used to simulate submitting a transaction to the network. When used for an application or smart signature call, the dryrunendpoint returns information on how the AVM processed the program’s logic. This offers a quick way to find bugs in your contracts or smart sigs.
The latest SDK releases offer some changes to the DryrunResponse object and provide some additional methods that will allow for better understanding of how your contract is being processed.
Example PyTeal Contract
To illustrate these features, the following PyTeal contract is used.
from pyteal import *
args = Txn.application_args
on_complete = lambda oc: Txn.on_completion() == oc
isCreate = Txn.application_id() == Int(0)
isOptIn = on_complete(OnComplete.OptIn)
isClear = on_complete(OnComplete.ClearState)
isClose = on_complete(OnComplete.CloseOut)
isUpdate = on_complete(OnComplete.UpdateApplication)
isDelete = on_complete(OnComplete.DeleteApplication)
isNoOp = on_complete(OnComplete.NoOp)
return_prefix = Bytes("base16", "0x151f7c75") # Literally hash('return')[:4]
@Subroutine(TealType.uint64)
def raise_to_power(x, y):
i = ScratchVar(TealType.uint64)
a = ScratchVar(TealType.uint64)
return Seq(
a.store(x),
For(i.store(Int(1)), i.load() <= y, i.store(i.load() + Int(1))).Do(
a.store(a.load()*x)
),
Log(Concat(return_prefix, Itob(a.load()))),
a.load(),
)
def approval():
router = Cond(
[args[0] == MethodSignature("raise(uint64,uint64)uint64"), raise_to_power(Btoi(args[1]), Btoi(args[2])-Int(1))],
)
return Cond(
[isCreate, Approve()],
[isOptIn, Approve()],
[isClear, Approve()],
[isClose, Approve()],
[isUpdate, Approve()],
[isDelete, Approve()],
[isNoOp, Return(router)]
)
def clear():
return Approve()
def get_approval():
return compileTeal(approval(), mode=Mode.Application, version=6)
def get_clear():
return compileTeal(clear(), mode=Mode.Application, version=6)
if __name__ == "__main__":
with open("app.teal", "w") as f:
f.write(get_approval())
with open("clear.teal", "w") as f:
f.write(get_clear())
The above is a simple ABI compliant smart contract that supports one method, named raise
. This method takes two uint64
arguments and returns a uint64
. Assume these are x
and y
. The ABI method signature is described as ”raise(uint64,uint64)uint64"
. Any other application call transactions with OnComplete
set to NoOp
will be rejected.
router = Cond(
[args[0] == MethodSignature("raise(uint64,uint64)uint64"), raise_to_power(Btoi(args[1]), Btoi(args[2])-Int(1))],
)
The method implementation takes the first argument (x
) and raises it to the power of the second argument (y
). The method uses a subroutine to loop from 1
to y-1
. Before looping the x
value is stored in variable a
which uses scratch space. For each iteration, this stored value is loaded and multiplied by x
and the result is stored back in a
. Once the loop is finished, the return value is logged to the transaction results.
@Subroutine(TealType.uint64)
def raise_to_power(x, y):
i = ScratchVar(TealType.uint64)
a = ScratchVar(TealType.uint64)
return Seq(
a.store(x),
For(i.store(Int(1)), i.load() <= y, i.store(i.load() + Int(1))).Do(
a.store(a.load()*x),
),
Log(Concat(return_prefix, Itob(a.load()))),
a.load(),
)
Calling the Example Contract
To call this deployed contract from the python SDK the following syntax can be used.
# Get suggested parameters
sp = client.suggested_params()
# contruct the ATC (Which supports ABI)
atc = AtomicTransactionComposer()
# Create signer object
signer = AccountTransactionSigner(pk)
# Construct the method object
meth = Method("raise", [Argument("uint64"), Argument("uint64")], Returns("uint64"))
# Add a call to the smart contract to raise 2 to the 3rd power
atc.add_method_call(app_id, meth, addr, sp, signer, method_args=[2,4])
# Execute the transaction
atc.execute(client, 3);
In this example, the Atomic Transaction Composer(ATC) is used because it natively supports calling ABI methods. The code creates the ATC object, creates a signer object to sign the transaction, constructs the ABI method call, and then adds it to the ATC object. Finally, the ATC object is executed.
Using Dryrun to Debug the Contract
Now suppose the code above fails or returns inaccurate results. In this case, you can either use the Teal Debugger to debug the contract line by line or you can use the Dryrun REST endpoint to simulate the entire processing of the contract. To use dryrun, you need to add the following to the code:
drr = transaction.create_dryrun(client, atc.gather_signatures())
dr = client.dryrun(drr)
dryrun_result = DryrunResponse(dr)
The first line creates the DryrunRequest object and the second line sends the object to the connected node to process. The DryrunResponse
object is the primary object used to format the response from the node. This object will contain every transaction that is specified in the ATC. This list of transactions can be iterated over in Python using the following code.
for txn in dryrun_result.txns:
For each transaction in the response, you have several methods that can now be used to list out various parts of the dryrun (these are the new updates!). The primary method is the app_trace
method which will contain the entire stack trace of the executed contract. To print this out use the following code.
print(txn.app_trace(StackPrinterConfig(max_value_width=30, top_of_stack_first=True)))
Note that this method takes a StackPrinterConfig
object that has two parameters. The first formats the width of the returned trace and the second parameter determines if the stack is displayed in the list as the top of the stack or the bottom of the stack first.
Executing the above code results in the following.
The pc#
column in this trace is the program counter and effectively tells you the byte number in the compiled contract. The ln#
column is the line number in the corresponding TEAL code. The scratch
column represents a write to the scratch space and the stack
column displays what was on the stack at the time the line was processed. So if we pass the two arguments to the example contract (x=2,y=4) the following will show in the stack trace.
At the top of this image, you can see where the subroutine is called (callsub
). The store 1
line shows when the code stores the initial y-1
variable. Note that it shows up in the scratch trace in the following line with the value 1 = 3
. So in slot 1 of scratch space, a value of 3 is written. The x
variable is then stored in slot 0. The stack column in the above image at the time of calling store 1
contains the values [3,2]
, which means we have the value 3 at the top of the stack and the value 2 right below it. You can reverse this order using the top_of_stack_first=False
parameter to the StackPrinterConfig. Note that storing the value pops it from the stack which is shown in the line below the operation. Lines 72 - 80 show the for loop within the code. Line 78 shows the multiplication of x
times x
and this value is stored in scratch space 3. This is the accumulator. If the contract runs correctly the bottom of the trace will print out the following.
Line 86 shows where the code loads the return byte string, 87-90 illustrate the loading of the accumulated value and converting it to bytes, concatenating the two, and finally logging it. Line 91 loads the accumulated value onto the top of the stack and returns from the subroutine, and exits the program with the final return. Because we have a 16 (a non-zero value) on the top of the stack the program execution will return successful.
Additional methods available with the dryrun response object allow printing out changes to global or local state variables, the logs, the opcode cost of the specific execution, and the result of the app call. These can be added using the following python code.
print(txn.app_trace(StackPrinterConfig(max_value_width=30, top_of_stack_first=True)))
print("Global Changes: ", txn.local_deltas)
print("Local Changes: ",txn.global_delta)
print("Opcode Cost: ",txn.cost)
print("Logs: ",txn.logs)
print("App Message: ",txn.app_call_messages)
The cost method can be valuable in determining how much budget you have left within your contract. Each standalone application call has a budget of 700. This can be expanded using additional inner transactions but if making this call with our test contract this will print out the following values.
For the parameters we passed we have no global or local state changes, the opcode cost was 97, the logs contain the bytes for the concatenated return string and the value 16, and finally the app messages indicate that the approval program was called and passed execution. If we change the parameters to x
=2 and y
=100, the results will be very different.
We actually have two errors here. The first is the overflow, which occurs in the loop when the result of x
* x
would exceed the capacity of the uint64. Additionally the opcode cost is 859, which exceeds our limit of 700. If we did not have the overflow the call would still fail because of the opcode budget constraint. The app message explains that the call is rejected and returns the last few lines in the program executed.
If you only want to show transaction failures you can change the code to use the app_call_rejected
method.
for txn in dryrun_result.txns:
if txn.app_call_rejected():
print(txn.app_trace(StackPrinterConfig(max_value_width=30, top_of_stack_first=True)))
print(txn.local_deltas)
print(txn.global_delta)
print(txn.cost)
print(txn.logs)
Debugging with the GUI
The DryRunRequest object can also be written to disk and used with the tealdbg
command line tool to step through each line in the Teal program. To do this, the following can be added to the above code.
Info
This section briefly covers using the Teal Debugger. For more information on the debugger see the developer documentation.
filename = "./dryrun.msgp"
with open(filename, "wb") as f:
import base64
f.write(base64.b64decode(msgpack_encode(drr)))
This will save the message pack encoded instance of the DryRunRequest to the dryrun.msgp file. To run the visual debugger, developers can simply run the following command. Note that I am setting the specific debugging port as you may get a port conflict if you are running Sandbox at the same time.
tealdbg debug -d dryrun.msgp --remote-debugging-port 9399
This will launch the debugger listener which can be connected to with Chrome Developer Tools. Open the Chrome browser enter the URL chrome://inspect
.
Then select the configure button and enter localhost: followed by the specific port the listener is currently listening on.
Click done and under Remote Target the Algorand TEAL debugger will be listed.
Click the inspect link to start the debugging session.
Future Improvements
The engineering team is continuing to improve the debugging experience and is already implementing an improved version of the dryrun feature to better support debugging grouped transactions. The next version of dryrun will correctly emulate on-chain logic accounting for all state changes within a group. This means that if one transaction in a group changes state, subsequent debugged transactions will reflect these changes.