Gasless Offchain for DNS and ENS via TheOffchainResolver

I’d like to share a new resolver setup for offchain resolution + some related tech.

  1. TheOffchainResolver.sol
  2. ezccip.js
  3. TheOffchainGateway.js

First, TheOffchainResolver.sol is a trustless singleton resolver contract that can dispatch to an offchain server for both DNS or ENS. The current mainnet deployment only supports DNS, as I’m waiting for the bug in the OffchainDNSResolver to get fixed, but you can use the DNS part right now.

  • This enables DNS to serve offchain names gas-free
    • Using a single wildcard TXT record, you can serve an entire subtree.
  • This enables on-chain ENS names to serve offchain names by setting 2 records (resolver + text record)
    • No need to deploy a custom resolver contract
    • Features: multicall(), PublicResolver fallback, etc.

It expects the CCIP-Read server confirms to a standard protocol and the queried name has valid context:

  • Create a context
    • CONTEXT = ${SIGNER} ${SERVER_ENDPOINT}
      • SIGNER = 0x-prefixed public address of your signing key
      • SERVER_ENDPOINT = URL of your CCIP-Read server
    • Example: 0xd00d726b2aD6C81E894DC6B87BE6Ce9c5572D2cd https://raffy.xyz/ezccip/
  • Set the context
    • “I have a DNS name” → TXT = ENS1 0xa4407E257Aa158C737292ac95317a29b4C90729D ${CONTEXT}
    • “I have an ENS name” → PublicResolver.setText("ccip.context", CONTEXT)

Example: ezccip.raffy.xyz DNS TXT → Resolver or ENS


Second, ezccip.js is “turnkey CCIP-Read Handler for ENS”. It’s a single function that you feed {sender, data} from POST/GET, a signing key, and a callback for name → Record? and it follows the TheOffchainResolver protocol and produces a response {data} or throws.

The github readme or example server.js explain this better.

Supports resolve(multicall()) and multicall(resolve())

Tracks call history and handles partial errors:

  • Call: resolve("raffy.eth", multicall([text("avatar), poop()])
    History.toString(): resolve("raffy.eth").multicall(2)[text(name) "Error: unsupported ccip method poop()"]
  • Imagine multicall() with 100 records, of which half fail, you don’t want to see 50 stack traces.

Third, TheOffchainGateway.js uses ezccip.js and provides a single server that hosts multiple endpoints with various routers. Here are some examples:

It’s extremely easy to configure:

  1. npm i
  2. edit config.js and pick your routers
  3. npm run start
  4. setup context (see above)
5 Likes

Great work @raffy :clap: :clap: :clap:

1 Like

Very cool!

Do you mean the reverse - that it only supports .eth? Otherwise, how does it support DNS given the bug?

1 Like

I was able to work around it.

I didn’t deploy the ENS side because of gas. It was 50 bucks and my first deployment was botched. The full contract works For an ENS, you’d make the following transactions::

node = namehash("raffy.eth")
PR.setText(node, "ccip.context", "${SIGNER} ${ENDPOINT}")
ENS.setResolver(node, 0xa4407E257Aa158C737292ac95317a29b4C90729D)

Then sub.raffy.eth will hit your ENDPOINT.

1 Like

Curious choice to use the public resolver as metadata storage - why not store it in the contract itself! What will you do when a new version of the public resolver is deployed?

Since any PR (or trusted resolver) will work, it seemed okay to use a specific deployment that already exists and respects ENS ownership.

But you’re right— in the contract would make it simpler (no write-through delegate stuff).

1 Like

I deployed the TheOffchainENSResolver to Goerli.

Here is an example: ezccip.eth or a random subdomain a.b.c.d.e.f.ezccip.eth

  • CONTEXT = 0xd00d726b2aD6C81E894DC6B87BE6Ce9c5572D2cd https://raffy.xyz/ezccip/ens-goerli
    • Transaction #1: ens.setResolver() = 0x2e513399b2c5337E82A0a71992cBD09b78170843 (TOR Goerli)
    • Transaction #2: setText("ccip.context", CONTEXT)
  • Server: demo server from ezccip.js

It’s a full resolver, like the PR, but it needs ABI() support, and versioning.

It’s hybrid in that on-chain records win out over off-chain records.

It has the ability to toggle a null record on-chain using toggleNull(), so you can explicitly say “there is no avatar” rather than “on-chain is null, try off-chain”. Although, it might be better to set this at node-level.

It uses custom storage and saves approximately 1 slot on average.

The contract is trustless. The security comes from the following logic: when resolve() is called and it needs OffchainLookup, it finds the basename from the queried name, which is the first ancestor that has it’s on-chain resolver set to the TOR. Once found, it can safely read and parse the ccip.context, revert, and wait for a signed response.


TKN volunteered to test out the DNS TOR.

Their team set 2 DNS records (1 for @ and 1 for *) for tkn.xyz and used their existing CCIP-Read server, which confirms to the TOR (TheOffchainResolver) protocol, and they’re entire ecosystem is now available over DNS too.

They had already DNS imported tkn.xyz, so once the final TOR is ready, they can set a context on-chain and bypass the OffchainDNSResolver relay.


Few more examples using the gateway demo code. These pass through the ENS version of the TOR on Goerli.

2 Likes

We deployed a combo (ENS+DNS) TOR to mainnet for experimentation.

It’s not the final version as it’s working around the OffchainDNSResolver issue but it’s free for DNS and ~$15 of gas for ENS to play with.

It appears to be functional but has a mistake in it’s fallback logic which prevents single-record fallbacks from working (only the Multicall version was tested.) We will deploy a fix soon that properly supports both internal and external fallbacks so I wouldn’t store any data in it beyond setting a "ccip.context".

Assuming you query text("raffy.eth", "avatar"), the TOR has the following fallback priority, where first non-null record wins:

  1. on-chain "_.raffy.eth" or custom namehash (external fallback, eg. an alias)
  2. on-chain "raffy.eth" (internal fallback, using TOR as a full resolver)
  3. off-chain "raffy.eth" (CCIP-Read, using TOR protocol)

Here are some mainnet demos:

My setup for tog.raffy.eth:

  1. 32k gas ($3) set resolver to TOR
  2. 100k gas ($10) set text("ccip.context") = 0xd00d726b2aD6C81E894DC6B87BE6Ce9c5572D2cd https://raffy.xyz/tog/multi

Instructions:

  1. setup context for ENS or DNS name
  2. run TOR-compat CCIP server: TheOffchainGateway.js or ezccip.js
  3. :tada:
1 Like

@raffy can you pls brand you resolver/js project as ezccip or ____?

The Offchain Resolver sounds cool but that’s only confusing average users & search engine… As the meme goes around, devs are really bad at naming things… like calling their dog as The Dog, or a dao as The DAO. :rofl:
& 2nd, TOR is “The Onion Router” in context of ENS contenthash and all web2+3…

We’ve couple of different Offchain resolvers & we’re bad at naming too, your Offchain resolver works similar to our dev3.eth resolver, it can work of both ENS & DNS but it wasn’t designed for DNS specifically.

1 Like

LOL I didn’t realize it was TOR / TOG until I started saying it, haha! I’m open to it.

The TOR is a trustless piece of developer plumbing. It is last ENS CCIP-Read contract needed-deployed until there are protocol improvements.

Another feature of the TOR is that it forces a log when context (signer keys) are changed.


Just throwing it out there: an useful client-side ENS feature would be showing the first/last time you saw a name and whether it was different from the last time you saw it over some fixed record set (and/or have a resolver that can prove that.)

Another cool piece of tech with a bad name: the XOR: eXclusivelyOnchainResolver.

The XOR lets you debug any hybrid resolver.

  • Scheme: [any-ens-name].onchain.eth

Example:

  • debug.eth is using the TOR
  • fixed.debug.eth hits the /fixed demo
  • on.fixed.debug.eth
    • on-chain, I set:
      • text("avatar") → purple cow
      • addr(60) → 0x9b87
    • Note: this demonstrates the TOR bug described earlier:
      • addr(60) incorrectly shows as 0x5105 because it’s a single addr(60) call
      • text(avatar) is part of a multicall, and gets hybrid treatment
  • on.fixed.debug.eth.onchain.eth
    • only shows the (2) records set

What is it doing?

  • XOR.resolve("[name].[basename]", call) → findENSIP10Resolver(name).staticcall(call)
    where
    • basename is first name or ancestor whose resolver is equal to itself (this finds the name of the XOR)
    • findENSIP10Resolver() is ENSIP-, eg. UR.findResolver()
    • call’s node is replaced by namehash(name)
    • it doesn’t call resolve()
    • it supports resolve(multicall())
1 Like

TOR has gone through some iteration and is nearly ready for deployment. I’d like to just describe a few of the features that came out of experimenting with a hybrid resolver.


(1) A hybrid resolver can mean multiple things:

  1. it serves a mix of on-chain and off-chain records
  2. it handles both ENSIP-10 and non ENSIP-10
  3. it always reverts for CCIP-Read or conditionally reverts

TOR does all of these.

(2) Testing CCIP-Read is somewhat complicated because it requires a resolver, a CCIP server, and an ENS ecosystem. I created a new project adraffy/blocksmith.js which combines Foundry + ethers into a simple testing framework. Sprinkle in some resolverworks/ezccip.js, and it’s pretty easy to start a private anvil network, deploy ENS, build a tree of names, deploy a TOR, spin up a CCIP-Read server, register the server with TOR, deploy a PR, and run some tests end-to-end.

(3) TOR ended up having a complex but useful resolution strategy.

  1. any owned node can toggleOnchain(boolean) where the default is false.

    • When a node is on-chain, it never throws OffchainLookup and will only answer with data available on-chain.
  2. every node can have fallback which is set by storing some bytes into an exotic coinType: keccak("fallback")

  3. all queries respect the following record priority:

    • on-chain nodes: TOR > Fallback
    • hybrid nodes: TOR > Fallback > CCIP
  4. there are (4) fallback styles, which are queried when a record is requested, and it doesn’t exist in TOR itself, before going off-chain:

    1. if fallback is 32-bytes, it is considered another node, and it will lookup the resolver of that node, and ask it for the same record.
      • This is an alias.
    2. if fallback is 20-bytes, it is considered a resolver, and it will ask for the same record (same node) in a different resolver.
      • This is traditional fallback.
    3. if fallback is non-zero bytes, fallback is disabled.
    4. if fallback is null (default), it will do the same as (1) except the node will be namehash("_." + name)
      • This means you could set _.name.eth to the PR, edit your names w/o any hybrid artifacts (I’ll explain below), but they show up under name.eth
  5. TOR will disassemble resolve(multicall(a, b, ...)) and try to answer each individual call separately. If it can, while obeying the above rules, it will answer without invoking CCIP-Read.

    • If any record is missing, and the node isn’t on-chain, it will do CCIP-Read, then replace any returned records, obeying the above rules.
    • If the name was on-chain, those records will answer null.
    • This is significantly more efficient than using UR.resolve(bytes,bytes[])
  6. TOR implements all of the normal resolver functions (addr, text, etc), and will answer them as-if on-chain was true (eg. fallback still applies.)

  7. You can apply (2) optional “lenses” to resolve(call)

    • if you prefix the call with 0x000000FF — off, you only get CCIP-record(s), or null(s) if on-chain is true.
    • if you prefix the call with 0xFFFFFF00 — on, you only get on-chain record(s)
  8. TOR considers bytes/string.length = 0, address(0), and pubkey (x: 0, y: 0) to be null.

(4) TOR uses TinyKV which is storage efficient and saves about 1-slot per record for records larger than 1 slot.

  • TinyKV is also a flat storage layout, where the slots of any record are the hash of the calldata of the record that reads it.
  • eg. sstore(keccak(abi.encodeSelector(addr, [node, 60])), 0x5105...)

The first thing you’ll notice when playing with a hybrid resolver in a traditional editor is that you can’t tell which records are on-chain or which ones are CCIP-read. Lenses allows a hypothetical editor to pull each column of the table below separately:

Record On-chain Off-chain Hybrid
text("avatar") https://…/1.jpg https://…/2.jpg https://…/1.jpg
addr(60) — 0x5105 0x5105
text("description") something — something

Here are some additional TOR use-cases:

  • If you want to try out TOR, you switch your resolver and set your fallback to your old resolver. You still have all your on-chain records, but you can now spin-up an off-chain server and provide virtual subdomains and/or virtual records on your existing name.

  • Maybe you don’t want any of that CCIP stuff on your basename, and want to have the share the availability as mainnet itself, so you toggleOnchain, but you can still provide virtual subdomains.

  • Maybe you have 100 names and want them to share the same set of records. Set their resolver to TOR and use node-based fallback.


However, the main purpose of TOR is:

  1. non-imported DNS names can link to an off-chain server and serve any record type, not just addr(60).
    • If you use a wildcard DNS record, you can serve a complete namespace.
  2. on-chain ENS names can link to an off-chain server with two records: resolver → TOR, and setText("ccip.context") and also benefit from all of the features above.

Neither requires deploying a contract. TOR is all you need.

1 Like

CCIP Context sounds great because we are currently deploying multiple expensive mainnet contracts just to use a different endpoint.

it would be nice to keep the DNS TXT records human readable:

ENS1 resolver=ccip.ens.eth ccip[signer]=0xd00d726b2aD6C81E894DC6B87BE6Ce9c5572D2cd ccip[url]=https://raffy.xyz/ezccip/

I don’t think ENS1 needs to look like raw calldata all the time. if you merge multiple ENS1 records then you have even more than 255 chars available. editing these TXT records may be the primary way some people interact with ENS.

ENS1 resolver=dns.ens.eth addr=0x0000 text[avatar]=http...

1 Like

I considered doing something like this, but for TOR, this part is a 1-time setup thing, and you’d only need 2 records: @ and *, both identical. I also wanted the context to be the same format if you have an ENS name, and human readable.

Early TOR versions had the signer in an addr() (20 bytes vs 42) and the endpoint in a text() but a single record is much simpler and easier to grok.

With TOR, all records are offchain, provided by CCIP-server in the context and verified to match the context’s signer address. They can be of arbitrary quantity and size, limited only by RPC capacity.

For example, raffy.xyz has the TXT:

ENS1 0x828ec5bDe537B8673AF98D77bCB275ae1CA26D1f 0xd00d726b2aD6C81E894DC6B87BE6Ce9c5572D2cd https://raffy.xyz/tog/fixed

where 0x828ec5bDe537B8673AF98D77bCB275ae1CA26D1f is an alpha TOR on mainnet and

0xd00d726b2aD6C81E894DC6B87BE6Ce9c5572D2cd https://raffy.xyz/tog/fixed

is the context.


The only issue at the moment with Offchain DNS resolution is that dnssec-oracle.ens.domains is slow and probably needs some short-term caching.

Here is a snapshot from my resolver where I first probe addr(60) and then immediately multicall(40) records, but both calls share a 2+ second resolution latency:

the contents of ENS1 record could/should be cached for as long as the TTL of the DNS TXT record? you could recommend people set longer TTL for better performance

Here’s another spin on the TOR / context concept.

I call this the OffchainTunnel

Here’s its first blocksmith.js test

What does it do?

  • Setup:
    • You claim a bytes4 selector and assign it a pointer to a context.
    • If you don’t have a context yet, you can claimAndSetContext() in one tx
    • Each owner can have uint96 indexed contexts.
    • The pointer of a selector is (owner, index)
    • selector → (owner, index) → context → (signer, endpoint)
  • Usage
    • You can CCIP-Read the contract with any function matching selector
    • inside of fallback(), it looks up the corresponding context, and does an OffchainLookup() to the endpoint
    • On response, it verifies it’s timely and matches your signer, then returns the result.
  • Demo:
    • I make an CCIP server, that has a function f(uint256 a, uint256 b) returns (uint256) and give it an implementation: 1000a+b
    • I set my context 0 to the address of my CCIP server and it’s signing key.
    • I claim f.selector on the OffchainTunnel using my context 0
    • I call f(69, 420) with CCIP-Read enabled and get 69420
2 Likes

I deployed an OffchainTunnel on Sepolia

Here’s a quick example, but there’s a ton of use-cases.

(1) I spun up a TOR-compatible CCIP-Read server. This is zero-effort with TOG or ezccip.js.

I made a simple router which handles the function fetchFlatJSON(string url) returns (tuple(string, string)[]).

fetchFlatJSON() basically fetches the provided url, parses it as JSON regardless of status code, and then returns a [k: string, v: string][] where the keys are the keypaths into the JSON and the values are the corresponding JSON primitives converted to a string. This conversion just makes it easy to be passed through Solidity.

For example, {a: 1, b: [2, "chonk"]} would correspond to: [ [ 'a', '1' ], [ 'b.0', '2' ], [ 'b.1', 'chonk' ] ]


(2) I registered the corresponding function selector: 0xe81d0c1c on the contract and gave it the context of my server, since this was my first registration.

If I claimed another selector, I could just point it to my index = 0 which is the same (signer, endpoint).

The /tog/tunnel/sot path is my TOG endpoint for the tunnel router targeting the sot (Sepolia OffchainTunnel) contract.

TunnelClaim


(3) I made a demo where I do the following:

import {ethers} from 'ethers';
let provider = new ethers.InfuraProvider(11155111);
let contract = new ethers.Contract('0xCa71342cB02714374e61e400f172FF003497B2c2', [
	'function fetchFlatJSON(string url) view returns (tuple(string, string)[])'
], provider);
let res = await contract.fetchFlatJSON('https://api.gmcafe.io/metadata/gmoo/331.json', {enableCcipRead: true});

Notice, there is no mention of my implementation. I’m simply calling the function fetchFlatJSON() which I associated above on the OffchainTunnel contract on Sepolia.


(4) This code returns the flattened contents of the requested URL.

[
  [ 'id', '331' ]
  [ 'name', 'đź”’Highland Cow #331 - Sir Tenderloin, PhD' ]
  [ 'image',  'https://gmcafe.s3.us-east-2.amazonaws.com/gmoo/original/331.png']
  [ 'external_url', 'https://gmcafe.io/moo/331' ]
  [ 'animation_url', 'https://api.gmcafe.io/gmoo-chooser?id=331' ]
  [ 'artist', 'Ben Colefax' ]
  [ 'attributes.0.trait_type', 'Delivery' ]
  [ 'attributes.0.value', 'Custom' ]
  [ 'attributes.1.trait_type', 'Color' ]
  [ 'attributes.1.value', 'Purple Gradient' ]
  ...
  [ 'attributes.15.trait_type', 'Status' ]
  [ 'attributes.15.value', 'Locked đź”’' ]
  [ 'info.type', 'custom' ]
  [ 'info.domain', 'moo331.gmcafe.art' ]
  [ 'info.fg.color', '#db9ded' ]
  [ 'info.bg.color', '#a992b5' ]
  [ 'info.title', 'Sir Tenderloin, PhD' ]
]

Here is a browser version of the implementation above.

Note: this example is silly, it’s just an cryptographically-signed, open-proxy for flattened JSON data, but the point is that it’s an API defined in a global registry that anyone can call and determine the signer.

TOR deployed to Mainnet at 0x84c5AdB77dd9f362A1a3480009992d8d47325dc3

I changed raffy.eth resolver to the new TOR and then set the fallback to the old PRv2, restoring all my on-chain records. My name is currently hybrid: both my "name" and "description" records are off-chain (see screenshot + code below.)

I set text("ccip.context) to "0x2Fc3Fd8444211091De2DF9052741f80e4d78eeee https://raffy.xyz/tog/raffy/e1" which corresponds to my server’s signing key (eeee), my server (raffy.xyz), the router handling my requests (raffy), and a key for the mainnet v1 deployment (e1).

I created mirror.raffy.eth as a real subdomain and also gave it the TOR resolver. On the TOR, I called toggleOnchain() and I changed the fallback to the namehash of nick.eth.

I launched a TOG and created a custom router for my name using a new schema which is like a mix of JSON-notation for records and router-notation for ENSIP-10 handlers.

Code

  • I’m also serving some routers I’ve previous shown wiki and eth.cb.raffy.eth.
  • A static route at moo.raffy.eth
  • And an inline handler at chonk.raffy.eth (which is internally mirroring chonk239.nft-owner.eth and then replacing the avatar since it used an avatar string)

I also created few new demos and updated some others: