Programmable USDC spending cards for AI agents on Stellar testnet. You set a daily cap and a per-transaction max. The Stellar validator set enforces both. No SDK override, no clever prompt, and no jailbreak can push the number higher.
Built for the Stellar x402 Hackathon (April 2026).
Live at agentcard.402.md. A product by 402.md.
Your agent can plan, write code, and call tools. Then it hits a paywall: an API that wants cents per request. Everything stops.
You can't hand it your credit card. You can't share an API key without paying for every abuse. You can't build a rolling spending cap in application code and trust it to survive one prompt injection. Application-level guards are promises. Promises break.
What you actually want is a card with a hard ceiling. A daily budget the agent can burn through freely, a per-transaction max that caps any single bad decision, and the certainty that the enforcement lives outside your process, outside your SDK, outside anything the agent can reason about.
- Deploys an OpenZeppelin Smart Account on Soroban with a Spending Limit Policy and a Per-Transaction Max Policy attached.
- Generates a scoped Ed25519 signer key for the agent -- narrow, non-custodial, useless outside the card's context rule.
- Gives you a single TypeScript file (
402md-signer.ts) that implements theClientStellarSignerinterface from@x402/stellar. Drop it into your project, set six env vars, and your agent pays for x402 APIs within the budget.
The chain enforces the cap. Not your code.
DEPLOY TIME RUNTIME
---------- -------
Browser Agent runtime
| |
| 1. Connect wallet | 1. Agent fetches priced URL
| 2. Configure budget | 2. Signer builds AuthPayload
| 3. Sign XDRs | 3. x402 client sends to facilitator
v v
Agent Card API OZ Facilitator
| (channels.openzeppelin.com)
| Build unsigned XDRs |
| Index card in Postgres | Settles payment on Stellar
v v
Stellar (Soroban) Stellar (Soroban)
| |
| Deploy Smart Account | __check_auth runs
| Deploy Spending Limit Policy | Policy checks spent-per-window
| Register agent context rule | Validator set accepts or rejects
v v
Card ready Payment settled (or rejected)
Agent Card is only on the deploy path. At runtime, the agent talks directly to Stellar via the OpenZeppelin facilitator. If Agent Card disappeared tomorrow, every deployed card would keep working.
Agent Card uses three audited WASM contracts from OpenZeppelin/stellar-contracts plus one custom policy contract (tx-max-policy) that enforces per-transaction spending caps on-chain.
The card itself. A Soroban contract that holds USDC, manages signers, and runs __check_auth on every transaction.
The smart account supports two signer types:
- Delegated: a classic Stellar account (the owner's wallet). Full control. Used to deploy, configure, freeze, rotate keys, change limits.
- External: a raw Ed25519 public key verified by a separate verifier contract. This is what the agent holds. It can only sign within the context rule it was registered under.
When the smart account's __check_auth is invoked, it:
- Reads the
AuthPayloadfrom the transaction's credentials:AuthPayload { signers: Map<SignerKey, Signature>, context_rule_ids: Vec<u32>, } - For each
context_rule_id, loads the correspondingContextRuleDatafrom storage. - Verifies the signer is registered under that rule.
- Computes the auth digest:
sha256(signature_payload || context_rule_ids.to_xdr()). - For External signers, calls the Ed25519 verifier contract to validate the signature against the digest.
- Runs every policy attached to the rule. If any policy rejects, the entire transaction fails.
Attached to the agent's context rule. Enforces two constraints:
spending_limit: maximum USDC (as i128 subunits, 7 decimals) the agent can spend per rolling window.period_ledgers: window size in ledgers. Agent Card sets this to17,280(~1 day on Stellar, where each ledger is ~5 seconds).
On every payment, the policy:
- Reads the current ledger sequence from the Soroban host.
- Checks if the rolling window has reset (current ledger > last_reset + period_ledgers). If so, resets
spent_this_periodto zero. - Adds the requested amount to
spent_this_period. - If
spent_this_period > spending_limit, the policy returns an error. The smart account propagates this to__check_auth, and the Stellar validator set rejects the transaction.
There is no admin override. There is no emergency backdoor. The math is the rule.
A custom Soroban policy contract (contracts/tx-max-policy/) deployed alongside the Spending Limit Policy. While the spending limit tracks cumulative spending over a rolling window, this policy checks each individual transfer amount against a configured cap.
On every payment, the policy:
- Extracts the transfer amount from the
Context::Contractargs (transfer(from, to, amount)). - Compares it to the stored
tx_maxlimit for this(smart_account, context_rule_id)pair. - If
amount > tx_max, the policy panics. The smart account propagates this to__check_auth, and the transaction is rejected.
The policy is stateless per-transaction -- it reads the limit and compares, no history tracking. This makes it cheaper in CPU instructions than the Spending Limit Policy.
The owner can update the per-transaction max via set_tx_max (requires wallet signing, same auth pattern as set_spending_limit). A read-only get_tx_max function is also available.
Source: contracts/tx-max-policy/src/lib.rs (~120 lines of Rust). Built with stellar contract build, uploaded to testnet via stellar contract upload. The WASM hash is stored in TX_MAX_POLICY_WASM_HASH.
A stateless contract deployed once per network. Given a public key, a message digest, and a signature, it calls Soroban's crypto.ed25519_verify host function. The smart account delegates external signer verification to this contract so it doesn't need to embed cryptographic logic.
Card creation requires four transactions, all signed by the owner's wallet:
| # | Transaction | What it does |
|---|---|---|
| 1 | createCustomContract (Smart Account WASM) |
Deploys the smart account with the owner as a Delegated signer. Constructor args: [Vec<Signer>, Map<Address, Val>]. |
| 2 | createCustomContract (Spending Limit Policy WASM) |
Deploys the spending limit policy contract. No constructor args. |
| 3 | createCustomContract (Tx Max Policy WASM) |
Deploys the per-transaction max policy contract. No constructor args. |
| 4 | add_context_rule on the smart account |
Registers the agent's context rule: binds the External signer (agent pubkey + verifier address), scopes it to CallContract(USDC_SAC), and installs both policies — the spending limit with { period_ledgers: 17280, spending_limit: <daily_budget> } and the tx-max with { tx_max: <per_tx_cap> }. Policy map keys are sorted by address XDR bytes. |
Contract IDs are deterministic: sha256(deployer_address || salt). The API generates random salts and derives the IDs before deployment so the wizard can show them in the review step.
The third transaction is the complex one. The owner's wallet signs the outer transaction, but Soroban also needs an auth entry for the smart account itself (because add_context_rule triggers __check_auth). The API builds an AuthPayload with { signers: { Delegated(owner): empty_bytes }, context_rule_ids: [0] } and patches it into the XDR. The smart account sees the Delegated signer, calls owner.require_auth_for_args((auth_digest,)), and the wallet's source-account credential satisfies it.
After the owner signs, the API re-simulates the patched transaction in enforce mode to get a complete Soroban footprint (the initial simulation runs with a void payload and misses storage keys that __check_auth reads when it has a real signer).
The three OpenZeppelin WASM binaries come from OpenZeppelin/stellar-contracts. They are uploaded to testnet once by a maintainer bootstrap script. The resulting WASM hashes are committed as environment variables (SMART_ACCOUNT_WASM_HASH, SPENDING_LIMIT_POLICY_WASM_HASH). The Ed25519 verifier is deployed once and its contract address stored in ED25519_VERIFIER_ADDRESS.
The per-transaction max policy is a custom contract in contracts/tx-max-policy/. It imports types from the stellar-accounts crate (v0.7.1) for guaranteed compatibility with the deployed Smart Account. Build and upload:
cd contracts/tx-max-policy
stellar contract build --profile release
stellar contract upload \
--wasm target/wasm32v1-none/release/tx_max_policy.optimized.wasm \
--source deployer --network testnetThe returned hash goes into TX_MAX_POLICY_WASM_HASH.
402md-signer.ts is a ~105-line TypeScript file that implements the ClientStellarSigner interface from @x402/stellar. It ships as a single file -- no npm package, no build step. You drop it into your project and import createStellarCardSigner.
What it does on every payment:
-
Decodes the agent key: the agent's private key is stored as 64 hex characters (raw Ed25519 seed). The signer decodes it to a 32-byte
Uint8Arrayand creates aKeypair. -
Receives the auth entry: the x402 client calls
signAuthEntry(entry, { address, expiration }). The signer extractssignaturePayloadHashfrom the entry -- a 32-byte hash that Soroban computed from the transaction'sHashIdPreimage::SorobanAuthorization { network_id, nonce, signature_expiration_ledger, invocation }. -
Computes the auth digest: the OpenZeppelin smart account doesn't sign the raw signature payload. It composes a digest:
auth_digest = sha256(signature_payload || Vec<u32>::to_xdr(context_rule_ids))The
context_rule_idsare serialized as a fullScVal::Vec(Some(ScVec([ScVal::U32(id)])))-- including the XDR discriminant bytes, not just raw u32s. This matches what the Rust contract does viacontext_rule_ids.to_xdr(env). -
Signs the digest:
keypair.sign(auth_digest)produces a 64-byte Ed25519 signature. -
Builds the AuthPayload: constructs the
ScValstructure that__check_authexpects:Map { "signers": Map { External(verifier_address, agent_pubkey): signature_bytes }, "context_rule_ids": Vec<u32> [agent_rule_id], } -
Attaches credentials: returns the modified auth entry with the AuthPayload as credentials and the card contract as
sourceAddress.
The signer never touches the network. It's pure cryptography: key decode, hash, sign, encode.
A Next.js 16 application with three user-facing routes and a REST API.
Multi-step card creation flow:
- Connect -- user connects a Stellar wallet (Freighter, xBull, Albedo, Lobstr) via
@creit.tech/stellar-wallets-kit(SEP-43). - Configure -- set a label, daily budget (USD), and per-transaction max (USD).
- Review -- the API returns unsigned XDRs with estimated costs (~0.06 XLM). The user inspects what they're signing.
- Progress -- user signs and submits. Two rounds of signing, ~12 seconds total.
- Done -- shows the card's contract ID, agent key, rule ID, and verifier address. Copy the env vars or download the config.
Card management:
- Card list: all cards owned by the connected wallet (indexed by
ownerPubkey). - Card detail (
/dashboard/[cardId]): live USDC balance (read from Soroban), daily spending limit, spent today, remaining, recent payments (from Horizon events). - Admin actions: freeze card, unfreeze card, rotate agent signer key, change daily limit, fund card with USDC from owner wallet. Each action builds an unsigned XDR, the user signs it, done.
An LLM instruction document. Paste https://agentcard.402.md/skill.md into Claude Code, Cursor, ChatGPT, or any agent that fetches URLs. It reads the skill, walks the user through wallet connection, deploys the card, writes 402md-signer.ts into the project, installs dependencies, sets env vars, and runs a test payment. The signer source code is inlined in the markdown.
The API builds unsigned XDRs and indexes deployed cards. It never holds keys, never signs transactions, never sits in the runtime payment path.
Agent Card is infrastructure, not a custodian. Built by 402.md.
- No keys held. The owner's wallet key stays in their browser extension. The agent's signer key is generated client-side and stored in the user's
.env. - No runtime dependency. Payments flow from the agent to OpenZeppelin's facilitator to Stellar. Agent Card is not in that path.
- No fee. Agent Card charges nothing. Transaction fees go to Stellar validators. Settlement goes through the OZ facilitator.
- No data lock-in. The Postgres database is a public index of on-chain state. If the database disappeared, cards would keep working. You'd just lose the "list my cards" UI.
If Agent Card went offline:
- Deployed cards keep working (they're Soroban contracts).
- The signer keeps working (it's a file in your project).
- Payments keep working (they go through OZ facilitator + Stellar).
- Only the wizard and dashboard break.
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| Language | TypeScript 5.6 (strict) |
| Runtime | React 19 |
| Styling | Tailwind CSS 4.0 |
| Package manager | Bun 1.2+ |
| Blockchain SDK | @stellar/stellar-sdk 13.0 |
| Wallet integration | @creit.tech/stellar-wallets-kit 1.7 (SEP-43) |
| x402 protocol | @x402/stellar, @x402/core |
| Smart contracts | OpenZeppelin Smart Account, Spending Limit Policy, Ed25519 Verifier (Soroban WASM) |
| Database | PostgreSQL (Neon), Drizzle ORM 0.36 |
| Validation | Zod 3.23 |
| Logging | Pino 9.0 |
| Hosting | Vercel (app + API), Neon (database), Cloudflare (DNS) |
| CI/CD | GitHub Actions (lint, typecheck, test, build) |
| Testing | Jest 29.7 + @swc/jest |
| Linting | ESLint 9.0 + TypeScript ESLint |
| Formatting | Prettier 3.3 |
| Git hooks | Husky 9.1 + lint-staged 15.0 |
stellar-card/
├── contracts/
│ └── tx-max-policy/ # Custom Soroban policy (Rust) — per-tx max enforcement
│ ├── Cargo.toml # soroban-sdk + stellar-accounts deps
│ └── src/lib.rs # Policy impl: install, enforce, uninstall, set_tx_max
│
├── packages/
│ ├── app/ # Next.js application (wizard, dashboard, API)
│ │ └── src/
│ │ ├── app/ # App Router pages + API routes
│ │ │ ├── api/v1/ # REST API (cards, keygen, config, health)
│ │ │ ├── create/ # Wizard page
│ │ │ └── dashboard/ # Dashboard pages
│ │ ├── components/
│ │ │ ├── wizard/ # Multi-step card creation flow
│ │ │ └── marketing/ # Landing page components
│ │ └── lib/
│ │ ├── stellar/ # Horizon + Soroban clients, tx builders, card state reader
│ │ ├── db/ # Drizzle schema + client
│ │ ├── oz/ # OpenZeppelin relayer proxy
│ │ ├── env.ts # Zod-validated environment variables
│ │ ├── api-response.ts # jsonOk, jsonError, handleRoute helpers
│ │ ├── api-client.ts # Browser-side fetch wrapper
│ │ ├── errors.ts # ApiError class + error factories
│ │ ├── logger.ts # Pino logger instance
│ │ └── rate-limit.ts # Per-IP rate limiting (60 req/min)
│ │
│ └── signer/ # 402md-signer.ts source + single-file builder
│ ├── src/ # Source modules (index, keys, digest, auth-payload, types)
│ └── dist/
│ └── 402md-signer.ts # Built single-file output
│
├── apps/
│ └── skill-md-generator/ # Builds skill.md with inlined signer source
│
├── docs/
│ └── operations/
│ └── deploy.md # Vercel, Neon, env var setup, disaster recovery
│
├── .github/workflows/
│ ├── ci.yml # Lint, typecheck, test, build
│ └── db-migrate.yml # Manual database migrations
│
├── package.json # Root monorepo config (Bun workspaces)
├── vercel.json # Vercel framework + build settings
├── tsconfig.base.json # Shared TypeScript base config
└── eslint.config.js # ESLint configuration
- Bun >= 1.2.0
- Node.js >= 20 (for tooling compatibility)
- PostgreSQL (Neon recommended, or any Postgres instance)
- Rust +
wasm32-unknown-unknowntarget (for building the tx-max-policy contract) - Stellar CLI >= 25.2.0 (for
stellar contract build/upload)
git clone <repo-url> && cd stellar-card
bun installCopy .env.example to .env.local in packages/app/ and fill in the values (see Environment variables below).
Run database migrations:
bun run --cwd packages/app db:generate
bun run --cwd packages/app db:migrateStart the dev server:
bun run devThe app runs at http://localhost:3000.
| Command | What it does |
|---|---|
bun run dev |
Next.js dev server (turbo, port 3000) |
bun run lint |
ESLint across all workspaces |
bun run lint:fix |
ESLint with auto-fix |
bun run format |
Prettier format all files |
bun run format:check |
Prettier check (CI mode) |
bun run typecheck |
TypeScript type checking across all workspaces |
bun run test |
Jest across all workspaces |
bun run --cwd packages/signer build:single |
Build 402md-signer.ts single file |
bun run --cwd apps/skill-md-generator build |
Build skill.md + copy signer to public/ |
bun run --cwd packages/app build |
Production build |
bun run --cwd packages/app db:generate |
Generate Drizzle migration SQL |
bun run --cwd packages/app db:migrate |
Apply migrations |
bun run --cwd packages/app db:studio |
Open Drizzle Studio (database UI) |
All validated at startup by Zod (packages/app/src/lib/env.ts). The app crashes immediately if any are missing or malformed.
| Variable | Description | Example |
|---|---|---|
STELLAR_NETWORK |
testnet or pubnet |
testnet |
HORIZON_URL |
Stellar Horizon API | https://horizon-testnet.stellar.org |
SOROBAN_RPC_URL |
Soroban RPC endpoint | https://soroban-testnet.stellar.org |
USDC_SAC_ADDRESS |
USDC Stellar Asset Contract address | C... |
USDC_ASSET_CODE |
Asset code | USDC |
USDC_ISSUER |
USDC issuer public key | G... |
USDC_ONRAMP_URL |
Link to USDC faucet/on-ramp | https://faucet.circle.com/ |
USDC_ONRAMP_LABEL |
Display label for the on-ramp link | Circle testnet faucet |
SMART_ACCOUNT_WASM_HASH |
Hex hash of uploaded Smart Account WASM | a1b2c3... |
SPENDING_LIMIT_POLICY_WASM_HASH |
Hex hash of uploaded Spending Limit Policy WASM | d4e5f6... |
TX_MAX_POLICY_WASM_HASH |
Hex hash of uploaded Tx Max Policy WASM | 6715d7... |
ED25519_VERIFIER_ADDRESS |
Deployed Ed25519 verifier contract | C... |
OZ_FACILITATOR_URL |
OpenZeppelin x402 facilitator | https://channels.openzeppelin.com/x402/testnet |
OZ_RELAYER_GEN_URL |
OpenZeppelin relayer key mint endpoint | https://channels.openzeppelin.com/testnet/gen |
DATABASE_URL |
PostgreSQL connection string | postgresql://user:pass@host/db |
LOG_LEVEL |
Pino log level | info |
Base URL: https://agentcard.402.md/api/v1
Rate limit: 60 requests per minute per IP.
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Service health check |
GET |
/config |
Network + contract metadata (cacheable) |
POST |
/keygen/agent |
Generate Ed25519 keypair for agent |
POST |
/cards/prepare |
Build unsigned deploy XDRs (4 transactions) |
POST |
/cards/register |
Index deployed card in Postgres (anti-spam: verifies on-chain state) |
GET |
/cards/:cardId |
Fetch live card state (balance, limits, status from Soroban) |
GET |
/cards/owned-by/:ownerPubkey |
List all cards for an owner |
POST |
/cards/relayer-key |
Mint OZ relayer API key (proxied to OZ) |
POST |
/cards/:cardId/set-limit/prepare |
Build XDR to change daily budget |
POST |
/cards/:cardId/set-tx-max/prepare |
Build XDR to change per-transaction max (on-chain) |
POST |
/cards/:cardId/freeze/prepare |
Build XDR to freeze agent rule |
POST |
/cards/:cardId/unfreeze/prepare |
Build XDR to unfreeze agent rule |
POST |
/cards/:cardId/rotate-agent/prepare |
Build XDR to swap agent signer key |
POST |
/cards/:cardId/fund/prepare |
Build XDR to send USDC from owner wallet to card |
All mutating endpoints return unsigned XDRs. The user signs them with their wallet. The API never signs anything.
Error codes: INVALID_PUBKEY, INVALID_AMOUNT, INVALID_CONTRACT_ID, CARD_NOT_FOUND, OWNER_MISMATCH, NOT_REGISTERED, SIMULATION_FAILED, HORIZON_UNAVAILABLE, SOROBAN_RPC_UNAVAILABLE, OZ_RELAYER_UNAVAILABLE, RATE_LIMITED.
Two tables in PostgreSQL (Neon). Managed by Drizzle ORM. Schema defined in packages/app/src/lib/db/schema.ts.
Public index of deployed cards. Every field is derivable from on-chain state -- the database is a convenience layer, not a source of truth.
| Column | Type | Description |
|---|---|---|
id |
SERIAL PK |
Auto-increment row ID |
contract_id |
TEXT UNIQUE NOT NULL |
Smart account contract ID (C...) |
owner_pubkey |
TEXT NOT NULL |
Owner's classic Stellar address (G...) |
agent_pubkey |
TEXT NOT NULL |
Agent's Ed25519 public key (64-char hex) |
spending_limit_policy_id |
TEXT NOT NULL |
Spending limit policy contract ID |
tx_max_policy_id |
TEXT |
Per-tx max policy contract ID (null for legacy cards) |
agent_rule_id |
INTEGER NOT NULL |
Context rule ID in the smart account |
label |
TEXT |
User-supplied label |
daily_budget_usd |
NUMERIC(18,6) NOT NULL |
Daily USDC cap |
tx_max_usd |
NUMERIC(18,6) |
Per-transaction USDC cap |
created_at |
TIMESTAMPTZ NOT NULL |
Row creation timestamp |
Indexes: idx_cards_owner on owner_pubkey, idx_cards_contract on contract_id.
Analytics-only table tracking OZ relayer key mints per card.
| Column | Type | Description |
|---|---|---|
id |
SERIAL PK |
Auto-increment row ID |
card_id |
TEXT NOT NULL FK |
References cards.contract_id |
minted_at |
TIMESTAMPTZ NOT NULL |
Mint timestamp |
Index: idx_mints_card on card_id.
No PII stored. All data is public on-chain. The database is purely an index for the dashboard UI.
- App + API: Vercel (automatic deploys on push to
main, preview deploys on PRs) - Database: Neon PostgreSQL (production branch)
- DNS: Cloudflare for
agentcard.402.md(DNS only, no proxy) - CI/CD: GitHub Actions runs lint + typecheck + test as a merge gate
Vercel project settings:
- Framework: Next.js
- Root directory:
packages/app - Build command:
bun run build - Install command:
bun install --frozen-lockfile
Database migrations run manually via the Database migrations GitHub Actions workflow (.github/workflows/db-migrate.yml).
After every deploy: run the wizard at https://agentcard.402.md/create end-to-end. Deploy a card, check it in the dashboard, verify the balance reads correctly.
| What goes down | Impact | Action |
|---|---|---|
| Vercel | Wizard + dashboard offline. Deployed cards keep working. | Wait for Vercel to recover. |
| Neon | "List my cards" breaks. Cards keep working. | Restore from Neon automatic backups. |
| OZ Facilitator | Runtime payments fail. | Nothing Agent Card can do. Wait for OZ. |
| Stellar testnet | Everything stops. | Wait for Stellar. |
Things Agent Card deliberately does not do:
- Not a wallet. It doesn't hold your keys or manage your balances. Use Freighter or another Stellar wallet for that.
- Not MPC/multisig. The agent signer is a scoped Ed25519 key, not a threshold scheme. Simplicity is the point.
- Not an npm package. The signer is one file. No dependency tree, no version conflicts, no supply chain attack surface.
- Not a hosted signer. The signer runs in your process. We never see your agent key.
- Not mainnet. Testnet only for this hackathon. The primitives are the same; the deploy is one env flag away.
- Not a whitelist policy. The spending limit is the only policy implemented. Whitelist (restricting which contracts the agent can call) is future work.
MIT