Skip to content

402md/agentcard

Repository files navigation

Agent Card

License: MIT x402 Stellar TypeScript

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.


The problem

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.

What Agent Card does

  1. Deploys an OpenZeppelin Smart Account on Soroban with a Spending Limit Policy and a Per-Transaction Max Policy attached.
  2. Generates a scoped Ed25519 signer key for the agent -- narrow, non-custodial, useless outside the card's context rule.
  3. Gives you a single TypeScript file (402md-signer.ts) that implements the ClientStellarSigner interface 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.


Architecture

System overview

                     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.

Smart contracts (the chain side)

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.

OpenZeppelin Smart Account

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:

  1. Reads the AuthPayload from the transaction's credentials:
    AuthPayload {
      signers: Map<SignerKey, Signature>,
      context_rule_ids: Vec<u32>,
    }
    
  2. For each context_rule_id, loads the corresponding ContextRuleData from storage.
  3. Verifies the signer is registered under that rule.
  4. Computes the auth digest: sha256(signature_payload || context_rule_ids.to_xdr()).
  5. For External signers, calls the Ed25519 verifier contract to validate the signature against the digest.
  6. Runs every policy attached to the rule. If any policy rejects, the entire transaction fails.

Spending Limit Policy

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 to 17,280 (~1 day on Stellar, where each ledger is ~5 seconds).

On every payment, the policy:

  1. Reads the current ledger sequence from the Soroban host.
  2. Checks if the rolling window has reset (current ledger > last_reset + period_ledgers). If so, resets spent_this_period to zero.
  3. Adds the requested amount to spent_this_period.
  4. 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.

Per-Transaction Max Policy

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:

  1. Extracts the transfer amount from the Context::Contract args (transfer(from, to, amount)).
  2. Compares it to the stored tx_max limit for this (smart_account, context_rule_id) pair.
  3. 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.

Ed25519 Verifier

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.

Deploy sequence

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

WASM sources

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 testnet

The returned hash goes into TX_MAX_POLICY_WASM_HASH.

The signer (lives in your project)

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:

  1. 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 Uint8Array and creates a Keypair.

  2. Receives the auth entry: the x402 client calls signAuthEntry(entry, { address, expiration }). The signer extracts signaturePayloadHash from the entry -- a 32-byte hash that Soroban computed from the transaction's HashIdPreimage::SorobanAuthorization { network_id, nonce, signature_expiration_ledger, invocation }.

  3. 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_ids are serialized as a full ScVal::Vec(Some(ScVec([ScVal::U32(id)]))) -- including the XDR discriminant bytes, not just raw u32s. This matches what the Rust contract does via context_rule_ids.to_xdr(env).

  4. Signs the digest: keypair.sign(auth_digest) produces a 64-byte Ed25519 signature.

  5. Builds the AuthPayload: constructs the ScVal structure that __check_auth expects:

    Map {
      "signers": Map { External(verifier_address, agent_pubkey): signature_bytes },
      "context_rule_ids": Vec<u32> [agent_rule_id],
    }
    
  6. 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.

The web app (agentcard.402.md)

A Next.js 16 application with three user-facing routes and a REST API.

Wizard (/create)

Multi-step card creation flow:

  1. Connect -- user connects a Stellar wallet (Freighter, xBull, Albedo, Lobstr) via @creit.tech/stellar-wallets-kit (SEP-43).
  2. Configure -- set a label, daily budget (USD), and per-transaction max (USD).
  3. Review -- the API returns unsigned XDRs with estimated costs (~0.06 XLM). The user inspects what they're signing.
  4. Progress -- user signs and submits. Two rounds of signing, ~12 seconds total.
  5. Done -- shows the card's contract ID, agent key, rule ID, and verifier address. Copy the env vars or download the config.

Dashboard (/dashboard)

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.

skill.md (/skill.md)

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.

API (/api/v1)

The API builds unsigned XDRs and indexes deployed cards. It never holds keys, never signs transactions, never sits in the runtime payment path.

Non-custodial guarantee

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.

Tech stack

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

Monorepo structure

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

Development

Prerequisites

  • Bun >= 1.2.0
  • Node.js >= 20 (for tooling compatibility)
  • PostgreSQL (Neon recommended, or any Postgres instance)
  • Rust + wasm32-unknown-unknown target (for building the tx-max-policy contract)
  • Stellar CLI >= 25.2.0 (for stellar contract build / upload)

Setup

git clone <repo-url> && cd stellar-card
bun install

Copy .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:migrate

Start the dev server:

bun run dev

The app runs at http://localhost:3000.

Scripts

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)

Environment variables

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

API reference

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.


Database schema

Two tables in PostgreSQL (Neon). Managed by Drizzle ORM. Schema defined in packages/app/src/lib/db/schema.ts.

cards

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.

relayer_key_mints

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.


Deployment

  • 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).

Smoke test

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.

Disaster recovery

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.

Non-goals

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.

License

MIT

About

x402 programmable prepaid cards for AI agents with spending limits enforced on-chain by Stellar.

Resources

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages