JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

ERC-721: how NFTs prove digital ownership

A dollar is a dollar, but a numbered concert ticket is yours alone. ERC-721 turns that uniqueness into code — a registry of serial numbers, each mapped to exactly one owner. Open the standard and see precisely what an NFT is and what it isn't.

When every token is one of a kind

Hand someone a US$1 bill and they don't care which dollar it is — any dollar buys the same coffee. That property is fungibility, and it is exactly what ERC-20 models: a token contract that only tracks how many units each address holds. Now hand someone a concert ticket stamped Seat 7A. Swap it for Seat 31F and you've changed something real. That is a non-fungible thing: identical-looking, but each one distinct and irreplaceable.

In late 2017 a game called CryptoKitties put this idea on-chain: every cat was a unique token you could breed and trade. It was popular enough to congest Ethereum and pile up a backlog of pending transactions — and it directly shaped EIP-721, finalized as a standard in early 2018. The result is the NFT: a token where each unit carries its own identity. This guide opens the ERC-721 standard and shows the surprisingly small machine behind it.

Here is the heart of it, stated plainly: an ERC-721 contract is a registry that maps a unique `tokenId` to exactly one owner address. "Owning" NFT #42 means this contract's records list your address against the number 42 — nothing more, nothing less. Everything else in the standard exists to read, transfer, or describe that single row.

The standard is an interface, not a coin

Just like ERC-20, ERC-721 is not a piece of software you download — it is a Solidity interface, a published list of function signatures that wallets and marketplaces agree to speak. Any contract that implements these functions correctly is an NFT collection, and every NFT tool in the world will understand it without prior knowledge. That shared vocabulary is the whole point of a token standard.

// The ERC-721 core interface (EIP-721), Solidity ^0.8
interface IERC721 {
    // Events — the only on-chain trail of who got what
    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
    event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
    event ApprovalForAll(address indexed owner, address indexed operator, bool approved);

    // Reads
    function balanceOf(address owner) external view returns (uint256);
    function ownerOf(uint256 tokenId) external view returns (address);
    function getApproved(uint256 tokenId) external view returns (address);
    function isApprovedForAll(address owner, address operator) external view returns (bool);

    // Writes
    function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
    function safeTransferFrom(address from, address to, uint256 tokenId) external;
    function transferFrom(address from, address to, uint256 tokenId) external;
    function approve(address to, uint256 tokenId) external;
    function setApprovalForAll(address operator, bool approved) external;
}
Eleven functions and three events. Notice transfers move ONE tokenId — never an amount.

Contrast that with ERC-20, whose `transfer(to, amount)` moves a quantity. ERC-721 has no amounts: `transferFrom(from, to, tokenId)` moves one indivisible token by its serial number. A marketplace also needs to ask "are you even an ERC-721?" — that's the job of ERC-165 `supportsInterface(bytes4)`, where the answer `0x80ac58cd` is the agreed fingerprint for the ERC-721 interface (and `0x5b5e139f` for its metadata extension).

Ownership is a mapping: ownerOf, balanceOf, and minting

Strip a minimal ERC-721 down and the whole thing rests on two pieces of state. One maps each token to its owner; the other counts how many tokens an address holds, so `balanceOf` is an O(1) lookup instead of a scan. `ownerOf` must return a real address for a token that exists, and the zero address `address(0)` is reserved to mean "no owner / does not exist."

contract MiniERC721 {
    mapping(uint256 => address) private _owners;    // tokenId  => owner
    mapping(address => uint256) private _balances;  // owner    => count

    function ownerOf(uint256 tokenId) public view returns (address) {
        address owner = _owners[tokenId];
        require(owner != address(0), "ERC721: token does not exist");
        return owner;
    }

    function balanceOf(address owner) public view returns (uint256) {
        require(owner != address(0), "ERC721: zero address");
        return _balances[owner];
    }

    // Create token `tokenId` and assign it to `to`.
    function _mint(address to, uint256 tokenId) internal {
        require(to != address(0), "ERC721: mint to zero address");
        require(_owners[tokenId] == address(0), "ERC721: already minted");
        _balances[to]   += 1;
        _owners[tokenId] = to;
        emit Transfer(address(0), to, tokenId);   // mint = Transfer FROM the zero address
    }

    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
}
Minting is nothing magical: write one storage slot, bump a counter, emit an event.

Minting is just the first write that brings a `tokenId` into existence. The detail that makes the ecosystem work is that line `emit Transfer(address(0), to, tokenId)`: a transfer from the zero address is the canonical "this token was just created" signal. Indexers and marketplaces watch for it to build their databases — they never read your storage directly, they replay your events. The `require(_owners[tokenId] == address(0))` is what enforces that a serial number can be minted once and only once.

  1. A collection deploys the contract; the creator calls `_mint(buyer, 42)`.
  2. Storage now holds `_owners[42] = buyer` and `_balances[buyer]` is incremented by one.
  3. A `Transfer(0x0, buyer, 42)` event is logged; an indexer sees it and adds #42 to the buyer's collection.
  4. From now on `ownerOf(42)` returns `buyer` until a transfer overwrites that slot.

Approvals: letting a marketplace move your token

When you list an NFT for sale, you are not online at the moment a buyer pays — the marketplace contract must move your token on your behalf, later, in a different transaction. So ERC-721 grants delegated authority through two tiers of approval. `approve(spender, tokenId)` hands one specific token to one address; `setApprovalForAll(operator, true)` makes an address an operator for your entire balance in that collection.

mapping(uint256 => address) private _tokenApprovals;             // tokenId => approved address
mapping(address => mapping(address => bool)) private _operatorApprovals; // owner => operator => ok

function approve(address to, uint256 tokenId) public {
    address owner = ownerOf(tokenId);
    require(msg.sender == owner || isApprovedForAll(owner, msg.sender), "not authorized");
    _tokenApprovals[tokenId] = to;
    emit Approval(owner, to, tokenId);
}

function setApprovalForAll(address operator, bool approved) public {
    _operatorApprovals[msg.sender][operator] = approved;
    emit ApprovalForAll(msg.sender, operator, approved);
}

function isApprovedForAll(address owner, address operator) public view returns (bool) {
    return _operatorApprovals[owner][operator];
}

// The gate every transfer must pass: owner, single-token approvee, or operator.
function _isApprovedOrOwner(address spender, uint256 tokenId) internal view returns (bool) {
    address owner = ownerOf(tokenId);
    return (spender == owner ||
            _tokenApprovals[tokenId] == spender ||
            isApprovedForAll(owner, spender));
}

function transferFrom(address from, address to, uint256 tokenId) public {
    require(_isApprovedOrOwner(msg.sender, tokenId), "ERC721: not owner nor approved");
    require(ownerOf(tokenId) == from, "ERC721: wrong from");
    require(to != address(0), "ERC721: transfer to zero address");
    delete _tokenApprovals[tokenId];   // a single-token approval is consumed by the transfer
    _balances[from] -= 1;
    _balances[to]   += 1;
    _owners[tokenId] = to;
    emit Transfer(from, to, tokenId);
}
transferFrom checks authorization, then clears the per-token approval so it can't be reused.

Marketplaces overwhelmingly use `setApprovalForAll` because it lets you list a hundred items after signing one approval. That convenience is also the standard's sharpest edge: an operator can move every token you own in that collection, at any time, with no further consent. Many real wallet drains came not from a clever exploit but from a user clicking "approve" on a malicious site and granting blanket operator rights to an attacker.

safeTransferFrom and the receiver check

There's a quiet way to lose an NFT forever. If you `transferFrom` a token to a contract that was never written to understand NFTs, the token lands there with no code able to ever transfer it out again — permanently stuck. `safeTransferFrom` exists to prevent exactly this: before completing a transfer to a contract, it asks that contract "can you actually handle an ERC-721?"

interface IERC721Receiver {
    function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data)
        external returns (bytes4);
}

// The magic value: bytes4(keccak256("onERC721Received(address,address,uint256,bytes)")) == 0x150b7a02
function _checkOnERC721Received(address from, address to, uint256 tokenId, bytes memory data)
    private returns (bool)
{
    if (to.code.length == 0) return true;   // a plain wallet (EOA) has no code — nothing to ask
    try IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, data) returns (bytes4 retval) {
        return retval == IERC721Receiver.onERC721Received.selector;  // must echo the magic value
    } catch {
        revert("ERC721: transfer to non-ERC721Receiver");
    }
}

function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public {
    transferFrom(from, to, tokenId);                                 // do the move first...
    require(_checkOnERC721Received(from, to, tokenId, data), "unsafe recipient");
}
A receiving contract must return exactly 0x150b7a02; anything else (or a revert) aborts the whole transfer.

The logic is precise. If the recipient is a plain wallet (an externally-owned account with no code) there's no one to ask, so the transfer proceeds. If it's a contract, it must implement `onERC721Received` and return the 4-byte magic value `0x150b7a02`; if it returns anything else or reverts, the whole transfer is rolled back and your token never leaves. This is how a marketplace escrow or a staking vault signals "yes, send NFTs here, I know what to do with them."

tokenURI: the pointer, not the picture

So far the contract proves who owns `tokenId` 42 — but says nothing about what 42 is. That's the job of the optional Metadata extension, which adds `name()`, `symbol()`, and the crucial `tokenURI(tokenId)`. `tokenURI` returns a string — usually an HTTP or `ipfs://` link — pointing to a JSON document that follows the metadata standard: a name, description, an `image` field, and a list of traits.

// On-chain: most collections store only a base URI and append the id.
string private _baseURI = "ipfs://bafybeih.../";

function tokenURI(uint256 tokenId) public view returns (string memory) {
    require(_owners[tokenId] != address(0), "ERC721: nonexistent token");
    return string.concat(_baseURI, Strings.toString(tokenId));   // e.g. ipfs://bafy.../42
}

// Off-chain, at that URI, lives the actual metadata JSON:
// {
//   "name": "Voyager #42",
//   "description": "A starship from the Voyager collection.",
//   "image": "ipfs://bafkrei.../42.png",
//   "attributes": [
//     { "trait_type": "Hull",  "value": "Titanium" },
//     { "trait_type": "Speed", "value": 88 }
//   ]
// }
The chain stores a short pointer; the name, traits, and image sit in a JSON file the URI points to.

This is the crux behind the "you don't own the JPEG" debate. The contract's authoritative record is the pointer, not the pixels. If that URI is a content-addressed IPFS hash, the bytes can't change without changing the hash — strong permanence. If it's a plain web server the team controls, they can swap the art (or let it vanish) any day. A rare design stores the image fully on-chain as SVG, immune to link rot but expensive. Where a tokenURI points is its own deep topic — the very next guide is devoted to it.

How a marketplace ties it all together

Now watch every piece work at once. When you open an NFT marketplace and look at an item, the site is making exactly the standard calls you've just learned — it needs nothing custom from the contract beyond ERC-721 compliance.

  1. Render the card: call `tokenURI(42)`, fetch the JSON, show the image and traits.
  2. Show the owner: call `ownerOf(42)` and display that address (or its ENS name).
  3. To list: the seller signs `setApprovalForAll(marketplace, true)` once, so the contract can later transfer on their behalf.
  4. On purchase: the marketplace verifies `isApprovedForAll(seller, marketplace)`, takes the buyer's payment, and calls `safeTransferFrom(seller, buyer, 42)` atomically.
  5. Build the history: replay every `Transfer` and `Approval` event to reconstruct the activity feed and provenance.

That is the entire ERC-721 machine: a mapping for ownership, a two-tier approval system for delegation, a safe-transfer handshake to avoid stuck tokens, and a metadata pointer for the art. Its deliberate limit is one-token-at-a-time, single-owner — which is exactly why the next standards extend it. ERC-1155 packs many token types (and semi-fungible balances) into one contract for efficient batch transfers; soulbound tokens deliberately remove transfer to model non-tradable credentials; and royalty and account-abstraction standards add the economic and UX layers on top. Each builds on the registry you just opened.