feat: add x402 test endpoint and payment test script

Add POST /api/x402-test — a standalone payment-gated endpoint with
no auth required, for testing the x402 flow end-to-end.

Add scripts/test-x402.ts using @x402/fetch to automatically handle
the 402 → sign → retry cycle on Base Sepolia.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-02 00:28:55 -08:00
parent 2a7071edb4
commit 74b15ba1b7
4 changed files with 128 additions and 19 deletions

View File

@ -10,7 +10,7 @@
"@encryptid/sdk": "file:../encryptid-sdk",
"@lit/reactive-element": "^2.0.4",
"@x402/core": "^2.3.1",
"@x402/evm": "^2.3.1",
"@x402/evm": "^2.5.0",
"hono": "^4.11.7",
"imapflow": "^1.0.170",
"mailparser": "^3.7.2",
@ -24,8 +24,10 @@
"@types/mailparser": "^3.4.0",
"@types/node": "^22.10.1",
"@types/nodemailer": "^6.4.0",
"@x402/fetch": "^2.5.0",
"bun-types": "^1.1.38",
"typescript": "^5.7.2",
"viem": "^2.46.3",
"vite": "^6.0.3",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
@ -217,9 +219,9 @@
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
"@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="],
"@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="],
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
@ -433,6 +435,8 @@
"@x402/extensions": ["@x402/extensions@2.5.0", "", { "dependencies": { "@scure/base": "^1.2.6", "@x402/core": "~2.5.0", "ajv": "^8.17.1", "siwe": "^2.3.2", "tweetnacl": "^1.0.3", "viem": "^2.43.5", "zod": "^3.24.2" } }, "sha512-e7IQShbGUM/XQmzI8DQh2tX/k2XDUGI9DNF+ij2NHUyPEqAt5/mJCwOlaxS/60FWFdfFRfWjTsqaoS7Z8WLi+A=="],
"@x402/fetch": ["@x402/fetch@2.5.0", "", { "dependencies": { "@x402/core": "~2.5.0", "viem": "^2.39.3", "zod": "^3.24.2" } }, "sha512-D2jH3bn0nf8w9Jg3Vxo+6reE6Z9GickzkSIw+udITJFvsrGOpfjZvhcTeflLcthCODk4Nuu9Oe8x7Q3NLUdaRQ=="],
"@zone-eu/mailsplit": ["@zone-eu/mailsplit@5.4.8", "", { "dependencies": { "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1" } }, "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA=="],
"abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="],
@ -635,13 +639,9 @@
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@scure/bip32/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="],
"@encryptid/sdk/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="],
"@scure/bip32/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@scure/bip39/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@spruceid/siwe-parser/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@encryptid/sdk/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
"ethers/@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.10.1", "", {}, "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw=="],
@ -663,14 +663,6 @@
"mailparser/nodemailer": ["nodemailer@7.0.13", "", {}, "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw=="],
"ox/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="],
"ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"viem/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="],
"viem/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],

View File

@ -26,14 +26,16 @@
"imapflow": "^1.0.170",
"mailparser": "^3.7.2",
"@x402/core": "^2.3.1",
"@x402/evm": "^2.3.1"
"@x402/evm": "^2.5.0"
},
"devDependencies": {
"@types/nodemailer": "^6.4.0",
"@types/mailparser": "^3.4.0",
"@types/node": "^22.10.1",
"@types/nodemailer": "^6.4.0",
"@x402/fetch": "^2.5.0",
"bun-types": "^1.1.38",
"typescript": "^5.7.2",
"viem": "^2.46.3",
"vite": "^6.0.3",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0"

100
scripts/test-x402.ts Normal file
View File

@ -0,0 +1,100 @@
/**
* test-x402.ts End-to-end x402 payment test against rSpace rSplat.
*
* Tests the full 402 flow:
* 1. POST to the gated endpoint expect 402 with payment requirements
* 2. Sign USDC payment on Base Sepolia using EIP-3009
* 3. Retry with X-PAYMENT header expect 200/201
*
* Usage:
* EVM_PRIVATE_KEY=0x... bun run scripts/test-x402.ts [url]
*
* Defaults to https://demo.rspace.online/rsplat/api/splats
* Wallet needs testnet USDC from https://faucet.circle.com
*/
import { x402Client, wrapFetchWithPayment } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm/exact/client";
import { privateKeyToAccount } from "viem/accounts";
const PRIVATE_KEY = process.env.EVM_PRIVATE_KEY as `0x${string}`;
if (!PRIVATE_KEY) {
console.error("Set EVM_PRIVATE_KEY env var (0x-prefixed hex private key)");
process.exit(1);
}
const TARGET_URL =
process.argv[2] || "https://demo.rspace.online/api/x402-test";
async function main() {
console.log("=== x402 Payment Test ===\n");
console.log("Target:", TARGET_URL);
// Step 1: Verify the endpoint returns 402
console.log("\n[1] Testing 402 response (no payment)...");
const raw = await fetch(TARGET_URL, { method: "POST" });
console.log(" Status:", raw.status);
if (raw.status !== 402) {
console.log(" Expected 402, got", raw.status);
console.log(" Body:", await raw.text());
process.exit(1);
}
const body = (await raw.json()) as {
paymentRequirements?: {
payTo: string;
network: string;
maxAmountRequired: string;
};
};
console.log(" Payment requirements:");
console.log(" payTo:", body.paymentRequirements?.payTo);
console.log(" network:", body.paymentRequirements?.network);
console.log(" amount:", body.paymentRequirements?.maxAmountRequired);
// Step 2: Set up x402 client with EVM signer
console.log("\n[2] Setting up x402 client...");
const account = privateKeyToAccount(PRIVATE_KEY);
console.log(" Wallet:", account.address);
const client = new x402Client();
client.register("eip155:84532", new ExactEvmScheme(account));
const paidFetch = wrapFetchWithPayment(fetch, client);
// Step 3: Make the paid request
console.log("\n[3] Making paid request (x402 client handles signing automatically)...");
try {
const res = await paidFetch(TARGET_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ test: true }),
});
console.log(" Status:", res.status);
const resBody = await res.text();
try {
console.log(
" Response:",
JSON.stringify(JSON.parse(resBody), null, 2),
);
} catch {
console.log(" Response:", resBody.slice(0, 500));
}
if (res.ok) {
console.log("\n x402 payment successful! USDC transferred on Base Sepolia.");
} else if (res.status === 402) {
console.log("\n Still 402 — payment signing or verification failed.");
console.log(
" Check wallet has USDC on Base Sepolia (faucet: https://faucet.circle.com)",
);
}
} catch (err) {
console.error(" Error:", err);
}
}
main();

View File

@ -488,6 +488,21 @@ app.get("/api/modules", (c) => {
return c.json({ modules: getModuleInfoList() });
});
// ── x402 test endpoint (no auth, payment-gated only) ──
import { setupX402FromEnv } from "../shared/x402/hono-middleware";
const x402Test = setupX402FromEnv({ description: "x402 test endpoint", resource: "/api/x402-test" });
app.post("/api/x402-test", async (c) => {
if (x402Test) {
const result = await new Promise<Response | null>((resolve) => {
x402Test(c, async () => { resolve(null); }).then((res) => {
if (res instanceof Response) resolve(res);
});
});
if (result) return result;
}
return c.json({ ok: true, message: "Payment received!", timestamp: new Date().toISOString() });
});
// ── Creative tools API endpoints ──
const FAL_KEY = process.env.FAL_KEY || "";