Part 7: Interacting with ERC-721 NFTs using `web3.py`
Divine Igbinoba

Divine Igbinoba @divine_igbinoba_fb6de7207

About: Backend Developer - Go, Python, Web3 (Solidity)

Joined:
Jun 24, 2024

Part 7: Interacting with ERC-721 NFTs using `web3.py`

Publish Date: Aug 6
1 0

Previously, we established the critical role of blockchain standards and then learned how to interact with fungible (ERC-20) tokens.

Today, we're shifting our focus to the other side of the coin: non-fungible tokens (NFTs) and their primary standard, ERC-721.


What is an ERC-721 NFT?

An ERC-721 token is a standard for unique, indivisible digital assets on the Ethereum blockchain.

Unlike an ERC-20 token, where every unit is identical (1 Pi is the same as another 1 Pi), each ERC-721 token is unique and has its own distinct properties - They are not interchangeable.

Think of it like a ticket; While you might have a ticket to the same show as someone else, your ticket has a unique seat number, making it fundamentally different.

The key to an ERC-721 token's uniqueness is its tokenId, a unique identifier (usually a uint256 integer) that separates it from all other tokens in the same contract.

This is what makes them ideal for representing digital art, collectables, in-game items, and even real-world assets like property deeds.


Key ERC-721 Functions We'll Use:

While the full standard is extensive, we'll focus on these core functions that allow us to interact with NFTs:

Read Functions (View):

  1. name() & symbol(): Returns the name and symbol of the NFT collection (e.g., "CryptoKitties", "CK").

2. balanceOf(address owner): Returns the number of NFTs an address owns in this collection.

3. ownerOf(uint256 tokenId): A fundamental function that returns the address of the current owner of a specific tokenId.

4. tokenURI(uint256 tokenId): Returns a URI (Uniform Resource Identifier) pointing to a JSON file containing the NFT's metadata (e.g., name, description, image, and other attributes).

This is how an NFT's unique data is linked to its on-chain token.

Write Functions (State-Changing):

1. transferFrom(address from, address to, uint256 tokenId): Transfers a specific NFT from one address to another. This is the function you use to sell or gift an NFT.

2. mint(address recipient): Create new tokens (custom function, not in standard)


Hands-On: Deploying and Minting Your Own ERC-721

Let's deploy a simple NFT contract to Ganache. This contract will be a basic ERC-721 implementation with a simple function to mint new tokens.

Here's the Solidity code for our SimpleNFT. It's a simplified version of the standard for clarity:

pragma solidity ^0.8.0;

contract SimpleNFT {
    string public name = "Very Simple NFT";
    string public symbol = "VSNFT";
    uint256 private _nextTokenId;

    // Mapping
    mapping(uint256 => address) private _owners;
    mapping(address => uint256) private _balances;
    mapping(uint256 => string) private _tokenURIs;

    // ERC-721 Events
    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);


    // --- View Functions (read-only) ---
    function balanceOf(address owner) public view returns (uint256) {
        require(owner != address(0), "Address zero is not a valid owner");
        return _balances[owner];
    }
    function ownerOf(uint256 tokenId) public view returns (address) {
        address owner = _owners[tokenId];
        require(owner != address(0), "Token ID does not exist");
        return owner;
    }
    function tokenURI(uint256 tokenId) public view returns (string memory) {
        return _tokenURIs[tokenId];
    }

    // --- State-Changing Functions (Transactions) --
    // mint a new token to an address
    function mint(address recipient) public returns (uint256) {
        uint256 newTokenId = _nextTokenId;
        require(recipient != address(0), "Cannot mint to the zero address");

        // set owner and increment balance
        _owners[newTokenId] = recipient;
        _balances[recipient] += 1;
        _nextTokenId++;

        // set a dummy metadata URI
        _tokenURIs[newTokenId] = string(abi.encodePacked("https://your.website/metadata/", _nextTokenId - 1));

        // emit the Transfer event (from null address to recipient for mint)
        emit Transfer(address(0), recipient, newTokenId);
        return newTokenId;
    }

    // transfer tokens
    function transferFrom(address from, address to, uint256 tokenId) public {
        require(ownerOf(tokenId) == from, "Sender is not the owner of the token");
        require(to != address(0), "Cannot transfer to the zero address");

        // this is a simplified transfer; a full ERC-721 implementation would check for approvals
        _balances[from] -= 1;
        _balances[to] += 1;
        _owners[tokenId] = to;
        emit Transfer(from, to, tokenId);
    }
}
Enter fullscreen mode Exit fullscreen mode

NOTE: This is a simplified ERC-721 implementation for learning purposes afull implementation would use libraries like OpenZeppelin.

Deployment Steps (using Remix IDE to Ganache):

Follow the detailed steps from Part 2: Deploying Your First Smart Contract for deploying a contract to Ganache, but with these specifics:

  • Paste the **SimpleNFT.sol** code into a new file in Remix (e.g., SimpleNFT.sol).

  • Compile it, remember to select paris as EVM Version.

  • Go to the "Deploy & Run Transactions" tab, ensure "DEV-GANACHE PROVIDER" is selected.

  • Click the orange "Deploy" button.

  • After deployment, your contract will appear in the "Deployed Contracts" list. Expand it.

  • Copy the **CONTRACT_ADDRESS** and the updated **CONTRACT_ABI** from the deployed contract section.


Interacting with Your NFT using web3.py

Now, let's update our Python script to interact with your newly minted NFT.

Update your Python script:


import os
import json
from web3 import Web3, HTTPProvider
from web3.middleware import ExtraDataToPOAMiddleware
from eth_account import Account
from dotenv import load_dotenv

load_dotenv()

RPC_URL = os.getenv("RPC_URL")
PRIVATE_KEY = os.getenv("GANACHE_PRIVATE_KEY")

if not RPC_URL or not PRIVATE_KEY:
    raise ValueError("RPC_URL or GANACHE_PRIVATE_KEY not found in .env file.")

try:
    w3 = Web3(HTTPProvider(RPC_URL))
    w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)

    if not w3.is_connected():
        print(f"❌ Failed to connect to Ethereum node at {RPC_URL}")
        exit()
    print(f"✅ Successfully connected to Ethereum node at {RPC_URL}")

    sender_account = Account.from_key(PRIVATE_KEY)
    print(f"\n🔑 Sender Account Address: {sender_account.address}")
    print(f"💰 Sender ETH Balance: {w3.from_wei(w3.eth.get_balance(sender_account.address), 'ether')} ETH")

    # --- ERC-721 NFT Interaction ---

    # IMPORTANT: replace with your NEWLY deployed SimpleNFT contract address and ABI Get these values after deploying SimpleNFT.sol in Remix
    ERC721_CONTRACT_ADDRESS = "0x85Bf1249EAf7b25a6Eae6F61ecaCDcBC4fCB8eCa"
    ERC721_CONTRACT_ABI = json.loads('''
[
    {
        "inputs": [],
        "stateMutability": "nonpayable",
        "type": "constructor"
    },
    {
        "anonymous": false,
        "inputs": [
            {"indexed": true, "internalType": "address", "name": "from", "type": "address"},
            {"indexed": true, "internalType": "address", "name": "to", "type": "address"},
            {"indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256"}
        ],
        "name": "Transfer",
        "type": "event"
    },
    {
        "inputs": [
            {"internalType": "address", "name": "owner", "type": "address"}
        ],
        "name": "balanceOf",
        "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
        "stateMutability": "view",
        "type": "function"
    },
    {
        "inputs": [
            {"internalType": "address", "name": "recipient", "type": "address"}
        ],
        "name": "mint",
        "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
        "stateMutability": "nonpayable",
        "type": "function"
    },
    {
        "inputs": [],
        "name": "name",
        "outputs": [{"internalType": "string", "name": "", "type": "string"}],
        "stateMutability": "view",
        "type": "function"
    },
    {
        "inputs": [
            {"internalType": "uint256", "name": "tokenId", "type": "uint256"}
        ],
        "name": "ownerOf",
        "outputs": [{"internalType": "address", "name": "", "type": "address"}],
        "stateMutability": "view",
        "type": "function"
    },
    {
        "inputs": [],
        "name": "symbol",
        "outputs": [{"internalType": "string", "name": "", "type": "string"}],
        "stateMutability": "view",
        "type": "function"
    },
    {
        "inputs": [
            {"internalType": "uint256", "name": "tokenId", "type": "uint256"}
        ],
        "name": "tokenURI",
        "outputs": [{"internalType": "string", "name": "", "type": "string"}],
        "stateMutability": "view",
        "type": "function"
    },
    {
        "inputs": [
            {"internalType": "address", "name": "from", "type": "address"},
            {"internalType": "address", "name": "to", "type": "address"},
            {"internalType": "uint256", "name": "tokenId", "type": "uint256"}
        ],
        "name": "transferFrom",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function"
    }
]
    ''') # replace this entire string with your copied, UPDATED ABI

    # create the ERC-721 contract instance
    nft_contract = w3.eth.contract(address=ERC721_CONTRACT_ADDRESS, abi=ERC721_CONTRACT_ABI)

    print(f"✅ ERC-721 contract loaded successfully at address: {ERC721_CONTRACT_ADDRESS}")

    # --- 1. Read NFT Information ---
    collection_name = nft_contract.functions.name().call()
    collection_symbol = nft_contract.functions.symbol().call()
    sender_nft_balance = nft_contract.functions.balanceOf(sender_account.address).call()

    print(f"\nℹ️ Collection Name: {collection_name} ({collection_symbol})")
    print(f"💰 {sender_account.address} owns {sender_nft_balance} {collection_symbol} NFT(s).")


    # --- 2. MINT the NFT ---

    print(f"\n➡️ Attempting to mint Token from to {sender_account.address}...")

    # build the 'mint' transaction
    nonce = w3.eth.get_transaction_count(sender_account.address)

    transfer_txn = nft_contract.functions.mint(
        sender_account.address,  # recipient
    ).build_transaction({
        'from': sender_account.address,
        'nonce': nonce,
        'gas': 200000,
        'gasPrice': w3.eth.gas_price
    })

    # sign and send the transaction
    signed_txn = w3.eth.account.sign_transaction(transfer_txn, private_key=PRIVATE_KEY)
    tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)
    print(f"🚀 Transfer transaction sent! Hash: {tx_hash.hex()}")

    # wait for the transaction to be mined
    tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)

    if tx_receipt['status'] == 1:
        print("🎉 NFT minting successful!")

        # verify new balances
        new_balance = nft_contract.functions.balanceOf(sender_account.address).call()
        print(f"💰 new balance ({sender_account.address}): {new_balance} {collection_symbol} NFT(s)")
    else:
        print(f"❌ Mint failed! Transaction receipt: \n {tx_receipt}")

    # Let's interact with our first minted token, which has tokenId 0
    token_id = 0

    try:
        # get the owner of the specific token
        owner_of_token = nft_contract.functions.ownerOf(token_id).call()
        print(f"👑 owner of Token #{token_id} is: {owner_of_token}")

        # get the metadata URI for the token
        token_uri = nft_contract.functions.tokenURI(token_id).call()
        print(f"🔗 metadata URI for Token #{token_id}: {token_uri}")

    except Exception as e:
        print(f"❌ Error getting owner or URI for Token #{token_id}: {e}")

    # --- 3. Transfer the NFT ---
    # get a recipient address from Ganache (e.g., the second account)
    if "127.0.0.1" in RPC_URL.lower() and len(w3.eth.accounts) > 1:
        recipient_address = w3.eth.accounts[1]
    else:
        recipient_address = "0xEnterSecondAddress"

    print(f"\n\n➡️ Attempting to transfer Token #{token_id} from {sender_account.address} to {recipient_address}...")

    # build the 'transferFrom' transaction
    nonce = w3.eth.get_transaction_count(sender_account.address)

    transfer_txn = nft_contract.functions.transferFrom(
        sender_account.address, # from
        recipient_address,      # to
        token_id                # tokenId
    ).build_transaction({
        'from': sender_account.address,
        'nonce': nonce,
        'gas': 200000,
        'gasPrice': w3.eth.gas_price
    })

    # sign and send the transaction
    signed_txn = w3.eth.account.sign_transaction(transfer_txn, private_key=PRIVATE_KEY)
    tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)
    print(f"\n🚀 Transfer transaction sent! Hash: {tx_hash.hex()}")

    # wait for the transaction to be mined
    tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)

    if tx_receipt['status'] == 1:
        print("\n🎉 NFT transfer successful!")

        # verify the new ownership
        new_owner = nft_contract.functions.ownerOf(token_id).call()
        print(f"\n👑 new owner of Token #{token_id} is now: {new_owner}")

        # verify new balances
        sender_new_balance = nft_contract.functions.balanceOf(sender_account.address).call()
        recipient_new_balance = nft_contract.functions.balanceOf(recipient_address).call()
        print(f"\n\n 💰 new balance of sender ({sender_account.address}): {sender_new_balance} {collection_symbol} NFT(s)")
        print(f"\n 💰 new balance of recipient ({recipient_address}): {recipient_new_balance} {collection_symbol} NFT(s)")
    else:
        print("❌ Transfer failed! Transaction receipt:")
        print(tx_receipt)

except Exception as e:
    print(f"An error occurred: {e}")
    print("\nensure your ERC721_CONTRACT_ADDRESS and ERC721_CONTRACT_ABI are correct and match your newly deployed token.")
    print("also, ensure your Ganache is running with the correct RPC URL and private key, and that you have minted at least one token.")

print("\n--- End of ERC-721 Interaction ---")

Enter fullscreen mode Exit fullscreen mode

After executing your script, you should see something like this:

NOTE: In a real-world scenario, the tokenURI would link to a decentralised storage solution like IPFS,

the tokenURI function should return a link to JSON metadata following this structure:

{
  "name": "Very Simple NFT #1",
  "description": "This is a unique digital collectible",
  "image": "https://ipfs.io/ipfs/QmHash...",
  "attributes": [
    {
      "trait_type": "Color",
      "value": "Blue"
    },
    {
      "trait_type": "Rarity",
      "value": "Legendary"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

What You've Accomplished

You've successfully bridged the gap from fungible tokens to unique digital assets. You now know how to:

  • Distinguish between fungible (ERC-20) and non-fungible (ERC-721) tokens.

  • Deploy your own simple ERC-721 NFT contract.

  • Use web3.py to:

  • Query NFT metadata and ownership details like name(), symbol(), balanceOf(), and ownerOf().

  • Mint new unique tokens on-chain.

  • Execute a transferFrom transaction to move ownership of a specific NFT.

This is the core of how NFT marketplaces and games function under the hood.


Resources for Continued Learning:


Questions about NFT implementation or encountered any issues? Drop a comment below

Comments 0 total

    Add comment