Zodiac Roles en x402: cuando la Safe firma por el agente
Las tesorerías de DAOs llevan años usando Zodiac Roles para que un gestor externo mueva fondos de una Safe sin tener sus llaves. Un presupuesto para agentes necesita exactamente eso. Pero x402 paga con una firma, y Roles solo gobierna transacciones. Conectamos ambos en un fork de Base, con el mastercopy de Roles desplegado y el SDK de x402 sin modificar, y medimos qué permite y cuánto cuesta cada enfoque.
Este es el cuarto post de una serie sobre dónde vive el presupuesto de un agente. La auditoría de los spend controls de x402 encontró un tope por pago en el cliente y ningún total acumulado. Spend Permissions lleva un total acumulado on-chain, pero fuera del camino de pago de x402. ERC-7710 conecta una delegación con el pago mismo. Zodiac Roles es más antiguo que los tres, con un repositorio que data de noviembre de 2021, y ya protege tesorerías de DAOs. Se diseñó para operaciones de tesorería, no para pagos.
Nuestras fuentes: el repositorio de Roles en el commit 820e5bc (25 de agosto de 2026), el código verificado del mastercopy de Roles en Base, la implementación de referencia de x402 en el commit 0cb1a1f (24 de septiembre de 2026) y @x402/evm 2.27.0 desde npm. Todos los experimentos corrieron en un fork de anvil de Base mainnet el 25 de septiembre de 2026. No se desplegó nada en mainnet.
Roles en una transacción
El Roles Modifier se ubica entre una Safe y las direcciones autorizadas a actuar en su nombre. Lo habilitas como módulo de la Safe. Asignas un rol a una dirección, por ejemplo la hot key de un agente. A partir de ahí, esa llave puede llamar a una función:
function execTransactionWithRole(
address to, uint256 value, bytes data,
Operation operation, // Call o DelegateCall
bytes32 roleKey,
bool shouldRevert
) returns (bool success);
Roles evalúa la llamada contra la política del rol y, si pasa, hace que la Safe la ejecute mediante execTransactionFromModule. La política tiene tres niveles: a qué targets puede llamar el rol, qué selectores de función en ellos, y un árbol de condiciones sobre el calldata decodificado.
El árbol de condiciones es lo que le da precisión a Roles. Cada nodo indica un tipo de parámetro y un operador. Los operadores incluyen EqualTo, EqualToAvatar, GreaterThan, LessThan, Bitmask, combinadores lógicos y de arrays, y Custom, que llama a un contrato externo para juzgar el valor. Un tipo de parámetro importa más que ninguno en este post: AbiEncoded, que decodifica un argumento bytes como datos ABI para que las condiciones lleguen a los campos que contiene.
Los presupuestos vienen de los allowances. Un allowance tiene cinco campos: refill, maxRefill, period, balance y timestamp. Una condición WithinAllowance lee un uint del calldata y consume ese monto. Las recargas ocurren por periodos completos y se detienen en maxRefill. Un periodo de cero hace que el allowance sea de un solo uso. El contrato descuenta el consumo antes de ejecutar y lo restituye si la llamada interna falla.
Este diseño tiene uso real. En la propuesta EP 5.12 de ENS, ejecutada en julio de 2024, la DAO migró su Endowment a Roles v2 para que karpatkey, que lo gestiona, quede limitado a "only pre-approved transactions, defined by the permissions policy voted on by the DAO". La carpeta docs del repositorio contiene cuatro informes de auditoría, de G0 Group y Omniscia, todos de 2023. El README lista despliegues en 16 mainnets y 2 testnets. Desde el 19 de junio de 2026 (PR #487) apunta a un nuevo mastercopy, 0xF296…83D5, cuyo código verificado difiere del anterior, 0x9646…D337, en dos archivos: tres cambios en PermissionChecker, y un SignatureChecker de Zodiac incluido que ahora también verifica si su llamada ERC-1271 tuvo éxito. Ese es el que usamos.
El desajuste: x402 paga con una firma
En el esquema exact de x402 sobre EVM, el pagador no envía una transacción. Firma un mensaje EIP-3009 TransferWithAuthorization, y el facilitador lo envía a USDC y paga el gas. El documento del esquema llama al campo del payload "the 65-byte signature" y le indica al facilitador que verifique que "recovers to the authorization.from address".
Cuando from es una Safe, la recuperación no es posible. USDC v2.2 detecta código en el pagador y llama a su isValidSignature de ERC-1271. En una Safe 1.4.1, esa llamada va al CompatibilityFallbackHandler (que configuramos al crear la Safe). El handler acepta exactamente dos cosas: firmas de suficientes owners para alcanzar el threshold, o una firma vacía cuando la Safe guardó antes el hash del mensaje en su mapping signedMessages.
Roles no aparece en ninguna parte de ese camino. Lo probamos directamente. La llave del agente era miembro de un rol con una política sobre USDC. Firmó un TransferWithAuthorization con la Safe como from. USDC lo rechazó en ambos overloads con FiatTokenV2: invalid signature. Un rol no le da a quien lo tiene ningún poder para firmar por la Safe.
Eso deja tres opciones. La primera es hacer owner al agente, pero con threshold 1 el agente controla toda la Safe, y restringirlo pierde sentido. Las otras dos mantienen a Roles al mando, y construimos ambas.
Patrón 1: la recarga
El puente más simple usa Roles para lo que ya hace bien. El rol puede llamar a USDC.transfer, pero solo hacia la hot wallet del agente y solo dentro de un allowance. La hot wallet luego paga x402 como una EOA común. Toda la política son tres nodos de condición:
// scopeFunction(ROLE, USDC, transfer.selector, conditions, ExecutionOptions.None)
[0] Calldata Matches
[1] Static EqualTo abi.encode(agentHotWallet) // to
[2] Static WithinAllowance KEY_TOPUP // amount
// setAllowance(KEY_TOPUP, balance=10e6, maxRefill=10e6, refill=10e6, period=1 days, 0)
En el fork, el agente retiró 4 USDC. Un segundo retiro de 7 USDC revirtió con ConditionViolation status 17, AllowanceExceeded. Una transferencia a cualquier otra dirección revirtió con status 7, ParameterNotAllowed. Después de adelantar el reloj un día, el balance se recargó a 10 USDC, no a 16, por el tope maxRefill, y un retiro de 7 USDC dejó 3. La hot wallet luego pagó un request x402 con el cliente y el facilitador estándar, que lo verificaron y liquidaron como cualquier otro pago desde una EOA.
El costo es bajo. Con storage caliente, una transacción de recarga usó 119.267 de gas y la liquidación desde la EOA 85.728, que paga el facilitador. A los precios de Base que leímos a las 09:15 UTC (0,006 gwei, ETH a $2.695,50 en el feed ETH/USD de Chainlink en Base, más el fee de datos de L1 del GasPriceOracle), eso es alrededor de $0,0019 por recarga y $0,0014 por liquidación. Una sola recarga puede cubrir muchos pagos.
Los límites son estructurales. Cuando el USDC llega a la hot wallet, Roles ya no tiene nada que decir. La hot wallet puede pagarle a cualquier payTo, y las reglas de la Safe sobre destinatarios dejan de aplicar. Roles tampoco pone tope al balance de la hot wallet, así que las recargas no gastadas se acumulan. Si se filtra la llave del agente, la pérdida es todo lo que haya en la hot wallet más el allowance que quede. Es la misma forma que Spend Permissions: un presupuesto que se aplica on-chain cuando se retiran los fondos y no cuando se gastan.
Patrón 2: la Safe firma, el rol decide qué
El segundo puente deja los fondos en la Safe y pone a Roles delante de la firma. Depende de un contrato sobre el que casi nadie ha escrito. El repositorio de Roles contiene SignTypedMessageLib desde el commit 557b17d5 del 25 de abril de 2025. La Safe le hace delegatecall con tres argumentos: un dominio EIP-712 codificado en ABI, un mensaje codificado en ABI y un árbol de tipos. La librería calcula el digest EIP-712, lo envuelve en el hash SafeMessage de Safe y fija signedMessages[hash] = 1. Desde ese momento, una firma vacía es válida para ese digest bajo ERC-1271.
El árbol de condiciones de Roles no puede inspeccionar una firma ECDSA arbitraria de 65 bytes. Sí puede inspeccionar los campos tipados de esta llamada, porque el dominio y el mensaje llegan como bytes que el tipo AbiEncoded sabe decodificar. Eso convierte a la firma misma en algo que un rol puede restringir. Nuestra política para x402 tiene 23 nodos. Los que hacen el trabajo:
// scopeFunction(ROLE, SignTypedMessageLib, signTypedMessage.selector, ..., ExecutionOptions.DelegateCall)
domain AbiEncoded -> Tuple
chainId EqualTo 8453
verifyingContract EqualTo USDC
message AbiEncoded -> Tuple
from EqualToAvatar // la propia Safe
to EqualTo payTo // destinatario en allowlist
value WithinAllowance KEY_PAY // 5 USDC por día
validBefore Custom ValidBeforeWindow(600 s)
types Tuple EqualTo abi.encode(árbol de tipos de TransferWithAuthorization)
El nodo Custom apunta a un contrato de 20 líneas que escribimos para la prueba. Verifica que validBefore no pase de 600 segundos después del bloque actual. Sin él, Roles no puede expresar un límite de tiempo relativo.
La librería no tiene una dirección de despliegue publicada. Ni el SDK ni la documentación la mencionan, y ningún test del repositorio la referencia. La compilamos desde el código fuente y la desplegamos en el fork.
Del lado del cliente no hace falta un fork del SDK. El cliente EVM de x402 le pide a su signer solo dos cosas: un address y una función signTypedData. Así que el adaptador reporta la Safe como dirección, y su "firma" es una transacción de Roles:
const safeSigner = {
address: SAFE,
async signTypedData({ domain, primaryType, message }) {
if (primaryType !== "TransferWithAuthorization") throw new Error("unsupported");
const data = encodeFunctionData({
abi: libAbi, functionName: "signTypedMessage",
args: [encodeDomain(domain), encodeMessage(message), TWA_TYPE_TREE],
});
const hash = await agentWallet.writeContract({
address: ROLES, abi: rolesAbi, functionName: "execTransactionWithRole",
args: [SIGN_LIB, 0n, data, 1 /* DelegateCall */, ROLE, true],
});
await publicClient.waitForTransactionReceipt({ hash });
return "0x"; // la Safe ahora responde ERC-1271 para este digest
},
};
Le pasamos ese signer al cliente ExactEvmScheme de @x402/evm 2.27.0 sin modificar y entregamos el payload resultante al facilitador sin modificar. Verify devolvió isValid: true con la Safe como pagador. Settle devolvió success: true. El balance de la Safe bajó exactamente el monto solicitado, y su isValidSignature(digest, 0x) devolvió 0x1626ba7e.
El facilitador lo acepta por dos decisiones del código de referencia, que ya examinamos en nuestra auditoría de signers con passkey. verifySignature.ts usa recuperación ECDSA cuando el pagador no tiene código y una llamada ERC-1271 estricta cuando sí lo tiene. eip3009-utils.ts usa el overload v, r, s solo cuando la firma mide exactamente 130 caracteres hex, y pasa todo lo demás, incluido un string vacío, como bytes. Nada en el camino EIP-3009 le exige 65 bytes a un pagador que es contrato.
Eso deja a la implementación de referencia y a la especificación en desacuerdo. Una firma de cero bytes no es "the 65-byte signature", y no "recupera" nada. Un facilitador escrito solo a partir del documento del esquema rechazaría este pago. Hoy funciona únicamente porque el código de referencia sigue las reglas que USDC aplica on-chain, no el texto del esquema.
Lo que el árbol de condiciones bloquea
Luego intentamos que el agente firmara cosas que la política debería prohibir. Cada intento revirtió antes de que la Safe guardara nada:
- Un destinatario distinto del
payToen allowlist: status 7,ParameterNotAllowed. - 6 USDC contra un allowance de 5 USDC: status 17,
AllowanceExceeded. validBeforea 30 días: status 16,CustomConditionViolation.fromcon otra dirección: status 7.- Un dominio que apunta a otro contrato de token: status 7.
- Los mismos campos bajo el type hash de
Permiten lugar deTransferWithAuthorization: status 7, detectado por el árbol de tipos fijado.
Ninguno de los intentos fallidos consumió allowance. El balance seguía en 5 USDC después de los seis.
Tres propiedades que solo se ven al ejecutarlo
El presupuesto se gasta al firmar, no al liquidar. El allowance se consume en la transacción de Roles, antes de que cualquier facilitador vea el pago. Firmamos una autorización de 3 USDC válida por 60 segundos y la dejamos expirar. USDC rechazó después la liquidación con FiatTokenV2: authorization is expired, y el allowance seguía mostrando 2 USDC de 5. Cada pago que se autoriza pero nunca se liquida, como uno que el servidor rechaza o un request que expira, consume presupuesto hasta la próxima recarga o hasta que el owner lo reinicie.
Revocar el rol no revoca las firmas. El agente pre-firmó dos autorizaciones de 1 USDC. Luego la Safe sacó al agente del rol, y su siguiente intento de firma revirtió con NoMembership(). Pero el facilitador igual liquidó la primera autorización después, porque la entrada de signedMessages de la Safe no depende del rol. Lo que frenó la segunda fue el propio cancelAuthorization de USDC, llamado con una firma de un owner sobre el hash de mensaje de la Safe, tras lo cual la liquidación falló con authorization is used or canceled. Después de una revocación, la exposición restante es cada autorización firmada y aún no liquidada. El allowance limita cuánto puede ser, y la ventana de validBefore limita cuánto tiempo sigue siendo válida. Por eso la condición de ventana tiene que estar en la política.
El entrypoint sin tipos entrega la Safe entera. SignTypedMessageLib también tiene signMessage(bytes), que marca como firmada cualquier cadena de bytes. Le dimos a un segundo rol permiso para llamarla sin condiciones. El agente pasó abi.encode(permitDigest) para un Permit de USDC que nombraba a un atacante como spender por el monto máximo. Cualquiera pudo entonces llamar a USDC.permit(safe, attacker, max, deadline, ""), y el atacante sacó los 100 USDC de la Safe. El SignMessageLib propio de Safe tiene el mismo comportamiento de signMessage(bytes). Permitir cualquiera de los dos le da al rol el poder de firma de los owners sobre todo contrato que acepte firmas ERC-1271 de la Safe. Para USDC, eso significa el balance completo.
El mismo razonamiento explica por qué nuestra política fija el argumento types completo y no solo los type hashes. Roles decodifica el mensaje según el árbol de condiciones. La librería lo hashea según el árbol de tipos que envía quien llama. Fijar el árbol de tipos obliga a ambos a interpretar los mismos bytes de la misma forma. No probamos un árbol sin fijar. Fijarlo elimina la pregunta.
El precio de una firma con permisos
Medimos recibos reales en el fork, con storage caliente:
gas calldata costo est.
Patrón 1 Recarga Roles 119.267 324 B ~$0,0019 (agente, amortizado)
Liquidación EOA 85.728 292 B ~$0,0014 (facilitador)
Patrón 2 Pre-firma Roles 475.823 3.652 B ~$0,0077 (agente, por pago)
Liquidación Safe 100.819 260 B ~$0,0016 (facilitador)
Un trace de la transacción de pre-firma muestra adónde va el gas. Unos 283.900 de gas se gastan dentro de Roles: cargar la política de 23 nodos, decodificar contra ella 3,6 KB de calldata y actualizar el allowance. 148.522 son la Safe ejecutando la llamada del módulo, de los cuales 96.637 son la librería hasheando y guardando el mensaje. El resto es el salto del proxy, el chequeo custom y el costo base de la transacción con su calldata. Del lado del facilitador, liquidar desde la Safe cuesta 15.091 de gas más que liquidar desde una EOA.
La latencia también importa. La transacción de pre-firma tiene que incluirse en un bloque antes de que el facilitador pueda verificar nada, así que cada pago espera al menos un bloque. Medimos 2.000 segundos para 1.000 bloques en Base, es decir, 2 segundos por bloque. La llave del agente además necesita ETH para gas, o un relayer, algo que un pagador x402 puro nunca necesita.
Para una llamada de inferencia de $0,01, pre-firmar cuesta unos $0,0093 de gas entre el agente y el facilitador, frente a $0,0014 para un pago desde una EOA. El gas casi iguala el precio de la llamada. Para pocos pagos grandes, como un batch job de $5, el mismo costo queda por debajo del 0,2%. El patrón 1 sirve para muchos pagos pequeños. El patrón 2 sirve para pagos lo bastante grandes como para justificar chequeos de política por pago.
Qué significa para LLM4Agents
Muchas organizaciones que quieren operar agentes ya tienen sus stablecoins en una Safe. Hasta ahora, darle acceso a un agente significaba exportar una llave o agregarlo como owner. Roles ofrece una tercera opción que las DAOs ya adoptaron para gestionar tesorería. Del lado del pago, nuestro gateway solo ve el resultado: una EOA en el patrón 1, y un pagador contrato que responde ERC-1271 en el patrón 2.
El patrón 1 no necesita nada de nosotros. Un agente financiado desde una Safe con un rol de recarga se ve como cualquier otro cliente x402. El patrón 2 depende de un comportamiento del facilitador que la especificación no describe. Si los facilitadores empiezan a seguir el documento del esquema al pie de la letra, los pagadores Safe que usan autorizaciones pre-firmadas dejarán de funcionar. Nuestro camino de facilitador debería soportar pagadores ERC-1271 de forma deliberada, no por accidente.
También hay un límite que conviene dejar claro. Roles aplica políticas en la tesorería del cliente, los topes del cliente x402 las aplican en el proceso del agente, y el gateway aplica los precios. Ninguna de estas capas ve el estado de las otras. Un allowance de recarga y un tope de precio por request no suman un solo presupuesto a menos que algo los concilie.
Cómo mantenerse en la frontera
1. Publicar una receta de recarga para agentes financiados desde una Safe. El árbol de condiciones de tres nodos, valores sugeridos de allowance en unidades base de USDC y una alerta que se dispare cuando el balance de la hot wallet supere la recarga de un periodo. Es la forma más rápida de que una Safe financie a un agente sin código nuevo.
2. Probar en CI el camino ERC-1271 con firma vacía. Correr un job sobre un fork de Base con un pagador Safe que use signedMessages, pasando por verify y por settle. Mantener la semántica estricta de SignatureChecker, sin fallback a ECDSA para pagadores contrato, y poner un límite de gas a la llamada isValidSignature para que una wallet maliciosa no desperdicie el gas del facilitador.
3. Publicar el signer de pre-firma como módulo opcional del cliente, con las protecciones incluidas. El adaptador de arriba, más una plantilla de política que fije el árbol de tipos completo, acote validBefore con una condición custom de ventana, restrinja to al payTo del gateway y nunca permita signMessage. Marcarlo como experimental: las cuatro auditorías del repositorio son anteriores a SignTypedMessageLib, y la librería no tiene un despliegue oficial.
4. Construir un barrido de revocación. Cuando un cliente revoque el rol de un agente, listar los eventos SignMsg de la Safe que no tengan liquidación correspondiente y preparar un cancelAuthorization para cada uno, listo para que los owners lo firmen. Sin eso, la revocación deja abiertas las autorizaciones ya firmadas hasta que expiren.
5. Empujar la especificación para que coincida con el código. Abriremos un issue en el repositorio de x402 proponiendo que el esquema exact de EVM defina la validez de la firma como ya la implementa la referencia: recuperación ECDSA para EOAs, ERC-1271 para contratos y cualquier largo para pagadores contrato. También le preguntaremos a Gnosis Guild si SignTypedMessageLib está pensada para producción y si hay planeado un despliegue auditado.
Roles tiene años de uso en tesorerías, y puede gobernar pagos x402. Lo que no puede es hacer barata una firma con permisos, ni revocar una que ya se hizo. Para pagos pequeños, deja a Roles en la tesorería y financia una hot wallet. Para pagos grandes, deja que la Safe firme, y limita qué firma, hasta cuándo y por cuánto.
Financia agentes desde una tesorería, paga por llamada
Un gateway compatible con OpenAI donde cada request se liquida con un pago firmado en stablecoins. Sin cuentas, sin crédito prepago.
Registrar un agente