一個同時裝著金幣與劍的背包
想像一個 RPG 的背包:一疊1000 枚金幣(每一枚都一模一樣)、三瓶回血藥水,還有一把整個遊戲裡只存在一份的傳說之劍。兩篇之前你學到,可互換的金錢是一種由 ERC-20 表達的可替代代幣;上一篇你看到,獨一無二的收藏品則是一種由 ERC-721 表達的 NFT。那麼,要怎麼把金幣和劍同時放上鏈呢?2018 年以前痛苦的答案是:為每一種道具都部署一份獨立的智慧合約——金幣一份 ERC-20、藥水又一份、劍一份 ERC-721。一個遊戲就得部署、稽核、索引數十份合約。
ERC-1155 由 Enjin 的 Witek Radomski 等人於 2018 年提出,用一個構想解決了這件事:讓單一合約一次管理許多代幣 id,而每個 id 都可以是可替代、不可替代,或介於兩者之間的任何形態。這正是它被稱為多代幣標準的原因。它並不取代 ERC-20 或 ERC-721——而是把兩者一併推廣,如今已成為遊戲、版次與綑綁資產首選的代幣標準。
二維的帳本
整個訣竅一行就講完。ERC-20 維護的是 `balanceOf(owner)`——一張從位址到數量的一維表;ERC-721 維護的是 `ownerOf(tokenId)`——每個 id 對應到唯一一位擁有者。ERC-1155 把兩者融合成 `balanceOf(account, id)`:一個同時以代幣 id 與持有者為鍵的二維帳本。一個映射 `id => (owner => amount)`,就一次表達了所有情形。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// The heart of ERC-1155: a balance is indexed by BOTH a token id AND an owner,
// so ONE contract can hold every kind of token a game (or app) needs.
interface IERC1155 {
// ERC-20 asks balanceOf(owner); ERC-721 asks ownerOf(tokenId).
// ERC-1155 fuses them: how many of token `id` does `account` hold?
function balanceOf(address account, uint256 id) external view returns (uint256);
function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids)
external view returns (uint256[] memory);
// No per-token approve: you grant an operator ALL of your token types at once.
function setApprovalForAll(address operator, bool approved) external;
function isApprovedForAll(address account, address operator) external view returns (bool);
function safeTransferFrom(
address from, address to, uint256 id, uint256 value, bytes calldata data
) external;
function safeBatchTransferFrom(
address from, address to,
uint256[] calldata ids, uint256[] calldata values, bytes calldata data
) external;
event TransferSingle(address indexed op, address indexed from, address indexed to, uint256 id, uint256 value);
event TransferBatch(address indexed op, address indexed from, address indexed to, uint256[] ids, uint256[] values);
event ApprovalForAll(address indexed account, address indexed operator, bool approved);
event URI(string value, uint256 indexed id);
}代幣的性格只不過是它在某個 id 上供給量的結果。若某個 id 有大量一模一樣的單位,它就表現得像一種可替代的貨幣;若某個 id 的總供給恰好為 1,它就表現得像不可替代代幣;而若某個 id 有少量、有上限、彼此可互換的複本,你得到的就是半可替代代幣——想想某件藝術品的 500 張編號版畫,或是演出前彼此可互換、演出後變成各自獨立收藏品的演唱會門票。ERC-1155 只憑著你選擇每個 id 鑄造多少,就橫跨了整個光譜。
一份合約,多種代幣:鑄造
我們真的來部署這個背包。下面這份合約在它的建構子裡,從同一個位址鑄造出兩種代幣:1000 枚可替代的 GOLD 與 1 把不可替代的 SWORD。依照標準的慣例,鑄造會以「從零位址轉出」的形式發出事件,如此一來瀏覽器與索引器就能像追蹤其他任何移動一樣追蹤發行。
// A simplified game-item contract. In production you would inherit
// OpenZeppelin's audited ERC1155 instead of hand-rolling the ledger.
contract GameItems {
// id => (owner => amount): the whole fungible + non-fungible ledger in one map.
mapping(uint256 => mapping(address => uint256)) public balanceOf;
mapping(address => mapping(address => bool)) public isApprovedForAll;
uint256 public constant GOLD = 1; // fungible: 1000 identical coins
uint256 public constant SWORD = 2; // non-fungible: total supply of exactly 1
event TransferSingle(address indexed op, address indexed from, address indexed to, uint256 id, uint256 value);
constructor() {
// Mint both token types from the SAME contract to the deployer.
balanceOf[GOLD][msg.sender] = 1000;
balanceOf[SWORD][msg.sender] = 1;
// ERC-1155 signals a mint as a transfer FROM the zero address.
emit TransferSingle(msg.sender, address(0), msg.sender, GOLD, 1000);
emit TransferSingle(msg.sender, address(0), msg.sender, SWORD, 1);
}
}留意剛才發生了什麼。一個 ERC-721 系列得有它自己的部署,外加逐代幣的 `ownerOf` 記帳;而純粹的 ERC-20 根本無法表達那把獨一無二的劍。ERC-1155 用一次部署、一個帳本就把兩件事都做了。這就是它最醒目的效率優勢:共用程式碼、讓錢包與市集只需追蹤一個位址,還能用單一的 `balanceOfBatch` 呼叫一次讀出多個餘額,而不必每種代幣各查一次。
批次轉移:一次搬動整個背包
第二項超能力是 `safeBatchTransferFrom`。把一份戰利品綑包——劍、50 枚金幣、3 瓶藥水——賣給另一位玩家,是一筆同時搬動這三個 id 的交易。關鍵在於它是原子的:要嘛整個批次成功,要嘛整批回滾、什麼都不動。換成 ERC-721,你得送出三筆獨立的轉移,每筆都要付 21,000 gas 的基礎成本,而且每筆都可能各自失敗,留下一筆只完成一半的交易。
// Move many token types in ONE atomic transaction:
// e.g. sell {SWORD x1, GOLD x50, POTION x3} as a single bundle.
function safeBatchTransferFrom(
address from,
address to,
uint256[] calldata ids,
uint256[] calldata values,
bytes calldata data
) external {
require(to != address(0), "transfer to zero address");
require(ids.length == values.length, "ids/values length mismatch");
require(from == msg.sender || isApprovedForAll[from][msg.sender], "not authorized");
for (uint256 i = 0; i < ids.length; ++i) {
balanceOf[ids[i]][from] -= values[i]; // reverts on underflow in 0.8+
balanceOf[ids[i]][to] += values[i];
}
emit TransferBatch(msg.sender, from, to, ids, values);
// If the recipient is a contract, it MUST acknowledge the batch or we revert.
if (to.code.length > 0) {
require(
IERC1155Receiver(to).onERC1155BatchReceived(msg.sender, from, ids, values, data)
== IERC1155Receiver.onERC1155BatchReceived.selector,
"unsafe recipient: not an ERC-1155 receiver"
);
}
}也要老實說省在哪裡。最大的攤提收益,是整個綑包共用單筆約 21,000 gas 的交易基礎費,再加上函式分派與「暖存取」開銷只付一次、而非 N 次。實際更新餘額的 `SSTORE` 仍是逐 id 計費——五種代幣大約就是五對儲存寫入。所以一筆 50 件的批次遠比 50 筆交易便宜,但它並不是憑空變免費。
安全機制:接收掛鉤與操作者授權
為什麼 `safeTransferFrom` 裡有個 `safe`?如果你把代幣送進一份根本不懂 ERC-1155 的合約,它們就會永遠卡死在那裡。於是標準要求:每當接收方是一份合約時,它必須實作 `onERC1155Received`(或 `onERC1155BatchReceived`)並回傳一個特定的 4 位元組魔術值。若它回傳任何其他值——或根本沒實作這個掛鉤——轉移就會回滾,你的代幣便安然留在錢包裡。這正是上一篇你在 `onERC721Received` 見過的同一套「接收方確認」模式。
interface IERC1155Receiver {
function onERC1155Received(
address operator, address from, uint256 id, uint256 value, bytes calldata data
) external returns (bytes4);
function onERC1155BatchReceived(
address operator, address from,
uint256[] calldata ids, uint256[] calldata values, bytes calldata data
) external returns (bytes4);
}
// A vault willing to custody game items must return the exact magic value,
// otherwise every safe(Batch)TransferFrom into it reverts and the tokens stay put.
contract ItemVault is IERC1155Receiver {
function onERC1155Received(address, address, uint256, uint256, bytes calldata)
external pure returns (bytes4)
{
return this.onERC1155Received.selector; // 0xf23a6e61
}
function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata)
external pure returns (bytes4)
{
return this.onERC1155BatchReceived.selector; // 0xbc197c81
}
}再來談授權——這裡有個鋒利的邊角。ERC-1155 沒有逐代幣的 approve,沒有任何相當於 ERC-20 額度或 ERC-721 `approve(tokenId)` 的東西。唯一的授權是 `setApprovalForAll(operator, true)`,它讓那位操作者能搬動你在這份合約裡持有的每一種代幣。這正是 NFT 市集為了上架你的道具所需要的,卻也是一把通往你整個背包的萬能鑰匙:只授權你信任的合約,並在用完後撤銷授權。
中繼資料、取捨,以及何時該選用 1155
中繼資料裡還藏著一個效率小技巧。ERC-1155 不為每個代幣各存一條 URI,而是公開單一一條含有字面子字串 `{id}` 的範本 URI。用戶端會把 id 換成小寫、補零的十六進位來解析出每個代幣的 JSON。一條字串就服務了百萬個 id。(那份 JSON 究竟裝了什麼、又存在 IPFS 還是伺服器上,正是下一篇的全部主題。)
// ONE URI template serves EVERY token id (the ERC-1155 metadata extension):
uri = "https://game.example/api/item/{id}.json"
// Clients replace the literal "{id}" with the LOWERCASE hex id,
// zero-padded to 64 characters, with NO 0x prefix.
// For GOLD (id = 1):
// {id} -> 0000000000000000000000000000000000000000000000000000000000000001
// Resolved metadata URL:
// https://game.example/api/item/000...0001.json
//
// One template, no per-token storage write -> cheap metadata for millions of ids.現在來談誠實的取捨,因為 ERC-1155 並非天下白吃的午餐。它沒有 `ownerOf`,也沒有內建的列舉:對一個 1-of-1 的代幣,你無法直接問「誰擁有 id 7?」——所有權只以 `balanceOf(addr, 7) == 1` 來表達,所以要找出持有者,得監看轉移事件或自建索引器。工具與部分市集至今仍把 ERC-721 當作頭等的 NFT 格式,因此純收藏品的專案,改用 ERC-721 可能獲得更順暢的支援。再者,只到操作者層級的授權模型,也比逐代幣授權來得粗糙。