Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions x402r-ai-garbage-detector/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,36 @@ PRIVATE_KEY=0x... ./src/scripts/pay-via-cli.sh /garbage
This is the same flow `pnpm run client` exercises via the SDK, just without
installing anything.

## x402scan discovery

The merchant serves an OpenAPI 3.1 document at `GET /openapi.json` so the demo
endpoints are discoverable by agents through
[x402scan](https://www.x402scan.com/discovery/spec). Each route advertises its
price via `x-payment-info` and exposes an input schema (`parameters`) so the
endpoint is invocable, not just visible.

```bash
$ curl -s http://localhost:4021/openapi.json | jq '.paths | keys'
[
"/garbage",
"/weather"
]
```

To register a public deployment, set `PUBLIC_URL` so the `servers` field
reflects the externally reachable origin (otherwise it falls back to the
request host). The value is normalized to its origin, so any path or trailing
slash is stripped before being written into the OpenAPI doc:

```env
PUBLIC_URL=https://my-merchant.example.com
```

Then submit the deployment URL at
[x402scan.com/resources/register](https://www.x402scan.com/resources/register).
x402scan fetches `/openapi.json` and probes the runtime `402` challenge to
verify discoverability.

## Scripts

| Script | Purpose |
Expand Down
134 changes: 129 additions & 5 deletions x402r-ai-garbage-detector/src/merchant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ const PORT = Number(process.env.PORT ?? process.env.MERCHANT_PORT ?? 4021);
const FACILITATOR_URL = process.env.FACILITATOR_URL;
if (!FACILITATOR_URL) throw new Error("FACILITATOR_URL env required");
const ARBITER_URL = process.env.ARBITER_URL ?? "http://localhost:3001";
// Optional: absolute URL agents should call (e.g. https://merchant.example.com).
// Used to populate the OpenAPI `servers` field for x402scan discovery; defaults
// to the request origin when not set.
const PUBLIC_URL = process.env.PUBLIC_URL;
const networkId = `eip155:${CHAIN_ID}` as const;

// Prefer env var, fall back to context.json
Expand Down Expand Up @@ -52,11 +56,17 @@ const unpaidResponseBody = () => ({
},
});

// Single source of truth for price. Drives both the x402 runtime `accepts`
// (`$0.01`) and the OpenAPI `x-payment-info.amount` (`0.010000` USD).
const PRICE_USD_CENTS = 1;
const PRICE_USD_DISPLAY = `$${(PRICE_USD_CENTS / 100).toFixed(2)}`;
const PRICE_USD_AMOUNT = (PRICE_USD_CENTS / 100).toFixed(6);

const paidRoute = {
accepts: [{
scheme: "commerce" as const,
network: networkId,
price: "$0.01",
price: PRICE_USD_DISPLAY,
payTo: MERCHANT_ADDRESS,
extra: {
escrowAddress: authCaptureEscrow,
Expand Down Expand Up @@ -86,17 +96,131 @@ app.use((_req, res, next) => {
next();
});

// OpenAPI document for x402scan discovery. Must be mounted before the payment
// middleware so it stays free to fetch. See https://www.x402scan.com/discovery/spec
//
// `@agentcash/discovery` prepends `new URL(servers[0].url).pathname` to every
// registered route, so we collapse the public URL to its origin to avoid
// silently registering /<sub-path>/weather instead of /weather.
const toOrigin = (raw: string): string => {
try { return new URL(raw).origin; } catch { return raw; }
};
const buildOpenApi = (publicUrl: string) => ({
openapi: "3.1.0",
info: {
title: "x402r AI Garbage Detector",
version: "0.1.0",
description:
"Demo merchant for x402r refundable payments. Responses that the configured AI arbiter classifies as garbage trigger an automatic on-chain refund of the buyer's escrow.",
"x-guidance":
"Two USD $0.01 demo endpoints behind a refundable x402r escrow. GET /weather returns a clean payload (arbiter PASSes, escrow is captured). GET /garbage returns an error-shaped payload (arbiter FAILs, escrow voids and the buyer is auto-refunded after the window). Pair the two to exercise both happy and refund paths.",
},
servers: [{ url: publicUrl }],
paths: {
"/weather": {
get: {
operationId: "getWeather",
summary: "Weather demo (arbiter PASS path)",
tags: ["Demo"],
"x-payment-info": {
price: { mode: "fixed", currency: "USD", amount: PRICE_USD_AMOUNT },
protocols: [{ x402: {} }],
},
parameters: [
{
name: "location",
in: "query",
required: false,
description: "Optional label echoed in the `location` response field. Defaults to 'San Francisco'.",
schema: { type: "string", maxLength: 64 },
},
],
responses: {
"200": {
description: "Weather payload",
content: {
"application/json": {
schema: {
type: "object",
properties: {
location: { type: "string" },
temperature: { type: "number" },
conditions: { type: "string" },
timestamp: { type: "string", format: "date-time" },
},
required: ["location", "temperature", "conditions", "timestamp"],
},
},
},
},
"402": { description: "Payment Required" },
},
},
},
"/garbage": {
get: {
operationId: "getGarbage",
summary: "Garbage demo (arbiter FAIL, auto-refund path)",
tags: ["Demo"],
"x-payment-info": {
price: { mode: "fixed", currency: "USD", amount: PRICE_USD_AMOUNT },
protocols: [{ x402: {} }],
},
parameters: [
{
name: "seed",
in: "query",
required: false,
description: "Optional integer echoed back in the response. Lets agents distinguish replays in logs.",
schema: { type: "integer" },
},
],
responses: {
"200": {
description: "Error-shaped 'garbage' payload returned with HTTP 200 (the body is what the arbiter judges)",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: { type: "string" },
message: { type: "string" },
code: { type: "integer" },
seed: { type: "integer" },
},
required: ["error", "message", "code"],
},
},
},
},
"402": { description: "Payment Required" },
},
},
},
},
});

app.get("/openapi.json", (req, res) => {
const publicUrl = toOrigin(PUBLIC_URL ?? `${req.protocol}://${req.get("host")}`);
res.json(buildOpenApi(publicUrl));
});

app.use(paymentMiddleware({
"GET /weather": paidRoute,
"GET /garbage": paidRoute,
}, resourceServer));

app.get("/weather", (_req, res) => {
res.json({ location: "San Francisco", temperature: 68, conditions: "Partly cloudy", timestamp: new Date().toISOString() });
app.get("/weather", (req, res) => {
const raw = req.query.location;
const location = typeof raw === "string" && raw.length > 0 && raw.length <= 64 ? raw : "San Francisco";
res.json({ location, temperature: 68, conditions: "Partly cloudy", timestamp: new Date().toISOString() });
});

app.get("/garbage", (_req, res) => {
res.json({ error: "Internal Server Error", message: "Something went wrong", code: 500 });
app.get("/garbage", (req, res) => {
const body: Record<string, unknown> = { error: "Internal Server Error", message: "Something went wrong", code: 500 };
const seed = Number(req.query.seed);
if (Number.isFinite(seed)) body.seed = seed;
res.json(body);
});

app.listen(PORT, () => {
Expand Down