Verify identity using a credential network
This tutorial covers the development of an Algorand stateful app (smart contract) in a python environment. The tutorial source code shown can be found at https://github.com/gmcgoldr/algorand-tut. The tutorial was developed alongside the algo-app-dev
python package, and doubles as an introduction its functionality.
The tutorial not only shows the steps required to build the smart contract, it also explains the concepts needed to understand those steps, linking back to the py-algorand-sdk
and pyteal
documentation when possible.
Requirements
The tutorial’s source code can be run in an Ubuntu (> 18.04) environment (including WSL2).
Get the tutorial source code
Clone the project and change your location to the project’s directory.
git clone https://github.com/gmcgoldr/algorand-tut.git
cd algorand-tut
Install the Algorand node software
You use the Algorand Sandbox or install an Algorand node:
sudo apt-get update
sudo apt-get install -y gnupg2 curl software-properties-common
curl -O https://releases.algorand.com/key.pub
sudo apt-key add key.pub
rm -f key.pub
sudo add-apt-repository "deb [arch=amd64] https://releases.algorand.com/deb/ stable main"
Install Python packages
pip install -U py-algorand-sdk pyteal algo-app-dev[dev]
Background
A good way to think of smart contracts is in terms of transactions. A smart contract defines some state which is recorded on the ledger, and some transactions (state changes) that are permissible under the rules of the contract.
The full potential of this technology is realized when thinking of transactions more broadly than in a strictly financial sense.
Consider the issue of personal identity. Currently, identity is largely established by credentials issued by some form of government. But a person can be denied credentials, their credentials can be invalidated, and their credentials can be used to discriminate based on arbitrary status.
This can, to some extent, be resolved by transacting in credibility on a blockchain. Someone can create a transaction in which they vouch for someone else’s credentials. The graph of such vouches can be used to assess a person’s credibility, in a permissionless, transparent, and consistent manner.
In the above example, Alice directly trusts Bob and Charlie. Alice can probably also trust Dave, given that she knows two people vouching for Dave. But Alice might be suspicious of Grace, because she knows only one person vouching for Grace. Which is all well and good as Grace, Erin and Frank are bots colluding to give the impression of credibility. And fooling Charlie isn’t enough to establish credibility in the eyes of Alice.
This tutorial will cover the steps required to build such a smart contract.
Steps
The following explains how to create and call the application. The associated code can be found in demo-app.py
. The code snippets assume the following imports: from pyteal import *
and algoappdev import *
. The result of pyteal
functions is typically an expression which can be compiled into TEAL source code.
Preliminaries
The functionality in stateful applications (apps) is executed with an app call transaction. This is a transaction with the TxType
field set to the appl
variant. Other transaction types are not discussed here.
Application call transactions can be constructed with py-algorand-sdk
in as follows:
future.transaction.Transaction(txn_type=constants.appcall_txn)
There are also a variety of derived classes which implement more specific behaviors.
An app is comprised of some state, and some rules which specify how the state can be affected by app call transactions. It is useful to think of the application as a function, and the transactions as calling that function, where the transaction is supplying arguments to the call.
There are many arguments (fields) which can be accessed by an app.
The app call as a function might be described as follows (some arguments are omitted):
call_app(
# scope: global
creator_address: Bytes,
current_application_id: Int,
latest_timestamp: Int,
...
# scope: transaction i
sender_i: Bytes,
on_completion_i: Int,
application_args_i_idx_j: Bytes,
applications_i_idx_j: Int,
assets_i_idx_j: Int,
...
# scope: asset i
asset_i_creator: Bytes,
asset_i_total: Int,
...
# scope: asset i, account j
asset_i_account_j_balance: Int,
asset_i_account_j_frozen: Int,
# scope: app i (global storage)
app_i_key_k: Union[Bytes, Int],
# scope: app i, account j (local storage)
app_i_account_j_key_k: Union[Bytes, Int],
)
The global scope arguments can be accessed with expressions: Global.field_name()
.
The transaction arguments can be accessed with expressions: Gtxn[i].field_name()
. And for those fields that are in an array (_idx
suffix above), they are accessed by indexing into an array: Gtxn[i].array_name[j]
.
The app state can be accessed with the methods:
App.globalGet(key)
App.localGet(address, key)
App.globalGetEx(id, key)
App.localGetEx(address, id, key)
Where key
is the key for the state value to retrieve (the app state is a key value store); id
is the id of the app in which to lookup the key, or an index in the applications array; address
is the address of the local storage in which to lookup the key, or an index in the accounts array.
The first two expressions return the state value and work only for the current app. The last two expressions return an object MaybeValue
which is itself an expression. When executed, it constructs two values: whether or not the key was found, and its value (or default value if not found). Then, those values can be accessed with expressions returned by the methods value()
and hasValue()
.
In the previous example, app_i_account_j_key_k
would be accessed by: App.localGetEx(address_j, app_id_i, key_k)
.
Note the following equivalences (keeping in mind that GetEx
calls must first be evaluated, and that GetEx
calls with app ID 0 will use the current application which is at index 0 in the applications array):
Txn
↔Gtxn[0]
Txn.sender()
↔Txn.accounts[0]
Global.current_application_id()
↔Txn.applications[0]
App.globalGet(key)
↔App.globalGetEx(0, key).value()
App.localGet(addr, key)
↔App.localGetEx(addr, 0, key).value()
An application program is a pyteal
expression, which returns either zero, or a non-zero value. A non-zero value indicates that the transaction is successful: changes made to the app’s state during the program execution are committed. A zero value indicates that the transaction is rejected: the state is left unchanged.
A stateful app on the Algorand chain consists of two programs: the approval program, and the clear state program.
The clear state program is executed when a app call transaction is sent with the OnComplete
code: ClearState
. This transaction will always remove the local app state from the caller’s account, regardless of return value.
There are two utility classes in algo-app-dev
which help in the creation of apps: The State
class and AppBuilder
class. The following sections cover how to use these to: define the state of an app, and define the app’s logic.
Build the state
An app can persist state globally and locally (per account). Up to 64 values can be stored in the global state, and up to 16 values can be store in the local state. Each accounts which opts into the app can then store its own instance of the local state.
The State
base object in algo-app-dev
is used to describe the key value pairs making up the state of a contract. It is initialized with a list of State.KeyInfo
objects, each specifying the key, the type of its associated value, and possibly a default value.
A State
object is used to:
- build expressions to set and get a state value
- build an expression to set default values (constructor)
- build the app schema which defines how much space the app can use
The StateGlobalExternal
subclass of State
is used to describe the global state for an external app (i.e. any app whose id is in the Txn.applications
array). It can get values, but cannot set them, since external apps read only.
The StateGlobal
subclass of StateGlobalExternal
is used to describe the global state for the current app. It adds the ability to set values. And it adds a get method which directly returns a value, instead of returning a MaybeValue
(in an external app, the existence of a value cannot be guaranteed).
The equivalent local classes are: StateLocalExternal
and StateLocal
.
In this app, each account has a name associated with it (the credential), and up to 8 accounts can vouch for that credential. The local storage is comprised of the name, and 8 voucher addresses.
TEAL values are either of type Bytes
or Int
. The Bytes
type represents a byte slice, and can be used to represent arbitrary binary data. Strings and addresses are encoded as byte slices. The Int
type represents an unsigned 64-bit integer.
# the state consists of 8 indices each for a voucher address
MAX_VOUCHERS = 8
state = apps.StateLocal(
[apps.State.KeyInfo(key="name", type=Bytes)]
+ [
apps.State.KeyInfo(key=f"voucher_{i}", type=Bytes)
for i in range(MAX_VOUCHERS)
]
)
Build the logic
The AppBuilder
class in algo-app-dev
builds an approval program’s logic with the following branches:
Txn.application_id() == Int(0)
→ Initialize the stateOnComplete == DeleteApplication
→ Delete the state and programsOnComplete == UpdateApplication
→ Update the programsOnComplete == OptIn
→ Initialize the local stateOnComplete == CloseOut
→ Delete the local stateOnComplete == NoOp
andTxn.application_args[0] == Bytes(name)
→ Call the invocation withname
OnComplete == NoOp
Call the default invocation
Exactly one branch will execute when an app call is made. Branches can be disabled by having them return zero.
The initialization branch is invoked when the app ID is zero, which happens only when a call is made to an app not yet on the chain.
The OnComplete
code indicates what state change the transaction is requesting. The NoOp
code requests that the app’s logic is run with no operation to follow. All other complete codes request some additional operations be carried out after the app’s logic is run. For example, the DeleteApplication
code requests the app’s programs be deleted alongside its state. If the app’s logic accepts the transaction (returns non-zero), then the network will carry out the requested operations.
In this demo application, the default app builder behavior is used: opt-in is allowed, but delete, update and close out are not allowed. Note that the clear state program is always available and will opt-out an account and delete its local state regardless of return value.
Additionally, the following three branches are added: setting the name (set_name
), vouching for an account (vouch_for
), and receiving a vouch (vouch_from
).
The voucher and vouchee must both agree for a vouch to succeed. It shouldn’t be possible for a random voucher to take up vouch spots in a vouchee’s account. And it shouldn’t be possible for a vouchee to claim a voucher without their permission.
The solution is to make the logic of writing a new vouch conditional on two transactions in a group.
# the previous txn in the group is that sent by the voucher
voucher_txn = Gtxn[Txn.group_index() - Int(1)]
# the 3rd argument of the vouchee txn is the key to write to the address to
vouch_key = Txn.application_args[2]
# valid vouch keys (limit to MAX_VOUCHERS)
vouch_keys = [Bytes(f"voucher_{i}") for i in range(MAX_VOUCHERS)]
builder = apps.AppBuilder(
invocations={
# setting the name changes the credentials, and so must clear the
# vouchers (i.e. the vouchers vouched for a name, so a new name
# requires new vouches)
"set_name": Seq(
# drop the old vouches
Seq(*[state.drop(f"voucher_{i}") for i in range(MAX_VOUCHERS)]),
# set the new name
state.set("name", Txn.application_args[1]),
Return(Int(1)),
),
# always allow the voucher to send this invocation
"vouch_for": Return(Int(1)),
# vouchee sends this invocation to write the vouch to local state
"vouch_from": Seq(
# ensure voucher is using this contract
Assert(voucher_txn.application_id() == Global.current_application_id()),
# ensure voucher is vouching
Assert(voucher_txn.application_args[0] == Bytes("vouch_for")),
# ensure voucher is vouching for vouchee
Assert(voucher_txn.application_args[1] == Txn.sender()),
# ensure vouchee is getting vouch from voucher
Assert(Txn.application_args[1] == voucher_txn.sender()),
# ensure setting a valid vouch key
Assert(Or(*[vouch_key == k for k in vouch_keys])),
# store the voucher's address in the given vouch index
App.localPut(Txn.sender(), vouch_key, voucher_txn.sender()),
Return(Int(1)),
),
},
local_state=state,
)
Create the app on the network
The create_txn
method combines all the branches into the approval and clear state programs, and builds the transaction required to publish the app to the chain.
txn = app_builder.create_txn(
algod_client, address, algod_client.suggested_params()
)
Here is what the application creation transaction looks like:
def compile_expr(expr: Expr) -> str:
return compileTeal(
expr,
mode=Mode.Application,
version=MAX_TEAL_VERSION,
)
def compile_source(client: AlgodClient, source: str) -> bytes:
result = client.compile(source)
result = result["result"]
return base64.b64decode(result)
future.transaction.ApplicationCreateTxn(
# this will be the app creator
sender=address,
sp=params,
# no state change requested in this transaction beyond app creation
on_complete=OnComplete.NoOpOC.real,
approval_program=compile_source(client, compile_expr(self.approval_expr())),
clear_program=compile_source(client, compile_expr(self.clear_expr())),
global_schema=self.global_schema(),
local_schema=self.local_schema(),
)
The application’s ID and address can be retrieved from the transaction result, using the AppMeta
class:
app_meta = utils.AppMeta.from_result(
transactions.get_confirmed_transaction(algod_client, txid, WAIT_ROUNDS)
)
Make calls to the app
Bob wants to let the network know that his name is Bob. He will first opt-in to the app:
txn = future.transaction.ApplicationOptInTxn(
address_bob,
algod_client.suggested_params(),
app_meta.app_id,
)
Then he will link his name to his account:
txn = future.transaction.ApplicationNoOpTxn(
address_bob,
algod_client.suggested_params(),
app_meta.app_id,
["set_name", "Bob"],
)
Now he can ask Alice to vouch for him:
txns = transactions.group_txns(
future.transaction.ApplicationNoOpTxn(
address_alice,
algod_client.suggested_params(),
app_meta.app_id,
# the address must be decoded to bytes from its base64 form
["vouch_for", decode_address(address_bob)],
),
future.transaction.ApplicationNoOpTxn(
address_bob,
algod_client.suggested_params(),
app_meta.app_id,
[
"vouch_from",
decode_address(address_alice),
# Bob has 8 vouch indices to choose from, this is his first so
# he puts it at index 0
"voucher_0",
],
),
)
Finally he will send Alice’s transaction to her, and have her sign it. Then he can send the transactions to the network.
The front-end would be responsible for traversing the graph to establish an account’s credibility. This would probably be done on a user’s machine with calls made to the indexer running on a node.
Testing the app
The algoappdev.dryruns
module helps setup dry runs for app calls. Dry runs allow for rapid testing and return useful debugging information.
Here is a simple example of how to use the dryruns
module to test the set_name
invocation:
def test_can_set_name(algod_client: AlgodClient):
# The `AlgodClient` connected to the node with data in `NODE_DIR` will be
# constructed and passed along by `pytest`. It is needed to compile the
# TEAL source into program bytes, and to execute the dry run.
app_builder = app_vouch.build_app()
# build a dummy address (will not need to sign anything with it)
address_1 = dryruns.idx_to_address(1)
result = algod_client.dryrun(
# construct an object which will fully specify the context in which the
# app call is run (i.e. set all arguments)
dryruns.AppCallCtx()
# add an app to the context, use the programs from the `app_builder`,
# and set the app id to 1
.with_app(app_builder.build_application(algod_client, 1))
# add an account opted into the last app
.with_account_opted_in(address=address_1)
# create a no-op call with the last account
.with_txn_call(args=["set_name", "abc"])
# build the dryrun request
.build_request()
)
# raise any errors in the dryrun result
dryruns.check_err(result)
# ensure the program returned non-zero
assert dryruns.get_messages(result) == ["ApprovalProgram", "PASS"]
# ensure the program changed the account's local state
assert dryruns.get_local_deltas(result) == {
address_1: [dryruns.KeyDelta(b"name", b"abc")]
}
The dryruns.get_trace
function can be used to iterate over stack trace lines, for when things do go wrong.
Integration tests should still involve sending proper transactions, though doing so with a node in dev mode can help speed things up significantly. Ultimately, some tests should be run in the actual test net.
The algoappdev.testing
module includes some useful fixtures for testing apps with pytest.
Set the environment variable AAD_NODE_DIR
to the node’s data directory (e.g. /var/lib/algorand/nets/private_dev/Primary
). Then, the fixtures can be used to quickly access get the algod_client
, kmd_client
, and a funded_account
.
from algoappdev.testing import *
The value of testing.WAIT_ROUNDS
is loaded from the environment variable AAD_WAIT_ROUNDS
. When testing with a non-dev node, then this should be set to a value of 5 or greater, to give the network time to confirm transactions.