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 · 1 hour

Create and test smart contracts using Python

In this tutorial, we’re going to create two smart contracts using two different approaches and then we’re going to test their implementation using pytest. All the source code for this tutorial is available in a public GitHub repository.

Requirements

This project uses a Python wrapper around Algorand SDK, so you should have Python 3 installed on your system. Also, this project uses python3-venv package for creating virtual environments and you have to install it if it’s not already installed in your system. For a Debian/Ubuntu based systems, you can do that by issuing the following command:

$ sudo apt-get install python3-venv

If you’re going to clone the Algorand Sandbox (as opposed to just download its installation archive), you’ll also need Git distributed version control system

For those of you eager to get started quickly, here’s a video that wraps around the process of installing the requirements and running the tests:

Background

There are two ways of creating Algorand smart contracts using the Python programming language. We’re going to create the first smart contract using a template that ships with the Python Algorand SDK. The second contract is going to be created using PyTeal, a Python wrapper around the Transaction Execution Approval Language (TEAL).

Finally, two test suites will be created in pytest using best practices and the logic behind them will be explained.

Steps

1. Setup and run Algorand Sandbox

Let’s create the root directory named algorand where this project and Sandbox will reside.

cd ~
mkdir algorand
cd algorand

This project depends on Algorand Sandbox running in your computer. Use its README for the instructions on how to prepare its installation on your system. You may clone the Algorand Sandbox repository with the following command:

git clone https://github.com/algorand/sandbox.git

The Sandbox Docker containers will be started automatically by running the tests from this project. As starting them for the first time takes time, it’s advisable to start the Sandbox before running the tests by issuing ./sandbox/sandbox up:
EditorImages/2021/08/05 22:45/starting-sandbox.png
The Sandbox will be up and running after a minute or two:
EditorImages/2021/08/05 22:45/sandbox-up-and-running.png


Note

This project’s code implies that the Sandbox executable is in the sandbox directory which is a sibling to this project’s directory:

$ tree -L 1
.
├── algorand-contracts-testing
└── sandbox

If that’s not the case, then you should set SANDBOX_DIR environment variable holding sandbox directory before running this project’s tests:

export SANDBOX_DIR="/home/ipaleka/dev/algorand/sandbox"


2. Create and activate Python virtual environment

Every Python-based project should run inside its own virtual environment. Create and activate one for this project with:

python3 -m venv contractsvenv
source contractsvenv/bin/activate

After successful activation, the environment name will be presented at your prompt and that indicates that all the Python package installations issued will reside only in that environment.

(contractsvenv) $

We’re ready now to install our project’s main dependencies: the Python Algorand SDK, PyTeal, and pytest.

(contractsvenv) $ pip install py-algorand-sdk pyteal pytest

3. Creating a smart contract from a template

Our first smart contract will be a split payment contract where a transaction amount is split between two receivers at provided ratio. For that purpose we created a function that accepts contract data as arguments:

from algosdk import template

def _create_split_contract(
    owner,
    receiver_1,
    receiver_2,
    rat_1=1,
    rat_2=3,
    expiry_round=5000000,
    min_pay=3000,
    max_fee=2000,
):
    """Create and return split template instance from the provided arguments."""
    return template.Split(
        owner, receiver_1, receiver_2, rat_1, rat_2, expiry_round, min_pay, max_fee
    )

We use template’s instance method get_split_funds_transaction in order to create a list of two transactions based on provided amount:

def _create_grouped_transactions(split_contract, amount):
    """Create grouped transactions for the provided `split_contract` and `amount`."""
    params = suggested_params()
    return split_contract.get_split_funds_transaction(
        split_contract.get_program(),
        amount,
        1,
        params.first,
        params.last,
        params.gh,
    )

def create_split_transaction(split_contract, amount):
    """Create transaction with provided amount for provided split contract."""
    transactions = _create_grouped_transactions(split_contract, amount)
    transaction_id = process_transactions(transactions)
    return transaction_id

That list of two transactions is then sent to process_transactions helper function that is responsible for deploying our smart contract to the Algorand blockchain.

def _algod_client():
    """Instantiate and return Algod client object."""
    algod_address = "http://localhost:4001"
    algod_token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
    return algod.AlgodClient(algod_token, algod_address)

def process_transactions(transactions):
    """Send provided grouped `transactions` to network and wait for confirmation."""
    client = _algod_client()
    transaction_id = client.send_transactions(transactions)
    _wait_for_confirmation(client, transaction_id, 4)
    return transaction_id


Note

Some helper functions aren’t shown here in the tutorial for the sake of simplicity. Please take a look at the project’s repository for their implementation.

4. Creating a smart contract with PyTeal

Our second smart contract is a simple bank for account contract where only a pre-defined receiver is able to withdraw funds from the smart contract:

def bank_for_account(receiver):
    """Only allow receiver to withdraw funds from this contract account.

    Args:
        receiver (str): Base 32 Algorand address of the receiver.
    """
    is_payment = Txn.type_enum() == TxnType.Payment
    is_single_tx = Global.group_size() == Int(1)
    is_correct_receiver = Txn.receiver() == Addr(receiver)
    no_close_out_addr = Txn.close_remainder_to() == Global.zero_address()
    no_rekey_addr = Txn.rekey_to() == Global.zero_address()
    acceptable_fee = Txn.fee() <= Int(BANK_ACCOUNT_FEE)

    return And(
        is_payment,
        is_single_tx,
        is_correct_receiver,
        no_close_out_addr,
        no_rekey_addr,
        acceptable_fee,
    )

The above PyTeal code is then compiled into TEAL byte-code using PyTeal’s compileTeal function and a signed logic signature is created from the compiled source:

def setup_bank_contract(**kwargs):
    """Initialize and return bank contract for provided receiver."""
    receiver = kwargs.pop("receiver", add_standalone_account()[1])

    teal_source = compileTeal(
        bank_for_account(receiver),
        mode=Mode.Signature,
        version=3,
    )
    logic_sig = logic_signature(teal_source)
    escrow_address = logic_sig.address()
    fund_account(escrow_address)
    return logic_sig, escrow_address, receiver


def create_bank_transaction(logic_sig, escrow_address, receiver, amount, fee=1000):
    """Create bank transaction with provided amount."""
    params = suggested_params()
    params.fee = fee
    params.flat_fee = True
    payment_transaction = create_payment_transaction(
        escrow_address, params, receiver, amount
    )
    transaction_id = process_logic_sig_transaction(logic_sig, payment_transaction)
    return transaction_id

As you may notice, we provide some funds to the escrow account after its creation by calling the fund_account function.

Among other used functions, the following helper functions are used for connecting to the blockchain and processing the smart contract:

import base64

from algosdk import account
from algosdk.future.transaction import LogicSig, LogicSigTransaction, PaymentTxn


def create_payment_transaction(escrow_address, params, receiver, amount):
    """Create and return payment transaction from provided arguments."""
    return PaymentTxn(escrow_address, params, receiver, amount)


def process_logic_sig_transaction(logic_sig, payment_transaction):
    """Create logic signature transaction and send it to the network."""
    client = _algod_client()
    logic_sig_transaction = LogicSigTransaction(payment_transaction, logic_sig)
    transaction_id = client.send_transaction(logic_sig_transaction)
    _wait_for_confirmation(client, transaction_id, 4)
    return transaction_id


def _compile_source(source):
    """Compile and return teal binary code."""
    compile_response = _algod_client().compile(source)
    return base64.b64decode(compile_response["result"])


def logic_signature(teal_source):
    """Create and return logic signature for provided `teal_source`."""
    compiled_binary = _compile_source(teal_source)
    return LogicSig(compiled_binary)

That’s all we need to prepare our smart contracts for testing.

5. Structure of a testing module

In order for our test_contracts.py testing module to be discovered by pytest test runner, we named it with test_ prefix. For a large-scale project, you may create tests directory and place your testing modules in it.

Pytest allows running a special function before the very first test from the current module is run. In our testing module, we use it to run the Sandbox daemon:

from helpers import call_sandbox_command

def setup_module(module):
    """Ensure Algorand Sandbox is up prior to running tests from this module."""
    call_sandbox_command("up")

A test suite for each of the two smart contracts is created and the setup_method is run before each test in the suite. We use that setup method to create the needed accounts:

from contracts import setup_bank_contract, setup_split_contract
from helpers import add_standalone_account


class TestSplitContract:
    """Class for testing the split smart contract."""

    def setup_method(self):
        """Create owner and receivers accounts before each test."""
        _, self.owner = add_standalone_account()
        _, self.receiver_1 = add_standalone_account()
        _, self.receiver_2 = add_standalone_account()

    def _create_split_contract(self, **kwargs):
        """Helper method for creating a split contract from pre-existing accounts

        and provided named arguments.
        """
        return setup_split_contract(
            owner=self.owner,
            receiver_1=self.receiver_1,
            receiver_2=self.receiver_2,
            **kwargs,
        )


class TestBankContract:
    """Class for testing the bank for account smart contract."""

    def setup_method(self):
        """Create receiver account before each test."""
        _, self.receiver = add_standalone_account()

    def _create_bank_contract(self, **kwargs):
        """Helper method for creating bank contract from pre-existing receiver

        and provided named arguments.
        """
        return setup_bank_contract(receiver=self.receiver, **kwargs)

Instead of repeating the code, we’ve created a helper method in each suite. That way we adhere to the DRY principle.


Note

We use only the setup_method that is executed before each test. In order to execute some code after each test, use the teardown_method. The same goes for the module level with teardown_module function.

6. Testing smart contracts implementation

Let’s start our testing journey by creating a test confirming that the accounts created in the setup method take their roles in our smart contract:

class TestSplitContract:
    #
    def test_split_contract_uses_existing_accounts_when_they_are_provided(self):
        """Provided accounts should be used in the smart contract."""
        contract = self._create_split_contract()
        assert contract.owner == self.owner
        assert contract.receiver_1 == self.receiver_1
        assert contract.receiver_2 == self.receiver_2

Start the test runner by issuing the pytest command from the project’s root directory:
EditorImages/2021/08/05 22:50/pytest-run.png
Well done, you have successfully tested the code responsible for creating the smart contract from a template!

Now add a test that checks the original smart contract creation function without providing any accounts to it, together with two counterpart tests in the bank contract test suite:

class TestSplitContract:
    #
    def test_split_contract_creates_new_accounts(self):
        """Contract creation function `setup_split_contract` should create new accounts

        if existing are not provided to it.
        """
        contract = setup_split_contract()
        assert contract.owner != self.owner
        assert contract.receiver_1 != self.receiver_1
        assert contract.receiver_2 != self.receiver_2


class TestBankContract:
    #
    def test_bank_contract_creates_new_receiver(self):
        """Contract creation function `setup_bank_contract` should create new receiver

        if existing is not provided to it.
        """
        _, _, receiver = setup_bank_contract()
        assert receiver != self.receiver

    def test_bank_contract_uses_existing_receiver_when_it_is_provided(self):
        """Provided receiver should be used in the smart contract."""
        _, _, receiver = self._create_bank_contract()
        assert receiver == self.receiver

In order to make the output more verbose, add the -v argument to pytest command:
EditorImages/2021/08/05 22:51/pytest-run-verbose.png


Note

As you can see from the provided screenshots, running these tests takes quite a lot of time. The initial delay is because we invoked the Sandbox daemon in the setup_module function, and processing the transactions in the blockchain spent the majority of the time (about 5 seconds for each of them). To considerably speed up the whole process, you may try implementing the devMode configuration which creates a block for every transaction. Please bear in mind that at the time of writing this tutorial the Algorand Sandbox doesn’t ship with such a template yet.

7. Testing smart contract transactions

Now let’s test the actual implementation of our smart contracts. As recording a transaction in the Algorand Indexer database takes some 5 seconds after it is submitted to the blockchain, we’ve created a helper function that will wait until the transaction can be retrieved:

import time

from algosdk.error import IndexerHTTPError
from algosdk.v2client import indexer

INDEXER_TIMEOUT = 10


def _indexer_client():
    """Instantiate and return Indexer client object."""
    indexer_address = "http://localhost:8980"
    indexer_token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
    return indexer.IndexerClient(indexer_token, indexer_address)

def transaction_info(transaction_id):
    """Return transaction with provided id."""
    timeout = 0
    while timeout < INDEXER_TIMEOUT:
        try:
            transaction = _indexer_client().transaction(transaction_id)
            break
        except IndexerHTTPError:
            time.sleep(1)
            timeout += 1
    else:
        raise TimeoutError(
            "Timeout reached waiting for transaction to be available in indexer"
        )

    return transaction

The code for our tests should be straightforward. We use the returned transaction’s ID to retrieve a transaction as a Python dictionary and we check some of its values afterward. It is worth noting that in the case of a split contract we check that the group key holds a valid address as a value which means the transactions are grouped, while for the bank account we test exactly the opposite - that no group key even exists:

from algosdk import constants
from algosdk.encoding import encode_address, is_valid_address

from contracts import BANK_ACCOUNT_FEE, create_bank_transaction, create_split_transaction
from helpers import transaction_info


class TestSplitContract:
    #
    def test_split_contract_transaction(self):
        """Successful transaction should have sender equal to escrow account.

        Also, receiver should be contract's receiver_1, the type should be payment,
        and group should be a valid address.
        """
        contract = setup_split_contract()
        transaction_id = create_split_transaction(contract, 1000000)
        transaction = transaction_info(transaction_id)
        assert transaction.get("transaction").get("tx-type") == constants.payment_txn
        assert transaction.get("transaction").get("sender") == contract.get_address()
        assert (
            transaction.get("transaction").get("payment-transaction").get("receiver")
            == contract.receiver_1
        )
        assert is_valid_address(
            encode_address(
                base64.b64decode(transaction.get("transaction").get("group"))
            )
        )


class TestBankContract:
    #
    def test_bank_contract_transaction(self):
        """Successful transaction should have sender equal to escrow account.

        Also, the transaction type should be payment, payment receiver should be
        contract's receiver, and the payment amount should be equal to provided amount.
        Finally, there should be no group field in transaction.
        """
        amount = 1000000
        logic_sig, escrow_address, receiver = self._create_bank_contract(
            fee=BANK_ACCOUNT_FEE
        )
        transaction_id = create_bank_transaction(
            logic_sig, escrow_address, receiver, amount
        )
        transaction = transaction_info(transaction_id)
        assert transaction.get("transaction").get("tx-type") == constants.payment_txn
        assert transaction.get("transaction").get("sender") == escrow_address
        assert (
            transaction.get("transaction").get("payment-transaction").get("receiver")
            == receiver
        )
        assert (
            transaction.get("transaction").get("payment-transaction").get("amount")
            == amount
        )
        assert transaction.get("transaction").get("group", None) is None

If you don’t want to run all the existing tests every time, add the -k argument to pytest followed by a text that identifies the test(s) you wish to run:
EditorImages/2021/08/05 22:51/pytest-run-by-name.png

8. Testing validity of provided arguments

In the previous section, we made the assertions based on the returned values from the target functions. Another approach is to call a function with some arguments provided and test if it raises an error:

from algosdk.error import AlgodHTTPError, TemplateInputError

from helpers import account_balance


class TestSplitContract:
    #
    def test_split_contract_min_pay(self):
        """Transaction should be created when the split amount for receiver_1

        is greater than `min_pay`.
        """
        min_pay = 250000
        contract = self._create_split_contract(min_pay=min_pay, rat_1=1, rat_2=3)
        amount = 2000000
        create_split_transaction(contract, amount)
        assert account_balance(contract.receiver_1) > min_pay

    def test_split_contract_min_pay_failed_transaction(self):
        """Transaction should fail when the split amount for receiver_1

        is less than `min_pay`.
        """
        min_pay = 300000
        contract = self._create_split_contract(min_pay=min_pay, rat_1=1, rat_2=3)
        amount = 1000000

        with pytest.raises(TemplateInputError) as exception:
            create_split_transaction(contract, amount)
        assert (
            str(exception.value)
            == f"the amount paid to receiver_1 must be greater than {min_pay}"
        )


class TestBankContract:
    #
    def test_bank_contract_raises_error_for_wrong_receiver(self):
        """Transaction should fail for a wrong receiver."""
        _, other_receiver = add_standalone_account()

        logic_sig, escrow_address, _ = self._create_bank_contract()
        with pytest.raises(AlgodHTTPError) as exception:
            create_bank_transaction(logic_sig, escrow_address, other_receiver, 2000000)
        assert "rejected by logic" in str(exception.value)

9. Parametrization of arguments for a test function

Pytest allows defining multiple sets of arguments and fixtures at the test function or class. Add the pytest.mark.parametrize decorator holding your fixture data to test function and define the arguments with the same names as fixture elements. We’ve created six tests using the same test function with the following code:

class TestSplitContract:
    #
    @pytest.mark.parametrize(
        "amount,rat_1,rat_2",
        [
            (1000000, 1, 3),
            (999999, 1, 2),
            (1400000, 2, 5),
            (1000000, 1, 9),
            (900000, 4, 5),
            (1200000, 5, 1),
        ],
    )
    def test_split_contract_balances_of_involved_accounts(self, amount, rat_1, rat_2):
        """After successful transaction, balance of involved accounts should pass

        assertion to result of expressions calculated from the provided arguments.
        """
        contract = self._create_split_contract(rat_1=rat_1, rat_2=rat_2)
        assert account_balance(contract.owner) == 0
        assert account_balance(contract.receiver_1) == 0
        assert account_balance(contract.receiver_2) == 0

        escrow = contract.get_address()
        escrow_balance = account_balance(escrow)

        create_split_transaction(contract, amount)
        assert account_balance(contract.owner) == 0
        assert account_balance(contract.receiver_1) == rat_1 * amount / (rat_1 + rat_2)
        assert account_balance(contract.receiver_2) == rat_2 * amount / (rat_1 + rat_2)
        assert account_balance(escrow) == escrow_balance - amount - contract.max_fee

You may take a look at the pytest documentation on fixtures for the use case that best suits your needs.

10. Speeding up by running the tests in parallel

If you have multiple CPU cores you can use those for a combined test run. All you have to do for that is to install the pytest-xdist plugin into your virtual environment:

(contractsvenv) $ pip install pytest-xdist

After that, you’ll be able to run tests in parallel on a number of cores set with the -n argument added to pytest command. The following example uses three cores running in parallel:
EditorImages/2021/08/05 22:52/running-tests-in-parallel.png

As you can see from this screenshot, some tests aren’t shown here in the tutorial for the sake of simplicity. Please take a look at the project’s repository for their implementation.

11. Conclusion

We introduced the reader to the two ways of creating Algorand smart contracts using the Python programming language. We created the first smart contract using a template that ships with the Python Algorand SDK. The second contract is created using PyTeal, a Python wrapper around the Transaction Execution Approval Language (TEAL).

Finally, we created two test suites in pytest using the best practices and also we explained the logic behind them.

For any questions or suggestions, use the issues section of this project’s repository or reach out in the Algorand Discord channel.

Enjoy your coding!