From 21f7bdce586ce5ec01bf9513fa2ed49832cbc44f Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Mon, 5 Oct 2026 21:54:38 +0800 Subject: [PATCH 1/6] feat(core): no verdict when the payer cannot cover the price A payer that cannot fund the payment got its refusal reported as the target's: on Solana devnet X402-06 failed with "got 402 (invalid_exact_svm_transaction_simulation_failed)", a reason real defects give too, so it cannot be told apart afterwards. On Stellar the SDK client's own simulation stopped the build first, reported as a harness error with only the token's code (Error(Contract, #13)). X402-06 now reads the payer's balance of the advertised asset once the option to pay is known and before anything is signed. When the chain reports less than the price, X402-06 is ERROR (setup), naming the payer key to fund, nothing is signed or sent, and the negative checks are skipped. When the balance cannot be read, the run pays as before, so a failed read never becomes a verdict. Read before signing, so a target that settles and then refuses still shows as one. Each adapter reads it its own way: EVM through ERC-20 balanceOf; Solana as the sum of the payer's token accounts for the mint, never less than what the client can spend; Stellar by simulating the token's balance, and, since a Stellar Asset Contract reports a missing trustline as an error, by looking the trustline up when the token is that asset's SAC (recognised by its own contract id, the issuer excepted). Live, with fresh unfunded payers: ERROR (setup) on Stellar, Base Sepolia and Solana devnet; with the funded payers every x402 fixture passes 10/10. 227 core tests; 7 mutants killed. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/x402/chains/evm.ts | 11 ++ packages/core/src/x402/chains/stellar.ts | 103 ++++++++++++- packages/core/src/x402/chains/svm.ts | 20 +++ packages/core/src/x402/chains/types.ts | 8 ++ packages/core/src/x402/simulator.ts | 56 +++++++- packages/core/test/unit/payer-balance.test.ts | 95 ++++++++++++ .../core/test/unit/payment-negatives.test.ts | 136 +++++++++++++++++- 7 files changed, 418 insertions(+), 11 deletions(-) create mode 100644 packages/core/test/unit/payer-balance.test.ts diff --git a/packages/core/src/x402/chains/evm.ts b/packages/core/src/x402/chains/evm.ts index 4abc1f9..67b8e05 100644 --- a/packages/core/src/x402/chains/evm.ts +++ b/packages/core/src/x402/chains/evm.ts @@ -18,7 +18,9 @@ import { ExactEvmScheme } from "@x402/evm/exact/client"; import { TransactionReceiptNotFoundError, createPublicClient, + erc20Abi, http, + isAddress, isAddressEqual, toEventSelector, type Chain, @@ -273,6 +275,15 @@ export const evmChain: PaymentChain = { // validBefore is now + 1 second; Base Sepolia blocks come every 2 seconds. expiredSigning: async () => ({ terms: { maxTimeoutSeconds: 1 }, holdMs: 5_000 }), payerAddress: (payerKey) => payerAccount(payerKey).address, + async payerBalance(_network, rpcUrl, payer, asset) { + if (!isAddress(asset) || !isAddress(payer)) return undefined; + return createPublicClient({ transport: http(rpcUrl) }).readContract({ + address: asset, + abi: erc20Abi, + functionName: "balanceOf", + args: [payer], + }); + }, isSettlementReference: (reference) => TRANSACTION_HASH.test(reference), verifySettlement: verifyEvmSettlement, corruptPayload: corruptEvmSignature, diff --git a/packages/core/src/x402/chains/stellar.ts b/packages/core/src/x402/chains/stellar.ts index 0fc0a42..1ec4b44 100644 --- a/packages/core/src/x402/chains/stellar.ts +++ b/packages/core/src/x402/chains/stellar.ts @@ -7,10 +7,21 @@ import { createEd25519Signer } from "@x402/stellar"; import { ExactStellarScheme as ExactStellarClientScheme } from "@x402/stellar/exact/client"; -import { Keypair, xdr } from "@stellar/stellar-sdk"; +import { + Account, + Address, + Asset, + Contract, + Keypair, + StrKey, + TransactionBuilder, + rpc, + scValToNative, + xdr, +} from "@stellar/stellar-sdk"; import { CheckSetupError } from "../../errors.js"; -import { resolveRpcUrl } from "../../mpp/network.js"; +import { networkPassphrase, resolveRpcUrl } from "../../mpp/network.js"; import { verifySettlement } from "../../settlement.js"; import type { PaymentChain } from "./types.js"; @@ -67,6 +78,93 @@ export function corruptAuthSignature(transaction: string): string { return envelope.toXDR("base64"); } +/** + * A format-valid all-zero account, as the simulation source: a read-only + * simulation never touches it, so it need not exist (channel-commitment.ts + * uses the same). + */ +const SIMULATION_SOURCE = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + +/** A read-only contract call, simulated; undefined unless it succeeds with a value. */ +async function simulateTokenRead( + server: rpc.Server, + passphrase: string, + contractId: string, + method: string, + args: xdr.ScVal[], +): Promise { + const transaction = new TransactionBuilder(new Account(SIMULATION_SOURCE, "0"), { + fee: "100", + networkPassphrase: passphrase, + }) + .addOperation(new Contract(contractId).call(method, ...args)) + .setTimeout(30) + .build(); + const simulation = await server.simulateTransaction(transaction); + if ( + !rpc.Api.isSimulationSuccess(simulation) || + rpc.Api.isSimulationRestore(simulation) || + simulation.result === undefined + ) { + return undefined; + } + return scValToNative(simulation.result.retval); +} + +/** + * The classic asset a Stellar Asset Contract wraps, from the `name` it + * reports ("CODE:ISSUER", CAP-46-06), only when that asset's contract id is + * `contractId`: any contract can return such a name, but only the SAC has + * the id derived from the asset. + */ +export function sacAssetFromName(name: unknown, contractId: string, passphrase: string): Asset | undefined { + if (typeof name !== "string") return undefined; + const match = /^([A-Za-z0-9]{1,12}):(G[A-Z2-7]{55})$/.exec(name); + if (match === null || !StrKey.isValidEd25519PublicKey(match[2]!)) return undefined; + const asset = new Asset(match[1]!, match[2]!); + return asset.contractId(passphrase) === contractId ? asset : undefined; +} + +/** + * The payer's balance of a SEP-41 token, read by simulating its `balance`. + * + * A Stellar Asset Contract reports a payer without a trustline as an error, + * not as zero, and an unfunded payer is the usual case. So when `balance` + * gives no value and the token is a SAC, the trustline is looked up: none + * means the payer holds nothing. The asset's issuer needs no trustline, so it + * is left unread. Anything else gives undefined. + */ +async function stellarTokenBalance( + network: string, + rpcUrl: string, + payer: string, + asset: string, +): Promise { + if (!StrKey.isValidContract(asset) || !StrKey.isValidEd25519PublicKey(payer)) return undefined; + const passphrase = networkPassphrase(network); + const server = new rpc.Server(rpcUrl); + const balance = await simulateTokenRead(server, passphrase, asset, "balance", [ + new Address(payer).toScVal(), + ]); + if (typeof balance === "bigint") return balance; + + const classic = sacAssetFromName( + await simulateTokenRead(server, passphrase, asset, "name", []), + asset, + passphrase, + ); + if (classic === undefined || classic.getIssuer() === payer) return undefined; + const { entries } = await server.getLedgerEntries( + xdr.LedgerKey.trustline( + new xdr.LedgerKeyTrustLine({ + accountId: Keypair.fromPublicKey(payer).xdrAccountId(), + asset: classic.toTrustLineXDRObject(), + }), + ), + ); + return entries.length === 0 ? 0n : undefined; +} + export const stellarChain: PaymentChain = { name: "Stellar", networks: ["stellar:testnet", "stellar:pubnet"], @@ -81,6 +179,7 @@ export const stellarChain: PaymentChain = { // every 5 to 6 seconds, so 20 seconds is three or more ledgers past it. expiredSigning: async () => ({ terms: { maxTimeoutSeconds: 1 }, holdMs: 20_000 }), payerAddress: (payerKey) => Keypair.fromSecret(payerKey).publicKey(), + payerBalance: stellarTokenBalance, isSettlementReference: (reference) => TRANSACTION_HASH.test(reference), verifySettlement, corruptPayload: (payload) => ({ diff --git a/packages/core/src/x402/chains/svm.ts b/packages/core/src/x402/chains/svm.ts index 85746aa..f2b6502 100644 --- a/packages/core/src/x402/chains/svm.ts +++ b/packages/core/src/x402/chains/svm.ts @@ -18,6 +18,7 @@ import { createPrivateKey } from "node:crypto"; import { DEVNET_RPC_URL, SOLANA_DEVNET_CAIP2 } from "@x402/svm"; import { ExactSvmScheme } from "@x402/svm/exact/client"; import { + address, createKeyPairSignerFromBytes, createSolanaRpc, getBase58Decoder, @@ -389,6 +390,25 @@ export const svmChain: PaymentChain = { }; }, payerAddress: (payerKey) => getBase58Decoder().decode(secretKeyBytes(payerKey).subarray(32)), + async payerBalance(_network, rpcUrl, payer, asset) { + // Every token account the payer holds for the mint, summed: the client + // pays from the associated one only, so this is never less than what it + // can spend, and a sum below the price means the payment cannot settle. + const { value } = await createSolanaRpc(rpcUrl) + .getTokenAccountsByOwner(address(payer), { mint: address(asset) }, { + encoding: "jsonParsed", + commitment: "confirmed", + }) + .send(); + let total = 0n; + for (const account of value) { + const amount = (account.account.data as { parsed?: { info?: { tokenAmount?: { amount?: unknown } } } }) + .parsed?.info?.tokenAmount?.amount; + if (typeof amount !== "string" || !/^\d+$/.test(amount)) return undefined; + total += BigInt(amount); + } + return total; + }, isSettlementReference(reference) { // kit's isSignature throws, rather than answering false, on a string of // the right length outside the base58 alphabet (an EVM hash, for one). diff --git a/packages/core/src/x402/chains/types.ts b/packages/core/src/x402/chains/types.ts index caf78d0..d9f0cd1 100644 --- a/packages/core/src/x402/chains/types.ts +++ b/packages/core/src/x402/chains/types.ts @@ -67,6 +67,14 @@ export interface PaymentChain { expiredSigning(selected: PaymentRequirements, rpcUrl: string): Promise; /** The payer's address, which the settlement must come from. */ payerAddress(payerKey: string): string; + /** + * The payer's balance of `asset`, in base units, as the chain reports it, + * or at most what the payer can spend from (never more than it holds). + * Undefined, or a throw, when the chain gives no clear answer: X402-06 then + * pays as if the balance were enough, so a failed read never becomes a + * verdict. + */ + payerBalance(network: string, rpcUrl: string, payer: string, asset: string): Promise; /** Whether `reference` has the shape of a settlement on this chain. */ isSettlementReference(reference: string): boolean; /** diff --git a/packages/core/src/x402/simulator.ts b/packages/core/src/x402/simulator.ts index 9f44e0a..1d3e068 100644 --- a/packages/core/src/x402/simulator.ts +++ b/packages/core/src/x402/simulator.ts @@ -443,11 +443,15 @@ function describeOffered(accepts: ReadonlyArray<{ readonly network: string }>): * Shared by every payment check so each one starts from a challenge the * target issued just now, rather than reusing a stale one. With `altered`, * the payment is signed for those terms instead, and its `accepted` is set - * back to what the target advertised. + * back to what the target advertised. `beforeSigning` runs once the option + * to pay is known and before the client builds anything: the Stellar client + * simulates the transfer while building, so a check placed later would never + * see an unfunded payer. */ async function preparePayment( options: X402PaymentCheckOptions, alter?: (selected: PaymentRequirements) => AlteredSigning | Promise, + beforeSigning?: (selected: PaymentRequirements) => Promise, ) { const chain = chainFor(options.network); const { client, httpClient } = await buildX402Client( @@ -508,6 +512,7 @@ async function preparePayment( // with its cached response (specs/extensions/payment_identifier.md). const idempotent = paymentRequired.extensions?.["payment-identifier"] !== undefined; + await beforeSigning?.(selected); if (alter === undefined) { const paymentPayload = await client.createPaymentPayload(paymentRequired); return { httpClient, paymentPayload, selected, idempotent }; @@ -592,12 +597,59 @@ interface HonestPayment { readonly idempotent: boolean; } +/** + * Stops X402-06 before it pays when the payer certainly cannot cover the + * price. + * + * A payment the payer cannot fund is refused, and the refusal reads as the + * target's: on Solana devnet the facilitator answers + * `invalid_exact_svm_transaction_simulation_failed`, the reason a real defect + * gives too, so it cannot be told apart afterwards; the Stellar client fails + * earlier, simulating the transfer while it builds, with only the token's + * error code. Read before signing, so a target that settles and then refuses + * still shows as one. Only a balance the + * chain reported counts: when it cannot be read, the run pays as before, and + * a failed read never becomes a verdict either way. + */ +async function assertPayerCanPay( + options: X402PaymentCheckOptions, + selected: PaymentRequirements, +): Promise { + let price: bigint; + try { + price = BigInt(selected.amount); + } catch { + return; + } + const chain = chainFor(options.network); + let balance: bigint | undefined; + try { + balance = await chain.payerBalance( + options.network, + chain.resolveRpcUrl(options.network, options.rpcUrl), + chain.payerAddress(options.payerSecretKey), + selected.asset, + ); + } catch { + balance = undefined; + } + if (balance !== undefined && balance < price) { + throw new CheckSetupError( + `This run's payer holds less of ${selected.asset} than the advertised ${price} ` + + `base units, so its payment cannot settle, and a refusal would say nothing ` + + `about the target. Fund the payer (${chain.payerKeyEnv}), then re-run.`, + ); + } +} + async function checkSignatureAccepted(options: X402PaymentCheckOptions): Promise { const id = "X402-06"; const name = "Signature Resubmit Accepted"; const fail = (detail: string, hint: string): CheckResult => ({ id, name, pass: false, detail, hint }); - const { httpClient, paymentPayload, idempotent } = await preparePayment(options); + const { httpClient, paymentPayload, idempotent } = await preparePayment(options, undefined, (selected) => + assertPayerCanPay(options, selected), + ); const paymentHeaders = httpClient.encodePaymentSignatureHeader(paymentPayload); const paid = await fetchTarget( options.target, diff --git a/packages/core/test/unit/payer-balance.test.ts b/packages/core/test/unit/payer-balance.test.ts new file mode 100644 index 0000000..73007c1 --- /dev/null +++ b/packages/core/test/unit/payer-balance.test.ts @@ -0,0 +1,95 @@ +/** + * The payer balance X402-06 reads before paying, offline. + * + * The rule itself (a balance below the price is ERROR (setup), an unreadable + * one pays as before) is in payment-negatives.test.ts. These are the chain + * readings it rests on: a Stellar Asset Contract is recognised only by its + * own contract id, and a Solana payer's balance is every token account it + * holds for the mint, or nothing when RPC answers in a shape it does not + * know. The Stellar `balance` read and the trustline lookup were run live + * against testnet (runs/2026-10-05-m4-solana.md). + */ + +import assert from "node:assert/strict"; +import http from "node:http"; +import { describe, it } from "node:test"; + +import { Networks } from "@stellar/stellar-sdk"; + +import { sacAssetFromName } from "../../src/x402/chains/stellar.js"; +import { svmChain } from "../../src/x402/chains/svm.js"; + +const USDC_SAC = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; +const USDC_NAME = "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + +describe("sacAssetFromName", () => { + it("recognises testnet USDC's Stellar Asset Contract by its name and id", () => { + const asset = sacAssetFromName(USDC_NAME, USDC_SAC, Networks.TESTNET); + assert.equal(asset?.getCode(), "USDC"); + assert.equal(asset?.getIssuer(), "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"); + }); + + it("refuses a name the contract's id does not belong to", () => { + // The same name on mainnet's passphrase is another contract: a lookalike + // token returning a SAC-style name is not taken for the SAC. + assert.equal(sacAssetFromName(USDC_NAME, USDC_SAC, Networks.PUBLIC), undefined); + for (const name of ["native", "USD Coin", "USDC:not-an-issuer", 7n, undefined]) { + assert.equal(sacAssetFromName(name, USDC_SAC, Networks.TESTNET), undefined, String(name)); + } + }); +}); + +/** A Solana JSON-RPC endpoint answering getTokenAccountsByOwner with `amounts`. */ +async function solanaRpc(amounts: readonly unknown[]): Promise<{ url: string; close: () => Promise }> { + const server = http.createServer((request, response) => { + let body = ""; + request.on("data", (chunk) => (body += chunk)); + request.on("end", () => { + const { id } = JSON.parse(body) as { id: unknown }; + const value = amounts.map((amount, index) => ({ + pubkey: `Acct${index}111111111111111111111111111111111111`, + account: { + data: { program: "spl-token", parsed: { type: "account", info: { tokenAmount: { amount } } }, space: 165 }, + executable: false, + lamports: 2039280, + owner: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + rentEpoch: 0, + }, + })); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ jsonrpc: "2.0", id, result: { context: { slot: 1 }, value } })); + }); + }); + 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}`, close: () => new Promise((resolve) => server.close(() => resolve())) }; +} + +describe("the Solana payer balance", () => { + const DEVNET = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; + const PAYER = "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin"; + const MINT = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; + + it("sums every token account for the mint, and reads none as zero", async () => { + for (const [amounts, expected] of [ + [["5000", "7000"], 12_000n], + [[], 0n], + ] as const) { + const rpc = await solanaRpc(amounts); + try { + assert.equal(await svmChain.payerBalance(DEVNET, rpc.url, PAYER, MINT), expected); + } finally { + await rpc.close(); + } + } + }); + + it("gives no reading when an amount is not a whole number of base units", async () => { + const rpc = await solanaRpc(["5000", 12]); + try { + assert.equal(await svmChain.payerBalance(DEVNET, rpc.url, PAYER, MINT), undefined); + } finally { + await rpc.close(); + } + }); +}); diff --git a/packages/core/test/unit/payment-negatives.test.ts b/packages/core/test/unit/payment-negatives.test.ts index 5e8b055..967d453 100644 --- a/packages/core/test/unit/payment-negatives.test.ts +++ b/packages/core/test/unit/payment-negatives.test.ts @@ -5,13 +5,15 @@ * * Offline: the payer is a throwaway EVM key, and EIP-3009 signing needs no * RPC, so a local stand-in on Base Sepolia's network id can take or refuse - * every payment while recording exactly what was signed. The X402-10 runs - * wait out a one-second lifetime, so each takes about five seconds. + * every payment while recording exactly what was signed. A second stand-in + * answers the payer's token balance, which X402-06 reads before paying. The + * X402-10 runs wait out a one-second lifetime, so each takes about five + * seconds. */ import assert from "node:assert/strict"; import http from "node:http"; -import { describe, it } from "node:test"; +import { after, before, describe, it } from "node:test"; import { generatePrivateKey } from "viem/accounts"; @@ -93,6 +95,53 @@ async function standIn(options: { }; } +/** + * A JSON-RPC endpoint that answers every `eth_call` (the ERC-20 `balanceOf` + * X402-06 reads) with `balance`, or fails every request with 500. + */ +async function rpcStandIn( + balance: bigint | "unreadable", +): Promise<{ url: string; calls: () => number; close: () => Promise }> { + let calls = 0; + const server = http.createServer((request, response) => { + let body = ""; + request.on("data", (chunk) => (body += chunk)); + request.on("end", () => { + calls++; + if (balance === "unreadable") { + response.writeHead(500); + response.end(); + return; + } + const { id, method } = JSON.parse(body) as { id: number; method: string }; + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify( + method === "eth_call" + ? { jsonrpc: "2.0", id, result: `0x${balance.toString(16).padStart(64, "0")}` } + : { jsonrpc: "2.0", id, error: { code: -32601, message: "not in this stand-in" } }, + ), + ); + }); + }); + 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}`, + calls: () => calls, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +/** A payer that holds plenty: the default for runs that are not about the balance. */ +let funded: Awaited>; +before(async () => { + funded = await rpcStandIn(10n ** 12n); +}); +after(async () => { + await funded.close(); +}); + function byId(results: CheckResult[]): Record { return Object.fromEntries(results.map((result) => [result.id, result])); } @@ -105,6 +154,7 @@ describe("the negative checks against a target that takes anything", () => { target: target.url, network: NETWORK, payerSecretKey: generatePrivateKey(), + rpcUrl: funded.url, }); assert.deepEqual( results.map((result) => result.id), @@ -140,7 +190,12 @@ describe("a target that refuses even the valid payment", () => { const target = await standIn({ mode: "refuse" }); try { const r = byId( - await runX402PaymentChecks({ target: target.url, network: NETWORK, payerSecretKey: generatePrivateKey() }), + await runX402PaymentChecks({ + target: target.url, + network: NETWORK, + payerSecretKey: generatePrivateKey(), + rpcUrl: funded.url, + }), ); assert.equal(r["X402-06"]?.pass, false); for (const id of ["X402-07", "X402-08", "X402-09", "X402-10"]) { @@ -161,7 +216,12 @@ describe("a Permit2 target that needs an approval this payer never made", () => const target = await standIn({ mode: "permit2-allowance" }); try { const r = byId( - await runX402PaymentChecks({ target: target.url, network: NETWORK, payerSecretKey: generatePrivateKey() }), + await runX402PaymentChecks({ + target: target.url, + network: NETWORK, + payerSecretKey: generatePrivateKey(), + rpcUrl: funded.url, + }), ); assert.equal(r["X402-06"]?.error?.kind, "setup"); assert.match(r["X402-06"]?.detail ?? "", /approved the Permit2 contract/); @@ -178,7 +238,12 @@ describe("a refused valid payment", () => { const target = await standIn({ mode: "refuse" }); try { const r = byId( - await runX402PaymentChecks({ target: target.url, network: NETWORK, payerSecretKey: generatePrivateKey() }), + await runX402PaymentChecks({ + target: target.url, + network: NETWORK, + payerSecretKey: generatePrivateKey(), + rpcUrl: funded.url, + }), ); assert.match(r["X402-06"]?.detail ?? "", /^Expected 2xx after a valid payment, got 402\.$/); } finally { @@ -187,12 +252,69 @@ describe("a refused valid payment", () => { }); }); +describe("a payer that cannot cover the price", () => { + // Measured on Solana devnet: an unfunded payer's payment is refused for a + // reason a real defect gives too. Read first, it is the payer's, not the + // target's: no verdict, and nothing is sent. + it("gets X402-06 as a setup error before any payment is sent", async () => { + const target = await standIn({ mode: "accept" }); + const chain = await rpcStandIn(9_999n); + try { + const r = byId( + await runX402PaymentChecks({ + target: target.url, + network: NETWORK, + payerSecretKey: generatePrivateKey(), + rpcUrl: chain.url, + }), + ); + assert.equal(r["X402-06"]?.error?.kind, "setup"); + assert.match(r["X402-06"]?.detail ?? "", /holds less of .* than the advertised 10000 base units/); + assert.match(r["X402-06"]?.detail ?? "", /EVM_PRIVATE_KEY/); + for (const id of ["X402-07", "X402-08", "X402-09", "X402-10"]) assert.equal(r[id]?.skipped, true, id); + assert.equal(target.received.length, 0); + } finally { + await chain.close(); + await target.close(); + } + }); + + it("pays as before when the balance cannot be read, or covers the price exactly", async () => { + for (const balance of ["unreadable", 10_000n] as const) { + const target = await standIn({ mode: "refuse" }); + const chain = await rpcStandIn(balance); + try { + const r = byId( + await runX402PaymentChecks({ + target: target.url, + network: NETWORK, + payerSecretKey: generatePrivateKey(), + rpcUrl: chain.url, + }), + ); + assert.ok(chain.calls() > 0, `${balance}: the balance was asked for`); + assert.equal(r["X402-06"]?.error, undefined, `${balance}: no setup error`); + assert.match(r["X402-06"]?.detail ?? "", /got 402/, `${balance}`); + assert.equal(target.received.length, 1, `${balance}: the payment was sent`); + } finally { + await chain.close(); + await target.close(); + } + } + }); +}); + describe("when a negative check has nothing to test", () => { it("skips X402-08 under payment-identifier, and X402-09 at a 1-unit price", async () => { const target = await standIn({ mode: "accept", amount: "1", paymentIdentifier: true }); try { const r = byId( - await runX402PaymentChecks({ target: target.url, network: NETWORK, payerSecretKey: generatePrivateKey() }), + await runX402PaymentChecks({ + target: target.url, + network: NETWORK, + payerSecretKey: generatePrivateKey(), + rpcUrl: funded.url, + }), ); assert.equal(r["X402-08"]?.skipped, true); assert.match(r["X402-08"]?.skipReason ?? "", /payment-identifier/); From adc778186ede20ff4218ef5949b83fa23af9a618 Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Mon, 5 Oct 2026 21:56:01 +0800 Subject: [PATCH 2/6] docs: the payer balance read before X402-06, and Circle's faucet for Solana devnet CHECKS.md's X402-06 row and "The payment checks cannot pay" say that the payer's balance is read before signing, that a balance below the price gives ERROR (setup) with nothing sent, and that an unreadable one pays as before. The CHANGELOG records it as a change. The evidence: fresh unfunded payers on Stellar, Base Sepolia and Solana devnet get ERROR (setup) naming the key to fund, where Solana used to fail X402-06 and Stellar to stop on the token's bare error code; funded payers pass 10/10 on every fixture. It also records one Permit2 run that drew 402 without a reason and moved no funds, not reproduced in the five runs after it. The Solana devnet payer's USDC comes from faucet.circle.com, as the configuration guide, CHECKS.md and the Solana evidence now say. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++ docs/CHECKS.md | 12 ++- .../2026-10-05-payer-balance-check-run.md | 86 +++++++++++++++++++ ...26-10-05-solana-devnet-verification-run.md | 3 +- docs/evidence/README.md | 1 + docs/guides/configuration.md | 4 +- 6 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 docs/evidence/2026-10-05-payer-balance-check-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d01e172..4a89a9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,14 @@ refuses `X402-06`'s valid payment, `X402-07` to `X402-10` are skipped rather tha a target that refuses everything proves nothing by refusing a bad payment. `X402-07` used to pass in that case. +**Changed — a payer that cannot cover the price gets no verdict.** `X402-06` reads the +payer's balance of the advertised asset before it signs; below the price, it reports +`ERROR (setup)` naming the key to fund, sends nothing, and skips `X402-07`–`10`. An +unfunded payer used to fail `X402-06` on Solana devnet, for a reason real defects give +too, and to stop on Stellar with only the token's error code. A balance that cannot be +read pays as before. Measured on all three chains with fresh payers +([evidence](docs/evidence/2026-10-05-payer-balance-check-run.md)). + **Changed — a payer key for the wrong chain, or a malformed one, stops the run at preflight.** It used to surface mid-payment as a harness error. The message never echoes the key. diff --git a/docs/CHECKS.md b/docs/CHECKS.md index 9e3db4d..ad6c642 100644 --- a/docs/CHECKS.md +++ b/docs/CHECKS.md @@ -17,7 +17,7 @@ in every test report. | `X402-03` | Header Payload Decodable | x402 spec §payment-required-object | The header value must be valid base64 that decodes to JSON | `atob()` + `JSON.parse()` succeed without error | | `X402-04` | Required Fields Present | x402 spec §payment-required-object | The payload must include the core payment terms, under the field names its own advertised version requires | Checked in **every** payment option in `accepts`, each reported by its index when the challenge offers more than one; a challenge with no options fails. In each option, every field the advertised `x402Version` requires is present: for v2 `scheme`, `network`, `amount`, `asset`, `payTo` and `maxTimeoutSeconds` (spec 5.1.2); for v1 `scheme`, `network`, `maxAmountRequired`, `asset`, `payTo`, `resource`, `description` and `maxTimeoutSeconds` (v1 spec 5.1). Each is a non-empty string, except `maxTimeoutSeconds`, a positive number of seconds; a field present with the wrong type is reported as such rather than as missing. The price field follows the version: `maxAmountRequired` for v1, `amount` for v2 (renamed in v2, which also moves the resource out of the option). The version is read from the challenge rather than accepting whichever name happens to appear, because a service advertising `x402Version: 2` while emitting the v1 field name is not conformant to the version it claims — and reporting that as a merely absent price would hide the actual defect. An unrecognised version fails: the field names cannot be checked against a version whose schema is unknown. | | `X402-05` | Network Identifier Valid | x402 v2 spec §11.1; CAIP-2 | Every advertised network id is CAIP-2 | Every option's `network` is a CAIP-2 identifier, `namespace:reference` with a 3 to 8 character lowercase namespace and a reference of at most 32 characters, as x402 v2 requires. Where the namespace's own CAIP-2 definition fixes the reference, that is checked too: `stellar` is `testnet` or `pubnet`; `eip155` is the chain id in base 10 (`eip155:84532`, not `eip155:0x14a34`); `solana` is the first 32 characters of the base58 genesis hash. A well-formed id in another namespace passes, and the result says only the format was checked there: x402 v2 asks for CAIP-2 and nothing more, so failing it would report Wasit's own lack of rules as the target's defect. An option without a `network` is left to `X402-04`. | -| `X402-06` | Signature Resubmit Accepted | x402 spec §payment-flow | A resubmitted request carrying a valid signature must be accepted | Response is no longer 402; a 2xx returns the original resource. The challenge is re-read immediately before signing, so the payment answers a challenge the target issued just now rather than a stale one. **Settles a real payment** — see the cost note below. A 2xx alone does not pass. The settlement the response reports in its `PAYMENT-RESPONSE` header (x402 v2 HTTP transport) must name a Stellar transaction hash, and that transaction is then looked up on Stellar RPC and held to the advertised terms exactly as `MPP-01` does: it must have succeeded and emitted exactly one `transfer` event, from this run's payer, to the advertised `payTo`, for the advertised `amount` of the advertised `asset`. A missing header, a reported failure, or a hash that does not match fails. A `settlement_pending` response, which the spec defines as broadcast but unconfirmed, is reconciled on chain rather than failed. Uses the same RPC wait as `MPP-01`. On Base Sepolia (`eip155:84532`) the payment uses the transfer method the target advertises: EIP-3009 by default, or Permit2, whose one-time approval the client signs as an EIP-2612 permit when the target offers the `eip2612GasSponsoring` extension. A Permit2 target without it, where this run's payer has not approved Permit2, gives no verdict (`ERROR (setup)`): the missing approval is the payer's, not the target's. The reference is an EVM transaction hash, and the receipt's ERC-20 `Transfer` log is held to the same terms (a Permit2 settlement also logs the permit's `Approval`, which is not a transfer): the transaction succeeded and logged exactly one token transfer, from this run's payer, to `payTo`, for `amount` of `asset` (an ERC-721 transfer, which shares the event signature, is not counted). The receipt is awaited for 30 blocks before the transaction counts as missing; an RPC that stops advancing gives no verdict. On Solana devnet (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) the client signs a transaction with one `TransferChecked` to the payee's associated token account, and the sponsor named in `extra.feePayer` signs as fee payer and submits it, so the payer needs no SOL. The reference is a transaction signature, and the confirmed transaction's token balances are held to the same terms: it succeeded, and exactly one transfer moved (one account debited and one credited, by the same amount of the same mint), for `amount` of `asset`, to an account `payTo` owns, from this run's payer. The transaction is awaited for 150 slots before it counts as missing; an RPC that stops advancing gives no verdict. | +| `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. Before anything is signed, the payer's balance of `asset` is read from the chain: when it is below `amount`, nothing is sent and the check gives no verdict (`ERROR (setup)`), since a payment the payer cannot fund is refused for the payer's sake; when the balance cannot be read, the payment goes ahead. **Settles a real payment** — see the cost note below. A 2xx alone does not pass. The settlement the response reports in its `PAYMENT-RESPONSE` header (x402 v2 HTTP transport) must name a Stellar transaction hash, and that transaction is then looked up on Stellar RPC and held to the advertised terms exactly as `MPP-01` does: it must have succeeded and emitted exactly one `transfer` event, from this run's payer, to the advertised `payTo`, for the advertised `amount` of the advertised `asset`. A missing header, a reported failure, or a hash that does not match fails. A `settlement_pending` response, which the spec defines as broadcast but unconfirmed, is reconciled on chain rather than failed. Uses the same RPC wait as `MPP-01`. On Base Sepolia (`eip155:84532`) the payment uses the transfer method the target advertises: EIP-3009 by default, or Permit2, whose one-time approval the client signs as an EIP-2612 permit when the target offers the `eip2612GasSponsoring` extension. A Permit2 target without it, where this run's payer has not approved Permit2, gives no verdict (`ERROR (setup)`): the missing approval is the payer's, not the target's. The reference is an EVM transaction hash, and the receipt's ERC-20 `Transfer` log is held to the same terms (a Permit2 settlement also logs the permit's `Approval`, which is not a transfer): the transaction succeeded and logged exactly one token transfer, from this run's payer, to `payTo`, for `amount` of `asset` (an ERC-721 transfer, which shares the event signature, is not counted). The receipt is awaited for 30 blocks before the transaction counts as missing; an RPC that stops advancing gives no verdict. On Solana devnet (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) the client signs a transaction with one `TransferChecked` to the payee's associated token account, and the sponsor named in `extra.feePayer` signs as fee payer and submits it, so the payer needs no SOL. The reference is a transaction signature, and the confirmed transaction's token balances are held to the same terms: it succeeded, and exactly one transfer moved (one account debited and one credited, by the same amount of the same mint), for `amount` of `asset`, to an account `payTo` owns, from this run's payer. The transaction is awaited for 150 slots before it counts as missing; an RPC that stops advancing gives no verdict. | | `X402-07` | Invalid Signature Rejected *(negative)* | x402 spec §payment-flow; `exact` scheme on Stellar | A payment whose authorization signature is wrong must be REJECTED | The target answers with a non-2xx status. The payment is built exactly as for `X402-06`, then only the client's authorization signature is corrupted: in the `exact` scheme on Stellar the client signs a Soroban authorization entry rather than the envelope, and one byte of that entry's `signature` is flipped. The transaction still decodes and carries the same amount, payer and recipient, so a target can refuse it only by verifying the signature. Measured against the `x402.org` facilitator on 2026-09-30, the rejection is `invalid_exact_stellar_payload_simulation_failed`, reached at the Soroban simulation that checks authorization; the pre-0.6.0 corruption, which overwrote the base64 tail and broke XDR decoding, drew `invalid_exact_stellar_payload_malformed` instead, and a target that decoded the envelope without verifying the signature passed it. Rejection is established only by an answer: a target that cannot be reached, or whose challenge cannot be read, produces no verdict and is reported as ERROR or SKIP, and a payload with no authorization signature to corrupt reports `ERROR (setup)`. On Base Sepolia the client signs an EIP-3009 `transferWithAuthorization`, or a Permit2 transfer, off-chain; the first byte of that signature is flipped and the authorization left intact, so the signer it recovers to is no longer the payer and only signature verification can refuse it. Measured against the `x402.org` facilitator on 2026-10-05: refused with 402. On Solana devnet the transaction carries one signature before the sponsor signs, the payer's; its first byte is flipped, and the message, transfer and blockhash are left intact. Measured against the `x402.org` facilitator on 2026-10-05: `invalid_exact_svm_payload_signature_invalid`. | | `X402-08` | Payment Replay Rejected *(negative)* | x402 v2 spec §10.1; `exact` scheme, one-time use | A payment that was already accepted must not be accepted again | The headers `X402-06`'s accepted payment was sent with are sent again, byte for byte, and the target answers with a non-2xx status. Each authorization is single-use: once the payment settles, its EIP-3009 nonce, or its Soroban auth entry's nonce, is spent, or on Solana the transaction has already executed, and the facilitator's verify refuses it. A 2xx means one payment bought the resource twice. Nothing can settle twice, so this costs nothing. **Skipped** when the challenge advertises the `payment-identifier` extension, under which a server may legitimately answer a repeated payment with its cached response. | | `X402-09` | Underpayment Rejected *(negative)* | `exact` scheme verification: amount | A validly signed payment for less than the advertised amount must be rejected | The payment is signed for half the advertised amount, while its `accepted` still claims the advertised terms, so the signature is valid and only the amount is wrong; the target answers with a non-2xx status. On Stellar the facilitator must hold the transfer to `requirements.amount` exactly; on EVM, verification step 3 holds the authorization's value to it; on Solana the scheme forbids a transfer below it (§1.4), and the reference facilitator's static path requires the exact amount (§3.1). **Skipped** when the price is 1 base unit, since nothing smaller can be offered. A target that accepts may settle the lower amount. | @@ -374,6 +374,11 @@ paying through. ### The payment checks cannot pay +`X402-06` reads the payer's balance before it signs. When the chain reports less than +the advertised amount it stops with `ERROR (setup)`: "This run's payer holds less of … +than the advertised … base units", naming the key to fund. Nothing is sent, and +`X402-07`–`10` are skipped ([evidence](evidence/2026-10-05-payer-balance-check-run.md)). + On Stellar, the payment checks need a testnet payer holding testnet USDC. `wasit wallet create --role x402 --fund` generates the key and funds it with testnet XLM, and `wasit wallet fund --role x402 --asset usdc` adds the USDC @@ -388,7 +393,8 @@ malformed key, is reported at `PREFLIGHT` before anything is sent. On Solana devnet (`--network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`), the payer is `SVM_PRIVATE_KEY`, the base58 encoding of the 64-byte keypair, as wallets export it. It -needs devnet USDC of the SDK's default mint (`4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`) -and no SOL: the facilitator signs as fee payer. The payee's USDC token account must +needs devnet USDC of the SDK's default mint (`4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`), +from the same faucet (network Solana Devnet), and no SOL: the facilitator signs as fee +payer. The payee's USDC token account must already exist, since the payment's transaction does not create it. A keypair whose public half does not belong to its seed, or any malformed key, is reported at `PREFLIGHT`. diff --git a/docs/evidence/2026-10-05-payer-balance-check-run.md b/docs/evidence/2026-10-05-payer-balance-check-run.md new file mode 100644 index 0000000..09d8fdc --- /dev/null +++ b/docs/evidence/2026-10-05-payer-balance-check-run.md @@ -0,0 +1,86 @@ +# A Payer That Cannot Pay Is the Payer's Problem — X402-06 Reads the Balance First + +**Date:** 2026-10-05 +**Wasit:** a build of the local `release/0.7.0` branch (`21f7bdc`), not yet published. +**Targets:** Wasit's own x402 fixtures on Stellar testnet (port 3001), Base Sepolia +(3005, EIP-3009; 3006, Permit2) and Solana devnet (3007), each settling through the +public `x402.org` facilitator. All on our machine. +**Environment:** macOS, Node `v26.8.1`. + +**What this is for.** A conformance tester must not blame a target for its own setup. A payer holding none of the asset cannot fund a payment, and before this +change that showed up as a verdict about the target, or as an unreadable error: + +- On Solana devnet, measured earlier the same day with a throwaway payer: + `FAIL X402-06 Expected 2xx after a valid payment, got 402 + (invalid_exact_svm_transaction_simulation_failed)`. The facilitator gives that reason + for real defects too, so it cannot be told apart afterwards. +- On Stellar testnet, the SDK client simulates the transfer while building the payment, + and stopped there: `ERROR X402-06 Check could not run (harness): Stellar simulation + failed with error message: HostError: Error(Contract, #13)`. No verdict, but nothing + that says what to do. +- On Base Sepolia this was not measured before the change. + +X402-06 now reads the payer's balance of the advertised asset before anything is +signed. When the chain reports less than the price, the result is `ERROR (setup)` and +nothing is sent. When the balance cannot be read, the run pays as before, so a failed +read never becomes a verdict. + +**Authorization.** None was needed or sought. Every target was Wasit's own code on our +machine. No service operated by anyone else was tested. + +## Fresh, unfunded payers + +Each payer was a keypair generated for the run, holding nothing, never printed. + +``` +ERROR X402-06 Signature Resubmit Accepted + Check could not run (setup): This run's payer holds less of + CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA than the advertised + 100000 base units, so its payment cannot settle, and a refusal would say + nothing about the target. Fund the payer (STELLAR_PRIVATE_KEY), then re-run. +``` + +| Chain | `X402-06` | `X402-07`–`10` | +|---|---|---| +| Stellar testnet | `ERROR (setup)`, names `STELLAR_PRIVATE_KEY` | SKIP | +| Base Sepolia | `ERROR (setup)`, names `EVM_PRIVATE_KEY` | SKIP | +| Solana devnet | `ERROR (setup)`, names `SVM_PRIVATE_KEY` | SKIP | + +How each balance is read: + +- **Base Sepolia:** the token's ERC-20 `balanceOf`. +- **Solana devnet:** every token account the payer holds for the mint, summed. The client + pays from the associated one only, so the sum is never less than what it can spend. +- **Stellar:** the token's `balance`, simulated. A Stellar Asset Contract answers an + account without a trustline with an error rather than zero, which is the usual state of + an unfunded payer. So when the token is that asset's SAC, recognised because the asset + named by its `name` (`USDC:GBBD47IF…FLA5`) derives this very contract id, the trustline + is looked up, and none means the payer holds nothing. + +The same reads, made directly against each chain's public RPC: the funded payers read +196000000 (Stellar), 19910000 (Base Sepolia) and 19970000 (Solana) base units; fresh +addresses read 0 on all three. + +## Funded payers + +With the funded payers, on the same build, every x402 fixture passed 10/10: Stellar +(port 3001), Base Sepolia EIP-3009 (3005) and Permit2 (3006), Solana devnet (3007), with +`X402-06` settled on-chain each time. + +## An observation on the way + +One Permit2 run (port 3006), started a few seconds after an EIP-3009 run from the same +payer, drew `FAIL X402-06 Expected 2xx after a valid payment, got 402.`, with no reason +given. The payer's USDC transfers on Base Sepolia show no settlement at that time, so the +payment was refused, not taken. Five Permit2 runs followed, three of them right after an +EIP-3009 run, as the failing one was: all passed 10/10. The cause was not found; it was +the target's facilitator refusing once, which Wasit reported as it happened. + +## Limits + +- Wasit's own fixtures only. No third-party service was tested. +- A balance that cannot be read gives no protection: the run pays, as before 0.7.0. +- On Stellar, only a Stellar Asset Contract's missing trustline is read as zero; for + other SEP-41 tokens a failed `balance` read pays as before. The asset's issuer, which + needs no trustline, is never read as zero. +- Unreleased: this ran from the branch, not from npm. diff --git a/docs/evidence/2026-10-05-solana-devnet-verification-run.md b/docs/evidence/2026-10-05-solana-devnet-verification-run.md index d3da887..c5bf05d 100644 --- a/docs/evidence/2026-10-05-solana-devnet-verification-run.md +++ b/docs/evidence/2026-10-05-solana-devnet-verification-run.md @@ -53,7 +53,8 @@ The second was decided against Solana devnet itself, through its public RPC ## An honest server Payer: a devnet account holding 20 USDC of the SDK's default devnet mint -(`4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`) and **no SOL**. Payee: a second account +(`4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`), from Circle's faucet +(https://faucet.circle.com, Solana Devnet), and **no SOL**. Payee: a second account whose USDC token account already existed (the payment's transaction does not create one). diff --git a/docs/evidence/README.md b/docs/evidence/README.md index bfeab24..da90103 100644 --- a/docs/evidence/README.md +++ b/docs/evidence/README.md @@ -92,6 +92,7 @@ and what it does and does not establish. Newest first. | Date | File | What it shows | Deliverable | |---|---|---|---| +| 2026-10-05 | [payer-balance-check-run](2026-10-05-payer-balance-check-run.md) | `X402-06` reads the payer's balance before signing (unreleased 0.7.0 branch): fresh, unfunded payers get `ERROR (setup)` with nothing sent on Stellar, Base Sepolia and Solana devnet, where they used to read as a target's failure or an unexplained error; funded payers pass 10/10 on every fixture | Beyond the SOW | | 2026-10-05 | [solana-devnet-verification-run](2026-10-05-solana-devnet-verification-run.md) | The x402 payment checks on Solana devnet (unreleased 0.7.0 branch): `wasit serve`'s lying modes fail `X402-06`–`10` and a mainnet request gets nothing sent; Wasit's own fixture passes 10/10 with `X402-06` settled on-chain and read back independently, the payer holding no SOL; the packed core installs and runs alongside TypeScript 5.8, 6 and 7 | Beyond the SOW | | 2026-10-05 | [x402-negative-checks-run](2026-10-05-x402-negative-checks-run.md) | `X402-08` replay, `X402-09` underpayment and `X402-10` expired authorization (unreleased 0.7.0 branch): Wasit's fixtures pass 10/10 on Stellar and Base Sepolia, each refusal for the reason the check is about; `wasit serve --mode no-settle` fails all of them on both chains | Beyond the SOW | | 2026-10-05 | [base-sepolia-verification-run](2026-10-05-base-sepolia-verification-run.md) | The x402 payment checks on Base Sepolia (unreleased 0.7.0 branch): servers that skip settlement or cite a missing transaction fail `X402-06` and `X402-07`; a mainnet request gets nothing sent; Wasit's own fixture passes 7/7 with `X402-06` settled on-chain and read back independently, the payer holding no ETH | Beyond the SOW | diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index d6b5923..9e8f099 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -68,8 +68,8 @@ anything is sent, without echoing it. For Solana devnet, `SVM_PRIVATE_KEY` is the base58 encoding of the keypair's 64 bytes (seed, then public key), the form wallets export. The payer needs devnet USDC -of the SDK's default mint, `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`, and **no -SOL**: the facilitator signs as fee payer and pays the fee. The payee's USDC token +of the SDK's default mint, `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`, from +https://faucet.circle.com (network Solana Devnet), and **no SOL**: the facilitator signs as fee payer and pays the fee. The payee's USDC token account must exist before the first payment, since the payment's transaction does not create it. A keypair whose public half does not belong to its seed, or any malformed key, is reported at `PREFLIGHT` without echoing it. From 3b450481e20f8a0843ac46b1b9963333c814690c Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Mon, 5 Oct 2026 22:24:51 +0800 Subject: [PATCH 3/6] feat(core): each payment adapter generates its own payer keys generatePayerKey() returns a new random key in the form the adapter's payerKeyEnv takes: a Stellar secret, an EVM private key, or the base58 of a Solana keypair's 64 bytes, the form wallets export. So a key handed out by `wasit wallet` is never one the payment checks would refuse at preflight; a test reads every generated key back through its adapter, and checks a Solana key's address against the one kit derives. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/x402/chains/evm.ts | 3 +- packages/core/src/x402/chains/stellar.ts | 1 + packages/core/src/x402/chains/svm.ts | 9 +++++- packages/core/src/x402/chains/types.ts | 2 ++ packages/core/test/unit/payer-keys.test.ts | 37 ++++++++++++++++++++++ 5 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 packages/core/test/unit/payer-keys.test.ts diff --git a/packages/core/src/x402/chains/evm.ts b/packages/core/src/x402/chains/evm.ts index 67b8e05..e19d65e 100644 --- a/packages/core/src/x402/chains/evm.ts +++ b/packages/core/src/x402/chains/evm.ts @@ -26,7 +26,7 @@ import { type Chain, type Hex, } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { baseSepolia } from "viem/chains"; import { CheckSetupError, ConfigurationError } from "../../errors.js"; @@ -275,6 +275,7 @@ export const evmChain: PaymentChain = { // validBefore is now + 1 second; Base Sepolia blocks come every 2 seconds. expiredSigning: async () => ({ terms: { maxTimeoutSeconds: 1 }, holdMs: 5_000 }), payerAddress: (payerKey) => payerAccount(payerKey).address, + generatePayerKey: () => generatePrivateKey(), async payerBalance(_network, rpcUrl, payer, asset) { if (!isAddress(asset) || !isAddress(payer)) return undefined; return createPublicClient({ transport: http(rpcUrl) }).readContract({ diff --git a/packages/core/src/x402/chains/stellar.ts b/packages/core/src/x402/chains/stellar.ts index 1ec4b44..d7fd1c8 100644 --- a/packages/core/src/x402/chains/stellar.ts +++ b/packages/core/src/x402/chains/stellar.ts @@ -180,6 +180,7 @@ export const stellarChain: PaymentChain = { expiredSigning: async () => ({ terms: { maxTimeoutSeconds: 1 }, holdMs: 20_000 }), payerAddress: (payerKey) => Keypair.fromSecret(payerKey).publicKey(), payerBalance: stellarTokenBalance, + generatePayerKey: () => Keypair.random().secret(), isSettlementReference: (reference) => TRANSACTION_HASH.test(reference), verifySettlement, corruptPayload: (payload) => ({ diff --git a/packages/core/src/x402/chains/svm.ts b/packages/core/src/x402/chains/svm.ts index f2b6502..f96df86 100644 --- a/packages/core/src/x402/chains/svm.ts +++ b/packages/core/src/x402/chains/svm.ts @@ -13,7 +13,7 @@ * USDC and the public facilitator settles there. */ -import { createPrivateKey } from "node:crypto"; +import { createPrivateKey, generateKeyPairSync } from "node:crypto"; import { DEVNET_RPC_URL, SOLANA_DEVNET_CAIP2 } from "@x402/svm"; import { ExactSvmScheme } from "@x402/svm/exact/client"; @@ -390,6 +390,13 @@ export const svmChain: PaymentChain = { }; }, payerAddress: (payerKey) => getBase58Decoder().decode(secretKeyBytes(payerKey).subarray(32)), + generatePayerKey() { + // The wallet export form: base58 of the seed followed by the public key. + const { d, x } = generateKeyPairSync("ed25519").privateKey.export({ format: "jwk" }); + return getBase58Decoder().decode( + new Uint8Array([...Buffer.from(d!, "base64url"), ...Buffer.from(x!, "base64url")]), + ); + }, async payerBalance(_network, rpcUrl, payer, asset) { // Every token account the payer holds for the mint, summed: the client // pays from the associated one only, so this is never less than what it diff --git a/packages/core/src/x402/chains/types.ts b/packages/core/src/x402/chains/types.ts index d9f0cd1..101bca2 100644 --- a/packages/core/src/x402/chains/types.ts +++ b/packages/core/src/x402/chains/types.ts @@ -67,6 +67,8 @@ export interface PaymentChain { expiredSigning(selected: PaymentRequirements, rpcUrl: string): Promise; /** The payer's address, which the settlement must come from. */ payerAddress(payerKey: string): string; + /** A new random payer key, in the form `payerKeyEnv` takes. */ + generatePayerKey(): string; /** * The payer's balance of `asset`, in base units, as the chain reports it, * or at most what the payer can spend from (never more than it holds). diff --git a/packages/core/test/unit/payer-keys.test.ts b/packages/core/test/unit/payer-keys.test.ts new file mode 100644 index 0000000..e79d605 --- /dev/null +++ b/packages/core/test/unit/payer-keys.test.ts @@ -0,0 +1,37 @@ +/** + * Each payment adapter generates payer keys in the form its own key check + * takes, so `wasit wallet create` never hands out a key the payment checks + * would refuse at preflight. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { createKeyPairSignerFromBytes, getBase58Encoder } from "@solana/kit"; + +import { paymentChainFor, paymentNetworks } from "../../src/x402/chains/index.js"; + +describe("generatePayerKey", () => { + for (const network of paymentNetworks()) { + it(`${network}: a fresh key the adapter reads back, different every time`, () => { + const chain = paymentChainFor(network)!; + const one = chain.generatePayerKey(); + const two = chain.generatePayerKey(); + assert.notEqual(one, two); + assert.ok(chain.payerAddress(one).length > 0); + assert.notEqual(chain.payerAddress(one), chain.payerAddress(two)); + }); + } + + it("gives Solana keys whose address is the one kit derives from the keypair", async () => { + const chain = paymentChainFor("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1")!; + const key = chain.generatePayerKey(); + const signer = await createKeyPairSignerFromBytes(new Uint8Array(getBase58Encoder().encode(key))); + assert.equal(chain.payerAddress(key), signer.address); + }); + + it("gives Stellar secrets and EVM private keys in their usual form", () => { + assert.match(paymentChainFor("stellar:testnet")!.generatePayerKey(), /^S[A-Z2-7]{55}$/); + assert.match(paymentChainFor("eip155:84532")!.generatePayerKey(), /^0x[0-9a-f]{64}$/); + }); +}); From 4c3ca55991a35d065b2193f1001a72bebcf43b10 Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Mon, 5 Oct 2026 22:24:51 +0800 Subject: [PATCH 4/6] feat(cli): wasit wallet --network for the Base Sepolia and Solana devnet payers `wasit wallet create --role x402` made Stellar keys only, so a builder paying on Base Sepolia or Solana devnet had to find another way to make the key the payment checks read. `--network eip155:84532` and `--network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` now generate it, printing the EVM_PRIVATE_KEY or SVM_PRIVATE_KEY line and the address to fund at faucet.circle.com; that is the only step, since the payer needs no ETH or SOL. `status --network` shows the configured payer's USDC balance of the SDK's default asset and exits 2 when it could not be read; `fund --network` prints the faucet step. `--fund` stays Stellar only (Friendbot). `--network` takes the testnets `wasit serve` poses on, nothing else, and the MPP roles stay on Stellar. The serve network profiles gain a name, a faucet label and the native token, which the messages use. The dashboard's environment panel lists EVM_PRIVATE_KEY and SVM_PRIVATE_KEY. Stellar behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 + docs/guides/cli.md | 23 ++- docs/guides/configuration.md | 6 +- packages/cli/README.md | 8 +- packages/cli/src/dashboard/environment.ts | 13 +- packages/cli/src/serve.ts | 17 +- packages/cli/src/wallet-command.ts | 181 +++++++++++++++++- packages/cli/test/unit/environment.test.ts | 8 +- .../cli/test/unit/wallet-networks.test.ts | 64 +++++++ 9 files changed, 306 insertions(+), 22 deletions(-) create mode 100644 packages/cli/test/unit/wallet-networks.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a89a9f..773a9fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,14 @@ the two settlement modes reproduce the servers built for the 0.6.0 A/B: modes on Base Sepolia, and `--network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` on Solana devnet. +**Added — `wasit wallet --network` for the Base Sepolia and Solana devnet payers.** +`wasit wallet create --role x402 --network eip155:84532` (or Solana devnet) generates the +payer key in the form the payment checks read and prints its `.env` line and the +address to fund at faucet.circle.com; `status --network` shows that payer's USDC +balance, and `fund --network` prints the faucet step, since the payer needs nothing +else. Testnets only; the MPP roles stay on Stellar. The dashboard's environment panel +lists `EVM_PRIVATE_KEY` and `SVM_PRIVATE_KEY`. + **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 7583c9c..2e8f20b 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -69,15 +69,16 @@ menu — it falls back to printing help, exactly as before this existed. ## Wallet setup ```bash -wasit wallet status [--role x402|mpp-charge] [--json] -wasit wallet create --role x402|mpp-charge|mpp-channel [--fund] -wasit wallet fund --role x402|mpp-charge [--asset xlm|usdc] [--amount ] +wasit wallet status [--role x402|mpp-charge] [--network ] [--json] +wasit wallet create --role x402|mpp-charge|mpp-channel [--network ] [--fund] +wasit wallet fund --role x402|mpp-charge [--network ] [--asset xlm|usdc] [--amount ] ``` Testnet-only convenience commands for the payer keys the other subcommands -read from `.env` — none of them take a `--network` flag, since Friendbot, the -printed USDC issuer, and the whole idea of a disposable generated key only -make sense on testnet. +read from `.env`. `--network` takes testnets only, `stellar:testnet` (the +default), `eip155:84532` (Base Sepolia) or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` +(Solana devnet), since Friendbot, the printed USDC issuer, and the whole idea +of a disposable generated key only make sense on testnet. `status` shows each configured role's on-chain balances (or "not yet created" for a key that has never been funded). `create` generates a new key and @@ -108,6 +109,16 @@ public key `wasit wallet fund` prints), or set `WASIT_USDC_DISTRIBUTOR_SECRET` in `.env` to an account you already funded that way — every run after that sends automatically from it. +On Base Sepolia and Solana devnet only `--role x402` applies: MPP runs on +Stellar. `create --role x402 --network eip155:84532` prints an +`EVM_PRIVATE_KEY=0x...` line, and `--network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` +an `SVM_PRIVATE_KEY=...` line (base58 of the 64-byte keypair), each with the +address to paste into https://faucet.circle.com. That faucet is the only step: +the payer needs USDC and no ETH or SOL, since the facilitator pays the fee, so +`--fund` is Stellar only and `fund` prints the faucet step. `status --network` +shows the configured payer's USDC balance of the SDK's default asset, and exits +2 when it could not be read. + ## Exit codes | Code | Meaning | diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 9e8f099..9f4070b 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -57,7 +57,8 @@ curl "https://friendbot.stellar.org/?addr=" string — it signs channel commitments directly rather than transactions. For Base Sepolia, `EVM_PRIVATE_KEY` is a raw private key, `0x` followed by 64 -hex characters, from any EVM wallet or `viem`'s `generatePrivateKey()`. The +hex characters, from `wasit wallet create --role x402 --network eip155:84532`, +any EVM wallet, or `viem`'s `generatePrivateKey()`. The payer needs Base Sepolia USDC, from https://faucet.circle.com, and **no ETH**: the facilitator pays the gas (the `exact` scheme's EIP-3009 method). A target that uses Permit2 instead needs no ETH either when it offers the @@ -67,7 +68,8 @@ once on-chain, which needs gas, and a run without that approval reports anything is sent, without echoing it. For Solana devnet, `SVM_PRIVATE_KEY` is the base58 encoding of the keypair's 64 -bytes (seed, then public key), the form wallets export. The payer needs devnet USDC +bytes (seed, then public key), the form wallets export and `wasit wallet create +--role x402 --network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` prints. The payer needs devnet USDC of the SDK's default mint, `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`, from https://faucet.circle.com (network Solana Devnet), and **no SOL**: the facilitator signs as fee payer and pays the fee. The payee's USDC token account must exist before the first payment, since the payment's transaction does diff --git a/packages/cli/README.md b/packages/cli/README.md index 23e7c2e..0a30c20 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -108,12 +108,12 @@ Lists every check by ID with the subcommand that runs it, and flags the ones tha ### `wasit wallet` — testnet keys ```bash -wasit wallet status [--role x402|mpp-charge] [--json] -wasit wallet create --role x402|mpp-charge|mpp-channel [--fund] -wasit wallet fund --role x402|mpp-charge [--asset xlm|usdc] [--amount ] +wasit wallet status [--role x402|mpp-charge] [--network ] [--json] +wasit wallet create --role x402|mpp-charge|mpp-channel [--network ] [--fund] +wasit wallet fund --role x402|mpp-charge [--network ] [--asset xlm|usdc] [--amount ] ``` -Testnet-only helpers for the keys the subcommands above read from `.env`. There is deliberately no `--network` flag. `create` prints the exact `.env` lines to paste and never writes the file itself; `fund --asset xlm` uses Stellar's public Friendbot and is fully automatic. +Testnet-only helpers for the keys the subcommands above read from `.env`. `--network` takes testnets only: `stellar:testnet` (default), `eip155:84532` or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`, where `create --role x402` prints an `EVM_PRIVATE_KEY` or `SVM_PRIVATE_KEY` line and the address to fund at [faucet.circle.com](https://faucet.circle.com); that payer needs USDC and no ETH or SOL. `create` prints the exact `.env` lines to paste and never writes the file itself; `fund --asset xlm` uses Stellar's public Friendbot and is fully automatic. `fund --asset usdc` opens the Circle testnet USDC trustline automatically, but **receiving a balance always needs one human step**: there is no scriptable USDC faucet for Stellar. Visit [faucet.circle.com](https://faucet.circle.com) once, or set `WASIT_USDC_DISTRIBUTOR_SECRET` to an account you funded that way and every later run sends from it automatically. diff --git a/packages/cli/src/dashboard/environment.ts b/packages/cli/src/dashboard/environment.ts index 1bc0e19..c89ed79 100644 --- a/packages/cli/src/dashboard/environment.ts +++ b/packages/cli/src/dashboard/environment.ts @@ -10,7 +10,10 @@ export interface EnvironmentCheck { * * All three payer roles are listed, including x402's: the wallet screen can * create and fund a key for every one of them, and showing only two of the - * three made the third look like it was not a role the tool knew about. + * three made the third look like it was not a role the tool knew about. The + * x402 payer keys for Base Sepolia and Solana devnet are listed too: `wasit + * test --network` pays with them, and `wasit wallet create --network` makes + * them. */ export function getEnvironmentStatus(): readonly EnvironmentCheck[] { return [ @@ -18,6 +21,14 @@ export function getEnvironmentStatus(): readonly EnvironmentCheck[] { label: "STELLAR_PRIVATE_KEY (x402 payment)", ready: Boolean(process.env.STELLAR_PRIVATE_KEY), }, + { + label: "EVM_PRIVATE_KEY (x402 payment, Base Sepolia)", + ready: Boolean(process.env.EVM_PRIVATE_KEY), + }, + { + label: "SVM_PRIVATE_KEY (x402 payment, Solana devnet)", + ready: Boolean(process.env.SVM_PRIVATE_KEY), + }, { label: "MPP_PAYER_SECRET (MPP charge)", ready: Boolean(process.env.MPP_PAYER_SECRET), diff --git a/packages/cli/src/serve.ts b/packages/cli/src/serve.ts index 3535b83..b5e8646 100644 --- a/packages/cli/src/serve.ts +++ b/packages/cli/src/serve.ts @@ -47,7 +47,13 @@ function base58(bytes: Uint8Array): string { } /** What changes from one network to another; the modes stay the same. */ -interface ServeNetwork { +export interface ServeNetwork { + /** The network's name, for messages. */ + readonly name: string; + /** The network as faucet.circle.com lists it. */ + readonly faucetNetwork: string; + /** The chain's native token, which an x402 payer does not need. */ + readonly nativeToken: string; /** The mainnet `wrong-network` asks for instead, and its name. */ readonly mainnet: string; readonly mainnetName: string; @@ -72,6 +78,9 @@ interface ServeNetwork { */ export const SERVE_NETWORKS: Readonly> = { "stellar:testnet": { + name: "Stellar testnet", + faucetNetwork: "Stellar Testnet", + nativeToken: "XLM", mainnet: "stellar:pubnet", mainnetName: "Stellar mainnet", asset: TESTNET_USDC, @@ -87,6 +96,9 @@ export const SERVE_NETWORKS: Readonly> = { payeeEnv: "STELLAR_PAYEE_ADDRESS", }, "eip155:84532": { + name: "Base Sepolia", + faucetNetwork: "Base Sepolia", + nativeToken: "ETH", mainnet: "eip155:8453", mainnetName: "Base mainnet", asset: BASE_SEPOLIA_USDC, @@ -100,6 +112,9 @@ export const SERVE_NETWORKS: Readonly> = { payeeEnv: "EVM_PAYEE_ADDRESS", }, "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": { + name: "Solana devnet", + faucetNetwork: "Solana Devnet", + nativeToken: "SOL", mainnet: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", mainnetName: "Solana mainnet", asset: SOLANA_DEVNET_USDC, diff --git a/packages/cli/src/wallet-command.ts b/packages/cli/src/wallet-command.ts index acefc88..525e31a 100644 --- a/packages/cli/src/wallet-command.ts +++ b/packages/cli/src/wallet-command.ts @@ -1,9 +1,12 @@ /** * `wasit wallet` — testnet-only convenience commands for the payer keys the - * other subcommands already read from .env. Deliberately has no --network - * flag: Friendbot, the printed USDC issuer, and the whole idea of a - * throwaway generated key only make sense on testnet, so there is nothing - * here that could be pointed at pubnet by mistake. + * other subcommands already read from .env. `--network` takes testnets only + * (the ones `wasit serve` poses on): Friendbot, the printed USDC issuer, and + * the whole idea of a throwaway generated key only make sense on testnet, so + * there is nothing here that could be pointed at a mainnet by mistake. + * Stellar testnet is the default and the only network for the MPP roles; on + * Base Sepolia and Solana devnet the x402 payer needs USDC from Circle's + * faucet and no native token, since the facilitator pays the fee. * * Every failure path here exits 2 (configuration) or 1 (the operation ran and * failed) with a single-line message, the same contract the check subcommands @@ -19,6 +22,7 @@ import { generateCommitmentKey, generateTestnetWallet, getTestnetWalletStatus, + paymentChainFor, publicKeyFromSecret, sendUsdcFromDistributor, TESTNET_USDC_ISSUER, @@ -27,6 +31,8 @@ import { type WalletStatus, } from "@wasit-dev/core"; +import { DEFAULT_SERVE_NETWORK, SERVE_NETWORKS } from "./serve.js"; + type WalletRole = "x402" | "mpp-charge" | "mpp-channel"; /** Every role a key can be generated for. */ @@ -100,6 +106,70 @@ function resolvePublicKey(role: WalletRole): { secret: string; publicKey: string } } +/** The testnets `wasit wallet` takes: the ones `wasit serve` poses on. */ +export const WALLET_NETWORKS: readonly string[] = Object.keys(SERVE_NETWORKS); + +/** + * Checks --network against the testnets, and the role against the network: + * the MPP roles are Stellar only. Returns the problem, or undefined. + */ +export function walletNetworkProblem(network: string, role: WalletRole): string | undefined { + if (!WALLET_NETWORKS.includes(network)) { + return `Unknown --network "${network}". Expected a testnet: ${WALLET_NETWORKS.join(", ")}.`; + } + if (network !== DEFAULT_SERVE_NETWORK && role !== "x402") { + return `--role ${role} is Stellar only: MPP runs on Stellar. Only --role x402 takes --network ${network}.`; + } + return undefined; +} + +function requireNetwork(value: string | undefined, role: WalletRole): string { + const network = value ?? DEFAULT_SERVE_NETWORK; + const problem = walletNetworkProblem(network, role); + if (problem !== undefined) { + console.error(problem); + process.exit(2); + } + return network; +} + +/** Base units as a decimal amount, e.g. 19970000 with 6 decimals as 19.97. */ +export function formatUnits(units: bigint, decimals: number): string { + const scale = 10n ** BigInt(decimals); + const fraction = (units % scale).toString().padStart(decimals, "0").replace(/0+$/, ""); + return `${units / scale}${fraction === "" ? "" : `.${fraction}`}`; +} + +/** Where a payer on `network` gets its USDC, and what it does not need. */ +function faucetLines(network: string, address: string): string[] { + const profile = SERVE_NETWORKS[network]!; + return [ + `Fund it with USDC at https://faucet.circle.com (network ${profile.faucetNetwork}),`, + `pasting ${address}.`, + `It needs no ${profile.nativeToken}: the facilitator pays the fee.`, + ]; +} + +/** What `wallet create --role x402 --network ` prints. */ +export function generatedKeyLines(network: string, secret: string): string[] { + const chain = paymentChainFor(network)!; + const address = chain.payerAddress(secret); + return [ + `Generated a new ${SERVE_NETWORKS[network]!.name} key for x402:`, + "", + `Address: ${address}`, + `Secret: ${secret}`, + "", + "Paste into .env:", + "", + `${chain.payerKeyEnv}=${secret}`, + "", + ...faucetLines(network, address), + "", + "Testnet only — never reuse this key, and never put a mainnet key here.", + ]; +} + function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -153,6 +223,66 @@ function printWalletStatus(role: WalletRole, publicKey: string, status: WalletSt } } +/** + * The configured x402 payer on a network other than Stellar testnet, exiting + * 2 when its key is unset or is not a key for that chain. The message never + * echoes the key. + */ +function resolvePayer(network: string): { address: string } { + const chain = paymentChainFor(network)!; + const secret = process.env[chain.payerKeyEnv]; + if (!secret) { + console.error( + `${chain.payerKeyEnv} is not set. Run \`wasit wallet create --role x402 --network ${network}\` first.`, + ); + process.exit(2); + } + try { + return { address: chain.payerAddress(secret) }; + } catch { + console.error(`${chain.payerKeyEnv} is not a ${chain.name} key.`); + process.exit(2); + } +} + +/** + * Prints the x402 payer's USDC balance on a network other than Stellar + * testnet, and returns the exit code: 2 when it could not be read, since + * `wallet status` must not pass on a balance it never saw. + */ +async function printPayerStatus(network: string, jsonMode: boolean): Promise<0 | 2> { + const chain = paymentChainFor(network)!; + const profile = SERVE_NETWORKS[network]!; + const { address } = resolvePayer(network); + let balance: bigint | undefined; + let error: string | undefined; + try { + const read = chain.payerBalance(network, chain.resolveRpcUrl(network), address, profile.asset); + balance = jsonMode ? await read : await oraPromise(read, `Checking x402 on ${profile.name}...`); + if (balance === undefined) error = "the chain gave no balance"; + } catch (caught) { + error = messageOf(caught); + } + if (jsonMode) { + console.log( + JSON.stringify( + [{ role: "x402", network, address, usdc: balance === undefined ? undefined : formatUnits(balance, profile.decimals), error }], + null, + 2, + ), + ); + } else { + console.log(`x402 ${address} (${profile.name})`); + console.log( + balance === undefined + ? ` Could not check: ${error}` + : ` ${"USDC".padEnd(10)} ${formatUnits(balance, profile.decimals)}`, + ); + if (balance === 0n) for (const line of faucetLines(network, address)) console.log(` ${line}`); + } + return balance === undefined ? 2 : 0; +} + export function registerWalletCommand(program: Command): void { const wallet = program .command("wallet") @@ -162,6 +292,7 @@ export function registerWalletCommand(program: Command): void { .command("status") .description("Show balances for the configured payer role(s)") .option("--role ", `One of: ${ACCOUNT_ROLES.join(", ")}. Default: check both.`) + .option("--network ", `Testnet: ${WALLET_NETWORKS.join(", ")}`, DEFAULT_SERVE_NETWORK) .option("--json", "Print as JSON instead of formatted text", false) .addHelpText( "after", @@ -169,16 +300,23 @@ export function registerWalletCommand(program: Command): void { Examples: $ wasit wallet status $ wasit wallet status --role mpp-charge --json + $ wasit wallet status --network eip155:84532 With --role, exits 2 if that role could not be checked (key not set, unreadable, or lookup failed); without it, every role is a row and it exits 0. -Testnet only — there is no --network flag. mpp-channel is not accepted here +Testnet only. On a network other than Stellar testnet it shows the x402 +payer's USDC balance. mpp-channel is not accepted here even when configured: COMMITMENT_SECRET_HEX only ever signs off-chain (see docs/guides/configuration.md) and has no on-chain balance of its own.`, ) .action(async (opts) => { const jsonMode = opts.json === true; + if (opts.network !== DEFAULT_SERVE_NETWORK) { + const role = opts.role === undefined ? "x402" : requireRole(opts.role, ACCOUNT_ROLES); + const network = requireNetwork(opts.network, role); + process.exit(await printPayerStatus(network, jsonMode)); + } const roles: readonly WalletRole[] = opts.role !== undefined ? [requireRole(opts.role, ACCOUNT_ROLES)] : ACCOUNT_ROLES; @@ -247,13 +385,16 @@ docs/guides/configuration.md) and has no on-chain balance of its own.`, .command("create") .description("Generate a new testnet key for a payer role") .requiredOption("--role ", `One of: ${ROLES.join(", ")}`) - .option("--fund", "Immediately fund the new key with testnet XLM", false) + .option("--network ", `Testnet: ${WALLET_NETWORKS.join(", ")}`, DEFAULT_SERVE_NETWORK) + .option("--fund", "Immediately fund the new key with testnet XLM (Stellar only)", false) .addHelpText( "after", ` Examples: $ wasit wallet create --role mpp-charge --fund $ wasit wallet create --role mpp-channel + $ wasit wallet create --role x402 --network eip155:84532 + $ wasit wallet create --role x402 --network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 Testnet only. Prints the exact .env line(s) to paste — never writes to .env itself, so it can never silently overwrite something already there. @@ -263,6 +404,21 @@ are recording, and never paste a pubnet key into these variables.`, ) .action(async (opts) => { const role = requireRole(opts.role, ROLES); + const network = requireNetwork(opts.network, role); + + if (network !== DEFAULT_SERVE_NETWORK) { + if (opts.fund === true) { + console.error( + `--fund is Stellar only (Friendbot). On ${SERVE_NETWORKS[network]!.name} the payer ` + + "needs USDC from https://faucet.circle.com and nothing else.", + ); + process.exit(2); + } + for (const line of generatedKeyLines(network, paymentChainFor(network)!.generatePayerKey())) { + console.log(line); + } + return; + } if (role === "mpp-channel") { const key = generateCommitmentKey(); @@ -307,6 +463,7 @@ are recording, and never paste a pubnet key into these variables.`, .command("fund") .description("Fund a configured payer role with testnet XLM or USDC") .requiredOption("--role ", `One of: ${ACCOUNT_ROLES.join(", ")}`) + .option("--network ", `Testnet: ${WALLET_NETWORKS.join(", ")}`, DEFAULT_SERVE_NETWORK) .option("--asset ", "xlm or usdc", "xlm") .option("--amount ", "USDC amount to request from the distributor account", "50") .addHelpText( @@ -321,10 +478,20 @@ Examples: actually receiving a balance needs either a manual visit to https://faucet.circle.com (paste the printed public key) or WASIT_USDC_DISTRIBUTOR_SECRET set in .env, naming an account you already -funded that way once — there is no scriptable USDC faucet for Stellar.`, +funded that way once — there is no scriptable USDC faucet for Stellar. + +On another network (--network) there is nothing to do on-chain first: it +prints where to get USDC for the configured x402 payer.`, ) .action(async (opts) => { const role = requireRole(opts.role, ACCOUNT_ROLES); + const network = requireNetwork(opts.network, role); + if (network !== DEFAULT_SERVE_NETWORK) { + const { address } = resolvePayer(network); + console.log(`No faucet can be called for ${SERVE_NETWORKS[network]!.name} from here.`); + for (const line of faucetLines(network, address)) console.log(line); + return; + } const { secret, publicKey } = resolvePublicKey(role); const asset: string = opts.asset; diff --git a/packages/cli/test/unit/environment.test.ts b/packages/cli/test/unit/environment.test.ts index 65301f7..38f3249 100644 --- a/packages/cli/test/unit/environment.test.ts +++ b/packages/cli/test/unit/environment.test.ts @@ -3,7 +3,13 @@ import { afterEach, beforeEach, describe, it } from "node:test"; import { getEnvironmentStatus } from "../../src/dashboard/environment.js"; -const ENV_KEYS = ["STELLAR_PRIVATE_KEY", "COMMITMENT_SECRET_HEX", "MPP_PAYER_SECRET"] as const; +const ENV_KEYS = [ + "STELLAR_PRIVATE_KEY", + "EVM_PRIVATE_KEY", + "SVM_PRIVATE_KEY", + "COMMITMENT_SECRET_HEX", + "MPP_PAYER_SECRET", +] as const; describe("getEnvironmentStatus", () => { const saved: Partial> = {}; diff --git a/packages/cli/test/unit/wallet-networks.test.ts b/packages/cli/test/unit/wallet-networks.test.ts new file mode 100644 index 0000000..c1a8aac --- /dev/null +++ b/packages/cli/test/unit/wallet-networks.test.ts @@ -0,0 +1,64 @@ +/** + * `wasit wallet --network`: testnets only, the MPP roles on Stellar only, + * and a generated key in the form the payment checks read. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { paymentChainFor } from "@wasit-dev/core"; + +import { + WALLET_NETWORKS, + formatUnits, + generatedKeyLines, + walletNetworkProblem, +} from "../../src/wallet-command.js"; + +const BASE = "eip155:84532"; +const SOLANA = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; + +describe("walletNetworkProblem", () => { + it("takes the testnets, for x402", () => { + for (const network of WALLET_NETWORKS) assert.equal(walletNetworkProblem(network, "x402"), undefined, network); + assert.ok(WALLET_NETWORKS.includes(BASE) && WALLET_NETWORKS.includes(SOLANA)); + }); + + it("refuses a mainnet or an unknown network", () => { + for (const network of ["stellar:pubnet", "eip155:8453", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "base"]) { + assert.match(walletNetworkProblem(network, "x402") ?? "", /Expected a testnet/, network); + } + }); + + it("keeps the MPP roles on Stellar", () => { + assert.equal(walletNetworkProblem("stellar:testnet", "mpp-charge"), undefined); + assert.match(walletNetworkProblem(BASE, "mpp-charge") ?? "", /Stellar only/); + assert.match(walletNetworkProblem(SOLANA, "mpp-channel") ?? "", /Stellar only/); + }); +}); + +describe("generatedKeyLines", () => { + for (const [network, env, faucet, native] of [ + [BASE, "EVM_PRIVATE_KEY", "Base Sepolia", "ETH"], + [SOLANA, "SVM_PRIVATE_KEY", "Solana Devnet", "SOL"], + ] as const) { + it(`${network}: the .env line, the address it pays from, and where to get USDC`, () => { + const chain = paymentChainFor(network)!; + const secret = chain.generatePayerKey(); + const text = generatedKeyLines(network, secret).join("\n"); + assert.ok(text.includes(`${env}=${secret}`)); + assert.ok(text.includes(`Address: ${chain.payerAddress(secret)}`)); + assert.ok(text.includes(`network ${faucet}`)); + assert.ok(text.includes(`needs no ${native}`)); + }); + } +}); + +describe("formatUnits", () => { + it("writes base units as a decimal amount", () => { + assert.equal(formatUnits(19_970_000n, 6), "19.97"); + assert.equal(formatUnits(20_000_000n, 6), "20"); + assert.equal(formatUnits(5n, 6), "0.000005"); + assert.equal(formatUnits(0n, 6), "0"); + }); +}); From 3d9c11cbcd41172fcd26abd4f2814aae99bbf2fe Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Mon, 5 Oct 2026 22:27:23 +0800 Subject: [PATCH 5/6] fix(core): x402 v1 challenge messages no longer read as Stellar-only X402-02's detail for a challenge in the v1 body form gave Stellar's `exact` scheme as the reason a header is required, and the payment checks' skip reason said Wasit pays through the exact scheme "on Stellar". Both predate the other chains: a v1 challenge from a Base Sepolia paywall was told about Stellar. They now say x402 v2 signals payment in PAYMENT-REQUIRED and that the payment checks pay through v2 only, keeping the Stellar fact as Stellar's. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/x402/simulator.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/x402/simulator.ts b/packages/core/src/x402/simulator.ts index 1d3e068..a84e2e6 100644 --- a/packages/core/src/x402/simulator.ts +++ b/packages/core/src/x402/simulator.ts @@ -275,9 +275,9 @@ export async function runX402ReadChecks( ? "Payment header found." : v1Payload !== undefined ? `Neither PAYMENT-REQUIRED nor X-Payment header was present. The ` + - `response body carries an x402 v1 challenge instead; the \`exact\` ` + - `scheme on Stellar is defined for v2 only, which signals payment in ` + - `the PAYMENT-REQUIRED header.` + `response body carries an x402 v1 challenge instead; x402 v2 signals ` + + `payment in the PAYMENT-REQUIRED header, and the \`exact\` scheme on ` + + `Stellar is defined for v2 only.` : "Neither PAYMENT-REQUIRED nor X-Payment header was present.", ...(headerValue !== null ? {} @@ -477,9 +477,9 @@ async function preparePayment( const v1 = readV1BodyChallenge(await readText(challenge)) !== undefined; throw new PaymentNotAttemptedError( v1 - ? `the target issued an x402 v1 challenge, and Wasit pays through the ` + - `v2 \`exact\` scheme on Stellar, the only version the spec defines, ` + - `so no payment was attempted (see X402-02).` + ? `the target issued an x402 v1 challenge, and Wasit's payment checks ` + + `pay through x402 v2 only (on Stellar, the only version the \`exact\` ` + + `scheme defines), so no payment was attempted (see X402-02).` : `the challenge could not be read as x402 payment requirements ` + `(${(error as Error).message}), so no payment was attempted ` + `(see X402-02–04).`, From d82555a996808a9d969e6c650f45d296476e46f1 Mon Sep 17 00:00:00 2001 From: Muhammad Dzakwan Najmi Date: Mon, 5 Oct 2026 22:30:38 +0800 Subject: [PATCH 6/6] feat(cli): wasit serve v1-challenge and malformed-header modes Two modes about the challenge itself, for testing how an agent reads one. `v1-challenge` sends only the x402 v1 form: the PaymentRequirementsResponse in the 402 body, with v1's field names (maxAmountRequired, resource, description, mimeType) and the network's v1 name (base-sepolia, solana-devnet), and no PAYMENT-REQUIRED header, as a paywall on the old SDK does. It refuses Stellar, which v1 names no network for. `malformed-header` sends a PAYMENT-REQUIRED header that is base64 of the challenge's JSON cut short. Both answer a payment with 402 and log which header it came in and what that says about the agent. Wasit's read checks see exactly that: the v1 challenge is complete as v1 (X402-04 "All required v1 fields present") and fails X402-02 and X402-05; the malformed header fails X402-03. With the official x402 SDK client as the agent, it paid the v1 challenge as v1 in X-PAYMENT on Base Sepolia and Solana devnet, and refused the malformed one without paying. The evidence file also records `wasit wallet --network` as run. The tests that every challenge is a well-formed v2 one now cover the four modes it is true of. 75 CLI tests; 6 mutants killed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +- ...0-05-wallet-network-and-serve-modes-run.md | 81 +++++++++++ docs/evidence/README.md | 1 + docs/guides/cli.md | 16 ++- packages/cli/README.md | 6 +- packages/cli/src/serve.ts | 131 +++++++++++++++++- packages/cli/test/unit/serve.test.ts | 108 ++++++++++++++- 7 files changed, 336 insertions(+), 14 deletions(-) create mode 100644 docs/evidence/2026-10-05-wallet-network-and-serve-modes-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 773a9fe..d0ea20e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,7 +121,12 @@ 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. `--network eip155:84532` poses the same modes on Base Sepolia, and `--network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` on Solana -devnet. +devnet. Two more modes are about the challenge: `v1-challenge` sends only an x402 v1 +challenge (Base Sepolia and Solana devnet; v1 names no Stellar network), and +`malformed-header` a `PAYMENT-REQUIRED` header that does not decode; both answer a +payment with 402 and log which header it came in. The official SDK client pays the +first as v1 and refuses the second without paying +([evidence](docs/evidence/2026-10-05-wallet-network-and-serve-modes-run.md)). **Added — `wasit wallet --network` for the Base Sepolia and Solana devnet payers.** `wasit wallet create --role x402 --network eip155:84532` (or Solana devnet) generates the diff --git a/docs/evidence/2026-10-05-wallet-network-and-serve-modes-run.md b/docs/evidence/2026-10-05-wallet-network-and-serve-modes-run.md new file mode 100644 index 0000000..8eae755 --- /dev/null +++ b/docs/evidence/2026-10-05-wallet-network-and-serve-modes-run.md @@ -0,0 +1,81 @@ +# `wasit wallet --network` and Two New `wasit serve` Modes — What They Do, as Run + +**Date:** 2026-10-05 +**Wasit:** builds of the local `release/0.7.0` branch (`4c3ca55` for `wallet --network`, +and the commit after it for the two modes), not yet published. +**Targets:** none operated by anyone else. `wasit wallet` read balances from each +chain's public RPC; `wasit serve` ran on our machine, and the official x402 SDK client +was pointed at it as the agent. +**Environment:** macOS, Node `v26.8.1`. + +**Authorization.** None was needed or sought. No service operated by anyone else was +tested. + +## `wasit wallet --network` + +Output as printed, with the generated secrets masked here: + +``` +$ wasit wallet create --role x402 --network eip155:84532 +Generated a new Base Sepolia key for x402: + +Address: 0x44170cBa1F242367Ce56BC8Df93531c39E40a05c +Secret: 0x<64-hex masked> + +Paste into .env: + +EVM_PRIVATE_KEY=0x<64-hex masked> + +Fund it with USDC at https://faucet.circle.com (network Base Sepolia), +pasting 0x44170cBa1F242367Ce56BC8Df93531c39E40a05c. +It needs no ETH: the facilitator pays the fee. +``` + +`--network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` printed the same shape with an +`SVM_PRIVATE_KEY=` line. Against the configured payers: + +``` +$ wasit wallet status --network eip155:84532 +x402 0xC1716C740469a8E3dc68EC420d196164666c0428 (Base Sepolia) + USDC 19.8 + +$ wasit wallet status --network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 --json +[ { "role": "x402", "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "address": "Dya3U1bbDA7N9SSqptqgCZ4RuEeDQr1RriF9WLiYoQ9m", "usdc": "19.95" } ] +``` + +Refused, each with exit 2: `--role mpp-charge --network eip155:84532` ("Stellar only: +MPP runs on Stellar"), `--network eip155:8453` ("Expected a testnet"), and `--fund` on +Base Sepolia ("--fund is Stellar only (Friendbot)"). `wasit wallet status --role x402` +without `--network` printed the Stellar balances as before. + +## Two `wasit serve` modes about the challenge + +The agent was the official x402 SDK client (`@x402/fetch`'s `wrapFetchWithPayment`, +2.28.0, with `@x402/evm` and `@x402/svm` registered), signing with throwaway keys. + +| Mode | What the paywall sends | What the SDK client did | `wasit test --read-only` | +|---|---|---|---| +| `v1-challenge`, Base Sepolia | an x402 v1 challenge in the body (`base-sepolia`, `maxAmountRequired`), no `PAYMENT-REQUIRED` header | paid it as v1, in `X-PAYMENT` | `X402-02` FAIL (v1 body challenge), `X402-04` "All required v1 fields present", `X402-05` FAIL (`base-sepolia` is not CAIP-2) | +| `v1-challenge`, Solana devnet | the same, on `solana-devnet` | paid it as v1, in `X-PAYMENT` | as above | +| `malformed-header`, Base Sepolia | a `PAYMENT-REQUIRED` header that is base64 of JSON cut short | threw `Failed to parse payment requirements: Unterminated string in JSON`, and paid nothing | `X402-02` PASS, `X402-03` FAIL, `X402-04`–`05` SKIP | + +The server's own lines, quoted: + +``` +GET /paid: 402 x402 v1 challenge in the body, 10000 base units on base-sepolia, no PAYMENT-REQUIRED header +GET /paid: payment received in X-PAYMENT for ? base units on base-sepolia. Your agent paid the v1 challenge as v1, in X-PAYMENT. Answered 402: wasit serve never settles. +GET /paid: 402 with a PAYMENT-REQUIRED header that does not decode +``` + +`v1-challenge` refuses Stellar (`x402 v1 names no Stellar testnet network`): the `exact` +scheme on Stellar is defined for v2 only. Both modes answer any payment with 402, since +they are about the challenge, and neither settles anything. + +## Limits + +- The agent here was the official SDK client only. How other agents behave is what the + modes are for, and was not run. +- `wasit wallet` cannot fund Base Sepolia or Solana devnet: Circle's faucet takes no + scripted requests, so `fund --network` prints the faucet step. +- Unreleased: this ran from the branch, not from npm. diff --git a/docs/evidence/README.md b/docs/evidence/README.md index da90103..7d454af 100644 --- a/docs/evidence/README.md +++ b/docs/evidence/README.md @@ -92,6 +92,7 @@ and what it does and does not establish. Newest first. | Date | File | What it shows | Deliverable | |---|---|---|---| +| 2026-10-05 | [wallet-network-and-serve-modes-run](2026-10-05-wallet-network-and-serve-modes-run.md) | `wasit wallet --network` makes and reads the Base Sepolia and Solana devnet payers; `wasit serve`'s `v1-challenge` and `malformed-header` modes, with the official SDK client as the agent: it pays the first as v1 and refuses the second (unreleased 0.7.0 branch) | Beyond the SOW | | 2026-10-05 | [payer-balance-check-run](2026-10-05-payer-balance-check-run.md) | `X402-06` reads the payer's balance before signing (unreleased 0.7.0 branch): fresh, unfunded payers get `ERROR (setup)` with nothing sent on Stellar, Base Sepolia and Solana devnet, where they used to read as a target's failure or an unexplained error; funded payers pass 10/10 on every fixture | Beyond the SOW | | 2026-10-05 | [solana-devnet-verification-run](2026-10-05-solana-devnet-verification-run.md) | The x402 payment checks on Solana devnet (unreleased 0.7.0 branch): `wasit serve`'s lying modes fail `X402-06`–`10` and a mainnet request gets nothing sent; Wasit's own fixture passes 10/10 with `X402-06` settled on-chain and read back independently, the payer holding no SOL; the packed core installs and runs alongside TypeScript 5.8, 6 and 7 | Beyond the SOW | | 2026-10-05 | [x402-negative-checks-run](2026-10-05-x402-negative-checks-run.md) | `X402-08` replay, `X402-09` underpayment and `X402-10` expired authorization (unreleased 0.7.0 branch): Wasit's fixtures pass 10/10 on Stellar and Base Sepolia, each refusal for the reason the check is about; `wasit serve --mode no-settle` fails all of them on both chains | Beyond the SOW | diff --git a/docs/guides/cli.md b/docs/guides/cli.md index 2e8f20b..54f9da0 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -254,10 +254,12 @@ wasit serve --mode no-settle | `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 | +| `v1-challenge` | Issues its challenge only in the x402 v1 form, a JSON body with v1 field names and network name (`base-sepolia`, `solana-devnet`) and no `PAYMENT-REQUIRED` header, as a paywall on the old SDK does. Base Sepolia and Solana devnet only: v1 names no Stellar network | Pays it as v1, in an `X-PAYMENT` header, or declines; does not answer it with a v2 payment | +| `malformed-header` | Sends a `PAYMENT-REQUIRED` header that does not decode: base64 of the challenge's JSON, cut short | Reports the challenge as unreadable and pays nothing | | Option | Default | Notes | |---|---|---| -| `--mode ` | required | One of the four above | +| `--mode ` | required | One of the six above | | `--port ` | `4020` | The server answers on every path | | `--host ` | `127.0.0.1` | Local only unless you bind another interface | | `--network ` | `stellar:testnet` | `stellar:testnet`, `eip155:84532` (Base Sepolia) or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` (Solana devnet). On Base Sepolia and Solana devnet the paywall uses the SDK's USDC, `wrong-network` asks for that chain's mainnet (`eip155:8453`, `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`), and `overprice` one million USDC in its 6 decimals. On Solana the challenge names the payee as `extra.feePayer`, which the spec allows; nothing is ever submitted | @@ -281,11 +283,17 @@ wasit serve: no-settle on http://127.0.0.1:4020/ (any path) 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 +The first four modes' challenges are well-formed x402 v2 challenges (`wasit +test --read-only` passes them), 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. +like from the checks' side: `X402-06` and `X402-07` fail. The last two are +about the challenge itself: `v1-challenge` is a complete v1 challenge +(`X402-04` reports every v1 field present) and `malformed-header` fails +`X402-03`. They answer any payment with 402 and log which header it came in. +The official x402 SDK client pays `v1-challenge` as v1 and refuses +`malformed-header` without paying +([evidence](../evidence/2026-10-05-wallet-network-and-serve-modes-run.md)). ## Reading output diff --git a/packages/cli/README.md b/packages/cli/README.md index 0a30c20..32a3454 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -122,11 +122,13 @@ Testnet-only helpers for the keys the subcommands above read from `.env`. `--net ### `wasit serve` — a paywall that misbehaves, for testing an agent that pays ```bash -wasit serve --mode no-settle # also: wrong-settlement, wrong-network, overprice +wasit serve --mode no-settle # also: wrong-settlement, wrong-network, overprice, + # v1-challenge, malformed-header ``` 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. +cites someone else's settlement, asks for mainnet, asks for one million USDC, issues +only an x402 v1 challenge, or sends a challenge header that does not decode. 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`) diff --git a/packages/cli/src/serve.ts b/packages/cli/src/serve.ts index b5e8646..2998174 100644 --- a/packages/cli/src/serve.ts +++ b/packages/cli/src/serve.ts @@ -17,7 +17,14 @@ import { randomBytes } from "node:crypto"; import http from "node:http"; -export const SERVE_MODES = ["no-settle", "wrong-settlement", "wrong-network", "overprice"] as const; +export const SERVE_MODES = [ + "no-settle", + "wrong-settlement", + "wrong-network", + "overprice", + "v1-challenge", + "malformed-header", +] as const; export type ServeMode = (typeof SERVE_MODES)[number]; /** Testnet USDC, the Stellar Asset Contract Wasit's own fixture charges in. */ @@ -54,6 +61,8 @@ export interface ServeNetwork { readonly faucetNetwork: string; /** The chain's native token, which an x402 payer does not need. */ readonly nativeToken: string; + /** The network's x402 v1 name, where v1 defines one (it defines none for Stellar). */ + readonly v1Network?: string; /** The mainnet `wrong-network` asks for instead, and its name. */ readonly mainnet: string; readonly mainnetName: string; @@ -99,6 +108,7 @@ export const SERVE_NETWORKS: Readonly> = { name: "Base Sepolia", faucetNetwork: "Base Sepolia", nativeToken: "ETH", + v1Network: "base-sepolia", mainnet: "eip155:8453", mainnetName: "Base mainnet", asset: BASE_SEPOLIA_USDC, @@ -115,6 +125,7 @@ export const SERVE_NETWORKS: Readonly> = { name: "Solana devnet", faucetNetwork: "Solana Devnet", nativeToken: "SOL", + v1Network: "solana-devnet", mainnet: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", mainnetName: "Solana mainnet", asset: SOLANA_DEVNET_USDC, @@ -160,6 +171,14 @@ export const MODE_DESCRIPTIONS: Readonly other.v1Network !== undefined) + .map(([id]) => id) + .join(" or ")}.` + ); + } return undefined; } @@ -227,24 +255,70 @@ export function challengeFor(options: ServeOptions, resourceUrl: string): Record }; } +/** + * The challenge in the x402 v1 form: the `PaymentRequirementsResponse` a v1 + * server returns in the 402 body (v1 spec 5.1), with v1's field names + * (`maxAmountRequired`, and `resource`, `description`, `mimeType` in each + * option) and the network's v1 name. + */ +export function v1ChallengeFor(options: ServeOptions, resourceUrl: string): Record { + const profile = serveNetwork(options); + return { + x402Version: 1, + error: "X-PAYMENT header is required", + accepts: [ + { + scheme: "exact", + network: profile.v1Network, + maxAmountRequired: options.amount ?? DEFAULT_AMOUNT, + resource: resourceUrl, + description: "Paid resource served by wasit serve", + mimeType: "application/json", + payTo: options.payTo, + maxTimeoutSeconds: 60, + asset: options.asset ?? profile.asset, + extra: { ...profile.extra(options.payTo) }, + }, + ], + }; +} + +/** + * The `malformed-header` mode's header: valid base64, of the challenge's + * JSON with its last characters cut, so it decodes to text that is not JSON. + */ +export function malformedHeaderFor(options: ServeOptions, resourceUrl: string): string { + const json = JSON.stringify(challengeFor(options, resourceUrl)); + return Buffer.from(json.slice(0, -12)).toString("base64"); +} + 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 { + /** The header it came in: `PAYMENT-SIGNATURE` (v2) or `X-PAYMENT` (v1). */ + readonly header: string; + readonly version?: number; readonly network?: string; readonly amount?: string; } -function readPayment(header: string): ReceivedPayment | undefined { +function readPayment(header: string, name: string): ReceivedPayment | undefined { try { const payload = JSON.parse(Buffer.from(header, "base64").toString("utf-8")) as { + x402Version?: unknown; + network?: unknown; accepted?: { network?: unknown; amount?: unknown }; }; if (typeof payload !== "object" || payload === null) return undefined; + // v2 names the terms in `accepted`; a v1 payload carries `network` itself. + const network = payload.accepted?.network ?? payload.network; return { - ...(typeof payload.accepted?.network === "string" ? { network: payload.accepted.network } : {}), + header: name, + ...(typeof payload.x402Version === "number" ? { version: payload.x402Version } : {}), + ...(typeof network === "string" ? { network } : {}), ...(typeof payload.accepted?.amount === "string" ? { amount: payload.accepted.amount } : {}), }; } catch { @@ -281,9 +355,27 @@ function verdictFor(options: ServeOptions, payment: ReceivedPayment, settlementT `An agent with a spending limit should have refused.` ); } + case "v1-challenge": + return payment.header === "X-PAYMENT" && payment.version === 1 + ? "Your agent paid the v1 challenge as v1, in X-PAYMENT. Answered 402: wasit serve never settles." + : `Your agent answered a v1 challenge in ${payment.header} with x402Version ` + + `${payment.version ?? "?"}. A v1 server reads only X-PAYMENT with a v1 payload, ` + + `so it would ignore this payment. Answered 402.`; + case "malformed-header": + return ( + "Your agent paid although the PAYMENT-REQUIRED header does not decode: it paid on " + + "terms it could not have read. Answered 402." + ); } } +/** + * The modes whose challenge is not a well-formed x402 v2 challenge, on + * purpose. They answer a payment with 402: they are about the challenge, not + * the settlement. + */ +export const BROKEN_CHALLENGE_MODES: readonly ServeMode[] = ["v1-challenge", "malformed-header"]; + /** * 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. @@ -296,11 +388,32 @@ export function createServeServer(options: ServeOptions): http.Server { 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 headerName = request.headers["payment-signature"] !== undefined ? "PAYMENT-SIGNATURE" : "X-PAYMENT"; 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 ?? "/"}`; + if (options.mode === "v1-challenge") { + const challenge = v1ChallengeFor(options, url); + const accept = (challenge["accepts"] as Array>)[0]!; + log( + `${where}: 402 x402 v1 challenge in the body, ${String(accept["maxAmountRequired"])} ` + + `base units on ${String(accept["network"])}, no PAYMENT-REQUIRED header`, + ); + response.writeHead(402, { "Content-Type": "application/json" }); + response.end(JSON.stringify(challenge)); + return; + } + if (options.mode === "malformed-header") { + log(`${where}: 402 with a PAYMENT-REQUIRED header that does not decode`); + response.writeHead(402, { + "PAYMENT-REQUIRED": malformedHeaderFor(options, url), + "Content-Type": "application/json", + }); + response.end("{}"); + return; + } 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"])}`); @@ -312,7 +425,7 @@ export function createServeServer(options: ServeOptions): http.Server { return; } - const payment = readPayment(paymentHeader); + const payment = readPayment(paymentHeader, headerName); if (payment === undefined) { log(`${where}: payment header did not decode to JSON; answered 402.`); response.writeHead(402, { "Content-Type": "application/json" }); @@ -320,6 +433,16 @@ export function createServeServer(options: ServeOptions): http.Server { return; } + if (BROKEN_CHALLENGE_MODES.includes(options.mode)) { + log( + `${where}: payment received in ${payment.header} for ${payment.amount ?? "?"} base units on ` + + `${payment.network ?? "?"}. ${verdictFor(options, payment, settlementTx)}`, + ); + response.writeHead(402, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: "wasit serve never settles" })); + return; + } + const headers: Record = { "Content-Type": "application/json" }; if (options.mode === "wrong-settlement") { headers["PAYMENT-RESPONSE"] = encode({ diff --git a/packages/cli/test/unit/serve.test.ts b/packages/cli/test/unit/serve.test.ts index 36f9b24..4ae234b 100644 --- a/packages/cli/test/unit/serve.test.ts +++ b/packages/cli/test/unit/serve.test.ts @@ -15,6 +15,7 @@ import { runX402ReadChecks } from "@wasit-dev/core"; import { BASE_SEPOLIA_USDC, + BROKEN_CHALLENGE_MODES, OVERPRICE_AMOUNT, SERVE_MODES, SOLANA_DEVNET_USDC, @@ -22,10 +23,14 @@ import { challengeFor, createServeServer, overpriceAmount, + v1ChallengeFor, validateServeOptions, type ServeMode, } from "../../src/serve.js"; +/** The modes whose challenge is a well-formed x402 v2 challenge. */ +const WELL_FORMED_MODES = SERVE_MODES.filter((mode) => !BROKEN_CHALLENGE_MODES.includes(mode)); + const PAY_TO = "GBNCC3VFT7PGUMGDQU5O35SXVWWTAHCO4LFFWLYFBLQCD56DNWYWM6ZS"; const CITED_TX = "02f56c0a9c1c702d504fc168013dbe4d4f1ce3bda52d7c1d432d60a6afcecfe8"; @@ -110,7 +115,7 @@ describe("challengeFor", () => { }); describe("every mode's challenge is a well-formed x402 v2 challenge", () => { - for (const mode of SERVE_MODES) { + for (const mode of WELL_FORMED_MODES) { it(`${mode}: Wasit's read checks pass it`, async () => { const { url, server } = await start(mode); try { @@ -264,7 +269,7 @@ describe("on Base Sepolia", () => { assert.match(validateServeOptions({ mode: "no-settle", network: "eip155:1", payTo: EVM_PAY_TO }) ?? "", /Unknown network/); }); - for (const mode of SERVE_MODES) { + for (const mode of WELL_FORMED_MODES) { it(`${mode}: Wasit's read checks pass the Base Sepolia challenge`, async () => { const { url, server } = await start(mode, BASE); try { @@ -331,7 +336,7 @@ describe("on Solana devnet", () => { ); }); - for (const mode of SERVE_MODES) { + for (const mode of WELL_FORMED_MODES) { it(`${mode}: Wasit's read checks pass the Solana devnet challenge`, async () => { const { url, server } = await start(mode, SOLANA); try { @@ -372,3 +377,100 @@ describe("on Solana devnet", () => { } }); }); + +function v1PaymentHeader(network: string): string { + return Buffer.from( + JSON.stringify({ x402Version: 1, scheme: "exact", network, payload: { signature: "0x00" } }), + ).toString("base64"); +} + +describe("v1-challenge", () => { + it("poses only where x402 v1 names the network", () => { + assert.equal(validateServeOptions({ mode: "v1-challenge", ...BASE }), undefined); + assert.equal(validateServeOptions({ mode: "v1-challenge", ...SOLANA }), undefined); + assert.match( + validateServeOptions({ mode: "v1-challenge", payTo: PAY_TO }) ?? "", + /x402 v1 names no Stellar testnet network/, + ); + }); + + it("issues the v1 body challenge with v1 field names and network names, and no header", async () => { + const { url, server } = await start("v1-challenge", BASE); + try { + const response = await fetch(url); + assert.equal(response.status, 402); + assert.equal(response.headers.get("payment-required"), null); + const body = (await response.json()) as { x402Version: number; accepts: Array> }; + assert.equal(body.x402Version, 1); + assert.equal(body.accepts[0]?.["network"], "base-sepolia"); + assert.equal(body.accepts[0]?.["maxAmountRequired"], "10000"); + assert.equal(body.accepts[0]?.["resource"], url); + assert.equal(body.accepts[0]?.["amount"], undefined, "v1 has no amount field"); + } finally { + await stop(server); + } + const solana = v1ChallengeFor({ mode: "v1-challenge", ...SOLANA }, "http://x/paid"); + const option = (solana["accepts"] as Array>)[0]!; + assert.equal(option["network"], "solana-devnet"); + assert.deepEqual(option["extra"], { feePayer: SVM_PAY_TO }); + }); + + it("is a complete v1 challenge to Wasit's read checks, and fails only as v1", async () => { + const { url, server } = await start("v1-challenge", BASE); + try { + const r = Object.fromEntries((await runX402ReadChecks({ target: url })).map((result) => [result.id, result])); + assert.equal(r["X402-02"]?.pass, false); + assert.match(r["X402-02"]?.detail ?? "", /x402 v1 challenge/); + assert.match(r["X402-04"]?.detail ?? "", /All required v1 fields present/); + assert.equal(r["X402-05"]?.pass, false, "base-sepolia is not CAIP-2"); + } finally { + await stop(server); + } + }); + + it("tells a v1 payment in X-PAYMENT from a v2 answer, and serves neither", async () => { + const { url, lines, server } = await start("v1-challenge", BASE); + try { + const v1 = await fetch(url, { headers: { "X-PAYMENT": v1PaymentHeader("base-sepolia") } }); + assert.equal(v1.status, 402); + assert.match(lines.at(-1) ?? "", /in X-PAYMENT for \? base units on base-sepolia\./); + assert.match(lines.at(-1) ?? "", /paid the v1 challenge as v1, in X-PAYMENT/); + const v2 = await fetch(url, { headers: { "PAYMENT-SIGNATURE": paymentHeader("eip155:84532", "10000") } }); + assert.equal(v2.status, 402); + assert.match(lines.at(-1) ?? "", /in PAYMENT-SIGNATURE with x402Version 2.*would ignore this payment/); + } finally { + await stop(server); + } + }); +}); + +describe("malformed-header", () => { + it("sends a header that is base64 but does not decode to JSON", async () => { + const { url, server } = await start("malformed-header"); + try { + const response = await fetch(url); + assert.equal(response.status, 402); + const header = response.headers.get("payment-required"); + assert.ok(header); + const text = Buffer.from(header, "base64").toString("utf-8"); + assert.ok(text.startsWith('{"x402Version":2'), "it is the real challenge, cut short"); + assert.throws(() => JSON.parse(text)); + } finally { + await stop(server); + } + }); + + it("fails X402-03, and serves no payment made on terms that could not be read", async () => { + const { url, lines, server } = await start("malformed-header"); + try { + const r = Object.fromEntries((await runX402ReadChecks({ target: url })).map((result) => [result.id, result])); + assert.equal(r["X402-02"]?.pass, true); + assert.equal(r["X402-03"]?.pass, false); + const paid = await fetch(url, { headers: { "PAYMENT-SIGNATURE": paymentHeader("stellar:testnet", "10000") } }); + assert.equal(paid.status, 402); + assert.match(lines.at(-1) ?? "", /paid on terms it could not have read/); + } finally { + await stop(server); + } + }); +});