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:
| Contract | Path | Role |
|---|---|---|
AIOUMAccount | src/aa/AIOUMAccount.sol | ERC-4337 smart account with budgeted session keys + ERC-1271 |
AIOUMAccountFactory | src/aa/AIOUMAccountFactory.sol | CREATE2 deterministic deployer (ERC-4337 initCode) |
AIOUMVerifyingPaymaster | src/aa/AIOUMVerifyingPaymaster.sol | The network's gas sponsor: pays gas only for UserOps carrying a time-boxed signature from the sponsor signer |
AIOUMPaymaster | src/aa/AIOUMPaymaster.sol | Local 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:
- a session key opens a session via a sponsored UserOp — the smart account is the client and the USDC is pulled from it;
- the session key signs the per-call voucher and the operator settles it via ERC-1271;
- an over-budget op is rejected (the EntryPoint reverts the bundle);
- a revoked key is rejected;
- 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.