Stateful smart contract example using Python/Pyteal: simple role-based application
Algorand python SDKs provides for developers easy and convenient way of expressing smart contract codes in a more natural form. Recently, the protocol was upgraded with room to use both on-chain (stateful) and local storages in tandem with the existing one-way of creating contract in a stateless manner. This mean that as a smart contract developer, you are able to do more than just merely creating and approving transactions.
Algorand Stateful smart contract can be very powerful if you can leverage on the underlying and complimentary technologies such as stateless smart contract, Atomic transfer, logic signatures, delegated approval and many more. For the coming weeks, I will be making a few examples of how to make and combine these technologies to create powerful programs. There are great starter kits, articles, solutions and tutorials on the developers portal to get you started.
In this article, I will show you how to create a stateful smart contract using pyteal (an Algorand language binding for Python). It will be a simple role-based smart contract that recognizes 5 addresses as ambassadors. One of the Interesting parts that other contracts will be able to identify these addresses as what we tagged them with.
- For the purpose of this tutorial, five addresses have been pre-selected as ambassadors and they will be eligible to get bonus from the bonus reward pool at a specified time of 28 days interval, so long the contract can read from some pre-defined tickers.
- It is assumed that the creator of this contract has a custom asset included in his account before now which can be read from the state of his account. For the sake of time, I will not cover asset creation in this tutorial. You can check check the developers portal or my personal blog for tutorials on how to create Algorand Standard Asset.
- I am referencing the asset with ID = 10403144 visit the explorer with the ID to confirm the asset.
- Predefined addresses that opted in will receive 5000 WIN reward every 28 days.
- To guard against manipulation, I have set a counter written locally to the addresses concerned. It is incremented every round of transfer.
- Only the creator can issue a transaction from this contract.
Writing stateful smart contract is equivalent to writing: to the Algorand blockchain (globally), to opted-in accounts (i.e locally) and reading from them. It enable us to manipulate stored data as well. Please follow diligently as you learn how these works.
To get started, you will need to have your workspace set up:
- Python application.
- Install Pyteal. See documentation.Pyteal documentation.
- Run Algorand node or use the sandbox or third party API(such as purestake). To compile this code, you do not need to connect to any node but it is advisable to make it easier when you need to establish a connection.
- An editor (VScode or Pycharm etc)
Pay attention to every line of code. There is documentation to help you understand what the code does. View code in github (open for contribution.
#pragma version 2 # Import from pyteal library all objects from pyteal import * # Five addresses to be set as ambassadors. For tutorial purpose only ronan_bytes = "NHEML642TY3HDM3GS2IVJJJQ4ATD5RUF5SW5LCROFTVWRBMTUJZFCT6MGE" hiboat_bytes = "TMKWZ63E5DH2JY4GIMZF6SQNXVHT75WM7UR72KGXD3JHFEZMN6ZXVLDDIM" rodgers_bytes = "OBDPTQDJX56N5L7W7LUDF5TSYKSFOWTIXMLESLGVHAJY74PJRPVEHV4GPE" godrace_bytes = "BLULTMZXFUDPF3MNHYPVR5UPPAEIOXBBUYC4IDDSNKG5SZ52YHMCTDE7A4" hasanacikgoz_bytes = "KCKKQ6TGKQUZXAN2GF2GVEF3BXVQMNL2U7WUH756OMPAGUIBXLIA4AUUFE" # Setting the global properties. # Explicitly define the owner of the contract. # Declare the five ambassador of types Bytes. We will fill them with a 32 Bytes address # address type i.e an Algorand address. # Declare the date, amount of reward and total amount of asset in the contract balance. # Set the total supply as the balance of asset this account is holding at this time. # And reserve to zero initially. def approval_program(): # Get asset balance of the originator using ID 10403144 asset_balance = Int(700000) # or use this method: AssetHolding.balance(Int(0), Int(10403144)) on_creation = Seq([ App.globalPut(Bytes("owner"), Txn.sender()), App.globalPut(Bytes("rodgers"), Addr(rodgers_bytes)), App.globalPut(Bytes("godrace"), Addr(godrace_bytes)), App.globalPut(Bytes("hiboat"), Addr(hiboat_bytes)), App.globalPut(Bytes("hasanacikgoz"), Addr(hasanacikgoz_bytes)), App.globalPut(Bytes("ronan"), Addr(ronan_bytes)), App.globalPut(Bytes("approved_pay_date"), Add(Global.latest_timestamp(), Int(2419200))), App.globalPut(Bytes("set_reward_per_head"), Int(5000)), App.globalPut(Bytes("total_supply"), asset_balance), App.globalPut(Bytes("reserve"), Int(0)), Return(Int(1)) ]) # Get the owner. # The amount of argument to parse in this case must not exceed 1 # Parse the amount of reward. # On_closeout: # Total supply is the amount of asset balance of the contract owner. # This will be deposited to the contract. # Set this address as the authorized account. # Total authorized amount in the reward pool will be the amount specified # in the argument list. is_owner = Txn.sender() == App.globalGet(Bytes("owner")) reward_pool_amount = Int(500000) on_closeout = Seq([ App.localPut(Int(0), Bytes("authorization_account"), Int(1)), App.globalPut(Bytes("approved_bonus_pool"), reward_pool_amount), App.globalPut(Bytes("reserve"), Minus(App.globalGet(Bytes("total_supply")), reward_pool_amount)), Return(Int(1)) ]) # Performing few checks on account interacting with this contract i.e ambassadors # before allowing to proceeding # Check if sender is authorized and or exist # Set approval for them. # Increase the count # Check if caller is registered globally and, # Check for a signature - counter that it exist. If true, # register a few identification keys such as balance, opted_count, is_ambassador and # pay_Count. # then identify caller with: write balance,call frequency, ambassador tag # and if already got paid within 28 days, else, the transaction is invalid. asq = Seq([ App.localPut(Int(0), Bytes("balance"), Int(0)), App.localPut(Int(0), Bytes("Opted_count"), Int(1)), App.localPut(Int(0), Bytes("is_ambassador"), Int(1)), App.localPut(Int(0), Bytes("pay_count"), Int(0)), Return(Int(1)) ]) reg_address = If( Eq( Btoi(Txn.sender()), Or( App.globalGet(Bytes("godrace")), App.globalGet(Bytes("hiboat")), App.globalGet(Bytes("hasanacikgoz")), App.globalGet(Bytes("rodgers")), App.globalGet(Bytes("ronan")) ) ), asq, Return(Int(0)) ) # Transfer function to credit pre-selected accounts. This will be called later in the # program. # Pay attention to every of code and what they do # Pointing to the first argument in the application argument array list. # Use for passing argument amount = Btoi(Txn.application_args) # Expressions that must evaluate to an integer of TealType 64 bit unsigned integer. # Be sure that amount requested for transfer is less than the max reward per person. # Firstly,reduce the balance in the reward pool by the amount parsed, add it to the # balance of the caller (in this case the creator), then write it to storage of account # in the list of Txn.accounts(). In this case, the Int(0)index to the current account interacting with the contract. # Increment the counter already written to the local storage i.e caller's storage xsfer_asset = Seq([ Assert(App.localGet(Int(0), Bytes("is_ambassador")) == Int(1)), Assert(Le(amount, App.globalGet(Bytes("set_reward_per_head"))), App.globalPut(Bytes("approved_bonus_pool"), Minus(App.globalGet(Bytes("approved_bonus_pool")), amount)), App.localPut(Int(0), Bytes("balance"), Add(App.localGet(Int(0), Bytes("balance")), amount)), App.localPut(Int(0), Bytes("pay_count"), Add(App.localGet(Int(0), Bytes("pay_count")), Int(1))), App.localPut(Int(0), Bytes("pay_time"), Global.latest_timestamp()) ]) # Utility for claiming reward. # Check that the claiming period is within approved date. # If the opted account does have the pay_count property i.e has been paid before now, has # opted in or has opted, if has the pay_count property, ensure that the next pay date # is 28 days in future from the last payment hence the Int(2419200) # If any of this condition is met, execute the payment. claim_reward = Seq([ Assert(Txn.type_enum() == Int(6)), # This must be an application call. Assert(Ge(Global.latest_timestamp(), App.globalGet(Bytes("approved_pay_date")))), Eq(App.localGet(Int(0), Bytes("pay_count")), Int(0)), reg_address, Ge(App.localGet(Int(0), Bytes("pay_count")), Int(0)), Ge(Global.latest_timestamp(), Add(App.globalGet(Bytes("approved_pay_date")), Int(2419200))), Assert(Gt(Global.latest_timestamp(), App.localGet(Int(0), Bytes("pay_time")))), Return(Int(1)) ]) # Here the heart of tne SM-C which is a chain of test conditions whose value must evaluate to Int(1)else, fails. # Set of conditions determining how the contract logic should run. program = Cond( [Txn.application_id() == Int(0), on_creation], [Txn.on_completion() == OnComplete.UpdateApplication, Return(is_owner)], [Txn.on_completion() == OnComplete.DeleteApplication, Return(is_owner)], [Txn.on_completion() == OnComplete.CloseOut, on_closeout], [Txn.on_completion() == OnComplete.OptIn, reg_address], # If the claim reward feed is invoked, then forward asset transfer. Note that the conditions inside the # claim_reward field is executed first before proceeding to transfer asset. [Txn.type_enum().field == claim_reward, xsfer_asset] ) return program
We can now compile the code.
with open('role_based_approval.teal', 'w') as y: compiled = compileTeal(approval_program() , Mode.Application) y.write(compiled)
- The compileTeal() functions is use for compiling pyteal expressions into TEAL assembly codes.
- It expects two arguments: (1) The pyteal expression to assemble, and (2) Mode which could be either application or signature depending on what your code does.
- The Pyteal is compiled and the Teal opcode is written to a file.
- address : a 32 byte address type.
- Btoi: Converts byte type to an integer of TealType 64 bits unsigned integer.
- App.globalPut(): Writes to global storage and maps a key to a value. Takes two parameter: first as the key followed by a comma and second as the value. The key must be of type Bytes while the value can be of any type either TealType.Bytes or TealType.uint64.
- App.localPut(): It does same this as App.globalPut() but with a slight difference. The first is an integer – Int() which is an index into the Txn.accounts corresponding to the account to write to. It must be of type integer. In this case, we are writing to the account interacting with this contract, so that should be the first index i.e Int(0). The next arguments are the key and value of same nature as seen its counterpart.
- Txn.application_args: A property of the pyteal.ast.txn.TxnObject which is a container for the application call argument array. It serves as an input method.
- Seq: This is used to hold a list of sequence of pyteal expressions. The attentive side is that target is on the final expression which must return a value. All other expressions not on the final list must not return any value. Example:
Seq([ App.localGet(Int(0), Bytes("pay_count")), App.localPut(Int(0), Bytes("balance"), Int(0)), Return(Int(0)) ])
- App.localGet(): Takes two argument: first is of type TealType.uint64 – an index into the account to read from, and second argument represent the key to read.
- App.globalGet(): Takes only the key.
- Return(): Return a success value and immediately exits the program. Nothing after it can be performed while in the same block.
- Eq(): An overloaded Python operator that takes two arguments and compares them. Arguments must be of the same type i.e Bytes – Bytes or Uint64 – Uint64. If correct, it returns 1 equivalent to TRUE, else, it returns 0 meaning FALSE.
- Add(): Does same as Eq() excepts that it adds.
- Minus(): Same as above excepts that it subtracts.
- Assert(): It creates an assert statement that raises an error if the condition inside the parentheses is false. The following code will return error and the program is terminated.
Assert(Eq(Minus(Int(20), Int(10)), Int(5)) # Assert that the result of subtracting 10 from 20 equals to 5.
- Or(): Takes in logic or expressions, returns 1 if any of the arguments is non-zero. It must contain at least two arguments to be valid.
- And(): Takes both logic and expressions. It must contain at least two arguments and all arguments must evaluate to non-zero.
To learn more about Pyteal, familiarize with the documentation.
Full code is available on the Github and open for contribution.