當每一枚代幣都獨一無二
把一張一美元鈔票遞給別人,他根本不在乎那是「哪一張」——任何一塊錢都能買到同一杯咖啡。這種性質叫做同質性(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;
}拿它和 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))`,就是強制保證一個序號只能、且只會被鑄造一次。
- 一個收藏部署合約;創作者呼叫 `_mint(buyer, 42)`。
- 儲存中此刻記著 `_owners[42] = buyer`,而 `_balances[buyer]` 被加一。
- 一筆 `Transfer(0x0, buyer, 42)` 事件被記錄;索引器看見它,便把 42 號加進買家的收藏。
- 從此 `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);
}市集絕大多數採用 `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");
}這套邏輯很精準。若收款方是一般錢包(沒有程式碼的外部帳戶),便無人可問,轉移照常進行。若它是一份合約,就「必須」實作 `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 }
// ]
// }這正是「你並不擁有那張 JPEG」這場爭論的核心。合約權威記錄的是那個「指標」,而非那些像素。若該 URI 是一個內容定址的 IPFS 雜湊,位元組不改變雜湊就無法更動——具有強力的永久性。若它是團隊控制的一般網頁伺服器,他們隨時都能把圖換掉(或任其消失)。少數設計把圖片以 SVG 完全存在 鏈上,免疫於連結腐壞,但代價高昂。一個 tokenURI「指向何處」本身就是一個深入的題目——下一篇正是為它而寫。
市集如何把這一切串起來
現在,看著每一塊拼圖同時運轉起來。當你打開一個 NFT 市集、瀏覽一件商品時,網站所做的,正是你剛學會的那些標準呼叫——除了符合 ERC-721 之外,它不需要合約提供任何客製的東西。
- 繪製商品卡:呼叫 `tokenURI(42)`,抓取 JSON,顯示圖片與特徵。
- 顯示擁有者:呼叫 `ownerOf(42)`,秀出那個位址(或它的 ENS 名稱)。
- 要掛單:賣家簽一次 `setApprovalForAll(marketplace, true)`,好讓市集合約日後能代為轉移。
- 成交時:市集驗證 `isApprovedForAll(seller, marketplace)`,收下買家的款項,並原子性地呼叫 `safeTransferFrom(seller, buyer, 42)`。
- 建立歷史:重播每一筆 `Transfer` 與 `Approval` 事件,重建活動紀錄與來源履歷。
這就是 ERC-721 機器的全貌:一張記所有權的映射、一套委託用的兩層授權系統、一場避免代幣卡死的安全轉移握手,以及一個指向藝術品的中繼資料指標。它刻意設下的限制是「一次一枚、單一擁有者」——這正是後續標準要擴充它的原因。ERC-1155 把多種代幣類型(以及 半同質 餘額)塞進一份合約,以利高效的批次轉移;靈魂綁定代幣刻意拿掉轉移功能,用以表達不可交易的憑證;而權利金與帳戶抽象化標準則在其上疊加經濟與體驗層。每一個,都建立在你剛打開的這張登記簿之上。