ERC-6551: Non-fungible Token Bound Accounts

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

@RobAnon Thanks for providing some extra context! Would love to ensure that Revest V2 can use this proposal.

To clarify: singleton ERC-1155 tokens will work out of the box with the structure of this proposal. All that’s necessary is a custom account implementation that can correctly determine the canonical owner given a token ID. Adding an ownerOf method to the ERC-1155 just means you can use existing account implementations designed for ERC-721 tokens without the need for custom account logic.

Having a single canonical owner is important for a few reasons. First, it allows for (partial) ERC-173 support and (full) ERC-5313 support, making token bound accounts compatible with existing tooling that uses owner() calls to determine smart contract ownership. This seems like a desirable characteristic. Secondly, it enables automatic transfer of account ownership when the underlying asset is transferred. These two traits seem very desirable in the context of ERC-721 tokens, but may be less important in the context of ERC-1155 tokens.

One goal of this proposal is to give every NFT a self-sovereign account, allowing NFTs to execute arbitrary on-chain actions just like any other account. Restricting the actions a wallet can take is the responsibility of the account implementation to enforce, not the registry.

One potential low-hanging-fruit proposal change which would allow ERC-1155 support within this proposal would be to specify that the return value of owner() may be the zero address for tokens that do not have a canonical owner. This would allow the development of ERC-1155 backed token bound accounts without changing the existing account interface. Revest’s custom implementation could use EIP-5615 balance calculation to determine execution permissions, while still allowing the canonical account owner to be queried for ERC-721 token account implementations.

Great to have you here @0xTraub! This is definitely possible with the changes I mentioned in my previous post.

The challenge with using this method within an owner() function is that you need more information than just the token ID to determine the canonical owner of an ERC-1155 token. Specifically, you need to know which account to check the balance of. This data is unavailable within the context of the current owner() function for ERC-1155 tokens, whereas it is queryable using the ownerOf method for ERC-721 tokens.

So my thought process is that if we know the user address and we know the id of the 1155 it is possible to use the system I described. We just do this

uint balance = balanceOf(address, id);
uint supply = supplyOf(id);
require(balance == supply, "restricted to owner");

In this case the owner is whomever owns 100% of the supply of the 1155 ID. It’s not idea especially if that 1155 is traded frequently but its certainly better than nothing and provides some notion of ownership.

@0xTraub this is correct, and works out of the box with this proposal in cases where the user address is known (when authorizing execution of a transaction for example).

However, within the context of an external owner() call the user address is not known (for example, if an external contract/dapp is checking ownership of an unknown account).

The owner() call issue can be solved by either:

  1. Storing the current owner of an account in storage and allowing callers to “claim” control of the contract if they meet certain balance thresholds (option #2 listed above). Importantly, the owner() method does not need to be used to authorize transactions.
  2. Modifying the spec to specifically allow owner() to return zero if there is no canonical order

In either case, this seems to be a relatively low lift and allows for native ERC-1155 support within this proposal. These approaches might not be a perfect fit for Revest v2 if you have strict requirements around minting/burning ERC-1155 tokens, but if Revest uses centrally controlled NFT contracts already it might make sense to bake this logic into the ERC-1155 tokens themselves.

Would love to understand if these changes allowing for ERC-1155 support would make it possible for Revest v2 to adopt this proposal.

Really cool!

I just had a quick look and thought about few things:

  • would be good for the account contract to have support for ERC-1271 so it can sign messages too
  • the account contract currently only support call it should also support create, create2, delegate_call

@wighawag thanks for the feedback!

ERC-1271 support is required for all accounts already (see the Account Interface section).

I agree that the execution interface is a bit rough at the moment. The intent behind not mentioning delegate_call, create or create2 is to minimize the required interface for accounts. Account implementations that wish to add support for other calls can do so outside the scope of the proposal (e.g. via an executeDelegateCall method).

As smart contract accounts become a more popular pattern, I think there needs to be a separate EIP that proposes a standard execution interface for smart contract accounts. The closest I’ve seen so far is ERC-725X, but it’s been in draft for a very long time. We would support a proposal attempting to standardize this.

In the absence of a standard contract account execution interface, this proposal includes a minimal execution interface to ensure all 6551 accounts can be called in the same way regardless of implementation. Would welcome any feedback on how we could make this interface more flexible or more standardized.

1 Like

There are also situations where we don’t want to support 1271 (or any real functionality). Consider a wallet holding a bunch of soulbound tokens where I don’t want anyone else adding any other tokens to it.

@wwhchung this is interesting! One thing I’ve been thinking of in this area is the concept of a null account - an account where the implementation address is set to the null address. This would give every NFT an address that can receive assets without allowing future withdrawals or executions. This would essentially provide a mechanic for permanently binding any onchain asset to any NFT. Curious to hear if this would fit your use case.

2 Likes

@jay What would be the benefit of permanently binding a non-executable or withdrawable asset to an existing NFT? Having these emphemeral wallets binded to a revest FNFT makes sense because it allows you to gain at least some kind of utility from it during the length of the locking period.