ERC-6551: Non-fungible Token Bound Accounts

  1. How to define a unified function call interface is a common problem for all smart contract wallets. Since the Account Abstraction is getting its momentum, I believe it deserves its own EIP and we should not mix it with this one. e.g. The sample implementation of smart account for account abstraction is called execute and executeBatch. We will need a more comprehensive/unified API set(e.g. the batch one) for smart contract wallets instead of a simple executeCall function.

  2. Removing owner function will not make it in-compatible with existing infrastructure since they can always implement their own owner function. We can suggest them to implement the owner function in the reference implementation but enforcing all implementations to have this function doesnā€™t make sense to me. Also ERC-4337 are actually intentionally avoiding the specific ā€œownerā€ function and uses verifyUserOp() function to do the ownership verification.

  3. A more common practice is to put chainId at the spec but not as explicit function params. Again, taking ERC-4337 as example, per the spec, the signature of UserOps should include chainId and the entry point contract, but itā€™s just in the spec and should not be reflected in the interface definition. For cross-chain application/wallets integration, it could be unified in a higher level(e.g. SDK) instead of hardcoding the chainId into the smart contract interfaces.

  4. I donā€™t have a preference for this one but I think these two are equivalent. Putting implementation as the function params assumes that there is only one global registry contract for all implementations, while extracting it out as independent view function assumes each implementation will have its own registry contract(just like ERC20). Iā€™m not sure which one is better, but my hunch is that the second approach will be easier for dApps who only care one implementation to integrate.

2 Likes
  1. ERC-6551 accounts are welcome to implement additional execution functions if they see fit to do so, the common interface does not restrict that. Agreed that there is a great opportunity to standardize the interface for these execution calls! Iā€™m fine with execute as the common interface function.
  2. Agreed that this is a long-tail edge case, as it would only apply to dapps that are no longer actively maintained (therefore will not support the proposal) and are also using owner calls as authorization.
  3. Would love to hear any recommendations you have to ensure the possibility of cross-chain support without including the chain ID in the creation params!
  4. With the current proposal, dapps wishing to support a single registry are welcome to do so at the client layer, or create their own registry wrapper contract which uses a single known implementation. The reverse is not true - if the proposal specified a single registry with a known implementation or multiple registries with a single implementation per registry, it would be impossible to build a single canonical registry. This exact tradeoff was explored at length during the development of this proposal, and it was determined that a single global registry contract would present the easiest path towards ecosystem adoption.
1 Like

1 & 2: One principle we should follow here is to minimize the required functions to cover the core functionalities and leave the rest to community, thatā€™s why I insist we should remove the owner and executeCall function and leave it to some other EIPs. As I said, we can always put it in the reference implementation and encourage others to follow but there is no need to enforce it in the spec

3: I just realize that I donā€™t fully understand the ā€œcross-chain compatibilityā€ problem you are referring here? What exact problems are you trying to solve or avoid by specifying this chain id field in function params? (Iā€™m expecting users will still use chain id when creating the account(since itā€™s required by the spec) but there is no need to put it in the function params, so I didnā€™t see why my proposal will undermine the cross-chain compatibility.)

4: For the registry part, I think you made a good point that a global registry is much easier to aggregate than a list of registry contracts if you want to index all implementations. Iā€™m thinking how the ecosystem can benefit from a canonical registry besides easy indexing though, since Iā€™m expecting there will be multiple registry contracts out there anyway even we unify the interface for all implementations.

1 Like
  1. I definitely agree with you in principle here, but think providing a unified way for callers to execute transactions and query for the owner(s) of an account is important functionality that should be included in the interface. Especially since these are likely to be two of the most common calls executed against accounts. Without a standard interface defined in the proposal, how would you suggest that clients wishing to adopt this proposal should perform these calls across all accounts given varying implementations?

  2. In the specific case of owner, having the function defined on the interface means that an external caller given only an account address doesnā€™t need to know that the address is a 6551 account in order to determine the account owner. Iā€™m very open to other interfaces for this, but this seems like an attractive property and Iā€™m curious how you would recommend handling this case without defining something on the interface?

  3. Assuming NFT #1 has account 0xabc... on a chain with ID 1, the only way for NFT #1 to also use 0xabc... on any other chain is by passing the chain ID as a parameter to the account creation call on the registry contract, which is deployed to the same address on both chains. Without this, how could NFT #1 use their unique address across all chains?

  4. The other major benefit of a canonical registry is that every NFT gets a canonical account address per implementation which can be used prior to contract creation. By allowing any implementation to be deployed via the canonical registry, the need for alternative registries is minimized.

1& 2: Just to clarify, I didnā€™t say we donā€™t need to unify the interface, my point is that itā€™s out of the scope of this EIP since it should be a common interface for all smart contract wallets. Not sure if there is any other EIPs addressing the issue, if there is, we can follow that. It does make it easier for dApps to integrate 6551 accounts with these functions. However, in the long run, if the interface of other smart contract wallets is unified in another way, dApps will be have to maintain two set of interfaces(Ideally dApps should be able to integrate 6551 accounts just like all other smart contract wallets.) Defining the general interface of smart contract wallets is a bigger topic which we can discuss it in other threads.

3: Just curious, why do we need a bound account for NFT at chain 1 for chain 2? In my understanding, the ownership is guaranteed by the ERC721 contract so there is no way to guarantee the ownership at chain 2 if the NFT contract is not deployed at chain 2. Also if the NFT contract is deployed at chain 2 with a different address, then the bound account address will also be different.

4: Minimize the need for alternative registry makes sense to me. My concern is that if we cannot eliminate the need, eventually dApps will have to maintain a list of registry contracts, which brings us back to the original situation. However, again, I donā€™t have a strong preference for this one, so either one lgtm

1&2: I agree! Would happily support a proposal for a cohesive execution interface across smart contract accounts. In the absence of a recommended existing proposal or the emergence of a new proposal for this purpose, I think these functions should remain in the interface for this proposal for now. It can always be updated prior to last call if an alternative proposal emerges. Account implementations can also be updated to support a new standard that emerges, so I donā€™t see a conflict here.

3: Youā€™re correct that there is no explicit guarantee of ownership of an NFT across chains, so it would be up to wallet implementations to determine if they would like to support cross-chain execution. This proposal includes chain ID as a parameter to the account creation call in order to leave open this possibility. One potential use case here is allowing a given NFT to collect POAPs (which are limited to use on a single chain).

If an NFT contract is deployed at the same address across chains, they will be distinct tokens (as a token with the same ID across chains can have two separate owners) and should have distinct wallet addresses. This proposal essentially treats an account address as a meta-asset that an NFT can own across all chains if the chosen wallet implementation supports it.

A few new updates to the proposal based on feedback that has been received so far:

  • The Registry now deploys ERC-1967 proxies instead of ERC-1167 proxies. This change increases compatibility with existing proxy detection infrastructure since ERC-1967 proxies can be detected by reading a storage slot rather than pattern matching against bytecode. This change also allows wallet implementations wishing to implement upgradability to do so without requiring an intermediate proxy.
  • The Registry contract is now deployed via the create2 factory at 0x4e59b44847b379578588920cA78FbF26c0B4956C. This allows for wider deployment compatibility with EVM chains that strictly enforce EIP-155, which breaks the deployment method that was used previously.
  • Added a salt parameter to the createAccount function, allowing tokens to have multiple token bound accounts per implementation
  • Added an initData parameter to the createAccount function, allowing for optional initialization of token bound accounts upon deployment
  • Added function nonce() external view returns (uint256) to the IERC6551Account interface to allow for easier tracking of state changes on the account. This will allow marketplaces to more easily implement scam prevention measures when fulfilling orders involving token bound accounts.
3 Likes

Are there any pitfalls to using this for an account-bound token(a token which canā€™t be transferred). Since the token canā€™t be moved out of the wallet, in the scenario of the wallet getting compromised will it end up losing everything tied to the account itself?

@tigerthedev good question! Token bound accounts will work out of the box with account-bound tokens, assuming that they conform to the ERC-721 standard. In general, token bound accounts are only as secure as the wallet holding the underlying token. In the case of a compromised wallet, all accounts for tokens stored in that wallet would also be compromised.

got it @jay. Currently what I see most of the projects do is issue a new account-bound token when the wallet is compromised. So the owner needs to take care of moving all the tokens out of the account to the new account which is bound to the token.

I can see a lot of discussions around PFPs as true on-chain identities using ERC-6551. I am a bit confused here about how a transferable token can act as an identity. Will you be able to shed some light here?
article link here: ERC 6551ā€Šā€”ā€ŠToken Bound Accounts. Every NFT will have their ownā€¦ | by Benny Giang | FUTURE PRIMITIVE | Mar, 2023 | Medium

Weā€™re looking to adopt EIP-6551 for mech and I started catching up on discussions here. :wave:

We modeled the API of our execution interface after Safe where transactions are defined with the following parameters:

address to
uint256 value
bytes memory data
Enum.Operation operation  // 0 = call, 1 = delegatecall

Support for delegatecall seems important to us (e.g.: for batching via multi-send), but it also comes with the implication that we cannot prevent account owners from writing to their TBAā€™s storage. Account implementations that allow generic delegate calls are implicitly upgradable with storage-based proxies.

Thatā€™s why I would like to suggest reconsidering this change:

The Registry now deploys ERC-1967 proxies instead of ERC-1167 proxies. This change increases compatibility with existing proxy detection infrastructure since ERC-1967 proxies can be detected by reading a storage slot rather than pattern matching against bytecode. This change also allows wallet implementations wishing to implement upgradability to do so without requiring an intermediate proxy.

I feel ERC-1167 proxies are better suited for the following reasons:

  • Implementation immutability, even if an account allows for generic delegate calls
  • Lower tx execution cost (no storage read)
  • IMO, they are common enough so that we can assume good support from proxy detection infrastructure.

Due to the nature of TBAs being transferred between people, immutability of the account implementation seems paramount for establishing trust.

1 Like

We actually started out with an 1167 version, with the option to support upgradeability via another upgradeable storage based proxy:

So, initially, it was:
Account = 1167 ā†’ Upgradeable Proxy ā†’ Implementation

But we debated on whether or not an intermediate proxy was good, or if we should just do 1967 natively.

Sounds like youā€™re recommending the 1167 ā†’ Upgradeable Proxy (even a 1967) ā†’ Implementation. Are we on the same page? If so, the changes seem pretty minor, weā€™re just offloading upgradeability to another intermediate proxy. Seems like weā€™d be introducing a bit of call overhead though, no?

Thanks for the comments @jan! Would love to ensure 6551 can support mech.

As @wwhchung mentioned, an earlier version of this proposal used ERC-1167 proxies. Part of the motivation for switching to ERC-1967 proxies was the concern that appending extra constant data to the proxy bytecode would cause problems in applications using naive approaches to identifying ERC-1167 proxies (e.g. using regex with an assumed bytecode length). Switching to ERC-1967 proxies sidesteps this issue. However, lack of proxy detection support might be less of a concern in practice.

The other motivation for switching to ERC-1967 proxies was to allow accounts wishing to implement static upgradability to do so without requiring an intermediate proxy. Arbitrary delegate calls allow the executor of the account to use any implementation, but donā€™t allow additional functions to be defined on the account which are accessible to non-executors. If an ERC-1167 proxy is used, implementing static upgradability would require a second upgradable proxy behind the ERC-1167 proxy. The shift to ERC-1967 was partially an attempt to reduce this redundancy.

You make a good point that the shift to ERC-1967 proxies forces all account implementations to use storage, which breaks compatibility with account implementations that allow for arbitrary delegate calls. Using ERC-1167 would allow for stateless account, while also allowing account implementations that wish to implement upgradability to do so with an intermediate proxy. This comes at the cost of some additional gas overhead for upgradable accounts, but that seems reasonable.

I think that trust in an account implementation can be established via either full immutability (as mech does) or via controls on storage access (so long as arbitrary delegate calls are disallowed). It seems that this decision should be made by each account implementation rather than being enforced at the proposal level.

I would support a return to ERC-1167 proxies with appended constant data in order to accommodate stateless accounts.

I fully agree with your reasoning. ERC-1167 seems like the least common denominator. For additional proxies a bit of call overhead seems reasonable, as this also leaves the choice over the exact proxy pattern to account implementers.

I now understand your concern about proxy detection better, seeing that you appended the static data to the 1167 proxy bytecode. This might indeed cause issues with proxy detection infra. In mech, we work around this by storing the static data at a different address rather than as a footer to the proxy bytecode.

1 Like

@jan This is a very neat approach, but it seems like it may incur significant gas fees both during account creation (as two contracts are being created instead of one) and per-transaction (as it introduces an additional cold call to the storage contract).

While lack of support for ERC-1167 proxies with appended data does pose some concern, I think the practical risk is minimal. It doesnā€™t seem like on-chain detection of ERC-1167 bytecode is a commonly used pattern, and off-chain detection logic can be easily updated to ignore any bytecode after the standard ERC-1167 proxy implementation.

Iā€™ve tested the modified ERC-1167 proxy against Etherscanā€™s proxy verification tool, and it detects the implementation address without issue. Iā€™ve also tested it against the original clone-factory repo specified in EIP-1167 with mixed results. ContractFactory.isClone returns true for the modified ERC-1167 implementation as expected, while ContractProbe.probe returns an incorrect implementation address (since the detection logic is length-based).

Given that the risk of using a slightly non-standard ERC-1167 proxy implementation seems to be low while the benefit of allowing stateless account implementations is significant, returning to the original ERC-1167 proxy implementation seems like a good path forward.

Awesome, @jay! In that case weā€™ll use the ERC-1167 footer for mech. (And Iā€™ll go right ahead and update evm-proxy-detection :smirk:)

@jan fantastic! TIL about evm-proxy-detection :slight_smile:

Do you have any other thoughts/feedback on the structure of the Registry or the Account interface?

I have a preemptive concern on the convenience of nesting NFT-accounts. If Iā€™m not mistaken, right now to move an NFT 3 layers deep my EOA calls my parent wallet (layer 1) which authā€™s my EOA owns the NFT, then calls wallet of one of its NFTs (layer 2) which auths that layer 1 wallet owns the inner NFT, which then calls layer 3 wallet, etc.

This seems like a problem on two main fronts:

  1. encoding the parent call for my EOA goes through N layers of executeCall(to, value, data) which is a pain for the frontend dev building with a nested feature
  2. this adds meaningful gas overhead for (a) additional calldata for the transaction, (b) many internal transactions hopping down each layer in the ownership tree, (c) storage updates from nonce increments in each layer.

My proposed amendment is to add an additional function specifically designed for executing calls on nested NFTs.

function executeCallNested(address to, uint256 value, bytes data, uint256 depth) external payable returns (bytes memory result) {
    address rootOwner = owner();
    address tokenContract;
    uint256 tokenId;
    for (int i = 0; i < depth; i++) {
        (_, tokenContract, tokenId) = IERC6551Account(rootOwner).token();
        rootOwner = IERC721(tokenContract).ownerOf(tokenId);
    }
    require(msg.sender == rootOwner, "Not token owner");
    
    bool success;
    (success, result) = to.call{value: value}(data);

    if (!success) {
        assembly {
            revert(add(result, 32), mload(result))
        }
    }
}

This function takes in an additional depth parameter for how many parents up the contract must loop over to find the root owner and finally checked against msg.sender. This does validation work upfront that would be performed anyways in absence of this function, but with cheaper view calls, less net calldata, no cascading nonce incrementing, and an obvious interface for developers.

On a similar note, I think it would be more convenient to build gating logic on nested NFT with a view function that returns if an address is an eventual parent of this nested NFT. I donā€™t think this one should be on the Account spec, maybe as a part of the global registry?

function isEventualParent(address account, address tokenContract, uint256 tokenId) external view (returns bool) {
    address owner = IERC721(tokenContract).ownerOf(tokenId);
    while (owner != account) {
        // no guarantee owner of token is IERC6551Account
        // if not, catch and return false
        try IERC6551Account(owner).token() returns (uint256 _chainId, address _tokenContract, uint256 _tokenId) {
            owner = IERC721(_tokenContract).ownerOf(_tokenId);
        } catch {
            return false;
        }
    }
    return true;
}

This function will keep going up the ownership tree until it finds the account of interest or hits an address that does not implement the ERC (e.g. EOA, other SCW, address(0)), therefore erroring out which is caught and triggers a false return.

1 Like

@conner thanks for highlighting this!

Youā€™re 100% correct that with the reference account implementation you would have to go through N layers of nested calls to transfer a nested NFT, which isnā€™t ideal from a gas cost or user experience perspective. The two solutions youā€™ve proposed are great, and should definitely be considered as a feature at the implementation level.

One edge case that may break these two solutions is accounts that have not been deployed yet. An NFT could be nested 3 levels deep, but if either of the two parent account contracts have not been deployed, the token method will be unavailable to call, causing a revert.

An alternative approach that accounts for pre-deploy accounts would be to pass in a proof of ownership in the form of an array of structs containing information about the parent accounts. The account contract could verify this proof based on the computed addresses of the accounts in the ownership chain. It might look something like this:

struct ERC6551AccountData {
    address implementation;
    uint256 chainId;
    address tokenContract;
    uint256 tokenId;
    uint256 salt;
}

function executeCallNested(
    address to,
    uint256 value,
    bytes calldata data,
    ERC6551AccountData[] calldata proof
) external payable returns (bytes memory result) {
    (, address tokenContract, uint256 tokenId) = this.token();

    for (uint256 i = 0; i < proof.length; i++) {
        require(proof.chainid == block.chainid, "Invalid token");

        address account = ERC6551Registry.account(
            proof.implementation,
            proof.chainId,
            proof.tokenContract,
            proof.tokenId,
            proof.salt
        );

        require(IERC721(tokenContract).ownerOf(tokenId) == account, "Invalid proof");

        tokenContract = proof.tokenContract;
        tokenId = proof.tokenId;
    }

    require(require(IERC721(tokenContract).ownerOf(tokenId) == msg.sender, "Not root owner"));

    bool success;
    (success, result) = to.call{value: value}(data);

    if (!success) {
        assembly {
            revert(add(result, 32), mload(result))
        }
    }
}

This could be further optimized by computing the account addresses locally without calling into the Registry contract.

I think both of these functions are great additions to account implementations, as they will improve the feasibility of executing nested calls and determining the root owner of a set of nested NFTs. However, Iā€™m not sure whether the implementation of these functions should be mandated by the proposal. Perhaps these would be better implemented in a companion library that account implementation authors could include if they wanted to opt into these features.