Create Publication

We are looking for publications that demonstrate building dApps or smart contracts!
See the full list of Gitcoin bounties that are eligible for rewards.

Tutorial Thumbnail
Beginner · 30 minutes

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.

graph

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.

context

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):

  • TxnGtxn[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.

TEAL programs

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 state
  • OnComplete == DeleteApplication → Delete the state and programs
  • OnComplete == UpdateApplication → Update the programs
  • OnComplete == OptIn → Initialize the local state
  • OnComplete == CloseOut → Delete the local state
  • OnComplete == NoOp and Txn.application_args[0] == Bytes(name) → Call the invocation with name
  • 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.