ENSIP: Passthrough resolution, enabling painless resolver upgrades

A lot of builders have creative ideas for new types of resolver contracts. However, for users, switching resolvers is a major point of friction: all of the records you set on the old resolver will not exist on the new resolver, essentially resetting your ENS name back to a blank slate. This friction makes it hard for users to experiment with new resolver types, and even prevents users from upgrading whenever ENS releases a new public resolver with expanded capabilities.

I am introducing a draft ENSIP which tackles this issue by defining “passthrough” functionality for resolvers. A new resolver can be deployed with a link to the resolver it is meant to supersede, and any queries that can’t be serviced by the new resolver will automatically “pass through” to the previous one.

Migration is dead simple for users: deploy or find a new resolver that points to your existing resolver, update your registry to point to the new resolver, and you are done. You can read and write all records to the new resolver, using any features it supports, and any queries for past data will be returned like normal.

The user can easily revert the changes by pointing back to the original resolver. Multiple resolvers can “pass through” to other resolvers in a chain, allowing users to upgrade an unlimited number of times without losing any past data.

This will unlock much easier experimentation and deployment of new resolvers. For example, it will give an easy deployment path to the new resolver Fluidkey has proposed in their SPP3 application. It would also make it easy for long-term ENS users to upgrade to an ENSIP-24-compatible resolver without losing any of their existing records.

Here is the work I have done so far: ESNIP: Passthrough resolution by jmacwhyte · Pull Request #83 · ensdomains/ensips · GitHub

I would appreciate any feedback!

2 Likes

At the protocol level, I think the best solution is a revert, like error ResolveAgain(address resolver, bytes name?), but the problem in the past has been this requires changing every library.

ENSv2’s focus on the UniversalResolver as the primary entry point greatly reduces the surface area of ad hoc resolution implementations, but unless every other client processes this revert, it’s not very useful.

The only backwards compatible solution I can think of, would be to retrofit the redirect into OffchainLookup as a special URL (like ENSIP-21) and then the UR could handle it and unaware clients would be forced to proxy their requests through an external resolution service.


At the resolver level, the best solution for calling another resolver is ResolverCaller. Simply mixin this contract and you can call any resolver. It supports standard resolution, IExtendedResolver, and IExtendedDNSResolver. It supports multicall, resolve(multicall), and OffchainLookup. It intelligently avoids the batch gateway if the resolver being called implements ENSIP-22. ResolverCaller is nearly the same thing as the UniversalResolver (they differ on error handling). It provides a standard calling interface with standard return value. You invoke it when needed and can intercept the response:

function callResolver(
    address resolver,
    bytes memory name, bytes memory data,   // resolve()
    bool hasContext, bytes memory context,  // IExtendedDNSResolver
    string memory batchGateways
)

ResolverCaller is used by every ENSv2 contract that functions like a “router”. The following resolvers: ENSv1→ENSv2 (ENSV2Resolver), ENSv2→ENSv1 (ENSV1Resolver), DNS wildcard (DNSTLDResolver), and DNS aliasing (DNSAliasResolver) all use it.

It has the same problem as any immutable contract—if we alter the resolution process, every implementation needs updated—but I think ENS should avoid future resolution changes.

It also is a non-trivial chunk of code.


I mention all that because your ENSIP essentially has the same issue: if the feature is implemented external to the resolver, something has to detect that no record was returned, and then call another resolver.

For reference, ENSIP-19 was implemented as a feature internal to the resolver, even though it would be cleaner to implement it at the protocol level.

For your problem, I think you could use ResolverCaller to implement the desired logic internal to your resolver.


Another idea I experimented with is FallbackResolver. It is an immutable clone factory, which given an array of resolvers (where order is resolution priority), it creates a resolver that processes resolution by trying each resolver in order until a non-null value is received. It supports all types of resolvers, including offchain.


A different problem related to resolver fallbacks is: where is the data? The resolver address is used for two purposes: (1) resolving data and (2) the place where data is stored.

For example, since FallbackResolver doesn’t store data, an editor would have to extract the first underlying resolver in its priority list or ask it for the "resolver write target”, similar in spirit to your passthroughResolver() but reversed.

For your problem (or any fallback resolver in general), when you want to clear a value, how do you find which resolver has that value stored? Or do you have a separate sentinel value which acts like a pseudo-null?


Lastly, the ENSv1 PublicResolver design unfortunately relies on registry ownership for authorization. ENSv2 migration relinquishes your ENSv1 ownership which freezes the data.

The ENSv2 approach is to encourage deployment of an upgradeable personal resolver, which avoids the registry authorization dependency, but you must copy your data into it.

1 Like

Resolver migration seems better handled at the app level since different apps may want different behavior.

For example, ENS Manager prompts users to port over existing records. Other flows may depend on users having a clean slate, and there are years of user expectations to account for here.

Are you proposing a change to protocol resolution, or a standard interface for resolver contracts that internally fallback to some other resolver? I read this thread as the former, but the ENSIP actually reads like the latter.

Fwiw I don’t think the former is necessary. And as far as I understand it, the latter doesn’t enable any new functionality, mostly improves debugging and discovery for clearing stale records. Is that right?

Sharing an old contract I wrote called HybridResolver.sol (code, tweet) as an old example of a resolver where I implemented similar logic to what you’re describing. It first checks if the contract has onchain records like the normal public resolver. If yes, it returns those. If not, it falls back to an offchain gateway.

To adapt this to your use case, you could add a simple mapping to your resolver contract (I suppose that’s your passthroughResolver() method, though would be passthroughResolver(bytes32 node) to be more configurable) where users would store a fallback resolver address. Then instead of resolveOffchain() on line 92, you’d just passthrough the resolve() call (or the underlying addr()-type call) to the user’s specified fallback address. My implementation is naive since it’s a few years old, but I hope clear enough to illustrate the example.

@raffy @slobo.eth @gregskril Thanks for taking the time to look at this.

There seemed to be a lot of questions (and perhaps some misunderstandings) in your comments. I think these are the main questions to answer:

Is this a protocol change?

No, this is just a standard interface for passthrough functionality, so that resolvers from different developers can be chained together. It defines how to handle passthrough functionality, and which additional methods are needed to enable the interactions between two resolvers in the chain.

Does it rely on off-chain processing?

No, all passthrough resolution functionality is handled inside of the resolver (see the section titled “Passthrough Resolution”). Externally, the contract works just the same as a normal resolver, so no changes are needed to existing consumers for normal reads and writes. Indexers would need to be updated, and in the current design, clients who wish to delete a record completely would need special handling (see the section titled “Updating and Deleting Records”).

I had thought of adding a sentinel system that would allow the head resolver to mark downstream records as deleted, providing full read/write compatibility with current infrastructure, but I decided to start with this simple design and consider adding more functionality later.

Where does the data live?

With the design, the data stays exactly where it has always been, and each resolver continues to work and serve queries like normal. I was wrong to use the word “migration” in the title, because the data is not being migrated; additional functionality is simply being layered on top of what is already there.

What would this actually be used for?

Imagine this scenario:

Bob is currently using the public resolver (resolver A), which supports addr() and text().

A new version of the public resolver is released (resolver B), which also supports data(). It implements passthrough resolution (this ENSIP) and stores the address of resolver A.

Bob updates the registry to point his name to resolver B. He can write new data() records (which will be stored in resolver B), and any text() requests will pass through to resolver A, returning the original values. If he wants to change an existing text record, he can simply write the new value to resolver B, and that value will be the one returned for any queries. Bob has essentially “migrated” to an updated version of the public resolver, without needing to move any data.

Next, a third party developer could create their own resolver profile for some other purpose. They deploy their resolver (resolver C) which supports foo(), with passthrough functionality pointing to resolver B.

Bob can use this new resolver type by pointing his name to resolver C, and all of his previous records will still be visible to any queries that come in to resolver C. If bob ever wants to undo these changes, he can always point his name back to resolver B or resolver A.

For any additional questions, I would appreciate having them called out specifically so i know exactly what to answer :grin:

I think I understand better now, however I still think this needs to be implemented as internal resolver logic. What about something like this?



interface IFallbackResolver is IExtendedResolver {
    function fallbackResolver(bytes calldata name) external view returns (address);
    function resolveWithoutFallback(bytes calldata name, bytes calldata data)
        external
        view
        returns (bool exists, bytes memory result);
}


abstract contract ResolverChain is IFallbackResolver {
    function resolve(bytes calldata name, bytes calldata data) public view returns (bytes memory) {
        (bool ok, bytes memory v) = this.resolveWithoutFallback(name, data);
        if (ok) {
            return v;
        }
        address resolver = this.fallbackResolver(name);
        if (
            ERC165Checker.supportsERC165InterfaceUnchecked(
                resolver,
                type(IExtendedResolver).interfaceId
            )
        ) {
            return IExtendedResolver(resolver).resolve(name, data);
        }
        (ok, v) = resolver.staticcall(data);
        if (ok) {
            return v;
        }
        assembly {
            revert(add(v, 32), mload(v))
        }
    }
}

Following all your examples. But I think the core behavior you’re describing doesn’t require a new interface at all! It would work today if multiple resolvers implement a fallback mechanism internally.

As far as I can tell, the main thing the new interface enables is manager apps to see past resolvers so they can clear records. Which is maybe still useful?

@raffy you would know better than me- is there any way to indicate a null value in the latest resolver instead of deleting records from all fallback resolvers in a chain?

I think above would “solve” it, as the general “null result” detection is difficult since a null pubkey() is 0x[64], a null addr() is 0x[32], a null ABI() is 0x[64], etc.

Yes, that’s pretty much exactly what I was thinking. The ENSIP I drafted only covers the interface definition and leaves the internal implementation up to developers.

Yes, exactly! Anyone could just implement it internally, but the point of the interface would be to define a way to externally find out where it is routing to next, and to do local (non-fallback) queries. Otherwise if there were no way to get that info, it could be really frustrating for users who can’t figure out where the records are actually coming from. (Which I guess is what you meant earlier about debugging being the main functionality)

My thought on this was to add an additional map of bools which flagged if a value had been explicitly deleted. When a query comes in, if that map returns true then return an empty value. Otherwise, continue with the query + fallback.

If a request comes in to set a record, set the deleted flag to true if empty, false if not.

However the interface would probably also need to expose a method to get the value of these flags, for the same reasons mentioned above about being able to audit the resolver chain.

1 Like

I would support standardizing around this interface:

interface I___Resolver is IExtendedResolver {
    function ___Resolver(bytes calldata name) external view returns (address);
    function resolveWithout___(bytes calldata name, bytes calldata data)
        external
        view
        returns (bool exists, bytes memory result);
}

Where ___ is Fallback or Passthrough or some other generic term.

The actual details of how resolveWithout___() would not be specified and up to the developer.

The interface itself is not necessary for resolution. The primary use of the interface is to provide a better editor UX, so you can traverse the chain of resolvers and determine where values are stored.


However, it doesn’t work correctly for profiles with internal logic.

ENSIP-19 is implemented via internal resolver logic. There exists IHasAddressResolver but it hasn’t been standardized (I wasn’t sure if it was necessary.) A more complicated abstract ResolverChain implementation would have to handle addr() / addr(*, coinType) where coinType is evm with care, by first finding the first resolver with hasAddr(*, coinType) and if none are found, querying the specific address, and if that is null, querying the default address.

abstract contract ResolverChain is IFallbackResolver {
    function resolve(bytes calldata name, bytes calldata data) public view returns (bytes memory) {
        // handle evm address differently
        if (bytes4(data) == IAddrResolver.addr.selector) {
            return abi.encode(_resolveAddrWithDefault(name, data, COIN_TYPE_ETH));
        } else if (bytes4(data) == IAddressResolver.addr.selector) {
            (, uint256 coinType) = abi.decode(data[4:], (uint256));
            if (ENSIP19.isEVMCoinType(coinType) && coinType != COIN_TYPE_DEFAULT) {
                address addr = _resolveAddrWithDefault(name, data, coinType);
                return abi.encode(addr == address(0) ? bytes("") : abi.encodePacked(addr));
            }
        }
        (bool ok, bytes memory v) = this.resolveWithoutFallback(name, data);
        if (ok) {
            return v;
        }
        return _callResolver(this.fallbackResolver(name), name, data);
        
    }

    // call a specific resolver and return wrapped or blow up
    function _callResolver(address resolver, bytes memory name, bytes memory data) internal view returns (bytes memory) {
        require(resolver.code.length > 0);
        if (
            ERC165Checker.supportsERC165InterfaceUnchecked(
                resolver,
                type(IExtendedResolver).interfaceId
            )
        ) {
            return IExtendedResolver(resolver).resolve(name, data);
        }
        (bool ok, bytes memory v) = resolver.staticcall(data);
        if (ok) {
            return v;
        }
        assembly {
            revert(add(v, 32), mload(v))
        }
    }
    
    // find the first resolver that has the specified evm address
    // if found, call it (or blow up)
    // otherwise, find the first evm address
    // if not found, find the first default evm address
    function _resolveAddrWithDefault(bytes calldata name, bytes calldata data, uint256 coinType) internal view returns (address addr) {
        bytes32 node = NameCoder.namehash(name, 0);
        address resolver = _findResolver(name, abi.encodeCall(IHasAddressResolver.hasAddr, (node, coinType)), _hasAddr);
        if (resolver != address(0)) {
            bytes memory a = _callResolver(resolver, name, data);
            if (bytes4(data) == IAddrResolver.addr.selector) {
                addr = abi.decode(a, (address));
            } else if (v.length == 20) {
                addr = address(bytes20(a));
            }
        } else {
            addr = _resolveAddr(name, node, coinType);
            if (addr == address(0)) {
                addr = _resolveAddr(name, node, COIN_TYPE_DEFAULT);
            }
        }
    }

    // safely call the resolver chain for `addr(evmCoinType)`
    // returns address(0) if reverts or invalid
    function _resolveAddr(bytes memory name, bytes32 node, uint256 evmCoinType) internal view returns (address addr) {
        if (evmCoinType == COIN_TYPE_ETH) {
            try this.resolve(name, abi.encodeCall(IAddrResolver, (node))) returns (address a) {
                addr = a;
            } catch {}
        } else {
            try this.resolve(name, abi.encodeCall(IAddressResolver, (node, evmCoinType))) returns (bytes memory a) {
                if (a.length == 20) {
                    addr = address(bytes20(a));
                }
            } catch {}
        }
    }
    
    // find the first resolver in the chain that has a response for `data`
    // and `testFn(data)` is true, or null
    function _findResolver(bytes memory name, bytes memory data, function(bytes memory) internal returns (bool) testFn) internal view returns (address) {
        IFallbackResolver resolver = this;
        for (;;) {
            try resolver.resolveWithoutFallback(name, data) returns (bool ok, bytes memory v) {
                if (ok && testFn(v)) {
                    return address(resolver);
                }
            } catch {}
            address next = resolver.fallbackResolver(name);
            if (
                !ERC165Checker.supportsERC165InterfaceUnchecked(
                    next,
                    type(IFallbackResolver).interfaceId
                )
            ) {
                return address(0);
            }
            resolver = IFallbackResolver(next);
        }
    }

    // safe predicate for `abi.decode(v, (bool))`
    function _hasAddr(bytes memory v) internal pure returns (bool) {
        return bytes32(v) != bytes32(0);
    }
}

And IABIResolver may need to query the resolver chain for each bit set in the contentTypes mask separately.

1 Like

Actually, a simpler implementation would be making it so resolveWithoutFallback doesn’t implement ENSIP-19. That way if no resolver in the fallback chain has a value for an EVM address, it can just return the result of calling the default address instead.

abstract contract ResolverChain is IFallbackResolver {
    function resolve(bytes calldata name, bytes calldata data) public view returns (bytes memory) {
        // find the first value that exists
        address resolver = address(this);
        for (;;) {
            (bool ok, bytes memory v) = IFallbackResolver(resolver).resolveWithoutFallback(name, data);
            if (ok) {
                return v;
            }
            resolver = IFallbackResolver(resolver).fallbackResolver(name);
            if (
                !ERC165Checker.supportsERC165InterfaceUnchecked(
                    resolver,
                    type(IFallbackResolver).interfaceId
                )
            ) {
                break;
            }
        }
        // determine if it was a non-default evm address
        bool isEVM = bytes4(data) == IAddrResolver.addr.selector;
        bool hasCoinType;
        if (!isEVM && bytes4(data) == IAddressResolver.addr.selector) {
            hasCoinType = true;
            isEVM = coinType != COIN_TYPE_DEFAULT && ENSIP19.isEVMCoinType(coinType);
        }
        // handle with special logic
        if (isEVM) {
            bytes memory data0 = abi.encodeCall(IAddressResolver.addr, (NameCoder.namehash(name, 0), COIN_TYPE_DEFAULT));
            address addr;
            // ask the last resolver for the address
            try this.resolveLast(resolver, name, data) returns (bytes memory v1) {
                if (!hasCoinType) {
                    addr = address(uint160(uint256(bytes32(v1))));
                } else if (v1.length == 20) {
                    addr = address(bytes20(v1));
                }
                // if non-zero, ask the last resolver for the default address
                if (addr != address(0)) {
                    try this.resolveLast(resolver, name, data0) returns (bytes memory v0) {
                        // if they aren't the same, use the address
                        if (v0.length == 0 || (v0.length == 20 && addr != address(bytes20(v0)))) {
                            return v1; // last resolver response
                        }
                    } catch {}
                }
            } catch {}
            // restart from the top and ask for the default address
            bytes memory a = this.resolve(name, data0);
            if (!hasCoinType) {
                return abi.encode(address(bytes20(a))); // fix return type
            }
            return a;
        }
        return resolveLast(resolver, name, data);
    }

    function resolveLast(address resolver, bytes calldata name, bytes calldata data) public view returns (bytes memory) {
        if (
            ERC165Checker.supportsERC165InterfaceUnchecked(
                resolver,
                type(IExtendedResolver).interfaceId
            )
        ) {
            return IExtendedResolver(resolver).resolve(name, data);
        }
        (bool ok, bytes memory v) = resolver.staticcall(data);
        if (ok) {
            return v;
        }
        assembly {
            revert(add(v, 32), mload(v))
        }
    }
}

Hum, this still has a problem with the last resolver not getting asked for addr(evmCoinType).
I think that fixes it.


Additionally, this would mean resolveWithoutFallback() is a strictly-stronger version of hasAddr() that works for any profile.

1 Like