aere-quantum/agents/x402/README.md

123 lines
13 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.
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: <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.
The blocks from `fromBlock` to the head read once with `eth_blockNumber` are read in windows of at most `--window` blocks (5,000 by
default; public endpoints refuse a long range, and on 2026-10-21 the testnet's refused 10,000 with `Requested range exceeds maximum
RPC range limit`), four at a time. A window counts only when the endpoint answers with a list; an error, an answer that is not a list
or a failed request leaves the window unread, and then the check is `UNMEASURED` with the count of unread windows, never "no
transfers" (a transfer out of the wallet that is not a payment, found in a window that was read, is `INVALID` all the same). Above
`--max-requests` windows (2,000 by default) nothing is asked and the check is `UNMEASURED`, saying how many windows it would need.
## 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.
## 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: <contract address>` 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 <solc 0.8.23>` 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` | 24/24 on 2026-10-21 without a network, on the chain responses recorded for the testnet runs below (the latest EOA wallet run and the 2-of-2 run; until that day the pattern of the EOA file also matched the 2-of-2 file, so rows 1-10 judged the 2-of-2 run and no row judged an EOA run), including the contract's code (one byte changed, another signer named, or no code at the wallet: `INVALID`); and `--all-transfers` on an endpoint that refuses long ranges: 3,178,224 blocks read in 636 windows of 5,000 (`VALID`), the transfers found in different windows, a foreign transfer in the last window (`INVALID`), one request over the whole range, a window answering an error, a non-list or HTTP 429, a head below `fromBlock`, a range above the request cap (each `UNMEASURED`, never `VALID`), and a foreign transfer found while another window is unread (`INVALID`) |
| `node control-negativ-wallet.mjs` | 36/36 on 2026-10-21: each guard of the wallet, the client (including the agent's co-signing checks), the seller and the verifier (including the contract code, and since 2026-10-21 the windows of `--all-transfers`: one request instead of windows, an unread window or a non-list taken for an empty one, an unread window hiding a foreign transfer, the request cap, a head below `fromBlock`) 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`) |
| `AERE_TESTNET_KEY_FILE=<funded testnet key> 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 <solc 0.8.23>` | `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
```
Measured on 2026-10-21 against the testnet endpoint: both `VALID` (exit 0), every transfer out of each wallet since its `fromBlock`
read in 636 and 635 windows of 5,000 blocks (about 3.2 million blocks each, 3 transfers each, all three payments of the ledger), in
8 min 18 s and 6 min 55 s (the endpoint took 3 to 10 seconds per window of old blocks), and again from a fresh clone of the
public copy as staged that evening, `VALID` both, in 6 min 6 s and 5 min 34 s. Before that day both commands answered
`UNMEASURED` (exit 2): they asked for the whole range in one request, and the endpoint refused it.
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.