ERC-6551: Non-fungible Token Bound Accounts

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.

So I’ve been building Financial NFT-based wallets for years now, and I’ll be honest, I’m a bit disappointed that Revest and my team weren’t a part of this EIP’s formulation. We have some stuff we’d love to contribute to it and some EIPs we’d love to branch off of it. Currently in the process of building our V2, were important in getting 6780 put together, and definitely don’t think there should be a standard surrounding the core tech we’ve developed that we aren’t a part of helping out with.

@jay I’d be very interested in getting connected. You can find me on Telegram as “RobAnon” - let’s get something put together, because having chain-wide standards for this sort of thing has been a long-time goal of ours.

1 Like

Replying to this thread to make sure it’s included. We’re interested in working together with you on this EIP and either including naive support for ERC-1155, or developing an extension of this EIP in parallel to support it. Also want to discuss valuation mechanics, as those are crucial to the success of such a system.

1 Like

Hey @RobAnon! We would welcome your contributions to this proposal. It is still in draft specifically so that folks interested in this pattern can contribute before it goes to review.

The biggest difference between Revest’s product and this proposal seems to be that this proposal applies to all ERC-721 tokens, while Revest seems to only work with ERC-1155 FNFTs created using the protocol. Please correct me if I’m wrong about this.

The key challenge to supporting ERC-1155 tokens is the lack of a canonical owner for a given token. In the absence of a canonical owner, how would you recommend handling authorization at the account level? How does Revest handle this? If there is a canonical owner for each ERC-1155 token in a collection, these tokens can already be supported within the current structure of this proposal by adding an ownerOf method to the ERC-1155 contract (as ENS NameWrapper does).

For ERC-1155 tokens without a canonical token owner, given the lack of totalSupply support in the ERC-1155 standard, it doesn’t seem like there is a clean way to determine who is allowed to control the account without resorting to 1-of-n execution (anyone whose balance is greater than zero has owner permissions on the account). This makes it challenging to support every ERC-1155 token that exists, which would be ideal if ERC-1155 support is explicitly included in the proposal.

There are a few options to add ERC-1155 support that have been considered:

  1. Remove the concept of a canonical owner address in favor of an isOwner(address) returns (bool) function which determines whether a given wallet is the owner of a token. The implementation would be responsible for determining if an address holds the correct balance of tokens to be considered an owner. This requires a change to the account interface, complicates the security model, and makes it difficult to optimize for the current use case which is ERC-721 tokens (which have a canonical owner).
  2. Parameters under which ERC-1155 token ownership is canonical can be passed in the salt value to a custom account implementation. This could be min/max balance thresholds for example. The implementation could allow “claiming” of an account, causing the caller to become the canonical owner if they meet the balance threshold requirements. This would break the current behavior of automatic transfer of account to the new canonical owner when the underlying token is transferred, which seems less than ideal.
  3. ERC-1155 token bound accounts could be considered unique per holder/token combination. This would mean that every unique holder of an ERC-1155 would have a corresponding token bound account address, with optional balance threshold enforcement at the implementation level. This can be accomplished by passing in the holder address as a part of the account salt. This means that there would not be a single canonical token bound account for a given ERC-1155 token, but rather many accounts, one per holder. Depending on the desired use case, this might be useful.
  4. ERC-1155 tokens could be “wrapped” into an ERC-721, allowing for usage with the existing architecture without any proposal changes.

I’m sure there are other approaches here as well, and would love your thoughts on them drawing on your depth of experience at Revest. We’d love to reconsider ERC-1155 support as a part of this proposal, but understand that the significant differences in functionality between ERC-721 tokens and ERC-1155 tokens may necessitate a separate proposal.

Revest V2 is slated to support both 721 and 1155. It makes sense for us to ensure that positive changes we’ve introduced within it are reflected in the system that you build here. It also makes sense for you that we use this standard in production, as that’d propagate it further and help drive consolidation efforts.

We view this proposal in the context of a series of three proposals that should be published to describe a unified set of Financial NFT standards. I agree with you that 721 might make sense as its own standard, but to be completely honest, I think that even having an NFT type attached to this standard is likely unnecessary. 1155 singletons work well with the 721 system, and while they don’t support ownerOf, they would function just fine on their own. External to this, if you window the functionality to deposit/withdraw functions, 1155s work just fine even with their multiple owners. We use our internal supplyOf(address) and balanceOf(address) to determine what proportion of a given ERC20 an account owns. When you limit these wallets on a per-asset basis, it opens a lot of doors - that’s what we’re interested in discussing w/r/t 3 proposals.

Can you help me understand why you feel it is necessary to have an ownerOf function in the smart wallet?

With respect to supplyOf, EIP-5615 seeks to address this. Revest FNFTs already support this, as it is a necessity for determining subdivideability.

I’m the dev working on Revest V2. A potential implementation for the system is that only the user who owns the entire supply of a given 1155 is able to access the smart wallet. It is functionally the equivalent of having a canonical owner like in 721 but being slightly more strict. It requires some extra work on behalf of the wallet deployer to ensure this but is certainly better than nothing and works in conjunction with EIP-5615

1 Like