From 552f7980255dcc31d492bb62f5d85dbc01616bc5 Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Mon, 5 Oct 2026 15:55:35 +0800 Subject: [PATCH 1/6] chore(core): x402 SDK 2.28, with @x402/evm and viem for EVM payments M4 adds payment checks on Base Sepolia, which need @x402/evm. It depends on @x402/core ~2.28, so the other x402 packages move from 2.19 to 2.28 with it, keeping one copy of @x402/core in the tree: a client from one copy registering a scheme from another is the kind of split the 0.4.0 cross-check bug came from. viem is the signer and RPC client @x402/evm uses. Nothing else changes in this commit. Verified on the bumped tree: build, core 180 and cli 45 tests, both typechecks, verify:clean-install, and the local fixtures on Stellar testnet (x402 7/7 with X402-06 settled on-chain, MPP-01 PASS, channel 3 passed and 2 skipped). Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 151 +++++++++++++++++++++++++++---------- packages/core/package.json | 12 +-- 2 files changed, 117 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index f58b90c..e70d5ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -674,13 +674,13 @@ } }, "node_modules/@signinwithethereum/siwe": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@signinwithethereum/siwe/-/siwe-4.2.0.tgz", - "integrity": "sha512-6W+oyKgMFUZRJI4o9P9mTMzrokkXfu3tq1/CfOJj9QrMqyPqujJqv1xnxUAqDQwWVyCA8TMg0Fxk/+gXrDg2Nw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@signinwithethereum/siwe/-/siwe-4.2.1.tgz", + "integrity": "sha512-CQEQhCIgN9blil1YpaGR+wvGPdsBVK6zed5DBu03s1IdbSO3kA/n7Zp+SAkZGiHy3HY6OJm1Su0/Bd33vKK6nQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@signinwithethereum/siwe-parser": "^4.2.0" + "@signinwithethereum/siwe-parser": "^4.2.1" }, "peerDependencies": { "ethers": "^5.7.0 || ^6.13.0", @@ -696,9 +696,9 @@ } }, "node_modules/@signinwithethereum/siwe-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@signinwithethereum/siwe-parser/-/siwe-parser-4.2.0.tgz", - "integrity": "sha512-e3edh8XpZrEjbzVYc0BZ4ySFOa8RKTZOQTafSf1E6ejCB5XcBH93jEpYmjzry1EEocgMNq4KlB3YunsFNCXakQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@signinwithethereum/siwe-parser/-/siwe-parser-4.2.1.tgz", + "integrity": "sha512-GAdwNiBaRpUMTiFfPrbQ3e/RBhPbEhRRN1sZmDMLgLEZuKqKwO7V0ksVzCerE9l8HFKTNw5R/q3kPDCWJRz4zA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1248,9 +1248,9 @@ "link": true }, "node_modules/@x402/core": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.19.0.tgz", - "integrity": "sha512-IPQIHzIrwvbri1339QWHvwtiTTDbGsrF5Yff0LZfufmVXNdOSYbjY8oz6vZg7+4W13f7/k81NUMAFoDOU4gswQ==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.28.0.tgz", + "integrity": "sha512-Da/7zKxCKqpBoB4Msc9tFgkAv7tJTD8nG+DSSm+QdXAyJS4PQsmgVSKe17rDJTtlu0pwNbLcOje6MyQ6kaDkig==", "license": "Apache-2.0", "dependencies": { "zod": "^3.24.2" @@ -1265,18 +1265,38 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@x402/evm": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.28.0.tgz", + "integrity": "sha512-BZuoaCKMoPQQuMTa4oibvoyI06WdCpgrzgdicUu8d+5NBMAfWb6LAmSQf1KkeiS8C+25/+2JGB4cHSL0VLGnSA==", + "license": "Apache-2.0", + "dependencies": { + "@x402/core": "~2.28.0", + "viem": "^2.48.11", + "zod": "^3.24.2" + } + }, + "node_modules/@x402/evm/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@x402/express": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/@x402/express/-/express-2.19.0.tgz", - "integrity": "sha512-rAXhDuHxS2cBK1ePAbnBh2EEWGVh9siBj16OfYnQIqnfESBJp/MHtgdrl1veB6FjMaohqsMVvVtIwJRlO9MOtQ==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/express/-/express-2.28.0.tgz", + "integrity": "sha512-/94ZT89ce9Y92TgAxMuBQiS32BbvZOh0ZziMxTR8js2dbroEZ8po5guayy8zHOG79/x0p5GHJ/yoUUC4faMnvA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@x402/core": "~2.19.0", - "@x402/extensions": "~2.19.0" + "@x402/core": "~2.28.0", + "@x402/extensions": "~2.28.0" }, "peerDependencies": { - "@x402/paywall": "^2.19.0", + "@x402/paywall": "^2.28.0", "express": "^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { @@ -1286,16 +1306,16 @@ } }, "node_modules/@x402/extensions": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/@x402/extensions/-/extensions-2.19.0.tgz", - "integrity": "sha512-lPPBlABFjvgHxhtqm8pFZpW/mo/1wGT2JbhHSc1rXXFx+xEZZ6qHY8vehssNIRM5cMEP1Nx32j1A3+ME8SKeiA==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/extensions/-/extensions-2.28.0.tgz", + "integrity": "sha512-M3SVxO8DJnx7gpok/ODQp803jr21s1XrCMholNw0hSgOVvSPRksfTZ+CUNfJwteNaHmaAxGjPelXWBhTCMD9oQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@noble/curves": "^1.9.0", "@scure/base": "^1.2.6", "@signinwithethereum/siwe": "^4.1.0", - "@x402/core": "~2.19.0", + "@x402/core": "~2.28.0", "ajv": "^8.17.1", "jose": "^5.9.6", "tweetnacl": "^1.0.3", @@ -1324,27 +1344,74 @@ } }, "node_modules/@x402/fetch": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/@x402/fetch/-/fetch-2.19.0.tgz", - "integrity": "sha512-5FOZP3RKtZSCpA2/zyBqenQyrdaXZ2OXTRb2bJyC2mRNqzM8QEtELhtBTOJB54SD288J9VAXOTq2nb4dFf6JgQ==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/fetch/-/fetch-2.28.0.tgz", + "integrity": "sha512-kF5IjZIz/izg2TD/Zav+SVj0jtC0zBDfE7WcKCnMZWIsZcLCyL2GgvwXCn7CCSsNjpQ/z3Rz4OqTCA4AaeKySQ==", "license": "Apache-2.0", "dependencies": { - "@x402/core": "~2.19.0" + "@x402/core": "~2.28.0" } }, "node_modules/@x402/stellar": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/@x402/stellar/-/stellar-2.19.0.tgz", - "integrity": "sha512-L82/j1/cuqg561O+4PI6p+N0//ThiVXbJh6B7oJVn+9dmvuQ7Op7rhx9lOhl3Q2Imu5ci5lB5IgJ9YeDC2aOtQ==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/stellar/-/stellar-2.28.0.tgz", + "integrity": "sha512-H/P46YVVEvJmnXlss8fJBNGgjaLkp3pWJ6PH2uOJY+jVemugUoQPArwtQKNovEV2BnF0gnJ+sdaRg+4Gucu6jQ==", "license": "Apache-2.0", "dependencies": { - "@stellar/stellar-sdk": "^16.0.1", - "@x402/core": "~2.19.0" + "@stellar/stellar-sdk": "^16.3.0", + "@x402/core": "~2.28.0" }, "engines": { "node": ">=22.0.0" } }, + "node_modules/@x402/stellar/node_modules/@stellar/stellar-sdk": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.3.1.tgz", + "integrity": "sha512-PPM1JA51NChSPyMkh+T9sHxJtP/v9e9Gcyc2BMNvnGvbZwqoKJclRSdPgmalLDAmTnTewGs7VMM9nRxDeCzzgQ==", + "license": "Apache-2.0", + "dependencies": { + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.2.0", + "@stellar/js-xdr": "4.0.0", + "axios": "1.18.0", + "base32.js": "^0.1.0", + "bignumber.js": "^11.1.4", + "buffer": "^6.0.3", + "commander": "^14.0.3", + "eventsource": "^4.1.0", + "feaxios": "^0.0.23", + "smol-toml": "^1.6.1", + "uint8array-extras": "^1.5.0" + }, + "bin": { + "stellar-js": "bin/stellar-js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@x402/stellar/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@x402/stellar/node_modules/eventsource": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", + "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/abitype": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.3.0.tgz", @@ -3824,9 +3891,9 @@ } }, "node_modules/viem": { - "version": "2.55.8", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.8.tgz", - "integrity": "sha512-BHqtsmK4iMLuLnRyrPIB1jVrmFVliRIP/K0dnFT7gBOpfo8Ko4ozhkzUCRNfR+Z/ZZdnlnVrh04fAOuIm5Svkg==", + "version": "2.57.3", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.57.3.tgz", + "integrity": "sha512-441q6Z42ZU7+mTJrYAjQsBpVWK76nyGQcKOCGT6hp+tBpw1XsnTIgKaqZFLTaW5xmBN1ummEFgzFWmR05FISTg==", "funding": [ { "type": "github", @@ -3841,7 +3908,7 @@ "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", - "ox": "0.14.32", + "ox": "0.14.45", "ws": "8.21.0" }, "peerDependencies": { @@ -3887,9 +3954,9 @@ } }, "node_modules/viem/node_modules/ox": { - "version": "0.14.32", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.32.tgz", - "integrity": "sha512-EPB214GvtsP2TtAYZXkNdizLzGp6PXtfaHcRrD4pcBk/D0Y7ZCNv71QgwrjeCsZ+82moVeMlZZG+NDEIUfxMpw==", + "version": "0.14.45", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.45.tgz", + "integrity": "sha512-jpsQ+p0JZh9mKCxxzxz0d5qFHZZg2/O9xW18IpEC/dNXXPVqmDJ0c//9RMz0CILkbSGPA42+cbU0fC/ghwJ03w==", "funding": [ { "type": "github", @@ -4090,15 +4157,17 @@ "dependencies": { "@stellar/mpp": "^0.7.1", "@stellar/stellar-sdk": "^16.1.0", - "@x402/fetch": "^2.19.0", - "@x402/stellar": "^2.19.0", - "mppx": "^0.8.14" + "@x402/evm": "^2.28.0", + "@x402/fetch": "^2.28.0", + "@x402/stellar": "^2.28.0", + "mppx": "^0.8.14", + "viem": "^2.57.3" }, "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^26.1.1", - "@x402/core": "^2.19.0", - "@x402/express": "^2.19.0", + "@x402/core": "^2.28.0", + "@x402/express": "^2.28.0", "dotenv": "^17.4.2", "express": "^5.2.1", "tsx": "^4.23.1" diff --git a/packages/core/package.json b/packages/core/package.json index 930057c..5ceddc7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,15 +41,17 @@ "dependencies": { "@stellar/mpp": "^0.7.1", "@stellar/stellar-sdk": "^16.1.0", - "@x402/fetch": "^2.19.0", - "@x402/stellar": "^2.19.0", - "mppx": "^0.8.14" + "@x402/evm": "^2.28.0", + "@x402/fetch": "^2.28.0", + "@x402/stellar": "^2.28.0", + "mppx": "^0.8.14", + "viem": "^2.57.3" }, "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^26.1.1", - "@x402/core": "^2.19.0", - "@x402/express": "^2.19.0", + "@x402/core": "^2.28.0", + "@x402/express": "^2.28.0", "dotenv": "^17.4.2", "express": "^5.2.1", "tsx": "^4.23.1" From 17b52debc50db9eb8e5aece15e13ab68c0f8d304 Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Mon, 5 Oct 2026 15:57:40 +0800 Subject: [PATCH 2/6] refactor(core): x402 payment checks go through a per-chain adapter X402-06 and X402-07 called Stellar code directly: the signer, the settlement lookup, the reference format and the signature corruption. Supporting another chain would have meant branching inside each check. A PaymentChain adapter now supplies those (x402/chains), and the checks ask it for the run's network. Stellar's adapter wraps the existing code unchanged; corruptAuthSignature moves into it and is still exported from simulator.ts. readSettlementReference takes the chain as an optional third argument, defaulting to Stellar, so its callers and tests are unaffected. A network no adapter pays on is still stopped at preflight, now with a message that lists the networks the payment checks do pay on. No behaviour change otherwise: 180 core tests pass, and the full flow against the Stellar fixture is still 7/7 with X402-06 settled on-chain. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/x402/chains/index.ts | 21 ++++ packages/core/src/x402/chains/stellar.ts | 87 ++++++++++++++++ packages/core/src/x402/chains/types.ts | 51 ++++++++++ packages/core/src/x402/simulator.ts | 121 ++++++++--------------- 4 files changed, 202 insertions(+), 78 deletions(-) create mode 100644 packages/core/src/x402/chains/index.ts create mode 100644 packages/core/src/x402/chains/stellar.ts create mode 100644 packages/core/src/x402/chains/types.ts diff --git a/packages/core/src/x402/chains/index.ts b/packages/core/src/x402/chains/index.ts new file mode 100644 index 0000000..d5d0be5 --- /dev/null +++ b/packages/core/src/x402/chains/index.ts @@ -0,0 +1,21 @@ +/** + * The chains the x402 payment checks can pay on. Adding one is adding an + * adapter here; the checks do not change. + */ + +import { stellarChain } from "./stellar.js"; +import type { PaymentChain } from "./types.js"; + +export type { PaymentChain } from "./types.js"; + +const CHAINS: readonly PaymentChain[] = [stellarChain]; + +/** The adapter that pays on `network`, if Wasit has one. */ +export function paymentChainFor(network: string): PaymentChain | undefined { + return CHAINS.find((chain) => chain.networks.includes(network)); +} + +/** Every network the payment checks can pay on, in adapter order. */ +export function paymentNetworks(): readonly string[] { + return CHAINS.flatMap((chain) => chain.networks); +} diff --git a/packages/core/src/x402/chains/stellar.ts b/packages/core/src/x402/chains/stellar.ts new file mode 100644 index 0000000..20455c1 --- /dev/null +++ b/packages/core/src/x402/chains/stellar.ts @@ -0,0 +1,87 @@ +/** + * The `exact` scheme on Stellar: Soroban authorization entries signed by the + * payer, settlement read from the token contract's `transfer` event on + * Stellar RPC (settlement.ts). This is the chain Wasit was built on, and the + * only one with MPP as well. + */ + +import { createEd25519Signer } from "@x402/stellar"; +import { ExactStellarScheme as ExactStellarClientScheme } from "@x402/stellar/exact/client"; +import { Keypair, xdr } from "@stellar/stellar-sdk"; + +import { CheckSetupError } from "../../errors.js"; +import { resolveRpcUrl } from "../../mpp/network.js"; +import { verifySettlement } from "../../settlement.js"; +import type { PaymentChain } from "./types.js"; + +/** A Stellar transaction hash: 64 hex characters. */ +const TRANSACTION_HASH = /^[0-9a-f]{64}$/i; + +/** + * Corrupts the client's authorization signature, and nothing else. + * + * In the `exact` scheme on Stellar the client signs a Soroban authorization + * entry, not the envelope: the facilitator signs the envelope later. So the + * signature that proves the payer consented is the address credential's + * `signature`, a `Vec<{ public_key, signature }>`. Flipping one byte of it + * leaves a transaction that still decodes, carries the same amount, payer and + * recipient, and fails only signature verification. A target can refuse it + * only by verifying the signature. + * + * Throws {@link CheckSetupError} if the payload carries no such signature, so + * the check reports no verdict rather than one it did not establish. + */ +export function corruptAuthSignature(transaction: string): string { + const envelope = xdr.TransactionEnvelope.fromXDR(transaction, "base64"); + const operations = + envelope.switch().name === "envelopeTypeTx" ? envelope.v1().tx().operations() : []; + + let corrupted = false; + for (const operation of operations) { + if (operation.body().switch().name !== "invokeHostFunction") continue; + for (const entry of operation.body().invokeHostFunctionOp().auth()) { + const credentials = entry.credentials(); + if (credentials.switch().name !== "sorobanCredentialsAddress") continue; + const signature = credentials.address().signature(); + if (signature.switch().name !== "scvVec") continue; + for (const item of signature.vec() ?? []) { + if (item.switch().name !== "scvMap") continue; + for (const field of item.map() ?? []) { + if (field.key().switch().name !== "scvSymbol") continue; + if (field.key().sym().toString() !== "signature") continue; + const bytes = Buffer.from(field.val().bytes()); + bytes[0] = bytes[0]! ^ 0xff; + field.val(xdr.ScVal.scvBytes(bytes)); + corrupted = true; + } + } + } + } + + if (!corrupted) { + throw new CheckSetupError( + "The payment payload carries no authorization-entry signature to " + + "corrupt, so a corrupted signature cannot be tested.", + ); + } + return envelope.toXDR("base64"); +} + +export const stellarChain: PaymentChain = { + name: "Stellar", + networks: ["stellar:testnet", "stellar:pubnet"], + payerKeyEnv: "STELLAR_PRIVATE_KEY", + referenceKind: "a settled Stellar transaction hash", + resolveRpcUrl: (network, override) => resolveRpcUrl(network, override), + registerPayer(client, network, payerKey) { + const signer = createEd25519Signer(payerKey, network as `${string}:${string}`); + client.register("stellar:*", new ExactStellarClientScheme(signer)); + }, + payerAddress: (payerKey) => Keypair.fromSecret(payerKey).publicKey(), + isSettlementReference: (reference) => TRANSACTION_HASH.test(reference), + verifySettlement, + corruptPayload: (payload) => ({ + ...payload, + transaction: corruptAuthSignature(payload["transaction"] as string), + }), +}; diff --git a/packages/core/src/x402/chains/types.ts b/packages/core/src/x402/chains/types.ts new file mode 100644 index 0000000..747753a --- /dev/null +++ b/packages/core/src/x402/chains/types.ts @@ -0,0 +1,51 @@ +/** + * What the x402 payment checks need that differs from chain to chain. + * + * X402-01 through X402-05 need none of it: they read the challenge only, and + * apply to an x402 service on any chain. X402-06 and X402-07 build a real + * payment, so they need a signer, a way to look the settlement up, and a way + * to forge only the payer's signature. One adapter per chain family supplies + * those, and the checks themselves stay the same everywhere. + */ + +import type { x402Client } from "@x402/fetch"; + +import type { ExpectedSettlement, SettlementVerdict } from "../../settlement.js"; + +export interface PaymentChain { + /** The chain family's name, for reports. */ + readonly name: string; + /** The CAIP-2 networks Wasit pays on with this adapter. */ + readonly networks: readonly string[]; + /** The environment variable front ends read the payer key from. */ + readonly payerKeyEnv: string; + /** What a settlement reference is on this chain, for a report ("a ... hash"). */ + readonly referenceKind: string; + /** + * The RPC endpoint X402-06 verifies the settlement on. Throws + * `ConfigurationError` when there is none, before any payment is made. + */ + resolveRpcUrl(network: string, override?: string): string; + /** Registers this chain's `exact` client scheme, signing with `payerKey`. */ + registerPayer(client: x402Client, network: string, payerKey: string): void; + /** The payer's address, which the settlement must come from. */ + payerAddress(payerKey: string): string; + /** Whether `reference` has the shape of a settlement on this chain. */ + isSettlementReference(reference: string): boolean; + /** + * Looks the settlement up and holds it to the advertised terms. Returns a + * verdict about the target; throws when the chain's RPC gives no answer, + * which says nothing about the target. + */ + verifySettlement( + rpcUrl: string, + reference: string, + expected: ExpectedSettlement, + ): Promise; + /** + * The payment payload's inner `payload` with the payer's signature, and + * nothing else, corrupted. Throws `CheckSetupError` when there is no + * signature to corrupt. + */ + corruptPayload(payload: Record): Record; +} diff --git a/packages/core/src/x402/simulator.ts b/packages/core/src/x402/simulator.ts index 3c63bc8..e51dd21 100644 --- a/packages/core/src/x402/simulator.ts +++ b/packages/core/src/x402/simulator.ts @@ -361,12 +361,11 @@ export async function runX402ReadChecks( } import { x402Client, x402HTTPClient } from "@x402/fetch"; -import { createEd25519Signer } from "@x402/stellar"; -import { ExactStellarScheme as ExactStellarClientScheme } from "@x402/stellar/exact/client"; -import { Keypair, xdr } from "@stellar/stellar-sdk"; -import { resolveRpcUrl } from "../mpp/network.js"; -import { verifySettlement } from "../settlement.js"; +import { paymentChainFor, paymentNetworks, type PaymentChain } from "./chains/index.js"; +import { stellarChain } from "./chains/stellar.js"; + +export { corruptAuthSignature } from "./chains/stellar.js"; export interface X402PaymentCheckOptions extends RequestShape { readonly target: string; @@ -386,10 +385,27 @@ class PaymentNotAttemptedError extends Error { } } -function buildX402Client(network: string, payerSecretKey: string) { - const signer = createEd25519Signer(payerSecretKey, network as `${string}:${string}`); - const client = new x402Client() - .register("stellar:*", new ExactStellarClientScheme(signer)) +/** + * The adapter for the run's network. Throws `ConfigurationError` for a + * network the payment checks cannot pay on; preflight calls this before + * anything is sent. + */ +function chainFor(network: string): PaymentChain { + const chain = paymentChainFor(network); + if (chain === undefined) { + throw new ConfigurationError( + `The payment checks pay on ${paymentNetworks().join(", ")}; "${network}" is ` + + `not one of them. X402-01..05 still apply to a challenge on any chain ` + + `(--read-only, MCP readOnly).`, + ); + } + return chain; +} + +function buildX402Client(chain: PaymentChain, network: string, payerSecretKey: string) { + const client = new x402Client(); + chain.registerPayer(client, network, payerSecretKey); + client // A challenge may offer several networks, including both Stellar ones. // The client's default pick is the first it supports, which would sign // for one network with a signer built for another; pay only on the @@ -414,6 +430,7 @@ function describeOffered(accepts: ReadonlyArray<{ readonly network: string }>): */ async function preparePayment(options: X402PaymentCheckOptions) { const { client, httpClient } = buildX402Client( + chainFor(options.network), options.network, options.payerSecretKey, ); @@ -468,9 +485,6 @@ async function preparePayment(options: X402PaymentCheckOptions) { return { httpClient, paymentPayload }; } -/** A Stellar transaction hash: 64 hex characters. */ -const TRANSACTION_HASH = /^[0-9a-f]{64}$/i; - /** * Reads the settlement a paid response reports, before anything is looked up. * @@ -485,6 +499,7 @@ export function readSettlementReference( readonly transaction?: string; readonly errorReason?: string; }, + chain: PaymentChain = stellarChain, ): { readonly reference: string } | { readonly failure: string; readonly hint: string } { let settlement; try { @@ -505,12 +520,12 @@ export function readSettlementReference( // `settlement_pending` means broadcast but unconfirmed; the x402 spec has the // caller reconcile on chain, which is exactly what X402-06 goes on to do. const pending = !settlement.success && settlement.errorReason === "settlement_pending"; - if ((!settlement.success && !pending) || !TRANSACTION_HASH.test(reference)) { + if ((!settlement.success && !pending) || !chain.isSettlementReference(reference)) { return { failure: `Valid payment accepted (HTTP ${status}), but its PAYMENT-RESPONSE ` + `reports ${settlement.success ? "success" : "failure"} with transaction ` + - `${JSON.stringify(reference)}, not a settled Stellar transaction hash.`, + `${JSON.stringify(reference)}, not ${chain.referenceKind}.`, hint: "PAYMENT-RESPONSE must report `success: true` and carry the settlement " + "transaction's hash in `transaction`.", @@ -552,8 +567,11 @@ async function checkSignatureAccepted( ); } - const read = readSettlementReference(paid.status, () => - httpClient.getPaymentSettleResponse((header) => paid.headers.get(header)), + const chain = chainFor(options.network); + const read = readSettlementReference( + paid.status, + () => httpClient.getPaymentSettleResponse((header) => paid.headers.get(header)), + chain, ); if ("failure" in read) return fail(read.failure, read.hint); const reference = read.reference; @@ -569,12 +587,12 @@ async function checkSignatureAccepted( ); } - const rpcUrl = resolveRpcUrl(options.network, options.rpcUrl); - const verdict = await verifySettlement(rpcUrl, reference, { + const rpcUrl = chain.resolveRpcUrl(options.network, options.rpcUrl); + const verdict = await chain.verifySettlement(rpcUrl, reference, { amount, token: accepted.asset, recipient: accepted.payTo, - payer: Keypair.fromSecret(options.payerSecretKey).publicKey(), + payer: chain.payerAddress(options.payerSecretKey), }); return verdict.pass @@ -592,56 +610,6 @@ async function checkSignatureAccepted( ); } -/** - * Corrupts the client's authorization signature, and nothing else. - * - * In the `exact` scheme on Stellar the client signs a Soroban authorization - * entry, not the envelope: the facilitator signs the envelope later. So the - * signature that proves the payer consented is the address credential's - * `signature`, a `Vec<{ public_key, signature }>`. Flipping one byte of it - * leaves a transaction that still decodes, carries the same amount, payer and - * recipient, and fails only signature verification. A target can refuse it - * only by verifying the signature. - * - * Throws {@link CheckSetupError} if the payload carries no such signature, so - * the check reports no verdict rather than one it did not establish. - */ -export function corruptAuthSignature(transaction: string): string { - const envelope = xdr.TransactionEnvelope.fromXDR(transaction, "base64"); - const operations = - envelope.switch().name === "envelopeTypeTx" ? envelope.v1().tx().operations() : []; - - let corrupted = false; - for (const operation of operations) { - if (operation.body().switch().name !== "invokeHostFunction") continue; - for (const entry of operation.body().invokeHostFunctionOp().auth()) { - const credentials = entry.credentials(); - if (credentials.switch().name !== "sorobanCredentialsAddress") continue; - const signature = credentials.address().signature(); - if (signature.switch().name !== "scvVec") continue; - for (const item of signature.vec() ?? []) { - if (item.switch().name !== "scvMap") continue; - for (const field of item.map() ?? []) { - if (field.key().switch().name !== "scvSymbol") continue; - if (field.key().sym().toString() !== "signature") continue; - const bytes = Buffer.from(field.val().bytes()); - bytes[0] = bytes[0]! ^ 0xff; - field.val(xdr.ScVal.scvBytes(bytes)); - corrupted = true; - } - } - } - } - - if (!corrupted) { - throw new CheckSetupError( - "The payment payload carries no authorization-entry signature to " + - "corrupt, so a corrupted signature cannot be tested.", - ); - } - return envelope.toXDR("base64"); -} - /** * X402-07 (negative): a corrupted signature must be rejected. * @@ -662,10 +630,7 @@ async function checkInvalidSignatureRejected( const corrupted = { ...paymentPayload, - payload: { - ...paymentPayload.payload, - transaction: corruptAuthSignature(paymentPayload.payload.transaction as string), - }, + payload: chainFor(options.network).corruptPayload(paymentPayload.payload), }; const paymentHeaders = httpClient.encodePaymentSignatureHeader(corrupted); @@ -714,11 +679,11 @@ export async function runX402PaymentChecks( try { assertHttpUrl(options.target); buildInit(options); - // Payment checks pay on Stellar only, and X402-06 can only verify a - // settlement it can look up. Resolving the RPC endpoint establishes both - // (it rejects any other network) before any money moves, so a run that - // could not finish is stopped here rather than after paying. - resolveRpcUrl(options.network, options.rpcUrl); + // The payment checks pay only where Wasit has an adapter, and X402-06 can + // only verify a settlement it can look up. Both are known before any + // money moves, so a run that could not finish is stopped here rather than + // after paying. + chainFor(options.network).resolveRpcUrl(options.network, options.rpcUrl); } catch (error) { const preflight = errored("PREFLIGHT", "Run Preflight", error); options.onResult?.(preflight); From 282004c487d53707c6971658e2253481b4fdc625 Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Mon, 5 Oct 2026 16:02:31 +0800 Subject: [PATCH 3/6] feat(core): x402 payment checks on Base Sepolia The payment checks paid on Stellar only. An EVM adapter now pays on eip155:84532 with the exact scheme's EIP-3009 method (USDC's transferWithAuthorization): the payer signs off-chain and the facilitator pays the gas, so a payer needs the token and nothing else (scheme_exact_evm.md). X402-06 holds the settlement to the receipt's ERC-20 Transfer log by the Stellar rules: the transaction succeeded, exactly one token transfer, the advertised amount of the advertised token, to payTo, from this run's payer. ERC-721 transfers share the event signature and are not read as token transfers. The receipt is awaited in blocks, as Stellar awaits ledgers: missing after 30 blocks, no verdict if the RPC stops advancing. X402-07 flips the first byte of the EIP-3009 signature, leaving the authorization intact, so only signature verification can refuse it. Front ends read the payer key by network: STELLAR_PRIVATE_KEY on Stellar, EVM_PRIVATE_KEY on Base Sepolia. A key for the wrong chain, or a malformed one, now stops the run at preflight, before anything is sent; the message never echoes the key. The default RPC is viem's Base Sepolia endpoint, https://sepolia.base.org, overridable with --rpc-url. Offline only so far: 19 new tests (199 core), 15 mutants killed. The Stellar fixture is still 7/7. A live Base Sepolia run, and the docs, come next. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/index.ts | 18 +- packages/core/src/index.ts | 5 + packages/core/src/x402/chains/evm.ts | 317 ++++++++++++++++++ packages/core/src/x402/chains/index.ts | 4 +- packages/core/src/x402/simulator.ts | 15 +- packages/core/test/unit/evm-chain.test.ts | 201 +++++++++++ packages/core/test/unit/x402-networks.test.ts | 26 +- packages/server/src/index.ts | 20 +- 8 files changed, 589 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/x402/chains/evm.ts create mode 100644 packages/core/test/unit/evm-chain.test.ts diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 72741ea..7762e48 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -18,6 +18,7 @@ import { PROTOCOL_IDS, checkStatus, fixFor, + paymentChainFor, runMppChannelSuite, runMppChargeSuite, runX402PaymentChecks, @@ -200,14 +201,17 @@ program .requiredOption("--target ", "URL of the service to test") .option( "--network ", - "Network the payment checks pay on: stellar:testnet or stellar:pubnet. " + - "X402-01..05 apply to a challenge on any chain.", + "Network the payment checks pay on: stellar:testnet, stellar:pubnet or " + + "eip155:84532 (Base Sepolia). X402-01..05 apply to a challenge on any chain.", "stellar:testnet", ) - .option("--rpc-url ", "Override the Soroban RPC endpoint used to verify X402-06's settlement") + .option( + "--rpc-url ", + "Override the RPC endpoint used to verify X402-06's settlement (Soroban RPC on Stellar, JSON-RPC on EVM)", + ) .option( "--payer-key ", - "Testnet payer secret key (overrides STELLAR_PRIVATE_KEY from .env)", + "Testnet payer key (overrides STELLAR_PRIVATE_KEY, or EVM_PRIVATE_KEY on eip155 networks, from .env)", ) .option( "--method ", @@ -244,14 +248,16 @@ signature. See docs/CHECKS.md for what each check ID verifies.`, }; const results = await runX402ReadChecks({ target: opts.target, ...shape }); - const payerKey: string | undefined = opts.payerKey ?? process.env.STELLAR_PRIVATE_KEY; + // Each chain reads its own payer key: a Stellar secret is no use on EVM. + const keyEnv = paymentChainFor(opts.network as string)?.payerKeyEnv ?? "STELLAR_PRIVATE_KEY"; + const payerKey: string | undefined = opts.payerKey ?? process.env[keyEnv]; if (opts.readOnly) { note(jsonMode, "(--read-only set: skipping payment checks)\n"); } else if (!payerKey) { note( jsonMode, - "(no payer key: set STELLAR_PRIVATE_KEY in .env or pass --payer-key — skipping payment checks)\n", + `(no payer key: set ${keyEnv} in .env or pass --payer-key — skipping payment checks)\n`, ); } else { note( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 66b040c..7678562 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -49,3 +49,8 @@ export type { GeneratedWallet, WalletStatus, } from "./wallet.js"; + +// Which networks the x402 payment checks pay on, and which payer key each +// reads: front ends pick the key by the run's network. +export { paymentChainFor, paymentNetworks } from "./x402/chains/index.js"; +export type { PaymentChain } from "./x402/chains/index.js"; diff --git a/packages/core/src/x402/chains/evm.ts b/packages/core/src/x402/chains/evm.ts new file mode 100644 index 0000000..f36491d --- /dev/null +++ b/packages/core/src/x402/chains/evm.ts @@ -0,0 +1,317 @@ +/** + * The `exact` scheme on EVM, with the EIP-3009 transfer method (USDC's + * `transferWithAuthorization`): the payer signs an authorization off-chain, + * the facilitator submits it and pays the gas (`scheme_exact_evm.md`). So a + * payer needs the token and nothing else. + * + * Settlement is read from the transaction receipt: the token contract's own + * ERC-20 `Transfer` log is held to the advertised terms, as the Stellar + * adapter holds the `transfer` event. Only Base Sepolia for now: + * the official SDK ships its USDC and the public facilitator settles there. + */ + +import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { + TransactionReceiptNotFoundError, + createPublicClient, + http, + isAddressEqual, + toEventSelector, + type Chain, + type Hex, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; + +import { CheckSetupError, ConfigurationError } from "../../errors.js"; +import type { ExpectedSettlement, SettlementVerdict } from "../../settlement.js"; +import type { PaymentChain } from "./types.js"; + +/** The networks this adapter pays on, with viem's chain definition for each. */ +const NETWORKS: Readonly> = { + "eip155:84532": baseSepolia, +}; + +/** An EVM transaction hash: 0x and 32 bytes of hex. */ +const TRANSACTION_HASH = /^0x[0-9a-fA-F]{64}$/; + +/** A raw secp256k1 private key: 0x and 32 bytes of hex. */ +const PRIVATE_KEY = /^0x[0-9a-fA-F]{64}$/; + +/** `Transfer(address indexed from, address indexed to, uint256 value)`. */ +const TRANSFER_TOPIC = toEventSelector("Transfer(address,address,uint256)"); + +/** + * How long to look for the settled transaction, in blocks, as the Stellar + * adapter counts ledgers. Base Sepolia makes a block every 2 seconds, so 30 + * blocks is about a minute: long enough for any facilitator that reports a + * hash before its receipt, short enough that a fabricated hash fails soon. + */ +const BLOCKS_BEFORE_MISSING = 30n; +const MAX_WAIT_MS = 120_000; +const POLL_DELAY_MS = 1_000; + +function payerAccount(payerKey: string) { + // The message never echoes the key: it is a secret, and a malformed one is + // often a real key pasted with a character missing. + if (!PRIVATE_KEY.test(payerKey)) { + throw new ConfigurationError( + "EVM_PRIVATE_KEY is not an EVM private key: expected 0x followed by 64 hex characters.", + ); + } + return privateKeyToAccount(payerKey as Hex); +} + +/** One ERC-20 `Transfer`, as the token contract logged it. */ +export interface EvmTransfer { + readonly token: string; + readonly from: string; + readonly to: string; + readonly value: bigint; +} + +/** A log as RPC returns it, reduced to what a transfer is read from. */ +export interface EvmLog { + readonly address: string; + readonly topics: readonly string[]; + readonly data: string; +} + +/** The last 20 bytes of a 32-byte topic: an indexed address. */ +function topicAddress(topic: string): string { + return `0x${topic.slice(-40)}`; +} + +/** + * The ERC-20 `Transfer` logs in a receipt. ERC-721 shares the event + * signature but indexes the token id as a fourth topic, so only logs with + * exactly three topics and a 32-byte value are read as token transfers. + */ +export function evmTransfers(logs: readonly EvmLog[]): EvmTransfer[] { + return logs + .filter( + (log) => + log.topics.length === 3 && + log.topics[0]?.toLowerCase() === TRANSFER_TOPIC && + /^0x[0-9a-fA-F]{64}$/.test(log.data), + ) + .map((log) => ({ + token: log.address, + from: topicAddress(log.topics[1]!), + to: topicAddress(log.topics[2]!), + value: BigInt(log.data), + })); +} + +/** + * Holds a settled transaction's receipt to what the target advertised. + * + * The same rules as on Stellar: the transaction succeeded, it moved exactly + * one token transfer, and that transfer is the advertised amount of the + * advertised token, to the advertised recipient, from this run's own payer, + * so a target cannot cite some other transaction that happens to match. + */ +export function verifyEvmReceipt( + receipt: { readonly status: string; readonly logs: readonly EvmLog[] }, + reference: string, + expected: ExpectedSettlement, +): SettlementVerdict { + const fail = (detail: string): SettlementVerdict => ({ pass: false, detail }); + + if (receipt.status !== "success") { + return fail( + `Transaction ${reference} exists on-chain but reverted. The target reported ` + + `success for a settlement that did not settle.`, + ); + } + + const transfers = evmTransfers(receipt.logs); + if (transfers.length === 0) { + return fail( + `Transaction ${reference} succeeded but logged no ERC-20 Transfer, so no ` + + `token movement can be verified.`, + ); + } + if (transfers.length !== 1) { + return fail( + `Transaction ${reference} logged ${transfers.length} ERC-20 Transfers; ` + + `exactly one was expected. Additional balance changes mean the settlement ` + + `moved more than what was advertised.`, + ); + } + + const actual = transfers[0]!; + const same = (a: string, b: string) => { + try { + return isAddressEqual(a as Hex, b as Hex); + } catch { + return false; + } + }; + const mismatches: string[] = []; + if (actual.value !== expected.amount) { + mismatches.push(`amount: advertised ${expected.amount} base units, moved ${actual.value}`); + } + if (!same(actual.to, expected.recipient)) { + mismatches.push(`recipient: advertised ${expected.recipient}, paid ${actual.to}`); + } + if (!same(actual.token, expected.token)) { + mismatches.push(`token: advertised ${expected.token}, moved ${actual.token}`); + } + if (!same(actual.from, expected.payer)) { + mismatches.push( + `payer: this run paid from ${expected.payer}, but the referenced transfer ` + + `came from ${actual.from}`, + ); + } + if (mismatches.length > 0) { + return fail( + `Settlement does not match what the target advertised — ` + + `${mismatches.join("; ")} (tx ${reference}).`, + ); + } + return { + pass: true, + detail: + `settled on-chain for exactly the advertised ${expected.amount} base ` + + `units of ${expected.token} to ${expected.recipient}, verified from the ` + + `Transfer log (tx ${reference})`, + }; +} + +/** What looking for a settled transaction on an EVM RPC established. */ +export type ReceiptLookup = + | { readonly kind: "found"; readonly receipt: R } + | { readonly kind: "missing"; readonly blocksWatched: bigint } + | { readonly kind: "stalled"; readonly blocksWatched: bigint; readonly waitedMs: number }; + +/** + * Polls for a receipt until it appears, until the chain has produced enough + * blocks without it to call it missing, or until the RPC stops advancing. + * + * `getReceipt` returns undefined while the transaction is unknown; any other + * failure is the harness's and propagates. Clock and sleep are parameters so + * the three outcomes can be exercised without a network. + */ +export async function waitForReceipt( + getReceipt: () => Promise, + getBlockNumber: () => Promise, + options: { + readonly blocks?: bigint; + readonly maxWaitMs?: number; + readonly delayMs?: number; + readonly now?: () => number; + readonly sleep?: (ms: number) => Promise; + } = {}, +): Promise> { + const blocks = options.blocks ?? BLOCKS_BEFORE_MISSING; + const maxWaitMs = options.maxWaitMs ?? MAX_WAIT_MS; + const delayMs = options.delayMs ?? POLL_DELAY_MS; + const now = options.now ?? Date.now; + const pause = options.sleep ?? ((ms: number) => new Promise((done) => setTimeout(done, ms))); + + const startedAt = now(); + let firstBlock: bigint | undefined; + + for (;;) { + const receipt = await getReceipt(); + if (receipt !== undefined) return { kind: "found", receipt }; + + const block = await getBlockNumber(); + firstBlock ??= block; + const blocksWatched = block - firstBlock; + if (blocksWatched >= blocks) return { kind: "missing", blocksWatched }; + + const waitedMs = now() - startedAt; + if (waitedMs >= maxWaitMs) return { kind: "stalled", blocksWatched, waitedMs }; + + await pause(delayMs); + } +} + +async function verifyEvmSettlement( + rpcUrl: string, + reference: string, + expected: ExpectedSettlement, +): Promise { + const client = createPublicClient({ transport: http(rpcUrl) }); + const lookup = await waitForReceipt( + async () => { + try { + return await client.getTransactionReceipt({ hash: reference as Hex }); + } catch (error) { + if (error instanceof TransactionReceiptNotFoundError) return undefined; + throw error; + } + }, + () => client.getBlockNumber(), + ); + + if (lookup.kind === "stalled") { + // An RPC that is not advancing says nothing about the target: no verdict. + throw new Error( + `RPC at ${rpcUrl} produced only ${lookup.blocksWatched} blocks in ` + + `${Math.round(lookup.waitedMs / 1000)} seconds while looking for tx ` + + `${reference}, so its absence says nothing about the target. Re-run, ` + + `or point --rpc-url at another endpoint.`, + ); + } + if (lookup.kind === "missing") { + return { + pass: false, + detail: + `Target reported settlement as tx ${reference}, but the chain produced ` + + `${lookup.blocksWatched} more blocks without it. Either it was never ` + + `broadcast, or the target referenced a transaction that does not exist.`, + }; + } + return verifyEvmReceipt(lookup.receipt, reference, expected); +} + +/** + * Corrupts the payer's signature, and nothing else. + * + * The EIP-3009 payload is `{ signature, authorization }`; Permit2's carries a + * `signature` too. Flipping the first byte of `r` leaves a signature of the + * same length whose recovered signer is no longer `authorization.from`, while + * the amount, recipient and validity window stay intact. A target can refuse + * it only by verifying the signature (spec, verification step 1). + */ +export function corruptEvmSignature(payload: Record): Record { + const signature = payload["signature"]; + // 65 bytes for an EOA; a contract wallet's signature is longer, never shorter. + if (typeof signature !== "string" || !/^0x(?:[0-9a-fA-F]{2}){65,}$/.test(signature)) { + throw new CheckSetupError( + "The payment payload carries no EVM signature to corrupt, so a corrupted " + + "signature cannot be tested.", + ); + } + const first = (Number.parseInt(signature.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0"); + return { ...payload, signature: `0x${first}${signature.slice(4)}` }; +} + +export const evmChain: PaymentChain = { + name: "EVM", + networks: Object.keys(NETWORKS), + payerKeyEnv: "EVM_PRIVATE_KEY", + referenceKind: "an EVM transaction hash", + resolveRpcUrl(network, override) { + const chain = NETWORKS[network]; + if (chain === undefined) { + throw new ConfigurationError(`No EVM network "${network}" in Wasit's payment checks.`); + } + if (override) return override; + const fallback = chain.rpcUrls.default.http[0]; + if (fallback === undefined) { + throw new ConfigurationError(`No default RPC endpoint for ${network}. Pass an explicit rpcUrl.`); + } + return fallback; + }, + registerPayer(client, _network, payerKey) { + client.register("eip155:*", new ExactEvmScheme(payerAccount(payerKey))); + }, + payerAddress: (payerKey) => payerAccount(payerKey).address, + isSettlementReference: (reference) => TRANSACTION_HASH.test(reference), + verifySettlement: verifyEvmSettlement, + corruptPayload: corruptEvmSignature, +}; diff --git a/packages/core/src/x402/chains/index.ts b/packages/core/src/x402/chains/index.ts index d5d0be5..ba23811 100644 --- a/packages/core/src/x402/chains/index.ts +++ b/packages/core/src/x402/chains/index.ts @@ -3,12 +3,14 @@ * adapter here; the checks do not change. */ +import { evmChain } from "./evm.js"; import { stellarChain } from "./stellar.js"; import type { PaymentChain } from "./types.js"; export type { PaymentChain } from "./types.js"; -const CHAINS: readonly PaymentChain[] = [stellarChain]; +// Stellar first: it is the default network, and the only chain with MPP. +const CHAINS: readonly PaymentChain[] = [stellarChain, evmChain]; /** The adapter that pays on `network`, if Wasit has one. */ export function paymentChainFor(network: string): PaymentChain | undefined { diff --git a/packages/core/src/x402/simulator.ts b/packages/core/src/x402/simulator.ts index e51dd21..364e630 100644 --- a/packages/core/src/x402/simulator.ts +++ b/packages/core/src/x402/simulator.ts @@ -683,7 +683,20 @@ export async function runX402PaymentChecks( // only verify a settlement it can look up. Both are known before any // money moves, so a run that could not finish is stopped here rather than // after paying. - chainFor(options.network).resolveRpcUrl(options.network, options.rpcUrl); + const chain = chainFor(options.network); + chain.resolveRpcUrl(options.network, options.rpcUrl); + // A key for another chain, or a malformed one, would otherwise surface + // mid-payment as a harness error. Checked here, it is what it is: the + // run's configuration. The message never echoes the key. + try { + chain.payerAddress(options.payerSecretKey); + } catch (error) { + if (error instanceof ConfigurationError) throw error; + throw new ConfigurationError( + `The payer key is not a valid ${chain.name} key (read from ${chain.payerKeyEnv} ` + + `or --payer-key).`, + ); + } } catch (error) { const preflight = errored("PREFLIGHT", "Run Preflight", error); options.onResult?.(preflight); diff --git a/packages/core/test/unit/evm-chain.test.ts b/packages/core/test/unit/evm-chain.test.ts new file mode 100644 index 0000000..b4e32a0 --- /dev/null +++ b/packages/core/test/unit/evm-chain.test.ts @@ -0,0 +1,201 @@ +/** + * The EVM adapter: X402-06 and X402-07 on Base Sepolia, offline. + * + * Settlement is held to the token contract's ERC-20 Transfer log by the same + * rules the Stellar adapter applies to the transfer event; X402-07 forges + * only the EIP-3009 signature. Spec: `scheme_exact_evm.md` (DECISIONS.md, + * Verified facts, 2026-10-05). Receipts here are built, not fetched. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { encodeAbiParameters, pad, toEventSelector, type Hex } from "viem"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; + +import { CheckSetupError, ConfigurationError } from "../../src/errors.js"; +import { + corruptEvmSignature, + evmChain, + evmTransfers, + verifyEvmReceipt, + waitForReceipt, + type EvmLog, +} from "../../src/x402/chains/evm.js"; +import { paymentChainFor } from "../../src/x402/chains/index.js"; +import { readSettlementReference } from "../../src/x402/simulator.js"; + +const USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; +const PAYER = "0x857b06519E91e3A54538791bDbb0E22373e36b66"; +const PAY_TO = "0x209693Bc6afc0C5328bA36FaF03C514EF312287C"; +const OTHER = "0x1111111111111111111111111111111111111111"; +const TX = `0x${"ab".repeat(32)}`; +const TRANSFER = toEventSelector("Transfer(address,address,uint256)"); + +function transferLog(token: string, from: string, to: string, value: bigint): EvmLog { + return { + address: token, + topics: [TRANSFER, pad(from as Hex, { size: 32 }), pad(to as Hex, { size: 32 })], + data: encodeAbiParameters([{ type: "uint256" }], [value]), + }; +} + +const EXPECTED = { amount: 10000n, token: USDC, recipient: PAY_TO, payer: PAYER }; + +describe("evmTransfers", () => { + it("reads token, from, to and value from a Transfer log", () => { + const [transfer] = evmTransfers([transferLog(USDC, PAYER, PAY_TO, 10000n)]); + assert.equal(transfer?.token, USDC); + assert.equal(transfer?.from.toLowerCase(), PAYER.toLowerCase()); + assert.equal(transfer?.to.toLowerCase(), PAY_TO.toLowerCase()); + assert.equal(transfer?.value, 10000n); + }); + + it("ignores other events and ERC-721 transfers", () => { + const erc721 = { ...transferLog(USDC, PAYER, PAY_TO, 1n), topics: [TRANSFER, pad(PAYER), pad(PAY_TO), pad("0x01")] }; + const approval = { ...transferLog(USDC, PAYER, PAY_TO, 1n), topics: [toEventSelector("Approval(address,address,uint256)"), pad(PAYER), pad(PAY_TO)] }; + assert.deepEqual(evmTransfers([erc721, approval]), []); + }); +}); + +describe("verifyEvmReceipt", () => { + const receipt = (logs: EvmLog[], status = "success") => ({ status, logs }); + + it("passes the advertised transfer, comparing addresses case-insensitively", () => { + const verdict = verifyEvmReceipt( + receipt([transferLog(USDC.toLowerCase(), PAYER, PAY_TO.toLowerCase(), 10000n)]), + TX, + EXPECTED, + ); + assert.equal(verdict.pass, true); + assert.match(verdict.detail, /exactly the advertised 10000 base units .* verified from the Transfer log/); + }); + + it("fails a reverted transaction", () => { + const verdict = verifyEvmReceipt(receipt([transferLog(USDC, PAYER, PAY_TO, 10000n)], "reverted"), TX, EXPECTED); + assert.equal(verdict.pass, false); + assert.match(verdict.detail, /reverted/); + }); + + it("fails a transaction that moved no token, or more than one transfer", () => { + assert.match(verifyEvmReceipt(receipt([]), TX, EXPECTED).detail, /no ERC-20 Transfer/); + const two = verifyEvmReceipt( + receipt([transferLog(USDC, PAYER, PAY_TO, 10000n), transferLog(USDC, PAYER, OTHER, 1n)]), + TX, + EXPECTED, + ); + assert.match(two.detail, /2 ERC-20 Transfers; exactly one was expected/); + }); + + it("names every way the transfer misses the terms", () => { + const verdict = verifyEvmReceipt(receipt([transferLog(OTHER, OTHER, OTHER, 1n)]), TX, EXPECTED); + assert.equal(verdict.pass, false); + for (const part of ["amount: advertised 10000 base units, moved 1", "recipient:", "token:", "payer:"]) { + assert.ok(verdict.detail.includes(part), part); + } + }); + + // Without the payer rule, a target could cite any earlier transfer that + // happens to match amount, token and recipient, and take nothing. + it("fails a matching transfer that came from someone else", () => { + const verdict = verifyEvmReceipt(receipt([transferLog(USDC, OTHER, PAY_TO, 10000n)]), TX, EXPECTED); + assert.equal(verdict.pass, false); + assert.match(verdict.detail, /^Settlement does not match .*payer: this run paid from/); + assert.equal(verdict.detail.includes("amount:"), false); + }); +}); + +describe("waitForReceipt", () => { + const clock = () => { + let t = 0; + return { now: () => t, sleep: async (ms: number) => void (t += ms) }; + }; + + it("returns the receipt once it appears", async () => { + let calls = 0; + const lookup = await waitForReceipt( + async () => (++calls >= 3 ? "receipt" : undefined), + async () => 100n, + clock(), + ); + assert.deepEqual(lookup, { kind: "found", receipt: "receipt" }); + }); + + it("calls a transaction missing once enough blocks pass without it", async () => { + let block = 100n; + const lookup = await waitForReceipt(async () => undefined, async () => (block += 5n), { + blocks: 30n, + ...clock(), + }); + assert.equal(lookup.kind, "missing"); + }); + + it("gives no verdict when the RPC stops advancing", async () => { + const lookup = await waitForReceipt(async () => undefined, async () => 100n, { + maxWaitMs: 10_000, + ...clock(), + }); + assert.equal(lookup.kind, "stalled"); + }); + + it("lets an RPC failure through, as a harness problem", async () => { + await assert.rejects( + waitForReceipt(async () => { + throw new Error("rpc down"); + }, async () => 1n), + /rpc down/, + ); + }); +}); + +describe("corruptEvmSignature", () => { + const signature = `0x2d6a7588d6acca505cbf0d9a4a227e0c52c6c34008c8e8986a1283259764173608a2ce6496642e377d6da8dbbf5836e9bd15092f9ecab05ded3d6293af148b571c`; + const authorization = { from: PAYER, to: PAY_TO, value: "10000" }; + + it("flips the first byte of the signature and nothing else", () => { + const corrupted = corruptEvmSignature({ signature, authorization }); + const forged = corrupted["signature"] as string; + assert.equal(forged.length, signature.length); + assert.equal(forged.slice(0, 4), "0xd2"); + assert.equal(forged.slice(4), signature.slice(4)); + assert.deepEqual(corrupted["authorization"], authorization); + }); + + it("reports no verdict when there is no signature to corrupt", () => { + assert.throws(() => corruptEvmSignature({ authorization }), CheckSetupError); + assert.throws(() => corruptEvmSignature({ signature: "0x1234" }), CheckSetupError); + }); +}); + +describe("the EVM adapter", () => { + it("pays on Base Sepolia, and Stellar still pays on Stellar", () => { + assert.equal(paymentChainFor("eip155:84532")?.name, "EVM"); + assert.equal(paymentChainFor("stellar:testnet")?.name, "Stellar"); + assert.equal(paymentChainFor("eip155:11155111"), undefined); + }); + + it("defaults to the chain's public RPC and honours an override", () => { + assert.equal(evmChain.resolveRpcUrl("eip155:84532"), "https://sepolia.base.org"); + assert.equal(evmChain.resolveRpcUrl("eip155:84532", "https://rpc.internal"), "https://rpc.internal"); + }); + + it("derives the payer address, and rejects a malformed key without echoing it", () => { + const key = generatePrivateKey(); + assert.equal(evmChain.payerAddress(key), privateKeyToAccount(key).address); + const bad = key.slice(0, -1); + assert.throws( + () => evmChain.payerAddress(bad), + (error: unknown) => error instanceof ConfigurationError && !String((error as Error).message).includes(bad), + ); + }); + + it("accepts an EVM transaction hash as a settlement reference, and only that", () => { + assert.deepEqual( + readSettlementReference(200, () => ({ success: true, transaction: TX }), evmChain), + { reference: TX }, + ); + const stellarShaped = readSettlementReference(200, () => ({ success: true, transaction: "ab".repeat(32) }), evmChain); + assert.ok("failure" in stellarShaped); + assert.match("failure" in stellarShaped ? stellarShaped.failure : "", /not an EVM transaction hash/); + }); +}); diff --git a/packages/core/test/unit/x402-networks.test.ts b/packages/core/test/unit/x402-networks.test.ts index c4b8f2a..b423460 100644 --- a/packages/core/test/unit/x402-networks.test.ts +++ b/packages/core/test/unit/x402-networks.test.ts @@ -277,11 +277,12 @@ describe("payment checks on a challenge Wasit cannot pay", () => { } it("stops before paying when asked to pay on a network it cannot pay on", async () => { - const target = await serveV2(v2(option(BASE_SEPOLIA))); + // Ethereum Sepolia: read-only only. + const target = await serveV2(v2(option("eip155:11155111"))); try { const results = await runX402PaymentChecks({ target: target.url, - network: BASE_SEPOLIA, + network: "eip155:11155111", payerSecretKey, }); assert.equal(results.length, 1); @@ -293,6 +294,27 @@ describe("payment checks on a challenge Wasit cannot pay", () => { } }); + // A Stellar secret on an EVM network, or any malformed key, is the run's + // configuration, caught before anything is sent. + for (const [label, network, key] of [ + ["a Stellar secret on Base Sepolia", BASE_SEPOLIA, Keypair.random().secret()], + ["a malformed Stellar secret", "stellar:testnet", "SNOTAREALKEY"], + ] as const) { + it(`stops before paying with ${label}`, async () => { + const target = await serveV2(v2(option(network))); + try { + const results = await runX402PaymentChecks({ target: target.url, network, payerSecretKey: key }); + assert.equal(results.length, 1); + assert.equal(results[0]?.id, "PREFLIGHT"); + assert.equal(results[0]?.error?.kind, "configuration"); + assert.equal(results[0]?.detail.includes(key), false); + assert.equal(target.paymentHeadersSeen(), 0); + } finally { + await target.close(); + } + }); + } + // pubnet has no default RPC endpoint. Without one X402-06 could // not verify the settlement, so the payment must not be made at all. it("stops before paying on pubnet without an RPC endpoint", async () => { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 235edcf..03747a0 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -23,6 +23,7 @@ import { SERVER_VERSION } from "./version.js"; import { checkStatus, fixFor, + paymentChainFor, runMppChannelSuite, runMppChargeSuite, runX402PaymentChecks, @@ -170,8 +171,9 @@ server.registerTool( title: "Test x402 compliance", description: "Runs the x402 conformance checks (X402-01..07) against a running service. " + - "Payment checks are included only when STELLAR_PRIVATE_KEY is set in this " + - "server's environment; otherwise they are skipped. When they do run, " + "Payment checks are included only when the network's payer key is set in " + + "this server's environment (STELLAR_PRIVATE_KEY on Stellar, EVM_PRIVATE_KEY " + + "on Base Sepolia); otherwise they are skipped. When they do run, " + "X402-06 settles a real payment and X402-07 attempts one, so each call " + "spends testnet funds and repeated calls spend repeatedly. Testnet only.", inputSchema: { @@ -180,8 +182,9 @@ server.registerTool( .string() .optional() .describe( - 'Network the payment checks pay on: "stellar:testnet" (default) or ' + - '"stellar:pubnet". The read-only checks apply to a challenge on any chain.', + 'Network the payment checks pay on: "stellar:testnet" (default), ' + + '"stellar:pubnet" or "eip155:84532" (Base Sepolia). The read-only ' + + "checks apply to a challenge on any chain.", ), readOnly: z .boolean() @@ -190,7 +193,9 @@ server.registerTool( rpcUrl: z .string() .optional() - .describe("Override the Soroban RPC endpoint used to verify X402-06's settlement"), + .describe( + "Override the RPC endpoint used to verify X402-06's settlement (Soroban RPC on Stellar, JSON-RPC on EVM)", + ), method: z .string() .optional() @@ -231,13 +236,14 @@ server.registerTool( ...(headers !== undefined ? { headers } : {}), }; const results = await runX402ReadChecks({ target, ...shape }); - const payerKey = process.env.STELLAR_PRIVATE_KEY; + const payNetwork = network ?? "stellar:testnet"; + const payerKey = process.env[paymentChainFor(payNetwork)?.payerKeyEnv ?? "STELLAR_PRIVATE_KEY"]; if (readOnly !== true && payerKey) { results.push( ...(await runX402PaymentChecks({ target, - network: network ?? "stellar:testnet", + network: payNetwork, payerSecretKey: payerKey, ...(rpcUrl !== undefined ? { rpcUrl } : {}), ...shape, From fb21760bff0fe6533449684814a6b16523e8b25b Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Mon, 5 Oct 2026 16:07:18 +0800 Subject: [PATCH 4/6] feat(cli): wasit serve on Base Sepolia wasit serve posed only as a Stellar paywall. --network eip155:84532 now poses the same four modes on Base Sepolia: the SDK's default Base Sepolia USDC (6 decimals) with its EIP-712 domain ("USDC", "2") in extra, wrong-network asking for Base mainnet (eip155:8453), overprice asking for one million USDC in 6 decimals, EVM-shaped payees and settlement hashes. The payee defaults to EVM_PAYEE_ADDRESS there, STELLAR_PAYEE_ADDRESS on Stellar. Network differences live in one profile per network; the modes are unchanged. On Stellar the only visible change is that wrong-network now names "Stellar mainnet". Run with wasit test --network eip155:84532 and a throwaway, unfunded key (EIP-3009 signing is off-chain): no-settle fails X402-06 and X402-07, wrong-network gets the payment checks skipped, and wrong-settlement fails X402-06 once the real Base Sepolia RPC has produced 32 blocks without the cited transaction. Nothing settled. 9 new tests (cli 54). Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/index.ts | 22 ++++- packages/cli/src/serve.ts | 131 +++++++++++++++++++++------ packages/cli/test/unit/serve.test.ts | 74 ++++++++++++++- 3 files changed, 191 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7762e48..c84c9ca 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,8 +7,10 @@ import { App } from "./dashboard/App.js"; import { CLI_VERSION } from "./version.js"; import { registerWalletCommand } from "./wallet-command.js"; import { + DEFAULT_SERVE_NETWORK, MODE_DESCRIPTIONS, SERVE_MODES, + SERVE_NETWORKS, createServeServer, validateServeOptions, type ServeMode, @@ -283,14 +285,20 @@ program .command("serve") .description("Run a local x402 paywall that misbehaves on purpose, to test an agent that pays") .requiredOption("--mode ", `How the paywall misbehaves: ${SERVE_MODES.join(", ")}`) + .option( + "--network ", + `Testnet to pose on: ${Object.keys(SERVE_NETWORKS).join(" or ")}`, + DEFAULT_SERVE_NETWORK, + ) .option("--port ", "Port to listen on", "4020") .option("--host ", "Interface to bind (local only by default)", "127.0.0.1") .option( "--pay-to
", - "Payee account (G...) with a USDC trustline on testnet (overrides STELLAR_PAYEE_ADDRESS)", + "Payee: a Stellar account (G...) with a USDC trustline, or an EVM address (0x...) " + + "on Base Sepolia (overrides STELLAR_PAYEE_ADDRESS or EVM_PAYEE_ADDRESS)", ) .option("--amount ", "Price in base units for every mode except overprice", "10000") - .option("--asset ", "Token contract (default: testnet USDC)") + .option("--asset ", "Token contract (default: the network's testnet USDC)") .option( "--settlement-tx ", "wrong-settlement: transaction to cite (default: a random hash that exists nowhere)", @@ -309,12 +317,16 @@ simulate the transfer to --pay-to before signing. Examples: $ wasit serve --mode no-settle $ wasit serve --mode wrong-settlement --settlement-tx - $ wasit serve --mode overprice --port 4021`, + $ wasit serve --mode overprice --port 4021 + $ wasit serve --mode no-settle --network eip155:84532 # Base Sepolia`, ) .action((opts) => { + const network = opts.network as string; + const payeeEnv = SERVE_NETWORKS[network]?.payeeEnv ?? "STELLAR_PAYEE_ADDRESS"; const options = { mode: opts.mode as ServeMode, - payTo: (opts.payTo as string | undefined) ?? process.env.STELLAR_PAYEE_ADDRESS ?? "", + network, + payTo: (opts.payTo as string | undefined) ?? process.env[payeeEnv] ?? "", amount: opts.amount as string, ...(opts.asset ? { asset: opts.asset as string } : {}), ...(opts.settlementTx ? { settlementTx: opts.settlementTx as string } : {}), @@ -334,7 +346,7 @@ Examples: }); server.listen(port, opts.host as string, () => { const description = MODE_DESCRIPTIONS[options.mode]; - console.log(`wasit serve: ${options.mode} on http://${opts.host}:${port}/ (any path)`); + console.log(`wasit serve: ${options.mode} on ${network}, http://${opts.host}:${port}/ (any path)`); console.log(` This paywall ${description.does}`); console.log(` An agent that pays carefully ${description.careful}`); console.log(" Nothing is settled or forwarded; no funds move. Ctrl+C to stop.\n"); diff --git a/packages/cli/src/serve.ts b/packages/cli/src/serve.ts index 7a705d0..f0502d1 100644 --- a/packages/cli/src/serve.ts +++ b/packages/cli/src/serve.ts @@ -9,8 +9,9 @@ * Wire formats follow the x402 v2 HTTP transport (`transports-v2/http.md`): * the challenge goes out base64-encoded in `PAYMENT-REQUIRED`, a payment comes * back in `PAYMENT-SIGNATURE`, and a settlement result is reported in - * `PAYMENT-RESPONSE`. The challenge carries `extra.areFeesSponsored`, which - * the `exact` scheme on Stellar requires. + * `PAYMENT-RESPONSE`. Each network's challenge carries the `extra` its + * `exact` scheme requires: `areFeesSponsored` on Stellar, the token's EIP-712 + * `name` and `version` on EVM. */ import { randomBytes } from "node:crypto"; @@ -22,11 +23,71 @@ export type ServeMode = (typeof SERVE_MODES)[number]; /** Testnet USDC, the Stellar Asset Contract Wasit's own fixture charges in. */ export const TESTNET_USDC = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; -/** Every Stellar asset, USDC included, has 7 decimal places. */ -const STELLAR_DECIMALS = 7; +/** Base Sepolia USDC, the official SDK's default asset there. */ +export const BASE_SEPOLIA_USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; -/** One million USDC in base units: no agent should agree to this unasked. */ -export const OVERPRICE_AMOUNT = (1_000_000n * 10n ** BigInt(STELLAR_DECIMALS)).toString(); +/** What changes from one network to another; the modes stay the same. */ +interface ServeNetwork { + /** The mainnet `wrong-network` asks for instead, and its name. */ + readonly mainnet: string; + readonly mainnetName: string; + readonly asset: string; + readonly decimals: number; + readonly extra: Readonly>; + readonly payTo: RegExp; + readonly payToHint: string; + readonly txHash: RegExp; + readonly txHashHint: string; + readonly randomTx: () => string; + readonly payeeEnv: string; +} + +/** + * The networks `wasit serve` can pose as. Stellar: USDC has 7 decimals (every + * Stellar asset does) and `exact` needs `areFeesSponsored`. Base Sepolia: the + * SDK's default USDC, 6 decimals, EIP-712 domain "USDC" version "2". + */ +export const SERVE_NETWORKS: Readonly> = { + "stellar:testnet": { + mainnet: "stellar:pubnet", + mainnetName: "Stellar mainnet", + asset: TESTNET_USDC, + decimals: 7, + extra: { areFeesSponsored: true }, + payTo: /^G[A-Z2-7]{55}$/, + payToHint: + "a Stellar account (G...) with a trustline for the asset on testnet: payment " + + "clients simulate the transfer to it before signing", + txHash: /^[0-9a-f]{64}$/i, + txHashHint: "a Stellar transaction hash, 64 hex characters", + randomTx: () => randomBytes(32).toString("hex"), + payeeEnv: "STELLAR_PAYEE_ADDRESS", + }, + "eip155:84532": { + mainnet: "eip155:8453", + mainnetName: "Base mainnet", + asset: BASE_SEPOLIA_USDC, + decimals: 6, + extra: { name: "USDC", version: "2" }, + payTo: /^0x[0-9a-fA-F]{40}$/, + payToHint: "an EVM address (0x followed by 40 hex characters)", + txHash: /^0x[0-9a-fA-F]{64}$/, + txHashHint: "an EVM transaction hash, 0x followed by 64 hex characters", + randomTx: () => `0x${randomBytes(32).toString("hex")}`, + payeeEnv: "EVM_PAYEE_ADDRESS", + }, +}; + +export const DEFAULT_SERVE_NETWORK = "stellar:testnet"; + +/** One million USDC in a network's base units: no agent should agree to this unasked. */ +export function overpriceAmount(network: string = DEFAULT_SERVE_NETWORK): string { + const decimals = SERVE_NETWORKS[network]?.decimals ?? 7; + return (1_000_000n * 10n ** BigInt(decimals)).toString(); +} + +/** One million Stellar USDC (7 decimals), kept for callers that predate other networks. */ +export const OVERPRICE_AMOUNT = overpriceAmount("stellar:testnet"); const DEFAULT_AMOUNT = "10000"; @@ -41,17 +102,19 @@ export const MODE_DESCRIPTIONS: Readonly void; } -const STELLAR_ACCOUNT = /^G[A-Z2-7]{55}$/; -const TRANSACTION_HASH = /^[0-9a-f]{64}$/i; const BASE_UNITS = /^[1-9][0-9]*$/; +function serveNetwork(options: ServeOptions): ServeNetwork { + return SERVE_NETWORKS[options.network ?? DEFAULT_SERVE_NETWORK]!; +} + /** Rejects options that would make the server lie in a way it did not mean to. */ export function validateServeOptions(options: ServeOptions): string | undefined { if (!SERVE_MODES.includes(options.mode)) { return `Unknown mode "${options.mode}". Expected one of: ${SERVE_MODES.join(", ")}.`; } - if (!STELLAR_ACCOUNT.test(options.payTo)) { - return ( - `--pay-to must be a Stellar account (G...), with a trustline for the asset on ` + - `testnet: payment clients simulate the transfer to it before signing.` - ); + const network = options.network ?? DEFAULT_SERVE_NETWORK; + const profile = SERVE_NETWORKS[network]; + if (profile === undefined) { + return `Unknown network "${network}". Expected one of: ${Object.keys(SERVE_NETWORKS).join(", ")}.`; + } + if (!profile.payTo.test(options.payTo)) { + return `--pay-to must be ${profile.payToHint}.`; } if (options.amount !== undefined && !BASE_UNITS.test(options.amount)) { return `--amount must be a positive whole number of base units, e.g. 10000.`; } - if (options.settlementTx !== undefined && !TRANSACTION_HASH.test(options.settlementTx)) { - return `--settlement-tx must be a transaction hash, 64 hex characters.`; + if (options.settlementTx !== undefined && !profile.txHash.test(options.settlementTx)) { + return `--settlement-tx must be ${profile.txHashHint}.`; } return undefined; } /** The challenge a mode issues, as the x402 v2 `PaymentRequired` object. */ export function challengeFor(options: ServeOptions, resourceUrl: string): Record { + const network = options.network ?? DEFAULT_SERVE_NETWORK; + const profile = serveNetwork(options); return { x402Version: 2, error: "PAYMENT-SIGNATURE header is required", @@ -96,12 +165,12 @@ export function challengeFor(options: ServeOptions, resourceUrl: string): Record accepts: [ { scheme: "exact", - network: options.mode === "wrong-network" ? "stellar:pubnet" : "stellar:testnet", - amount: options.mode === "overprice" ? OVERPRICE_AMOUNT : (options.amount ?? DEFAULT_AMOUNT), - asset: options.asset ?? TESTNET_USDC, + network: options.mode === "wrong-network" ? profile.mainnet : network, + amount: options.mode === "overprice" ? overpriceAmount(network) : (options.amount ?? DEFAULT_AMOUNT), + asset: options.asset ?? profile.asset, payTo: options.payTo, maxTimeoutSeconds: 60, - extra: { areFeesSponsored: true }, + extra: { ...profile.extra }, }, ], }; @@ -146,17 +215,21 @@ function verdictFor(options: ServeOptions, payment: ReceivedPayment, settlementT `this payment. If your agent accepts that without looking it up, a ` + `dishonest server can claim any settlement.` ); - case "wrong-network": + case "wrong-network": { + const profile = serveNetwork(options); return ( - `Your agent paid a challenge on ${payment.network ?? "stellar:pubnet"} ` + - `(mainnet). An agent set up for testnet should have refused it.` + `Your agent paid a challenge on ${payment.network ?? profile.mainnet} ` + + `(${profile.mainnetName}). An agent set up for testnet should have refused it.` ); - case "overprice": + } + case "overprice": { + const profile = serveNetwork(options); return ( - `Your agent agreed to pay ${payment.amount ?? OVERPRICE_AMOUNT} base units` + - `${(options.asset ?? TESTNET_USDC) === TESTNET_USDC ? " (one million USDC)" : ""}. ` + + `Your agent agreed to pay ${payment.amount ?? overpriceAmount(options.network)} base units` + + `${(options.asset ?? profile.asset) === profile.asset ? " (one million USDC)" : ""}. ` + `An agent with a spending limit should have refused.` ); + } } } @@ -167,7 +240,7 @@ function verdictFor(options: ServeOptions, payment: ReceivedPayment, settlementT */ export function createServeServer(options: ServeOptions): http.Server { const log = options.log ?? (() => {}); - const settlementTx = options.settlementTx ?? randomBytes(32).toString("hex"); + const settlementTx = options.settlementTx ?? serveNetwork(options).randomTx(); return http.createServer((request, response) => { const where = `${request.method ?? "GET"} ${request.url ?? "/"}`; @@ -201,7 +274,7 @@ export function createServeServer(options: ServeOptions): http.Server { headers["PAYMENT-RESPONSE"] = encode({ success: true, transaction: settlementTx, - network: payment.network ?? "stellar:testnet", + network: payment.network ?? (options.network ?? DEFAULT_SERVE_NETWORK), }); } log( diff --git a/packages/cli/test/unit/serve.test.ts b/packages/cli/test/unit/serve.test.ts index 8e93fda..6259a66 100644 --- a/packages/cli/test/unit/serve.test.ts +++ b/packages/cli/test/unit/serve.test.ts @@ -14,11 +14,13 @@ import { after, before, describe, it } from "node:test"; import { runX402ReadChecks } from "@wasit-dev/core"; import { + BASE_SEPOLIA_USDC, OVERPRICE_AMOUNT, SERVE_MODES, TESTNET_USDC, challengeFor, createServeServer, + overpriceAmount, validateServeOptions, type ServeMode, } from "../../src/serve.js"; @@ -43,7 +45,7 @@ function paymentHeader(network: string, amount: string): string { async function start( mode: ServeMode, - extra: { settlementTx?: string } = {}, + extra: { settlementTx?: string; network?: string; payTo?: string } = {}, ): Promise<{ url: string; lines: string[]; server: http.Server }> { const lines: string[] = []; const server = createServeServer({ mode, payTo: PAY_TO, log: (line) => lines.push(line), ...extra }); @@ -172,7 +174,7 @@ describe("answers to a payment", () => { const { url, lines, server } = await start("wrong-network"); try { await fetch(url, { headers: { "PAYMENT-SIGNATURE": paymentHeader("stellar:pubnet", "10000") } }); - assert.match(lines.at(-1) ?? "", /paid a challenge on stellar:pubnet \(mainnet\)/); + assert.match(lines.at(-1) ?? "", /paid a challenge on stellar:pubnet \(Stellar mainnet\)/); } finally { await stop(server); } @@ -229,3 +231,71 @@ describe("the challenge names the URL the agent asked for", () => { assert.match(String((challenge["resource"] as { url: string }).url), /\/paid\?q=1$/); }); }); + +const EVM_PAY_TO = "0x209693Bc6afc0C5328bA36FaF03C514EF312287C"; +const BASE = { network: "eip155:84532", payTo: EVM_PAY_TO }; + +describe("on Base Sepolia", () => { + function accept(mode: ServeMode): Record { + return (challengeFor({ mode, ...BASE }, "http://x/paid")["accepts"] as Array>)[0]!; + } + + it("poses with the SDK's Base Sepolia USDC and its EIP-712 domain", () => { + const option = accept("no-settle"); + assert.equal(option["network"], "eip155:84532"); + assert.equal(option["asset"], BASE_SEPOLIA_USDC); + assert.deepEqual(option["extra"], { name: "USDC", version: "2" }); + }); + + it("asks for Base mainnet in wrong-network, and one million 6-decimal USDC in overprice", () => { + assert.equal(accept("wrong-network")["network"], "eip155:8453"); + assert.equal(accept("overprice")["amount"], "1000000000000"); + assert.equal(overpriceAmount("eip155:84532"), "1000000000000"); + }); + + it("validates the payee and the cited hash in EVM form", () => { + assert.equal(validateServeOptions({ mode: "no-settle", ...BASE }), undefined); + assert.match(validateServeOptions({ mode: "no-settle", ...BASE, payTo: PAY_TO }) ?? "", /EVM address/); + assert.match( + validateServeOptions({ mode: "wrong-settlement", ...BASE, settlementTx: CITED_TX }) ?? "", + /EVM transaction hash/, + ); + assert.match(validateServeOptions({ mode: "no-settle", network: "eip155:1", payTo: EVM_PAY_TO }) ?? "", /Unknown network/); + }); + + for (const mode of SERVE_MODES) { + it(`${mode}: Wasit's read checks pass the Base Sepolia challenge`, async () => { + const { url, server } = await start(mode, BASE); + try { + const results = await runX402ReadChecks({ target: url }); + for (const result of results) assert.equal(result.pass, true, `${mode} ${result.id}: ${result.detail}`); + } finally { + await stop(server); + } + }); + } + + it("wrong-settlement cites a well-formed EVM hash on the payment's network", async () => { + const { url, server } = await start("wrong-settlement", BASE); + try { + const response = await fetch(url, { + headers: { "PAYMENT-SIGNATURE": paymentHeader("eip155:84532", "10000") }, + }); + const settlement = decode(response.headers.get("payment-response")); + assert.match(String(settlement["transaction"]), /^0x[0-9a-f]{64}$/); + assert.equal(settlement["network"], "eip155:84532"); + } finally { + await stop(server); + } + }); + + it("names Base mainnet when the agent pays the wrong-network challenge", async () => { + const { url, lines, server } = await start("wrong-network", BASE); + try { + await fetch(url, { headers: { "PAYMENT-SIGNATURE": paymentHeader("eip155:8453", "10000") } }); + assert.match(lines.at(-1) ?? "", /paid a challenge on eip155:8453 \(Base mainnet\)/); + } finally { + await stop(server); + } + }); +}); From 7d27551f6b98dff6952a156b2733a1e6030b58f3 Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Mon, 5 Oct 2026 16:13:52 +0800 Subject: [PATCH 5/6] test(fixtures): a Base Sepolia x402 fixture, run by fixtures.sh and run-all.sh The Base Sepolia twin of the Stellar x402 fixture: the same route and price, settled through the same public facilitator, on port 3005. It needs EVM_PAYEE_ADDRESS, so fixtures.sh starts it only when .env has one and skips it otherwise, and run-all.sh adds its suite only when it is up: a Stellar-only setup runs exactly as before. .env.example documents EVM_PRIVATE_KEY and EVM_PAYEE_ADDRESS. Run on Base Sepolia with a payer holding 20 USDC and no ETH: wasit test --network eip155:84532 is 7/7, X402-06 settled in tx 0xb182df33...7e91. Read back independently: one Transfer from the payer to the payee for 10000 units, the gas paid by the facilitator, the payer still at 0 ETH. X402-07's forged signature was refused and nothing settled. Co-Authored-By: Claude Opus 5.5 --- .env.example | 9 +++++ .../core/test/fixtures/x402-evm-server.ts | 34 +++++++++++++++++++ scripts/fixtures.sh | 12 ++++++- scripts/run-all.sh | 14 ++++++-- 4 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 packages/core/test/fixtures/x402-evm-server.ts diff --git a/.env.example b/.env.example index 9e621b2..de350e8 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,15 @@ STELLAR_PRIVATE_KEY= # Only needed to run the bundled fixture, not to test a third-party service. STELLAR_PAYEE_ADDRESS= +# --- x402 payment checks on Base Sepolia (eip155:84532) --------------------- +# Payer private key (0x + 64 hex) for --network eip155:84532. It needs Base +# Sepolia USDC and no ETH: the facilitator pays the gas (EIP-3009). +EVM_PRIVATE_KEY= + +# Recipient address (0x...) the Base Sepolia fixture server charges to. +# Only needed to run the bundled fixture. +EVM_PAYEE_ADDRESS= + # --- MPP charge mode (MPP-01) ------------------------------------------------ # Payer secret key (S...) for the charge check. Settles a real payment on every # run; repeated runs spend repeatedly. diff --git a/packages/core/test/fixtures/x402-evm-server.ts b/packages/core/test/fixtures/x402-evm-server.ts new file mode 100644 index 0000000..8fa09d0 --- /dev/null +++ b/packages/core/test/fixtures/x402-evm-server.ts @@ -0,0 +1,34 @@ +import "dotenv/config"; +import express from "express"; +import { paymentMiddlewareFromConfig } from "@x402/express"; +import { HTTPFacilitatorClient } from "@x402/core/server"; +import { ExactEvmScheme } from "@x402/evm/exact/server"; + +// The Base Sepolia twin of x402-real-server.ts: the same route and price, +// settled through the same public facilitator, which pays the gas. The SDK +// prices "$0.01" in its default Base Sepolia USDC (6 decimals: 10000 units). +const PORT = 3005; +const ROUTE_PATH = "/protected"; +const PRICE = "$0.01"; +const NETWORK = "eip155:84532"; +const FACILITATOR_URL = "https://www.x402.org/facilitator"; +const PAY_TO = process.env.EVM_PAYEE_ADDRESS; +if (!PAY_TO) throw new Error("EVM_PAYEE_ADDRESS is not set in .env"); + +const app = express(); + +app.use( + paymentMiddlewareFromConfig( + { + [`GET ${ROUTE_PATH}`]: { + accepts: { scheme: "exact", price: PRICE, network: NETWORK, payTo: PAY_TO }, + }, + }, + new HTTPFacilitatorClient({ url: FACILITATOR_URL }), + [{ network: NETWORK, server: new ExactEvmScheme() }], + ), +); + +app.get(ROUTE_PATH, (_, res) => res.json({ secret: "wasit test resource" })); + +app.listen(PORT, () => console.log(`Test x402 server (Base Sepolia) on http://localhost:${PORT}${ROUTE_PATH}`)); diff --git a/scripts/fixtures.sh b/scripts/fixtures.sh index 037c8bf..e7c7e74 100755 --- a/scripts/fixtures.sh +++ b/scripts/fixtures.sh @@ -26,6 +26,7 @@ FIXTURES=( "charge:3002:packages/core/test/fixtures/mpp-charge-server.ts" "channel:3003:packages/core/test/fixtures/mpp-channel-server.ts" "refusing:3004:packages/core/test/fixtures/mpp-channel-refusing-server.ts" + "x402-evm:3005:packages/core/test/fixtures/x402-evm-server.ts" ) field() { echo "$1" | cut -d: -f"$2"; } @@ -78,13 +79,22 @@ cmd_start() { exit 1 fi echo "Starting fixtures:" - for fixture in "${FIXTURES[@]}"; do start_one "$fixture"; done + for fixture in "${FIXTURES[@]}"; do + # The Base Sepolia fixture needs its own payee; without one it is skipped, + # not failed, so a Stellar-only setup starts as before. + if [ "$(field "$fixture" 1)" = "x402-evm" ] && ! grep -q '^EVM_PAYEE_ADDRESS=.' "$ROOT/.env"; then + printf " %-9s skipped (EVM_PAYEE_ADDRESS not set in .env)\n" "x402-evm" + continue + fi + start_one "$fixture" + done echo echo "Targets:" echo " x402 http://localhost:3001/protected" echo " charge http://localhost:3002/data" echo " channel http://localhost:3003/data" echo " refusing http://localhost:3004/data (always refuses, by design)" + echo " x402-evm http://localhost:3005/protected (Base Sepolia, needs EVM_PAYEE_ADDRESS)" } cmd_stop() { diff --git a/scripts/run-all.sh b/scripts/run-all.sh index a9bf4ff..2dc4163 100755 --- a/scripts/run-all.sh +++ b/scripts/run-all.sh @@ -2,8 +2,9 @@ # # Runs every Wasit suite against the local fixtures in one go. # -# Starts the fixtures if they are not already up, runs the x402, MPP charge and -# MPP channel suites, prints one summary, and stops the fixtures it started. +# Starts the fixtures if they are not already up, runs the x402 (Stellar, and +# Base Sepolia when configured), MPP charge and MPP channel suites, prints one +# summary, and stops the fixtures it started. # # ./scripts/run-all.sh free checks only (the default) # ./scripts/run-all.sh --full also the checks that settle testnet payments @@ -103,6 +104,15 @@ else SUITES+=("x402|test --target http://localhost:3001/protected --read-only") echo "Mode: free checks only. Pass --full to include the payment checks." fi +# Base Sepolia, only when its fixture is up: fixtures.sh starts it only when +# .env has EVM_PAYEE_ADDRESS. The payment checks read EVM_PRIVATE_KEY. +if lsof -nP -iTCP:3005 -sTCP:LISTEN -t >/dev/null 2>&1; then + if [ "$FULL" = 1 ]; then + SUITES+=("x402-evm|test --target http://localhost:3005/protected --network eip155:84532") + else + SUITES+=("x402-evm|test --target http://localhost:3005/protected --network eip155:84532 --read-only") + fi +fi SUITES+=("mpp-channel|mpp-channel --target http://localhost:3003/data") # The CLI reads .env from its working directory. From f0ccd04b0901db36afaeec0ecf647ce98d3f67af Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Mon, 5 Oct 2026 16:16:53 +0800 Subject: [PATCH 6/6] docs: x402 payment checks on Base Sepolia, with the evidence The payment checks now pay on Base Sepolia, so the docs say so, now that it has been run. CHECKS.md describes what X402-06 and X402-07 do there (the receipt's Transfer log; the forged EIP-3009 signature), the networks note lists the three networks the payment checks pay on, and Common failures explains what a Base Sepolia payer needs: USDC and no ETH. The CLI, MCP and configuration guides, the CLI README and SKILL.md name EVM_PRIVATE_KEY and --network eip155:84532. CHANGELOG records the change. The evidence write-up records the runs: servers that skip settlement or cite a missing transaction fail X402-06 and X402-07, a mainnet request gets nothing sent, and Wasit's own fixture passes 7/7 with the settlement read back independently on-chain, the payer holding no ETH. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 19 ++- docs/CHECKS.md | 29 +++-- ...026-10-05-base-sepolia-verification-run.md | 108 ++++++++++++++++++ docs/evidence/README.md | 1 + docs/guides/cli.md | 9 +- docs/guides/configuration.md | 15 ++- docs/guides/mcp.md | 3 +- packages/cli/README.md | 7 +- skills/wasit/SKILL.md | 7 +- 9 files changed, 173 insertions(+), 25 deletions(-) create mode 100644 docs/evidence/2026-10-05-base-sepolia-verification-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8788de1..e2ad14b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,22 @@ type, such as a numeric `amount` or a `maxTimeoutSeconds` that is not a positive number, is reported as such instead of as missing. Challenges built with the official x402 server SDK carry every field, so they are unaffected. +**Added — the x402 payment checks pay on Base Sepolia.** `--network +eip155:84532` (MCP: `network`) runs `X402-06` and `X402-07` there, with the payer key +in `EVM_PRIVATE_KEY`. Payment uses the `exact` scheme's EIP-3009 method, so the +facilitator pays the gas and a payer needs Base Sepolia USDC and no ETH. `X402-06` +holds the settlement to the receipt's ERC-20 `Transfer` log by the Stellar rules; +`X402-07` forges only the EIP-3009 signature. Against Wasit's own Base Sepolia +fixture, 7/7 with `X402-06` settled on-chain; against `wasit serve` posing on Base +Sepolia, the lying modes fail both checks +([evidence](docs/evidence/2026-10-05-base-sepolia-verification-run.md)). Stellar stays +the default; MPP is Stellar only. Under the hood, the payment checks now go through a +per-chain adapter, and the x402 SDK moves from 2.19 to 2.28. + +**Changed — a payer key for the wrong chain, or a malformed one, stops the run at +preflight.** It used to surface mid-payment as a harness error. The message never +echoes the key. + **Added — `wasit serve`, a paywall that misbehaves on purpose.** The checks test a service that sells; this tests an agent that pays. It runs a local x402 paywall in one of four modes: `no-settle` serves without settling, @@ -56,7 +72,8 @@ asks for mainnet, `overprice` asks for one million USDC. The server reports what the agent did. It never settles or forwards anything, so no funds move. Every mode's challenge is well-formed (`wasit test --read-only` passes it), and the two settlement modes reproduce the servers built for the 0.6.0 A/B: -`X402-06` and `X402-07` fail against them. +`X402-06` and `X402-07` fail against them. `--network eip155:84532` poses the same +modes on Base Sepolia. **Changed — the CLI's payment warning says when it applies.** It said funds would move before every payment run, including runs where the target offered diff --git a/docs/CHECKS.md b/docs/CHECKS.md index 6250cdc..ef06758 100644 --- a/docs/CHECKS.md +++ b/docs/CHECKS.md @@ -17,8 +17,8 @@ in every test report. | `X402-03` | Header Payload Decodable | x402 spec §payment-required-object | The header value must be valid base64 that decodes to JSON | `atob()` + `JSON.parse()` succeed without error | | `X402-04` | Required Fields Present | x402 spec §payment-required-object | The payload must include the core payment terms, under the field names its own advertised version requires | Checked in **every** payment option in `accepts`, each reported by its index when the challenge offers more than one; a challenge with no options fails. In each option, every field the advertised `x402Version` requires is present: for v2 `scheme`, `network`, `amount`, `asset`, `payTo` and `maxTimeoutSeconds` (spec 5.1.2); for v1 `scheme`, `network`, `maxAmountRequired`, `asset`, `payTo`, `resource`, `description` and `maxTimeoutSeconds` (v1 spec 5.1). Each is a non-empty string, except `maxTimeoutSeconds`, a positive number of seconds; a field present with the wrong type is reported as such rather than as missing. The price field follows the version: `maxAmountRequired` for v1, `amount` for v2 (renamed in v2, which also moves the resource out of the option). The version is read from the challenge rather than accepting whichever name happens to appear, because a service advertising `x402Version: 2` while emitting the v1 field name is not conformant to the version it claims — and reporting that as a merely absent price would hide the actual defect. An unrecognised version fails: the field names cannot be checked against a version whose schema is unknown. | | `X402-05` | Network Identifier Valid | x402 v2 spec §11.1; CAIP-2 | Every advertised network id is CAIP-2 | Every option's `network` is a CAIP-2 identifier, `namespace:reference` with a 3 to 8 character lowercase namespace and a reference of at most 32 characters, as x402 v2 requires. Where the namespace's own CAIP-2 definition fixes the reference, that is checked too: `stellar` is `testnet` or `pubnet`; `eip155` is the chain id in base 10 (`eip155:84532`, not `eip155:0x14a34`); `solana` is the first 32 characters of the base58 genesis hash. A well-formed id in another namespace passes, and the result says only the format was checked there: x402 v2 asks for CAIP-2 and nothing more, so failing it would report Wasit's own lack of rules as the target's defect. An option without a `network` is left to `X402-04`. | -| `X402-06` | Signature Resubmit Accepted | x402 spec §payment-flow | A resubmitted request carrying a valid signature must be accepted | Response is no longer 402; a 2xx returns the original resource. The challenge is re-read immediately before signing, so the payment answers a challenge the target issued just now rather than a stale one. **Settles a real payment** — see the cost note below. A 2xx alone does not pass. The settlement the response reports in its `PAYMENT-RESPONSE` header (x402 v2 HTTP transport) must name a Stellar transaction hash, and that transaction is then looked up on Stellar RPC and held to the advertised terms exactly as `MPP-01` does: it must have succeeded and emitted exactly one `transfer` event, from this run's payer, to the advertised `payTo`, for the advertised `amount` of the advertised `asset`. A missing header, a reported failure, or a hash that does not match fails. A `settlement_pending` response, which the spec defines as broadcast but unconfirmed, is reconciled on chain rather than failed. Uses the same RPC wait as `MPP-01`. | -| `X402-07` | Invalid Signature Rejected *(negative)* | x402 spec §payment-flow; `exact` scheme on Stellar | A payment whose authorization signature is wrong must be REJECTED | The target answers with a non-2xx status. The payment is built exactly as for `X402-06`, then only the client's authorization signature is corrupted: in the `exact` scheme on Stellar the client signs a Soroban authorization entry rather than the envelope, and one byte of that entry's `signature` is flipped. The transaction still decodes and carries the same amount, payer and recipient, so a target can refuse it only by verifying the signature. Measured against the `x402.org` facilitator on 2026-09-30, the rejection is `invalid_exact_stellar_payload_simulation_failed`, reached at the Soroban simulation that checks authorization; the pre-0.6.0 corruption, which overwrote the base64 tail and broke XDR decoding, drew `invalid_exact_stellar_payload_malformed` instead, and a target that decoded the envelope without verifying the signature passed it. Rejection is established only by an answer: a target that cannot be reached, or whose challenge cannot be read, produces no verdict and is reported as ERROR or SKIP, and a payload with no authorization signature to corrupt reports `ERROR (setup)`. | +| `X402-06` | Signature Resubmit Accepted | x402 spec §payment-flow | A resubmitted request carrying a valid signature must be accepted | Response is no longer 402; a 2xx returns the original resource. The challenge is re-read immediately before signing, so the payment answers a challenge the target issued just now rather than a stale one. **Settles a real payment** — see the cost note below. A 2xx alone does not pass. The settlement the response reports in its `PAYMENT-RESPONSE` header (x402 v2 HTTP transport) must name a Stellar transaction hash, and that transaction is then looked up on Stellar RPC and held to the advertised terms exactly as `MPP-01` does: it must have succeeded and emitted exactly one `transfer` event, from this run's payer, to the advertised `payTo`, for the advertised `amount` of the advertised `asset`. A missing header, a reported failure, or a hash that does not match fails. A `settlement_pending` response, which the spec defines as broadcast but unconfirmed, is reconciled on chain rather than failed. Uses the same RPC wait as `MPP-01`. On Base Sepolia (`eip155:84532`) the reference is an EVM transaction hash, and the receipt's ERC-20 `Transfer` log is held to the same terms: the transaction succeeded and logged exactly one token transfer, from this run's payer, to `payTo`, for `amount` of `asset` (an ERC-721 transfer, which shares the event signature, is not counted). The receipt is awaited for 30 blocks before the transaction counts as missing; an RPC that stops advancing gives no verdict. | +| `X402-07` | Invalid Signature Rejected *(negative)* | x402 spec §payment-flow; `exact` scheme on Stellar | A payment whose authorization signature is wrong must be REJECTED | The target answers with a non-2xx status. The payment is built exactly as for `X402-06`, then only the client's authorization signature is corrupted: in the `exact` scheme on Stellar the client signs a Soroban authorization entry rather than the envelope, and one byte of that entry's `signature` is flipped. The transaction still decodes and carries the same amount, payer and recipient, so a target can refuse it only by verifying the signature. Measured against the `x402.org` facilitator on 2026-09-30, the rejection is `invalid_exact_stellar_payload_simulation_failed`, reached at the Soroban simulation that checks authorization; the pre-0.6.0 corruption, which overwrote the base64 tail and broke XDR decoding, drew `invalid_exact_stellar_payload_malformed` instead, and a target that decoded the envelope without verifying the signature passed it. Rejection is established only by an answer: a target that cannot be reached, or whose challenge cannot be read, produces no verdict and is reported as ERROR or SKIP, and a payload with no authorization signature to corrupt reports `ERROR (setup)`. On Base Sepolia the client signs an EIP-3009 `transferWithAuthorization` off-chain; the first byte of that signature is flipped and the authorization left intact, so the signer it recovers to is no longer the payer and only signature verification can refuse it. Measured against the `x402.org` facilitator on 2026-10-05: refused with 402. | **Note on the x402 payment checks' cost (Week 2).** `X402-06` and `X402-07` are not free. `X402-06` settles a real payment against the target, and `X402-07` @@ -59,14 +59,14 @@ in that case, contradicting the `X402-07` row above, and a v1 challenge left **Note on networks (0.7.0).** `X402-01`–`05` read the challenge only, so they apply to an x402 service on any chain. The payment checks pay through the -`exact` scheme on Stellar, on the network the run names (`stellar:testnet` by -default). When a challenge offers several options, the payment is built for +`exact` scheme on the network the run names: `stellar:testnet` (the default), +`stellar:pubnet`, or `eip155:84532` (Base Sepolia, with the EIP-3009 method, +where the facilitator pays the gas). When a challenge offers several options, the payment is built for the option on that network; a challenge with no such option, or only one in another scheme, gets `X402-06` and `X402-07` **skipped** with the networks it -does offer, since nothing was paid and nothing refused. Asking for a network -other than `stellar:testnet` or `stellar:pubnet`, or for pubnet without an RPC -endpoint, stops the run before any payment, as no settlement could be -verified. +does offer, since nothing was paid and nothing refused. Asking for any other network, for pubnet without an RPC endpoint, or paying +with a key for the wrong chain, stops the run before any payment, as no +settlement could be verified. ## MPP — Charge Mode @@ -338,9 +338,14 @@ paying through. ### The payment checks cannot pay -The payment checks need a testnet payer holding testnet USDC. +On Stellar, the payment checks need a testnet payer holding testnet USDC. `wasit wallet create --role x402 --fund` generates the key and funds it with testnet XLM, and `wasit wallet fund --role x402 --asset usdc` adds the USDC -trustline; the -balance itself needs one visit to https://faucet.circle.com, since there is no -scriptable USDC faucet for Stellar (see the CLI guide's wallet setup). +trustline; the balance itself needs one visit to https://faucet.circle.com, +since there is no scriptable USDC faucet for Stellar (see the CLI guide's +wallet setup). + +On Base Sepolia (`--network eip155:84532`), the payer is `EVM_PRIVATE_KEY`, a +raw `0x` private key. It needs Base Sepolia USDC from the same faucet and no +ETH at all: the facilitator pays the gas. A Stellar secret there, or any +malformed key, is reported at `PREFLIGHT` before anything is sent. diff --git a/docs/evidence/2026-10-05-base-sepolia-verification-run.md b/docs/evidence/2026-10-05-base-sepolia-verification-run.md new file mode 100644 index 0000000..3a6f2d7 --- /dev/null +++ b/docs/evidence/2026-10-05-base-sepolia-verification-run.md @@ -0,0 +1,108 @@ +# x402 Payment Checks on Base Sepolia — A/B Against Lying Servers, and a Real Settlement + +**Date:** 2026-10-05 +**Wasit:** builds of the local `release/0.7.0` branch (`282004c` for the EVM adapter, +`fb21760` for `wasit serve` on Base Sepolia, `7d27551` for the fixture), not yet +published. +**Targets:** `wasit serve --network eip155:84532`, the paywall that misbehaves on +purpose, in three modes; and Wasit's own Base Sepolia x402 fixture +(`packages/core/test/fixtures/x402-evm-server.ts`), settling through the public +`x402.org` facilitator. Both ran on our machine; the chain is Base Sepolia. +**Environment:** macOS, Node `v26.8.1`. + +**What this is for.** Until 0.7.0, `X402-06` and `X402-07` paid on Stellar only. They +now also pay on Base Sepolia (`eip155:84532`), with the `exact` scheme's EIP-3009 +method: the payer signs a `transferWithAuthorization` off-chain and the facilitator +submits it and pays the gas. The same two things have to hold as when the checks were +made stricter in 0.6.0: they fail servers that lie, and they pass an honest one. + +**Authorization.** None was needed or sought. Every target was Wasit's own code on our +machine. No service operated by anyone else was tested. + +## Servers that lie + +Payer: a throwaway key generated for the run, holding nothing. That is enough here: +EIP-3009 signing needs no balance, and `wasit serve` never settles, so nothing moved. + +| `wasit serve` mode | What it does | `X402-06` | `X402-07` | +|---|---|---|---| +| `no-settle` | Serves any payment with 200, without settling and without `PAYMENT-RESPONSE` | **FAIL** | **FAIL** | +| `wrong-settlement` | Serves with a `PAYMENT-RESPONSE` naming a transaction that does not exist | **FAIL** | **FAIL** | +| `wrong-network` | Asks to be paid on Base mainnet (`eip155:8453`) | SKIP, nothing sent | SKIP | + +Output, quoted as printed: + +``` +FAIL X402-06 Signature Resubmit Accepted + Valid payment accepted (HTTP 200), but the response carried no + PAYMENT-RESPONSE header, which the x402 v2 HTTP transport uses to report + settlement. Whether the payment settled cannot be verified. +FAIL X402-07 Invalid Signature Rejected + A payment with a corrupted authorization signature was accepted with + HTTP 200 — security-relevant failure. +``` + +``` +FAIL X402-06 Signature Resubmit Accepted + Target reported settlement as tx 0x18bb3379…2393, but the chain produced + 32 more blocks without it. Either it was never broadcast, or the target + referenced a transaction that does not exist. +``` + +The second was decided against Base Sepolia itself, through its public RPC +(`https://sepolia.base.org`): the run took 65 seconds, nearly all of it waiting until +the chain had produced 32 blocks without the cited transaction. + +``` +SKIP X402-06 Signature Resubmit Accepted + Skipped: the target offers no payment option on eip155:84532 (it offers + eip155:8453), so no payment was attempted (see X402-05). +``` + +## An honest server + +Payer: a Base Sepolia account holding 20 USDC, from Circle's faucet, and **no ETH**. + +`wasit test --target http://localhost:3005/protected --network eip155:84532`: **7/7.** + +``` +PASS X402-06 Signature Resubmit Accepted + Valid payment accepted (HTTP 200) and settled on-chain for exactly the + advertised 10000 base units of 0x036CbD53842c5426634e7929541eC2318f3dCF7e + to 0xb57bFdA962aAa607D5260747A175cda8f4bDDad7, verified from the Transfer + log (tx 0xb182df33…7e91). +PASS X402-07 Invalid Signature Rejected + Payment with a corrupted authorization signature correctly rejected (HTTP 402). +``` + +[`0xb182df33…7e91`](https://base-sepolia.blockscout.com/tx/0xb182df33ee676ac15ec9137717027a61432fc24af4761d9fa89fddb23cbf7e91) + +Read back independently afterwards, outside Wasit, with `viem` against the same RPC: + +| | | +|---|---| +| Receipt | `success`, block 47709831 | +| Sent by (gas paid by) | `0xd407…f1bf`, the facilitator's account, calling the USDC contract | +| Transfers logged | exactly one: payer `0xC171…0428` to payee `0xb57b…Dad7`, 10000 units | +| Payer after | 19.99 USDC, **still 0 ETH** | +| Payee after | 20.01 USDC | + +So the settlement Wasit verified is the one that happened, and a Base Sepolia payer +needs the token and nothing else. `X402-07`'s forged signature was refused, and nothing +settled for it. + +## What did not change + +The same day, on the same build, Wasit's Stellar fixtures were unchanged: x402 7/7 with +`X402-06` settled on-chain, `MPP-01` PASS, channel 3 passed and 2 skipped. 199 core and +54 CLI offline tests pass. + +## Limits + +- One EVM network: Base Sepolia. Ethereum Sepolia and BNB Smart Chain testnet get the + read-only checks only; no public facilitator settles them and the official SDK ships + no default token for either. +- The honest target is Wasit's own fixture on the official SDK. No third-party Base + Sepolia service was tested. +- Unreleased: this ran from the branch, not from npm. Registry parity follows the 0.7.0 + release. diff --git a/docs/evidence/README.md b/docs/evidence/README.md index a7beb5d..1637862 100644 --- a/docs/evidence/README.md +++ b/docs/evidence/README.md @@ -92,6 +92,7 @@ and what it does and does not establish. Newest first. | Date | File | What it shows | Deliverable | |---|---|---|---| +| 2026-10-05 | [base-sepolia-verification-run](2026-10-05-base-sepolia-verification-run.md) | The x402 payment checks on Base Sepolia (unreleased 0.7.0 branch): servers that skip settlement or cite a missing transaction fail `X402-06` and `X402-07`; a mainnet request gets nothing sent; Wasit's own fixture passes 7/7 with `X402-06` settled on-chain and read back independently, the payer holding no ETH | Beyond the SOW | | 2026-09-30 | [x402-0.6.0-verification-run](2026-09-30-x402-0.6.0-verification-run.md) | 0.6.0's stricter `X402-06` and `X402-07`, A/B against 0.5.0: servers built to skip settlement, misreport it or skip signature checks pass 0.5.0 and fail 0.6.0. `stellar/x402-stellar`'s reference still passes 7/7, with `X402-06` verified on-chain. Registry parity for the published 0.6.0 | D1 | | 2026-09-28 | [authorized-third-party-run](2026-09-28-authorized-third-party-run.md) | Published 0.5.0 against defi-copilot, a third-party service, with its operator's written authorization, as a local instance: 3 pass, 2 diverge from the Stellar spec (x402 v1, non-CAIP-2 network), 2 no verdict, nothing paid. Also 0.5.0 registry parity against Wasit's own fixtures | D2 | | 2026-09-24 | [official-sdk-reference-run](2026-09-24-official-sdk-reference-run.md) | Published 0.4.0 against `stellar/stellar-mpp-sdk`'s own example servers. Charge settles on-chain; an A/B across the SDK's `mppx` 0.10.1 bump shows rejected channel vouchers moving from 402 to 500 ([#82](https://github.com/stellar/stellar-mpp-sdk/issues/82)). Closes 0.4.0 registry parity | D2 | diff --git a/docs/guides/cli.md b/docs/guides/cli.md index b47f0f3..9f77416 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -158,9 +158,9 @@ wasit test --target [options] | Option | Default | Notes | |---|---|---| | `--target ` | required | Must include the scheme | -| `--network ` | `stellar:testnet` | Network the payment checks pay on: `stellar:testnet` or `stellar:pubnet`. `X402-01`–`05` apply to a challenge on any chain | -| `--payer-key ` | `STELLAR_PRIVATE_KEY` | Secret key, `S...` | -| `--rpc-url ` | testnet default | Soroban RPC used to verify `X402-06`'s settlement; required for pubnet | +| `--network ` | `stellar:testnet` | Network the payment checks pay on: `stellar:testnet`, `stellar:pubnet` or `eip155:84532` (Base Sepolia). `X402-01`–`05` apply to a challenge on any chain | +| `--payer-key ` | `STELLAR_PRIVATE_KEY`, or `EVM_PRIVATE_KEY` on Base Sepolia | A Stellar secret (`S...`), or an EVM private key (`0x` + 64 hex) | +| `--rpc-url ` | the network's default | RPC used to verify `X402-06`'s settlement: Soroban RPC on Stellar, required for pubnet; JSON-RPC on Base Sepolia (default `https://sepolia.base.org`) | | `--method ` | `GET` | HTTP method the paid endpoint uses. Endpoints that compute something usually take `POST` | | `--body ` | — | Request body, sent verbatim; implies `Content-Type: application/json` | | `--header ` | — | Extra request header the endpoint needs before it will issue a challenge. Repeatable | @@ -249,7 +249,8 @@ wasit serve --mode no-settle | `--mode ` | required | One of the four above | | `--port ` | `4020` | The server answers on every path | | `--host ` | `127.0.0.1` | Local only unless you bind another interface | -| `--pay-to
` | `STELLAR_PAYEE_ADDRESS` | A testnet account (`G...`) with a trustline for the asset | +| `--network ` | `stellar:testnet` | `stellar:testnet` or `eip155:84532` (Base Sepolia). On Base Sepolia the paywall uses the SDK's USDC, `wrong-network` asks for Base mainnet (`eip155:8453`), and `overprice` one million USDC in its 6 decimals | +| `--pay-to
` | `STELLAR_PAYEE_ADDRESS`, or `EVM_PAYEE_ADDRESS` on Base Sepolia | A testnet account (`G...`) with a trustline for the asset, or an EVM address (`0x...`) | | `--amount ` | `10000` | Price in base units, for every mode except `overprice` | | `--asset ` | testnet USDC | `CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA` | | `--settlement-tx ` | a random hash | `wrong-settlement` only. The default cites a transaction that exists nowhere; pass a real, unrelated transaction to test an agent that finds it on-chain but must notice it is not this payment | diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 13f8d6d..e406a5c 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -11,7 +11,8 @@ keys in this file. | Checks | Required | |---|---| | `X402-01`–`05` | nothing | -| `X402-06`, `07` | `STELLAR_PRIVATE_KEY` | +| `X402-06`, `07` on Stellar | `STELLAR_PRIVATE_KEY` | +| `X402-06`, `07` on Base Sepolia (`--network eip155:84532`) | `EVM_PRIVATE_KEY` | | `MPP-01` | `MPP_PAYER_SECRET`, `MPP_STELLAR_NETWORK` | | `MPP-10`–`12`, `14` | `COMMITMENT_SECRET_HEX`, `MPP_STELLAR_NETWORK` | | `MPP-13` | the above, plus `CHANNEL_CONTRACT_DISPOSABLE` and an explicit opt-in | @@ -29,6 +30,11 @@ Only testnet has a default RPC endpoint. Pubnet deliberately has none, so a pubnet run must pass `--rpc-url` explicitly rather than silently reaching a third-party node. +The x402 payment checks also pay on Base Sepolia (`eip155:84532`), through +`--network` (MCP: `network`), with the payer key in `EVM_PRIVATE_KEY`. Its +default RPC endpoint is Base's public `https://sepolia.base.org`, overridable +with `--rpc-url`. Any other network gets the read-only checks only. + ## Getting testnet keys ```bash @@ -46,6 +52,13 @@ curl "https://friendbot.stellar.org/?addr=" `COMMITMENT_SECRET_HEX` is a raw ed25519 seed in hex, not a Stellar `S...` string — it signs channel commitments directly rather than transactions. +For Base Sepolia, `EVM_PRIVATE_KEY` is a raw private key, `0x` followed by 64 +hex characters, from any EVM wallet or `viem`'s `generatePrivateKey()`. The +payer needs Base Sepolia USDC, from https://faucet.circle.com, and **no ETH**: +the facilitator pays the gas (the `exact` scheme's EIP-3009 method). A wrong +or malformed key is reported at `PREFLIGHT` before anything is sent, without +echoing it. + ## The disposable channel `MPP-13` closes a channel permanently. `CHANNEL_CONTRACT_DISPOSABLE` should name diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 7a843c8..9caa233 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -171,7 +171,8 @@ of every config file: `set -a; source .env; set +a`, then `copilot`. Any MCP client that runs stdio servers takes the same three things: `command` set to `npx`, `args` set to `["-y", "@wasit-dev/server"]`, and the environment variables `MPP_STELLAR_NETWORK`, `STELLAR_PRIVATE_KEY`, `MPP_PAYER_SECRET` and -`COMMITMENT_SECRET_HEX`. Use absolute paths for `node packages/server/dist/index.js` +`COMMITMENT_SECRET_HEX`, plus `EVM_PRIVATE_KEY` for x402 payment checks on Base +Sepolia (`network: "eip155:84532"`). Use absolute paths for `node packages/server/dist/index.js` if launching from a local checkout instead of npx, since a client launches the server from a working directory you don't control. diff --git a/packages/cli/README.md b/packages/cli/README.md index 1d3f55d..000b7a6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -48,8 +48,8 @@ wasit test --target [options] | Option | Default | Notes | |---|---|---| | `--target ` | required | Must include the scheme | -| `--network ` | `stellar:testnet` | Network the payment checks pay on (`stellar:testnet` or `stellar:pubnet`); `X402-01`–`05` apply on any chain | -| `--payer-key ` | `STELLAR_PRIVATE_KEY` | Testnet secret key, `S...` | +| `--network ` | `stellar:testnet` | Network the payment checks pay on (`stellar:testnet`, `stellar:pubnet` or `eip155:84532`, Base Sepolia); `X402-01`–`05` apply on any chain | +| `--payer-key ` | `STELLAR_PRIVATE_KEY`, or `EVM_PRIVATE_KEY` on Base Sepolia | A Stellar secret (`S...`), or an EVM private key (`0x` + 64 hex) | | `--rpc-url ` | testnet default | Soroban RPC used to verify `X402-06`'s settlement | | `--method ` | `GET` | HTTP method the paid endpoint uses | | `--body ` | — | Request body; implies `Content-Type: application/json` | @@ -168,7 +168,8 @@ A run with both a failure and an error exits `1` — a real finding outranks a m | Env var | Used by | |---|---| -| `STELLAR_PRIVATE_KEY` | `test` (x402 payment checks) | +| `STELLAR_PRIVATE_KEY` | `test` (x402 payment checks on Stellar) | +| `EVM_PRIVATE_KEY` | `test --network eip155:84532` (x402 payment checks on Base Sepolia) | | `MPP_PAYER_SECRET` | `mpp-charge` | | `MPP_STELLAR_NETWORK` | `mpp-charge`, `mpp-channel` | | `COMMITMENT_SECRET_HEX` | `mpp-channel` | diff --git a/skills/wasit/SKILL.md b/skills/wasit/SKILL.md index e4a31fa..3c9e749 100644 --- a/skills/wasit/SKILL.md +++ b/skills/wasit/SKILL.md @@ -51,9 +51,10 @@ Zero-cost check against your own service, no keys required: npx @wasit-dev/cli test --target https://your-service.example/paid --read-only ``` -That runs `X402-01` through `X402-05`. Add a payer key -(`--payer-key`/`STELLAR_PRIVATE_KEY`) to also run `X402-06`/`07`, which settle -a real testnet payment; `X402-06` then verifies that settlement on-chain. `wasit mpp-charge` and `wasit mpp-channel` cover MPP; +That runs `X402-01` through `X402-05`, on a challenge from any chain. Add a +payer key (`--payer-key`/`STELLAR_PRIVATE_KEY`, or `EVM_PRIVATE_KEY` with +`--network eip155:84532` for Base Sepolia) to also run `X402-06`/`07`, which +settle a real testnet payment; `X402-06` then verifies that settlement on-chain. `wasit mpp-charge` and `wasit mpp-channel` cover MPP; channel mode is free except for the destructive close check, which is opt-in only (`--allow-destructive`) and never runs by accident.