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.