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
68 changes: 68 additions & 0 deletions .github/workflows/interoperability.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
50 changes: 50 additions & 0 deletions interop/README.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions interop/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Development-only cross-language payment peers."""
211 changes: 211 additions & 0 deletions interop/node-peer.mjs
Original file line number Diff line number Diff line change
@@ -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");
4 changes: 4 additions & 0 deletions interop/node.lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"repository": "inflowpayai/inflow-node",
"revision": "309aab2650dcb06dd652a5076dc176d5c41196d7"
}
Loading
Loading