# 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. There are two modes. In the first (`eoa`), the wallet's address is its own key, so the owner who holds that key could also pay without the agent. In the second (`cosign`), the money sits in a **2-of-2 contract wallet** (`AereAgentWallet2of2`, ERC-1271) that pays only with the agent's signature **and** the wallet service's signature: neither the agent nor the owner can spend alone. 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. ## The 2-of-2 contract wallet (`cosign` mode) `contract/AereAgentWallet2of2.sol` (in the published package; compiled in `AereAgentWallet2of2.json`) holds the agent's tokens and has no function that moves them. A token that checks signatures with ERC-1271, like the testnet's EIP-3009 token, asks it whether a signature over a digest is valid, and it answers yes only for 130 bytes: the agent's secp256k1 signature followed by the policy signer's, both over that digest. Both signers are fixed when it is deployed; there is no owner, no rotation and no recovery. - The wallet service is started with `contractWallet: ` and the **policy signer's** key. It makes every check above and returns its half of the signature with the digest, not a payment header. - The agent (`payWithAgent({ ..., agentEvmKey })`, `completeazaCosemnarea` in `client.mjs`) adds its half only after it recomputes the payment itself: from the contract, to the seller and for the amount of the purchase, with the nonce of **its own** ledger entry, a bounded validity, the same digest, and a policy half that recovers to the declared policy signer. A compromised wallet service cannot make the agent sign another payment. - `verifica-plati.mjs`, given an evidence file with `walletContract: { agentSigner, policySigner }`, also requires the wallet's code on chain to be **exactly** the compiled contract with those two signers in place of its immutables. A contract that only answers `agentSigner()` could lie; its bytes cannot. - `node recompileaza-contract.mjs --solc ` compiles the artifact's standard input again and requires the creation and runtime code to come out byte for byte, and the readable source to be the compiled one; a comment changed by one character must give other code, or the check reports that it cannot tell. `--solcjs` runs `npx solc@0.8.23` instead (not measured here). ## What it does not do - In `eoa` mode it is not trustless custody: whoever holds the wallet key (the owner) can pay without the agent. The `cosign` mode removes that; its contract has no recovery, so if either key is lost the tokens stay in it. - In both modes the payment signatures are classical (secp256k1): EIP-3009 requires it from a key, and this contract checks secp256k1 too. A contract could check a post-quantum signature through ERC-1271 instead; this one does not. What is post-quantum is the agent's identity and ledger (ML-DSA-65), which the wallet service checks before it signs. - 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-cosign.mjs` | 16/16 without a network (the contract's ERC-1271 check emulated in the local facilitator): three purchases from a 2-of-2 wallet with both halves in order, the fourth refused by the policy; the agent alone, and the policy signer alone, cannot pay; a compromised wallet service that changes the recipient, the payer, the amount, the nonce, the validity, the digest, or signs with another key: the agent refuses to co-sign; the co-signing service over HTTP | | `node proba-verifica-plati.mjs` | 14/14 without a network, on the chain responses recorded for both testnet runs below, including the contract's code (one byte changed, another signer named, or no code at the wallet: `INVALID`) | | `node control-negativ-wallet.mjs` | 30/30: each guard of the wallet, the client (including the agent's co-signing checks), the seller and the verifier (including the contract code) 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`) | | `AERE_TESTNET_KEY_FILE= node proba-cosign-testnet.mjs` | 13/13 on the public testnet 28001: an `AereAgentWallet2of2` deployed from `AereAgentWallet2of2.json` with two fresh keys, its code on chain exactly the compiled code; `isValidSignature` on chain yes only for agent then policy signer, no for seven other forms; three purchases of 0.01 from it through the testnet facilitator, the fourth refused by the policy; the agent alone and the policy signer alone refused by the facilitator, and by the token itself asked on chain without the facilitator; balances and nonces on chain; the evidence verified `VALID` with the contract code (another policy signer named: `INVALID`) | | `node recompileaza-contract.mjs --solc ` | `IDENTICAL`: 1193 bytes of creation code and 934 of runtime code, with the native solc 0.8.23+commit.f704f362 | The contract's own tests (7, hardhat, with the testnet token) and their negative control (each of its four guards removed in a copy, the named test must fail: 4/4) run in the Aere Network contracts project and are not part of this package. The testnet runs of 2026-09-29 are in `dovezi-28001/`: the evidence files and the recorded chain responses. Anyone can check them: ``` node verifica-plati.mjs dovezi-28001/plati-agent-2026-09-29-20-23-16.json --rpc https://testnet-rpc.aere.network --all-transfers node verifica-plati.mjs dovezi-28001/plati-agent-2of2-2026-09-29-21-29-48.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.