StarkNet ERC721 Workshop: Exercise 4

Note: This article was updated on Sep 20th, 2022 to use Cairo 0.10

Burning NFTs

  • Create a function to allow breeders to declare dead animals (burn the NFT)
  • Deploy your new contract
  • Call submit_exercise() in the Evaluator to configure the contract you want evaluated
  • Call ex4_declare_dead_animal() to get points (2 pts)

Looking for exercise 3? Click here.

Solution

As usual we start by copying the smart contract we created on exercise 3 to use as our base for exercise 4.

$ cp src/ERC721_ex3.cairo src/ERC721_ex4.cairo

To understand what new functionality we need to develop we need to explore how the function ex4_declare_dead_animal interacts with our ERC721 smart contract.

Evaluator.cairo

@external
func ex4_declare_dead_animal{...}() {
    ...
    let (evaluator_init_balance) = IERC721.balanceOf(
        contract_address=submited_exercise_address, owner=evaluator_address
    );
    ...
    let (token_id) = IExerciseSolution.token_of_owner_by_index(
        contract_address=submited_exercise_address, account=evaluator_address, index=0
    );
    ...
    IExerciseSolution.declare_dead_animal(
        contract_address=submited_exercise_address, token_id=token_id
    );
    ...
    let (read_sex, read_legs, read_wings) = IExerciseSolution.get_animal_characteristics(
        contract_address=submited_exercise_address, token_id=token_id
    );
    ...
}

The Evaluator expects our smart contract to implement four functions: balanceOf, token_of_owner_by_index, declare_dead_animal and get_animal_characteristics. Because we started off using an ERC721 implementation by OpenZeppelin back in exercise 1, we know that our smart contract already implements the function balanceOf as it is part of the standard.

src/ERC721_ex4.cairo

@view
func balanceOf{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(owner : felt) -> (
    balance : Uint256
) {
    let (balance : Uint256) = ERC721.balance_of(owner);
    return (balance);
}

We also know that we have the function get_animal_characteristics as we developed it on exercise 3.

@external
func declare_animal{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
    sex : felt, legs : felt, wings : felt
) -> (token_id : Uint256) {
    alloc_locals;
    assert_only_breeder();

    // Increment token id by 1
    let current_token_id : Uint256 = last_token_id.read();
    let one_as_uint256 = Uint256(1, 0);
    let (local new_token_id, _) = uint256_add(current_token_id, one_as_uint256);

    let (sender_address) = get_caller_address();

    // Mint NFT and store characteristics on-chain
    ERC721._mint(sender_address, new_token_id);
    animals.write(new_token_id, Animal(sex=sex, legs=legs, wings=wings));

    // Update and return new token id
    last_token_id.write(new_token_id);
    return (token_id=new_token_id);
}

What we don’t have yet are the functions token_of_owner_by_index and declare_dead_animal. Let’s focus on the latter first.

If we inspect our smart contract we will find a function created by OpenZeppelin called burn.

@external
func burn{...}(tokenId : Uint256) {
    ERC721.assert_only_token_owner(tokenId);
    ERC721._burn(tokenId);
    return ();
}

This function does pretty much what we wanted for declare_dead_animal and, because the function burn it is not part of the ERC721 standard, we can simply rename it to suit our purpose.

@external
func declare_dead_animal{...}(token_id : Uint256) {
    ERC721.assert_only_token_owner(token_id);
    ERC721._burn(token_id);
    return ();
}

A small adjustment was required as the original function was expecting an argument called tokenId while the Evaluator is calling it with the argument token_id.

The function token_of_owner_by_index is also a pretty common extension for ERC721 smart contracts so it is no surprise that OpenZeppelin has it as part of their Enumerable library located at:

from openzeppelin.token.erc721.enumerable.library import ERC721Enumerable

If we inspect the library we can see the details of how this function is implemented by OZ.

func token_of_owner_by_index{...}(owner: felt, index: Uint256) -> (token_id: Uint256) {
    alloc_locals;
    uint256_check(index);
    // Ensures index argument is less than owner's balance
    let (len: Uint256) = ERC721.balance_of(owner);
    let (is_lt) = uint256_lt(index, len);
    with_attr error_message("ERC721Enumerable: owner index out of bounds") {
        assert is_lt = TRUE;
    }

    let (token_id: Uint256) = ERC721Enumerable_owned_tokens.read(owner, index);
    return (token_id);
}

It is important to note here that the library is taking care of ensuring that the index provided is not only a valid uint256 number but also smaller than the balance of the provided address (owner). That’s two less things to worry about ourselves.

The other important thing to notice is that there’s a small mismatch between OZ’s function signature and the signature expected by the Evaluator which is:

func token_of_owner_by_index(account : felt, index : felt) -> (token_id : Uint256)

The Evaluator is passing the address as the argument account instead of owner, and it’s providing the index as a felt instead of a Uint256 as OZ expects.

Let’s make this interface available to the outside world taking care of this mapping and delegating the call internally to OZ’s implementation.

src/ERC721_ex4.cairo

from openzeppelin.token.erc721.enumerable.library import ERC721Enumerable
from starkware.cairo.common.math import split_felt

@view
func token_of_owner_by_index{...}(
    account : felt, index : felt
) -> (token_id : Uint256) {
    let (index_uint256) = felt_to_uint256(index);
    let (token_id) = ERC721Enumerable.token_of_owner_by_index(owner=account, index=index_uint256);
    return (token_id);
}

func felt_to_uint256{...}(
    felt_value : felt
) -> (uint256_value : Uint256) {
    let (high, low) = split_felt(felt_value);
    let uint256_value : Uint256 = Uint256(low, high);
    return (uint256_value);
}

We have created a helper function called felt_to_uint256 to help us transform a felt value into a Uint256 as this seems to be a common occurrence.

Although OZ’s implementation of token_of_owner_by_index performs some checks on the passed arguments, I want to add additional checks to cover some edge cases like the provided address being the zero address or the index being a negative number.

from openzeppelin.token.erc721.enumerable.library import ERC721Enumerable
from starkware.cairo.common.math import assert_not_zero, assert_nn, split_felt

@view
func token_of_owner_by_index{...}(
    account : felt, index : felt
) -> (token_id : Uint256) {
    alloc_locals;
    with_attr error_message("ERC721: the zero address is not supported as a token holder") {
        assert_not_zero(account);
    }
    with_attr error_message("ERC721: index must be a positive integer") {
        assert_nn(index);
    }
    let (index_uint256) = felt_to_uint256(index);
    let (token_id) = ERC721Enumerable.token_of_owner_by_index(owner=account, index=index_uint256);
    return (token_id);
}

The function token_of_owner_by_index of the ERC721Enumerable extension would only work if we use the mint function that comes with the extension instead of the one coming from the vanilla ERC721 implementation from OZ that we used before. This is due that during minting we now need to keep track of the list of NFTs owned by each account.

ERC721Enumerable implementation of mint

func _mint{...}(to: felt, token_id: Uint256) {
    _add_token_to_all_tokens_enumeration(token_id);
    _add_token_to_owner_enumeration(to, token_id);
    ERC721._mint(to, token_id);
    return ()
}

The same is true for many other base level functions like burn, transferFrom, etc. We need to delegate those calls not to the ERC721 library but to the ERC721Enumerable library instead. Below are the changes that we need to make to our smart contract.

src/ERC721_ex4.cairo

@external
func transferFrom{...}(
    from_ : felt, to : felt, tokenId : Uint256
) {
    ERC721Enumerable.transfer_from(from_, to, tokenId);
    return ();
}

@external
func safeTransferFrom{...}(
    from_ : felt, to : felt, tokenId : Uint256, data_len : felt, data : felt*
) {
    ERC721Enumerable.safe_transfer_from(from_, to, tokenId, data_len, data);
    return ();
}

@external
func declare_dead_animal{pedersen_ptr : HashBuiltin*, syscall_ptr : felt*, range_check_ptr}(
    token_id : Uint256
) {
    ERC721.assert_only_token_owner(token_id);
    ERC721Enumerable._burn(token_id);
    return ();
}

@external
func declare_animal{...}(
    sex : felt, legs : felt, wings : felt
) -> (token_id : Uint256) {
    ...
    ERC721Enumerable._mint(sender_address, new_token_id);
    ...
}

With these changes we should be ready to compile and deploy our ERC721 smart contract.

$ starknet-compile src/ERC721_ex4.cairo \
  --output comp/ERC721_ex4.json --abi abis/ERC721_ex4.json

$ starknet deploy --contract comp/ERC721_ex4.json \
  --network alpha-goerli --no_wallet \
  --inputs 71942470984044 4279881 680769605472490446995541710352012140980533076999125541840625342975082521171

>>>
...
Contract address: 0x0101a5d373a20abb4fb19f62bf3aa1fb6965b0dd8ad325e2a9e6cccab85fa788
...

Before we start interacting with our deployed smart contract we need to set up some environment variables for the CLI.

$ export STARKNET_WALLET="starkware.starknet.wallets.open_zeppelin.OpenZeppelinAccount"
$ export CONTRACT_ADDRESS="0x0101a5d373a20abb4fb19f62bf3aa1fb6965b0dd8ad325e2a9e6cccab85fa788"
$ export STARKNET_NETWORK="alpha-goerli"

Looking back at the implementation of the function ex4_declare_dead_animal at the beginning of this article we can see that the function expects an NFT to be assigned to the Evaluator. To create an NFT we need to add our wallet to the list of authorized breeders invoking the function add_breeder.

$ starknet invoke \
    --address ${CONTRACT_ADDRESS} \
    --abi abis/ERC721_ex4.json \
    --function add_breeder \
    --inputs 680769605472490446995541710352012140980533076999125541840625342975082521171

The function ex4_declare_dead_animal expects the NFT to have the value 0 on the three properties sex, wings and legs. To create an NFT with those characteristics we need to invoke the function declare_animal

$ starknet invoke \
    --address ${CONTRACT_ADDRESS} \
    --abi abis/ERC721_ex4.json \
    --function declare_animal \
    --inputs 0 0 0

Finally, for the Evaluator to be able to call the function declare_dead_animal on the NFT we just created, we need to transfer ownership of the NFT to the smart contract using the function transferFrom.

$ starknet invoke \
    --address ${CONTRACT_ADDRESS} \
    --abi abis/ERC721_ex4.json \
    --function transferFrom \
    --inputs 680769605472490446995541710352012140980533076999125541840625342975082521171 3086258404888638876219097282085579162243564028072194906443891907322397116021 1 0

We are now ready to jump to Voyager and interact with the Evaluator smart contract (0x06d2…) to submit our ERC721 smart contract (0x0101…) for evaluation.

Submitting exercise 4’s ERC721 smart contract to the Evaluator
Submitting exercise 4’s ERC721 smart contract to the Evaluator

We can now ask the Evaluator to test if we have completed all the requirements of the exercise by invoking the function ex4_declare_dead_animal.

Verifying exercise 4
Verifying exercise 4

The execution didn’t throw any error so we should have been assigned 2 more points (12 in total) in the Pointers Counter smart contract (​​0x00a0…). We can verify this by calling the function balanceOf providing the address of our default account.

Checking the score after completing exercise 4
Checking the score after completing exercise 4

And there you have it, we have been granted 2 more points in our account, the exercise was completed successfully.

To see the final implementation of the ERC721 token for exercise 4 go to my GitHub repo.

To continue the tutorial go to Exercise 5: Adding Permissions and Payments.

So, what do you think?

This site uses Akismet to reduce spam. Learn how your comment data is processed.