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).
6.4 KiB
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
- The agent asks for a resource and gets
402with the payment requirements. - 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. - 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.
- 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.