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

ERC-721:NFT 如何證明數位所有權

一塊錢就是一塊錢,但一張有座位號的演唱會門票卻只屬於你。ERC-721 把這份「獨一無二」寫成程式——一份序號登記簿,每個序號恰好對映一位擁有者。打開這份標準,看清 NFT 究竟是什麼、又不是什麼。

當每一枚代幣都獨一無二

把一張一美元鈔票遞給別人,他根本不在乎那是「哪一張」——任何一塊錢都能買到同一杯咖啡。這種性質叫做同質性(fungibility),正是 ERC-20 所描繪的:一份代幣合約只記錄每個位址「持有幾單位」。但若你遞出的是一張印著 7A 座位的演唱會門票,把它換成 31F 座,你就改變了某種真實的東西。這就是 非同質 之物:看似雷同,卻每一份都各自獨立、無可替代。

2017 年底,一款名為 CryptoKitties(謎戀貓)的遊戲把這個想法搬上鏈:每隻貓都是一枚可繁殖、可交易的獨特代幣。它紅到把以太坊塞爆、堆積出一長串待處理交易——並直接形塑了 EIP-721,於 2018 年初定案為正式標準。它的產物就是 NFT:每一單位都帶著自己身分的代幣。本篇將打開 ERC-721 標準,揭示它背後那台小得出奇的機器。

把核心一句話講白:一份 ERC-721 合約就是一張把獨特的 `tokenId` 對映到「恰好一個」擁有者位址的登記簿。「擁有」42 號 NFT,意思是這份合約的紀錄把「你的」位址登記在數字 42 名下——不多,也不少。標準裡其餘的一切,都只是為了讀取、轉移或描述那一行紀錄而存在。

標準是一份介面,而非一種貨幣

和 ERC-20 一樣,ERC-721 並不是一套你能下載的軟體——它是一份 Solidity 介面,一張公開的函式簽章清單,是錢包與市集都同意去說的共同語言。任何正確實作這些函式的合約「就是」一個 NFT 收藏;世上每一個 NFT 工具,無需事先認識它,都能讀懂它。這份共享的詞彙,正是 代幣標準 存在的全部意義。

// 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;
}
十一個函式、三個事件。請注意,每次轉移搬動的是「一個」tokenId——而非一筆金額。

拿它和 ERC-20 對照:後者的 `transfer(to, amount)` 搬動的是一個數量。ERC-721 沒有數量概念:`transferFrom(from, to, tokenId)` 按序號搬動一枚不可分割的代幣。市集還需要先問一句「你到底是不是 ERC-721?」——這就是 ERC-165 的 `supportsInterface(bytes4)` 的工作,其中答案 `0x80ac58cd` 是 ERC-721 介面講好的指紋(而 `0x5b5e139f` 則對應它的中繼資料擴充)。

所有權就是一張映射表:ownerOf、balanceOf 與鑄造

把一份最精簡的 ERC-721 剝到見骨,整件事其實只靠兩塊狀態撐著。一塊把每個代幣對映到它的擁有者;另一塊計算某位址持有幾枚代幣,讓 `balanceOf` 成為 O(1) 的查表、而非一次掃描。`ownerOf` 對一個存在的代幣必須回傳一個真實位址;而零位址 `address(0)` 被保留,用來表示「無主/不存在」。

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);
}
鑄造毫無神奇之處:寫進一個儲存槽、把計數加一、發出一個事件。

鑄造 不過是讓某個 `tokenId` 開始存在的第一次寫入。讓整個生態運轉起來的關鍵細節,是那一行 `emit Transfer(address(0), to, tokenId)`:一筆「來自」零位址的轉移,正是「此代幣剛被創造」這件事的標準訊號。索引器與市集靠監看它來建立資料庫——它們從不直接讀你的儲存,而是重播你的事件。而那句 `require(_owners[tokenId] == address(0))`,就是強制保證一個序號只能、且只會被鑄造一次

  1. 一個收藏部署合約;創作者呼叫 `_mint(buyer, 42)`。
  2. 儲存中此刻記著 `_owners[42] = buyer`,而 `_balances[buyer]` 被加一。
  3. 一筆 `Transfer(0x0, buyer, 42)` 事件被記錄;索引器看見它,便把 42 號加進買家的收藏。
  4. 從此 `ownerOf(42)` 都會回傳 `buyer`,直到某次轉移覆蓋掉那個槽位為止。

授權:讓市集得以搬動你的代幣

當你掛單出售一個 NFT 時,買家付款的那一刻「你」並不在線上——市集合約必須在稍後、於另一筆交易中代你搬動代幣。於是 ERC-721 透過兩層 授權 授予委託權限。`approve(spender, tokenId)` 把某一特定代幣交給某一位址;而 `setApprovalForAll(operator, true)` 則讓某位址成為你在「整個」收藏中所有代幣的操作者(operator)

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 先檢查授權,接著清掉該代幣的授權,使其無法被重複使用。

市集絕大多數採用 `setApprovalForAll`,因為只要簽一次授權,你就能掛上一百件商品。但這份方便,也是標準裡最鋒利的一面:一個操作者能在任何時候、無需任何進一步同意,搬走你在該收藏中持有的「每一枚」代幣。許多真實的錢包被掏空事件,並非源自什麼精巧的漏洞,而是使用者在惡意網站上按下「approve」,把全面的操作者權限拱手交給了攻擊者。

safeTransferFrom 與接收方檢查

有一種悄無聲息的方式會讓你永遠失去一個 NFT。如果你用 `transferFrom` 把代幣轉「給一份合約」,而那份合約從來不是為了讀懂 NFT 而寫的,代幣就會落在那裡、沒有任何程式能再把它轉出來——永久卡死。`safeTransferFrom` 的存在正是為了防止這件事:在把代幣轉給一份合約之前,它會先問那份合約一句「你真的能處理 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");
}
接收方合約必須回傳恰好 0x150b7a02;其他任何回傳值(或一次 revert)都會中止整筆轉移。

這套邏輯很精準。若收款方是一般錢包(沒有程式碼的外部帳戶),便無人可問,轉移照常進行。若它是一份合約,就「必須」實作 `onERC721Received` 並回傳那四位元組的魔術值 `0x150b7a02`;若它回傳別的東西或發生 revert,整筆轉移就會被回滾,你的代幣根本不會離開。市集的託管合約、質押金庫,正是靠這個訊號表態:「沒錯,把 NFT 送來這裡,我知道該怎麼處理。」

tokenURI:指標,而非圖片

到目前為止,合約證明了「誰擁有」42 號 `tokenId`——卻對 42 號「是什麼」隻字未提。這是選用的中繼資料擴充(Metadata extension)的工作,它加入了 `name()`、`symbol()`,以及最關鍵的 `tokenURI(tokenId)`。`tokenURI` 回傳一個字串——通常是一個 HTTP 或 `ipfs://` 連結——指向一份遵循 中繼資料標準 的 JSON 文件:包含名稱、描述、一個 `image` 欄位,以及一串特徵屬性。

// 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 }
//   ]
// }
鏈上只存一個簡短的指標;名稱、特徵與圖片都坐落在那個 URI 所指向的 JSON 檔案裡。

這正是「你並不擁有那張 JPEG」這場爭論的核心。合約權威記錄的是那個「指標」,而非那些像素。若該 URI 是一個內容定址的 IPFS 雜湊,位元組不改變雜湊就無法更動——具有強力的永久性。若它是團隊控制的一般網頁伺服器,他們隨時都能把圖換掉(或任其消失)。少數設計把圖片以 SVG 完全存在 鏈上,免疫於連結腐壞,但代價高昂。一個 tokenURI「指向何處」本身就是一個深入的題目——下一篇正是為它而寫。

市集如何把這一切串起來

現在,看著每一塊拼圖同時運轉起來。當你打開一個 NFT 市集、瀏覽一件商品時,網站所做的,正是你剛學會的那些標準呼叫——除了符合 ERC-721 之外,它不需要合約提供任何客製的東西。

  1. 繪製商品卡:呼叫 `tokenURI(42)`,抓取 JSON,顯示圖片與特徵。
  2. 顯示擁有者:呼叫 `ownerOf(42)`,秀出那個位址(或它的 ENS 名稱)。
  3. 要掛單:賣家簽一次 `setApprovalForAll(marketplace, true)`,好讓市集合約日後能代為轉移。
  4. 成交時:市集驗證 `isApprovedForAll(seller, marketplace)`,收下買家的款項,並原子性地呼叫 `safeTransferFrom(seller, buyer, 42)`。
  5. 建立歷史:重播每一筆 `Transfer` 與 `Approval` 事件,重建活動紀錄與來源履歷。

這就是 ERC-721 機器的全貌:一張記所有權的映射、一套委託用的兩層授權系統、一場避免代幣卡死的安全轉移握手,以及一個指向藝術品的中繼資料指標。它刻意設下的限制是「一次一枚、單一擁有者」——這正是後續標準要擴充它的原因。ERC-1155 把多種代幣類型(以及 半同質 餘額)塞進一份合約,以利高效的批次轉移;靈魂綁定代幣刻意拿掉轉移功能,用以表達不可交易的憑證;而權利金與帳戶抽象化標準則在其上疊加經濟與體驗層。每一個,都建立在你剛打開的這張登記簿之上。