Blog · Turnstile series, part 7

Spending mandates for agents: the wallet can never raise its own limit

How to give agents a money cap they cannot touch: a Privy server wallet and a spend policy owned by a separate 2-of-2 key quorum.

TL;DR

As part of Turnstile we built an agent that buys answers over paid HTTP (x402-style). An agent that pays needs a spending limit, and the limit is worthless unless one rule holds:

The key that spends can never raise its own limit.

A limit exists for the day the agent misbehaves: a prompt injection, a bad dependency, a leaked key. Whoever controls the agent's key can then do anything that key can do. If raising the limit is one of those things, they raise it first and spend second.

This is how we made that true without writing the enforcement ourselves.

  • Two wallets. A Privy organization wallet that funds the agent, and the agent wallet, which only signs offchain authorizations and has never sent a transaction (nonce 0).
  • Two key quorums. The org wallet is owned by a 1-of-2 operations quorum. The spend policy that caps it is owned by a separate 2-of-2 board quorum. Funding the agent takes one person. Changing the rules takes two.
  • Raising the cap is a policy PATCH. Privy refuses it with one signature (401) and accepts it with two.
  • Who holds the approval keys matters. Ours are made in the browser and our server only sees the public half, but passkeys or Privy users would be stronger.
  • Rough edges. A final DENY rule blocks everything, address matching is case-sensitive, and a denial doesn't say which rule fired.

The code is at github.com/IpastorSan/turnstile, and the /mandate page is on camera in the demo video. Everything below runs on testnet.

Part 7 of 9 in the Turnstile series: building a paid lane for onchain data agents.

The problem: an agent with a card and no manager

The usual first design for a paying agent goes like this. The agent has a wallet. Some config says "max $1 per day". The agent's code checks the config before paying. The problem is that the thing enforcing the limit and the thing being limited share a process. A prompt injection, a bad dependency or a leaked .env defeats both at once, because whoever can spend can also edit the check.

In Turnstile, the limits a buyer gives its agent are a mandate: a total spend cap, a ceiling per query and a list of sellers it may pay. The agent's code checks the mandate before every payment, but that check is exactly the one a compromised agent skips.

The agent can only spend what is in its wallet, and the only way money gets in is a deposit from the buyer organization's wallet. Cap that deposit and a compromised agent can't overspend: it runs out of money instead.

So we set a stricter goal. Assume our server is fully compromised, app secret included. What should still hold?

  1. No deposit to the agent can exceed the mandate's cap.
  2. Nobody holding our server's secrets can raise that cap.
  3. Raising it needs more than one human.

The agent never asks for money. An operator tops it up by hand, one deposit at a time (scripts/arc-setup.ts), and the agent spends what it holds; inside that balance, only its own checks stop it. So the mandate doesn't make a compromised agent behave. It bounds the loss to what was deposited.

Topping up by hand is fine for a testnet demo, but it does not scale. Automating it means a job that holds an operations key, and if that job runs on our server, a compromised server can approve capped deposits as often as it likes. At that point the cap has to cover the running total, not just each deposit.

One direction of authority

On the buyer side, authority runs one way: from the humans who set the rules, through the wallet that funds the agent, down to the agent that spends. Each layer may authorize less than the one above it.

Layer What it is May authorize
Board quorum Two operators, 2-of-2, owning the mandate policy Raising the cap
Operations quorum Two operators, 1-of-2, owning the org wallet Funding the agent, within the cap
Org wallet A Privy server wallet for the buyer organization Deposits to the agent, and only what the policy allows
Agent wallet The buyer agent's wallet on Arc Nothing. It spends inside the mandate

The seller side has the same split: its hot key can change the price but not the payout address (see The offer is the name: ENSv2 records and a key split the chain enforces).

The agent wallet is the interesting one. The agent pays through Circle Gateway by signing EIP-3009 authorizations offchain, and the batcher submits them. The agent never submits a transaction, so it has no gas and no way to deposit, withdraw or widen anything.

That leaves one tap that fills the agent's balance, and the agent doesn't control it. The org wallet calls depositFor(token, agent, value) on Circle's GatewayWallet contract: the org wallet sends the transaction and pays the gas, and the balance is credited to the agent. Cap that call and the mandate is capped at its source.

The org wallet: a server wallet with a spending policy

The org wallet is a Privy server wallet: Privy holds its private key, and we ask Privy to sign with it. A deposit goes like this: our code builds the depositFor transaction and sends it, unsigned, to Privy's API, together with an operator's signature approving the request. Privy checks that signature against the wallet's owners, checks the transaction against the wallet's policy, and only if both pass does it sign. We get back a signed transaction and broadcast it to Arc ourselves.

Three things follow from that.

  1. No org private key exists on our machine. It is reconstituted inside Privy's enclave, so reading .env gives nobody the power to spend.
  2. Every signature is checked against a policy first. Privy's docs say the enclave "evaluates all policy rules in a tamper-proof environment before signing."
  3. The owner is a group of people, not a key in a file. In Privy, a wallet's owner is whoever must approve each request to sign. Ours is a key quorum: the public keys of our operators plus a threshold, how many of them must sign. A request without enough operator signatures is refused, however much of our server an attacker controls.

The policy is two ALLOW rules and nothing else:

return [
  {
    name: 'Fund the agent, within the mandate',
    method: 'eth_signTransaction',
    conditions: [
      { field_source: 'ethereum_transaction', field: 'to', operator: 'eq', value: gateway },
      { field_source: 'ethereum_calldata', field: 'depositFor.depositor', abi: DEPOSIT_FOR_ABI, operator: 'eq', value: agent },
      { field_source: 'ethereum_calldata', field: 'depositFor.value', abi: DEPOSIT_FOR_ABI, operator: 'lte', value: cap },
    ],
    action: 'ALLOW',
  },
  // ...a second rule caps approve(spender = GatewayWallet, value <= cap)
];

Trimmed from buyer/mandate/policy.ts

Three conditions do the work:

  • The transaction must go to the GatewayWallet address.
  • The depositor argument must be this agent, so the org wallet cannot fund some other address inside its cap.
  • value must be at or below the cap, written as hex USDC atomic units and rounded down, because rounding a ceiling up authorizes more than the operators approved.

Two quorums, different purposes

A single quorum cannot express the control we wanted. If the wallet needed 2-of-N, funding the agent would need a meeting. If the policy needed 1-of-N, one compromised operator key could raise the cap and then spend against it.

So the org wallet and the policy have different owners:

Quorum Threshold Owns So this needs
operations 1 of 2 the org wallet any one operator, for routine funding
board 2 of 2 the mandate policy both operators, to widen what the agent may spend

The setup script creates both quorums from the same two operators and hands each one a different resource:

const ops = await createKeyQuorum(privy, { displayName: 'Turnstile operations', members: operators, threshold: 1 });
const board = await createKeyQuorum(privy, { displayName: 'Turnstile board', members: operators, threshold: 2 });

const policy = await createMandatePolicy(privy, { mandate, agentAddress, ownerQuorumId: board.id });
const wallet = await createOrgWallet(privy, { ownerQuorumId: ops.id, policyId: policy.id, displayName: 'Turnstile buyer org' });

From scripts/privy-org-setup.ts

The wallet is created with its owner and policy already set. A wallet created bare and patched later has no owner and no policy for a moment, and we did not want that moment to exist on something holding a treasury. createKeyQuorum also refuses a threshold above the member count, because that quorum could never approve anything, and you only find out when you need it.

Every request to change something a quorum owns needs two kinds of proof. Our app secret proves the request comes from Turnstile. The operators' signatures prove who approved it. Each signature covers that exact request, so it cannot be reused to approve a different one. Stealing the app secret gets an attacker the first proof, never the second, so it cannot raise the cap. The two-person rule is enforced by Privy, not by an approval screen in our app that an attacker could go around by calling the API directly.

Turnstile's mandate page, read live from Privy: a $1 spend cap, $0.10 per query, two rails, and the org wallet, operations quorum, board quorum, policy and allowlist behind them

Refusing invalid operations

npm run privy:mandate plays the whole story against the live API. This is the part of the transcript that matters:

3 & 4. THE ORG WALLET FUNDS THE AGENT: PRIVY DECIDES, NOT US
fund $0.25, at the cap, alice approves        SIGNED  0x02f8d2834cef5280830f4240…
fund $1, over the cap, alice approves         REFUSED 400 policy_violation
fund $0.25, nobody approves                   REFUSED 401 Missing `privy-authorization-signature` header

5. RAISING THE CAP NEEDS A QUORUM
alice (CFO) proposes raising the cap $0.25 → $1

alice alone      REFUSED 401 Number of signatures in `privy-authorization-signature` header
                 does not match the wallet's authorization threshold.

bob (Head of Research) approves.

alice + bob      RAISED → Turnstile mandate, $1 cap
fund $1, the same request as beat 4           SIGNED  0x02f8d2834cef5280830f4240…

From docs/privy-mandate.md, punctuation lightly edited

The three failures are different, and we keep them apart in code. 400 policy_violation is the organization saying no. 401 with a missing header means nobody asked. 401 with a threshold mismatch means someone asked and not enough people agreed. The error text says "wallet's" even though the resource being patched is a policy.

The cap raise with one signature, refused by Privy with a 401: the board quorum needs two signatures and the request carried one

The raise itself is an ordinary PATCH /v1/policies/{id} with new rules. raiseSpendCap has no force flag and no admin override, and could not have a useful one: the threshold check runs on Privy's side, so an override in our code would be a 401 with extra steps. It also refuses to lower a cap. Lowering carries different risk and should not slip through the raise path.

Then the money moved. The org wallet signed approve and depositFor with one operator's approval, inside the policy, and we broadcast both ourselves. Privy does not route RPC for Arc testnet, and it doesn't need to: eth_signTransaction only needs a chain_id to produce an EIP-155 signature. Both transactions are on ArcScan, sent from the org wallet four seconds apart. The agent's Gateway balance went from 0.1055 to 0.3555 USDC, and its nonce stayed at 0.

Who holds the operator keys

Operator keys are P-256 keys, the kind Privy accepts in a quorum. On /mandate/new the browser generates them and sends us only the public half, so our database, logs and environment never hold anyone's approval key.

That protection is not perfect. The private key is shown on the page and the operator copies it into a .env file, so it ends up as plain text on their laptop. And our server serves the page's code: a compromised server could ship a version that copies the key as it is made.

If we built it again, we would not hand out raw keys. Privy accepts two better kinds of quorum member:

  1. A passkey is a P-256 key held in the device's secure hardware, so it cannot be exported or copied into a file.
  2. A Privy user is a person who logs in to Privy. That is the route for a wallet such as MetaMask: its keys are on a different curve, so it cannot be a member directly.

Multisigs, session keys and x402 buyers

Multisig and threshold approval. A key quorum is an m-of-n approval, like a Safe. The difference is where it runs: a Safe checks signatures on chain and pays gas to do it. A Privy quorum is checked by Privy's API and enclave before a signature exists, so nothing reaches the chain unless the policy and threshold both pass. You gain speed and any-chain signing, but you trust Privy's infrastructure instead of a contract you can read. For an org wallet that funds an agent a few times a day, that trade is fine. For a cold treasury we would still want something on chain.

Account abstraction session keys. A session key is a scoped signer on a smart account (targets, a spend limit, an expiry) enforced by a validator module. Same idea of bounded delegation, but the limit lives next to the account. Our setup has no smart account: the agent is a plain EOA that never transacts, and the limit sits one level up, on the tap that fills its balance. What session keys do not give you out of the box is the asymmetry: one approval to operate, two to change the rules.

x402 buyers. An x402 buyer is where this matters most, because the agent reads a 402 challenge and decides to sign a payment on its own. Turnstile enforces the mandate twice. First, the agent's own code checks each offer against the seller allowlist, the per-query ceiling and what is left of the cap, and refuses before signing. Then Privy refuses to hand the agent money beyond the cap at all. The first check is the agent being well behaved. The second holds even when it is not.

Rough edges and gotchas

Five come from Turnstile itself.

  1. A catch-all { method: '*', action: 'DENY' } blocks everything, including requests an earlier ALLOW matched. Privy's docs do say "if any rule evaluates to a DENY action, the policy engine will DENY the request", but writing rules as a list invites the firewall habit of a final deny. Privy is already deny-by-default, so leave it out.
  2. Address comparisons are case-sensitive. A classic one. Privy stores your rule's address checksummed, and a lowercase to then fails to match. Run every address through getAddress().
  3. policy_violation has no detail. It does not name the rule or condition that denied, so we debugged a three-condition rule by bisecting it into new policies.
  4. Gas fields reject decimal strings. gas_limit and the fee fields take a JSON number or a 0x string. bigint.toString() produces exactly the rejected form, and Arc fee values overflow a safe integer, so we send hex everywhere.
  5. GET /v1/policies and GET /v1/key_quorums return 405, so an interrupted setup leaves orphans you cannot list.

Turnstile's Privy cap is per deposit, not cumulative. The running total is tracked by the agent's own ledger. We reused the same Privy app in our next project, Sente, where you hire an AI agent to trade on Monad, and tried to close that gap with a stateful policy. That work added three more findings.

  1. Rolling-cap aggregation is enforced late. A second sign request straight after the first overshot the cap. The same request was refused five seconds later. Privy's docs are candid about this: aggregation values are "updated after a request is successfully signed, not before", and stateful policies are meant as "disaster prevention" rather than strict real-time enforcement. So keep a per-transaction cap below the aggregate and space your writes.
  2. A sign request right after a policy PATCH can still run under the old rule. In that run the new rule took effect roughly 0.85 to 1.3 seconds after the PATCH returned. We would not promise instant revocation. Poll with a sign-only probe until two answers in a row agree.
  3. Chain ids are encoded differently in different places. The transaction chain_id accepted hex. The typed-data domain chainId condition and the timestamp needed decimal. Typed-data requests carry no transaction chain id at all, so the chain has to be pinned through the EIP-712 domain.

Sources


Next in the series: Sell the answer, keep the method: Chainlink CRE Confidential Workflows, part 8 of 9.