The agent does not hold the payment key: the wallet holds it for the owner and signs an EIP-3009 authorization only for a payment the agent wrote into its signed ledger, verified without trusting the agent under the policy the owner pinned and against the ledger heads the wallet itself saw (a branch is refused with a proof of equivocation, a backdated entry is refused), naming exactly this purchase, written now, and within the limit judged also against what the wallet itself has signed. The authorization nonce is sha256(entry hash), so the on-chain payment names the ledger entry. The policy gains an optional `wallet` field. Also: an x402 v2 client, a minimal resource server, a local facilitator for tests, and verifica-plati.mjs, which proves from outside that a wallet's on-chain payments were allowed by the agent's policy (with --all-transfers, that no payment left the wallet without a ledger entry). Tests: wallet 25/25 and payment verifier 10/10 without a network (the verifier on chain responses recorded on testnet 28001), negative control 21/21; policy 27/27, agents control 26/26. On the public testnet 28001 through its x402 facilitator: 9/9, with the evidence in agents/x402/dovezi-28001/. Needs ethers (npm install in agents/x402).
77 lines
6.4 KiB
Markdown
77 lines
6.4 KiB
Markdown
# 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: <wallet>, to: payTo, amount, asset: "eip155:<chain>/erc20:<token>",
|
|
ref: <hash of the resource and the requirement> }`. 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: <its address>`, 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=<funded testnet key> 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.
|