Understanding ERC-6551: Non-Fungible Token Bound Accounts Explained

Understanding ERC-6551: Non-Fungible Token Bound Accounts Explained

Recently, there has been a lot of buzz around ERC-6551, so let's talk about what ERC-6551 is exactly. In this article, I would like to explain ERC-6551 from its principles, code implementation, and application cases. I believe that after reading this blog, everyone should have a comprehensive understanding of ERC-6551.

What is ERC-6551?

In simple terms, ERC-6551 is a standard for creating wallets for NFTs. What does this mean?

Let's consider a scenario where in a game, my address A owns a character named Bob. This character itself is an ERC721 NFT and it also has various items such as hats, shoes, weapons, etc., as well as some assets like gold coins which are tokens of types ERC20 or ERC721. These items and assets belong to my character Bob in the game logic but in the actual underlying contract implementation, they all belong to my address A. If I want to sell my character Bob to someone else, I would need to transfer each item and asset individually to the buyer. This is not very logical or practical.

The purpose of the ERC-6551 standard is to create a wallet for the character Bob so that all the items it owns are attributed specifically to that character. This makes much more sense. We can take a look at the example diagram provided in the official EIP (Ethereum Improvement Proposal) here.

The user account has two NFTs, namely A#123 and B#456. Among them, A#123 has two accounts (A and B), and B#456 has one account (C).

Now let's take a look at the right side of the diagram. The 6551 protocol provides a Registry contract, which is used to create NFT wallets. By calling its createAccount function, we can create a contract wallet for that NFT. Since contract wallets can be written in various ways, this function requires us to provide an Implementation for the wallet. In other words, we can customize the contract of the wallet, such as AA Wallet or Safe Wallet. The diagram also mentions the keyword proxies, which means that when creating a wallet, the Registry creates a proxy for the Implementation instead of copying it exactly as it is. This is done to save gas fees. This part applies knowledge from EIP-1167; if you are unfamiliar with it, you can refer to my previous article here.

In EIP-6551, the wallet created for NFT is referred to as a token-bound account. One important feature of this is that it can be backward compatible with existing standard NFT contracts deployed on the blockchain.

The wallets we usually use, whether they are externally owned accounts (EOA) or contract wallets, have assets owned by the wallets themselves. However, in an NFT account, the NFT itself does not own any assets; instead, it possesses a contract wallet which serves as the entity owning the assets. In other words, the NFT itself plays a role more similar to that of a person. A diagram would provide a clearer illustration:

Code implementation

The ERC-6551 standard suggests Registry implement the  IERC6551Registry interface:

interface IERC6551Registry {
/// @dev The registry SHALL emit the AccountCreated event upon successful account creationeventAccountCreated(
        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 accountfunctioncreateAccount(
        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 accountfunctionaccount(
        address implementation,
        uint256 chainId,
        address tokenContract,
        uint256 tokenId,
        uint256 salt
    ) external view returns (address);
}

The createAccount function is used to create a contract wallet for NFTs, while the account function is used to calculate the address of the contract wallet. Both functions utilize the [create2](<https://eips.ethereum.org/EIPS/eip-1014>) mechanism, allowing them to return a deterministic address.

The official ERC-6551 standard has provided a deployed Registry implementation for reference. The function getCreationCode is worth paying attention to:

functiongetCreationCode(
    address implementation_,
    uint256 chainId_,
    address tokenContract_,
    uint256 tokenId_,
    uint256 salt_
) internal pure returns (bytes memory) {
// Concatenate salt, chainId, tokenContract, and tokenId to the end of the bytecode and return it.
        abi.encodePacked(
            hex"3d60ad80600a3d3981f3363d3d373d3d3d363d73",
            implementation_,
            hex"5af43d82803e903d91602b57fd5bf3",
            abi.encode(salt_, chainId_, tokenContract_, tokenId_)
        );
}

It is a function used to assemble proxy bytecode. It can be seen that at the end, data such as salt_, chainId_, tokenContract_, and tokenId_ are concatenated after the EIP-1167 proxy bytecode. The purpose here is to directly read these data through bytecode in the created contract wallet. A similar approach was also used in the SudoSwap code we previously studied, you can refer to it here.

For the created NFT wallet contract (i.e., Implementation contract), ERC-6551 has also made some requirements:

  • It should be created through Registry.
  • Must implement the ERC-165 interface.
  • Must implement the ERC-1271 interface.
  • Must implement the following IERC6551Account interface:
/// @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 callfunctionexecuteCall(
        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 tokenfunctiontoken()
        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 accountfunctionowner() external view returns (address);

/// @dev Returns a nonce value that is updated on every successful transaction////// @return The current account noncefunctionnonce() external view returns (uint256);
}

This is an implemented contract wallet example.

Case study

Sapienz NFT series uses the ERC-6551 standard. Let's take a look at how it is applied with ERC-6551, the code can be found here.

Check out the _mintWithTokens function, which allows users to mint Sapienz NFT by using certain whitelisted NFTs (such as PIGEON). The logic behind this function is that it transfers the whitelisted NFTs owned by the user into the contract wallet associated with the upcoming Sapienz NFT (i.e., token-bound accounts), and then mints Sapienz NFT for the user. In other words, these transferred whitelisted NFTs belong to the generated Sapienz NFT.

Let's take a look at the main code:

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];

// Calculate the address corresponding to all tba for the current tokenId (which is the tokenId of the current Sapienz contract, not the passed-in tokenId). The address tba = erc6551Registry.account(
            erc6551AccountImplementation,
            block.chainid,
            address(this),
            startTokenId + i,
            0
        );

// ....// Transfer the whitelist NFT#tokenId from msg.sender to tba using IERC721Upgradeable(tokenAddress).safeTransferFrom(
            msg.sender,
            tba,
            tokenId
        );
    }

_safeMint(recipient, quantity);
}

In the previous IERC6551Registry interface, we can see that the account function is used to calculate the generated contract wallet address. In other words, tba in the code represents the address of the contract wallet. Later on, using the safeTransferFrom function of tokenAddress, the NFT whitelist will be transferred into tba.

We noticed that in the code, only the contract address for tba was calculated but there was no deployment of a contract. This means that the target address for this transfer operation may be an address without actual deployed code; it is just a reserved address. For example, this tba owns NFTs but does not have any contract code (although there may have been changes by now when you see it). If we want this address to have a contract code, we need to call Registry's createAccount function to deploy a contract. For example, this tba is already a deployed contract address:

The bytecode is composed of two parts. The first half is the concatenated bytecode of EIP-1167, and the shaded part at the end is the data concatenated from salt_, chainId_, tokenContract_, and tokenId_ in the previously mentioned getCreationCode function.

Summary

In this blog, we have learned ERC-6551 through principles, code implementation, and practical case studies. Its content itself is not complicated and provides a new approach to creating NFT wallets. I think its most user-friendly aspect is that it can be forward-compatible with NFTs without being intrusive. This means that NFTs themselves do not need to concern themselves with the logic of 6551; they just need to develop according to their own business.

Reference

ERC-6551: Non-fungible Token Bound Accounts
An interface and registry for smart contract accounts owned by ERC-721 tokenseips.ethereum.org