From 9ac5264b3a03874505e2fcf38264b456db935143 Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Thu, 1 Oct 2026 19:19:32 -0700 Subject: [PATCH] test: verify Python and Node payment interoperability --- .github/workflows/interoperability.yml | 68 +++ README.md | 6 + interop/README.md | 50 +++ interop/__init__.py | 1 + interop/node-peer.mjs | 211 ++++++++++ interop/node.lock.json | 4 + interop/peer.py | 171 ++++++++ pyproject.toml | 2 +- scripts/interoperability.mjs | 551 +++++++++++++++++++++++++ 9 files changed, 1063 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/interoperability.yml create mode 100644 interop/README.md create mode 100644 interop/__init__.py create mode 100644 interop/node-peer.mjs create mode 100644 interop/node.lock.json create mode 100644 interop/peer.py create mode 100644 scripts/interoperability.mjs diff --git a/.github/workflows/interoperability.yml b/.github/workflows/interoperability.yml new file mode 100644 index 0000000..13bad18 --- /dev/null +++ b/.github/workflows/interoperability.yml @@ -0,0 +1,68 @@ +name: Node interoperability + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + interoperability: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.12", "3.13", "3.14"] + defaults: + run: + working-directory: sdk + steps: + - uses: actions/checkout@v7 + with: + path: sdk + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python }} + - uses: astral-sh/setup-uv@v10.2.0 + with: + version: "0.11.8" + - uses: actions/setup-node@v7 + with: + node-version: 24 + - name: Read Node revision + id: pin + run: | + node --input-type=module -e ' + import { readFileSync, appendFileSync } from "node:fs"; + const { revision } = JSON.parse(readFileSync("interop/node.lock.json", "utf8")); + if (!/^[0-9a-f]{40}$/.test(revision)) throw new Error("Invalid node-sdk revision"); + appendFileSync(process.env.GITHUB_OUTPUT, `revision=${revision}\n`); + ' + - uses: actions/checkout@v7 + with: + repository: inflowpayai/inflow-node + ref: ${{ steps.pin.outputs.revision }} + path: node-sdk + persist-credentials: false + - uses: pnpm/action-setup@v6 + with: + package_json_file: node-sdk/package.json + - run: pnpm install --frozen-lockfile + working-directory: node-sdk + - run: pnpm build + working-directory: node-sdk + - run: uv sync --all-extras --all-groups --locked --python "${{ matrix.python }}" + - name: Run real HTTP exchanges + run: node scripts/interoperability.mjs ../node-sdk "$RUNNER_TEMP/interoperability.json" + - uses: actions/upload-artifact@v7 + if: always() + with: + name: inflow-python-node-interoperability-${{ matrix.python }} + path: ${{ runner.temp }}/interoperability.json + if-no-files-found: warn + retention-days: 14 diff --git a/README.md b/README.md index 72c7217..4af16a6 100644 --- a/README.md +++ b/README.md @@ -720,3 +720,9 @@ This differs from the InFlow Node facilitator's one-hour capability cache. Node refreshes that cache when it is queried after expiry; it does not automatically reinitialize the application's resource server every hour. In Python, there is no timed capability refresh or background polling. + +## Cross-language verification + +The [Python–Node interoperability suite](https://github.com/inflowpayai/inflow-python/blob/main/interop/README.md) exercises Buyers and +Sellers from both SDKs over local HTTP, including payment rejection and settlement +failure. It uses a synthetic InFlow platform and does not make live payments. diff --git a/interop/README.md b/interop/README.md new file mode 100644 index 0000000..c0c2f71 --- /dev/null +++ b/interop/README.md @@ -0,0 +1,50 @@ +# Python and Node interoperability + +These checks run Python Buyers against Node Sellers and Node Buyers against Python +Sellers over real loopback HTTP. Each peer uses its SDK and upstream payment +transport or web middleware. The InFlow platform is a synthetic HTTP server: no +accounts, wallets, live signatures, or settlement are involved. + +## Run + +Use Node 24. Check out `inflow-node` at the commit in `node.lock.json`, with a clean +working tree. In that repository, run `pnpm install --frozen-lockfile` and +`pnpm build`. In this repository: + +```sh +make sync +make verify +node scripts/interoperability.mjs ../inflow-node /tmp/python-node-report.json +``` + +The report path must not already exist. Set `INTEROP_PYTHON` to use a Python +environment other than `.venv`; install all locked extras and development groups +in that environment first. The report records both source revisions, Python +dependencies, Node package versions, and each case's observed platform requests. + +## What is checked + +- MPP InFlow and Tempo charges in both directions; Python subscription creation + and existing-subscription use against Node Sellers. +- x402 balance and exact payments in both directions. +- Immediate and pending approval results, verification rejection, settlement + failure, and protected-handler failure. +- One purchase per request, application-header preservation, no platform API key + sent to a merchant, receipt identity, and verification/settlement ordering. +- Deliberately corrupted receipts must fail the harness assertions in all four + protocol/direction combinations. + +MPP broadcasts before calling the protected handler. x402 verifies before the +handler and settles after a successful handler response. The assertions reflect +these different lifecycles rather than treating them as interchangeable. + +Python MPP Seller subscriptions are excluded because pympp's Seller routes lack +the required terms; see [pympp #269](https://github.com/tempoxyz/pympp/issues/269). +The standalone Python MPP decorator does not set private cache headers on the +handler's response; the application owns those headers. Cache assertions apply +to Node MPP and both x402 middleware implementations. + +This suite does not prove live platform authorization, blockchain execution, MCP +interoperability, or external-wallet signing. Native tests and shared conformance +checks remain separate requirements. These test peers are not shipped in the +Python distribution. diff --git a/interop/__init__.py b/interop/__init__.py new file mode 100644 index 0000000..a0bf4de --- /dev/null +++ b/interop/__init__.py @@ -0,0 +1 @@ +"""Development-only cross-language payment peers.""" diff --git a/interop/node-peer.mjs b/interop/node-peer.mjs new file mode 100644 index 0000000..3424881 --- /dev/null +++ b/interop/node-peer.mjs @@ -0,0 +1,211 @@ +import { createServer } from "node:http"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import { resolve } from "node:path"; +import { createInterface } from "node:readline"; + +const root = resolve(process.argv[2]); +const lines = createInterface({ input: process.stdin }); +const { value } = await lines[Symbol.asyncIterator]().next(); +lines.close(); +const s = JSON.parse(value); +for (const value of [s.Platform, ...(s.Role === "buyer" ? [s.Target] : [])]) { + const url = new URL(value); + if ( + url.protocol !== "http:" || + url.hostname !== "127.0.0.1" || + url.username || + url.password + ) + throw Error("Loopback endpoints required"); +} +const load = (name) => + import(pathToFileURL(resolve(root, "packages", name, "dist/index.js"))); +const dependency = (name, specifier) => + import( + pathToFileURL( + createRequire(resolve(root, "packages", name, "package.json")).resolve( + specifier, + ), + ) + ); +const options = { + baseUrl: s.Platform, + apiKey: `test-only-${s.Role}-key`, + timeoutMs: 5000, +}; +const output = (value) => process.stdout.write(`${JSON.stringify(value)}\n`); + +if (s.Role === "buyer") { + let response, + receipt = null; + if (s.Protocol === "mpp") { + const buyer = await load("mpp-buyer"); + const method = + s.Variant === "tempo" + ? buyer.tempo(options) + : s.Variant === "subscription" + ? buyer.inflow.subscription(options) + : buyer.inflow(options); + // Exercise one payment attempt; upstream defaults can buy again after a rejected credential. + const client = buyer.Mppx.create({ + methods: [method], + polyfill: false, + maxPaymentRetries: 1, + }); + try { + response = await client.fetch(s.Target, { + headers: { "X-App-Session": "test-only-session" }, + ...(s.SubscriptionID + ? { context: { subscriptionId: s.SubscriptionID } } + : {}), + }); + } finally { + method.cleanup(); + } + if (response.headers.has("Payment-Receipt")) + receipt = (await load("mpp")).decodeReceipt( + response.headers.get("Payment-Receipt"), + ); + } else { + const buyer = await load("x402-buyer"); + const { x402HTTPClient } = await dependency( + "x402-buyer", + "@x402/core/client", + ); + const client = new x402HTTPClient( + await buyer.createInflowClient({ ...options, pollIntervalMs: 0 }), + ); + response = await fetch(s.Target, { + headers: { "X-App-Session": "test-only-session" }, + redirect: "error", + }); + if (response.status === 402) { + const required = client.getPaymentRequiredResponse((name) => + response.headers.get(name), + ); + const payload = await client.createPaymentPayload(required); + response = await fetch(s.Target, { + headers: { + ...client.encodePaymentSignatureHeader(payload), + "X-App-Session": "test-only-session", + }, + redirect: "error", + }); + } + if (response.headers.has("PAYMENT-RESPONSE")) + receipt = client.getPaymentSettleResponse((name) => + response.headers.get(name), + ); + } + output({ + status: response.status, + body: await response.text(), + receipt, + cache: response.headers.get("Cache-Control"), + }); +} else if (s.Role === "seller") { + let listener; + const handle = async () => { + const response = await fetch(`${s.Platform}/handler`, { + method: "POST", + redirect: "error", + }); + if (!response.ok) throw Error("Handler evidence rejected"); + }; + if (s.Protocol === "mpp") { + const seller = await load("mpp-seller"); + const method = + s.Variant === "tempo" + ? seller.tempo({ + ...options, + currency: "0x20c0000000000000000000000000000000000000", + recipient: "0x1111111111111111111111111111111111111111", + }) + : s.Variant === "subscription" + ? seller.inflow.subscription(options) + : seller.inflow(options); + const framework = seller.Mppx.create({ + methods: [method], + realm: "interop", + secretKey: "test-only-binding-secret-at-least-32-bytes", + }); + const terms = { + amount: s.Variant === "tempo" ? "10000" : "0.01", + ...(s.Variant === "tempo" ? {} : { currency: "USDC" }), + ...(s.Variant === "subscription" + ? { + periodUnit: "month", + periodCount: 1, + subscriptionExpires: "2099-01-01T00:00:00Z", + } + : {}), + }; + listener = async (req, res) => { + const result = await framework[ + s.Variant === "subscription" ? "subscription" : "charge" + ](terms)( + new Request(`http://127.0.0.1${req.url}`, { headers: req.headers }), + ); + const response = + result.status === 402 + ? result.challenge + : (await handle(), + result.withReceipt( + new Response('{"paidResource":true}', { + status: s.HandlerStatus, + }), + )); + res.writeHead(response.status, Object.fromEntries(response.headers)); + res.end(await response.text()); + }; + } else { + const seller = await load("x402-seller"); + const require = createRequire( + resolve(root, "examples/x402-seller-express/package.json"), + ); + const { default: express } = await import( + pathToFileURL(require.resolve("express")) + ); + const { paymentMiddlewareFromConfig } = await import( + pathToFileURL(require.resolve("@x402/express")) + ); + const client = await seller.createInflowSellerClient(options); + const route = await seller.inflowRoute(client, { + price: "0.01 USDC", + schemes: [s.Variant], + }); + const app = express(); + app.use( + paymentMiddlewareFromConfig( + { "GET /paid": route }, + [seller.createInflowFacilitator(options)], + await seller.inflowSchemeRegistrations(client, { + schemes: [s.Variant], + }), + ), + ); + app.get("/paid", async (_req, res) => { + await handle(); + res.status(s.HandlerStatus).json({ paidResource: true }); + }); + listener = app; + } + const server = createServer((req, res) => { + if ( + req.headers["x-api-key"] || + req.headers["x-app-session"] !== "test-only-session" + ) { + res.writeHead(500); + res.end("authentication boundary failure"); + return; + } + Promise.resolve(listener(req, res)).catch((error) => { + process.stderr.write(`${error.stack}\n`); + res.writeHead(500); + res.end("Peer failed"); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + output({ url: `http://127.0.0.1:${server.address().port}/paid` }); +} else throw Error("Unknown peer role"); diff --git a/interop/node.lock.json b/interop/node.lock.json new file mode 100644 index 0000000..b5d678a --- /dev/null +++ b/interop/node.lock.json @@ -0,0 +1,4 @@ +{ + "repository": "inflowpayai/inflow-node", + "revision": "309aab2650dcb06dd652a5076dc176d5c41196d7" +} diff --git a/interop/peer.py b/interop/peer.py new file mode 100644 index 0000000..2b65866 --- /dev/null +++ b/interop/peer.py @@ -0,0 +1,171 @@ +import asyncio +import json +import socket +import sys +from contextlib import AsyncExitStack +from typing import Any +from urllib.parse import urlsplit + +import httpx +import uvicorn +from fastapi import FastAPI, Request +from mpp import Credential, Receipt +from mpp.server.decorator import pay +from starlette.middleware.base import RequestResponseEndpoint +from starlette.responses import JSONResponse, Response +from x402 import x402ResourceServer +from x402.http import decode_payment_response_header +from x402.http.clients.httpx import x402AsyncTransport +from x402.http.middleware.fastapi import payment_middleware + +from inflowpay import ClientOptions +from inflowpay.mpp import decode_receipt +from inflowpay.mpp.buyer import BuyerMethod, payment_transport +from inflowpay.mpp.seller import Seller as MppSeller +from inflowpay.x402.buyer import Buyer +from inflowpay.x402.facilitator import Facilitator +from inflowpay.x402.seller import Seller + + +# The harness passes JSON settings; production APIs retain their declared types. +async def run(settings: dict[str, Any]) -> None: + for value in [ + settings["Platform"], + *([settings["Target"]] if settings["Role"] == "buyer" else []), + ]: + url = urlsplit(value) + if url.scheme != "http" or url.hostname != "127.0.0.1" or url.username or url.password: + raise ValueError("Loopback endpoints required") + role, protocol, variant = settings["Role"], settings["Protocol"], settings["Variant"] + if role not in ("buyer", "seller") or protocol not in ("mpp", "x402"): + raise ValueError("Unknown peer role or protocol") + options = ClientOptions(base_url=settings["Platform"], api_key=f"test-only-{role}-key") + async with AsyncExitStack() as stack: + if role == "buyer": + if protocol == "mpp": + method = await stack.enter_async_context( + BuyerMethod( + options, + method="tempo" if variant == "tempo" else "inflow", + intent="subscription" if variant == "subscription" else "charge", + subscription_id=settings.get("SubscriptionID"), + poll_interval=0, + pending_timeout=5, + ) + ) + transport: httpx.AsyncBaseTransport = payment_transport([method]) + else: + buyer = await stack.enter_async_context( + await Buyer.create(options, poll_interval=0, pending_timeout=5) + ) + transport = x402AsyncTransport(buyer) + http = await stack.enter_async_context( + httpx.AsyncClient(transport=transport, follow_redirects=False) + ) + async with asyncio.timeout(10): + response = await http.get( + settings["Target"], headers={"X-App-Session": "test-only-session"} + ) + receipt: object = None + if raw := response.headers.get("Payment-Receipt"): + receipt = decode_receipt(raw) + elif raw := response.headers.get("PAYMENT-RESPONSE"): + receipt = decode_payment_response_header(raw).model_dump( + by_alias=True, exclude_none=True + ) + print( + json.dumps( + { + "status": response.status_code, + "body": response.text, + "receipt": receipt, + "cache": response.headers.get("Cache-Control"), + } + ), + flush=True, + ) + return + + app = FastAPI() + http = await stack.enter_async_context(httpx.AsyncClient(follow_redirects=False)) + + async def handle() -> None: + evidence = await http.post(settings["Platform"] + "/handler") + evidence.raise_for_status() + + if protocol == "mpp": + seller = await stack.enter_async_context( + await MppSeller.create(options, method="tempo" if variant == "tempo" else "inflow") + ) + terms = seller.charge_request( + { + "amount": "10000", + "currency": "0x20c0000000000000000000000000000000000000", + "recipient": "0x1111111111111111111111111111111111111111", + } + if variant == "tempo" + else {"amount": "0.01", "currency": "USDC"} + ) + + @app.get("/paid") + @pay( + intent=seller, + method=seller.method, + request=terms, + realm="interop", + secret_key="test-only-binding-secret-at-least-32-bytes", + ) + async def paid( + request: Request, credential: Credential, receipt: Receipt + ) -> JSONResponse: + await handle() + return JSONResponse( + {"paidResource": True}, + status_code=settings["HandlerStatus"], + headers={"Payment-Receipt": receipt.to_payment_receipt()}, + ) + else: + x_seller = await stack.enter_async_context(await Seller.create(options)) + facilitator = await stack.enter_async_context(await Facilitator.create(options)) + resource = x402ResourceServer(facilitator) + for registration in await x_seller.scheme_registrations(schemes=[variant]): + resource.register(registration["network"], registration["server"]) + route = await x_seller.route("0.01 USDC", schemes=[variant]) + app.middleware("http")(payment_middleware({"GET /paid": route}, resource)) + + @app.get("/paid") + async def x_paid() -> JSONResponse: + await handle() + return JSONResponse({"paidResource": True}, status_code=settings["HandlerStatus"]) + + @app.middleware("http") + async def credentials(request: Request, call_next: RequestResponseEndpoint) -> Response: + if ( + request.headers.get("x-api-key") + or request.headers.get("x-app-session") != "test-only-session" + ): + return JSONResponse({"error": "authentication boundary failure"}, status_code=500) + return await call_next(request) + + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + server = uvicorn.Server(uvicorn.Config(app, log_level="error", lifespan="off")) + task = asyncio.create_task(server.serve(sockets=[listener])) + try: + while not server.started: + if task.done(): + await task + raise RuntimeError("Server did not start") + await asyncio.sleep(0.01) + print( + json.dumps({"url": f"http://127.0.0.1:{listener.getsockname()[1]}/paid"}), + flush=True, + ) + await task + finally: + server.should_exit = True + await task + + +if __name__ == "__main__": + asyncio.run(run(json.loads(sys.stdin.readline()))) diff --git a/pyproject.toml b/pyproject.toml index 9cbc4b4..c734570 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ packages = ["src/inflowpay"] [tool.mypy] python_version = "3.11" strict = true -files = ["src", "tests", "scripts", "examples", "conformance"] +files = ["src", "tests", "scripts", "examples", "conformance", "interop"] [tool.pytest.ini_options] addopts = [ diff --git a/scripts/interoperability.mjs b/scripts/interoperability.mjs new file mode 100644 index 0000000..736a21c --- /dev/null +++ b/scripts/interoperability.mjs @@ -0,0 +1,551 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { spawn, execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { once } from "node:events"; + +const root = resolve(import.meta.dirname, ".."); +const nodeRoot = resolve(process.argv[2] ?? "../inflow-node"); +const reportPath = resolve(process.argv[3] ?? "interoperability.json"); +const pin = JSON.parse( + readFileSync(join(root, "interop/node.lock.json")), +).revision; +const git = (cwd, ...args) => + execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +assert.equal( + git(nodeRoot, "rev-parse", "HEAD"), + pin, + "Node checkout must match the pin", +); +assert.equal( + git(nodeRoot, "status", "--porcelain"), + "", + "Node checkout must be clean", +); +const python = + process.env.INTEROP_PYTHON || + join( + root, + ".venv", + process.platform === "win32" ? "Scripts/python.exe" : "bin/python", + ); +const children = new Set(); +let interrupted = false; +for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, () => { + interrupted = true; + process.exitCode = 1; + for (const child of children) child.kill("SIGKILL"); + }); +} +const report = { + sdk_revision: git(root, "rev-parse", "HEAD"), + sdk_dirty: git(root, "status", "--porcelain") !== "", + node_revision: pin, + node_version: process.version, + platform: "synthetic loopback HTTP; no live signing or settlement", + node_packages: Object.fromEntries( + ["mpp", "mpp-buyer", "mpp-seller", "x402", "x402-buyer", "x402-seller"].map( + (name) => [ + name, + JSON.parse( + readFileSync(join(nodeRoot, "packages", name, "package.json")), + ).version, + ], + ), + ), + exclusions: [ + "Python MPP Seller subscriptions: pympp#269; Python Buyer subscriptions are exercised against Node Sellers.", + ], + cases: [], + passed: false, +}; +const id = "22222222-2222-4222-8222-222222222222"; +const approval = "33333333-3333-4333-8333-333333333333"; +const sellerId = "11111111-1111-4111-8111-111111111111"; +const mppCases = JSON.parse( + readFileSync(join(root, "tests/fixtures/mpp-seller.json")), +); +const mppConfig = mppCases.cases + .flatMap((c) => c.platform.exchanges) + .find((e) => e.request.path === "/v1/mpp/config").response.json; +const xCases = JSON.parse( + readFileSync(join(root, "tests/fixtures/x402-buyer.json")), +); +const supported = xCases.cases[0].platform.exchanges.find( + (e) => e.request.path === "/v1/transactions/x402-supported", +).response.json; +const config = JSON.parse( + readFileSync(join(root, "tests/fixtures/x402-seller.json")), +).cases.find((c) => c.id === "x402.seller.offers-default").input.config; +const encode = (object, encoding = "base64url") => + Buffer.from(JSON.stringify(object)).toString(encoding); + +function peer(language, settings) { + const child = + language === "python" + ? spawn(python, ["-m", "interop.peer"], { cwd: root }) + : spawn(process.execPath, [ + join(root, "interop/node-peer.mjs"), + nodeRoot, + ]); + children.add(child); + let stdout = "", + stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + if (stdout.length > 1048576) child.kill("SIGKILL"); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + if (stderr.length > 1048576) child.kill("SIGKILL"); + }); + child.stdin.on("error", () => {}); + child.stdin.end(JSON.stringify(settings)); + const timer = setTimeout(() => child.kill("SIGKILL"), 20000); + const exited = new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (code) => { + clearTimeout(timer); + children.delete(child); + code === 0 && stdout.length <= 1048576 && stderr.length <= 1048576 + ? resolve(stdout) + : reject(Error(`Peer exited ${code}: ${stderr}`)); + }); + }); + // A listening Seller intentionally remains alive until its case finishes. + exited.catch(() => {}); + return { + child, + exited, + async ready() { + for (let i = 0; i < 400; i++) { + if (stdout.includes("\n")) return JSON.parse(stdout.split("\n")[0]); + if (child.exitCode !== null) throw Error(stderr); + await new Promise((r) => setTimeout(r, 25)); + } + throw Error("Seller startup timed out"); + }, + }; +} + +async function runCase( + protocol, + sellerLanguage, + scenario, + variant, + corruptReceipt = false, +) { + if (interrupted) throw Error("Interoperability run interrupted"); + const existingSubscription = variant === "existing-subscription"; + const events = [], + errors = []; + let credential, + payload, + created = 0, + verified = 0, + completed = 0, + polls = 0; + const platform = createServer(async (req, res) => { + res.setHeader("Content-Type", "application/json"); + const send = (object) => res.end(JSON.stringify(object)); + try { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks)) : {}; + const path = req.url; + events.push(`${req.method} ${path}`); + if (path === "/handler") { + assert.equal(req.method, "POST"); + return send({ ok: true }); + } + assert.equal( + req.headers["x-api-key"], + `test-only-${path.startsWith("/v1/transactions") || path.startsWith("/v1/subscriptions/") ? "buyer" : "seller"}-key`, + ); + if (path === "/v1/mpp/config") return send(mppConfig); + if (path === "/v1/x402/config") return send(config); + if (path === "/v1/transactions/x402-supported") return send(supported); + if (path === "/v1/x402/supported") + return send({ kinds: config.supported }); + if ( + path === "/v1/transactions/mpp" || + path === `/v1/subscriptions/${id}/authorize` + ) { + assert.equal(req.method, "POST"); + assert.equal( + path, + existingSubscription + ? `/v1/subscriptions/${id}/authorize` + : "/v1/transactions/mpp", + ); + if (existingSubscription) + assert.deepEqual(Object.keys(body), ["challenge"]); + assert.equal(++created, 1); + const request = JSON.parse( + Buffer.from(body.challenge.request, "base64url"), + ); + assert.equal(request.amount, variant === "tempo" ? "10000" : "0.01"); + assert.equal( + request.currency, + variant === "tempo" + ? "0x20c0000000000000000000000000000000000000" + : "USDC", + ); + assert.equal( + request.recipient, + variant === "tempo" + ? "0x1111111111111111111111111111111111111111" + : sellerId, + ); + credential = { + challenge: body.challenge, + source: "did:inflow:66666666-6666-4666-8666-666666666666", + payload: existingSubscription + ? { + authorizationExpires: body.challenge.expires, + authorizationId: approval, + subscriptionId: id, + transactionId: id, + authorizationSignature: "synthetic-platform-signature", + } + : variant === "tempo" + ? { type: "hash", hash: `0x${"11".repeat(32)}` } + : { transactionId: id }, + }; + if (existingSubscription) + return send({ credential: encode(credential) }); + return send( + scenario === "pending" + ? { + state: "pending", + transactionId: id, + approvalId: approval, + retryAfterSeconds: 0, + } + : { + state: "ready", + transactionId: id, + credential: encode(credential), + }, + ); + } + if (path === `/v1/transactions/${id}/mpp`) { + polls++; + return send({ + state: "ready", + transactionId: id, + credential: encode(credential), + }); + } + if (path === "/v1/mpp/validate" || path === "/v1/mpp/broadcast") { + assert.deepEqual(body.credential, credential); + if (path.endsWith("/validate")) { + verified++; + if (scenario === "invalid") + return send({ + success: false, + problem: { + type: "https://paymentauth.org/problems/verification-failed", + title: "Rejected test payment", + status: 402, + }, + }); + return send({ + success: true, + credential, + challenge: credential.challenge, + source: credential.source, + method: credential.challenge.method, + intent: credential.challenge.intent, + request: JSON.parse( + Buffer.from(credential.challenge.request, "base64url"), + ), + details: {}, + }); + } + assert.equal(verified, 1); + completed++; + if (scenario === "settlement-failed") + return send({ + problem: { + type: "https://paymentauth.org/problems/verification-failed", + title: "Rejected test payment", + status: 402, + }, + }); + return send({ + receipt: { + method: credential.challenge.method, + status: "success", + reference: corruptReceipt ? approval : id, + timestamp: "2026-09-29T00:00:00Z", + }, + }); + } + if (path === "/v1/transactions/x402") { + assert.equal(++created, 1); + assert.equal( + body.accept.amount, + variant === "exact" ? "10000" : "1000000", + ); + assert.equal( + body.accept.payTo, + variant === "exact" ? config.wallets[0].address : sellerId, + ); + payload = { + x402Version: 2, + accepted: body.accept, + resource: body.resource, + payload: { transactionId: id }, + extensions: { + "payment-identifier": { + info: { required: false, id: "interop-payment-identifier" }, + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + id: { + type: "string", + minLength: 16, + maxLength: 128, + pattern: "^[a-zA-Z0-9_-]+$", + }, + required: { type: "boolean" }, + }, + required: ["required"], + }, + }, + }, + }; + return send({ + transactionId: id, + approvalId: approval, + approvalStatus: "APPROVED", + amount: "0.01", + currency: "USDC", + }); + } + if (path === `/v1/transactions/${id}/x402`) { + polls++; + if (scenario === "pending" && polls === 1) + return send({ status: "INITIATED" }); + return send({ + status: "COMPLETED", + paymentPayload: payload, + encodedPayload: encode(payload, "base64"), + }); + } + if (path === "/v1/x402/verify" || path === "/v1/x402/settle") { + assert.deepEqual(body.paymentPayload, payload); + assert.deepEqual(body.paymentRequirements, payload.accepted); + assert.deepEqual(body.paymentPayload.accepted, payload.accepted); + assert.deepEqual(body.paymentPayload.resource, payload.resource); + if (path.endsWith("/verify")) { + verified++; + return send({ + isValid: scenario !== "invalid", + ...(scenario === "invalid" + ? { invalidReason: "test_rejected" } + : {}), + }); + } + assert.equal(verified, 1); + completed++; + return send({ + success: scenario !== "settlement-failed", + network: payload.accepted.network, + transaction: corruptReceipt ? approval : id, + ...(scenario === "settlement-failed" + ? { errorReason: "test_rejected" } + : {}), + }); + } + throw Error(`Unexpected platform request ${req.method} ${path}`); + } catch (error) { + errors.push(error.message); + res.statusCode = 400; + send({ error: "Unexpected test request" }); + } + }); + platform.listen(0, "127.0.0.1"); + await once(platform, "listening"); + const settings = { + Protocol: protocol, + Platform: `http://127.0.0.1:${platform.address().port}`, + Variant: existingSubscription ? "subscription" : variant, + ...(existingSubscription ? { SubscriptionID: id } : {}), + HandlerStatus: scenario === "handler-failed" ? 500 : 200, + }; + let seller; + try { + seller = peer(sellerLanguage, { ...settings, Role: "seller" }); + const { url } = await seller.ready(); + const buyer = peer(sellerLanguage === "python" ? "node" : "python", { + ...settings, + Role: "buyer", + Target: url, + }); + const result = JSON.parse(await buyer.exited); + assert.deepEqual(errors, []); + assert.equal(created, 1); + assert.equal(verified, 1); + if (existingSubscription) assert.equal(polls, 0); + const handlerCount = events.filter((e) => e === "POST /handler").length; + const denied = scenario === "invalid" || scenario === "settlement-failed"; + assert.equal( + result.status, + denied ? 402 : scenario === "handler-failed" ? 500 : 200, + ); + assert.equal( + handlerCount, + scenario === "invalid" || + (protocol === "mpp" && scenario === "settlement-failed") + ? 0 + : 1, + ); + assert.equal( + completed, + scenario === "invalid" || + (protocol === "x402" && scenario === "handler-failed") + ? 0 + : 1, + ); + if (!denied && scenario !== "handler-failed") { + assert.ok(result.receipt); + if (protocol === "mpp") { + assert.equal(result.receipt.reference, id, "receipt mismatch"); + assert.equal(result.receipt.status, "success"); + assert.equal( + result.receipt.method, + variant === "tempo" ? "tempo" : "inflow", + ); + } else { + assert.equal(result.receipt.transaction, id, "receipt mismatch"); + assert.equal(result.receipt.success, true); + assert.equal(result.receipt.network, payload.accepted.network); + } + if (protocol === "x402" || sellerLanguage === "node") + assert.match(result.cache, /private/i); + } + if (scenario === "pending") + assert.ok(polls >= (protocol === "x402" ? 2 : 1)); + if (!denied) + assert.deepEqual(JSON.parse(result.body), { paidResource: true }); + if (handlerCount && completed) { + const terminal = events.findIndex((e) => + e.endsWith(protocol === "mpp" ? "/broadcast" : "/settle"), + ); + const handler = events.indexOf("POST /handler"); + assert.ok(protocol === "mpp" ? terminal < handler : handler < terminal); + } + return { + protocol, + seller: sellerLanguage, + buyer: sellerLanguage === "python" ? "node" : "python", + scenario, + variant, + passed: true, + events, + }; + } finally { + if (seller) { + seller.child.kill("SIGTERM"); + await seller.exited.catch(() => {}); + } + platform.closeAllConnections(); + await new Promise((r) => platform.close(r)); + } +} + +try { + report.python = JSON.parse( + execFileSync( + python, + [ + "-c", + 'import json,platform; from importlib.metadata import distributions; print(json.dumps({"version":platform.python_version(),"dependencies":{d.metadata["Name"]:d.version for d in distributions()}}))', + ], + { encoding: "utf8" }, + ), + ); + for (const protocol of ["mpp", "x402"]) + for (const variant of protocol === "mpp" + ? ["charge", "subscription", "existing-subscription", "tempo"] + : ["balance", "exact"]) + for (const seller of ["python", "node"]) + for (const scenario of [ + "ready", + "pending", + "invalid", + "settlement-failed", + "handler-failed", + ]) { + if ( + (seller === "python" && + ["subscription", "existing-subscription"].includes(variant)) || + (variant === "existing-subscription" && scenario === "pending") + ) + continue; + try { + report.cases.push( + await runCase(protocol, seller, scenario, variant), + ); + process.stdout.write( + `PASS ${protocol} ${variant} ${seller} seller ${scenario}\n`, + ); + } catch (error) { + report.cases.push({ + protocol, + variant, + seller, + scenario, + passed: false, + error: error.stack, + }); + process.stderr.write( + `FAIL ${protocol} ${variant} ${seller} seller ${scenario}: ${error.message}\n`, + ); + } + } + for (const protocol of ["mpp", "x402"]) { + for (const seller of ["python", "node"]) { + await assert.rejects( + runCase( + protocol, + seller, + "ready", + protocol === "mpp" ? "charge" : "balance", + true, + ), + (error) => + error.code === "ERR_ASSERTION" && + error.message.startsWith("receipt mismatch"), + "The harness must reject a corrupted receipt", + ); + report.cases.push({ + protocol, + seller, + negative_control: "corrupted receipt rejected", + passed: true, + }); + } + } + report.passed = !interrupted && report.cases.every((c) => c.passed); + if (!report.passed) process.exitCode = 1; +} finally { + await Promise.all( + [...children].map( + (child) => + new Promise((resolve) => { + child.once("close", resolve); + child.kill("SIGKILL"); + }), + ), + ); + writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, { + flag: "wx", + mode: 0o600, + }); +}