Inner Transactions in Algorand Python

Let's explore how we can use inner transactions in Algorand Python smart contracts.

To follow along with the tutorial, start by importing the itxn submodule from algopy:

from algopy import itxn

You should then be able to see the different types of inner transactions that are available, using IntelliSense in your IDE:

Minting NFTs

Let's start by using an inner AssetConfig transaction to mint NFTs in a smart contract.

A common convention for Algorand NFTs is to have the asset name reflect the collection name (e.g. 'DOG'), and for the unit names to have incremental suffixes ('DOG_1', 'DOG_2'...).

To achieve this in a smart contract, we can store a counter variable in the application's global state that tracks the number of NFTs that have been minted so far.

class Inner(ARC4Contract):
    """A contract demonstrating inner transactions in algopy."""

    def __init__(self) -> None:
        self.counter = UInt64(0)

    @arc4.abimethod
    def mint_nft(self) -> UInt64:
        """Mints an NFT.

        Returns:
            UInt64: The asset ID of the NFT minted.
        """
        self.counter += 1
        return (
            itxn.AssetConfig(total=1, decimals=0, asset_name="DOG", unit_name=b"DOG_" + itoa(self.counter), fee=0)
            .submit()
            .created_asset.id
        )

Here we define an ARC-4 ABI method that can be called from on-chain or off-chain code to mint a new NFT.

The method starts by incrementing the counter, and then submitting an inner AssetConfig transaction with the appropriate parameters.

For a pure NFT, total must be 1 and decimals must be 0.

To derive an asset's unit name, we need a function to convert the counter value to ASCII bytes:

@subroutine
def itoa(n: UInt64, /) -> Bytes:
    """Convert an integer to ASCII bytes.

    Args:
        n (UInt64): The integer.

    Returns:
        Bytes: The ASCII bytes.
    """
    digits = Bytes(b"0123456789")
    acc = Bytes()
    while n > 0:
        acc = digits[n % 10] + acc
        n //= 10
    return acc or Bytes(b"0")

Which can be concatenated with the appropriate prefix:

counter = 562
b"DOG_" + itoa(counter) == b"DOG_562"

We can confirm this is working as expected by viewing the application account in Dappflow:

Opting in to Assets

On Algorand, each account must opt in to an asset before they can receive it.

We can use an inner AssetTransfer transaction to opt the application account in to receive an asset:

@arc4.abimethod
def opt_in(self, asset: Asset) -> None:
    """Opts the application account in to receive an asset.

    Args:
        asset (Asset): The asset to opt in to.
    """
    itxn.AssetTransfer(
        asset_receiver=Global.current_application_address,
        xfer_asset=asset,
        fee=0,
    ).submit()

Depending on your use case, you might want to add additional checks here to restrict who can call this method, or which assets they can opt the account in to.

Withdrawing Funds

Finally, let's look at using an inner Payment transaction to allow the contract creator to withdraw Algos from the application account:

@arc4.abimethod
def withdraw(self, amount: UInt64) -> None:
    """Transfers Algos to the application creator's account.

    Args:
        amount (UInt64): The amount of MicroAlgos to withdraw.
    """
    assert Txn.sender == Global.creator_address, "Only the creator can withdraw"
    itxn.Payment(receiver=Global.creator_address, amount=amount, fee=0).submit()

First, we check that the transaction is sent from the application creator's address.

Then we submit a Payment, passing the amount of Algos from the withdraw method as a parameter to the inner transaction.

The Complete Contract Code

The complete contract code below is also available on GitHub:

from algopy import ARC4Contract, Asset, Bytes, Global, Txn, UInt64, arc4, itxn, subroutine


@subroutine
def itoa(n: UInt64, /) -> Bytes:
    """Convert an integer to ASCII bytes.

    Args:
        n (UInt64): The integer.

    Returns:
        Bytes: The ASCII bytes.
    """
    digits = Bytes(b"0123456789")
    acc = Bytes()
    while n > 0:
        acc = digits[n % 10] + acc
        n //= 10
    return acc or Bytes(b"0")


class Inner(ARC4Contract):
    """A contract demonstrating inner transactions in algopy."""

    def __init__(self) -> None:
        self.counter = UInt64(0)

    @arc4.abimethod
    def mint_nft(self) -> UInt64:
        """Mints an NFT.

        Returns:
            UInt64: The asset ID of the NFT minted.
        """
        self.counter += 1
        return (
            itxn.AssetConfig(total=1, decimals=0, asset_name="DOG", unit_name=b"DOG_" + itoa(self.counter), fee=0)
            .submit()
            .created_asset.id
        )

    @arc4.abimethod
    def opt_in(self, asset: Asset) -> None:
        """Opts the application account in to receive an asset.

        Args:
            asset (Asset): The asset to opt in to.
        """
        itxn.AssetTransfer(
            asset_receiver=Global.current_application_address,
            xfer_asset=asset,
            fee=0,
        ).submit()

    @arc4.abimethod
    def withdraw(self, amount: UInt64) -> None:
        """Transfers Algos to the application creator's account.

        Args:
            amount (UInt64): The amount of MicroAlgos to withdraw.
        """
        assert Txn.sender == Global.creator_address, "Only the creator can withdraw"
        itxn.Payment(receiver=Global.creator_address, amount=amount, fee=0).submit()

Writing Tests

The tests below are also available on GitHub:

import algokit_utils
import pytest
from algokit_utils import (
    TransactionParameters,
    TransferAssetParameters,
    TransferParameters,
    get_localnet_default_account,
    transfer,
    transfer_asset,
)
from algokit_utils.config import config
from algosdk import transaction
from algosdk.v2client.algod import AlgodClient
from algosdk.v2client.indexer import IndexerClient

from smart_contracts.artifacts.inner_txns.client import InnerClient


@pytest.fixture(scope="session")
def app_client(algod_client: AlgodClient, indexer_client: IndexerClient) -> InnerClient:
    account = get_localnet_default_account(algod_client)

    config.configure(
        debug=True,
        # trace_all=True,
    )

    client = InnerClient(
        algod_client,
        creator=account,
        indexer_client=indexer_client,
    )

    client.deploy(
        on_schema_break=algokit_utils.OnSchemaBreak.AppendApp,
        on_update=algokit_utils.OnUpdate.AppendApp,
    )

    transfer(
        algod_client,
        TransferParameters(
            from_account=get_localnet_default_account(algod_client),
            to_address=client.app_address,
            micro_algos=100_000,
        ),
    )

    return client


def test_mint_nft(app_client: InnerClient) -> None:
    """Tests the mint_nft() method."""
    sp = app_client.algod_client.suggested_params()
    sp.fee = 2_000
    sp.flat_fee = True
    transfer(
        app_client.algod_client,
        TransferParameters(
            from_account=get_localnet_default_account(app_client.algod_client),
            to_address=app_client.app_address,
            micro_algos=100_000,
        ),
    )

    asset_id = app_client.mint_nft(transaction_parameters=TransactionParameters(suggested_params=sp)).return_value
    assert isinstance(asset_id, int)

    asset_params = app_client.algod_client.asset_info(asset_id)["params"]
    assert asset_params["creator"] == app_client.app_address
    assert asset_params["total"] == 1
    assert asset_params["decimals"] == 0
    assert asset_params["name"] == "DOG"
    assert asset_params["unit-name"] == f"DOG_{app_client.get_global_state().counter}"


def test_opt_in(app_client: InnerClient) -> None:
    """Tests the opt_in() method."""
    algod_client = app_client.algod_client
    account = get_localnet_default_account(algod_client)
    txn = transaction.AssetConfigTxn(
        sender=account.address,
        sp=algod_client.suggested_params(),
        default_frozen=False,
        unit_name="rug",
        asset_name="Really Useful Gift",
        manager=account.address,
        reserve=account.address,
        freeze=account.address,
        clawback=account.address,
        url="https://path/to/my/asset/details",
        total=1000,
        decimals=0,
    )
    stxn = txn.sign(account.private_key)
    txid = algod_client.send_transaction(stxn)
    results = transaction.wait_for_confirmation(algod_client, txid, 4)
    created_asset = results["asset-index"]

    sp = algod_client.suggested_params()
    sp.fee = 2_000
    sp.flat_fee = True

    transfer(
        algod_client,
        TransferParameters(
            from_account=account,
            to_address=app_client.app_address,
            micro_algos=100_000,
        ),
    )

    app_client.opt_in(asset=created_asset, transaction_parameters=TransactionParameters(suggested_params=sp))

    transfer_asset(
        algod_client,
        TransferAssetParameters(
            from_account=account,
            to_address=app_client.app_address,
            asset_id=created_asset,
            amount=1,
        ),
    )

    asset_holding = algod_client.account_asset_info(address=app_client.app_address, asset_id=created_asset)[
        "asset-holding"
    ]

    assert asset_holding["asset-id"] == created_asset
    assert asset_holding["amount"] == 1


def test_withdraw(app_client: InnerClient) -> None:
    """Tests the withdraw() method."""
    algod_client = app_client.algod_client
    account = get_localnet_default_account(algod_client)
    transfer(
        algod_client,
        TransferParameters(
            from_account=account,
            to_address=app_client.app_address,
            micro_algos=2_000_000,
        ),
    )
    balance = lambda a: algod_client.account_info(a.address)["amount"]
    balance_before = balance(account)

    sp = algod_client.suggested_params()
    sp.fee = 2_000
    sp.flat_fee = True
    app_client.withdraw(amount=500_000, transaction_parameters=TransactionParameters(suggested_params=sp))
    assert balance(account) - balance_before == 500_000 - 2_000