Account abstraction — session keys so an agent pays on its own

A plain wallet key signs one voucher per call, and a human authorizes the run by starting it. Fine for a script, impossible for a recurring workflow: call n needs an EIP-712 PaymentVoucher (payments), and nobody wants to be asked every time. AIOUM's point is that a person funds a budget once and the agent pays per call by itself, for whatever service it finds. The chain keeps that promise, not trust.

The solution: an ERC-4337 smart account, AIOUMAccount, with budgeted, expiring session keys. The owner authorizes a key once (spend cap + expiry); from then on it opens prepaid sessions and signs per-call vouchers autonomously, with the budget enforced on-chain. "Set a budget once, let the agent pay, no human in the loop."

The private node's scheduler enforces the same idea off-chain with a budget-capped agent wallet (offchain/src/private-node/) that pays for recurring jobs with no human signing each call; the session key is its on-chain analog, the cap living in the smart account instead of the scheduler. Payments is where the vouchers land, Routing how a call picks a node, and Run a node the side that gets paid.

The pieces

Four contracts carry the scheme, one job each:

ContractPathRole
AIOUMAccountsrc/aa/AIOUMAccount.solERC-4337 smart account with budgeted session keys + ERC-1271
AIOUMAccountFactorysrc/aa/AIOUMAccountFactory.solCREATE2 deterministic deployer (ERC-4337 initCode)
AIOUMVerifyingPaymastersrc/aa/AIOUMVerifyingPaymaster.solThe network's gas sponsor: pays gas only for UserOps carrying a time-boxed signature from the sponsor signer
AIOUMPaymastersrc/aa/AIOUMPaymaster.solLocal development paymaster; sponsors every op

All are built on the v0.9.0 account-abstraction library and, on live chains, run against the canonical v0.7 EntryPoint singleton. The library requires ^0.8.28, so the repo compiles with Solidity 0.8.28.

How a session key works

The owner funds the smart account with USDC once, then authorizes a scoped key:

// AIOUMAccount.sol — onlyOwner (or the account itself)
function authorizeSessionKey(address key, uint48 validUntil, uint256 spendCap) external;
function revokeSessionKey(address key) external;

spendCap is the total USDC the key may ever route into sessions; validUntil is when it expires. Authorizing again with a higher cap tops it up (a new cap may not fall below what the key has spent). The key is scoped to one call: only the account's own openSessionFor, only at the wired PaymentManager, so it cannot move funds anywhere else.

From there the key runs the session itself:

// Only reachable through the EntryPoint (a validated UserOp). Opens a prepaid session
// funded from this account's USDC balance.
function openSessionFor(uint256 serviceId, uint256 amount) external returns (uint256 sessionId);

Each openSessionFor rides in a UserOp signed by the session key. _validateSignature enforces the budget before the call runs: the op must target openSessionFor and spent + amount must stay within spendCap (spent is incremented on-chain); anything else, the owner aside, is rejected, and the EntryPoint enforces validUntil. The cap is a contract invariant, not an off-chain check, so an agent can be let loose to find and try services: whatever it opens, it opens inside the cap.

Per-call vouchers validate via ERC-1271

The agent still signs an EIP-712 PaymentVoucher per call. AIOUMAccount implements ERC-1271: a voucher counts as signed by the account when it carries the owner's signature, or an enriched one, abi.encode(sessionId, cumulativeAmount, innerSig), whose inner signer is the session key that opened that session. Authority is judged at open, so revoking or expiring a key later does not invalidate vouchers for sessions it already opened:

function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4);

The crucial property: PaymentManager.settle does not change at all. OpenZeppelin's SignatureChecker.isValidSignatureNow(client, digest, signature) routes by itself: an EOA client through ECDSA.recover, a contract client through ERC-1271 (isValidSignature). An AIOUMAccount is a paying client with no special case, the plain-EOA path is unchanged and backward-compatible, and a node never knows which kind is paying it.

Gas sponsorship — agents never hold the gas token

AIOUMVerifyingPaymaster, funded via depositTo into the EntryPoint, sponsors gas for UserOps, so an agent paying in USDC never needs the chain's gas token. It sponsors only ops carrying a time-boxed signature from the sponsor signer (the managed-billing service, SPONSOR_SIGNER_KEY); the bundler relay in front further limits what is relayed (only openSessionFor, a per-account gas cap, a paymaster deposit floor). On the local development network AIOUMPaymaster sponsors every op. The facilitator's gas sponsorship covers the gasless EOA funding paths.

In this repo: set a budget once (@aioum/sdk)

packages/sdk/src/account.ts (exported from @aioum/sdk) turns "set a budget once, then let the agent run" into a single owner action, on plain viem clients, no React or wagmi, so the same code runs in Node (agents, servers) and the browser. setupSmartAccount(pub, wallet, cfg, { owner, fundAmount, cap, validUntil }) is the one-time authorization the owner approves with a prompt: it deploys the AIOUMAccount (via the factory's createAccount), funds it with USDC, approves the PaymentManager for the cap, and authorizes a session key with the cap + an expiry. Every step is idempotent, reading on-chain state and skipping work already done, so a retry after a partial failure resumes instead of wedging a funded-but-keyless account; onStep reports deploy → fund → approve → authorize progress and resumeKey reuses a prior attempt's key. The session key lives in memory only, never persisted, so the blast radius is bounded by the on-chain cap and expiry.

After that one approval, runs are autonomous: openSession(pub, cfg, session, serviceId, deposit) opens each session through a session-key UserOp (via the bundler relay, gas sponsored, no prompt), and signEnrichedVoucher in voucher.ts signs each per-call voucher with the session key, validated by node and PaymentManager via ERC-1271. remainingBudget reads cap / spent / validUntil / revoked; revokeSessionKey cuts the key off early. The owner never signs again until the cap is spent or the key expires.

The SDK's AIOUMAgent always takes this path: createAgent runs the setup and every agent it returns pays from a smart account with a session key (build an agent). The plain-EOA flow lives in offchain/src/sdk and the Python SDK. In the app, the Wallet budget page (/agents/budget) runs the same setup from a connected wallet: set a spend cap in USDC and a key lifetime, approve four transactions (deploy, fund, approve, authorize), paste the key into Claude Desktop, Claude Code or your own code. Revoke it there any time; unspent funds come back. See frontend & UI.

Bundler relay — handleOps needs an EOA caller

EntryPoint.handleOps must be called by an EOA, which the browser is not. The bundler relay (offchain/src/bundler/server.ts, npm run bundler, port 8548) bridges the gap: the browser builds and signs a PackedUserOperation and POSTs it to /sendUserOperation; a gas-only relayer EOA submits handleOps for it. The relayer key is BUNDLER_PRIVATE_KEY; on the local development network it defaults to a dedicated Anvil account, index 21 (BUNDLER_RELAYER_INDEX to override), so it never fights the operator or facilitator over nonces. It is hardened (rate-limit, replay guard, paymaster allowlist, a FailedOp dry-run before submission) and, like the facilitator, runs as a single relayer instance (payments).

cd offchain
npm run bundler        # ERC-4337 bundler relay on :8548

Deterministic addresses — AIOUMAccountFactory

AIOUMAccountFactory deploys accounts via CREATE2, so an account's address is a pure function of (owner, salt), known before it exists. That lets the ERC-4337 initCode flow deploy the sender account on its first UserOp, and the budget page show an agent's address before a single transaction is sent:

function createAccount(address owner, uint256 salt) external returns (AIOUMAccount);
function getAddress(address owner, uint256 salt) external view returns (address);

Verified end-to-end

Forge e2e tests in test/AccountAbstraction.t.sol drive real UserOps through EntryPoint.handleOps (exactly what a bundler does), with the paymaster funding gas. Five cover the core flow:

  1. a session key opens a session via a sponsored UserOp — the smart account is the client and the USDC is pulled from it;
  2. the session key signs the per-call voucher and the operator settles it via ERC-1271;
  3. an over-budget op is rejected (the EntryPoint reverts the bundle);
  4. a revoked key is rejected;
  5. a random/unauthorized key is rejected.

Beyond the test suite, the deploy script stands the stack up on-chain and a live UserOp demo runs the open → voucher → settle path against it.

Deploying the AA stack

The deploy script (offchain/src/deploy.ts) deploys the ERC-4337 stack last, so protocol addresses stay deterministic: the AIOUMAccountFactory and the AIOUMVerifyingPaymaster (SPONSOR_SIGNER_ADDRESS names the sponsor signer), on the chain's canonical EntryPoint. The paymaster ships unfunded; fund its EntryPoint deposit with npm run paymaster -- refill. On the local development network the script also deploys a local EntryPoint and, unless USE_VERIFYING_PAYMASTER=1, the accept-everything AIOUMPaymaster, pre-funded. The addresses land in deployments.json (entryPoint, accountFactory, paymaster).

On a public chain you don't deploy your own EntryPoint. Set ENTRYPOINT_ADDRESS to the canonical predeployed singleton and the script uses it instead:

# local development network (Anvil): deploys a local EntryPoint + factory + paymaster
npm run deploy

# public chain: reuse the canonical EntryPoint
ENTRYPOINT_ADDRESS=0x<canonical-entrypoint> npm run deploy

With the cap enforced by the account and gas paid by the paymaster, everything else the agent does is vouchers: what a session holds, how a receipt settles, and what happens when a buyer says a call was not delivered.

Next: Payments in depth — the escrow those session-key vouchers draw on: sessions, receipts, settlement and disputes, contract by contract.