Hand an AI agent a wallet that holds nothing and can only ever move value inside policy.
Autonomous agents need to move money to act, but an unrestricted key plus one prompt injection or runaway loop can drain it. SpendArc gives each agent a policy-checked USDC vault on Arc: the agent holds no funds, a server policy gate rejects off-policy requests before they are ever signed, and the vault re-checks every spend on-chain before a single micro-unit of USDC moves.
Live frontend: deploy with the one-command script (see Deploy) · Contracts on Arc mainnet 5042.
One command · The one rule · Architecture · How it uses Arc · Tests · Deploy
Built for the Circle Arc Microgrants program on Arc mainnet, MIT licensed. SpendArc is developer infrastructure for bounding agent spend; it is not a custodial service and nothing here is financial advice.
- See it in one command
- The one rule
- What SpendArc does
- Architecture
- Component by component
- Safety, enforced in code
- How it uses Arc
- Proven on Arc mainnet
- Engineering decisions
- What is real vs pending
- Tests
- Run it locally
- The one-flow demo
- Configuration
- Deploy
- Project layout
- Tech stack, credits, roadmap
- Disclaimer and license
Contracts:
cd SpendArc
forge build # compile the vault + factory
forge test # 24 passingFrontend:
cd SpendArc/web
npm install
cp .env.example .env.local # fill in the values from Configuration
npm run dev # http://localhost:3000After it is running, connect a wallet, create your vault with a leash, deposit USDC, register an agent to mint a one-time API key, and hand that key to any AI agent. Every write is a real transaction your wallet signs.
An agent can never move value outside the policy its owner set.
That rule is enforced in three layers, and no layer trusts the one before it:
- Key scope. The agent holds only a scoped API key, never a private key and never custody of funds. The vault holds the USDC.
- Server policy gate (fence 1). Every payment request is checked against the owner's leash - active, not expired, per-tx cap, daily cap, per-service budgets, recipient and token allowlists - before the executor key signs anything. An off-policy request is answered with a structured
BLOCKEDdecision and is never broadcast. - Contract layer (fence 2).
executeSpendForre-checks the full on-chain policy before moving USDC. A blocked action emitsAgentActionBlockedand moves nothing - no revert, no state change.
One signature deploys a vault owned by the caller through SpendArcVaultFactory (one vault per wallet), pre-configured with the chosen leash and pre-allowlisted to USDC and the owner's own address.
createVault(maxPerTx, dailyCap, expiry) -> new SpendArcVault owned by msg.sender
The factory records the vault and authorizes the platform executor to spend within policy only - it can never change policy or withdraw.
An agent requests a payment with its API key; the server runs fence 1, then the executor submits executeSpendFor, which runs fence 2 on-chain.
POST /api/payments/request { agentId, recipient, amount, token }
Authorization: Bearer <agent-api-key>
-> APPROVED (tx hash) | BLOCKED (reason)
The executor decodes the receipt and only records a payment as confirmed when the vault emits AgentActionApproved.
Each allowlisted payee carries its own per-tx and daily budget, enforced on-chain and mirrored to the server fence, on top of the agent's global leash.
setAllowedService(agent, target, label, maxPerTx, dailyCap, expiry, allowed)
The vault owner tightens a leash or allowlists a service by signing the change in their own wallet; the server store is then updated only after the on-chain change is verified, so the two fences never drift apart.
Decision 1: the vault is the source of truth. The server policy store is a mirror of on-chain state, never an independent authority, so a bug or bypass in the server cannot loosen what the chain enforces.
Decision 2: the executor key is powerless beyond spending within policy. It can submit executeSpendFor but can never change policy, add executors, or withdraw, so its blast radius is bounded by each vault's leash even if it leaks.
flowchart LR
Agent[AI agent<br/>scoped API key] -->|payment request| Server[Server policy gate<br/>fence 1]
Server -->|off-policy| Blocked[BLOCKED<br/>never signed]
Server -->|in-policy| Executor[Executor key<br/>executeSpendFor]
Executor --> Vault[SpendArcVault<br/>fence 2 on-chain]
Vault -->|re-check fails| OnchainBlock[AgentActionBlocked<br/>nothing moves]
Vault -->|re-check passes| Paid[USDC to recipient<br/>AgentActionApproved]
| Tier | Runs | Can it move funds? | Responsibility |
|---|---|---|---|
| Agent | anywhere | No | Requests payments with a scoped key |
| Server gate | Next.js API | No (cannot sign) | Rejects off-policy requests before signing |
| Executor | server key | Only within policy | Submits executeSpendFor |
| Vault | Arc mainnet | Yes, within policy | Final on-chain enforcement |
| Owner | owner wallet | Yes (deposit/withdraw) | Sets policy, funds the vault |
Contracts:
| File | Responsibility |
|---|---|
src/SpendArcVault.sol |
Holds USDC and enforces per-agent policy on every spend |
src/SpendArcVaultFactory.sol |
Deploys one owner-controlled vault per wallet |
src/MockUSD.sol |
Local test USDC |
Backend (Next.js API):
| Route | Responsibility |
|---|---|
app/api/agents/user |
Register an agent after a wallet-ownership signature; mint the API key |
app/api/payments/request |
Run fence 1 then the on-chain spend (API key required) |
app/api/allowlist/[agentId]/sync |
Mirror a verified on-chain allowlist change into the server store |
app/api/policies/[agentId]/update |
Return owner-signable calldata for a leash change |
app/api/transactions/proof |
Latest approved + blocked actions for the live proof |
Frontend libraries:
| Module | Responsibility |
|---|---|
web/lib/arc.ts |
Network-aware chain config (defaults to Arc mainnet 5042) |
web/lib/executor.ts |
Submit spends and decode the receipt events |
web/lib/serverAuth.ts |
API-key and wallet-signature verification |
web/lib/proof.ts |
Live read of the on-chain proof receipts |
| Claim | How it is enforced |
|---|---|
| An agent cannot spend without a valid key | payments/request returns 401 without a matching Bearer key |
| You cannot register a wallet you do not control | registration verifies a signature over a canonical challenge |
| The server fence cannot be loosened out of band | the allowlist/policy store is written only by /sync after an on-chain read |
| A spend cannot invoke an arbitrary contract | executeSpend* reverts on non-empty calldata; only ERC-20 USDC transfers move value |
| A blocked spend is never counted as paid | the executor records a payment only when AgentActionApproved is emitted |
| A leaked executor key cannot drain a vault | the executor can only spend within each agent's on-chain leash |
SpendArc is Arc-native: USDC is the gas token and the unit of account, and the vault enforces spend policy directly on-chain.
Reads: vault policy, per-service budgets, remaining daily cap, and the approved/blocked proof receipts, all read live over Arc RPC.
Writes: createVault, deposit, setAllowedService, setAgentPolicy (owner-signed), and executeSpendFor (executor-signed).
| Contract | Address |
|---|---|
| SpendArcVaultFactory | 0xd79850b02aFDeE63F9fcB0eB385eB6B1526dB7fb |
| SpendArcVault (reference) | 0x8D80cEEe03C86de7d90478c5D7599004f375Ef6F |
| USDC (native ERC-20 interface) | 0x3600000000000000000000000000000000000000 |
RPC https://rpc.mainnet.arc.io · Explorer https://explorer.arc.io · Chain id 5042.
A live same-agent, one-variable proof against the reference vault's $1/tx leash - two spends differing only in amount:
| Action | Amount | Result | Tx |
|---|---|---|---|
| Approved | 0.5 USDC | AgentActionApproved, USDC moved |
0x05796ab9 |
| Blocked | 2 USDC | AgentActionBlocked, reason exceeds maxPerTx, nothing moved |
0xbb922fda |
| Executor authorization | - | setExecutor |
0x9c9081d8 |
The vault never performs an arbitrary call.
An earlier design let a spend carry calldata and do target.call{value}(data), which is a path to invoking any contract as the vault.
The vault now rejects non-empty calldata and only ever transfers ERC-20 USDC, so a spend cannot be turned into an arbitrary action.
The server trusts the chain, not the other way around. The server policy store exists for fast pre-checks and an audit trail, but it is only ever written to match verified on-chain state. This means a compromised or buggy server can at worst fail closed; it cannot authorize a spend the chain would reject.
Registration proves wallet ownership. Minting an API key for an address requires a signature over a canonical challenge from that address, so no one can register a vault they do not control or front-run a real owner.
| Capability | Status |
|---|---|
| Policy enforcement on-chain | Real - verified on Arc mainnet (see proof txs) |
| Two-fence payment pipeline | Real - server gate plus on-chain re-check |
| Per-service budgets | Real - enforced on-chain and mirrored to the server |
| Wallet-ownership registration | Real - signature verified server-side |
| Hosted live deployment | Not yet established - deploy to a host with the production env set |
| Professional security audit | Not yet established - the current review is deadline-scoped |
The hosted URL and a full audit are the honest next steps; the on-chain enforcement itself is live and verifiable now.
| Suite | Tests | Covers |
|---|---|---|
test/SpendArcVault.t.sol |
16 | Caps, expiry, allowlists, per-service budgets, dedup, calldata rejection |
test/SpendArcVaultFactory.t.sol |
8 | One vault per owner, leash validation, wiring |
cd SpendArc
forge test # 24 passingFrontend type safety and build:
cd SpendArc/web
npm run typecheck
npm run build- Contracts:
cd SpendArc
forge build
forge test- Frontend:
cd SpendArc/web
npm install
cp .env.example .env.local # fill in the values from Configuration
npm run dev # http://localhost:3000- Point
.env.localat mainnet or testnet with the matching chain id, RPC, explorer, and deployed addresses.
- Connect your wallet on the dashboard.
- Create your vault with a leash (for example $1 per tx, $5 per day).
- Deposit USDC into the vault.
- Register your agent - one wallet signature proves ownership and mints a one-time API key.
- Make an approved payment under the cap and watch it confirm on-chain (
AgentActionApproved). - Make a payment over the cap and watch it get blocked (
AgentActionBlocked, nothing moves).
Frontend (web/.env.local):
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_ARC_CHAIN_ID |
5042 for mainnet, 5042002 for testnet |
NEXT_PUBLIC_ARC_RPC_URL |
Arc RPC endpoint |
NEXT_PUBLIC_ARC_EXPLORER_URL |
Arc explorer base URL |
NEXT_PUBLIC_USDC_ADDRESS |
Native USDC ERC-20 interface |
NEXT_PUBLIC_FACTORY_ADDRESS |
Deployed factory |
NEXT_PUBLIC_VAULT_ADDRESS |
Reference vault |
NEXT_PUBLIC_PRIVY_APP_ID |
Privy auth app id |
Server (web/.env.local):
| Variable | Purpose |
|---|---|
EXECUTOR_PRIVATE_KEY |
Signs executeSpendFor only; keep separate from the owner key |
TURSO_DATABASE_URL |
Policy store (required in production) |
TURSO_AUTH_TOKEN |
Policy store auth |
Deploy script (repo root .env or inline):
| Variable | Purpose |
|---|---|
PRIVATE_KEY |
Funded Arc key used by deploy-mainnet.sh |
EXECUTOR_ADDRESS |
Separate server signer authorized on the factory (recommended) |
Contracts to Arc mainnet, one command - the only input is a funded Arc key:
cd SpendArc
PRIVATE_KEY=0x<funded-key> ./deploy-mainnet.shIt deploys the factory, creates your reference vault, and prints the NEXT_PUBLIC_FACTORY_ADDRESS and NEXT_PUBLIC_VAULT_ADDRESS lines to paste into web/.env.local.
See MAINNET.md for the full checklist, including setting a separate executor key.
Frontend to a host (for example Vercel): set the frontend and server variables from Configuration, including TURSO_*, and build with npm run build.
SpendArc/
src/ # Solidity - vault, factory, mock USDC
test/ # Foundry suite (24 tests)
script/ # deploy scripts (DeployMainnet is the one-command entry)
deploy-mainnet.sh # one-command Arc deploy wrapper
web/ # Next.js app (marketing + dashboard + API)
lib/ # vendored deps (OpenZeppelin, forge-std)
MAINNET.md # mainnet deploy + go-live checklist
Solidity 0.8.28, Foundry, Next.js 15, React 19, viem, wagmi, Privy, Turso/libSQL, Tailwind.
Built on Circle's Arc and native USDC, OpenZeppelin contracts, and the Foundry toolchain.
Hosted deployment, a separate production executor key on the factory, a professional audit before raising caps, and an in-app testnet/mainnet switch for first-time testing.
SpendArc is developer infrastructure for bounding autonomous-agent spend. It is not a custodial service, not a wallet, and nothing in this repository is financial advice. The contracts have had a deadline-scoped security review, not a full audit; do not hold significant value in a vault until an audit is complete.
MIT licensed. See LICENSE.