Skip to content
Merged
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: 15 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@
},
"overrides": {
"qs": "^6.15.2",
"ws": "^8.21.0"
"ws": "^8.21.0",
"@hono/node-server": "^1.19.15",
"fast-uri": "^3.1.5",
"hono": "^4.12.34",
"ip-address": "^10.3.1"
},
"engines": {
"node": ">=18.17"
Expand Down
6 changes: 3 additions & 3 deletions x402-service/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ SERA_MCP_DIST=/absolute/path/to/sera-mcp/dist/index.js

# ─── Live-mode requirements (when X402_MODE=live) ───────────
# X402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402
# X402_CDP_API_KEY_ID=...
# X402_CDP_API_KEY_SECRET=...
# X402_CDP_API_KEY_ID=... # CDP API Key Identifier
# X402_CDP_API_KEY_SECRET=... # CDP EC private key (PEM format, standard newlines or escaped \n)
# X402_VAULT_ADDRESS=0x...
# X402_LIVE_ACK=true
# X402_LIVE_ACK=true # Operator acknowledgment required for live mode
# X402_NETWORK=base
# X402_USDC_ADDRESS=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
# X402_CONFIRMATION_DEPTH=3
Expand Down
8 changes: 4 additions & 4 deletions x402-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ Required env (boot refuses if any is missing):
export X402_MODE=live
export X402_NETWORK=base-sepolia # start on testnet
export X402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402
export X402_CDP_API_KEY_ID=...
export X402_CDP_API_KEY_SECRET=...
export X402_CDP_API_KEY_ID=... # CDP API Key Identifier
export X402_CDP_API_KEY_SECRET=... # CDP EC private key in PEM format (to sign ES256 JWTs)
export X402_VAULT_ADDRESS=0xYourVault # where USDC lands
export X402_CONFIRMATION_DEPTH=3 # ≥3 per arXiv:2605.11781 (mitigates revert-grant)
export X402_LIVE_ACK=true # operator acknowledges live wiring not yet
Expand Down Expand Up @@ -118,8 +118,8 @@ Selected vars (full list in `.env.example`):
| `X402_LIVE_ACK` | `false` | Required `true` to boot `X402_MODE=live` — operator ack of not-yet-mainnet-tested |
| `X402_NETWORK` | `base` | `base` / `base-sepolia` / `polygon` / `arbitrum` / `solana` |
| `X402_FACILITATOR_URL` | — | CDP facilitator endpoint (live mode only) |
| `X402_CDP_API_KEY_ID` | — | CDP API key id (live mode only) |
| `X402_CDP_API_KEY_SECRET` | — | CDP API key secret (live mode only) |
| `X402_CDP_API_KEY_ID` | — | CDP API key identifier (live mode only) |
| `X402_CDP_API_KEY_SECRET` | — | CDP EC private key in PEM format used to sign ES256 JWTs (live mode only) |
| `X402_VAULT_ADDRESS` | — | Wallet that holds pooled USDC + signs Sera intents (live mode only) |
| `X402_CONFIRMATION_DEPTH` | `3` | Confirmation depth before release. Boot refuses < 3 in live mode. |
| `X402_STATE_DB` | — | SQLite path for payment state (recommended for live; memory-only otherwise) |
Expand Down
69 changes: 59 additions & 10 deletions x402-service/facilitator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
* the first Base Sepolia E2E test.
*/

import { createPrivateKey, randomBytes, sign } from "node:crypto";

export interface VerifyResult {
isValid: boolean;
invalidReason?: string;
Expand Down Expand Up @@ -48,13 +50,58 @@ export interface PaymentRequirements {
extra: Record<string, unknown>;
}

function authHeader(cfg: FacilitatorConfig): Record<string, string> {
// CDP typically takes a Bearer token derived from {api_key_id}:{api_secret}.
// Some installs use HMAC-SHA256 signed JWT — adjust here when verified
// against the live CDP integration. We keep the simpler concat form for
// now and document the override below.
/**
* Generate a short-lived ES256 JWT for Coinbase CDP API v2 endpoints.
* Includes request-specific `uri` claim formatted as `<METHOD> <host><pathname>`
* and a cryptographic random nonce in the header.
*/
export function buildCdpJwt(
apiKeyId: string,
apiKeySecret: string,
method: string,
requestUrl: string,
): string {
const parsedUrl = new URL(requestUrl);
const uri = `${method.toUpperCase()} ${parsedUrl.host}${parsedUrl.pathname}`;
const now = Math.floor(Date.now() / 1000);

const header = {
alg: "ES256",
typ: "JWT",
kid: apiKeyId,
nonce: randomBytes(16).toString("hex"),
};

const payload = {
iss: "cdp",
sub: apiKeyId,
nbf: now,
exp: now + 120,
uri,
};

const headerB64 = Buffer.from(JSON.stringify(header)).toString("base64url");
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
const data = `${headerB64}.${payloadB64}`;

const normalizedKey = apiKeySecret.includes("\\n")
? apiKeySecret.replace(/\\n/g, "\n")
: apiKeySecret;
const privateKey = createPrivateKey(normalizedKey);

const signature = sign("SHA256", Buffer.from(data), {
key: privateKey,
dsaEncoding: "ieee-p1363",
});
const signatureB64 = signature.toString("base64url");

return `${data}.${signatureB64}`;
}

function authHeader(cfg: FacilitatorConfig, method: string, url: string): Record<string, string> {
const jwt = buildCdpJwt(cfg.apiKeyId, cfg.apiKeySecret, method, url);
return {
authorization: `Bearer ${cfg.apiKeyId}:${cfg.apiKeySecret}`,
authorization: `Bearer ${jwt}`,
};
}

Expand All @@ -64,12 +111,13 @@ export async function facilitatorVerify(
requirements: PaymentRequirements,
): Promise<VerifyResult> {
try {
const res = await fetch(`${cfg.url.replace(/\/+$/, "")}/verify`, {
const url = `${cfg.url.replace(/\/+$/, "")}/verify`;
const res = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
...authHeader(cfg),
...authHeader(cfg, "POST", url),
},
body: JSON.stringify({
x402Version: 1,
Expand Down Expand Up @@ -97,12 +145,13 @@ export async function facilitatorSettle(
requirements: PaymentRequirements,
): Promise<SettleResult> {
try {
const res = await fetch(`${cfg.url.replace(/\/+$/, "")}/settle`, {
const url = `${cfg.url.replace(/\/+$/, "")}/settle`;
const res = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
...authHeader(cfg),
...authHeader(cfg, "POST", url),
},
body: JSON.stringify({
x402Version: 1,
Expand Down
107 changes: 101 additions & 6 deletions x402-service/test/facilitator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,30 @@
* facilitator.test.ts — Coinbase CDP /verify + /settle wrapper.
*
* Mocks global fetch to validate request shape (URL, headers, body) and
* response handling (success, network error, non-ok status).
* response handling (success, network error, non-ok status). Validates
* that outgoing Authorization headers carry cryptographically valid
* ES256 JWTs with request-specific claims.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { generateKeyPairSync, verify as cryptoVerify } from "node:crypto";
import {
buildCdpJwt,
facilitatorVerify,
facilitatorSettle,
type FacilitatorConfig,
type PaymentRequirements,
} from "../facilitator.js";

const { privateKey: TEST_PRIVATE_KEY_PEM, publicKey: TEST_PUBLIC_KEY_PEM } = generateKeyPairSync("ec", {
namedCurve: "prime256v1",
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});

const CFG: FacilitatorConfig = {
url: "https://api.cdp.coinbase.com/platform/v2/x402",
apiKeyId: "test-id",
apiKeySecret: "test-secret",
apiKeySecret: TEST_PRIVATE_KEY_PEM,
network: "base",
confirmationDepth: 3,
};
Expand All @@ -33,14 +43,72 @@ const REQUIREMENTS: PaymentRequirements = {
extra: { name: "USD Coin", version: "2" },
};

function verifyAndDecodeJwt(authHeaderValue: string) {
expect(authHeaderValue).toMatch(/^Bearer ey/);
const token = authHeaderValue.slice("Bearer ".length);
const parts = token.split(".");
expect(parts).toHaveLength(3);
const [headerB64, payloadB64, sigB64] = parts;
const header = JSON.parse(Buffer.from(headerB64, "base64url").toString());
const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString());
const signature = Buffer.from(sigB64, "base64url");
const data = Buffer.from(`${headerB64}.${payloadB64}`);
const isValidSig = cryptoVerify(
"SHA256",
data,
{ key: TEST_PUBLIC_KEY_PEM, dsaEncoding: "ieee-p1363" },
signature,
);
expect(isValidSig).toBe(true);
return { header, payload, isValidSig };
}

describe("buildCdpJwt", () => {
it("generates a valid ES256 JWT with correct headers, claims, and nonce", () => {
const jwt = buildCdpJwt("test-id", TEST_PRIVATE_KEY_PEM, "POST", "https://api.cdp.coinbase.com/platform/v2/x402/verify");
const { header, payload } = verifyAndDecodeJwt(`Bearer ${jwt}`);
expect(header.alg).toBe("ES256");
expect(header.typ).toBe("JWT");
expect(header.kid).toBe("test-id");
expect(typeof header.nonce).toBe("string");
expect(header.nonce.length).toBeGreaterThan(0);
expect(payload.iss).toBe("cdp");
expect(payload.sub).toBe("test-id");
expect(payload.uri).toBe("POST api.cdp.coinbase.com/platform/v2/x402/verify");
const now = Math.floor(Date.now() / 1000);
expect(payload.nbf).toBeGreaterThanOrEqual(now - 5);
expect(payload.nbf).toBeLessThanOrEqual(now + 5);
expect(payload.exp).toBe(payload.nbf + 120);
});

it("generates distinct nonces for separate JWT invocations", () => {
const jwt1 = buildCdpJwt("test-id", TEST_PRIVATE_KEY_PEM, "POST", "https://api.cdp.coinbase.com/platform/v2/x402/verify");
const jwt2 = buildCdpJwt("test-id", TEST_PRIVATE_KEY_PEM, "POST", "https://api.cdp.coinbase.com/platform/v2/x402/verify");
const { header: h1 } = verifyAndDecodeJwt(`Bearer ${jwt1}`);
const { header: h2 } = verifyAndDecodeJwt(`Bearer ${jwt2}`);
expect(typeof h1.nonce).toBe("string");
expect(typeof h2.nonce).toBe("string");
expect(h1.nonce.length).toBe(32); // 16 bytes hex
expect(h2.nonce.length).toBe(32);
expect(h1.nonce).not.toBe(h2.nonce);
});

it("handles escaped \\n in private key PEM", () => {
const escapedPem = TEST_PRIVATE_KEY_PEM.replace(/\n/g, "\\n");
const jwt = buildCdpJwt("test-id", escapedPem, "POST", "https://api.cdp.coinbase.com/platform/v2/x402/verify");
const { isValidSig } = verifyAndDecodeJwt(`Bearer ${jwt}`);
expect(isValidSig).toBe(true);
});
});

describe("facilitatorVerify", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
globalThis.fetch = fetchMock as any;
});

it("calls /verify with auth header + correct body shape", async () => {
it("calls /verify with ES256 JWT auth header containing nonce + correct body shape", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ isValid: true }),
Expand All @@ -50,7 +118,21 @@ describe("facilitatorVerify", () => {
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://api.cdp.coinbase.com/platform/v2/x402/verify");
expect(init.method).toBe("POST");
expect((init.headers as any).authorization).toBe("Bearer test-id:test-secret");

const { header, payload } = verifyAndDecodeJwt((init.headers as any).authorization);
expect(header.alg).toBe("ES256");
expect(header.typ).toBe("JWT");
expect(header.kid).toBe("test-id");
expect(typeof header.nonce).toBe("string");
expect(header.nonce.length).toBe(32);
expect(payload.iss).toBe("cdp");
expect(payload.sub).toBe("test-id");
expect(payload.uri).toBe("POST api.cdp.coinbase.com/platform/v2/x402/verify");
const now = Math.floor(Date.now() / 1000);
expect(payload.nbf).toBeGreaterThanOrEqual(now - 5);
expect(payload.nbf).toBeLessThanOrEqual(now + 5);
expect(payload.exp).toBe(payload.nbf + 120);

expect((init.headers as any)["content-type"]).toBe("application/json");
const body = JSON.parse(init.body);
expect(body.x402Version).toBe(1);
Expand Down Expand Up @@ -110,7 +192,7 @@ describe("facilitatorSettle", () => {
globalThis.fetch = fetchMock as any;
});

it("calls /settle with auth header + correct body shape", async () => {
it("calls /settle with ES256 JWT auth header containing nonce + correct body shape", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true, txHash: "0xdeadbeef", networkId: "base" }),
Expand All @@ -119,7 +201,20 @@ describe("facilitatorSettle", () => {
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://api.cdp.coinbase.com/platform/v2/x402/settle");
expect(init.method).toBe("POST");
expect((init.headers as any).authorization).toBe("Bearer test-id:test-secret");

const { header, payload } = verifyAndDecodeJwt((init.headers as any).authorization);
expect(header.alg).toBe("ES256");
expect(header.typ).toBe("JWT");
expect(header.kid).toBe("test-id");
expect(typeof header.nonce).toBe("string");
expect(header.nonce.length).toBe(32);
expect(payload.iss).toBe("cdp");
expect(payload.sub).toBe("test-id");
expect(payload.uri).toBe("POST api.cdp.coinbase.com/platform/v2/x402/settle");
const now = Math.floor(Date.now() / 1000);
expect(payload.nbf).toBeGreaterThanOrEqual(now - 5);
expect(payload.nbf).toBeLessThanOrEqual(now + 5);
expect(payload.exp).toBe(payload.nbf + 120);
});

it("returns txHash + networkId on success", async () => {
Expand Down
Loading
Loading