Solana-native spending guardrails for AI agents. Give your agent a Solana wallet. Keep the rules on-chain.
| 🌐 Live Dapp | https://x402guard.acedata.cloud |
| 🔗 Anchor program (devnet) | 56TbAziiW8pDHFpRsxfnfBUfimBRTMCHw4gwDGw9uPW6 |
| 🤖 MCP transport | Streamable HTTP at https://x402guard.acedata.cloud/mcp/<session-token> |
| 📜 Hackathon | Colosseum Frontier 2026 (deadline May 11, 2026) |
✅ End-to-end verified live on 2026-05-10. Multiple on-chain
aceguard_spendinvocations against the production MCP endpoint settled on Solana devnet, e.g.249u8Pion…3y3Dand2v4c8TDV…aeYN, each returning cleanly withisError: falsefrom the MCP tool. The vault USDC balance ticked down by exactly the spend amount each time; on-chain ATA matches the DB exactly. Follow the hand-held demo below to reproduce on your own laptop.
AI agents are about to spend money on their own. Today the only options are:
- Hand it your private key / credit card — and watch one bad prompt drain the wallet.
- Approve every transaction by hand — which kills the autonomy that makes the agent useful.
x402guard is the third option: a Solana program that holds the agent's USDC inside a PDA and enforces a user-defined spending policy on every payment — daily caps, per-call caps, endpoint allowlists — so the agent can spend freely inside the rules and never outside them.
A single web app at x402guard.acedata.cloud that does three things:
| Surface | URL | Audience |
|---|---|---|
| Dapp UI | / |
Humans — connect Phantom, create vault PDA, top up, set policy, pause / clawback |
| REST API | /api/* |
The Dapp UI itself + the MCP layer |
| MCP endpoint | /mcp/<token> |
AI agents (Claude Desktop, Cursor, custom) — speaks Streamable HTTP MCP; exposes aceguard_balance, aceguard_history, aceguard_spend, aceguard_pay_for_api |
…all backed by one Anchor program that is the only thing capable of moving USDC out of the vault.
Alice (Phantom) x402guard.acedata.cloud
│ │
│ 1. Connect & sign challenge │
├─────────────────────────────────────────►│
│ │
│ 2. Create AgentVault (Phantom signs) │
├─────────────────────────────────────────►├──► Solana: agent_vault::create_vault(...)
│ │ Creates PDA: ["vault", alice, agent_id]
│ │ Stores Policy { daily_cap, per_call_cap,
│ │ endpoint_allowlist, ... }
│ 3. Top up USDC (Phantom signs SPL tx) │
├─────────────────────────────────────────►├──► USDC moves into vault PDA's ATA
│ │
│ 4. Mint MCP URL — /mcp/<token> │
│ │
▼ │
Pastes URL into Claude Desktop config │
│ │
▼ │
"Make me a birthday card" │
│ │
Claude → MCP "aceguard" │
│ tool: pay_for_api(midjourney/imagine) │
├─────────────────────────────────────────►│
│ │
│ api.acedata.cloud → 402 Payment Required
│ │
│ x402guard backend invokes
│ agent_vault::spend(0.025 USDC, ace-pay-to)
│ │
│ Solana program checks policy:
│ ✅ not paused, ✅ not expired,
│ ✅ daily cap, ✅ per-call cap,
│ ✅ endpoint allowlist, ✅ nonce
│ ✅ delegation key
│ → PDA-signs SPL transfer → tx 4fsV…3t
│ │
│ Backend builds X-Payment header from tx,
│ retries the API → 200 OK + image URL
│ │
◄──────────────────────────────────────────┤
Image returned to Claude → shown to Alice │
│ │
│ Vault page live-updates: balance 5 → 4.975, new spend on Solscan
Every spend is on-chain. Every rejection is on-chain. If the backend gets pwned, the program is still the boundary — attackers can spend at most daily_cap × allowlist, and Alice is one click from full clawback.
| Component | Solana primitive |
|---|---|
| Vault account holding USDC | PDA (no private key — only the program can move funds) |
| Spending policy storage | Anchor #[account] PDA, sibling to the vault |
| Atomic spend + ledger update | One spend() ix updates used_today, transfers USDC, emits a SpendEvent |
| Wallet UX | Phantom + @solana/wallet-adapter — no bridges, no MetaMask |
| Fee economics | ~5 000 lamports / spend (≈ $0.0008) makes 0.025 USDC microspends viable. Doesn't work on Ethereum L1. |
This isn't a multi-chain tool that supports Solana — it's Solana-only. PDA + SPL Token + sub-cent fees are load-bearing primitives, not decoration.
programs/agent_vault/ Anchor program (Rust). The on-chain spending boundary.
api/ FastAPI backend. Builds Solana txs, hosts MCP, signs delegation txs.
web/ Vue 3 + Vite Dapp. Phantom UX for vault management.
deploy/ Production K8s manifests + Caddy ingress.
.plans/ Design docs.
DEMO.md 4-minute demo runbook (scene-by-scene).
Each subdirectory has its own README.md with run instructions.
Follow this section start-to-finish on a fresh laptop. No Solana / Anchor / MCP background needed. By the end you'll have a finalized USDC transfer on Solana devnet, signed by the on-chain program, with a Solscan link to prove it.
- Install Phantom wallet (browser extension or mobile).
- Create or import a wallet. Save the seed phrase — you'll need it to recover.
- Switch Phantom to Devnet:
- Click the gear (⚙️) icon → Developer Settings → Testnet Mode → Enable.
- Then top-right network selector → Devnet.
- Copy your Phantom address — you'll paste it as
YOUR-PHANTOM-ADDRESSeverywhere below.
⚠️ The dapp is locked to devnet (it readsclusterfrom/.well-known/x402guard). If Phantom is on mainnet you'll get cryptic "blockhash not found" errors. Always double-check the network selector in Phantom shows "Devnet".
You need ~0.05 SOL of devnet gas. Open https://faucet.solana.com → paste your Phantom address → cluster Devnet → Confirm Airdrop. Refresh Phantom; you should see ~1 SOL within 5 seconds. (If the faucet is rate-limiting your IP, try Helius's faucet instead.)
The vault, your wallet, and the spend-recipient all need USDC ATAs (associated token accounts) on devnet. Minting yourself some devnet USDC creates yours in the same step.
- Open https://spl-token-faucet.com → pick USDC (Dev) → paste your Phantom address → Get tokens.
- Refresh Phantom → you should now see a USDC balance (e.g. 100 USDC). The Circle devnet USDC mint is
4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU.
💡 Why this matters for the demo: when we later run
aceguard_spend --recipient <YOUR-PHANTOM-ADDRESS>, the on-chain program needs the recipient's USDC ATA to already exist (Anchor returnsAccountNotInitialized/ error 3012 if it doesn't, deliberately — the program will never auto-create someone else's ATA). Because you're sending USDC back to your own wallet, the ATA you just created in this step is exactly what the program will write into. No extra setup needed.
-
Open https://x402guard.acedata.cloud in the same browser as Phantom.
-
Click Connect Phantom → Phantom popup → Sign the message that starts with
x402guard | sign in to manage your AI agent vaults | nonce=…. No SOL is spent — this is just an Ed25519 signature. -
Click + New vault and fill the form. The values below are tuned so the demo
aceguard_spendof 0.01 USDC fits comfortably under both caps:Field Demo value What it does Agent name demo-agentFree-form label Daily cap (USDC) 1Max the agent can spend per UTC day Per-call cap (USDC) 0.1Max in any single tx Expires in (days) 7After this, all spends are rejected Endpoint allowlist api.acedata.cloudOne host per line. Spends with any other endpoint_hostwill be rejected on-chain -
Click Create vault → Phantom popup → Approve. ~3 s for confirmation. Page redirects to the vault detail; the Vault PDA field is a Solscan deep-link — click it to see the on-chain account.
The vault PDA starts empty. Move some of your devnet USDC into it:
- On the vault detail page, find the Top up card.
- Enter
1(= 1 USDC) → Send USDC → Phantom popup → Approve. - Wait ~3 s. The vault's Balance chip should flip from
0.000000to1.000000USDC.
Scroll to the MCP sessions card on the vault detail page:
- Type a label, e.g.
cli-demo(optional, for your own reference). - Click + New MCP URL → a new row appears with
https://x402guard.acedata.cloud/mcp/<TOKEN>and a Copy button. - Copy that URL. Set it in your shell:
URL='https://x402guard.acedata.cloud/mcp/<YOUR-TOKEN>'
This URL is the only thing an agent ever sees. It's bound to one vault; you can revoke it any time from the same card.
Before installing anything heavier, prove the URL itself is healthy:
# tools/list — must return all four aceguard_* tools
curl -sS "$URL" -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | python3 -m json.tool
# aceguard_balance — must return your real on-chain vault balance
curl -sS "$URL" -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"aceguard_balance","arguments":{}}}' \
| python3 -m json.toolExpected: four tools listed (aceguard_balance, aceguard_history, aceguard_spend, aceguard_pay_for_api), and balance_usdc: 1.0.
If this works, your endpoint is healthy. Any
MCP could not be loadedyou see in Claude Desktop after this is a client config issue, not a server problem — see Step 9.
The repo ships a single-file demo that drives the MCP endpoint over JSON-RPC. No SDK, no MCP client, no Claude:
git clone https://github.com/AceDataCloud/x402Guard
cd x402Guard
# Read-only — lists tools, reads balance, reads history. Zero on-chain tx.
python3 -m pip install --user httpx # one-off
python3 scripts/demo.py "$URL"
# Or with bash + curl + jq if you don't have Python handy
brew install jq # one-off
./scripts/mcp-curl.sh "$URL"You should see the four tools, the vault balance (1.0 USDC), and an empty history.
Now spend 0.01 USDC from the vault. The recipient is your own Phantom wallet — its USDC ATA already exists from Step 2, so the on-chain transfer settles immediately.
python3 scripts/demo.py "$URL" --spend \
--recipient <YOUR-PHANTOM-ADDRESS> \
--amount 0.01
# Or with bash
./scripts/mcp-curl.sh "$URL" --spend \
--recipient <YOUR-PHANTOM-ADDRESS> \
--amount 0.01What happens:
- CLI sends a JSON-RPC
tools/callwithaceguard_spend(amount_usdc=0.01, recipient=<you>, endpoint_host="api.acedata.cloud"). - x402guard backend invokes
agent_vault::spend(...)on Solana devnet. - The Anchor program checks every gate on-chain: not-paused, not-expired, daily cap, per-call cap, allowlist hash match, monotonic nonce, valid delegation key.
- The program PDA-signs an SPL
transferCheckedfrom the vault's USDC ATA → your USDC ATA. - Transaction is finalized;
aceguard_spendreturns the tx signature + a Solscan link.
Expected output (real values from a verified live run):
━━━ 3. aceguard_spend → 0.01 USDC → <YOUR-PHANTOM-ADDRESS>
{
"tx": "2v4c8TDV…aeYN",
"amount_usdc": 0.01,
"nonce": 1,
"solscan": "https://solscan.io/tx/2v4c8TDV…aeYN"
}
━━━ 4. aceguard_balance (after)
balance: 1.000000 → 0.990000 USDC (Δ = -0.010000)
daily_remaining: 1.0 → 0.99 USDC
━━━ 5. aceguard_history (latest 5)
• <timestamp> | 0.01 USDC | → <YOUR-PHANTOM-ADDRESS> | endpoint=api.acedata.cloud
solscan: https://solscan.io/tx/2v4c8TDV…aeYN
✅ done.
Open the Solscan link — the tx is finalized on devnet, your USDC ATA is +0.01, the vault's USDC ATA is -0.01. Refresh the Dapp page → vault balance shows 0.990000 and a new history row.
💡 Want a different recipient? Any Solana address whose USDC ATA exists on devnet works. The simplest way to bootstrap one: have that recipient also follow Step 2 once (one-time per address), or any holder can pay rent:
spl-token --url devnet create-account 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU --owner <RECIPIENT>.
Now that you've proven the endpoint works, hook it into a real MCP client.
Claude Desktop speaks stdio MCP only — it does not load HTTP MCP endpoints from a {"url": "..."} config (that schema is for Claude.ai web "Custom Connectors", a different product). Use the community-standard mcp-remote bridge:
{
"mcpServers": {
"aceguard": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://x402guard.acedata.cloud/mcp/<YOUR-TOKEN>"
]
}
}
}Save to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows). Then fully quit Claude (Cmd+Q on macOS, not just close the window) and relaunch. Requires Node ≥ 18 on PATH so npx can fetch mcp-remote.
The four aceguard_* tools light up. Try:
"Use
aceguard_balanceto read my vault, then useaceguard_spendto send 0.01 USDC to<YOUR-PHANTOM-ADDRESS>for endpointapi.acedata.cloud. Show me the Solscan link."
Cursor / Cline / any HTTP-MCP-aware client take the URL directly with no bridge. Look in that client's MCP config for a field like mcpServers.<name>.url and paste your URL there.
| Tool | Use |
|---|---|
aceguard_balance |
Check USDC balance + remaining caps before spending |
aceguard_history |
List recent spends with Solscan tx links |
aceguard_spend |
Direct USDC transfer to any recipient (low-level) |
aceguard_pay_for_api |
High-level wrapper — give it a paid URL, it does the full x402 dance. Mainnet-only as of devnet deploy — see Known limitations. |
To prove the on-chain enforcement is real, deliberately violate the policy. Each rejection is signed by the on-chain program — the backend cannot override it.
# 1. Wrong endpoint — host is not on the allowlist → EndpointNotAllowed (Anchor 6005)
python3 scripts/demo.py "$URL" --spend \
--recipient <YOUR-PHANTOM-ADDRESS> \
--endpoint-host evil-api.com
# 2. Over per-call cap — vault was created with per_call_cap=0.1 USDC → PerCallCapExceeded
python3 scripts/demo.py "$URL" --spend \
--recipient <YOUR-PHANTOM-ADDRESS> \
--amount 0.5
# 3. Pause the vault from the Dapp (one Phantom signature), then retry → VaultPaused
# Resume from the same UI when done.You'll see each rejection with a clear Anchor error code in the response, e.g.:
detail: ... custom program error: 0x1779
Error Code: NonceReplay. Error Number: 6009. ...
Every rejection is on-chain. If the backend is compromised, the program is still the boundary — the most an attacker can do is spend daily_cap × allowlist, and the user is one click from full clawback.
aceguard_pay_for_apiagainstapi.acedata.cloudrequires mainnet.api.acedata.cloudissues its 402 quotes against the mainnet USDC mint (EPjFWdd5…,payTo: 5iVXFr…). The current x402guard deploy is on devnet with the Circle devnet mint — they're different chains, and the on-chain program correctly refuses to settle a mainnet quote with devnet tokens. This is expected and called out in.plans/X402GUARD.md§11; mainnet flip is a one-line config change.- Until then: drive
aceguard_spendto any devnet recipient (Step 8 above) — same on-chain ix, same policy enforcement, same Solscan-verifiable result. Or use the@acedatacloud/x402-clientSDK directly to make paidapi.acedata.cloudcalls from your own wallet (no x402guard policy enforcement, but proves the wire format end-to-end on mainnet today).
| Thing | Value |
|---|---|
| Anchor program | 56TbAziiW8pDHFpRsxfnfBUfimBRTMCHw4gwDGw9uPW6 |
| Cluster | devnet |
| USDC mint | 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU (Circle devnet) |
| Backend image | ghcr.io/acedatacloud/x402guard-api (linux/amd64) |
| Web image | ghcr.io/acedatacloud/x402guard-web (linux/amd64) |
| K8s namespace | acedatacloud (Tencent Cloud TKE, Hong Kong) |
| TLS | Let's Encrypt via the platform's tls-wildcard-acedata-cloud secret |
| Self-describe | https://x402guard.acedata.cloud/.well-known/x402guard |
💡 Why devnet, not mainnet? Deploying the program to mainnet costs ~3 SOL of real rent (rent-exempt deposit; recoverable on
program close). Devnet is the standard hackathon target — judges know to switch their wallets to devnet to verify. The mainnet deploy is a one-line change (SOLANA_CLUSTER=mainnet+solana program deploy --url mainnet) and we'll do it the day we record the final submission video.
docker compose up --build
open http://localhost:8080Brings up:
postgres— same engine as productionx402guard-api— FastAPI + uvicorn on:8000, schema auto-createdx402guard-web— nginx serving the Vite build, proxying/api,/mcp,/.well-known,/healthto the api
In Phantom: switch to Devnet (the local stack uses the public devnet RPC). Address 127.0.0.1:8080/.well-known/x402guard to see the cluster + program ID.
# Anchor program (Rust + cargo-build-sbf)
cd programs/agent_vault
cargo build-sbf --manifest-path Cargo.toml
# .so artifact lands at ../../target/deploy/agent_vault.so
# FastAPI backend
cd ../../api
poetry install
cp .env.example .env # then edit
PYTHONPATH=.. poetry run uvicorn api.app:app --reload --port 8000
# Vue Dapp (Vite hot-reload)
cd ../web
npm install
npm run dev # → http://localhost:5173 (proxies /api → :8000)| Where | Command | What |
|---|---|---|
api/ |
PYTHONPATH=.. pytest tests/ -q |
35 unit tests: auth flow, crypto wrap/unwrap, ix-builder discriminators + borsh field order, vault routes (with stubbed Solana RPC), MCP tools (with stubbed httpx) |
web/ |
npm run lint && npx vue-tsc --noEmit && CI=1 npx playwright test |
ESLint + Vue type-check + 4 Playwright e2e (home, pillars, /vaults guard, new-vault form validation) |
programs/agent_vault/ |
anchor test |
TypeScript+ts-mocha against a local validator: create_vault, spend, pause/resume, update_policy, clawback happy + 10 rejection cases |
| Local stack e2e | bash scripts/e2e-smoke.sh (planned) |
Auth → create → list → MCP tools/list against the running stack |
A clean run of all three suites takes <1 minute on a 2024 MacBook.
If you fork the repo you'll want your own program ID. The build pipeline expects target/deploy/agent_vault-keypair.json to exist — cargo build-sbf generates one on first build. Then:
# 1. Build the program
. "$HOME/.cargo/env"
export PATH="$HOME/.local/share/solana/install/active_release/bin:$PATH"
cd /path/to/x402guard
cargo build-sbf --manifest-path programs/agent_vault/Cargo.toml
# 2. Read the program ID from the build keypair
PROGRAM_ID=$(solana address -k target/deploy/agent_vault-keypair.json)
echo "$PROGRAM_ID"
# 3. Patch declare_id! / config / docker-compose / DEMO.md / api.yaml
OLD=56TbAziiW8pDHFpRsxfnfBUfimBRTMCHw4gwDGw9uPW6
for f in docker-compose.yaml deploy/production/api.yaml DEMO.md Anchor.toml \
programs/agent_vault/src/lib.rs api/core/config.py \
api/tests/conftest.py api/tests/test_health.py api/.env.example \
programs/agent_vault/README.md; do
sed -i '' "s|$OLD|$PROGRAM_ID|g" "$f"
done
# 4. Rebuild after the declare_id! change
cargo build-sbf --manifest-path programs/agent_vault/Cargo.toml
# 5. Fund a deployer wallet — devnet is free, mainnet needs ~3 real SOL
solana config set --url devnet
solana balance # should be ≥ 3 SOL
# 6. Deploy
solana program deploy target/deploy/agent_vault.so \
--program-id target/deploy/agent_vault-keypair.json \
--url devnet # swap to `mainnet` when you're ready
# 7. Verify
solana program show "$PROGRAM_ID" --url devnetFor a mainnet-bound deploy, also:
- Use
solana-keygen grind --starts-with X402:1to mint a vanity keypair before step 1. - Provision a real
x402guard-secretsKubernetes secret (APP_SECRET_KEY,CONNECTION_VAULT_KEY,DATABASE_URL,POSTGRES_PASSWORD). - Switch
SOLANA_CLUSTER+SOLANA_RPC_URLindeploy/production/api.yamltomainnet/ a paid RPC (Helius, Triton, etc. — the public one rate-limits).
# Pull the cluster's kubeconfig (stash it under /tmp; it never lives in repo)
source .claude/.env
python3 .claude/scripts/tke.py kubeconfig cls-fhngwqc2 > /tmp/kubeconfig-hk.yaml
export KUBECONFIG=/tmp/kubeconfig-hk.yaml
# (One-time) bootstrap secrets
kubectl -n acedatacloud create secret generic x402guard-secrets \
--from-literal=APP_SECRET_KEY=$(openssl rand -hex 32) \
--from-literal=CONNECTION_VAULT_KEY=$(openssl rand -hex 32) \
--from-literal=POSTGRES_PASSWORD=$(openssl rand -hex 16) \
--from-literal=DATABASE_URL='postgresql+asyncpg://x402guard:<PG_PASS>@x402guard-postgres.acedatacloud.svc.cluster.local:5432/x402guard'
# Build + push amd64 images (cluster nodes are x86_64)
TAG=$(date +%Y%m%d-%H%M%S)-amd64
docker buildx build --platform linux/amd64 \
-t "ghcr.io/acedatacloud/x402guard-api:$TAG" \
--load -f api/Dockerfile . && docker push "ghcr.io/acedatacloud/x402guard-api:$TAG"
docker buildx build --platform linux/amd64 \
-t "ghcr.io/acedatacloud/x402guard-web:$TAG" \
--load -f web/Dockerfile ./web && docker push "ghcr.io/acedatacloud/x402guard-web:$TAG"
# Roll out
BUILD_NUMBER=$TAG bash deploy/run.shThis pushes postgres.yaml, api.yaml, web.yaml, and ingress.yaml and waits for rollouts. The script ends with a probe of https://x402guard.acedata.cloud/health.
The same flow runs from .github/workflows/deploy.yaml once the cluster credentials land in repo secrets (gated on vars.DEPLOY_TO_K8S == 'true').
# Liveness
curl -sS https://x402guard.acedata.cloud/health
# → {"status":"ok","version":"0.1.0"}
# Self-describe
curl -sS https://x402guard.acedata.cloud/.well-known/x402guard
# → {"service":"x402guard","cluster":"devnet",
# "agent_vault_program_id":"56TbAzii...","usdc_mint":"4zMMC9..."}
# TLS cert chain
echo | openssl s_client -servername x402guard.acedata.cloud \
-connect x402guard.acedata.cloud:443 2>/dev/null \
| openssl x509 -noout -subject -issuer
# → subject=CN=acedata.cloud
# → issuer=Let's Encrypt E8
# MCP shape (unauthorised — just confirms the route is live)
curl -sS -X POST https://x402guard.acedata.cloud/mcp/no-such-token \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
# → {"detail":"unknown or revoked token"}
# Solana program is alive
curl -sS https://api.devnet.solana.com -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"getAccountInfo","params":["56TbAziiW8pDHFpRsxfnfBUfimBRTMCHw4gwDGw9uPW6",{"encoding":"jsonParsed"}]}' \
| python3 -c 'import json,sys; v=json.load(sys.stdin)["result"]["value"]; print("executable=", v["executable"], "owner=", v["owner"])'
# → executable= True owner= BPFLoaderUpgradeab1e11111111111111111111111Drives the full Phantom-auth → create-vault → MCP-session → tools/list flow against the live stack using a throwaway Ed25519 keypair. Useful for CI smoke after a deploy and for sanity-checking that nothing regressed without firing up Phantom.
cd api
poetry install || pip install -e .
python3 - <<'PY'
import asyncio, base64, json
import httpx
from datetime import UTC, datetime, timedelta
from nacl.signing import SigningKey
from solders.pubkey import Pubkey
BASE = "https://x402guard.acedata.cloud"
async def main():
async with httpx.AsyncClient(base_url=BASE, timeout=15) as c:
# 1. Phantom-style: GET challenge → Ed25519 sign → POST login
challenge = (await c.get("/api/v1/auth/challenge")).json()
sk = SigningKey.generate()
pubkey = str(Pubkey.from_bytes(bytes(sk.verify_key)))
sig = base64.b64encode(sk.sign(challenge["message"].encode()).signature).decode()
login = (
await c.post(
"/api/v1/auth/login",
json={"pubkey": pubkey, "message": challenge["message"], "signature": sig},
)
).json()
token = login["session_token"]
H = {"Authorization": f"Bearer {token}"}
print(f"AUTH_OK pubkey={pubkey[:10]}…")
# 2. Build an unsigned create_vault tx (we won't actually sign + send,
# just confirm the backend produced a valid tx envelope + persisted
# the pending row).
body = {
"agent_name": "Smoke-Agent",
"daily_cap_usdc": 1.0,
"per_call_cap_usdc": 0.5,
"endpoint_allowlist": ["api.acedata.cloud"],
"expires_at": (datetime.now(UTC) + timedelta(days=7)).isoformat(),
}
create = (await c.post("/api/v1/vaults/create", headers=H, json=body)).json()
print(f"CREATE_OK vault_pda={create['vault_pda'][:12]}… tx_b64_len={len(create['tx_b64'])}")
listed = (await c.get("/api/v1/vaults", headers=H)).json()
print(f"LIST_OK count={len(listed['vaults'])}")
vid = listed["vaults"][0]["id"]
# 3. Mint an MCP session for the vault we just created.
mcp = (
await c.post(
f"/api/v1/vaults/{vid}/mcp-sessions",
headers=H,
json={"label": "smoke-test"},
)
).json()
print(f"MCP_SESSION_OK mcp_url={mcp['mcp_url']}")
# 4. Hit the MCP endpoint directly. tools/list must return all 4 tools.
rpc = await c.post(
mcp["mcp_url"],
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
)
names = sorted(t["name"] for t in rpc.json()["result"]["tools"])
print(f"MCP_TOOLS_LIST_OK tools={names}")
asyncio.run(main())
PYExpected output:
AUTH_OK pubkey=ABcD123…
CREATE_OK vault_pda=Hx12abcdEF… tx_b64_len=756
LIST_OK count=1
MCP_SESSION_OK mcp_url=https://x402guard.acedata.cloud/mcp/<token>
MCP_TOOLS_LIST_OK tools=['aceguard_balance', 'aceguard_history', 'aceguard_pay_for_api', 'aceguard_spend']
The script does not execute spend (that needs real devnet USDC + a delegation key signing on-chain) — for that, see the in-browser walkthrough above.
The /mcp/<token> URL works with any MCP client. Quickest way to poke it without writing code:
# 1. Mint an MCP URL for one of your vaults (web UI → Vault detail → +New MCP URL → Copy)
MCP_URL="https://x402guard.acedata.cloud/mcp/<your-token>"
# 2. Run the official inspector (npx; no install needed)
npx @modelcontextprotocol/inspector --transport http --url "$MCP_URL"
# 3. In the popped-up browser tab:
# - Click "Connect" → you'll see "x402guard 0.1.0" in serverInfo
# - Click "List tools"
# - Expand "aceguard_balance" → Run (no args) → JSON payload
# - Expand "aceguard_pay_for_api" → fill `url` = "https://api.acedata.cloud/midjourney/imagine"
# `json_body` = {"prompt": "test"}
# → RunIf aceguard_pay_for_api returns on-chain spend rejected: …, the on-chain VaultError reason maps 1:1 to the agent error message — vault_paused, per_call_cap_exceeded, daily_cap_exceeded, endpoint_not_allowed, etc. That's the policy boundary doing its job.
| Symptom | Cause | Fix |
|---|---|---|
tx confirmation timed out after Create vault |
Phantom is on mainnet but the program is on devnet | Phantom → ⚙️ Developer Settings → Network → Devnet |
| Phantom popup says "This dapp couldn't connect" | Phantom blocks the site (extension is locked or ad-blocker interferes) | Click the Phantom toolbar icon → unlock; disable shields for x402guard.acedata.cloud |
spend rejected: endpoint_not_allowed in Claude |
The URL host doesn't sha256-match a vault allowlist entry | Open the vault detail page → confirm Allowlist shows the host. The hash check normalises scheme + path + case but the host string itself must match exactly |
spend rejected: per_call_cap_exceeded for tiny calls |
The vault's per_call_cap_usdc is below the upstream API's price |
Raise the per-call cap from the vault detail page (1 Phantom signature) |
| Top-up Phantom popup shows "insufficient funds" | Wallet has no devnet USDC | Visit https://spl-token-faucet.com/?token-name=USDC-Dev → mint to your wallet. Devnet USDC mint is 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU |
Failed to load vaults: Request failed with status code 500 |
Backend lost its DB connection or a fresh deploy hasn't run schema migrations | Check kubectl -n acedatacloud logs deploy/x402guard-api. The lifespan hook auto-runs init_models() on every boot, so a pod restart usually clears it |
MCP tools/list returns unknown or revoked token |
Session token revoked or vault deleted | Go to the vault detail page and create a fresh MCP URL |
aceguard_pay_for_api hangs >30 s |
Upstream API is slow; Solana RPC under load | The 180 s ingress timeout covers most cases. If recurring, switch to a paid RPC by setting SOLANA_RPC_URL in x402guard-secrets |
| Phantom signs but "Solscan tx not found" | Phantom is set to mainnet while frontend confirms on devnet (or vice versa) | Make sure both Phantom and the site agree. The site's cluster is exposed at /.well-known/x402guard |
Devnet airdrop returns 429 / faucet dry |
Same IP rate-limited | Use https://faucet.solana.com (browser captcha) or a paid RPC's faucet (Helius/QuickNode) |
- Anchor program ✅ deployed to devnet (
56TbAzii…uPW6); 6 instructions, 19 ts-mocha tests - FastAPI backend ✅ Phantom auth, vault tx builders, MCP at
/mcp/<token>, spend executor; 35 pytest cases - Vue 3 Dapp ✅ create / top-up / pause / resume / clawback / MCP sessions; 4 Playwright smoke tests
- Docker + Tencent TKE + Caddy ✅ live at
x402guard.acedata.cloud - 4-minute demo runbook ✅
DEMO.md
Mainnet deploy + final demo video ⏳ — pending submission window.
See .plans/X402GUARD.md for the full design plan, six-day track schedule, and risk register.
This project is a submission to Colosseum Frontier 2026, the global Solana-only hackathon run by Colosseum and the Solana Foundation.
Why we'll place:
- Solana primitives, used correctly. PDA, SPL, Anchor errors, on-chain events, sub-cent fees — every one is load-bearing, not decoration. Judges who care about depth see this in 60 seconds of code review.
- Working live demo. Real devnet program, real Solscan tx hashes for every action, real upstream
api.acedata.cloud402 calls (the AceDataCloud platform already supports x402 + Solana mainnet — see X402Client). - Pitch survives the hallway test. "Give your AI agent a Solana wallet. Keep the rules on-chain."
MIT — see LICENSE.
Built by Ace Data Cloud. @x402guard on the web.