AereAgentWallet2of2 holds the agent's tokens and accepts only the agent's signature followed by the policy service's, over the same digest. In the new cosign mode the wallet service signs only its half, after every check it already made, and the agent adds its half only after it recomputes the payment itself (payer, recipient, amount, the nonce of its own ledger entry, validity, digest, declared policy signer). verifica-plati.mjs requires the wallet's code on chain to be exactly the compiled contract with the two signers; recompileaza-contract.mjs recompiles the published artifact byte for byte with solc 0.8.23. Tests: co-signing 16/16, payment verifier 14/14, negative control 30/30, wallet 25/25. On the public testnet 28001 on 2026-09-29: 13/13 with the 2-of-2 wallet (the agent alone and the policy signer alone refused by the facilitator and by the token asked on chain) and 9/9 with the wallet key. Evidence in dovezi-28001/. Nothing here has been run on the Aere Network mainnet.
111 lines
11 KiB
Markdown
111 lines
11 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.
|
|
|
|
## 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` | 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=<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
|
|
```
|
|
|
|
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.
|