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
59 changes: 57 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,25 @@ Decentralized facilitator toolkit for the X402 protocol. Run a facilitator node

- Facilitator node: EVM + SVM (Solana) support
- Express adapter: mounts `/supported`, `/verify`, `/settle`
- HTTP gateway: routes `verify` and `settle` across many nodes
- Hono adapter: same routes, idiomatic Hono usage - import from `x402-open/hono`
- HTTP gateway: routes `verify` and `settle` across many nodes (Express or Hono)
- Auto-registration: nodes can self-register with the gateway (no manual peer lists)

## Installation

```bash
# Express
pnpm add x402-open express viem
# or
npm i x402-open express viem

# Hono
pnpm add x402-open hono viem
# or
npm i x402-open hono viem
```

`express` is a peer dependency.
`express` and `hono` are optional peer dependencies β€” install whichever you use.

---
## Run a facilitator node
Expand Down Expand Up @@ -91,6 +98,33 @@ app.listen(4021, () => console.log("Server on http://localhost:4021"));
```

---

## Run a facilitator node (Hono)

```ts
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { Facilitator } from "x402-open";
import { createHonoAdapter } from "x402-open/hono";
import { baseSepolia } from "viem/chains";

const facilitator = new Facilitator({
evmPrivateKey: process.env.PRIVATE_KEY as `0x${string}`,
evmNetworks: [baseSepolia],
// svmPrivateKey: process.env.SOLANA_PRIVATE_KEY!,
// svmNetworks: ["solana-devnet"],
});

const app = new Hono();
app.route("/facilitator", createHonoAdapter(facilitator));

serve({ fetch: app.fetch, port: 4101 }, () =>
console.log("Hono Node on http://localhost:4101")
);
```

---

## Run the HTTP gateway (single URL for many nodes)

```ts
Expand All @@ -114,6 +148,27 @@ createHttpGatewayAdapter(app, {
app.listen(8080, () => console.log("HTTP Gateway on http://localhost:8080"));
```

### Hono gateway variant

```ts
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { createHonoGatewayAdapter } from "x402-open/hono";

const app = new Hono();
app.route("/facilitator", createHonoGatewayAdapter({
httpPeers: [
"http://localhost:4101/facilitator",
// "http://localhost:4102/facilitator",
],
debug: true,
}));

serve({ fetch: app.fetch, port: 8080 }, () =>
console.log("Hono Gateway on http://localhost:8080")
);
```

### Gateway behavior

- `POST /facilitator/verify`
Expand Down
19 changes: 15 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./hono": {
"types": "./dist/hono.d.ts",
"import": "./dist/hono.js",
"default": "./dist/hono.js"
},
"./package.json": "./package.json"
},
"sideEffects": false,
Expand All @@ -31,6 +36,7 @@
"license": "ISC",
"packageManager": "pnpm@10.14.0",
"devDependencies": {
"@hono/node-server": "^1.19.9",
"@types/express": "^4.17.21",
"@types/node": "^24.9.2",
"@types/supertest": "^2.0.16",
Expand All @@ -40,16 +46,21 @@
"vitest": "^2.1.4"
},
"dependencies": {
"@scure/base": "^1.1.9",
"@solana/kit": "^5.0.0",
"@solana/web3.js": "^1.95.3",
"bs58": "^6.0.0",
"dotenv": "^16.4.5",
"viem": "^2.21.34",
"@scure/base": "^1.1.9",
"zod": "^3.23.8",
"x402": "^0.7.0"
"x402": "^0.7.0",
"zod": "^3.23.8"
},
"peerDependencies": {
"express": ">=4"
"express": ">=4",
"hono": ">=4"
},
"peerDependenciesMeta": {
"express": { "optional": true },
"hono": { "optional": true }
}
}
142 changes: 79 additions & 63 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

20 changes: 5 additions & 15 deletions src/expressAdapter.ts β†’ src/adapters/expressAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Request, Response, Router } from "express";
import type { Facilitator } from "./facilitator";
import type { Facilitator } from "../facilitator.js";
import { formatError } from "./shared/errorHandler.js";

export function createExpressAdapter(
facilitator: Facilitator,
Expand All @@ -16,10 +17,7 @@ export function createExpressAdapter(
const response = await facilitator.handleRequest({ method: "GET", path: "/supported" });
res.status(response.status).json(response.body);
} catch (error) {
res.status(500).json({
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error",
});
res.status(500).json(formatError(error));
}
});

Expand All @@ -28,10 +26,7 @@ export function createExpressAdapter(
const response = await facilitator.handleRequest({ method: "POST", path: "/verify", body: req.body });
res.status(response.status).json(response.body);
} catch (error) {
res.status(500).json({
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error",
});
res.status(500).json(formatError(error));
}
});

Expand All @@ -40,12 +35,7 @@ export function createExpressAdapter(
const response = await facilitator.handleRequest({ method: "POST", path: "/settle", body: req.body });
res.status(response.status).json(response.body);
} catch (error) {
res.status(500).json({
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error",
});
res.status(500).json(formatError(error));
}
});
}


47 changes: 47 additions & 0 deletions src/adapters/honoAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Hono } from "hono";
import type { Facilitator } from "../facilitator.js";
import { formatError } from "./shared/errorHandler.js";

/**
* Creates a Hono app wired to the given Facilitator.
* Mount it with `parentApp.route("/facilitator", createHonoAdapter(facilitator))`.
*
* Routes exposed (relative to mount point):
* GET /supported
* POST /verify
* POST /settle
*/
export function createHonoAdapter(facilitator: Facilitator): Hono {
const app = new Hono();

app.get("/supported", async (c) => {
try {
const response = await facilitator.handleRequest({ method: "GET", path: "/supported" });
return c.json(response.body, response.status as any);
} catch (error) {
return c.json(formatError(error), 500);
}
});

app.post("/verify", async (c) => {
try {
const body = await c.req.json();
const response = await facilitator.handleRequest({ method: "POST", path: "/verify", body });
return c.json(response.body, response.status as any);
} catch (error) {
return c.json(formatError(error), 500);
}
});

app.post("/settle", async (c) => {
try {
const body = await c.req.json();
const response = await facilitator.handleRequest({ method: "POST", path: "/settle", body });
return c.json(response.body, response.status as any);
} catch (error) {
return c.json(formatError(error), 500);
}
});

return app;
}
126 changes: 126 additions & 0 deletions src/adapters/honoGateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { Hono } from "hono";
import {
type GatewayOptions,
type PeerResponse,
postJson,
normalizeForwardBody,
normalizeUrl,
pickSelectedPeerForVerify,
rotateToNext,
aggregateSupportedKinds,
StickyRouter,
PeerRegistry,
VERIFY_TIMEOUT,
SETTLE_TIMEOUT,
} from "../gateway/core.js";

export type HonoGatewayOptions = GatewayOptions;

/**
* Creates a Hono app that acts as an HTTP gateway, routing verify/settle
* requests across multiple facilitator nodes with sticky routing.
*
* Mount with `parentApp.route("/facilitator", createHonoGatewayAdapter(opts))`.
*
* Routes exposed (relative to mount point):
* GET /supported β€” aggregated kinds from all peers
* POST /verify β€” random node, sticky selection recorded
* POST /settle β€” sticky node from verify, fallback to others
* POST /register β€” node self-registration
* GET /peers β€” diagnostic: list active peers
*/
export function createHonoGatewayAdapter(options: HonoGatewayOptions): Hono {
const app = new Hono();
const sticky = new StickyRouter();
const registry = new PeerRegistry();

function peers(): string[] {
return registry.getActivePeers(options.httpPeers ?? []);
}

// GET /supported β€” aggregate from peers
app.get("/supported", async (c) => {
const kinds = await aggregateSupportedKinds(peers());
return c.json({ kinds });
});

// POST /verify β€” single randomly selected node (stick to this node by payer/header)
app.post("/verify", async (c) => {
const activePeers = peers();
if (!activePeers || activePeers.length === 0) return c.json({ error: "No peers configured" }, 503);

const inbound = await c.req.json();
const forwardBody = normalizeForwardBody(inbound);

try {
const primary = pickSelectedPeerForVerify(activePeers);
const order = rotateToNext(activePeers, primary);
let lastError: PeerResponse | undefined;
for (const base of order) {
const url = normalizeUrl(base) + "/verify";
try {
if (options.debug) console.log("[hono-gateway] verify via", url);
const response = await postJson(url, forwardBody, VERIFY_TIMEOUT);
if (response.status === 200) {
sticky.recordSelection(base, forwardBody, response.body);
return c.json(response.body);
}
if (options.debug) console.log("[hono-gateway] verify non-200 from", url, response.status, response.body);
lastError = response;
} catch (e: unknown) {
if (options.debug) console.log("[hono-gateway] verify network error from", url, e instanceof Error ? e.message : e);
}
}
if (lastError) return c.json(lastError.body, lastError.status as 400);
return c.json({ error: "Verification unavailable" }, 503);
} catch (err: unknown) {
return c.json({ error: "Internal error", message: err instanceof Error ? err.message : "Unknown error" }, 500);
}
});

// POST /settle β€” use the same selected node (sticky by payer/header); fallback to others on failure
app.post("/settle", async (c) => {
const activePeers = peers();
if (!activePeers || activePeers.length === 0) {
return c.json({ success: false, error: "No peers configured", txHash: null, networkId: null }, 503);
}

const inbound = await c.req.json();
const forwardBody = normalizeForwardBody(inbound);
const preferred = sticky.getPreferredPeer(forwardBody) ?? pickSelectedPeerForVerify(activePeers);
const order = rotateToNext(activePeers, preferred);

for (const peer of order) {
const url = normalizeUrl(peer) + "/settle";
try {
if (options.debug) console.log("[hono-gateway] settling via", url);
const response = await postJson(url, forwardBody, SETTLE_TIMEOUT);
if (response.status === 200) return c.json(response.body);
if (options.debug) console.log("[hono-gateway] settle non-200 from", url, response.status, response.body);
} catch (err: unknown) {
if (options.debug) console.log("[hono-gateway] settle network error from", url, err instanceof Error ? err.message : err);
}
}
return c.json({ success: false, error: "Settle unavailable", txHash: null, networkId: null }, 503);
});

// POST /register β€” nodes can self-register with the gateway
app.post("/register", async (c) => {
try {
const body = (await c.req.json()) as { url?: string; kinds?: unknown[] };
const url = String(body?.url || "").trim();
if (!url || !/^https?:\/\//i.test(url)) return c.json({ error: "Invalid url" }, 400);
registry.register(url, body?.kinds as Parameters<typeof registry.register>[1]);
return c.json({ ok: true });
} catch (e: unknown) {
return c.json({ error: e instanceof Error ? e.message : "Invalid request" }, 400);
}
});

// GET /peers β€” diagnostic endpoint
app.get("/peers", (c) => {
return c.json({ peers: peers() });
});

return app;
}
17 changes: 17 additions & 0 deletions src/adapters/shared/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Shared error formatting for adapter error responses.
*/
export interface FormattedError {
error: string;
message: string;
}

/**
* Formats an unknown error into a consistent structure for HTTP responses.
*/
export function formatError(error: unknown): FormattedError {
return {
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error",
};
}
7 changes: 2 additions & 5 deletions src/facilitator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ export class Facilitator {
this.evmNetworks = (config.evmNetworks ?? config.networks) ?? [];
this.svmNetworks = config.svmNetworks ?? (this.svmPrivateKey ? ["solana-devnet"] : []);
this.x402Config = this.svmRpcUrl ? { svmConfig: { rpcUrl: this.svmRpcUrl } } : undefined;

}

async handleRequest(req: HandlerRequest): Promise<HandlerResponse> {
Expand Down Expand Up @@ -126,8 +125,8 @@ export class Facilitator {

if (this.svmPrivateKey && this.svmNetworks.length > 0) {
for (const network of this.svmNetworks) {
if (!SupportedSVMNetworks.includes(network as any)) continue;
const signer = await createSigner(network as any, this.svmPrivateKey);
if (!SupportedSVMNetworks.includes(network as SupportedPaymentKind["network"])) continue;
const signer = await createSigner(network as SupportedPaymentKind["network"], this.svmPrivateKey);
const feePayer = isSvmSignerWallet(signer) ? signer.address : undefined;
kinds.push({ x402Version: 1, scheme: "exact", network: network as SupportedPaymentKind["network"], extra: { feePayer } });
}
Expand All @@ -144,5 +143,3 @@ export class Facilitator {
return network;
}
}


Loading