← Blog
September 25, 2026 · 15 min

Zodiac Roles on x402: when the Safe signs for the agent

DAO treasuries have used Zodiac Roles for years to let an outside manager move funds from a Safe without holding its keys. An agent budget needs the same thing. But x402 pays with a signature, and Roles only governs transactions. We connected the two on a Base fork, using the deployed Roles mastercopy and the unmodified x402 SDK, and measured what each approach allows and what it costs.

This is the fourth post in a series on where an agent's budget lives. The x402 spend-controls audit found a per-payment cap on the client and no running total. Spend Permissions keeps a running total on-chain, but outside the x402 payment path. ERC-7710 connects a delegation to the payment itself. Zodiac Roles is older than all three, with a repository dating from November 2021, and it already guards DAO treasuries. It was built for treasury operations, not for payments.

Our sources: the Roles repository at commit 820e5bc (25 August 2026), the verified source of the Roles mastercopy on Base, the x402 reference implementation at commit 0cb1a1f (24 September 2026), and @x402/evm 2.27.0 from npm. Every experiment ran on an anvil fork of Base mainnet on 25 September 2026. Nothing was deployed to mainnet.

Roles in one transaction

The Roles Modifier sits between a Safe and the addresses allowed to act on its behalf. You enable it as a module on the Safe. You assign a role to an address, for example an agent's hot key. From then on, that key can call one function:

function execTransactionWithRole(
    address to, uint256 value, bytes data,
    Operation operation,   // Call or DelegateCall
    bytes32 roleKey,
    bool shouldRevert
) returns (bool success);

Roles checks the call against the role's policy and, if it passes, has the Safe execute it through execTransactionFromModule. The policy has three levels: which targets the role may call, which function selectors on them, and a condition tree over the decoded calldata.

The condition tree is where Roles gets its precision. Each node names a parameter type and an operator. The operators include EqualTo, EqualToAvatar, GreaterThan, LessThan, Bitmask, logical and array combinators, and Custom, which calls an external contract to judge the value. One parameter type matters most for this post: AbiEncoded, which decodes a bytes argument as ABI data so conditions can reach the fields inside it.

Budgets come from allowances. An allowance has five fields: refill, maxRefill, period, balance and timestamp. A WithinAllowance condition reads a uint from calldata and consumes that amount. Refills happen in whole periods and stop at maxRefill. A period of zero makes the allowance one-shot. The contract deducts the consumption before executing and restores it if the inner call fails.

This design has real-world use. In ENS proposal EP 5.12, executed in July 2024, the DAO moved its Endowment to Roles v2 so that karpatkey, which manages it, is limited to "only pre-approved transactions, defined by the permissions policy voted on by the DAO". The repository's docs folder holds four audit reports, by G0 Group and Omniscia, all from 2023. The README lists deployments on 16 mainnets and 2 testnets. Since 19 June 2026 (PR #487) it has pointed to a new mastercopy, 0xF296…83D5, whose verified source differs from the previous one, 0x9646…D337, in two files: three changes in PermissionChecker, and a bundled Zodiac SignatureChecker that now also checks whether its ERC-1271 call succeeded. That is the one we used.

The mismatch: x402 pays with a signature

In the x402 exact scheme on EVM, the payer does not send a transaction. It signs an EIP-3009 TransferWithAuthorization message, and the facilitator submits it to USDC and pays the gas. The scheme document calls the payload field "the 65-byte signature" and tells the facilitator to verify that it "recovers to the authorization.from address".

When from is a Safe, recovery is not possible. USDC v2.2 sees contract code at the payer and calls the payer's ERC-1271 isValidSignature. On a Safe 1.4.1, that call goes to the CompatibilityFallbackHandler (which we set when creating the Safe). The handler accepts exactly two things: signatures from enough owners to meet the threshold, or an empty signature when the Safe has previously stored the message hash in its signedMessages mapping.

Roles does not appear anywhere on that path. We tested it directly. The agent key was a Roles member with a USDC policy. It signed a TransferWithAuthorization with the Safe as from. USDC rejected it on both overloads with FiatTokenV2: invalid signature. A role gives its holder no power to sign for the Safe.

That leaves three options. The first is to make the agent an owner, but with threshold 1 the agent has full control of the Safe, which removes the point of scoping. The other two keep Roles in charge, and we built both.

Pattern 1: the top-up

The simplest bridge uses Roles for what it already does well. The role may call USDC.transfer, but only to the agent's hot wallet, and only within an allowance. The hot wallet then pays x402 as an ordinary EOA. The whole policy is three condition nodes:

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

On the fork, the agent pulled 4 USDC. A second pull of 7 USDC reverted with ConditionViolation status 17, AllowanceExceeded. A transfer to any other address reverted with status 7, ParameterNotAllowed. After we advanced the clock one day, the balance refilled to 10 USDC, not 16, because of the maxRefill cap, and a 7 USDC pull left 3. The hot wallet then paid an x402 request through the stock client and the stock facilitator, which verified and settled it like any other EOA payment.

The cost is low. With warm storage, a top-up transaction used 119,267 gas and the EOA settlement 85,728, which the facilitator pays. At the Base prices we read at 09:15 UTC (0.006 gwei, ETH at $2,695.50 on the Chainlink ETH/USD feed on Base, plus the L1 data fee from the GasPriceOracle), that is about $0.0019 per top-up and $0.0014 per settlement. One top-up can cover many payments.

The limits are structural. Once USDC reaches the hot wallet, Roles has no further say. The hot wallet can pay any payTo, and the Safe's rules about recipients no longer apply. Roles does not cap the hot wallet's balance either, so unspent top-ups accumulate. If the agent key leaks, the loss is everything in the hot wallet plus whatever allowance remains. This is the same shape as Spend Permissions: a budget that is enforced on-chain when funds are pulled and not enforced when they are spent.

Pattern 2: the Safe signs, the role decides what

The second bridge keeps the funds in the Safe and puts Roles in front of the signature. It relies on a contract that almost nobody has written about. The Roles repository has contained SignTypedMessageLib since commit 557b17d5 of 25 April 2025. The Safe delegatecalls it with three arguments: an ABI-encoded EIP-712 domain, an ABI-encoded message, and a type tree. The library computes the EIP-712 digest, wraps it in Safe's SafeMessage hash, and sets signedMessages[hash] = 1. After that, an empty signature is valid for that digest under ERC-1271.

The Roles condition tree cannot inspect an arbitrary 65-byte ECDSA signature. It can inspect the typed fields of this call, because the domain and the message arrive as bytes the AbiEncoded type can decode. That makes the signature itself something a role can be scoped to. Our policy for x402 has 23 nodes. The ones that do the work:

// scopeFunction(ROLE, SignTypedMessageLib, signTypedMessage.selector, ..., ExecutionOptions.DelegateCall)
domain   AbiEncoded -> Tuple
           chainId            EqualTo          8453
           verifyingContract  EqualTo          USDC
message  AbiEncoded -> Tuple
           from               EqualToAvatar                  // the Safe itself
           to                 EqualTo          payTo         // allowlisted payee
           value              WithinAllowance  KEY_PAY       // 5 USDC per day
           validBefore        Custom           ValidBeforeWindow(600 s)
types    Tuple    EqualTo  abi.encode(TransferWithAuthorization type tree)

The Custom node points to a 20-line contract we wrote for the test. It checks that validBefore is at most 600 seconds after the current block. Roles cannot express a relative time bound without it.

The library has no published deployment address. Neither the SDK nor the documentation mentions it, and no test in the repository references it. We compiled it from source and deployed it on the fork.

The client side needs no fork of the SDK. The x402 EVM client asks its signer for only two things, an address and a signTypedData function. So the adapter reports the Safe as the address, and its "signature" is a Roles transaction:

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";   // the Safe now answers ERC-1271 for this digest
  },
};

We passed that signer to the unmodified ExactEvmScheme client from @x402/evm 2.27.0 and gave the resulting payload to the unmodified facilitator. Verify returned isValid: true with the Safe as payer. Settle returned success: true. The Safe's balance dropped by exactly the requested amount, and its isValidSignature(digest, 0x) returned 0x1626ba7e.

The facilitator accepts this because of two decisions in the reference code, both of which we examined in our passkey signer audit. verifySignature.ts uses ECDSA recovery when the payer has no code and a strict ERC-1271 call when it does. eip3009-utils.ts uses the v, r, s overload only when the signature is exactly 130 hex characters, and passes anything else, including an empty string, as bytes. Nothing in the EIP-3009 path requires 65 bytes from a contract payer.

That puts the reference implementation and the specification in disagreement. A zero-byte signature is not "the 65-byte signature", and it does not "recover" to anything. A facilitator written from the scheme document alone would reject this payment. It works today only because the reference code follows the rules USDC applies on-chain, not the text of the scheme.

What the condition tree stops

We then tried to make the agent sign things the policy should forbid. Each attempt reverted before the Safe stored anything:

None of the failed attempts consumed any allowance. The balance was still 5 USDC after all six.

Three properties you only see by running it

The budget is spent at signing, not at settlement. The allowance is consumed in the Roles transaction, before any facilitator has seen the payment. We signed a 3 USDC authorization valid for 60 seconds and let it expire. USDC then rejected the settlement with FiatTokenV2: authorization is expired, and the allowance still showed 2 USDC out of 5. Every payment that is authorized but never settled, such as a server that rejects it or a request that times out, uses up budget until the next refill or until the owner resets it.

Revoking the role does not revoke the signatures. The agent pre-signed two 1 USDC authorizations. The Safe then removed the agent from the role, and the agent's next signing attempt reverted with NoMembership(). But the facilitator still settled the first authorization afterwards, because the Safe's signedMessages entry does not depend on the role. What stopped the second one was USDC's own cancelAuthorization, called with an owner signature over the Safe message hash, after which settlement failed with authorization is used or canceled. After a revocation, the remaining exposure is every authorization that is signed but not yet settled. The allowance limits how much that can be, and the validBefore window limits how long it stays valid. That is why the window condition belongs in the policy.

The untyped entrypoint gives away the whole Safe. SignTypedMessageLib also has signMessage(bytes), which marks any byte string as signed. We gave a second role permission to call it with no conditions. The agent passed abi.encode(permitDigest) for a USDC Permit naming an attacker as spender for the maximum amount. Anyone could then call USDC.permit(safe, attacker, max, deadline, ""), and the attacker moved all 100 USDC out of the Safe. Safe's own SignMessageLib has the same signMessage(bytes) behavior. Allowing either one gives the role the owners' signing power over every contract that accepts ERC-1271 signatures from the Safe. For USDC, that means the whole balance.

The same reasoning is why our policy pins the whole types argument and not only the type hashes. Roles decodes the message according to the condition tree. The library hashes it according to the type tree the caller supplies. Pinning the type tree forces both to interpret the same bytes the same way. We did not test an unpinned tree. Pinning it removes the question.

The price of a permissioned signature

We measured real receipts on the fork, with warm storage:

                               gas       calldata   est. cost
Pattern 1  Roles top-up        119,267     324 B    ~$0.0019   (agent, amortized)
           EOA settlement       85,728     292 B    ~$0.0014   (facilitator)
Pattern 2  Roles pre-sign      475,823   3,652 B    ~$0.0077   (agent, per payment)
           Safe settlement     100,819     260 B    ~$0.0016   (facilitator)

A trace of the pre-sign transaction shows where the gas goes. About 283,900 gas is spent inside Roles itself: loading the 23-node policy, decoding 3.6 KB of calldata against it, and updating the allowance. 148,522 is the Safe executing the module call, of which 96,637 is the library hashing and storing the message. The rest is the proxy hop, the custom check, and the base transaction cost with calldata. On the facilitator side, settling from the Safe costs 15,091 more gas than settling from an EOA.

Latency matters too. The pre-sign transaction must be included in a block before the facilitator can verify anything, so every payment waits at least one block. We measured 2,000 seconds for 1,000 blocks on Base, which means 2 seconds per block. The agent key also needs ETH for gas, or a relayer, which a pure x402 payer never needs.

For a $0.01 inference call, pre-signing costs about $0.0093 in gas between the agent and the facilitator, compared with $0.0014 for an EOA payment. The gas nearly equals the price of the call. For a few larger payments, such as a $5 batch job, the same cost is under 0.2%. Pattern 1 suits many small payments. Pattern 2 suits payments that are large enough to justify per-payment policy checks.

What it means for LLM4Agents

Many organizations that want to run agents already hold their stablecoins in a Safe. Until now, giving an agent access meant exporting a key or adding it as an owner. Roles offers a third option that DAOs have already adopted for treasury management. On the payment side, our gateway sees only the result: an EOA in Pattern 1, and a contract payer answering ERC-1271 in Pattern 2.

Pattern 1 needs nothing from us. A Safe-funded agent with a top-up role looks like any other x402 client. Pattern 2 depends on facilitator behavior that the specification does not describe. If facilitators start following the scheme document strictly, Safe payers that use pre-signed authorizations will stop working. Our facilitator path should support ERC-1271 payers deliberately, not by accident.

There is also a boundary to be clear about. Roles enforces policy in the customer's treasury, x402 client caps enforce it in the agent's process, and the gateway enforces pricing. None of these layers can see the others' state. A top-up allowance and a per-request price cap do not add up to one budget unless something reconciles them.

Staying on the frontier

1. Publish a top-up recipe for Safe-funded agents. The three-node condition tree, suggested allowance values in USDC base units, and an alert that fires when the hot wallet's balance exceeds one period's refill. This is the fastest way for a Safe to fund an agent with no new code.

2. Test the empty-signature ERC-1271 path in CI. Run a Base fork job with a Safe payer that uses signedMessages, through both verify and settle. Keep strict SignatureChecker semantics, with no ECDSA fallback for contract payers, and put a gas limit on the isValidSignature call so a malicious wallet cannot waste the facilitator's gas.

3. Ship the pre-sign signer as an optional client module, with the safeguards built in. The adapter above, plus a policy template that pins the full type tree, bounds validBefore with a custom window condition, restricts to to the gateway's payTo, and never allows signMessage. Mark it experimental: the four audits in the repository predate SignTypedMessageLib, and the library has no official deployment.

4. Build a revocation sweep. When a customer revokes an agent's role, list the Safe's SignMsg events that have no matching settlement and prepare a cancelAuthorization for each one, ready for the owners to sign. Without it, revocation leaves the already-signed authorizations open until they expire.

5. Push the specification to match the code. We will open an issue on the x402 repository proposing that the exact EVM scheme define signature validity the way the reference already implements it: ECDSA recovery for EOAs, ERC-1271 for contracts, and any length for contract payers. We will also ask Gnosis Guild whether SignTypedMessageLib is intended for production, and whether an audited deployment is planned.

Roles has years of treasury use behind it, and it can govern x402 payments. What it cannot do is make a permissioned signature cheap, or revoke one that has already been made. For small payments, keep Roles at the treasury and fund a hot wallet. For large ones, let the Safe sign, and limit what it signs, until when, and for how much.

Fund agents from a treasury, pay per call

An OpenAI-compatible gateway where every request settles with a signed stablecoin payment. No accounts, no prepaid credit.

Register an agent