ABI Support
Warning
- ABI support is still taking shape and is subject to backwards incompatible changes.
Based on feedback, the API and usage patterns are likely to change.
For the following use cases, feel encouraged to rely on abstractions. Expect a best-effort attempt to minimize backwards incompatible changes along with a migration path.
ABIReturnSubroutine
usage for ABI Application entry point definition.Router
usage for defining how to route program invocations.
For general purpose
Subroutine
definition usage, use at your own risk. Based on feedback, the API and usage patterns will change more freely and with less effort to provide migration paths.
For these reasons, we strongly recommend using pragma
or the Pragma
expression to pin the version of PyTeal in your source code. See Version Pragmas for more information.
ARC-4 introduces a set of standards that increase the interoperability of smart contracts in the Algorand ecosystem. This set of standards is commonly referred to as Algorand’s application binary interface, or ABI.
This page will introduce and explain the relevant concepts necessary to build a PyTeal application that adheres to the ARC-4 ABI standards.
Types
The ABI supports a variety of data types whose encodings are standardized.
Note
Be aware that the ABI type system in PyTeal has been designed specifically for the limited use case of describing a program’s inputs and outputs. At the time of writing, we do not recommend using ABI types in a program’s internal storage or computation logic, as the more basic TealType.uint64
and TealType.bytes
Expr
types are far more efficient for these purposes.
Fundamentals
Before diving into the specific ABI types, let us first explain the the fundamentals of PyTeal’s ABI type system, which includes behavior common to all ABI types.
abi.BaseType
abi.BaseType
is an abstract base class that all ABI type classes inherit from. This class defines a few methods common to all ABI types:
abi.BaseType.decode(...)
is used to decode and populate a type’s value from an encoded byte string.abi.BaseType.encode()
is used to encode a type’s value into an encoded byte string.abi.BaseType.type_spec()
is used to get an instance ofabi.TypeSpec
that describes that type.
abi.TypeSpec
abi.TypeSpec
is an abstract base class used to describe ABI types. Every child class of abi.BaseType
also has a companion abi.TypeSpec
child class. The abi.TypeSpec
class has a few methods that return information about the type it represents, but one of the class’s most important features is the method abi.TypeSpec.new_instance()
, which creates and returns a new abi.BaseType
instance of the ABI type it represents.
Static vs Dynamic Types
An important property of an ABI type is whether it is static or dynamic.
Static types are defined as types whose encoded length does not depend on the value of that type. This property allows encoding and decoding of static types to be more efficient. For example, the encoding of a boolean
type will always have a fixed length, regardless of whether the value is true or false.
Likewise, dynamic types are defined as types whose encoded length does in fact depend on the value that type has. For example, it’s not possible to know the encoding size of a variable-sized string
type without also knowing its value. Due to this dependency on values, the code that PyTeal generates to encode, decode, and manipulate dynamic types is more complex and generally less efficient than the code needed for static types.
Because of the difference in complexity and efficiency when working with static and dynamic types, we strongly recommend using static types over dynamic types whenever possible. Using static types generally makes your program’s resource usage more predictable as well, so you can be more confident your app has enough computation budget and storage space when using static types.
Instantiating Types
There are a few ways to create an instance of an ABI type. Each method produces the same result, but some may be more convenient than others in certain situations.
Note
The following examples reference specific ABI types, which will be introduced in the Type Categories section.
With the Constructor
The most straightforward way is to use its constructor, like so:
from pyteal import *
my_uint8 = abi.Uint8()
my_uint64 = abi.Uint64()
my_array = abi.StaticArray(abi.StaticArrayTypeSpec(abi.Uint8TypeSpec(), 12))
For simple types, using the constructor is straightforward and works as you would expect. However, compound types like abi.StaticArray
have type-level arguments, so their constructor must take an abi.TypeSpec
which fully defines all necessary arguments. These types can be created with a constructor, but it’s often not the most convenient way to do so.
With an abi.TypeSpec
Instance
Recall that abi.TypeSpec
has a new_instance()
method which instantiates ABI types. This is another way of instantiating ABI types, if you have an abi.TypeSpec
instance available. For example:
from pyteal import *
my_uint_type = abi.Uint8TypeSpec()
my_uint = my_uint_type.new_instance()
my_array_type = abi.StaticArrayTypeSpec(my_uint_type, 12)
my_array = my_array_type.new_instance()
With abi.make
Using abi.TypeSpec.new_instance()
makes sense if you already have an instance of the right abi.TypeSpec
, but otherwise it’s not much better than using the constructor. Because of this, we have the abi.make
method, which is perhaps the most convenient way to create a compound type.
To use it, you pass in a PEP 484 Python type annotation that describes the ABI type, and abi.make
will create an instance of it for you. For example:
from typing import Literal
from pyteal import *
my_uint8 = abi.make(abi.Uint8)
my_uint64 = abi.make(abi.Uint64)
my_array = abi.make(abi.StaticArray[abi.Uint8, Literal[12]])
Note
Since Python does not allow integers to be directly embedded in type annotations, you must wrap any integer arguments in the Literal
annotation from the typing
module.
Computed Values
With the introduction of ABI types, it’s only natural for there to be functions and operations which return ABI values. In a conventional language, it would be enough to return an instance of the type directly from the operation. However, in PyTeal, these operations must actually return two values:
An instance of the ABI type that will be populated with the right value
An
Expr
object that contains the expressions necessary to compute and populate the value that the return type should have
In order to combine these two pieces of information, the abi.ComputedValue[T]
interface was introduced. Instead of directly returning an instance of the appropriate ABI type, functions that return ABI values will return an abi.ComputedValue
instance parameterized by the return type.
For example, the abi.Tuple.__getitem__
function does not return an abi.BaseType
; instead, it returns an abi.TupleElement[abi.BaseType]
instance, which inherits from abi.ComputedValue[abi.BaseType]
.
The abi.ComputedValue[T]
abstract base class provides the following methods:
abi.ComputedValue[T].produced_type_spec()
: returns theabi.TypeSpec
representing the ABI type produced by this object.abi.ComputedValue[T].store_into(output: T)
: computes the value and store it into the ABI type instanceoutput
.abi.ComputedValue[T].use(action: Callable[[T], Expr])
: computes the value and passes it to the callable expressionaction
. This is offered as a convenience over thestore_into(...)
method if you don’t want to create a new variable to store the value before using it.
Note
If you call the methods store_into(...)
or use(...)
multiple times, the computation to determine the value will be repeated each time. For this reason, it’s recommended to only issue a single call to either of these two methods.
A brief example is below:
from typing import Literal as L
from pyteal import *
@Subroutine(TealType.none)
def assert_sum_equals(
array: abi.StaticArray[abi.Uint64, L[10]], expected_sum: Expr
) -> Expr:
"""This subroutine asserts that the sum of the elements in `array` equals `expected_sum`"""
i = ScratchVar(TealType.uint64)
actual_sum = ScratchVar(TealType.uint64)
tmp_value = abi.Uint64()
return Seq(
For(i.store(Int(0)), i.load() < array.length(), i.store(i.load() + Int(1))).Do(
If(i.load() <= Int(5))
# Both branches of this If statement are equivalent
.Then(
# This branch showcases how to use `store_into`
Seq(
array[i.load()].store_into(tmp_value),
actual_sum.store(actual_sum.load() + tmp_value.get()),
)
).Else(
# This branch showcases how to use `use`
array[i.load()].use(
lambda value: actual_sum.store(actual_sum.load() + value.get())
)
)
),
Assert(actual_sum.load() == expected_sum),
)
Type Categories
There are three categories of ABI types:
Each of which is described in detail in the following subsections.
Basic Types
Basic types are the most straightforward category of ABI types. These types are used to hold values and they have no other special meaning, in contrast to the other categories of types.
Definitions
PyTeal supports the following basic types:
PyTeal Type |
ARC-4 Type |
Dynamic / Static |
Description |
---|---|---|---|
|
Static |
An 8-bit unsigned integer |
|
|
Static |
A 16-bit unsigned integer |
|
|
Static |
A 32-bit unsigned integer |
|
|
Static |
A 64-bit unsigned integer |
|
|
Static |
A boolean value that can be either 0 or 1 |
|
|
Static |
An 8-bit unsigned integer. This is an alias for |
|
|
Static when |
A fixed-length array of |
|
|
Static |
A 32-byte Algorand address. This is an alias for |
|
|
Static |
A fixed-length array with |
|
|
Dynamic |
A variable-length array of |
|
|
Dynamic |
A variable-length array of |
|
|
Dynamic |
A variable-length byte array assumed to contain UTF-8 encoded content. This is an alias for |
|
|
Static when all elements are static |
A tuple of multiple types |
Note
*A proper implementation of abi.Tuple
requires a variable amount of generic arguments. Python 3.11 will support this with the introduction of PEP 646 - Variadic Generics, but until then it will not be possible to make abi.Tuple
a generic type. As a workaround, we have introduced the following subclasses of abi.Tuple
for tuples containing up to 5 generic arguments:
abi.Tuple0
: a tuple of zero values,()
abi.Tuple1[T1]
: a tuple of one value,(T1)
abi.Tuple2[T1,T2]
: a tuple of two values,(T1,T2)
abi.Tuple3[T1,T2,T3]
: a tuple of three values,(T1,T2,T3)
abi.Tuple4[T1,T2,T3,T4]
: a tuple of four values,(T1,T2,T3,T4)
abi.Tuple5[T1,T2,T3,T4,T5]
: a tuple of five values,(T1,T2,T3,T4,T5)
While we are still on PyTeal 3.10, we have a workaround for abi.Tuple
by abi.NamedTuple
, which allows one to define a tuple with more than 5 generic arguments, and access tuple elements by field name. For example:
from pyteal import *
from typing import Literal as L
class InheritedFromNamedTuple(abi.NamedTuple):
acct_address: abi.Field[abi.Address]
amount: abi.Field[abi.Uint64]
retrivable: abi.Field[abi.Bool]
desc: abi.Field[abi.String]
list_of_addrs: abi.Field[abi.DynamicArray[abi.Address]]
balance_list: abi.Field[abi.StaticArray[abi.Uint64, L[10]]]
These ARC-4 types are not yet supported in PyTeal:
Non-power-of-2 unsigned integers under 64 bits, i.e.
uint24
,uint48
,uint56
Unsigned integers larger than 64 bits
Fixed point unsigned integers, i.e.
ufixed<N>x<M>
Limitations
Due to the nature of their encoding, dynamic container types, i.e. abi.DynamicArray[T]
and abi.String
, have an implicit limit on the number of elements they may contain. This limit is 2^16 - 1
, or 65535. However, the AVM has a stack size limit of 4096 for byte strings, so it’s unlikely this encoding limit will be reached by your program.
Static container types have no such limit.
Usage
Setting Values
All basic types have a set()
method which can be used to assign a value. The arguments for this method differ depending on the ABI type. For convenience, here are links to the docs for each class’s method:
abi.Uint.set(...)
, which is used by allabi.Uint
classes andabi.Byte
A brief example is below. Please consult the documentation linked above for each method to learn more about specific usage and behavior.
from pyteal import *
my_address = abi.make(abi.Address)
my_bool = abi.make(abi.Bool)
my_uint64 = abi.make(abi.Uint64)
my_tuple = abi.make(abi.Tuple3[abi.Address, abi.Bool, abi.Uint64])
program = Seq(
my_address.set(Txn.sender()),
my_bool.set(Txn.fee() == Int(0)),
# It's ok to set an abi.Uint to a Python integer. This is actually preferred since PyTeal
# can determine at compile-time that the value will fit in the integer type.
my_uint64.set(5000),
my_tuple.set(my_address, my_bool, my_uint64)
)
Getting Single Values
All basic types that represent a single value have a get()
method, which can be used to extract that value. The supported types and methods are:
abi.Uint.get()
, which is used by allabi.Uint
classes andabi.Byte
A brief example is below. Please consult the documentation linked above for each method to learn more about specific usage and behavior.
from pyteal import *
@Subroutine(TealType.uint64)
def minimum(a: abi.Uint64, b: abi.Uint64) -> Expr:
"""Return the minimum value of the two arguments."""
return (
If(a.get() < b.get())
.Then(a.get())
.Else(b.get())
)
Getting Values at Indexes - Compound Types
The types abi.StaticArray
, abi.Address
, abi.DynamicArray
, abi.String
, and abi.Tuple
are compound types, meaning they contain other types whose values can be extracted. The __getitem__
method, accessible by using square brackets to “index into” an object, can be used to access these values.
The supported methods are:
abi.StaticArray.__getitem__(index: int | Expr)
, used forabi.StaticArray
andabi.Address
abi.Array.__getitem__(index: int | Expr)
, used forabi.DynamicArray
andabi.String
abi.Tuple.__getitem__(index: int)
, used forabi.Tuple
andabi.NamedTuple
*
Note
Be aware that these methods return a ComputedValue
, similar to other PyTeal operations which return ABI types. More information about why that is necessary and how to use a ComputedValue
can be found in the Computed Values section.
Note
*For abi.NamedTuple
, one can access tuple elements through both methods
A brief example is below. Please consult the documentation linked above for each method to learn more about specific usage and behavior.
from typing import Literal as L
from pyteal import *
@Subroutine(TealType.none)
def ensure_all_values_greater_than_5(array: abi.StaticArray[abi.Uint64, L[10]]) -> Expr:
"""This subroutine asserts that every value in the input array is greater than 5."""
i = ScratchVar(TealType.uint64)
return For(
i.store(Int(0)), i.load() < array.length(), i.store(i.load() + Int(1))
).Do(
array[i.load()].use(lambda value: Assert(value.get() > Int(5)))
)
Reference Types
Many applications require the caller to provide “foreign array” values when calling the app. These are the blockchain entities (such as accounts, assets, or other applications) that the application will interact with when executing this call. In the ABI, we have Reference Types to describe these requirements.
Definitions
PyTeal supports the following Reference Types:
PyTeal Type |
ARC-4 Type |
Dynamic / Static |
Description |
---|---|---|---|
|
Static |
Represents an account that the current transaction can access, stored in the |
|
|
Static |
Represents an asset that the current transaction can access, stored in the |
|
|
Static |
Represents an application that the current transaction can access, stored in the |
These types all inherit from the abstract class abi.ReferenceType
.
Limitations
Because References Types have a special meaning, they should not be directly created, and they cannot be assigned a value by a program.
Additionally, Reference Types are only valid in the arguments of a method. They may not appear in a method’s return value.
Note that the AVM has limitations on the maximum number of foreign references an application call transaction may contain. At the time of writing, these limits are:
Accounts: 4
Assets: 8
Applications: 8
Sum of Accounts, Assets, and Applications: 8
Warning
Because of these limits, methods that have a large amount of Reference Type arguments may be impossible to call as intended at runtime.
Usage
Getting Referenced Values
Depending on the Reference Type, there are different methods available to obtain the value being referenced:
A brief example is below:
from pyteal import *
@Subroutine(TealType.none)
def send_inner_txns(
receiver: abi.Account, asset_to_transfer: abi.Asset, app_to_call: abi.Application
) -> Expr:
return Seq(
InnerTxnBuilder.Begin(),
InnerTxnBuilder.SetFields(
{
TxnField.type_enum: TxnType.AssetTransfer,
TxnField.receiver: receiver.address(),
TxnField.xfer_asset: asset_to_transfer.asset_id(),
TxnField.amount: Int(1_000_000),
}
),
InnerTxnBuilder.Submit(),
InnerTxnBuilder.Begin(),
InnerTxnBuilder.SetFields(
{
TxnField.type_enum: TxnType.ApplicationCall,
TxnField.application_id: app_to_call.application_id(),
Txn.application_args: [Bytes("hello")],
}
),
InnerTxnBuilder.Submit(),
)
Accessing Parameters of Referenced Values
Reference Types allow the program to access more information about them. Each Reference Type has a params()
method which can be used to access that object’s parameters. These methods are listed below:
abi.Account.params()
returns anAccountParamObject
abi.Asset.params()
returns anAssetParamObject
abi.Application.params()
returns anAppParamObject
These method are provided for convenience. They expose the same properties accessible from the AccountParam
, AssetParam
, and AppParam
classes.
A brief example is below:
from pyteal import *
@Subroutine(TealType.none)
def referenced_params_example(
account: abi.Account, asset: abi.Asset, app: abi.Application
) -> Expr:
return Seq(
account.params().auth_address().outputReducer(
lambda value, has_value: Assert(And(has_value, value == Global.zero_address()))
),
asset.params().total().outputReducer(
lambda value, has_value: Assert(And(has_value, value == Int(1)))
),
app.params().creator_address().outputReducer(
lambda value, has_value: Assert(And(has_value, value == Txn.sender()))
)
)
Note
All returned parameters are instances of MaybeValue
, which is why the outputReducer(...)
method is used.
Accessing Asset Holdings
Similar to the parameters above, asset holding properties can be accessed using one of the following methods:
abi.Account.asset_holding(asset: Expr | abi.Asset)
: given an asset, returns anAssetHoldingObject
abi.Asset.holding(account: Expr | abi.Account)
: given an account, returns anAssetHoldingObject
These method are provided for convenience. They expose the same properties accessible from the AssetHolding
class.
A brief example is below:
from pyteal import *
@Subroutine(TealType.none)
def ensure_asset_balance_is_nonzero(account: abi.Account, asset: abi.Asset) -> Expr:
return Seq(
account.asset_holding(asset)
.balance()
.outputReducer(lambda value, has_value: Assert(And(has_value, value > Int(0)))),
# this check is equivalent
asset.holding(account)
.balance()
.outputReducer(lambda value, has_value: Assert(And(has_value, value > Int(0)))),
)
Transaction Types
Some application calls require that they are invoked as part of a larger transaction group containing specific additional transactions. In order to express these types of calls, the ABI has Transaction Types.
Every Transaction Type argument represents a specific and unique transaction that must appear immediately before the application call in the same transaction group. A method may have multiple Transaction Type arguments, in which case they must appear in the same order as the method’s arguments immediately before the method application call.
Definitions
PyTeal supports the following Transaction Types:
PyTeal Type |
ARC-4 Type |
Dynamic / Static |
Description |
---|---|---|---|
|
Static |
A catch-all for any type of transaction |
|
|
Static |
A payment transaction |
|
|
Static |
A key registration transaction |
|
|
Static |
An asset configuration transaction |
|
|
Static |
An asset transfer transaction |
|
|
Static |
An asset freeze transaction |
|
|
Static |
An application call transaction |
Limitations
Due to the special meaning of Transaction Types, they cannot be used as the return value of a method. They can be used as method arguments, but only at the top-level. This means that it’s not possible to embed a Transaction Type inside a tuple or array.
Transaction Types should not be directly created, and they cannot be modified by a program.
Because the AVM has a maximum of 16 transactions in a single group, at most 15 Transaction Types may be used in the arguments of a method.
Usage
Getting the Transaction Group Index
All Transaction Types implement the abi.Transaction.index()
method, which returns the absolute index of that transaction in the group.
A brief example is below:
from pyteal import *
@Subroutine(TealType.none)
def handle_txn_args(
any_txn: abi.Transaction,
pay: abi.PaymentTransaction,
axfer: abi.AssetTransferTransaction,
) -> Expr:
return Seq(
Assert(any_txn.index() == Txn.group_index() - Int(3)),
Assert(pay.index() == Txn.group_index() - Int(2)),
Assert(axfer.index() == Txn.group_index() - Int(1)),
)
Accessing Transaction Fields
All Transaction Types implement the abi.Transaction.get()
method, which returns a TxnObject
instance that can be used to access fields from that transaction.
A brief example is below:
from pyteal import *
@Subroutine(TealType.none)
def deposit(payment: abi.PaymentTransaction, sender: abi.Account) -> Expr:
"""This method receives a payment from an account opted into this app
and records it in their local state.
"""
return Seq(
Assert(payment.get().sender() == sender.address()),
Assert(payment.get().receiver() == Global.current_application_address()),
App.localPut(
sender.address(),
Bytes("balance"),
App.localGet(sender.address(), Bytes("balance")) + payment.get().amount(),
),
)
Subroutines with ABI Types
Subroutines can be created that accept ABI types as arguments and produce ABI types as return values. PyTeal will type check all subroutine calls and ensure that the correct types are being passed to such subroutines and that their return values are used correctly.
There are two different ways to use ABI types in subroutines, depending on whether the return value is an ABI type or a PyTeal Expr
.
Subroutines that Return Expressions
If you’d like to create a subroutine that accepts some or all arguments as ABI types, but whose return value is a PyTeal Expr
, the @Subroutine
decorator can be used.
To indicate the type of each argument, PEP 484 Python type annotations are used. Unlike normal usage of Python type annotations which are ignored at runtime, type annotations for subroutines inform the PyTeal compiler about the inputs and outputs of a subroutine. Changing these values has a direct effect on the code PyTeal generates.
An example of this type of subroutine is below:
from pyteal import *
@Subroutine(TealType.uint64)
def get_volume_of_rectangular_prism(
length: abi.Uint16, width: abi.Uint64, height: Expr
) -> Expr:
return length.get() * width.get() * height
Notice that this subroutine accepts the following arguments, not all of which are ABI types:
length
: an ABIabi.Uint16
typewidth
: an ABIabi.Uint64
typeheight
: a PyTealExpr
type
Despite some inputs being ABI types, calling this subroutine works the same as usual, except the values for the ABI type arguments must be the appropriate ABI type.
The following example shows how to prepare the arguments for and call get_volume_of_rectangular_prism()
:
# This is a continuation of the previous example
length = abi.Uint16()
width = abi.Uint64()
height = Int(10)
program = Seq(
length.set(4),
width.set(9),
Assert(get_volume_of_rectangular_prism(length, width, height) > Int(0))
)
Subroutines that Return ABI Types
Warning
ABIReturnSubroutine
is still taking shape and is subject to backwards incompatible changes.
For ABI Application entry point definition, feel encouraged to use
ABIReturnSubroutine
. Expect a best-effort attempt to minimize backwards incompatible changes along with a migration path.For general purpose usage, use at your own risk. Based on feedback, the API and usage patterns will change more freely and with less effort to provide migration paths.
For these reasons, we strongly recommend using pragma
or the Pragma
expression to pin the version of PyTeal in your source code. See Version Pragmas for more information.
In addition to accepting ABI types as arguments, it’s also possible for a subroutine to return an ABI type value.
As mentioned in the Computed Values section, operations which return ABI values instead of traditional Expr
objects need extra care. In order to solve this problem for subroutines, a new decorator, @ABIReturnSubroutine
has been introduced.
The @ABIReturnSubroutine
decorator should be used with subroutines that return an ABI value. Subroutines defined with this decorator will have two places to output information: the function return value, and a keyword-only argument called output
. The function return value must remain an Expr
, while the output
keyword argument will contain the ABI value the subroutine wishes to return. An example is below:
from pyteal import *
@ABIReturnSubroutine
def get_account_status(
account: abi.Address, *, output: abi.Tuple2[abi.Uint64, abi.Bool]
) -> Expr:
balance = abi.Uint64()
is_admin = abi.Bool()
return Seq(
balance.set(App.localGet(account.get(), Bytes("balance"))),
is_admin.set(App.localGet(account.get(), Bytes("is_admin"))),
output.set(balance, is_admin),
)
account = abi.make(abi.Address)
status = abi.make(abi.Tuple2[abi.Uint64, abi.Bool])
program = Seq(
account.set(Txn.sender()),
# NOTE! The return value of get_account_status(account) is actually a ComputedValue[abi.Tuple2[abi.Uint64, abi.Bool]]
get_account_status(account).store_into(status),
)
Notice that even though the original get_account_status
function returns an Expr
object, the @ABIReturnSubroutine
decorator automatically transforms the function’s return value and the output
variable into a ComputedValue
. As a result, callers of this subroutine must work with a ComputedValue
.
The only exception to this transformation is if the subroutine has no return value. Without a return value, a ComputedValue
is unnecessary and the subroutine will still return an Expr
to the caller. In this case, the @ABIReturnSubroutine
decorator acts identically the @Subroutine
decorator.
The name of the subroutine constructed by the @ABIReturnSubroutine
decorator is by default the function name. In order to override the default subroutine name, the decorator ABIReturnSubroutine.name_override
is introduced to construct a subroutine with its name overridden. An example is below:
from pyteal import *
@ABIReturnSubroutine.name_override("increment")
def add_by_one(prev: abi.Uint32, *, output: abi.Uint32) -> Expr:
return output.set(prev.get() + Int(1))
# NOTE! In this case, the `ABIReturnSubroutine` is initialized with a name "increment"
# overriding its original name "add_by_one"
assert add_by_one.method_spec().dictify()["name"] == "increment"
Creating an ARC-4 Program
An ARC-4 program, like all other programs, can be called by application call transactions. ARC-4 programs respond to two specific subtypes of application call transactions:
Method calls, which encode a specific method to be called and arguments for that method, if needed.
Bare app calls, which have no arguments and no return value.
A method is a section of code intended to be invoked externally with an application call transaction. Methods may take arguments and may produce a return value. PyTeal implements methods as subroutines which are exposed to be externally callable.
A bare app call is more limited than a method, since it takes no arguments and cannot return a value. For this reason, bare app calls are more suited to allow on completion actions to take place, such as opting into an app.
To make it easier for an application to route across the many bare app calls and methods it may support, PyTeal introduces the Router
class. This class adheres to the ARC-4 ABI conventions with respect to when methods and bare app calls should be invoked. For methods, it also conveniently decodes all arguments and properly encodes and logs the return value as needed.
The following sections explain how to register bare app calls and methods with the Router
class.
Warning
Router
usage is still taking shape and is subject to backwards incompatible changes.
Feel encouraged to use Router
and expect a best-effort attempt to minimize backwards incompatible changes along with a migration path.
For these reasons, we strongly recommend using pragma
or the Pragma
expression to pin the version of PyTeal in your source code. See Version Pragmas for more information.
Registering Bare App Calls
The AVM supports 6 types of OnCompletion options that may be specified on an app call transaction. These actions are:
No-op, the absence of an action, represented by
OnComplete.NoOp
Opt in, which allocates account-local storage for an app, represented by
OnComplete.OptIn
Close out, which removes account-local storage for an app, represented by
OnComplete.CloseOut
Clear state, which forcibly removes account-local storage for an app, represented by
OnComplete.ClearState
Update application, which updates an app, represented by
OnComplete.UpdateApplication
Delete application, which deletes an app, represented by
OnComplete.DeleteApplication
In PyTeal, you have the ability to register a bare app call handler for each of these actions. Additionally, a bare app call handler must also specify whether the handler can be invoking during an app creation transaction (CallConfig.CREATE
), during a non-creation app call (CallConfig.CALL
), or during either (CallConfig.ALL
).
The BareCallActions
class is used to define a bare app call handler for on completion actions. Each bare app call handler must be an instance of the OnCompleteAction
class.
The OnCompleteAction
class is responsible for holding the actual code for the bare app call handler (an instance of either Expr
or a subroutine that takes no args and returns nothing) as well as a CallConfig
option that indicates whether the action is able to be called during a creation app call, a non-creation app call, or either.
All the bare app calls that an application wishes to support must be provided to the Router.__init__
method.
A brief example is below:
from pyteal import *
@Subroutine(TealType.none)
def opt_in_handler() -> Expr:
return App.localPut(Txn.sender(), Bytes("opted_in_round"), Global.round())
@Subroutine(TealType.none)
def assert_sender_is_creator() -> Expr:
return Assert(Txn.sender() == Global.creator_address())
router = Router(
name="ExampleApp",
bare_calls=BareCallActions(
# Allow app creation with a no-op action
no_op=OnCompleteAction(
action=Approve(), call_config=CallConfig.CREATE
),
# Register the `opt_in_handler` to be called during opt in.
#
# Since we use `CallConfig.ALL`, this is also a valid way to create this app
# (if the creator wishes to immediately opt in).
opt_in=OnCompleteAction(
action=opt_in_handler, call_config=CallConfig.ALL
),
# Allow anyone who opted in to close out from the app.
close_out=OnCompleteAction(
action=Approve(), call_config=CallConfig.CALL
),
# Only approve update and delete operations if `assert_sender_is_creator` succeeds.
update_application=OnCompleteAction(
action=assert_sender_is_creator, call_config=CallConfig.CALL
),
delete_application=OnCompleteAction(
action=assert_sender_is_creator, call_config=CallConfig.CALL
),
),
)
Note
When deciding which CallConfig
value is appropriate for a bare app call or method, consider the question, should it be valid for someone to create my app with this operation? Most of the time the answer will be no, in which case CallConfig.CALL
should be used.
Registering Methods
Warning
The Router
does not validate inputs for compound types (abi.StaticArray
, abi.Address
, abi.DynamicArray
, abi.String
, or abi.Tuple
).
We strongly recommend methods immediately access and validate compound type parameters before persisting arguments for later transactions. For validation, it is sufficient to attempt to extract each element your method will use. If there is an input error for an element, indexing into that element will fail.
Notes:
This recommendation applies to recursively contained compound types as well. Successfully extracting an element which is a compound type does not guarantee the extracted value is valid; you must also inspect its elements as well.
Because of this,
abi.Address
is not guaranteed to have exactly 32 bytes. To defend against unintended behavior, manually verify the length is 32 bytes, i.e.Assert(Len(address.get()) == Int(32))
.
There are two ways to register a method with the Router
class.
The first way to register a method is with the Router.add_method_handler
method, which takes an existing subroutine decorated with @ABIReturnSubroutine
. An example of this is below:
from pyteal import *
router = Router(
name="Calculator",
bare_calls=BareCallActions(
# Allow this app to be created with a no-op call
no_op=OnCompleteAction(action=Approve(), call_config=CallConfig.CREATE),
# Allow standalone user opt in and close out
opt_in=OnCompleteAction(action=Approve(), call_config=CallConfig.CALL),
close_out=OnCompleteAction(action=Approve(), call_config=CallConfig.CALL),
),
)
@ABIReturnSubroutine
def add(a: abi.Uint64, b: abi.Uint64, *, output: abi.Uint64) -> Expr:
"""Adds the two arguments and returns the result.
If addition will overflow a uint64, this method will fail.
"""
return output.set(a.get() + b.get())
@ABIReturnSubroutine
def addAndStore(a: abi.Uint64, b: abi.Uint64, *, output: abi.Uint64) -> Expr:
"""Adds the two arguments, returns the result, and stores it in the sender's local state.
If addition will overflow a uint64, this method will fail.
The sender must be opted into the app. Opt-in can occur during this call.
"""
return Seq(
output.set(a.get() + b.get()),
# store the result in the sender's local state too
App.localPut(Txn.sender(), Bytes("result", output.get())),
)
# Register the `add` method with the router, using the default `MethodConfig`
# (only no-op, non-creation calls allowed).
router.add_method_handler(add)
# Register the `addAndStore` method with the router, using a `MethodConfig` that allows
# no-op and opt in non-creation calls.
router.add_method_handler(
addAndStore,
method_config=MethodConfig(no_op=CallConfig.CALL, opt_in=CallConfig.CALL),
)
This example registers two methods with the router, add
and addAndStore
.
Because the add
method does not pass a value for the method_config
parameter of Router.add_method_handler
, it will use the default value, which will make it only callable with a transaction that is not an app creation and whose on completion value is OnComplete.NoOp
.
On the other hand, the addAndStore
method does provide a method_config
value. A value of MethodConfig(no_op=CallConfig.CALL, opt_in=CallConfig.CALL)
indicates that this method can only be called with a transaction that is not an app creation and whose on completion value is one of OnComplete.NoOp
or OnComplete.OptIn
.
The second way to register a method is with the Router.method
decorator placed directly on a function. This way is equivalent to the first, but has some properties that make it more convenient for some scenarios. Below is an example equivalent to the prior one, but using the Router.method
syntax:
from pyteal import *
my_router = Router(
name="Calculator",
bare_calls=BareCallActions(
# Allow this app to be created with a no-op call
no_op=OnCompleteAction(action=Approve(), call_config=CallConfig.CREATE),
# Allow standalone user opt in and close out
opt_in=OnCompleteAction(action=Approve(), call_config=CallConfig.CALL),
close_out=OnCompleteAction(action=Approve(), call_config=CallConfig.CALL),
),
)
# NOTE: the first part of the decorator `@my_router.method` is the router variable's name
@my_router.method
def add(a: abi.Uint64, b: abi.Uint64, *, output: abi.Uint64) -> Expr:
"""Adds the two arguments and returns the result.
If addition will overflow a uint64, this method will fail.
"""
return output.set(a.get() + b.get())
@my_router.method(no_op=CallConfig.CALL, opt_in=CallConfig.CALL)
def addAndStore(a: abi.Uint64, b: abi.Uint64, *, output: abi.Uint64) -> Expr:
"""Adds the two arguments, returns the result, and stores it in the sender's local state.
If addition will overflow a uint64, this method will fail.
The sender must be opted into the app. Opt-in can occur during this call.
"""
return Seq(
output.set(a.get() + b.get()),
# store the result in the sender's local state too
App.localPut(Txn.sender(), Bytes("result", output.get())),
)
Compiling a Router Program
Now that we know how to add bare app call and method call handlers to a Router
, the next step is to compile the Router
into TEAL code.
The Router.compile_program
method exists for this purpose. It combines all registered methods and bare app calls into two ASTs, one for the approval program and one for clear state program, then internally calls compileTeal
to compile these expressions and create TEAL code.
Note
We recommend enabling the scratch_slots
optimization when compiling a program that uses ABI types, since PyTeal’s ABI types implementation makes frequent use of scratch slots under-the-hood. See the Compiler Optimization page for more information.
In addition to receiving the approval and clear state programs, the Router.compile_program
method also returns a Python SDK Contract
object. This object represents an ARC-4 Contract Description, which can be distributed to clients to enable them to call the methods on the contract.
Here’s an example of a complete application that uses the Router
class:
# This example is provided for informational purposes only and has not been audited for security.
from pyteal import *
import json
@Subroutine(TealType.none)
def assert_sender_is_creator() -> Expr:
return Assert(Txn.sender() == Global.creator_address())
# move any balance that the user has into the "lost" amount when they close out or clear state
transfer_balance_to_lost = App.globalPut(
Bytes("lost"),
App.globalGet(Bytes("lost")) + App.localGet(Txn.sender(), Bytes("balance")),
)
router = Router(
name="AlgoBank",
bare_calls=BareCallActions(
# approve a creation no-op call
no_op=OnCompleteAction(action=Approve(), call_config=CallConfig.CREATE),
# approve opt-in calls during normal usage, and during creation as a convenience for the creator
opt_in=OnCompleteAction(action=Approve(), call_config=CallConfig.ALL),
# move any balance that the user has into the "lost" amount when they close out or clear state
close_out=OnCompleteAction(
action=transfer_balance_to_lost, call_config=CallConfig.CALL
),
clear_state=OnCompleteAction(
action=transfer_balance_to_lost, call_config=CallConfig.CALL
),
# only the creator can update or delete the app
update_application=OnCompleteAction(
action=assert_sender_is_creator, call_config=CallConfig.CALL
),
delete_application=OnCompleteAction(
action=assert_sender_is_creator, call_config=CallConfig.CALL
),
),
)
@router.method(no_op=CallConfig.CALL, opt_in=CallConfig.CALL)
def deposit(payment: abi.PaymentTransaction, sender: abi.Account) -> Expr:
"""This method receives a payment from an account opted into this app and records it as a deposit.
The caller may opt into this app during this call.
Args:
payment: A payment transaction containing the amount of Algos the user wishes to deposit.
The receiver of this transaction must be this app's escrow account.
sender: An account that is opted into this app (or will opt in during this method call).
The deposited funds will be recorded in this account's local state. This account must
be the same as the sender of the `payment` transaction.
"""
return Seq(
Assert(payment.get().sender() == sender.address()),
Assert(payment.get().receiver() == Global.current_application_address()),
App.localPut(
sender.address(),
Bytes("balance"),
App.localGet(sender.address(), Bytes("balance")) + payment.get().amount(),
),
)
@router.method
def getBalance(user: abi.Account, *, output: abi.Uint64) -> Expr:
"""Lookup the balance of a user held by this app.
Args:
user: The user whose balance you wish to look up. This user must be opted into this app.
Returns:
The balance corresponding to the given user, in microAlgos.
"""
return output.set(App.localGet(user.address(), Bytes("balance")))
@router.method
def withdraw(amount: abi.Uint64, recipient: abi.Account) -> Expr:
"""Withdraw an amount of Algos held by this app.
The sender of this method call will be the source of the Algos, and the destination will be
the `recipient` argument.
The Algos will be transferred to the recipient using an inner transaction whose fee is set
to 0, meaning the caller's transaction must include a surplus fee to cover the inner
transaction.
Args:
amount: The amount of Algos requested to be withdraw, in microAlgos. This method will fail
if this amount exceeds the amount of Algos held by this app for the method call sender.
recipient: An account who will receive the withdrawn Algos. This may or may not be the same
as the method call sender.
"""
return Seq(
# if amount is larger than App.localGet(Txn.sender(), Bytes("balance")), the subtraction
# will underflow and fail this method call
App.localPut(
Txn.sender(),
Bytes("balance"),
App.localGet(Txn.sender(), Bytes("balance")) - amount.get(),
),
InnerTxnBuilder.Begin(),
InnerTxnBuilder.SetFields(
{
TxnField.type_enum: TxnType.Payment,
TxnField.receiver: recipient.address(),
TxnField.amount: amount.get(),
TxnField.fee: Int(0),
}
),
InnerTxnBuilder.Submit(),
)
approval_program, clear_state_program, contract = router.compile_program(
version=6, optimize=OptimizeOptions(scratch_slots=True)
)
if __name__ == "__main__":
with open("algobank_approval.teal", "w") as f:
f.write(approval_program)
with open("algobank_clear_state.teal", "w") as f:
f.write(clear_state_program)
with open("algobank.json", "w") as f:
f.write(json.dumps(contract.dictify(), indent=4))
This example uses the Router.compile_program
method to create the approval program, clear state program, and contract description for the “AlgoBank” contract. The produced algobank.json
file is below:
{
"name": "AlgoBank",
"methods": [
{
"name": "deposit",
"args": [
{
"type": "pay",
"name": "payment",
"desc": "A payment transaction containing the amount of Algos the user wishes to deposit. The receiver of this transaction must be this app's escrow account."
},
{
"type": "account",
"name": "sender",
"desc": "An account that is opted into this app (or will opt in during this method call). The deposited funds will be recorded in this account's local state. This account must be the same as the sender of the `payment` transaction."
}
],
"returns": {
"type": "void"
},
"desc": "This method receives a payment from an account opted into this app and records it as a deposit.\nThe caller may opt into this app during this call."
},
{
"name": "getBalance",
"args": [
{
"type": "account",
"name": "user",
"desc": "The user whose balance you wish to look up. This user must be opted into this app."
}
],
"returns": {
"type": "uint64",
"desc": "The balance corresponding to the given user, in microAlgos."
},
"desc": "Lookup the balance of a user held by this app."
},
{
"name": "withdraw",
"args": [
{
"type": "uint64",
"name": "amount",
"desc": "The amount of Algos requested to be withdraw, in microAlgos. This method will fail if this amount exceeds the amount of Algos held by this app for the method call sender."
},
{
"type": "account",
"name": "recipient",
"desc": "An account who will receive the withdrawn Algos. This may or may not be the same as the method call sender."
}
],
"returns": {
"type": "void"
},
"desc": "Withdraw an amount of Algos held by this app.\nThe sender of this method call will be the source of the Algos, and the destination will be the `recipient` argument.\nThe Algos will be transferred to the recipient using an inner transaction whose fee is set to 0, meaning the caller's transaction must include a surplus fee to cover the inner transaction."
}
],
"networks": {}
}
Calling an ARC-4 Program
One of the advantages of developing an ABI-compliant PyTeal contract is that there is a standard way for clients to call your contract.
Broadly, there are two categories of clients that may wish to call your contract: off-chain systems and other on-chain contracts. The following sections describe how each of these clients can call ABI methods implemented by your contract.
Off-Chain, from an SDK or goal
Off-chain systems can use the Algorand SDKs or the command-line tool goal to interact with ABI-compliant contracts.
Every SDK contains an AtomicTransactionComposer
type that can be used to build and execute transaction groups, including groups containing ABI method calls. More information and examples of this are available on the Algorand Developer Portal.
The goal
CLI has subcommands for creating and submitting various types of transactions. The relevant ones for ABI-compliant contracts are mentioned below:
- For bare app calls:
For calls that create an app,
goal app create
(docs) can be used to construct and send an app creation bare app call.For non-creation calls,
goal app <action>
can be used to construct and send a non-creation bare app call. The<action>
keyword should be replaced with one of “call” (no-op), “optin”, “closeout”, “clear”, “update”, or “delete”, depending on the on-completion value the caller wishes to use.
- For all method calls:
goal app method
(docs) can be used to construct, send, and read the return value of a method call. This command can be used for application creation as well, if the application allows this to happen in one of its methods.
On-Chain, in an Inner Transaction
Algorand applications can issue inner transactions, which can be used to invoke other applications.
In PyTeal, this can be achieved using the InnerTxnBuilder
class and its functions. To invoke an ABI method, PyTeal has InnerTxnBuilder.MethodCall(...)
to properly build a method call and encode its arguments.
Note
At the time of writing, there is no streamlined way to obtain a method return value. You must manually inspect the last_log()
property of either InnerTxn
or Gitxn[<index>]
to obtain the logged return value. As described in ARC-4, this value will be prefixed with the 4 bytes 151f7c75
(shown in hex), and after this prefix the encoded ABI value will be available. You can use the decode(...)
method on an instance of the appropriate ABI type in order to decode and use this value.