最近 ERC-6551 的热度很高,我们就来聊聊 ERC-6551 到底是什么。这篇文章我想从它的原理,代码实现,以及应用案例来讲解 6551。相信看完这篇文章后大家应该会对 6551 有一个比较全面的认识。
简单来说,6551 是一个为 NFT 创建钱包的标准。这是什么意思呢?
我们考虑一个场景,假设在游戏中,我的地址 A 拥有一个角色 Bob,这个角色它本身是一个 ERC721 的 NFT,同时它的身上也有许多道具,例如帽子,鞋子,武器等,也可能拥有一些资产例如金元宝等。这些资产本身也是 ERC20,ERC721 等类型的 token。这些道具和资产在游戏逻辑中是属于我的角色 Bob 的,但是在实际的底层合约实现中,其实它们都是属于我的地址 A 的。如果我想将我的角色 Bob 出售给别人,我就需要分别将 Bob 还有它所拥有的所有资产都一一转账给买家。这在逻辑和操作上都不是很合理。
6551 标准的目的就是为角色 Bob 创建一个钱包,使得它所拥有的道具都是属于角色 Bob 的,这样看起来就合理多了。我们可以看看官方 EIP 里给到的示例图:
用户账户中拥有两个 NFT,分别是 A#123 和 B#456,其中 A#123 拥有两个账户( A 和 B),B#456 拥有一个账户(C)。
我们再来看看图中右边这一块是什么意思。6551 协议中提供了一个 Registry
合约,它是用来创建 NFT 钱包的合约,通过调用它的 createAccount
函数就可以对该 NFT 创建一个合约钱包。由于合约钱包的写法可以各式各样,因此该函数需要我们提供一个合约钱包的 Implementation。也就是说我们可以自定义钱包的合约,例如可以是 AA 钱包,可以是 Safe 钱包。图中还有 proxies
关键字,这表示 Registry
在创建钱包的时候是创建了 Implementation 的代理,而不是原模原样将其复制了一份,这样做的目的是为了节省 Gas。这块应用到了 EIP-1167 的知识,对其不熟悉的朋友可以看看我之前的文章。
6551 EIP 中把为 NFT 创建的钱包称为 token bound accounts。它有一个很重要的特性是可以向前兼容 NFT,对于之前链上已经部署的标准 NFT 合约,都可以兼容 6551。
我们平时使用的钱包,不论是 EOA 还是合约钱包,都是钱包本身拥有资产。但在 NFT 账户中,NFT 本身是不拥有资产的,而是它拥有一个合约钱包,该钱包是拥有资产的主体。也就是说 NFT 本身更类似于人
的角色。用图说明应该更清晰一点:
6551 标准建议 Registry
实现 IERC6551Registry
接口:
interface IERC6551Registry {
/// @dev The registry SHALL emit the AccountCreated event upon successful account creation
event AccountCreated(
address account,
address implementation,
uint256 chainId,
address tokenContract,
uint256 tokenId,
uint256 salt
);
/// @dev Creates a token bound account for an ERC-721 token.
///
/// If account has already been created, returns the account address without calling create2.
///
/// If initData is not empty and account has not yet been created, calls account with
/// provided initData after creation.
///
/// Emits AccountCreated event.
///
/// @return the address of the account
function createAccount(
address implementation,
uint256 chainId,
address tokenContract,
uint256 tokenId,
uint256 salt,
bytes calldata initData
) external returns (address);
/// @dev Returns the computed address of a token bound account
///
/// @return The computed address of the account
function account(
address implementation,
uint256 chainId,
address tokenContract,
uint256 tokenId,
uint256 salt
) external view returns (address);
}
createAccount
函数用来为 NFT 创建合约钱包,account
函数用来计算合约钱包地址。它们都利用了 create2 机制,因此可以返回一个确定的地址。对 create2 不了解的朋友可以看我之前的文章。
6551 官方给出了一个已经部署的 Registry
实现可供参考。
其中的 getCreationCode
函数值得留意一下:
function getCreationCode(
address implementation_,
uint256 chainId_,
address tokenContract_,
uint256 tokenId_,
uint256 salt_
) internal pure returns (bytes memory) {
// 将 salt, chainId, tokenContract, tokenId 拼接在 bytecode 后面
return
abi.encodePacked(
hex"3d60ad80600a3d3981f3363d3d373d3d3d363d73",
implementation_,
hex"5af43d82803e903d91602b57fd5bf3",
abi.encode(salt_, chainId_, tokenContract_, tokenId_)
);
}
它是用来组装 proxy 字节码的函数,可以看到在最后将 salt_
、chainId_
、tokenContract_
、tokenId_
等数据拼接在了 EIP-1167 的代理字节码后面。这里的目的是为了在创建的合约钱包中可以直接通过字节码读取到这些数据,我们之前学习过的 SudoSwap 代码中也使用了类似的写法,参考这里。
对于创建的 NFT 钱包合约(即 Implementation 合约),6551 也作出了一些要求:
应该通过 Registry 创建
必须实现 ERC-165
接口
必须实现 ERC-1271
接口
必须实现下面的 IERC6551Account
接口:
/// @dev the ERC-165 identifier for this interface is `0x400a0398`
interface IERC6551Account {
/// @dev Token bound accounts MUST implement a `receive` function.
///
/// Token bound accounts MAY perform arbitrary logic to restrict conditions
/// under which Ether can be received.
receive() external payable;
/// @dev Executes `call` on address `to`, with value `value` and calldata
/// `data`.
///
/// MUST revert and bubble up errors if call fails.
///
/// By default, token bound accounts MUST allow the owner of the ERC-721 token
/// which owns the account to execute arbitrary calls using `executeCall`.
///
/// Token bound accounts MAY implement additional authorization mechanisms
/// which limit the ability of the ERC-721 token holder to execute calls.
///
/// Token bound accounts MAY implement additional execution functions which
/// grant execution permissions to other non-owner accounts.
///
/// @return The result of the call
function executeCall(
address to,
uint256 value,
bytes calldata data
) external payable returns (bytes memory);
/// @dev Returns identifier of the ERC-721 token which owns the
/// account
///
/// The return value of this function MUST be constant - it MUST NOT change
/// over time.
///
/// @return chainId The EIP-155 ID of the chain the ERC-721 token exists on
/// @return tokenContract The contract address of the ERC-721 token
/// @return tokenId The ID of the ERC-721 token
function token()
external
view
returns (
uint256 chainId,
address tokenContract,
uint256 tokenId
);
/// @dev Returns the owner of the ERC-721 token which controls the account
/// if the token exists.
///
/// This is value is obtained by calling `ownerOf` on the ERC-721 contract.
///
/// @return Address of the owner of the ERC-721 token which owns the account
function owner() external view returns (address);
/// @dev Returns a nonce value that is updated on every successful transaction
///
/// @return The current account nonce
function nonce() external view returns (uint256);
}
这里是一个已经实现的合约钱包示例。
Sapienz NFT 系列使用了 ERC 6551 标准,我们来看看它是怎样应用 6551 的,代码在此。
我们主要来看其中的 _mintWithTokens
函数,它的作用是,用户需要使用一些白名单的 NFT(例如 PIGEON)来 mint Sapienz NFT。具体逻辑是,函数将用户拥有的这些白名单的 NFT 转入到即将生成的 Sapienz NFT 对应的合约钱包中(即 token bound accounts),然后再为用户 mint Sapienz NFT。也就是说这些转入的白名单 NFT 是归属于生成的 Sapienz NFT 的。
我们来看看主要代码:
function _mintWithTokens(
address tokenAddress,
uint256[] memory tokenIds,
bytes32[][] memory proof,
address recipient
) internal {
// ...
uint256 quantity = tokenIds.length;
for (uint256 i = 0; i < quantity; i++) {
uint256 tokenId = tokenIds[i];
// 计算出 当前 tokenId(是当前 Sapienz 合约的 tokenId,不是传入的 tokenId)所有对应 tba 的地址
address tba = erc6551Registry.account(
erc6551AccountImplementation,
block.chainid,
address(this),
startTokenId + i,
0
);
// ....
// 将白名单 NFT#tokenId 从 msg.sender 转移到 tba 中
IERC721Upgradeable(tokenAddress).safeTransferFrom(
msg.sender,
tba,
tokenId
);
}
_safeMint(recipient, quantity);
}
我们在前面的 IERC6551Registry
接口中看到,account
函数是用来计算生成的合约钱包地址的,即代码中的 tba
就是合约钱包的地址,然后在后面会通过 tokenAddress
的 safeTransferFrom
函数将白名单 NFT 转入 tba 中。
我们注意到,代码中仅仅是计算出了 tba 的合约地址,但是并没有部署合约这一步操作,也就是说,这里的转入操作的目标地址可能是一个没有实际部署代码的地址,仅仅是一个预留地址而已。例如这个 tba,它是拥有 NFT 的,但是却并没有合约代码(不过在你看到的时候可能已经发生了状态变化)。如果我们希望该地址是有合约代码的话,需要调用 Registry 的 createAccount
函数来创建部署合约。例如这个 tba 就是已经部署的合约地址。
其中字节码的前半部分是 EIP-1167 的拼接字节码,后面的阴影部分是我们前面提到的 getCreationCode
函数中拼接的salt_
、chainId_
、tokenContract_
、tokenId_
数据。
我们通过原理,代码实现和实际案例学习了 ERC-6551,它本身内容并不复杂,提供了一个创建 NFT 钱包的新思路。我觉得它最友好的一点就是可以向前兼容 NFT,没有侵入性,这样使得 NFT 本身也不用去关心 6551 的逻辑,只要按照自己的业务开发就行。
欢迎和我交流