ERC-6909 代币标准是 ERC-1155 代币标准的精简替代方案。
ERC-1155 标准引入了一个多代币接口,允许单个智能合约包含同质化和非同质化代币(即 ERC20 和 ERC721)。
ERC-1155 解决了几个挑战,例如降低部署成本、最大限度地减少 Ethereum 区块链上的冗余字节码,以及简化多代币交易的代币授权。
然而,由于每次转账都强制要求回调、强制包含批量转账(batch transfer),以及缺乏对单操作员(single-operator)授权机制的细粒度控制,它引入了一些臃肿和 Gas 低效的问题。ERC-6909 通过消除合约级别的回调和批量转账,并将单操作员权限机制替换为混合(allowance-operator)权限机制以实现细粒度的代币管理,从而解决了这些缺点。
注意:
以下部分假定您熟悉 ERC-1155 标准及其概念。如果您不熟悉,请在继续阅读之前回顾该文章。
ERC-6909 与 ERC-1155 标准的对比
ERC-6909 移除了转账的回调要求
ERC-1155 规范要求 safeTransferFrom 和 safeBatchTransferFrom 检查接收账户是否为合约。如果是,它必须调用接收方合约账户上的 ERC1155TokenReceiver 接口函数(onERC1155Received、onERC1155BatchReceived),以检查其是否接受转账。
这些回调在某些情况下是有用的。但是,对于希望选择退出(opt out)此行为的接收方来说,它们是不必要的外部调用。回调会影响接收方合约账户的 Gas 成本和代码大小,因为它们要求实现多个回调(即通过 onERC1155Received、onERC1155BatchReceived)并返回神奇的 4 字节值才能接收代币。相比之下,ERC-6909 允许实现者自行决定其回调架构。
ERC-6909 省略了批量转账逻辑
批量转账虽然有时是有益的,但在 ERC-6909 标准中被故意省略了,以便开发者能够实现为特定执行环境量身定制的批量转账逻辑。开发者可以按照自己认为合适的方式实现批量转账,而不必仅仅为了遵循标准而添加额外的批量转账函数。
如下所示的 safeBatchTransferFrom 函数在 ERC-1155 标准中执行批量转账。然而,它的强制包含为不需要它们的应用增加了臃肿感:
// ERC-1155
function safeBatchTransferFrom(
address _from,
address _to,
uint256[] calldata _ids,
uint256[] calldata _values,
bytes calldata _data
) external;
这是 ERC-6909 的 transferFrom 函数。我们可以看到批量功能和 _data 参数已被消除。
// ERC-6909
function transferFrom(
address sender,
address receiver,
uint256 id,
uint256 amount
) public returns (bool) {
if (sender != msg.sender && !isOperator[sender][msg.sender]) {
uint256 senderAllowance = allowance[sender][msg.sender][id];
if (senderAllowance < amount) revert InsufficientPermission();
if (senderAllowance != type(uint256).max) {
allowance[sender][msg.sender][id] = senderAllowance - amount;
}
}
if (balanceOf[sender][id] < amount) revert InsufficientBalance();
balanceOf[sender][id] -= amount;
balanceOf[receiver][id] += amount;
emit Transfer(msg.sender, sender, receiver, id, amount);
return true;
}
ERC-6909 同时支持全局授权和细粒度额度
// in ERC-1155 →
function setApprovalForAll(
address _operator,
bool _approved
) external;
如上所示的 setApprovalForAll 函数是 ERC-1155 中的全局操作员模型,允许一个账户授权另一个账户代表其管理(作为操作员)所有代币 ID。一旦获得授权,操作员就可以不受限制地访问和转账授权账户拥有的任何代币 ID 的任意数量。
虽然这种方法简化了委托,但它缺乏细粒度的控制:
- 无法授予特定于单个代币 ID 或数量的权限。
- 这种要么全有要么全无的方法无法适应需要受控权限的场景。
为了引入细粒度控制,ERC-6909 的混合操作员权限机制结合了以下内容:
- 来自 ERC-1155 的操作员模型,
- 以及受 ERC-20 启发额度模型。
ERC-6909 中的操作员模型
在下面显示的 ERC-6909 setOperator 函数中,spender 变量被设置为操作员,并被授予全局权限以转账该账户拥有的所有代币 ID,而不受额度限制。
function setOperator(address spender, bool approved) public returns (bool) {
isOperator[msg.sender][spender] = approved;
emit OperatorSet(msg.sender, spender, approved);
return true;
}
ERC-6909 中的额度模型
额度模型引入了一个特定于代币和数量的控制系统,账户可以在其中为特定的代币 ID 设置有限的额度。
例如,Alice 可以使用接下来展示的 ERC-6909 中的 approve 函数,允许 Bob 转账 100 个单位的代币 ID 42,而不授予访问其他代币 ID 或无限数量的权限。
function approve(address spender, uint256 id, uint256 amount) public returns (bool) {
allowance[msg.sender][spender][id] = amount;
emit Approval(msg.sender, spender, id, amount);
return true;
}
approve 中的 spender 变量是一个被授权代表代币所有者转账特定代币 ID 的特定数量的账户。
例如,代币所有者可以允许 spender 转账 <= 100 个单位的特定代币 ID。或者,他们也可以通过将额度设置为 type(uint256).max 来为特定代币 ID 授予无限授权。
ERC-6909 未规定是否应扣除设置为 type(uint256).max 的额度。相反,这种行为由实现者自行决定,类似于 ERC-20。
核心数据结构
ERC-6909 实现使用三个映射来更新账户余额和授权的状态。
balanceOf:所有者某 ID 的余额
balanceOf 映射跟踪一个地址(owner)持有的特定代币 ID 的余额。映射中 owner => (id => amount) 的结构表明,单个所有者可以持有多种代币,并通过各自的 ID 跟踪其余额。
mapping(address owner => mapping(uint256 id => uint256 amount)) public balanceOf;
allowance:支出者某 ID 的额度
allowance 映射定义了支出者可以代表所有者转账多少特定代币(ID)。它有助于对代币支出进行细粒度控制。
mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) public allowance;
例如,allowance[0xDEF...][0x123...][5] 将返回所有者 0xDEF... 已允许支出者 0x123... 转账的代币(ID 为 5)的数量。
isOperator:操作员授权状态
mapping(address owner => mapping(address operator => bool isOperator)) public isOperator;
此映射跟踪支出者是否已被授权为某个地址拥有的所有代币的操作员。例如,如果允许地址 0xABC... 支出地址 0x123... 拥有的代币,则 isOperator[0x123...][0xABC...] 返回 true;否则返回 false。
核心 ERC-6909 函数及其数据参数
Transfer 函数
此规范不遵循在 ERC-721 和 ERC-1155 中看到的“安全转账机制”,因为该命名约定被认为具有误导性,因为它们会对任意合约进行外部调用。ERC-6909 使用 transfer 和 transferFrom 函数,详细信息如下。
transfer:
ERC-6909 transfer 函数的行为与 ERC-20 transfer 相同,只不过它适用于特定的代币 ID。该函数将接收者地址、代币 ID 和要转账的金额作为输入参数,并使用 balanceOf 映射更新余额。与 ERC-20 的 transfer 函数一样,如果交易成功执行,则必须返回 true。
//ERC-20 interface transfer function
function transfer(address _to, uint256 _value) public returns (bool)
// ERC-6909 transfer function Reference Implementation
// @notice Transfers an amount of an id from the caller to a receiver.
// @param receiver The address of the receiver.
// @param id The id of the token.
// @param amount The amount of the token.
function transfer(address receiver, uint256 id, uint256 amount) public returns (bool) {
if (balanceOf[msg.sender][id] < amount) revert InsufficientBalance(msg.sender, id);
balanceOf[msg.sender][id] -= amount;
balanceOf[receiver][id] += amount;
emit Transfer(msg.sender, msg.sender, receiver, id, amount);
return true;
}
transferFrom:
ERC-6909 的 transferFrom 函数与 ERC-20 的不同之处在于它需要代币 ID。此外,除了额度外,它还检查操作员授权。
该函数首先检查 if (sender != msg.sender && !isOperator[sender][msg.sender]),确保调用者(msg.sender)要么是:
- 所有者(
sender),或者 - 已获授权的操作员(
isOperator[sender][msg.sender] == true)。
如果 msg.sender 不是所有者或已获授权的操作员,该函数将检查调用者是否有足够的额度进行转账。如果存在额度但未设置为无限制(type(uint256).max),则从额度中扣除转账的 amount。
此外,标准规定如果调用者是操作员或 sender,该函数不应从调用者的代币 id 的 allowance 中减去 amount。
// ERC-6909 transferFrom
function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public returns (bool) {
if (sender != msg.sender && !isOperator[sender][msg.sender]) {
uint256 senderAllowance = allowance[sender][msg.sender][id];
if (senderAllowance < amount) revert InsufficientPermission();
if (senderAllowance != type(uint256).max) {
allowance[sender][msg.sender][id] = senderAllowance - amount;
}
}
if (balanceOf[sender][id] < amount) revert InsufficientBalance();
balanceOf[sender][id] -= amount;
balanceOf[receiver][id] += amount;
emit Transfer(msg.sender, sender, receiver, id, amount);
return true;
}
approve:
approve 函数允许调用者(msg.sender)向支出者授予特定代币(ID)的特定额度。这会更新 allowance 映射以反映新的额度,并触发 Approval 事件。
function approve(address spender, uint256 id, uint256 amount) public returns (bool) {
allowance[msg.sender][spender][id] = amount;
emit Approval(msg.sender, spender, id, amount);
return true;
}
setOperator:
setOperator 函数允许调用者(msg.sender)通过将 approved 参数设置为 true 或 false,为其代表的特定地址(spender)授予或撤销操作员权限。该函数相应地更新 isOperator 映射并触发 OperatorSet 事件,以将此更改通知外部监听器。
function setOperator(address spender, bool approved) public returns (bool) {
isOperator[msg.sender][spender] = approved;
emit OperatorSet(msg.sender, spender, approved);
return true;
}
ERC-6909 中的事件与日志
ERC-6909 定义了关键事件,用于跟踪多代币合约中的代币转账、授权和操作员权限。
1. Transfer 事件:
/// @notice The event emitted when a transfer occurs.
event Transfer(address caller, address indexed sender, address indexed receiver, uint256 indexed id, uint256 amount);
ERC-6909 中的 Transfer 事件用于跟踪代币变动,且必须在以下条件下触发:
- 当一定
amount的代币id从一个账户转账到另一个账户时,触发该事件。它记录了sender、receiver、代币 ID 以及转账的amount。 - 创建新代币时,必须以零地址(
0x0)作为sender触发该事件。 - 销毁代币时,必须以零地址(
0x0)作为接收者触发该事件,以表示代币的移除。
2. OperatorSet 事件:
/// @notice The event emitted when an operator is set.
event OperatorSet(address indexed owner, address indexed spender, bool approved);
每当所有者为另一个地址分配或撤销操作员权限时,都会触发 OperatorSet 事件。该事件记录所有者的地址、支出者的地址以及更新的授权状态(授予为 true,撤销为 false)。
3. Approval 事件:
/// @notice The event emitted when an approval occurs.
event Approval(address indexed owner, address indexed spender, uint256 indexed id, uint256 amount);
当所有者代表其自身设置或更新对支出者的授权,以转账给定代币 ID 的特定数量时,必须触发 Approval 事件。该事件记录 owner、spender、代币 id 和已授权的 amount。
既然我们已经探讨了 ERC-6909 和 ERC-1155 之间的差异,以及 ERC-6909 中的核心方法和事件,让我们来研究一下该标准的一些现实用例。
Uniswap v4 PoolManager 如何实现 ERC-6909。
在 Uniswap v3 中,工厂/流动性池模型通过使用 UniswapV3Factory 合约为每个流动性池部署单独的合约来创建新的代币对。这种方法增加了 Gas 成本,因为每个新池都需要部署新合约。
相比之下,Uniswap v4 引入了一个单例合约(PoolManager.sol),该合约将所有流动性池作为其内部状态的一部分进行管理,而不需要单独部署合约。这种设计显著降低了创建池的 Gas 成本。
此外,在以前的版本中,涉及多个 Uniswap 池的交易需要在多个合约之间进行代币转账和冗余的状态更新。在 Uniswap v4 中,PoolManager 合约可以集中持有用户 ERC-20 代币的 ERC-6909 表示,而不是将 ERC-20 代币转入和转出池中。
例如,如果用户为代币 A 提供流动性,他们稍后可以选择提取他们的份额,并通过 ERC-20 转账将代币 A 接收到他们的钱包中。然而,如果他们选择将代币留在协议内,Uniswap v4 的 PoolManager 可以向 LP 铸造其代币余额的 ERC-6909 表示,而不是将 ERC-20 代币转出合约——从而节省了跨合约调用。这些 ERC-6909 余额允许用户在协议内进行交易或交互,而无需在钱包之间移动代币。
这意味着当用户稍后将代币 A 兑换为代币 B 时,Uniswap 不必从他们的钱包中转账 ERC-20 代币,只需在池中更新其 ERC-6909 余额即可。
注意:在 Uniswap V4 中,ERC-6909 并未被用作 LP 代币。
针对单例 DeFi 架构和 NFT 集合的 ERC-6909 元数据注意事项
以下是 IERC6909Metadata 接口,它定义了 ERC-6909 标准如何将名称、符号和小数位数等元数据与单个代币相关联。
请注意,下面的 name、symbol 和 decimals 函数可以根据 id 发生变化,允许 ERC-6909 中的不同代币具有不同的名称、符号和小数位数。
/// @notice Contains metadata about individual tokens.
interface IERC6909Metadata is IERC6909 {
/// @notice Name of a given token.
/// @param id The id of the token.
/// @return name The name of the token.
function name(uint256 id) external view returns (string memory);
/// @notice Symbol of a given token.
/// @param id The id of the token.
/// @return symbol The symbol of the token.
function symbol(uint256 id) external view returns (string memory);
/// @notice Decimals of a given token.
/// @param id The id of the token.
/// @return decimals The decimals of the token.
function decimals(uint256 id) external view returns (uint8);
}
对于 DeFi 协议,我们可能有几个 LP 代币,我们可能希望将它们标准化为具有相同数量的小数位数,例如 18。但是,我们可能希望名称和符号能够反映池中持有的不同资产。
相反,对于 NFT,decimals 值应始终设置为 1,因为 NFT 是不可分割的。
在典型的 NFT 集合(例如 ERC-721)中,所有代币共享相同的名称和符号,以代表整个集合(例如,“CryptoPunks”的符号为“PUNK”)。ERC-6909 允许我们遵循 ERC-712 约定,其中集合中的所有 NFT 共享相同的元数据。
ERC-6909 非同质化代币的实现示例。
ERC-6909 规范并未明确定义支持非同质化代币的独特方法。然而,ERC-1155 规范中描述的 ID 位拆分技术可用于在 ERC-6909 中实现非同质化代币。这种方法使用位移和加法操作将集合 ID 和项目 ID 编码为单个 uint256 代币 ID。
function getTokenId(uint256 collectionId, uint256 itemId) public pure returns (uint256) {
return (collectionId << 128) + itemId;
}
下面的 ERC6909MultiCollectionNFT 合约是一个非同质化代币(NFT)实现示例,它使用 getTokenId 从 collectionId 和 itemId 生成代币 ID。
mintNFT 函数确保每个 tokenId 只能被铸造一次,而与地址无关。它使用 mintedTokens 映射全局跟踪 NFT tokenId 是否已被铸造。
由于 amount 变量在 mintNFT 中被设置为 1,该函数中的 _mint(to, tokenId, amount) 调用将仅为一个 tokenId 铸造一个副本。在任何 amount > 1 的情况下,代币将变成同质化而非非同质化。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./ERC6909.sol";
contract ERC6909MultiCollectionNFT is ERC6909 {
struct NFT {
string uri;
}
mapping(uint256 => NFT) private _tokens;
mapping(uint256 => string) private _collectionURIs;
mapping(uint256 => bool) public mintedTokens;
event MintedNFT(address indexed to, uint256 indexed collectionId, uint256 indexed itemId, uint256 tokenId, string uri);
// Compute Token ID by concatenating collectionId and itemId
function getTokenId(uint256 collectionId, uint256 itemId) public pure returns (uint256) {
return (collectionId << 128) + itemId;
}
function _mint(address to, uint256 tokenId, uint256 amount) internal {
balanceOf[to][tokenId] += amount;
emit Transfer(msg.sender, address(0), to, tokenId, amount);
}
function mintNFT(address to, uint256 collectionId, uint256 itemId, string memory uri) external {
uint256 amount = 1;
uint256 tokenId = getTokenId(collectionId, itemId);
require(!mintedTokens[tokenId], "ERC6909MultiCollectionNFT: Token already minted");
require(amount == 1, "ERC6909MultiCollectionNFT: Token copies must be 1");
_tokens[tokenId] = NFT(uri);
mintedTokens[tokenId] = true; // Mark as minted
_mint(to, tokenId, amount); // amount is defined as 1.
emit MintedNFT(to, collectionId, itemId, tokenId, uri);
}
function nftBalanceOf(address owner, uint256 tokenId) public view returns (uint256) {
return balanceOf[owner][tokenId];
}
}
回想一下,上面 mintNFT 中的 _mint 调用会将 balanceOf 映射更新为 1,因为这些铸造完全是非同质化的。因此,如果 owner 地址确实铸造了 tokenId,则预计此合约中的 nftBalanceOf 函数将始终返回 1。
为了转账代币所有权,下面的 nftTransfer 函数通过在允许转账唯一现存单位之前验证其次余额,确保只有 NFT 所有者才能发起转账。
function nftTransfer(address to, uint256 tokenId) external {
require(balanceOf[tokenId][msg.sender] == 1, "ERC6909MultiCollectionNFT: This should be non-fungible.");
require(to != address(0), "ERC6909MultiCollectionNFT: transfer to zero address");
transfer(to, tokenId, 1);
// the amount in this case is equal to 1.
emit Transfer(msg.sender, address(0), to, tokenId, 1);
}
ERC-6909 内容 URI 扩展与元数据 URI JSON 模式
为了在 ERC-6909 中标准化元数据访问,可选的 IERC6909ContentURI 接口定义了两个 URI 函数(contractURI 和 tokenURI),用于检索合约和代币级别的元数据。ERC-6909 标准并不强制要求代币具有与之关联的 URI 元数据。但是,如果实现中包含了这些 URI 函数,则返回的 URI 应该指向符合 ERC-6909 元数据 URI JSON 模式的 JSON 文件。
pragma solidity ^0.8.19;
import "./IERC6909.sol";
/// @title ERC6909 Content URI Interface
interface IERC6909ContentURI is IERC6909 {
/// @notice Contract level URI
/// @return uri The contract level URI.
function contractURI() external view returns (string memory);
/// @notice Token level URI
/// @param id The id of the token.
/// @return uri The token level URI.
function tokenURI(uint256 id) external view returns (string memory);
}
如上面代码所示,ERC-6909 IERC6909ContentURI 接口定义了两个可选的 URI 函数,即 contractURI 和 tokenURI;每个都有其对应的 URI JSON 模式。
contractURI 函数(不接受任何参数)返回指向合约级别元数据的单个 URI,而 tokenURI() 返回特定于每个代币 ID 的 URI。
下面是有关如何构建合约 URI JSON 模式的示例,如 ERC-6909 标准所规定的那样。
{
"title": "Contract Metadata",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the contract."
},
"description": {
"type": "string",
"description": "The description of the contract."
},
"image_url": {
"type": "string",
"format": "uri",
"description": "The URL of the image representing the contract."
},
"banner_image_url": {
"type": "string",
"format": "uri",
"description": "The URL of the banner image of the contract."
},
"external_link": {
"type": "string",
"format": "uri",
"description": "The external link of the contract."
},
"editors": {
"type": "array",
"items": {
"type": "string",
"description": "An Ethereum address representing an authorized editor of the contract."
},
"description": "An array of Ethereum addresses representing editors (authorized editors) of the contract."
},
"animation_url": {
"type": "string",
"description": "An animation URL for the contract."
}
},
"required": ["name"]
}
另一方面,tokenURI 函数接收代币的 uint256 参数 id 并返回代币 URI。如果代币 id 不存在,该函数可能会 revert。与合约交互的客户端必须将 URI 中出现的每个 {id} 替换为实际的代币 ID,以访问与该代币关联的正确元数据。
下面是 tokenURI 函数的实现,它返回一个遵循占位符格式的静态 URI 模板:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "./ERC6909.sol";
import "./interfaces/IERC6909ContentURI.sol";
contract ERC6909ContentURI is ERC6909, IERC6909ContentURI {
/// @notice The contract level URI.
string public contractURI;
/// @notice The URI for each id.
/// @return The URI of the token.
function tokenURI(uint256) public pure override returns (string memory) {
return "<baseuri>/{id}";
}
}
这是按照 ERC-6909 标准规定的有关如何构建 URI JSON 模式的示例。
{
"title": "Asset Metadata",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Identifies the token"
},
"description": {
"type": "string",
"description": "Describes the token"
},
"image": {
"type": "string",
"description": "A URI pointing to an image resource."
},
"animation_url": {
"type": "string",
"description": "An animation URL for the token."
}
},
"required": ["name", "description", "image"]
}
ERC-6909 规范中 Allowance 与 Operator 的歧义
想象一个场景,账户(A)将操作员权限授予另一个账户(B),并且还为 B 设置了转账特定数量代币的额度。
如果 B 代表 A 发起转账,则实现必须确定正确的检查顺序以及额度如何与操作员权限相互作用。
歧义在于检查的顺序。合约应该:
- 首先检查额度,如果额度不足则 revert,即使 B 拥有操作员权限。
- 首先检查操作员权限,允许转账继续进行而无视额度。
在下面的 allowanceFirst 合约中,如果账户 B 拥有操作员权限但额度不足,则额度检查将失败,导致交易 revert。这可能有违直觉,因为操作员权限通常意味着不受限制的访问,用户可能会期望交易成功。
相反,在 operatorFirst 合约中,如果实现首先检查操作员权限,它将绕过额度检查,并且基于操作员的不受限制访问,交易将成功。
contract operatorFirst {
function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public {
// check if `isOperator` first
if (msg.sender != sender && !isOperator[sender][msg.sender]) {
require(allowance[sender][msg.sender][id] >= amount, "insufficient allowance");
allowance[sender][msg.sender][id] -= amount;
}
// -- snip --
}
}
contract allowanceFirst{
function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public {
// check if allowance is sufficient first
if (msg.sender != sender && allowance[sender][msg.sender][id] < amount) {
require(isOperator[sender][msg.sender], "insufficient allowance");
}
// ERROR: when allowance is insufficient, this panics due to arithmetic underflow, regardless of
// whether the caller has operator permissions.
allowance[sender][msg.sender][id] -= amount;
// -- snip --
}
}
该标准故意对权限检查的决定不加约束,赋予实现者选择的灵活性。当一个账户同时拥有操作员权限但额度不足时,转账行为取决于执行检查的顺序。
结论
ERC-6909 标准通过移除批量处理和 transfer 函数中的强制回调,显著提高了 ERC-1155 的效率。移除批量处理允许进行具体情况具体分析的优化,特别是对于 Rollup 或对 Gas 敏感的环境。
它还通过混合操作员权限机制引入了对代币授权的可扩展控制。
致谢
我们要感谢 vectorized、jtriley 和 neodaoist(ERC-6909 的合著者)对本文的审阅。