Volver a artículos

Construyendo un Resolver ENS Offchain Práctico con CCIP-Read

12 min de lectura
ENSCCIP-ReadSolidityWeb3
Construyendo un Resolver ENS Offchain Práctico con CCIP-Read

Construyendo un Resolver ENS Offchain Práctico con CCIP-Read

ENS suele presentarse como un sistema de nombres: alice.eth apunta a una dirección, un content hash, registros de texto u otros datos de perfil. En productos reales, la parte más difícil no es leer un único registro desde un smart contract. Lo complicado es administrar muchos registros, actualizarlos con seguridad, soportar subnombres, mantener una buena experiencia de usuario y evitar pagar gas por cada cambio chico en un perfil.

El desafío se vuelve más evidente cuando ENS se usa como infraestructura y no solo como perfil personal. Administrar miles de subnombres, permisos dinámicos, rotación de wallets, señales de reputación o identidades externas convierte cada actualización onchain en una carga operativa.

Ahí es donde un resolver ENS offchain se vuelve útil.

Un resolver offchain permite que el smart contract mantenga el límite de confianza onchain mientras mueve la lectura de datos dinámicos a un servicio backend. El contrato no confía ciegamente en la respuesta del backend. En cambio, le pide datos a un gateway CCIP-Read, recibe un resultado firmado y verifica esa firma antes de devolver el valor al cliente ENS.

En este artículo voy a recorrer un diseño práctico de resolver ENS híbrido: los registros de dirección onchain tienen prioridad, y la resolución offchain se usa como fallback a través de CCIP-Read. Los ejemplos están basados en un backend estilo producción en Go y un contrato Solidity HybridResolver.

El Problema

El modelo estándar de resolver ENS funciona bien cuando los registros cambian poco. Un usuario define una dirección o un text record onchain, y los clientes lo leen directamente. Es simple y sólido, pero tiene un costo:

  • cada actualización de registro necesita una transacción;
  • los datos de perfil se vuelven caros de administrar a escala;
  • las plataformas de subnombres necesitan lógica de permisos propia;
  • los sistemas backend igual tienen que espejar datos ENS para funcionalidades de producto;
  • los usuarios esperan actualizaciones tipo Web2, pero las escrituras ENS son transacciones Web3.

Para una cantidad chica de nombres, esto es aceptable. Para un producto que administra muchos subnombres, direcciones multichain, reglas de elegibilidad, expiraciones y campos de perfil, obligar a que cada actualización pase por una transacción onchain se convierte en un problema de UX y operación.

El objetivo no es sacar los smart contracts del sistema. El objetivo es mantener el contrato como verificador y fuente de confianza, mientras permitís que los datos que cambian seguido vivan offchain.

Por Qué Importa

La mayoría de los equipos que construyen productos basados en ENS terminan enfrentando el mismo trade-off:

  • el storage onchain es transparente y fácil de verificar, pero caro de modificar;
  • el storage offchain es flexible, pero no puede convertirse en una fuente de verdad imposible de verificar;
  • los usuarios quieren compatibilidad con ENS, no un protocolo de lookup propietario;
  • wallets y dApps deberían poder seguir llamando funciones estándar del resolver como addr(), text() y contenthash().

CCIP-Read resuelve justamente este problema de integración. Les da a los contratos una forma estándar de decirle a los clientes compatibles: "no puedo responder esta llamada directamente; buscá estos datos en un gateway y después volvé a llamarme con la respuesta".

Para ENS, esto significa que un resolver puede implementar las interfaces normales de ENS mientras usa un gateway offchain por detrás.

Vista General de la Solución

La arquitectura tiene tres partes principales:

  1. Un contrato resolver en Solidity que implementa interfaces de resolver ENS y OffchainLookup de EIP-3668.
  2. Un gateway backend CCIP-Read que decodifica llamadas al resolver y devuelve resultados ENS ABI-encoded.
  3. Una clave de firma que firma respuestas de corta duración del gateway, con el contrato verificando el signer durante el callback.

El resolver de este ejemplo es híbrido:

  • si una dirección está definida onchain, el contrato la devuelve inmediatamente;
  • si no existe una dirección onchain, el contrato revierte con OffchainLookup;
  • el cliente llama la URL del gateway incluida en el revert payload;
  • el backend construye la respuesta ENS y la firma;
  • el cliente llama resolveWithProof;
  • el contrato verifica la firma y devuelve el resultado.

Esto nos da una propiedad muy útil para producción: los registros de alto valor o permanentes pueden quedar fijados onchain, mientras que los registros dinámicos se sirven offchain.

Por Qué Importa un Modelo Híbrido

CCIP-Read no reemplaza el storage onchain. Es un modelo híbrido que combina las garantías de confianza de los smart contracts con la flexibilidad de sistemas offchain.

Los registros críticos pueden permanecer onchain, mientras que los registros dinámicos pueden servirse desde sistemas externos y ser verificados por el contrato resolver antes de volver al cliente.

Esto permite que los equipos mantengan los smart contracts como fuente de confianza sin pagar el costo operativo de guardar directamente onchain cada atributo que cambia con frecuencia.

En vez de elegir entre arquitecturas totalmente onchain o totalmente offchain, CCIP-Read ofrece un punto medio práctico: la verificación sigue onchain, mientras que la gestión de datos gana mucha flexibilidad.

Flujo de Resolución

Flujo de Resolución

Cómo Funciona CCIP-Read

CCIP-Read está definido en EIP-3668. El flujo puede parecer raro la primera vez que lo implementás porque el contrato revierte a propósito.

Ese revert no es un error en el sentido de producto. Es una señal de protocolo.

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

Cuando el resolver no puede responder directamente, revierte con OffchainLookup. Un cliente compatible con CCIP-Read captura ese revert, envía callData a una de las URLs del gateway, recibe la respuesta del gateway y después llama la función callback en el resolver.

Vale la pena notar que EIP-3668 no prescribe cómo el contrato debe verificar la respuesta del gateway. El callback resolveWithProof recibe bytes opacos, y el contrato puede aplicar cualquier lógica de verificación que necesite:

  • firma ECDSA de un signer de confianza (el enfoque usado en este artículo);
  • Merkle proof contra un state root conocido;
  • DNSSEC proof para resolución de nombres basada en DNS;
  • L2 state proof para datos cross-chain;
  • cualquier otro esquema que se ajuste al modelo de confianza.

El protocolo solo define el transporte: revert, fetch, callback. Lo que pasa dentro del callback queda completamente a criterio del resolver. En este artículo usamos firmas ECDSA porque son el modelo más simple para un backend gateway, pero la arquitectura no depende de esa elección.

En el resolver híbrido, la función resolve() primero revisa si la dirección pedida existe 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))
    );
}

Hay dos detalles importantes acá.

Primero, los datos onchain ganan. Si un usuario o backend ya comprometió una dirección en el contrato resolver, el gateway no se consulta para esa dirección.

Segundo, extraData contiene tanto el callData original como la dirección del resolver. Eso ata la respuesta del gateway a esta instancia concreta del resolver y evita que una firma creada para un resolver pueda reutilizarse contra otro.

El Rol del Universal Resolver

Los clientes no llaman a tu resolver custom directamente. Llaman al ENS Universal Resolver, un contrato único de entrada desplegado por el equipo de ENS.

El Universal Resolver hace tres cosas:

  1. Encuentra el resolver correcto para un nombre dado recorriendo el registry de ENS.
  2. Llama resolve(bytes,bytes) en ese resolver.
  3. Si el resolver revierte con OffchainLookup, el Universal Resolver lo captura, re-envuelve el revert con su propia dirección como sender y lo propaga de vuelta al cliente.

Este re-envolvimiento es importante. Cuando un cliente compatible con CCIP-Read recibe el revert OffchainLookup, el campo sender apunta al Universal Resolver, no a tu resolver custom. El cliente busca datos en el gateway y después llama el callback en el Universal Resolver. El Universal Resolver verifica que el revert original vino de un resolver legítimo y reenvía el callback a tu resolveWithProof.

Desde la perspectiva de tu resolver, nada cambia. Seguís revirtiendo con OffchainLookup e implementando resolveWithProof. Pero tenés que saber que el Universal Resolver está entre el cliente y tu contrato. Por eso el extraData en el revert OffchainLookup incluye address(this): le permite a tu callback verificar que la respuesta fue destinada a tu instancia de resolver, incluso cuando la llamada fue ruteada a través del Universal Resolver.

Si estás testeando resolución de punta a punta, llamá la función resolve() del Universal Resolver, no la de tu resolver directamente. Ese es el camino que toman los clientes reales.

Verificando la Respuesta del Gateway

La respuesta del backend no se acepta porque venga de una URL conocida. Se acepta porque trae una firma válida de un signer CCIP autorizado.

El callback verifica la respuesta así:

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;
}

El hash de la firma sigue el mismo formato que usa la implementación de referencia de ENS offchain resolver:

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)
        )
    );
}

Esto intencionalmente no es EIP-712. Es un hash compacto para respuestas CCIP-Read. El contrato y el backend tienen que implementar exactamente la misma construcción del hash; si no, el callback va a fallar.

Implementación del Backend

El gateway backend recibe un request JSON desde el cliente CCIP-Read:

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

El backend decodifica el request, extrae la llamada original al resolver, resuelve la función ENS solicitada, firma el resultado y devuelve:

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

El data devuelto está ABI-encoded como:

(bytes result, uint64 expires, bytes sig)

El campo result no es JSON arbitrario. Tiene que ser el valor de retorno ABI-encoded para la función original del resolver ENS.

Por ejemplo:

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

El backend despacha por 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)
	}
}

Para resolución de dirección ENS, los dos selectors más importantes son:

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

La segunda forma es el resolver de direcciones multichain de ENSIP-9 / EIP-2304. Ethereum usa coinType=60.

Ejemplo Práctico: Dirección Ethereum, coinType 60

Para addr(bytes32,uint256), el backend recibe:

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

Para Ethereum, coinType es 60.

El servicio resolver decodifica el node y el coin type, normaliza coin types estilo EVM, lee los bytes de la dirección y devuelve bytes ABI-encoded.

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
}

Lo importante no es la llamada al repository. Lo importante es el formato de salida. addr(bytes32,uint256) devuelve bytes, por lo que el backend tiene que devolver dynamic bytes ABI-encoded:

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
}

Para una dirección Ethereum, value tiene que tener exactamente 20 bytes. El valor final de retorno ABI es:

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

Un error común es devolver un hex string como "0xabc..." desde el gateway. Eso está mal para callbacks CCIP-Read. El gateway tiene que devolver bytes que el callback del resolver pueda retornar directamente al cliente ENS original.

Firmando la Respuesta del Backend

Después de que el backend obtiene el result ABI-encoded, crea el mismo hash que espera el verificador Solidity:

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)
}

Luego firma el hash y ABI-encodea la respuesta CCIP completa:

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),
}

Más tarde, el contrato decodifica responseData como:

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

Esta es la invariante central del sistema: el backend puede servir datos, pero el contrato decide si la respuesta es válida.

Escrituras de Dirección Onchain

El resolver híbrido también permite mover registros seleccionados onchain con setAddr. Esto es útil cuando un usuario quiere mayor persistencia o cuando un producto necesita fijar registros importantes.

El write path usa un conjunto de signers separado del conjunto de signers CCIP-Read:

  • los CCIP signers son hot keys para respuestas de lectura de corta duración;
  • los setAddr signers son claves de autorización más frías para escrituras onchain.

El contrato verifica un payload firmado:

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);
}

Esto evita que usuarios arbitrarios escriban registros onchain arbitrarios. El backend autoriza el payload exacto: node, coin type, address bytes, dirección del resolver, deadline y nonce.

Testing

El set mínimo de tests para esta arquitectura debería cubrir todo el flujo CCIP:

  • resolve() devuelve la dirección onchain cuando existe;
  • resolve() revierte con OffchainLookup cuando necesita datos del gateway;
  • el gateway decodifica correctamente el call data de resolve(bytes,bytes);
  • addr(bytes32) devuelve una dirección ABI de 32 bytes;
  • addr(bytes32,uint256) devuelve dynamic bytes ABI-encoded;
  • coinType=60 devuelve datos de dirección Ethereum de 20 bytes;
  • coin types desconocidos devuelven bytes vacíos en vez de datos inválidos;
  • el callback rechaza firmas expiradas;
  • el callback rechaza firmas de signers no autorizados;
  • el callback rechaza firmas creadas para otra dirección de resolver.

Para tests de backend, me gusta decodificar manualmente la respuesta del gateway. Eso detecta muchos errores ABI antes que los tests de integración con una librería de wallet.

Errores Comunes

El error más común es confundir el encoding de la respuesta JSON con el encoding ABI del retorno. El gateway devuelve JSON solo como wrapper de transporte. El campo data dentro de ese JSON tiene que ser ABI data.

Otro error es firmar los bytes equivocados del request. El contrato verifica el hash sobre el callData original, no sobre una versión reserializada o parcialmente decodificada del request.

También es fácil olvidarse de que addr(bytes32) y addr(bytes32,uint256) tienen distintos tipos de retorno. La función legacy devuelve address. La función multichain devuelve bytes.

Por último, no uses la misma clave para todo. La firma de respuestas de lectura y la autorización de escrituras onchain tienen perfiles de riesgo distintos.

Aplicaciones Prácticas Más Allá de Perfiles ENS

ENS suele asociarse con nombres, direcciones y registros de perfil. Los resolvers offchain se vuelven más interesantes cuando ENS se usa como capa de identidad e infraestructura.

Un caso práctico es la gestión dinámica de wallets para exchanges, proveedores de pago, custodios y otros negocios offchain. Los usuarios pueden interactuar con un nombre estable como payments.company.eth, mientras el resolver devuelve wallets de destino administradas por sistemas backend. Esto permite rotar wallets, migrar infraestructura, separar flujos hot y cold, o reemplazar wallets comprometidas sin cambiar el nombre ENS visible para el usuario.

El mismo patrón aplica a reputación, credenciales y datos de compliance. Muchos atributos de identidad cambian seguido: estado de verificación, puntaje de reputación, historial de participación, nivel de acreditación o estado de completitud de tareas. Mantener estos datos completamente onchain suele ser caro y operativamente incómodo. Un resolver CCIP-Read puede exponer atributos de identidad firmados mientras el contrato verifica la autenticidad de la respuesta antes de devolverla al cliente.

El descubrimiento de agentes de IA es otra dirección útil. Metadata de agentes como modelos soportados, herramientas disponibles, endpoints de servicio, pricing y capacidades de protocolo puede cambiar seguido. En vez de actualizar un smart contract cada vez que cambia la configuración del agente, un nombre ENS como research-agent.eth puede resolver metadata firmada a través de un gateway offchain, manteniendo un camino de confianza verificable.

El patrón común es el mismo en los tres casos: ENS da el identificador estable, el backend administra estado dinámico y el contrato resolver verifica la respuesta firmada.

Consideraciones de Seguridad

CCIP-Read no elimina la necesidad de una revisión de seguridad. Cambia dónde están los límites de confianza.

Algunas reglas que mantendría estrictas:

  • Atá las firmas a la dirección del contrato resolver.
  • Incluí un timestamp de expiración en cada respuesta del gateway.
  • Mantené cortos los TTLs de respuestas CCIP.
  • Mantené una allowlist onchain de direcciones de CCIP signers.
  • Rotá hot keys de firma CCIP cuando haga falta.
  • Usá signers separados para autorización de escrituras onchain.
  • Validá los largos de address bytes antes de guardar o escribir registros.
  • Tratá APIs externas de elegibilidad o registración como inputs no confiables.
  • Testeá la protección contra replay alrededor de nonces y deadlines.

El contrato debería verificar firmas, no el cliente. Los clientes son parte del flujo de transporte, pero no son el trust anchor.

Limitaciones

Un resolver offchain depende de la disponibilidad del gateway. Si todas las URLs del gateway están caídas, las lecturas offchain frescas van a fallar. Múltiples URLs ayudan, pero no eliminan el requerimiento operativo.

CCIP-Read también depende del soporte del cliente. Las librerías y clientes modernos compatibles con ENS lo soportan, pero integraciones custom pueden necesitar manejo explícito de OffchainLookup.

También hay un trade-off de latencia. Una lectura directa a un resolver onchain es una llamada RPC. CCIP-Read puede requerir un request HTTP adicional y una simulación de callback.

El modelo híbrido reduce estos riesgos para registros de dirección importantes al permitir fijarlos onchain, pero no convierte mágicamente los datos offchain en equivalentes al storage onchain.

Experiencia Práctica

En la práctica, la parte más difícil de construir un resolver ENS offchain no fue el código Solidity. Lo difícil fue la corrección ABI, la consistencia de firmas y decidir qué datos debían vivir onchain.

El camino de coinType=60 es un buen ejemplo. Desde producto, es simplemente "devolver la dirección Ethereum". Desde la perspectiva de un resolver ENS, addr(bytes32,uint256) tiene que devolver dynamic bytes, no un address, y no un string. Esa diferencia chica alcanza para romper la resolución en clientes.

El enfoque híbrido funciona bien porque les da a los engineers un modelo operativo claro:

  • usá registros offchain para actualizaciones rápidas y perfiles administrados por producto;
  • usá registros onchain para datos de dirección de alta confianza;
  • mantené el contrato responsable de la verificación;
  • mantené las respuestas backend con vida corta y firmadas.

Este es el balance que prefiero para sistemas ENS en producción. Evita fingir que todo tiene que estar onchain, pero también evita un resolver backend completamente confiado.

Conclusión

La resolución ENS offchain sirve cuando necesitás compatibilidad con ENS sin forzar cada actualización de registro a una transacción onchain. CCIP-Read te da el puente estándar entre smart contracts, wallets y servicios backend.

La lección de ingeniería es simple: el backend puede preparar la respuesta, pero el contrato tiene que verificarla.

Para una implementación de producción, recomiendo arrancar con una superficie chica de resolver:

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

Después agregá más perfiles solo cuando el producto lo necesite. Mantené los tests de ABI encoding cerca del código del resolver, separá signers de lectura de signers de escritura y tratá cada respuesta firmada como un límite de seguridad.

Recursos Útiles