Minting Fibonacci NFTs on Algorand

Let's explore how to create an Algorand smart contract that mints NFTs with Fibonacci numbers as metadata.

We'll write the smart contract code in python, using PuyaPy.

Fibonacci Sequence

The Fibonacci sequence is defined as:

F(0) = 0, F(1) = 1
F(n) = F(n - 1) + F(n - 2), for n > 1.

It's often taught as a classic use case for recursion, but it can be solved many different ways.

For the sake of this smart contract, we want a user to be able to mint an NFT where the metadata hash represents the next number in the Fibonacci sequence:

Fibonacci recurrence relation starting with 0

And it's going to be more efficient to do this imperatively.

Global State

Let's start by defining the global state the contract will use:

class Fibonacci(ARC4Contract):
    """A smart contract for minting NFTs with Fibonacci numbers as metadata."""

    def __init__(self) -> None:
        self.is_bootstrapped = False
        self.n_minted = UInt64(0)
        self.a = UInt64(0)
        self.b = UInt64(1)
KeyDescription
is_bootstrappedTracks whether the application has been bootstrapped (funded) by the creator.
n_mintedThe number of NFTs minted by the smart contract (we'll use this to avoid integer overflow in the Fibonacci sequence).
aRepresents n - 2
bRepresents n - 1

We'll also define the maximum number of NFTs that can be minted:

MAX_ASSETS = 93

Minting Logic

Each time a user mints a new token, we'll increment the n_minted counter and update a and b as follows:

self.n_minted += 1
if self.n_minted < MAX_ASSETS:
    self.a, self.b = self.b, self.a + self.b

Each asset on Algorand has a unique ID, but we also want a unique unit name.

Since the Fibonacci sequence begins with the 0, 1, 1... , the number alone can't be used.

A unit name is also limited to 8 bytes, so we'd get beyond that at some point.

To create a unique unit name, we'll use the n_minted counter as the suffix:

@subroutine
def create_asset(minter: Account, fib: UInt64, suffix: UInt64) -> Asset:
    """Submits an inner transaction to create an asset.

    Args:
        minter (Account): The account that will be able to claim the asset.
        fib (UInt64): The Fibonacci number to use as metadata.
        suffix (UInt64): The unit name suffix.

    Returns:
        Asset: The asset created.
    """
    return (
        itxn.AssetConfig(
            asset_name=b"FIB",
            unit_name=b"FIB" + itoa(suffix),
            total=1,
            decimals=0,
            metadata_hash=arc4.UInt256(fib).bytes,
            manager=Global.current_application_address,
            reserve=minter,
            fee=0,
        )
        .submit()
        .created_asset
    )

The first three NFTs minted will have the unit names FIB00, FIB01, FIB02...

Since n_minted is stored as a UInt64, we need a function to convert its value to ascii bytes:

@subroutine
def itoa(n: UInt64) -> Bytes:
    """Convert an integer to ascii.

    Args:
        n (UInt64): The integer.

    Returns:
        Bytes: The byte array.
    """
    digits = Bytes(b"0123456789")
    return digits[n // 10] + digits[n % 10]

We can store the Fibonacci number itself in the metadata hash (32 bytes).

Note that this will be base64 encoded.

Opting In

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

But in order to opt in, you need the asset ID, which can't be known ahead of time.

So it's not currently possible for the contract to mint a new asset transfer it to the user in a single step.

As a workaround, we will store the minter's address as the reserve address for the asset. This parameter has no specific authority in the Algorand protocol, but we can use it here to verify which user should be able to claim the asset.

The process is going to be:

  1. User calls the application to mint a new NFT.

  2. Application creates the asset, sets the user's address as the reserve address, and returns the asset ID.

  3. User opts in to receive the asset, and calls the application again to claim the token.

  4. Application validates the call and transfers the asset to the user.

Please don't assume this is secure. It's only for demonstration purposes and has not been audited:

@arc4.abimethod()
def claim(self, asa: Asset) -> UInt64:
    """Claim an asset back from the application.

    Args:
        asa (Asset): The asset to claim.

    Returns:
        UInt64: The amount returned.
    """
    assert self.is_bootstrapped, "Application must be bootstrapped"
    assert asa.balance(Global.current_application_address) > 0, "Asset not found"
    assert asa.creator == Global.current_application_address, "Invalid creator"
    assert asa.reserve == Txn.sender, "Invalid reserve"
    return transfer_asset(asa, Txn.sender)

The basic checks here are that:

  • The asset claimed is one created by the contract.

  • The application account has at least one unit of this asset.

  • The reserve address for the asset is the sender of the claiming transaction (verifying they are the minter).

To transfer the asset we can submit an inner transaction:

@subroutine
def transfer_asset(asa: Asset, receiver: Account) -> UInt64:
    """Transfers the asset to the `receiver` account.

    Returns:
        UInt64: The amount transferred.
    """
    return (
        itxn.AssetTransfer(
            xfer_asset=asa,
            asset_receiver=receiver,
            asset_amount=1,
        )
        .submit()
        .asset_amount
    )

The amount of the asset is hard-coded to 1, since these are all pure NFTs.

Avoiding Integer Overflow

At some point the next number in the Fibonacci sequence will be too big to be stored as a UInt64.

For this tutorial, we'll keep it simple and avoid overflow by limiting the total number of NFTs that can be minted to 93.

Bootstrapping the Application

From our off-chain code, we'll call the application's bootstrap() method and fund it with the minimum balance (100,000 microalgos):

>>> signer = AccountTransactionSigner(account.private_key)
>>> ptxn = PaymentTxn(account.address, sp, app_client.app_address, 100_000)
>>> tws = TransactionWithSigner(ptxn, signer)
>>> if not app_client.get_global_state().is_bootstrapped:
...     print("Bootstrapping")
...     print(f"Seeded with {app_client.bootstrap(seed=tws).return_value} microalgos")
...     print(f"{app_client.app_id=}")
Bootstrapping
Seeded with 100000 microalgos
app_client.app_id=1001

We can now view the LocalNet application in Dappflow: https://app.dappflow.org/explorer/application/1001/transactions

And confirm that the initial state is correct:

Minting & Claiming the First NFT

From our off-chain code, we will call the application's mint() method, opt in to the new asset, and then call the claim() method to have it transferred to our account:

>>> signer = AccountTransactionSigner(account.private_key)
>>> ptxn = PaymentTxn(account.address, sp, app_client.app_address, 200_000)
>>> tws = TransactionWithSigner(ptxn, signer)

>>> asset_id = app_client.mint(txn=tws).return_value
>>> print(f"Minted asset {asset_id}")

>>> not is_opted_in(algod_client, account, asset_id) and opt_in(
...     algod_client, account, [asset_id]
... )

>>> amount_received = app_client.claim(asa=asset_id).return_value
>>> print(f"{amount_received=} of {asset_id=} from {app_client.app_id=}")
Minted asset 1006
amount_received=1 of asset_id=1006 from app_client.app_id=1001

Great! Now we can check it in Dappflow:

Simulating on LocalNet

We can use a for loop to quickly mint the rest of the assets:

Let's check f(1) and f(92) against this reference sheet:

>>> from base64 import b64decode
>>> print(int(b64decode("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE=").hex(), 16))
1

>>> from base64 import b64decode
>>> print(int(b64decode("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaKPdjmHsz70=").hex(), 16))
7540113804746346429

Perfect!

The Complete Contract Code

from puyapy import (
    Account,
    ARC4Contract,
    Asset,
    Bytes,
    Global,
    Txn,
    UInt64,
    arc4,
    gtxn,
    itxn,
    subroutine,
)

MAX_ASSETS = 93


@subroutine
def itoa(n: UInt64) -> Bytes:
    """Convert an integer to ascii.

    Args:
        n (UInt64): The integer.

    Returns:
        Bytes: The byte array.
    """
    digits = Bytes(b"0123456789")
    return digits[n // 10] + digits[n % 10]


@subroutine
def create_asset(minter: Account, fib: UInt64, suffix: UInt64) -> Asset:
    """Submits an inner transaction to create an asset.

    Args:
        minter (Account): The account that will be able to claim the asset.
        fib (UInt64): The Fibonacci number to use as metadata.
        suffix (UInt64): The unit name suffix.

    Returns:
        Asset: The asset created.
    """
    return (
        itxn.AssetConfig(
            asset_name=b"FIB",
            unit_name=b"FIB" + itoa(suffix),
            total=1,
            decimals=0,
            metadata_hash=arc4.UInt256(fib).bytes,
            manager=Global.current_application_address,
            reserve=minter,
            fee=0,
        )
        .submit()
        .created_asset
    )


@subroutine
def transfer_asset(asa: Asset, receiver: Account) -> UInt64:
    """Transfers the asset to the `receiver` account.

    Returns:
        UInt64: The amount transferred.
    """
    return (
        itxn.AssetTransfer(
            xfer_asset=asa,
            asset_receiver=receiver,
            asset_amount=1,
        )
        .submit()
        .asset_amount
    )


class Fibonacci(ARC4Contract):
    """A smart contract for minting NFTs with Fibonacci numbers as metadata."""

    def __init__(self) -> None:
        self.is_bootstrapped = False
        self.n_minted = UInt64(0)
        self.a = UInt64(0)
        self.b = UInt64(1)

    @arc4.abimethod()
    def bootstrap(self, seed: gtxn.PaymentTransaction) -> UInt64:
        """Bootstrap the application. This method is idempotent.

        Args:
            seed (gtxn.PaymentTransaction): Initial payment transaction to the app account.

        Returns:
            UInt64: The amount of microalgos funded.
        """
        assert not self.is_bootstrapped, "Application has already been bootstrapped"
        assert (
            seed.receiver == Global.current_application_address
        ), "Receiver must be app address"
        assert Global.group_size == 2, "Group size must be 2"
        assert seed.amount >= 100_000, "Amount must be >= 0.1 Algos"
        self.is_bootstrapped = True
        return seed.amount

    @arc4.abimethod()
    def mint(self, txn: gtxn.PaymentTransaction) -> UInt64:
        """Mint a fibonacci NFT.

        Args:
            txn (gtxn.PaymentTransaction): The payment transaction.

        Returns:
            UInt64: The asset ID.
        """
        assert self.is_bootstrapped, "Application must be bootstrapped"
        assert (
            txn.receiver == Global.current_application_address
        ), "Receiver must be app address"
        assert txn.amount >= 200_000, "Amount must be >= 0.2 Algos"
        assert self.n_minted < MAX_ASSETS, "No more assets to mint."

        asa = create_asset(Txn.sender, self.a, self.n_minted)
        self.n_minted += 1
        if self.n_minted < MAX_ASSETS:
            self.a, self.b = self.b, self.a + self.b
        return asa.asset_id

    @arc4.abimethod()
    def claim(self, asa: Asset) -> UInt64:
        """Claim an asset back from the application.

        Args:
            asa (Asset): The asset to claim.

        Returns:
            UInt64: The amount returned.
        """
        assert self.is_bootstrapped, "Application must be bootstrapped"
        assert asa.balance(Global.current_application_address) > 0, "Asset not found"
        assert asa.creator == Global.current_application_address, "Invalid creator"
        assert asa.reserve == Txn.sender, "Invalid reserve"
        return transfer_asset(asa, Txn.sender)