ARC69 Minter and Modifier Part 3

Video Tutorial

Contract - User Sends Pokemon To Training (Stakes) and Removes from Training (Unstakes)

Compile the contract using:

algokit compile arc69NFTmodifier.py
from algopy import ARC4Contract, itxn, Global, GlobalState, UInt64, gtxn, Bytes, subroutine, String, BoxRef, Txn, op, LocalState, Account
from algopy.arc4 import abimethod, Struct, Address, Bool
from algopy.arc4 import UInt64 as arc4UInt64
from algopy.arc4 import String as arc4String

class availablePokemon(Struct):
    pokemon_name: arc4String
    pokemon_type: arc4String
    pokemon_description: arc4String
    pokemon_ipfs_hash: arc4String
    
class userPokemonInfo(Struct):
    uid: arc4UInt64
    owner: Address
    asset_id: arc4UInt64
    pokemon_id: arc4UInt64
    level: arc4UInt64
    exp: arc4UInt64
    training: Bool
    training_start_time: arc4UInt64

class arc69NFTmodifier(ARC4Contract):
    def __init__(self) -> None:
        self.pokemonUnitCounter = GlobalState(arc4UInt64(0))
        self.pokemonCreated = GlobalState(arc4UInt64(0))
        
    @subroutine
    def itoa(self, i: UInt64) -> Bytes:
        digits = Bytes(b"0123456789")
        radix = digits.length
        if i < radix:
            return digits[i]
        return self.itoa(i // radix) + digits[i % radix]      
    
    @abimethod
    def registerNewPokemonData(
        self,
        pokemon_name: arc4String,
        pokemon_type: arc4String,
        pokemon_description: arc4String,
        pokemon_ipfs_hash: arc4String,
        payment_txn: gtxn.PaymentTransaction,
    ) -> arc4String:        
        
       # assert payment_txn.amount
        assert payment_txn.receiver == Global.current_application_address
        
        self.pokemonUnitCounter.value = arc4UInt64(self.pokemonUnitCounter.value.native + 1)
        
        box_ref = BoxRef(key=self.pokemonUnitCounter.value.bytes)
        value, exists = box_ref.maybe()
        assert not exists
        
        new_pokemon_info = availablePokemon(
            pokemon_name=pokemon_name,
            pokemon_type=pokemon_type,
            pokemon_description=pokemon_description,
            pokemon_ipfs_hash=pokemon_ipfs_hash
        )
        
        box_ref.create(size=new_pokemon_info.bytes.length)
        box_ref.put(new_pokemon_info.bytes)
        
        
        return arc4String('Pokemon Registered to Contract: ') + pokemon_name    
    
    @abimethod
    def determineUserPokemon(
        self,
        payment_txn: gtxn.PaymentTransaction
    ) -> tuple[arc4String, arc4UInt64]:
        
        assert payment_txn.amount == 18_500
        assert payment_txn.receiver == Global.current_application_address
        
        randomizer = arc4UInt64(
            Global.latest_timestamp + Global.round + arc4UInt64.from_bytes(Txn.tx_id[0:8]).native % arc4UInt64.from_bytes(Txn.tx_id[-8:]).native
        )
        
        hashed_randomizer = op.sha256(randomizer.bytes)
        
        pokemon_selection = arc4UInt64((arc4UInt64.from_bytes(hashed_randomizer[0:8]).native % self.pokemonUnitCounter.value.native) + 1)
        
        users_claim_box = BoxRef(key=Txn.sender.bytes)
        value, exists = users_claim_box.maybe()
        assert not exists
        
        users_claim_box.create(size=8)
        users_claim_box.put(pokemon_selection.bytes)
        
        return arc4String('Users Pokemon # Selected:'), pokemon_selection
        
    @abimethod()
    def mintUserPokemon(
        self,
        payment_txn: gtxn.PaymentTransaction
    ) -> tuple[arc4String, UInt64]:

        assert payment_txn.amount >= 152900
        assert payment_txn.receiver == Global.current_application_address
        
        users_claim_box = BoxRef(key=Txn.sender.bytes)
        claim_box_value, exists = users_claim_box.maybe()
        assert exists
        
        pokemon_selection = arc4UInt64.from_bytes(claim_box_value)
        
        pokemon_info_bytes = BoxRef(key=pokemon_selection.bytes)
        pokemon_info_value, exists = pokemon_info_bytes.maybe()
        assert exists
        
        available_pokemon_info = availablePokemon.from_bytes(pokemon_info_value)
        unit_name_int_as_string = String.from_bytes(self.itoa(pokemon_selection.native))
        
        uid_as_string = String.from_bytes(self.itoa(self.pokemonCreated.value.native))        
        
        pokemon_creation_tx = itxn.AssetConfig(
            total=1,
            manager=Global.current_application_address,
            asset_name=available_pokemon_info.pokemon_name.native,
            unit_name='PK#' + unit_name_int_as_string,
            decimals=0,
            url= 'https://gateway.pinata.cloud/ipfs/' + available_pokemon_info.pokemon_ipfs_hash.native,
            note = (
                '{"standard": "arc69", "mime_type": "image/png", "properties": {'
                '"Type": "' + available_pokemon_info.pokemon_type.native + '", '
                '"Description": "' + available_pokemon_info.pokemon_description.native + '", '
                '"Level": "1", '
                '"Experience": "0", '
                '"Pokemon Number": "' + unit_name_int_as_string + '", '
                '"UID": "' + uid_as_string + '" }}' 
            ),
            fee=Global.min_txn_fee
        ).submit()
        
        
        user_pokemon_box = BoxRef(key=self.pokemonCreated.value.bytes + Txn.sender.bytes)
        user_pokemon_box_value, exists = user_pokemon_box.maybe()
        assert not exists
        
        new_user_pokemon_info = userPokemonInfo(
            uid=self.pokemonCreated.value,
            owner=Address(Txn.sender),
            asset_id=arc4UInt64(pokemon_creation_tx.created_asset.id),
            pokemon_id=pokemon_selection,
            level=arc4UInt64(1),
            exp=arc4UInt64(0),
            training=Bool(False),
            training_start_time=arc4UInt64(0),
        )
        
        
        user_pokemon_box.create(size=new_user_pokemon_info.bytes.length)
        user_pokemon_box.put(new_user_pokemon_info.bytes)
        
        users_claim_box.delete()
        
        self.pokemonCreated.value = arc4UInt64(self.pokemonCreated.value.native + 1)
        
        return arc4String('User pokemon generated, asset ID: '), pokemon_creation_tx.created_asset.id
        
        
    @abimethod
    def claimPokemon(
        self,
        opt_in_txn: gtxn.AssetTransferTransaction,
        uid: arc4UInt64
    ) -> String:
        
        user_pokemon_box = BoxRef(key=uid.bytes + Txn.sender.bytes)
        user_pokemon_box_value, exists = user_pokemon_box.maybe()
        
        assert exists
        
        users_pokemon_info = userPokemonInfo.from_bytes(user_pokemon_box_value)
        
        #assert users_pokemon_info.owner == Address(Txn.sender)
        #assert opt_in_txn.xfer_asset.id == users_pokemon_info.asset_id
        
        itxn.AssetTransfer(
            asset_receiver=Txn.sender,
            xfer_asset=users_pokemon_info.asset_id.native,
            fee=Global.min_txn_fee,
            asset_amount=1
        ).submit()        
        
        return String('User received their pokemon')       
                
    @abimethod
    def trainPokemon(
        self,
        stake_pokemon_tx: gtxn.AssetTransferTransaction,
        uid: arc4UInt64
    ) -> String:
        
        assert stake_pokemon_tx.asset_amount == 1
        assert stake_pokemon_tx.asset_receiver == Global.current_application_address
        
        pokemon_to_train = stake_pokemon_tx.xfer_asset.id
        
        users_pokemon_info = BoxRef(key=uid.bytes + Txn.sender.bytes)
        value, exists = users_pokemon_info.maybe()
        assert exists
        
        current_pokemon_info = userPokemonInfo.from_bytes(value)
        assert pokemon_to_train == current_pokemon_info.asset_id
        
        current_pokemon_info.training_start_time = arc4UInt64(Global.latest_timestamp)
        current_pokemon_info.training = Bool(True)
        
        users_pokemon_info.put(current_pokemon_info.bytes)
    
        return String("Pokemon Staked")
        
        
    @abimethod
    def removePokemonFromTraining(
        self,
        pokemon_requested_uid: arc4UInt64
    ) -> String:
        
        users_pokemon_info_box = BoxRef(key=pokemon_requested_uid.bytes + Txn.sender.bytes)
        users_pokemon_info_value, exists = users_pokemon_info_box.maybe()
        assert exists
        
        current_users_pokemon_info = userPokemonInfo.from_bytes(users_pokemon_info_value)
        
        general_pokemon_info_box = BoxRef(key=current_users_pokemon_info.pokemon_id.bytes)
        general_pokemon_info_value, exists = general_pokemon_info_box.maybe()
        
        general_pokemon_info = availablePokemon.from_bytes(general_pokemon_info_value)


        unit_name_as_string = String.from_bytes(self.itoa(current_users_pokemon_info.pokemon_id.native))
        
        initial_training_time = current_users_pokemon_info.training_start_time.native
        current_time = Global.latest_timestamp
        time_difference = current_time - initial_training_time
        
        current_level = current_users_pokemon_info.level.native
        current_experience = current_users_pokemon_info.exp.native
        
        base_experience = UInt64(200)
        cumulative_experience = current_level * base_experience
        
        total_experience = current_experience + time_difference
        
        if total_experience >= cumulative_experience:
            levels_gained = (total_experience + base_experience - cumulative_experience) // base_experience
            
            new_level = current_level + levels_gained
            new_experience = total_experience - ((new_level - 1) * base_experience)
            
        else:
            new_level = current_level
            new_experience = total_experience
            
            
        new_level_string = String.from_bytes(self.itoa(new_level))
        new_experience_string = String.from_bytes(self.itoa(new_experience))

        uid_as_string = String.from_bytes(self.itoa(current_users_pokemon_info.uid.native))
        
        itxn.AssetConfig(
            sender=Global.current_application_address,
            manager=Global.current_application_address,
            config_asset=current_users_pokemon_info.asset_id.native,
            note = (
                '{"standard": "arc69", "mime_type": "image/png", "properties": {'
                '"Type": "' + general_pokemon_info.pokemon_type.native + '", '
                '"Description": "' + general_pokemon_info.pokemon_description.native + '", '
                '"Level": "' + new_level_string + '", '
                '"Experience": "' + new_experience_string + '", '
                '"Pokemon Number": "' + unit_name_as_string + '", '
                '"UID": "' + uid_as_string + '" }}' 
            ),
            fee=Global.min_txn_fee
        ).submit()
        
        itxn.AssetTransfer(
            asset_receiver=Txn.sender,
            asset_amount=1,
            xfer_asset=current_users_pokemon_info.asset_id.native,
            fee=Global.min_txn_fee
        ).submit()
        
        
        new_users_pokemon_info = current_users_pokemon_info.copy()
        new_users_pokemon_info.level = arc4UInt64(new_level)
        new_users_pokemon_info.exp = arc4UInt64(new_experience)
        new_users_pokemon_info.training = Bool(False)

        users_pokemon_info_box.put(new_users_pokemon_info.bytes)
        
        return String('Pokemon Removed from Stake')

Seeing the Current Status of the User's Pokemon

Note: The UID in the metadata and the user's address is the box name

from algosdk.v2client.algod import AlgodClient
import os
from base64 import b64decode
from algosdk.abi import ABIType
from dotenv import load_dotenv

load_dotenv()

algod_token = os.getenv('algod_token')
algod_server = os.getenv('algod_server')
algod_client = AlgodClient(algod_token, algod_server)

app_id = int(os.getenv('app_id'))
app_boxes = algod_client.application_boxes(app_id)['boxes']

pokemon_info_coder = ABIType.from_string('(uint64,address,uint64,uint64,uint64,uint64,bool,uint64)')

for box in app_boxes:
    box_name = b64decode(box['name'])
    if len(box_name) == 40: #This is user specific pokemon information (box name is uid for pokemon [8 bytes] + user address [32 bytes])
        box_value = algod_client.application_box_by_name(app_id, )['value']
        decoded_box_value = pokemon_info_coder.decode(b64decode(box_value))
        print(decoded_box_value)

Executing in Python

The Pokemon Level-up System

Note: Experience and level gain are calculated based on time staked, with each second providing an additional experience point.

initial_training_time = current_users_pokemon_info.training_start_time.native
current_time = Global.latest_timestamp
time_difference = current_time - initial_training_time

current_level = current_users_pokemon_info.level.native
current_experience = current_users_pokemon_info.exp.native

base_experience = UInt64(200)
cumulative_experience = current_level * base_experience

total_experience = current_experience + time_difference

if total_experience >= cumulative_experience:
    levels_gained = (total_experience + base_experience - cumulative_experience) // base_experience
    
    new_level = current_level + levels_gained
    new_experience = total_experience - ((new_level - 1) * base_experience)
    
else:
    new_level = current_level
    new_experience = total_experience

Staking the Pokemon

from algosdk.v2client.algod import AlgodClient
from algokit_utils import ApplicationClient
from algosdk.atomic_transaction_composer import AtomicTransactionComposer, AccountTransactionSigner, TransactionWithSigner
from algosdk.transaction import PaymentTxn, AssetTransferTxn
from algosdk.account import address_from_private_key
from pathlib import Path
from algosdk.abi import ABIType
from algosdk.encoding import decode_address
from dotenv import load_dotenv
from base64 import b64decode
import os

load_dotenv()

node_token = os.getenv('algod_token')
node_server = os.getenv('algod_server')

private_key = os.getenv('private_key')

algod_client = AlgodClient(node_token, node_server)
app_spec = Path(__file__).parent / './arc69NFTmodifier.arc32.json'
app_id = int(os.getenv('app_id'))
signer = AccountTransactionSigner(private_key=private_key)
address = address_from_private_key(private_key=private_key)
params = algod_client.suggested_params()

application_client = ApplicationClient(
    algod_client=algod_client,
    app_spec=app_spec,
    app_id=app_id,
    signer=signer,
    sender=address,
    suggested_params=params
)

atc = AtomicTransactionComposer()

decoded_address = decode_address(address)

users_pokemon = 728462064

stake_pokemon_tx = AssetTransferTxn(
    sender=address,
    receiver=application_client.app_address,
    sp=params,
    amt=1,
    index=users_pokemon
)

wrapped_stake_tx = TransactionWithSigner(stake_pokemon_tx, signer)

current_uid = (0).to_bytes(8, 'big')
box_ref_1 = current_uid + decoded_address

application_client.compose_call(
    atc, 
    call_abi_method='trainPokemon', 
    stake_pokemon_tx=wrapped_stake_tx,
    uid=0,
    transaction_parameters={
        'boxes':[[app_id, box_ref_1]],
        'foreign_assets': [users_pokemon]
    }
)

results = atc.execute(algod_client, 2)

tx_ids = [results.tx_ids[i] for i in range(len(results.tx_ids))]
abi_results = [results.abi_results[i].return_value for i in range(len(results.abi_results))]
print(tx_ids)
print(abi_results)

Unstaking the Pokemon

from algosdk.v2client.algod import AlgodClient
from algokit_utils import ApplicationClient
from algosdk.atomic_transaction_composer import AtomicTransactionComposer, AccountTransactionSigner, TransactionWithSigner
from algosdk.transaction import PaymentTxn, AssetTransferTxn
from algosdk.account import address_from_private_key
from pathlib import Path
from algosdk.abi import ABIType
from algosdk.encoding import decode_address
from dotenv import load_dotenv
from base64 import b64decode
import os

load_dotenv()

node_token = os.getenv('algod_token')
node_server = os.getenv('algod_server')

private_key = os.getenv('private_key')

algod_client = AlgodClient(node_token, node_server)
app_spec = Path(__file__).parent / './arc69NFTmodifier.arc32.json'
app_id = int(os.getenv('app_id'))
signer = AccountTransactionSigner(private_key=private_key)
address = address_from_private_key(private_key=private_key)
params = algod_client.suggested_params()

application_client = ApplicationClient(
    algod_client=algod_client,
    app_spec=app_spec,
    app_id=app_id,
    signer=signer,
    sender=address,
    suggested_params=params
)

atc = AtomicTransactionComposer()

decoded_address = decode_address(address)

mbr_fee_payment_tx_1 = PaymentTxn(
    sender=address,
    sp=params,
    receiver=application_client.app_address,
    amt=3000,
    note='#1'
)
wrapped_payment_tx_1 = TransactionWithSigner(mbr_fee_payment_tx_1, signer)
atc.add_transaction(wrapped_payment_tx_1)

users_pokemon = 728462064

stake_pokemon_tx = AssetTransferTxn(
    sender=address,
    receiver=application_client.app_address,
    sp=params,
    amt=1,
    index=users_pokemon
)

wrapped_stake_tx = TransactionWithSigner(stake_pokemon_tx, signer)

current_uid = (0).to_bytes(8, 'big')
box_ref_1 = current_uid + decoded_address
box_ref_2 = (3).to_bytes(8, 'big')

application_client.compose_call(
    atc, 
    call_abi_method='removePokemonFromTraining', 
    pokemon_requested_uid=0,
    transaction_parameters={
        'boxes':[[app_id, box_ref_1], [app_id, box_ref_2]],
        'foreign_assets': [users_pokemon]
    }
)

results = atc.execute(algod_client, 2)

tx_ids = [results.tx_ids[i] for i in range(len(results.tx_ids))]
abi_results = [results.abi_results[i].return_value for i in range(len(results.abi_results))]
print(tx_ids)
print(abi_results)

Code Editor