Tokenised Subscriptions on Algorand

Introduction

In this blog post, we'll explore a novel way to implement periodic payments on Algorand.

The general idea is to write a smart contract that issues redeemable subscription tokens.

Each subscription is represented by a unique asset, and each unit of the asset is a token that can be redeemed for a single subscription payment:

These tokens can be freely traded, allowing token holders to transfer them to the application account at the designated time and receive a payment.

The contract will be written in Algorand Python.

State

Instead of storing subscription details in the contract's state, we store the information in asset parameters:

DatumDescriptionAsset Parameter
initial_redeemerThe account that can initially withdraw the subscription tokens.reserve
active_fromThe round that the subscription is active from.metadata_hash
payment_amountThe amount of MicroAlgos that can be withdrawn each payment cycle.metadata_hash
payment_frequencyThe number of rounds per payment cycle.metadata_hash
max_paymentsThe maximum number of payments that can be claimed for a subscription.total

The metadata_hash is an arbitrary field of 32 bytes (256 bits), so we can fit the bytes of four UInt64 values in it:

metadata_hash = active_from.bytes + payment_amount.bytes + payment_frequency.bytes + arc4.UInt64(Global.round).bytes

The Global.round isn't really needed, but since we have 8 bytes left at the end, I've added it anyway.

For convenience and readability, we'll provide methods for parsing these values from a subscription token's metadata hash:

@arc4.abimethod
def active_from(self, subscription: Asset) -> UInt64:
    return op.extract_uint64(subscription.metadata_hash, 0)

@arc4.abimethod
def payment_amount(self, subscription: Asset) -> UInt64:
    return op.extract_uint64(subscription.metadata_hash, 8)

@arc4.abimethod
def payment_frequency(self, subscription: Asset) -> UInt64:
    return op.extract_uint64(subscription.metadata_hash, 16)

As a side note, it would be cool if Algorand Python supports the @property decorator in the future.

Payment Schedules

The total units of the subscription token represents the maximum number of payments that can be made.

If there are 100 units of the asset, the subscription has 100 pay cycles.

An individual should be able to claim a payment if they own a unit of the token, and transfer it to the contract during the pay cycle interval.

The current cycle number should be zero for all rounds prior to the subscription's active_from round.

For all subsequent rounds, the cycle number is calculated as the number of rounds that have elapsed since the active_from round, floor divided by the payment_frequency, plus 1:

@arc4.abimethod
def cycle_number(self, subscription: Asset, at_round: UInt64) -> UInt64:
    active_from = self.active_from(subscription)
    return UInt64(0) if at_round < active_from else (at_round - active_from) // self.payment_frequency(subscription) + 1

For example, if the subscription is active_from round 5, with a payment_frequency of 1 round:

If active_from is round 1,000, with a payment_frequency of 1,000 rounds:

Claiming Payments

After receiving a subscription token, the contract's balance of the token must exactly equal the current cycle number in order for a payment to be made.

This prevents the token owner from withdrawing funds outside of the current pay cycle interval.

The only way for someone to receive a payment is by transferring a token to the contract, so the contract's balance of the token has to increase each time.

It should be impossible for someone to make two claims in the same payment cycle, because on the subsequent application call the balance will no longer equal the payment cycle number.

In the contract:

@arc4.abimethod
def claim_payment(self, axfer: gtxn.AssetTransferTransaction) -> UInt64:
    subscription = axfer.xfer_asset
    assert (
        subscription.creator == Global.current_application_address
    ), "Asset must have been created by this application"
    assert axfer.asset_receiver == Global.current_application_address, "Asset receiver must be application account"
    assert axfer.asset_amount == 1, "Asset amount must be 1"

    cycle_number = self.cycle_number(subscription, Global.round)
    next_balance = subscription.balance(Global.current_application_address) + 1
    assert not next_balance > cycle_number, "Cannot claim payment for future cycle"
    if next_balance == cycle_number:
        return itxn.Payment(
            receiver=axfer.sender,
            amount=self.payment_amount(subscription),
            fee=0,
        ).submit().amount
    return UInt64(0)

We first check that the asset being transferred to the application account was created by the contract (meaning it's a valid subscription token), and that a single unit of the asset is being transferred.

The contract then calculates the current pay cycle number and the proposed next balance of the contract (current balance + the single unit being transferred).

If the proposed next balance will be > the current cycle number, an error is thrown (preventing the transfer).

This should prevent someone accidentally trying to deposit the token prior to the corresponding pay cycle, but obviously there's nothing stopping someone transferring it without calling this method.

In the latter scenario, the token owner would effectively forfeit the right to claim a payment for that cycle.

If the proposed next balance exactly equals the cycle number, then a payment is made.

Otherwise, if it's <= the current cycle number (meaning the owner failed to claim some previous payments on time), the contract accepts the additional token but doesn't transfer any funds to the claimant.

The Complete Contract Code

The code below is also available on GitHub.

# pyright: reportMissingModuleSource=false
from algopy import Account, ARC4Contract, Asset, Global, Txn, UInt64, arc4, gtxn, itxn, op


class TokenisedSubscriptions(ARC4Contract):
    """A contract for subscription payments using redeemable NFTs."""

    @arc4.abimethod
    def mint_tokens(
        self,
        initial_redeemer: Account,
        active_from: arc4.UInt64,
        payment_amount: arc4.UInt64,
        payment_frequency: arc4.UInt64,
        max_payments: arc4.UInt64,
    ) -> UInt64:
        """Creates

        Args:
            initial_redeemer (Account): _description_
            active_from (arc4.UInt64): _description_
            payment_amount (arc4.UInt64): _description_
            payment_frequency (arc4.UInt64): _description_
            max_payments (arc4.UInt64): _description_

        Returns:
            UInt64: _description_
        """
        assert Txn.sender == Global.creator_address, "Only the contract creator can call this method"
        return (
            itxn.AssetConfig(
                asset_name=Global.creator_address.bytes,
                unit_name=arc4.UInt64(Global.current_application_address.total_assets_created).bytes,
                total=max_payments.native,
                decimals=0,
                metadata_hash=active_from.bytes
                + payment_amount.bytes
                + payment_frequency.bytes
                + arc4.UInt64(Global.round).bytes,
                manager=Global.current_application_address,
                reserve=initial_redeemer,
                freeze=Global.current_application_address,
                clawback=Global.current_application_address,
                fee=0,
            )
            .submit()
            .created_asset.id
        )

    @arc4.abimethod
    def withdraw_tokens(self, subscription: Asset) -> None:
        """Transfers the subscription tokens to the initial redeemer.

        Args:
            subscription (Asset): The subscription token.
        """
        assert subscription.reserve == Txn.sender
        itxn.AssetTransfer(
            xfer_asset=subscription, asset_receiver=Txn.sender, asset_amount=subscription.total, fee=0
        ).submit()

    @arc4.abimethod
    def active_from(self, subscription: Asset) -> UInt64:
        """Parses the 'active from' round number from the subscription token.

        Args:
            subscription (Asset): The subscription token.

        Returns:
            UInt64: The 'active from' round number.
        """
        return op.extract_uint64(subscription.metadata_hash, 0)

    @arc4.abimethod
    def payment_amount(self, subscription: Asset) -> UInt64:
        """Parses the payment amount from the subscription token.

        Args:
            subscription (Asset): The subscription token.

        Returns:
            UInt64: The payment amount in MicroAlgos.
        """
        return op.extract_uint64(subscription.metadata_hash, 8)

    @arc4.abimethod
    def payment_frequency(self, subscription: Asset) -> UInt64:
        """Parses the payment frequency from the subscription token.

        Args:
            subscription (Asset): The subscription token.

        Returns:
            UInt64: The payment frequency (number of rounds).
        """
        return op.extract_uint64(subscription.metadata_hash, 16)

    @arc4.abimethod
    def cycle_number(self, subscription: Asset, at_round: UInt64) -> UInt64:
        """Calculates the payment cycle number 'as at' a specific round.

        Args:
            subscription (Asset): The subscription token.
            at_round (UInt64): The round to calculate the payment cycle 'as at'.

        Returns:
            UInt64: The payment cycle number.
        """
        active_from = self.active_from(subscription)
        return (
            UInt64(0)
            if at_round < active_from
            else (at_round - active_from) // self.payment_frequency(subscription) + 1
        )

    @arc4.abimethod
    def claim_payment(self, axfer: gtxn.AssetTransferTransaction) -> UInt64:
        """Makes a payment to the subscription token owner, if eligible.

        Args:
            axfer (gtxn.AssetTransferTransaction): The transaction transferring a unit of the subscription token to the contract account.

        Returns:
            UInt64: The amount of MicroAlgos paid.
        """
        subscription = axfer.xfer_asset
        assert (
            subscription.creator == Global.current_application_address
        ), "Asset must have been created by this application"
        assert axfer.asset_receiver == Global.current_application_address, "Asset receiver must be application account"
        assert axfer.asset_amount == 1, "Asset amount must be 1"

        cycle_number = self.cycle_number(subscription, Global.round)
        next_balance = subscription.balance(Global.current_application_address) + 1
        assert not next_balance > cycle_number, "Cannot claim payment for future cycle"
        if next_balance == cycle_number:
            return (
                itxn.Payment(
                    receiver=axfer.sender,
                    amount=self.payment_amount(subscription),
                    fee=0,
                )
                .submit()
                .amount
            )
        return UInt64(0)

Writing Tests

The code below is also available on GitHub.

from base64 import b64decode

import algokit_utils
import pytest
from algokit_utils import TransactionParameters, TransferParameters, get_localnet_default_account, opt_in, transfer
from algokit_utils.config import config
from algosdk.atomic_transaction_composer import (
    AccountTransactionSigner,
    TransactionWithSigner,
)
from algosdk.transaction import AssetTransferTxn
from algosdk.v2client.algod import AlgodClient
from algosdk.v2client.indexer import IndexerClient
from pydantic import BaseModel
from sympy import Piecewise, symbols

from smart_contracts.artifacts.tokenised_subscriptions.client import TokenisedSubscriptionsClient


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

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

    client = TokenisedSubscriptionsClient(
        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=1_000_000_000,
        ),
    )

    return client


class Subscription(BaseModel):
    asset_id: int
    initial_redeemer: str
    active_from: int
    payment_amount: int
    payment_frequency: int
    max_payments: int


@pytest.fixture
def subscription(algod_client: AlgodClient, app_client: TokenisedSubscriptionsClient) -> Subscription:
    last_round = algod_client.status()["last-round"]
    account = get_localnet_default_account(algod_client)

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

    initial_redeemer = account.address
    active_from = last_round
    payment_amount = 1_000_000
    payment_frequency = 5
    max_payments = 10

    asset_id = app_client.mint_tokens(
        initial_redeemer=initial_redeemer,
        active_from=active_from,
        payment_amount=payment_amount,
        payment_frequency=payment_frequency,
        max_payments=max_payments,
        transaction_parameters=TransactionParameters(suggested_params=sp),
    ).return_value

    return Subscription(
        asset_id=asset_id,
        initial_redeemer=initial_redeemer,
        active_from=active_from,
        payment_amount=payment_amount,
        payment_frequency=payment_frequency,
        max_payments=max_payments,
    )


def test_mint_tokens(
    algod_client: AlgodClient, app_client: TokenisedSubscriptionsClient, subscription: Subscription
) -> None:
    """Tests the mint_tokens() and withdraw_tokens() methods."""
    assert isinstance(subscription.asset_id, int) and subscription.asset_id > 0, "Asset minting failed"

    app_balance = algod_client.account_asset_info(address=app_client.app_address, asset_id=subscription.asset_id)[
        "asset-holding"
    ]["amount"]
    assert app_balance == subscription.max_payments, "Asset not owned by application account"


def test_asset_params(
    algod_client: AlgodClient, app_client: TokenisedSubscriptionsClient, subscription: Subscription
) -> None:
    """Tests the subscription token's asset params."""
    asset_params = algod_client.asset_info(subscription.asset_id)["params"]

    assert asset_params["total"] == subscription.max_payments, "Asset total units is incorrect"
    assert (
        asset_params["creator"]
        == asset_params["manager"]
        == asset_params["freeze"]
        == asset_params["clawback"]
        == app_client.app_address
    ), "Incorrect asset params"
    assert asset_params["reserve"] == subscription.initial_redeemer, "Incorrect initial redeemer"

    # Check token state in metadata hash
    datum = b64decode(asset_params["metadata-hash"])
    btoi = lambda b: int.from_bytes(b, "big")
    active_from = btoi(datum[0:8])
    payment_amount = btoi(datum[8:16])
    payment_frequency = btoi(datum[16:24])

    assert active_from == subscription.active_from, "Incorrect active from round"
    assert payment_amount == subscription.payment_amount, "Incorrect payment amount"
    assert payment_frequency == subscription.payment_frequency, "Incorrect payment_frequency"


def test_withdraw_tokens(
    algod_client: AlgodClient, app_client: TokenisedSubscriptionsClient, subscription: Subscription
) -> None:
    """Tests the withdraw_tokens() method."""
    account = get_localnet_default_account(algod_client)
    opt_in(algod_client, account, asset_ids=[subscription.asset_id])

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

    balance_before = algod_client.account_asset_info(account.address, subscription.asset_id)["asset-holding"]["amount"]
    assert balance_before == 0, "Starting balance of token should be zero"
    app_client.withdraw_tokens(
        subscription=subscription.asset_id, transaction_parameters=TransactionParameters(suggested_params=sp)
    )
    balance_after = algod_client.account_asset_info(account.address, subscription.asset_id)["asset-holding"]["amount"]
    assert balance_after == subscription.max_payments, "Balance of token after withdrawing is incorrect"


def test_cycle_number(app_client: TokenisedSubscriptionsClient, subscription: Subscription) -> None:
    """Tests the cycle_number() method against a reference implementation in Sympy."""
    active_from, payment_frequency, at_round = symbols(
        r"{active\_from} {payment\_frequency} {at\_round}", integer=True, nonnegative=True
    )
    # Sympy reference implementation
    expr = Piecewise(
        (0, at_round < active_from), ((at_round - active_from) // payment_frequency + 1, True), evaluate=False
    )

    for r in range(subscription.active_from - 2, subscription.active_from + subscription.max_payments + 10):
        reference_value = int(
            expr.subs(
                [
                    (active_from, subscription.active_from),
                    (at_round, r),
                    (payment_frequency, subscription.payment_frequency),
                ]
            )
        )
        assert (
            app_client.cycle_number(subscription=subscription.asset_id, at_round=r).return_value == reference_value
        ), "Cycle number does not match Sympy reference implementation"


def test_claim_payment(
    algod_client: AlgodClient, app_client: TokenisedSubscriptionsClient, subscription: Subscription
) -> None:
    """Tests the claim_payment() method."""

    account = get_localnet_default_account(algod_client)
    opt_in(algod_client, account, asset_ids=[subscription.asset_id])

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

    # Withdraw subscription tokens first
    app_client.withdraw_tokens(
        subscription=subscription.asset_id, transaction_parameters=TransactionParameters(suggested_params=sp)
    )

    account_balance_start = algod_client.account_info(account.address)["amount"]

    fees = 0
    received = 0

    while True:
        token_balance = algod_client.account_asset_info(account.address, subscription.asset_id)["asset-holding"][
            "amount"
        ]

        if not token_balance:
            break
        try:
            axfer_txn = AssetTransferTxn(
                sp=sp,
                sender=account.address,
                receiver=app_client.app_address,
                amt=1,
                index=subscription.asset_id,
            )
            received += app_client.claim_payment(
                axfer=TransactionWithSigner(txn=axfer_txn, signer=AccountTransactionSigner(account.private_key))
            ).return_value

            fees += 3_000
        except algokit_utils.logic_error.LogicError:
            # Trigger another round on test net
            app_client.cycle_number(subscription=subscription.asset_id, at_round=0)
            fees += 1_000

    account_balance_end = algod_client.account_info(account.address)["amount"]
    token_balance_end = algod_client.account_asset_info(account.address, subscription.asset_id)["asset-holding"][
        "amount"
    ]
    assert token_balance_end == 0, "Tokens not transferred"
    assert (
        account_balance_end == account_balance_start + (subscription.max_payments * subscription.payment_amount) - fees
    ), "Incorrect balances after withdrawing"
    assert received == subscription.max_payments * subscription.payment_amount, "Incorrect amount received"