Reverse Resolution in off-chain settings

While developing justaname, we noted a limitation in address resolution that could impact the promises of off-chain names vs their on-chain equivalents. This was also brought forward to us by @lightwalker.eth during a call. The following is a technical step-by-step proposal that aims to tackle this. Any feedback would be highly appreciated!

Problem:

Resolving addresses in off-chain environments presents a current challenge. This procedure involves translating an address into a user-friendly name, enhancing ease and intuitiveness in user interactions. Dealing with off-chain storage can introduce complexities due to the decentralized nature and security measures of blockchain technology, making it challenging to access the required data or resources for resolution efficiently.

Proposal:

Setting the Primary Name:

Setting the resolver to the off-chain resolver:

const nameHash = ethers.namehash(testAddress.substring(2) + ".addr.reverse");

First step is to call the function [”function recordExists(bytes32) view returns (bool)”] on the ENS Registry contract, the next steps would depend on its response:

  • True: Call ["function setResolver(bytes32 node, address resolver)”] on ENS Registry contract and pass the nameHash and the off-chain resolver address. Costs 31,165 gas units.
  • False: Call [”function claimWithResolver(address owner, address resolver)”] on Reverse Registrar Contract and pass the address along with the off-chain resolver address. Costs 44,002 gas units and 81,002 gas units for the first time.

Setting the Primary Name:

This is dependent on the off-chain solutions provider’s specific implementation. As an example we can have an entity that looks like this:

PrimaryName: {
name: string,
address: string,
nameHash: string,
chainId: number
}

The provider should also implement a SIWE-like type of message to verify the message’s authenticity, along with authenticating the ownership of the name.

Reverse Resolution:


const nameHash = ethers.namehash(testAddress.substring(2) + ".addr.reverse");

First Step: Query Registry Contract:

The first step would be to query the registry contract to get the resolver contract’s address associated with a given nameHash. This should return the off-chain provider’s resolver contract address.

Second Step: Query the Resolver Contract:

Supports Interface:

Call the function [”function supportsInterface(bytes4 interfaceID) returns bool”] with interfaceId = “0x9061b923”.

False:

In case it returns false, call the function [”function name(bytes32 node) returns (string)”] on the Resolver contract. This should return the associated name.

True:

In case it returns true,

call the function [”resolve(bytes name, bytes data) returns (bytes)”] on the Off-chain Resolver Contract.

The param name should be the DNS-encoded nameHash.

The param data should be [”name(bytes32)”]

Query the contract:

The rest of the flow would be an extension to CCIP-Read and ENSIP-10

The contract should revert with the same error:


error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData);

Call the gateway:

Query the gateway on the url returned by the contract in step2, and pass the sender with the calldata. Example:

{url}/resolve/{sender}/{callData}.json

The off-chain gateway handling the request should add its own logic for handling the [”name(bytes32)”] and return the correct data, similar to how it is handling the logic of:

[”addr(bytes32)”,

“addr(bytes32,uint256)”,

“text(bytes32,string)”,

“contenthash(bytes32)”]

Callback Function:

Call the callbackFunction of the contract (returned in revert error of step 2), and pass the gateway response of step3, along with the extraData returned in step 2.

The response returned from the contract should be decoded and will return the primary name associated with the nameHash.

Introducing a Custom NPM Package for Off-Chain Reverse Resolution Support:

To overcome the limitations related to off-chain address resolution, a dedicated npm package has been crafted specifically for this purpose. This development addresses the challenges uncovered during the exploration and creation of justaname, particularly the necessity for a reliable method to convert Ethereum addresses back to human-readable names without relying solely on on-chain processes. Through this npm package, a novel solution is presented, combining both on-chain and off-chain technologies to enable reverse resolution. This ensures a smooth and user-friendly interaction for users, and this package aims to work as an extension to ethers.js’ existing ens related functionality. For more information, including installation guidelines and documentation, visit the npm repository under the name @justaname.id/address-resolution. This package is open-source, and community contributions to expand its functionality or improve its features are strongly encouraged.

2 Likes

Hi @justghadi.eth

Thank you for the proposal.
Have you looked into the draft proposal for EVM reverse resolution?

The gist of the idea is to introduce a chan specific reverse namespace such as address.{coinType}.reverse. This is necessary for smart accounts where contract address is unique to the chain (unless you use cross chain factory pattern) but may not be ideal for EOAs. The proposal also propose for the fallback address such as address.default.reverse which can only be set via setNameWithSignature to prevent contract accounts from setting chain independent reverse resolution.

I have a reference implementation PR at Default reverse resolver by makoto · Pull Request #33 · ensdomains/evmgateway · GitHub so you can take a look and give us feedback.

In the PR, the ability of setting a custom reverse resolver (including offchain resolver as in your proposal) is disabled as requested by @nick.eth to guarantee the consistent behavior but that could be up for debate

Hey, @matoken.eth and @justghadi.eth, I can see a lot of thought and effort is going into this research. Nice. Also going to bring @Premm.eth into this for his advice as I understand he’s thought about this topic before too.

It doesn’t feel right to me to introduce the complexity of having different primary names on different chains. Perhaps I’m missing something, please let me know.

Let’s put aside technical factors for a moment and instead focus on end-user psychology and the value proposition of ENS.

ENS presents itself as “Your web3 username”. Note this isn’t your “Arbitrum username” or your “Optimism username”, your “Ethereum mainnet username”, or your “Goerli username”, etc


The ens.domains website says: “No more sandboxed usernames. Own your username, 
 and use it across services.”

The majority of ENS names are now being issued offchain. It’s amazing to see the market demand and validation for this.

We know that these offchain ENS names are not “sandboxed usernames” for the use case of forward resolution. However, it seems we’re moving towards “sandboxed usernames” for the use case of reverse resolution.

The value proposition of using ENS as a portable social identity is getting damaged. We already have millions of cb.id names that are practically “sandboxed” for the purposes of reverse resolution. As I understand there’s now hundreds of thousands of uni.eth names experiencing this “sandboxing” too.

Of course I appreciate how claiming “myname.cb.id” doesn’t necessarily mean that I want it to be my primary ENS name. But for normal people, it’s important to enable paths for a full ENS onboarding (forward + reverse resolution) that’s super simple and completely free.

I also appreciate that it’s already possible for people to make cb.id names their primary ENS name. I configured this myself a long time ago with a wallet I control for exampleprimary.cb.id. But just because something is technically possible doesn’t mean it achieves adoption. There’s a big difference between small fees and no fees. Even small fees can decimate adoption.

The way I see it: ENS onboarding is incomplete if only forward resolution is configured. A complete ENS onboarding should include configuring a primary name that isn’t “sandboxed” to a particular app or chain.

This is mostly to cater for smart account which must be deployed into each chain (because they are smart contract) and the address are not guaranteed to be same across different chains (depending on how the contract account is deployed, someone else deploy the same address in the different chain as demosntrated during Optimism Wintermute Multisig hack hence it is more of the security requirement for primary name to be chain specific). For those who use EOA, they can just set default primary name and choose not to set any coin/chain specific primary name.

Appreciate that there’s some value in supporting that case.

Hopefully all needs could be well supported, but if we had no choice but to pick between:

A. Catering for the millions of people who already have offchain ENS names and the tens of millions more hopefully to come in the near future; or
B. Catering for smart accounts deployed across multiple chains;

A seems to be a strong and easy winner. Do you see it differently? How could B be more important than A, especially if it damages the value proposition of ENS as “your web3 username” and “no sandboxed usernames”?

Edit: Updating this reply for the edit above:

I see. Perhaps there’s a way to implement special rules depending on the account type then? Maybe some other solution? Not sure. I haven’t thought through all the technical factors here yet. But the idea of different primary names on different chains feels viscerally wrong to me for reasons including those mentioned above.

(A) can be served by the use of default reverse/primary name so I am not sure why you think it’s only useful for (B) (this fallback option is actually proposed by @Premm.eth). As I also mentioned in the modified post above, the ability to specify chain specific primary name is more of a security requirement and security does come as a highest priority in crypto imo

Agreed not to compromise on security. Hopefully there’s a path that doesn’t compromise security while also not compromising on the value propositions of ENS or on ENS market adoption.

That’s basically the role of the default primary name. Contract accounts cannot set the default primary name because it only accepts signature which only EOA can generate.

As I repeated, you don’t have to set chain specific name if you only set the default primary name.

The ability to specify chain specific address has been discussed in other places. The ability to specify which chain to accept payment/transactions is actually a useful UX feature to give assurance to the end users that recipients won’t lose their funding by sending assets to a wrong chain.

There’s a lot of words here and I’m not sure I understand, so I’m just gonna say things:


Reverse resolution {addr}.addr.reverse is just address → name.

I see no purpose in having anything more complicated than this, so I’m against the current subdomain → resolver = PR → PR.setName() setup simply for gas and complexity.

For example, do we really need this? raffy.51050ec063d393217b436747617ad1c2285aeeee.addr.reverse
Note: I think there’s value in having EoA-claimable nodes in the registry, but they’re massive overkill for what reverse names do for 99.99999% of use-cases.

The fact that contracts aren’t widely named in 2024 is also odd to me.


A much simpler registry would just store reverse-key (node or address) → resolver address outside of the ENS tree — aka 1 slot on-chain. This registry would only be editable through DAO approved registrars using different proving mechanisms. These should be trustless and irrevocable.

*.addr.reverse (or *.reverse) can be wildcard, and it consults this simplified registry, and forwards the request to the resolver set for that address.

  1. There should be a simple storage contract L (literal) that stores reverse-key → name (string) and any EOA who wants string-based on-chain names can use that.

  2. There should be another contract W (wrapper) that has a map reverse-key → node which converts your reverse address namehash to a wrapped namehash, and then just calls names(), eliminating the need to store a name because it’s already stored on-chain.

  3. If you want a different proving mechanism, deploy another contract (eg. signature, contract ownership, etc.). For example, why can’t contracts just be asked for their own primary? For example, if a contract sets their reverse-key → F (contract forward), F can be a contract that simply calls C.primaryName() when it’s asked to F.resolve(C.addr.reverse, name()).

  4. If you set your reverse-key → custom resolver, then that contract can do whatever, and go off-chain.

If we’re considering using CCIP for reverse resolution, which requires client changes, we should use a solution like this, as it gives maximum control to the owner and minimizes gas.


Some examples:

  • If I have 100 EoAs, I should be able to deploy a contract Q that answers resolve(*.reverse.addr, name()) for any of those 100 addresses, and responds "raffy.eth". And then for each of those 100 EoAs, set reverse key → Q. I should also be able to provide some kind of delegated signature, so I can do this in 1 transaction.

  • If I deploy a new contract, I should be able implement a function primaryName() returns (string) that is my primary name. During the constructor, I claim that name by finding that registrar by name, and then calling claim() on it. That should cost almost nothing (eg. set resolver key → F (forwarding contract above).

  • For virtual names of basename B, EoAs should be able to set reverse-key → B and then that wildcard contract (likely off-chain) can provide the primary.

Additionally, name(node) could be generalized to name(node, chain).

A related idea from Expanding Beyond Mainnet was having a special coinType which indicates universal EVM deployment address (maybe 0x8000003c, evm bit + 60).

Thank you everyone for the rich and enlightening discussion on this topic. The insights shared have greatly contributed to evolving my understanding and perspective regarding the implementation of our proposal.

Firstly, I apologize for the unconventional format of my initial proposal. Instead of a traditional, well-structured proposal, I opted for a more illustrative, step-by-step guide. I realize now the importance of summarizing our concept more succinctly and cohesively:

At the core, our proposal aims to enable off-chain subnames to set primary names, facilitating reverse resolution. Our goal was to devise a solution that would seamlessly integrate without disrupting existing implementations while also optimizing the gas costs for users. Our primary focus was on off-chain mechanisms, although, with hindsight, I acknowledge the potential for extending this to include L2 chains through a more generalized approach.

The critical component for off-chain solutions is establishing an on-chain pointer. This pointer would direct the reverse resolution process to a customer resolver being utilized for off-chain resolutions. Our proposed custom resolver is relatively straightforward, featuring a resolve function capable of enabling address reverse resolution via CCIP-Read (similar to the forward resolution flow). However, incorporating @raffy ’s suggestion for a “delegated signature mechanism” could further enhance efficiency, although it would necessitate additional modifications.

Thank you @matoken.eth for sharing the EVM reverse resolution proposal, I really appreciate the approach you are taking by setting a generalized solution for L2s, “without resolver customisability”, and I completely agree with the way you approached this to account for smart contract accounts and not only EOAs. But I also agree with @lightwalker.eth that “ENS onboarding is incomplete if only forward resolution is configured. A complete ENS onboarding should include configuring a primary name that isn’t “sandboxed” to a particular app or chain” for off-chain names.

I am more than happy to continue working on this with you guys, but I am unsure of future steps. For example, I don’t know if it would be better to have two separate proposals, one being the EVM reverse resolution which is accounting for L2 chains (along with contract accounts) and one for off-chains solutions, or merge them both in a generalized big proposal, I am unsure of how feasible this would be, given the divergence between the two proposals.

1 Like

As a quick demo, I set the TOR as my reverse resolver and set the context to use this demo which returns the requesting IP address + the current time as the reverse name.

My resolver already supports CCIP everywhere so this just works:

1 Like

Exactly! I think we’re aligned on the flow, the only difference is that I was using a custom resolver instead of the TOR (btw cool name), but it should be pretty straightforward moving the implementation towards a generalised resolver, as it makes sense


I think I had missed this part when first reading the replies to the thread. This is a really interesting alternative, instead of having the user perform a one time transaction. Could you please elaborate on how this would work?

In general, I think the only feature we need from reverse resolution is address → resolver, as we must store at least one slot on-chain (as no default is possible) so why not just store a resolver, and call resolver.name(address) view returns (string) with CCIP-Read enabled.

However, setSubnodeRecord is sufficient. I wonder if it’s cheaper if you call it twice in the same tx and zero everything but the resolver.


I think the only necessary change (which I personally think is the current standard) is that ENSIP-10 resolution should apply universally — so if the reverse resolver is wildcard, you must call resolve(name()).

If this is a change, might as well make it: name(node, chain) returns (string)

With this clarification in middleware, ENS could offer free? offchain reverse at signup by setting their resolver and storing an off-chain record. Obviously, they would be encouraged to set an on-chain record, but it doesn’t have to be right away.

1 Like

It would be interesting to see if setSubnodeRecord is indeed cheaper. I was experimenting, and I didn’t try it. This was the cheapest flow I had found

Call the function recordExists() on the ENS Registry contract, the next steps would depend on its response:

  • True: Call ["function setResolver(bytes32 node, address resolver)”] on ENS Registry contract and pass the node and the off-chain resolver address. Costs 31,165 gas units.
  • False: Call [”function claimWithResolver(address owner, address resolver)”] on Reverse Registrar Contract and pass the address along with the off-chain resolver address. Costs 44,002 gas units and 81,002 gas units for the first time.

I’d have to review the contract to be sure, but setting just the resolver would be sufficient (ENSIP-10 doesn’t check owner), but there’s no setSubnodeResolver() so you must call setSubnodeRecord() (setOwner + setResolver) but the parent could zero owner before returning using a different registrar.

I’m not sure what it is you’re actually proposing; your first post reads like a description of how things work today, to me. What is it that you think should be changed?

It was intended as a proposal to standardize the flow for setting a primary name for subnames, not a proposal for changes

It does look like you can save some gas using this technique.

I deployed a BudgetReverseRegistrar and then set a resolver from an EOA, where:

function claim(address resolver) external {
    bytes32 hash = sha3HexAddress(msg.sender);
    ens.setSubnodeRecord(REVERSE_NODE, hash, address(this), resolver, 0);
    ens.setSubnodeOwner(REVERSE_NODE, hash, address(0));
}

This is just exploiting the fact that the owner of a reverse record is superfluous as the reverse registrar lets the rightful owner reclaim it.

If that resolver was an wildcard off-chain resolver, an external server would just answer resolve("{addr}.addr.reverse", name()) which is address → name.

85k gas for any length name (since no name stored on-chain)

The current reverse registrar for short names is 114k gas (34% more expensive) and 160k gas for a 33-character primary.

IMO, setting a wildcard on addr.reverse seems dangerous since it lets that resolver provide a name for any address, but individually setting each address’s resolver to the same wildcard seems fine. Minimizing gas to set your reverse resolver to an offchain database seems reasonable for on-boarding.


Like I described previously, using the same setup, new contracts could set their resolver to themselves during construction, and then just answer their own name(node) query.

Or set their resolver to some fixed deployment, like DotEthContractNameWildcardResolver, which on resolve("{addr}.addr.reverse", name()), reads addr.dotethname() from the corresponding contract and appends ".eth".

I believe Viem uses the UR but ethers was using non-ENSIP-10 resolution for lookupAddress(). I submit a PR to fix this.

1 Like