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
Subscribe to my newsletter
Read articles from Alexander Codes directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Alexander Codes
Alexander Codes
Data Engineer โจ๏ธ | Pythonista ๐ | Blogger ๐ You can support my work at https://app.nf.domains/name/alexandercodes.algo