Back to articles

Building a Practical ENS Offchain Resolver with CCIP-Read

12 min read
ENSCCIP-ReadSolidityWeb3
Building a Practical ENS Offchain Resolver with CCIP-Read

Building a Practical ENS Offchain Resolver with CCIP-Read

ENS is usually presented as a naming system: alice.eth points to an address, a content hash, text records, or other profile data. In real products the harder question is not how to read one record from a smart contract. The harder question is how to manage many records, update them safely, support subnames, keep the user experience simple, and avoid paying gas for every small profile change.

The challenge becomes even more visible when ENS is used as infrastructure rather than a personal profile. Managing thousands of subnames, dynamic permissions, rotating wallets, reputation signals, or external identities quickly turns every onchain update into an operational burden.

This is where an ENS offchain resolver becomes useful.

An offchain resolver lets a smart contract keep the trust boundary onchain while moving dynamic data retrieval to a backend service. The contract does not blindly trust the backend response. Instead, it asks a CCIP-Read gateway for data, receives a signed result, and verifies that signature before returning the value to the ENS client.

In this article I will walk through a practical hybrid ENS resolver design: onchain address records have priority, and offchain resolution is used as a fallback through CCIP-Read. The examples are based on a production-style Go backend and a Solidity HybridResolver contract.

The Problem

The default ENS resolver model works well when records change rarely. A user sets an address or text record onchain, and clients read it directly. This is simple and strong, but it has a cost:

  • every record update needs a transaction;
  • profile data becomes expensive to manage at scale;
  • subname platforms need custom permission logic;
  • backend systems still need to mirror ENS data for product features;
  • users expect Web2-like updates, but ENS writes are Web3 transactions.

For a small number of names this is acceptable. For a product that manages many subnames, multichain addresses, eligibility rules, expirations, and profile fields, forcing every update through an onchain transaction becomes a UX and operations problem.

The goal is not to remove smart contracts from the system. The goal is to keep the contract as the verifier and source of trust, while allowing frequently changing data to live offchain.

Why This Matters

Most teams that build ENS-based products eventually hit the same trade-off:

  • onchain storage is transparent and easy to verify, but expensive to mutate;
  • offchain storage is flexible, but must not become an unverifiable source of truth;
  • users want ENS compatibility, not a custom lookup protocol;
  • wallets and dApps should still call standard resolver functions like addr(), text(), and contenthash().

CCIP-Read solves this specific integration problem. It gives contracts a standard way to tell compatible clients: "I cannot answer this call directly. Fetch this data from a gateway, then call me back with the gateway response."

For ENS, this means a resolver can implement normal ENS interfaces while using an offchain gateway behind the scenes.

Solution Overview

The architecture has three main parts:

  1. A Solidity resolver contract implementing ENS resolver interfaces and EIP-3668 OffchainLookup.
  2. A backend CCIP-Read gateway that decodes resolver calls and returns ABI-encoded ENS results.
  3. A signing key that signs short-lived gateway responses, with the contract verifying the signer on callback.

The resolver used here is hybrid:

  • if an address is set onchain, the contract returns it immediately;
  • if no onchain address exists, the contract reverts with OffchainLookup;
  • the client calls the gateway URL from the revert payload;
  • the backend builds the ENS response and signs it;
  • the client calls resolveWithProof;
  • the contract verifies the signature and returns the result.

That gives us a useful production property: high-value or permanent address records can be pinned onchain, while dynamic records can still be served offchain.

Why a Hybrid Model Matters

CCIP-Read is not a replacement for onchain storage. It is a hybrid model that combines the trust guarantees of smart contracts with the flexibility of offchain systems.

Critical records can remain onchain, while dynamic records can be served from external systems and verified by the resolver contract before being returned to clients.

This allows teams to keep smart contracts as the source of trust while avoiding the operational cost of storing every frequently changing attribute directly onchain.

Rather than choosing between fully onchain and fully offchain architectures, CCIP-Read provides a practical middle ground: verification remains onchain, while data management becomes significantly more flexible.

Resolution Flow

Resolution Flow

How CCIP-Read Works

CCIP-Read is defined in EIP-3668. The flow looks unusual the first time you implement it because the contract intentionally reverts.

The revert is not an error in the product sense. It is a protocol signal.

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

When the resolver cannot answer directly, it reverts with OffchainLookup. A CCIP-Read aware client catches that revert, sends callData to one of the gateway URLs, receives the gateway response, and then calls the callback function on the resolver.

It is worth noting that EIP-3668 does not prescribe how the contract should verify the gateway response. The resolveWithProof callback receives opaque bytes, and the contract can apply any verification logic it needs:

  • ECDSA signature from a trusted signer (the approach used in this article);
  • Merkle proof against a known state root;
  • DNSSEC proof for DNS-based name resolution;
  • L2 state proof for cross-chain data;
  • any other scheme that fits the trust model.

The protocol only defines the transport: revert, fetch, callback. What happens inside the callback is entirely up to the resolver. In this article we use ECDSA signatures because they are the simplest model for a backend gateway, but the architecture does not depend on that choice.

In the hybrid resolver, the resolve() function first checks whether the requested address exists onchain:

function resolve(bytes calldata name, bytes calldata data)
    external
    view
    returns (bytes memory)
{
    bytes4 selector = bytes4(data);

    if (selector == SELECTOR_ADDR_LEGACY) {
        bytes32 nodeParam = abi.decode(data[4:], (bytes32));
        address addrValue = addr(nodeParam);
        if (addrValue != address(0)) {
            return abi.encode(addrValue);
        }
    }

    if (selector == SELECTOR_ADDR_MULTICOIN) {
        (bytes32 nodeParam, uint256 coinType) =
            abi.decode(data[4:], (bytes32, uint256));
        bytes memory addrBytes = addr(nodeParam, coinType);
        if (addrBytes.length > 0) {
            return abi.encode(addrBytes);
        }
    }

    bytes memory callData = abi.encodeWithSelector(
        IResolverService.resolve.selector,
        name,
        data
    );

    revert OffchainLookup(
        address(this),
        urls,
        callData,
        HybridResolver.resolveWithProof.selector,
        abi.encode(callData, address(this))
    );
}

There are two important details here.

First, onchain data wins. If a user or backend has committed an address to the resolver contract, the gateway is not queried for that address.

Second, extraData contains both the original callData and the resolver address. That binds the gateway response to this resolver instance and prevents a signature created for one resolver from being reused against another resolver.

The Role of the Universal Resolver

Clients do not call your custom resolver directly. They call the ENS Universal Resolver, a single entry point contract deployed by the ENS team.

The Universal Resolver does three things:

  1. Finds the correct resolver for a given name by traversing the ENS registry.
  2. Calls resolve(bytes,bytes) on that resolver.
  3. If the resolver reverts with OffchainLookup, the Universal Resolver catches it, rewraps the revert with its own address as the sender, and propagates it back to the client.

This rewrapping is important. When a CCIP-Read aware client receives the OffchainLookup revert, the sender field points to the Universal Resolver, not your custom resolver. The client fetches data from the gateway, then calls the callback on the Universal Resolver. The Universal Resolver verifies that the original revert came from a legitimate resolver and forwards the callback to your resolver's resolveWithProof.

From your resolver's perspective, nothing changes. You still revert with OffchainLookup and implement resolveWithProof. But you should be aware that the Universal Resolver sits between the client and your contract. This is why the extraData in the OffchainLookup revert includes address(this): it lets your callback verify that the response was intended for your resolver instance, even though the call was routed through the Universal Resolver.

If you are testing resolution end-to-end, call the Universal Resolver's resolve() function, not your resolver directly. That is the path real clients take.

Verifying the Gateway Response

The backend response is not accepted because it came from a known URL. It is accepted because it carries a valid signature from an authorized CCIP signer.

The callback verifies the response like this:

function resolveWithProof(bytes calldata response, bytes calldata extraData)
    external
    view
    returns (bytes memory)
{
    (bytes memory callData, address sender) =
        abi.decode(extraData, (bytes, address));

    if (sender != address(this)) revert SenderMismatch();

    (address signer, bytes memory result) =
        SignatureVerifier.verify(callData, sender, response);

    if (!ccipSigners[signer]) revert InvalidCCIPSigner();

    return result;
}

The signature hash follows the same format used by the ENS offchain resolver reference implementation:

function makeSignatureHash(
    address target,
    uint64 expires,
    bytes memory request,
    bytes memory result
) internal pure returns (bytes32) {
    return keccak256(
        abi.encodePacked(
            hex"1900",
            target,
            expires,
            keccak256(request),
            keccak256(result)
        )
    );
}

This is intentionally not EIP-712. It is a compact hash for CCIP-Read responses. The contract and backend must implement the exact same hash construction, otherwise the callback will fail.

Backend Implementation

The backend gateway receives a JSON request from the CCIP-Read client:

{
  "data": "0x...",
  "extraData": "0x..."
}

The backend decodes the request, extracts the original resolver call, resolves the requested ENS function, signs the result, and returns:

{
  "data": "0x..."
}

The returned data is ABI-encoded as:

(bytes result, uint64 expires, bytes sig)

The result field is not arbitrary JSON. It must be the ABI-encoded return value for the original ENS resolver function.

For example:

  • addr(bytes32) returns an ABI-encoded address;
  • addr(bytes32,uint256) returns ABI-encoded bytes;
  • text(bytes32,string) returns ABI-encoded string;
  • contenthash(bytes32) returns ABI-encoded bytes.

The backend dispatches by function selector:

func (r *Resolver) ResolveRequest(data []byte) ([]byte, error) {
	if len(data) < 4 {
		return nil, fmt.Errorf("data too short for function selector")
	}

	selector := data[0:4]

	switch {
	case bytes.Equal(selector, selectorAddr):
		return r.resolveAddr(data[4:])
	case bytes.Equal(selector, selectorAddrMultichain):
		return r.resolveAddrMultichain(data[4:])
	case bytes.Equal(selector, selectorText):
		return r.resolveText(data[4:])
	case bytes.Equal(selector, selectorContentHash):
		return r.resolveContentHash(data[4:])
	default:
		return nil, fmt.Errorf("unsupported function selector: %x", selector)
	}
}

For ENS address resolution, two selectors matter most:

addr(bytes32)          -> 0x3b3b57de
addr(bytes32,uint256)  -> 0xf1cb7e06

The second form is the multichain address resolver from ENSIP-9 / EIP-2304. Ethereum uses coinType=60.

Practical Example: Ethereum Address, coinType 60

For addr(bytes32,uint256), the backend receives:

0xf1cb7e06
<32-byte ENS node>
<32-byte coinType>

For Ethereum, coinType is 60.

The resolver service decodes the node and coin type, normalizes EVM-style coin types, reads the address bytes, and returns ABI-encoded bytes.

func (r *Resolver) resolveAddrMultichain(params []byte) ([]byte, error) {
	if len(params) < 64 {
		return nil, fmt.Errorf("invalid params length")
	}

	nodeBytes := params[0:32]
	coinTypeBig := new(big.Int).SetBytes(params[32:64])

	if !coinTypeBig.IsUint64() {
		return encodeBytes(nil), nil
	}

	coinType := coinTypeBig.Uint64()
	coinTypeNormalized := coinType

	if coinType == coinTypeDefaultEVM {
		coinTypeNormalized = coinTypeETH
	} else if coinType >= 0x80000000 {
		coinTypeNormalized = coinType & 0x7fffffff
	}

	addressBytes, err := r.repository.GetMultichainAddress(models.QueryParams{
		Node:     nodeBytes,
		CoinType: &coinTypeNormalized,
	})
	if err != nil {
		addressBytes = nil
	}

	if len(addressBytes) == 0 && coinTypeNormalized == coinTypeETH {
		addressBytes, _ = r.repository.GetAddress(models.QueryParams{
			Node: nodeBytes,
		})
	}

	return encodeBytes(addressBytes), nil
}

The important part is not the repository call. The important part is the output format. addr(bytes32,uint256) returns bytes, so the backend must return ABI-encoded dynamic bytes:

func encodeBytes(value []byte) []byte {
	padding := (32 - (len(value) % 32)) % 32
	result := make([]byte, 64+len(value)+padding)

	big.NewInt(32).FillBytes(result[0:32])
	big.NewInt(int64(len(value))).FillBytes(result[32:64])
	copy(result[64:], value)

	return result
}

For an Ethereum address, value must be exactly 20 bytes. The final ABI return value is:

offset = 32
length = 20
data   = 20-byte Ethereum address
padding to 32-byte boundary

A common mistake is returning a hex string like "0xabc..." from the gateway. That is wrong for CCIP-Read callbacks. The gateway must return bytes that the resolver callback can return directly to the original ENS client.

Signing the Backend Response

After the backend has the ABI-encoded result, it creates the same hash expected by the Solidity verifier:

func (s *Signer) CreateSignatureHash(
	target common.Address,
	expires uint64,
	request []byte,
	result []byte,
) common.Hash {
	requestHash := crypto.Keccak256Hash(request)
	resultHash := crypto.Keccak256Hash(result)

	expiresBytes := make([]byte, 8)
	big.NewInt(int64(expires)).FillBytes(expiresBytes)

	packed := append([]byte{0x19, 0x00}, target.Bytes()...)
	packed = append(packed, expiresBytes...)
	packed = append(packed, requestHash.Bytes()...)
	packed = append(packed, resultHash.Bytes()...)

	return crypto.Keccak256Hash(packed)
}

Then it signs the hash and ABI-encodes the full CCIP response:

hash := signer.CreateSignatureHash(targetAddress, expires, callData, result)
signature, err := signer.Sign(hash)
if err != nil {
	return err
}

responseData, err := utils.EncodeResponse(result, expires, signature)
if err != nil {
	return err
}

resp := CCIPReadResponse{
	Data: "0x" + hex.EncodeToString(responseData),
}

The contract later decodes responseData as:

(bytes memory result, uint64 expires, bytes memory sig) =
    abi.decode(response, (bytes, uint64, bytes));

This is the core invariant of the system: the backend can serve data, but the contract decides whether the response is valid.

Onchain Address Writes

The hybrid resolver also supports moving selected records onchain with setAddr. This is useful when a user wants stronger persistence or when a product wants to pin important records.

The write path uses a separate signer set from the CCIP-Read signer set:

  • CCIP signers are hot keys for short-lived read responses;
  • setAddr signers are colder authorization keys for onchain writes.

The contract verifies a signed payload:

function setAddr(
    bytes32 node,
    uint256 coinType,
    bytes memory addressBytes,
    uint64 deadline,
    uint256 nonce,
    bytes calldata signature
) public virtual {
    if (deadline < block.timestamp) revert SetAddrSignatureExpired();
    if (deadline > block.timestamp + MAX_SETADDR_TTL) revert DeadlineTooFar();
    if (nonce != nonces[node]) revert InvalidNonce();

    address signer = ECDSA.recover(
        setAddrSignatureHash(node, coinType, addressBytes, deadline, nonce),
        signature
    );

    if (!_isValidSetAddrSigner(signer)) revert InvalidSigner();

    nonces[node] = nonces[node] + 1;
    _setAddr(node, coinType, addressBytes);
}

This avoids letting arbitrary users write arbitrary onchain records. The backend authorizes the exact payload: node, coin type, address bytes, resolver address, deadline, and nonce.

Testing

The minimum test set for this architecture should cover the full CCIP flow:

  • resolve() returns onchain address when it exists;
  • resolve() reverts with OffchainLookup when it needs gateway data;
  • gateway decodes resolve(bytes,bytes) call data correctly;
  • addr(bytes32) returns a 32-byte ABI address;
  • addr(bytes32,uint256) returns ABI-encoded dynamic bytes;
  • coinType=60 returns 20-byte Ethereum address data;
  • unknown coin types return empty bytes instead of invalid data;
  • callback rejects expired signatures;
  • callback rejects signatures from unauthorized signers;
  • callback rejects signatures for another resolver address.

For backend tests, I like to decode the gateway response manually. It catches many ABI mistakes earlier than integration tests with a wallet library.

Common Mistakes

The most common mistake is confusing JSON response encoding with ABI return encoding. The gateway returns JSON only as a transport wrapper. The data field inside that JSON must be ABI data.

Another mistake is signing the wrong request bytes. The contract verifies the hash over the original callData, not a reserialized or partially decoded version of the request.

It is also easy to forget that addr(bytes32) and addr(bytes32,uint256) have different return types. The legacy function returns address. The multichain function returns bytes.

Finally, do not use the same key for every purpose. Read-response signing and onchain write authorization have different risk profiles.

Practical Applications Beyond ENS Profiles

ENS is commonly associated with names, addresses, and profile records. Offchain resolvers become more interesting when ENS is used as an identity and infrastructure layer.

One practical use case is dynamic wallet management for exchanges, payment providers, custodial systems, and other offchain businesses. Users can interact with a stable name such as payments.company.eth, while the resolver returns destination wallets managed by backend systems. That makes it possible to rotate wallets, migrate infrastructure, separate hot and cold wallet flows, or replace compromised wallets without changing the user-facing ENS name.

The same pattern applies to reputation, credentials, and compliance data. Many identity attributes change frequently: verification status, reputation score, participation history, accreditation level, or task completion state. Keeping this data fully onchain is often expensive and operationally awkward. A CCIP-Read resolver can expose signed identity attributes while the contract verifies the authenticity of the response before returning it to the client.

AI agent discovery is another useful direction. Agent metadata such as supported models, available tools, service endpoints, pricing, and protocol capabilities can change often. Instead of updating a smart contract every time the agent configuration changes, an ENS name such as research-agent.eth can resolve signed metadata through an offchain gateway while preserving a verifiable trust path.

The common pattern is the same in all three cases: ENS provides the stable identifier, the backend manages dynamic state, and the resolver contract verifies the signed response.

Security Considerations

CCIP-Read does not remove the need for security review. It changes where the trust boundaries are.

A few rules I would keep strict:

  • Bind signatures to the resolver contract address.
  • Include an expiration timestamp in every gateway response.
  • Keep CCIP response TTLs short.
  • Maintain an allowlist of CCIP signer addresses onchain.
  • Rotate hot CCIP signing keys when needed.
  • Use separate signers for onchain write authorization.
  • Validate address byte lengths before storing or writing records.
  • Treat external eligibility or registration APIs as untrusted inputs.
  • Test replay protection around nonces and deadlines.

The contract should verify signatures, not the client. Clients are part of the transport flow, but they are not the trust anchor.

Limitations

An offchain resolver depends on gateway availability. If all gateway URLs are down, fresh offchain reads will fail. Multiple gateway URLs help, but they do not remove the operational requirement.

CCIP-Read also depends on client support. Modern ENS-aware libraries and clients support it, but custom integrations may need explicit handling for OffchainLookup.

There is also a latency trade-off. A direct onchain resolver read is one RPC call. CCIP-Read may require an additional HTTP request and callback simulation.

The hybrid model reduces these risks for important address records by allowing them to be pinned onchain, but it does not make offchain data magically equivalent to onchain storage.

Practical Experience

In practice, the hardest part of building an ENS offchain resolver was not the Solidity code. The hard parts were ABI correctness, signature consistency, and deciding which data should live onchain.

The coinType=60 path is a good example. From a product perspective, it is just "return the Ethereum address." From an ENS resolver perspective, addr(bytes32,uint256) must return dynamic bytes, not an address, and not a string. That small distinction is enough to break resolution in clients.

The hybrid approach has worked well because it gives engineers a clear operational model:

  • use offchain records for fast updates and product-managed profiles;
  • use onchain records for high-confidence address data;
  • keep the contract responsible for verification;
  • keep backend responses short-lived and signed.

This is the balance I prefer for production ENS systems. It avoids pretending that everything must be onchain, while also avoiding a fully trusted backend resolver.

Conclusion

ENS offchain resolution is useful when you need ENS compatibility without forcing every record update into an onchain transaction. CCIP-Read gives you the standard bridge between smart contracts, wallets, and backend services.

The key engineering lesson is simple: the backend may prepare the answer, but the contract must verify it.

For a production implementation, I recommend starting with a small resolver surface:

  • addr(bytes32);
  • addr(bytes32,uint256);
  • text(bytes32,string);
  • contenthash(bytes32).

Then add more profiles only when the product needs them. Keep ABI encoding tests close to the resolver code, separate read signers from write signers, and treat every signed response as a security boundary.

Useful Resources