How Do ERC-721 Tokens Work?

What is an ERC-721 Token?

An ERC-721 token is a smart contract on Ethereum that implements the methods and events specified in the EIP-721: Non-Fungible Token Standard. It is designed to be used as a non-fungible token (NFT), meaning the token’s instances (or units) are distinct from one another. 

The ERC-721 standard is based on the fungible token standard (ERC-20), so it would make sense to explore the functionality by comparing it with ERC-20. 

If you’re not familiar with the ERC-20 standard, you might find it helpful to check my previous article below.

Like ERC-20 tokens, ERC-721 tokens provide the following functionality: 

  • Transfer tokens from one account to another
  • Get the current token counts of an account
  • Approve whether a third-party account can transfer tokens 

In addition, ERC-721 tokens provide the following functionality, which is unique to NFT:

  • Get the owner of the specific token

We’ll look at them one by one in this article.

Overview of a Basic ERC-721 Token

This article will look at the ERC-721 token implementation by OpenZeppelin. We can find the Solidity code in their Github repository. In this article, we use the latest version (v4.4.1) at the time of writing. 

_owners state variable

The core of an ERC-721 token is the _owners private variable, which is a map of the token ID and its owner address. 

// Mapping from token ID to owner address
mapping(uint256 => address) private _owners;

(Line 29 – 30)

The ERC-721’s non-fungibility is achieved by assigning a unique identifier to each token and keeping the ID and owner information in this variable. 

For example, when Alice has the NFT (ID = 1) and Bob has the NFT (ID = 2), the _owners variable would contain the following entries.

_owners

Token IDAddress
1Alice
2Bob

So, simply put, when we say we β€œown” a certain NFT, it just means that its smart contract keeps the token ID and our address in this variable. The token ID and owner address are added to this variable whenever a new NFT is created (or minted). 

The ownerOf() function looks up this variable and returns a corresponding entry if it finds one.

/**
 * @dev See {IERC721-ownerOf}.
 */
function ownerOf(uint256 tokenId) public view virtual override returns (address) {
    address owner = _owners[tokenId];
    require(owner != address(0), "ERC721: owner query for nonexistent token");
    return owner;
}

(Line 67 – 74)

_balances variable

Similar to ERC-20, ERC-721 has the _balances private variable that keeps track of the holdings of each account.

// Mapping owner address to token count
mapping(address => uint256) private _balances;

(Line 32 – 33)

For example, when Alice and Bob have an NFT each, the variable would contain the entries like below.

_balances

AddressToken count
Alice1
Bob1

The balanceOf() function looks up this variable and returns a corresponding entry if it finds one.

/**
 * @dev See {IERC721-balanceOf}.
 */
function balanceOf(address owner) public view virtual override returns (uint256) {
    require(owner != address(0), "ERC721: balance query for the zero address");
    return _balances[owner];
}

(Line 59 – 65)

Create a new token (minting)

The _mint() internal function creates (or mints) a new token. It updates the owner’s count in the _balances variable and adds the owner of the token ID in the _owners variable.

/**
 * @dev Mints `tokenId` and transfers it to `to`.
 *
 * WARNING: Usage of this method is discouraged, use {_safeMint} whenever possible
 *
 * Requirements:
 *
 * - `tokenId` must not exist.
 * - `to` cannot be the zero address.
 *
 * Emits a {Transfer} event.
 */
function _mint(address to, uint256 tokenId) internal virtual {
    require(to != address(0), "ERC721: mint to the zero address");
    require(!_exists(tokenId), "ERC721: token already minted");

    _beforeTokenTransfer(address(0), to, tokenId);

    _balances[to] += 1;
    _owners[tokenId] = to;

    emit Transfer(address(0), to, tokenId);

    _afterTokenTransfer(address(0), to, tokenId);
}

(Line 268 – 292)

For example, if Alice mints a new NFT, the _owners and _balances variables are updated, as shown below.

 _owners

Token IDAddress
1Alice
2Bob
3Alice

_balances

AddressToken count
Alice2
Bob1

There are two internal functions called _safeMint() (with and without _data parameter), which additionally verify that the new owner (the to address) can receive ERC-721 tokens.

/**
 * @dev Safely mints `tokenId` and transfers it to `to`.
 *
 * Requirements:
 *
 * - `tokenId` must not exist.
 * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon a safe transfer.
 *
 * Emits a {Transfer} event.
 */
function _safeMint(address to, uint256 tokenId) internal virtual {
    _safeMint(to, tokenId, "");
}

/**
 * @dev Same as {xref-ERC721-_safeMint-address-uint256-}[`_safeMint`], with an additional `data` parameter which is
 * forwarded in {IERC721Receiver-onERC721Received} to contract recipients.
 */
function _safeMint(
    address to,
    uint256 tokenId,
    bytes memory _data
) internal virtual {
    _mint(to, tokenId);
    require(
        _checkOnERC721Received(address(0), to, tokenId, _data),
        "ERC721: transfer to non ERC721Receiver implementer"
    );
}

Transfer workflow

Like ERC-20, ERC-721 has two workflows to transfer tokens from one address to another. The first workflow is used when the owner transfers tokens, and it is implemented in the _transfer() internal function, as shown below.

/**
 * @dev Transfers `tokenId` from `from` to `to`.
 *  As opposed to {transferFrom}, this imposes no restrictions on msg.sender.
 *
 * Requirements:
 *
 * - `to` cannot be the zero address.
 * - `tokenId` token must be owned by `from`.
 *
 * Emits a {Transfer} event.
 */
function _transfer(
    address from,
    address to,
    uint256 tokenId
) internal virtual {
   require(ERC721.ownerOf(tokenId) == from, "ERC721: transfer from incorrect owner");
    require(to != address(0), "ERC721: transfer to the zero address");

    _beforeTokenTransfer(from, to, tokenId);

    // Clear approvals from the previous owner
    _approve(address(0), tokenId);

    _balances[from] -= 1;
    _balances[to] += 1;
    _owners[tokenId] = to;

    emit Transfer(from, to, tokenId);

    _afterTokenTransfer(from, to, tokenId);
}

(Line 320 – 351)

The core part of the function is to update the _owners and _balances variables.

For example, when Alice has the NFTs (ID = 1 and 3) and Bob has the NFT (ID = 2), the _owners and _balances variables contain the following entries.

_owners

Token IDAddress
1Alice
2Bob
3Alice

_balances

AddressToken count
Alice2
Bob1

Then, when Alice transfers the NFT (ID 3) to Charlie, the address value for Token ID 3 is updated from Alice to Charlie in the _owners variable. Their counts are also updated in the _balances variable accordingly.

_owners

Token IDAddress
1Alice
2Bob
3Charlie

_balances

AddressToken count
Alice1
Bob1
Charlie1

Although this function performs the actual transfer operations, it is not exposed to the users because it is internal. The transferFrom() function (and its variants) is used for the transfer workflow instead, which we will explore in the next section. 

TransferFrom workflow

The second workflow to transfer tokens is used when a third party transfers tokens using the transferFrom() function on behalf of the owner.

/**
 * @dev See {IERC721-transferFrom}.
 */
function transferFrom(
    address from,
    address to,
    uint256 tokenId
) public virtual override {
    //solhint-disable-next-line max-line-length
    require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC721: transfer caller is not owner nor approved");

    _transfer(from, to, tokenId);
}

(Line 147 – 159)

The difference is that the transferFrom() function also calls the _isApprovedOrOwner() internal function, which is implemented as shown below.

/**
 * @dev Returns whether `spender` is allowed to manage `tokenId`.
 *
 * Requirements:
 *
 * - `tokenId` must exist.
 */
function _isApprovedOrOwner(address spender, uint256 tokenId) internal view virtual returns (bool) {
    require(_exists(tokenId), "ERC721: operator query for nonexistent token");
    address owner = ERC721.ownerOf(tokenId);
    return (spender == owner || getApproved(tokenId) == spender || isApprovedForAll(owner, spender));
}

(Line 225 – 236)

In other words, the transferFrom() function is checking if any of the following conditions is true or not:

  • The msg.sender (who is calling this function) is the owner.
  • The msg.sender has been approved to transfer the token on behalf of the owner.
  • The owner has approved the msg.sender to transfer all their tokens.

The first condition is true when the owner calls the transferFrom() function. It means that we can use the transferFrom() function in the transfer workflow we looked at in the previous section.

The getApproved() function is used in the second condition, which simply looks up the _tokenApprovals private variable.

/**
 * @dev See {IERC721-getApproved}.
 */
function getApproved(uint256 tokenId) public view virtual override returns (address) {
    require(_exists(tokenId), "ERC721: approved query for nonexistent token");

    return _tokenApprovals[tokenId];
}

(Line 124 – 131)

The _tokenApprovals variable is a map of a token ID and an address.

// Mapping from token ID to approved address
mapping(uint256 => address) private _tokenApprovals;

(Line 35 – 36)

It is updated by the approve() function via the _approve() internal function.

/**
 * @dev See {IERC721-approve}.
 */
function approve(address to, uint256 tokenId) public virtual override {
    address owner = ERC721.ownerOf(tokenId);
    require(to != owner, "ERC721: approval to current owner");

    require(
        _msgSender() == owner || isApprovedForAll(owner, _msgSender()),
        "ERC721: approve caller is not owner nor approved for all"
    );

    _approve(to, tokenId);
}

(Line 109 – 122)

/**
 * @dev Approve `to` to operate on `tokenId`
 *
 * Emits a {Approval} event.
 */
function _approve(address to, uint256 tokenId) internal virtual {
    _tokenApprovals[tokenId] = to;
    emit Approval(ERC721.ownerOf(tokenId), to, tokenId);
}

(Line 353 – 361)

So, the owner needs to call the approve() function first for the second condition to be true. It will add the spender address and the token ID to the the _tokenApprovals variable, so the _isApprovedOrOwner() internal function will return true. 

In the third condition, the isApprovedForAll() function checks if the owner has given msg.sender a blanket approval to send their tokens. 

/**
 * @dev See {IERC721-isApprovedForAll}.
 */
function isApprovedForAll(address owner, address operator) public view virtual override returns (bool) {
    return _operatorApprovals[owner][operator];
}

(Line 140 – 145)

It looks up the _operatorApprovals private variable, a map of maps.

// Mapping from owner to operator approvals
mapping(address => mapping(address => bool)) private _operatorApprovals;

It is updated by the setApprovalForAll() function via the _setApprovalForAll() internal function. 

/**
 * @dev See {IERC721-setApprovalForAll}.
 */
function setApprovalForAll(address operator, bool approved) public virtual override {
    _setApprovalForAll(_msgSender(), operator, approved);
}

(Line 133 – 138)

/**
 * @dev Approve `operator` to operate on all of `owner` tokens
 *
 * Emits a {ApprovalForAll} event.
 */
function _setApprovalForAll(
    address owner,
    address operator,
    bool approved
) internal virtual {
    require(owner != operator, "ERC721: approve to caller");
    _operatorApprovals[owner][operator] = approved;
    emit ApprovalForAll(owner, operator, approved);
}

(Line 363 – 376)

So, for the third condition to be true, the owner would need to call the setApprovalForAll() function to give the msg.sender approval in advance.

If any of these three conditions is true, the transferFrom() function calls the _transfer() internal function, and it performs the transfer, as explained in the previous section.

Safe Transfer

In addition to the transferFrom() function, the safeTransferFrom() functions (there are two variations, with and without the _data parameter) are available. They perform extra checks to see if the recipient can receive ERC-721 tokens or not, via the _safeTransfer() internal function. 

/**
 * @dev See {IERC721-safeTransferFrom}.
 */
function safeTransferFrom(
    address from,
    address to,
    uint256 tokenId
) public virtual override {
    safeTransferFrom(from, to, tokenId, "");
}

/**
 * @dev See {IERC721-safeTransferFrom}.
 */
function safeTransferFrom(
    address from,
    address to,
    uint256 tokenId,
    bytes memory _data
) public virtual override {
    require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC721: transfer caller is not owner nor approved");
    _safeTransfer(from, to, tokenId, _data);
}

(Line 161 – 183)

ERC-721 Metadata Extension

So far, we’ve looked at the basic functionality of ERC-721, but the token’s non-fungibility is only manifested in the token IDs. Although we can see that each token is unique by looking at the ID, its use case is very limited in the real world if unique data is only the IDs.

The ERC-721 specification defines the optional metadata extension (ERC721Metadata), where we can implement the tokenURI() function to give each token a unique URI (Uniform Resource Identifier). A typical form of URI is URL (such as an HTTP address).

Another popular form of URI for NFT is the content identifier (CID) in the IPFS network because we cannot modify the data without changing the CID. We will not go into details about IPFS, but you can read more in the IPFS documentation.Β 

The OpenZeppelin library includes the ERC721URIStorage extension, which provides a basic implementation of the tokenURI() function. In real-world use cases, each project will need to override this function depending on the requirements for the NFT.

ERC-721 Token with Brownie

This section will create an ERC-721 token using an example in the OpenZeppelin documentation and try some functions.

 Set up the Brownie environment

If you haven’t installed Brownie, you can install it by following the tutorial below:

After installing Brownie, go to a new directory and run the brownie init command.

[~/erc721_test]$ brownie init
Brownie v1.17.1 - Python development framework for Ethereum

SUCCESS: A new Brownie project has been initialized at /Users/mikio/erc721_test

Then install the OpenZeppelin library.Β 

[~/erc721_test]$ brownie pm install OpenZeppelin/openzeppelin-contracts@4.4.1                  
Brownie v1.17.1 - Python development framework for Ethereum

1.71MiB [00:00, 11.2MiB/s]
New compatible solc version available: 0.8.10
Compiling contracts...
  Solc version: 0.8.10
  Optimizer: Enabled  Runs: 200
  EVM Version: Istanbul
Generating build data...
 - AccessControl
 - AccessControlEnumerable
...
...
...

SUCCESS: Package 'OpenZeppelin/openzeppelin-contracts@4.4.1' has been installed

Create the configuration file brownie-config.yaml in the current directory and add the following content.

brownie-config.yaml

dependencies:
  - OpenZeppelin/openzeppelin-contracts@4.4.1
compiler:
    solc:
        remappings:
          - "@openzeppelin=OpenZeppelin/openzeppelin-contracts@4.4.1"

Set up accounts

In this example, we will use the accounts alice, bob, charlie, which we created in my previous article. If you don’t have these accounts, you can create them in Brownie, as shown below. When prompted, type a password. Remember it, as we need it in the later steps.

[~/erc721_test]$ brownie accounts generate alice
Brownie v1.17.1 - Python development framework for Ethereum

Generating a new private key...
mnemonic: 'xxxx xxxx ...'
Enter the password to encrypt this account with: 
SUCCESS: A new account '0x32d506BccD1367e779c53A2887070C611cC40Aa8' has been generated with the id 'alice'
[~/erc721_test]$ brownie accounts generate bob  
Brownie v1.17.1 - Python development framework for Ethereum

Generating a new private key...
mnemonic: 'yyyy yyyy ....'
Enter the password to encrypt this account with: 
SUCCESS: A new account '0x80b8EFE085A0671931606eB1cF1314cf4dcc9062' has been generated with the id 'bob'
[~/erc721_test]$ brownie accounts generate charlie
Brownie v1.17.1 - Python development framework for Ethereum

Generating a new private key...
mnemonic: 'zzzz zzzz ....'
Enter the password to encrypt this account with: 
SUCCESS: A new account '0xCa31275A8eF4a16cfaC4f7317aBeeDe31F73558D' has been generated with the id 'charlie'

Deploy smart contract

We use the following example for demonstration purposes, which I took from the OpenZeppelin documentation and slightly modified it. Save this file as GameItem.sol in the contracts directory.

contracts/GameItem.sol

// contracts/GameItem.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract GameItem is ERC721URIStorage {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;

    constructor() ERC721("GameItem", "ITM") {}

    function awardItem(address player, string memory tokenURI)
        public
        returns (uint256)
    {
        _tokenIds.increment();

        uint256 newItemId = _tokenIds.current();
        _mint(player, newItemId);
        _setTokenURI(newItemId, tokenURI);

        return newItemId;
    }

}

The smart contract inherits from ERC721URIStorage, which again inherits from ERC721. Both of them are provided in the OpenZeppelin library.Β 

The awardItem() function creates a new NFT. It generates a token ID, creates a new token using the _mint() function with the token ID, and sets the specified URI to the token metadata using the _setTokenURI() function. In this example, anyone can call this function and mint a new NFT.

Let’s deploy this smart contract in the local development environment to interact with it using the Brownie console.Β 

[~/erc721_test]$ brownie console
Brownie v1.17.1 - Python development framework for Ethereum

New compatible solc version available: 0.8.10
Compiling contracts...
  Solc version: 0.8.10
  Optimizer: Enabled  Runs: 200
  EVM Version: Istanbul
Generating build data...
 - OpenZeppelin/openzeppelin-contracts@4.4.1/ERC721
 - OpenZeppelin/openzeppelin-contracts@4.4.1/IERC721
 - OpenZeppelin/openzeppelin-contracts@4.4.1/IERC721Receiver
 - OpenZeppelin/openzeppelin-contracts@4.4.1/ERC721URIStorage
 - OpenZeppelin/openzeppelin-contracts@4.4.1/IERC721Metadata
 - OpenZeppelin/openzeppelin-contracts@4.4.1/Address
 - OpenZeppelin/openzeppelin-contracts@4.4.1/Context
 - OpenZeppelin/openzeppelin-contracts@4.4.1/Counters
 - OpenZeppelin/openzeppelin-contracts@4.4.1/Strings
 - OpenZeppelin/openzeppelin-contracts@4.4.1/ERC165
 - OpenZeppelin/openzeppelin-contracts@4.4.1/IERC165
 - GameItem

Erc721TestProject is the active project.

Launching 'ganache-cli --accounts 10 --hardfork istanbul --gasLimit 12000000 --mnemonic brownie --port 8545'...
Brownie environment is ready.
>>> 

Then, load the accounts.

>>> alice = accounts.load('alice')
Enter password for "alice": 
>>> bob = accounts.load('bob')
Enter password for "bob": 
>>> charlie = accounts.load('charlie')
Enter password for "charlie": 

Now, let’s deploy the smart contract using the alice account.

>>> gameItem = GameItem.deploy({'from': alice})
Transaction sent: 0xc973d95cd3ee93fccbab0536778a450252c82932653e595a32af4b2d532829df
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 0
  GameItem.constructor confirmed   Block: 1   Gas used: 1367483 (11.40%)
  GameItem deployed at: 0xa0433E13f093739643D7d8bb632f5fa87942ae24

Create NFTs

Let’s create a new NFT and award it to Alice. The URI is just a random URL in this example.

>>> tx1 = gameItem.awardItem(alice, 'http://example.com/item1.json')
Transaction sent: 0x44db1ab578dfc8cea532a06a1d0408c028e5a34788337622aa68c06ca3188da6
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 1
  GameItem.awardItem confirmed   Block: 2   Gas used: 111734 (0.93%)

The token ID is 1 in the transaction details.Β 

>>> tx1.info()
Transaction was Mined 
---------------------
Tx Hash: 0x44db1ab578dfc8cea532a06a1d0408c028e5a34788337622aa68c06ca3188da6
From: 0x32d506BccD1367e779c53A2887070C611cC40Aa8
To: 0xa0433E13f093739643D7d8bb632f5fa87942ae24
Value: 0
Function: GameItem.awardItem
Block: 2
Gas Used: 111734 / 12000000 (0.9%)

Events In This Transaction
--------------------------
└── GameItem (0xa0433E13f093739643D7d8bb632f5fa87942ae24)
    └── Transfer
        β”œβ”€β”€ from: 0x0000000000000000000000000000000000000000
        β”œβ”€β”€ to: 0x32d506BccD1367e779c53A2887070C611cC40Aa8
        └── tokenId: 1

Now, we can run the ownerOf() function to verify that Alice is indeed the owner of the token 1. We can also run the balanceOf() function to check that Alice owns one token.

>>> gameItem.ownerOf(1) == alice
True
>>> gameItem.balanceOf(alice)
1

We can also run the tokenURI() function to find the token URI.Β 

>>> gameItem.tokenURI(1)
'http://example.com/item1.json'

We can repeat the same process and award a new item to Bob.

>>> tx2 = gameItem.awardItem(bob, 'http://example.com/item2.json')
Transaction sent: 0xafb89196d99b50f265490f1ae077959488da8de369c6dec303a901e76b7e84f0
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 2
  GameItem.awardItem confirmed   Block: 3   Gas used: 96734 (0.81%)

>>> tx2.info()
Transaction was Mined 
---------------------
Tx Hash: 0xafb89196d99b50f265490f1ae077959488da8de369c6dec303a901e76b7e84f0
From: 0x32d506BccD1367e779c53A2887070C611cC40Aa8
To: 0xa0433E13f093739643D7d8bb632f5fa87942ae24
Value: 0
Function: GameItem.awardItem
Block: 3
Gas Used: 96734 / 12000000 (0.8%)

Events In This Transaction
--------------------------
└── GameItem (0xa0433E13f093739643D7d8bb632f5fa87942ae24)
    └── Transfer
        β”œβ”€β”€ from: 0x0000000000000000000000000000000000000000
        β”œβ”€β”€ to: 0x80b8EFE085A0671931606eB1cF1314cf4dcc9062
        └── tokenId: 2

>>> gameItem.ownerOf(2) == bob
True
>>> gameItem.balanceOf(bob)
1
>>> gameItem.tokenURI(2)
'http://example.com/item2.json'

Transfer an NFT

We can use the transferFrom() function to transfer an NFT.

Firstly, let’s check that Alice can transfer the NFT 1 to Charlie.

>>> tx3 = gameItem.transferFrom(alice, charlie, 1, {'from': alice})
Transaction sent: 0x4b2977d149f81867351457ff0ed7afcf701134e5eab7dbf2e9a4246fde2dff1b
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 3
  GameItem.transferFrom confirmed   Block: 4   Gas used: 49948 (0.42%)

>>> tx3.info()
Transaction was Mined 
---------------------
Tx Hash: 0x4b2977d149f81867351457ff0ed7afcf701134e5eab7dbf2e9a4246fde2dff1b
From: 0x32d506BccD1367e779c53A2887070C611cC40Aa8
To: 0xa0433E13f093739643D7d8bb632f5fa87942ae24
Value: 0
Function: GameItem.transferFrom
Block: 4
Gas Used: 49948 / 12000000 (0.4%)

Events In This Transaction
--------------------------
└── GameItem (0xa0433E13f093739643D7d8bb632f5fa87942ae24)
    β”œβ”€β”€ Approval
    β”‚   β”œβ”€β”€ owner: 0x32d506BccD1367e779c53A2887070C611cC40Aa8
    β”‚   β”œβ”€β”€ approved: 0x0000000000000000000000000000000000000000
    β”‚   └── tokenId: 1
    └── Transfer
        β”œβ”€β”€ from: 0x32d506BccD1367e779c53A2887070C611cC40Aa8
        β”œβ”€β”€ to: 0xCa31275A8eF4a16cfaC4f7317aBeeDe31F73558D
        └── tokenId: 1

We can see two events, Approval and Transfer. The first event comes from the _approve() internal function and the second comes from the _transfer() internal function.

Now, we can confirm that the token 1 owner is Charlie, not Alice.

>>> gameItem.ownerOf(1) == alice
False
>>> gameItem.ownerOf(1) == charlie
True

Next, let’s see if Alice can transfer Bob’s token to Charlie.

>>> gameItem.transferFrom(bob, charlie, 2, {'from': alice})
Transaction sent: 0x26b0f19314c9962970580cf20e0e79059b2e70654676212ea3e2057ebef66f32
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 4
  GameItem.transferFrom confirmed (ERC721: transfer caller is not owner nor approved)   Block: 5   Gas used: 27476 (0.23%)

<Transaction '0x26b0f19314c9962970580cf20e0e79059b2e70654676212ea3e2057ebef66f32'>

This call fails ("transfer caller is not the owner nor approved"). It is correct because Alice is not the owner of the token 2, and Bob hasn’t approved Alice.

Assuming that Bob is happy for Alice to transfer his token, he runs the approve() function.

>>> gameItem.approve(alice, 2, {'from': bob})
Transaction sent: 0xd1dbaf920fa26b0af79055e5ed4f68ba5a8a55dac1cdece7f61c86c627238089
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 0
  GameItem.approve confirmed   Block: 6   Gas used: 46822 (0.39%)

<Transaction '0xd1dbaf920fa26b0af79055e5ed4f68ba5a8a55dac1cdece7f61c86c627238089'>

Alternatively, Bob can run the setApprovalForAll() function if he wants to give Alice his approval to transfer all his tokens.

Either way, Alice can now transfer Bob’s token 2 to Charlie.

>>> gameItem.transferFrom(bob, charlie, 2, {'from': alice})
Transaction sent: 0xcace7e5b7f6317861046e6b2c2e84c2376fac9f36b10e552545f60337f76ab1c
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 5
  GameItem.transferFrom confirmed   Block: 7   Gas used: 28026 (0.23%)

<Transaction '0xcace7e5b7f6317861046e6b2c2e84c2376fac9f36b10e552545f60337f76ab1c'>

Now, we can see that Charlie is the token 2 owner.

>>> gameItem.ownerOf(2) == charlie
True

Summary

In this article, we looked at the basic functionality of the ERC-721 token. 

An ERC-721 token is a smart contract on Ethereum that implements the methods and events specified in the EIP-721: Non-Fungible Token Standard. It is designed to be used as a non-fungible token (NFT), meaning the token’s instances (or units) are distinct from one another.Β 

The non-fungibility is achieved by assigning a unique identifier to each token, and then, the ID and owner are kept in a map variable.

The ERC-721 standard defines the functionality of the ERC-721 token, such as creating and transferring tokens. It also specifies the optional functionality, such as the metadata extension, where we can implement a function to give each token a unique URI (Uniform Resource Identifier). This part will vary significantly depending on the requirements for the NFT project.

I hope this overview has been helpful to understand how ERC-721 tokens work. 

You can find more in the following resources.

Learn Solidity Course

Solidity is the programming language of the future.

It gives you the rare and sought-after superpower to program against the “Internet Computer”, i.e., against decentralized Blockchains such as Ethereum, Binance Smart Chain, Ethereum Classic, Tron, and Avalanche – to mention just a few Blockchain infrastructures that support Solidity.

In particular, Solidity allows you to create smart contracts, i.e., pieces of code that automatically execute on specific conditions in a completely decentralized environment. For example, smart contracts empower you to create your own decentralized autonomous organizations (DAOs) that run on Blockchains without being subject to centralized control.

NFTs, DeFi, DAOs, and Blockchain-based games are all based on smart contracts.

This course is a simple, low-friction introduction to creating your first smart contract using the Remix IDE on the Ethereum testnet – without fluff, significant upfront costs to purchase ETH, or unnecessary complexity.