diff --git a/.gitignore b/.gitignore index 5de3f4f..f7da16b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,8 @@ dist coverage .DS_Store -# Markdown files (except skills) +# Markdown files (except skills and application content) *.md !README.md !.claude/skills/**/*.md +!packages/*/src/**/*.md diff --git a/packages/relay/src/agent-meta/api-catalog.json b/packages/relay/src/agent-meta/api-catalog.json new file mode 100644 index 0000000..fa8409c --- /dev/null +++ b/packages/relay/src/agent-meta/api-catalog.json @@ -0,0 +1,28 @@ +{ + "linkset": [ + { + "anchor": "https://facilitator.402.md/", + "service-desc": [ + { + "href": "https://facilitator.402.md/swagger/json", + "type": "application/json", + "title": "402md Facilitator OpenAPI spec" + } + ], + "service-doc": [ + { + "href": "https://facilitator.402.md/swagger", + "type": "text/html", + "title": "402md Facilitator API reference" + } + ], + "status": [ + { + "href": "https://facilitator.402.md/health", + "type": "application/json", + "title": "Facilitator health check" + } + ] + } + ] +} diff --git a/packages/relay/src/agent-meta/assets.d.ts b/packages/relay/src/agent-meta/assets.d.ts new file mode 100644 index 0000000..86f3e3f --- /dev/null +++ b/packages/relay/src/agent-meta/assets.d.ts @@ -0,0 +1,14 @@ +declare module '*.md' { + const content: string + export default content +} + +declare module '*.json' { + const content: string + export default content +} + +declare module '*.xml' { + const content: string + export default content +} diff --git a/packages/relay/src/agent-meta/dashboard.md b/packages/relay/src/agent-meta/dashboard.md new file mode 100644 index 0000000..2a85c0d --- /dev/null +++ b/packages/relay/src/agent-meta/dashboard.md @@ -0,0 +1,32 @@ +# 402md Facilitator — Activity dashboard + +A read-only view of live facilitator activity: supported chains, cross-chain routes, top resources and sellers, recent transactions, and cost comparisons. + +Canonical URL: https://facilitator.402.md/dashboard + +> **If you are an agent, do not scrape this page.** Every number on the dashboard comes from a structured JSON endpoint. Use those directly. + +## Structured endpoints (preferred) + +| Endpoint | Purpose | +| -------------------------------- | ----------------------------------------------------------------------- | +| `GET /discovery/resources` | Active x402 resources. Filter by `network`, sort by `uses` or `volume`. | +| `GET /discovery/stats` | Aggregate facilitator stats over a time window. | +| `GET /discovery/routes` | Cross-chain route activity and status matrix. | +| `GET /discovery/sellers` | Top sellers by volume or transaction count. | +| `GET /discovery/transactions` | Recent transactions with chain, route, and status. | +| `GET /discovery/cost-comparison` | Per-route gas cost comparison. | +| `GET /health` | Dependency health (DB, Redis, Temporal). | +| `GET /swagger/json` | Full OpenAPI spec. | + +Every `discovery/*` endpoint accepts a `window` query parameter (`1h`, `24h`, `7d`, `30d`, `all`) and standard `limit`/`offset` pagination. + +## What's on the dashboard + +- **Architecture snapshot** — chains supported, active routes, protocols. +- **Per-chain breakdown** — transactions, volume, and health per chain. +- **Route matrix** — every source → destination pair, with volume and latency. +- **Top resources** — most-used x402 resources, rankable by uses or volume. +- **Top sellers** — ranked by USDC received. +- **Recent transactions** — latest settlements across all chains. +- **Cost comparison** — gas allowance per route vs. competing rails. diff --git a/packages/relay/src/agent-meta/landing.md b/packages/relay/src/agent-meta/landing.md new file mode 100644 index 0000000..858212a --- /dev/null +++ b/packages/relay/src/agent-meta/landing.md @@ -0,0 +1,79 @@ +# 402md Facilitator + +**Cross-chain USDC settlement for x402 and MPP.** Buyer pays on any supported chain, seller receives native USDC on their chain. One HTTP request to onboard. No SDK, no dashboard, no custom code. + +Canonical URL: https://facilitator.402.md/ + +## Supported chains + +All via Circle CCTP V2 with native USDC (no wrapped tokens, no slippage). + +- **EVM**: Base, Ethereum, Optimism, Arbitrum, Linea, Unichain, World Chain +- **Solana**: mainnet +- **Stellar**: pubnet + +## Getting started (seller) + +```http +POST https://facilitator.402.md/register +Content-Type: application/json + +{ "wallet": "GSELLER...", "network": "stellar:pubnet" } +``` + +The response returns your `merchantId` and facilitator pay-to addresses on every supported chain. Drop them into standard `@x402/express` and you are done. + +## How it works + +1. Seller registers their wallet once. No login, no dashboard. +2. Seller uses standard `@x402/express` middleware. Facilitator address as `payTo`, `merchantId` in `extra`. Zero 402md dependencies. +3. Buyer pays in USDC on any supported chain. Same-chain = direct transfer. Cross-chain = Circle CCTP V2 burns on source, mints native USDC on seller's chain. + +Settlement latency: + +- Same-chain: under 5 seconds +- Solana/Stellar cross-chain: 5-30 seconds +- EVM cross-chain: 15-19 minutes (block finality) + +Buyer always gets the resource instantly — settlement runs in the background. + +## Design choices + +- **Non-custodial.** CCTP V2 mints directly to the seller's wallet. 402md never holds funds beyond the gas allowance needed to execute settlement. +- **Free.** 0% platform fee at launch. Only a fixed gas allowance is deducted per route. +- **Zero dependencies.** Sellers use the standard `@x402/express` SDK from Coinbase. The only 402md-specific step is `POST /register`. +- **Dual protocol.** Same facilitator serves x402 and MPP. Two ecosystems, one registration. +- **Native USDC only.** Circle CCTP V2 burns and mints real USDC. No DEX, no bridge tokens. + +## Pricing + +Zero platform fee. Only fixed gas allowance per route. On a $1.00 payment: + +| Route | Seller receives | Gas allowance | +| ----------------- | --------------- | ----------------- | +| Stellar → Stellar | $0.99999 | $0.00001 (0.001%) | +| Solana → Solana | $0.9990 | $0.0010 (0.10%) | +| Base → Base | $0.9980 | $0.0020 (0.20%) | +| Stellar → Solana | $0.9985 | $0.0015 (0.15%) | +| Solana → Stellar | $0.9975 | $0.0025 (0.25%) | +| Stellar → Base | $0.9975 | $0.0025 (0.25%) | +| Base → Stellar | $0.9968 | $0.0032 (0.32%) | +| Base → Solana | $0.9965 | $0.0035 (0.35%) | +| Solana → Base | $0.9965 | $0.0035 (0.35%) | + +## Discovery for agents + +- `GET /health` — dependency health +- `GET /swagger` — OpenAPI docs (HTML) +- `GET /swagger/json` — OpenAPI spec (JSON) +- `GET /discovery/resources` — public registry of x402 resources +- `GET /discovery/routes` — cross-chain route and cost matrix +- `GET /.well-known/api-catalog` — RFC 9727 link set +- `GET /.well-known/agent-skills/index.json` — skills index for agent onboarding + +## Ecosystem + +- [x402 spec](https://www.x402.org/) — Coinbase's HTTP 402 protocol +- [MPP spec](https://github.com/AgoraPay-Stellar/mpp-specification) — Machine Payments Protocol +- [Circle CCTP V2](https://www.circle.com/cctp) — native USDC cross-chain +- [Source code](https://github.com/402md) diff --git a/packages/relay/src/agent-meta/negotiation.test.ts b/packages/relay/src/agent-meta/negotiation.test.ts new file mode 100644 index 0000000..38a520c --- /dev/null +++ b/packages/relay/src/agent-meta/negotiation.test.ts @@ -0,0 +1,84 @@ +import { describe, test, expect } from 'bun:test' +import '@/shared/test-helpers' +import { Elysia } from 'elysia' +import { html } from '@elysiajs/html' +import { LandingPage } from '@/views/landing/page' +import { DashboardPage } from '@/views/dashboard/page' +import { + AGENT_DISCOVERY_LINK_HEADER, + landingMarkdown, + dashboardMarkdown, + wantsMarkdown, +} from './routes' + +// Mirrors the `/` and `/dashboard` handlers in src/index.ts. Kept in sync +// so agent-facing behavior (Accept negotiation + Link header) has a test +// without spinning up the full relay (DB/Redis/Temporal) in unit tests. +const app = new Elysia() + .use(html()) + .get('/', ({ request, set }) => { + set.headers['link'] = AGENT_DISCOVERY_LINK_HEADER + set.headers['vary'] = 'Accept' + if (wantsMarkdown(request.headers.get('accept'))) { + set.headers['content-type'] = 'text/markdown; charset=utf-8' + return landingMarkdown + } + return LandingPage() + }) + .get('/dashboard', ({ request, set }) => { + set.headers['link'] = AGENT_DISCOVERY_LINK_HEADER + set.headers['vary'] = 'Accept' + if (wantsMarkdown(request.headers.get('accept'))) { + set.headers['content-type'] = 'text/markdown; charset=utf-8' + return dashboardMarkdown + } + return DashboardPage() + }) + +function get(path: string, headers: Record = {}) { + return new Request(`http://localhost${path}`, { headers }) +} + +describe('GET / — content negotiation', () => { + test('returns HTML by default with Link and Vary headers', async () => { + const res = await app.handle(get('/')) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('text/html') + expect(res.headers.get('vary')).toBe('Accept') + const link = res.headers.get('link') + expect(link).toContain('rel="api-catalog"') + expect(link).toContain('rel="service-desc"') + const body = await res.text() + expect(body.startsWith('')).toBe(true) + }) + + test('returns markdown when Accept: text/markdown is preferred', async () => { + const res = await app.handle(get('/', { accept: 'text/markdown' })) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('text/markdown') + expect(res.headers.get('vary')).toBe('Accept') + expect(res.headers.get('link')).toContain('rel="api-catalog"') + const body = await res.text() + expect(body).toContain('# 402md Facilitator') + expect(body).toContain('POST https://facilitator.402.md/register') + }) +}) + +describe('GET /dashboard — content negotiation', () => { + test('returns HTML by default with Link and Vary headers', async () => { + const res = await app.handle(get('/dashboard')) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('text/html') + expect(res.headers.get('vary')).toBe('Accept') + expect(res.headers.get('link')).toContain('rel="service-desc"') + }) + + test('returns markdown when Accept: text/markdown is preferred', async () => { + const res = await app.handle(get('/dashboard', { accept: 'text/markdown' })) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('text/markdown') + const body = await res.text() + expect(body).toContain('# 402md Facilitator — Activity dashboard') + expect(body).toContain('/discovery/') + }) +}) diff --git a/packages/relay/src/agent-meta/robots.txt b/packages/relay/src/agent-meta/robots.txt new file mode 100644 index 0000000..f38aa47 --- /dev/null +++ b/packages/relay/src/agent-meta/robots.txt @@ -0,0 +1,67 @@ +# robots.txt for facilitator.402.md +# 402md Facilitator is a public HTTP API designed for AI agents. +# Crawling, indexing, and real-time agent retrieval are welcome. +# The live dashboard is excluded from indexing because it is already +# exposed as a structured API under /discovery/*. + +User-agent: * +# Content Signals (https://contentsignals.org/) +# ai-train=no : do not use this content for training +# search=yes : index for search, including AI-assisted search +# ai-input=yes: agents may read this content at query time +Content-Signal: search=yes, ai-train=no, ai-input=yes +Allow: / +Disallow: /dashboard + +# ── AI / LLM crawlers ───────────────────────────────────────────── +# Training crawlers: disallowed. This site's content is marketing +# and API reference, not a training corpus. +# Real-time retrieval agents: allowed. Helping agents use the +# facilitator is literally the product. + +User-agent: GPTBot +Disallow: / + +User-agent: ClaudeBot +Disallow: / + +User-agent: Google-Extended +Disallow: / + +User-agent: CCBot +Disallow: / + +User-agent: anthropic-ai +Disallow: / + +User-agent: Meta-ExternalAgent +Disallow: / + +User-agent: Applebot-Extended +Disallow: / + +User-agent: OAI-SearchBot +Allow: / +Disallow: /dashboard + +User-agent: ChatGPT-User +Allow: / +Disallow: /dashboard + +User-agent: Claude-Web +Allow: / +Disallow: /dashboard + +User-agent: Claude-User +Allow: / +Disallow: /dashboard + +User-agent: PerplexityBot +Allow: / +Disallow: /dashboard + +User-agent: Perplexity-User +Allow: / +Disallow: /dashboard + +Sitemap: https://facilitator.402.md/sitemap.xml diff --git a/packages/relay/src/agent-meta/routes.test.ts b/packages/relay/src/agent-meta/routes.test.ts new file mode 100644 index 0000000..60145eb --- /dev/null +++ b/packages/relay/src/agent-meta/routes.test.ts @@ -0,0 +1,158 @@ +import { describe, test, expect } from 'bun:test' +import '@/shared/test-helpers' +import { Elysia } from 'elysia' +import { agentMetaRoutes, wantsMarkdown, AGENT_DISCOVERY_LINK_HEADER } from './routes' + +const app = new Elysia().use(agentMetaRoutes) + +function get(path: string, headers: Record = {}) { + return new Request(`http://localhost${path}`, { headers }) +} + +describe('wantsMarkdown', () => { + test('true when only text/markdown is requested', () => { + expect(wantsMarkdown('text/markdown')).toBe(true) + }) + + test('true when markdown has higher q than html', () => { + expect(wantsMarkdown('text/html;q=0.5, text/markdown;q=0.9')).toBe(true) + }) + + test('false when html is preferred', () => { + expect(wantsMarkdown('text/html, text/markdown;q=0.5')).toBe(false) + }) + + test('false when header is missing or wildcard', () => { + expect(wantsMarkdown(null)).toBe(false) + expect(wantsMarkdown('*/*')).toBe(false) + }) +}) + +describe('AGENT_DISCOVERY_LINK_HEADER', () => { + test('advertises the required agent-discovery relations', () => { + expect(AGENT_DISCOVERY_LINK_HEADER).toContain('rel="api-catalog"') + expect(AGENT_DISCOVERY_LINK_HEADER).toContain('rel="service-desc"') + expect(AGENT_DISCOVERY_LINK_HEADER).toContain('rel="service-doc"') + expect(AGENT_DISCOVERY_LINK_HEADER).toContain('rel="status"') + expect(AGENT_DISCOVERY_LINK_HEADER).toContain('rel="sitemap"') + }) +}) + +describe('GET /robots.txt', () => { + test('returns 200 as plain text with AI rules, content-signal, and sitemap', async () => { + const res = await app.handle(get('/robots.txt')) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('text/plain') + const body = await res.text() + expect(body).toContain('User-agent: *') + expect(body).toContain('User-agent: GPTBot') + expect(body).toContain('User-agent: Claude-Web') + expect(body).toContain('User-agent: Google-Extended') + expect(body).toContain('Sitemap: https://facilitator.402.md/sitemap.xml') + }) + + test('places Content-Signal inside the User-agent: * block', async () => { + const res = await app.handle(get('/robots.txt')) + const body = await res.text() + const starIdx = body.indexOf('User-agent: *') + const signalIdx = body.indexOf('Content-Signal:') + const nextGroupIdx = body.indexOf('User-agent: GPTBot') + expect(starIdx).toBeGreaterThanOrEqual(0) + expect(signalIdx).toBeGreaterThan(starIdx) + expect(signalIdx).toBeLessThan(nextGroupIdx) + }) +}) + +describe('GET /sitemap.xml', () => { + test('returns valid sitemap XML with canonical URLs', async () => { + const res = await app.handle(get('/sitemap.xml')) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('application/xml') + const body = await res.text() + expect(body).toContain(' { + const res = await app.handle(get('/sitemap.xml')) + const body = await res.text() + expect(body).not.toContain('/dashboard') + }) +}) + +describe('GET /.well-known/api-catalog', () => { + test('returns linkset+json with required relations', async () => { + const res = await app.handle(get('/.well-known/api-catalog')) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('application/linkset+json') + const body = (await res.json()) as { + linkset: Array<{ + anchor: string + 'service-desc': Array<{ href: string }> + 'service-doc': Array<{ href: string }> + status: Array<{ href: string }> + }> + } + expect(body.linkset).toHaveLength(1) + expect(body.linkset[0].anchor).toBe('https://facilitator.402.md/') + expect(body.linkset[0]['service-desc'][0].href).toContain('/swagger/json') + expect(body.linkset[0]['service-doc'][0].href).toContain('/swagger') + expect(body.linkset[0].status[0].href).toContain('/health') + }) +}) + +describe('GET /.well-known/agent-skills/index.json', () => { + test('conforms to the agent-skills-discovery v0.2.0 schema', async () => { + const res = await app.handle(get('/.well-known/agent-skills/index.json')) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('application/json') + const body = (await res.json()) as { + $schema: string + skills: Array<{ + name: string + type: string + description: string + url: string + digest: string + }> + } + expect(body.$schema).toBe('https://schemas.agentskills.io/discovery/0.2.0/schema.json') + expect(body.skills.length).toBeGreaterThan(0) + const howTo = body.skills.find((s) => s.name === 'how-to-use-facilitator') + expect(howTo).toBeDefined() + expect(howTo!.type).toBe('skill-md') + expect(howTo!.url).toContain('/.well-known/agent-skills/how-to-use-facilitator/SKILL.md') + expect(howTo!.digest).toMatch(/^sha256:[a-f0-9]{64}$/) + }) + + test('digest matches the actual SKILL.md body', async () => { + const indexRes = await app.handle(get('/.well-known/agent-skills/index.json')) + const index = (await indexRes.json()) as { + skills: Array<{ name: string; digest: string }> + } + const skillRes = await app.handle( + get('/.well-known/agent-skills/how-to-use-facilitator/SKILL.md'), + ) + const skillBody = await skillRes.text() + const hasher = new Bun.CryptoHasher('sha256') + hasher.update(skillBody) + const expected = `sha256:${hasher.digest('hex')}` + const howTo = index.skills.find((s) => s.name === 'how-to-use-facilitator')! + expect(howTo.digest).toBe(expected) + }) +}) + +describe('GET /.well-known/agent-skills/how-to-use-facilitator/SKILL.md', () => { + test('returns markdown content describing how to use the facilitator', async () => { + const res = await app.handle(get('/.well-known/agent-skills/how-to-use-facilitator/SKILL.md')) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('text/markdown') + const body = await res.text() + expect(body).toContain('name: how-to-use-facilitator') + expect(body).toContain('type: skill-md') + expect(body).toContain('POST https://facilitator.402.md/register') + expect(body).toContain('CCTP V2') + }) +}) diff --git a/packages/relay/src/agent-meta/routes.ts b/packages/relay/src/agent-meta/routes.ts new file mode 100644 index 0000000..a99d7a3 --- /dev/null +++ b/packages/relay/src/agent-meta/routes.ts @@ -0,0 +1,110 @@ +import { Elysia } from 'elysia' +import robotsTxt from './robots.txt' with { type: 'text' } +import sitemapXml from './sitemap.xml' with { type: 'text' } +import apiCatalogJson from './api-catalog.json' with { type: 'text' } +import howToUseSkillMd from './skills/how-to-use-facilitator.md' with { type: 'text' } +import landingMd from './landing.md' with { type: 'text' } +import dashboardMd from './dashboard.md' with { type: 'text' } + +export const PUBLIC_BASE_URL = 'https://facilitator.402.md' + +export const landingMarkdown = landingMd +export const dashboardMarkdown = dashboardMd + +// Narrow Accept-header parser: returns true only when the client explicitly +// prefers text/markdown over text/html. Does not handle wildcards (`text/*`) +// or media-range parameters beyond `q=` — sufficient for our two-type menu. +export function wantsMarkdown(acceptHeader: string | null | undefined): boolean { + if (!acceptHeader) return false + const entries = acceptHeader.split(',').map((part) => { + const [media, ...params] = part.trim().split(';') + const qParam = params.find((p) => p.trim().startsWith('q=')) + const q = qParam ? Number(qParam.split('=')[1]) : 1 + return { media: media.toLowerCase(), q: Number.isFinite(q) ? q : 1 } + }) + const md = entries.find((e) => e.media === 'text/markdown') + const html = entries.find((e) => e.media === 'text/html') + if (!md || md.q === 0) return false + if (!html) return true + return md.q > html.q +} + +export const AGENT_DISCOVERY_LINK_HEADER = [ + `; rel="api-catalog"; type="application/linkset+json"`, + `; rel="service-desc"; type="application/json"`, + `; rel="service-doc"; type="text/html"`, + `; rel="status"; type="application/json"`, + `; rel="sitemap"; type="application/xml"`, + `; rel="https://agentskills.io/rel/index"; type="application/json"`, +].join(', ') + +function sha256Hex(input: string): string { + const hasher = new Bun.CryptoHasher('sha256') + hasher.update(input) + return hasher.digest('hex') +} + +const SKILL_HOW_TO_USE_URL = `${PUBLIC_BASE_URL}/.well-known/agent-skills/how-to-use-facilitator/SKILL.md` + +const agentSkillsIndex = { + $schema: 'https://schemas.agentskills.io/discovery/0.2.0/schema.json', + skills: [ + { + name: 'how-to-use-facilitator', + type: 'skill-md', + description: + 'How an AI agent or an x402 seller integrates with the 402md Facilitator to send or receive cross-chain USDC payments.', + url: SKILL_HOW_TO_USE_URL, + digest: `sha256:${sha256Hex(howToUseSkillMd)}`, + }, + ], +} + +const agentSkillsIndexJson = JSON.stringify(agentSkillsIndex, null, 2) + +export const agentMetaRoutes = new Elysia() + .get( + '/robots.txt', + ({ set }) => { + set.headers['content-type'] = 'text/plain; charset=utf-8' + set.headers['cache-control'] = 'public, max-age=3600' + return robotsTxt + }, + { detail: { hide: true } }, + ) + .get( + '/sitemap.xml', + ({ set }) => { + set.headers['content-type'] = 'application/xml; charset=utf-8' + set.headers['cache-control'] = 'public, max-age=3600' + return sitemapXml + }, + { detail: { hide: true } }, + ) + .get( + '/.well-known/api-catalog', + ({ set }) => { + set.headers['content-type'] = 'application/linkset+json' + set.headers['cache-control'] = 'public, max-age=3600' + return apiCatalogJson + }, + { detail: { hide: true } }, + ) + .get( + '/.well-known/agent-skills/index.json', + ({ set }) => { + set.headers['content-type'] = 'application/json; charset=utf-8' + set.headers['cache-control'] = 'public, max-age=3600' + return agentSkillsIndexJson + }, + { detail: { hide: true } }, + ) + .get( + '/.well-known/agent-skills/how-to-use-facilitator/SKILL.md', + ({ set }) => { + set.headers['content-type'] = 'text/markdown; charset=utf-8' + set.headers['cache-control'] = 'public, max-age=3600' + return howToUseSkillMd + }, + { detail: { hide: true } }, + ) diff --git a/packages/relay/src/agent-meta/sitemap.xml b/packages/relay/src/agent-meta/sitemap.xml new file mode 100644 index 0000000..a4b0727 --- /dev/null +++ b/packages/relay/src/agent-meta/sitemap.xml @@ -0,0 +1,13 @@ + + + + https://facilitator.402.md/ + weekly + 1.0 + + + https://facilitator.402.md/swagger + weekly + 0.9 + + diff --git a/packages/relay/src/agent-meta/skills/how-to-use-facilitator.md b/packages/relay/src/agent-meta/skills/how-to-use-facilitator.md new file mode 100644 index 0000000..b9e70f1 --- /dev/null +++ b/packages/relay/src/agent-meta/skills/how-to-use-facilitator.md @@ -0,0 +1,117 @@ +--- +name: how-to-use-facilitator +description: How an AI agent or an x402 seller integrates with the 402md Facilitator to send or receive cross-chain USDC payments. +type: skill-md +--- + +# How to use the 402md Facilitator + +The 402md Facilitator is a cross-chain USDC settlement relay for the **x402** protocol (HTTP 402) and the **Machine Payments Protocol (MPP)**. A buyer pays on any supported chain; the seller receives native USDC on their chosen chain. No custom SDK, no smart contracts. + +Base URL: `https://facilitator.402.md` + +## Supported chains + +All chains use Circle CCTP V2 with native USDC. Polygon and Avalanche are explicitly excluded (CCTP V1 only). + +- **EVM**: Base, Ethereum, Optimism, Arbitrum, Linea, Unichain, World Chain +- **Solana**: mainnet +- **Stellar**: pubnet + +## If you are a seller (you want to receive USDC) + +1. **Register your wallet once.** Send one HTTP request: + + ```http + POST https://facilitator.402.md/register + Content-Type: application/json + + { + "wallet": "GSELLER...", + "network": "stellar:pubnet" + } + ``` + + Response includes your `merchantId` plus facilitator pay-to addresses for every supported chain. That `merchantId` routes every cross-chain payment back to your single wallet. + +2. **Use the standard `@x402/express` middleware** from Coinbase. Point each route's `payTo` to the facilitator address for that chain and include your `merchantId` in `extra`: + + ```ts + import { paymentMiddleware } from '@x402/express' + + app.use( + paymentMiddleware( + { + 'GET /your-endpoint': { + accepts: [ + { + scheme: 'exact', + network: 'eip155:8453', + payTo: '0x...', + price: '$0.001', + extra: { merchantId: '7c-vQZHANEd5ICp' }, + }, + { + scheme: 'exact', + network: 'solana:mainnet', + payTo: '4r7q...', + price: '$0.001', + extra: { merchantId: '7c-vQZHANEd5ICp' }, + }, + { + scheme: 'exact', + network: 'stellar:pubnet', + payTo: 'GAX5...', + price: '$0.001', + extra: { merchantId: '7c-vQZHANEd5ICp' }, + }, + ], + }, + }, + 'https://facilitator.402.md', + ), + ) + ``` + +3. **Do nothing else.** 402md verifies payments, bridges via CCTP V2 when needed, and delivers native USDC to your wallet. + +## If you are a buyer / agent (you want to pay) + +1. **GET the seller's resource.** If it is x402-protected, you will receive an HTTP `402 Payment Required` with a list of accepted payment methods (chains, prices, and facilitator addresses). +2. **Sign a standard x402 `PaymentPayload`** on whichever supported chain you hold USDC on. No 402md-specific fields; use the standard x402 payload. +3. **Replay the request** with the `X-PAYMENT` header set to the base64-encoded payload. The seller verifies with the facilitator (`POST /verify`) and settles (`POST /settle`). You get the resource in the response. + +No bridges, no swaps, no wrapped tokens — 402md uses Circle CCTP V2 under the hood. + +## Machine Payments Protocol (MPP) + +The facilitator also exposes MPP endpoints for Stellar-ecosystem agents (OpenAI, Anthropic, Dune, etc.). Same facilitator, same settlement rail, different HTTP surface. See the OpenAPI spec for the MPP route set. + +## Fees + +- **Platform fee**: 0% at launch. +- **Gas allowance**: fixed schedule per route, deducted from the payout. Example on a $1.00 payment: Stellar→Stellar $0.00001, Base→Base $0.0020, Base→Stellar $0.0032. +- **Same-chain**: direct USDC transfer, no bridge, lowest cost. + +## Discovery endpoints + +- `GET /health` — dependency status (DB, Redis, Temporal) +- `GET /swagger` — full OpenAPI reference +- `GET /swagger/json` — machine-readable OpenAPI spec +- `GET /discovery/resources` — public registry of active x402 resources (filter by network, sort by uses or volume) +- `GET /discovery/routes` — cross-chain route activity and cost matrix +- `GET /.well-known/api-catalog` — RFC 9727 link set pointing at the OpenAPI spec, docs, and status + +## Non-goals + +- 402md never custodies seller funds. CCTP V2 mints directly to the seller's address. +- 402md does not deploy custom smart contracts. It calls USDC (EIP-3009) and CCTP TokenMessenger directly via chain SDKs. +- 402md does not run a dashboard, login, or seller SDK. The public `/dashboard` is a read-only view of aggregate activity; all of its data is also available via `/discovery/*` as structured JSON. + +## Links + +- Landing: https://facilitator.402.md/ +- OpenAPI: https://facilitator.402.md/swagger/json +- Source: https://github.com/402md +- x402 spec: https://www.x402.org/ +- Circle CCTP V2: https://www.circle.com/cctp diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts index 3fd50de..8dce3af 100644 --- a/packages/relay/src/index.ts +++ b/packages/relay/src/index.ts @@ -21,6 +21,13 @@ import { settlementsRoutes } from '@/settlements/settlements.routes' import { onrampRoutes } from '@/onramp/onramp.routes' import { stellarMppRoutes } from '@/mpp/stellar-mpp.routes' import { bazaarRoutes, bazaarEnrichedRoutes } from '@/bazaar/bazaar.routes' +import { + agentMetaRoutes, + AGENT_DISCOVERY_LINK_HEADER, + landingMarkdown, + dashboardMarkdown, + wantsMarkdown, +} from '@/agent-meta/routes' import { db } from '@402md/shared/db' import { redis } from '@402md/shared/cache' import { getTemporalClient } from '@/shared/temporal' @@ -55,8 +62,33 @@ export const app = new Elysia() .use(stellarMppRoutes) .use(bazaarRoutes) .use(bazaarEnrichedRoutes) - .get('/', () => LandingPage(), { detail: { hide: true } }) - .get('/dashboard', () => DashboardPage(), { detail: { hide: true } }) + .use(agentMetaRoutes) + .get( + '/', + ({ request, set }) => { + set.headers['link'] = AGENT_DISCOVERY_LINK_HEADER + set.headers['vary'] = 'Accept' + if (wantsMarkdown(request.headers.get('accept'))) { + set.headers['content-type'] = 'text/markdown; charset=utf-8' + return landingMarkdown + } + return LandingPage() + }, + { detail: { hide: true } }, + ) + .get( + '/dashboard', + ({ request, set }) => { + set.headers['link'] = AGENT_DISCOVERY_LINK_HEADER + set.headers['vary'] = 'Accept' + if (wantsMarkdown(request.headers.get('accept'))) { + set.headers['content-type'] = 'text/markdown; charset=utf-8' + return dashboardMarkdown + } + return DashboardPage() + }, + { detail: { hide: true } }, + ) .get('/cover.mp4', () => Bun.file(new URL('../public/cover.mp4', import.meta.url).pathname)) .get('/health', async () => { const checks = {