# Agent payments over x402: a wallet that pays only what the agent's policy allows An AI agent that holds a payment key can spend without limit, whatever its policy says: the policy is only a promise. Here the agent does **not** hold the payment key. A small service, the **agent wallet**, holds it for the owner, and signs an x402 payment only when the agent has written that payment into its signed ledger (`../agent-ledger.mjs`) and the owner's policy allows it. The agent authenticates to the wallet with the ledger entry itself, which only its ML-DSA-65 key can sign. Node.js 24 and `ethers` (for EIP-712 and secp256k1: `npm install` here). Payments follow x402 version 2 (`exact` scheme, EIP-3009 `transferWithAuthorization`), with the HTTP headers `PAYMENT-REQUIRED`, `PAYMENT-SIGNATURE` and `PAYMENT-RESPONSE`. ## The flow 1. The agent asks for a resource and gets `402` with the payment requirements. 2. The agent writes the payment into its ledger: `{ kind: "payment", from: , to: payTo, amount, asset: "eip155:/erc20:", ref: }`. If its policy refuses (over the limit, a recipient not allowed, approval missing, revoked), it stops here. 3. The agent sends the requirement and its ledger to the wallet (`POST /authorize`). The wallet: - checks the requirement (its network and asset, a positive integer amount, a timeout it caps at 300 seconds); - verifies the whole ledger without trusting the agent, under the policy the owner pinned when starting the wallet: identity, every signature, the chain of hashes, every decision re-run, the revocations the owner gave the wallet, and the **heads the wallet itself saw before**, with its own clock. A ledger that does not continue what the wallet saw is a branch: it is refused and, when the wallet kept the entry, answered with a proof of equivocation anyone can check; - requires the last entry to be a new, allowed payment from this wallet, naming exactly this purchase, written now by the wallet's clock (within 120 seconds); - checks the limit a second time against what the **wallet itself has signed**, so a rewritten ledger, a branch, or a new ledger under a changed policy cannot take out more than the limit allows; - signs an EIP-3009 authorization whose nonce is `sha256(entry hash)`: the on-chain payment names the ledger entry, and one entry can pay once, at the wallet and on chain. 4. The agent retries the resource with `PAYMENT-SIGNATURE`; the resource server settles through its facilitator and serves. `verifica-plati.mjs` then proves from the outside that a wallet's on-chain payments were allowed by the agent's policy: the ledger verifies, each payment names an allowed entry, its transaction emits `AuthorizationUsed(wallet, sha256(entry hash))` and the `Transfer` of that amount, and with `--all-transfers` every transfer out of the wallet since a block is one of those payments. ## Use ``` node wallet.mjs serve --config wallet.json [--port 8793] # POST /authorize, POST /revocations, GET /status on 127.0.0.1 ``` `wallet.json`: `{ policy, policyHash, network: "eip155:28001", token: { address, name, version }, evmKeyFile, stateFile }`. The policy must name the wallet (`wallet: `, see `definePolicy`) and a spending limit in its asset, or the wallet does not start. The EVM key is read from its file and never printed; the state file keeps the heads seen and what was signed, and is written before a signature is returned. The agent side is `payWithAgent({ url, ledger, wallet })` in `client.mjs`; `walletOverHttp(url)` talks to the service. `resource-server.mjs` is a minimal x402 seller for tests; `facilitator-local.mjs` makes the same checks as an x402 facilitator, in memory, for tests without a chain. ## What it does not do - It is not trustless custody: whoever holds the wallet key (the owner) can pay without the agent. A 2-of-2 contract wallet (the agent and the policy service, ERC-1271, which the testnet token accepts) would remove that trust; it is not built. - An authorization that is signed but never settled still counts against the limit until the window passes (conservative). - The wallet API must be reached over a trusted channel: an entry intercepted on the way could be submitted by someone else, who would then receive the payment payload (the money still goes to the seller the entry names). - It does not check that the seller delivered what was paid for. - The wallet keeps every signed payment in its state; nothing prunes it yet. ## Tests Measured on 2026-09-29 (Node.js 24.14.1, ethers 6.16.0): | test | result | |---|---| | `node proba-wallet.mjs` | 25/25 without a network, real EIP-712 signatures and ML-DSA-65 keys: five paid purchases through a local facilitator, the sixth refused by the policy; attacks by the holder of the agent key (a forged "allowed" entry over the limit, an entry for another amount, the same entry twice, a branch with its equivocation proof, a new ledger under a changed policy, a backdated entry, two branches sent at the same time), the owner's revocation, human approval, a payment for another requirement and a replayed payment at the seller | | `node proba-verifica-plati.mjs` | 10/10 without a network, on the chain responses recorded for the testnet run below | | `node control-negativ-wallet.mjs` | 21/21: each guard of the wallet, the client, the seller and the verifier removed in a copy, and the named check must fail | | `AERE_TESTNET_KEY_FILE= node proba-x402-testnet.mjs` | 9/9 on the public testnet 28001 through its x402 facilitator: a fresh wallet funded with 0.05 tUSD, three purchases of 0.01 paid and settled on chain, the fourth refused by the policy, a replayed payment refused, the balances and the used authorization nonces read on chain, and the evidence file verified `VALID` (a changed amount, or a payment removed from the file: `INVALID`) | The testnet run of 2026-09-29 is in `dovezi-28001/`: the evidence file and the recorded chain responses. Anyone can check it: ``` node verifica-plati.mjs dovezi-28001/plati-agent-2026-09-29-20-23-16.json --rpc https://testnet-rpc.aere.network --all-transfers ``` tUSD is the testnet's EIP-3009 token, minted by a faucet and worth nothing. Nothing here has been run on the Aere Network mainnet. Code comments and test names are in Romanian; messages, data and the documentation are in English. No third party has reviewed it.