ERC-1155 多代币标准完全学习指南(上)

ERC-1155 多代币标准完整指南上篇,涵盖核心设计思想、数据结构、接口详解、事件系统与接收者钩子机制。

· ☕ 14 分钟阅读
ERC-1155 多代币标准完全学习指南(上)

ERC-1155 多代币标准 — 程序员完全学习指南

目录

  1. 概述与背景
  2. 核心设计思想
  3. 数据结构详解
  4. 核心接口详解
  5. 事件系统
  6. 接收者钩子机制
  7. URI 元数据机制
  8. Token ID 设计模式
  9. 与 ERC-20/ERC-721 对比
  10. 安全注意事项
  11. 实战代码示例
  12. 常见应用场景
  13. 总结

1. 概述与背景

什么是 ERC-1155?

ERC-1155 是以太坊上的多代币标准(Multi Token Standard),由 Enjin 团队于 2018 年提出(EIP-1155)。它允许一个智能合约同时管理多种代币类型——包括同质化代币(Fungible Token)、非同质化代币(Non-Fungible Token)和半同质化代币(Semi-Fungible Token)。

为什么需要 ERC-1155?

问题ERC-20/721 的局限ERC-1155 的解决方案
多代币管理每种代币需要部署一个合约一个合约管理所有代币
批量操作无原生批量转账支持原生支持批量转账/查询
Gas 成本N 次转账 = N 笔交易N 次转账 = 1 笔交易
代币类型只能是 FT 或 NFT 其中之一同时支持 FT + NFT + SFT

2. 核心设计思想

2.1 一合约多代币

传统模式下,一个游戏道具系统可能需要:

  • 1 个 ERC-20 合约管理金币
  • 1 个 ERC-20 合约管理银币
  • N 个 ERC-721 合约管理不同类型的装备

ERC-1155 只需要 1 个合约,通过 id 来区分不同代币类型。

2.2 批量优先(Batch-First)

ERC-1155 的设计哲学是”批量操作是一等公民”:

  • safeBatchTransferFrom — 一次转多种代币
  • balanceOfBatch — 一次查多个余额

2.3 最小化存储

由于一个合约管理所有代币,链上只需要存储:

  • 一个二维映射(地址 × 代币ID → 余额)
  • 一个授权映射(地址 × 操作者 → bool)

3. 数据结构详解

这是理解 ERC-1155 的核心。从程序员角度看,ERC-1155 的存储结构极其精简:

3.1 核心存储变量

// 余额映射:tokenId => (账户地址 => 余额)
// 类似 Map<uint256, Map<address, uint256>>
mapping(uint256 => mapping(address => uint256)) private _balances;

// 操作者授权映射:账户地址 => (操作者地址 => 是否授权)
// 类似 Map<address, Map<address, bool>>
mapping(address => mapping(address => bool)) private _operatorApprovals;

// 元数据 URI(可选,通常是一个模板字符串)
string private _uri;

3.2 数据结构图解

┌─────────────────────────────────────────────────────┐
│                  ERC-1155 合约                        │
├─────────────────────────────────────────────────────┤
│                                                     │
│  _balances (二维映射)                                │
│  ┌──────────┬────────────────────────────────────┐  │
│  │ Token ID │ 账户余额映射                         │  │
│  ├──────────┼────────────────────────────────────┤  │
│  │ id=1     │ {Alice: 1000, Bob: 500}   ← 金币   │  │
│  │ (金币)   │                           (FT)     │  │
│  ├──────────┼────────────────────────────────────┤  │
│  │ id=2     │ {Alice: 50, Bob: 200}     ← 木材   │  │
│  │ (木材)   │                           (FT)     │  │
│  ├──────────┼────────────────────────────────────┤  │
│  │ id=1001  │ {Alice: 1}               ← 传说剑  │  │
│  │ (传说剑) │                           (NFT)    │  │
│  ├──────────┼────────────────────────────────────┤  │
│  │ id=2001  │ {Bob: 5, Carol: 3}       ← 门票   │  │
│  │ (门票)   │                           (SFT)    │  │
│  └──────────┴────────────────────────────────────┘  │
│                                                     │
│  _operatorApprovals (授权映射)                       │
│  ┌──────────┬──────────────────────────────────┐    │
│  │ 所有者    │ 操作者授权                        │    │
│  ├──────────┼──────────────────────────────────┤    │
│  │ Alice    │ {Marketplace: true, Bob: false}  │    │
│  │ Bob      │ {Marketplace: true}              │    │
│  └──────────┴──────────────────────────────────┘    │
│                                                     │
│  _uri = "https://game.example/api/item/{id}.json"   │
│                                                     │
└─────────────────────────────────────────────────────┘

3.3 与 ERC-20/721 数据结构对比

// ERC-20 的存储(每个代币一个合约)
mapping(address => uint256) private _balances;          // 一维
mapping(address => mapping(address => uint256)) private _allowances;

// ERC-721 的存储(每个 NFT 系列一个合约)
mapping(uint256 => address) private _owners;            // tokenId => 所有者
mapping(address => uint256) private _balances;          // 地址 => 持有数量
mapping(uint256 => address) private _tokenApprovals;    // 逐个授权

// ERC-1155 的存储(一个合约管理所有)
mapping(uint256 => mapping(address => uint256)) private _balances;  // 二维
mapping(address => mapping(address => bool)) private _operatorApprovals; // 全局授权

关键区别:

  • ERC-1155 没有 _owners 映射 — 因为同一个 id 可能有多个持有者
  • ERC-1155 的授权是 全局的setApprovalForAll),没有逐个代币的 approve
  • ERC-1155 不追踪单个代币的所有者,只追踪余额

3.4 为什么没有 approve 单个代币?

这是 ERC-1155 的一个重要设计决策:

// ❌ ERC-1155 没有这个
function approve(address spender, uint256 id, uint256 amount) external;

// ✅ ERC-1155 只有这个:要么全部授权,要么不授权
function setApprovalForAll(address operator, bool approved) external;

原因: 批量操作场景下,逐个授权的 Gas 成本太高。ERC-1155 选择了更粗粒度但更高效的授权模型。


4. 核心接口详解

4.1 IERC1155 完整接口

interface IERC1155 {
    // ============ 事件 ============
    
    event TransferSingle(
        address indexed operator,
        address indexed from,
        address indexed to,
        uint256 id,
        uint256 value
    );
    
    event TransferBatch(
        address indexed operator,
        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);
    
    // ============ 查询方法 ============
    
    /// @notice 查询某地址持有某代币ID的余额
    /// @param account 持有者地址
    /// @param id 代币ID
    /// @return 余额数量
    function balanceOf(address account, uint256 id) 
        external view returns (uint256);
    
    /// @notice 批量查询余额(数组长度必须一致)
    /// @param accounts 地址数组
    /// @param ids 代币ID数组
    /// @return 对应的余额数组
    function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids)
        external view returns (uint256[] memory);
    
    // ============ 授权方法 ============
    
    /// @notice 全局授权/取消授权某操作者
    /// @param operator 被授权的操作者地址
    /// @param approved true=授权, false=取消
    function setApprovalForAll(address operator, bool approved) external;
    
    /// @notice 查询授权状态
    function isApprovedForAll(address account, address operator)
        external view returns (bool);
    
    // ============ 转账方法 ============
    
    /// @notice 安全转账单个代币
    /// @param from 发送者
    /// @param to 接收者
    /// @param id 代币ID
    /// @param amount 数量
    /// @param data 附加数据(传给接收者钩子)
    function safeTransferFrom(
        address from, address to, uint256 id, uint256 amount, bytes calldata data
    ) external;
    
    /// @notice 安全批量转账
    /// @param from 发送者
    /// @param to 接收者
    /// @param ids 代币ID数组
    /// @param amounts 数量数组(与ids一一对应)
    /// @param data 附加数据
    function safeBatchTransferFrom(
        address from, address to, uint256[] calldata ids, 
        uint256[] calldata amounts, bytes calldata data
    ) external;
}

4.2 方法执行流程

safeTransferFrom 完整流程:

调用者发起 safeTransferFrom(from, to, id, amount, data)

    ├─ 1. 权限校验
    │     msg.sender == from || isApprovedForAll(from, msg.sender)
    │     如果不满足 → revert

    ├─ 2. 余额校验
    │     _balances[id][from] >= amount
    │     如果不满足 → revert

    ├─ 3. 更新状态
    │     _balances[id][from] -= amount
    │     _balances[id][to]   += amount

    ├─ 4. 触发事件
    │     emit TransferSingle(msg.sender, from, to, id, amount)

    └─ 5. 接收者检查(如果 to 是合约)
          调用 to.onERC1155Received(operator, from, id, amount, data)
          返回值必须等于 selector,否则 revert

5. 事件系统

5.1 TransferSingle vs TransferBatch

// 单次转账时触发
event TransferSingle(
    address indexed operator,  // 实际调用者可能是授权操作者
    address indexed from,      // 代币来源0地址=铸造
    address indexed to,        // 代币去向0地址=销毁
    uint256 id,               // 代币ID
    uint256 value             // 数量
);

// 批量转账时触发
event TransferBatch(
    address indexed operator,
    address indexed from,
    address indexed to,
    uint256[] ids,            // 代币ID数组
    uint256[] values          // 对应数量数组
);

关键规则:

  • 铸造(Mint):from = address(0)
  • 销毁(Burn):to = address(0)
  • 必须在状态变更后触发对应事件,链下索引器依赖此规则

5.2 为什么 operator 很重要?

// 场景:marketplace 合约代替用户转移代币
// operator = marketplace地址
// from = 卖家地址
// to = 买家地址
emit TransferSingle(marketplace, seller, buyer, tokenId, amount);

这让链下系统能区分”谁发起了操作”和”谁的代币被移动”。


6. 接收者钩子机制

6.1 IERC1155Receiver 接口

interface IERC1155Receiver {
    /// @notice 接收单个代币时的回调
    /// @return `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))`
    function onERC1155Received(
        address operator,
        address from,
        uint256 id,
        uint256 amount,
        bytes calldata data
    ) external returns (bytes4);
    
    /// @notice 接收批量代币时的回调
    /// @return `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))`
    function onERC1155BatchReceived(
        address operator,
        address from,
        uint256[] calldata ids,
        uint256[] calldata amounts,
        bytes calldata data
    ) external returns (bytes4);
}

6.2 为什么需要这个机制?

// 问题场景:如果没有接收者检查
用户误将 ERC-1155 代币转给一个普通合约(如 Uniswap V2 Pair)
→ 合约不知道如何处理这些代币
→ 代币永远锁死在合约里
→ 用户资产丢失

// 解决方案:safe 前缀方法会检查接收者
if (to.isContract()) {
    require(
        IERC1155Receiver(to).onERC1155Received(...) == selector,
        "ERC1155: transfer to non-ERC1155Receiver implementer"
    );
}

6.3 EOA vs 合约地址

  • EOA(外部账户):不需要实现 Receiver,直接接收
  • 合约地址:必须实现 IERC1155Receiver 并返回正确的 selector

7. URI 元数据机制

7.1 URI 模板与 ID 替换

// 合约中设置的 URI 模板
_uri = "https://game.example/api/item/{id}.json"

// 当查询 token id = 314592 时
// 客户端应将 {id} 替换为十六进制(64位补零):
// "https://game.example/api/item/000000000000000000000000000000000000000000000000000000000004ccf0.json"

规范要求:

  • {id} 必须替换为小写十六进制64个字符无 0x 前缀
  • 这是客户端的职责,合约只返回模板

7.2 元数据 JSON 结构

{
    "name": "黄金之剑",
    "description": "一把闪耀着金色光芒的传说武器",
    "image": "https://game.example/images/sword_001.png",
    "properties": {
        "attack": 150,
        "durability": 500,
        "rarity": "legendary"
    }
}

8. Token ID 设计模式

8.1 简单递增模式

uint256 private _currentId;

function mint(address to, uint256 amount) external {
    _currentId++;
    _mint(to, _currentId, amount, "");
}

8.2 类型分段模式(推荐)

// 用高位表示类型,低位表示具体编号
// 类型 1 (FT-金币):  0x0001_000000000000 ~ 0x0001_FFFFFFFFFFFF
// 类型 2 (NFT-武器): 0x0002_000000000000 ~ 0x0002_FFFFFFFFFFFF

uint256 constant GOLD   = (1 << 128) | 0;  // 同质化金币
uint256 constant SILVER = (1 << 128) | 1;  // 同质化银币
uint256 constant SWORD_BASE = 2 << 128;     // NFT 武器系列起始

function mintSword(address to) external {
    swordCount++;
    uint256 id = SWORD_BASE | swordCount;  // 每把剑有唯一ID
    _mint(to, id, 1, "");                  // 数量为1 = NFT
}

8.3 如何区分 FT 和 NFT?

ERC-1155 标准本身不区分,这是应用层的约定:

类型总供应量单用户余额典型示例
FT(同质化)无限/大量可以 > 1金币、材料
NFT(非同质化)= 1只能是 0 或 1唯一装备
SFT(半同质化)有限数量可以 > 1门票、优惠券

9. 与 ERC-20/ERC-721 对比

维度ERC-20ERC-721ERC-1155
代币类型仅同质化仅非同质化两者皆可
合约数量每种代币1个每个系列1个1个合约管所有
批量转账❌ 需多次调用❌ 需多次调用✅ 原生支持
批量查询balanceOfBatch
授权粒度精确到数量精确到单个NFT全局开关
部署成本高(多合约)高(多合约)低(单合约)
转账 Gas基准基准批量时显著更低
接收者安全❌ 无检查
元数据✅ tokenURI✅ uri(模板)
ERC-165✅ 必须实现

10. 安全注意事项

10.1 重入攻击

// ⚠️ 危险模式:先调用外部合约,再更新状态
function unsafeTransfer(address to, uint256 id, uint256 amount) internal {
    // 调用接收者钩子(外部调用!)
    IERC1155Receiver(to).onERC1155Received(...);
    // 才更新余额 — 重入窗口!
    _balances[id][from] -= amount;
    _balances[id][to] += amount;
}

// ✅ 安全模式:先更新状态,再调用外部合约(Checks-Effects-Interactions)
function safeTransfer(address to, uint256 id, uint256 amount) internal {
    // Checks
    require(_balances[id][from] >= amount);
    // Effects
    _balances[id][from] -= amount;
    _balances[id][to] += amount;
    // Interactions
    emit TransferSingle(...);
    if (to.isContract()) {
        require(IERC1155Receiver(to).onERC1155Received(...) == selector);
    }
}

10.2 数组长度匹配

// 批量操作时必须校验数组长度一致
function safeBatchTransferFrom(..., uint256[] ids, uint256[] amounts, ...) {
    require(ids.length == amounts.length, "ERC1155: ids and amounts length mismatch");
    // ...
}

10.3 零地址检查

require(to != address(0), "ERC1155: transfer to the zero address");

11. 实战代码示例

11.1 基于 OpenZeppelin 的完整实现

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract GameItems is ERC1155, Ownable {
    // 代币ID常量
    uint256 public constant GOLD = 0;
    uint256 public constant SILVER = 1;
    uint256 public constant WOOD = 2;
    uint256 public constant LEGENDARY_SWORD = 3;
    uint256 public constant EPIC_SHIELD = 4;

    // 跟踪每个ID的总供应量
    mapping(uint256 => uint256) private _totalSupply;

    constructor() 
        ERC1155("https://game.example/api/item/{id}.json") 
        Ownable(msg.sender) 
    {
        // 铸造初始物资
        _mint(msg.sender, GOLD, 10000 * 10**18, "");
        _mint(msg.sender, SILVER, 50000 * 10**18, "");
        _mint(msg.sender, WOOD, 100000, "");
        _mint(msg.sender, LEGENDARY_SWORD, 1, "");   // NFT:仅1个
        _mint(msg.sender, EPIC_SHIELD, 100, "");     // SFT:限量100个
    }

    /// @notice 管理员铸造
    function mint(
        address account, 
        uint256 id, 
        uint256 amount, 
        bytes memory data
    ) public onlyOwner {
        _mint(account, id, amount, data);
        _totalSupply[id] += amount;
    }

    /// @notice 批量铸造
    function mintBatch(
        address to,
        uint256[] memory ids,
        uint256[] memory amounts,
        bytes memory data
    ) public onlyOwner {
        _mintBatch(to, ids, amounts, data);
        for (uint256 i = 0; i < ids.length; i++) {
            _totalSupply[ids[i]] += amounts[i];
        }
    }

    /// @notice 用户自行销毁
    function burn(address account, uint256 id, uint256 amount) public {
        require(
            account == msg.sender || isApprovedForAll(account, msg.sender),
            "ERC1155: caller is not token owner or approved"
        );
        _burn(account, id, amount);
        _totalSupply[id] -= amount;
    }

    /// @notice 查询总供应量
    function totalSupply(uint256 id) public view returns (uint256) {
        return _totalSupply[id];
    }

    /// @notice 判断某 ID 是否存在(已铸造)
    function exists(uint256 id) public view returns (bool) {
        return _totalSupply[id] > 0;
    }
}

11.2 接收者合约示例

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
import "@openzeppelin/contracts/utils/introspection/ERC165.sol";

contract GameVault is IERC1155Receiver, ERC165 {
    
    // 记录存入的代币
    mapping(address => mapping(uint256 => uint256)) public deposits;
    
    function onERC1155Received(
        address operator,
        address from,
        uint256 id,
        uint256 value,
        bytes calldata data
    ) external override returns (bytes4) {
        deposits[from][id] += value;
        return this.onERC1155Received.selector;
    }

    function onERC1155BatchReceived(
        address operator,
        address from,
        uint256[] calldata ids,
        uint256[] calldata values,
        bytes calldata data
    ) external override returns (bytes4) {
        for (uint256 i = 0; i < ids.length; i++) {
            deposits[from][ids[i]] += values[i];
        }
        return this.onERC1155BatchReceived.selector;
    }

    function supportsInterface(bytes4 interfaceId) 
        public view override(ERC165, IERC165) returns (bool) 
    {
        return interfaceId == type(IERC1155Receiver).interfaceId
            || super.supportsInterface(interfaceId);
    }
}

12. 常见应用场景

12.1 游戏道具系统

Token ID 设计:
├── 0-999:       同质化资源(金币、木材、铁矿...)
├── 1000-9999:   半同质化道具(药水、箭矢、门票...)
└── 10000+:      非同质化装备(每件唯一的传说武器)

12.2 NFT 市场

  • 艺术家一次铸造多个版本(SFT 模式)
  • 市场合约通过 setApprovalForAll 获得转移权限
  • 批量上架/下架通过 safeBatchTransferFrom

12.3 DeFi 场景

  • LP Token 作为 ERC-1155(每个池子一个 ID)
  • 期权/债券到期日作为不同 ID
  • 多头寸管理(一个合约管理所有池子)

12.4 门票/凭证系统

  • 不同场次/席位用不同 ID
  • 同一场次同价位用相同 ID(SFT)
  • 批量分发 = 一笔交易

13. 总结

核心要点速记

#要点说明
1一合约多币单合约通过 id 管理无限种代币
2二维余额映射mapping(id => mapping(addr => balance))
3批量操作原生转账和查询都支持数组参数
4全局授权只有 setApprovalForAll,无单代币 approve
5接收者检查合约必须实现 Receiver 接口才能接收
6URI 模板{id} 替换为 64 位十六进制
7事件驱动所有状态变更必须有对应事件
8FT/NFT/SFT标准不区分,由应用层约定

学习路径建议

1. 理解数据结构 → 2. 阅读接口定义 → 3. 跑通 OpenZeppelin 示例
    → 4. 实现自定义铸造逻辑 → 5. 编写接收者合约
    → 6. 部署测试网验证 → 7. 添加 URI 元数据服务

参考资料

💬 评论