This is the second part of Algorand Starter series. Smart contract is an indispensable part of any blockchains. It enables developing various dApps and extends the blockchain ecosystem. On Algorand chain, smart contracts (ASC1) are also known as stateful contract. This blog will introduce contract architecture and a simple example for demonstration.
Algorand Starter series:
- Part 1: Client side
- Part 2: Stateful contract (Smart contract)
- Part 3: Stateless contract (Smart signature)
- Part 4: Test scripts
Contract Architecture
Model
Algorand smart contracts (ASC1) are stateful contracts, but they do not contain any variables. The variables will be stored at creator and user accounts, and ASC1 will read and write these variables using TEAL opcodes.
Local storage values are stored in the user account's balance record. An account can have its local storage modified by the smart contract as long as the account has opted into the smart contract. Global storage are stored in creators and can also be modified by the smart contract code.
Application methods
Smart contracts are implemented using two programs:
- ApprovalProgram: Responsible for most of the logic of an application. This program will succeed only if one nonzero value is returned
- ClearStateProgram: Handle accounts using the clear call to remove the smart contract from their balance record
We can call to smart contracts using With ApplicationCall transactions. There are 6 types of transactions:
- NoOps: Generic application calls to execute the ApprovalProgram
- OptIn: Accounts use this transaction to opt into the smart contract to participate (local storage usage).
- DeleteApplication: Transaction to delete the application.
- UpdateApplication: Transaction to update TEAL Programs for a contract.
- CloseOut: Accounts use this transaction to close out their participation in the contract. This call can fail based on the TEAL logic, preventing the account from removing the contract from its balance record.
- ClearState: Similar to CloseOut, but the transaction will always clear a contract from the account’s balance record whether the program succeeds or fails.
Runtime execution
A set of arrays can be passed with any application transaction, which instructs the protocol to load additional data for use in the contract. These arrays are:
- applications array: used to read state for the specific contracts
- accounts array: allows additional accounts to be passed to the contract for balance information and local storage
- assets array: used to retrieve configuration and asset balance information
- arguments array: used as method's input parameters.
On runtime, TEAL program will load necessary variables on its stack. The applications array, accounts array, and assets array are read-only, while global and local state are readable and writable. The program can also use temporary variables by requesting memory from scratch memory.
Pyteal examples
The following sample builds a simple counter smart contract that either adds or deducts one from a global counter based on how the contract is called.
Setup development environment
Install pyteal
pip3 install pyteal
Install sandbox
git clone https://github.com/algorand/sandbox.git
cd sandbox
./sandbox up testnet
Build contracts
First, we need to handle 5 types of transactions for approval program. In this example, we don't handle OptIn
, CloseOut
(does not require local storage from users); UpdateApplication
, and DeleteApplication
, so just return 0 for errors.
# counter_contract.py
def clear_state_program():
program = Return(Int(1))
compileTeal(program, Mode.Application, version=5)
def approval_program():
# ...
program = Cond(
[Txn.application_id() == Int(0), on_creation],
[Txn.on_completion() == OnComplete.OptIn, Return(Int(0))],
[Txn.on_completion() == OnComplete.CloseOut, Return(Int(0))],
[Txn.on_completion() == OnComplete.UpdateApplication, Return(Int(0))],
[Txn.on_completion() == OnComplete.DeleteApplication, Return(Int(0))],
[Txn.on_completion() == OnComplete.NoOp, handle_noop]
)
compileTeal(program, Mode.Application, version=5)
The compileTeal function compiles the program as defined by the program variable. The compileTeal method also sets the Mode.Application to let PyTeal know this is for a smart contract and not a smart signature. The version parameter instructs PyTeal on which version of TEAL to produce when compiling. We don't store any user data in both global and local state, so the clear program just returns 1 for successful method call.
In approval program, Cond
expression allows several conditions to be chained. The first is condition, and the second is the condition body. If none of the conditions are true the smart contract will return an err and fail. When the contract is first created, the contract’s ID will be equal to 0. After that, all smart contracts will have a unique ID and a unique Algorand address. The first condition checks if this is the first execution of the contract. Next, we will define on_create
:
# counter_contract.py
def approval_program():
# ...
on_creation = Seq([
App.globalPut(Bytes("Count"), Int(0)),
Return(Int(1))
])
# ...
The Seq
is used to provide a sequence of expressions. When this smart contract is first deployed it will store a global variable named Count
with a value of 0 and immediately return success. Next, we will define handle_noop
# counter_contract.py
def approval_program():
# ...
scratchCount = ScratchVar(TealType.uint64)
add = Seq([
scratchCount.store(App.globalGet(Bytes("Count"))),
App.globalPut(Bytes("Count"), scratchCount.load() + Int(1)),
Return(Int(1))
])
deduct = Seq([
scratchCount.store(App.globalGet(Bytes("Count"))),
If(scratchCount.load() > Int(0),
App.globalPut(Bytes("Count"), scratchCount.load() - Int(1)),
),
Return(Int(1))
])
handle_noop = Cond(
[And(
Global.group_size() == Int(1),
Txn.application_args[0] == Bytes("Add")
), add],
[And(
Global.group_size() == Int(1),
Txn.application_args[0] == Bytes("Deduct")
), deduct],
)
# ...
We request scratch memory with size of uint64
to store data from the global Count
variable, and we can read and write global state with App.globalGet
and App.globalPut
. The condition Global.group_size() == Int(1)
is used to guarantee that this transaction is not submitted with other transactions in a group. We use the first application argument as function name - Add
and Deduct
.
Deploying and Calling Contract
First, we define algod endpoint and the account for signer. Usually, we use a wallet to sign transactions, and our private keys will be kept secretly in wallet. In this example, we define mnemonic directly in code for practicing, and this shall never be used in production.
creator_mnemoic = "YOUR MNEMONIC"
algod_address = "http://localhost:4001"
algod_token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
Next, we define some helpful functions:
def compile_program(client, source_code):
compile_response = client.compile(source_code)
return base64.b64decode(compile_response['result'])
def wait_for_confirmation(client, transaction_id, timeout):
start_round = client.status()["last-round"] + 1
current_round = start_round
while current_round < start_round + timeout:
try:
pending_txn = client.pending_transaction_info(transaction_id)
except Exception:
return
if pending_txn.get("confirmed-round", 0) > 0:
return pending_txn
elif pending_txn["pool-error"]:
raise Exception('pool error: {}'.format(pending_txn["pool-error"]))
client.status_after_block(current_round)
current_round += 1
raise Exception("pending tx not found in timeout rounds, timeout value = : {}".format(timeout))
def format_state(state):
formatted = {}
for item in state:
key = item['key']
value = item['value']
formatted_key = base64.b64decode(key).decode('utf-8')
if value['type'] == 1:
if formatted_key == 'voted':
formatted_value = base64.b64decode(value['bytes']).decode('utf-8')
else:
formatted_value = value['bytes']
formatted[formatted_key] = formatted_value
else:
formatted[formatted_key] = value['uint']
return formatted
def read_global_state(client, addr, app_id):
results = client.account_info(addr)
apps_created = results['created-apps']
for app in apps_created:
if app['id'] == app_id:
return format_state(app['params']['global-state'])
return {}
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)
signed_txn = txn.sign(private_key)
tx_id = signed_txn.transaction.get_txid()
client.send_transactions([signed_txn])
wait_for_confirmation(client, tx_id, 5)
transaction_response = client.pending_transaction_info(tx_id)
app_id = transaction_response['application-index']
print("Created new app_id:", app_id)
return app_id
def call_app(client, private_key, index, app_args):
sender = account.address_from_private_key(private_key)
params = client.suggested_params()
txn = transaction.ApplicationNoOpTxn(sender, params, index, app_args)
signed_txn = txn.sign(private_key)
tx_id = signed_txn.transaction.get_txid()
client.send_transactions([signed_txn])
wait_for_confirmation(client, tx_id, 5)
print("Application called")
The function compile_program
will use algod api to compile from Teal to bytecode. wait_for_confirmation
is a generic function to check if a transaction is confirmed on the blockchain. Each loop, it gets the TX status by pending_transaction_info
, and the max number of loop is specified by the number of blocks counting from the current block. The format_state
will parse key and value to prettier and readable format, and read_global_state
will read the raw state of an application from the creator account. The create_app
and call_app
can be used for any application. The create_app
just uses transaction.ApplicationCreateTxn
to build NoOp TX, next signs TX with private key recovered from mnemonic, then send TX and wait for confirmation, finally, it gets TX status and returns app ID. The call_app
is similar to create_app
, but it adds application arguments into NoOp transaction. Finally, we build main
function for easier use:
def main():
algod_client = algod.AlgodClient(algod_token, algod_address)
creator_private_key = mnemonic.to_private_key(creator_mnemoic)
global_schema = transaction.StateSchema(num_uints=1, num_byte_slices=0)
local_schema = transaction.StateSchema(num_uints=0, num_byte_slices=0)
approval_program_compiled = compile_program(algod_client, approval_program())
clear_state_program_compiled = compile_program(algod_client, clear_state_program())
print("--------------------------------------------")
print("Deploying Counter application...")
app_id = create_app(algod_client, creator_private_key, approval_program_compiled, clear_state_program_compiled, global_schema, local_schema)
print("Global state:", read_global_state(algod_client, account.address_from_private_key(creator_private_key), app_id))
print("--------------------------------------------")
print("Calling Counter application...")
call_app(algod_client, creator_private_key, app_id, app_args=["Add"])
print("Global state:", read_global_state(algod_client, account.address_from_private_key(creator_private_key), app_id))
main()
When creating application, we must specify how much memory we use for global and local state. For this smart contract, we use only 1 global uint. Run below command and enjoy result:
$ python3 counter_contract.py
--------------------------------------------
Deploying Counter application...
Created new app_id: 51868031
Global state: {'Count': 0}
--------------------------------------------
Calling Counter application...
Application called
Global state: {'Count': 1}
Full code: github.com/liamhieuvu/algorand-first-contra..
Reference: developer.algorand.org/docs
Thank you for reading my blog. Stay tune for the next parts.