Usage
Tutorial
Note
This tutorial assumes you’ve already got a local algod
node running from either Algokit or the sandbox.
Let’s write a simple calculator app. Full source here.
First, create an instance of the Application
class to represent our application.
calculator_app = Application("Calculator")
This is a full application, though it doesn’t do much.
Build it and take a look at the resulting application spec.
calc_app_spec = calculator_app.build()
print(calc_app_spec.approval_program)
print(calc_app_spec.clear_program)
print(calc_app_spec.to_json())
Great! This is already enough to provide the TEAL programs and ABI specification.
Note
The Application.export
method can be used to write the approval.teal
, clear.teal
, contract.json
, and application.json
to the local file system.
Let’s now add some methods to be handled by an incoming ApplicationCallTransaction.
We can do this by tagging a PyTeal ABI method with the external
decorator.
@calculator_app.external
def add(a: pt.abi.Uint64, b: pt.abi.Uint64, *, output: pt.abi.Uint64) -> pt.Expr:
"""Add a and b, return the result"""
return output.set(a.get() + b.get())
@calculator_app.external
def mul(a: pt.abi.Uint64, b: pt.abi.Uint64, *, output: pt.abi.Uint64) -> pt.Expr:
"""Multiply a and b, return the result"""
return output.set(a.get() * b.get())
@calculator_app.external
def sub(a: pt.abi.Uint64, b: pt.abi.Uint64, *, output: pt.abi.Uint64) -> pt.Expr:
"""Subtract b from a, return the result"""
return output.set(a.get() - b.get())
@calculator_app.external
def div(a: pt.abi.Uint64, b: pt.abi.Uint64, *, output: pt.abi.Uint64) -> pt.Expr:
"""Divide a by b, return the result"""
return output.set(a.get() / b.get())
The @Application.external
decorator adds an ABI method to our application and includes it in the routing logic for handling an ABI call.
Note
The python method must return a PyTeal Expr
, to be invoked when the external method is called.
Let’s now deploy and call our contract using an ApplicationClient.
def demo() -> None:
# Here we use `localnet` but beaker.client.api_providers can also be used
# with something like ``AlgoNode(Network.TestNet).algod()``
algod_client = localnet.get_algod_client()
acct = localnet.get_accounts().pop()
# Create an Application client containing both an algod client and app
app_client = ApplicationClient(
client=algod_client, app=calculator_app, signer=acct.signer
)
# Create the application on chain, implicitly sets the app id for the app client
app_id, app_addr, txid = app_client.create()
print(f"Created App with id: {app_id} and address addr: {app_addr} in tx: {txid}")
result = app_client.call(add, a=2, b=2)
print(f"add result: {result.return_value}")
That’s it!
To summarize, we:
- Wrote an application using Beaker and PyTeal
By instantiating a new
Application
and addingexternal
methods.
- Built the smart contract, converting it to TEAL
Done by calling the
build
method on by theApplication
instance.
- Assembled the TEAL to binary
Done automatically by the
ApplicationClient
during initialization by sending the TEAL to the algodcompile
endpoint.
- Deployed the application on-chain
Done by invoking the
app_client.create
, which submits anApplicationCallTransaction
including our compiled programs.Note
Once created, subsequent calls to the app_client are directed to the
app_id
. The constructor may also be passed anapp_id
directly if one is already deployed.
- Called the method we defined
Using
app_client.call
, passing the method defined in our class and args the method specified as keyword arguments.Note
The args passed must match the types of those specified in of the method (i.e. don’t pass a string when it wants an int).
The result contains the parsed
return_value
which is a python native type that reflects the return type of the ABI method.
Application structure
The recommended way to structure a beaker application is to have one smart contract per file. This then makes it simpler for any other code to build or interact with the app by importing the module.
For example with a smart contract in a file named my_smart_contract.py
class MyState:
global_state_value = beaker.GlobalStateValue(TealType.uint64)
local_state_value = beaker.LocalStateValue(TealType.bytes)
state = MyState()
app = beaker.Application("MySmartContract", state=state)
@app.external
def my_method(self, x: pyteal.abi.Uint64, *, output: pyteal.abi.Uint64) -> pyteal.Expr
...
You can then import that smart contract as a module, and interact with it’s components.
import my_smart_contract
my_smart_contract.app # reference the app
my_smart_contract.my_method # reference a method
my_smart_contract.my_method.get_method_signature() # get ABI signature of my_method
my_smart_contract.app.state # to reference the state, OR
my_smart_contract.state # if the app state was assigned to a global variable
Decorators
Above, we used the decorator @app.external
to mark a method as being exposed in the ABI and available to be called from off-chain.
The @external
decorator can take parameters to change how it may be called or what accounts may call it.
It is also possible to use one of the OnComplete
decorators (e.g. update
, delete
, opt_in
, etc…) to specify a single OnComplete
type the contract expects.
State Management
Beaker provides a way to define state using a class which encapsulates the state configuration. This provides a convenient way to organize and reference state values.
Let’s write a new app, adding Global State to our Application.
class CounterState:
counter = GlobalStateValue(
stack_type=pt.TealType.uint64,
descr="A counter for showing how to use application state",
)
counter_app = Application("CounterApp", state=CounterState())
@counter_app.external(authorize=Authorize.only_creator())
def increment(*, output: pt.abi.Uint64) -> pt.Expr:
"""increment the counter"""
return pt.Seq(
counter_app.state.counter.set(counter_app.state.counter + pt.Int(1)),
output.set(counter_app.state.counter),
)
@counter_app.external(authorize=Authorize.only_creator())
def decrement(*, output: pt.abi.Uint64) -> pt.Expr:
"""decrement the counter"""
return pt.Seq(
counter_app.state.counter.set(counter_app.state.counter - pt.Int(1)),
output.set(counter_app.state.counter),
)
We’ve created a class to hold our State and added a GlobalStateValue attribute to it with configuration options for how it should be treated. We can reference it by name throughout our application.
You may also define state values for applications, called LocalState (or Local storage) and even allow for reserved state keys when you’re not sure what the keys will be.
For more example usage see the example here.
Code Reuse
What about extending our Application with some functionality that already exists? For example, what if we wanted to provide the correct interface for a specific ARC?
We can use the blueprint
pattern to include things like method handlers to our application!
# A blueprint that adds a method named `add` to the external
# methods of the Application passed
def add_blueprint(app: Application) -> None:
@app.external
def add(a: abi.Uint64, b: abi.Uint64, *, output: abi.Uint64) -> Expr:
return output.set(a.get() + b.get())
Here we add a method handler for an ABI method to our application in two ways.
If no arguments are needed for the blueprint method, we can pass it to apply
and the blueprint method will be called with our Application
as the argument.
app = Application("BlueprintExampleNoArgs").apply(add_blueprint)
If the blueprint requires some arguments to customize it’s behavior, we can pass the additional arguments the blueprint expects:
# A blueprint that adds a method named `addN` to the external
# methods of the Application passed
def add_n_blueprint(app: Application, n: int) -> None:
@app.external
def add_n(a: abi.Uint64, *, output: abi.Uint64) -> Expr:
return output.set(a.get() + Int(n))
app = Application("BlueprintExampleWithArgs").apply(add_n_blueprint, n=1)
Parameter Default Values
In the OpUp
example, there is a method handler, hash_it
, which specifies the argument opup_app
should be the ID of the application that we use to increase our budget via inner app calls.
Note
The default argument shown below is _not_ a valid type, it’s only used as a hint to the compiler. If you’re using mypy or similar, a type ignore directive should be used to stop it from complaining.
@app.external
def hash_it(
input: pt.abi.String,
iters: pt.abi.Uint64,
opup_app: pt.abi.Application = app.state.opup_app_id, # type: ignore[assignment]
*,
output: pt.abi.StaticBytes[Literal[32]],
) -> pt.Expr:
return pt.Seq(
pt.Assert(opup_app.application_id() == app.state.opup_app_id),
Repeat(255, call_opup()),
(current := pt.ScratchVar()).store(input.get()),
pt.For(
(i := pt.ScratchVar()).store(pt.Int(0)),
i.load() < iters.get(),
i.store(i.load() + pt.Int(1)),
).Do(current.store(pt.Sha256(current.load()))),
This value should not change frequently, if at all, but is still required to be passed by the caller, so we may use it in our logic.
By specifying the default value of the argument in the method signature, we can communicate to the caller, through the hints of the Application Specification, what the value should be.
Options for default arguments are:
A constant: one of
bytes | int | str | Bytes | Int
State Values: one of
GlobalStateValue | LocalStateValue
A read-only ABI method: a method defined to produce some more complex value than a state value or constant would be able to produce.
The result of specifying the default value here, is that we can call the method without specifying the opup_app argument:
result = app_client.call(app.hash_it, input="hashme", iters=10)
When invoked, the ApplicationClient
consults the Application Specification to check that all the expected arguments are passed.
If it finds that an argument the method expects is not passed, it will check the hints for a default value for the argument of the method that may be used directly (constant) or resolved (need to look it up on chain or call method).
Upon finding a default value that needs to be resolved, it will look up the state value or call the method. The resulting value is passed in for argument to the application call.
Precompiles
Often an app developer needs to have the fully assembled binary of a program available at contract runtime. One way to get this binary is to use the precompile
feature.
In the OpUp
example, the ExpensiveApp
needs to create an application to use as the target app for op budget increase calls.
@pt.Subroutine(pt.TealType.none)
def create_opup() -> pt.Expr:
"""internal method to create the target application"""
#: The app to be created to receiver opup requests
target = beaker.precompiled(target_app)
return pt.Seq(
pt.InnerTxnBuilder.Begin(),
pt.InnerTxnBuilder.SetFields(
{
**target.get_create_config(),
pt.TxnField.fee: pt.Int(0),
}
),
pt.InnerTxnBuilder.Submit(),
app.state.opup_app_id.set(pt.InnerTxn.created_application_id()),
)
Another situations where a precompile
is useful is when validating the logic of a LogicSignature
.
In the offload_compute
example, we check to make sure that the address of the signer is the LogicSignature
we’re expecting so that we’re sure it is doing “the right thing”.
@app.external
def check_eth_sig(
hash: pt.abi.StaticBytes[Literal[32]],
signature: pt.abi.StaticBytes[Literal[65]],
*,
output: pt.abi.String
) -> pt.Expr:
# The lsig that will be responsible for validating the
# incoming signature against the incoming hash
# When passed to Precompile, it flags the init of the Application
# to prevent building approval/clear programs until the precompile is
# compiled so we have access to compiled information (its address for instance)
verifier = beaker.precompiled(verify_lsig)
return pt.Seq(
# The precompiled lsig should have its address and binary available
# here so we can use it to make sure we've been called
# with the correct lsig
pt.Assert(pt.Txn.sender() == verifier.address()),
output.set("lsig validated"),
)
Additionally, if the LogicSignature
needs one or more TemplateVariables
the LogicSignatureTemplate
is used and functions similarly, by passing the named template arguments to the call to get the address
.
@app.external
def check(
signer_address: pt.abi.Address, msg: pt.abi.String, sig: Signature
) -> pt.Expr:
sig_checker_pc = beaker.precompiled(lsig)
# The lsig will take care of verifying the signature
# all we need to do is check that its been used to sign this transaction
return pt.Assert(
pt.Txn.sender() == sig_checker_pc.address(user_addr=signer_address.get())
)