Understanding the TEAL Opcode Budget
Unlike other blockchains such as Ethereum, Algorand does not charge fees based on the computational cost of a smart contract call. Instead, smart contracts are given an overall opcode budget of 700 (application calls fail if this limit is surpassed). Opcode costs for each of TEAL’s opcodes are given here.
There are many advantages to this approach. Unlike in Ethereum, developers do not have to sacrifice readability for negligible performance improvements, as fees are flat instead of dependent on computational usage. This limit also ensures that computationally expensive contracts do not add unnecessary bloat to single blocks.
In this guide, we’ll go over how the Algorand Virtual Machine (AVM) handles these opcode budgets. We’ll also take advantage of a clever feature of atomic transactions to (finitely) extend it.
Basic familiarity with PyTEAL is assumed, but not more than what’s covered in Algorand’s PyTeal overview.
Requirements
For this tutorial, you’ll need the following requirements:
- A pre-funded Algorand account. You can fund an account using the testnet bank.
- A running sandbox instance.
- The latest version of PyTeal installed (0.9.1 at the time of writing).
pip install pyteal==0.9.1
- The Algorand Python SDK used for interacting with the Algorand blockchain.
pip install py-algorand-sdk
Steps
1. Python SDK Setup
For this project, you’ll want to have two files: contracts.py
and testing.py
. Below is some Algorand Python SDK boilerplate for executing transactions, which should go in testing.py
.
import base64
from algosdk.future import transaction
from algosdk import account, mnemonic, logic
from algosdk.v2client import algod
from algosdk.logic import get_application_address
from contracts import *
def compile_program(client, source_code):
compile_response = client.compile(source_code)
return base64.b64decode(compile_response['result'])
def create_app(client, private_key, approval_program, clear_program, global_schema, local_schema):
sender = account.address_from_private_key(private_key)
on_complete = transaction.OnComplete.NoOpOC.real
params = client.suggested_params()
txn = transaction.ApplicationCreateTxn(sender, params, on_complete,
approval_program, clear_program,
global_schema, local_schema)
return execute_transaction(client, txn, private_key)
def execute_transaction(client, txn, private_key):
signed_txn = txn.sign(private_key)
tx_id = signed_txn.transaction.get_txid()
client.send_transactions([signed_txn])
transaction.wait_for_confirmation(client, tx_id, 10)
return client.pending_transaction_info(tx_id)
def execute_group_transaction(client, txns, private_key):
stxns = []
for txn in txns:
stxns.append(txn.sign(private_key))
tx_id = client.send_transactions(stxns)
transaction.wait_for_confirmation(client, tx_id, 10)
ALGOD_TOKEN = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
ALGOD_ADDRESS = "http://localhost:4001"
algod_client = algod.AlgodClient(ALGOD_TOKEN, ALGOD_ADDRESS)
MNEMONIC = "YOUR_MNEMONIC_HERE" # replace with your own mnemonic
pkey = mnemonic.to_private_key(MNEMONIC)
compiled_approval = compile_program(algod_client, approval_program())
compiled_clearstate = compile_program(algod_client, clear_state_program())
global_schema = transaction.StateSchema(0, 0) # no ints or bytes stored in global or local state
local_schema = transaction.StateSchema(0, 0)
The compile_program
and create_app
functions are all taken from Algorand’s PyTEAL overview.
I’ve also added two new functions: execute_transaction
and execute_group_transaction
, which call an arbitrary transaction and grouped transactions (given an array of transactions), respectively.
The create_app
function has also been modified to use execute_transaction
to avoid redundancy.
As you might have noticed, we are importing from contracts.py
at the top, as well as calling undefined functions approval_program()
and clear_state_program()
. Let’s go make them.
2. Creating and Executing an Empty Contract
In contracts.py
, create the following two functions:
from pyteal import *
def approval_program():
return compileTeal(Approve(), Mode.Application, version=5)
def clear_state_program():
return compileTeal(Approve(), Mode.Application, version=5)
These two basic functions handle every potential app call with an Approve()
statement. You may see some examples using Return(Int(1))
as a default approval return value; this has now been replaced by the equivalent Approve()
statement (with Reject()
corresponding to Return(Int(0))
).
Now try running testing.py
. You should get nothing in return. Fantastic!
Next, let’s properly set up our approval_program()
to handle different app calls:
def approval_program():
handle_noop=Seq([
Approve(),
])
program = Cond(
[Txn.application_id() == Int(0), Approve()],
[Txn.on_completion() == OnComplete.OptIn, Reject()],
[Txn.on_completion() == OnComplete.CloseOut, Reject()],
[Txn.on_completion() == OnComplete.UpdateApplication, Reject()],
[Txn.on_completion() == OnComplete.DeleteApplication, Reject()],
[Txn.on_completion() == OnComplete.NoOp, handle_noop]
)
return compileTeal(program, Mode.Application, version=5)
Here, we’ve (obviously) allowed for app creation (when an app call transaction is sent with an application ID of 0). Update and delete transactions are not allowed, and opt-in/close-out calls are also rejected since our smart contract will not be dealing with the local state. The most important transaction type to handle here is a NoOp transaction, which we’ve fed through to an empty Seq()
right now.
Now, let’s deploy and execute this smart contract with the Python SDK. At the bottom of testing.py
, add the following:
createAppTxn = create_app(algod_client, pkey, compiled_approval, compiled_clearstate, global_schema, local_schema)
app_id = createAppTxn['application-index']
sender = account.address_from_private_key(pkey)
noopTxn = transaction.ApplicationNoOpTxn(sender, algod_client.suggested_params(), app_id)
execute_transaction(algod_client, noopTxn, pkey)
The contract will now execute the Approve()
statement in handle_noop, but nothing should print to the console.
3. Opcode Overview
Let’s now explore the mechanics behind the Algorand Virtual Machine (AVM) opcode budget system. We’ll be working before the Approve()
statement in the handle_noop Seq
, as this is what will be executed during a standard app call. From the opcode budget list, we see that the Keccak256 hash has a cost of 130. To test this out, add the following lines to your Seq
:
handle_noop=Seq([
Pop(Keccak256(Bytes("a"))),
Pop(Keccak256(Bytes("b"))),
Pop(Keccak256(Bytes("c"))),
Pop(Keccak256(Bytes("d"))),
Pop(Keccak256(Bytes("e"))),
Approve(),
])
There is no significance to the letters we are feeding in; the cost will be the same regardless of the input bytes. To ensure that the execution stack is clear before the Approve()
is reached, we are popping each generated hash as we go. If we try running testing.py
again, we see nothing happens, as expected: (5 hashes) * (130 per hash) = 650 < 700. However, if we add another hash to our Seq
:
handle_noop=Seq([
Pop(Keccak256(Bytes("a"))),
Pop(Keccak256(Bytes("b"))),
Pop(Keccak256(Bytes("c"))),
Pop(Keccak256(Bytes("d"))),
Pop(Keccak256(Bytes("e"))),
Pop(Keccak256(Bytes("f"))),
Approve(),
])
The execution fails, with “logic eval error: dynamic cost budget exceeded”.
4. Control Flow
How are opcode budgets calculated across different potential execution paths? In previous versions of the AVM, opcode budgets were calculated line-by-line, regardless of which statements would be executed. For example, an If-Else pair with Keccak256 hashes in each code block would contribute 2*130 to the overall budget, despite only one hash being computed during execution. However, the AVM now tallies opcodes as a program executes, ensuring there is sufficient budget remaining before executing each statement and failing only if the 700 budget will be exceeded.
To demonstrate, wrap the final two Keccak256 hashes in an If-Else pair:
handle_noop=Seq([
Pop(Keccak256(Bytes("a"))),
Pop(Keccak256(Bytes("b"))),
Pop(Keccak256(Bytes("c"))),
Pop(Keccak256(Bytes("d"))),
If(Int(1)).Then(
Pop(Keccak256(Bytes("e"))),
).Else(
Pop(Keccak256(Bytes("f"))),
),
Approve(),
])
As expected, the transaction stays within its opcode budget and does not fail. While this if statement is obviously not useful, it nicely demonstrates the underlying mechanism for computing opcode usage; as either path in the if statement results in 5 total hash computations.
5. Expanding the budget
What if you need a budget larger than 700? As of TEAL 4, opcode budgets are shared across group transactions, so your total shared budget for a grouped transaction can be up to 16 * 700 = 11200 (from the 16 maximum number of transactions in an atomic transaction). To show how this works, let’s first construct the following group transaction in testing.py
:
noopTxn = transaction.ApplicationNoOpTxn(sender, algod_client.suggested_params(), APP_ID, [0])
noopTxn2 = transaction.ApplicationNoOpTxn(sender, algod_client.suggested_params(), APP_ID, [1])
groupTxnId = transaction.calculate_group_id([noopTxn, noopTxn2])
noopTxn.group = groupTxnId
noopTxn2.group = groupTxnId
execute_group_transaction(algod_client, [noopTxn, noopTxn2], pkey)
Here, we are creating two nearly-identical NoOp application calls, differing only by a single parameter (0 for noopTxn
and 1 for noopTxn2
). We then package them into a single group transaction and execute them using the helper function execute_group_transaction
provided at the beginning. Next, let’s update contracts.py
to handle these different parameters. Inside the handle_noop Seq()
, let’s modify our If-Else:
handle_noop=Seq([
Pop(Keccak256(Bytes("a"))),
Pop(Keccak256(Bytes("b"))),
Pop(Keccak256(Bytes("c"))),
Pop(Keccak256(Bytes("d"))),
If(Btoi(Txn.application_args[0])).Then(Seq([
Pop(Keccak256(Bytes("e"))),
Pop(Keccak256(Bytes("f"))),
]))
.Else(Seq([
Pop(Keccak256(Bytes("g"))),
])),
Approve(),
])
The Btoi(Txn.application_args[0]) will evaluate to false (Int(0)
) if our argument is 0, and true otherwise. From our group transaction, we see that the first NoOp transaction will evaluate to false, while the second will be true. Running testing.py
again, we find that the execution fails: Although we have a total budget of (700-10) * 2 = 1380, the first transaction computes hashes for the letters a-d and g for an opcode cost of 5 * 130 = 650, while the second transaction computes hashes for a-f for an opcode cost of 6 * 130 = 780. The total opcode cost for the group transaction, therefore, is 650 + 780 = 1430, exceeding the 1400 limit.
However, if we remove the “g” hash in the Else
block (just commenting it out), the execution succeeds. While the second transaction alone (the one in which the first If
block is executed) will compute hashes for the letters a-f for an opcode cost of 6 * 130 = 780 > 700, the first transaction only computes the four hashes before the if-else block, for an opcode cost of 4 * 130 = 520. The total opcode cost for the group transaction is then 520 + 780 = 1300, which is less than the 1400 limit.
6. Conclusion
Congratulations! You now understand the TEAL opcode budget and are able to use atomic transactions to increase this budget amount. TEAL is evolving rapidly, so make sure to stay informed for future changes, such as budget increases beyond the current 700 or other ways to increase the budget yourself, such as by creating “inner applications.”