DEV Community

Cover image for Smart ASA
Jaybee020
Jaybee020

Posted on

Smart ASA

Implementing Royalty Transfers and Asset delegation of ASA using a Smart ASA contract.

Overview

Introduction

The Algorand Standard Asset (ASA) is an excellent building block for on-chain applications. It is battle-tested and widely supported by SDKs, wallets, and dApps.

However, the ASA lacks in flexibility and configurability. For instance, once issued it can’t be re-configured (its unit name, decimals, maximum supply). Also, it is freely transferable (unless frozen). This prevents developers from specifying additional business logic to be checked while transferring it (think of royalties or asset_delegation).ARC-0020 allows creators to add extra functionality to their tokens that is not possible with ASAs by leveraging smart contracts. This particular contracts adds the below additional functionalities.

  • Transferring a set amount of the asset whenever any type of asset transfer occurs between two holders of the asset

*Enabling other holders of the asset to spend a preset amount of your assets.

This solution was built using Algorand’s Smart Contracts generated with PyTeal (TEAL v7).

Design Overview

Assumptions

In this solution we assume that every transfer of this asset is always greater than the royalty amount set.The royalty amount can't also be changed after setting it on deployment.

State

The smart contract 14 global variables where 6 are integers and 8 are bytes
1.smart_asa_id-algorand standard asset Id this contract controls
2.royalty_amount-amount of asa paid to the creator of the contract for every asset transfer
3.frozen-freeze transfer of these assets
4.total-total supply of asset
5.decimals-number of digits to use after the decimal point when displaying the asset
6.default_frozen- freeze holdings for this asset by default.
7.unit_name-name of a unit of this asset
8.name-name of the asset
9.url-specifies a URL where more information about the asset can be retrieved
10.metadata_hash- 32-byte hash of some metadata that is relevant to the asset and contract.
11.manager_addr-address of the account that can manage the configuration of the asset
12.reserve_addr-address of the account that holds the reserve (non-minted) units of the asset
13.freeze_addr-address of the account used to freeze holdings of this asset
14.clawback_addr-address of the account that can clawback holdings of this asset.

Opted-In accounts local storage are also used as follows:
1.smart_asa_id-asa this user has opted into.Must be equal to that controlled by the smart contract
2.frozen-frozen status of account.
3.The rest of the users local storage is used to for asset delegation

Operations

Our smart contract supports 3 major methods:

  1. asset_transfer-transfer assets between two accounts(opted into the asset and contract) and enforces a royalty fee transfer to the creator
  2. asset_delegate- allow another account (opted into the contract and asset) to spend certain amounts of your tokens.
  3. get_delegate_allowance-retrieve the amount allowed to be spent by a delegated account
  4. delegate_asset_transfer-the delegated account initiates a transfer of the asset on behalf of the owner.

PyTeal Code

Let us get into the actual pyteal code needed for the implementation of the above methods

Initial deployment of the contract is handled as follows

@Subroutine(TealType.none)
def set_global_vars(total: Expr, decimals: Expr, default_frozen: Expr, unit_name: Expr, name: Expr, url: Expr, metadata_hash: Expr, manager_addr: Expr, reserve_addr: Expr, freeze_addr: Expr, clawback_addr: Expr):
    return Seq(
        App.globalPut(Bytes("total"), total),
        App.globalPut(Bytes("decimals"), decimals),
        App.globalPut(Bytes("default_frozen"), default_frozen),
        App.globalPut(Bytes("unit_name"), unit_name),
        App.globalPut(Bytes("name"), name),
        App.globalPut(Bytes("url"), url),
        App.globalPut(Bytes("metadata_hash"), metadata_hash),
        App.globalPut(Bytes("manager_addr"), manager_addr),
        App.globalPut(Bytes("reserve_addr"), reserve_addr),
        App.globalPut(Bytes("freeze_addr"), freeze_addr),
        App.globalPut(Bytes("clawback_addr"), clawback_addr),
    )

# store the deployer of the contract in global storage and initialize global vars
handle_Creation = Seq(
    App.globalPut(Bytes("smart_asa_id"), Int(0)),
    App.globalPut(Bytes("royalty_amount"), Int(2)),
    App.globalPut(Bytes("frozen"), Int(0)),
    set_global_vars(Int(0), Int(0), Int(0), Bytes(""), Bytes(""), Bytes(""), Bytes(
        ""), Global.zero_address(), Global.zero_address(), Global.zero_address(), Global.zero_address()),
    Approve()
)
Enter fullscreen mode Exit fullscreen mode

The above sequence just simply initialied value for the smart contract global variables

ABI SUBROUTINES

asset_transfer

@ABIReturnSubroutine
def asset_transfer(xfer_asset: abi.Asset, asset_amount: abi.Uint64, asset_sender: abi.Account, asset_receiver: abi.Account, royalty_receiver: abi.Account):
    is_sender = Txn.sender() == asset_sender.address()
    asset_frozen = App.globalGet(Bytes("frozen"))
    is_smartASA_id = xfer_asset.asset_id() == smart_asa_id
    is_clawback = Txn.sender() == current_clawback_addr
    sender_frozen = App.localGet(asset_sender.address(), Bytes("frozen"))
    receiver_frozen = App.localGet(asset_receiver.address(), Bytes("frozen"))
    # only reserve addr can mint token and the tokens must come from the smart contract
    is_minting = And(
        Txn.sender() == current_reserve_addr,
        asset_sender.address() == Global.current_application_address()
    )
    # only reserve addr can burn tokens and reserve can only burn its own tokens to be sent to
    is_burning = And(
        is_sender,
        asset_sender.address() == current_reserve_addr,
        asset_receiver.address() == Global.current_application_address()
    )
    users_opted_in = And(
        smart_asa_id == App.localGet(
            asset_sender.address(), Bytes("smart_asa_id")),
        smart_asa_id == App.localGet(
            asset_receiver.address(), Bytes("smart_asa_id")),
    )
    return Seq(
        Assert(
            And(
                smart_asa_id,
                is_smartASA_id,
                Len(asset_sender.address()) == Int(32),
                Len(asset_receiver.address()) == Int(32),
                asset_amount.get() > current_royalty_amount,
                royalty_receiver.address() == Global.creator_address()
            )
        ),
        # if it is a normal transaction and not clawback addr
        If(And(is_sender, Not(is_clawback))).Then(
            Assert(
                And(
                    Not(sender_frozen),
                    Not(receiver_frozen),
                    Not(asset_frozen),
                    users_opted_in)
            )
        ).ElseIf(is_minting).Then(
            # make sure mint doesnt carry it over the total
            Assert(
                And(
                    Not(asset_frozen),
                    Not(receiver_frozen),
                    smart_asa_id == App.localGet(
                        asset_receiver.address(), Bytes("smart_asa_id")),
                    calculate_circulating_supply(xfer_asset.asset_id(
                    ))+asset_amount.get() <= App.globalGet(Bytes("total"))
                )

            )
        ).ElseIf(is_burning).Then(
            Assert(
                And(
                    Not(asset_frozen),
                    Not(sender_frozen),
                    smart_asa_id == App.localGet(
                        asset_sender.address(), Bytes("smart_asa_id")),
                )
            )
        ).Else(
            Assert(And(is_clawback, users_opted_in)),
        ),
        # Group transaction to transfer asset and pay royalty sum to creator
        InnerTxnBuilder.Begin(),
        InnerTxnBuilder.SetFields(
            {
                TxnField.type_enum: TxnType.AssetTransfer,
                TxnField.xfer_asset: smart_asa_id,
                TxnField.asset_amount: asset_amount.get()-current_royalty_amount,
                TxnField.asset_sender: asset_sender.address(),
                TxnField.asset_receiver: asset_receiver.address(),
                TxnField.fee: Int(0),
            }
        ),
        InnerTxnBuilder.Next(),
        InnerTxnBuilder.SetFields(
            {
                TxnField.type_enum: TxnType.AssetTransfer,
                TxnField.xfer_asset: smart_asa_id,
                TxnField.asset_amount: current_royalty_amount,
                TxnField.asset_sender: asset_sender.address(),
                TxnField.asset_receiver: royalty_receiver.address(),
                TxnField.fee: Int(0),
            }
        ),
        InnerTxnBuilder.Submit(),
    )

Enter fullscreen mode Exit fullscreen mode

The above subroutine simply checks if valid conditions are met for the transfer to occur.The royalty fee is removed from the asset amount to be transferred and sent to the creator of the contract.The asset transfer transaction and royalty transaction are then grouped together and submitted.


asset_delegate

@ABIReturnSubroutine
def asset_delegate(delegate_asset: abi.Asset, amount: abi.Uint64, spender: abi.Account):
    asset_frozen = App.globalGet(Bytes("default_frozen"))
    is_smartASA_id = smart_asa_id == delegate_asset.asset_id()
    owner_optedIn = smart_asa_id == App.localGet(
        Txn.sender(), Bytes("smart_asa_id"))
    owner_frozen = App.localGet(Txn.sender(), Bytes("frozen"))
    owner_balance = AssetHolding.balance(
        Txn.sender(), delegate_asset.asset_id()
    )
    spender_optedIn = smart_asa_id == App.localGet(
        spender.address(), Bytes("smart_asa_id"))
    spender_frozen = App.localGet(spender.address(), Bytes("frozen"))
    return Seq(
        # check spender and owner are opted in and are not frozen and you cant delegate yourself
        Assert(
            And(
                smart_asa_id,
                spender.address() != Txn.sender(),
                Not(asset_frozen),
                is_smartASA_id,
                owner_optedIn,
                Not(owner_frozen),
                spender_optedIn,
                Not(spender_frozen)
            )
        ),
        owner_balance,
        # assert that amount to be delegated is not more than user balance
        Assert(
            And(
                owner_balance.hasValue(),
                amount.get() <= owner_balance.value()
            )
        ),
        App.localPut(Txn.sender(), spender.address(), amount.get()),
        Approve()
    )
Enter fullscreen mode Exit fullscreen mode

The above subroutine is used to delegate assets to other accounts to spend.The contract asserts that the spender and owner(sender of the transaction) are opted into the contract and are not frozen. It also ensures that the amount delegated is less than the asset balance of the owner. It then puts a key-value pair in the sender accounts local storage where the key is the address of the sender and the value is the asset amount that can be spent by the delegate.


get_delegate_allowance

@ABIReturnSubroutine
def get_delegate_allowance(delegate_asset: abi.Asset, owner: abi.Account, spender: abi.Account, *, output: abi.Uint64):
    is_smartASA_id = delegate_asset.asset_id() == smart_asa_id
    owner_optedIn = smart_asa_id == App.localGet(
        owner.address(), Bytes("smart_asa_id"))
    spender_optedIn = smart_asa_id == App.localGet(
        spender.address(), Bytes("smart_asa_id"))
    return Seq(
        Assert(
            And(
                smart_asa_id,
                is_smartASA_id,
                owner_optedIn,
                spender_optedIn
            ),
        ),
        output.set(App.localGet(owner.address(), spender.address()))
    )

Enter fullscreen mode Exit fullscreen mode

The above method is used to get the current allowance of a delegate spender of an asset for the owner of the asset.


@ABIReturnSubroutine
def delegate_asset_transfer(xfer_asset: abi.Asset, asset_amount: abi.Uint64, asset_owner: abi.Account, asset_receiver: abi.Account, royalty_receiver: abi.Account):
    asset_frozen = App.globalGet(Bytes("default_frozen"))
    is_smartASA_id = xfer_asset.asset_id() == smart_asa_id
    owner_optedIn = smart_asa_id == App.localGet(
        asset_owner.address(), Bytes("smart_asa_id"))
    owner_frozen = App.localGet(asset_owner.address(), Bytes("frozen"))
    spender_optedIn = App.localGet(Txn.sender(), Bytes("smart_asa_id"))
    spender_frozen = App.localGet(Txn.sender(), Bytes("frozen"))
    spender_allowance = App.localGet(asset_owner.address(), Txn.sender())
    return Seq(
        Assert(
            And(
                smart_asa_id,
                Not(asset_frozen),
                is_smartASA_id,
                owner_optedIn,
                Not(owner_frozen),
                spender_optedIn,
                Not(spender_frozen),
                asset_amount.get() <= spender_allowance,
                asset_amount.get() > current_royalty_amount,
                royalty_receiver.address() == Global.creator_address()
            )
        ),
        App.localPut(asset_owner.address(), Txn.sender(),
                     spender_allowance-asset_amount.get()),
        InnerTxnBuilder.Begin(),
        InnerTxnBuilder.SetFields(
            {
                TxnField.type_enum: TxnType.AssetTransfer,
                TxnField.xfer_asset: smart_asa_id,
                TxnField.asset_amount: asset_amount.get()-current_royalty_amount,
                TxnField.asset_sender: asset_owner.address(),
                TxnField.asset_receiver: asset_receiver.address(),
                TxnField.fee: Int(0),
            }
        ),
        InnerTxnBuilder.Next(),
        InnerTxnBuilder.SetFields(
            {
                TxnField.type_enum: TxnType.AssetTransfer,
                TxnField.xfer_asset: smart_asa_id,
                TxnField.asset_amount: current_royalty_amount,
                TxnField.asset_sender: asset_owner.address(),
                TxnField.asset_receiver: royalty_receiver.address(),
                TxnField.fee: Int(0),
            }
        ),
        InnerTxnBuilder.Submit(),

    )
Enter fullscreen mode Exit fullscreen mode

This method is used by a delegated spender to transfer the smart-asa on behalf of the owner.The contract asserts that the spender is opted into the contract and asset,the asset amount to be transferred is not more than the preset amount and the royalty receiver address is valid. The asset is transferred and the spender's local state is updated with the new balance of the account.

Note we have the following constants in out pyteal contract

  • is_creator = Txn.sender() == Global.creator_address()
  • is_manager = Txn.sender() == App.globalGet(Bytes("manager_addr"))
  • smart_asa_id = App.globalGet(Bytes("smart_asa_id"))
  • current_royalty_amount = App.globalGet(Bytes("royalty_amount"))
  • current_clawback_addr = App.globalGet(Bytes("clawback_addr"))
  • current_reserve_addr = App.globalGet(Bytes("reserve_addr"))
  • current_freeze_addr = App.globalGet(Bytes("freeze_addr"))
  • current_manager_addr = App.globalGet(Bytes("manager_addr"))

The approval program
The routing of calls of the contract and generation of approval and clear program is handled as follows

# handle routing logic of your contract
router = Router(
    name="Smart ASA",
    bare_calls=BareCallActions(
        no_op=OnCompleteAction(
            action=handle_Creation, call_config=CallConfig.CREATE
        ),
        opt_in=OnCompleteAction(
            action=Reject(), call_config=CallConfig.CALL
        ),
        clear_state=OnCompleteAction(
            action=Reject(), call_config=CallConfig.CALL
        ),
        close_out=OnCompleteAction(
            action=Reject(), call_config=CallConfig.CALL
        ),
        # Prevent updating and deleting of this application
        update_application=OnCompleteAction(
            action=Reject(), call_config=CallConfig.CALL
        ),
        delete_application=OnCompleteAction(
            action=Reject(), call_config=CallConfig.CALL
        ),

    )
)

router.add_method_handler(asset_create)
# router.add_method_handler(asset_config)
router.add_method_handler(get_asset_config)
router.add_method_handler(
    asset_optin, method_config=MethodConfig(opt_in=CallConfig.CALL))
router.add_method_handler(asset_transfer)
# router.add_method_handler(asset_freeze)
# router.add_method_handler(get_asset_frozen)
# router.add_method_handler(get_account_frozen)
# router.add_method_handler(get_circulating_supply)
# router.add_method_handler(asset_destroy)
# router.add_method_handler(
#     asset_optout, method_config=MethodConfig(close_out=CallConfig.CALL))
router.add_method_handler(asset_delegate)
router.add_method_handler(get_delegate_allowance)
router.add_method_handler(delegate_asset_transfer)


approval_program, clear_state_program, contract = router.compile_program(
    version=7, optimize=OptimizeOptions(scratch_slots=True)
)


with open("contracts/app.teal", "w") as f:
    f.write(approval_program)

with open("contracts/clear.teal", "w") as f:
    f.write(clear_state_program)


with open("contracts/contract.json", "w") as f:
    f.write(json.dumps(contract.dictify(), indent=4))

Enter fullscreen mode Exit fullscreen mode

USAGE

The smart-ASA can be deployed and tested locally or testned.Watch this video for steps on testing locally.

Top comments (0)