From 2bf26a7cabac1fb3c9bbd49d8c61067e3a128ec5 Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Sun, 4 Oct 2026 08:36:51 +0800 Subject: [PATCH 1/4] feat(core): x402 read checks on any chain, payment on the named network X402-04 and X402-05 read only accepts[0], and X402-05 accepted only stellar:testnet and stellar:pubnet. A conformant x402 service on another chain failed it, and a challenge offering several networks was judged on whichever came first. Both checks now read every payment option and name a broken one by its index. X402-05 requires CAIP-2, as x402 v2 does (spec 11.1), and applies the rules each namespace's CAIP-2 definition fixes: stellar is testnet or pubnet, eip155 a base-10 chain id, solana the first 32 base58 characters of the genesis hash. A well-formed id in another namespace passes, and the result says only the format was checked, since failing it would report Wasit's lack of rules as the target's defect. The rules move to x402/requirements.ts so they are tested without a network. The payment checks pay only on the network the run names. x402Client picks the first option it supports, which could be the other Stellar network; a policy now filters to --network. A challenge with no option on that network, or none in the exact scheme, skips X402-06/07 with the networks it offers and sends nothing. An unsupported --network, or pubnet without an RPC endpoint, stops at preflight instead of after paying with no way to verify the settlement. 24 new offline tests, mutation-checked. Run against a local stand-in offering Base Sepolia and Solana devnet (5/5 read-only, payment checks skipped, nothing sent) and against the Stellar fixture (7/7, X402-06 settled on-chain). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 28 ++ docs/CHECKS.md | 18 +- docs/guides/cli.md | 2 +- packages/cli/README.md | 2 +- packages/cli/src/index.ts | 7 +- packages/core/src/catalogue.ts | 5 +- packages/core/src/x402/requirements.ts | 269 +++++++++++++++ packages/core/src/x402/simulator.ts | 150 +++------ packages/core/test/unit/x402-networks.test.ts | 310 ++++++++++++++++++ packages/server/src/index.ts | 5 +- 10 files changed, 679 insertions(+), 117 deletions(-) create mode 100644 packages/core/src/x402/requirements.ts create mode 100644 packages/core/test/unit/x402-networks.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a83e56e..f35753f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes to Wasit are recorded here. Versions follow [Semantic Versioning](https://semver.org/): patch releases are fixes, minor releases add checks or features without breaking existing usage, major releases break something. +## [Unreleased] + +**Changed — `X402-04` and `X402-05` read every payment option, on any chain.** +Both read only `accepts[0]`, and `X402-05` accepted only `stellar:testnet` and +`stellar:pubnet`, so a conformant x402 service on another chain failed it, and +a challenge offering several networks was judged on whichever came first. Both +now check every option and name the one at fault by its index. `X402-05` +requires CAIP-2, as x402 v2 does, and applies each namespace's own rules where +its CAIP-2 definition fixes them: `stellar` is `testnet` or `pubnet`, `eip155` +is a base-10 chain id, `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. A challenge with no options now fails `X402-04` +with that reason instead of three missing fields. + +**Changed — the payment checks pay only on the network the run names.** A +challenge offering several options is paid on the option for `--network` +(MCP: `network`), instead of the first option the client supports, which could +be the other Stellar network. A challenge with no option on that network, or +only one in a scheme other than `exact`, gets `X402-06` and `X402-07` skipped +with the networks it offers; nothing is sent. A `--network` other than +`stellar:testnet` or `stellar:pubnet`, or pubnet without `--rpc-url`, now stops +the run at preflight, before any payment, rather than after paying with no way +to verify the settlement. + +**Results can change on upgrade.** A service on a chain other than Stellar now +passes `X402-05` where it failed. A challenge with a broken second option now +fails `X402-04` or `X402-05` where it passed. + ## [0.6.0] — 2026-09-30 All three packages, versioned together as usual. The two x402 payment checks diff --git a/docs/CHECKS.md b/docs/CHECKS.md index 964161a..33f0d46 100644 --- a/docs/CHECKS.md +++ b/docs/CHECKS.md @@ -15,8 +15,8 @@ in every test report. | `X402-01` | 402 Response Status | x402 spec, HTTP semantics | An unpaid request must be answered with status code `402` | Response status is exactly `402`, not `401`/`403`/other | | `X402-02` | Payment Header Present | x402 built-on-stellar guide | The 402 response must include a payment header | Either `PAYMENT-REQUIRED` or `X-Payment` header is present (both are checked — the spec itself is not yet consistent, see note in README). An x402 v1 challenge in the response body fails this check, and `X402-03`–`05` still inspect it; see the note on v1 below | | `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 | `network` and `payTo` are present and non-empty, and the price field matches the advertised `x402Version`: `maxAmountRequired` for v1, `amount` for v2 (renamed in v2, which also drops the embedded resource object). 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 built-on-stellar guide | Network id format follows CAIP-2 | Matches the pattern `stellar:testnet` or `stellar:pubnet` | +| `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, `network` and `payTo` are present and non-empty, and the price field matches the advertised `x402Version`: `maxAmountRequired` for v1, `amount` for v2 (renamed in v2, which also drops the embedded resource object). 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)`. | @@ -49,13 +49,25 @@ identifiers ([scheme_exact_stellar.md](https://github.com/x402-foundation/x402/b Stellar service fails `X402-02`, and the failure says a v1 challenge was found in the body. Its terms are still worth reading, so `X402-03`–`05` inspect the body instead of being skipped: `X402-04` applies the v1 field names, and -`X402-05` reports whether the network is a CAIP-2 identifier. `X402-06` and +`X402-05` reports whether the network is a CAIP-2 identifier (v1 used plain +names such as `base-sepolia`, and the failure says so). `X402-06` and `X402-07` are **skipped**: Wasit pays through the v2 `exact` scheme, so no payment is built or sent, and neither check has a verdict. The same holds for any challenge the payment client cannot read. Before 0.5.0 both reported FAIL in that case, contradicting the `X402-07` row above, and a v1 challenge left `X402-03`–`05` skipped. +**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 +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. + ## MPP — Charge Mode | ID | Check Name | Spec Reference | What It Checks | Pass Criteria | diff --git a/docs/guides/cli.md b/docs/guides/cli.md index 99f4c06..212c792 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -158,7 +158,7 @@ wasit test --target [options] | Option | Default | Notes | |---|---|---| | `--target ` | required | Must include the scheme | -| `--network ` | `stellar:testnet` | CAIP-2 | +| `--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 | | `--method ` | `GET` | HTTP method the paid endpoint uses. Endpoints that compute something usually take `POST` | diff --git a/packages/cli/README.md b/packages/cli/README.md index e4220c2..3e60fe0 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -48,7 +48,7 @@ wasit test --target [options] | Option | Default | Notes | |---|---|---| | `--target ` | required | Must include the scheme | -| `--network ` | `stellar:testnet` | CAIP-2 network id | +| `--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...` | | `--rpc-url ` | testnet default | Soroban RPC used to verify `X402-06`'s settlement | | `--method ` | `GET` | HTTP method the paid endpoint uses | diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 42de474..b1671a2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -183,7 +183,12 @@ program .command("test") .description("Run x402 compliance checks against a target service") .requiredOption("--target ", "URL of the service to test") - .option("--network ", "Network identifier", "stellar:testnet") + .option( + "--network ", + "Network the payment checks pay on: stellar:testnet or stellar:pubnet. " + + "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( "--payer-key ", diff --git a/packages/core/src/catalogue.ts b/packages/core/src/catalogue.ts index 2ee4529..9e4db3c 100644 --- a/packages/core/src/catalogue.ts +++ b/packages/core/src/catalogue.ts @@ -68,8 +68,9 @@ export const CHECK_CATALOGUE: readonly CheckCatalogueEntry[] = [ id: "X402-05", name: "Network Identifier Valid", protocol: "x402", - specRef: "x402 built-on-stellar guide", - summary: "The network identifier must follow CAIP-2 (stellar:testnet or stellar:pubnet).", + specRef: "x402 v2 spec §11.1; CAIP-2", + summary: + "Every advertised network identifier must be CAIP-2, and follow its namespace's rules where Wasit knows them (stellar, eip155, solana).", }, { id: "X402-06", diff --git a/packages/core/src/x402/requirements.ts b/packages/core/src/x402/requirements.ts new file mode 100644 index 0000000..6e107f2 --- /dev/null +++ b/packages/core/src/x402/requirements.ts @@ -0,0 +1,269 @@ +/** + * The payment terms an x402 challenge advertises, and the rules X402-04 and + * X402-05 hold them to. + * + * Split from the HTTP calls in simulator.ts so the rules can be exercised + * without a network. Nothing here is specific to one chain: x402 v2 names the + * network of every payment option with a CAIP-2 identifier, and a challenge + * may offer several options on several chains at once. + */ + +import { skipped, type CheckResult } from "../check.js"; + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null + ? (value as Record) + : undefined; +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** + * Every payment option a challenge offers, in order. + * + * x402 v2 nests the payment terms inside `accepts[]`. Each entry is a separate + * option, typically one per network or asset, so each is checked on its own: + * judging only the first would pass a challenge whose other options are broken, + * and fail one whose first option is on a chain the reader did not expect. + * An entry that is not an object is kept as `undefined`, so it reads as an + * option with every field missing rather than disappearing from the count. + */ +export function acceptsOf( + payload: unknown, +): ReadonlyArray | undefined> | undefined { + const accepts = asRecord(payload)?.["accepts"]; + return Array.isArray(accepts) ? accepts.map(asRecord) : undefined; +} + +/** + * The field carrying the price, per protocol version. + * + * x402 v2 renamed `maxAmountRequired` to `amount` and dropped the embedded + * resource object. Checking for whichever name happens to be present would + * hide a real conformance failure: a service advertising `x402Version: 2` + * while emitting the v1 field name is not conformant to the version it claims. + */ +const PRICE_FIELD: Readonly> = { + 1: "maxAmountRequired", + 2: "amount", +}; + +function isSupportedVersion(value: unknown): value is 1 | 2 { + return value === 1 || value === 2; +} + +/** Names one option in a report: unprefixed when it is the only one. */ +function optionLabel(index: number, count: number): string { + return count === 1 ? "" : `accepts[${index}]: `; +} + +/** What is wrong with one payment option's fields, or undefined if nothing is. */ +function fieldProblem( + accept: Record | undefined, + version: 1 | 2, +): string | undefined { + const expected = PRICE_FIELD[version]; + const wrongVersionField = version === 2 ? "maxAmountRequired" : "amount"; + + // A v2 challenge carrying the v1 field name is a specific, actionable + // failure — say which name was found and which one the version requires, + // rather than reporting the price as simply absent. + if (!nonEmptyString(accept?.[expected]) && accept?.[wrongVersionField] !== undefined) { + return ( + `Challenge advertises x402Version ${version} but carries ` + + `\`${wrongVersionField}\`, which is the v${version === 2 ? 1 : 2} field ` + + `name. v${version} requires \`${expected}\`.` + ); + } + + const missing = [ + nonEmptyString(accept?.[expected]) ? null : expected, + nonEmptyString(accept?.["network"]) ? null : "network", + nonEmptyString(accept?.["payTo"]) ? null : "payTo", + ].filter((entry): entry is string => entry !== null); + + return missing.length === 0 ? undefined : `Missing: ${missing.join(", ")}.`; +} + +/** X402-04: every payment option must carry price, network, and payTo. */ +export function checkRequiredFields(payload: unknown): CheckResult { + const id = "X402-04"; + const name = "Required Fields Present"; + const version = asRecord(payload)?.["x402Version"]; + + if (!isSupportedVersion(version)) { + return { + id, + name, + pass: false, + detail: + `Challenge advertises x402Version ${JSON.stringify(version)}, which is ` + + `neither 1 nor 2. The payment terms use different field names per ` + + `version, so they cannot be checked against an unknown one.`, + }; + } + + const accepts = acceptsOf(payload); + if (accepts === undefined || accepts.length === 0) { + return { + id, + name, + pass: false, + detail: + "Challenge offers no payment options: `accepts` is missing or empty, so " + + `there is no price, network or payTo to read.`, + }; + } + + const problems = accepts + .map((accept, index) => { + const problem = fieldProblem(accept, version); + return problem === undefined ? undefined : optionLabel(index, accepts.length) + problem; + }) + .filter((problem): problem is string => problem !== undefined); + + if (problems.length > 0) { + return { id, name, pass: false, detail: problems.join(" ") }; + } + return { + id, + name, + pass: true, + detail: + accepts.length === 1 + ? `All required v${version} fields present.` + : `All required v${version} fields present in each of the ` + + `${accepts.length} payment options.`, + }; +} + +/** CAIP-2: `namespace:reference`, with the character sets the standard fixes. */ +const CAIP2 = /^([-a-z0-9]{3,8}):([-_a-zA-Z0-9]{1,32})$/; + +/** Base58 (bitcoin alphabet): no 0, O, I or l. */ +const BASE58_32 = /^[1-9A-HJ-NP-Za-km-z]{32}$/; + +/** + * Reference rules for the namespaces whose CAIP-2 definition fixes them. + * Each returns why a reference is wrong, or undefined when it is right. + * + * - stellar: the namespace knows exactly two networks. + * - eip155: the EIP-155 chain id, in base 10 (`eth_chainId` returns hex, + * which the namespace converts). + * - solana: the first 32 characters of the base58 genesis hash. + * + * Sources: ChainAgnostic/namespaces `stellar`, `eip155`, `solana` caip2.md. + */ +const NAMESPACE_RULES: Readonly string | undefined>> = { + stellar: (reference) => + reference === "testnet" || reference === "pubnet" + ? undefined + : `"stellar:${reference}" does not match stellar:testnet or stellar:pubnet, ` + + `the only Stellar networks CAIP-2 defines.`, + eip155: (reference) => + /^[1-9][0-9]*$/.test(reference) + ? undefined + : `"eip155:${reference}" does not carry an EIP-155 chain id in base 10, ` + + `which the eip155 namespace requires (Base Sepolia is eip155:84532).`, + solana: (reference) => + BASE58_32.test(reference) + ? undefined + : `"solana:${reference}" is not the first 32 characters of a base58 ` + + `genesis hash, which the solana namespace requires (devnet is ` + + `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1).`, +}; + +/** What X402-05 established about one network identifier. */ +export type NetworkVerdict = + | { readonly valid: true; readonly namespace: string; readonly known: boolean } + | { readonly valid: false; readonly reason: string }; + +/** + * Validates one network identifier against CAIP-2, which x402 v2 requires, + * and against its namespace's own rules where Wasit knows them. + * + * A well-formed identifier in a namespace Wasit has no rules for is valid: + * x402 v2 asks for CAIP-2 and nothing more, and even encourages non-blockchain + * networks to follow it (`ach:us`). Failing it would report Wasit's own + * ignorance as the target's defect. + */ +export function validateNetwork(network: string): NetworkVerdict { + const match = CAIP2.exec(network); + if (match === null) { + return { + valid: false, + reason: + `"${network}" is not a CAIP-2 identifier (namespace:reference, e.g. ` + + `stellar:testnet or eip155:84532), which x402 v2 requires.`, + }; + } + const namespace = match[1]!; + const rule = NAMESPACE_RULES[namespace]; + if (rule === undefined) return { valid: true, namespace, known: false }; + const reason = rule(match[2]!); + return reason === undefined + ? { valid: true, namespace, known: true } + : { valid: false, reason }; +} + +/** X402-05: every advertised network identifier must be CAIP-2. */ +export function checkNetworkIdentifier(payload: unknown): CheckResult { + const id = "X402-05"; + const name = "Network Identifier Valid"; + const accepts = acceptsOf(payload) ?? []; + const advertised = accepts + .map((accept, index) => ({ index, network: accept?.["network"] })) + .filter((entry): entry is { index: number; network: string } => + nonEmptyString(entry.network), + ); + + // Absent is X402-04's finding, not a second one: report it there only. + if (advertised.length === 0) { + return skipped(id, name, "the challenge carries no network field to validate (see X402-04)."); + } + + const v1 = asRecord(payload)?.["x402Version"] === 1; + const verdicts = advertised.map((entry) => ({ ...entry, verdict: validateNetwork(entry.network) })); + const invalid = verdicts.filter((entry) => !entry.verdict.valid); + + if (invalid.length > 0) { + const reasons = invalid.map( + (entry) => + optionLabel(entry.index, accepts.length) + + (entry.verdict as { reason: string }).reason, + ); + // x402 v1 named networks plainly ("base-sepolia"); saying so turns a bare + // FAIL into the one change that fixes it. + const v1Note = v1 ? " x402 v1 used plain names; v2 requires CAIP-2." : ""; + return { id, name, pass: false, detail: reasons.join(" ") + v1Note }; + } + + const unknownNamespaces = [ + ...new Set( + verdicts + .map((entry) => entry.verdict) + .filter((verdict) => verdict.valid && !verdict.known) + .map((verdict) => (verdict as { namespace: string }).namespace), + ), + ]; + const unknownNote = + unknownNamespaces.length === 0 + ? "" + : ` Wasit has no further rules for the ${unknownNamespaces + .map((namespace) => `"${namespace}"`) + .join(", ")} namespace${unknownNamespaces.length === 1 ? "" : "s"}, ` + + `so only the CAIP-2 format was checked there.`; + + const networks = advertised.map((entry) => entry.network); + return { + id, + name, + pass: true, + detail: + (networks.length === 1 + ? `Network identifier "${networks[0]}" is valid.` + : `Network identifiers are valid: ${networks.join(", ")}.`) + unknownNote, + }; +} diff --git a/packages/core/src/x402/simulator.ts b/packages/core/src/x402/simulator.ts index 74421f0..ff0c6c7 100644 --- a/packages/core/src/x402/simulator.ts +++ b/packages/core/src/x402/simulator.ts @@ -6,6 +6,7 @@ import { assertHttpUrl, fetchTarget, } from "../errors.js"; +import { checkNetworkIdentifier, checkRequiredFields } from "./requirements.js"; export type { CheckResult }; @@ -139,85 +140,6 @@ function asRecord(value: unknown): Record | undefined { : undefined; } -/** x402 v2 nests the payment terms inside `accepts[]`, not at the top level. */ -function firstAccept(payload: unknown): Record | undefined { - const accepts = asRecord(payload)?.["accepts"]; - return Array.isArray(accepts) ? asRecord(accepts[0]) : undefined; -} - -/** - * The field carrying the price, per protocol version. - * - * x402 v2 renamed `maxAmountRequired` to `amount` and dropped the embedded - * resource object. Checking for whichever name happens to be present would - * hide a real conformance failure: a service advertising `x402Version: 2` - * while emitting the v1 field name is not conformant to the version it claims. - */ -const PRICE_FIELD: Readonly> = { - 1: "maxAmountRequired", - 2: "amount", -}; - -function isSupportedVersion(value: unknown): value is 1 | 2 { - return value === 1 || value === 2; -} - -function nonEmptyString(value: unknown): boolean { - return typeof value === "string" && value.length > 0; -} - -/** X402-04: the challenge must carry price, network, and payTo. */ -function checkRequiredFields(payload: unknown): CheckResult { - const version = asRecord(payload)?.["x402Version"]; - const accept = firstAccept(payload); - - if (!isSupportedVersion(version)) { - return { - id: "X402-04", - name: "Required Fields Present", - pass: false, - detail: - `Challenge advertises x402Version ${JSON.stringify(version)}, which is ` + - `neither 1 nor 2. The payment terms use different field names per ` + - `version, so they cannot be checked against an unknown one.`, - }; - } - - const expected = PRICE_FIELD[version]; - const wrongVersionField = version === 2 ? "maxAmountRequired" : "amount"; - - // A v2 challenge carrying the v1 field name is a specific, actionable - // failure — say which name was found and which one the version requires, - // rather than reporting the price as simply absent. - if (!nonEmptyString(accept?.[expected]) && accept?.[wrongVersionField] !== undefined) { - return { - id: "X402-04", - name: "Required Fields Present", - pass: false, - detail: - `Challenge advertises x402Version ${version} but carries ` + - `\`${wrongVersionField}\`, which is the v${version === 2 ? 1 : 2} field ` + - `name. v${version} requires \`${expected}\`.`, - }; - } - - const missing = [ - nonEmptyString(accept?.[expected]) ? null : expected, - nonEmptyString(accept?.["network"]) ? null : "network", - nonEmptyString(accept?.["payTo"]) ? null : "payTo", - ].filter((entry): entry is string => entry !== null); - - return { - id: "X402-04", - name: "Required Fields Present", - pass: missing.length === 0, - detail: - missing.length === 0 - ? `All required v${version} fields present.` - : `Missing: ${missing.join(", ")}.`, - }; -} - /** * Reads an x402 v1 challenge from a 402 response body, if that is what it is. * @@ -246,31 +168,6 @@ async function readText(response: Response): Promise { } } -/** X402-05: the network identifier must be CAIP-2. */ -function checkNetworkIdentifier(payload: unknown): CheckResult { - const network = firstAccept(payload)?.["network"]; - - // Absent is X402-04's finding, not a second one: report it there only. - if (typeof network !== "string" || network.length === 0) { - return skipped( - "X402-05", - "Network Identifier Valid", - "the challenge carries no network field to validate (see X402-04).", - ); - } - - const pass = /^stellar:(testnet|pubnet)$/.test(network); - return { - id: "X402-05", - name: "Network Identifier Valid", - pass, - detail: pass - ? `Network identifier "${network}" is valid.` - : `"${network}" does not match stellar:testnet or stellar:pubnet, the CAIP-2 ` + - `identifiers the \`exact\` scheme on Stellar defines.`, - }; -} - /** * Runs X402-01 through X402-05 against a target. * @@ -453,13 +350,24 @@ 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), - ); + const client = new x402Client() + .register("stellar:*", new ExactStellarClientScheme(signer)) + // 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 + // network this run was asked to use. + .registerPolicy((_version, requirements) => + requirements.filter((requirement) => requirement.network === network), + ); return { client, httpClient: new x402HTTPClient(client) }; } +/** The networks a challenge offers, for a report. */ +function describeOffered(accepts: ReadonlyArray<{ readonly network: string }>): string { + const networks = [...new Set(accepts.map((requirement) => requirement.network))]; + return networks.length === 0 ? "none" : networks.join(", "); +} + /** * Reads the challenge and produces a signed payment payload for it. * @@ -497,6 +405,27 @@ async function preparePayment(options: X402PaymentCheckOptions) { ); } + // A target that offers no option Wasit can pay has not refused anything: + // there is no payment to judge, so neither check gets a verdict. X402-05 + // already reports which networks it advertises. + const onNetwork = paymentRequired.accepts.filter( + (requirement) => requirement.network === options.network, + ); + if (onNetwork.length === 0) { + throw new PaymentNotAttemptedError( + `the target offers no payment option on ${options.network} (it offers ` + + `${describeOffered(paymentRequired.accepts)}), so no payment was ` + + `attempted (see X402-05).`, + ); + } + if (!onNetwork.some((requirement) => requirement.scheme === "exact")) { + throw new PaymentNotAttemptedError( + `the target's ${options.network} option uses the ` + + `"${onNetwork[0]!.scheme}" scheme, and Wasit pays through the \`exact\` ` + + `scheme only, so no payment was attempted.`, + ); + } + const paymentPayload = await client.createPaymentPayload(paymentRequired); return { httpClient, paymentPayload }; } @@ -715,6 +644,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); } catch (error) { const preflight = errored("PREFLIGHT", "Run Preflight", error); options.onResult?.(preflight); diff --git a/packages/core/test/unit/x402-networks.test.ts b/packages/core/test/unit/x402-networks.test.ts new file mode 100644 index 0000000..c1137e3 --- /dev/null +++ b/packages/core/test/unit/x402-networks.test.ts @@ -0,0 +1,310 @@ +/** + * X402-04 and X402-05 on challenges from any chain, and the payment checks on + * a challenge that offers nothing Wasit can pay. + * + * Before 0.7.0, X402-05 accepted only stellar:testnet and stellar:pubnet, so a + * conformant x402 service on any other chain failed it, and both checks read + * only `accepts[0]`, so a challenge offering several networks was judged on + * whichever came first. Network rules come from CAIP-2 and the namespace + * definitions (DECISIONS.md, Verified facts, 2026-10-04). + */ + +import assert from "node:assert/strict"; +import http from "node:http"; +import { describe, it } from "node:test"; + +import { Keypair } from "@stellar/stellar-sdk"; + +import type { CheckResult } from "../../src/check.js"; +import { + checkNetworkIdentifier, + checkRequiredFields, + validateNetwork, +} from "../../src/x402/requirements.js"; +import { runX402PaymentChecks } from "../../src/x402/simulator.js"; + +const STELLAR_ASSET = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; +const STELLAR_PAY_TO = "GBNCC3VFT7PGUMGDQU5O35SXVWWTAHCO4LFFWLYFBLQCD56DNWYWM6ZS"; +const BASE_SEPOLIA = "eip155:84532"; +const SOLANA_DEVNET = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; + +function option(network: string, overrides: Record = {}): Record { + return { + scheme: "exact", + network, + amount: "10000", + asset: network.startsWith("stellar:") ? STELLAR_ASSET : "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: network.startsWith("stellar:") ? STELLAR_PAY_TO : "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + maxTimeoutSeconds: 60, + ...overrides, + }; +} + +function v2(...accepts: unknown[]): Record { + return { x402Version: 2, resource: { url: "http://127.0.0.1/paid" }, accepts }; +} + +describe("validateNetwork", () => { + it("accepts the identifiers the namespaces define", () => { + for (const network of [ + "stellar:testnet", + "stellar:pubnet", + BASE_SEPOLIA, + "eip155:8453", + "eip155:56", + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + SOLANA_DEVNET, + ]) { + const verdict = validateNetwork(network); + assert.equal(verdict.valid, true, network); + assert.equal(verdict.valid && verdict.known, true, network); + } + }); + + it("rejects what is not CAIP-2 at all, naming the format", () => { + for (const network of [ + "base-sepolia", // x402 v1 style + "stellar-testnet", + "Stellar:testnet", // namespace is lowercase only + "ab:x", // namespace is 3 to 8 characters + "eip155:", // empty reference + `eip155:${"1".repeat(33)}`, // reference is at most 32 characters + "eip155:84532:extra", + ]) { + const verdict = validateNetwork(network); + assert.equal(verdict.valid, false, network); + assert.match(!verdict.valid ? verdict.reason : "", /not a CAIP-2 identifier/, network); + } + }); + + it("holds stellar to its two networks", () => { + for (const network of ["stellar:mainnet", "stellar:futurenet", "stellar:Testnet"]) { + const verdict = validateNetwork(network); + assert.equal(verdict.valid, false, network); + assert.match(!verdict.valid ? verdict.reason : "", /stellar:testnet or stellar:pubnet/); + } + }); + + it("holds eip155 to a base-10 chain id", () => { + for (const network of ["eip155:0x14a34", "eip155:0", "eip155:01", "eip155:base"]) { + const verdict = validateNetwork(network); + assert.equal(verdict.valid, false, network); + assert.match(!verdict.valid ? verdict.reason : "", /EIP-155 chain id in base 10/); + } + }); + + it("holds solana to 32 base58 characters of the genesis hash", () => { + for (const network of [ + "solana:devnet", + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa", // 31 characters + "solana:0tWTRABZaYq6iMfeYKouRu166VU2xqa1", // 0 is not base58 + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqI1", // I is not base58 + ]) { + const verdict = validateNetwork(network); + assert.equal(verdict.valid, false, network); + assert.match(!verdict.valid ? verdict.reason : "", /genesis hash/); + } + }); + + // x402 v2 asks only for CAIP-2; failing a namespace Wasit has no rules for + // would report Wasit's ignorance as the target's defect. + it("accepts a well-formed identifier in a namespace it has no rules for", () => { + for (const network of ["ach:us", "hedera:testnet", "aptos:2"]) { + const verdict = validateNetwork(network); + assert.equal(verdict.valid, true, network); + assert.equal(verdict.valid && verdict.known, false, network); + } + }); +}); + +describe("checkRequiredFields across payment options", () => { + it("passes when every option carries its fields, and says how many", () => { + const result = checkRequiredFields(v2(option("stellar:testnet"), option(BASE_SEPOLIA))); + assert.equal(result.pass, true); + assert.match(result.detail, /each of the 2 payment options/); + }); + + it("keeps the single-option wording", () => { + const result = checkRequiredFields(v2(option("stellar:testnet"))); + assert.equal(result.detail, "All required v2 fields present."); + }); + + it("fails on a broken option even when the first one is fine", () => { + const result = checkRequiredFields( + v2(option("stellar:testnet"), option(BASE_SEPOLIA, { payTo: undefined })), + ); + assert.equal(result.pass, false); + assert.equal(result.detail, "accepts[1]: Missing: payTo."); + }); + + it("names the v1 field in a v2 option, per option", () => { + const result = checkRequiredFields( + v2(option(SOLANA_DEVNET), option("stellar:testnet", { amount: undefined, maxAmountRequired: "1" })), + ); + assert.equal(result.pass, false); + assert.match(result.detail, /^accepts\[1\]: .*`maxAmountRequired`.*requires `amount`/); + }); + + it("fails a challenge that offers no options at all", () => { + for (const payload of [v2(), { x402Version: 2 }]) { + const result = checkRequiredFields(payload); + assert.equal(result.pass, false); + assert.match(result.detail, /no payment options/); + } + }); + + it("reads an option that is not an object as one with every field missing", () => { + const result = checkRequiredFields(v2(option("stellar:testnet"), "oops")); + assert.equal(result.detail, "accepts[1]: Missing: amount, network, payTo."); + }); +}); + +function byId(results: CheckResult[]): Record { + return Object.fromEntries(results.map((result) => [result.id, result])); +} + +describe("checkNetworkIdentifier across payment options", () => { + it("passes a challenge offering several valid networks, listing them", () => { + const result = checkNetworkIdentifier(v2(option(BASE_SEPOLIA), option("stellar:testnet"))); + assert.equal(result.pass, true); + assert.equal(result.detail, `Network identifiers are valid: ${BASE_SEPOLIA}, stellar:testnet.`); + }); + + it("passes a non-Stellar network on its own", () => { + const result = checkNetworkIdentifier(v2(option(SOLANA_DEVNET))); + assert.equal(result.pass, true); + assert.equal(result.detail, `Network identifier "${SOLANA_DEVNET}" is valid.`); + }); + + it("fails on any invalid network and names the option", () => { + const result = checkNetworkIdentifier(v2(option("stellar:testnet"), option("base-sepolia"))); + assert.equal(result.pass, false); + assert.match(result.detail, /^accepts\[1\]: "base-sepolia" is not a CAIP-2 identifier/); + }); + + it("tells a v1 challenge that v2 renamed its networks", () => { + const result = checkNetworkIdentifier({ + x402Version: 1, + accepts: [{ network: "base-sepolia" }], + }); + assert.equal(result.pass, false); + assert.match(result.detail, /x402 v1 used plain names; v2 requires CAIP-2\.$/); + }); + + it("says when only the CAIP-2 format could be checked", () => { + const result = checkNetworkIdentifier(v2(option("ach:us"))); + assert.equal(result.pass, true); + assert.match(result.detail, /no further rules for the "ach" namespace/); + }); + + it("leaves a missing network to X402-04", () => { + const result = checkNetworkIdentifier(v2(option("stellar:testnet", { network: undefined }))); + assert.equal(result.skipped, true); + assert.match(result.skipReason ?? "", /X402-04/); + }); + + // A missing network in one option is X402-04's finding; the others are + // still checked rather than the whole check being skipped. + it("checks the options that do name a network", () => { + const result = checkNetworkIdentifier( + v2(option("stellar:testnet", { network: undefined }), option("stellar:mainnet")), + ); + assert.equal(result.pass, false); + assert.match(result.detail, /^accepts\[1\]: "stellar:mainnet"/); + }); +}); + +/** Answers every request with 402 and a v2 `PAYMENT-REQUIRED` header. */ +async function serveV2(payload: unknown): Promise<{ + url: string; + paymentHeadersSeen: () => number; + close: () => Promise; +}> { + let paymentHeaders = 0; + const header = Buffer.from(JSON.stringify(payload)).toString("base64"); + const server = http.createServer((request, response) => { + if (request.headers["x-payment"] || request.headers["payment-signature"]) paymentHeaders++; + response.writeHead(402, { "payment-required": header, "content-type": "application/json" }); + response.end("{}"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as { port: number }; + return { + url: `http://127.0.0.1:${port}/paid`, + paymentHeadersSeen: () => paymentHeaders, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +describe("payment checks on a challenge Wasit cannot pay", () => { + const payerSecretKey = Keypair.random().secret(); + + for (const [label, payload, reason] of [ + [ + "a challenge on other chains only", + v2(option(BASE_SEPOLIA), option(SOLANA_DEVNET)), + new RegExp(`offers no payment option on stellar:testnet \\(it offers ${BASE_SEPOLIA}, ${SOLANA_DEVNET}\\)`), + ], + [ + "a challenge on the other Stellar network only", + v2(option("stellar:pubnet")), + /offers no payment option on stellar:testnet \(it offers stellar:pubnet\)/, + ], + [ + "a Stellar option in a scheme other than exact", + v2(option("stellar:testnet", { scheme: "upto" })), + /uses the "upto" scheme, and Wasit pays through the `exact` scheme only/, + ], + ] as const) { + it(`skips X402-06 and X402-07 for ${label}, and sends nothing`, async () => { + const target = await serveV2(payload); + try { + const results = byId( + await runX402PaymentChecks({ target: target.url, network: "stellar:testnet", payerSecretKey }), + ); + for (const id of ["X402-06", "X402-07"]) { + assert.equal(results[id]?.skipped, true, id); + assert.match(results[id]?.skipReason ?? "", reason, id); + } + assert.equal(target.paymentHeadersSeen(), 0); + } finally { + await target.close(); + } + }); + } + + it("stops before paying when asked to pay on a network it cannot pay on", async () => { + const target = await serveV2(v2(option(BASE_SEPOLIA))); + try { + const results = await runX402PaymentChecks({ + target: target.url, + network: BASE_SEPOLIA, + payerSecretKey, + }); + assert.equal(results.length, 1); + assert.equal(results[0]?.id, "PREFLIGHT"); + assert.equal(results[0]?.error?.kind, "configuration"); + 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 () => { + const target = await serveV2(v2(option("stellar:pubnet"))); + try { + const results = await runX402PaymentChecks({ + target: target.url, + network: "stellar:pubnet", + payerSecretKey, + }); + assert.equal(results[0]?.id, "PREFLIGHT"); + assert.equal(results[0]?.error?.kind, "configuration"); + assert.equal(target.paymentHeadersSeen(), 0); + } finally { + await target.close(); + } + }); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index b9f70f9..62df659 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -169,7 +169,10 @@ server.registerTool( network: z .string() .optional() - .describe('CAIP-2 network id, e.g. "stellar:testnet" (default)'), + .describe( + 'Network the payment checks pay on: "stellar:testnet" (default) or ' + + '"stellar:pubnet". The read-only checks apply to a challenge on any chain.', + ), readOnly: z .boolean() .optional() From b5c6cc54c13164e79a978641fda193e349422a35 Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Sun, 4 Oct 2026 08:56:59 +0800 Subject: [PATCH 2/4] feat(core): every failure says what to change; X402-04 checks all fields A FAIL said what was wrong and left the fix to the reader. Builders new to x402 then had to read the spec to find it. A FAIL now carries a fix and a docs link, in the CLI's text and --json output and in the MCP server's structuredContent (fix, docs). Where a check can tell the cause, the fix is specific: eip155:0x14a34 is told to write eip155:84532, a 200 on an unpaid request to put the payment middleware in front of the route, a missing PAYMENT-RESPONSE what to return. Otherwise the check's catalogue entry supplies a general fix, and every entry now has one, so no catalogued check can fail without guidance. Only a FAIL gets any: a skip or a no-verdict is not a defect. Core gains fixFor(), catalogueEntry(), docsUrlFor() and an optional hint on CheckResult; X402-07's verdict is split out so it is testable offline. X402-04 checked three fields. x402 v2 requires six in every option (scheme, network, amount, asset, payTo, maxTimeoutSeconds) and v1 eight; it now checks the advertised version's list, and reports a field of the wrong type as such rather than as missing. The official server SDK fills every field, so services built with it are unaffected. The CLI's payment warning now says it applies only when the target offers an option on the run's network. 41 new offline tests; 18 mutants, all killed. Run against a hand-built broken challenge through the CLI and the MCP server. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 26 +- docs/CHECKS.md | 2 +- docs/guides/cli.md | 17 + docs/guides/mcp.md | 5 + packages/cli/src/index.ts | 12 +- packages/core/README.md | 6 +- packages/core/src/catalogue.ts | 39 ++- packages/core/src/check.ts | 50 ++- packages/core/src/index.ts | 4 +- packages/core/src/x402/requirements.ts | 219 +++++++++--- packages/core/src/x402/simulator.ts | 86 ++++- packages/core/test/unit/fix-hints.test.ts | 324 ++++++++++++++++++ packages/core/test/unit/x402-networks.test.ts | 5 +- packages/server/src/index.ts | 12 +- skills/wasit/SKILL.md | 3 + 15 files changed, 743 insertions(+), 67 deletions(-) create mode 100644 packages/core/test/unit/fix-hints.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f35753f..5fa8994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,33 @@ with the networks it offers; nothing is sent. A `--network` other than the run at preflight, before any payment, rather than after paying with no way to verify the settlement. +**Added — every failure says what to change.** A FAIL now carries a `Fix` +line and a `Docs` link, in the CLI's text output, in `--json` and in the MCP +server's `structuredContent` (`fix`, `docs`). Where a check can tell the cause, +the fix is specific to it: `eip155:0x14a34` is told to write `eip155:84532`, a +200 on an unpaid request is told to put the payment middleware in front of the +route, a missing `PAYMENT-RESPONSE` is told what to return. Otherwise it is the +check's general fix, now part of every catalogue entry (`fix`). Only a FAIL +carries one; a skip or a no-verdict is not a defect. New in core: `fixFor()`, +`catalogueEntry()`, `docsUrlFor()`, and an optional `hint` on `CheckResult`. + +**Changed — `X402-04` checks every field the advertised version requires.** +It checked three: the price, `network` and `payTo`. x402 v2 requires `scheme`, +`network`, `amount`, `asset`, `payTo` and `maxTimeoutSeconds` in every option; +v1 also requires `resource` and `description`. A field present with the wrong +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. + +**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 +no option on the run's network and nothing was paid. + **Results can change on upgrade.** A service on a chain other than Stellar now passes `X402-05` where it failed. A challenge with a broken second option now -fails `X402-04` or `X402-05` where it passed. +fails `X402-04` or `X402-05` where it passed. A challenge without `scheme`, +`asset` or `maxTimeoutSeconds`, or with a wrongly typed field, now fails +`X402-04`. ## [0.6.0] — 2026-09-30 diff --git a/docs/CHECKS.md b/docs/CHECKS.md index 33f0d46..c353474 100644 --- a/docs/CHECKS.md +++ b/docs/CHECKS.md @@ -15,7 +15,7 @@ in every test report. | `X402-01` | 402 Response Status | x402 spec, HTTP semantics | An unpaid request must be answered with status code `402` | Response status is exactly `402`, not `401`/`403`/other | | `X402-02` | Payment Header Present | x402 built-on-stellar guide | The 402 response must include a payment header | Either `PAYMENT-REQUIRED` or `X-Payment` header is present (both are checked — the spec itself is not yet consistent, see note in README). An x402 v1 challenge in the response body fails this check, and `X402-03`–`05` still inspect it; see the note on v1 below | | `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, `network` and `payTo` are present and non-empty, and the price field matches the advertised `x402Version`: `maxAmountRequired` for v1, `amount` for v2 (renamed in v2, which also drops the embedded resource object). 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-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)`. | diff --git a/docs/guides/cli.md b/docs/guides/cli.md index 212c792..0c4a3b8 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -238,6 +238,20 @@ SKIP MPP-13 Close Settlement [destructive] permanently ends it. ``` +A FAIL also says what to change, and where the check is documented: + +``` +FAIL X402-05 Network Identifier Valid + "eip155:0x14a34" does not carry an EIP-155 chain id in base 10, which the + eip155 namespace requires (Base Sepolia is eip155:84532). + Fix: Write the chain id in base 10: eip155:84532. + Docs: https://usewasit.dev/docs/checks/x402 +``` + +`Fix` is specific to the cause the check found where Wasit can tell it, and the +check's general fix otherwise. Only a FAIL carries one: a SKIP or an ERROR is not +a defect in the target, so there is nothing for it to change. + `PREFLIGHT` appears in place of the checks when the target URL or network identifier is invalid. Both are wrong for every check in the suite, so they are reported once rather than repeated identically. @@ -264,6 +278,9 @@ wasit test --target https://api.example.com/paid-endpoint --read-only --json } ``` +A result with `"status": "FAIL"` also carries `fix` and `docs`, the same two +lines the text output prints. + Any advisory line that would normally print above the results (missing payer key, `--read-only` set, a payment-cost warning) is written to stderr instead of stdout when `--json` is set, so stdout stays parseable — safe to pipe into diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index c71ce41..7a843c8 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -255,6 +255,11 @@ and `no-verdict` must never be read as `conformant`. When any check errored, the prose channel carries an explicit caveat that no statement was made about the target's conformance. +A result with `"status": "FAIL"` also carries `fix`, what to change for the +cause the check found (or the check's general fix), and `docs`, the check's +page on usewasit.dev. An agent can pass `fix` on to the developer as the next +step. Nothing else carries them: a skip or a no-verdict is not a defect. + A result with `"status": "ERROR"` also carries `errorKind`, one of `unreachable`, `configuration`, `setup` or `harness`. `setup` means a precondition the check needed could not be established — for the channel checks, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b1671a2..478bcd1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -10,6 +10,7 @@ import { CHECK_CATALOGUE, PROTOCOL_IDS, checkStatus, + fixFor, runMppChannelSuite, runMppChargeSuite, runX402PaymentChecks, @@ -106,7 +107,13 @@ function report(results: CheckResult[], json: boolean): number { for (const result of results) { const flag = result.destructive ? " [destructive]" : ""; console.log(`${checkStatus(result)} ${result.id} ${result.name}${flag}`); - console.log(` ${result.detail}\n`); + console.log(` ${result.detail}`); + const guidance = fixFor(result); + if (guidance !== undefined) { + console.log(` Fix: ${guidance.fix}`); + if (guidance.docs !== undefined) console.log(` Docs: ${guidance.docs}`); + } + console.log(""); } const line = [`${counts.passed} passed`]; @@ -241,7 +248,8 @@ signature. See docs/CHECKS.md for what each check ID verifies.`, } else { note( jsonMode, - "X402-06 settles a real payment and X402-07 attempts one. Testnet funds will move.\n", + `When the target offers a ${opts.network} option, X402-06 settles a real ` + + "payment and X402-07 attempts one. Testnet funds will move.\n", ); results.push( ...(await runX402PaymentChecks({ diff --git a/packages/core/README.md b/packages/core/README.md index 56a1eab..03783bf 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -84,9 +84,11 @@ The full check catalogue — every check's exact pass criteria and spec referenc | Export | What it is | |---|---| -| `CHECK_CATALOGUE` | `readonly CheckCatalogueEntry[]` — every check the suite can run: `id`, `name`, `protocol`, `specRef`, a one-line `summary`, and the optional flags `negative`, `destructive`, `costsFunds`. This is what `wasit checks` prints. | +| `CHECK_CATALOGUE` | `readonly CheckCatalogueEntry[]` — every check the suite can run: `id`, `name`, `protocol`, `specRef`, a one-line `summary`, a `fix` saying what to change when it fails, and the optional flags `negative`, `destructive`, `costsFunds`. This is what `wasit checks` prints. | +| `fixFor(result)` | → `FailureGuidance \| undefined` — for a FAIL, `{ fix, docs }`: the result's own `hint` when it has one, else the catalogue `fix`, and the check's docs page. Undefined for anything that is not a FAIL. | +| `catalogueEntry(id)`, `docsUrlFor(id)` | Look up a check's catalogue entry, or its docs page on usewasit.dev | | `PROTOCOL_IDS` | `readonly ProtocolId[]` — `"x402"`, `"mpp-charge"`, `"mpp-channel"` | -| `toStructuredRun(results)` | → `StructuredRun` — the machine-readable reshape of a run: `outcome`, per-status counts, and a `results` array of `StructuredCheckResult`. The CLI's `--json` and the MCP server's `structuredContent` are both this function's output verbatim, so the three surfaces cannot describe the same run differently. | +| `toStructuredRun(results)` | → `StructuredRun` — the machine-readable reshape of a run: `outcome`, per-status counts, and a `results` array of `StructuredCheckResult` (a FAIL also carries `fix` and `docs`). The CLI's `--json` and the MCP server's `structuredContent` are both this function's output verbatim, so the three surfaces cannot describe the same run differently. | `outcome` is a name — `"conformant"`, `"non-conformant"`, `"no-verdict"` — not an exit code. `no-verdict` must never be read as `conformant`. diff --git a/packages/core/src/catalogue.ts b/packages/core/src/catalogue.ts index 9e4db3c..5668d1c 100644 --- a/packages/core/src/catalogue.ts +++ b/packages/core/src/catalogue.ts @@ -26,6 +26,12 @@ export interface CheckCatalogueEntry { readonly specRef: string; /** One-line summary of what the check verifies. Full pass criteria: docs/CHECKS.md. */ readonly summary: string; + /** + * What to change when the check fails, in one or two sentences. A result + * may carry a more specific `hint` for the cause it found; this is the + * fallback, so every failure comes with something to act on. + */ + readonly fix: string; /** True for a check that only passes because the target correctly REJECTED something. */ readonly negative?: true; /** True for a check that permanently alters the target's state (see SECURITY.md). */ @@ -41,6 +47,7 @@ export const CHECK_CATALOGUE: readonly CheckCatalogueEntry[] = [ protocol: "x402", specRef: "x402 spec, HTTP semantics", summary: "An unpaid request must be answered with status code 402.", + fix: "Answer an unpaid request with 402 Payment Required and the challenge in a PAYMENT-REQUIRED header. A 200 means the route serves without payment; a 404 often means the wrong path or method (try --method POST).", }, { id: "X402-02", @@ -48,6 +55,7 @@ export const CHECK_CATALOGUE: readonly CheckCatalogueEntry[] = [ protocol: "x402", specRef: "x402 built-on-stellar guide", summary: "The 402 response must include a payment header.", + fix: "Send the challenge as base64-encoded JSON in a PAYMENT-REQUIRED response header, as the x402 v2 HTTP transport defines.", }, { id: "X402-03", @@ -55,6 +63,7 @@ export const CHECK_CATALOGUE: readonly CheckCatalogueEntry[] = [ protocol: "x402", specRef: "x402 spec §payment-required-object", summary: "The header value must be valid base64 that decodes to JSON.", + fix: "Base64-encode the JSON PaymentRequired object for the PAYMENT-REQUIRED header; the value must decode to valid JSON.", }, { id: "X402-04", @@ -62,7 +71,8 @@ export const CHECK_CATALOGUE: readonly CheckCatalogueEntry[] = [ protocol: "x402", specRef: "x402 spec §payment-required-object", summary: - "The payload must include the core payment terms, under the field names its advertised version requires.", + "Every payment option must carry each field its advertised version requires, with the right types.", + fix: "Give every option in `accepts` the fields its x402Version requires (v2: scheme, network, amount, asset, payTo, maxTimeoutSeconds).", }, { id: "X402-05", @@ -71,6 +81,7 @@ export const CHECK_CATALOGUE: readonly CheckCatalogueEntry[] = [ specRef: "x402 v2 spec §11.1; CAIP-2", summary: "Every advertised network identifier must be CAIP-2, and follow its namespace's rules where Wasit knows them (stellar, eip155, solana).", + fix: "Name each network with its CAIP-2 id, such as stellar:testnet or eip155:84532.", }, { id: "X402-06", @@ -79,6 +90,7 @@ export const CHECK_CATALOGUE: readonly CheckCatalogueEntry[] = [ specRef: "x402 spec §payment-flow", summary: "A resubmitted request carrying a valid signature must be accepted.", costsFunds: true, + fix: "After a valid payment, settle it, serve the resource with a 2xx, and return a PAYMENT-RESPONSE header naming the transaction that moved exactly the advertised amount of the asset to payTo.", }, { id: "X402-07", @@ -88,6 +100,7 @@ export const CHECK_CATALOGUE: readonly CheckCatalogueEntry[] = [ summary: "A deliberately corrupted signature must be rejected, not accepted.", negative: true, costsFunds: true, + fix: "Verify every payment before serving: pass it to the facilitator's verify step and refuse it when verification fails. Never serve on a payload that was only decoded.", }, { id: "MPP-01", @@ -96,6 +109,7 @@ export const CHECK_CATALOGUE: readonly CheckCatalogueEntry[] = [ specRef: "MPP Charge Guide; CAP-46 transfer events", summary: "The charge settles on-chain for exactly what the target advertised.", costsFunds: true, + fix: "Settle each charge on-chain for exactly the advertised amount, currency and recipient, from the paying account, and reference that transaction in the Payment-Receipt header.", }, { id: "MPP-10", @@ -104,6 +118,7 @@ export const CHECK_CATALOGUE: readonly CheckCatalogueEntry[] = [ specRef: "MPP Channel Guide", summary: "The channel contract deploys correctly and is the same channel the target bills through.", + fix: "Bill through the channel advertised in the 402 challenge, opened with the token, funder, recipient and refund waiting period you expect.", }, { id: "MPP-11", @@ -112,6 +127,7 @@ export const CHECK_CATALOGUE: readonly CheckCatalogueEntry[] = [ specRef: "MPP Channel Guide §closing-the-channel; @stellar/mpp channel server", summary: "A commitment must exceed the stored cumulative and cover the price of the current request.", + fix: "Refuse with 402 any commitment that does not exceed the stored cumulative amount, or that does not cover the price of the current request.", }, { id: "MPP-12", @@ -120,6 +136,7 @@ export const CHECK_CATALOGUE: readonly CheckCatalogueEntry[] = [ specRef: "MPP Channel Guide §closing-the-channel; @stellar/mpp channel server", summary: "A byte-identical credential resubmitted against the same challenge must be rejected.", negative: true, + fix: "Record each challenge id when a credential for it is accepted, and refuse a second credential for the same challenge with 402.", }, { id: "MPP-13", @@ -129,6 +146,7 @@ export const CHECK_CATALOGUE: readonly CheckCatalogueEntry[] = [ summary: "Closing with the highest commitment settles on-chain. Permanently ends the channel.", destructive: true, costsFunds: true, + fix: "On close, settle the highest commitment on-chain: `withdrawn` must equal the committed amount and `closeEffectiveAtLedger` must be set.", }, { id: "MPP-14", @@ -137,5 +155,24 @@ export const CHECK_CATALOGUE: readonly CheckCatalogueEntry[] = [ specRef: "MPP Channel Guide §closing-the-channel; @stellar/mpp channel server", summary: "A captured (amount, signature) pair must not be redeemable against a new challenge.", negative: true, + fix: "Refuse with 402 a previously accepted commitment presented under a new challenge: the cumulative amount must strictly increase.", }, ]; + +/** Where each protocol's checks are documented on the site. */ +const DOCS_URLS: Readonly> = { + x402: "https://usewasit.dev/docs/checks/x402", + "mpp-charge": "https://usewasit.dev/docs/checks/mpp-charge-mode", + "mpp-channel": "https://usewasit.dev/docs/checks/mpp-channel-mode", +}; + +/** The catalogue entry for a check id, if there is one (PREFLIGHT has none). */ +export function catalogueEntry(id: string): CheckCatalogueEntry | undefined { + return CHECK_CATALOGUE.find((entry) => entry.id === id); +} + +/** The docs page for a check id, if it is in the catalogue. */ +export function docsUrlFor(id: string): string | undefined { + const entry = catalogueEntry(id); + return entry === undefined ? undefined : DOCS_URLS[entry.protocol]; +} diff --git a/packages/core/src/check.ts b/packages/core/src/check.ts index 891b5f1..cb4cd43 100644 --- a/packages/core/src/check.ts +++ b/packages/core/src/check.ts @@ -2,6 +2,7 @@ * The result shape shared by every conformance check, across protocols. */ +import { catalogueEntry, docsUrlFor } from "./catalogue.js"; import { type CheckError, classifyCheckError } from "./errors.js"; export interface CheckResult { @@ -11,6 +12,12 @@ export interface CheckResult { /** False when the check ran and the target did not conform. */ pass: boolean; detail: string; + /** + * What to change, for the specific cause this failure found. Optional: + * a failure without one falls back to its catalogue entry's `fix` (see + * {@link fixFor}). Meaningful only on a FAIL. + */ + hint?: string; /** * True when the check did not run. A skipped check is neither a pass nor a * failure: `pass` is false so it can never be counted as conformance, and @@ -150,6 +157,25 @@ export function skipped(id: string, name: string, reason: string): CheckResult { }; } +/** What to do about a failure, and where it is documented. */ +export interface FailureGuidance { + readonly fix: string; + readonly docs?: string; +} + +/** + * The guidance a front end shows under a FAIL: the result's own hint when it + * has one, else the check's catalogue fix. Undefined for anything that is not + * a FAIL, so a pass, a skip or a no-verdict never reads as a defect to fix. + */ +export function fixFor(result: CheckResult): FailureGuidance | undefined { + if (checkStatus(result) !== "FAIL") return undefined; + const fix = result.hint ?? catalogueEntry(result.id)?.fix; + if (fix === undefined) return undefined; + const docs = docsUrlFor(result.id); + return docs === undefined ? { fix } : { fix, docs }; +} + /** One check's result, reshaped for a machine-readable front end (CLI --json, MCP). */ export interface StructuredCheckResult { readonly id: string; @@ -162,6 +188,10 @@ export interface StructuredCheckResult { * unreachable | configuration | harness | setup. */ readonly errorKind?: string; + /** Present only when `status` is "FAIL": what to change (see {@link fixFor}). */ + readonly fix?: string; + /** Present only when `status` is "FAIL": the check's docs page. */ + readonly docs?: string; } /** A full run, reshaped for a machine-readable front end (CLI --json, MCP). */ @@ -200,13 +230,17 @@ export function toStructuredRun(results: CheckResult[]): StructuredRun { failed: counts.failed, errored: counts.errored, skipped: counts.skipped, - results: results.map((result) => ({ - id: result.id, - name: result.name, - status: checkStatus(result), - detail: result.detail, - destructive: result.destructive === true, - ...(result.error ? { errorKind: result.error.kind } : {}), - })), + results: results.map((result) => { + const guidance = fixFor(result); + return { + id: result.id, + name: result.name, + status: checkStatus(result), + detail: result.detail, + destructive: result.destructive === true, + ...(result.error ? { errorKind: result.error.kind } : {}), + ...(guidance ?? {}), + }; + }), }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ab8b84a..66b040c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export { checkStatus, errored, + fixFor, skipped, skippedDestructive, summarize, @@ -9,12 +10,13 @@ export { export type { CheckResult, CheckStatus, + FailureGuidance, RunSummary, StructuredCheckResult, StructuredRun, } from "./check.js"; -export { CHECK_CATALOGUE, PROTOCOL_IDS } from "./catalogue.js"; +export { CHECK_CATALOGUE, PROTOCOL_IDS, catalogueEntry, docsUrlFor } from "./catalogue.js"; export type { CheckCatalogueEntry, ProtocolId } from "./catalogue.js"; export * from "./errors.js"; diff --git a/packages/core/src/x402/requirements.ts b/packages/core/src/x402/requirements.ts index 6e107f2..44f16cb 100644 --- a/packages/core/src/x402/requirements.ts +++ b/packages/core/src/x402/requirements.ts @@ -38,18 +38,49 @@ export function acceptsOf( } /** - * The field carrying the price, per protocol version. + * The fields every payment option must carry, per protocol version (x402 v2 + * spec 5.1.2; v1 spec 5.1). * - * x402 v2 renamed `maxAmountRequired` to `amount` and dropped the embedded - * resource object. Checking for whichever name happens to be present would - * hide a real conformance failure: a service advertising `x402Version: 2` - * while emitting the v1 field name is not conformant to the version it claims. + * x402 v2 renamed `maxAmountRequired` to `amount` and moved `resource` and + * `description` out of the option. Checking for whichever price name happens + * to be present would hide a real conformance failure: a service advertising + * `x402Version: 2` while emitting the v1 field name is not conformant to the + * version it claims. */ -const PRICE_FIELD: Readonly> = { - 1: "maxAmountRequired", - 2: "amount", +const REQUIRED_FIELDS: Readonly> = { + 2: ["scheme", "network", "amount", "asset", "payTo", "maxTimeoutSeconds"], + 1: [ + "scheme", + "network", + "maxAmountRequired", + "asset", + "payTo", + "resource", + "description", + "maxTimeoutSeconds", + ], }; +const PRICE_FIELD = { 1: "maxAmountRequired", 2: "amount" } as const; + +/** One sentence per missing field, saying what to put there. */ +const FIELD_FIXES: Readonly> = { + scheme: 'Add `scheme`, e.g. "exact": clients pick a payment option by its scheme.', + network: "Add `network` as a CAIP-2 id, e.g. stellar:testnet or eip155:84532.", + amount: 'Add `amount`: the price in the token\'s smallest unit, as a string (e.g. "10000").', + maxAmountRequired: + "Add `maxAmountRequired`: the price in the token's smallest unit, as a string.", + asset: "Add `asset`: the address of the token the client pays in.", + payTo: "Add `payTo`: the address that receives the payment.", + resource: "Add `resource`: the URL of the paid resource.", + description: "Add `description`: a human-readable description of the resource.", + maxTimeoutSeconds: + "Add `maxTimeoutSeconds`: the seconds a client has to complete payment.", +}; + +/** Said once whenever a field is missing: the usual cause is a hand-built 402. */ +const SDK_NOTE = "The official x402 server SDK fills every required field, maxTimeoutSeconds defaulting to 300."; + function isSupportedVersion(value: unknown): value is 1 | 2 { return value === 1 || value === 2; } @@ -59,11 +90,22 @@ function optionLabel(index: number, count: number): string { return count === 1 ? "" : `accepts[${index}]: `; } +/** What is wrong with one option, and what to do about it. */ +interface OptionProblem { + readonly detail: string; + readonly fixes: readonly string[]; + readonly missing: boolean; +} + +function describeValue(value: unknown): string { + return typeof value === "object" ? "an object" : `${typeof value} ${JSON.stringify(value)}`; +} + /** What is wrong with one payment option's fields, or undefined if nothing is. */ function fieldProblem( accept: Record | undefined, version: 1 | 2, -): string | undefined { +): OptionProblem | undefined { const expected = PRICE_FIELD[version]; const wrongVersionField = version === 2 ? "maxAmountRequired" : "amount"; @@ -71,23 +113,59 @@ function fieldProblem( // failure — say which name was found and which one the version requires, // rather than reporting the price as simply absent. if (!nonEmptyString(accept?.[expected]) && accept?.[wrongVersionField] !== undefined) { - return ( - `Challenge advertises x402Version ${version} but carries ` + - `\`${wrongVersionField}\`, which is the v${version === 2 ? 1 : 2} field ` + - `name. v${version} requires \`${expected}\`.` - ); + return { + detail: + `Challenge advertises x402Version ${version} but carries ` + + `\`${wrongVersionField}\`, which is the v${version === 2 ? 1 : 2} field ` + + `name. v${version} requires \`${expected}\`.`, + fixes: [ + `Rename \`${wrongVersionField}\` to \`${expected}\`, which is what v${version} ` + + `calls the price, or advertise x402Version ${version === 2 ? 1 : 2} if the ` + + `service speaks that version.`, + ], + missing: false, + }; } - const missing = [ - nonEmptyString(accept?.[expected]) ? null : expected, - nonEmptyString(accept?.["network"]) ? null : "network", - nonEmptyString(accept?.["payTo"]) ? null : "payTo", - ].filter((entry): entry is string => entry !== null); + const missing: string[] = []; + const wrongType: string[] = []; + for (const field of REQUIRED_FIELDS[version]) { + const value = accept?.[field]; + if (value === undefined || value === null || value === "") { + missing.push(field); + } else if (field === "maxTimeoutSeconds") { + // A zero or negative timeout leaves the client no time to pay. + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + wrongType.push( + `maxTimeoutSeconds must be a positive number of seconds (got ${describeValue(value)})`, + ); + } + } else if (typeof value !== "string") { + wrongType.push(`${field} must be a string (got ${describeValue(value)})`); + } + } + + if (missing.length === 0 && wrongType.length === 0) return undefined; - return missing.length === 0 ? undefined : `Missing: ${missing.join(", ")}.`; + const detail = [ + missing.length > 0 ? `Missing: ${missing.join(", ")}.` : undefined, + wrongType.length > 0 ? `Wrong type: ${wrongType.join("; ")}.` : undefined, + ] + .filter((part): part is string => part !== undefined) + .join(" "); + const fixes = [ + ...missing.map((field) => FIELD_FIXES[field]!), + ...(wrongType.length > 0 + ? [ + "Send each field with the type the spec gives it: strings for amounts " + + "and addresses, a number for maxTimeoutSeconds.", + ] + : []), + ]; + return { detail, fixes, missing: missing.length > 0 }; } -/** X402-04: every payment option must carry price, network, and payTo. */ +/** X402-04: every payment option must carry the fields its version requires. */ export function checkRequiredFields(payload: unknown): CheckResult { const id = "X402-04"; const name = "Required Fields Present"; @@ -102,6 +180,7 @@ export function checkRequiredFields(payload: unknown): CheckResult { `Challenge advertises x402Version ${JSON.stringify(version)}, which is ` + `neither 1 nor 2. The payment terms use different field names per ` + `version, so they cannot be checked against an unknown one.`, + hint: "Set `x402Version` to 2, the current protocol version, and use its field names.", }; } @@ -114,18 +193,35 @@ export function checkRequiredFields(payload: unknown): CheckResult { detail: "Challenge offers no payment options: `accepts` is missing or empty, so " + `there is no price, network or payTo to read.`, + hint: + "List at least one payment option in `accepts`, each with " + + `${REQUIRED_FIELDS[version].join(", ")}.`, }; } const problems = accepts .map((accept, index) => { const problem = fieldProblem(accept, version); - return problem === undefined ? undefined : optionLabel(index, accepts.length) + problem; + return problem === undefined + ? undefined + : { ...problem, detail: optionLabel(index, accepts.length) + problem.detail }; }) - .filter((problem): problem is string => problem !== undefined); + .filter((problem): problem is OptionProblem => problem !== undefined); if (problems.length > 0) { - return { id, name, pass: false, detail: problems.join(" ") }; + const fixes = [ + ...new Set([ + ...problems.flatMap((problem) => problem.fixes), + ...(problems.some((problem) => problem.missing) ? [SDK_NOTE] : []), + ]), + ]; + return { + id, + name, + pass: false, + detail: problems.map((problem) => problem.detail).join(" "), + hint: fixes.join(" "), + }; } return { id, @@ -145,9 +241,27 @@ const CAIP2 = /^([-a-z0-9]{3,8}):([-_a-zA-Z0-9]{1,32})$/; /** Base58 (bitcoin alphabet): no 0, O, I or l. */ const BASE58_32 = /^[1-9A-HJ-NP-Za-km-z]{32}$/; +/** Why a network id is wrong, and what to write instead. */ +interface NetworkProblem { + readonly reason: string; + readonly fix: string; +} + +const CAIP2_EXAMPLES = + "stellar:testnet, eip155:84532 (Base Sepolia) or " + + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 (Solana devnet)"; + +/** `eip155:0x14a34` is a common slip: `eth_chainId` returns hex. */ +function eip155Fix(reference: string): string { + if (/^0x[0-9a-f]+$/i.test(reference)) { + return `Write the chain id in base 10: eip155:${BigInt(reference).toString()}.`; + } + return "Use the chain id in base 10, as eth_chainId returns it converted from hex (Base Sepolia is eip155:84532)."; +} + /** * Reference rules for the namespaces whose CAIP-2 definition fixes them. - * Each returns why a reference is wrong, or undefined when it is right. + * Each returns what is wrong with a reference, or undefined when it is right. * * - stellar: the namespace knows exactly two networks. * - eip155: the EIP-155 chain id, in base 10 (`eth_chainId` returns hex, @@ -156,29 +270,46 @@ const BASE58_32 = /^[1-9A-HJ-NP-Za-km-z]{32}$/; * * Sources: ChainAgnostic/namespaces `stellar`, `eip155`, `solana` caip2.md. */ -const NAMESPACE_RULES: Readonly string | undefined>> = { +const NAMESPACE_RULES: Readonly< + Record NetworkProblem | undefined> +> = { stellar: (reference) => reference === "testnet" || reference === "pubnet" ? undefined - : `"stellar:${reference}" does not match stellar:testnet or stellar:pubnet, ` + - `the only Stellar networks CAIP-2 defines.`, + : { + reason: + `"stellar:${reference}" does not match stellar:testnet or stellar:pubnet, ` + + `the only Stellar networks CAIP-2 defines.`, + fix: "Use stellar:testnet or stellar:pubnet.", + }, eip155: (reference) => /^[1-9][0-9]*$/.test(reference) ? undefined - : `"eip155:${reference}" does not carry an EIP-155 chain id in base 10, ` + - `which the eip155 namespace requires (Base Sepolia is eip155:84532).`, + : { + reason: + `"eip155:${reference}" does not carry an EIP-155 chain id in base 10, ` + + `which the eip155 namespace requires (Base Sepolia is eip155:84532).`, + fix: eip155Fix(reference), + }, solana: (reference) => BASE58_32.test(reference) ? undefined - : `"solana:${reference}" is not the first 32 characters of a base58 ` + - `genesis hash, which the solana namespace requires (devnet is ` + - `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1).`, + : { + reason: + `"solana:${reference}" is not the first 32 characters of a base58 ` + + `genesis hash, which the solana namespace requires (devnet is ` + + `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1).`, + fix: + "Use the first 32 characters of the cluster's genesis hash (RPC " + + "getGenesisHash): solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 for devnet, " + + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp for mainnet.", + }, }; /** What X402-05 established about one network identifier. */ export type NetworkVerdict = | { readonly valid: true; readonly namespace: string; readonly known: boolean } - | { readonly valid: false; readonly reason: string }; + | { readonly valid: false; readonly reason: string; readonly fix: string }; /** * Validates one network identifier against CAIP-2, which x402 v2 requires, @@ -197,15 +328,16 @@ export function validateNetwork(network: string): NetworkVerdict { reason: `"${network}" is not a CAIP-2 identifier (namespace:reference, e.g. ` + `stellar:testnet or eip155:84532), which x402 v2 requires.`, + fix: `Use the network's CAIP-2 id, namespace:reference, such as ${CAIP2_EXAMPLES}.`, }; } const namespace = match[1]!; const rule = NAMESPACE_RULES[namespace]; if (rule === undefined) return { valid: true, namespace, known: false }; - const reason = rule(match[2]!); - return reason === undefined + const problem = rule(match[2]!); + return problem === undefined ? { valid: true, namespace, known: true } - : { valid: false, reason }; + : { valid: false, ...problem }; } /** X402-05: every advertised network identifier must be CAIP-2. */ @@ -229,15 +361,20 @@ export function checkNetworkIdentifier(payload: unknown): CheckResult { const invalid = verdicts.filter((entry) => !entry.verdict.valid); if (invalid.length > 0) { + const problems = invalid.map((entry) => entry.verdict as NetworkProblem); const reasons = invalid.map( - (entry) => - optionLabel(entry.index, accepts.length) + - (entry.verdict as { reason: string }).reason, + (entry, i) => optionLabel(entry.index, accepts.length) + problems[i]!.reason, ); // x402 v1 named networks plainly ("base-sepolia"); saying so turns a bare // FAIL into the one change that fixes it. const v1Note = v1 ? " x402 v1 used plain names; v2 requires CAIP-2." : ""; - return { id, name, pass: false, detail: reasons.join(" ") + v1Note }; + return { + id, + name, + pass: false, + detail: reasons.join(" ") + v1Note, + hint: [...new Set(problems.map((problem) => problem.fix))].join(" "), + }; } const unknownNamespaces = [ diff --git a/packages/core/src/x402/simulator.ts b/packages/core/src/x402/simulator.ts index ff0c6c7..3c63bc8 100644 --- a/packages/core/src/x402/simulator.ts +++ b/packages/core/src/x402/simulator.ts @@ -126,6 +126,29 @@ const READ_CHECKS: ReadonlyArray = [ ["X402-05", "Network Identifier Valid"], ]; +/** What a wrong status on an unpaid request usually means, and what to do. */ +function statusHint(status: number): string { + if (status >= 200 && status < 300) { + return ( + `The route served HTTP ${status} without payment: put the x402 payment ` + + `middleware in front of it, so unpaid requests get 402 and a challenge.` + ); + } + if (status === 401 || status === 403) { + return ( + `Answer an unpaid request with 402, not ${status}: authentication errors ` + + `and payment challenges are different signals, and x402 clients act only on 402.` + ); + } + if (status === 404 || status === 405) { + return ( + `A ${status} usually means the wrong path or method. Check the URL, and ` + + `pass the method the endpoint uses (--method POST, MCP \`method\`).` + ); + } + return "Answer an unpaid request with 402 Payment Required and the challenge in a PAYMENT-REQUIRED header."; +} + /** Skips every check after `id`, naming the one cause they all depend on. */ function skipAfter(id: string, reason: string): CheckResult[] { const index = READ_CHECKS.findIndex(([candidate]) => candidate === id); @@ -217,6 +240,7 @@ export async function runX402ReadChecks( detail: statusPass ? "Server responded with 402 as required." : `Expected status 402, got ${response.status}.`, + ...(statusPass ? {} : { hint: statusHint(response.status) }), }; if (!statusPass) { @@ -255,6 +279,17 @@ export async function runX402ReadChecks( `scheme on Stellar is defined for v2 only, which signals payment in ` + `the PAYMENT-REQUIRED header.` : "Neither PAYMENT-REQUIRED nor X-Payment header was present.", + ...(headerValue !== null + ? {} + : { + hint: + v1Payload !== undefined + ? "Move to x402 v2: send the challenge base64-encoded in a " + + "PAYMENT-REQUIRED response header instead of the body, with " + + "x402Version 2 and v2 field names." + : "Send the challenge as base64-encoded JSON in a PAYMENT-REQUIRED " + + "response header, as the x402 v2 HTTP transport defines.", + }), }; if (headerValue === null && v1Payload !== undefined) { @@ -298,6 +333,9 @@ export async function runX402ReadChecks( name: "Header Payload Decodable", pass: false, detail: `Failed to decode/parse: ${(error as Error).message}`, + hint: + "Base64-encode the JSON PaymentRequired object for the header; raw " + + "JSON or a truncated value does not decode.", }, ...skipAfter( "X402-03", @@ -447,7 +485,7 @@ export function readSettlementReference( readonly transaction?: string; readonly errorReason?: string; }, -): { readonly reference: string } | { readonly failure: string } { +): { readonly reference: string } | { readonly failure: string; readonly hint: string } { let settlement; try { settlement = readSettlement(); @@ -457,6 +495,9 @@ export function readSettlementReference( `Valid payment accepted (HTTP ${status}), 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.`, + hint: + "After settling, return the facilitator's settle result base64-encoded " + + "in a PAYMENT-RESPONSE header; the official x402 middleware does this.", }; } @@ -470,6 +511,9 @@ export function readSettlementReference( `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.`, + hint: + "PAYMENT-RESPONSE must report `success: true` and carry the settlement " + + "transaction's hash in `transaction`.", }; } return { reference }; @@ -490,7 +534,7 @@ async function checkSignatureAccepted( ): Promise { const id = "X402-06"; const name = "Signature Resubmit Accepted"; - const fail = (detail: string): CheckResult => ({ id, name, pass: false, detail }); + const fail = (detail: string, hint: string): CheckResult => ({ id, name, pass: false, detail, hint }); const { httpClient, paymentPayload } = await preparePayment(options); const paymentHeaders = httpClient.encodePaymentSignatureHeader(paymentPayload); @@ -500,13 +544,18 @@ async function checkSignatureAccepted( ); if (paid.status < 200 || paid.status >= 300) { - return fail(`Expected 2xx after a valid payment, got ${paid.status}.`); + return fail( + `Expected 2xx after a valid payment, got ${paid.status}.`, + "A valid payment was refused. Your server's log of the facilitator's " + + "verify or settle response says why; serve the resource with a 2xx once " + + "the payment settles.", + ); } const read = readSettlementReference(paid.status, () => httpClient.getPaymentSettleResponse((header) => paid.headers.get(header)), ); - if ("failure" in read) return fail(read.failure); + if ("failure" in read) return fail(read.failure, read.hint); const reference = read.reference; const accepted = paymentPayload.accepted; @@ -535,7 +584,12 @@ async function checkSignatureAccepted( pass: true, detail: `Valid payment accepted (HTTP ${paid.status}) and ${verdict.detail}.`, } - : fail(verdict.detail); + : fail( + verdict.detail, + "PAYMENT-RESPONSE must name the transaction that settled this payment, " + + "and that transaction must move exactly `amount` of `asset` from the " + + "payer to `payTo`, in a single transfer.", + ); } /** @@ -620,16 +674,32 @@ async function checkInvalidSignatureRejected( withPaymentHeaders(options, paymentHeaders), ); - const accepted = response.status >= 200 && response.status < 300; + return signatureRejectionVerdict(response.status); +} + +/** + * X402-07's verdict from the status a corrupted payment drew. Split out so + * the verdict can be tested without building a payment. + */ +export function signatureRejectionVerdict(status: number): CheckResult { + const accepted = status >= 200 && status < 300; return { id: "X402-07", name: "Invalid Signature Rejected", pass: !accepted, detail: accepted ? `A payment with a corrupted authorization signature was accepted with ` + - `HTTP ${response.status} — security-relevant failure.` + `HTTP ${status} — security-relevant failure.` : `Payment with a corrupted authorization signature correctly rejected ` + - `(HTTP ${response.status}).`, + `(HTTP ${status}).`, + ...(accepted + ? { + hint: + "Verify every payment before serving: pass it to the facilitator's " + + "verify step and refuse it when verification fails. Never serve on a " + + "payload that was only decoded.", + } + : {}), }; } diff --git a/packages/core/test/unit/fix-hints.test.ts b/packages/core/test/unit/fix-hints.test.ts new file mode 100644 index 0000000..f172cee --- /dev/null +++ b/packages/core/test/unit/fix-hints.test.ts @@ -0,0 +1,324 @@ +/** + * Fix hints: every failure says what to change. + * + * A FAIL used to say only what was wrong. Builders new to x402 then had to + * read the spec to work out the fix. Each FAIL now carries guidance: the + * result's own `hint` for the cause it found, or its catalogue entry's `fix` + * as a fallback, plus the docs page. Nothing that is not a FAIL gets any, + * so a skip or a no-verdict never reads as a defect to fix. + * + * Also covers X402-04 checking every field its version requires. + */ + +import assert from "node:assert/strict"; +import http from "node:http"; +import { describe, it } from "node:test"; + +import { CHECK_CATALOGUE } from "../../src/catalogue.js"; +import { fixFor, toStructuredRun, type CheckResult } from "../../src/check.js"; +import { checkNetworkIdentifier, checkRequiredFields } from "../../src/x402/requirements.js"; +import { + readSettlementReference, + runX402ReadChecks, + signatureRejectionVerdict, +} from "../../src/x402/simulator.js"; + +function v2Option(overrides: Record = {}): Record { + return { + scheme: "exact", + network: "stellar:testnet", + amount: "10000", + asset: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + payTo: "GBNCC3VFT7PGUMGDQU5O35SXVWWTAHCO4LFFWLYFBLQCD56DNWYWM6ZS", + maxTimeoutSeconds: 60, + ...overrides, + }; +} + +function v2(...accepts: unknown[]): Record { + return { x402Version: 2, resource: { url: "http://127.0.0.1/paid" }, accepts }; +} + +describe("X402-04 checks every field its version requires", () => { + for (const field of ["scheme", "network", "amount", "asset", "payTo", "maxTimeoutSeconds"]) { + it(`fails a v2 option without ${field}`, () => { + const result = checkRequiredFields(v2(v2Option({ [field]: undefined }))); + assert.equal(result.pass, false); + assert.equal(result.detail, `Missing: ${field}.`); + assert.match(result.hint ?? "", new RegExp(`Add \`${field}\``)); + }); + } + + it("holds a v1 option to the v1 list, including resource and description", () => { + const v1Option = { + scheme: "exact", + network: "stellar:testnet", + maxAmountRequired: "10000", + asset: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + payTo: "GBNCC3VFT7PGUMGDQU5O35SXVWWTAHCO4LFFWLYFBLQCD56DNWYWM6ZS", + resource: "http://127.0.0.1/paid", + description: "test", + maxTimeoutSeconds: 60, + }; + assert.equal(checkRequiredFields({ x402Version: 1, accepts: [v1Option] }).pass, true); + const without = checkRequiredFields({ + x402Version: 1, + accepts: [{ ...v1Option, resource: undefined, description: undefined }], + }); + assert.equal(without.detail, "Missing: resource, description."); + }); + + it("does not ask a v2 option for v1-only fields", () => { + assert.equal(checkRequiredFields(v2(v2Option())).pass, true); + }); + + it("reports a field of the wrong type as such, not as missing", () => { + const result = checkRequiredFields(v2(v2Option({ amount: 10000 }))); + assert.equal(result.pass, false); + assert.equal(result.detail, "Wrong type: amount must be a string (got number 10000)."); + assert.match(result.hint ?? "", /strings for amounts/); + }); + + // A zero or negative timeout leaves the client no time to pay. + for (const value of [0, -5, "60", Number.POSITIVE_INFINITY]) { + it(`rejects maxTimeoutSeconds ${String(value)}`, () => { + const result = checkRequiredFields(v2(v2Option({ maxTimeoutSeconds: value }))); + assert.equal(result.pass, false); + assert.match(result.detail, /maxTimeoutSeconds must be a positive number of seconds/); + }); + } + + it("points a hand-built challenge at the SDK when fields are missing", () => { + const result = checkRequiredFields(v2(v2Option({ maxTimeoutSeconds: undefined }))); + assert.match(result.hint ?? "", /official x402 server SDK fills every required field/); + }); + + it("says each fix once across options", () => { + const result = checkRequiredFields( + v2(v2Option({ payTo: undefined }), v2Option({ payTo: undefined })), + ); + const hint = result.hint ?? ""; + assert.equal(hint.split("Add `payTo`").length - 1, 1); + }); + + it("tells a v2 challenge with the v1 price name to rename it", () => { + const result = checkRequiredFields(v2(v2Option({ amount: undefined, maxAmountRequired: "1" }))); + assert.match(result.hint ?? "", /Rename `maxAmountRequired` to `amount`/); + }); + + it("guides an unknown version and an empty accepts", () => { + assert.match(checkRequiredFields({ x402Version: 3 }).hint ?? "", /x402Version` to 2/); + assert.match(checkRequiredFields(v2()).hint ?? "", /at least one payment option/); + }); +}); + +describe("X402-05 hints", () => { + it("converts a hex eip155 chain id to base 10", () => { + const result = checkNetworkIdentifier(v2(v2Option({ network: "eip155:0x14a34" }))); + assert.equal(result.pass, false); + assert.equal(result.hint, "Write the chain id in base 10: eip155:84532."); + }); + + it("names CAIP-2 examples for a plain name", () => { + const result = checkNetworkIdentifier(v2(v2Option({ network: "base-sepolia" }))); + assert.match(result.hint ?? "", /CAIP-2 id.*eip155:84532 \(Base Sepolia\)/); + }); + + it("names the two Stellar networks", () => { + const result = checkNetworkIdentifier(v2(v2Option({ network: "stellar:mainnet" }))); + assert.equal(result.hint, "Use stellar:testnet or stellar:pubnet."); + }); + + it("names the Solana genesis-hash form", () => { + const result = checkNetworkIdentifier(v2(v2Option({ network: "solana:devnet" }))); + assert.match(result.hint ?? "", /getGenesisHash/); + }); + + it("says each fix once across options", () => { + const result = checkNetworkIdentifier( + v2(v2Option({ network: "stellar:mainnet" }), v2Option({ network: "stellar:main" })), + ); + assert.equal(result.hint, "Use stellar:testnet or stellar:pubnet."); + }); + + it("gives a passing check no hint", () => { + assert.equal(checkNetworkIdentifier(v2(v2Option())).hint, undefined); + }); +}); + +/** Answers every request with `status`, an optional header and body. */ +async function answer( + status: number, + headers: Record = {}, + body = "", +): Promise<{ url: string; close: () => Promise }> { + const server = http.createServer((_request, response) => { + response.writeHead(status, headers); + response.end(body); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as { port: number }; + return { + url: `http://127.0.0.1:${port}/paid`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +function byId(results: CheckResult[]): Record { + return Object.fromEntries(results.map((result) => [result.id, result])); +} + +describe("X402-01 to X402-03 hints", () => { + for (const [status, pattern] of [ + [200, /served HTTP 200 without payment/], + [401, /402, not 401/], + [403, /402, not 403/], + [404, /wrong path or method/], + [500, /Answer an unpaid request with 402/], + ] as const) { + it(`X402-01 explains a ${status}`, async () => { + const target = await answer(status); + try { + const results = byId(await runX402ReadChecks({ target: target.url })); + assert.equal(results["X402-01"]?.pass, false); + assert.match(results["X402-01"]?.hint ?? "", pattern); + } finally { + await target.close(); + } + }); + } + + it("X402-02 asks for the PAYMENT-REQUIRED header", async () => { + const target = await answer(402, {}, "payment required"); + try { + const results = byId(await runX402ReadChecks({ target: target.url })); + assert.match(results["X402-02"]?.hint ?? "", /PAYMENT-REQUIRED response header/); + } finally { + await target.close(); + } + }); + + it("X402-02 moves a v1 body challenge to v2", async () => { + const body = JSON.stringify({ x402Version: 1, accepts: [] }); + const target = await answer(402, { "content-type": "application/json" }, body); + try { + const results = byId(await runX402ReadChecks({ target: target.url })); + assert.match(results["X402-02"]?.hint ?? "", /Move to x402 v2/); + } finally { + await target.close(); + } + }); + + it("X402-03 explains an undecodable header", async () => { + const target = await answer(402, { "payment-required": "{not base64 json" }); + try { + const results = byId(await runX402ReadChecks({ target: target.url })); + assert.equal(results["X402-03"]?.pass, false); + assert.match(results["X402-03"]?.hint ?? "", /Base64-encode the JSON PaymentRequired/); + } finally { + await target.close(); + } + }); + + it("a passing check carries no hint", async () => { + const header = Buffer.from(JSON.stringify(v2(v2Option()))).toString("base64"); + const target = await answer(402, { "payment-required": header }); + try { + for (const result of await runX402ReadChecks({ target: target.url })) { + assert.equal(result.pass, true, result.id); + assert.equal(result.hint, undefined, result.id); + } + } finally { + await target.close(); + } + }); +}); + +describe("X402-06 and X402-07 hints", () => { + it("asks for PAYMENT-RESPONSE when it is missing", () => { + const read = readSettlementReference(200, () => { + throw new Error("Payment response header not found"); + }); + assert.ok("failure" in read); + assert.match( + "hint" in read ? read.hint : "", + /settle result base64-encoded in a PAYMENT-RESPONSE header/, + ); + }); + + it("asks for a successful settlement hash when the reference is not one", () => { + const read = readSettlementReference(200, () => ({ success: false, transaction: "" })); + assert.match("hint" in read ? read.hint : "", /success: true/); + }); + + it("tells a target that accepted a forged signature to verify", () => { + const verdict = signatureRejectionVerdict(200); + assert.equal(verdict.pass, false); + assert.match(verdict.hint ?? "", /verify step/); + }); + + it("gives a correct rejection no hint", () => { + const verdict = signatureRejectionVerdict(402); + assert.equal(verdict.pass, true); + assert.equal(verdict.hint, undefined); + }); +}); + +describe("fixFor", () => { + it("prefers the result's own hint, and links the docs page", () => { + const guidance = fixFor({ id: "X402-05", name: "n", pass: false, detail: "d", hint: "do this" }); + assert.deepEqual(guidance, { fix: "do this", docs: "https://usewasit.dev/docs/checks/x402" }); + }); + + it("falls back to the catalogue fix, per protocol page", () => { + const mpp = CHECK_CATALOGUE.find((entry) => entry.id === "MPP-11")!; + assert.deepEqual(fixFor({ id: "MPP-11", name: "n", pass: false, detail: "d" }), { + fix: mpp.fix, + docs: "https://usewasit.dev/docs/checks/mpp-channel-mode", + }); + assert.equal( + fixFor({ id: "MPP-01", name: "n", pass: false, detail: "d" })?.docs, + "https://usewasit.dev/docs/checks/mpp-charge-mode", + ); + }); + + // Enforces that every check can explain its own failure: there is no + // catalogued check whose FAIL comes without something to act on. + it("guides a failure of every catalogued check", () => { + for (const entry of CHECK_CATALOGUE) { + assert.ok(entry.fix.length > 0, entry.id); + assert.ok(fixFor({ id: entry.id, name: entry.name, pass: false, detail: "d" }), entry.id); + } + }); + + it("gives nothing for a pass, a skip or a no-verdict", () => { + assert.equal(fixFor({ id: "X402-01", name: "n", pass: true, detail: "d", hint: "x" }), undefined); + assert.equal( + fixFor({ id: "X402-01", name: "n", pass: false, detail: "d", skipped: true, skipReason: "r" }), + undefined, + ); + assert.equal( + fixFor({ + id: "X402-01", + name: "n", + pass: false, + detail: "d", + error: { kind: "unreachable", message: "m" }, + }), + undefined, + ); + }); +}); + +describe("structured output carries the guidance", () => { + it("adds fix and docs to a FAIL only", () => { + const run = toStructuredRun([ + { id: "X402-01", name: "a", pass: true, detail: "ok" }, + { id: "X402-04", name: "b", pass: false, detail: "Missing: asset.", hint: "Add `asset`." }, + ]); + assert.equal("fix" in run.results[0]!, false); + assert.equal("docs" in run.results[0]!, false); + assert.equal(run.results[1]!.fix, "Add `asset`."); + assert.equal(run.results[1]!.docs, "https://usewasit.dev/docs/checks/x402"); + }); +}); diff --git a/packages/core/test/unit/x402-networks.test.ts b/packages/core/test/unit/x402-networks.test.ts index c1137e3..c4b8f2a 100644 --- a/packages/core/test/unit/x402-networks.test.ts +++ b/packages/core/test/unit/x402-networks.test.ts @@ -155,7 +155,10 @@ describe("checkRequiredFields across payment options", () => { it("reads an option that is not an object as one with every field missing", () => { const result = checkRequiredFields(v2(option("stellar:testnet"), "oops")); - assert.equal(result.detail, "accepts[1]: Missing: amount, network, payTo."); + assert.equal( + result.detail, + "accepts[1]: Missing: scheme, network, amount, asset, payTo, maxTimeoutSeconds.", + ); }); }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 62df659..235edcf 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -22,6 +22,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { SERVER_VERSION } from "./version.js"; import { checkStatus, + fixFor, runMppChannelSuite, runMppChargeSuite, runX402PaymentChecks, @@ -60,6 +61,10 @@ const resultShape = { destructive: z.boolean(), /** Present only on ERROR: unreachable | configuration | harness | setup. */ errorKind: z.string().optional(), + /** Present only on FAIL: what to change. */ + fix: z.string().optional(), + /** Present only on FAIL: the check's docs page. */ + docs: z.string().optional(), }; const runOutputShape = { @@ -86,7 +91,12 @@ function present(results: CheckResult[]): { const lines = results.map((result) => { const flag = result.destructive === true ? " [destructive]" : ""; - return `${checkStatus(result)} ${result.id} ${result.name}${flag}\n ${result.detail}`; + const guidance = fixFor(result); + const advice = + guidance === undefined + ? "" + : `\n Fix: ${guidance.fix}${guidance.docs ? `\n Docs: ${guidance.docs}` : ""}`; + return `${checkStatus(result)} ${result.id} ${result.name}${flag}\n ${result.detail}${advice}`; }); const summaryLine = diff --git a/skills/wasit/SKILL.md b/skills/wasit/SKILL.md index 8595d71..e4a31fa 100644 --- a/skills/wasit/SKILL.md +++ b/skills/wasit/SKILL.md @@ -85,6 +85,9 @@ and a per-check result array. `outcome` is the field to read, not an exit code: an integer means nothing to an agent on its own, and `no-verdict` is never a pass. +A per-check `status` of `FAIL` carries `fix`, what the developer should change, +and `docs`, the check's documentation page. Relay `fix` as the next step. + A per-check `status` of `ERROR` carries an `errorKind` of `unreachable`, `configuration`, `setup` or `harness`, and means no verdict was reached about the target. Report it as such, never as a defect. `setup` in particular means a From 3da6b5fea65e28848a4ae981e1822596330d3426 Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Sun, 4 Oct 2026 09:04:50 +0800 Subject: [PATCH 3/4] feat(cli): wasit serve, a paywall that misbehaves on purpose Every check tests a service that sells. Builders whose agent pays had nothing to test it against: a real paywall behaves, and the lying servers written for the 0.6.0 A/B were never committed. wasit serve runs a local x402 paywall in one of four modes. no-settle serves for any payment without settling or sending PAYMENT-RESPONSE; wrong-settlement reports success for a transaction that is not the payment (a random hash by default, or --settlement-tx); wrong-network asks for stellar:pubnet; overprice asks for one million USDC. It prints one line per request saying what the agent did and what a careful agent does instead. It never settles or forwards a payment, so nothing it receives can move funds. Each mode lies in one way only: its challenge is a well-formed x402 v2 challenge that Wasit's read checks pass, carrying the extra field the exact scheme on Stellar requires, so an agent that falls for it fell for the misbehaviour. Against wasit test, the settlement modes reproduce the 0.6.0 A/B exactly (X402-06 and X402-07 fail), and wrong-network gets the payment checks skipped with nothing sent. The CLI guide gains "Testing an agent that pays" (/docs/cli/serve). 17 new offline tests; 12 mutants, all killed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 ++ docs/guides/cli.md | 48 ++++++ frontend/lib/docs-nav.ts | 1 + packages/cli/README.md | 13 ++ packages/cli/src/index.ts | 71 ++++++++ packages/cli/src/serve.ts | 214 +++++++++++++++++++++++++ packages/cli/test/unit/serve.test.ts | 231 +++++++++++++++++++++++++++ 7 files changed, 588 insertions(+) create mode 100644 packages/cli/src/serve.ts create mode 100644 packages/cli/test/unit/serve.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fa8994..8dd1ed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,16 @@ 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 — `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, +`wrong-settlement` cites a transaction that is not the payment, `wrong-network` +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. + **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 no option on the run's network and nothing was paid. diff --git a/docs/guides/cli.md b/docs/guides/cli.md index 0c4a3b8..b47f0f3 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -227,6 +227,54 @@ once. should be. Only the channel's operator knows these, so without all four the check is skipped rather than guessed at. +## Testing an agent that pays + +The other commands test a service that sells. `wasit serve` tests the other +side, an agent that pays: it runs a local x402 paywall that misbehaves in one +chosen way, so you can point your agent at it and watch what the agent does. + +```bash +wasit serve --mode no-settle +``` + +| Mode | What the paywall does | What a careful agent does | +|---|---|---| +| `no-settle` | Issues an honest challenge, then serves the resource for any payment without settling it and without a `PAYMENT-RESPONSE` header | Finds no `PAYMENT-RESPONSE` and does not count the payment as made | +| `wrong-settlement` | Issues an honest challenge, then serves the resource with a `PAYMENT-RESPONSE` reporting success for a transaction that is not this payment | Looks the transaction up on-chain before trusting it | +| `wrong-network` | Asks to be paid on `stellar:pubnet` (mainnet) instead of `stellar:testnet` | Refuses before signing anything | +| `overprice` | Asks for one million USDC (`10000000000000` base units) | Refuses a price above its spending limit | + +| Option | Default | Notes | +|---|---|---| +| `--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 | +| `--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 | + +Nothing is settled or forwarded in any mode, so no payment the server receives +can move funds. Your agent still needs a funded testnet wallet: payment +clients build and simulate the transfer to `--pay-to` before signing, which is +also why the payee must exist on testnet with a trustline for the asset. + +The server prints one line per request: the challenge it sent, or the payment +it received and what that says about the agent: + +``` +wasit serve: no-settle on http://127.0.0.1:4020/ (any path) +01:02:46 GET /paid: payment received for 10000 base units on stellar:testnet. + Served 200 without settling and without PAYMENT-RESPONSE. If your agent + now treats the payment as made, it trusts a paywall that took nothing. +``` + +Every mode's challenge is a well-formed x402 v2 challenge (`wasit test +--read-only` passes it), so an agent that falls for one fell for the +misbehaviour, not for a malformed message. Pointing `wasit test` itself at +`no-settle` or `wrong-settlement` shows what a non-conformant paywall looks +like from the checks' side: `X402-06` and `X402-07` fail. + ## Reading output ``` diff --git a/frontend/lib/docs-nav.ts b/frontend/lib/docs-nav.ts index 1568052..c8fa098 100644 --- a/frontend/lib/docs-nav.ts +++ b/frontend/lib/docs-nav.ts @@ -98,6 +98,7 @@ export const DOCS_NAV: DocNavEntry[] = [ { title: "x402 (Test)", slug: ["cli", "test-x402"] }, { title: "MPP Charge Mode", slug: ["cli", "mpp-charge"] }, { title: "MPP Channel Mode", slug: ["cli", "mpp-channel"] }, + { title: "Testing an agent that pays", slug: ["cli", "serve"] }, { title: "Reading output", slug: ["cli", "reading-output"] }, ], }, diff --git a/packages/cli/README.md b/packages/cli/README.md index 3e60fe0..1d3f55d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -119,6 +119,19 @@ Testnet-only helpers for the keys the subcommands above read from `.env`. There `create` prints a secret key to stdout — don't run it on a screen you're recording. +### `wasit serve` — a paywall that misbehaves, for testing an agent that pays + +```bash +wasit serve --mode no-settle # also: wrong-settlement, wrong-network, overprice +``` + +Runs a local x402 paywall that lies in one chosen way: serves without settling, +cites someone else's settlement, asks for mainnet, or asks for one million USDC. +Point your agent at `http://127.0.0.1:4020/` and the server reports what the +agent did. Nothing is settled or forwarded, so no funds move; the agent still +needs a funded testnet wallet, and `--pay-to` (default `STELLAR_PAYEE_ADDRESS`) +must be a testnet account with a USDC trustline. See `wasit serve --help`. + ### The interactive dashboard Running `wasit` with no arguments in a terminal opens a menu: the same three check runners, a catalogue browser, and a wallet screen, driven by arrow keys. A run shows a live elapsed timer and per-check progress, and `s` saves the finished run to `wasit--.json` — the same shape `--json` prints. Piped or in CI it prints help instead, so nothing that scripts Wasit today changes behaviour. See [`docs/guides/cli.md`](https://github.com/wasit-dev/wasit/blob/main/docs/guides/cli.md). diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 478bcd1..72741ea 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,6 +6,13 @@ import { Command } from "commander"; import { App } from "./dashboard/App.js"; import { CLI_VERSION } from "./version.js"; import { registerWalletCommand } from "./wallet-command.js"; +import { + MODE_DESCRIPTIONS, + SERVE_MODES, + createServeServer, + validateServeOptions, + type ServeMode, +} from "./serve.js"; import { CHECK_CATALOGUE, PROTOCOL_IDS, @@ -64,6 +71,7 @@ Examples: $ wasit mpp-charge --target https://api.example.com/paid-endpoint --payer-key S... $ wasit mpp-channel --target https://api.example.com/paid-endpoint $ wasit wallet create --role mpp-charge --fund + $ wasit serve --mode no-settle # a lying paywall, to test an agent that pays Run "wasit --help" for that command's own options and cost notes. Add --json to test/mpp-charge/mpp-channel for machine-readable output. @@ -265,6 +273,69 @@ signature. See docs/CHECKS.md for what each check ID verifies.`, process.exit(report(results, jsonMode)); }); +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("--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)", + ) + .option("--amount ", "Price in base units for every mode except overprice", "10000") + .option("--asset ", "Token contract (default: testnet USDC)") + .option( + "--settlement-tx ", + "wrong-settlement: transaction to cite (default: a random hash that exists nowhere)", + ) + .addHelpText( + "after", + ` +Modes: +${SERVE_MODES.map((mode) => ` ${mode.padEnd(17)} ${MODE_DESCRIPTIONS[mode].does}`).join("\n")} + +Point your agent at http://127.0.0.1:4020/ (any path) and watch what it does. +The server never settles and never forwards a payment, so nothing it receives +moves funds. Your agent still needs a funded testnet wallet: payment clients +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`, + ) + .action((opts) => { + const options = { + mode: opts.mode as ServeMode, + payTo: (opts.payTo as string | undefined) ?? process.env.STELLAR_PAYEE_ADDRESS ?? "", + amount: opts.amount as string, + ...(opts.asset ? { asset: opts.asset as string } : {}), + ...(opts.settlementTx ? { settlementTx: opts.settlementTx as string } : {}), + log: (line: string) => console.log(`${new Date().toISOString().slice(11, 19)} ${line}`), + }; + const problem = validateServeOptions(options); + if (problem !== undefined) { + console.error(problem); + process.exit(2); + } + + const port = Number(opts.port); + const server = createServeServer(options); + server.on("error", (error) => { + console.error(`Could not listen on ${opts.host}:${opts.port}: ${(error as Error).message}`); + process.exit(2); + }); + 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(` 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"); + }); + process.on("SIGINT", () => server.close(() => process.exit(0))); + }); + program .command("mpp-channel") .description("Run MPP channel-mode compliance checks against a target service") diff --git a/packages/cli/src/serve.ts b/packages/cli/src/serve.ts new file mode 100644 index 0000000..7a705d0 --- /dev/null +++ b/packages/cli/src/serve.ts @@ -0,0 +1,214 @@ +/** + * `wasit serve`: a local x402 paywall that misbehaves on purpose. + * + * Wasit's checks test a service that sells. This tests an agent that pays: + * point the agent at the server and it answers the way a broken or dishonest + * paywall would, then says what the agent did. The server never settles and + * never forwards a payment anywhere, so nothing it receives can move funds. + * + * 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. + */ + +import { randomBytes } from "node:crypto"; +import http from "node:http"; + +export const SERVE_MODES = ["no-settle", "wrong-settlement", "wrong-network", "overprice"] as const; +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; + +/** 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(); + +const DEFAULT_AMOUNT = "10000"; + +/** What each mode does, and what an agent that pays carefully does about it. */ +export const MODE_DESCRIPTIONS: Readonly> = { + "no-settle": { + does: "issues an honest challenge, then serves the resource for any payment without settling it and without a PAYMENT-RESPONSE header.", + careful: "finds no PAYMENT-RESPONSE and does not count the payment as made.", + }, + "wrong-settlement": { + does: "issues an honest challenge, then serves the resource with a PAYMENT-RESPONSE that reports success for a transaction that is not this payment.", + careful: "looks the reported transaction up on-chain before trusting it, and finds it is not this payment.", + }, + "wrong-network": { + does: "asks to be paid on stellar:pubnet (mainnet) instead of stellar:testnet.", + careful: "refuses a challenge on a network it was not set up to pay on, before signing anything.", + }, + overprice: { + does: `asks for ${OVERPRICE_AMOUNT} base units, one million USDC.`, + careful: "refuses a price above its spending limit, before signing anything.", + }, +}; + +export interface ServeOptions { + readonly mode: ServeMode; + /** The payee. Must exist on testnet with a trustline for `asset`: payment clients simulate the transfer to it. */ + readonly payTo: string; + readonly asset?: string; + /** Price in base units for every mode except `overprice`. */ + readonly amount?: string; + /** For `wrong-settlement`: the transaction to cite. Defaults to a random hash that exists nowhere. */ + readonly settlementTx?: string; + /** Receives one line per event, for the terminal. */ + readonly log?: (line: string) => void; +} + +const STELLAR_ACCOUNT = /^G[A-Z2-7]{55}$/; +const TRANSACTION_HASH = /^[0-9a-f]{64}$/i; +const BASE_UNITS = /^[1-9][0-9]*$/; + +/** 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.` + ); + } + 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.`; + } + return undefined; +} + +/** The challenge a mode issues, as the x402 v2 `PaymentRequired` object. */ +export function challengeFor(options: ServeOptions, resourceUrl: string): Record { + return { + x402Version: 2, + error: "PAYMENT-SIGNATURE header is required", + resource: { url: resourceUrl, description: "Paid resource served by wasit serve", mimeType: "application/json" }, + 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, + payTo: options.payTo, + maxTimeoutSeconds: 60, + extra: { areFeesSponsored: true }, + }, + ], + }; +} + +function encode(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64"); +} + +/** The payment a client sent, as far as the server needs to describe it. */ +interface ReceivedPayment { + readonly network?: string; + readonly amount?: string; +} + +function readPayment(header: string): ReceivedPayment | undefined { + try { + const payload = JSON.parse(Buffer.from(header, "base64").toString("utf-8")) as { + accepted?: { network?: unknown; amount?: unknown }; + }; + if (typeof payload !== "object" || payload === null) return undefined; + return { + ...(typeof payload.accepted?.network === "string" ? { network: payload.accepted.network } : {}), + ...(typeof payload.accepted?.amount === "string" ? { amount: payload.accepted.amount } : {}), + }; + } catch { + return undefined; + } +} + +/** What the server tells the operator after serving a payment, per mode. */ +function verdictFor(options: ServeOptions, payment: ReceivedPayment, settlementTx: string): string { + switch (options.mode) { + case "no-settle": + return ( + "Served 200 without settling and without PAYMENT-RESPONSE. If your agent " + + "now treats the payment as made, it trusts a paywall that took nothing." + ); + case "wrong-settlement": + return ( + `Served 200 with PAYMENT-RESPONSE citing tx ${settlementTx}, which is not ` + + `this payment. If your agent accepts that without looking it up, a ` + + `dishonest server can claim any settlement.` + ); + case "wrong-network": + return ( + `Your agent paid a challenge on ${payment.network ?? "stellar:pubnet"} ` + + `(mainnet). An agent set up for testnet should have refused it.` + ); + case "overprice": + return ( + `Your agent agreed to pay ${payment.amount ?? OVERPRICE_AMOUNT} base units` + + `${(options.asset ?? TESTNET_USDC) === TESTNET_USDC ? " (one million USDC)" : ""}. ` + + `An agent with a spending limit should have refused.` + ); + } +} + +/** + * Builds the server. Answers every path: a request without a payment header + * gets the mode's 402 challenge; one with a payment gets the mode's answer. + * Nothing is settled or forwarded in any mode. + */ +export function createServeServer(options: ServeOptions): http.Server { + const log = options.log ?? (() => {}); + const settlementTx = options.settlementTx ?? randomBytes(32).toString("hex"); + + return http.createServer((request, response) => { + const where = `${request.method ?? "GET"} ${request.url ?? "/"}`; + // v2 sends PAYMENT-SIGNATURE; X-PAYMENT is the v1 name, still seen in the wild. + const header = request.headers["payment-signature"] ?? request.headers["x-payment"]; + const paymentHeader = Array.isArray(header) ? header[0] : header; + + if (paymentHeader === undefined) { + const url = `http://${request.headers.host ?? "localhost"}${request.url ?? "/"}`; + const challenge = challengeFor(options, url); + const accept = (challenge["accepts"] as Array>)[0]!; + log(`${where}: 402 challenge, ${String(accept["amount"])} base units on ${String(accept["network"])}`); + response.writeHead(402, { + "PAYMENT-REQUIRED": encode(challenge), + "Content-Type": "application/json", + }); + response.end("{}"); + return; + } + + const payment = readPayment(paymentHeader); + if (payment === undefined) { + log(`${where}: payment header did not decode to JSON; answered 402.`); + response.writeHead(402, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: "invalid payment header" })); + return; + } + + const headers: Record = { "Content-Type": "application/json" }; + if (options.mode === "wrong-settlement") { + headers["PAYMENT-RESPONSE"] = encode({ + success: true, + transaction: settlementTx, + network: payment.network ?? "stellar:testnet", + }); + } + log( + `${where}: payment received for ${payment.amount ?? "?"} base units on ` + + `${payment.network ?? "?"}. ${verdictFor(options, payment, settlementTx)}`, + ); + response.writeHead(200, headers); + response.end(JSON.stringify({ resource: "paid content from wasit serve" })); + }); +} diff --git a/packages/cli/test/unit/serve.test.ts b/packages/cli/test/unit/serve.test.ts new file mode 100644 index 0000000..8e93fda --- /dev/null +++ b/packages/cli/test/unit/serve.test.ts @@ -0,0 +1,231 @@ +/** + * `wasit serve`: the paywall that misbehaves on purpose. + * + * Each mode must lie in exactly the way it says and no other: its challenge + * must still be a well-formed x402 v2 challenge (Wasit's own read checks pass + * it), so an agent that falls for it fell for the misbehaviour, not for a + * malformed message. No mode may ever settle or forward a payment. + */ + +import assert from "node:assert/strict"; +import type http from "node:http"; +import { after, before, describe, it } from "node:test"; + +import { runX402ReadChecks } from "@wasit-dev/core"; + +import { + OVERPRICE_AMOUNT, + SERVE_MODES, + TESTNET_USDC, + challengeFor, + createServeServer, + validateServeOptions, + type ServeMode, +} from "../../src/serve.js"; + +const PAY_TO = "GBNCC3VFT7PGUMGDQU5O35SXVWWTAHCO4LFFWLYFBLQCD56DNWYWM6ZS"; +const CITED_TX = "02f56c0a9c1c702d504fc168013dbe4d4f1ce3bda52d7c1d432d60a6afcecfe8"; + +function decode(header: string | null): Record { + assert.ok(header, "header present"); + return JSON.parse(Buffer.from(header, "base64").toString("utf-8")) as Record; +} + +function paymentHeader(network: string, amount: string): string { + return Buffer.from( + JSON.stringify({ + x402Version: 2, + accepted: { scheme: "exact", network, amount }, + payload: { transaction: "AAAA" }, + }), + ).toString("base64"); +} + +async function start( + mode: ServeMode, + extra: { settlementTx?: 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 }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as { port: number }; + return { url: `http://127.0.0.1:${port}/paid`, lines, server }; +} + +function stop(server: http.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +describe("validateServeOptions", () => { + it("accepts a well-formed configuration", () => { + assert.equal(validateServeOptions({ mode: "no-settle", payTo: PAY_TO }), undefined); + }); + + it("rejects an unknown mode, a bad payee, amount or hash", () => { + assert.match(validateServeOptions({ mode: "honest" as ServeMode, payTo: PAY_TO }) ?? "", /Unknown mode/); + assert.match(validateServeOptions({ mode: "no-settle", payTo: "" }) ?? "", /--pay-to/); + assert.match(validateServeOptions({ mode: "no-settle", payTo: "0xabc" }) ?? "", /--pay-to/); + assert.match(validateServeOptions({ mode: "no-settle", payTo: PAY_TO, amount: "0" }) ?? "", /--amount/); + assert.match(validateServeOptions({ mode: "no-settle", payTo: PAY_TO, amount: "1.5" }) ?? "", /--amount/); + assert.match( + validateServeOptions({ mode: "wrong-settlement", payTo: PAY_TO, settlementTx: "abc" }) ?? "", + /--settlement-tx/, + ); + }); +}); + +describe("challengeFor", () => { + function accept(mode: ServeMode): Record { + return (challengeFor({ mode, payTo: PAY_TO }, "http://x/paid")["accepts"] as Array< + Record + >)[0]!; + } + + it("is honest except for the one thing each mode is about", () => { + for (const mode of ["no-settle", "wrong-settlement"] as const) { + const option = accept(mode); + assert.equal(option["network"], "stellar:testnet", mode); + assert.equal(option["amount"], "10000", mode); + } + assert.equal(accept("wrong-network")["network"], "stellar:pubnet"); + assert.equal(accept("wrong-network")["amount"], "10000"); + assert.equal(accept("overprice")["network"], "stellar:testnet"); + assert.equal(accept("overprice")["amount"], OVERPRICE_AMOUNT); + }); + + it("asks for one million USDC in overprice mode", () => { + // Stellar assets have 7 decimal places. + assert.equal(OVERPRICE_AMOUNT, "10000000000000"); + }); + + it("carries what the exact scheme on Stellar requires", () => { + const option = accept("no-settle"); + assert.equal(option["asset"], TESTNET_USDC); + assert.equal(option["payTo"], PAY_TO); + assert.deepEqual(option["extra"], { areFeesSponsored: true }); + }); +}); + +describe("every mode's challenge is a well-formed x402 v2 challenge", () => { + for (const mode of SERVE_MODES) { + it(`${mode}: Wasit's read checks pass it`, async () => { + const { url, server } = await start(mode); + 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); + } + }); + } +}); + +describe("answers to a payment", () => { + it("no-settle serves 200 without PAYMENT-RESPONSE, and says so", async () => { + const { url, lines, server } = await start("no-settle"); + try { + const response = await fetch(url, { + headers: { "PAYMENT-SIGNATURE": paymentHeader("stellar:testnet", "10000") }, + }); + assert.equal(response.status, 200); + assert.equal(response.headers.get("payment-response"), null); + assert.match(lines.at(-1) ?? "", /payment received for 10000 base units on stellar:testnet/); + assert.match(lines.at(-1) ?? "", /without settling/); + } finally { + await stop(server); + } + }); + + it("wrong-settlement cites the given transaction as a success", async () => { + const { url, lines, server } = await start("wrong-settlement", { settlementTx: CITED_TX }); + try { + const response = await fetch(url, { + headers: { "PAYMENT-SIGNATURE": paymentHeader("stellar:testnet", "10000") }, + }); + assert.equal(response.status, 200); + const settlement = decode(response.headers.get("payment-response")); + assert.deepEqual(settlement, { success: true, transaction: CITED_TX, network: "stellar:testnet" }); + assert.match(lines.at(-1) ?? "", new RegExp(`citing tx ${CITED_TX}`)); + } finally { + await stop(server); + } + }); + + it("wrong-settlement defaults to a well-formed hash that is new each run", async () => { + const hashes: string[] = []; + for (let run = 0; run < 2; run++) { + const { url, server } = await start("wrong-settlement"); + try { + const response = await fetch(url, { + headers: { "PAYMENT-SIGNATURE": paymentHeader("stellar:testnet", "10000") }, + }); + hashes.push(String(decode(response.headers.get("payment-response"))["transaction"])); + } finally { + await stop(server); + } + } + for (const hash of hashes) assert.match(hash, /^[0-9a-f]{64}$/); + assert.notEqual(hashes[0], hashes[1]); + }); + + it("wrong-network names the mainnet payment the agent made", async () => { + 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\)/); + } finally { + await stop(server); + } + }); + + it("overprice names the price the agent agreed to", async () => { + const { url, lines, server } = await start("overprice"); + try { + await fetch(url, { + headers: { "PAYMENT-SIGNATURE": paymentHeader("stellar:testnet", OVERPRICE_AMOUNT) }, + }); + assert.match(lines.at(-1) ?? "", /agreed to pay 10000000000000 base units \(one million USDC\)/); + } finally { + await stop(server); + } + }); + + it("treats the v1 header name as a payment too", async () => { + const { url, server } = await start("no-settle"); + try { + const response = await fetch(url, { + headers: { "X-PAYMENT": paymentHeader("stellar:testnet", "10000") }, + }); + assert.equal(response.status, 200); + } finally { + await stop(server); + } + }); + + it("answers an undecodable payment header with 402", async () => { + const { url, lines, server } = await start("no-settle"); + try { + const response = await fetch(url, { headers: { "PAYMENT-SIGNATURE": "not base64 json" } }); + assert.equal(response.status, 402); + assert.match(lines.at(-1) ?? "", /did not decode/); + } finally { + await stop(server); + } + }); +}); + +describe("the challenge names the URL the agent asked for", () => { + let target: { url: string; server: http.Server }; + before(async () => { + target = await start("no-settle"); + }); + after(async () => { + await stop(target.server); + }); + + it("echoes the requested path in resource.url", async () => { + const response = await fetch(`${target.url}?q=1`); + const challenge = decode(response.headers.get("payment-required")); + assert.match(String((challenge["resource"] as { url: string }).url), /\/paid\?q=1$/); + }); +}); From c57c96ad55a20ad92f6a7ac639e161a658e85261 Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Sun, 4 Oct 2026 09:09:11 +0800 Subject: [PATCH 4/4] docs(checks): common failures and fixes, by check Every FAIL now carries its own fix, but a builder reading the docs before a run, or comparing two failures, had nowhere that collected them. The check catalogue gains a "Common failures and fixes" page: for each check, what the failure looks like, why, and what fixes it. It draws only on what is established: the hints in core, the divergence classes in the conformance findings, Findings 4 and 5 for MPP, and the 0.6.0 verification run. The CAIP-2 table lists Stellar's two networks, the x402 v2 spec's own Base and Solana examples, and BNB Smart Chain's EIP-155 chain ids (ethereum-lists/chains). No table row opens with a check id, so the site's check count and the catalogue test still see 13. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ docs/CHECKS.md | 138 +++++++++++++++++++++++++++++++++++++++ frontend/lib/docs-nav.ts | 1 + 3 files changed, 143 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd1ed6..8788de1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,10 @@ route, a missing `PAYMENT-RESPONSE` is told what to return. Otherwise it is the check's general fix, now part of every catalogue entry (`fix`). Only a FAIL carries one; a skip or a no-verdict is not a defect. New in core: `fixFor()`, `catalogueEntry()`, `docsUrlFor()`, and an optional `hint` on `CheckResult`. +The check catalogue gains "Common failures and fixes" +(`/docs/checks/common-failures`): the failures builders hit most, by check, with +the reasoning, the CAIP-2 ids of common networks, and links to where each was +met in a real implementation. **Changed — `X402-04` checks every field the advertised version requires.** It checked three: the price, `network` and `payTo`. x402 v2 requires `scheme`, diff --git a/docs/CHECKS.md b/docs/CHECKS.md index c353474..6250cdc 100644 --- a/docs/CHECKS.md +++ b/docs/CHECKS.md @@ -206,3 +206,141 @@ payment rather than authorisation; configuring it with the funder's key produces a transaction that reaches the chain and fails there with an opaque `scecInvalidAction`, which the SDK surfaces as `[object Object]`. By contrast, `close_start()` requires `from.require_auth()` — the funder. Both details are documented in [docs/findings/upstream-sdk.md](findings/upstream-sdk.md) for upstream reporting. + +## Common failures and fixes + +The failures builders hit most often, what each looks like in a run, and what +fixes it. Every FAIL in a run already carries its own `Fix` line; this page +collects them by check, with the reasoning. Where a failure was met in a real +implementation, it links the write-up. + +### The unpaid request does not get a 402 (`X402-01`) + +- **200**: the route serves without payment. The payment middleware is not in + front of it, or protects a different path. +- **401 or 403**: the endpoint asks for authentication instead of payment. + x402 clients act only on 402. +- **404 or 405**: usually the wrong path or method. An endpoint that computes + something often takes POST: `wasit test --method POST --body '{...}'` (MCP: + `method` and `body`). + +### There is no payment header (`X402-02`) + +x402 v2 carries the challenge base64-encoded in the `PAYMENT-REQUIRED` response +header. A service that puts an x402 v1 challenge in the response body fails +here, and the result says it found one; its terms are still checked. The +`exact` scheme on Stellar is defined for v2 only. This is the divergence Wasit +has met in a real implementation +([conformance findings](findings/conformance-findings.md), class 1). + +### The header does not decode (`X402-03`) + +The header value must be the base64 of the JSON `PaymentRequired` object. Raw +JSON, or a value cut short, does not decode. + +### A field is missing or has the wrong type (`X402-04`) + +Every option in `accepts` needs every field its `x402Version` requires: for v2, +`scheme`, `network`, `amount`, `asset`, `payTo` and `maxTimeoutSeconds`. The +usual slips in a challenge built by hand: + +- `maxTimeoutSeconds` left out. The official x402 server SDK sets it to 300 + when you do not. +- `amount` sent as a number. It is a string of the token's smallest units, + such as `"10000"`. +- The v1 price name `maxAmountRequired` in a v2 challenge. v2 calls it + `amount`. + +A challenge built with the official server SDK carries every field. + +### The network id is rejected (`X402-05`) + +x402 v2 names each network with a CAIP-2 id, `namespace:reference`: + +| Network | CAIP-2 id | +|---|---| +| Stellar testnet | `stellar:testnet` | +| Stellar mainnet | `stellar:pubnet` | +| Base Sepolia | `eip155:84532` | +| Base | `eip155:8453` | +| BNB Smart Chain testnet | `eip155:97` | +| BNB Smart Chain | `eip155:56` | +| Solana devnet | `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | +| Solana mainnet | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | + +The Stellar ids come from the `exact` scheme on Stellar, the Base and Solana ids +are the x402 v2 specification's own examples (§11.1), and the BNB Smart Chain +ids are its EIP-155 chain ids. The usual slips: + +- A v1 name such as `stellar-testnet` or `base-sepolia` + ([conformance findings](findings/conformance-findings.md), class 2). +- A hex chain id. `eth_chainId` returns hex; the CAIP-2 reference is base 10, + so `eip155:0x14a34` is `eip155:84532`. +- `stellar:mainnet`. Stellar's mainnet is `stellar:pubnet`. +- A Solana cluster name. The reference is the first 32 characters of the + cluster's genesis hash. + +### A valid payment is refused (`X402-06`) + +Wasit built the payment with the official client, for exactly the advertised +terms, and the target refused it. The target's own log of the facilitator's +verify or settle response says why. + +### The paid response has no `PAYMENT-RESPONSE` (`X402-06`) + +After settling, the server returns the facilitator's settle result, +base64-encoded, in `PAYMENT-RESPONSE`; it names the settlement transaction. +Without it a client cannot tell whether the payment settled, so `X402-06` +fails. The official x402 middleware sends it. A server that serves without +settling looks exactly like this, and `wasit serve --mode no-settle` is one, +for testing an agent against it. + +### The settlement does not match (`X402-06`) + +The transaction `PAYMENT-RESPONSE` names must have succeeded and moved exactly +`amount` of `asset` from this run's payer to `payTo`, in one transfer. A server +that cites another transaction fails, even a real and successful one +([0.6.0 verification run](evidence/2026-09-30-x402-0.6.0-verification-run.md)). + +### A forged signature is accepted (`X402-07`) + +The target served a payment whose authorization signature was corrupted: it +decoded the payment without verifying it. Pass every payment to the +facilitator's verify step and refuse it when verification fails. + +### A charge to a muxed recipient is refused (`MPP-01`) + +A charge server on `@stellar/mpp` 0.7.1 configured with a muxed (`M...`) +recipient cannot verify the payment and refuses it. Since CAP-67, a transfer +to a muxed address reports its amount in a form the SDK does not read. Use a +`G...` recipient until the SDK handles it +([Finding 5](findings/upstream-sdk.md), reported as +[stellar/stellar-mpp-sdk#89](https://github.com/stellar/stellar-mpp-sdk/issues/89)). +Wasit reads both forms. + +### A stale or replayed voucher is accepted, or refused with 500 (`MPP-11`, `MPP-12`, `MPP-14`) + +The server must refuse with 402: a commitment that does not exceed the stored +cumulative or does not cover the price, a second credential for a challenge +already used, and an accepted commitment presented under a new challenge. The +`@stellar/mpp` channel server enforces all three. Refusals reported as HTTP 500 +instead of 402 were seen on the SDK's `main` branch between its `mppx` 0.10.1 +upgrade and the fix in #83, never in a release +([Finding 4](findings/upstream-sdk.md)). + +### No verdict on the channel checks (`ERROR (setup)`) + +`MPP-11`, `MPP-12` and `MPP-14` each need one correctly advancing commitment +accepted first. When three attempts are refused, the run reports no verdict +rather than a failure, because a channel another payer is advancing at the same +time looks the same from the client. Re-run against a channel nothing else is +paying through. + +### The payment checks cannot pay + +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). diff --git a/frontend/lib/docs-nav.ts b/frontend/lib/docs-nav.ts index c8fa098..38d0905 100644 --- a/frontend/lib/docs-nav.ts +++ b/frontend/lib/docs-nav.ts @@ -151,6 +151,7 @@ export const DOCS_NAV: DocNavEntry[] = [ { title: "x402", slug: ["checks", "x402"] }, { title: "MPP - Charge Mode", slug: ["checks", "mpp-charge-mode"] }, { title: "MPP - Channel Mode", slug: ["checks", "mpp-channel-mode"] }, + { title: "Common failures and fixes", slug: ["checks", "common-failures"] }, ], }, {