x402 · USDC on Base + Solana

x402-skill-registry

A searchable index of agent skills you can pay for. Register with a signed listing, search per query. Every listing takes USDC on Base and Solana — enforced, not encouraged.

git clone https://github.com/nirholas/x402-skill-registry && cd x402-skill-registry && npm install && npm run dev
GitHub Tutorial API Reference For AI Agents Checkout demo

Pricing

RoutePriceWhat lands in the 200 body
POST /register$0.01The signed listing record — name, description, skill.md URL, route prices, rails, expiry — plus a one-time updateKey
GET /search?q=…$0.001Ranked results with skill.md URLs, prices and rails, each carrying score and matched so relevance is explainable
GET /skills.jsonfreeThe whole public index — every live listing with its signature
GET /listing/:id · POST /check-signature · GET / · /health · /skill.md · /.well-known/x402 · /openapi.jsonfreeLookup, signature validation, discovery
GET /index.htmlfreeA working browser checkout — connect a wallet and buy a search

How a paid call works

Agent GET /resource 402 Payment Required accepts: [ two rails ] USDC on Base EIP-3009 signature USDC on Solana signed SPL transfer the client picks X-PAYMENT facilitator: verify → settle 200 artifact
One round trip of overhead. Every 402 quotes both rails; the client signs whichever chain it holds USDC on.

Two rails, one price

USDC on Base
network base-sepolia / base
scheme exact · EIP-3009 transferWithAuthorization
payTo 0x40252CFDF8B20Ed757D61ff157719F33Ec332402
USDC on Solana
network solana / solana-devnet
scheme exact · SPL transferChecked, fee sponsored
payTo WwwuGbqHrwF5RG89KhUbmRWEvjnRH9k5kVM5p7T3WwW

Both rails appear in the accepts array of every 402. Settlement runs through the x402 facilitator, and the 200 carries an X-PAYMENT-RESPONSE receipt naming the rail and the transaction.

Quickstart

# 1. run it — seeded on first boot, so the index is not empty
npm install && npm run dev                    # http://localhost:4030

# 2. the index is free
curl -s localhost:4030/skills.json | jq '{count, policy}'
# { "count": 4, "policy": "Every listing here accepts USDC on both Base and Solana." }

# 3. search costs $0.001 → the dual-rail challenge
curl -s 'localhost:4030/search?q=domain' | jq '.accepts[] | {network, payTo, maxAmountRequired}'
# { "network": "base-sepolia", "payTo": "0x40252CF…2402", "maxAmountRequired": "1000" }
# { "network": "solana",       "payTo": "WwwuGbqH…T3WwW", "maxAmountRequired": "1000" }

# 4. pay → ranked listings, each with the skill.md URL you need next
const pay = wrapFetchWithPayment(fetch, wallet);
const { results } = await (await pay(`${BASE}/search?q=domain&rail=solana`)).json();

results[0].skillMdUrl   // "https://…/x402-domains/main/skill.md"
results[0].score        // 11
results[0].matched      // ["name", "category", "description", "resource"]

The one rule

A listing must carry both an EVM rail and a Solana rail. POST /register returns 422 NOT_DUAL_RAIL otherwise. Not a warning, not a badge — a rejection.

An agent's wallet lives on exactly one chain. A single-rail entry is unusable to everyone on the other one, and an index whose entries you cannot pay is a list of names, not a directory. Because the rule holds for every row, GET /search never needs a “can I actually pay this” filter: the answer is always yes.

curl -s localhost:4030/skills.json | jq '.listings[].listing.rails[] | {rail, network}'

{ "rail": "evm",    "network": "base-sepolia" }
{ "rail": "solana", "network": "solana" }

Why the index is free and search is not

GET /skills.json costs nothing. A directory nobody can read is not a directory, and gatekeeping the list would defeat the point of publishing it.

What costs $0.001 is ranking and filtering — the query, the score, the rail filter, the price ceiling. That is real work on your behalf, and it is what an agent actually wants: not “here are all 400 services” but “here are the three that do what I need, on my chain, under my budget.”

A working browser checkout

The repo ships public/index.html — a real checkout, not a mockup. Serve it and a human can connect a wallet and buy a search:

<script type="module" src="https://unpkg.com/@three-ws/x402-payment-modal"></script>

<button data-x402-endpoint="/search?q=domain"
        data-x402-method="GET"
        data-x402-merchant="x402-skill-registry"
        data-x402-action="Search the registry">Pay $0.001 &amp; search</button>

<script>
  addEventListener("x402:result", (e) => render(e.detail.data.results));
</script>

The modal reads both rails out of the 402 and offers the wallet choice itself — Phantom for Solana, any EVM wallet for Base — so nothing extra was needed beyond having two entries in accepts. It also does SIWX re-entry (sign in once, later purchases skip the prompt) and per-origin spending caps, so a page cannot quietly drain a wallet.

Signed, but not endorsed

Every listing is HMAC-SHA256 over canonical JSON, and POST /check-signature validates one for free. But read a signature for what it is: proof that this registry recorded that listing — not that the service behind skillMdUrl behaves as described. Fetch the skill.md and judge for yourself, which is exactly why the listing carries that URL.

Seeded rows carry origin: "seed" and registered ones origin: "registered", distinguishable in every response — a seeded row is not pretending to be traction.