← Blog
28 de agosto, 2026 · 13 min

Auditoría de los default assets de x402: 26 tokens, un dominio EIP-712 roto

Una sola tabla dentro de los SDKs de x402 decide en qué stablecoin se convierte un precio en dólares, qué dominio EIP-712 firma el cliente y qué activos puede pagar un agente. Probamos cada fila contra la cadena.

Cuando un vendedor escribe price: "$0.10", algo tiene que convertir ese string en una dirección de token y un monto atómico. En x402 ese algo es la default asset table: un archivo por familia de cadenas, por SDK. En EVM hoy tiene 26 filas.

Esas filas pesan más de lo que su tamaño sugiere. Cada una declara un name y un version de EIP-712 — los dos strings que entran en el domain separator que firma el pagador. Si están mal, el pagador produce una firma que recupera a una dirección que nadie controla. El token la rechaza. El pago nunca liquida.

Así que hicimos lo obvio: tomamos cada fila EVM en el HEAD del repositorio, nos conectamos a cada cadena y le preguntamos al propio token si el dominio declarado es el que verifica.

Qué controla realmente la tabla

Tres mecanismos distintos leen el mismo archivo, y por eso un string equivocado tiene un radio de daño desproporcionado.

Money parsing. El resource server resuelve "$0.10" vía getDefaultAsset(network, symbol?). La primera entrada de una red es el default; un precio con sufijo como "$0.10 USDT" selecciona por ticker.

El dominio de firma. El mismo lookup escribe name y version dentro de extra en los payment requirements. El cliente los copia directo al dominio EIP-712, sin consultar nunca la cadena:

// typescript/packages/mechanisms/evm/src/exact/client/eip3009.ts
const { name, version } = requirements.extra;

const domain = {
  name,
  version,
  chainId,
  verifyingContract: getAddress(requirements.asset),
};

El allowlist de spend controls. Desde que llegaron los spend controls en agosto, el lookup inverso findDefaultAsset(asset, network) decide si una oferta sobrevive. Cubrimos el cap por defecto de $1 y sus escapes cuando salió; lo que importa acá es el filtro que corre antes del cap:

let filtered = allowAnyAsset
  ? requirements
  : requirements.filter(requirement => {
      if (defaultAssetFor(requirement) != null) return true;
      return findAssetEntry(requirement) != null;
    });

Un activo que no está en la tabla, y que el operador no puso explícitamente en el allowlist, se descarta antes de comparar ningún monto. La tabla no es una comodidad. Para un cliente con configuración por defecto es la superficie de pago completa.

Método

Trabajamos sobre el monorepo de x402 en HEAD e398a9e5 (2026-08-28), el repositorio x402-foundation/x402, más los paquetes publicados @x402/core y @x402/evm 2.24.0 de npm. Los endpoints RPC salieron del registro de cadenas de ethereum-lists; las 26 cadenas respondieron.

Para cada fila leímos name(), symbol(), decimals(), version() y DOMAIN_SEPARATOR(), probamos authorizationState(address,bytes32) para soporte EIP-3009 y nonces(address) para EIP-2612, y recomputamos el domain separator con los propios strings de la tabla.

Después viene la parte que prueba algo. Para cada una de las 20 filas EIP-3009 firmamos un TransferWithAuthorization real con una clave nueva sin saldo, usando exactamente el name y el version que declara la tabla, y lo replayeamos por eth_call contra el token vivo. El mensaje de revert es el veredicto. ERC20: transfer amount exceeds balance significa que la firma verificó y solo faltaban fondos — el dominio está bien. Un revert de firma inválida significa que el dominio está mal.

Los tres SDKs coinciden entre sí

Primer resultado, y es genuinamente bueno: TypeScript, Python y Go son idénticos. Las mismas 26 redes, las mismas direcciones, el mismo name, version, symbol, decimals, los mismos flags de transfer method y EIP-2612. Cero deriva entre tres lenguajes. El commit de alineación (PR #3241, 2026-08-24) hizo su trabajo.

Cada red tiene exactamente un activo — ninguna red usa todavía la capacidad multi-entrada que el schema soporta. Veinte filas son EIP-3009, seis están marcadas assetTransferMethod: "permit2", y cinco de esas seis declaran supportsEip2612: true. Todos esos flags coincidieron con lo que encontramos on-chain: los seis tokens permit2 efectivamente revierten en authorizationState, y el único sin el flag 2612 (el USDC de Igra) es la única fila de la tabla sin nonces(address).

Los decimals declarados coincidieron con la cadena en las 26 filas.

Diecinueve de veinte

Diecinueve filas EIP-3009 aceptaron una firma construida con su propio dominio declarado. Base, Ethereum, Polygon, Arbitrum, Avalanche, Celo, Sei, Monad, XDC, ADI, HPP, Flare, Stable mainnet y los testnets que las acompañan devolvieron el revert de balance.

Una no.

eip155:2201 — Stable Testnet USDT0 — la tabla declara el nombre EIP-712 "USDT0". El name() del token devuelve "USD₮0", con U+20AE, el signo Tugrik. Firmar con el string de la tabla devuelve TetherToken: invalid signature. Firmar con el string on-chain devuelve ERC20: transfer amount exceeds balance.

El domain separator recomputado lo confirma de forma independiente: keccak256 sobre ("USD₮0", "1", 2201, token) reproduce bit a bit el valor que devuelve DOMAIN_SEPARATOR(). El string de la tabla no.

Después cerramos el ciclo con el software publicado y no con nuestro propio firmante. Hicimos que @x402/evm 2.24.0 construyera un payment payload para eip155:2201 usando exactamente el extra que emitiría el resource server, tomamos la firma que produjo y la replayeamos contra el token:

// firmante recuperado bajo cada dominio candidato
tabla x402 'USDT0'     -> 0x70997970C51812dc3A010C7d01b50e0d17dc79C8  // == pagador
on-chain   'USD₮0'     -> 0x93dF47D7E9bD6Bc35933BcAf69bb37Adeb228087

// replay del payload firmado por el SDK en Stable testnet
TetherToken: invalid signature

La firma está bien formada y recupera limpiamente al pagador — bajo un dominio que el token no usa. No es un payload malformado. Es un mensaje correctamente firmado dirigido a la identidad de contrato equivocada.

Cómo sobrevivió cinco meses

El string entró al repositorio el 2026-03-31 en el PR #1786, que agregó soporte de Stable testnet y declaró Name: "USDT0" simultáneamente en Go, Python y TypeScript. Stable mainnet había llegado una semana antes en el PR #1775 con el mismo string — y ahí es correcto, porque el name() de ese deployment sí devuelve "USDT0". Los dos deployments de la misma marca difieren en un carácter, y la fila del testnet heredó la respuesta del mainnet.

El 2026-08-13, el PR #3124 (146 archivos, +3.795/−1.317) reestructuró todo en el nuevo defaultAssets.ts y arrastró el string tal cual. Python y Go siguieron el 2026-08-18. El mismo PR agregó el USDT0 de Flare con "USD₮0" escrito correctamente — el proyecto maneja bien el carácter a una fila de distancia de donde no lo hace.

Nada en el pipeline estaba en posición de atraparlo. Los tests unitarios de los default assets EVM solo verifican consistencia interna: que un lookup por dirección con checksum y en minúsculas devuelva la misma entrada, que el mUSD de Mezo tenga 18 decimals, que un activo desconocido devuelva undefined. No hay ningún test que compare un dominio declarado contra una cadena. De hecho el string DOMAIN_SEPARATOR no aparece en ninguna parte del paquete de mecanismos EVM.

La documentación para contribuidores está más cerca que el código. DEFAULT_ASSETS.md indica: "Read the name() and version() functions from the token contract (EIP-712 domain values)". Si sigues eso literalmente obtienes "USD₮0". El procedimiento era correcto; se copió de una fila hermana en vez de ejecutarse.

El facilitator sí lo sabe

Vale decirlo sin rodeos, porque es la parte que el diseño resolvió bien. Cuando falla una simulación EIP-3009, el facilitator corre un multicall de diagnóstico sobre balanceOf, name, version y authorizationState, y compara el nombre on-chain contra extra.name:

if (
  nameResult.status === "success" &&
  requirements.extra?.name &&
  nameResult.result !== requirements.extra.name
) {
  return { isValid: false, invalidReason: Errors.ErrEip3009TokenNameMismatch, payer };
}

Así que un pagador en Stable testnet no recibe un fallo misterioso. Recibe invalid_exact_evm_token_name_mismatch, que nombra el problema con precisión. Es un buen camino de error, y es la razón por la que esto es una fila de testnet rota y no una pérdida silenciosa de fondos.

Pero observa dónde está el chequeo. Corre después de que la simulación ya falló, en el facilitator, como autopsia. Nada verifica el dominio antes de que el cliente firme, y nada lo verifica en tiempo de build. La comparación autoritativa existe en el codebase; simplemente nunca corre contra la tabla misma. Nuestra auditoría es ese chequeo, ejecutado una vez, offline.

El allowlist también es un techo

El hallazgo de segundo orden no tiene que ver con strings equivocados sino con filas ausentes.

El paquete EVM nombra 23 redes en su mapa legacy v1. Diez no tienen default asset: sepolia, abstract, abstract-testnet, avalanche-fuji, iotex, polygon-amoy, peaq, story, educhain, skale-base-sepolia. Como el lookup inverso filtra la oferta antes que el cap, un cliente con configuración por defecto rechaza de plano toda oferta en esas cadenas. Lo confirmamos contra los paquetes publicados, no contra el código fuente:

Base USDC (en la tabla)     : FIRMÓ
Sei USDC (en la tabla)      : FIRMÓ
IoTeX (nombrada, sin default): RECHAZADA — only default assets or entries in
                              spendControls.allowedAssets are allowed
Story (nombrada, sin default): RECHAZADA — idem
Optimism (no está en el SDK) : RECHAZADA — idem

Optimism es la que debería hacer dudar a un vendedor. No es una cadena marginal y no está en la tabla, mientras que Igra, ADI Chain, HPP y Radius sí. Nueve de las 26 filas son testnets. Esto no es una crítica a las selecciones — cada fila llegó ahí porque alguien hizo el trabajo de agregarla — pero significa que la cobertura sigue la atención de los contribuidores y no el volumen. Un vendedor que tarifa en dólares en una cadena no cubierta es invisible para los clientes por defecto, y va a leer ese silencio como falta de demanda.

El escape existe y está documentado: spendControls.allowedAssets, o spendControls: false. La trampa es que el comprador tiene que saber que debe usarlo, y el comprador es con frecuencia un agente autónomo leyendo un string de error, no un humano leyendo release notes.

Dos cosas que parecen bugs y no lo son

La deriva de símbolos es la primera. Cinco filas llevan un ticker distinto del symbol() del propio token: MegaUSD contra USDm, el mUSD de Mezo contra MUSD dos veces, y ambas filas de USDT0 contra USD₮0. Nada se rompe, porque el símbolo de la tabla es el que resuelve los precios con sufijo — pero implica que "$1 USDm" no resuelve en MegaETH mientras que "$1 MegaUSD" sí. El ticker que un agente lee de la cadena no siempre es el ticker que quiere el string de precio.

La segunda es una trampa de metodología que vale publicar porque nos costó una hora. Nuestra primera pasada usó la clave de prueba conocida 0x1111…1111 y obtuvo reverts de firma inválida en Base, Polygon, Arbitrum y Celo mientras Avalanche y Sei pasaban. Los dominios estaban bien. Esa dirección tiene una delegación EIP-7702 en exactamente esas cuatro cadenas — 0xef0100 seguido de un delegate — y el SignatureChecker de Circle rutea cualquier firmante con código por ERC-1271 en vez de ECDSA. Es el comportamiento de verificación estricta que mapeamos en la auditoría de firmas 7702, reproducido por accidente. Repetimos con una clave nueva y cruzamos contra seis autorizaciones de producción vivas en Base, todas las cuales recuperan bajo el dominio declarado.

Una nota estructural para cerrar la sección técnica: 24 de los 26 tokens están detrás de un proxy actualizable. El name y el version que un cliente compila en su binario son estado mutable del contrato en casi todas las filas. Correcto hoy no es una propiedad que se sostenga sola.

Qué significa para LLM4Agents

Ruteamos llamadas a modelos y las liquidamos por uso en stablecoins. Eso nos hace pagadores en rieles ajenos mucho más seguido que vendedores en el propio, y esta auditoría describe exactamente la clase de fallo que absorbe un pagador: un payload criptográficamente perfecto y económicamente inútil, detectable solo después de un round trip.

Tres consecuencias que tratamos como operativas, no teóricas.

La tabla es una dependencia, no una constante. Una fila de default asset es una afirmación compilada sobre estado on-chain mutable, embarcada dentro de un paquete con unas 976.000 descargas mensuales para @x402/core y 612.000 para @x402/evm. Actualizar el SDK puede cambiar en qué cadenas podemos pagar y qué dominio firmamos, sin que cambie una línea de nuestro código. Eso pertenece a la revisión de dependencias, al lado del camino de settlement EIP-3009 mismo.

La taxonomía de fallos vale más que los reintentos. invalid_exact_evm_token_name_mismatch es un defecto de configuración del lado del vendedor. Reintentar, hacer backoff o rotar un nonce no logra nada; la misma firma va a fallar siempre. Un agente que trata todo no-200 como transitorio va a quemar su presupuesto en una ruta que no puede tener éxito. Domain mismatch y EIP-3009 no soportado son terminales, y nuestra lógica de fallback tiene que saber la diferencia.

Los límites de cobertura son límites de negocio. El conjunto de cadenas donde un cliente por defecto puede pagarnos es más chico que el conjunto de cadenas que soportamos. Si tarifamos en dólares en una cadena sin fila de default asset, los compradores bien portados rechazan la oferta antes incluso de consultar su cap, y nunca vemos el request.

Cómo mantenerse en la frontera

Pasos concretos, en el orden en que los haríamos.

1. Verificar el dominio antes del primer pago, no después. Para cada par (cadena, activo) con el que estemos dispuestos a operar, computar el domain separator EIP-712 desde el name y version declarados y compararlo contra el DOMAIN_SEPARATOR() del token. Es un eth_call y un keccak256. Donde el getter no exista o el token diverja, caer a firmar una autorización de valor cero y leer el revert. Cachear el veredicto por activo con expiración corta — 24 de 26 tokens son actualizables, así que el cache tiene que poder quedar obsoleto.

2. Pinnear y diffear la tabla en cada bump del SDK. Snapshot de las 26 filas al actualizar y falla de CI ante cualquier cambio de dirección, name, version o decimals que ya hayamos verificado. Eso convierte un cambio silencioso de comportamiento en un ítem de revisión. Es la misma disciplina que aplicamos al cap por defecto de $1, que también llegó en una versión minor.

3. Clasificar los errores del facilitator como terminales o transitorios, explícitamente. Construir el mapeo desde las constantes de error del facilitator y no por match de strings. Name mismatch, version mismatch, EIP-3009 no soportado y recipient mismatch son terminales para esa oferta; nonce ya usado y timeouts de simulación no lo son. Rutear los fallos terminales directo a la siguiente oferta de accepts, y registrarlos como defectos del vendedor y no como ruido de red. Esta es la pieza que conecta con el contrato de verify/settle que mapeamos antes.

4. Declarar nuestras propias filas explícitamente. Donde vendemos, emitir extra.name y extra.version leídos del token en tiempo de deploy y afirmados en nuestra suite de tests, en vez de heredar lo que la tabla del SDK tenga. Donde compramos, llevar un allowlist chico para cadenas que la tabla no cubre, con caps por activo, para que los huecos de cobertura sean una decisión nuestra y no un rechazo que absorbemos.

5. Contribuir el chequeo upstream. La comparación ya existe en el camino de diagnóstico del facilitator. Esa misma lógica como test de integración sobre DEFAULT_ASSETS, corriendo contra RPCs públicos, habría atrapado esta fila el día en que se escribió y atraparía la próxima. Es mejor contribución que un fix de una línea a un solo string.

La lección más amplia no es sobre un carácter equivocado. Es que los stacks de pago para agentes están acumulando tablas de configuración con peso criptográfico — dominios, registries, listas de facilitators, allowlists — y que esas tablas se validan por convención y no por máquina. Un string sobre el que ningún test puede estar equivocado es un string que ningún test está revisando.

Paga por llamada, en stablecoins, con un gateway compatible con OpenAI

Registra un agente, fondéalo y liquida cada inferencia on-chain.

Registrar agente