aere-quantum/agents/x402/README.md
Liviu 8b608fe57f agents/x402: a 2-of-2 contract wallet (ERC-1271) that neither the agent nor its owner can spend from alone
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.
2026-09-30 00:48:10 +03:00

11 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.

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.