From 998f4210c3ed2e9e87ee8d4c0dc135d5cc8ae876 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 25 May 2026 23:59:51 +0300 Subject: [PATCH 01/10] test(interop): add x402-exact intent + TS reference fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the canonical x402 `exact` intent to the cross-language interop harness, plus TypeScript reference client and server fixtures and matrix wiring that registers the Rust spine adapters already shipped under `rust/crates/x402/src/bin/`. Language adapters can now target the harness contract (X402_INTEROP_* env vars, ready/result JSON shapes) to validate against the Rust spine cell. The TS reference fixture carries a stub credential payload (challenge id + resource) so the harness wiring, negative-code classification, cross-server portability, and idempotent-resubmit flows can run without a full Solana signer. Pair restriction in the matrix gates TS↔TS and Rust↔Rust by default; full TS↔Rust on-chain settlement parity lands with a follow-up SDK port. The legacy MPP charge runner hard-skips the new intent so default `pnpm test` behaviour is unchanged. --- harness/README.md | 49 +++ harness/src/contracts.ts | 21 +- .../src/fixtures/typescript/exact-client.ts | 225 +++++++++++ .../src/fixtures/typescript/exact-server.ts | 368 ++++++++++++++++++ .../src/fixtures/typescript/exact-shared.ts | 87 +++++ harness/src/implementations.ts | 70 ++++ harness/src/intents/x402-exact.ts | 119 ++++++ harness/test/cross-server-scenarios.test.ts | 210 ++++++++++ harness/test/e2e.test.ts | 14 +- harness/test/intent-selection.test.ts | 31 +- harness/test/x402-exact.e2e.test.ts | 128 ++++++ 11 files changed, 1313 insertions(+), 9 deletions(-) create mode 100644 harness/src/fixtures/typescript/exact-client.ts create mode 100644 harness/src/fixtures/typescript/exact-server.ts create mode 100644 harness/src/fixtures/typescript/exact-shared.ts create mode 100644 harness/src/intents/x402-exact.ts create mode 100644 harness/test/cross-server-scenarios.test.ts create mode 100644 harness/test/x402-exact.e2e.test.ts diff --git a/harness/README.md b/harness/README.md index 490662fc0..8a6546533 100644 --- a/harness/README.md +++ b/harness/README.md @@ -123,6 +123,55 @@ Use these environment variables to filter the active matrix: - `MPP_INTEROP_INTENTS=charge` - `MPP_INTEROP_SCENARIOS=charge-basic,charge-split-ata,charge-network-mismatch,charge-cross-route-replay` +### x402 exact intent + +A second intent, `x402-exact`, exercises the canonical x402 `exact` scheme +against the Rust spine in `rust/crates/x402/src/bin/interop_{client,server}.rs`. +The TypeScript reference adapters live at +`src/fixtures/typescript/exact-{client,server}.ts` and share the same +harness contract as the Rust spine: identical `X402_INTEROP_*` env vars, +identical `PAYMENT-REQUIRED` / `PAYMENT-SIGNATURE` headers, identical +ready / result JSON shapes. The TS reference fixture carries a stub +credential payload (challenge id + resource) and is paired against the +TS reference server in the default matrix; the Rust spine is paired +against itself. As language adapters that carry a real Solana +PaymentProof land, they expand the matrix by registering under +`intents: ["x402-exact"]` in `implementations.ts`. + +Env vars consumed by both roles: + +- `X402_INTEROP_RPC_URL`, `X402_INTEROP_NETWORK`, `X402_INTEROP_MINT` +- `X402_INTEROP_PAY_TO`, `X402_INTEROP_PRICE` +- `X402_INTEROP_FACILITATOR_SECRET_KEY` + +Server-only: + +- `X402_INTEROP_EXTRA_OFFERED_MINTS` (CSV of additional mint addresses) + +Client-only: + +- `X402_INTEROP_TARGET_URL` +- `X402_INTEROP_CLIENT_SECRET_KEY` +- `X402_INTEROP_PREFER_CURRENCIES` (CSV of preferred currencies) + +Run the x402 matrix slice: + +```bash +X402_INTEROP_MATRIX=1 \ +X402_INTEROP_RPC_URL=http://127.0.0.1:8899 \ +X402_INTEROP_MINT=... X402_INTEROP_PAY_TO=... \ +X402_INTEROP_CLIENT_SECRET_KEY='[...]' \ +X402_INTEROP_FACILITATOR_SECRET_KEY='[...]' \ +pnpm test x402-exact.e2e.test.ts +``` + +Cross-server portability and idempotent-resubmit scenarios are gated +separately: + +```bash +X402_INTEROP_CROSS_SERVER=1 pnpm test cross-server-scenarios.test.ts +``` + The current scenario set covers only the `charge` intent. It includes a basic payment, a split payment that requires the server fee payer to create the split recipient ATA, a negative network-mismatch payment, and a cross-route replay diff --git a/harness/src/contracts.ts b/harness/src/contracts.ts index 145301551..288ed18a7 100644 --- a/harness/src/contracts.ts +++ b/harness/src/contracts.ts @@ -1,11 +1,12 @@ import type { CanonicalErrorCode } from "./canonical-codes"; import { chargeScenarios } from "./intents/charge"; +import { x402ExactScenarios } from "./intents/x402-exact"; export type { CanonicalErrorCode }; export type AdapterKind = "client" | "server"; -export type InteropIntent = "charge"; +export type InteropIntent = "charge" | "x402-exact"; export type InteropScenarioSplit = { recipientKey: string; @@ -136,8 +137,10 @@ export type AdapterMessage = ReadyMessage | ClientRunResult; export { chargeCanonicalJsonVectors } from "./intents/charge"; -export const interopScenarios: readonly InteropScenario[] = - chargeScenarios; +export const interopScenarios: readonly InteropScenario[] = [ + ...chargeScenarios, + ...x402ExactScenarios, +]; export const interopScenario: InteropScenario = { ...(interopScenarios[0] as InteropScenario), @@ -191,11 +194,18 @@ function selectScenarioIds(rawSelection: string | undefined): string[] { return selected; } +// The legacy MPP charge runner predates the x402-exact intent. To keep +// the existing CI matrix's default behaviour (charge-only) stable while +// still surfacing the new intent through `selectInteropIntents("x402-exact")`, +// the empty-selection default is restricted to "charge". Callers that +// want the full intent set should pass the explicit list. +const DEFAULT_INTENTS: readonly InteropIntent[] = ["charge"]; + export function selectInteropIntents( rawSelection: string | undefined, ): InteropIntent[] { if (!rawSelection || rawSelection.trim() === "") { - return [...supportedInteropIntents]; + return [...DEFAULT_INTENTS]; } const selected = rawSelection @@ -209,8 +219,7 @@ export function selectInteropIntents( if (unsupported.length > 0) { throw new Error( `Unsupported MPP_INTEROP_INTENTS value(s): ${unsupported.join(", ")}. ` + - `Supported intents: ${supportedInteropIntents.join(", ")}. ` + - "Session and subscription scenarios are not implemented in this harness yet.", + `Supported intents: ${supportedInteropIntents.join(", ")}.`, ); } diff --git a/harness/src/fixtures/typescript/exact-client.ts b/harness/src/fixtures/typescript/exact-client.ts new file mode 100644 index 000000000..67807f376 --- /dev/null +++ b/harness/src/fixtures/typescript/exact-client.ts @@ -0,0 +1,225 @@ +// TypeScript reference x402 `exact` interop client. +// +// Shares the same `X402_INTEROP_*` env-var contract and ready/result +// JSON protocol as the Rust spine (`rust/crates/x402/src/bin/ +// interop_client.rs`). Sends an unpaid GET, parses the base64 +// `PAYMENT-REQUIRED` envelope, selects an offer (`preferredCurrencies` +// first) and resubmits with `PAYMENT-SIGNATURE`. Prints one result +// JSON line to stdout. +// +// Scope: the fixture carries a stub credential payload (challenge id + +// resource) so the harness wiring, negative-code classification, and +// cross-server portability + idempotent-resubmit flows can run without +// a full Solana signer. Real SVM PaymentProof construction (signed +// VersionedTransaction or settled signature) lives in the Rust spine +// and the TS SDK port; this client only pairs against the TS reference +// server in the default matrix (see `test/x402-exact.e2e.test.ts`). + +import { + PAYMENT_REQUIRED_HEADER, + PAYMENT_SIGNATURE_HEADER, + readX402ClientEnvironment, +} from "./exact-shared"; + +type PaymentRequirement = { + scheme: string; + network: string; + resource?: string; + payTo: string; + asset: string; + maxAmountRequired: string; + extra?: { decimals?: number; tokenProgram?: string }; +}; + +type PaymentRequiredEnvelope = { + x402Version: number; + accepts: PaymentRequirement[]; + resource?: string; +}; + +const STABLECOIN_MINTS: Record> = { + USDC: { + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": + "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + }, + PYUSD: { + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": + "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", + }, +}; + +function resolveMint(currency: string, network: string): string { + const upper = currency.toUpperCase(); + const byNetwork = STABLECOIN_MINTS[upper]; + if (byNetwork && byNetwork[network]) { + return byNetwork[network]; + } + return currency; +} + +function pickOffer( + envelope: PaymentRequiredEnvelope, + preferred: string[], + network: string, +): PaymentRequirement | undefined { + const supported = envelope.accepts.filter( + offer => offer.scheme === "exact" && offer.network === network, + ); + if (supported.length === 0) { + return undefined; + } + if (preferred.length === 0) { + return supported[0]; + } + for (const wanted of preferred) { + const wantedMint = resolveMint(wanted, network); + const match = supported.find(offer => offer.asset === wantedMint); + if (match) return match; + } + return supported[0]; +} + +function decodePaymentRequired(headerValue: string | null): PaymentRequiredEnvelope | null { + if (!headerValue) return null; + try { + const raw = Buffer.from(headerValue, "base64").toString("utf8"); + return JSON.parse(raw) as PaymentRequiredEnvelope; + } catch { + return null; + } +} + +async function readResponseBody(response: Response): Promise { + const raw = await response.text(); + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +async function main() { + const env = readX402ClientEnvironment(); + + const firstResponse = await fetch(env.targetUrl); + const envelope = decodePaymentRequired( + firstResponse.headers.get(PAYMENT_REQUIRED_HEADER), + ); + + if (!envelope) { + console.log( + JSON.stringify({ + type: "result", + implementation: "typescript", + role: "client", + ok: false, + status: firstResponse.status, + responseHeaders: Object.fromEntries(firstResponse.headers.entries()), + responseBody: await readResponseBody(firstResponse), + settlement: null, + error: "missing or unparseable PAYMENT-REQUIRED header", + }), + ); + return; + } + + const offer = pickOffer(envelope, env.preferredCurrencies, env.network); + if (!offer) { + console.log( + JSON.stringify({ + type: "result", + implementation: "typescript", + role: "client", + ok: false, + status: firstResponse.status, + responseHeaders: Object.fromEntries(firstResponse.headers.entries()), + responseBody: await readResponseBody(firstResponse), + settlement: null, + error: `no offer matched network ${env.network}`, + }), + ); + return; + } + + // Credential payload mirrors the canonical x402 `exact` shape: an + // adapter-specific id plus the offer the client is committing to. + // A live SDK would also embed a signed Solana transaction here; the + // matrix runner uses the rust spine for the actual on-chain + // settlement assertions. The TS fixture's role is wire-level + // protocol compliance. + // Use the server-issued challenge id if present (TS reference server + // emits one in the `x-challenge-id` header on the 402). This lets the + // server verify the credential was issued against its own 402 — the + // cross-server portability scenario relies on this distinction. + const issuedChallengeId = firstResponse.headers.get("x-challenge-id"); + const credentialId = + issuedChallengeId ?? + `ts-x402-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + // Mirrors the Rust spine's PaymentPayload wire shape: + // { x402Version, accepted: { scheme, network, asset, payTo, amount, extra? }, + // payload: { ... scheme-specific blob ... }, resource?: string } + // The `payload` field is required by Rust's parser. For the wire-only + // TS adapter the payload carries the credential id plus the route the + // client is committing to; a full SDK fixture would carry a signed + // Solana transaction here. + const credential = { + x402Version: envelope.x402Version, + accepted: { + scheme: offer.scheme, + network: offer.network, + asset: offer.asset, + payTo: offer.payTo, + amount: offer.maxAmountRequired, + extra: offer.extra ?? null, + }, + payload: { + challengeId: credentialId, + resource: offer.resource ?? envelope.resource, + }, + resource: offer.resource ?? envelope.resource, + }; + const credentialHeader = Buffer.from(JSON.stringify(credential), "utf8").toString( + "base64", + ); + + const paidResponse = await fetch(env.targetUrl, { + headers: { [PAYMENT_SIGNATURE_HEADER]: credentialHeader }, + }); + + const responseHeaders = Object.fromEntries(paidResponse.headers.entries()); + // Echo the credential the client sent so the harness can replay it in + // cross-server portability + idempotent-resubmit scenarios. The credential + // is a request header so it is never reflected in the response on its own. + responseHeaders[`${PAYMENT_SIGNATURE_HEADER}-sent`] = credentialHeader; + + console.log( + JSON.stringify({ + type: "result", + implementation: "typescript", + role: "client", + ok: paidResponse.ok, + status: paidResponse.status, + responseHeaders, + responseBody: await readResponseBody(paidResponse), + settlement: paidResponse.headers.get(env.settlementHeader), + }), + ); +} + +void main().catch(error => { + console.log( + JSON.stringify({ + type: "result", + implementation: "typescript", + role: "client", + ok: false, + status: 0, + responseHeaders: {}, + responseBody: null, + settlement: null, + error: error instanceof Error ? error.message : String(error), + }), + ); +}); diff --git a/harness/src/fixtures/typescript/exact-server.ts b/harness/src/fixtures/typescript/exact-server.ts new file mode 100644 index 000000000..780c6633e --- /dev/null +++ b/harness/src/fixtures/typescript/exact-server.ts @@ -0,0 +1,368 @@ +// TypeScript reference x402 `exact` interop server. +// +// Wire-compatible with `rust/crates/x402/src/bin/interop_server.rs`: +// - 402 carries a `PAYMENT-REQUIRED` header whose value is the +// base64 of the JSON envelope `{x402Version, accepts, resource}`. +// - The credential is delivered in the `PAYMENT-SIGNATURE` header. +// - On successful settlement, the response includes +// `PAYMENT-RESPONSE` and the fixture settlement header. +// +// This fixture deliberately keeps the SDK surface area minimal so the +// adapter is portable across pay-kit checkouts. The cross-language +// matrix is the load-bearing path; this adapter exists so language +// adapters have a TS counterpart to pair against while the canonical +// SDK lands. End-to-end verification against a live Surfpool RPC is +// driven by the matrix runner. + +import http from "node:http"; +import { + PAYMENT_REQUIRED_HEADER, + PAYMENT_RESPONSE_HEADER, + PAYMENT_SIGNATURE_HEADER, + X402_VERSION_V2, + readX402ServerEnvironment, +} from "./exact-shared"; + +const TOKEN_DECIMALS = 6; +const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + +type PaymentRequirement = { + scheme: "exact"; + network: string; + resource: string; + description: string; + mimeType: string; + payTo: string; + asset: string; + maxAmountRequired: string; + maxTimeoutSeconds: number; + extra: { + decimals: number; + tokenProgram?: string; + feePayer?: string; + }; +}; + +function buildRequirements( + env: ReturnType, +): PaymentRequirement[] { + const primary: PaymentRequirement = { + scheme: "exact", + network: env.network, + resource: env.resourcePath, + description: "Surfpool-backed protected content", + mimeType: "application/json", + payTo: env.payTo, + asset: env.mint, + maxAmountRequired: env.price, + maxTimeoutSeconds: 60, + extra: { + decimals: TOKEN_DECIMALS, + tokenProgram: TOKEN_PROGRAM, + }, + }; + + const extras: PaymentRequirement[] = env.extraOfferedMints.map(mint => ({ + scheme: "exact", + network: env.network, + resource: env.resourcePath, + description: "Surfpool-backed protected content", + mimeType: "application/json", + payTo: env.payTo, + asset: mint, + maxAmountRequired: env.price, + maxTimeoutSeconds: 60, + extra: { decimals: TOKEN_DECIMALS }, + })); + + return [primary, ...extras]; +} + +function encodePaymentRequiredHeader(accepts: PaymentRequirement[]): string { + const envelope = { + x402Version: X402_VERSION_V2, + accepts, + resource: accepts[0]?.resource, + error: null, + }; + return Buffer.from(JSON.stringify(envelope), "utf8").toString("base64"); +} + +type DecodedCredential = { + x402Version?: number; + accepted?: { + scheme?: string; + network?: string; + asset?: string; + payTo?: string; + amount?: string; + }; + payload?: { + challengeId?: string; + resource?: string; + }; + resource?: string; +}; + +function decodeCredential(headerValue: string): DecodedCredential | null { + try { + const decoded = Buffer.from(headerValue, "base64").toString("utf8"); + return JSON.parse(decoded) as DecodedCredential; + } catch { + return null; + } +} + +type RejectReason = { + code: + | "payment_invalid" + | "wrong_network" + | "charge_request_mismatch" + | "challenge_verification_failed"; + message: string; +}; + +function classifyCredential( + credential: DecodedCredential | null, + accepts: PaymentRequirement[], + requestedResource: string, +): { offer: PaymentRequirement; credentialKey: string } | { reject: RejectReason } { + if (!credential || !credential.accepted || !credential.payload) { + return { + reject: { + code: "payment_invalid", + message: "credential is missing accepted/payload fields", + }, + }; + } + + const offer = accepts.find( + candidate => + candidate.asset === credential.accepted?.asset && + candidate.network === credential.accepted?.network && + candidate.scheme === credential.accepted?.scheme, + ); + + if (!offer) { + // Could be either network mismatch or no matching offer. + if ( + credential.accepted.network && + !accepts.some(c => c.network === credential.accepted?.network) + ) { + return { + reject: { + code: "wrong_network", + message: `credential network ${credential.accepted.network} does not match server`, + }, + }; + } + return { + reject: { + code: "charge_request_mismatch", + message: "no offered requirement matches the credential", + }, + }; + } + + if (offer.payTo !== credential.accepted.payTo) { + return { + reject: { + code: "charge_request_mismatch", + message: "recipient does not match", + }, + }; + } + + if (offer.maxAmountRequired !== credential.accepted.amount) { + return { + reject: { + code: "charge_request_mismatch", + message: "amount does not match", + }, + }; + } + + const credentialResource = credential.payload.resource ?? credential.resource; + if (credentialResource && credentialResource !== requestedResource) { + return { + reject: { + code: "charge_request_mismatch", + message: `credential resource ${credentialResource} does not match requested ${requestedResource}`, + }, + }; + } + + const challengeId = credential.payload.challengeId; + if (!challengeId || typeof challengeId !== "string") { + return { + reject: { + code: "challenge_verification_failed", + message: "credential payload missing challengeId", + }, + }; + } + + return { offer, credentialKey: challengeId }; +} + +async function main() { + const env = readX402ServerEnvironment(); + const accepts = buildRequirements(env); + const paymentRequiredHeader = encodePaymentRequiredHeader(accepts); + + // Track consumed credentials by challengeId to surface + // `signature_consumed` on idempotent resubmit. + const consumed = new Set(); + // Track challenge IDs this server has issued (recognised when a + // credential's payload.challengeId matches). Cross-server portability: + // server B sees a credential carrying an id only server A issued, so B + // rejects with `challenge_verification_failed`. A real x402 facilitator + // verifies HMAC over the challenge id with its own secret; this fixture + // simulates that by tracking issuance in-process. + const issued = new Set(); + + const server = http.createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + + if (url.pathname === "/health") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: true })); + return; + } + + if (url.pathname !== env.resourcePath) { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + return; + } + + const paymentHeader = request.headers[PAYMENT_SIGNATURE_HEADER] as + | string + | undefined; + + if (!paymentHeader) { + // Issue a fresh challenge id so the client can echo it back. The + // fixture's "verification" is presence-in-`issued`; a real + // facilitator would HMAC the id with its secret. + const challengeId = `ts-srv-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 10)}`; + issued.add(challengeId); + response.writeHead(402, { + "content-type": "application/json", + [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, + "x-challenge-id": challengeId, + }); + response.end( + JSON.stringify({ error: "payment_required", challengeId }), + ); + return; + } + + const credential = decodeCredential(paymentHeader); + const classified = classifyCredential(credential, accepts, env.resourcePath); + + if ("reject" in classified) { + response.writeHead(402, { + "content-type": "application/json", + [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, + }); + response.end( + JSON.stringify({ + error: classified.reject.code, + code: classified.reject.code, + message: classified.reject.message, + }), + ); + return; + } + + const { credentialKey } = classified; + + if (consumed.has(credentialKey)) { + response.writeHead(402, { + "content-type": "application/json", + [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, + }); + response.end( + JSON.stringify({ + error: "signature_consumed", + code: "signature_consumed", + message: "signature already consumed", + }), + ); + return; + } + + // Cross-server portability check: when the client supplies a payload + // challengeId, it must be one this server issued (or this server + // never required HMAC issuance). The first paid request that didn't + // come from this server's 402 will be missing from `issued`. + if (issued.size > 0 && !issued.has(credentialKey)) { + response.writeHead(402, { + "content-type": "application/json", + [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, + }); + response.end( + JSON.stringify({ + error: "challenge_verification_failed", + code: "challenge_verification_failed", + message: "challenge id was not issued by this server", + }), + ); + return; + } + + consumed.add(credentialKey); + + // Settlement: a real facilitator would broadcast a signed Solana + // transaction here. The fixture returns a deterministic placeholder + // so the harness can assert presence of the settlement header. + const settlement = `ts-x402-exact-${credentialKey.slice(0, 16)}`; + const paymentResponse = JSON.stringify({ + success: true, + network: accepts[0]?.network, + transaction: settlement, + }); + + response.writeHead(200, { + "content-type": "application/json", + [env.settlementHeader]: settlement, + [PAYMENT_RESPONSE_HEADER]: paymentResponse, + }); + response.end( + JSON.stringify({ + ok: true, + paid: true, + settlement: { + success: true, + transaction: settlement, + network: accepts[0]?.network, + }, + }), + ); + }); + + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to bind TypeScript x402 interop server"); + } + + console.log( + JSON.stringify({ + type: "ready", + implementation: "typescript", + role: "server", + port: address.port, + capabilities: ["exact"], + }), + ); + }); + + const shutdown = () => server.close(() => process.exit(0)); + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); +} + +void main(); diff --git a/harness/src/fixtures/typescript/exact-shared.ts b/harness/src/fixtures/typescript/exact-shared.ts new file mode 100644 index 000000000..d9771bd8c --- /dev/null +++ b/harness/src/fixtures/typescript/exact-shared.ts @@ -0,0 +1,87 @@ +// Env contract for the TypeScript x402 `exact` fixture adapters. The +// wire shape mirrors the Rust spine (`rust/crates/x402/src/bin/ +// interop_{client,server}.rs`) verbatim so any language adapter that +// targets this contract can pair against either TS or Rust. + +export type X402InteropEnvironment = { + rpcUrl: string; + network: string; + mint: string; + payTo: string; + price: string; + resourcePath: string; + settlementHeader: string; + facilitatorSecretKey: Uint8Array; + // Server-only. Comma-separated mint addresses advertised alongside the + // primary currency. Read from `X402_INTEROP_EXTRA_OFFERED_MINTS`. + extraOfferedMints: string[]; +}; + +export type X402ClientEnvironment = X402InteropEnvironment & { + targetUrl: string; + clientSecretKey: Uint8Array; + // Comma-separated currency preference list (symbols or mints) read + // from `X402_INTEROP_PREFER_CURRENCIES`. Empty when unset. + preferredCurrencies: string[]; +}; + +const DEFAULT_NETWORK = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; +const DEFAULT_RESOURCE_PATH = "/protected"; +const DEFAULT_PRICE = "0.001"; +const DEFAULT_SETTLEMENT_HEADER = "x-fixture-settlement"; + +function readRequiredEnv(name: string): string { + const value = process.env[name]; + if (!value || value.trim() === "") { + throw new Error(`${name} is required`); + } + return value; +} + +function parseSecretKey(name: string): Uint8Array { + const raw = readRequiredEnv(name); + const parsed = JSON.parse(raw) as number[]; + return new Uint8Array(parsed); +} + +function parseCsv(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split(",") + .map(value => value.trim()) + .filter(Boolean); +} + +function readBase(): X402InteropEnvironment { + return { + rpcUrl: readRequiredEnv("X402_INTEROP_RPC_URL"), + network: process.env.X402_INTEROP_NETWORK ?? DEFAULT_NETWORK, + mint: readRequiredEnv("X402_INTEROP_MINT"), + payTo: readRequiredEnv("X402_INTEROP_PAY_TO"), + price: process.env.X402_INTEROP_PRICE ?? DEFAULT_PRICE, + resourcePath: process.env.X402_INTEROP_RESOURCE_PATH ?? DEFAULT_RESOURCE_PATH, + settlementHeader: + process.env.X402_INTEROP_SETTLEMENT_HEADER ?? DEFAULT_SETTLEMENT_HEADER, + facilitatorSecretKey: parseSecretKey("X402_INTEROP_FACILITATOR_SECRET_KEY"), + extraOfferedMints: parseCsv(process.env.X402_INTEROP_EXTRA_OFFERED_MINTS), + }; +} + +export function readX402ServerEnvironment(): X402InteropEnvironment { + return readBase(); +} + +export function readX402ClientEnvironment(): X402ClientEnvironment { + const base = readBase(); + return { + ...base, + targetUrl: readRequiredEnv("X402_INTEROP_TARGET_URL"), + clientSecretKey: parseSecretKey("X402_INTEROP_CLIENT_SECRET_KEY"), + preferredCurrencies: parseCsv(process.env.X402_INTEROP_PREFER_CURRENCIES), + }; +} + +export const PAYMENT_REQUIRED_HEADER = "payment-required"; +export const PAYMENT_SIGNATURE_HEADER = "payment-signature"; +export const PAYMENT_RESPONSE_HEADER = "payment-response"; +export const X402_VERSION_V2 = 2; diff --git a/harness/src/implementations.ts b/harness/src/implementations.ts index 71d0ca997..4871eb610 100644 --- a/harness/src/implementations.ts +++ b/harness/src/implementations.ts @@ -4,6 +4,10 @@ export type ImplementationDefinition = { role: "client" | "server"; command: string[]; enabled: boolean; + // Optional. When set, this adapter only participates in scenarios whose + // `intent` is in this list. Defaults to "charge" only for back-compat + // with the existing MPP charge matrix. + intents?: string[]; }; function isEnabled(id: string, envName: string, defaultEnabled: boolean): boolean { @@ -80,6 +84,39 @@ export const clientImplementations: ImplementationDefinition[] = [ ], enabled: isEnabled("kotlin", "MPP_INTEROP_CLIENTS", true), }, + { + id: "ts-x402", + label: "TypeScript x402 exact client", + role: "client", + command: [ + "pnpm", + "exec", + "node", + "--import", + "tsx", + "src/fixtures/typescript/exact-client.ts", + ], + enabled: isEnabled("ts-x402", "X402_INTEROP_CLIENTS", true), + intents: ["x402-exact"], + }, + { + id: "rust-x402", + label: "Rust x402 exact client", + role: "client", + command: [ + "cargo", + "run", + "--quiet", + "--manifest-path", + "../../rust/Cargo.toml", + "-p", + "solana-x402", + "--bin", + "interop_client", + ], + enabled: isEnabled("rust-x402", "X402_INTEROP_CLIENTS", true), + intents: ["x402-exact"], + }, ]; export const serverImplementations: ImplementationDefinition[] = [ @@ -172,4 +209,37 @@ export const serverImplementations: ImplementationDefinition[] = [ command: ["sh", "-c", "cd go-server && go run ."], enabled: isEnabled("go", "MPP_INTEROP_SERVERS", true), }, + { + id: "ts-x402", + label: "TypeScript x402 exact server", + role: "server", + command: [ + "pnpm", + "exec", + "node", + "--import", + "tsx", + "src/fixtures/typescript/exact-server.ts", + ], + enabled: isEnabled("ts-x402", "X402_INTEROP_SERVERS", true), + intents: ["x402-exact"], + }, + { + id: "rust-x402", + label: "Rust x402 exact server", + role: "server", + command: [ + "cargo", + "run", + "--quiet", + "--manifest-path", + "../../rust/Cargo.toml", + "-p", + "solana-x402", + "--bin", + "interop_server", + ], + enabled: isEnabled("rust-x402", "X402_INTEROP_SERVERS", true), + intents: ["x402-exact"], + }, ]; diff --git a/harness/src/intents/x402-exact.ts b/harness/src/intents/x402-exact.ts new file mode 100644 index 000000000..85f1afe93 --- /dev/null +++ b/harness/src/intents/x402-exact.ts @@ -0,0 +1,119 @@ +import type { InteropScenario } from "../contracts"; + +// Canonical x402 `exact` intent scenarios. The harness contract (env +// vars, ready/result JSON shapes, capabilities) mirrors the Rust spine +// (`rust/crates/x402/src/bin/interop_{client,server}.rs`). The matrix +// pairs each x402 client against each x402 server registered in +// `implementations.ts`; the default-matrix pair set is restricted in +// `test/x402-exact.e2e.test.ts` while the TS reference adapter ships +// without a full Solana signing path. Adding language adapters that +// carry a real PaymentProof expands the matrix. +// +// Reject codes (cross-server portability / replay / network mismatch) +// reuse the canonical L6 set declared in `canonical-codes.ts`; the +// matrix asserts each x402 server adapter classifies the failure +// to the same canonical snake_case code as every other adapter. +export const x402ExactScenarios: readonly InteropScenario[] = [ + { + id: "x402-exact-basic", + intent: "x402-exact", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + price: "0.001", + amount: "1000", + asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + resourcePath: "/protected", + settlementHeader: "x-fixture-settlement", + expectedStatus: 200, + }, + { + // Network mismatch: client signs against localnet but the challenge + // requires devnet (or vice versa). Server must reject the credential + // with canonical `wrong_network`. + id: "x402-exact-network-mismatch", + intent: "x402-exact", + network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + price: "0.001", + amount: "1000", + asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + resourcePath: "/protected/network-mismatch", + settlementHeader: "x-fixture-settlement", + expectedStatus: 402, + expectedCode: "wrong_network", + clientIds: ["ts-x402", "rust-x402"], + serverIds: ["ts-x402", "rust-x402"], + }, + { + // Cross-route replay: credential issued for /protected/cheap is + // re-submitted against /protected/expensive. Server must reject with + // `charge_request_mismatch` because the credential's pinned route / + // amount does not match the served route. + id: "x402-exact-cross-route-replay", + intent: "x402-exact", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + price: "0.001", + amount: "1000", + asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + resourcePath: "/protected/expensive", + settlementHeader: "x-fixture-settlement", + replaySource: { + resourcePath: "/protected/cheap", + price: "0.0005", + amount: "500", + }, + expectedStatus: 402, + expectedCode: "charge_request_mismatch", + clientIds: ["ts-x402"], + serverIds: ["ts-x402", "rust-x402"], + }, + { + // Cross-server credential portability. Client pays server A and + // re-submits the same payment header to server B. B must reject with + // canonical `challenge_verification_failed` because B's verifier + // does not accept A's challenge issuance. + id: "x402-exact-cross-server-portability", + intent: "x402-exact", + kind: "cross-server-portability", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + price: "0.001", + amount: "1000", + asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + resourcePath: "/protected", + settlementHeader: "x-fixture-settlement", + expectedStatus: 402, + expectedCode: "challenge_verification_failed", + clientIds: ["ts-x402"], + serverIds: ["ts-x402", "rust-x402"], + // Cross-server portability requires the client adapter to expose the + // credential it sent so the runner can replay it. The TS reference + // client echoes `payment-signature-sent`; the Rust spine adapter does + // not (and is preserved as the canonical settlement-signing path + // rather than a credential-capturing one). Pairs that use the TS + // client cover the asymmetric direction too: TS pays server A, then + // replays the captured credential against server B. + crossServerPairs: [["ts-x402", "rust-x402"]], + }, + { + // Same-server idempotent resubmit. Client pays server A, then + // re-submits the same payment header. Server must reject with + // `signature_consumed`. + id: "x402-exact-idempotent-resubmit", + intent: "x402-exact", + kind: "idempotent-resubmit", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + price: "0.001", + amount: "1000", + asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + resourcePath: "/protected", + settlementHeader: "x-fixture-settlement", + expectedStatus: 402, + expectedCode: "signature_consumed", + // Driven by the TS client (the only one that echoes the sent + // credential back to the harness). The first paid request must + // reach 200, which constrains us to the TS reference server in + // the default matrix because that server is what speaks the TS + // client's stub payload. Rust server coverage of `signature_consumed` + // lives in the Rust crate's own integration tests. + clientIds: ["ts-x402"], + serverIds: ["ts-x402"], + }, +] as const; diff --git a/harness/test/cross-server-scenarios.test.ts b/harness/test/cross-server-scenarios.test.ts new file mode 100644 index 000000000..4dad52861 --- /dev/null +++ b/harness/test/cross-server-scenarios.test.ts @@ -0,0 +1,210 @@ +// Cross-server portability + idempotent-resubmit scenarios for the x402 +// `exact` intent. Mirrors MPP §19.6: +// +// - Cross-server portability: the client pays server A and re-submits the +// same payment-signature header to server B. B must reject with the +// canonical `challenge_verification_failed` code because B's verifier +// does not accept A's challenge. +// +// - Idempotent resubmit: the client pays server A, then re-submits the +// same payment-signature header to server A. A must reject with +// `signature_consumed`. +// +// Gated behind `X402_INTEROP_CROSS_SERVER=1` because the matrix needs +// two long-lived servers and live RPC credentials, neither of which the +// default `pnpm test` run wires up. + +import { afterAll, describe, expect, it } from "vitest"; +import { interopScenarios } from "../src/contracts"; +import { classifyMessageToCanonicalCode } from "../src/canonical-codes"; +import { + clientImplementations, + serverImplementations, +} from "../src/implementations"; +import { runClient, startServer, stopServer } from "../src/process"; + +const CROSS_SERVER_ENABLED = process.env.X402_INTEROP_CROSS_SERVER === "1"; + +const requiredEnvs = [ + "X402_INTEROP_RPC_URL", + "X402_INTEROP_MINT", + "X402_INTEROP_PAY_TO", + "X402_INTEROP_CLIENT_SECRET_KEY", + "X402_INTEROP_FACILITATOR_SECRET_KEY", +]; + +function missingEnvs(): string[] { + return requiredEnvs.filter( + name => !process.env[name] || process.env[name]?.trim() === "", + ); +} + +const portabilityScenario = interopScenarios.find( + scenario => scenario.id === "x402-exact-cross-server-portability", +); +const resubmitScenario = interopScenarios.find( + scenario => scenario.id === "x402-exact-idempotent-resubmit", +); + +const serversById = new Map(serverImplementations.map(s => [s.id, s])); +const clientsById = new Map(clientImplementations.map(c => [c.id, c])); + +type RunningServer = Awaited>; +const runningServers: RunningServer[] = []; + +afterAll(async () => { + for (const server of runningServers.splice(0)) { + await stopServer(server); + } +}); + +function extractCanonicalCode(body: unknown): string | undefined { + if (body && typeof body === "object" && !Array.isArray(body)) { + const record = body as Record; + if (typeof record.code === "string") return record.code; + const source = + (typeof record.error === "string" && record.error) || + (typeof record.message === "string" && record.message) || + undefined; + if (source) return classifyMessageToCanonicalCode(source); + } + if (typeof body === "string") { + return classifyMessageToCanonicalCode(body); + } + return undefined; +} + +describe("x402 exact — cross-server portability + idempotent resubmit", () => { + if (!CROSS_SERVER_ENABLED) { + it.skip("cross-server suite is gated behind X402_INTEROP_CROSS_SERVER=1", () => {}); + return; + } + + const missing = missingEnvs(); + if (missing.length > 0) { + it.skip(`missing required env vars: ${missing.join(", ")}`, () => {}); + return; + } + + if (portabilityScenario && portabilityScenario.crossServerPairs) { + for (const [serverAId, serverBId] of portabilityScenario.crossServerPairs) { + const serverA = serversById.get(serverAId); + const serverB = serversById.get(serverBId); + // Use the TS reference client to drive the pay-then-replay flow + // because it echoes the sent credential under `payment-signature-sent`. + // The Rust spine client does not surface the captured credential to + // the harness; its portability coverage is exercised by the Rust + // crate's own integration tests. + const client = clientsById.get("ts-x402"); + if (!serverA?.enabled || !serverB?.enabled || !client?.enabled) { + it.skip(`portability ${serverAId} -> ${serverBId}: adapter not enabled`, () => {}); + continue; + } + + it(`portability: pay ${serverAId} then resubmit credential to ${serverBId}`, async () => { + const env = { + X402_INTEROP_NETWORK: portabilityScenario.network, + X402_INTEROP_PRICE: portabilityScenario.price, + X402_INTEROP_RESOURCE_PATH: portabilityScenario.resourcePath, + X402_INTEROP_SETTLEMENT_HEADER: portabilityScenario.settlementHeader, + }; + + const runningA = await startServer(serverA, env); + runningServers.push(runningA); + const runningB = await startServer(serverB, env); + runningServers.push(runningB); + + try { + const urlA = `http://127.0.0.1:${runningA.ready.port}${portabilityScenario.resourcePath}`; + const payA = await runClient(client, urlA, { + X402_INTEROP_TARGET_URL: urlA, + ...env, + }); + expect(payA.status).toBe(200); + + // Re-submit the captured payment-signature header to server B. + // Adapters echo the credential they sent under `*-sent` so the + // harness can replay it. Falls back to the live payment-signature + // header for adapters that don't echo (rust spine). + const headers = payA.responseHeaders as Record; + const credential = + headers["payment-signature-sent"] ?? headers["payment-signature"]; + const urlB = `http://127.0.0.1:${runningB.ready.port}${portabilityScenario.resourcePath}`; + const replay = await fetch(urlB, { + headers: credential + ? { "payment-signature": String(credential) } + : {}, + }); + const body = await replay.json().catch(() => null); + + expect(replay.status).toBe(portabilityScenario.expectedStatus); + if (portabilityScenario.expectedCode) { + expect(extractCanonicalCode(body)).toBe(portabilityScenario.expectedCode); + } + } finally { + await stopServer(runningA); + await stopServer(runningB); + runningServers.splice(runningServers.indexOf(runningA), 1); + runningServers.splice(runningServers.indexOf(runningB), 1); + } + }, 180_000); + } + } else { + it.skip("portability scenario missing crossServerPairs", () => {}); + } + + if (resubmitScenario) { + const serverIds = resubmitScenario.serverIds ?? ["ts-x402"]; + for (const sid of serverIds) { + const server = serversById.get(sid); + // Same rationale as portability above: drive with the TS client so + // the harness can replay the captured credential. + const client = clientsById.get("ts-x402"); + if (!server?.enabled || !client?.enabled) { + it.skip(`idempotent-resubmit on ${sid}: adapter not enabled`, () => {}); + continue; + } + + it(`idempotent resubmit against ${sid}`, async () => { + const env = { + X402_INTEROP_NETWORK: resubmitScenario.network, + X402_INTEROP_PRICE: resubmitScenario.price, + X402_INTEROP_RESOURCE_PATH: resubmitScenario.resourcePath, + X402_INTEROP_SETTLEMENT_HEADER: resubmitScenario.settlementHeader, + }; + + const running = await startServer(server, env); + runningServers.push(running); + + try { + const url = `http://127.0.0.1:${running.ready.port}${resubmitScenario.resourcePath}`; + const first = await runClient(client, url, { + X402_INTEROP_TARGET_URL: url, + ...env, + }); + expect(first.status).toBe(200); + + const headers = first.responseHeaders as Record; + const credential = + headers["payment-signature-sent"] ?? headers["payment-signature"]; + const replay = await fetch(url, { + headers: credential + ? { "payment-signature": String(credential) } + : {}, + }); + const body = await replay.json().catch(() => null); + + expect(replay.status).toBe(resubmitScenario.expectedStatus); + if (resubmitScenario.expectedCode) { + expect(extractCanonicalCode(body)).toBe(resubmitScenario.expectedCode); + } + } finally { + await stopServer(running); + runningServers.splice(runningServers.indexOf(running), 1); + } + }, 180_000); + } + } else { + it.skip("idempotent-resubmit scenario missing", () => {}); + } +}); diff --git a/harness/test/e2e.test.ts b/harness/test/e2e.test.ts index 2c0d76d91..4e72e847c 100644 --- a/harness/test/e2e.test.ts +++ b/harness/test/e2e.test.ts @@ -320,13 +320,23 @@ describe("mpp interop", () => { ) { continue; } + // The x402-exact intent has its own runner in + // `test/x402-exact.e2e.test.ts` that emits `X402_INTEROP_*` env vars. + // The legacy MPP runner builds `MPP_INTEROP_*` env, which the x402 + // adapters do not consume, so we hard-skip the new intent here even + // when MPP_INTEROP_INTENTS explicitly selects it. + if (scenario.intent === "x402-exact") { + continue; + } const scenarioServers = activeServers.filter( (implementation) => - !scenario.serverIds || scenario.serverIds.includes(implementation.id), + (!implementation.intents || implementation.intents.includes(scenario.intent)) && + (!scenario.serverIds || scenario.serverIds.includes(implementation.id)), ); const scenarioClients = activeClients.filter( (implementation) => - !scenario.clientIds || scenario.clientIds.includes(implementation.id), + (!implementation.intents || implementation.intents.includes(scenario.intent)) && + (!scenario.clientIds || scenario.clientIds.includes(implementation.id)), ); for (const serverImplementation of scenarioServers) { diff --git a/harness/test/intent-selection.test.ts b/harness/test/intent-selection.test.ts index 6e8660278..1dcef686f 100644 --- a/harness/test/intent-selection.test.ts +++ b/harness/test/intent-selection.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import { selectInteropIntents, selectInteropScenarios } from "../src/contracts"; describe("interop intent selection", () => { - it("defaults to the implemented charge scenario", () => { + it("defaults to the legacy charge intent for CI stability", () => { + // x402-exact is opt-in via MPP_INTEROP_INTENTS=x402-exact (or + // comma-list) so the canonical MPP charge matrix in the existing + // runner is not perturbed by the new intent's enabled-by-default + // adapters. expect(selectInteropIntents(undefined)).toEqual(["charge"]); }); @@ -10,6 +14,17 @@ describe("interop intent selection", () => { expect(selectInteropIntents(" charge ")).toEqual(["charge"]); }); + it("accepts the implemented x402-exact intent", () => { + expect(selectInteropIntents("x402-exact")).toEqual(["x402-exact"]); + }); + + it("accepts both intents at once", () => { + expect(selectInteropIntents("charge,x402-exact")).toEqual([ + "charge", + "x402-exact", + ]); + }); + it("rejects scenarios that are not implemented yet", () => { expect(() => selectInteropIntents("session")).toThrow( /Unsupported MPP_INTEROP_INTENTS/, @@ -42,6 +57,20 @@ describe("interop scenario selection", () => { ]); }); + it("returns x402-exact scenarios when explicitly requested", () => { + expect( + selectInteropScenarios("x402-exact", undefined).map( + (scenario) => scenario.id, + ), + ).toEqual([ + "x402-exact-basic", + "x402-exact-network-mismatch", + "x402-exact-cross-route-replay", + "x402-exact-cross-server-portability", + "x402-exact-idempotent-resubmit", + ]); + }); + it("runs one requested scenario", () => { expect( selectInteropScenarios("charge", "charge-split-ata").map( diff --git a/harness/test/x402-exact.e2e.test.ts b/harness/test/x402-exact.e2e.test.ts new file mode 100644 index 000000000..03aeb262e --- /dev/null +++ b/harness/test/x402-exact.e2e.test.ts @@ -0,0 +1,128 @@ +// Cross-language matrix for the x402 `exact` intent. Iterates every +// active x402 client × every active x402 server registered in +// `src/implementations.ts` and asserts the happy-path scenario reaches +// HTTP 200 with the fixture settlement header populated. +// +// Gated behind `X402_INTEROP_MATRIX=1` so the default `pnpm test` run +// in pay-kit does not require cargo or a live Surfpool RPC. The +// canonical CI invocation is: +// +// X402_INTEROP_MATRIX=1 \ +// X402_INTEROP_RPC_URL=... \ +// X402_INTEROP_PAY_TO=... \ +// X402_INTEROP_CLIENT_SECRET_KEY=[...] \ +// X402_INTEROP_FACILITATOR_SECRET_KEY=[...] \ +// pnpm test x402-exact.e2e.test.ts + +import { afterAll, describe, expect, it } from "vitest"; +import { interopScenarios } from "../src/contracts"; +import { + clientImplementations, + serverImplementations, +} from "../src/implementations"; +import { runClient, startServer, stopServer } from "../src/process"; + +const MATRIX_ENABLED = process.env.X402_INTEROP_MATRIX === "1"; + +const requiredEnvs = [ + "X402_INTEROP_RPC_URL", + "X402_INTEROP_MINT", + "X402_INTEROP_PAY_TO", + "X402_INTEROP_CLIENT_SECRET_KEY", + "X402_INTEROP_FACILITATOR_SECRET_KEY", +]; + +function missingEnvs(): string[] { + return requiredEnvs.filter( + name => !process.env[name] || process.env[name]?.trim() === "", + ); +} + +const happyPath = interopScenarios.find( + scenario => scenario.id === "x402-exact-basic", +); + +const x402Clients = clientImplementations.filter( + impl => impl.enabled && (impl.intents ?? ["charge"]).includes("x402-exact"), +); +const x402Servers = serverImplementations.filter( + impl => impl.enabled && (impl.intents ?? ["charge"]).includes("x402-exact"), +); + +type RunningServer = Awaited>; +const runningServers: RunningServer[] = []; + +afterAll(async () => { + for (const server of runningServers.splice(0)) { + await stopServer(server); + } +}); + +describe("x402 exact intent — cross-language matrix", () => { + if (!MATRIX_ENABLED) { + it.skip("matrix is gated behind X402_INTEROP_MATRIX=1", () => {}); + return; + } + + const missing = missingEnvs(); + if (missing.length > 0) { + it.skip(`missing required env vars: ${missing.join(", ")}`, () => {}); + return; + } + + if (!happyPath) { + it.fails("happy-path scenario x402-exact-basic missing from registry", () => { + throw new Error("x402-exact-basic scenario not found in interopScenarios"); + }); + return; + } + + // Pair restriction: the TS reference adapters speak a stub payload + // (no real signed Solana transaction in the fixture) so they only + // interoperate with each other. The Rust spine adapters carry the + // canonical PaymentProof and are exercised end-to-end by the rust + // crate's own integration tests (`cargo test -p solana-x402`). + // The cross-language matrix asserts the harness wiring and the + // ready/result protocol; full TS<->Rust on-chain settlement parity + // arrives with the TS SDK port (tracked separately). + const allowedPair = (clientId: string, serverId: string): boolean => { + if (clientId === "ts-x402" && serverId === "ts-x402") return true; + if (clientId === "rust-x402" && serverId === "rust-x402") return true; + return false; + }; + + for (const server of x402Servers) { + for (const client of x402Clients) { + if (!allowedPair(client.id, server.id)) { + it.skip(`${client.id} client ↔ ${server.id} server: pair not in default matrix`, () => {}); + continue; + } + it(`${client.id} client ↔ ${server.id} server: happy path`, async () => { + const env = { + X402_INTEROP_NETWORK: happyPath.network, + X402_INTEROP_PRICE: happyPath.price, + X402_INTEROP_RESOURCE_PATH: happyPath.resourcePath, + X402_INTEROP_SETTLEMENT_HEADER: happyPath.settlementHeader, + } satisfies Record; + + const running = await startServer(server, env); + runningServers.push(running); + + try { + const targetUrl = `http://127.0.0.1:${running.ready.port}${happyPath.resourcePath}`; + const result = await runClient(client, targetUrl, { + X402_INTEROP_TARGET_URL: targetUrl, + ...env, + }); + + expect(result.status).toBe(happyPath.expectedStatus); + expect(result.ok).toBe(true); + expect(result.settlement).toBeTruthy(); + } finally { + await stopServer(running); + runningServers.splice(runningServers.indexOf(running), 1); + } + }, 120_000); + } + } +}); From bdbf7140d69d0a6b85ee2db6b9cb9080270f1ef8 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 25 May 2026 22:56:40 +0300 Subject: [PATCH 02/10] feat(go): port x402 exact (client+server) Ports the x402 `exact` scheme (client + server) Go implementation into `go/x402/`, following the `rust/crates/x402/` reference spine landed in PR #121. Source: upstream reference port (tip e3bf746). Constants, instruction allowlist, fee-payer guard, Lighthouse program-ID passthrough, destination-ATA re-derivation, broadcast-first L8 ordering, cross-server reject token, and the X402_INTEROP_* env-var contract all match the Rust spine 1:1. The Go binaries live under `go/x402/cmd/interop-{client,server}/` as sub-packages of the existing single mpp-sdk Go module (github.com/solana-foundation/pay-kit/go). Verification: go test ./x402/... -cover -race interop-server 90.9% / interop-client 91.9% gofmt -l go/x402/ clean go vet ./x402/... clean pnpm typecheck clean for changed files --- go/x402/README.md | 81 + go/x402/cmd/interop-client/challenge_test.go | 1369 +++++++++++ go/x402/cmd/interop-client/main.go | 622 +++++ go/x402/cmd/interop-server/main.go | 1088 +++++++++ go/x402/cmd/interop-server/main_test.go | 2272 ++++++++++++++++++ harness/src/implementations.ts | 24 + notes/codex-review-go-x402-r4.md | 36 + 7 files changed, 5492 insertions(+) create mode 100644 go/x402/README.md create mode 100644 go/x402/cmd/interop-client/challenge_test.go create mode 100644 go/x402/cmd/interop-client/main.go create mode 100644 go/x402/cmd/interop-server/main.go create mode 100644 go/x402/cmd/interop-server/main_test.go create mode 100644 notes/codex-review-go-x402-r4.md diff --git a/go/x402/README.md b/go/x402/README.md new file mode 100644 index 000000000..61346e299 --- /dev/null +++ b/go/x402/README.md @@ -0,0 +1,81 @@ +# Go x402 SDK + +Go implementation of the x402 `exact` scheme (client + server) for Solana. + +This sub-package mirrors the canonical Rust spine at `rust/crates/x402/` +and ships the interop adapters used by the cross-language harness. + +## Layout + +```text +go/x402/ +├── cmd/ +│ ├── interop-client/ interop harness client binary +│ └── interop-server/ interop harness server binary +└── README.md +``` + +The exact-scheme protocol types, verifier, and settler live inline in +the two `main.go` files. The Rust crate keeps a separate +`protocol/schemes/exact/`, `server/exact.rs`, `client/exact/payment.rs` +split; the Go port keeps them inline because both binaries are +self-contained and there is no third caller. The spine's wire format, +constants, and pipeline ordering are mirrored 1:1. + +## Test + +```bash +cd go +go test ./x402/... -cover -race +``` + +Expected coverage: server ≥ 90 %, client ≥ 90 %. + +## Format and vet + +```bash +gofmt -l go/x402/ +go vet ./x402/... +``` + +## Parity with the Rust spine + +The Go port matches `rust/crates/x402/` on: + +- CAIP-2 network identifiers (`solana:5eykt...`, `solana:EtWTR...`, + `solana:4uhc...`) — verbatim. +- Program IDs (Token, Token-2022, Associated Token, Compute Budget, + System, Memo, Lighthouse) — verbatim. +- Stablecoin mint addresses per network (USDC/USDT/USDG/PYUSD/CASH) — + verbatim. +- Constants: `EXACT_SCHEME = "exact"`, `maxMemoBytes = 256`. +- Instruction allowlist: ComputeBudget (Set CU Limit + Price), SPL + Token / Token-2022 `TransferChecked`, plus optional Lighthouse + + Memo + ATA-create. +- Lighthouse passthrough by program-ID match only (no discriminator + allowlist, no account-count cap) — spine parity. +- Fee-payer-in-instruction-accounts sweep with the legitimate + ATA-create payer slot exception. +- Destination ATA re-derived from `(payTo, mint, tokenProgram)` and + compared against the transaction's destination index. +- L8 settlement ordering: broadcast → confirm → mark. +- Cross-server credential rejection with canonical 4xx + token in body. +- Env-var contract: `X402_INTEROP_TARGET_URL`, + `X402_INTEROP_RPC_URL`, `X402_INTEROP_NETWORK`, + `X402_INTEROP_CLIENT_SECRET_KEY`, + `X402_INTEROP_FACILITATOR_SECRET_KEY`, `X402_INTEROP_PAY_TO`, + `X402_INTEROP_MINT`, `X402_INTEROP_PRICE`, + `X402_INTEROP_EXTRA_OFFERED_MINTS` (CSV), + `X402_INTEROP_PREFER_CURRENCIES` (CSV). +- Client `result` and server `ready` stdout JSON shapes. + +Intentional Go-side specifics (not divergences): + +- Mint alias resolution happens at the env-read boundary + (`X402_INTEROP_MINT` may be a symbol or base58); the rest of the + code sees canonical base58. The spine accepts the same pattern. +- Duplicate-settlement cache keys are SHA-256 of the encoded + transaction, in addition to Solana's native per-signature + uniqueness — defense-in-depth, matches the source x402-sdk PR #18. + +No upstream behavior changes vs x402-sdk PR #18 (tip `e3bf746`). diff --git a/go/x402/cmd/interop-client/challenge_test.go b/go/x402/cmd/interop-client/challenge_test.go new file mode 100644 index 000000000..6d22442c6 --- /dev/null +++ b/go/x402/cmd/interop-client/challenge_test.go @@ -0,0 +1,1369 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/gagliardetto/solana-go" +) + +func TestSelectSVMRequirementFromPaymentRequiredHeader(t *testing.T) { + requirement := map[string]any{ + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + } + envelope, err := json.Marshal(map[string]any{ + "x402Version": 2, + "accepts": []map[string]any{requirement}, + }) + if err != nil { + t.Fatal(err) + } + + selected := selectSVMRequirement( + map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(envelope)}, + "", + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + ) + + if selected == nil { + t.Fatal("expected selected requirement") + } + if selected.Asset != requirement["asset"] { + t.Fatalf("unexpected asset: %s", selected.Asset) + } +} + +func TestSelectSVMRequirementFromBody(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "eip155:8453", + "asset": "0x0000000000000000000000000000000000000000", + "amount": "1000", + }, + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected := selectSVMRequirement( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + ) + + if selected == nil { + t.Fatal("expected selected requirement") + } + if selected.Network != "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" { + t.Fatalf("unexpected network: %s", selected.Network) + } +} + +func TestSelectSVMRequirementIgnoresUnsupportedScheme(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "upto", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected := selectSVMRequirement( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + ) + + if selected != nil { + t.Fatalf("expected no selected requirement, got %+v", selected) + } +} + +func TestSelectSVMRequirementSupportsRequestedUptoScheme(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "upto", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected := selectSVMRequirement( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "upto", + ) + + if selected == nil { + t.Fatal("expected selected upto requirement") + } + if selected.Scheme != "upto" { + t.Fatalf("unexpected scheme: %s", selected.Scheme) + } +} + +func TestSelectSVMChallengeHonorsPreferredCurrencyOrder(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, _ := selectSVMChallengeWithPreferences( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + []string{"PYUSD", "USDC"}, + ) + + if selected == nil { + t.Fatal("expected selected requirement") + } + if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { + t.Fatalf("expected PYUSD mint, got %s", selected.Asset) + } +} + +func TestSelectSVMChallengeReturnsNilWhenPreferredCurrenciesDoNotMatch(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, _ := selectSVMChallengeWithPreferences( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + []string{"PYUSD"}, + ) + + if selected != nil { + t.Fatalf("expected no selected requirement, got %+v", selected) + } +} + +func TestSelectSVMChallengeChecksBodyWhenHeaderPreferencesDoNotMatch(t *testing.T) { + headerEnvelope, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(map[string]any{ + "resource": map[string]any{"uri": "/body"}, + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, resource := selectSVMChallengeWithPreferences( + map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + []string{"PYUSD"}, + ) + + if selected == nil { + t.Fatal("expected selected requirement from body") + } + if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { + t.Fatalf("expected body PYUSD mint, got %s", selected.Asset) + } + if resource["uri"] != "/body" { + t.Fatalf("expected body resource, got %#v", resource) + } +} + +func TestSelectSVMChallengeWithoutPreferencesPicksCheapestAmount(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000000", + }, + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "So11111111111111111111111111111111111111112", + "amount": "5000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, _ := selectSVMChallengeWithPreferences( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + nil, + ) + + if selected == nil { + t.Fatal("expected selected requirement") + } + if selected.Asset != "So11111111111111111111111111111111111111112" { + t.Fatalf("expected cheapest offer, got %s", selected.Asset) + } +} + +func TestSelectSVMChallengeSkipsIncompleteAndMalformedCandidates(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "", + "amount": "1", + }, + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "So11111111111111111111111111111111111111112", + "amount": "not-int", + }, + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, _ := selectSVMChallengeWithPreferences( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + nil, + ) + + if selected == nil { + t.Fatal("expected selected requirement") + } + if selected.Asset != "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" { + t.Fatalf("expected valid cheapest candidate, got %+v", selected) + } +} + +func TestSelectSVMChallengeUsesCurrencyPreferencesFromEnv(t *testing.T) { + t.Setenv("X402_INTEROP_PREFER_CURRENCIES", " PYUSD, USDC ,,") + body, err := json.Marshal(map[string]any{ + "resource": map[string]any{"uri": "/protected"}, + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", + "amount": "2000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, resource := selectSVMChallenge( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + ) + + if selected == nil { + t.Fatal("expected selected requirement") + } + if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { + t.Fatalf("expected PYUSD preference to win, got %s", selected.Asset) + } + if resource["uri"] != "/protected" { + t.Fatalf("expected resource to be returned, got %+v", resource) + } +} + +func TestPaymentRequiredLoadersRejectMalformedInputs(t *testing.T) { + if envelope := loadPaymentRequiredHeader(map[string]string{"payment-required": "not base64"}); envelope != nil { + t.Fatalf("expected invalid base64 header to return nil") + } + encodedInvalidJSON := base64.StdEncoding.EncodeToString([]byte("{")) + if envelope := loadPaymentRequiredHeader(map[string]string{"payment-required": encodedInvalidJSON}); envelope != nil { + t.Fatalf("expected invalid JSON header to return nil") + } + if envelope := loadPaymentRequiredBody("{"); envelope != nil { + t.Fatalf("expected invalid JSON body to return nil") + } + if envelope := loadPaymentRequiredBody(""); envelope != nil { + t.Fatalf("expected empty body to return nil") + } +} + +func TestResolveStablecoinMintCanonicalAliases(t *testing.T) { + tests := map[string]struct { + currency string + network string + want string + }{ + "devnet USD alias": { + currency: " usd ", + network: "devnet", + want: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + }, + "mainnet PYUSD": { + currency: "PYUSD", + network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + want: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", + }, + "localnet USDG": { + currency: "USDG", + network: "localnet", + want: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", + }, + "USDT": { + currency: "USDT", + network: "devnet", + want: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + }, + "CASH": { + currency: "CASH", + network: "devnet", + want: "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH", + }, + "mint passthrough": { + currency: " So11111111111111111111111111111111111111112 ", + network: "devnet", + want: "So11111111111111111111111111111111111111112", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if got := resolveStablecoinMint(test.currency, test.network); got != test.want { + t.Fatalf("resolveStablecoinMint() = %q, want %q", got, test.want) + } + }) + } +} + +func TestRequirementExtraParsersValidateTypes(t *testing.T) { + requirement := paymentRequirement{ + Extra: map[string]any{ + "decimalsFloat": float64(6), + "decimalsText": "9", + "tokenProgram": solana.TokenProgramID.String(), + "badInteger": "not-int", + "badString": 12, + "emptyString": "", + }, + } + + if got, err := intFromRequirement(requirement, "decimalsFloat"); err != nil || got != 6 { + t.Fatalf("float integer = %d, %v", got, err) + } + if got, err := intFromRequirement(requirement, "decimalsText"); err != nil || got != 9 { + t.Fatalf("string integer = %d, %v", got, err) + } + if _, err := intFromRequirement(requirement, "missing"); err == nil { + t.Fatal("expected missing integer error") + } + if _, err := intFromRequirement(requirement, "badInteger"); err == nil { + t.Fatal("expected invalid integer error") + } + if _, err := intFromRequirement(paymentRequirement{Extra: map[string]any{"bad": true}}, "bad"); err == nil { + t.Fatal("expected invalid integer type error") + } + if got, err := stringFromExtra(requirement, "tokenProgram"); err != nil || got != solana.TokenProgramID.String() { + t.Fatalf("string extra = %q, %v", got, err) + } + if _, err := stringFromExtra(requirement, "missing"); err == nil { + t.Fatal("expected missing string error") + } + if _, err := stringFromExtra(requirement, "badString"); err == nil { + t.Fatal("expected invalid string type error") + } + if _, err := stringFromExtra(requirement, "emptyString"); err == nil { + t.Fatal("expected empty string error") + } +} + +func TestKeypairFromJSONSecretValidatesShape(t *testing.T) { + privateKey, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal([]byte(privateKey)) + if err != nil { + t.Fatal(err) + } + + decoded, err := keypairFromJSONSecret(string(encoded)) + if err != nil { + t.Fatal(err) + } + if !decoded.PublicKey().Equals(privateKey.PublicKey()) { + t.Fatalf("decoded key does not match original") + } + if _, err := keypairFromJSONSecret("{"); err == nil { + t.Fatal("expected JSON decode error") + } + if _, err := keypairFromJSONSecret("[1,2,3]"); err == nil { + t.Fatal("expected length validation error") + } +} + +func TestLatestBlockhashHandlesJSONRPCResponses(t *testing.T) { + originalHTTPClient := httpClient + defer func() { + httpClient = originalHTTPClient + }() + + blockhash := solana.Hash{}.String() + httpClient = &http.Client{Transport: clientRoundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rawBody), `"method":"getLatestBlockhash"`) { + t.Fatalf("unexpected RPC body: %s", string(rawBody)) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"value":{"blockhash":"` + blockhash + `"}}}`)), + }, nil + })} + + got, err := latestBlockhash("http://rpc.test") + if err != nil { + t.Fatal(err) + } + if got.String() != blockhash { + t.Fatalf("latestBlockhash = %s, want %s", got, blockhash) + } + + httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"error":{"message":"nope"}}`)), + }, nil + })} + if _, err := latestBlockhash("http://rpc.test"); err == nil { + t.Fatal("expected RPC error") + } + + httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadGateway, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`bad gateway`)), + }, nil + })} + if _, err := latestBlockhash("http://rpc.test"); err == nil { + t.Fatal("expected HTTP error") + } + + httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{`)), + }, nil + })} + if _, err := latestBlockhash("http://rpc.test"); err == nil { + t.Fatal("expected invalid JSON error") + } +} + +func TestLatestBlockhashReturnsTransportErrors(t *testing.T) { + originalHTTPClient := httpClient + defer func() { + httpClient = originalHTTPClient + }() + + httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + return nil, errors.New("rpc unavailable") + })} + if _, err := latestBlockhash("http://rpc.test"); err == nil { + t.Fatal("expected transport error") + } +} + +func TestTransferCheckedInstructionRejectsMalformedRequirement(t *testing.T) { + signer := solana.NewWallet().PublicKey() + base := paymentRequirement{ + Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + Amount: "1000", + PayTo: solana.NewWallet().PublicKey().String(), + } + + tests := map[string]paymentRequirement{ + "amount": func() paymentRequirement { + requirement := base + requirement.Amount = "not-int" + return requirement + }(), + "asset": func() paymentRequirement { + requirement := base + requirement.Asset = "not-base58" + return requirement + }(), + "payTo": func() paymentRequirement { + requirement := base + requirement.PayTo = "not-base58" + return requirement + }(), + } + + for name, requirement := range tests { + t.Run(name, func(t *testing.T) { + if _, err := transferCheckedInstruction(requirement, signer, 6, solana.TokenProgramID); err == nil { + t.Fatal("expected malformed requirement to be rejected") + } + }) + } +} + +func TestReadResponseAndParseResponseBody(t *testing.T) { + response := &http.Response{ + Header: http.Header{ + "X-Test": []string{"first", "second"}, + }, + Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), + } + + headers, body, err := readResponse(response) + if err != nil { + t.Fatal(err) + } + if headers["X-Test"] != "first" { + t.Fatalf("expected first header value, got %q", headers["X-Test"]) + } + if body != `{"ok":true}` { + t.Fatalf("unexpected body: %s", body) + } + parsed, ok := parseResponseBody(body).(map[string]any) + if !ok || parsed["ok"] != true { + t.Fatalf("expected JSON body to parse, got %#v", parsed) + } + if got := parseResponseBody("not json"); got != "not json" { + t.Fatalf("expected invalid JSON body passthrough, got %#v", got) + } + t.Setenv("X402_TEST_DEFAULT", "configured") + if got := readEnvWithDefault("X402_TEST_DEFAULT", "fallback"); got != "configured" { + t.Fatalf("readEnvWithDefault configured = %q", got) + } + if got := readEnvWithDefault("X402_TEST_MISSING", "fallback"); got != "fallback" { + t.Fatalf("readEnvWithDefault fallback = %q", got) + } +} + +func TestMainReportsUnimplementedChallengeResult(t *testing.T) { + originalHTTPClient := httpClient + defer func() { + httpClient = originalHTTPClient + }() + httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusPaymentRequired, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"accepts":[{"scheme":"upto","network":"solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1","asset":"4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU","amount":"1000"}]}`)), + }, nil + })} + + t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") + t.Setenv("X402_INTEROP_SCHEME", "upto") + + output := captureStdoutForTest(t, main) + var payload map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil { + t.Fatal(err) + } + if payload["implementation"] != "go" || payload["role"] != "client" || payload["ok"] != false { + t.Fatalf("unexpected result payload: %#v", payload) + } + body := payload["responseBody"].(map[string]any) + if body["error"] != "go_upto_client_not_implemented" { + t.Fatalf("unexpected error domain: %#v", body) + } +} + +func TestMainPanicsWhenTargetURLMissing(t *testing.T) { + mustPanicClient(t, main) +} + +func TestMainPanicsWhenChallengeRequestFails(t *testing.T) { + originalHTTPClient := httpClient + defer func() { + httpClient = originalHTTPClient + }() + httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + return nil, errors.New("network down") + })} + + t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") + + mustPanicClient(t, main) +} + +func TestMainReportsExactPaymentBuildFailure(t *testing.T) { + requirement := map[string]any{ + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + "payTo": solana.NewWallet().PublicKey().String(), + "extra": map[string]any{ + "decimals": 6, + "feePayer": solana.NewWallet().PublicKey().String(), + "tokenProgram": solana.TokenProgramID.String(), + }, + } + challenge, err := json.Marshal(paymentEnvelope{ + Accepts: []paymentRequirement{{ + Scheme: requirement["scheme"].(string), + Network: requirement["network"].(string), + Asset: requirement["asset"].(string), + Amount: requirement["amount"].(string), + PayTo: requirement["payTo"].(string), + Extra: requirement["extra"].(map[string]any), + }}, + }) + if err != nil { + t.Fatal(err) + } + originalHTTPClient := httpClient + defer func() { + httpClient = originalHTTPClient + }() + httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusPaymentRequired, + Header: http.Header{ + "PAYMENT-REQUIRED": []string{base64.StdEncoding.EncodeToString(challenge)}, + "content-type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{"error":"payment_required"}`)), + }, nil + })} + + t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") + t.Setenv("X402_INTEROP_CLIENT_SECRET_KEY", "{") + t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") + + output := captureStdoutForTest(t, main) + var payload map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil { + t.Fatal(err) + } + if payload["ok"] != false || payload["status"] != float64(http.StatusPaymentRequired) { + t.Fatalf("unexpected payment failure result: %#v", payload) + } + body := payload["responseBody"].(map[string]any) + if body["error"] != "go_exact_client_payment_failed" { + t.Fatalf("unexpected payment failure body: %#v", body) + } +} + +func TestMainPaysExactChallengeAndReportsSettlement(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + encodedClientKey, err := json.Marshal([]byte(client)) + if err != nil { + t.Fatal(err) + } + feePayer := solana.NewWallet().PublicKey() + payTo := solana.NewWallet().PublicKey() + challenge, err := json.Marshal(paymentEnvelope{ + Accepts: []paymentRequirement{ + { + Scheme: "exact", + Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + Amount: "1000", + PayTo: payTo.String(), + Extra: map[string]any{ + "decimals": float64(6), + "feePayer": feePayer.String(), + "tokenProgram": solana.TokenProgramID.String(), + "recentBlockhash": solana.Hash{}.String(), + "memo": "unit-main-success", + }, + }, + }, + Resource: map[string]any{"uri": "/protected"}, + }) + if err != nil { + t.Fatal(err) + } + + originalHTTPClient := httpClient + defer func() { + httpClient = originalHTTPClient + }() + requests := 0 + httpClient = &http.Client{Transport: clientRoundTripFunc(func(request *http.Request) (*http.Response, error) { + requests++ + if requests == 1 { + return &http.Response{ + StatusCode: http.StatusPaymentRequired, + Header: http.Header{ + "PAYMENT-REQUIRED": []string{base64.StdEncoding.EncodeToString(challenge)}, + "content-type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{"error":"payment_required"}`)), + }, nil + } + if got := request.Header.Get("PAYMENT-SIGNATURE"); got == "" { + t.Fatal("expected PAYMENT-SIGNATURE on paid retry") + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "x-fixture-settlement": []string{"unit-settlement"}, + "content-type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"paid":true}`)), + }, nil + })} + + t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") + t.Setenv("X402_INTEROP_CLIENT_SECRET_KEY", string(encodedClientKey)) + t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") + + output := captureStdoutForTest(t, main) + var payload map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil { + t.Fatal(err) + } + if payload["ok"] != true || payload["status"] != float64(http.StatusOK) || payload["settlement"] != "unit-settlement" { + t.Fatalf("unexpected paid result: %#v", payload) + } + if requests != 2 { + t.Fatalf("expected challenge request plus paid retry, got %d", requests) + } +} + +func TestBuildExactPaymentSignatureEnvelope(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + feePayer, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + payTo, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + requirement := paymentRequirement{ + Scheme: "exact", + Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + Amount: "1000", + PayTo: payTo.PublicKey().String(), + MaxTimeoutSeconds: 60, + Extra: map[string]any{ + "feePayer": feePayer.PublicKey().String(), + "decimals": float64(6), + "tokenProgram": solana.TokenProgramID.String(), + "recentBlockhash": solana.Hash{}.String(), + "memo": "unit-test", + }, + } + resource := map[string]any{ + "url": "/protected", + "description": "test", + } + + header, err := buildExactPaymentSignature(requirement, resource, client, "http://127.0.0.1:8899") + if err != nil { + t.Fatal(err) + } + + decoded, err := base64.StdEncoding.DecodeString(header) + if err != nil { + t.Fatal(err) + } + var envelope paymentSignatureEnvelope + if err := json.Unmarshal(decoded, &envelope); err != nil { + t.Fatal(err) + } + if envelope.X402Version != 2 { + t.Fatalf("unexpected x402Version: %d", envelope.X402Version) + } + if envelope.Accepted.MaxTimeoutSeconds != requirement.MaxTimeoutSeconds { + t.Fatalf("accepted did not preserve maxTimeoutSeconds") + } + if envelope.Payload["transaction"] == "" { + t.Fatalf("expected transaction payload") + } + + tx := new(solana.Transaction) + if err := tx.UnmarshalBase64(envelope.Payload["transaction"]); err != nil { + t.Fatal(err) + } + if !tx.Message.IsVersioned() { + t.Fatalf("expected v0 transaction") + } + + signerIndex := -1 + feePayerIndex := -1 + for index, key := range tx.Message.AccountKeys { + if key.Equals(client.PublicKey()) { + signerIndex = index + } + if key.Equals(feePayer.PublicKey()) { + feePayerIndex = index + } + } + if signerIndex < 0 { + t.Fatalf("client signer missing from transaction") + } + if feePayerIndex < 0 { + t.Fatalf("fee payer missing from transaction") + } + if tx.Signatures[feePayerIndex] != (solana.Signature{}) { + t.Fatalf("fee payer signature should remain default") + } + message, err := tx.Message.MarshalBinary() + if err != nil { + t.Fatal(err) + } + if !tx.Signatures[signerIndex].Verify(client.PublicKey(), message) { + t.Fatalf("client signature did not verify") + } +} + +func TestBuildExactPaymentSignatureFetchesRecentBlockhashWhenMissing(t *testing.T) { + originalHTTPClient := httpClient + defer func() { + httpClient = originalHTTPClient + }() + blockhash := solana.Hash{}.String() + httpClient = &http.Client{Transport: clientRoundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rawBody), `"method":"getLatestBlockhash"`) { + t.Fatalf("unexpected RPC body: %s", string(rawBody)) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"value":{"blockhash":"` + blockhash + `"}}}`)), + }, nil + })} + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + requirement := paymentRequirement{ + Scheme: "exact", + Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + Amount: "1000", + PayTo: solana.NewWallet().PublicKey().String(), + Extra: map[string]any{ + "feePayer": solana.NewWallet().PublicKey().String(), + "decimals": float64(6), + "tokenProgram": solana.TokenProgramID.String(), + "memo": "unit-fetch-blockhash", + }, + } + + header, err := buildExactPaymentSignature(requirement, nil, client, "http://rpc.test") + if err != nil { + t.Fatal(err) + } + if header == "" { + t.Fatal("expected payment signature") + } +} + +func TestBuildExactPaymentSignatureRejectsInvalidRequirements(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + requirement := paymentRequirement{ + Scheme: "exact", + Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + Amount: "1000", + PayTo: solana.NewWallet().PublicKey().String(), + Extra: map[string]any{ + "feePayer": solana.NewWallet().PublicKey().String(), + "decimals": float64(6), + "tokenProgram": solana.TokenProgramID.String(), + "recentBlockhash": solana.Hash{}.String(), + "memo": "unit-test", + }, + } + + tests := map[string]func(paymentRequirement) paymentRequirement{ + "scheme": func(value paymentRequirement) paymentRequirement { + value.Scheme = "upto" + return value + }, + "missing decimals": func(value paymentRequirement) paymentRequirement { + value.Extra = cloneClientExtra(value.Extra) + delete(value.Extra, "decimals") + return value + }, + "invalid token program": func(value paymentRequirement) paymentRequirement { + value.Extra = cloneClientExtra(value.Extra) + value.Extra["tokenProgram"] = "not-base58" + return value + }, + "invalid fee payer": func(value paymentRequirement) paymentRequirement { + value.Extra = cloneClientExtra(value.Extra) + value.Extra["feePayer"] = "not-base58" + return value + }, + "invalid blockhash": func(value paymentRequirement) paymentRequirement { + value.Extra = cloneClientExtra(value.Extra) + value.Extra["recentBlockhash"] = "not-base58" + return value + }, + "invalid amount": func(value paymentRequirement) paymentRequirement { + value.Amount = "not-int" + return value + }, + "invalid payTo": func(value paymentRequirement) paymentRequirement { + value.PayTo = "not-base58" + return value + }, + } + + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + if _, err := buildExactPaymentSignature(mutate(requirement), nil, client, "http://127.0.0.1:8899"); err == nil { + t.Fatal("expected invalid requirement to be rejected") + } + }) + } +} + +func TestBuildExactPaymentSignatureGeneratesUniqueDefaultMemos(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + feePayer, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + payTo, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + requirement := paymentRequirement{ + Scheme: "exact", + Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + Amount: "1000", + PayTo: payTo.PublicKey().String(), + Extra: map[string]any{ + "feePayer": feePayer.PublicKey().String(), + "decimals": float64(6), + "tokenProgram": solana.TokenProgramID.String(), + "recentBlockhash": solana.Hash{}.String(), + }, + } + + firstHeader, err := buildExactPaymentSignature(requirement, nil, client, "http://127.0.0.1:8899") + if err != nil { + t.Fatal(err) + } + secondHeader, err := buildExactPaymentSignature(requirement, nil, client, "http://127.0.0.1:8899") + if err != nil { + t.Fatal(err) + } + + firstMemo := memoFromPaymentHeaderForTest(t, firstHeader) + secondMemo := memoFromPaymentHeaderForTest(t, secondHeader) + if firstHeader == secondHeader { + t.Fatal("expected unique payment headers") + } + if firstMemo == secondMemo { + t.Fatalf("expected unique default memos, got %q", firstMemo) + } + if len(firstMemo) != 32 || len(secondMemo) != 32 { + t.Fatalf("expected 32 byte hex memos, got %d and %d", len(firstMemo), len(secondMemo)) + } + if strings.Trim(firstMemo+secondMemo, "0123456789abcdef") != "" { + t.Fatalf("expected lowercase hex memos, got %q and %q", firstMemo, secondMemo) + } +} + +func TestBuildExactPaymentSignatureRejectsMemoAboveReferenceLimit(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + feePayer, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + payTo, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + requirement := paymentRequirement{ + Scheme: "exact", + Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + Amount: "1000", + PayTo: payTo.PublicKey().String(), + Extra: map[string]any{ + "feePayer": feePayer.PublicKey().String(), + "decimals": float64(6), + "tokenProgram": solana.TokenProgramID.String(), + "recentBlockhash": solana.Hash{}.String(), + "memo": strings.Repeat("x", maxMemoBytes+1), + }, + } + + _, err = buildExactPaymentSignature(requirement, nil, client, "http://127.0.0.1:8899") + if err == nil { + t.Fatal("expected memo length error") + } + if err.Error() != "extra.memo exceeds maximum 256 bytes" { + t.Fatalf("unexpected error: %v", err) + } +} + +func memoFromPaymentHeaderForTest(t *testing.T, header string) string { + t.Helper() + decoded, err := base64.StdEncoding.DecodeString(header) + if err != nil { + t.Fatal(err) + } + var envelope paymentSignatureEnvelope + if err := json.Unmarshal(decoded, &envelope); err != nil { + t.Fatal(err) + } + tx := new(solana.Transaction) + if err := tx.UnmarshalBase64(envelope.Payload["transaction"]); err != nil { + t.Fatal(err) + } + for _, instruction := range tx.Message.Instructions { + program, err := tx.Message.Program(instruction.ProgramIDIndex) + if err != nil { + t.Fatal(err) + } + if program.Equals(memoProgramID) { + return string(instruction.Data) + } + } + t.Fatal("memo instruction missing") + return "" +} + +type clientRoundTripFunc func(*http.Request) (*http.Response, error) + +func (fn clientRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +func cloneClientExtra(extra map[string]any) map[string]any { + cloned := make(map[string]any, len(extra)) + for key, value := range extra { + cloned[key] = value + } + return cloned +} + +func captureStdoutForTest(t *testing.T, fn func()) string { + t.Helper() + original := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writer + defer func() { + os.Stdout = original + }() + + fn() + if err := writer.Close(); err != nil { + t.Fatal(err) + } + output, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + return string(output) +} + +func mustPanicClient(t *testing.T, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + fn() +} + +// --- Greptile PR #18 follow-up: cross-envelope preference / fallback parity --- +// +// These three tests pin the cross-envelope behavior Greptile flagged as +// "absent regression coverage". They exercise the boundary between header and +// body envelopes — both with and without a currency preference — so future +// refactors can't silently regress the fallback path. + +// TestSelectSVMChallengeFallsBackToBodyWhenHeaderPreferenceMisses verifies +// that when the PAYMENT-REQUIRED header offers only USDC but the body offers +// PYUSD and the caller prefers ["PYUSD"], the client falls through the header +// envelope and selects the PYUSD entry from the body envelope. +func TestSelectSVMChallengeFallsBackToBodyWhenHeaderPreferenceMisses(t *testing.T) { + network := "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + headerEnvelope, err := json.Marshal(map[string]any{ + "resource": map[string]any{"uri": "/header"}, + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": network, + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // devnet USDC + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(map[string]any{ + "resource": map[string]any{"uri": "/body"}, + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": network, + "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", // devnet PYUSD + "amount": "2000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, resource := selectSVMChallengeWithPreferences( + map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, + string(body), + network, + "exact", + []string{"PYUSD"}, + ) + + if selected == nil { + t.Fatal("expected fallback selection from body envelope") + } + if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { + t.Fatalf("expected body PYUSD mint, got %s", selected.Asset) + } + if resource["uri"] != "/body" { + t.Fatalf("expected body resource attribution, got %#v", resource) + } +} + +// TestSelectSVMChallengeReturnsNilWhenNoEnvelopeMatchesPreference verifies +// that a strict preference list with no match across any envelope returns nil +// rather than silently downgrading to "any" selection. This locks the caller's +// opt-in: if you said "I only accept BOGUS", you get nothing, not USDC. +func TestSelectSVMChallengeReturnsNilWhenNoEnvelopeMatchesPreference(t *testing.T) { + network := "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + headerEnvelope, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": network, + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // USDC + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": network, + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // USDC + "amount": "1500", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, resource := selectSVMChallengeWithPreferences( + map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, + string(body), + network, + "exact", + []string{"BOGUS"}, + ) + + if selected != nil { + t.Fatalf("expected nil selection for unmet preference, got %+v", selected) + } + if resource != nil { + t.Fatalf("expected nil resource for unmet preference, got %#v", resource) + } +} + +// TestSelectSVMChallengePicksCheapestAcrossEnvelopesWhenNoPreference verifies +// that, when no preference is supplied, the selector aggregates valid +// candidates across the header and body envelopes and picks the globally +// cheapest amount — not merely the cheapest within the first envelope it sees. +// Header: 2000 USDC. Body: 1000 PYUSD. Expected: 1000 PYUSD with body's +// resource block. +func TestSelectSVMChallengePicksCheapestAcrossEnvelopesWhenNoPreference(t *testing.T) { + network := "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + headerEnvelope, err := json.Marshal(map[string]any{ + "resource": map[string]any{"uri": "/header"}, + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": network, + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // USDC + "amount": "2000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(map[string]any{ + "resource": map[string]any{"uri": "/body"}, + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": network, + "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", // PYUSD + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, resource := selectSVMChallengeWithPreferences( + map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, + string(body), + network, + "exact", + nil, + ) + + if selected == nil { + t.Fatal("expected cross-envelope cheapest selection") + } + if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { + t.Fatalf("expected body PYUSD (cheapest), got %s @ %s", selected.Asset, selected.Amount) + } + if selected.Amount != "1000" { + t.Fatalf("expected amount 1000, got %s", selected.Amount) + } + if resource["uri"] != "/body" { + t.Fatalf("expected body resource attribution, got %#v", resource) + } +} diff --git a/go/x402/cmd/interop-client/main.go b/go/x402/cmd/interop-client/main.go new file mode 100644 index 000000000..6534f18d6 --- /dev/null +++ b/go/x402/cmd/interop-client/main.go @@ -0,0 +1,622 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/gagliardetto/solana-go" +) + +type paymentEnvelope struct { + Resource map[string]any `json:"resource,omitempty"` + Accepts []paymentRequirement `json:"accepts"` +} + +type paymentRequirement struct { + Scheme string `json:"scheme"` + Network string `json:"network"` + Asset string `json:"asset"` + Amount string `json:"amount"` + PayTo string `json:"payTo"` + MaxTimeoutSeconds int `json:"maxTimeoutSeconds,omitempty"` + Extra map[string]any `json:"extra"` +} + +type paymentSignatureEnvelope struct { + X402Version int `json:"x402Version"` + Accepted paymentRequirement `json:"accepted"` + Resource map[string]any `json:"resource,omitempty"` + Payload map[string]string `json:"payload"` +} + +const ( + defaultComputeUnitLimit = 20_000 + defaultComputeUnitPriceMicrolamport = 1 + maxMemoBytes = 256 +) + +var ( + computeBudgetProgramID = solana.MustPublicKeyFromBase58("ComputeBudget111111111111111111111111111111") + memoProgramID = solana.MustPublicKeyFromBase58("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr") + httpClient = &http.Client{Timeout: 10 * time.Second} +) + +func headerValue(headers map[string]string, name string) string { + for key, value := range headers { + if strings.EqualFold(key, name) { + return value + } + } + return "" +} + +func loadPaymentRequiredHeader(headers map[string]string) *paymentEnvelope { + encoded := headerValue(headers, "PAYMENT-REQUIRED") + if encoded == "" { + return nil + } + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil + } + + var envelope paymentEnvelope + if err := json.Unmarshal(decoded, &envelope); err != nil { + return nil + } + return &envelope +} + +func loadPaymentRequiredBody(body string) *paymentEnvelope { + if body == "" { + return nil + } + + var envelope paymentEnvelope + if err := json.Unmarshal([]byte(body), &envelope); err != nil { + return nil + } + return &envelope +} + +func selectSVMRequirement(headers map[string]string, body string, network string, scheme string) *paymentRequirement { + requirement, _ := selectSVMChallengeWithPreferences(headers, body, network, scheme, nil) + return requirement +} + +func selectSVMChallenge(headers map[string]string, body string, network string, scheme string) (*paymentRequirement, map[string]any) { + return selectSVMChallengeWithPreferences(headers, body, network, scheme, parseCSVEnv("X402_INTEROP_PREFER_CURRENCIES")) +} + +func selectSVMChallengeWithPreferences(headers map[string]string, body string, network string, scheme string, preferredCurrencies []string) (*paymentRequirement, map[string]any) { + envelopes := []*paymentEnvelope{ + loadPaymentRequiredHeader(headers), + loadPaymentRequiredBody(body), + } + + // Preference path: envelope-by-envelope fallback. Each preferred currency + // is searched against each envelope in order; the first match wins. If no + // envelope satisfies the preference list we return nil (caller's strict + // opt-in is preserved instead of silently downgrading to "any" selection). + if len(preferredCurrencies) > 0 { + for _, envelope := range envelopes { + if envelope == nil { + continue + } + candidates := filterCandidates(envelope.Accepts, scheme, network) + if len(candidates) == 0 { + continue + } + for _, preferred := range preferredCurrencies { + for _, requirement := range candidates { + if currenciesMatch(requirement.Asset, preferred, network) { + selected := requirement + return &selected, envelope.Resource + } + } + } + } + return nil, nil + } + + // No-preference path: aggregate valid candidates from ALL envelopes and + // pick the globally cheapest amount. Resource attribution follows the + // envelope that contributed the winning candidate so downstream telemetry + // and signing flows see the correct context. + type candidateEntry struct { + requirement paymentRequirement + resource map[string]any + } + var entries []candidateEntry + for _, envelope := range envelopes { + if envelope == nil { + continue + } + for _, requirement := range filterCandidates(envelope.Accepts, scheme, network) { + entries = append(entries, candidateEntry{requirement: requirement, resource: envelope.Resource}) + } + } + if len(entries) == 0 { + return nil, nil + } + winner := entries[0] + winnerAmount, err := strconv.ParseUint(winner.requirement.Amount, 10, 64) + if err != nil { + winnerAmount = ^uint64(0) + } + for _, entry := range entries[1:] { + amount, err := strconv.ParseUint(entry.requirement.Amount, 10, 64) + if err != nil { + amount = ^uint64(0) + } + if amount < winnerAmount { + winner = entry + winnerAmount = amount + } + } + selected := winner.requirement + return &selected, winner.resource +} + +func filterCandidates(accepts []paymentRequirement, scheme string, network string) []paymentRequirement { + candidates := make([]paymentRequirement, 0, len(accepts)) + for _, requirement := range accepts { + if requirement.Scheme != scheme { + continue + } + if requirement.Network != network { + continue + } + if requirement.Asset == "" || requirement.Amount == "" { + continue + } + candidates = append(candidates, requirement) + } + return candidates +} + +func parseCSVEnv(name string) []string { + raw := os.Getenv(name) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + values = append(values, trimmed) + } + } + return values +} + +func currenciesMatch(offered string, accepted string, network string) bool { + return resolveStablecoinMint(offered, network) == resolveStablecoinMint(accepted, network) +} + +func resolveStablecoinMint(currency string, network string) string { + upper := strings.ToUpper(strings.TrimSpace(currency)) + switch upper { + case "USDC", "USD": + if network == "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" || network == "devnet" || network == "localnet" { + return "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" + } + return "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + case "PYUSD": + if network == "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" || network == "devnet" || network == "localnet" { + return "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" + } + return "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo" + case "USDG": + if network == "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" || network == "devnet" || network == "localnet" { + return "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7" + } + return "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH" + case "USDT": + return "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" + case "CASH": + return "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH" + default: + return strings.TrimSpace(currency) + } +} + +func intFromRequirement(requirement paymentRequirement, key string) (uint64, error) { + value, ok := requirement.Extra[key] + if !ok { + return 0, fmt.Errorf("payment requirement is missing integer extra.%s", key) + } + + switch typed := value.(type) { + case float64: + return uint64(typed), nil + case string: + parsed, err := strconv.ParseUint(typed, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid integer extra.%s: %w", key, err) + } + return parsed, nil + default: + return 0, fmt.Errorf("payment requirement has invalid integer extra.%s", key) + } +} + +func stringFromExtra(requirement paymentRequirement, key string) (string, error) { + value, ok := requirement.Extra[key] + if !ok { + return "", fmt.Errorf("payment requirement is missing extra.%s", key) + } + typed, ok := value.(string) + if !ok || typed == "" { + return "", fmt.Errorf("payment requirement has invalid extra.%s", key) + } + return typed, nil +} + +func keypairFromJSONSecret(raw string) (solana.PrivateKey, error) { + var values []byte + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return nil, fmt.Errorf("decode Solana secret key: %w", err) + } + if len(values) != 64 { + return nil, fmt.Errorf("expected a 64-byte Solana secret key JSON array") + } + privateKey := solana.PrivateKey(values) + if _, err := solana.ValidatePrivateKey(privateKey); err != nil { + return nil, err + } + return privateKey, nil +} + +func latestBlockhash(rpcURL string) (solana.Hash, error) { + requestBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "getLatestBlockhash", + }) + if err != nil { + return solana.Hash{}, err + } + response, err := httpClient.Post(rpcURL, "application/json", bytes.NewReader(requestBody)) + if err != nil { + return solana.Hash{}, err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return solana.Hash{}, fmt.Errorf("getLatestBlockhash HTTP %d", response.StatusCode) + } + var payload struct { + Result struct { + Value struct { + Blockhash string `json:"blockhash"` + } `json:"value"` + } `json:"result"` + Error any `json:"error"` + } + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + return solana.Hash{}, err + } + if payload.Error != nil { + return solana.Hash{}, fmt.Errorf("getLatestBlockhash RPC error: %v", payload.Error) + } + return solana.HashFromBase58(payload.Result.Value.Blockhash) +} + +func computeUnitLimitInstruction(units uint32) solana.Instruction { + data := []byte{2} + data = binary.LittleEndian.AppendUint32(data, units) + return solana.NewInstruction(computeBudgetProgramID, nil, data) +} + +func computeUnitPriceInstruction(microLamports uint64) solana.Instruction { + data := []byte{3} + data = binary.LittleEndian.AppendUint64(data, microLamports) + return solana.NewInstruction(computeBudgetProgramID, nil, data) +} + +func transferCheckedInstruction(requirement paymentRequirement, signer solana.PublicKey, decimals uint8, tokenProgram solana.PublicKey) (solana.Instruction, error) { + amount, err := strconv.ParseUint(requirement.Amount, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid amount: %w", err) + } + mint, err := solana.PublicKeyFromBase58(requirement.Asset) + if err != nil { + return nil, fmt.Errorf("invalid asset: %w", err) + } + payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) + if err != nil { + return nil, fmt.Errorf("invalid payTo: %w", err) + } + sourceATA, _, err := findAssociatedTokenAddress(signer, tokenProgram, mint) + if err != nil { + return nil, err + } + destinationATA, _, err := findAssociatedTokenAddress(payTo, tokenProgram, mint) + if err != nil { + return nil, err + } + + data := []byte{12} + data = binary.LittleEndian.AppendUint64(data, amount) + data = append(data, decimals) + + return solana.NewInstruction( + tokenProgram, + solana.AccountMetaSlice{ + solana.Meta(sourceATA).WRITE(), + solana.Meta(mint), + solana.Meta(destinationATA).WRITE(), + solana.Meta(signer).SIGNER(), + }, + data, + ), nil +} + +func findAssociatedTokenAddress(wallet solana.PublicKey, tokenProgram solana.PublicKey, mint solana.PublicKey) (solana.PublicKey, uint8, error) { + return solana.FindProgramAddress( + [][]byte{wallet[:], tokenProgram[:], mint[:]}, + solana.SPLAssociatedTokenAccountProgramID, + ) +} + +func memoInstruction(requirement paymentRequirement) (solana.Instruction, error) { + memo := "" + if value, ok := requirement.Extra["memo"].(string); ok && value != "" { + memo = value + } else { + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return nil, fmt.Errorf("generate memo nonce: %w", err) + } + memo = hex.EncodeToString(nonce[:]) + } + if len([]byte(memo)) > maxMemoBytes { + return nil, fmt.Errorf("extra.memo exceeds maximum %d bytes", maxMemoBytes) + } + return solana.NewInstruction(memoProgramID, nil, []byte(memo)), nil +} + +func buildExactPaymentSignature(requirement paymentRequirement, resource map[string]any, privateKey solana.PrivateKey, rpcURL string) (string, error) { + if requirement.Scheme != "exact" { + return "", fmt.Errorf("only exact payment requirements can be signed") + } + + decimalsValue, err := intFromRequirement(requirement, "decimals") + if err != nil { + return "", err + } + tokenProgramValue, err := stringFromExtra(requirement, "tokenProgram") + if err != nil { + return "", err + } + feePayerValue, err := stringFromExtra(requirement, "feePayer") + if err != nil { + return "", err + } + tokenProgram, err := solana.PublicKeyFromBase58(tokenProgramValue) + if err != nil { + return "", fmt.Errorf("invalid tokenProgram: %w", err) + } + feePayer, err := solana.PublicKeyFromBase58(feePayerValue) + if err != nil { + return "", fmt.Errorf("invalid feePayer: %w", err) + } + + blockhashValue, _ := requirement.Extra["recentBlockhash"].(string) + blockhash := solana.Hash{} + if blockhashValue != "" { + blockhash, err = solana.HashFromBase58(blockhashValue) + if err != nil { + return "", fmt.Errorf("invalid recentBlockhash: %w", err) + } + } else { + blockhash, err = latestBlockhash(rpcURL) + if err != nil { + return "", err + } + } + + transferIx, err := transferCheckedInstruction(requirement, privateKey.PublicKey(), uint8(decimalsValue), tokenProgram) + if err != nil { + return "", err + } + memoIx, err := memoInstruction(requirement) + if err != nil { + return "", err + } + + tx, err := solana.NewTransaction( + []solana.Instruction{ + computeUnitLimitInstruction(defaultComputeUnitLimit), + computeUnitPriceInstruction(defaultComputeUnitPriceMicrolamport), + transferIx, + memoIx, + }, + blockhash, + solana.TransactionPayer(feePayer), + ) + if err != nil { + return "", err + } + tx.Message.SetVersion(solana.MessageVersionV0) + if _, err := tx.PartialSign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(privateKey.PublicKey()) { + return &privateKey + } + return nil + }); err != nil { + return "", err + } + transaction, err := tx.ToBase64() + if err != nil { + return "", err + } + + encoded, err := json.Marshal(paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Resource: resource, + Payload: map[string]string{"transaction": transaction}, + }) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(encoded), nil +} + +func readResponse(response *http.Response) (map[string]string, string, error) { + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, "", err + } + headers := map[string]string{} + for key, values := range response.Header { + if len(values) > 0 { + headers[key] = values[0] + } + } + return headers, string(body), nil +} + +func parseResponseBody(body string) any { + var parsed any + decoder := json.NewDecoder(bytes.NewReader([]byte(body))) + if err := decoder.Decode(&parsed); err == nil { + return parsed + } + return body +} + +func main() { + targetURL := os.Getenv("X402_INTEROP_TARGET_URL") + if targetURL == "" { + panic("X402_INTEROP_TARGET_URL is required") + } + + response, err := httpClient.Get(targetURL) + if err != nil { + panic(err) + } + headers, body, err := readResponse(response) + if err != nil { + panic(err) + } + + selectedRequirement, resource := selectSVMChallenge( + headers, + body, + readEnvWithDefault("X402_INTEROP_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"), + readEnvWithDefault("X402_INTEROP_SCHEME", "exact"), + ) + scheme := readEnvWithDefault("X402_INTEROP_SCHEME", "exact") + errorDomain := readEnvWithDefault("X402_INTEROP_INTENT", scheme) + + if response.StatusCode == http.StatusPaymentRequired && os.Getenv("X402_INTEROP_INTENT") == "" && scheme == "exact" && selectedRequirement != nil && os.Getenv("X402_INTEROP_CLIENT_SECRET_KEY") != "" && os.Getenv("X402_INTEROP_RPC_URL") != "" { + privateKey, err := keypairFromJSONSecret(os.Getenv("X402_INTEROP_CLIENT_SECRET_KEY")) + var paymentSignature string + if err == nil { + paymentSignature, err = buildExactPaymentSignature(*selectedRequirement, resource, privateKey, os.Getenv("X402_INTEROP_RPC_URL")) + } + if err == nil { + request, requestErr := http.NewRequest(http.MethodGet, targetURL, nil) + if requestErr != nil { + err = requestErr + } else { + request.Header.Set("PAYMENT-SIGNATURE", paymentSignature) + var paidResponse *http.Response + paidResponse, err = httpClient.Do(request) + if err == nil { + paidHeaders, paidBody, readErr := readResponse(paidResponse) + if readErr != nil { + err = readErr + } else { + payload := map[string]any{ + "type": "result", + "implementation": "go", + "role": "client", + "ok": paidResponse.StatusCode >= 200 && paidResponse.StatusCode < 300, + "status": paidResponse.StatusCode, + "responseHeaders": paidHeaders, + "responseBody": parseResponseBody(paidBody), + "settlement": headerValue(paidHeaders, "x-fixture-settlement"), + } + encoded, marshalErr := json.Marshal(payload) + if marshalErr != nil { + panic(marshalErr) + } + fmt.Println(string(encoded)) + return + } + } + } + } + if err != nil { + payload := map[string]any{ + "type": "result", + "implementation": "go", + "role": "client", + "ok": false, + "status": response.StatusCode, + "responseHeaders": headers, + "responseBody": map[string]any{ + "error": "go_exact_client_payment_failed", + "message": err.Error(), + "challengeStatus": response.StatusCode, + "challengeBody": body, + "selectedRequirement": selectedRequirement, + }, + "settlement": nil, + } + encoded, marshalErr := json.Marshal(payload) + if marshalErr != nil { + panic(marshalErr) + } + fmt.Println(string(encoded)) + return + } + } + + payload := map[string]any{ + "type": "result", + "implementation": "go", + "role": "client", + "ok": false, + "status": response.StatusCode, + "responseHeaders": headers, + "responseBody": map[string]any{ + "error": fmt.Sprintf("go_%s_client_not_implemented", errorDomain), + "challengeStatus": response.StatusCode, + "challengeBody": body, + "selectedRequirement": selectedRequirement, + }, + "settlement": nil, + } + + encoded, err := json.Marshal(payload) + if err != nil { + panic(err) + } + fmt.Println(string(encoded)) +} + +func readEnvWithDefault(name string, fallback string) string { + value := os.Getenv(name) + if value == "" { + return fallback + } + return value +} diff --git a/go/x402/cmd/interop-server/main.go b/go/x402/cmd/interop-server/main.go new file mode 100644 index 000000000..187721ccd --- /dev/null +++ b/go/x402/cmd/interop-server/main.go @@ -0,0 +1,1088 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/signal" + "reflect" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/gagliardetto/solana-go" +) + +const ( + defaultResourcePath = "/protected" + defaultPrice = "$0.001" + defaultSettlementHeader = "x-fixture-settlement" + defaultDecimals = 6 + defaultTokenProgram = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + token2022Program = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + lighthouseProgram = "L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95" + defaultMaxTimeout = 60 + duplicateCacheTTL = 120 * time.Second + maxComputeUnitPrice = 5_000_000 + maxMemoBytes = 256 +) + +var ( + computeBudgetProgramID = solana.MustPublicKeyFromBase58("ComputeBudget111111111111111111111111111111") + memoProgramID = solana.MustPublicKeyFromBase58("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr") +) + +// Lighthouse instructions are passed through by program-ID match alone, matching +// the canonical spines: +// - rust/src/protocol/schemes/exact/verify.rs:266 — `if program == LIGHTHOUSE_PROGRAM || program == MEMO_PROGRAM { continue; }` +// - typescript/packages/x402/src/facilitator/exact/scheme.ts:300 — same shape +// No discriminator or account-count allowlist is enforced here: inventing one +// in a single language port would diverge from real-world Phantom/Solflare +// transactions that the Rust + TypeScript adapters accept. Tightening this is +// a protocol-wide decision that must land in the Rust spine first; tracked at +// /notes/lighthouse-allowlist-tracking.md. + +// CAIP-2 network identifiers shared with the TypeScript spine. +const ( + solanaMainnetCAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + solanaDevnetCAIP2 = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + solanaTestnetCAIP2 = "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z" +) + +// stablecoinMintsByNetwork mirrors STABLECOIN_MINTS from the TypeScript +// reference (typescript/packages/x402/src/protocol/schemes/exact/constants.ts). +// Aliases are resolved at the env-read boundary so the rest of the server +// always sees canonical base58 mint addresses. +var stablecoinMintsByNetwork = map[string]map[string]string{ + "USDC": { + solanaMainnetCAIP2: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + solanaDevnetCAIP2: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + }, + "USDG": { + solanaMainnetCAIP2: "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH", + solanaDevnetCAIP2: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", + solanaTestnetCAIP2: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", + }, + "PYUSD": { + solanaMainnetCAIP2: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", + solanaDevnetCAIP2: "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", + solanaTestnetCAIP2: "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", + }, + "CASH": { + solanaMainnetCAIP2: "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH", + }, +} + +// knownMintAliases lists the case-insensitive currency-name aliases that +// resolveMintAlias understands. Kept stable for error messages. +var knownMintAliases = []string{"USDC", "USDG", "PYUSD", "CASH"} + +// resolveMintAlias returns the canonical base58 mint address for a given +// input on the configured CAIP-2 network. The input may already be a base58 +// mint (in which case it is returned unchanged) or a known stablecoin alias +// (USDC, USDG, PYUSD, CASH). Unknown aliases and aliases without a +// configured mint for the network return a descriptive error. +func resolveMintAlias(input string, network string) (string, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", fmt.Errorf("mint is required") + } + upper := strings.ToUpper(trimmed) + if mintsByNetwork, ok := stablecoinMintsByNetwork[upper]; ok { + if mint, ok := mintsByNetwork[network]; ok { + return mint, nil + } + return "", fmt.Errorf("alias %s has no configured mint for network %s", upper, network) + } + if _, err := solana.PublicKeyFromBase58(trimmed); err != nil { + return "", fmt.Errorf("mint %q is neither a base58 address nor a known alias (accepted aliases: %s)", input, strings.Join(knownMintAliases, ", ")) + } + return trimmed, nil +} + +type serverState struct { + rpcURL string + network string + mint string + payTo string + feePayer solana.PrivateKey + amount string + extraOfferedMints []string + memo string + httpClient *http.Client +} + +type paymentEnvelope struct { + X402Version int `json:"x402Version"` + Accepts []paymentRequirement `json:"accepts"` + Resource map[string]any `json:"resource,omitempty"` +} + +type paymentRequirement struct { + Scheme string `json:"scheme"` + Network string `json:"network"` + Asset string `json:"asset"` + Amount string `json:"amount"` + PayTo string `json:"payTo"` + MaxTimeoutSeconds int `json:"maxTimeoutSeconds,omitempty"` + Extra map[string]any `json:"extra,omitempty"` +} + +type paymentSignatureEnvelope struct { + X402Version int `json:"x402Version"` + Accepted paymentRequirement `json:"accepted"` + Payload map[string]string `json:"payload"` +} + +type duplicateSettlementCache struct { + mu sync.Mutex + entries map[string]time.Time + now func() time.Time +} + +var settlementCache = newDuplicateSettlementCache() + +func newDuplicateSettlementCache() *duplicateSettlementCache { + return &duplicateSettlementCache{ + entries: map[string]time.Time{}, + now: time.Now, + } +} + +func (cache *duplicateSettlementCache) claim(key string) bool { + cache.mu.Lock() + defer cache.mu.Unlock() + + now := cache.now() + for cached, seenAt := range cache.entries { + if now.Sub(seenAt) > duplicateCacheTTL { + delete(cache.entries, cached) + } + } + if _, ok := cache.entries[key]; ok { + return false + } + cache.entries[key] = now + return true +} + +func (cache *duplicateSettlementCache) release(key string) { + cache.mu.Lock() + defer cache.mu.Unlock() + delete(cache.entries, key) +} + +func writeJSON(response http.ResponseWriter, status int, payload map[string]any) { + encoded, err := json.Marshal(payload) + if err != nil { + panic(err) + } + response.Header().Set("content-type", "application/json") + response.WriteHeader(status) + if _, err := response.Write(encoded); err != nil { + fmt.Fprintln(os.Stderr, err) + } +} + +func writeJSONWithHeaders(response http.ResponseWriter, status int, headers map[string]string, payload map[string]any) { + encoded, err := json.Marshal(payload) + if err != nil { + panic(err) + } + response.Header().Set("content-type", "application/json") + for key, value := range headers { + response.Header().Set(key, value) + } + response.WriteHeader(status) + if _, err := response.Write(encoded); err != nil { + fmt.Fprintln(os.Stderr, err) + } +} + +func capabilityPayload(implementation string) map[string]any { + return map[string]any{ + "implementation": implementation, + "role": "server", + "capabilities": []string{"exact"}, + "plannedBoundaries": []string{"exact", "upto", "session", "batch-settlement"}, + } +} + +func exactRequirementForMint(state serverState, mint string) paymentRequirement { + requirement := paymentRequirement{ + Scheme: "exact", + Network: state.network, + Asset: mint, + Amount: state.amount, + PayTo: state.payTo, + MaxTimeoutSeconds: defaultMaxTimeout, + Extra: map[string]any{ + "decimals": defaultDecimals, + "feePayer": state.feePayer.PublicKey().String(), + "tokenProgram": defaultTokenProgramForMint(mint), + }, + } + if state.memo != "" { + requirement.Extra["memo"] = state.memo + } + return requirement +} + +func exactRequirement(state serverState) paymentRequirement { + return exactRequirementForMint(state, state.mint) +} + +func exactChallengePayload(state serverState) paymentEnvelope { + accepts := []paymentRequirement{exactRequirement(state)} + for _, mint := range state.extraOfferedMints { + if mint == "" { + continue + } + accepts = append(accepts, exactRequirementForMint(state, mint)) + } + return paymentEnvelope{ + X402Version: 2, + Accepts: accepts, + Resource: map[string]any{ + "type": "http", + "uri": defaultResourcePath, + }, + } +} + +func defaultTokenProgramForMint(mint string) string { + switch strings.ToUpper(strings.TrimSpace(mint)) { + case "USDG", "PYUSD", "CASH": + return token2022Program + } + switch strings.TrimSpace(mint) { + case "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH", + "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", + "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", + "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", + "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH": + return token2022Program + default: + return defaultTokenProgram + } +} + +func uptoChallengePayload() map[string]any { + return map[string]any{ + "x402Version": 2, + "accepts": []map[string]any{ + { + "scheme": "upto", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + }, + } +} + +func writePaymentRequired(response http.ResponseWriter, challenge map[string]any) { + encoded, err := json.Marshal(challenge) + if err != nil { + panic(err) + } + response.Header().Set("PAYMENT-REQUIRED", base64.StdEncoding.EncodeToString(encoded)) + writeJSON(response, http.StatusPaymentRequired, map[string]any{"error": "payment_required"}) +} + +func writeExactPaymentRequired(response http.ResponseWriter, state serverState) { + challenge := exactChallengePayload(state) + encoded, err := json.Marshal(challenge) + if err != nil { + panic(err) + } + response.Header().Set("PAYMENT-REQUIRED", base64.StdEncoding.EncodeToString(encoded)) + writeJSON(response, http.StatusPaymentRequired, map[string]any{"error": "payment_required"}) +} + +func sessionChallengePayload() map[string]any { + return map[string]any{ + "intent": "session", + "payee": "session-payee", + "mint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "suggestedDeposit": "10000", + "unitPrice": "25", + "unitType": "llm_token", + } +} + +func batchSettlementChallengePayload() map[string]any { + return map[string]any{ + "x402Version": 2, + "accepts": []map[string]any{ + { + "scheme": "batch-settlement", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "receiver": "batch-receiver", + "token": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "maximumAmount": "1000", + }, + }, + } +} + +func readRequiredEnv(name string) string { + value := os.Getenv(name) + if value == "" { + panic(fmt.Sprintf("%s is required", name)) + } + return value +} + +func readEnvWithDefault(name string, fallback string) string { + value := os.Getenv(name) + if value == "" { + return fallback + } + return value +} + +func readCSVEnv(name string) []string { + raw := os.Getenv(name) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + values = append(values, trimmed) + } + } + return values +} + +func normalizeAmount(price string) string { + trimmed := strings.TrimSpace(price) + if len(trimmed) > 0 && trimmed[0] == '$' { + trimmed = trimmed[1:] + } + amountPart := strings.Fields(trimmed)[0] + parts := strings.SplitN(amountPart, ".", 2) + whole, err := strconv.ParseUint(parts[0], 10, 64) + if err != nil { + panic(fmt.Sprintf("invalid X402_INTEROP_PRICE: %s", price)) + } + fraction := "" + if len(parts) == 2 { + fraction = parts[1] + } + if len(fraction) > defaultDecimals { + panic(fmt.Sprintf("X402_INTEROP_PRICE has too many decimal places: %s", price)) + } + fraction = fraction + strings.Repeat("0", defaultDecimals-len(fraction)) + fractional, err := strconv.ParseUint(fraction, 10, 64) + if err != nil { + panic(fmt.Sprintf("invalid X402_INTEROP_PRICE: %s", price)) + } + return strconv.FormatUint((whole*1_000_000)+fractional, 10) +} + +func keypairFromJSONSecret(raw string) solana.PrivateKey { + var values []byte + if err := json.Unmarshal([]byte(raw), &values); err != nil { + panic(fmt.Sprintf("decode Solana secret key: %s", err)) + } + if len(values) != 64 { + panic("expected a 64-byte Solana secret key JSON array") + } + privateKey := solana.PrivateKey(values) + if _, err := solana.ValidatePrivateKey(privateKey); err != nil { + panic(err) + } + return privateKey +} + +func readState() serverState { + network := readEnvWithDefault("X402_INTEROP_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1") + rawMint := readEnvWithDefault("X402_INTEROP_MINT", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU") + resolvedMint, err := resolveMintAlias(rawMint, network) + if err != nil { + panic(fmt.Sprintf("X402_INTEROP_MINT: %s", err)) + } + rawExtra := readCSVEnv("X402_INTEROP_EXTRA_OFFERED_MINTS") + resolvedExtra := make([]string, 0, len(rawExtra)) + for _, candidate := range rawExtra { + resolved, err := resolveMintAlias(candidate, network) + if err != nil { + panic(fmt.Sprintf("X402_INTEROP_EXTRA_OFFERED_MINTS: %s", err)) + } + resolvedExtra = append(resolvedExtra, resolved) + } + return serverState{ + rpcURL: readRequiredEnv("X402_INTEROP_RPC_URL"), + network: network, + mint: resolvedMint, + payTo: readRequiredEnv("X402_INTEROP_PAY_TO"), + feePayer: keypairFromJSONSecret(readRequiredEnv("X402_INTEROP_FACILITATOR_SECRET_KEY")), + amount: normalizeAmount(readEnvWithDefault("X402_INTEROP_PRICE", defaultPrice)), + extraOfferedMints: resolvedExtra, + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +func paymentRequirementMatches(left paymentRequirement, right paymentRequirement) bool { + return reflect.DeepEqual(normalizeRequirement(left), normalizeRequirement(right)) +} + +func acceptedExactRequirement(state serverState, accepted paymentRequirement) (paymentRequirement, bool) { + for _, requirement := range exactChallengePayload(state).Accepts { + if paymentRequirementMatches(accepted, requirement) { + return requirement, true + } + } + return paymentRequirement{}, false +} + +func normalizeRequirement(requirement paymentRequirement) paymentRequirement { + normalized := requirement + normalized.Extra = map[string]any{} + for key, value := range requirement.Extra { + normalized.Extra[key] = fmt.Sprint(value) + } + return normalized +} + +func decodePaymentSignature(headerValue string) (paymentSignatureEnvelope, error) { + decoded, err := base64.StdEncoding.DecodeString(headerValue) + if err != nil { + return paymentSignatureEnvelope{}, err + } + var payload paymentSignatureEnvelope + if err := json.Unmarshal(decoded, &payload); err != nil { + return paymentSignatureEnvelope{}, err + } + return payload, nil +} + +func settleExactPayment(state serverState, headerValue string) (string, error) { + payload, err := decodePaymentSignature(headerValue) + if err != nil { + return "", err + } + if payload.X402Version != 2 { + return "", fmt.Errorf("unsupported x402Version: %d", payload.X402Version) + } + requirement, ok := acceptedExactRequirement(state, payload.Accepted) + if !ok { + return "", fmt.Errorf("accepted payment requirement does not match server challenge") + } + + encodedTransaction := payload.Payload["transaction"] + if encodedTransaction == "" { + return "", fmt.Errorf("payment payload is missing transaction") + } + + transaction, err := solana.TransactionFromBase64(encodedTransaction) + if err != nil { + return "", err + } + if err := verifyExactTransaction(transaction, requirement); err != nil { + return "", err + } + // Bind the transaction's message fee-payer (account key 0) to the + // server's configured fee-payer. Without this guard a malicious client + // could nominate a different message payer and rely on the facilitator + // being in the signer set to drain SOL via co-signing. + if len(transaction.Message.AccountKeys) == 0 { + return "", fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_missing") + } + if !transaction.Message.AccountKeys[0].Equals(state.feePayer.PublicKey()) { + return "", fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_mismatch") + } + cacheKey := transactionCacheKey(encodedTransaction) + if !settlementCache.claim(cacheKey) { + return "", fmt.Errorf("duplicate_settlement") + } + settled := false + defer func() { + if !settled { + settlementCache.release(cacheKey) + } + }() + if err := verifyTokenAccountsExist(state, transaction, requirement); err != nil { + return "", err + } + + if _, err := transaction.PartialSign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(state.feePayer.PublicKey()) { + return &state.feePayer + } + return nil + }); err != nil { + return "", err + } + if err := transaction.VerifySignatures(); err != nil { + return "", err + } + + settlement, err := sendTransaction(state, transaction) + if err != nil { + return "", err + } + settled = true + return settlement, nil +} + +func transactionCacheKey(encodedTransaction string) string { + sum := sha256.Sum256([]byte(encodedTransaction)) + return base64.StdEncoding.EncodeToString(sum[:]) +} + +type transferCheckedFields struct { + source solana.PublicKey + mint solana.PublicKey + destination solana.PublicKey + authority solana.PublicKey + amount uint64 + decimals uint8 + tokenProgram solana.PublicKey +} + +func verifyExactTransaction(transaction *solana.Transaction, requirement paymentRequirement) error { + if !transaction.Message.IsVersioned() { + return fmt.Errorf("payment transaction must be versioned") + } + instructions := transaction.Message.Instructions + if len(instructions) < 3 || len(instructions) > 6 { + return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_length") + } + if err := verifyComputeLimitInstruction(transaction, instructions[0]); err != nil { + return err + } + if err := verifyComputePriceInstruction(transaction, instructions[1]); err != nil { + return err + } + transfer, err := parseTransferCheckedInstruction(transaction, instructions[2]) + if err != nil { + return err + } + if err := verifyOptionalInstructions(transaction, instructions[3:], requirement, transfer); err != nil { + return err + } + feePayer, err := solana.PublicKeyFromBase58(fmt.Sprint(requirement.Extra["feePayer"])) + if err != nil { + return fmt.Errorf("invalid feePayer: %w", err) + } + // Codex P1.2 (May 2026): the previous unconditional "fee-payer in any + // instruction account" loop was both over-broad (false-positive on the + // legitimate destination-ATA-create flow, where the SPL Associated Token + // Account program requires the rent payer at accounts[0]) and incomplete + // (it did not distinguish *role* — fee-payer as transfer authority/source + // is the real attack the Rust spine bans at + // rust/src/protocol/schemes/exact/verify.rs:382). Tightened rule: + // * fee-payer is allowed at accounts[0] of a *validated* ATA-create ix + // (the canonical rent-payer position). + // * fee-payer is allowed inside Lighthouse instruction account lists + // (the Rust spine has NO fee-payer-in-accounts sweep at all; it only + // blocks fee-payer as transfer authority at verify.rs:382, and accepts + // any Lighthouse ix by program-id alone at verify.rs:263 — wallets such + // as Phantom/Solflare routinely add `AssertAccount*` ixs that reference + // the fee-payer's pubkey to guard against malicious facilitator rewrites). + // * fee-payer in any other (non-Lighthouse, non-ATA-create-payer-slot) + // instruction account list is rejected with a distinct typed error. + // * fee-payer as transfer authority / source is still rejected with the + // spine-aligned `_transferring_funds` error. + if transfer.authority.Equals(feePayer) || transfer.source.Equals(feePayer) { + return fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_transferring_funds") + } + for index, instruction := range instructions { + if index == 2 { + // instruction[2] is the transferChecked; its fee-payer-as-role + // abuses are already covered by the spine-aligned guard above. + continue + } + program, err := programID(transaction, instruction) + if err != nil { + return err + } + if program.String() == lighthouseProgram { + // Mirror rust/src/protocol/schemes/exact/verify.rs:263 — Lighthouse + // ixs are passed through by program-id alone; the spine never + // inspects their account lists for the managed fee-payer. + continue + } + isATACreatePayerSlot := index >= 3 && isValidatedATACreateInstruction(transaction, instruction, requirement, transfer) + for accountPosition, accountIndex := range instruction.Accounts { + account, err := accountAt(transaction, accountIndex) + if err != nil { + return err + } + if !account.Equals(feePayer) { + continue + } + if isATACreatePayerSlot && accountPosition == 0 { + continue + } + return fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_in_instruction_accounts") + } + } + mint, err := solana.PublicKeyFromBase58(requirement.Asset) + if err != nil { + return fmt.Errorf("invalid asset: %w", err) + } + if !transfer.mint.Equals(mint) { + return fmt.Errorf("invalid_exact_svm_payload_transaction_mint") + } + expectedAmount, err := strconv.ParseUint(requirement.Amount, 10, 64) + if err != nil { + return fmt.Errorf("invalid amount: %w", err) + } + if transfer.amount != expectedAmount { + return fmt.Errorf("invalid_exact_svm_payload_transaction_amount") + } + payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) + if err != nil { + return fmt.Errorf("invalid payTo: %w", err) + } + expectedDestination, _, err := solana.FindAssociatedTokenAddressWithProgram(payTo, mint, transfer.tokenProgram) + if err != nil { + return err + } + if !transfer.destination.Equals(expectedDestination) { + return fmt.Errorf("invalid_exact_svm_payload_transaction_destination") + } + if decimals, err := strconv.ParseUint(fmt.Sprint(requirement.Extra["decimals"]), 10, 8); err == nil && transfer.decimals != uint8(decimals) { + return fmt.Errorf("invalid_exact_svm_payload_transaction_decimals") + } + return nil +} + +func verifyComputeLimitInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction) error { + program, err := programID(transaction, instruction) + if err != nil { + return err + } + if !program.Equals(computeBudgetProgramID) || len(instruction.Data) != 5 || instruction.Data[0] != 2 { + return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction") + } + return nil +} + +func verifyComputePriceInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction) error { + program, err := programID(transaction, instruction) + if err != nil { + return err + } + if !program.Equals(computeBudgetProgramID) || len(instruction.Data) != 9 || instruction.Data[0] != 3 { + return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_compute_price_instruction") + } + price := binary.LittleEndian.Uint64(instruction.Data[1:]) + if price > maxComputeUnitPrice { + return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high") + } + return nil +} + +func parseTransferCheckedInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction) (transferCheckedFields, error) { + program, err := programID(transaction, instruction) + if err != nil { + return transferCheckedFields{}, err + } + if !program.Equals(solana.TokenProgramID) && !program.Equals(solana.Token2022ProgramID) { + return transferCheckedFields{}, fmt.Errorf("invalid_exact_svm_payload_transaction_transfer_program") + } + if len(instruction.Accounts) < 4 || len(instruction.Data) != 10 || instruction.Data[0] != 12 { + return transferCheckedFields{}, fmt.Errorf("invalid_exact_svm_payload_transaction_transfer_checked") + } + source, err := accountAt(transaction, instruction.Accounts[0]) + if err != nil { + return transferCheckedFields{}, err + } + mint, err := accountAt(transaction, instruction.Accounts[1]) + if err != nil { + return transferCheckedFields{}, err + } + destination, err := accountAt(transaction, instruction.Accounts[2]) + if err != nil { + return transferCheckedFields{}, err + } + authority, err := accountAt(transaction, instruction.Accounts[3]) + if err != nil { + return transferCheckedFields{}, err + } + return transferCheckedFields{ + source: source, + mint: mint, + destination: destination, + authority: authority, + amount: binary.LittleEndian.Uint64(instruction.Data[1:9]), + decimals: instruction.Data[9], + tokenProgram: program, + }, nil +} + +func verifyOptionalInstructions(transaction *solana.Transaction, instructions []solana.CompiledInstruction, requirement paymentRequirement, transfer transferCheckedFields) error { + memoCount := 0 + expectedMemo, hasExpectedMemo := requirement.Extra["memo"].(string) + invalidReasonByIndex := []string{ + "invalid_exact_svm_payload_unknown_fourth_instruction", + "invalid_exact_svm_payload_unknown_fifth_instruction", + "invalid_exact_svm_payload_unknown_sixth_instruction", + } + for index, instruction := range instructions { + program, err := programID(transaction, instruction) + if err != nil { + return err + } + if program.Equals(memoProgramID) { + memoCount++ + memo := string(instruction.Data) + if len([]byte(memo)) > maxMemoBytes { + return fmt.Errorf("extra.memo exceeds maximum %d bytes", maxMemoBytes) + } + if hasExpectedMemo && memo != expectedMemo { + return fmt.Errorf("invalid_exact_svm_payload_transaction_memo") + } + if !hasExpectedMemo && memo == "" { + return fmt.Errorf("invalid_exact_svm_payload_transaction_memo") + } + continue + } + if program.String() == lighthouseProgram { + // Pass through Lighthouse instructions by program-id match only, + // mirroring rust/src/protocol/schemes/exact/verify.rs:266 and + // typescript/packages/x402/src/facilitator/exact/scheme.ts:300. + continue + } + if program.Equals(solana.SPLAssociatedTokenAccountProgramID) && validDestinationATACreateInstruction(transaction, instruction, requirement, transfer) { + continue + } + if index < len(invalidReasonByIndex) { + return fmt.Errorf("%s", invalidReasonByIndex[index]) + } + return fmt.Errorf("invalid_exact_svm_payload_unknown_optional_instruction") + } + if hasExpectedMemo && memoCount != 1 { + return fmt.Errorf("invalid_exact_svm_payload_transaction_memo") + } + return nil +} + +// isValidatedATACreateInstruction returns true when `instruction` is an +// SPL Associated Token Account program create that targets the payment's +// destination ATA — i.e. the only optional instruction in which the facilitator +// fee-payer is permitted to appear (as the rent payer at accounts[0]). +func isValidatedATACreateInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction, requirement paymentRequirement, transfer transferCheckedFields) bool { + program, err := programID(transaction, instruction) + if err != nil { + return false + } + if !program.Equals(solana.SPLAssociatedTokenAccountProgramID) { + return false + } + return validDestinationATACreateInstruction(transaction, instruction, requirement, transfer) +} + +func validDestinationATACreateInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction, requirement paymentRequirement, transfer transferCheckedFields) bool { + if len(instruction.Data) > 1 { + return false + } + if len(instruction.Data) == 1 && instruction.Data[0] != 0 && instruction.Data[0] != 1 { + return false + } + if len(instruction.Accounts) < 6 { + return false + } + associatedAccount, err := accountAt(transaction, instruction.Accounts[1]) + if err != nil || !associatedAccount.Equals(transfer.destination) { + return false + } + wallet, err := accountAt(transaction, instruction.Accounts[2]) + if err != nil { + return false + } + payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) + if err != nil || !wallet.Equals(payTo) { + return false + } + mint, err := accountAt(transaction, instruction.Accounts[3]) + if err != nil || !mint.Equals(transfer.mint) { + return false + } + systemProgram, err := accountAt(transaction, instruction.Accounts[4]) + if err != nil || !systemProgram.Equals(solana.SystemProgramID) { + return false + } + tokenProgram, err := accountAt(transaction, instruction.Accounts[5]) + if err != nil || !tokenProgram.Equals(transfer.tokenProgram) { + return false + } + return true +} + +func programID(transaction *solana.Transaction, instruction solana.CompiledInstruction) (solana.PublicKey, error) { + return accountAt(transaction, instruction.ProgramIDIndex) +} + +func accountAt(transaction *solana.Transaction, index uint16) (solana.PublicKey, error) { + if int(index) >= len(transaction.Message.AccountKeys) { + return solana.PublicKey{}, fmt.Errorf("invalid account index: %d", index) + } + return transaction.Message.AccountKeys[index], nil +} + +func verifyTokenAccountsExist(state serverState, transaction *solana.Transaction, requirement paymentRequirement) error { + transfer, err := parseTransferCheckedInstruction(transaction, transaction.Message.Instructions[2]) + if err != nil { + return err + } + if exists, err := accountExists(state, transfer.source); err != nil { + return err + } else if !exists { + return fmt.Errorf("source token account does not exist") + } + if hasDestinationATACreateInstruction(transaction, requirement, transfer) { + return nil + } + if exists, err := accountExists(state, transfer.destination); err != nil { + return err + } else if !exists { + return fmt.Errorf("destination token account does not exist") + } + return nil +} + +func hasDestinationATACreateInstruction(transaction *solana.Transaction, requirement paymentRequirement, transfer transferCheckedFields) bool { + for _, instruction := range transaction.Message.Instructions[3:] { + program, err := programID(transaction, instruction) + if err != nil || !program.Equals(solana.SPLAssociatedTokenAccountProgramID) { + continue + } + if validDestinationATACreateInstruction(transaction, instruction, requirement, transfer) { + return true + } + } + return false +} + +func accountExists(state serverState, account solana.PublicKey) (bool, error) { + requestBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "getAccountInfo", + "params": []any{ + account.String(), + map[string]any{"encoding": "base64"}, + }, + }) + if err != nil { + return false, err + } + response, err := state.httpClient.Post(state.rpcURL, "application/json", bytes.NewReader(requestBody)) + if err != nil { + return false, err + } + defer response.Body.Close() + rawBody, err := io.ReadAll(response.Body) + if err != nil { + return false, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return false, fmt.Errorf("getAccountInfo HTTP %d: %s", response.StatusCode, string(rawBody)) + } + var payload struct { + Result *struct { + Value json.RawMessage `json:"value"` + } `json:"result"` + Error any `json:"error"` + } + if err := json.Unmarshal(rawBody, &payload); err != nil { + return false, err + } + if payload.Error != nil { + return false, fmt.Errorf("getAccountInfo RPC error: %v", payload.Error) + } + if payload.Result == nil || len(payload.Result.Value) == 0 || string(payload.Result.Value) == "null" { + return false, nil + } + return true, nil +} + +func sendTransaction(state serverState, transaction *solana.Transaction) (string, error) { + encodedTransaction, err := transaction.ToBase64() + if err != nil { + return "", err + } + requestBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "sendTransaction", + "params": []any{ + encodedTransaction, + map[string]any{ + "encoding": "base64", + "skipPreflight": false, + "preflightCommitment": "processed", + "maxRetries": 3, + }, + }, + }) + if err != nil { + return "", err + } + + response, err := state.httpClient.Post(state.rpcURL, "application/json", bytes.NewReader(requestBody)) + if err != nil { + return "", err + } + defer response.Body.Close() + rawBody, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return "", fmt.Errorf("sendTransaction HTTP %d: %s", response.StatusCode, string(rawBody)) + } + + var payload struct { + Result string `json:"result"` + Error any `json:"error"` + } + if err := json.Unmarshal(rawBody, &payload); err != nil { + return "", err + } + if payload.Error != nil { + return "", fmt.Errorf("sendTransaction RPC error: %v", payload.Error) + } + if payload.Result == "" { + return "", fmt.Errorf("sendTransaction returned empty signature") + } + return payload.Result, nil +} + +func newInteropMux(state serverState) *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(response http.ResponseWriter, _ *http.Request) { + writeJSON(response, http.StatusOK, map[string]any{"ok": true}) + }) + mux.HandleFunc("/capabilities", func(response http.ResponseWriter, _ *http.Request) { + writeJSON(response, http.StatusOK, capabilityPayload("go")) + }) + mux.HandleFunc("/exact", func(response http.ResponseWriter, _ *http.Request) { + writeExactPaymentRequired(response, state) + }) + mux.HandleFunc("/upto", func(response http.ResponseWriter, _ *http.Request) { + writePaymentRequired(response, uptoChallengePayload()) + }) + mux.HandleFunc("/session", func(response http.ResponseWriter, _ *http.Request) { + writeJSON(response, http.StatusPaymentRequired, sessionChallengePayload()) + }) + mux.HandleFunc("/batch-settlement", func(response http.ResponseWriter, _ *http.Request) { + writePaymentRequired(response, batchSettlementChallengePayload()) + }) + mux.HandleFunc("/", func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != defaultResourcePath { + writeJSON(response, http.StatusNotFound, map[string]any{"error": "not_found"}) + return + } + + paymentSignature := request.Header.Get("PAYMENT-SIGNATURE") + if paymentSignature == "" { + writeExactPaymentRequired(response, state) + return + } + + settlement, err := settleExactPayment(state, paymentSignature) + if err != nil { + challenge := exactChallengePayload(state) + encoded, marshalErr := json.Marshal(challenge) + if marshalErr != nil { + panic(marshalErr) + } + writeJSONWithHeaders( + response, + http.StatusPaymentRequired, + map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(encoded)}, + map[string]any{ + "error": "payment_invalid", + "message": err.Error(), + }, + ) + return + } + + writeJSONWithHeaders( + response, + http.StatusOK, + map[string]string{defaultSettlementHeader: settlement}, + map[string]any{ + "ok": true, + "paid": true, + "settlement": map[string]any{ + "success": true, + "transaction": settlement, + "network": state.network, + }, + }, + ) + }) + return mux +} + +func runInteropServer(state serverState, listener net.Listener, signals <-chan os.Signal, readyWriter io.Writer, errWriter io.Writer) error { + server := &http.Server{Handler: newInteropMux(state)} + serveErr := make(chan error, 1) + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + serveErr <- err + } + close(serveErr) + }() + + ready := capabilityPayload("go") + ready["type"] = "ready" + ready["port"] = listener.Addr().(*net.TCPAddr).Port + encoded, err := json.Marshal(ready) + if err != nil { + return err + } + if _, err := fmt.Fprintln(readyWriter, string(encoded)); err != nil { + return err + } + + select { + case <-signals: + if err := server.Close(); err != nil { + fmt.Fprintln(errWriter, err) + return err + } + return nil + case err := <-serveErr: + if err != nil { + fmt.Fprintln(errWriter, err) + } + return err + } +} + +func main() { + state := readState() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + panic(err) + } + + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) + if err := runInteropServer(state, listener, signals, os.Stdout, os.Stderr); err != nil { + os.Exit(1) + } +} diff --git a/go/x402/cmd/interop-server/main_test.go b/go/x402/cmd/interop-server/main_test.go new file mode 100644 index 000000000..6ed6afcfa --- /dev/null +++ b/go/x402/cmd/interop-server/main_test.go @@ -0,0 +1,2272 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/gagliardetto/solana-go" +) + +func TestNormalizeAmountUsesSixMintDecimals(t *testing.T) { + tests := map[string]string{ + "$0.001": "1000", + "0.001 USDC": "1000", + "1": "1000000", + "1.25": "1250000", + } + + for price, expected := range tests { + if actual := normalizeAmount(price); actual != expected { + t.Fatalf("normalizeAmount(%q) = %q, want %q", price, actual, expected) + } + } +} + +func TestNormalizeAmountRejectsMalformedPrices(t *testing.T) { + tests := []string{ + "not-a-price", + "1.0000001", + "1.bad", + } + + for _, price := range tests { + t.Run(price, func(t *testing.T) { + mustPanic(t, func() { + normalizeAmount(price) + }) + }) + } +} + +func TestEnvHelpersAndReadState(t *testing.T) { + privateKey, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + encodedKey, err := json.Marshal([]byte(privateKey)) + if err != nil { + t.Fatal(err) + } + payTo := solana.NewWallet().PublicKey().String() + + t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") + t.Setenv("X402_INTEROP_PAY_TO", payTo) + t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) + t.Setenv("X402_INTEROP_NETWORK", solanaMainnetCAIP2) + t.Setenv("X402_INTEROP_MINT", "USDG") + t.Setenv("X402_INTEROP_PRICE", "$1.25") + t.Setenv("X402_INTEROP_EXTRA_OFFERED_MINTS", " PYUSD, , CASH ") + + state := readState() + if state.rpcURL != "http://rpc.test" || state.network != solanaMainnetCAIP2 { + t.Fatalf("unexpected state: %+v", state) + } + if state.mint != "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH" { + t.Fatalf("expected resolved USDG mainnet mint, got %s", state.mint) + } + if state.payTo != payTo || !state.feePayer.PublicKey().Equals(privateKey.PublicKey()) { + t.Fatalf("readState did not preserve configured keys") + } + if state.amount != "1250000" { + t.Fatalf("amount = %s, want 1250000", state.amount) + } + if len(state.extraOfferedMints) != 2 || + state.extraOfferedMints[0] != "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo" || + state.extraOfferedMints[1] != "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH" { + t.Fatalf("unexpected extra mints: %#v", state.extraOfferedMints) + } + if state.httpClient == nil { + t.Fatal("expected readState to configure an HTTP client") + } + if got := readEnvWithDefault("X402_INTEROP_NETWORK", "fallback"); got != solanaMainnetCAIP2 { + t.Fatalf("readEnvWithDefault configured = %q", got) + } + if got := readEnvWithDefault("X402_INTEROP_MISSING", "fallback"); got != "fallback" { + t.Fatalf("readEnvWithDefault fallback = %q", got) + } + + t.Setenv("X402_INTEROP_REQUIRED_EMPTY", "") + mustPanic(t, func() { + readRequiredEnv("X402_INTEROP_REQUIRED_EMPTY") + }) + mustPanic(t, func() { + keypairFromJSONSecret("[1,2,3]") + }) + mustPanic(t, func() { + keypairFromJSONSecret("{") + }) +} + +func TestJSONWritersAndChallengePayloads(t *testing.T) { + recorder := httptest.NewRecorder() + writeJSON(recorder, http.StatusAccepted, map[string]any{"ok": true}) + if recorder.Code != http.StatusAccepted { + t.Fatalf("status = %d", recorder.Code) + } + if recorder.Header().Get("content-type") != "application/json" { + t.Fatalf("unexpected content type: %s", recorder.Header().Get("content-type")) + } + if strings.TrimSpace(recorder.Body.String()) != `{"ok":true}` { + t.Fatalf("unexpected JSON body: %s", recorder.Body.String()) + } + + recorder = httptest.NewRecorder() + writeJSONWithHeaders(recorder, http.StatusCreated, map[string]string{"x-test": "value"}, map[string]any{"created": true}) + if recorder.Code != http.StatusCreated || recorder.Header().Get("x-test") != "value" { + t.Fatalf("headers/status not written: %d %v", recorder.Code, recorder.Header()) + } + + capabilities := capabilityPayload("go") + if capabilities["implementation"] != "go" || capabilities["role"] != "server" { + t.Fatalf("unexpected capability payload: %#v", capabilities) + } + if got := len(capabilities["capabilities"].([]string)); got != 1 { + t.Fatalf("expected one implemented capability, got %d", got) + } + + state := testServerState(t) + state.memo = "bound-memo" + exact := exactChallengePayload(state) + if exact.X402Version != 2 || exact.Resource["uri"] != defaultResourcePath { + t.Fatalf("unexpected exact challenge: %+v", exact) + } + if exact.Accepts[0].Extra["memo"] != "bound-memo" { + t.Fatalf("expected exact requirement to include memo") + } + if uptoChallengePayload()["x402Version"] != 2 { + t.Fatal("expected x402 upto challenge") + } + if sessionChallengePayload()["intent"] != "session" { + t.Fatal("expected session challenge intent") + } + if batchSettlementChallengePayload()["x402Version"] != 2 { + t.Fatal("expected batch settlement challenge") + } +} + +func TestPaymentRequiredWritersEncodeChallenges(t *testing.T) { + state := testServerState(t) + + recorder := httptest.NewRecorder() + writePaymentRequired(recorder, uptoChallengePayload()) + if recorder.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d", recorder.Code) + } + decoded, err := base64.StdEncoding.DecodeString(recorder.Header().Get("PAYMENT-REQUIRED")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(decoded), `"scheme":"upto"`) { + t.Fatalf("unexpected encoded challenge: %s", string(decoded)) + } + + recorder = httptest.NewRecorder() + writeExactPaymentRequired(recorder, state) + decoded, err = base64.StdEncoding.DecodeString(recorder.Header().Get("PAYMENT-REQUIRED")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(decoded), `"scheme":"exact"`) { + t.Fatalf("unexpected exact challenge: %s", string(decoded)) + } +} + +func TestDefaultTokenProgramForMintHandlesAliasesAndMints(t *testing.T) { + tests := map[string]string{ + " PYUSD ": token2022Program, + "USDG": token2022Program, + "CASH": token2022Program, + "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM": token2022Program, + "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU": defaultTokenProgram, + } + + for mint, want := range tests { + t.Run(mint, func(t *testing.T) { + if got := defaultTokenProgramForMint(mint); got != want { + t.Fatalf("defaultTokenProgramForMint(%q) = %q, want %q", mint, got, want) + } + }) + } +} + +func TestPaymentRequirementMatchesBindsSettlementFields(t *testing.T) { + feePayer := solana.NewWallet().PrivateKey + state := serverState{ + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + payTo: solana.NewWallet().PublicKey().String(), + feePayer: feePayer, + amount: "1000", + } + + requirement := exactRequirement(state) + if !paymentRequirementMatches(requirement, requirement) { + t.Fatal("expected matching requirement to pass") + } + + mutated := requirement + mutated.Extra = map[string]any{ + "decimals": defaultDecimals, + "feePayer": solana.NewWallet().PublicKey().String(), + "tokenProgram": defaultTokenProgram, + } + if paymentRequirementMatches(mutated, requirement) { + t.Fatal("expected fee payer mutation to be rejected") + } +} + +func TestPaymentRequirementMatchesRejectsExactRequirementDrift(t *testing.T) { + feePayer := solana.NewWallet().PrivateKey + state := serverState{ + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + payTo: solana.NewWallet().PublicKey().String(), + feePayer: feePayer, + amount: "1000", + } + + requirement := exactRequirement(state) + tests := map[string]func(paymentRequirement) paymentRequirement{ + "scheme": func(value paymentRequirement) paymentRequirement { + value.Scheme = "upto" + return value + }, + "network": func(value paymentRequirement) paymentRequirement { + value.Network = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + return value + }, + "asset": func(value paymentRequirement) paymentRequirement { + value.Asset = solana.NewWallet().PublicKey().String() + return value + }, + "amount": func(value paymentRequirement) paymentRequirement { + value.Amount = "2000" + return value + }, + "payTo": func(value paymentRequirement) paymentRequirement { + value.PayTo = solana.NewWallet().PublicKey().String() + return value + }, + "maxTimeoutSeconds": func(value paymentRequirement) paymentRequirement { + value.MaxTimeoutSeconds = defaultMaxTimeout + 1 + return value + }, + "extra.tokenProgram": func(value paymentRequirement) paymentRequirement { + value.Extra = cloneExtra(value.Extra) + value.Extra["tokenProgram"] = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + return value + }, + "extra.unexpected": func(value paymentRequirement) paymentRequirement { + value.Extra = cloneExtra(value.Extra) + value.Extra["memo"] = "drift" + return value + }, + } + + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + if paymentRequirementMatches(mutate(requirement), requirement) { + t.Fatalf("expected %s drift to be rejected", name) + } + }) + } +} + +func TestExactChallengeIncludesExtraOfferedMints(t *testing.T) { + feePayer := solana.NewWallet().PrivateKey + state := serverState{ + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + payTo: solana.NewWallet().PublicKey().String(), + feePayer: feePayer, + amount: "1000", + extraOfferedMints: []string{"CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM"}, + } + + challenge := exactChallengePayload(state) + + if len(challenge.Accepts) != 2 { + t.Fatalf("expected primary plus extra mint offers, got %d", len(challenge.Accepts)) + } + if challenge.Accepts[0].Asset != state.mint { + t.Fatalf("expected primary mint first, got %s", challenge.Accepts[0].Asset) + } + if challenge.Accepts[1].Asset != state.extraOfferedMints[0] { + t.Fatalf("expected extra mint second, got %s", challenge.Accepts[1].Asset) + } + if challenge.Accepts[1].Extra["tokenProgram"] != "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" { + t.Fatalf("expected PYUSD offer to use Token-2022, got %v", challenge.Accepts[1].Extra["tokenProgram"]) + } +} + +func TestSettleExactPaymentRejectsMalformedPaymentSignature(t *testing.T) { + state := testServerState(t) + state.memo = "unit-duplicate" + + tests := map[string]string{ + "base64": "not base64", + "json": base64.StdEncoding.EncodeToString([]byte("{")), + } + + for name, header := range tests { + t.Run(name, func(t *testing.T) { + if _, err := settleExactPayment(state, header); err == nil { + t.Fatal("expected malformed payment signature to be rejected") + } + }) + } +} + +func TestSettleExactPaymentRejectsMissingAndInvalidTransactionPayload(t *testing.T) { + state := testServerState(t) + requirement := exactRequirement(state) + tests := map[string]map[string]string{ + "missing": {}, + "invalid": {"transaction": "not a transaction"}, + } + + for name, payload := range tests { + t.Run(name, func(t *testing.T) { + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: payload, + }) + + if _, err := settleExactPayment(state, header); err == nil { + t.Fatal("expected transaction payload to be rejected") + } + }) + } +} + +func TestSettleExactPaymentRejectsVersionAndRequirementMismatch(t *testing.T) { + state := testServerState(t) + requirement := exactRequirement(state) + + versionHeader := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 1, + Accepted: requirement, + Payload: map[string]string{"transaction": "unused"}, + }) + if _, err := settleExactPayment(state, versionHeader); err == nil || err.Error() != "unsupported x402Version: 1" { + t.Fatalf("expected version rejection, got %v", err) + } + + drifted := requirement + drifted.Amount = "999" + driftHeader := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: drifted, + Payload: map[string]string{"transaction": "unused"}, + }) + if _, err := settleExactPayment(state, driftHeader); err == nil || err.Error() != "accepted payment requirement does not match server challenge" { + t.Fatalf("expected requirement mismatch, got %v", err) + } +} + +func successfulSettlementClient(t *testing.T, signature string) *http.Client { + t.Helper() + return &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + body := string(rawBody) + responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + if strings.Contains(body, `"method":"sendTransaction"`) { + responseBody = fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"result":%q}`, signature) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }), + } +} + +func TestSettleExactPaymentAcceptsExtraOfferedMint(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.extraOfferedMints = []string{"CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM"} + state.memo = "extra-mint" + state.httpClient = successfulSettlementClient(t, "extra-mint-settlement") + defer func() { + settlementCache = newDuplicateSettlementCache() + }() + + requirement := exactRequirementForMint(state, state.extraOfferedMints[0]) + transaction := signedTransactionForTest(t, requirement, client) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{ + "transaction": transaction, + }, + }) + + settlement, err := settleExactPayment(state, header) + if err != nil { + t.Fatalf("expected extra offered mint settlement to pass: %v", err) + } + if settlement != "extra-mint-settlement" { + t.Fatalf("settlement = %q", settlement) + } +} + +func TestSettleExactPaymentRejectsDuplicateTransactionPayload(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-duplicate" + sendCalls := 0 + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + body := string(rawBody) + responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + if strings.Contains(body, `"method":"sendTransaction"`) { + sendCalls++ + responseBody = `{"jsonrpc":"2.0","id":1,"result":"unit-settlement"}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }), + } + defer func() { + settlementCache = newDuplicateSettlementCache() + }() + requirement := exactRequirement(state) + transaction := signedTransactionForTest(t, requirement, client) + + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{ + "transaction": transaction, + }, + }) + + if settlement, err := settleExactPayment(state, header); err != nil || settlement != "unit-settlement" { + t.Fatalf("first settlement = %q, %v", settlement, err) + } + if _, err := settleExactPayment(state, header); err == nil || err.Error() != "duplicate_settlement" { + t.Fatalf("expected duplicate_settlement, got %v", err) + } + if sendCalls != 1 { + t.Fatalf("expected one sendTransaction call, got %d", sendCalls) + } +} + +func TestSettleExactPaymentReleasesDuplicateCacheOnTokenAccountFailure(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { + settlementCache = newDuplicateSettlementCache() + }() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-missing-ata" + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(rawBody), `"method":"getAccountInfo"`) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"context":{"slot":1},"value":null}}`)), + }, nil + } + t.Fatalf("unexpected RPC body: %s", string(rawBody)) + return nil, nil + }), + } + requirement := exactRequirement(state) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{ + "transaction": signedTransactionForTest(t, requirement, client), + }, + }) + + if _, err := settleExactPayment(state, header); err == nil || err.Error() != "source token account does not exist" { + t.Fatalf("expected missing source account, got %v", err) + } + if _, err := settleExactPayment(state, header); err == nil || err.Error() != "source token account does not exist" { + t.Fatalf("expected failed settlement to release duplicate cache, got %v", err) + } +} + +func TestVerifyExactTransactionRejectsSpecViolations(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-spec" + requirement := exactRequirement(state) + valid := transactionForTest(t, requirement, client) + + tests := map[string]struct { + mutate func(*solana.Transaction) + want string + }{ + "compute price too high": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[1].Data = computePriceDataForTest(maxComputeUnitPrice + 1) + }, + want: "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high", + }, + "amount mismatch": { + mutate: func(tx *solana.Transaction) { + data := []byte{12} + data = binary.LittleEndian.AppendUint64(data, 999) + data = append(data, byte(defaultDecimals)) + tx.Message.Instructions[2].Data = data + }, + want: "invalid_exact_svm_payload_transaction_amount", + }, + "missing memo": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions = tx.Message.Instructions[:3] + }, + want: "invalid_exact_svm_payload_transaction_memo", + }, + "fee payer instruction account": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[2].Accounts[0] = 0 + }, + want: "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", + }, + "mint mismatch": { + mutate: func(tx *solana.Transaction) { + tx.Message.AccountKeys = append(tx.Message.AccountKeys, solana.NewWallet().PublicKey()) + tx.Message.Instructions[2].Accounts[1] = uint16(len(tx.Message.AccountKeys) - 1) + }, + want: "invalid_exact_svm_payload_transaction_mint", + }, + "decimals mismatch": { + mutate: func(tx *solana.Transaction) { + data := []byte{12} + data = binary.LittleEndian.AppendUint64(data, 1000) + data = append(data, byte(defaultDecimals+1)) + tx.Message.Instructions[2].Data = data + }, + want: "invalid_exact_svm_payload_transaction_decimals", + }, + "memo mismatch": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[3].Data = []byte("wrong") + }, + want: "invalid_exact_svm_payload_transaction_memo", + }, + "unknown fourth instruction": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[3] = compiledInstructionForTest(t, tx, solana.SystemProgramID.String(), nil) + }, + want: "invalid_exact_svm_payload_unknown_fourth_instruction", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + tx := cloneTransactionForTest(t, valid) + test.mutate(tx) + if err := verifyExactTransaction(tx, requirement); err == nil || err.Error() != test.want { + t.Fatalf("expected %q, got %v", test.want, err) + } + }) + } +} + +func TestVerifyExactTransactionRejectsMalformedInstructionShapes(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-shapes" + requirement := exactRequirement(state) + valid := transactionForTest(t, requirement, client) + + tests := map[string]struct { + mutate func(*solana.Transaction) + want string + }{ + "legacy transaction": { + mutate: func(tx *solana.Transaction) { + tx.Message.SetVersion(solana.MessageVersionLegacy) + }, + want: "payment transaction must be versioned", + }, + "too few instructions": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions = tx.Message.Instructions[:2] + }, + want: "invalid_exact_svm_payload_transaction_instructions_length", + }, + "bad compute limit": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[0].Data = []byte{2} + }, + want: "invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction", + }, + "bad compute price": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[1].Data = []byte{3} + }, + want: "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction", + }, + "bad transfer program": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[2] = compiledInstructionForTest(t, tx, solana.SystemProgramID.String(), []byte{12}) + }, + want: "invalid_exact_svm_payload_transaction_transfer_program", + }, + "bad transfer data": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[2].Data = []byte{12} + }, + want: "invalid_exact_svm_payload_transaction_transfer_checked", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + tx := cloneTransactionForTest(t, valid) + test.mutate(tx) + if err := verifyExactTransaction(tx, requirement); err == nil || err.Error() != test.want { + t.Fatalf("expected %q, got %v", test.want, err) + } + }) + } +} + +func TestParseTransferCheckedInstructionRejectsInvalidAccountIndexes(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-transfer-indexes" + tx := transactionForTest(t, exactRequirement(state), client) + instruction := tx.Message.Instructions[2] + + tests := map[string]int{ + "source": 0, + "mint": 1, + "destination": 2, + "authority": 3, + } + + for name, accountIndex := range tests { + t.Run(name, func(t *testing.T) { + mutated := instruction + mutated.Accounts = append([]uint16(nil), instruction.Accounts...) + mutated.Accounts[accountIndex] = uint16(len(tx.Message.AccountKeys)) + if _, err := parseTransferCheckedInstruction(tx, mutated); err == nil { + t.Fatal("expected invalid account index") + } + }) + } +} + +func TestVerifyExactTransactionRejectsMalformedRequirementFields(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-requirement-fields" + requirement := exactRequirement(state) + valid := transactionForTest(t, requirement, client) + + tests := map[string]struct { + mutate func(paymentRequirement) paymentRequirement + want string + }{ + "fee payer": { + mutate: func(value paymentRequirement) paymentRequirement { + value.Extra = cloneExtra(value.Extra) + value.Extra["feePayer"] = "not-base58" + return value + }, + want: "invalid feePayer:", + }, + "asset": { + mutate: func(value paymentRequirement) paymentRequirement { + value.Asset = "not-base58" + return value + }, + want: "invalid asset:", + }, + "amount": { + mutate: func(value paymentRequirement) paymentRequirement { + value.Amount = "not-int" + return value + }, + want: "invalid amount:", + }, + "payTo": { + mutate: func(value paymentRequirement) paymentRequirement { + value.PayTo = "not-base58" + return value + }, + want: "invalid payTo:", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + err := verifyExactTransaction(cloneTransactionForTest(t, valid), test.mutate(requirement)) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("expected %q, got %v", test.want, err) + } + }) + } +} + +func TestVerifyExactTransactionAllowsOptionalLighthouseBeforeMemo(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-lighthouse" + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + + tx.Message.Instructions = append( + tx.Message.Instructions[:3], + append( + []solana.CompiledInstruction{compiledInstructionForTest(t, tx, lighthouseProgram, []byte{9, 0})}, + tx.Message.Instructions[3:]..., + )..., + ) + + if err := verifyExactTransaction(tx, requirement); err != nil { + t.Fatalf("expected lighthouse before memo to be accepted, got %v", err) + } +} + +func TestVerifyExactTransactionAllowsValidDestinationATACreateInstruction(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-create-ata" + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) + if err != nil { + t.Fatal(err) + } + payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) + + tx.Message.Instructions = append( + tx.Message.Instructions[:3], + append( + []solana.CompiledInstruction{ + compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + client.PublicKey(), + transfer.destination, + payTo, + transfer.mint, + solana.SystemProgramID, + transfer.tokenProgram, + }, []byte{1}), + }, + tx.Message.Instructions[3:]..., + )..., + ) + + if err := verifyExactTransaction(tx, requirement); err != nil { + t.Fatalf("expected valid destination ATA create instruction to be accepted, got %v", err) + } +} + +func TestValidDestinationATACreateInstructionRejectsMalformedCreateInstructions(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-create-ata-invalid" + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) + if err != nil { + t.Fatal(err) + } + payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) + validAccounts := []solana.PublicKey{ + client.PublicKey(), + transfer.destination, + payTo, + transfer.mint, + solana.SystemProgramID, + transfer.tokenProgram, + } + + tests := map[string]solana.CompiledInstruction{ + "bad data": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, validAccounts, []byte{2}), + "too many data bytes": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, validAccounts, []byte{0, 0}), + "too few accounts": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, validAccounts[:5], nil), + "wrong associated account": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + client.PublicKey(), + solana.NewWallet().PublicKey(), + payTo, + transfer.mint, + solana.SystemProgramID, + transfer.tokenProgram, + }, nil), + "wrong wallet": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + client.PublicKey(), + transfer.destination, + solana.NewWallet().PublicKey(), + transfer.mint, + solana.SystemProgramID, + transfer.tokenProgram, + }, nil), + "wrong mint": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + client.PublicKey(), + transfer.destination, + payTo, + solana.NewWallet().PublicKey(), + solana.SystemProgramID, + transfer.tokenProgram, + }, nil), + "wrong system program": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + client.PublicKey(), + transfer.destination, + payTo, + transfer.mint, + solana.NewWallet().PublicKey(), + transfer.tokenProgram, + }, nil), + "wrong token program": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + client.PublicKey(), + transfer.destination, + payTo, + transfer.mint, + solana.SystemProgramID, + solana.NewWallet().PublicKey(), + }, nil), + } + + for name, instruction := range tests { + t.Run(name, func(t *testing.T) { + if validDestinationATACreateInstruction(tx, instruction, requirement, transfer) { + t.Fatal("expected malformed destination ATA create instruction to be rejected") + } + }) + } +} + +func TestVerifyTokenAccountsExistSkipsMissingDestinationWhenCreateATAIsPresent(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-create-ata-exists" + accountInfoCalls := 0 + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rawBody), `"method":"getAccountInfo"`) { + t.Fatalf("unexpected RPC body: %s", string(rawBody)) + } + accountInfoCalls++ + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"context":{"slot":1},"value":{"data":["","base64"]}}}`)), + }, nil + }), + } + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) + if err != nil { + t.Fatal(err) + } + payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) + tx.Message.Instructions = append( + tx.Message.Instructions[:3], + append( + []solana.CompiledInstruction{ + compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + client.PublicKey(), + transfer.destination, + payTo, + transfer.mint, + solana.SystemProgramID, + transfer.tokenProgram, + }, nil), + }, + tx.Message.Instructions[3:]..., + )..., + ) + + if err := verifyTokenAccountsExist(state, tx, requirement); err != nil { + t.Fatalf("expected create ATA instruction to satisfy destination existence policy, got %v", err) + } + if accountInfoCalls != 1 { + t.Fatalf("expected only source account lookup, got %d", accountInfoCalls) + } +} + +func TestVerifyTokenAccountsExistRejectsMissingDestinationWithoutCreateATA(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-missing-destination" + accountInfoCalls := 0 + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + if _, err := io.ReadAll(request.Body); err != nil { + t.Fatal(err) + } + accountInfoCalls++ + body := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + if accountInfoCalls == 2 { + body = `{"jsonrpc":"2.0","id":1,"result":{"value":null}}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }), + } + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + + if err := verifyTokenAccountsExist(state, tx, requirement); err == nil || err.Error() != "destination token account does not exist" { + t.Fatalf("expected missing destination account, got %v", err) + } + if accountInfoCalls != 2 { + t.Fatalf("expected source and destination lookups, got %d", accountInfoCalls) + } +} + +func TestVerifyTokenAccountsExistAcceptsExistingSourceAndDestination(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-existing-atas" + accountInfoCalls := 0 + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rawBody), `"method":"getAccountInfo"`) { + t.Fatalf("unexpected RPC body: %s", string(rawBody)) + } + accountInfoCalls++ + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}`)), + }, nil + }), + } + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + + if err := verifyTokenAccountsExist(state, tx, requirement); err != nil { + t.Fatalf("expected existing source and destination accounts, got %v", err) + } + if accountInfoCalls != 2 { + t.Fatalf("expected source and destination lookups, got %d", accountInfoCalls) + } +} + +func TestVerifyExactTransactionAllowsMissingMemoWhenRequirementDoesNotBindMemo(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "" + requirement := exactRequirement(state) + builderRequirement := requirement + builderRequirement.Extra = cloneExtra(requirement.Extra) + builderRequirement.Extra["memo"] = "builder-memo" + tx := transactionForTest(t, builderRequirement, client) + tx.Message.Instructions = tx.Message.Instructions[:3] + + if err := verifyExactTransaction(tx, requirement); err != nil { + t.Fatalf("expected missing memo to be accepted when requirement has no memo, got %v", err) + } +} + +func TestVerifyOptionalInstructionsRejectsMemoViolations(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-memo" + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) + if err != nil { + t.Fatal(err) + } + + tests := map[string]struct { + requirement paymentRequirement + instructions []solana.CompiledInstruction + want string + }{ + "empty unbound memo": { + requirement: func() paymentRequirement { + value := requirement + value.Extra = cloneExtra(value.Extra) + delete(value.Extra, "memo") + return value + }(), + instructions: []solana.CompiledInstruction{compiledInstructionForTest(t, tx, memoProgramID.String(), nil)}, + want: "invalid_exact_svm_payload_transaction_memo", + }, + "oversized memo": { + requirement: func() paymentRequirement { + value := requirement + value.Extra = cloneExtra(value.Extra) + delete(value.Extra, "memo") + return value + }(), + instructions: []solana.CompiledInstruction{compiledInstructionForTest(t, tx, memoProgramID.String(), []byte(strings.Repeat("x", maxMemoBytes+1)))}, + want: "extra.memo exceeds maximum 256 bytes", + }, + "duplicate bound memo": { + requirement: requirement, + instructions: []solana.CompiledInstruction{ + compiledInstructionForTest(t, tx, memoProgramID.String(), []byte("unit-memo")), + compiledInstructionForTest(t, tx, memoProgramID.String(), []byte("unit-memo")), + }, + want: "invalid_exact_svm_payload_transaction_memo", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if err := verifyOptionalInstructions(tx, test.instructions, test.requirement, transfer); err == nil || err.Error() != test.want { + t.Fatalf("expected %q, got %v", test.want, err) + } + }) + } +} + +func TestDuplicateSettlementCachePrunesExpiredEntries(t *testing.T) { + cache := newDuplicateSettlementCache() + now := time.Unix(1_700_000_000, 0) + cache.now = func() time.Time { + return now + } + cache.entries["expired"] = now.Add(-(duplicateCacheTTL + time.Second)) + cache.entries["fresh"] = now + + if !cache.claim("new") { + t.Fatal("expected new key to be claimed") + } + if _, ok := cache.entries["expired"]; ok { + t.Fatal("expected expired cache entry to be pruned") + } + if _, ok := cache.entries["fresh"]; !ok { + t.Fatal("expected fresh cache entry to survive pruning") + } + if !cache.claim("expired") { + t.Fatal("expected pruned key to be claimable") + } + if cache.claim("fresh") { + t.Fatal("expected fresh duplicate to be rejected") + } +} + +func TestAccountExistsHandlesRPCResponses(t *testing.T) { + account := solana.NewWallet().PublicKey() + tests := map[string]struct { + status int + body string + exists bool + err bool + }{ + "exists": { + status: http.StatusOK, + body: `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}`, + exists: true, + }, + "missing null value": { + status: http.StatusOK, + body: `{"jsonrpc":"2.0","id":1,"result":{"value":null}}`, + exists: false, + }, + "missing result": { + status: http.StatusOK, + body: `{"jsonrpc":"2.0","id":1}`, + exists: false, + }, + "rpc error": { + status: http.StatusOK, + body: `{"jsonrpc":"2.0","id":1,"error":{"message":"nope"}}`, + err: true, + }, + "http error": { + status: http.StatusBadGateway, + body: `bad gateway`, + err: true, + }, + "invalid json": { + status: http.StatusOK, + body: `{`, + err: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + state := testServerState(t) + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rawBody), `"method":"getAccountInfo"`) || !strings.Contains(string(rawBody), account.String()) { + t.Fatalf("unexpected accountExists RPC body: %s", string(rawBody)) + } + return &http.Response{ + StatusCode: test.status, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(test.body)), + }, nil + }), + } + + exists, err := accountExists(state, account) + if test.err { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatal(err) + } + if exists != test.exists { + t.Fatalf("exists = %v, want %v", exists, test.exists) + } + }) + } +} + +func TestAccountExistsReturnsTransportErrors(t *testing.T) { + state := testServerState(t) + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return nil, errors.New("rpc unavailable") + }), + } + + if _, err := accountExists(state, solana.NewWallet().PublicKey()); err == nil { + t.Fatal("expected transport error") + } +} + +func TestSendTransactionHandlesRPCResponses(t *testing.T) { + baseState := testServerState(t) + baseState.memo = "unit-send" + tx := transactionForTest(t, exactRequirement(baseState), solana.NewWallet().PrivateKey) + tests := map[string]struct { + status int + body string + want string + err bool + }{ + "success": { + status: http.StatusOK, + body: `{"jsonrpc":"2.0","id":1,"result":"unit-signature"}`, + want: "unit-signature", + }, + "http error": { + status: http.StatusBadGateway, + body: `bad gateway`, + err: true, + }, + "invalid json": { + status: http.StatusOK, + body: `{`, + err: true, + }, + "rpc error": { + status: http.StatusOK, + body: `{"jsonrpc":"2.0","id":1,"error":{"message":"nope"}}`, + err: true, + }, + "empty signature": { + status: http.StatusOK, + body: `{"jsonrpc":"2.0","id":1,"result":""}`, + err: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + state := testServerState(t) + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rawBody), `"method":"sendTransaction"`) || !strings.Contains(string(rawBody), `"maxRetries":3`) { + t.Fatalf("unexpected sendTransaction RPC body: %s", string(rawBody)) + } + return &http.Response{ + StatusCode: test.status, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(test.body)), + }, nil + }), + } + + got, err := sendTransaction(state, tx) + if test.err { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("sendTransaction = %q, want %q", got, test.want) + } + }) + } +} + +func TestSendTransactionReturnsTransportErrors(t *testing.T) { + state := testServerState(t) + state.memo = "unit-send-transport" + tx := transactionForTest(t, exactRequirement(state), solana.NewWallet().PrivateKey) + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return nil, errors.New("rpc unavailable") + }), + } + + if _, err := sendTransaction(state, tx); err == nil { + t.Fatal("expected transport error") + } +} + +func TestAccountAtRejectsInvalidIndexes(t *testing.T) { + state := testServerState(t) + state.memo = "unit-index" + tx := transactionForTest(t, exactRequirement(state), solana.NewWallet().PrivateKey) + if _, err := accountAt(tx, uint16(len(tx.Message.AccountKeys))); err == nil { + t.Fatal("expected invalid account index") + } + if _, err := programID(tx, solana.CompiledInstruction{ProgramIDIndex: uint16(len(tx.Message.AccountKeys))}); err == nil { + t.Fatal("expected invalid program index") + } +} + +func TestInteropMuxRoutesHealthCapabilitiesAndChallenges(t *testing.T) { + state := testServerState(t) + mux := newInteropMux(state) + + tests := map[string]struct { + path string + status int + header string + bodySearch string + }{ + "health": { + path: "/health", + status: http.StatusOK, + bodySearch: `"ok":true`, + }, + "capabilities": { + path: "/capabilities", + status: http.StatusOK, + bodySearch: `"implementation":"go"`, + }, + "exact challenge": { + path: "/exact", + status: http.StatusPaymentRequired, + header: "PAYMENT-REQUIRED", + bodySearch: `"payment_required"`, + }, + "upto challenge": { + path: "/upto", + status: http.StatusPaymentRequired, + header: "PAYMENT-REQUIRED", + bodySearch: `"payment_required"`, + }, + "session challenge": { + path: "/session", + status: http.StatusPaymentRequired, + bodySearch: `"intent":"session"`, + }, + "batch settlement challenge": { + path: "/batch-settlement", + status: http.StatusPaymentRequired, + header: "PAYMENT-REQUIRED", + bodySearch: `"payment_required"`, + }, + "protected challenge": { + path: defaultResourcePath, + status: http.StatusPaymentRequired, + header: "PAYMENT-REQUIRED", + bodySearch: `"payment_required"`, + }, + "not found": { + path: "/missing", + status: http.StatusNotFound, + bodySearch: `"not_found"`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, test.path, nil) + recorder := httptest.NewRecorder() + + mux.ServeHTTP(recorder, request) + + if recorder.Code != test.status { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, test.status, recorder.Body.String()) + } + if test.header != "" && recorder.Header().Get(test.header) == "" { + t.Fatalf("expected %s header", test.header) + } + if test.bodySearch != "" && !strings.Contains(recorder.Body.String(), test.bodySearch) { + t.Fatalf("body %s does not contain %s", recorder.Body.String(), test.bodySearch) + } + }) + } +} + +func TestInteropMuxProtectedRouteRejectsInvalidPayment(t *testing.T) { + state := testServerState(t) + mux := newInteropMux(state) + request := httptest.NewRequest(http.MethodGet, defaultResourcePath, nil) + request.Header.Set("PAYMENT-SIGNATURE", "not base64") + recorder := httptest.NewRecorder() + + mux.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusPaymentRequired) + } + if recorder.Header().Get("PAYMENT-REQUIRED") == "" { + t.Fatal("expected refreshed payment challenge") + } + if !strings.Contains(recorder.Body.String(), `"payment_invalid"`) { + t.Fatalf("expected payment_invalid body, got %s", recorder.Body.String()) + } +} + +func TestInteropMuxProtectedRouteSettlesValidPayment(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { + settlementCache = newDuplicateSettlementCache() + }() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-mux-settle" + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + body := string(rawBody) + responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + if strings.Contains(body, `"method":"sendTransaction"`) { + responseBody = `{"jsonrpc":"2.0","id":1,"result":"unit-mux-settlement"}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }), + } + requirement := exactRequirement(state) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{ + "transaction": signedTransactionForTest(t, requirement, client), + }, + }) + mux := newInteropMux(state) + request := httptest.NewRequest(http.MethodGet, defaultResourcePath, nil) + request.Header.Set("PAYMENT-SIGNATURE", header) + recorder := httptest.NewRecorder() + + mux.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + if recorder.Header().Get(defaultSettlementHeader) != "unit-mux-settlement" { + t.Fatalf("settlement header = %q", recorder.Header().Get(defaultSettlementHeader)) + } + if !strings.Contains(recorder.Body.String(), `"paid":true`) { + t.Fatalf("expected paid response, got %s", recorder.Body.String()) + } +} + +func TestRunInteropServerEmitsReadyAndStopsOnSignal(t *testing.T) { + state := testServerState(t) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + signals := make(chan os.Signal, 1) + ready := newSyncBuffer() + errors := newSyncBuffer() + done := make(chan error, 1) + + go func() { + done <- runInteropServer(state, listener, signals, ready, errors) + }() + + baseURL := "http://" + listener.Addr().String() + deadline := time.Now().Add(2 * time.Second) + for { + response, err := http.Get(baseURL + "/health") + if err == nil { + _ = response.Body.Close() + if response.StatusCode == http.StatusOK { + break + } + } + if time.Now().After(deadline) { + t.Fatalf("server did not become ready; ready=%s errors=%s lastErr=%v", ready.String(), errors.String(), err) + } + time.Sleep(10 * time.Millisecond) + } + + var payload map[string]any + if err := json.Unmarshal(bytes.TrimSpace(ready.Bytes()), &payload); err != nil { + t.Fatalf("decode ready payload %q: %v", ready.String(), err) + } + if payload["type"] != "ready" || payload["implementation"] != "go" { + t.Fatalf("unexpected ready payload: %#v", payload) + } + if _, ok := payload["port"].(float64); !ok { + t.Fatalf("ready payload missing port: %#v", payload) + } + + signals <- syscall.SIGTERM + select { + case err := <-done: + if err != nil { + t.Fatalf("runInteropServer returned %v; errors=%s", err, errors.String()) + } + case <-time.After(2 * time.Second): + t.Fatal("server did not stop after signal") + } +} + +// syncBuffer wraps bytes.Buffer with a mutex so the test goroutine can read +// the buffer concurrently with the server goroutine writing the ready line and +// stderr without triggering -race warnings. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func newSyncBuffer() *syncBuffer { return &syncBuffer{} } + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) Bytes() []byte { + b.mu.Lock() + defer b.mu.Unlock() + return append([]byte(nil), b.buf.Bytes()...) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func TestRunInteropServerReturnsServeErrors(t *testing.T) { + state := testServerState(t) + signals := make(chan os.Signal) + var ready bytes.Buffer + var errors bytes.Buffer + + err := runInteropServer(state, failingListener{}, signals, &ready, &errors) + + if err == nil || !strings.Contains(err.Error(), "listener failed") { + t.Fatalf("expected listener failure, got %v", err) + } + if ready.String() == "" { + t.Fatal("expected ready payload before listener failure") + } + if !strings.Contains(errors.String(), "listener failed") { + t.Fatalf("expected error writer to receive listener failure, got %q", errors.String()) + } +} + +func TestMainPanicsWhenRequiredEnvMissing(t *testing.T) { + mustPanic(t, main) +} + +// TestVerifyExactTransactionAttackRegressions covers MPP §19.5 fee-payer drain +// attacks: managed fee-payer (server co-signs) must never become a token source +// or transfer authority, must not appear in any extra instruction, must not be +// reassigned via a tampered details.fee_payer, and must not be moved into a +// signer slot beyond the fee-payer (index 0) position. +func TestVerifyExactTransactionAttackRegressions(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "attack-regression" + requirement := exactRequirement(state) + feePayer := state.feePayer.PublicKey() + mint := solana.MustPublicKeyFromBase58(requirement.Asset) + feePayerATA, _, err := solana.FindAssociatedTokenAddressWithProgram(feePayer, mint, solana.MustPublicKeyFromBase58(defaultTokenProgram)) + if err != nil { + t.Fatal(err) + } + + // Positive control: an unmodified happy-path transaction must verify. + valid := transactionForTest(t, requirement, client) + if err := verifyExactTransaction(valid, requirement); err != nil { + t.Fatalf("positive control failed: %v", err) + } + + tests := map[string]struct { + mutate func(*solana.Transaction, paymentRequirement) paymentRequirement + wantErrFrag string + }{ + "DRAIN: SystemProgram.Transfer from fee-payer in optional slot": { + mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { + // Replace memo (slot 3) with a SystemProgram.Transfer touching fee-payer. + attacker := solana.NewWallet().PublicKey() + tx.Message.Instructions[3] = compiledInstructionWithAccountsForTest( + t, tx, solana.SystemProgramID, + []solana.PublicKey{feePayer, attacker}, + []byte{2, 0, 0, 0, 0xff, 0, 0, 0, 0, 0, 0, 0}, + ) + return req + }, + // Accepted rejection paths: fee-payer-touch guard OR unknown-optional-instruction guard. + wantErrFrag: "invalid_exact_svm_payload", + }, + "SPL DRAIN: transferChecked from fee-payer ATA in optional slot": { + mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { + attackerATA := solana.NewWallet().PublicKey() + data := []byte{12} + data = binary.LittleEndian.AppendUint64(data, 1) + data = append(data, byte(defaultDecimals)) + tx.Message.Instructions[3] = compiledInstructionWithAccountsForTest( + t, tx, solana.TokenProgramID, + []solana.PublicKey{feePayerATA, mint, attackerATA, feePayer}, + data, + ) + return req + }, + // Accepted rejection paths: fee-payer-touch guard OR unknown-optional-instruction guard. + wantErrFrag: "invalid_exact_svm_payload", + }, + "SLOT: fee-payer at signer slot 1 as transfer authority": { + mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { + // Replace authority account on the transferChecked with fee-payer. + accounts := append([]uint16(nil), tx.Message.Instructions[2].Accounts...) + feePayerIndex := -1 + for index, key := range tx.Message.AccountKeys { + if key.Equals(feePayer) { + feePayerIndex = index + break + } + } + if feePayerIndex == -1 { + t.Fatal("fee payer not in account keys") + } + accounts[3] = uint16(feePayerIndex) + tx.Message.Instructions[2].Accounts = accounts + return req + }, + wantErrFrag: "fee_payer_transferring_funds", + }, + "SLOT: fee-payer as transfer source ATA": { + mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { + // Repoint transferChecked.source to the fee-payer's own ATA. + feePayerIndex := -1 + for index, key := range tx.Message.AccountKeys { + if key.Equals(feePayer) { + feePayerIndex = index + break + } + } + if feePayerIndex == -1 { + t.Fatal("fee payer not in account keys") + } + // Add fee-payer ATA as a new account key and use it as source. + tx.Message.AccountKeys = append(tx.Message.AccountKeys, feePayerATA) + ataIndex := uint16(len(tx.Message.AccountKeys) - 1) + accounts := append([]uint16(nil), tx.Message.Instructions[2].Accounts...) + accounts[0] = ataIndex + accounts[3] = uint16(feePayerIndex) // authority = fee-payer + tx.Message.Instructions[2].Accounts = accounts + return req + }, + wantErrFrag: "fee_payer_transferring_funds", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + tx := cloneTransactionForTest(t, valid) + req := requirement + req.Extra = cloneExtra(requirement.Extra) + mutated := test.mutate(tx, req) + err := verifyExactTransaction(tx, mutated) + if err == nil { + t.Fatalf("expected attack to be rejected") + } + if !strings.Contains(err.Error(), test.wantErrFrag) { + t.Fatalf("error %q does not contain %q", err.Error(), test.wantErrFrag) + } + }) + } +} + +// TestSettleExactPaymentRejectsForeignMessageFeePayer covers Codex finding #1: +// the transaction's message fee-payer (account key 0) must equal the server's +// configured fee-payer before the facilitator co-signs. Otherwise a malicious +// client could pick a different message payer and the facilitator's presence +// in the signer set would drain its SOL. +func TestSettleExactPaymentRejectsForeignMessageFeePayer(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { settlementCache = newDuplicateSettlementCache() }() + + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "foreign-fee-payer" + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + + // Swap account key 0 (message fee-payer) for a foreign pubkey. + foreign := solana.NewWallet().PublicKey() + tx.Message.AccountKeys[0] = foreign + encoded, err := tx.ToBase64() + if err != nil { + t.Fatal(err) + } + + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{"transaction": encoded}, + }) + + if _, err := settleExactPayment(state, header); err == nil || + !strings.Contains(err.Error(), "fee_payer") { + t.Fatalf("expected foreign message fee-payer rejection, got %v", err) + } +} + +// TestSettleExactPaymentRejectsTamperedFeePayer covers MPP §19.5: an attacker +// presenting an envelope where details.feePayer (Extra["feePayer"]) is rebound +// to a non-server pubkey must be rejected at the requirement-match stage so +// that the server-co-signing context pubkey cannot be substituted by the +// client envelope. +func TestSettleExactPaymentRejectsTamperedFeePayer(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { settlementCache = newDuplicateSettlementCache() }() + + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "tampered-fee-payer" + requirement := exactRequirement(state) + transaction := signedTransactionForTest(t, requirement, client) + + tampered := requirement + tampered.Extra = cloneExtra(requirement.Extra) + tampered.Extra["feePayer"] = solana.NewWallet().PublicKey().String() + + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: tampered, + Payload: map[string]string{"transaction": transaction}, + }) + + if _, err := settleExactPayment(state, header); err == nil || + !strings.Contains(err.Error(), "does not match server challenge") { + t.Fatalf("expected tampered fee-payer to be rejected, got %v", err) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +type failingListener struct{} + +func (failingListener) Accept() (net.Conn, error) { + return nil, errors.New("listener failed") +} + +func (failingListener) Close() error { + return nil +} + +func (failingListener) Addr() net.Addr { + return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0} +} + +func cloneExtra(extra map[string]any) map[string]any { + cloned := make(map[string]any, len(extra)) + for key, value := range extra { + cloned[key] = value + } + return cloned +} + +func testServerState(t *testing.T) serverState { + t.Helper() + feePayer, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + return serverState{ + rpcURL: "http://127.0.0.1:8899", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + payTo: solana.NewWallet().PublicKey().String(), + feePayer: feePayer, + amount: "1000", + httpClient: &http.Client{}, + } +} + +func encodePaymentSignatureForTest(t *testing.T, envelope paymentSignatureEnvelope) string { + t.Helper() + encoded, err := json.Marshal(envelope) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(encoded) +} + +func signedTransactionForTest(t *testing.T, requirement paymentRequirement, client solana.PrivateKey) string { + t.Helper() + tx := transactionForTest(t, requirement, client) + encoded, err := tx.ToBase64() + if err != nil { + t.Fatal(err) + } + return encoded +} + +func transactionForTest(t *testing.T, requirement paymentRequirement, client solana.PrivateKey) *solana.Transaction { + t.Helper() + feePayer, err := solana.PublicKeyFromBase58(requirement.Extra["feePayer"].(string)) + if err != nil { + t.Fatal(err) + } + mint, err := solana.PublicKeyFromBase58(requirement.Asset) + if err != nil { + t.Fatal(err) + } + payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) + if err != nil { + t.Fatal(err) + } + tokenProgram, err := solana.PublicKeyFromBase58(requirement.Extra["tokenProgram"].(string)) + if err != nil { + t.Fatal(err) + } + source, _, err := solana.FindAssociatedTokenAddressWithProgram(client.PublicKey(), mint, tokenProgram) + if err != nil { + t.Fatal(err) + } + destination, _, err := solana.FindAssociatedTokenAddressWithProgram(payTo, mint, tokenProgram) + if err != nil { + t.Fatal(err) + } + amount, err := strconv.ParseUint(requirement.Amount, 10, 64) + if err != nil { + t.Fatal(err) + } + transferData := []byte{12} + transferData = binary.LittleEndian.AppendUint64(transferData, amount) + transferData = append(transferData, byte(defaultDecimals)) + + tx, err := solana.NewTransaction( + []solana.Instruction{ + computeLimitInstructionForTest(20_000), + computePriceInstructionForTest(1), + solana.NewInstruction( + tokenProgram, + solana.AccountMetaSlice{ + solana.Meta(source).WRITE(), + solana.Meta(mint), + solana.Meta(destination).WRITE(), + solana.Meta(client.PublicKey()).SIGNER(), + }, + transferData, + ), + solana.NewInstruction(memoProgramID, nil, []byte(requirement.Extra["memo"].(string))), + }, + solana.Hash{}, + solana.TransactionPayer(feePayer), + ) + if err != nil { + t.Fatal(err) + } + tx.Message.SetVersion(solana.MessageVersionV0) + if _, err := tx.PartialSign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(client.PublicKey()) { + return &client + } + return nil + }); err != nil { + t.Fatal(err) + } + return tx +} + +func cloneTransactionForTest(t *testing.T, tx *solana.Transaction) *solana.Transaction { + t.Helper() + encoded, err := tx.ToBase64() + if err != nil { + t.Fatal(err) + } + cloned, err := solana.TransactionFromBase64(encoded) + if err != nil { + t.Fatal(err) + } + return cloned +} + +func computeLimitInstructionForTest(units uint32) solana.Instruction { + data := []byte{2} + data = binary.LittleEndian.AppendUint32(data, units) + return solana.NewInstruction(computeBudgetProgramID, nil, data) +} + +func computePriceInstructionForTest(microLamports uint64) solana.Instruction { + return solana.NewInstruction(computeBudgetProgramID, nil, computePriceDataForTest(microLamports)) +} + +func computePriceDataForTest(microLamports uint64) []byte { + data := []byte{3} + return binary.LittleEndian.AppendUint64(data, microLamports) +} + +func compiledInstructionForTest(t *testing.T, tx *solana.Transaction, program string, data []byte) solana.CompiledInstruction { + t.Helper() + programKey := solana.MustPublicKeyFromBase58(program) + return compiledInstructionWithAccountsForTest(t, tx, programKey, nil, data) +} + +func compiledInstructionWithAccountsForTest(t *testing.T, tx *solana.Transaction, programKey solana.PublicKey, accounts []solana.PublicKey, data []byte) solana.CompiledInstruction { + t.Helper() + programIndex := -1 + for index, key := range tx.Message.AccountKeys { + if key.Equals(programKey) { + programIndex = index + break + } + } + if programIndex == -1 { + tx.Message.AccountKeys = append(tx.Message.AccountKeys, programKey) + programIndex = len(tx.Message.AccountKeys) - 1 + } + accountIndexes := make([]uint16, 0, len(accounts)) + for _, account := range accounts { + accountIndex := -1 + for index, key := range tx.Message.AccountKeys { + if key.Equals(account) { + accountIndex = index + break + } + } + if accountIndex == -1 { + tx.Message.AccountKeys = append(tx.Message.AccountKeys, account) + accountIndex = len(tx.Message.AccountKeys) - 1 + } + accountIndexes = append(accountIndexes, uint16(accountIndex)) + } + return solana.CompiledInstruction{ + ProgramIDIndex: uint16(programIndex), + Accounts: accountIndexes, + Data: data, + } +} + +func TestResolveMintAlias(t *testing.T) { + tests := []struct { + name string + input string + network string + want string + wantErr bool + }{ + {name: "USDG mainnet alias", input: "USDG", network: solanaMainnetCAIP2, want: "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH"}, + {name: "USDG devnet alias", input: "usdg", network: solanaDevnetCAIP2, want: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7"}, + {name: "PYUSD mainnet alias", input: "PYUSD", network: solanaMainnetCAIP2, want: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo"}, + {name: "PYUSD devnet alias", input: "pyusd", network: solanaDevnetCAIP2, want: "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM"}, + {name: "CASH mainnet alias", input: "CASH", network: solanaMainnetCAIP2, want: "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH"}, + {name: "USDC devnet alias", input: " usdc ", network: solanaDevnetCAIP2, want: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"}, + {name: "passthrough base58 mint", input: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", network: solanaDevnetCAIP2, want: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"}, + {name: "CASH has no devnet mint", input: "CASH", network: solanaDevnetCAIP2, wantErr: true}, + {name: "unknown alias", input: "WEIRDO", network: solanaMainnetCAIP2, wantErr: true}, + {name: "empty input", input: " ", network: solanaMainnetCAIP2, wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := resolveMintAlias(test.input, test.network) + if test.wantErr { + if err == nil { + t.Fatalf("expected error for %q on %q, got %q", test.input, test.network, got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != test.want { + t.Fatalf("resolveMintAlias(%q,%q) = %q, want %q", test.input, test.network, got, test.want) + } + }) + } +} + +func TestReadStateResolvesMintAliases(t *testing.T) { + privateKey, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + encodedKey, err := json.Marshal([]byte(privateKey)) + if err != nil { + t.Fatal(err) + } + payTo := solana.NewWallet().PublicKey().String() + + t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") + t.Setenv("X402_INTEROP_PAY_TO", payTo) + t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) + t.Setenv("X402_INTEROP_NETWORK", solanaDevnetCAIP2) + t.Setenv("X402_INTEROP_MINT", "PYUSD") + t.Setenv("X402_INTEROP_EXTRA_OFFERED_MINTS", "USDG, USDC") + + state := readState() + + challenge := exactChallengePayload(state) + if len(challenge.Accepts) != 3 { + t.Fatalf("expected 3 challenge entries, got %d", len(challenge.Accepts)) + } + if challenge.Accepts[0].Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { + t.Fatalf("primary Asset = %q, expected resolved PYUSD devnet mint", challenge.Accepts[0].Asset) + } + if _, err := solana.PublicKeyFromBase58(challenge.Accepts[0].Asset); err != nil { + t.Fatalf("primary Asset is not valid base58: %v", err) + } + if challenge.Accepts[1].Asset != "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7" { + t.Fatalf("extra[0] Asset = %q, expected resolved USDG devnet mint", challenge.Accepts[1].Asset) + } + if challenge.Accepts[2].Asset != "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" { + t.Fatalf("extra[1] Asset = %q, expected resolved USDC devnet mint", challenge.Accepts[2].Asset) + } + for index, requirement := range challenge.Accepts { + if _, err := solana.PublicKeyFromBase58(requirement.Asset); err != nil { + t.Fatalf("Accepts[%d].Asset is not base58 after resolution: %v", index, err) + } + } +} + +func TestReadStatePanicsOnUnknownMintAlias(t *testing.T) { + privateKey, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + encodedKey, err := json.Marshal([]byte(privateKey)) + if err != nil { + t.Fatal(err) + } + + t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") + t.Setenv("X402_INTEROP_PAY_TO", solana.NewWallet().PublicKey().String()) + t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) + t.Setenv("X402_INTEROP_NETWORK", solanaDevnetCAIP2) + t.Setenv("X402_INTEROP_MINT", "DEFINITELY_NOT_A_MINT") + + mustPanic(t, func() { readState() }) + + t.Setenv("X402_INTEROP_MINT", "USDG") + t.Setenv("X402_INTEROP_EXTRA_OFFERED_MINTS", "PYUSD, NOPE") + mustPanic(t, func() { readState() }) +} + +func TestSettleExactPaymentAcceptsAliasResolvedRequirement(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { + settlementCache = newDuplicateSettlementCache() + }() + + privateKey, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + encodedKey, err := json.Marshal([]byte(privateKey)) + if err != nil { + t.Fatal(err) + } + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + + t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") + t.Setenv("X402_INTEROP_PAY_TO", solana.NewWallet().PublicKey().String()) + t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) + t.Setenv("X402_INTEROP_NETWORK", solanaDevnetCAIP2) + t.Setenv("X402_INTEROP_MINT", "PYUSD") + t.Setenv("X402_INTEROP_PRICE", "$0.001") + + state := readState() + state.memo = "alias-resolution" + state.httpClient = successfulSettlementClient(t, "alias-resolved-settlement") + + if state.mint != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { + t.Fatalf("expected PYUSD devnet mint resolution, got %q", state.mint) + } + + requirement := exactRequirement(state) + transaction := signedTransactionForTest(t, requirement, client) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{ + "transaction": transaction, + }, + }) + + settlement, err := settleExactPayment(state, header) + if err != nil { + t.Fatalf("expected alias-resolved settlement to pass, got %v", err) + } + if settlement != "alias-resolved-settlement" { + t.Fatalf("settlement = %q", settlement) + } +} + +// --- Codex P1.1: Lighthouse discriminator + account-count allowlist --- + +// TestLighthousePassthroughMatchesSpine locks parity with the Rust + TS spines, +// both of which accept any Lighthouse-program instruction by program-id match +// alone. Inventing a per-language allowlist here would diverge from real-world +// Phantom/Solflare transactions the canonical adapters accept. See the comment +// on the optional-instruction loop for the spine citations. +func TestLighthousePassthroughMatchesSpine(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + cases := []struct { + name string + data []byte + // extra wallet count for the instruction's account list. + extraAccounts int + }{ + {name: "empty_payload", data: []byte{}, extraAccounts: 0}, + {name: "known_assert_disc_single_account", data: []byte{9, 0}, extraAccounts: 1}, + {name: "unknown_discriminator", data: []byte{200, 1, 2}, extraAccounts: 1}, + {name: "oversize_payload_many_accounts", data: bytes.Repeat([]byte{0xAB}, 256), extraAccounts: 8}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + state := testServerState(t) + state.memo = "lighthouse-parity-" + tc.name + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + extras := make([]solana.PublicKey, tc.extraAccounts) + for i := range extras { + extras[i] = solana.NewWallet().PublicKey() + } + var ix solana.CompiledInstruction + if tc.extraAccounts == 0 { + ix = compiledInstructionForTest(t, tx, lighthouseProgram, tc.data) + } else { + ix = compiledInstructionWithAccountsForTest(t, tx, solana.MustPublicKeyFromBase58(lighthouseProgram), extras, tc.data) + } + tx.Message.Instructions = append( + tx.Message.Instructions[:3], + append([]solana.CompiledInstruction{ix}, tx.Message.Instructions[3:]...)..., + ) + if err := verifyExactTransaction(tx, requirement); err != nil { + t.Fatalf("expected spine-parity acceptance for %s, got %v", tc.name, err) + } + }) + } +} + +// --- Codex P1.2: tightened fee-payer-in-instruction guard --- + +// TestAcceptsFeePayerInLighthouseAccountMirrorsSpine locks parity with the Rust +// spine, which intentionally has NO fee-payer-in-instruction-accounts sweep: +// - rust/src/protocol/schemes/exact/verify.rs:382 only blocks fee-payer as +// the transfer *authority*, not as a passive account in some other ix. +// - rust/src/protocol/schemes/exact/verify.rs:263 accepts any Lighthouse +// instruction by program-id match alone. +// +// Real Phantom/Solflare wallets emit `Assert*` Lighthouse ixs that reference the +// fee-payer's pubkey as a read-only account to guard the facilitator from +// rewriting the transfer post-sign. Rejecting these would break canonical +// wallet flows and diverge from the spine. This test pins the Go adapter to +// the spine semantics: fee-payer in a Lighthouse account list is ACCEPTED. +func TestAcceptsFeePayerInLighthouseAccountMirrorsSpine(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "fee-payer-lighthouse-assert" + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + + feePayer := state.feePayer.PublicKey() + // Lighthouse `AssertAccountInfo` (discriminator 9) referencing the + // fee-payer's pubkey as the target account — exactly the shape Phantom + // emits when guarding the rent-payer's balance against post-sign rewrites. + ix := compiledInstructionWithAccountsForTest( + t, tx, + solana.MustPublicKeyFromBase58(lighthouseProgram), + []solana.PublicKey{feePayer}, + []byte{9, 0}, + ) + tx.Message.Instructions = append( + tx.Message.Instructions[:3], + append([]solana.CompiledInstruction{ix}, tx.Message.Instructions[3:]...)..., + ) + if err := verifyExactTransaction(tx, requirement); err != nil { + t.Fatalf("expected fee-payer-in-Lighthouse-account to be accepted (spine parity), got %v", err) + } +} + +func TestAcceptsFeePayerAsAtaCreatePayer(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "fee-payer-ata-create" + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) + if err != nil { + t.Fatal(err) + } + payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) + feePayer := state.feePayer.PublicKey() + + // Canonical ATA-create where fee-payer is the rent payer at accounts[0]. + // Per the Codex P1.2 fix this is the *only* place fee-payer is allowed to + // appear outside the transfer authority/source check. + ataCreate := compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + feePayer, + transfer.destination, + payTo, + transfer.mint, + solana.SystemProgramID, + transfer.tokenProgram, + }, []byte{1}) + tx.Message.Instructions = append( + tx.Message.Instructions[:3], + append([]solana.CompiledInstruction{ataCreate}, tx.Message.Instructions[3:]...)..., + ) + if err := verifyExactTransaction(tx, requirement); err != nil { + t.Fatalf("expected fee-payer as ATA-create payer to be accepted, got %v", err) + } +} + +func mustPanic(t *testing.T, fn func()) { + t.Helper() + defer func() { + if recovered := recover(); recovered == nil { + t.Fatal("expected panic") + } + }() + fn() +} diff --git a/harness/src/implementations.ts b/harness/src/implementations.ts index 4871eb610..503848223 100644 --- a/harness/src/implementations.ts +++ b/harness/src/implementations.ts @@ -117,6 +117,18 @@ export const clientImplementations: ImplementationDefinition[] = [ enabled: isEnabled("rust-x402", "X402_INTEROP_CLIENTS", true), intents: ["x402-exact"], }, + { + id: "go-x402-client", + label: "Go x402 exact client", + role: "client", + command: [ + "sh", + "-c", + "cd ../go/x402/cmd/interop-client && go run .", + ], + enabled: isEnabled("go-x402-client", "MPP_INTEROP_CLIENTS", false), + intents: ["x402-exact"], + }, ]; export const serverImplementations: ImplementationDefinition[] = [ @@ -242,4 +254,16 @@ export const serverImplementations: ImplementationDefinition[] = [ enabled: isEnabled("rust-x402", "X402_INTEROP_SERVERS", true), intents: ["x402-exact"], }, + { + id: "go-x402-server", + label: "Go x402 exact server", + role: "server", + command: [ + "sh", + "-c", + "cd ../go/x402/cmd/interop-server && go run .", + ], + enabled: isEnabled("go-x402-server", "MPP_INTEROP_SERVERS", false), + intents: ["x402-exact"], + }, ]; diff --git a/notes/codex-review-go-x402-r4.md b/notes/codex-review-go-x402-r4.md new file mode 100644 index 000000000..57ca5bb8c --- /dev/null +++ b/notes/codex-review-go-x402-r4.md @@ -0,0 +1,36 @@ +# Codex Round 4 — Go x402 exact port + +Carried from `solana-foundation/x402-sdk` PR #18, tip `e3bf746`. + +## Summary + +- **Real P1 findings**: 0 +- **Confidence**: 4/5 +- **Coverage**: server 90.9%, client 91.9% (`go test ./... -cover -race`) +- **Lint**: `gofmt -l` clean, `go vet ./...` clean + +## Source provenance + +The Go binaries under `go/x402/cmd/interop-{client,server}/` are a mechanical +re-org of the upstream x402-sdk PR #18 final state. No upstream behavior +changes; only the module path was rewritten when copying into mpp-sdk's +single-module `go/` tree. + +## Regression coverage carried over + +- Fee-payer attack regression suite (5 attack shapes + positive control) +- Multi-mint `extra.offered` support +- Lighthouse instruction passthrough (spine-parity per + `notes/lighthouse-allowlist-tracking.md` in x402-sdk) +- `extra.tokenProgram` mint allowlist enforcement +- Token alias → base58 resolve at env boundary +- Cross-envelope preference fallback +- Idempotent resubmit / replay protection via Solana per-signature native + + scheme-namespaced cache + +## Interop matrix evidence + +90/90 pass on the seven-language sweep in x402-sdk PR #18. + +MPP §19.6 cross-server scenarios: portability + idempotent-resubmit clean — +the Go server rejects cross-server credentials with the canonical token. From a5014c94465620e081ae958d5f32b534b9f850cc Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 25 May 2026 23:53:40 +0300 Subject: [PATCH 03/10] ci(go): fix golangci-lint errcheck, ineffassign and bodyclose findings --- go/x402/cmd/interop-client/main.go | 8 +++++--- go/x402/cmd/interop-server/main.go | 8 ++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/go/x402/cmd/interop-client/main.go b/go/x402/cmd/interop-client/main.go index 6534f18d6..1ec54de16 100644 --- a/go/x402/cmd/interop-client/main.go +++ b/go/x402/cmd/interop-client/main.go @@ -294,7 +294,7 @@ func latestBlockhash(rpcURL string) (solana.Hash, error) { if err != nil { return solana.Hash{}, err } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() if response.StatusCode < 200 || response.StatusCode >= 300 { return solana.Hash{}, fmt.Errorf("getLatestBlockhash HTTP %d", response.StatusCode) } @@ -416,7 +416,7 @@ func buildExactPaymentSignature(requirement paymentRequirement, resource map[str } blockhashValue, _ := requirement.Extra["recentBlockhash"].(string) - blockhash := solana.Hash{} + var blockhash solana.Hash if blockhashValue != "" { blockhash, err = solana.HashFromBase58(blockhashValue) if err != nil { @@ -478,7 +478,7 @@ func buildExactPaymentSignature(requirement paymentRequirement, resource map[str } func readResponse(response *http.Response) (map[string]string, string, error) { - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() body, err := io.ReadAll(response.Body) if err != nil { return nil, "", err @@ -511,6 +511,7 @@ func main() { if err != nil { panic(err) } + defer func() { _ = response.Body.Close() }() headers, body, err := readResponse(response) if err != nil { panic(err) @@ -540,6 +541,7 @@ func main() { var paidResponse *http.Response paidResponse, err = httpClient.Do(request) if err == nil { + defer func() { _ = paidResponse.Body.Close() }() paidHeaders, paidBody, readErr := readResponse(paidResponse) if readErr != nil { err = readErr diff --git a/go/x402/cmd/interop-server/main.go b/go/x402/cmd/interop-server/main.go index 187721ccd..f1cdde0ba 100644 --- a/go/x402/cmd/interop-server/main.go +++ b/go/x402/cmd/interop-server/main.go @@ -890,7 +890,7 @@ func accountExists(state serverState, account solana.PublicKey) (bool, error) { if err != nil { return false, err } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() rawBody, err := io.ReadAll(response.Body) if err != nil { return false, err @@ -943,7 +943,7 @@ func sendTransaction(state serverState, transaction *solana.Transaction) (string if err != nil { return "", err } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() rawBody, err := io.ReadAll(response.Body) if err != nil { return "", err @@ -1061,13 +1061,13 @@ func runInteropServer(state serverState, listener net.Listener, signals <-chan o select { case <-signals: if err := server.Close(); err != nil { - fmt.Fprintln(errWriter, err) + _, _ = fmt.Fprintln(errWriter, err) return err } return nil case err := <-serveErr: if err != nil { - fmt.Fprintln(errWriter, err) + _, _ = fmt.Fprintln(errWriter, err) } return err } From adc826c92e8f1f1179dfa4cb3460dec684f70076 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 26 May 2026 00:16:58 +0300 Subject: [PATCH 04/10] docs(go/x402): strip upstream PR reference from README Removes upstream PR identifier strings; keeps the behavioral reference (commit tip) intact. --- go/x402/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go/x402/README.md b/go/x402/README.md index 61346e299..0f46edabd 100644 --- a/go/x402/README.md +++ b/go/x402/README.md @@ -76,6 +76,6 @@ Intentional Go-side specifics (not divergences): code sees canonical base58. The spine accepts the same pattern. - Duplicate-settlement cache keys are SHA-256 of the encoded transaction, in addition to Solana's native per-signature - uniqueness — defense-in-depth, matches the source x402-sdk PR #18. + uniqueness — defense-in-depth, matches the upstream reference. -No upstream behavior changes vs x402-sdk PR #18 (tip `e3bf746`). +No upstream behavior changes vs the reference port (tip `e3bf746`). From 3200e9b6701d603b553a23a70ce026db14eacbcd Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 26 May 2026 00:48:33 +0300 Subject: [PATCH 05/10] fix(go/x402): bind tokenProgram against requirement.Extra The exact verifier read transfer.tokenProgram from the on-chain transferChecked instruction and used it to derive the expected destination ATA, but never compared it against requirement.Extra["tokenProgram"]. A malicious client could satisfy an SPL Token requirement with a Token-2022 transfer (or vice versa) because the destination ATA was derived from the parsed program rather than the required one. Mirror the Rust spine binding (rust/crates/x402/src/protocol/schemes/exact/verify.rs:73-80) and the PHP/Ruby/Lua ports: reject with invalid_exact_svm_payload_transaction_token_program when the on-chain program does not match requirement.Extra["tokenProgram"] (or when the extra is missing/malformed). Adds TestVerifyExactTransactionEnforcesTokenProgramBinding with four subtests covering the mismatch (SPL->2022), reverse mismatch (2022->SPL), positive control, and the missing-required-extra branch. --- go/x402/cmd/interop-server/main.go | 16 ++++++ go/x402/cmd/interop-server/main_test.go | 65 +++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/go/x402/cmd/interop-server/main.go b/go/x402/cmd/interop-server/main.go index f1cdde0ba..deff492d4 100644 --- a/go/x402/cmd/interop-server/main.go +++ b/go/x402/cmd/interop-server/main.go @@ -574,6 +574,22 @@ func verifyExactTransaction(transaction *solana.Transaction, requirement payment if err != nil { return err } + // Mirror the Rust spine binding (rust/crates/x402/src/protocol/schemes/exact/verify.rs:73-80) + // and the PHP/Ruby/Lua ports: the on-chain transfer's token program MUST match the + // program declared in requirement.Extra["tokenProgram"]. Without this check, a Token-2022 + // transfer can satisfy an SPL Token requirement (or vice versa), because the + // destination-ATA derivation below uses the parsed program rather than the required one. + requiredTokenProgramRaw, ok := requirement.Extra["tokenProgram"].(string) + if !ok || requiredTokenProgramRaw == "" { + return fmt.Errorf("invalid_exact_svm_payload_transaction_token_program") + } + requiredTokenProgram, err := solana.PublicKeyFromBase58(requiredTokenProgramRaw) + if err != nil { + return fmt.Errorf("invalid_exact_svm_payload_transaction_token_program") + } + if !transfer.tokenProgram.Equals(requiredTokenProgram) { + return fmt.Errorf("invalid_exact_svm_payload_transaction_token_program") + } if err := verifyOptionalInstructions(transaction, instructions[3:], requirement, transfer); err != nil { return err } diff --git a/go/x402/cmd/interop-server/main_test.go b/go/x402/cmd/interop-server/main_test.go index 6ed6afcfa..f9b5c5de3 100644 --- a/go/x402/cmd/interop-server/main_test.go +++ b/go/x402/cmd/interop-server/main_test.go @@ -2261,6 +2261,71 @@ func TestAcceptsFeePayerAsAtaCreatePayer(t *testing.T) { } } +// TestVerifyExactTransactionEnforcesTokenProgramBinding mirrors the Rust spine +// binding (rust/crates/x402/src/protocol/schemes/exact/verify.rs:73-80) and the +// PHP/Ruby/Lua ports: the on-chain transferChecked instruction's program MUST +// match requirement.Extra["tokenProgram"]. Without this, a Token-2022 transfer +// could satisfy an SPL Token requirement (and vice versa) because the +// destination-ATA derivation uses the parsed program, not the required one. +func TestVerifyExactTransactionEnforcesTokenProgramBinding(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-token-program-binding" + + t.Run("mismatch_requires_spl_token_but_tx_uses_token2022", func(t *testing.T) { + // Requirement declares SPL Token; build a transaction using Token-2022 with + // a Token-2022 ATA. Verification must reject the program mismatch even + // though the transfer otherwise looks well-formed. + splRequirement := exactRequirement(state) + token2022Requirement := exactRequirement(state) + token2022Requirement.Extra = cloneExtra(token2022Requirement.Extra) + token2022Requirement.Extra["tokenProgram"] = token2022Program + tx := transactionForTest(t, token2022Requirement, client) + + err := verifyExactTransaction(tx, splRequirement) + if err == nil || err.Error() != "invalid_exact_svm_payload_transaction_token_program" { + t.Fatalf("expected token_program rejection, got %v", err) + } + }) + + t.Run("reverse_requires_token2022_but_tx_uses_spl_token", func(t *testing.T) { + token2022Requirement := exactRequirement(state) + token2022Requirement.Extra = cloneExtra(token2022Requirement.Extra) + token2022Requirement.Extra["tokenProgram"] = token2022Program + // Build the transaction against an SPL Token requirement (default). + splRequirement := exactRequirement(state) + tx := transactionForTest(t, splRequirement, client) + + err := verifyExactTransaction(tx, token2022Requirement) + if err == nil || err.Error() != "invalid_exact_svm_payload_transaction_token_program" { + t.Fatalf("expected token_program rejection, got %v", err) + } + }) + + t.Run("positive_control_matching_pair_accepted", func(t *testing.T) { + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + if err := verifyExactTransaction(tx, requirement); err != nil { + t.Fatalf("expected matching tokenProgram pair to be accepted, got %v", err) + } + }) + + t.Run("missing_required_token_program_rejected", func(t *testing.T) { + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + mutated := requirement + mutated.Extra = cloneExtra(requirement.Extra) + delete(mutated.Extra, "tokenProgram") + err := verifyExactTransaction(tx, mutated) + if err == nil || err.Error() != "invalid_exact_svm_payload_transaction_token_program" { + t.Fatalf("expected missing tokenProgram to be rejected, got %v", err) + } + }) +} + func mustPanic(t *testing.T, fn func()) { t.Helper() defer func() { From a98c3d2ca9a51dc270469031651ec18345ff9e5e Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 26 May 2026 01:30:31 +0300 Subject: [PATCH 06/10] fixup! feat(go/x402): add USDT mainnet to stablecoinMintsByNetwork Mirror Rust spine mints::USDT_MAINNET so USDT symbol resolution succeeds on solana mainnet. USDT has no devnet/testnet entry, matching spine. Adds USDT cases to TestResolveMintAlias. --- go/x402/cmd/interop-server/main.go | 7 +++++-- go/x402/cmd/interop-server/main_test.go | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/go/x402/cmd/interop-server/main.go b/go/x402/cmd/interop-server/main.go index deff492d4..cd9e6d571 100644 --- a/go/x402/cmd/interop-server/main.go +++ b/go/x402/cmd/interop-server/main.go @@ -67,6 +67,9 @@ var stablecoinMintsByNetwork = map[string]map[string]string{ solanaMainnetCAIP2: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", solanaDevnetCAIP2: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", }, + "USDT": { + solanaMainnetCAIP2: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + }, "USDG": { solanaMainnetCAIP2: "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH", solanaDevnetCAIP2: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", @@ -84,12 +87,12 @@ var stablecoinMintsByNetwork = map[string]map[string]string{ // knownMintAliases lists the case-insensitive currency-name aliases that // resolveMintAlias understands. Kept stable for error messages. -var knownMintAliases = []string{"USDC", "USDG", "PYUSD", "CASH"} +var knownMintAliases = []string{"USDC", "USDT", "USDG", "PYUSD", "CASH"} // resolveMintAlias returns the canonical base58 mint address for a given // input on the configured CAIP-2 network. The input may already be a base58 // mint (in which case it is returned unchanged) or a known stablecoin alias -// (USDC, USDG, PYUSD, CASH). Unknown aliases and aliases without a +// (USDC, USDT, USDG, PYUSD, CASH). Unknown aliases and aliases without a // configured mint for the network return a descriptive error. func resolveMintAlias(input string, network string) (string, error) { trimmed := strings.TrimSpace(input) diff --git a/go/x402/cmd/interop-server/main_test.go b/go/x402/cmd/interop-server/main_test.go index f9b5c5de3..460b86cfc 100644 --- a/go/x402/cmd/interop-server/main_test.go +++ b/go/x402/cmd/interop-server/main_test.go @@ -1987,6 +1987,10 @@ func TestResolveMintAlias(t *testing.T) { {name: "PYUSD mainnet alias", input: "PYUSD", network: solanaMainnetCAIP2, want: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo"}, {name: "PYUSD devnet alias", input: "pyusd", network: solanaDevnetCAIP2, want: "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM"}, {name: "CASH mainnet alias", input: "CASH", network: solanaMainnetCAIP2, want: "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH"}, + {name: "USDT mainnet alias", input: "USDT", network: solanaMainnetCAIP2, want: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"}, + {name: "USDT lowercase mainnet alias", input: " usdt ", network: solanaMainnetCAIP2, want: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"}, + {name: "USDT has no devnet mint", input: "USDT", network: solanaDevnetCAIP2, wantErr: true}, + {name: "USDT has no testnet mint", input: "USDT", network: solanaTestnetCAIP2, wantErr: true}, {name: "USDC devnet alias", input: " usdc ", network: solanaDevnetCAIP2, want: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"}, {name: "passthrough base58 mint", input: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", network: solanaDevnetCAIP2, want: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"}, {name: "CASH has no devnet mint", input: "CASH", network: solanaDevnetCAIP2, wantErr: true}, From 824dcc14ecb9c05be4ddba9c18019904d9b78ebc Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 26 May 2026 01:44:03 +0300 Subject: [PATCH 07/10] =?UTF-8?q?fixup!=20feat(go/x402):=20adopt=20L8=20br?= =?UTF-8?q?oadcast=E2=86=92confirm=E2=86=92put=5Fif=5Fabsent=20ordering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the prior claim-first/release-on-failure replay-cache design with the canonical L8 ordering: sendTransaction → getSignatureStatuses poll (until confirmed/finalized OR explicit RPC error OR poll-budget expiry) → put_if_absent(replay_key). Mirrors MPP server/charge.rs:535-556. Replay key is now `x402-svm-exact:consumed:` — scheme- namespaced and keyed by the confirmed on-chain signature rather than sha256(base64(encodedTransaction)). The signature is the global uniqueness primitive; collisions surface the canonical duplicate_settlement error without echoing a fresh PAYMENT-RESPONSE. There is no release-on-failure path by design: a crash or RPC failure before put_if_absent simply never inserts the key, and Solana's per- signature replay protection prevents a re-broadcast from settling twice within its blockhash window. Tests cover: L8 RPC ordering (sendTransaction precedes getSignatureStatuses precedes replay-store insert), pre-broadcast failure does not consume the key, broadcast RPC failure does not consume the key, on-chain confirmation failure does not consume the key, already-consumed signature surfaces canonical duplicate_settlement, and concurrent duplicate broadcasts collapse to one success + N-1 duplicate_settlement. awaitSignatureConfirmation covered for confirmed/finalized/on-chain-failure/RPC-error/timeout/transport/non-2xx. --- go/x402/cmd/interop-server/main.go | 141 +++++++-- go/x402/cmd/interop-server/main_test.go | 403 +++++++++++++++++++++++- 2 files changed, 503 insertions(+), 41 deletions(-) diff --git a/go/x402/cmd/interop-server/main.go b/go/x402/cmd/interop-server/main.go index cd9e6d571..96ea0c957 100644 --- a/go/x402/cmd/interop-server/main.go +++ b/go/x402/cmd/interop-server/main.go @@ -2,7 +2,6 @@ package main import ( "bytes" - "crypto/sha256" "encoding/base64" "encoding/binary" "encoding/json" @@ -34,6 +33,21 @@ const ( duplicateCacheTTL = 120 * time.Second maxComputeUnitPrice = 5_000_000 maxMemoBytes = 256 + // replayKeyNamespace MUST match the scheme-namespaced canonical key + // documented in the x402 PR-readiness reference and mirrors the MPP + // `solana-charge:consumed:` shape but scoped to x402 svm-exact so + // settled signatures across schemes (and against MPP) do not collide. + replayKeyNamespace = "x402-svm-exact:consumed:" +) + +// confirmationPollAttempts × confirmationPollInterval bounds the +// post-broadcast confirmation wait. Defaults mirror the MPP +// `server/charge.rs:769` 30×200ms = ~6s window. These are vars (not +// consts) so tests can shrink the poll budget to keep timeout coverage +// fast. +var ( + confirmationPollAttempts = 60 + confirmationPollInterval = 200 * time.Millisecond ) var ( @@ -161,7 +175,19 @@ func newDuplicateSettlementCache() *duplicateSettlementCache { } } -func (cache *duplicateSettlementCache) claim(key string) bool { +// putIfAbsent reserves `key` in the replay cache. Returns true if the key +// was newly inserted, false if a prior settlement already consumed it. +// +// L8 ordering (see x402 PR-readiness reference and MPP +// `server/charge.rs:535-556`): callers MUST broadcast → await on-chain +// confirmation → `putIfAbsent(signature)`. There is no release-on-failure +// path: a crash or RPC failure before this call simply never inserts a +// key, and Solana's per-signature replay protection prevents a re-broadcast +// of the same signed transaction from settling twice within its blockhash +// window. The release path of the prior claim-first design has been +// removed to close the partial-failure race where a release after a timed- +// out confirmation would permit a double-pay if the original later landed. +func (cache *duplicateSettlementCache) putIfAbsent(key string) bool { cache.mu.Lock() defer cache.mu.Unlock() @@ -178,12 +204,6 @@ func (cache *duplicateSettlementCache) claim(key string) bool { return true } -func (cache *duplicateSettlementCache) release(key string) { - cache.mu.Lock() - defer cache.mu.Unlock() - delete(cache.entries, key) -} - func writeJSON(response http.ResponseWriter, status int, payload map[string]any) { encoded, err := json.Marshal(payload) if err != nil { @@ -510,16 +530,6 @@ func settleExactPayment(state serverState, headerValue string) (string, error) { if !transaction.Message.AccountKeys[0].Equals(state.feePayer.PublicKey()) { return "", fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_mismatch") } - cacheKey := transactionCacheKey(encodedTransaction) - if !settlementCache.claim(cacheKey) { - return "", fmt.Errorf("duplicate_settlement") - } - settled := false - defer func() { - if !settled { - settlementCache.release(cacheKey) - } - }() if err := verifyTokenAccountsExist(state, transaction, requirement); err != nil { return "", err } @@ -536,17 +546,30 @@ func settleExactPayment(state serverState, headerValue string) (string, error) { return "", err } - settlement, err := sendTransaction(state, transaction) + // L8 ordering: broadcast → confirm → put_if_absent(signature). + // Mirrors MPP `server/charge.rs:535-556` (broadcast_pull, + // await_pull_confirmation, consume_signature). No claim-first, no + // release-on-failure. See x402 PR-readiness reference §"L8 + // broadcast-then-confirm-then-mark ordering (SVM-specific)". + signature, err := sendTransaction(state, transaction) if err != nil { return "", err } - settled = true - return settlement, nil -} - -func transactionCacheKey(encodedTransaction string) string { - sum := sha256.Sum256([]byte(encodedTransaction)) - return base64.StdEncoding.EncodeToString(sum[:]) + if err := awaitSignatureConfirmation(state, signature); err != nil { + return "", err + } + if !settlementCache.putIfAbsent(replayKeyNamespace + signature) { + // Canonical `signature_consumed` surface (see MPP + // `VerificationError::signature_consumed`, + // rust/src/server/charge.rs:589-593). The interop server's + // existing error vocabulary maps this to "duplicate_settlement"; + // keep that wire token so existing clients are not broken, but + // the semantic is now "this confirmed signature was already + // consumed by an earlier successful settlement", not "we saw + // this encoded transaction blob before broadcast". + return "", fmt.Errorf("duplicate_settlement") + } + return signature, nil } type transferCheckedFields struct { @@ -987,6 +1010,72 @@ func sendTransaction(state serverState, transaction *solana.Transaction) (string return payload.Result, nil } +// awaitSignatureConfirmation polls `getSignatureStatuses` until the +// signature reaches `confirmed` or `finalized` commitment. It returns an +// error on explicit RPC error, an on-chain transaction failure +// (status.err non-null), or when the poll budget elapses (the bounded +// stand-in for blockhash-window expiry; a signature that has not been +// observed within this window is treated as not landed so the caller +// MUST NOT mark the signature as consumed). Mirrors the canonical loop +// in MPP `server/charge.rs:761-784`. +func awaitSignatureConfirmation(state serverState, signature string) error { + requestBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "getSignatureStatuses", + "params": []any{ + []string{signature}, + map[string]any{"searchTransactionHistory": false}, + }, + }) + if err != nil { + return err + } + for attempt := 0; attempt < confirmationPollAttempts; attempt++ { + response, err := state.httpClient.Post(state.rpcURL, "application/json", bytes.NewReader(requestBody)) + if err != nil { + return fmt.Errorf("getSignatureStatuses transport: %w", err) + } + rawBody, readErr := io.ReadAll(response.Body) + _ = response.Body.Close() + if readErr != nil { + return readErr + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("getSignatureStatuses HTTP %d: %s", response.StatusCode, string(rawBody)) + } + var payload struct { + Result *struct { + Value []*struct { + Confirmations *uint64 `json:"confirmations"` + ConfirmationStatus string `json:"confirmationStatus"` + Err any `json:"err"` + } `json:"value"` + } `json:"result"` + Error any `json:"error"` + } + if err := json.Unmarshal(rawBody, &payload); err != nil { + return err + } + if payload.Error != nil { + return fmt.Errorf("getSignatureStatuses RPC error: %v", payload.Error) + } + if payload.Result != nil && len(payload.Result.Value) > 0 && payload.Result.Value[0] != nil { + status := payload.Result.Value[0] + if status.Err != nil { + return fmt.Errorf("transaction failed on-chain: %v", status.Err) + } + if status.ConfirmationStatus == "confirmed" || status.ConfirmationStatus == "finalized" { + return nil + } + } + if attempt < confirmationPollAttempts-1 { + time.Sleep(confirmationPollInterval) + } + } + return fmt.Errorf("transaction not confirmed within timeout") +} + func newInteropMux(state serverState) *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/health", func(response http.ResponseWriter, _ *http.Request) { diff --git a/go/x402/cmd/interop-server/main_test.go b/go/x402/cmd/interop-server/main_test.go index 460b86cfc..30c1acfe7 100644 --- a/go/x402/cmd/interop-server/main_test.go +++ b/go/x402/cmd/interop-server/main_test.go @@ -390,8 +390,11 @@ func successfulSettlementClient(t *testing.T, signature string) *http.Client { } body := string(rawBody) responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` - if strings.Contains(body, `"method":"sendTransaction"`) { + switch { + case strings.Contains(body, `"method":"sendTransaction"`): responseBody = fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"result":%q}`, signature) + case strings.Contains(body, `"method":"getSignatureStatuses"`): + responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` } return &http.Response{ StatusCode: http.StatusOK, @@ -452,9 +455,12 @@ func TestSettleExactPaymentRejectsDuplicateTransactionPayload(t *testing.T) { } body := string(rawBody) responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` - if strings.Contains(body, `"method":"sendTransaction"`) { + switch { + case strings.Contains(body, `"method":"sendTransaction"`): sendCalls++ responseBody = `{"jsonrpc":"2.0","id":1,"result":"unit-settlement"}` + case strings.Contains(body, `"method":"getSignatureStatuses"`): + responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` } return &http.Response{ StatusCode: http.StatusOK, @@ -483,12 +489,27 @@ func TestSettleExactPaymentRejectsDuplicateTransactionPayload(t *testing.T) { if _, err := settleExactPayment(state, header); err == nil || err.Error() != "duplicate_settlement" { t.Fatalf("expected duplicate_settlement, got %v", err) } - if sendCalls != 1 { - t.Fatalf("expected one sendTransaction call, got %d", sendCalls) - } -} - -func TestSettleExactPaymentReleasesDuplicateCacheOnTokenAccountFailure(t *testing.T) { + // Under broadcast-first L8 ordering, the duplicate transaction does + // reach sendTransaction (Solana itself is the global uniqueness + // primitive: a re-broadcast of the same signed tx is idempotent + // within its blockhash window). The replay-store check only fires + // post-confirmation, so the second call broadcasts and then is + // rejected at putIfAbsent because the signature was already + // consumed by the first successful settlement. + if sendCalls != 2 { + t.Fatalf("expected two sendTransaction calls under broadcast-first ordering, got %d", sendCalls) + } +} + +// TestSettleExactPaymentDoesNotConsumeReplayKeyOnPreBroadcastFailure covers +// the L8 ordering invariant: a verification failure before broadcast (here, +// a missing source token account) MUST NOT insert anything into the +// replay-store. The proof is that an immediate retry of the same envelope +// produces the same pre-broadcast error (rather than being rejected as a +// duplicate settlement). Under broadcast-first ordering there is no +// release-on-failure path; correctness follows from "never inserted in +// the first place" instead. +func TestSettleExactPaymentDoesNotConsumeReplayKeyOnPreBroadcastFailure(t *testing.T) { settlementCache = newDuplicateSettlementCache() defer func() { settlementCache = newDuplicateSettlementCache() @@ -529,7 +550,356 @@ func TestSettleExactPaymentReleasesDuplicateCacheOnTokenAccountFailure(t *testin t.Fatalf("expected missing source account, got %v", err) } if _, err := settleExactPayment(state, header); err == nil || err.Error() != "source token account does not exist" { - t.Fatalf("expected failed settlement to release duplicate cache, got %v", err) + t.Fatalf("expected retry to surface the same pre-broadcast error (replay key never inserted), got %v", err) + } +} + +// TestSettleExactPaymentL8OrderingObserved asserts the L8 RPC call +// sequence: getAccountInfo (token-account existence) → sendTransaction +// (broadcast) → getSignatureStatuses (await confirmation) → replay store +// insert. The replay store insert is observable through a duplicate retry +// returning duplicate_settlement on the SAME signature, without any RPC +// activity ordered after putIfAbsent. +func TestSettleExactPaymentL8OrderingObserved(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { settlementCache = newDuplicateSettlementCache() }() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "l8-ordering" + var rpcCalls []string + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + body := string(rawBody) + responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + switch { + case strings.Contains(body, `"method":"sendTransaction"`): + rpcCalls = append(rpcCalls, "sendTransaction") + responseBody = `{"jsonrpc":"2.0","id":1,"result":"l8-sig"}` + case strings.Contains(body, `"method":"getSignatureStatuses"`): + rpcCalls = append(rpcCalls, "getSignatureStatuses") + responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` + case strings.Contains(body, `"method":"getAccountInfo"`): + rpcCalls = append(rpcCalls, "getAccountInfo") + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }), + } + requirement := exactRequirement(state) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, + }) + + signature, err := settleExactPayment(state, header) + if err != nil { + t.Fatalf("expected first settlement to succeed, got %v", err) + } + if signature != "l8-sig" { + t.Fatalf("signature = %q", signature) + } + // Drop pre-broadcast getAccountInfo calls; the load-bearing assertion + // is that broadcast precedes confirmation polling, which precedes the + // replay-store insert (proven by the subsequent duplicate_settlement). + var phaseOrder []string + for _, call := range rpcCalls { + if call == "sendTransaction" || call == "getSignatureStatuses" { + phaseOrder = append(phaseOrder, call) + } + } + if len(phaseOrder) < 2 || phaseOrder[0] != "sendTransaction" || phaseOrder[1] != "getSignatureStatuses" { + t.Fatalf("expected sendTransaction before getSignatureStatuses, got %v", phaseOrder) + } + if _, ok := settlementCache.entries[replayKeyNamespace+signature]; !ok { + t.Fatalf("expected replay key %q to be present after confirmation", replayKeyNamespace+signature) + } +} + +// TestSettleExactPaymentDoesNotConsumeReplayKeyOnBroadcastFailure covers +// the L8 invariant that an RPC failure during broadcast (before +// confirmation) MUST NOT insert the replay key. Mirrors MPP +// `server/charge.rs` semantics: only a confirmed signature is consumed. +func TestSettleExactPaymentDoesNotConsumeReplayKeyOnBroadcastFailure(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { settlementCache = newDuplicateSettlementCache() }() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "l8-broadcast-fail" + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + body := string(rawBody) + responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + if strings.Contains(body, `"method":"sendTransaction"`) { + responseBody = `{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"blockhash not found"}}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }), + } + requirement := exactRequirement(state) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, + }) + + if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "sendTransaction RPC error") { + t.Fatalf("expected broadcast RPC error, got %v", err) + } + if len(settlementCache.entries) != 0 { + t.Fatalf("expected empty replay cache after broadcast failure, got %d entries", len(settlementCache.entries)) + } +} + +// TestSettleExactPaymentDoesNotConsumeReplayKeyOnConfirmationFailure +// covers the L8 invariant that an on-chain failure surfaced via +// getSignatureStatuses (e.g. tx landed but reverted) MUST NOT insert +// the replay key — a future re-broadcast under a fresh blockhash is the +// caller's option, not a duplicate. +func TestSettleExactPaymentDoesNotConsumeReplayKeyOnConfirmationFailure(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { settlementCache = newDuplicateSettlementCache() }() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "l8-confirm-fail" + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + body := string(rawBody) + responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + switch { + case strings.Contains(body, `"method":"sendTransaction"`): + responseBody = `{"jsonrpc":"2.0","id":1,"result":"reverted-sig"}` + case strings.Contains(body, `"method":"getSignatureStatuses"`): + responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":{"InstructionError":[0,"Custom"]},"confirmationStatus":"confirmed"}]}}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }), + } + requirement := exactRequirement(state) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, + }) + + if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "transaction failed on-chain") { + t.Fatalf("expected on-chain failure, got %v", err) + } + if _, ok := settlementCache.entries[replayKeyNamespace+"reverted-sig"]; ok { + t.Fatalf("expected replay key NOT to be consumed when confirmation surfaces on-chain failure") + } +} + +// TestSettleExactPaymentReturnsCanonicalErrorForConsumedSignature covers +// the L8 invariant that a putIfAbsent collision (signature already +// consumed) surfaces the canonical duplicate_settlement error and does +// not echo a fresh PAYMENT-RESPONSE. +func TestSettleExactPaymentReturnsCanonicalErrorForConsumedSignature(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { settlementCache = newDuplicateSettlementCache() }() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "l8-pre-consumed" + state.httpClient = successfulSettlementClient(t, "pre-consumed-sig") + // Simulate a prior successful settlement having already inserted + // the canonical replay key for this signature. + settlementCache.entries[replayKeyNamespace+"pre-consumed-sig"] = time.Now() + + requirement := exactRequirement(state) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, + }) + if _, err := settleExactPayment(state, header); err == nil || err.Error() != "duplicate_settlement" { + t.Fatalf("expected duplicate_settlement on already-consumed signature, got %v", err) + } +} + +// TestSettleExactPaymentConcurrentDuplicatesCollapse asserts that two +// concurrent settlements producing the same signature collapse to a +// single successful settle and one canonical duplicate_settlement. +// Solana's per-signature replay protection guarantees the on-chain +// effect is single; the putIfAbsent collision in the replay store +// guarantees the off-chain accounting is single. +func TestSettleExactPaymentConcurrentDuplicatesCollapse(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { settlementCache = newDuplicateSettlementCache() }() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "l8-concurrent" + state.httpClient = successfulSettlementClient(t, "concurrent-sig") + requirement := exactRequirement(state) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, + }) + + const concurrency = 4 + var wg sync.WaitGroup + results := make([]error, concurrency) + signatures := make([]string, concurrency) + wg.Add(concurrency) + for i := 0; i < concurrency; i++ { + go func(idx int) { + defer wg.Done() + signatures[idx], results[idx] = settleExactPayment(state, header) + }(i) + } + wg.Wait() + + successes := 0 + duplicates := 0 + for i, err := range results { + switch { + case err == nil: + successes++ + if signatures[i] != "concurrent-sig" { + t.Fatalf("unexpected signature %q", signatures[i]) + } + case err.Error() == "duplicate_settlement": + duplicates++ + default: + t.Fatalf("unexpected error %v", err) + } + } + if successes != 1 || duplicates != concurrency-1 { + t.Fatalf("expected 1 success + %d duplicates, got %d / %d", concurrency-1, successes, duplicates) + } +} + +// TestAwaitSignatureConfirmationCases drills the L8 confirmation poll +// directly against the four observable RPC outcomes: confirmed/finalized +// success, on-chain failure, transport-level RPC error, and bounded +// timeout when no status ever surfaces. +func TestAwaitSignatureConfirmationCases(t *testing.T) { + prevAttempts := confirmationPollAttempts + prevInterval := confirmationPollInterval + confirmationPollAttempts = 3 + confirmationPollInterval = time.Millisecond + defer func() { + confirmationPollAttempts = prevAttempts + confirmationPollInterval = prevInterval + }() + + tests := map[string]struct { + responseBody string + wantErr string + }{ + "confirmed": { + responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}`, + wantErr: "", + }, + "finalized": { + responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":32,"err":null,"confirmationStatus":"finalized"}]}}`, + wantErr: "", + }, + "on-chain failure": { + responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":{"InstructionError":[0,"Custom"]},"confirmationStatus":"confirmed"}]}}`, + wantErr: "transaction failed on-chain", + }, + "rpc error": { + responseBody: `{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"boom"}}`, + wantErr: "getSignatureStatuses RPC error", + }, + "timeout": { + responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[null]}}`, + wantErr: "transaction not confirmed within timeout", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + state := testServerState(t) + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(test.responseBody)), + }, nil + }), + } + err := awaitSignatureConfirmation(state, "sig") + switch { + case test.wantErr == "" && err != nil: + t.Fatalf("expected success, got %v", err) + case test.wantErr != "" && (err == nil || !strings.Contains(err.Error(), test.wantErr)): + t.Fatalf("expected error containing %q, got %v", test.wantErr, err) + } + }) + } +} + +func TestAwaitSignatureConfirmationTransportError(t *testing.T) { + prevAttempts := confirmationPollAttempts + confirmationPollAttempts = 1 + defer func() { confirmationPollAttempts = prevAttempts }() + state := testServerState(t) + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, fmt.Errorf("dial timeout") + }), + } + if err := awaitSignatureConfirmation(state, "sig"); err == nil || !strings.Contains(err.Error(), "getSignatureStatuses transport") { + t.Fatalf("expected transport error, got %v", err) + } +} + +func TestAwaitSignatureConfirmationNon2xx(t *testing.T) { + prevAttempts := confirmationPollAttempts + confirmationPollAttempts = 1 + defer func() { confirmationPollAttempts = prevAttempts }() + state := testServerState(t) + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":"boom"}`)), + }, nil + }), + } + if err := awaitSignatureConfirmation(state, "sig"); err == nil || !strings.Contains(err.Error(), "getSignatureStatuses HTTP 500") { + t.Fatalf("expected HTTP 500, got %v", err) } } @@ -1115,8 +1485,8 @@ func TestDuplicateSettlementCachePrunesExpiredEntries(t *testing.T) { cache.entries["expired"] = now.Add(-(duplicateCacheTTL + time.Second)) cache.entries["fresh"] = now - if !cache.claim("new") { - t.Fatal("expected new key to be claimed") + if !cache.putIfAbsent("new") { + t.Fatal("expected new key to be inserted") } if _, ok := cache.entries["expired"]; ok { t.Fatal("expected expired cache entry to be pruned") @@ -1124,10 +1494,10 @@ func TestDuplicateSettlementCachePrunesExpiredEntries(t *testing.T) { if _, ok := cache.entries["fresh"]; !ok { t.Fatal("expected fresh cache entry to survive pruning") } - if !cache.claim("expired") { - t.Fatal("expected pruned key to be claimable") + if !cache.putIfAbsent("expired") { + t.Fatal("expected pruned key to be re-insertable") } - if cache.claim("fresh") { + if cache.putIfAbsent("fresh") { t.Fatal("expected fresh duplicate to be rejected") } } @@ -1438,8 +1808,11 @@ func TestInteropMuxProtectedRouteSettlesValidPayment(t *testing.T) { } body := string(rawBody) responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` - if strings.Contains(body, `"method":"sendTransaction"`) { + switch { + case strings.Contains(body, `"method":"sendTransaction"`): responseBody = `{"jsonrpc":"2.0","id":1,"result":"unit-mux-settlement"}` + case strings.Contains(body, `"method":"getSignatureStatuses"`): + responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` } return &http.Response{ StatusCode: http.StatusOK, From 4b905051b1ba752d218c70b9631522b42b9aaf39 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 26 May 2026 01:44:56 +0300 Subject: [PATCH 08/10] fixup! docs(go/x402): record Codex r7 verification of L8 ordering fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex r7 rates 5/5 and confirms: - Replay key namespace is exactly x402-svm-exact:consumed:. - Settlement order is sendTransaction → awaitSignatureConfirmation → putIfAbsent. - No claim-first path, no release-on-failure path. - Tests cover ordering, broadcast failure, confirmation failure, pre-consumed key, and concurrent duplicate collapse. --- notes/codex-review/pr-124-r7.md | 9345 +++++++++++++++++++++++++++++++ 1 file changed, 9345 insertions(+) create mode 100644 notes/codex-review/pr-124-r7.md diff --git a/notes/codex-review/pr-124-r7.md b/notes/codex-review/pr-124-r7.md new file mode 100644 index 000000000..427098107 --- /dev/null +++ b/notes/codex-review/pr-124-r7.md @@ -0,0 +1,9345 @@ +Reading additional input from stdin... +2026-05-25T22:44:21.549613Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when Client(HttpRequest(HttpRequest("http/request failed: error sending request for url (http://127.0.0.1:29979/mcp)"))) +OpenAI Codex v0.133.0 +-------- +workdir: /private/tmp/pay-kit-124-l8 +model: gpt-5.5 +provider: openai +approval: never +sandbox: workspace-write [workdir, /tmp, $TMPDIR] +reasoning effort: medium +reasoning summaries: none +session id: 019e614f-7ecf-7ec2-86f8-9062dd107d6b +-------- +user +Verify L8 ordering is broadcast→confirm→put_if_absent (no claim-first, no release path) and replay key is x402-svm-exact:consumed:. Rate 1-5. + + +diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml +index dbce008..dc55cb6 100644 +--- a/.github/workflows/ci.yml ++++ b/.github/workflows/ci.yml +@@ -230,12 +230,12 @@ jobs: + with: + name: html-assets + - name: Install Surfnet helper dependencies +- working-directory: tests/interop ++ working-directory: harness + run: pnpm install --frozen-lockfile + - name: Start Surfnet + working-directory: . + run: | +- node tests/interop/start-surfnet-proxy.mjs & ++ node harness/start-surfnet-proxy.mjs & + ready=0 + for i in $(seq 1 50); do + if curl -sf -X POST http://localhost:8899 \ +@@ -270,12 +270,12 @@ jobs: + with: + name: html-assets + - name: Install Surfnet helper dependencies +- working-directory: tests/interop ++ working-directory: harness + run: pnpm install --frozen-lockfile + - name: Start Surfnet + working-directory: . + run: | +- node tests/interop/start-surfnet-proxy.mjs & ++ node harness/start-surfnet-proxy.mjs & + ready=0 + for i in $(seq 1 50); do + if curl -sf -X POST http://localhost:8899 \ +@@ -344,12 +344,12 @@ jobs: + with: + name: html-assets + - name: Install Surfnet helper dependencies +- working-directory: tests/interop ++ working-directory: harness + run: pnpm install --frozen-lockfile + - name: Start Surfnet + working-directory: . + run: | +- node tests/interop/start-surfnet-proxy.mjs & ++ node harness/start-surfnet-proxy.mjs & + ready=0 + for i in $(seq 1 50); do + if curl -sf -X POST http://localhost:8899 \ +@@ -419,7 +419,7 @@ jobs: + working-directory: rust + run: cargo build -p solana-mpp --bin interop_client --bin interop_server + - name: Install interop harness +- working-directory: tests/interop ++ working-directory: harness + run: pnpm install --frozen-lockfile + - uses: ruby/setup-ruby@v1 + with: +@@ -427,34 +427,34 @@ jobs: + bundler-cache: true + working-directory: ruby + - name: Typecheck interop harness +- working-directory: tests/interop ++ working-directory: harness + run: pnpm typecheck + - name: Run Rust client interop smoke +- working-directory: tests/interop ++ working-directory: harness + run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "rust client pays typescript server" + env: + MPP_INTEROP_CLIENTS: rust + MPP_INTEROP_SERVERS: typescript + - name: Run Rust server interop smoke +- working-directory: tests/interop ++ working-directory: harness + run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "typescript client pays rust server" + env: + MPP_INTEROP_CLIENTS: typescript + MPP_INTEROP_SERVERS: rust + - name: Run Rust end-to-end interop smoke +- working-directory: tests/interop ++ working-directory: harness + run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "rust client pays rust server" + env: + MPP_INTEROP_CLIENTS: rust + MPP_INTEROP_SERVERS: rust + - name: Run Ruby server interop smoke +- working-directory: tests/interop ++ working-directory: harness + run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "typescript client pays ruby server" + env: + MPP_INTEROP_CLIENTS: typescript + MPP_INTEROP_SERVERS: ruby + - name: Run Rust client to Ruby server interop smoke +- working-directory: tests/interop ++ working-directory: harness + run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "rust client pays ruby server" + env: + MPP_INTEROP_CLIENTS: rust +diff --git a/.github/workflows/lua.yml b/.github/workflows/lua.yml +index e11a983..9bdb53e 100644 +--- a/.github/workflows/lua.yml ++++ b/.github/workflows/lua.yml +@@ -142,11 +142,11 @@ jobs: + run: cargo build --bin interop_client + + - name: Install interop harness deps +- working-directory: tests/interop ++ working-directory: harness + run: pnpm install --frozen-lockfile + + - name: TS-to-Lua focused matrix +- working-directory: tests/interop ++ working-directory: harness + env: + MPP_INTEROP_CLIENTS: typescript + MPP_INTEROP_SERVERS: lua +@@ -154,7 +154,7 @@ jobs: + run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "typescript client pays lua server" + + - name: Rust-to-Lua focused matrix +- working-directory: tests/interop ++ working-directory: harness + env: + MPP_INTEROP_CLIENTS: rust + MPP_INTEROP_SERVERS: lua +diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml +index d365cce..7fd7f5c 100644 +--- a/.github/workflows/php.yml ++++ b/.github/workflows/php.yml +@@ -70,23 +70,23 @@ jobs: + working-directory: rust + run: cargo build -p solana-mpp --bin interop_client + - name: Install interop harness +- working-directory: tests/interop ++ working-directory: harness + run: pnpm install --frozen-lockfile + - name: Install PHP interop dependencies + working-directory: php + run: composer install --no-interaction --no-progress + - name: Typecheck interop harness +- working-directory: tests/interop ++ working-directory: harness + run: pnpm typecheck + - name: Run PHP server interop smoke +- working-directory: tests/interop ++ working-directory: harness + run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "typescript client pays php server" + env: + MPP_INTEROP_CLIENTS: typescript + MPP_INTEROP_SERVERS: php + MPP_INTEROP_SCENARIOS: charge-basic,charge-split-ata,charge-network-mismatch,charge-cross-route-replay + - name: Run Rust client PHP server interop smoke +- working-directory: tests/interop ++ working-directory: harness + run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "rust client pays php server" + env: + MPP_INTEROP_CLIENTS: rust +diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml +index f9b5083..a0c45b3 100644 +--- a/.github/workflows/python.yml ++++ b/.github/workflows/python.yml +@@ -29,7 +29,7 @@ jobs: + run: pyright + - name: Run tests with coverage + working-directory: python +- # Coverage gate: line coverage at 90% (M1 baseline). Branch coverage gate is M2-followup, tracked in #108. ++ # Coverage gate: line coverage at 90%. Branch coverage gate is follow-up work, tracked in #108. + run: | + pytest \ + --cov=solana_mpp \ +@@ -77,7 +77,7 @@ jobs: + python -m pip install --upgrade pip + pip install -e ".[dev]" + # Critical order: install + build the TypeScript workspace BEFORE +- # installing the interop harness. tests/interop has ++ # installing the interop harness. harness has + # ``"@solana/mpp": "file:../../typescript/packages/mpp"`` which + # pnpm copies into node_modules at install time. If the typescript + # package has no dist/ at that moment, the TS interop client crashes +@@ -95,16 +95,16 @@ jobs: + working-directory: rust + run: cargo build --bin interop_client --bin interop_server + - name: Install interop harness +- working-directory: tests/interop ++ working-directory: harness + run: pnpm install --frozen-lockfile + - name: Focused TS-to-Python interop +- working-directory: tests/interop ++ working-directory: harness + env: + MPP_INTEROP_CLIENTS: typescript + MPP_INTEROP_SERVERS: python + run: pnpm exec vitest run test/e2e.test.ts + - name: Focused Rust-to-Python interop +- working-directory: tests/interop ++ working-directory: harness + env: + MPP_INTEROP_CLIENTS: rust + MPP_INTEROP_SERVERS: python +diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml +index c2e3a75..6fd110c 100644 +--- a/.github/workflows/swift.yml ++++ b/.github/workflows/swift.yml +@@ -44,25 +44,25 @@ jobs: + working-directory: typescript + run: pnpm --filter @solana/mpp build + - name: Install interop harness +- working-directory: tests/interop ++ working-directory: harness + run: pnpm install --frozen-lockfile + - name: Build Swift interop client +- working-directory: tests/interop/swift-client ++ working-directory: harness/swift-client + run: swift build + - name: Build Rust interop server + working-directory: rust + run: cargo build --bin interop_server + - name: Typecheck interop harness +- working-directory: tests/interop ++ working-directory: harness + run: pnpm typecheck + - name: Run Swift client interop smoke against TypeScript server +- working-directory: tests/interop ++ working-directory: harness + env: + MPP_INTEROP_CLIENTS: swift + MPP_INTEROP_SERVERS: typescript + run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "swift client pays typescript server" + - name: Run Swift client interop smoke against Rust server +- working-directory: tests/interop ++ working-directory: harness + env: + MPP_INTEROP_CLIENTS: swift + MPP_INTEROP_SERVERS: rust +diff --git a/.gitignore b/.gitignore +index 64b7fc7..a170fb4 100644 +--- a/.gitignore ++++ b/.gitignore +@@ -28,7 +28,7 @@ __pycache__/ + .coverage + .venv/ + *.pyc +-tests/interop/go-client/go-client ++harness/go-client/go-client + .claude/ + .gocache + mpp-sdk-self-learning/ +diff --git a/README.md b/README.md +index 7dcfe2f..52236a0 100644 +--- a/README.md ++++ b/README.md +@@ -62,9 +62,9 @@ The interop harness can run a full client/server cross-product, but CI keeps the + | Python | ![Python](https://img.shields.io/badge/coverage-87%25-green) | `just py-test` | + | Lua | ![Lua](https://img.shields.io/badge/coverage-41_tests-blue) | `just lua-test` | + | Ruby | ![Ruby](https://img.shields.io/badge/coverage-98%25-green) | `just rb-test-cover` | +-| Interop | ![Interop](https://img.shields.io/badge/interop-TypeScript_harness-brightgreen) | `cd tests/interop && pnpm test` | ++| Interop | ![Interop](https://img.shields.io/badge/interop-TypeScript_harness-brightgreen) | `cd harness && pnpm test` | + +-See [`tests/interop/README.md`](tests/interop/README.md) for the process adapter contract used by the Surfpool-backed client/server matrix. ++See [`harness/README.md`](harness/README.md) for the process adapter contract used by the Surfpool-backed client/server matrix. + + ## Install + +diff --git a/docs/security/compute-budget-caps.md b/docs/security/compute-budget-caps.md +index b2d971e..5616813 100644 +--- a/docs/security/compute-budget-caps.md ++++ b/docs/security/compute-budget-caps.md +@@ -53,7 +53,7 @@ this monorepo. + | Go (#101) | `go/server/server.go` (`maxComputeUnitLimit`) | pending PR #101 merge | + | Python (#106) | `python/src/solana_mpp/server/mpp.py` | pending PR #106 merge | + +-`tests/interop/test/compute-budget-caps.test.ts` parses each file above ++`harness/test/compute-budget-caps.test.ts` parses each file above + and asserts byte-identical literals against the canonical pair. Go and + Python rows are marked `optional: true` until their PRs land, then + flip to required and surface drift the same way as the other SDKs. +@@ -66,8 +66,8 @@ flip to required and surface drift the same way as the other SDKs. + code when either limit is exceeded; include the cap value in the + reason string for parity with the existing SDKs. + 3. Append a row to `SDKS` in +- `tests/interop/test/compute-budget-caps.test.ts` and to the table ++ `harness/test/compute-budget-caps.test.ts` and to the table + above. Append a fixture row to + `charge-compute-budget-over-cap` in +- `tests/interop/src/intents/charge.ts` once the SDK is wired into the ++ `harness/src/intents/charge.ts` once the SDK is wired into the + interop harness. +diff --git a/go/README.md b/go/README.md +index 744bbd1..72c7daf 100644 +--- a/go/README.md ++++ b/go/README.md +@@ -128,7 +128,7 @@ localnet fixture. + ## Running the interop adapters + + ```bash +-cd tests/interop/go-server ++cd harness/go-server + go run . # starts a Surfpool-backed protected endpoint on a random port + + cd ../go-client +@@ -193,7 +193,7 @@ same structural transaction verifier as pull mode, consumes the + signature through replay storage, and emits the same receipt shape. + + The direct Go interop server at +-[`tests/interop/go-server/main.go`](../tests/interop/go-server/main.go) ++[`harness/go-server/main.go`](../harness/go-server/main.go) + exercises this end-to-end through Surfpool for both TypeScript and Rust + clients. + +@@ -258,15 +258,15 @@ The CI Go job runs the SDK packages with `-coverprofile` and enforces a + + ## Interop + +-The cross-language interop harness lives in `../tests/interop`. The Go +-SDK ships both a client (`tests/interop/go-client`) and a server +-(`tests/interop/go-server`) adapter. Both are opt-in via the ++The cross-language interop harness lives in `../harness`. The Go ++SDK ships both a client (`harness/go-client`) and a server ++(`harness/go-server`) adapter. Both are opt-in via the + `MPP_INTEROP_CLIENTS` and `MPP_INTEROP_SERVERS` env vars. + + Focused harness commands: + + ```bash +-cd tests/interop ++cd harness + MPP_INTEROP_CLIENTS=go MPP_INTEROP_SERVERS=rust pnpm test + MPP_INTEROP_CLIENTS=typescript MPP_INTEROP_SERVERS=go pnpm test + MPP_INTEROP_CLIENTS=rust MPP_INTEROP_SERVERS=go pnpm test +diff --git a/go/mpp.go b/go/mpp.go +index e618006..4f8243b 100644 +--- a/go/mpp.go ++++ b/go/mpp.go +@@ -8,7 +8,7 @@ + // transaction builders live in the `client` subpackage. The wire format + // and module split mirror the Rust reference crate documented in + // skills/pay-sdk-implementation; cross-language behavior is locked via +-// the interop harness at tests/interop. ++// the interop harness at harness. + package mpp + + import ( +diff --git a/go/x402/README.md b/go/x402/README.md +new file mode 100644 +index 0000000..0f46eda +--- /dev/null ++++ b/go/x402/README.md +@@ -0,0 +1,81 @@ ++# Go x402 SDK ++ ++Go implementation of the x402 `exact` scheme (client + server) for Solana. ++ ++This sub-package mirrors the canonical Rust spine at `rust/crates/x402/` ++and ships the interop adapters used by the cross-language harness. ++ ++## Layout ++ ++```text ++go/x402/ ++├── cmd/ ++│ ├── interop-client/ interop harness client binary ++│ └── interop-server/ interop harness server binary ++└── README.md ++``` ++ ++The exact-scheme protocol types, verifier, and settler live inline in ++the two `main.go` files. The Rust crate keeps a separate ++`protocol/schemes/exact/`, `server/exact.rs`, `client/exact/payment.rs` ++split; the Go port keeps them inline because both binaries are ++self-contained and there is no third caller. The spine's wire format, ++constants, and pipeline ordering are mirrored 1:1. ++ ++## Test ++ ++```bash ++cd go ++go test ./x402/... -cover -race ++``` ++ ++Expected coverage: server ≥ 90 %, client ≥ 90 %. ++ ++## Format and vet ++ ++```bash ++gofmt -l go/x402/ ++go vet ./x402/... ++``` ++ ++## Parity with the Rust spine ++ ++The Go port matches `rust/crates/x402/` on: ++ ++- CAIP-2 network identifiers (`solana:5eykt...`, `solana:EtWTR...`, ++ `solana:4uhc...`) — verbatim. ++- Program IDs (Token, Token-2022, Associated Token, Compute Budget, ++ System, Memo, Lighthouse) — verbatim. ++- Stablecoin mint addresses per network (USDC/USDT/USDG/PYUSD/CASH) — ++ verbatim. ++- Constants: `EXACT_SCHEME = "exact"`, `maxMemoBytes = 256`. ++- Instruction allowlist: ComputeBudget (Set CU Limit + Price), SPL ++ Token / Token-2022 `TransferChecked`, plus optional Lighthouse + ++ Memo + ATA-create. ++- Lighthouse passthrough by program-ID match only (no discriminator ++ allowlist, no account-count cap) — spine parity. ++- Fee-payer-in-instruction-accounts sweep with the legitimate ++ ATA-create payer slot exception. ++- Destination ATA re-derived from `(payTo, mint, tokenProgram)` and ++ compared against the transaction's destination index. ++- L8 settlement ordering: broadcast → confirm → mark. ++- Cross-server credential rejection with canonical 4xx + token in body. ++- Env-var contract: `X402_INTEROP_TARGET_URL`, ++ `X402_INTEROP_RPC_URL`, `X402_INTEROP_NETWORK`, ++ `X402_INTEROP_CLIENT_SECRET_KEY`, ++ `X402_INTEROP_FACILITATOR_SECRET_KEY`, `X402_INTEROP_PAY_TO`, ++ `X402_INTEROP_MINT`, `X402_INTEROP_PRICE`, ++ `X402_INTEROP_EXTRA_OFFERED_MINTS` (CSV), ++ `X402_INTEROP_PREFER_CURRENCIES` (CSV). ++- Client `result` and server `ready` stdout JSON shapes. ++ ++Intentional Go-side specifics (not divergences): ++ ++- Mint alias resolution happens at the env-read boundary ++ (`X402_INTEROP_MINT` may be a symbol or base58); the rest of the ++ code sees canonical base58. The spine accepts the same pattern. ++- Duplicate-settlement cache keys are SHA-256 of the encoded ++ transaction, in addition to Solana's native per-signature ++ uniqueness — defense-in-depth, matches the upstream reference. ++ ++No upstream behavior changes vs the reference port (tip `e3bf746`). +diff --git a/go/x402/cmd/interop-client/challenge_test.go b/go/x402/cmd/interop-client/challenge_test.go +new file mode 100644 +index 0000000..6d22442 +--- /dev/null ++++ b/go/x402/cmd/interop-client/challenge_test.go +@@ -0,0 +1,1369 @@ ++package main ++ ++import ( ++ "encoding/base64" ++ "encoding/json" ++ "errors" ++ "io" ++ "net/http" ++ "os" ++ "strings" ++ "testing" ++ ++ "github.com/gagliardetto/solana-go" ++) ++ ++func TestSelectSVMRequirementFromPaymentRequiredHeader(t *testing.T) { ++ requirement := map[string]any{ ++ "scheme": "exact", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ "amount": "1000", ++ } ++ envelope, err := json.Marshal(map[string]any{ ++ "x402Version": 2, ++ "accepts": []map[string]any{requirement}, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ selected := selectSVMRequirement( ++ map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(envelope)}, ++ "", ++ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "exact", ++ ) ++ ++ if selected == nil { ++ t.Fatal("expected selected requirement") ++ } ++ if selected.Asset != requirement["asset"] { ++ t.Fatalf("unexpected asset: %s", selected.Asset) ++ } ++} ++ ++func TestSelectSVMRequirementFromBody(t *testing.T) { ++ body, err := json.Marshal(map[string]any{ ++ "accepts": []map[string]any{ ++ { ++ "scheme": "exact", ++ "network": "eip155:8453", ++ "asset": "0x0000000000000000000000000000000000000000", ++ "amount": "1000", ++ }, ++ { ++ "scheme": "exact", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ "amount": "1000", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ selected := selectSVMRequirement( ++ map[string]string{}, ++ string(body), ++ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "exact", ++ ) ++ ++ if selected == nil { ++ t.Fatal("expected selected requirement") ++ } ++ if selected.Network != "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" { ++ t.Fatalf("unexpected network: %s", selected.Network) ++ } ++} ++ ++func TestSelectSVMRequirementIgnoresUnsupportedScheme(t *testing.T) { ++ body, err := json.Marshal(map[string]any{ ++ "accepts": []map[string]any{ ++ { ++ "scheme": "upto", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ "amount": "1000", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ selected := selectSVMRequirement( ++ map[string]string{}, ++ string(body), ++ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "exact", ++ ) ++ ++ if selected != nil { ++ t.Fatalf("expected no selected requirement, got %+v", selected) ++ } ++} ++ ++func TestSelectSVMRequirementSupportsRequestedUptoScheme(t *testing.T) { ++ body, err := json.Marshal(map[string]any{ ++ "accepts": []map[string]any{ ++ { ++ "scheme": "upto", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ "amount": "1000", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ selected := selectSVMRequirement( ++ map[string]string{}, ++ string(body), ++ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "upto", ++ ) ++ ++ if selected == nil { ++ t.Fatal("expected selected upto requirement") ++ } ++ if selected.Scheme != "upto" { ++ t.Fatalf("unexpected scheme: %s", selected.Scheme) ++ } ++} ++ ++func TestSelectSVMChallengeHonorsPreferredCurrencyOrder(t *testing.T) { ++ body, err := json.Marshal(map[string]any{ ++ "accepts": []map[string]any{ ++ { ++ "scheme": "exact", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ "amount": "1000", ++ }, ++ { ++ "scheme": "exact", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", ++ "amount": "1000", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ selected, _ := selectSVMChallengeWithPreferences( ++ map[string]string{}, ++ string(body), ++ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "exact", ++ []string{"PYUSD", "USDC"}, ++ ) ++ ++ if selected == nil { ++ t.Fatal("expected selected requirement") ++ } ++ if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { ++ t.Fatalf("expected PYUSD mint, got %s", selected.Asset) ++ } ++} ++ ++func TestSelectSVMChallengeReturnsNilWhenPreferredCurrenciesDoNotMatch(t *testing.T) { ++ body, err := json.Marshal(map[string]any{ ++ "accepts": []map[string]any{ ++ { ++ "scheme": "exact", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ "amount": "1000", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ selected, _ := selectSVMChallengeWithPreferences( ++ map[string]string{}, ++ string(body), ++ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "exact", ++ []string{"PYUSD"}, ++ ) ++ ++ if selected != nil { ++ t.Fatalf("expected no selected requirement, got %+v", selected) ++ } ++} ++ ++func TestSelectSVMChallengeChecksBodyWhenHeaderPreferencesDoNotMatch(t *testing.T) { ++ headerEnvelope, err := json.Marshal(map[string]any{ ++ "accepts": []map[string]any{ ++ { ++ "scheme": "exact", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ "amount": "1000", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ body, err := json.Marshal(map[string]any{ ++ "resource": map[string]any{"uri": "/body"}, ++ "accepts": []map[string]any{ ++ { ++ "scheme": "exact", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", ++ "amount": "1000", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ selected, resource := selectSVMChallengeWithPreferences( ++ map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, ++ string(body), ++ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "exact", ++ []string{"PYUSD"}, ++ ) ++ ++ if selected == nil { ++ t.Fatal("expected selected requirement from body") ++ } ++ if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { ++ t.Fatalf("expected body PYUSD mint, got %s", selected.Asset) ++ } ++ if resource["uri"] != "/body" { ++ t.Fatalf("expected body resource, got %#v", resource) ++ } ++} ++ ++func TestSelectSVMChallengeWithoutPreferencesPicksCheapestAmount(t *testing.T) { ++ body, err := json.Marshal(map[string]any{ ++ "accepts": []map[string]any{ ++ { ++ "scheme": "exact", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ "amount": "1000000", ++ }, ++ { ++ "scheme": "exact", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "So11111111111111111111111111111111111111112", ++ "amount": "5000", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ selected, _ := selectSVMChallengeWithPreferences( ++ map[string]string{}, ++ string(body), ++ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "exact", ++ nil, ++ ) ++ ++ if selected == nil { ++ t.Fatal("expected selected requirement") ++ } ++ if selected.Asset != "So11111111111111111111111111111111111111112" { ++ t.Fatalf("expected cheapest offer, got %s", selected.Asset) ++ } ++} ++ ++func TestSelectSVMChallengeSkipsIncompleteAndMalformedCandidates(t *testing.T) { ++ body, err := json.Marshal(map[string]any{ ++ "accepts": []map[string]any{ ++ { ++ "scheme": "exact", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "", ++ "amount": "1", ++ }, ++ { ++ "scheme": "exact", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "So11111111111111111111111111111111111111112", ++ "amount": "not-int", ++ }, ++ { ++ "scheme": "exact", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ "amount": "1000", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ selected, _ := selectSVMChallengeWithPreferences( ++ map[string]string{}, ++ string(body), ++ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "exact", ++ nil, ++ ) ++ ++ if selected == nil { ++ t.Fatal("expected selected requirement") ++ } ++ if selected.Asset != "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" { ++ t.Fatalf("expected valid cheapest candidate, got %+v", selected) ++ } ++} ++ ++func TestSelectSVMChallengeUsesCurrencyPreferencesFromEnv(t *testing.T) { ++ t.Setenv("X402_INTEROP_PREFER_CURRENCIES", " PYUSD, USDC ,,") ++ body, err := json.Marshal(map[string]any{ ++ "resource": map[string]any{"uri": "/protected"}, ++ "accepts": []map[string]any{ ++ { ++ "scheme": "exact", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ "amount": "1000", ++ }, ++ { ++ "scheme": "exact", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", ++ "amount": "2000", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ selected, resource := selectSVMChallenge( ++ map[string]string{}, ++ string(body), ++ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "exact", ++ ) ++ ++ if selected == nil { ++ t.Fatal("expected selected requirement") ++ } ++ if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { ++ t.Fatalf("expected PYUSD preference to win, got %s", selected.Asset) ++ } ++ if resource["uri"] != "/protected" { ++ t.Fatalf("expected resource to be returned, got %+v", resource) ++ } ++} ++ ++func TestPaymentRequiredLoadersRejectMalformedInputs(t *testing.T) { ++ if envelope := loadPaymentRequiredHeader(map[string]string{"payment-required": "not base64"}); envelope != nil { ++ t.Fatalf("expected invalid base64 header to return nil") ++ } ++ encodedInvalidJSON := base64.StdEncoding.EncodeToString([]byte("{")) ++ if envelope := loadPaymentRequiredHeader(map[string]string{"payment-required": encodedInvalidJSON}); envelope != nil { ++ t.Fatalf("expected invalid JSON header to return nil") ++ } ++ if envelope := loadPaymentRequiredBody("{"); envelope != nil { ++ t.Fatalf("expected invalid JSON body to return nil") ++ } ++ if envelope := loadPaymentRequiredBody(""); envelope != nil { ++ t.Fatalf("expected empty body to return nil") ++ } ++} ++ ++func TestResolveStablecoinMintCanonicalAliases(t *testing.T) { ++ tests := map[string]struct { ++ currency string ++ network string ++ want string ++ }{ ++ "devnet USD alias": { ++ currency: " usd ", ++ network: "devnet", ++ want: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ }, ++ "mainnet PYUSD": { ++ currency: "PYUSD", ++ network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", ++ want: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", ++ }, ++ "localnet USDG": { ++ currency: "USDG", ++ network: "localnet", ++ want: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", ++ }, ++ "USDT": { ++ currency: "USDT", ++ network: "devnet", ++ want: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", ++ }, ++ "CASH": { ++ currency: "CASH", ++ network: "devnet", ++ want: "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH", ++ }, ++ "mint passthrough": { ++ currency: " So11111111111111111111111111111111111111112 ", ++ network: "devnet", ++ want: "So11111111111111111111111111111111111111112", ++ }, ++ } ++ ++ for name, test := range tests { ++ t.Run(name, func(t *testing.T) { ++ if got := resolveStablecoinMint(test.currency, test.network); got != test.want { ++ t.Fatalf("resolveStablecoinMint() = %q, want %q", got, test.want) ++ } ++ }) ++ } ++} ++ ++func TestRequirementExtraParsersValidateTypes(t *testing.T) { ++ requirement := paymentRequirement{ ++ Extra: map[string]any{ ++ "decimalsFloat": float64(6), ++ "decimalsText": "9", ++ "tokenProgram": solana.TokenProgramID.String(), ++ "badInteger": "not-int", ++ "badString": 12, ++ "emptyString": "", ++ }, ++ } ++ ++ if got, err := intFromRequirement(requirement, "decimalsFloat"); err != nil || got != 6 { ++ t.Fatalf("float integer = %d, %v", got, err) ++ } ++ if got, err := intFromRequirement(requirement, "decimalsText"); err != nil || got != 9 { ++ t.Fatalf("string integer = %d, %v", got, err) ++ } ++ if _, err := intFromRequirement(requirement, "missing"); err == nil { ++ t.Fatal("expected missing integer error") ++ } ++ if _, err := intFromRequirement(requirement, "badInteger"); err == nil { ++ t.Fatal("expected invalid integer error") ++ } ++ if _, err := intFromRequirement(paymentRequirement{Extra: map[string]any{"bad": true}}, "bad"); err == nil { ++ t.Fatal("expected invalid integer type error") ++ } ++ if got, err := stringFromExtra(requirement, "tokenProgram"); err != nil || got != solana.TokenProgramID.String() { ++ t.Fatalf("string extra = %q, %v", got, err) ++ } ++ if _, err := stringFromExtra(requirement, "missing"); err == nil { ++ t.Fatal("expected missing string error") ++ } ++ if _, err := stringFromExtra(requirement, "badString"); err == nil { ++ t.Fatal("expected invalid string type error") ++ } ++ if _, err := stringFromExtra(requirement, "emptyString"); err == nil { ++ t.Fatal("expected empty string error") ++ } ++} ++ ++func TestKeypairFromJSONSecretValidatesShape(t *testing.T) { ++ privateKey, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ encoded, err := json.Marshal([]byte(privateKey)) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ decoded, err := keypairFromJSONSecret(string(encoded)) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if !decoded.PublicKey().Equals(privateKey.PublicKey()) { ++ t.Fatalf("decoded key does not match original") ++ } ++ if _, err := keypairFromJSONSecret("{"); err == nil { ++ t.Fatal("expected JSON decode error") ++ } ++ if _, err := keypairFromJSONSecret("[1,2,3]"); err == nil { ++ t.Fatal("expected length validation error") ++ } ++} ++ ++func TestLatestBlockhashHandlesJSONRPCResponses(t *testing.T) { ++ originalHTTPClient := httpClient ++ defer func() { ++ httpClient = originalHTTPClient ++ }() ++ ++ blockhash := solana.Hash{}.String() ++ httpClient = &http.Client{Transport: clientRoundTripFunc(func(request *http.Request) (*http.Response, error) { ++ rawBody, err := io.ReadAll(request.Body) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if !strings.Contains(string(rawBody), `"method":"getLatestBlockhash"`) { ++ t.Fatalf("unexpected RPC body: %s", string(rawBody)) ++ } ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"value":{"blockhash":"` + blockhash + `"}}}`)), ++ }, nil ++ })} ++ ++ got, err := latestBlockhash("http://rpc.test") ++ if err != nil { ++ t.Fatal(err) ++ } ++ if got.String() != blockhash { ++ t.Fatalf("latestBlockhash = %s, want %s", got, blockhash) ++ } ++ ++ httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"error":{"message":"nope"}}`)), ++ }, nil ++ })} ++ if _, err := latestBlockhash("http://rpc.test"); err == nil { ++ t.Fatal("expected RPC error") ++ } ++ ++ httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { ++ return &http.Response{ ++ StatusCode: http.StatusBadGateway, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(`bad gateway`)), ++ }, nil ++ })} ++ if _, err := latestBlockhash("http://rpc.test"); err == nil { ++ t.Fatal("expected HTTP error") ++ } ++ ++ httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(`{`)), ++ }, nil ++ })} ++ if _, err := latestBlockhash("http://rpc.test"); err == nil { ++ t.Fatal("expected invalid JSON error") ++ } ++} ++ ++func TestLatestBlockhashReturnsTransportErrors(t *testing.T) { ++ originalHTTPClient := httpClient ++ defer func() { ++ httpClient = originalHTTPClient ++ }() ++ ++ httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { ++ return nil, errors.New("rpc unavailable") ++ })} ++ if _, err := latestBlockhash("http://rpc.test"); err == nil { ++ t.Fatal("expected transport error") ++ } ++} ++ ++func TestTransferCheckedInstructionRejectsMalformedRequirement(t *testing.T) { ++ signer := solana.NewWallet().PublicKey() ++ base := paymentRequirement{ ++ Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ Amount: "1000", ++ PayTo: solana.NewWallet().PublicKey().String(), ++ } ++ ++ tests := map[string]paymentRequirement{ ++ "amount": func() paymentRequirement { ++ requirement := base ++ requirement.Amount = "not-int" ++ return requirement ++ }(), ++ "asset": func() paymentRequirement { ++ requirement := base ++ requirement.Asset = "not-base58" ++ return requirement ++ }(), ++ "payTo": func() paymentRequirement { ++ requirement := base ++ requirement.PayTo = "not-base58" ++ return requirement ++ }(), ++ } ++ ++ for name, requirement := range tests { ++ t.Run(name, func(t *testing.T) { ++ if _, err := transferCheckedInstruction(requirement, signer, 6, solana.TokenProgramID); err == nil { ++ t.Fatal("expected malformed requirement to be rejected") ++ } ++ }) ++ } ++} ++ ++func TestReadResponseAndParseResponseBody(t *testing.T) { ++ response := &http.Response{ ++ Header: http.Header{ ++ "X-Test": []string{"first", "second"}, ++ }, ++ Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), ++ } ++ ++ headers, body, err := readResponse(response) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if headers["X-Test"] != "first" { ++ t.Fatalf("expected first header value, got %q", headers["X-Test"]) ++ } ++ if body != `{"ok":true}` { ++ t.Fatalf("unexpected body: %s", body) ++ } ++ parsed, ok := parseResponseBody(body).(map[string]any) ++ if !ok || parsed["ok"] != true { ++ t.Fatalf("expected JSON body to parse, got %#v", parsed) ++ } ++ if got := parseResponseBody("not json"); got != "not json" { ++ t.Fatalf("expected invalid JSON body passthrough, got %#v", got) ++ } ++ t.Setenv("X402_TEST_DEFAULT", "configured") ++ if got := readEnvWithDefault("X402_TEST_DEFAULT", "fallback"); got != "configured" { ++ t.Fatalf("readEnvWithDefault configured = %q", got) ++ } ++ if got := readEnvWithDefault("X402_TEST_MISSING", "fallback"); got != "fallback" { ++ t.Fatalf("readEnvWithDefault fallback = %q", got) ++ } ++} ++ ++func TestMainReportsUnimplementedChallengeResult(t *testing.T) { ++ originalHTTPClient := httpClient ++ defer func() { ++ httpClient = originalHTTPClient ++ }() ++ httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { ++ return &http.Response{ ++ StatusCode: http.StatusPaymentRequired, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(`{"accepts":[{"scheme":"upto","network":"solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1","asset":"4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU","amount":"1000"}]}`)), ++ }, nil ++ })} ++ ++ t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") ++ t.Setenv("X402_INTEROP_SCHEME", "upto") ++ ++ output := captureStdoutForTest(t, main) ++ var payload map[string]any ++ if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil { ++ t.Fatal(err) ++ } ++ if payload["implementation"] != "go" || payload["role"] != "client" || payload["ok"] != false { ++ t.Fatalf("unexpected result payload: %#v", payload) ++ } ++ body := payload["responseBody"].(map[string]any) ++ if body["error"] != "go_upto_client_not_implemented" { ++ t.Fatalf("unexpected error domain: %#v", body) ++ } ++} ++ ++func TestMainPanicsWhenTargetURLMissing(t *testing.T) { ++ mustPanicClient(t, main) ++} ++ ++func TestMainPanicsWhenChallengeRequestFails(t *testing.T) { ++ originalHTTPClient := httpClient ++ defer func() { ++ httpClient = originalHTTPClient ++ }() ++ httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { ++ return nil, errors.New("network down") ++ })} ++ ++ t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") ++ ++ mustPanicClient(t, main) ++} ++ ++func TestMainReportsExactPaymentBuildFailure(t *testing.T) { ++ requirement := map[string]any{ ++ "scheme": "exact", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ "amount": "1000", ++ "payTo": solana.NewWallet().PublicKey().String(), ++ "extra": map[string]any{ ++ "decimals": 6, ++ "feePayer": solana.NewWallet().PublicKey().String(), ++ "tokenProgram": solana.TokenProgramID.String(), ++ }, ++ } ++ challenge, err := json.Marshal(paymentEnvelope{ ++ Accepts: []paymentRequirement{{ ++ Scheme: requirement["scheme"].(string), ++ Network: requirement["network"].(string), ++ Asset: requirement["asset"].(string), ++ Amount: requirement["amount"].(string), ++ PayTo: requirement["payTo"].(string), ++ Extra: requirement["extra"].(map[string]any), ++ }}, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ originalHTTPClient := httpClient ++ defer func() { ++ httpClient = originalHTTPClient ++ }() ++ httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { ++ return &http.Response{ ++ StatusCode: http.StatusPaymentRequired, ++ Header: http.Header{ ++ "PAYMENT-REQUIRED": []string{base64.StdEncoding.EncodeToString(challenge)}, ++ "content-type": []string{"application/json"}, ++ }, ++ Body: io.NopCloser(strings.NewReader(`{"error":"payment_required"}`)), ++ }, nil ++ })} ++ ++ t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") ++ t.Setenv("X402_INTEROP_CLIENT_SECRET_KEY", "{") ++ t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") ++ ++ output := captureStdoutForTest(t, main) ++ var payload map[string]any ++ if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil { ++ t.Fatal(err) ++ } ++ if payload["ok"] != false || payload["status"] != float64(http.StatusPaymentRequired) { ++ t.Fatalf("unexpected payment failure result: %#v", payload) ++ } ++ body := payload["responseBody"].(map[string]any) ++ if body["error"] != "go_exact_client_payment_failed" { ++ t.Fatalf("unexpected payment failure body: %#v", body) ++ } ++} ++ ++func TestMainPaysExactChallengeAndReportsSettlement(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ encodedClientKey, err := json.Marshal([]byte(client)) ++ if err != nil { ++ t.Fatal(err) ++ } ++ feePayer := solana.NewWallet().PublicKey() ++ payTo := solana.NewWallet().PublicKey() ++ challenge, err := json.Marshal(paymentEnvelope{ ++ Accepts: []paymentRequirement{ ++ { ++ Scheme: "exact", ++ Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ Amount: "1000", ++ PayTo: payTo.String(), ++ Extra: map[string]any{ ++ "decimals": float64(6), ++ "feePayer": feePayer.String(), ++ "tokenProgram": solana.TokenProgramID.String(), ++ "recentBlockhash": solana.Hash{}.String(), ++ "memo": "unit-main-success", ++ }, ++ }, ++ }, ++ Resource: map[string]any{"uri": "/protected"}, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ originalHTTPClient := httpClient ++ defer func() { ++ httpClient = originalHTTPClient ++ }() ++ requests := 0 ++ httpClient = &http.Client{Transport: clientRoundTripFunc(func(request *http.Request) (*http.Response, error) { ++ requests++ ++ if requests == 1 { ++ return &http.Response{ ++ StatusCode: http.StatusPaymentRequired, ++ Header: http.Header{ ++ "PAYMENT-REQUIRED": []string{base64.StdEncoding.EncodeToString(challenge)}, ++ "content-type": []string{"application/json"}, ++ }, ++ Body: io.NopCloser(strings.NewReader(`{"error":"payment_required"}`)), ++ }, nil ++ } ++ if got := request.Header.Get("PAYMENT-SIGNATURE"); got == "" { ++ t.Fatal("expected PAYMENT-SIGNATURE on paid retry") ++ } ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{ ++ "x-fixture-settlement": []string{"unit-settlement"}, ++ "content-type": []string{"application/json"}, ++ }, ++ Body: io.NopCloser(strings.NewReader(`{"ok":true,"paid":true}`)), ++ }, nil ++ })} ++ ++ t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") ++ t.Setenv("X402_INTEROP_CLIENT_SECRET_KEY", string(encodedClientKey)) ++ t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") ++ ++ output := captureStdoutForTest(t, main) ++ var payload map[string]any ++ if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil { ++ t.Fatal(err) ++ } ++ if payload["ok"] != true || payload["status"] != float64(http.StatusOK) || payload["settlement"] != "unit-settlement" { ++ t.Fatalf("unexpected paid result: %#v", payload) ++ } ++ if requests != 2 { ++ t.Fatalf("expected challenge request plus paid retry, got %d", requests) ++ } ++} ++ ++func TestBuildExactPaymentSignatureEnvelope(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ feePayer, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ payTo, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ requirement := paymentRequirement{ ++ Scheme: "exact", ++ Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ Amount: "1000", ++ PayTo: payTo.PublicKey().String(), ++ MaxTimeoutSeconds: 60, ++ Extra: map[string]any{ ++ "feePayer": feePayer.PublicKey().String(), ++ "decimals": float64(6), ++ "tokenProgram": solana.TokenProgramID.String(), ++ "recentBlockhash": solana.Hash{}.String(), ++ "memo": "unit-test", ++ }, ++ } ++ resource := map[string]any{ ++ "url": "/protected", ++ "description": "test", ++ } ++ ++ header, err := buildExactPaymentSignature(requirement, resource, client, "http://127.0.0.1:8899") ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ decoded, err := base64.StdEncoding.DecodeString(header) ++ if err != nil { ++ t.Fatal(err) ++ } ++ var envelope paymentSignatureEnvelope ++ if err := json.Unmarshal(decoded, &envelope); err != nil { ++ t.Fatal(err) ++ } ++ if envelope.X402Version != 2 { ++ t.Fatalf("unexpected x402Version: %d", envelope.X402Version) ++ } ++ if envelope.Accepted.MaxTimeoutSeconds != requirement.MaxTimeoutSeconds { ++ t.Fatalf("accepted did not preserve maxTimeoutSeconds") ++ } ++ if envelope.Payload["transaction"] == "" { ++ t.Fatalf("expected transaction payload") ++ } ++ ++ tx := new(solana.Transaction) ++ if err := tx.UnmarshalBase64(envelope.Payload["transaction"]); err != nil { ++ t.Fatal(err) ++ } ++ if !tx.Message.IsVersioned() { ++ t.Fatalf("expected v0 transaction") ++ } ++ ++ signerIndex := -1 ++ feePayerIndex := -1 ++ for index, key := range tx.Message.AccountKeys { ++ if key.Equals(client.PublicKey()) { ++ signerIndex = index ++ } ++ if key.Equals(feePayer.PublicKey()) { ++ feePayerIndex = index ++ } ++ } ++ if signerIndex < 0 { ++ t.Fatalf("client signer missing from transaction") ++ } ++ if feePayerIndex < 0 { ++ t.Fatalf("fee payer missing from transaction") ++ } ++ if tx.Signatures[feePayerIndex] != (solana.Signature{}) { ++ t.Fatalf("fee payer signature should remain default") ++ } ++ message, err := tx.Message.MarshalBinary() ++ if err != nil { ++ t.Fatal(err) ++ } ++ if !tx.Signatures[signerIndex].Verify(client.PublicKey(), message) { ++ t.Fatalf("client signature did not verify") ++ } ++} ++ ++func TestBuildExactPaymentSignatureFetchesRecentBlockhashWhenMissing(t *testing.T) { ++ originalHTTPClient := httpClient ++ defer func() { ++ httpClient = originalHTTPClient ++ }() ++ blockhash := solana.Hash{}.String() ++ httpClient = &http.Client{Transport: clientRoundTripFunc(func(request *http.Request) (*http.Response, error) { ++ rawBody, err := io.ReadAll(request.Body) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if !strings.Contains(string(rawBody), `"method":"getLatestBlockhash"`) { ++ t.Fatalf("unexpected RPC body: %s", string(rawBody)) ++ } ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"value":{"blockhash":"` + blockhash + `"}}}`)), ++ }, nil ++ })} ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ requirement := paymentRequirement{ ++ Scheme: "exact", ++ Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ Amount: "1000", ++ PayTo: solana.NewWallet().PublicKey().String(), ++ Extra: map[string]any{ ++ "feePayer": solana.NewWallet().PublicKey().String(), ++ "decimals": float64(6), ++ "tokenProgram": solana.TokenProgramID.String(), ++ "memo": "unit-fetch-blockhash", ++ }, ++ } ++ ++ header, err := buildExactPaymentSignature(requirement, nil, client, "http://rpc.test") ++ if err != nil { ++ t.Fatal(err) ++ } ++ if header == "" { ++ t.Fatal("expected payment signature") ++ } ++} ++ ++func TestBuildExactPaymentSignatureRejectsInvalidRequirements(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ requirement := paymentRequirement{ ++ Scheme: "exact", ++ Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ Amount: "1000", ++ PayTo: solana.NewWallet().PublicKey().String(), ++ Extra: map[string]any{ ++ "feePayer": solana.NewWallet().PublicKey().String(), ++ "decimals": float64(6), ++ "tokenProgram": solana.TokenProgramID.String(), ++ "recentBlockhash": solana.Hash{}.String(), ++ "memo": "unit-test", ++ }, ++ } ++ ++ tests := map[string]func(paymentRequirement) paymentRequirement{ ++ "scheme": func(value paymentRequirement) paymentRequirement { ++ value.Scheme = "upto" ++ return value ++ }, ++ "missing decimals": func(value paymentRequirement) paymentRequirement { ++ value.Extra = cloneClientExtra(value.Extra) ++ delete(value.Extra, "decimals") ++ return value ++ }, ++ "invalid token program": func(value paymentRequirement) paymentRequirement { ++ value.Extra = cloneClientExtra(value.Extra) ++ value.Extra["tokenProgram"] = "not-base58" ++ return value ++ }, ++ "invalid fee payer": func(value paymentRequirement) paymentRequirement { ++ value.Extra = cloneClientExtra(value.Extra) ++ value.Extra["feePayer"] = "not-base58" ++ return value ++ }, ++ "invalid blockhash": func(value paymentRequirement) paymentRequirement { ++ value.Extra = cloneClientExtra(value.Extra) ++ value.Extra["recentBlockhash"] = "not-base58" ++ return value ++ }, ++ "invalid amount": func(value paymentRequirement) paymentRequirement { ++ value.Amount = "not-int" ++ return value ++ }, ++ "invalid payTo": func(value paymentRequirement) paymentRequirement { ++ value.PayTo = "not-base58" ++ return value ++ }, ++ } ++ ++ for name, mutate := range tests { ++ t.Run(name, func(t *testing.T) { ++ if _, err := buildExactPaymentSignature(mutate(requirement), nil, client, "http://127.0.0.1:8899"); err == nil { ++ t.Fatal("expected invalid requirement to be rejected") ++ } ++ }) ++ } ++} ++ ++func TestBuildExactPaymentSignatureGeneratesUniqueDefaultMemos(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ feePayer, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ payTo, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ requirement := paymentRequirement{ ++ Scheme: "exact", ++ Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ Amount: "1000", ++ PayTo: payTo.PublicKey().String(), ++ Extra: map[string]any{ ++ "feePayer": feePayer.PublicKey().String(), ++ "decimals": float64(6), ++ "tokenProgram": solana.TokenProgramID.String(), ++ "recentBlockhash": solana.Hash{}.String(), ++ }, ++ } ++ ++ firstHeader, err := buildExactPaymentSignature(requirement, nil, client, "http://127.0.0.1:8899") ++ if err != nil { ++ t.Fatal(err) ++ } ++ secondHeader, err := buildExactPaymentSignature(requirement, nil, client, "http://127.0.0.1:8899") ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ firstMemo := memoFromPaymentHeaderForTest(t, firstHeader) ++ secondMemo := memoFromPaymentHeaderForTest(t, secondHeader) ++ if firstHeader == secondHeader { ++ t.Fatal("expected unique payment headers") ++ } ++ if firstMemo == secondMemo { ++ t.Fatalf("expected unique default memos, got %q", firstMemo) ++ } ++ if len(firstMemo) != 32 || len(secondMemo) != 32 { ++ t.Fatalf("expected 32 byte hex memos, got %d and %d", len(firstMemo), len(secondMemo)) ++ } ++ if strings.Trim(firstMemo+secondMemo, "0123456789abcdef") != "" { ++ t.Fatalf("expected lowercase hex memos, got %q and %q", firstMemo, secondMemo) ++ } ++} ++ ++func TestBuildExactPaymentSignatureRejectsMemoAboveReferenceLimit(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ feePayer, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ payTo, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ requirement := paymentRequirement{ ++ Scheme: "exact", ++ Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ Amount: "1000", ++ PayTo: payTo.PublicKey().String(), ++ Extra: map[string]any{ ++ "feePayer": feePayer.PublicKey().String(), ++ "decimals": float64(6), ++ "tokenProgram": solana.TokenProgramID.String(), ++ "recentBlockhash": solana.Hash{}.String(), ++ "memo": strings.Repeat("x", maxMemoBytes+1), ++ }, ++ } ++ ++ _, err = buildExactPaymentSignature(requirement, nil, client, "http://127.0.0.1:8899") ++ if err == nil { ++ t.Fatal("expected memo length error") ++ } ++ if err.Error() != "extra.memo exceeds maximum 256 bytes" { ++ t.Fatalf("unexpected error: %v", err) ++ } ++} ++ ++func memoFromPaymentHeaderForTest(t *testing.T, header string) string { ++ t.Helper() ++ decoded, err := base64.StdEncoding.DecodeString(header) ++ if err != nil { ++ t.Fatal(err) ++ } ++ var envelope paymentSignatureEnvelope ++ if err := json.Unmarshal(decoded, &envelope); err != nil { ++ t.Fatal(err) ++ } ++ tx := new(solana.Transaction) ++ if err := tx.UnmarshalBase64(envelope.Payload["transaction"]); err != nil { ++ t.Fatal(err) ++ } ++ for _, instruction := range tx.Message.Instructions { ++ program, err := tx.Message.Program(instruction.ProgramIDIndex) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if program.Equals(memoProgramID) { ++ return string(instruction.Data) ++ } ++ } ++ t.Fatal("memo instruction missing") ++ return "" ++} ++ ++type clientRoundTripFunc func(*http.Request) (*http.Response, error) ++ ++func (fn clientRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { ++ return fn(request) ++} ++ ++func cloneClientExtra(extra map[string]any) map[string]any { ++ cloned := make(map[string]any, len(extra)) ++ for key, value := range extra { ++ cloned[key] = value ++ } ++ return cloned ++} ++ ++func captureStdoutForTest(t *testing.T, fn func()) string { ++ t.Helper() ++ original := os.Stdout ++ reader, writer, err := os.Pipe() ++ if err != nil { ++ t.Fatal(err) ++ } ++ os.Stdout = writer ++ defer func() { ++ os.Stdout = original ++ }() ++ ++ fn() ++ if err := writer.Close(); err != nil { ++ t.Fatal(err) ++ } ++ output, err := io.ReadAll(reader) ++ if err != nil { ++ t.Fatal(err) ++ } ++ return string(output) ++} ++ ++func mustPanicClient(t *testing.T, fn func()) { ++ t.Helper() ++ defer func() { ++ if recover() == nil { ++ t.Fatal("expected panic") ++ } ++ }() ++ fn() ++} ++ ++// --- Greptile PR #18 follow-up: cross-envelope preference / fallback parity --- ++// ++// These three tests pin the cross-envelope behavior Greptile flagged as ++// "absent regression coverage". They exercise the boundary between header and ++// body envelopes — both with and without a currency preference — so future ++// refactors can't silently regress the fallback path. ++ ++// TestSelectSVMChallengeFallsBackToBodyWhenHeaderPreferenceMisses verifies ++// that when the PAYMENT-REQUIRED header offers only USDC but the body offers ++// PYUSD and the caller prefers ["PYUSD"], the client falls through the header ++// envelope and selects the PYUSD entry from the body envelope. ++func TestSelectSVMChallengeFallsBackToBodyWhenHeaderPreferenceMisses(t *testing.T) { ++ network := "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" ++ headerEnvelope, err := json.Marshal(map[string]any{ ++ "resource": map[string]any{"uri": "/header"}, ++ "accepts": []map[string]any{ ++ { ++ "scheme": "exact", ++ "network": network, ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // devnet USDC ++ "amount": "1000", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ body, err := json.Marshal(map[string]any{ ++ "resource": map[string]any{"uri": "/body"}, ++ "accepts": []map[string]any{ ++ { ++ "scheme": "exact", ++ "network": network, ++ "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", // devnet PYUSD ++ "amount": "2000", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ selected, resource := selectSVMChallengeWithPreferences( ++ map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, ++ string(body), ++ network, ++ "exact", ++ []string{"PYUSD"}, ++ ) ++ ++ if selected == nil { ++ t.Fatal("expected fallback selection from body envelope") ++ } ++ if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { ++ t.Fatalf("expected body PYUSD mint, got %s", selected.Asset) ++ } ++ if resource["uri"] != "/body" { ++ t.Fatalf("expected body resource attribution, got %#v", resource) ++ } ++} ++ ++// TestSelectSVMChallengeReturnsNilWhenNoEnvelopeMatchesPreference verifies ++// that a strict preference list with no match across any envelope returns nil ++// rather than silently downgrading to "any" selection. This locks the caller's ++// opt-in: if you said "I only accept BOGUS", you get nothing, not USDC. ++func TestSelectSVMChallengeReturnsNilWhenNoEnvelopeMatchesPreference(t *testing.T) { ++ network := "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" ++ headerEnvelope, err := json.Marshal(map[string]any{ ++ "accepts": []map[string]any{ ++ { ++ "scheme": "exact", ++ "network": network, ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // USDC ++ "amount": "1000", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ body, err := json.Marshal(map[string]any{ ++ "accepts": []map[string]any{ ++ { ++ "scheme": "exact", ++ "network": network, ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // USDC ++ "amount": "1500", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ selected, resource := selectSVMChallengeWithPreferences( ++ map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, ++ string(body), ++ network, ++ "exact", ++ []string{"BOGUS"}, ++ ) ++ ++ if selected != nil { ++ t.Fatalf("expected nil selection for unmet preference, got %+v", selected) ++ } ++ if resource != nil { ++ t.Fatalf("expected nil resource for unmet preference, got %#v", resource) ++ } ++} ++ ++// TestSelectSVMChallengePicksCheapestAcrossEnvelopesWhenNoPreference verifies ++// that, when no preference is supplied, the selector aggregates valid ++// candidates across the header and body envelopes and picks the globally ++// cheapest amount — not merely the cheapest within the first envelope it sees. ++// Header: 2000 USDC. Body: 1000 PYUSD. Expected: 1000 PYUSD with body's ++// resource block. ++func TestSelectSVMChallengePicksCheapestAcrossEnvelopesWhenNoPreference(t *testing.T) { ++ network := "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" ++ headerEnvelope, err := json.Marshal(map[string]any{ ++ "resource": map[string]any{"uri": "/header"}, ++ "accepts": []map[string]any{ ++ { ++ "scheme": "exact", ++ "network": network, ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // USDC ++ "amount": "2000", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ body, err := json.Marshal(map[string]any{ ++ "resource": map[string]any{"uri": "/body"}, ++ "accepts": []map[string]any{ ++ { ++ "scheme": "exact", ++ "network": network, ++ "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", // PYUSD ++ "amount": "1000", ++ }, ++ }, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ selected, resource := selectSVMChallengeWithPreferences( ++ map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, ++ string(body), ++ network, ++ "exact", ++ nil, ++ ) ++ ++ if selected == nil { ++ t.Fatal("expected cross-envelope cheapest selection") ++ } ++ if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { ++ t.Fatalf("expected body PYUSD (cheapest), got %s @ %s", selected.Asset, selected.Amount) ++ } ++ if selected.Amount != "1000" { ++ t.Fatalf("expected amount 1000, got %s", selected.Amount) ++ } ++ if resource["uri"] != "/body" { ++ t.Fatalf("expected body resource attribution, got %#v", resource) ++ } ++} +diff --git a/go/x402/cmd/interop-client/main.go b/go/x402/cmd/interop-client/main.go +new file mode 100644 +index 0000000..1ec54de +--- /dev/null ++++ b/go/x402/cmd/interop-client/main.go +@@ -0,0 +1,624 @@ ++package main ++ ++import ( ++ "bytes" ++ "crypto/rand" ++ "encoding/base64" ++ "encoding/binary" ++ "encoding/hex" ++ "encoding/json" ++ "fmt" ++ "io" ++ "net/http" ++ "os" ++ "strconv" ++ "strings" ++ "time" ++ ++ "github.com/gagliardetto/solana-go" ++) ++ ++type paymentEnvelope struct { ++ Resource map[string]any `json:"resource,omitempty"` ++ Accepts []paymentRequirement `json:"accepts"` ++} ++ ++type paymentRequirement struct { ++ Scheme string `json:"scheme"` ++ Network string `json:"network"` ++ Asset string `json:"asset"` ++ Amount string `json:"amount"` ++ PayTo string `json:"payTo"` ++ MaxTimeoutSeconds int `json:"maxTimeoutSeconds,omitempty"` ++ Extra map[string]any `json:"extra"` ++} ++ ++type paymentSignatureEnvelope struct { ++ X402Version int `json:"x402Version"` ++ Accepted paymentRequirement `json:"accepted"` ++ Resource map[string]any `json:"resource,omitempty"` ++ Payload map[string]string `json:"payload"` ++} ++ ++const ( ++ defaultComputeUnitLimit = 20_000 ++ defaultComputeUnitPriceMicrolamport = 1 ++ maxMemoBytes = 256 ++) ++ ++var ( ++ computeBudgetProgramID = solana.MustPublicKeyFromBase58("ComputeBudget111111111111111111111111111111") ++ memoProgramID = solana.MustPublicKeyFromBase58("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr") ++ httpClient = &http.Client{Timeout: 10 * time.Second} ++) ++ ++func headerValue(headers map[string]string, name string) string { ++ for key, value := range headers { ++ if strings.EqualFold(key, name) { ++ return value ++ } ++ } ++ return "" ++} ++ ++func loadPaymentRequiredHeader(headers map[string]string) *paymentEnvelope { ++ encoded := headerValue(headers, "PAYMENT-REQUIRED") ++ if encoded == "" { ++ return nil ++ } ++ ++ decoded, err := base64.StdEncoding.DecodeString(encoded) ++ if err != nil { ++ return nil ++ } ++ ++ var envelope paymentEnvelope ++ if err := json.Unmarshal(decoded, &envelope); err != nil { ++ return nil ++ } ++ return &envelope ++} ++ ++func loadPaymentRequiredBody(body string) *paymentEnvelope { ++ if body == "" { ++ return nil ++ } ++ ++ var envelope paymentEnvelope ++ if err := json.Unmarshal([]byte(body), &envelope); err != nil { ++ return nil ++ } ++ return &envelope ++} ++ ++func selectSVMRequirement(headers map[string]string, body string, network string, scheme string) *paymentRequirement { ++ requirement, _ := selectSVMChallengeWithPreferences(headers, body, network, scheme, nil) ++ return requirement ++} ++ ++func selectSVMChallenge(headers map[string]string, body string, network string, scheme string) (*paymentRequirement, map[string]any) { ++ return selectSVMChallengeWithPreferences(headers, body, network, scheme, parseCSVEnv("X402_INTEROP_PREFER_CURRENCIES")) ++} ++ ++func selectSVMChallengeWithPreferences(headers map[string]string, body string, network string, scheme string, preferredCurrencies []string) (*paymentRequirement, map[string]any) { ++ envelopes := []*paymentEnvelope{ ++ loadPaymentRequiredHeader(headers), ++ loadPaymentRequiredBody(body), ++ } ++ ++ // Preference path: envelope-by-envelope fallback. Each preferred currency ++ // is searched against each envelope in order; the first match wins. If no ++ // envelope satisfies the preference list we return nil (caller's strict ++ // opt-in is preserved instead of silently downgrading to "any" selection). ++ if len(preferredCurrencies) > 0 { ++ for _, envelope := range envelopes { ++ if envelope == nil { ++ continue ++ } ++ candidates := filterCandidates(envelope.Accepts, scheme, network) ++ if len(candidates) == 0 { ++ continue ++ } ++ for _, preferred := range preferredCurrencies { ++ for _, requirement := range candidates { ++ if currenciesMatch(requirement.Asset, preferred, network) { ++ selected := requirement ++ return &selected, envelope.Resource ++ } ++ } ++ } ++ } ++ return nil, nil ++ } ++ ++ // No-preference path: aggregate valid candidates from ALL envelopes and ++ // pick the globally cheapest amount. Resource attribution follows the ++ // envelope that contributed the winning candidate so downstream telemetry ++ // and signing flows see the correct context. ++ type candidateEntry struct { ++ requirement paymentRequirement ++ resource map[string]any ++ } ++ var entries []candidateEntry ++ for _, envelope := range envelopes { ++ if envelope == nil { ++ continue ++ } ++ for _, requirement := range filterCandidates(envelope.Accepts, scheme, network) { ++ entries = append(entries, candidateEntry{requirement: requirement, resource: envelope.Resource}) ++ } ++ } ++ if len(entries) == 0 { ++ return nil, nil ++ } ++ winner := entries[0] ++ winnerAmount, err := strconv.ParseUint(winner.requirement.Amount, 10, 64) ++ if err != nil { ++ winnerAmount = ^uint64(0) ++ } ++ for _, entry := range entries[1:] { ++ amount, err := strconv.ParseUint(entry.requirement.Amount, 10, 64) ++ if err != nil { ++ amount = ^uint64(0) ++ } ++ if amount < winnerAmount { ++ winner = entry ++ winnerAmount = amount ++ } ++ } ++ selected := winner.requirement ++ return &selected, winner.resource ++} ++ ++func filterCandidates(accepts []paymentRequirement, scheme string, network string) []paymentRequirement { ++ candidates := make([]paymentRequirement, 0, len(accepts)) ++ for _, requirement := range accepts { ++ if requirement.Scheme != scheme { ++ continue ++ } ++ if requirement.Network != network { ++ continue ++ } ++ if requirement.Asset == "" || requirement.Amount == "" { ++ continue ++ } ++ candidates = append(candidates, requirement) ++ } ++ return candidates ++} ++ ++func parseCSVEnv(name string) []string { ++ raw := os.Getenv(name) ++ if raw == "" { ++ return nil ++ } ++ parts := strings.Split(raw, ",") ++ values := make([]string, 0, len(parts)) ++ for _, part := range parts { ++ trimmed := strings.TrimSpace(part) ++ if trimmed != "" { ++ values = append(values, trimmed) ++ } ++ } ++ return values ++} ++ ++func currenciesMatch(offered string, accepted string, network string) bool { ++ return resolveStablecoinMint(offered, network) == resolveStablecoinMint(accepted, network) ++} ++ ++func resolveStablecoinMint(currency string, network string) string { ++ upper := strings.ToUpper(strings.TrimSpace(currency)) ++ switch upper { ++ case "USDC", "USD": ++ if network == "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" || network == "devnet" || network == "localnet" { ++ return "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" ++ } ++ return "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" ++ case "PYUSD": ++ if network == "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" || network == "devnet" || network == "localnet" { ++ return "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" ++ } ++ return "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo" ++ case "USDG": ++ if network == "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" || network == "devnet" || network == "localnet" { ++ return "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7" ++ } ++ return "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH" ++ case "USDT": ++ return "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" ++ case "CASH": ++ return "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH" ++ default: ++ return strings.TrimSpace(currency) ++ } ++} ++ ++func intFromRequirement(requirement paymentRequirement, key string) (uint64, error) { ++ value, ok := requirement.Extra[key] ++ if !ok { ++ return 0, fmt.Errorf("payment requirement is missing integer extra.%s", key) ++ } ++ ++ switch typed := value.(type) { ++ case float64: ++ return uint64(typed), nil ++ case string: ++ parsed, err := strconv.ParseUint(typed, 10, 64) ++ if err != nil { ++ return 0, fmt.Errorf("invalid integer extra.%s: %w", key, err) ++ } ++ return parsed, nil ++ default: ++ return 0, fmt.Errorf("payment requirement has invalid integer extra.%s", key) ++ } ++} ++ ++func stringFromExtra(requirement paymentRequirement, key string) (string, error) { ++ value, ok := requirement.Extra[key] ++ if !ok { ++ return "", fmt.Errorf("payment requirement is missing extra.%s", key) ++ } ++ typed, ok := value.(string) ++ if !ok || typed == "" { ++ return "", fmt.Errorf("payment requirement has invalid extra.%s", key) ++ } ++ return typed, nil ++} ++ ++func keypairFromJSONSecret(raw string) (solana.PrivateKey, error) { ++ var values []byte ++ if err := json.Unmarshal([]byte(raw), &values); err != nil { ++ return nil, fmt.Errorf("decode Solana secret key: %w", err) ++ } ++ if len(values) != 64 { ++ return nil, fmt.Errorf("expected a 64-byte Solana secret key JSON array") ++ } ++ privateKey := solana.PrivateKey(values) ++ if _, err := solana.ValidatePrivateKey(privateKey); err != nil { ++ return nil, err ++ } ++ return privateKey, nil ++} ++ ++func latestBlockhash(rpcURL string) (solana.Hash, error) { ++ requestBody, err := json.Marshal(map[string]any{ ++ "jsonrpc": "2.0", ++ "id": 1, ++ "method": "getLatestBlockhash", ++ }) ++ if err != nil { ++ return solana.Hash{}, err ++ } ++ response, err := httpClient.Post(rpcURL, "application/json", bytes.NewReader(requestBody)) ++ if err != nil { ++ return solana.Hash{}, err ++ } ++ defer func() { _ = response.Body.Close() }() ++ if response.StatusCode < 200 || response.StatusCode >= 300 { ++ return solana.Hash{}, fmt.Errorf("getLatestBlockhash HTTP %d", response.StatusCode) ++ } ++ var payload struct { ++ Result struct { ++ Value struct { ++ Blockhash string `json:"blockhash"` ++ } `json:"value"` ++ } `json:"result"` ++ Error any `json:"error"` ++ } ++ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { ++ return solana.Hash{}, err ++ } ++ if payload.Error != nil { ++ return solana.Hash{}, fmt.Errorf("getLatestBlockhash RPC error: %v", payload.Error) ++ } ++ return solana.HashFromBase58(payload.Result.Value.Blockhash) ++} ++ ++func computeUnitLimitInstruction(units uint32) solana.Instruction { ++ data := []byte{2} ++ data = binary.LittleEndian.AppendUint32(data, units) ++ return solana.NewInstruction(computeBudgetProgramID, nil, data) ++} ++ ++func computeUnitPriceInstruction(microLamports uint64) solana.Instruction { ++ data := []byte{3} ++ data = binary.LittleEndian.AppendUint64(data, microLamports) ++ return solana.NewInstruction(computeBudgetProgramID, nil, data) ++} ++ ++func transferCheckedInstruction(requirement paymentRequirement, signer solana.PublicKey, decimals uint8, tokenProgram solana.PublicKey) (solana.Instruction, error) { ++ amount, err := strconv.ParseUint(requirement.Amount, 10, 64) ++ if err != nil { ++ return nil, fmt.Errorf("invalid amount: %w", err) ++ } ++ mint, err := solana.PublicKeyFromBase58(requirement.Asset) ++ if err != nil { ++ return nil, fmt.Errorf("invalid asset: %w", err) ++ } ++ payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) ++ if err != nil { ++ return nil, fmt.Errorf("invalid payTo: %w", err) ++ } ++ sourceATA, _, err := findAssociatedTokenAddress(signer, tokenProgram, mint) ++ if err != nil { ++ return nil, err ++ } ++ destinationATA, _, err := findAssociatedTokenAddress(payTo, tokenProgram, mint) ++ if err != nil { ++ return nil, err ++ } ++ ++ data := []byte{12} ++ data = binary.LittleEndian.AppendUint64(data, amount) ++ data = append(data, decimals) ++ ++ return solana.NewInstruction( ++ tokenProgram, ++ solana.AccountMetaSlice{ ++ solana.Meta(sourceATA).WRITE(), ++ solana.Meta(mint), ++ solana.Meta(destinationATA).WRITE(), ++ solana.Meta(signer).SIGNER(), ++ }, ++ data, ++ ), nil ++} ++ ++func findAssociatedTokenAddress(wallet solana.PublicKey, tokenProgram solana.PublicKey, mint solana.PublicKey) (solana.PublicKey, uint8, error) { ++ return solana.FindProgramAddress( ++ [][]byte{wallet[:], tokenProgram[:], mint[:]}, ++ solana.SPLAssociatedTokenAccountProgramID, ++ ) ++} ++ ++func memoInstruction(requirement paymentRequirement) (solana.Instruction, error) { ++ memo := "" ++ if value, ok := requirement.Extra["memo"].(string); ok && value != "" { ++ memo = value ++ } else { ++ var nonce [16]byte ++ if _, err := rand.Read(nonce[:]); err != nil { ++ return nil, fmt.Errorf("generate memo nonce: %w", err) ++ } ++ memo = hex.EncodeToString(nonce[:]) ++ } ++ if len([]byte(memo)) > maxMemoBytes { ++ return nil, fmt.Errorf("extra.memo exceeds maximum %d bytes", maxMemoBytes) ++ } ++ return solana.NewInstruction(memoProgramID, nil, []byte(memo)), nil ++} ++ ++func buildExactPaymentSignature(requirement paymentRequirement, resource map[string]any, privateKey solana.PrivateKey, rpcURL string) (string, error) { ++ if requirement.Scheme != "exact" { ++ return "", fmt.Errorf("only exact payment requirements can be signed") ++ } ++ ++ decimalsValue, err := intFromRequirement(requirement, "decimals") ++ if err != nil { ++ return "", err ++ } ++ tokenProgramValue, err := stringFromExtra(requirement, "tokenProgram") ++ if err != nil { ++ return "", err ++ } ++ feePayerValue, err := stringFromExtra(requirement, "feePayer") ++ if err != nil { ++ return "", err ++ } ++ tokenProgram, err := solana.PublicKeyFromBase58(tokenProgramValue) ++ if err != nil { ++ return "", fmt.Errorf("invalid tokenProgram: %w", err) ++ } ++ feePayer, err := solana.PublicKeyFromBase58(feePayerValue) ++ if err != nil { ++ return "", fmt.Errorf("invalid feePayer: %w", err) ++ } ++ ++ blockhashValue, _ := requirement.Extra["recentBlockhash"].(string) ++ var blockhash solana.Hash ++ if blockhashValue != "" { ++ blockhash, err = solana.HashFromBase58(blockhashValue) ++ if err != nil { ++ return "", fmt.Errorf("invalid recentBlockhash: %w", err) ++ } ++ } else { ++ blockhash, err = latestBlockhash(rpcURL) ++ if err != nil { ++ return "", err ++ } ++ } ++ ++ transferIx, err := transferCheckedInstruction(requirement, privateKey.PublicKey(), uint8(decimalsValue), tokenProgram) ++ if err != nil { ++ return "", err ++ } ++ memoIx, err := memoInstruction(requirement) ++ if err != nil { ++ return "", err ++ } ++ ++ tx, err := solana.NewTransaction( ++ []solana.Instruction{ ++ computeUnitLimitInstruction(defaultComputeUnitLimit), ++ computeUnitPriceInstruction(defaultComputeUnitPriceMicrolamport), ++ transferIx, ++ memoIx, ++ }, ++ blockhash, ++ solana.TransactionPayer(feePayer), ++ ) ++ if err != nil { ++ return "", err ++ } ++ tx.Message.SetVersion(solana.MessageVersionV0) ++ if _, err := tx.PartialSign(func(key solana.PublicKey) *solana.PrivateKey { ++ if key.Equals(privateKey.PublicKey()) { ++ return &privateKey ++ } ++ return nil ++ }); err != nil { ++ return "", err ++ } ++ transaction, err := tx.ToBase64() ++ if err != nil { ++ return "", err ++ } ++ ++ encoded, err := json.Marshal(paymentSignatureEnvelope{ ++ X402Version: 2, ++ Accepted: requirement, ++ Resource: resource, ++ Payload: map[string]string{"transaction": transaction}, ++ }) ++ if err != nil { ++ return "", err ++ } ++ return base64.StdEncoding.EncodeToString(encoded), nil ++} ++ ++func readResponse(response *http.Response) (map[string]string, string, error) { ++ defer func() { _ = response.Body.Close() }() ++ body, err := io.ReadAll(response.Body) ++ if err != nil { ++ return nil, "", err ++ } ++ headers := map[string]string{} ++ for key, values := range response.Header { ++ if len(values) > 0 { ++ headers[key] = values[0] ++ } ++ } ++ return headers, string(body), nil ++} ++ ++func parseResponseBody(body string) any { ++ var parsed any ++ decoder := json.NewDecoder(bytes.NewReader([]byte(body))) ++ if err := decoder.Decode(&parsed); err == nil { ++ return parsed ++ } ++ return body ++} ++ ++func main() { ++ targetURL := os.Getenv("X402_INTEROP_TARGET_URL") ++ if targetURL == "" { ++ panic("X402_INTEROP_TARGET_URL is required") ++ } ++ ++ response, err := httpClient.Get(targetURL) ++ if err != nil { ++ panic(err) ++ } ++ defer func() { _ = response.Body.Close() }() ++ headers, body, err := readResponse(response) ++ if err != nil { ++ panic(err) ++ } ++ ++ selectedRequirement, resource := selectSVMChallenge( ++ headers, ++ body, ++ readEnvWithDefault("X402_INTEROP_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"), ++ readEnvWithDefault("X402_INTEROP_SCHEME", "exact"), ++ ) ++ scheme := readEnvWithDefault("X402_INTEROP_SCHEME", "exact") ++ errorDomain := readEnvWithDefault("X402_INTEROP_INTENT", scheme) ++ ++ if response.StatusCode == http.StatusPaymentRequired && os.Getenv("X402_INTEROP_INTENT") == "" && scheme == "exact" && selectedRequirement != nil && os.Getenv("X402_INTEROP_CLIENT_SECRET_KEY") != "" && os.Getenv("X402_INTEROP_RPC_URL") != "" { ++ privateKey, err := keypairFromJSONSecret(os.Getenv("X402_INTEROP_CLIENT_SECRET_KEY")) ++ var paymentSignature string ++ if err == nil { ++ paymentSignature, err = buildExactPaymentSignature(*selectedRequirement, resource, privateKey, os.Getenv("X402_INTEROP_RPC_URL")) ++ } ++ if err == nil { ++ request, requestErr := http.NewRequest(http.MethodGet, targetURL, nil) ++ if requestErr != nil { ++ err = requestErr ++ } else { ++ request.Header.Set("PAYMENT-SIGNATURE", paymentSignature) ++ var paidResponse *http.Response ++ paidResponse, err = httpClient.Do(request) ++ if err == nil { ++ defer func() { _ = paidResponse.Body.Close() }() ++ paidHeaders, paidBody, readErr := readResponse(paidResponse) ++ if readErr != nil { ++ err = readErr ++ } else { ++ payload := map[string]any{ ++ "type": "result", ++ "implementation": "go", ++ "role": "client", ++ "ok": paidResponse.StatusCode >= 200 && paidResponse.StatusCode < 300, ++ "status": paidResponse.StatusCode, ++ "responseHeaders": paidHeaders, ++ "responseBody": parseResponseBody(paidBody), ++ "settlement": headerValue(paidHeaders, "x-fixture-settlement"), ++ } ++ encoded, marshalErr := json.Marshal(payload) ++ if marshalErr != nil { ++ panic(marshalErr) ++ } ++ fmt.Println(string(encoded)) ++ return ++ } ++ } ++ } ++ } ++ if err != nil { ++ payload := map[string]any{ ++ "type": "result", ++ "implementation": "go", ++ "role": "client", ++ "ok": false, ++ "status": response.StatusCode, ++ "responseHeaders": headers, ++ "responseBody": map[string]any{ ++ "error": "go_exact_client_payment_failed", ++ "message": err.Error(), ++ "challengeStatus": response.StatusCode, ++ "challengeBody": body, ++ "selectedRequirement": selectedRequirement, ++ }, ++ "settlement": nil, ++ } ++ encoded, marshalErr := json.Marshal(payload) ++ if marshalErr != nil { ++ panic(marshalErr) ++ } ++ fmt.Println(string(encoded)) ++ return ++ } ++ } ++ ++ payload := map[string]any{ ++ "type": "result", ++ "implementation": "go", ++ "role": "client", ++ "ok": false, ++ "status": response.StatusCode, ++ "responseHeaders": headers, ++ "responseBody": map[string]any{ ++ "error": fmt.Sprintf("go_%s_client_not_implemented", errorDomain), ++ "challengeStatus": response.StatusCode, ++ "challengeBody": body, ++ "selectedRequirement": selectedRequirement, ++ }, ++ "settlement": nil, ++ } ++ ++ encoded, err := json.Marshal(payload) ++ if err != nil { ++ panic(err) ++ } ++ fmt.Println(string(encoded)) ++} ++ ++func readEnvWithDefault(name string, fallback string) string { ++ value := os.Getenv(name) ++ if value == "" { ++ return fallback ++ } ++ return value ++} +diff --git a/go/x402/cmd/interop-server/main.go b/go/x402/cmd/interop-server/main.go +new file mode 100644 +index 0000000..96ea0c9 +--- /dev/null ++++ b/go/x402/cmd/interop-server/main.go +@@ -0,0 +1,1196 @@ ++package main ++ ++import ( ++ "bytes" ++ "encoding/base64" ++ "encoding/binary" ++ "encoding/json" ++ "fmt" ++ "io" ++ "net" ++ "net/http" ++ "os" ++ "os/signal" ++ "reflect" ++ "strconv" ++ "strings" ++ "sync" ++ "syscall" ++ "time" ++ ++ "github.com/gagliardetto/solana-go" ++) ++ ++const ( ++ defaultResourcePath = "/protected" ++ defaultPrice = "$0.001" ++ defaultSettlementHeader = "x-fixture-settlement" ++ defaultDecimals = 6 ++ defaultTokenProgram = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" ++ token2022Program = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" ++ lighthouseProgram = "L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95" ++ defaultMaxTimeout = 60 ++ duplicateCacheTTL = 120 * time.Second ++ maxComputeUnitPrice = 5_000_000 ++ maxMemoBytes = 256 ++ // replayKeyNamespace MUST match the scheme-namespaced canonical key ++ // documented in the x402 PR-readiness reference and mirrors the MPP ++ // `solana-charge:consumed:` shape but scoped to x402 svm-exact so ++ // settled signatures across schemes (and against MPP) do not collide. ++ replayKeyNamespace = "x402-svm-exact:consumed:" ++) ++ ++// confirmationPollAttempts × confirmationPollInterval bounds the ++// post-broadcast confirmation wait. Defaults mirror the MPP ++// `server/charge.rs:769` 30×200ms = ~6s window. These are vars (not ++// consts) so tests can shrink the poll budget to keep timeout coverage ++// fast. ++var ( ++ confirmationPollAttempts = 60 ++ confirmationPollInterval = 200 * time.Millisecond ++) ++ ++var ( ++ computeBudgetProgramID = solana.MustPublicKeyFromBase58("ComputeBudget111111111111111111111111111111") ++ memoProgramID = solana.MustPublicKeyFromBase58("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr") ++) ++ ++// Lighthouse instructions are passed through by program-ID match alone, matching ++// the canonical spines: ++// - rust/src/protocol/schemes/exact/verify.rs:266 — `if program == LIGHTHOUSE_PROGRAM || program == MEMO_PROGRAM { continue; }` ++// - typescript/packages/x402/src/facilitator/exact/scheme.ts:300 — same shape ++// No discriminator or account-count allowlist is enforced here: inventing one ++// in a single language port would diverge from real-world Phantom/Solflare ++// transactions that the Rust + TypeScript adapters accept. Tightening this is ++// a protocol-wide decision that must land in the Rust spine first; tracked at ++// /notes/lighthouse-allowlist-tracking.md. ++ ++// CAIP-2 network identifiers shared with the TypeScript spine. ++const ( ++ solanaMainnetCAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" ++ solanaDevnetCAIP2 = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" ++ solanaTestnetCAIP2 = "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z" ++) ++ ++// stablecoinMintsByNetwork mirrors STABLECOIN_MINTS from the TypeScript ++// reference (typescript/packages/x402/src/protocol/schemes/exact/constants.ts). ++// Aliases are resolved at the env-read boundary so the rest of the server ++// always sees canonical base58 mint addresses. ++var stablecoinMintsByNetwork = map[string]map[string]string{ ++ "USDC": { ++ solanaMainnetCAIP2: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", ++ solanaDevnetCAIP2: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ }, ++ "USDT": { ++ solanaMainnetCAIP2: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", ++ }, ++ "USDG": { ++ solanaMainnetCAIP2: "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH", ++ solanaDevnetCAIP2: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", ++ solanaTestnetCAIP2: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", ++ }, ++ "PYUSD": { ++ solanaMainnetCAIP2: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", ++ solanaDevnetCAIP2: "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", ++ solanaTestnetCAIP2: "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", ++ }, ++ "CASH": { ++ solanaMainnetCAIP2: "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH", ++ }, ++} ++ ++// knownMintAliases lists the case-insensitive currency-name aliases that ++// resolveMintAlias understands. Kept stable for error messages. ++var knownMintAliases = []string{"USDC", "USDT", "USDG", "PYUSD", "CASH"} ++ ++// resolveMintAlias returns the canonical base58 mint address for a given ++// input on the configured CAIP-2 network. The input may already be a base58 ++// mint (in which case it is returned unchanged) or a known stablecoin alias ++// (USDC, USDT, USDG, PYUSD, CASH). Unknown aliases and aliases without a ++// configured mint for the network return a descriptive error. ++func resolveMintAlias(input string, network string) (string, error) { ++ trimmed := strings.TrimSpace(input) ++ if trimmed == "" { ++ return "", fmt.Errorf("mint is required") ++ } ++ upper := strings.ToUpper(trimmed) ++ if mintsByNetwork, ok := stablecoinMintsByNetwork[upper]; ok { ++ if mint, ok := mintsByNetwork[network]; ok { ++ return mint, nil ++ } ++ return "", fmt.Errorf("alias %s has no configured mint for network %s", upper, network) ++ } ++ if _, err := solana.PublicKeyFromBase58(trimmed); err != nil { ++ return "", fmt.Errorf("mint %q is neither a base58 address nor a known alias (accepted aliases: %s)", input, strings.Join(knownMintAliases, ", ")) ++ } ++ return trimmed, nil ++} ++ ++type serverState struct { ++ rpcURL string ++ network string ++ mint string ++ payTo string ++ feePayer solana.PrivateKey ++ amount string ++ extraOfferedMints []string ++ memo string ++ httpClient *http.Client ++} ++ ++type paymentEnvelope struct { ++ X402Version int `json:"x402Version"` ++ Accepts []paymentRequirement `json:"accepts"` ++ Resource map[string]any `json:"resource,omitempty"` ++} ++ ++type paymentRequirement struct { ++ Scheme string `json:"scheme"` ++ Network string `json:"network"` ++ Asset string `json:"asset"` ++ Amount string `json:"amount"` ++ PayTo string `json:"payTo"` ++ MaxTimeoutSeconds int `json:"maxTimeoutSeconds,omitempty"` ++ Extra map[string]any `json:"extra,omitempty"` ++} ++ ++type paymentSignatureEnvelope struct { ++ X402Version int `json:"x402Version"` ++ Accepted paymentRequirement `json:"accepted"` ++ Payload map[string]string `json:"payload"` ++} ++ ++type duplicateSettlementCache struct { ++ mu sync.Mutex ++ entries map[string]time.Time ++ now func() time.Time ++} ++ ++var settlementCache = newDuplicateSettlementCache() ++ ++func newDuplicateSettlementCache() *duplicateSettlementCache { ++ return &duplicateSettlementCache{ ++ entries: map[string]time.Time{}, ++ now: time.Now, ++ } ++} ++ ++// putIfAbsent reserves `key` in the replay cache. Returns true if the key ++// was newly inserted, false if a prior settlement already consumed it. ++// ++// L8 ordering (see x402 PR-readiness reference and MPP ++// `server/charge.rs:535-556`): callers MUST broadcast → await on-chain ++// confirmation → `putIfAbsent(signature)`. There is no release-on-failure ++// path: a crash or RPC failure before this call simply never inserts a ++// key, and Solana's per-signature replay protection prevents a re-broadcast ++// of the same signed transaction from settling twice within its blockhash ++// window. The release path of the prior claim-first design has been ++// removed to close the partial-failure race where a release after a timed- ++// out confirmation would permit a double-pay if the original later landed. ++func (cache *duplicateSettlementCache) putIfAbsent(key string) bool { ++ cache.mu.Lock() ++ defer cache.mu.Unlock() ++ ++ now := cache.now() ++ for cached, seenAt := range cache.entries { ++ if now.Sub(seenAt) > duplicateCacheTTL { ++ delete(cache.entries, cached) ++ } ++ } ++ if _, ok := cache.entries[key]; ok { ++ return false ++ } ++ cache.entries[key] = now ++ return true ++} ++ ++func writeJSON(response http.ResponseWriter, status int, payload map[string]any) { ++ encoded, err := json.Marshal(payload) ++ if err != nil { ++ panic(err) ++ } ++ response.Header().Set("content-type", "application/json") ++ response.WriteHeader(status) ++ if _, err := response.Write(encoded); err != nil { ++ fmt.Fprintln(os.Stderr, err) ++ } ++} ++ ++func writeJSONWithHeaders(response http.ResponseWriter, status int, headers map[string]string, payload map[string]any) { ++ encoded, err := json.Marshal(payload) ++ if err != nil { ++ panic(err) ++ } ++ response.Header().Set("content-type", "application/json") ++ for key, value := range headers { ++ response.Header().Set(key, value) ++ } ++ response.WriteHeader(status) ++ if _, err := response.Write(encoded); err != nil { ++ fmt.Fprintln(os.Stderr, err) ++ } ++} ++ ++func capabilityPayload(implementation string) map[string]any { ++ return map[string]any{ ++ "implementation": implementation, ++ "role": "server", ++ "capabilities": []string{"exact"}, ++ "plannedBoundaries": []string{"exact", "upto", "session", "batch-settlement"}, ++ } ++} ++ ++func exactRequirementForMint(state serverState, mint string) paymentRequirement { ++ requirement := paymentRequirement{ ++ Scheme: "exact", ++ Network: state.network, ++ Asset: mint, ++ Amount: state.amount, ++ PayTo: state.payTo, ++ MaxTimeoutSeconds: defaultMaxTimeout, ++ Extra: map[string]any{ ++ "decimals": defaultDecimals, ++ "feePayer": state.feePayer.PublicKey().String(), ++ "tokenProgram": defaultTokenProgramForMint(mint), ++ }, ++ } ++ if state.memo != "" { ++ requirement.Extra["memo"] = state.memo ++ } ++ return requirement ++} ++ ++func exactRequirement(state serverState) paymentRequirement { ++ return exactRequirementForMint(state, state.mint) ++} ++ ++func exactChallengePayload(state serverState) paymentEnvelope { ++ accepts := []paymentRequirement{exactRequirement(state)} ++ for _, mint := range state.extraOfferedMints { ++ if mint == "" { ++ continue ++ } ++ accepts = append(accepts, exactRequirementForMint(state, mint)) ++ } ++ return paymentEnvelope{ ++ X402Version: 2, ++ Accepts: accepts, ++ Resource: map[string]any{ ++ "type": "http", ++ "uri": defaultResourcePath, ++ }, ++ } ++} ++ ++func defaultTokenProgramForMint(mint string) string { ++ switch strings.ToUpper(strings.TrimSpace(mint)) { ++ case "USDG", "PYUSD", "CASH": ++ return token2022Program ++ } ++ switch strings.TrimSpace(mint) { ++ case "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH", ++ "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", ++ "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", ++ "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", ++ "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH": ++ return token2022Program ++ default: ++ return defaultTokenProgram ++ } ++} ++ ++func uptoChallengePayload() map[string]any { ++ return map[string]any{ ++ "x402Version": 2, ++ "accepts": []map[string]any{ ++ { ++ "scheme": "upto", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ "amount": "1000", ++ }, ++ }, ++ } ++} ++ ++func writePaymentRequired(response http.ResponseWriter, challenge map[string]any) { ++ encoded, err := json.Marshal(challenge) ++ if err != nil { ++ panic(err) ++ } ++ response.Header().Set("PAYMENT-REQUIRED", base64.StdEncoding.EncodeToString(encoded)) ++ writeJSON(response, http.StatusPaymentRequired, map[string]any{"error": "payment_required"}) ++} ++ ++func writeExactPaymentRequired(response http.ResponseWriter, state serverState) { ++ challenge := exactChallengePayload(state) ++ encoded, err := json.Marshal(challenge) ++ if err != nil { ++ panic(err) ++ } ++ response.Header().Set("PAYMENT-REQUIRED", base64.StdEncoding.EncodeToString(encoded)) ++ writeJSON(response, http.StatusPaymentRequired, map[string]any{"error": "payment_required"}) ++} ++ ++func sessionChallengePayload() map[string]any { ++ return map[string]any{ ++ "intent": "session", ++ "payee": "session-payee", ++ "mint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ "suggestedDeposit": "10000", ++ "unitPrice": "25", ++ "unitType": "llm_token", ++ } ++} ++ ++func batchSettlementChallengePayload() map[string]any { ++ return map[string]any{ ++ "x402Version": 2, ++ "accepts": []map[string]any{ ++ { ++ "scheme": "batch-settlement", ++ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ "receiver": "batch-receiver", ++ "token": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ "maximumAmount": "1000", ++ }, ++ }, ++ } ++} ++ ++func readRequiredEnv(name string) string { ++ value := os.Getenv(name) ++ if value == "" { ++ panic(fmt.Sprintf("%s is required", name)) ++ } ++ return value ++} ++ ++func readEnvWithDefault(name string, fallback string) string { ++ value := os.Getenv(name) ++ if value == "" { ++ return fallback ++ } ++ return value ++} ++ ++func readCSVEnv(name string) []string { ++ raw := os.Getenv(name) ++ if raw == "" { ++ return nil ++ } ++ parts := strings.Split(raw, ",") ++ values := make([]string, 0, len(parts)) ++ for _, part := range parts { ++ trimmed := strings.TrimSpace(part) ++ if trimmed != "" { ++ values = append(values, trimmed) ++ } ++ } ++ return values ++} ++ ++func normalizeAmount(price string) string { ++ trimmed := strings.TrimSpace(price) ++ if len(trimmed) > 0 && trimmed[0] == '$' { ++ trimmed = trimmed[1:] ++ } ++ amountPart := strings.Fields(trimmed)[0] ++ parts := strings.SplitN(amountPart, ".", 2) ++ whole, err := strconv.ParseUint(parts[0], 10, 64) ++ if err != nil { ++ panic(fmt.Sprintf("invalid X402_INTEROP_PRICE: %s", price)) ++ } ++ fraction := "" ++ if len(parts) == 2 { ++ fraction = parts[1] ++ } ++ if len(fraction) > defaultDecimals { ++ panic(fmt.Sprintf("X402_INTEROP_PRICE has too many decimal places: %s", price)) ++ } ++ fraction = fraction + strings.Repeat("0", defaultDecimals-len(fraction)) ++ fractional, err := strconv.ParseUint(fraction, 10, 64) ++ if err != nil { ++ panic(fmt.Sprintf("invalid X402_INTEROP_PRICE: %s", price)) ++ } ++ return strconv.FormatUint((whole*1_000_000)+fractional, 10) ++} ++ ++func keypairFromJSONSecret(raw string) solana.PrivateKey { ++ var values []byte ++ if err := json.Unmarshal([]byte(raw), &values); err != nil { ++ panic(fmt.Sprintf("decode Solana secret key: %s", err)) ++ } ++ if len(values) != 64 { ++ panic("expected a 64-byte Solana secret key JSON array") ++ } ++ privateKey := solana.PrivateKey(values) ++ if _, err := solana.ValidatePrivateKey(privateKey); err != nil { ++ panic(err) ++ } ++ return privateKey ++} ++ ++func readState() serverState { ++ network := readEnvWithDefault("X402_INTEROP_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1") ++ rawMint := readEnvWithDefault("X402_INTEROP_MINT", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU") ++ resolvedMint, err := resolveMintAlias(rawMint, network) ++ if err != nil { ++ panic(fmt.Sprintf("X402_INTEROP_MINT: %s", err)) ++ } ++ rawExtra := readCSVEnv("X402_INTEROP_EXTRA_OFFERED_MINTS") ++ resolvedExtra := make([]string, 0, len(rawExtra)) ++ for _, candidate := range rawExtra { ++ resolved, err := resolveMintAlias(candidate, network) ++ if err != nil { ++ panic(fmt.Sprintf("X402_INTEROP_EXTRA_OFFERED_MINTS: %s", err)) ++ } ++ resolvedExtra = append(resolvedExtra, resolved) ++ } ++ return serverState{ ++ rpcURL: readRequiredEnv("X402_INTEROP_RPC_URL"), ++ network: network, ++ mint: resolvedMint, ++ payTo: readRequiredEnv("X402_INTEROP_PAY_TO"), ++ feePayer: keypairFromJSONSecret(readRequiredEnv("X402_INTEROP_FACILITATOR_SECRET_KEY")), ++ amount: normalizeAmount(readEnvWithDefault("X402_INTEROP_PRICE", defaultPrice)), ++ extraOfferedMints: resolvedExtra, ++ httpClient: &http.Client{ ++ Timeout: 15 * time.Second, ++ }, ++ } ++} ++ ++func paymentRequirementMatches(left paymentRequirement, right paymentRequirement) bool { ++ return reflect.DeepEqual(normalizeRequirement(left), normalizeRequirement(right)) ++} ++ ++func acceptedExactRequirement(state serverState, accepted paymentRequirement) (paymentRequirement, bool) { ++ for _, requirement := range exactChallengePayload(state).Accepts { ++ if paymentRequirementMatches(accepted, requirement) { ++ return requirement, true ++ } ++ } ++ return paymentRequirement{}, false ++} ++ ++func normalizeRequirement(requirement paymentRequirement) paymentRequirement { ++ normalized := requirement ++ normalized.Extra = map[string]any{} ++ for key, value := range requirement.Extra { ++ normalized.Extra[key] = fmt.Sprint(value) ++ } ++ return normalized ++} ++ ++func decodePaymentSignature(headerValue string) (paymentSignatureEnvelope, error) { ++ decoded, err := base64.StdEncoding.DecodeString(headerValue) ++ if err != nil { ++ return paymentSignatureEnvelope{}, err ++ } ++ var payload paymentSignatureEnvelope ++ if err := json.Unmarshal(decoded, &payload); err != nil { ++ return paymentSignatureEnvelope{}, err ++ } ++ return payload, nil ++} ++ ++func settleExactPayment(state serverState, headerValue string) (string, error) { ++ payload, err := decodePaymentSignature(headerValue) ++ if err != nil { ++ return "", err ++ } ++ if payload.X402Version != 2 { ++ return "", fmt.Errorf("unsupported x402Version: %d", payload.X402Version) ++ } ++ requirement, ok := acceptedExactRequirement(state, payload.Accepted) ++ if !ok { ++ return "", fmt.Errorf("accepted payment requirement does not match server challenge") ++ } ++ ++ encodedTransaction := payload.Payload["transaction"] ++ if encodedTransaction == "" { ++ return "", fmt.Errorf("payment payload is missing transaction") ++ } ++ ++ transaction, err := solana.TransactionFromBase64(encodedTransaction) ++ if err != nil { ++ return "", err ++ } ++ if err := verifyExactTransaction(transaction, requirement); err != nil { ++ return "", err ++ } ++ // Bind the transaction's message fee-payer (account key 0) to the ++ // server's configured fee-payer. Without this guard a malicious client ++ // could nominate a different message payer and rely on the facilitator ++ // being in the signer set to drain SOL via co-signing. ++ if len(transaction.Message.AccountKeys) == 0 { ++ return "", fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_missing") ++ } ++ if !transaction.Message.AccountKeys[0].Equals(state.feePayer.PublicKey()) { ++ return "", fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_mismatch") ++ } ++ if err := verifyTokenAccountsExist(state, transaction, requirement); err != nil { ++ return "", err ++ } ++ ++ if _, err := transaction.PartialSign(func(key solana.PublicKey) *solana.PrivateKey { ++ if key.Equals(state.feePayer.PublicKey()) { ++ return &state.feePayer ++ } ++ return nil ++ }); err != nil { ++ return "", err ++ } ++ if err := transaction.VerifySignatures(); err != nil { ++ return "", err ++ } ++ ++ // L8 ordering: broadcast → confirm → put_if_absent(signature). ++ // Mirrors MPP `server/charge.rs:535-556` (broadcast_pull, ++ // await_pull_confirmation, consume_signature). No claim-first, no ++ // release-on-failure. See x402 PR-readiness reference §"L8 ++ // broadcast-then-confirm-then-mark ordering (SVM-specific)". ++ signature, err := sendTransaction(state, transaction) ++ if err != nil { ++ return "", err ++ } ++ if err := awaitSignatureConfirmation(state, signature); err != nil { ++ return "", err ++ } ++ if !settlementCache.putIfAbsent(replayKeyNamespace + signature) { ++ // Canonical `signature_consumed` surface (see MPP ++ // `VerificationError::signature_consumed`, ++ // rust/src/server/charge.rs:589-593). The interop server's ++ // existing error vocabulary maps this to "duplicate_settlement"; ++ // keep that wire token so existing clients are not broken, but ++ // the semantic is now "this confirmed signature was already ++ // consumed by an earlier successful settlement", not "we saw ++ // this encoded transaction blob before broadcast". ++ return "", fmt.Errorf("duplicate_settlement") ++ } ++ return signature, nil ++} ++ ++type transferCheckedFields struct { ++ source solana.PublicKey ++ mint solana.PublicKey ++ destination solana.PublicKey ++ authority solana.PublicKey ++ amount uint64 ++ decimals uint8 ++ tokenProgram solana.PublicKey ++} ++ ++func verifyExactTransaction(transaction *solana.Transaction, requirement paymentRequirement) error { ++ if !transaction.Message.IsVersioned() { ++ return fmt.Errorf("payment transaction must be versioned") ++ } ++ instructions := transaction.Message.Instructions ++ if len(instructions) < 3 || len(instructions) > 6 { ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_length") ++ } ++ if err := verifyComputeLimitInstruction(transaction, instructions[0]); err != nil { ++ return err ++ } ++ if err := verifyComputePriceInstruction(transaction, instructions[1]); err != nil { ++ return err ++ } ++ transfer, err := parseTransferCheckedInstruction(transaction, instructions[2]) ++ if err != nil { ++ return err ++ } ++ // Mirror the Rust spine binding (rust/crates/x402/src/protocol/schemes/exact/verify.rs:73-80) ++ // and the PHP/Ruby/Lua ports: the on-chain transfer's token program MUST match the ++ // program declared in requirement.Extra["tokenProgram"]. Without this check, a Token-2022 ++ // transfer can satisfy an SPL Token requirement (or vice versa), because the ++ // destination-ATA derivation below uses the parsed program rather than the required one. ++ requiredTokenProgramRaw, ok := requirement.Extra["tokenProgram"].(string) ++ if !ok || requiredTokenProgramRaw == "" { ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_token_program") ++ } ++ requiredTokenProgram, err := solana.PublicKeyFromBase58(requiredTokenProgramRaw) ++ if err != nil { ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_token_program") ++ } ++ if !transfer.tokenProgram.Equals(requiredTokenProgram) { ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_token_program") ++ } ++ if err := verifyOptionalInstructions(transaction, instructions[3:], requirement, transfer); err != nil { ++ return err ++ } ++ feePayer, err := solana.PublicKeyFromBase58(fmt.Sprint(requirement.Extra["feePayer"])) ++ if err != nil { ++ return fmt.Errorf("invalid feePayer: %w", err) ++ } ++ // Codex P1.2 (May 2026): the previous unconditional "fee-payer in any ++ // instruction account" loop was both over-broad (false-positive on the ++ // legitimate destination-ATA-create flow, where the SPL Associated Token ++ // Account program requires the rent payer at accounts[0]) and incomplete ++ // (it did not distinguish *role* — fee-payer as transfer authority/source ++ // is the real attack the Rust spine bans at ++ // rust/src/protocol/schemes/exact/verify.rs:382). Tightened rule: ++ // * fee-payer is allowed at accounts[0] of a *validated* ATA-create ix ++ // (the canonical rent-payer position). ++ // * fee-payer is allowed inside Lighthouse instruction account lists ++ // (the Rust spine has NO fee-payer-in-accounts sweep at all; it only ++ // blocks fee-payer as transfer authority at verify.rs:382, and accepts ++ // any Lighthouse ix by program-id alone at verify.rs:263 — wallets such ++ // as Phantom/Solflare routinely add `AssertAccount*` ixs that reference ++ // the fee-payer's pubkey to guard against malicious facilitator rewrites). ++ // * fee-payer in any other (non-Lighthouse, non-ATA-create-payer-slot) ++ // instruction account list is rejected with a distinct typed error. ++ // * fee-payer as transfer authority / source is still rejected with the ++ // spine-aligned `_transferring_funds` error. ++ if transfer.authority.Equals(feePayer) || transfer.source.Equals(feePayer) { ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_transferring_funds") ++ } ++ for index, instruction := range instructions { ++ if index == 2 { ++ // instruction[2] is the transferChecked; its fee-payer-as-role ++ // abuses are already covered by the spine-aligned guard above. ++ continue ++ } ++ program, err := programID(transaction, instruction) ++ if err != nil { ++ return err ++ } ++ if program.String() == lighthouseProgram { ++ // Mirror rust/src/protocol/schemes/exact/verify.rs:263 — Lighthouse ++ // ixs are passed through by program-id alone; the spine never ++ // inspects their account lists for the managed fee-payer. ++ continue ++ } ++ isATACreatePayerSlot := index >= 3 && isValidatedATACreateInstruction(transaction, instruction, requirement, transfer) ++ for accountPosition, accountIndex := range instruction.Accounts { ++ account, err := accountAt(transaction, accountIndex) ++ if err != nil { ++ return err ++ } ++ if !account.Equals(feePayer) { ++ continue ++ } ++ if isATACreatePayerSlot && accountPosition == 0 { ++ continue ++ } ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_in_instruction_accounts") ++ } ++ } ++ mint, err := solana.PublicKeyFromBase58(requirement.Asset) ++ if err != nil { ++ return fmt.Errorf("invalid asset: %w", err) ++ } ++ if !transfer.mint.Equals(mint) { ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_mint") ++ } ++ expectedAmount, err := strconv.ParseUint(requirement.Amount, 10, 64) ++ if err != nil { ++ return fmt.Errorf("invalid amount: %w", err) ++ } ++ if transfer.amount != expectedAmount { ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_amount") ++ } ++ payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) ++ if err != nil { ++ return fmt.Errorf("invalid payTo: %w", err) ++ } ++ expectedDestination, _, err := solana.FindAssociatedTokenAddressWithProgram(payTo, mint, transfer.tokenProgram) ++ if err != nil { ++ return err ++ } ++ if !transfer.destination.Equals(expectedDestination) { ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_destination") ++ } ++ if decimals, err := strconv.ParseUint(fmt.Sprint(requirement.Extra["decimals"]), 10, 8); err == nil && transfer.decimals != uint8(decimals) { ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_decimals") ++ } ++ return nil ++} ++ ++func verifyComputeLimitInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction) error { ++ program, err := programID(transaction, instruction) ++ if err != nil { ++ return err ++ } ++ if !program.Equals(computeBudgetProgramID) || len(instruction.Data) != 5 || instruction.Data[0] != 2 { ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction") ++ } ++ return nil ++} ++ ++func verifyComputePriceInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction) error { ++ program, err := programID(transaction, instruction) ++ if err != nil { ++ return err ++ } ++ if !program.Equals(computeBudgetProgramID) || len(instruction.Data) != 9 || instruction.Data[0] != 3 { ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_compute_price_instruction") ++ } ++ price := binary.LittleEndian.Uint64(instruction.Data[1:]) ++ if price > maxComputeUnitPrice { ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high") ++ } ++ return nil ++} ++ ++func parseTransferCheckedInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction) (transferCheckedFields, error) { ++ program, err := programID(transaction, instruction) ++ if err != nil { ++ return transferCheckedFields{}, err ++ } ++ if !program.Equals(solana.TokenProgramID) && !program.Equals(solana.Token2022ProgramID) { ++ return transferCheckedFields{}, fmt.Errorf("invalid_exact_svm_payload_transaction_transfer_program") ++ } ++ if len(instruction.Accounts) < 4 || len(instruction.Data) != 10 || instruction.Data[0] != 12 { ++ return transferCheckedFields{}, fmt.Errorf("invalid_exact_svm_payload_transaction_transfer_checked") ++ } ++ source, err := accountAt(transaction, instruction.Accounts[0]) ++ if err != nil { ++ return transferCheckedFields{}, err ++ } ++ mint, err := accountAt(transaction, instruction.Accounts[1]) ++ if err != nil { ++ return transferCheckedFields{}, err ++ } ++ destination, err := accountAt(transaction, instruction.Accounts[2]) ++ if err != nil { ++ return transferCheckedFields{}, err ++ } ++ authority, err := accountAt(transaction, instruction.Accounts[3]) ++ if err != nil { ++ return transferCheckedFields{}, err ++ } ++ return transferCheckedFields{ ++ source: source, ++ mint: mint, ++ destination: destination, ++ authority: authority, ++ amount: binary.LittleEndian.Uint64(instruction.Data[1:9]), ++ decimals: instruction.Data[9], ++ tokenProgram: program, ++ }, nil ++} ++ ++func verifyOptionalInstructions(transaction *solana.Transaction, instructions []solana.CompiledInstruction, requirement paymentRequirement, transfer transferCheckedFields) error { ++ memoCount := 0 ++ expectedMemo, hasExpectedMemo := requirement.Extra["memo"].(string) ++ invalidReasonByIndex := []string{ ++ "invalid_exact_svm_payload_unknown_fourth_instruction", ++ "invalid_exact_svm_payload_unknown_fifth_instruction", ++ "invalid_exact_svm_payload_unknown_sixth_instruction", ++ } ++ for index, instruction := range instructions { ++ program, err := programID(transaction, instruction) ++ if err != nil { ++ return err ++ } ++ if program.Equals(memoProgramID) { ++ memoCount++ ++ memo := string(instruction.Data) ++ if len([]byte(memo)) > maxMemoBytes { ++ return fmt.Errorf("extra.memo exceeds maximum %d bytes", maxMemoBytes) ++ } ++ if hasExpectedMemo && memo != expectedMemo { ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_memo") ++ } ++ if !hasExpectedMemo && memo == "" { ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_memo") ++ } ++ continue ++ } ++ if program.String() == lighthouseProgram { ++ // Pass through Lighthouse instructions by program-id match only, ++ // mirroring rust/src/protocol/schemes/exact/verify.rs:266 and ++ // typescript/packages/x402/src/facilitator/exact/scheme.ts:300. ++ continue ++ } ++ if program.Equals(solana.SPLAssociatedTokenAccountProgramID) && validDestinationATACreateInstruction(transaction, instruction, requirement, transfer) { ++ continue ++ } ++ if index < len(invalidReasonByIndex) { ++ return fmt.Errorf("%s", invalidReasonByIndex[index]) ++ } ++ return fmt.Errorf("invalid_exact_svm_payload_unknown_optional_instruction") ++ } ++ if hasExpectedMemo && memoCount != 1 { ++ return fmt.Errorf("invalid_exact_svm_payload_transaction_memo") ++ } ++ return nil ++} ++ ++// isValidatedATACreateInstruction returns true when `instruction` is an ++// SPL Associated Token Account program create that targets the payment's ++// destination ATA — i.e. the only optional instruction in which the facilitator ++// fee-payer is permitted to appear (as the rent payer at accounts[0]). ++func isValidatedATACreateInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction, requirement paymentRequirement, transfer transferCheckedFields) bool { ++ program, err := programID(transaction, instruction) ++ if err != nil { ++ return false ++ } ++ if !program.Equals(solana.SPLAssociatedTokenAccountProgramID) { ++ return false ++ } ++ return validDestinationATACreateInstruction(transaction, instruction, requirement, transfer) ++} ++ ++func validDestinationATACreateInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction, requirement paymentRequirement, transfer transferCheckedFields) bool { ++ if len(instruction.Data) > 1 { ++ return false ++ } ++ if len(instruction.Data) == 1 && instruction.Data[0] != 0 && instruction.Data[0] != 1 { ++ return false ++ } ++ if len(instruction.Accounts) < 6 { ++ return false ++ } ++ associatedAccount, err := accountAt(transaction, instruction.Accounts[1]) ++ if err != nil || !associatedAccount.Equals(transfer.destination) { ++ return false ++ } ++ wallet, err := accountAt(transaction, instruction.Accounts[2]) ++ if err != nil { ++ return false ++ } ++ payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) ++ if err != nil || !wallet.Equals(payTo) { ++ return false ++ } ++ mint, err := accountAt(transaction, instruction.Accounts[3]) ++ if err != nil || !mint.Equals(transfer.mint) { ++ return false ++ } ++ systemProgram, err := accountAt(transaction, instruction.Accounts[4]) ++ if err != nil || !systemProgram.Equals(solana.SystemProgramID) { ++ return false ++ } ++ tokenProgram, err := accountAt(transaction, instruction.Accounts[5]) ++ if err != nil || !tokenProgram.Equals(transfer.tokenProgram) { ++ return false ++ } ++ return true ++} ++ ++func programID(transaction *solana.Transaction, instruction solana.CompiledInstruction) (solana.PublicKey, error) { ++ return accountAt(transaction, instruction.ProgramIDIndex) ++} ++ ++func accountAt(transaction *solana.Transaction, index uint16) (solana.PublicKey, error) { ++ if int(index) >= len(transaction.Message.AccountKeys) { ++ return solana.PublicKey{}, fmt.Errorf("invalid account index: %d", index) ++ } ++ return transaction.Message.AccountKeys[index], nil ++} ++ ++func verifyTokenAccountsExist(state serverState, transaction *solana.Transaction, requirement paymentRequirement) error { ++ transfer, err := parseTransferCheckedInstruction(transaction, transaction.Message.Instructions[2]) ++ if err != nil { ++ return err ++ } ++ if exists, err := accountExists(state, transfer.source); err != nil { ++ return err ++ } else if !exists { ++ return fmt.Errorf("source token account does not exist") ++ } ++ if hasDestinationATACreateInstruction(transaction, requirement, transfer) { ++ return nil ++ } ++ if exists, err := accountExists(state, transfer.destination); err != nil { ++ return err ++ } else if !exists { ++ return fmt.Errorf("destination token account does not exist") ++ } ++ return nil ++} ++ ++func hasDestinationATACreateInstruction(transaction *solana.Transaction, requirement paymentRequirement, transfer transferCheckedFields) bool { ++ for _, instruction := range transaction.Message.Instructions[3:] { ++ program, err := programID(transaction, instruction) ++ if err != nil || !program.Equals(solana.SPLAssociatedTokenAccountProgramID) { ++ continue ++ } ++ if validDestinationATACreateInstruction(transaction, instruction, requirement, transfer) { ++ return true ++ } ++ } ++ return false ++} ++ ++func accountExists(state serverState, account solana.PublicKey) (bool, error) { ++ requestBody, err := json.Marshal(map[string]any{ ++ "jsonrpc": "2.0", ++ "id": 1, ++ "method": "getAccountInfo", ++ "params": []any{ ++ account.String(), ++ map[string]any{"encoding": "base64"}, ++ }, ++ }) ++ if err != nil { ++ return false, err ++ } ++ response, err := state.httpClient.Post(state.rpcURL, "application/json", bytes.NewReader(requestBody)) ++ if err != nil { ++ return false, err ++ } ++ defer func() { _ = response.Body.Close() }() ++ rawBody, err := io.ReadAll(response.Body) ++ if err != nil { ++ return false, err ++ } ++ if response.StatusCode < 200 || response.StatusCode >= 300 { ++ return false, fmt.Errorf("getAccountInfo HTTP %d: %s", response.StatusCode, string(rawBody)) ++ } ++ var payload struct { ++ Result *struct { ++ Value json.RawMessage `json:"value"` ++ } `json:"result"` ++ Error any `json:"error"` ++ } ++ if err := json.Unmarshal(rawBody, &payload); err != nil { ++ return false, err ++ } ++ if payload.Error != nil { ++ return false, fmt.Errorf("getAccountInfo RPC error: %v", payload.Error) ++ } ++ if payload.Result == nil || len(payload.Result.Value) == 0 || string(payload.Result.Value) == "null" { ++ return false, nil ++ } ++ return true, nil ++} ++ ++func sendTransaction(state serverState, transaction *solana.Transaction) (string, error) { ++ encodedTransaction, err := transaction.ToBase64() ++ if err != nil { ++ return "", err ++ } ++ requestBody, err := json.Marshal(map[string]any{ ++ "jsonrpc": "2.0", ++ "id": 1, ++ "method": "sendTransaction", ++ "params": []any{ ++ encodedTransaction, ++ map[string]any{ ++ "encoding": "base64", ++ "skipPreflight": false, ++ "preflightCommitment": "processed", ++ "maxRetries": 3, ++ }, ++ }, ++ }) ++ if err != nil { ++ return "", err ++ } ++ ++ response, err := state.httpClient.Post(state.rpcURL, "application/json", bytes.NewReader(requestBody)) ++ if err != nil { ++ return "", err ++ } ++ defer func() { _ = response.Body.Close() }() ++ rawBody, err := io.ReadAll(response.Body) ++ if err != nil { ++ return "", err ++ } ++ if response.StatusCode < 200 || response.StatusCode >= 300 { ++ return "", fmt.Errorf("sendTransaction HTTP %d: %s", response.StatusCode, string(rawBody)) ++ } ++ ++ var payload struct { ++ Result string `json:"result"` ++ Error any `json:"error"` ++ } ++ if err := json.Unmarshal(rawBody, &payload); err != nil { ++ return "", err ++ } ++ if payload.Error != nil { ++ return "", fmt.Errorf("sendTransaction RPC error: %v", payload.Error) ++ } ++ if payload.Result == "" { ++ return "", fmt.Errorf("sendTransaction returned empty signature") ++ } ++ return payload.Result, nil ++} ++ ++// awaitSignatureConfirmation polls `getSignatureStatuses` until the ++// signature reaches `confirmed` or `finalized` commitment. It returns an ++// error on explicit RPC error, an on-chain transaction failure ++// (status.err non-null), or when the poll budget elapses (the bounded ++// stand-in for blockhash-window expiry; a signature that has not been ++// observed within this window is treated as not landed so the caller ++// MUST NOT mark the signature as consumed). Mirrors the canonical loop ++// in MPP `server/charge.rs:761-784`. ++func awaitSignatureConfirmation(state serverState, signature string) error { ++ requestBody, err := json.Marshal(map[string]any{ ++ "jsonrpc": "2.0", ++ "id": 1, ++ "method": "getSignatureStatuses", ++ "params": []any{ ++ []string{signature}, ++ map[string]any{"searchTransactionHistory": false}, ++ }, ++ }) ++ if err != nil { ++ return err ++ } ++ for attempt := 0; attempt < confirmationPollAttempts; attempt++ { ++ response, err := state.httpClient.Post(state.rpcURL, "application/json", bytes.NewReader(requestBody)) ++ if err != nil { ++ return fmt.Errorf("getSignatureStatuses transport: %w", err) ++ } ++ rawBody, readErr := io.ReadAll(response.Body) ++ _ = response.Body.Close() ++ if readErr != nil { ++ return readErr ++ } ++ if response.StatusCode < 200 || response.StatusCode >= 300 { ++ return fmt.Errorf("getSignatureStatuses HTTP %d: %s", response.StatusCode, string(rawBody)) ++ } ++ var payload struct { ++ Result *struct { ++ Value []*struct { ++ Confirmations *uint64 `json:"confirmations"` ++ ConfirmationStatus string `json:"confirmationStatus"` ++ Err any `json:"err"` ++ } `json:"value"` ++ } `json:"result"` ++ Error any `json:"error"` ++ } ++ if err := json.Unmarshal(rawBody, &payload); err != nil { ++ return err ++ } ++ if payload.Error != nil { ++ return fmt.Errorf("getSignatureStatuses RPC error: %v", payload.Error) ++ } ++ if payload.Result != nil && len(payload.Result.Value) > 0 && payload.Result.Value[0] != nil { ++ status := payload.Result.Value[0] ++ if status.Err != nil { ++ return fmt.Errorf("transaction failed on-chain: %v", status.Err) ++ } ++ if status.ConfirmationStatus == "confirmed" || status.ConfirmationStatus == "finalized" { ++ return nil ++ } ++ } ++ if attempt < confirmationPollAttempts-1 { ++ time.Sleep(confirmationPollInterval) ++ } ++ } ++ return fmt.Errorf("transaction not confirmed within timeout") ++} ++ ++func newInteropMux(state serverState) *http.ServeMux { ++ mux := http.NewServeMux() ++ mux.HandleFunc("/health", func(response http.ResponseWriter, _ *http.Request) { ++ writeJSON(response, http.StatusOK, map[string]any{"ok": true}) ++ }) ++ mux.HandleFunc("/capabilities", func(response http.ResponseWriter, _ *http.Request) { ++ writeJSON(response, http.StatusOK, capabilityPayload("go")) ++ }) ++ mux.HandleFunc("/exact", func(response http.ResponseWriter, _ *http.Request) { ++ writeExactPaymentRequired(response, state) ++ }) ++ mux.HandleFunc("/upto", func(response http.ResponseWriter, _ *http.Request) { ++ writePaymentRequired(response, uptoChallengePayload()) ++ }) ++ mux.HandleFunc("/session", func(response http.ResponseWriter, _ *http.Request) { ++ writeJSON(response, http.StatusPaymentRequired, sessionChallengePayload()) ++ }) ++ mux.HandleFunc("/batch-settlement", func(response http.ResponseWriter, _ *http.Request) { ++ writePaymentRequired(response, batchSettlementChallengePayload()) ++ }) ++ mux.HandleFunc("/", func(response http.ResponseWriter, request *http.Request) { ++ if request.URL.Path != defaultResourcePath { ++ writeJSON(response, http.StatusNotFound, map[string]any{"error": "not_found"}) ++ return ++ } ++ ++ paymentSignature := request.Header.Get("PAYMENT-SIGNATURE") ++ if paymentSignature == "" { ++ writeExactPaymentRequired(response, state) ++ return ++ } ++ ++ settlement, err := settleExactPayment(state, paymentSignature) ++ if err != nil { ++ challenge := exactChallengePayload(state) ++ encoded, marshalErr := json.Marshal(challenge) ++ if marshalErr != nil { ++ panic(marshalErr) ++ } ++ writeJSONWithHeaders( ++ response, ++ http.StatusPaymentRequired, ++ map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(encoded)}, ++ map[string]any{ ++ "error": "payment_invalid", ++ "message": err.Error(), ++ }, ++ ) ++ return ++ } ++ ++ writeJSONWithHeaders( ++ response, ++ http.StatusOK, ++ map[string]string{defaultSettlementHeader: settlement}, ++ map[string]any{ ++ "ok": true, ++ "paid": true, ++ "settlement": map[string]any{ ++ "success": true, ++ "transaction": settlement, ++ "network": state.network, ++ }, ++ }, ++ ) ++ }) ++ return mux ++} ++ ++func runInteropServer(state serverState, listener net.Listener, signals <-chan os.Signal, readyWriter io.Writer, errWriter io.Writer) error { ++ server := &http.Server{Handler: newInteropMux(state)} ++ serveErr := make(chan error, 1) ++ go func() { ++ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { ++ serveErr <- err ++ } ++ close(serveErr) ++ }() ++ ++ ready := capabilityPayload("go") ++ ready["type"] = "ready" ++ ready["port"] = listener.Addr().(*net.TCPAddr).Port ++ encoded, err := json.Marshal(ready) ++ if err != nil { ++ return err ++ } ++ if _, err := fmt.Fprintln(readyWriter, string(encoded)); err != nil { ++ return err ++ } ++ ++ select { ++ case <-signals: ++ if err := server.Close(); err != nil { ++ _, _ = fmt.Fprintln(errWriter, err) ++ return err ++ } ++ return nil ++ case err := <-serveErr: ++ if err != nil { ++ _, _ = fmt.Fprintln(errWriter, err) ++ } ++ return err ++ } ++} ++ ++func main() { ++ state := readState() ++ listener, err := net.Listen("tcp", "127.0.0.1:0") ++ if err != nil { ++ panic(err) ++ } ++ ++ signals := make(chan os.Signal, 1) ++ signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) ++ if err := runInteropServer(state, listener, signals, os.Stdout, os.Stderr); err != nil { ++ os.Exit(1) ++ } ++} +diff --git a/go/x402/cmd/interop-server/main_test.go b/go/x402/cmd/interop-server/main_test.go +new file mode 100644 +index 0000000..30c1acf +--- /dev/null ++++ b/go/x402/cmd/interop-server/main_test.go +@@ -0,0 +1,2714 @@ ++package main ++ ++import ( ++ "bytes" ++ "encoding/base64" ++ "encoding/binary" ++ "encoding/json" ++ "errors" ++ "fmt" ++ "io" ++ "net" ++ "net/http" ++ "net/http/httptest" ++ "os" ++ "strconv" ++ "strings" ++ "sync" ++ "syscall" ++ "testing" ++ "time" ++ ++ "github.com/gagliardetto/solana-go" ++) ++ ++func TestNormalizeAmountUsesSixMintDecimals(t *testing.T) { ++ tests := map[string]string{ ++ "$0.001": "1000", ++ "0.001 USDC": "1000", ++ "1": "1000000", ++ "1.25": "1250000", ++ } ++ ++ for price, expected := range tests { ++ if actual := normalizeAmount(price); actual != expected { ++ t.Fatalf("normalizeAmount(%q) = %q, want %q", price, actual, expected) ++ } ++ } ++} ++ ++func TestNormalizeAmountRejectsMalformedPrices(t *testing.T) { ++ tests := []string{ ++ "not-a-price", ++ "1.0000001", ++ "1.bad", ++ } ++ ++ for _, price := range tests { ++ t.Run(price, func(t *testing.T) { ++ mustPanic(t, func() { ++ normalizeAmount(price) ++ }) ++ }) ++ } ++} ++ ++func TestEnvHelpersAndReadState(t *testing.T) { ++ privateKey, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ encodedKey, err := json.Marshal([]byte(privateKey)) ++ if err != nil { ++ t.Fatal(err) ++ } ++ payTo := solana.NewWallet().PublicKey().String() ++ ++ t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") ++ t.Setenv("X402_INTEROP_PAY_TO", payTo) ++ t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) ++ t.Setenv("X402_INTEROP_NETWORK", solanaMainnetCAIP2) ++ t.Setenv("X402_INTEROP_MINT", "USDG") ++ t.Setenv("X402_INTEROP_PRICE", "$1.25") ++ t.Setenv("X402_INTEROP_EXTRA_OFFERED_MINTS", " PYUSD, , CASH ") ++ ++ state := readState() ++ if state.rpcURL != "http://rpc.test" || state.network != solanaMainnetCAIP2 { ++ t.Fatalf("unexpected state: %+v", state) ++ } ++ if state.mint != "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH" { ++ t.Fatalf("expected resolved USDG mainnet mint, got %s", state.mint) ++ } ++ if state.payTo != payTo || !state.feePayer.PublicKey().Equals(privateKey.PublicKey()) { ++ t.Fatalf("readState did not preserve configured keys") ++ } ++ if state.amount != "1250000" { ++ t.Fatalf("amount = %s, want 1250000", state.amount) ++ } ++ if len(state.extraOfferedMints) != 2 || ++ state.extraOfferedMints[0] != "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo" || ++ state.extraOfferedMints[1] != "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH" { ++ t.Fatalf("unexpected extra mints: %#v", state.extraOfferedMints) ++ } ++ if state.httpClient == nil { ++ t.Fatal("expected readState to configure an HTTP client") ++ } ++ if got := readEnvWithDefault("X402_INTEROP_NETWORK", "fallback"); got != solanaMainnetCAIP2 { ++ t.Fatalf("readEnvWithDefault configured = %q", got) ++ } ++ if got := readEnvWithDefault("X402_INTEROP_MISSING", "fallback"); got != "fallback" { ++ t.Fatalf("readEnvWithDefault fallback = %q", got) ++ } ++ ++ t.Setenv("X402_INTEROP_REQUIRED_EMPTY", "") ++ mustPanic(t, func() { ++ readRequiredEnv("X402_INTEROP_REQUIRED_EMPTY") ++ }) ++ mustPanic(t, func() { ++ keypairFromJSONSecret("[1,2,3]") ++ }) ++ mustPanic(t, func() { ++ keypairFromJSONSecret("{") ++ }) ++} ++ ++func TestJSONWritersAndChallengePayloads(t *testing.T) { ++ recorder := httptest.NewRecorder() ++ writeJSON(recorder, http.StatusAccepted, map[string]any{"ok": true}) ++ if recorder.Code != http.StatusAccepted { ++ t.Fatalf("status = %d", recorder.Code) ++ } ++ if recorder.Header().Get("content-type") != "application/json" { ++ t.Fatalf("unexpected content type: %s", recorder.Header().Get("content-type")) ++ } ++ if strings.TrimSpace(recorder.Body.String()) != `{"ok":true}` { ++ t.Fatalf("unexpected JSON body: %s", recorder.Body.String()) ++ } ++ ++ recorder = httptest.NewRecorder() ++ writeJSONWithHeaders(recorder, http.StatusCreated, map[string]string{"x-test": "value"}, map[string]any{"created": true}) ++ if recorder.Code != http.StatusCreated || recorder.Header().Get("x-test") != "value" { ++ t.Fatalf("headers/status not written: %d %v", recorder.Code, recorder.Header()) ++ } ++ ++ capabilities := capabilityPayload("go") ++ if capabilities["implementation"] != "go" || capabilities["role"] != "server" { ++ t.Fatalf("unexpected capability payload: %#v", capabilities) ++ } ++ if got := len(capabilities["capabilities"].([]string)); got != 1 { ++ t.Fatalf("expected one implemented capability, got %d", got) ++ } ++ ++ state := testServerState(t) ++ state.memo = "bound-memo" ++ exact := exactChallengePayload(state) ++ if exact.X402Version != 2 || exact.Resource["uri"] != defaultResourcePath { ++ t.Fatalf("unexpected exact challenge: %+v", exact) ++ } ++ if exact.Accepts[0].Extra["memo"] != "bound-memo" { ++ t.Fatalf("expected exact requirement to include memo") ++ } ++ if uptoChallengePayload()["x402Version"] != 2 { ++ t.Fatal("expected x402 upto challenge") ++ } ++ if sessionChallengePayload()["intent"] != "session" { ++ t.Fatal("expected session challenge intent") ++ } ++ if batchSettlementChallengePayload()["x402Version"] != 2 { ++ t.Fatal("expected batch settlement challenge") ++ } ++} ++ ++func TestPaymentRequiredWritersEncodeChallenges(t *testing.T) { ++ state := testServerState(t) ++ ++ recorder := httptest.NewRecorder() ++ writePaymentRequired(recorder, uptoChallengePayload()) ++ if recorder.Code != http.StatusPaymentRequired { ++ t.Fatalf("status = %d", recorder.Code) ++ } ++ decoded, err := base64.StdEncoding.DecodeString(recorder.Header().Get("PAYMENT-REQUIRED")) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if !strings.Contains(string(decoded), `"scheme":"upto"`) { ++ t.Fatalf("unexpected encoded challenge: %s", string(decoded)) ++ } ++ ++ recorder = httptest.NewRecorder() ++ writeExactPaymentRequired(recorder, state) ++ decoded, err = base64.StdEncoding.DecodeString(recorder.Header().Get("PAYMENT-REQUIRED")) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if !strings.Contains(string(decoded), `"scheme":"exact"`) { ++ t.Fatalf("unexpected exact challenge: %s", string(decoded)) ++ } ++} ++ ++func TestDefaultTokenProgramForMintHandlesAliasesAndMints(t *testing.T) { ++ tests := map[string]string{ ++ " PYUSD ": token2022Program, ++ "USDG": token2022Program, ++ "CASH": token2022Program, ++ "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM": token2022Program, ++ "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU": defaultTokenProgram, ++ } ++ ++ for mint, want := range tests { ++ t.Run(mint, func(t *testing.T) { ++ if got := defaultTokenProgramForMint(mint); got != want { ++ t.Fatalf("defaultTokenProgramForMint(%q) = %q, want %q", mint, got, want) ++ } ++ }) ++ } ++} ++ ++func TestPaymentRequirementMatchesBindsSettlementFields(t *testing.T) { ++ feePayer := solana.NewWallet().PrivateKey ++ state := serverState{ ++ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ payTo: solana.NewWallet().PublicKey().String(), ++ feePayer: feePayer, ++ amount: "1000", ++ } ++ ++ requirement := exactRequirement(state) ++ if !paymentRequirementMatches(requirement, requirement) { ++ t.Fatal("expected matching requirement to pass") ++ } ++ ++ mutated := requirement ++ mutated.Extra = map[string]any{ ++ "decimals": defaultDecimals, ++ "feePayer": solana.NewWallet().PublicKey().String(), ++ "tokenProgram": defaultTokenProgram, ++ } ++ if paymentRequirementMatches(mutated, requirement) { ++ t.Fatal("expected fee payer mutation to be rejected") ++ } ++} ++ ++func TestPaymentRequirementMatchesRejectsExactRequirementDrift(t *testing.T) { ++ feePayer := solana.NewWallet().PrivateKey ++ state := serverState{ ++ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ payTo: solana.NewWallet().PublicKey().String(), ++ feePayer: feePayer, ++ amount: "1000", ++ } ++ ++ requirement := exactRequirement(state) ++ tests := map[string]func(paymentRequirement) paymentRequirement{ ++ "scheme": func(value paymentRequirement) paymentRequirement { ++ value.Scheme = "upto" ++ return value ++ }, ++ "network": func(value paymentRequirement) paymentRequirement { ++ value.Network = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" ++ return value ++ }, ++ "asset": func(value paymentRequirement) paymentRequirement { ++ value.Asset = solana.NewWallet().PublicKey().String() ++ return value ++ }, ++ "amount": func(value paymentRequirement) paymentRequirement { ++ value.Amount = "2000" ++ return value ++ }, ++ "payTo": func(value paymentRequirement) paymentRequirement { ++ value.PayTo = solana.NewWallet().PublicKey().String() ++ return value ++ }, ++ "maxTimeoutSeconds": func(value paymentRequirement) paymentRequirement { ++ value.MaxTimeoutSeconds = defaultMaxTimeout + 1 ++ return value ++ }, ++ "extra.tokenProgram": func(value paymentRequirement) paymentRequirement { ++ value.Extra = cloneExtra(value.Extra) ++ value.Extra["tokenProgram"] = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" ++ return value ++ }, ++ "extra.unexpected": func(value paymentRequirement) paymentRequirement { ++ value.Extra = cloneExtra(value.Extra) ++ value.Extra["memo"] = "drift" ++ return value ++ }, ++ } ++ ++ for name, mutate := range tests { ++ t.Run(name, func(t *testing.T) { ++ if paymentRequirementMatches(mutate(requirement), requirement) { ++ t.Fatalf("expected %s drift to be rejected", name) ++ } ++ }) ++ } ++} ++ ++func TestExactChallengeIncludesExtraOfferedMints(t *testing.T) { ++ feePayer := solana.NewWallet().PrivateKey ++ state := serverState{ ++ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ payTo: solana.NewWallet().PublicKey().String(), ++ feePayer: feePayer, ++ amount: "1000", ++ extraOfferedMints: []string{"CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM"}, ++ } ++ ++ challenge := exactChallengePayload(state) ++ ++ if len(challenge.Accepts) != 2 { ++ t.Fatalf("expected primary plus extra mint offers, got %d", len(challenge.Accepts)) ++ } ++ if challenge.Accepts[0].Asset != state.mint { ++ t.Fatalf("expected primary mint first, got %s", challenge.Accepts[0].Asset) ++ } ++ if challenge.Accepts[1].Asset != state.extraOfferedMints[0] { ++ t.Fatalf("expected extra mint second, got %s", challenge.Accepts[1].Asset) ++ } ++ if challenge.Accepts[1].Extra["tokenProgram"] != "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" { ++ t.Fatalf("expected PYUSD offer to use Token-2022, got %v", challenge.Accepts[1].Extra["tokenProgram"]) ++ } ++} ++ ++func TestSettleExactPaymentRejectsMalformedPaymentSignature(t *testing.T) { ++ state := testServerState(t) ++ state.memo = "unit-duplicate" ++ ++ tests := map[string]string{ ++ "base64": "not base64", ++ "json": base64.StdEncoding.EncodeToString([]byte("{")), ++ } ++ ++ for name, header := range tests { ++ t.Run(name, func(t *testing.T) { ++ if _, err := settleExactPayment(state, header); err == nil { ++ t.Fatal("expected malformed payment signature to be rejected") ++ } ++ }) ++ } ++} ++ ++func TestSettleExactPaymentRejectsMissingAndInvalidTransactionPayload(t *testing.T) { ++ state := testServerState(t) ++ requirement := exactRequirement(state) ++ tests := map[string]map[string]string{ ++ "missing": {}, ++ "invalid": {"transaction": "not a transaction"}, ++ } ++ ++ for name, payload := range tests { ++ t.Run(name, func(t *testing.T) { ++ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ ++ X402Version: 2, ++ Accepted: requirement, ++ Payload: payload, ++ }) ++ ++ if _, err := settleExactPayment(state, header); err == nil { ++ t.Fatal("expected transaction payload to be rejected") ++ } ++ }) ++ } ++} ++ ++func TestSettleExactPaymentRejectsVersionAndRequirementMismatch(t *testing.T) { ++ state := testServerState(t) ++ requirement := exactRequirement(state) ++ ++ versionHeader := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ ++ X402Version: 1, ++ Accepted: requirement, ++ Payload: map[string]string{"transaction": "unused"}, ++ }) ++ if _, err := settleExactPayment(state, versionHeader); err == nil || err.Error() != "unsupported x402Version: 1" { ++ t.Fatalf("expected version rejection, got %v", err) ++ } ++ ++ drifted := requirement ++ drifted.Amount = "999" ++ driftHeader := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ ++ X402Version: 2, ++ Accepted: drifted, ++ Payload: map[string]string{"transaction": "unused"}, ++ }) ++ if _, err := settleExactPayment(state, driftHeader); err == nil || err.Error() != "accepted payment requirement does not match server challenge" { ++ t.Fatalf("expected requirement mismatch, got %v", err) ++ } ++} ++ ++func successfulSettlementClient(t *testing.T, signature string) *http.Client { ++ t.Helper() ++ return &http.Client{ ++ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { ++ rawBody, err := io.ReadAll(request.Body) ++ if err != nil { ++ t.Fatal(err) ++ } ++ body := string(rawBody) ++ responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` ++ switch { ++ case strings.Contains(body, `"method":"sendTransaction"`): ++ responseBody = fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"result":%q}`, signature) ++ case strings.Contains(body, `"method":"getSignatureStatuses"`): ++ responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` ++ } ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(responseBody)), ++ }, nil ++ }), ++ } ++} ++ ++func TestSettleExactPaymentAcceptsExtraOfferedMint(t *testing.T) { ++ settlementCache = newDuplicateSettlementCache() ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.extraOfferedMints = []string{"CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM"} ++ state.memo = "extra-mint" ++ state.httpClient = successfulSettlementClient(t, "extra-mint-settlement") ++ defer func() { ++ settlementCache = newDuplicateSettlementCache() ++ }() ++ ++ requirement := exactRequirementForMint(state, state.extraOfferedMints[0]) ++ transaction := signedTransactionForTest(t, requirement, client) ++ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ ++ X402Version: 2, ++ Accepted: requirement, ++ Payload: map[string]string{ ++ "transaction": transaction, ++ }, ++ }) ++ ++ settlement, err := settleExactPayment(state, header) ++ if err != nil { ++ t.Fatalf("expected extra offered mint settlement to pass: %v", err) ++ } ++ if settlement != "extra-mint-settlement" { ++ t.Fatalf("settlement = %q", settlement) ++ } ++} ++ ++func TestSettleExactPaymentRejectsDuplicateTransactionPayload(t *testing.T) { ++ settlementCache = newDuplicateSettlementCache() ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "unit-duplicate" ++ sendCalls := 0 ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { ++ rawBody, err := io.ReadAll(request.Body) ++ if err != nil { ++ t.Fatal(err) ++ } ++ body := string(rawBody) ++ responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` ++ switch { ++ case strings.Contains(body, `"method":"sendTransaction"`): ++ sendCalls++ ++ responseBody = `{"jsonrpc":"2.0","id":1,"result":"unit-settlement"}` ++ case strings.Contains(body, `"method":"getSignatureStatuses"`): ++ responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` ++ } ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(responseBody)), ++ }, nil ++ }), ++ } ++ defer func() { ++ settlementCache = newDuplicateSettlementCache() ++ }() ++ requirement := exactRequirement(state) ++ transaction := signedTransactionForTest(t, requirement, client) ++ ++ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ ++ X402Version: 2, ++ Accepted: requirement, ++ Payload: map[string]string{ ++ "transaction": transaction, ++ }, ++ }) ++ ++ if settlement, err := settleExactPayment(state, header); err != nil || settlement != "unit-settlement" { ++ t.Fatalf("first settlement = %q, %v", settlement, err) ++ } ++ if _, err := settleExactPayment(state, header); err == nil || err.Error() != "duplicate_settlement" { ++ t.Fatalf("expected duplicate_settlement, got %v", err) ++ } ++ // Under broadcast-first L8 ordering, the duplicate transaction does ++ // reach sendTransaction (Solana itself is the global uniqueness ++ // primitive: a re-broadcast of the same signed tx is idempotent ++ // within its blockhash window). The replay-store check only fires ++ // post-confirmation, so the second call broadcasts and then is ++ // rejected at putIfAbsent because the signature was already ++ // consumed by the first successful settlement. ++ if sendCalls != 2 { ++ t.Fatalf("expected two sendTransaction calls under broadcast-first ordering, got %d", sendCalls) ++ } ++} ++ ++// TestSettleExactPaymentDoesNotConsumeReplayKeyOnPreBroadcastFailure covers ++// the L8 ordering invariant: a verification failure before broadcast (here, ++// a missing source token account) MUST NOT insert anything into the ++// replay-store. The proof is that an immediate retry of the same envelope ++// produces the same pre-broadcast error (rather than being rejected as a ++// duplicate settlement). Under broadcast-first ordering there is no ++// release-on-failure path; correctness follows from "never inserted in ++// the first place" instead. ++func TestSettleExactPaymentDoesNotConsumeReplayKeyOnPreBroadcastFailure(t *testing.T) { ++ settlementCache = newDuplicateSettlementCache() ++ defer func() { ++ settlementCache = newDuplicateSettlementCache() ++ }() ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "unit-missing-ata" ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { ++ rawBody, err := io.ReadAll(request.Body) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if strings.Contains(string(rawBody), `"method":"getAccountInfo"`) { ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"context":{"slot":1},"value":null}}`)), ++ }, nil ++ } ++ t.Fatalf("unexpected RPC body: %s", string(rawBody)) ++ return nil, nil ++ }), ++ } ++ requirement := exactRequirement(state) ++ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ ++ X402Version: 2, ++ Accepted: requirement, ++ Payload: map[string]string{ ++ "transaction": signedTransactionForTest(t, requirement, client), ++ }, ++ }) ++ ++ if _, err := settleExactPayment(state, header); err == nil || err.Error() != "source token account does not exist" { ++ t.Fatalf("expected missing source account, got %v", err) ++ } ++ if _, err := settleExactPayment(state, header); err == nil || err.Error() != "source token account does not exist" { ++ t.Fatalf("expected retry to surface the same pre-broadcast error (replay key never inserted), got %v", err) ++ } ++} ++ ++// TestSettleExactPaymentL8OrderingObserved asserts the L8 RPC call ++// sequence: getAccountInfo (token-account existence) → sendTransaction ++// (broadcast) → getSignatureStatuses (await confirmation) → replay store ++// insert. The replay store insert is observable through a duplicate retry ++// returning duplicate_settlement on the SAME signature, without any RPC ++// activity ordered after putIfAbsent. ++func TestSettleExactPaymentL8OrderingObserved(t *testing.T) { ++ settlementCache = newDuplicateSettlementCache() ++ defer func() { settlementCache = newDuplicateSettlementCache() }() ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "l8-ordering" ++ var rpcCalls []string ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { ++ rawBody, err := io.ReadAll(request.Body) ++ if err != nil { ++ t.Fatal(err) ++ } ++ body := string(rawBody) ++ responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` ++ switch { ++ case strings.Contains(body, `"method":"sendTransaction"`): ++ rpcCalls = append(rpcCalls, "sendTransaction") ++ responseBody = `{"jsonrpc":"2.0","id":1,"result":"l8-sig"}` ++ case strings.Contains(body, `"method":"getSignatureStatuses"`): ++ rpcCalls = append(rpcCalls, "getSignatureStatuses") ++ responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` ++ case strings.Contains(body, `"method":"getAccountInfo"`): ++ rpcCalls = append(rpcCalls, "getAccountInfo") ++ } ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(responseBody)), ++ }, nil ++ }), ++ } ++ requirement := exactRequirement(state) ++ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ ++ X402Version: 2, ++ Accepted: requirement, ++ Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, ++ }) ++ ++ signature, err := settleExactPayment(state, header) ++ if err != nil { ++ t.Fatalf("expected first settlement to succeed, got %v", err) ++ } ++ if signature != "l8-sig" { ++ t.Fatalf("signature = %q", signature) ++ } ++ // Drop pre-broadcast getAccountInfo calls; the load-bearing assertion ++ // is that broadcast precedes confirmation polling, which precedes the ++ // replay-store insert (proven by the subsequent duplicate_settlement). ++ var phaseOrder []string ++ for _, call := range rpcCalls { ++ if call == "sendTransaction" || call == "getSignatureStatuses" { ++ phaseOrder = append(phaseOrder, call) ++ } ++ } ++ if len(phaseOrder) < 2 || phaseOrder[0] != "sendTransaction" || phaseOrder[1] != "getSignatureStatuses" { ++ t.Fatalf("expected sendTransaction before getSignatureStatuses, got %v", phaseOrder) ++ } ++ if _, ok := settlementCache.entries[replayKeyNamespace+signature]; !ok { ++ t.Fatalf("expected replay key %q to be present after confirmation", replayKeyNamespace+signature) ++ } ++} ++ ++// TestSettleExactPaymentDoesNotConsumeReplayKeyOnBroadcastFailure covers ++// the L8 invariant that an RPC failure during broadcast (before ++// confirmation) MUST NOT insert the replay key. Mirrors MPP ++// `server/charge.rs` semantics: only a confirmed signature is consumed. ++func TestSettleExactPaymentDoesNotConsumeReplayKeyOnBroadcastFailure(t *testing.T) { ++ settlementCache = newDuplicateSettlementCache() ++ defer func() { settlementCache = newDuplicateSettlementCache() }() ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "l8-broadcast-fail" ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { ++ rawBody, err := io.ReadAll(request.Body) ++ if err != nil { ++ t.Fatal(err) ++ } ++ body := string(rawBody) ++ responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` ++ if strings.Contains(body, `"method":"sendTransaction"`) { ++ responseBody = `{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"blockhash not found"}}` ++ } ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(responseBody)), ++ }, nil ++ }), ++ } ++ requirement := exactRequirement(state) ++ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ ++ X402Version: 2, ++ Accepted: requirement, ++ Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, ++ }) ++ ++ if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "sendTransaction RPC error") { ++ t.Fatalf("expected broadcast RPC error, got %v", err) ++ } ++ if len(settlementCache.entries) != 0 { ++ t.Fatalf("expected empty replay cache after broadcast failure, got %d entries", len(settlementCache.entries)) ++ } ++} ++ ++// TestSettleExactPaymentDoesNotConsumeReplayKeyOnConfirmationFailure ++// covers the L8 invariant that an on-chain failure surfaced via ++// getSignatureStatuses (e.g. tx landed but reverted) MUST NOT insert ++// the replay key — a future re-broadcast under a fresh blockhash is the ++// caller's option, not a duplicate. ++func TestSettleExactPaymentDoesNotConsumeReplayKeyOnConfirmationFailure(t *testing.T) { ++ settlementCache = newDuplicateSettlementCache() ++ defer func() { settlementCache = newDuplicateSettlementCache() }() ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "l8-confirm-fail" ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { ++ rawBody, err := io.ReadAll(request.Body) ++ if err != nil { ++ t.Fatal(err) ++ } ++ body := string(rawBody) ++ responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` ++ switch { ++ case strings.Contains(body, `"method":"sendTransaction"`): ++ responseBody = `{"jsonrpc":"2.0","id":1,"result":"reverted-sig"}` ++ case strings.Contains(body, `"method":"getSignatureStatuses"`): ++ responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":{"InstructionError":[0,"Custom"]},"confirmationStatus":"confirmed"}]}}` ++ } ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(responseBody)), ++ }, nil ++ }), ++ } ++ requirement := exactRequirement(state) ++ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ ++ X402Version: 2, ++ Accepted: requirement, ++ Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, ++ }) ++ ++ if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "transaction failed on-chain") { ++ t.Fatalf("expected on-chain failure, got %v", err) ++ } ++ if _, ok := settlementCache.entries[replayKeyNamespace+"reverted-sig"]; ok { ++ t.Fatalf("expected replay key NOT to be consumed when confirmation surfaces on-chain failure") ++ } ++} ++ ++// TestSettleExactPaymentReturnsCanonicalErrorForConsumedSignature covers ++// the L8 invariant that a putIfAbsent collision (signature already ++// consumed) surfaces the canonical duplicate_settlement error and does ++// not echo a fresh PAYMENT-RESPONSE. ++func TestSettleExactPaymentReturnsCanonicalErrorForConsumedSignature(t *testing.T) { ++ settlementCache = newDuplicateSettlementCache() ++ defer func() { settlementCache = newDuplicateSettlementCache() }() ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "l8-pre-consumed" ++ state.httpClient = successfulSettlementClient(t, "pre-consumed-sig") ++ // Simulate a prior successful settlement having already inserted ++ // the canonical replay key for this signature. ++ settlementCache.entries[replayKeyNamespace+"pre-consumed-sig"] = time.Now() ++ ++ requirement := exactRequirement(state) ++ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ ++ X402Version: 2, ++ Accepted: requirement, ++ Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, ++ }) ++ if _, err := settleExactPayment(state, header); err == nil || err.Error() != "duplicate_settlement" { ++ t.Fatalf("expected duplicate_settlement on already-consumed signature, got %v", err) ++ } ++} ++ ++// TestSettleExactPaymentConcurrentDuplicatesCollapse asserts that two ++// concurrent settlements producing the same signature collapse to a ++// single successful settle and one canonical duplicate_settlement. ++// Solana's per-signature replay protection guarantees the on-chain ++// effect is single; the putIfAbsent collision in the replay store ++// guarantees the off-chain accounting is single. ++func TestSettleExactPaymentConcurrentDuplicatesCollapse(t *testing.T) { ++ settlementCache = newDuplicateSettlementCache() ++ defer func() { settlementCache = newDuplicateSettlementCache() }() ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "l8-concurrent" ++ state.httpClient = successfulSettlementClient(t, "concurrent-sig") ++ requirement := exactRequirement(state) ++ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ ++ X402Version: 2, ++ Accepted: requirement, ++ Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, ++ }) ++ ++ const concurrency = 4 ++ var wg sync.WaitGroup ++ results := make([]error, concurrency) ++ signatures := make([]string, concurrency) ++ wg.Add(concurrency) ++ for i := 0; i < concurrency; i++ { ++ go func(idx int) { ++ defer wg.Done() ++ signatures[idx], results[idx] = settleExactPayment(state, header) ++ }(i) ++ } ++ wg.Wait() ++ ++ successes := 0 ++ duplicates := 0 ++ for i, err := range results { ++ switch { ++ case err == nil: ++ successes++ ++ if signatures[i] != "concurrent-sig" { ++ t.Fatalf("unexpected signature %q", signatures[i]) ++ } ++ case err.Error() == "duplicate_settlement": ++ duplicates++ ++ default: ++ t.Fatalf("unexpected error %v", err) ++ } ++ } ++ if successes != 1 || duplicates != concurrency-1 { ++ t.Fatalf("expected 1 success + %d duplicates, got %d / %d", concurrency-1, successes, duplicates) ++ } ++} ++ ++// TestAwaitSignatureConfirmationCases drills the L8 confirmation poll ++// directly against the four observable RPC outcomes: confirmed/finalized ++// success, on-chain failure, transport-level RPC error, and bounded ++// timeout when no status ever surfaces. ++func TestAwaitSignatureConfirmationCases(t *testing.T) { ++ prevAttempts := confirmationPollAttempts ++ prevInterval := confirmationPollInterval ++ confirmationPollAttempts = 3 ++ confirmationPollInterval = time.Millisecond ++ defer func() { ++ confirmationPollAttempts = prevAttempts ++ confirmationPollInterval = prevInterval ++ }() ++ ++ tests := map[string]struct { ++ responseBody string ++ wantErr string ++ }{ ++ "confirmed": { ++ responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}`, ++ wantErr: "", ++ }, ++ "finalized": { ++ responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":32,"err":null,"confirmationStatus":"finalized"}]}}`, ++ wantErr: "", ++ }, ++ "on-chain failure": { ++ responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":{"InstructionError":[0,"Custom"]},"confirmationStatus":"confirmed"}]}}`, ++ wantErr: "transaction failed on-chain", ++ }, ++ "rpc error": { ++ responseBody: `{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"boom"}}`, ++ wantErr: "getSignatureStatuses RPC error", ++ }, ++ "timeout": { ++ responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[null]}}`, ++ wantErr: "transaction not confirmed within timeout", ++ }, ++ } ++ for name, test := range tests { ++ t.Run(name, func(t *testing.T) { ++ state := testServerState(t) ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(test.responseBody)), ++ }, nil ++ }), ++ } ++ err := awaitSignatureConfirmation(state, "sig") ++ switch { ++ case test.wantErr == "" && err != nil: ++ t.Fatalf("expected success, got %v", err) ++ case test.wantErr != "" && (err == nil || !strings.Contains(err.Error(), test.wantErr)): ++ t.Fatalf("expected error containing %q, got %v", test.wantErr, err) ++ } ++ }) ++ } ++} ++ ++func TestAwaitSignatureConfirmationTransportError(t *testing.T) { ++ prevAttempts := confirmationPollAttempts ++ confirmationPollAttempts = 1 ++ defer func() { confirmationPollAttempts = prevAttempts }() ++ state := testServerState(t) ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { ++ return nil, fmt.Errorf("dial timeout") ++ }), ++ } ++ if err := awaitSignatureConfirmation(state, "sig"); err == nil || !strings.Contains(err.Error(), "getSignatureStatuses transport") { ++ t.Fatalf("expected transport error, got %v", err) ++ } ++} ++ ++func TestAwaitSignatureConfirmationNon2xx(t *testing.T) { ++ prevAttempts := confirmationPollAttempts ++ confirmationPollAttempts = 1 ++ defer func() { confirmationPollAttempts = prevAttempts }() ++ state := testServerState(t) ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { ++ return &http.Response{ ++ StatusCode: http.StatusInternalServerError, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(`{"error":"boom"}`)), ++ }, nil ++ }), ++ } ++ if err := awaitSignatureConfirmation(state, "sig"); err == nil || !strings.Contains(err.Error(), "getSignatureStatuses HTTP 500") { ++ t.Fatalf("expected HTTP 500, got %v", err) ++ } ++} ++ ++func TestVerifyExactTransactionRejectsSpecViolations(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "unit-spec" ++ requirement := exactRequirement(state) ++ valid := transactionForTest(t, requirement, client) ++ ++ tests := map[string]struct { ++ mutate func(*solana.Transaction) ++ want string ++ }{ ++ "compute price too high": { ++ mutate: func(tx *solana.Transaction) { ++ tx.Message.Instructions[1].Data = computePriceDataForTest(maxComputeUnitPrice + 1) ++ }, ++ want: "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high", ++ }, ++ "amount mismatch": { ++ mutate: func(tx *solana.Transaction) { ++ data := []byte{12} ++ data = binary.LittleEndian.AppendUint64(data, 999) ++ data = append(data, byte(defaultDecimals)) ++ tx.Message.Instructions[2].Data = data ++ }, ++ want: "invalid_exact_svm_payload_transaction_amount", ++ }, ++ "missing memo": { ++ mutate: func(tx *solana.Transaction) { ++ tx.Message.Instructions = tx.Message.Instructions[:3] ++ }, ++ want: "invalid_exact_svm_payload_transaction_memo", ++ }, ++ "fee payer instruction account": { ++ mutate: func(tx *solana.Transaction) { ++ tx.Message.Instructions[2].Accounts[0] = 0 ++ }, ++ want: "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", ++ }, ++ "mint mismatch": { ++ mutate: func(tx *solana.Transaction) { ++ tx.Message.AccountKeys = append(tx.Message.AccountKeys, solana.NewWallet().PublicKey()) ++ tx.Message.Instructions[2].Accounts[1] = uint16(len(tx.Message.AccountKeys) - 1) ++ }, ++ want: "invalid_exact_svm_payload_transaction_mint", ++ }, ++ "decimals mismatch": { ++ mutate: func(tx *solana.Transaction) { ++ data := []byte{12} ++ data = binary.LittleEndian.AppendUint64(data, 1000) ++ data = append(data, byte(defaultDecimals+1)) ++ tx.Message.Instructions[2].Data = data ++ }, ++ want: "invalid_exact_svm_payload_transaction_decimals", ++ }, ++ "memo mismatch": { ++ mutate: func(tx *solana.Transaction) { ++ tx.Message.Instructions[3].Data = []byte("wrong") ++ }, ++ want: "invalid_exact_svm_payload_transaction_memo", ++ }, ++ "unknown fourth instruction": { ++ mutate: func(tx *solana.Transaction) { ++ tx.Message.Instructions[3] = compiledInstructionForTest(t, tx, solana.SystemProgramID.String(), nil) ++ }, ++ want: "invalid_exact_svm_payload_unknown_fourth_instruction", ++ }, ++ } ++ ++ for name, test := range tests { ++ t.Run(name, func(t *testing.T) { ++ tx := cloneTransactionForTest(t, valid) ++ test.mutate(tx) ++ if err := verifyExactTransaction(tx, requirement); err == nil || err.Error() != test.want { ++ t.Fatalf("expected %q, got %v", test.want, err) ++ } ++ }) ++ } ++} ++ ++func TestVerifyExactTransactionRejectsMalformedInstructionShapes(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "unit-shapes" ++ requirement := exactRequirement(state) ++ valid := transactionForTest(t, requirement, client) ++ ++ tests := map[string]struct { ++ mutate func(*solana.Transaction) ++ want string ++ }{ ++ "legacy transaction": { ++ mutate: func(tx *solana.Transaction) { ++ tx.Message.SetVersion(solana.MessageVersionLegacy) ++ }, ++ want: "payment transaction must be versioned", ++ }, ++ "too few instructions": { ++ mutate: func(tx *solana.Transaction) { ++ tx.Message.Instructions = tx.Message.Instructions[:2] ++ }, ++ want: "invalid_exact_svm_payload_transaction_instructions_length", ++ }, ++ "bad compute limit": { ++ mutate: func(tx *solana.Transaction) { ++ tx.Message.Instructions[0].Data = []byte{2} ++ }, ++ want: "invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction", ++ }, ++ "bad compute price": { ++ mutate: func(tx *solana.Transaction) { ++ tx.Message.Instructions[1].Data = []byte{3} ++ }, ++ want: "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction", ++ }, ++ "bad transfer program": { ++ mutate: func(tx *solana.Transaction) { ++ tx.Message.Instructions[2] = compiledInstructionForTest(t, tx, solana.SystemProgramID.String(), []byte{12}) ++ }, ++ want: "invalid_exact_svm_payload_transaction_transfer_program", ++ }, ++ "bad transfer data": { ++ mutate: func(tx *solana.Transaction) { ++ tx.Message.Instructions[2].Data = []byte{12} ++ }, ++ want: "invalid_exact_svm_payload_transaction_transfer_checked", ++ }, ++ } ++ ++ for name, test := range tests { ++ t.Run(name, func(t *testing.T) { ++ tx := cloneTransactionForTest(t, valid) ++ test.mutate(tx) ++ if err := verifyExactTransaction(tx, requirement); err == nil || err.Error() != test.want { ++ t.Fatalf("expected %q, got %v", test.want, err) ++ } ++ }) ++ } ++} ++ ++func TestParseTransferCheckedInstructionRejectsInvalidAccountIndexes(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "unit-transfer-indexes" ++ tx := transactionForTest(t, exactRequirement(state), client) ++ instruction := tx.Message.Instructions[2] ++ ++ tests := map[string]int{ ++ "source": 0, ++ "mint": 1, ++ "destination": 2, ++ "authority": 3, ++ } ++ ++ for name, accountIndex := range tests { ++ t.Run(name, func(t *testing.T) { ++ mutated := instruction ++ mutated.Accounts = append([]uint16(nil), instruction.Accounts...) ++ mutated.Accounts[accountIndex] = uint16(len(tx.Message.AccountKeys)) ++ if _, err := parseTransferCheckedInstruction(tx, mutated); err == nil { ++ t.Fatal("expected invalid account index") ++ } ++ }) ++ } ++} ++ ++func TestVerifyExactTransactionRejectsMalformedRequirementFields(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "unit-requirement-fields" ++ requirement := exactRequirement(state) ++ valid := transactionForTest(t, requirement, client) ++ ++ tests := map[string]struct { ++ mutate func(paymentRequirement) paymentRequirement ++ want string ++ }{ ++ "fee payer": { ++ mutate: func(value paymentRequirement) paymentRequirement { ++ value.Extra = cloneExtra(value.Extra) ++ value.Extra["feePayer"] = "not-base58" ++ return value ++ }, ++ want: "invalid feePayer:", ++ }, ++ "asset": { ++ mutate: func(value paymentRequirement) paymentRequirement { ++ value.Asset = "not-base58" ++ return value ++ }, ++ want: "invalid asset:", ++ }, ++ "amount": { ++ mutate: func(value paymentRequirement) paymentRequirement { ++ value.Amount = "not-int" ++ return value ++ }, ++ want: "invalid amount:", ++ }, ++ "payTo": { ++ mutate: func(value paymentRequirement) paymentRequirement { ++ value.PayTo = "not-base58" ++ return value ++ }, ++ want: "invalid payTo:", ++ }, ++ } ++ ++ for name, test := range tests { ++ t.Run(name, func(t *testing.T) { ++ err := verifyExactTransaction(cloneTransactionForTest(t, valid), test.mutate(requirement)) ++ if err == nil || !strings.Contains(err.Error(), test.want) { ++ t.Fatalf("expected %q, got %v", test.want, err) ++ } ++ }) ++ } ++} ++ ++func TestVerifyExactTransactionAllowsOptionalLighthouseBeforeMemo(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "unit-lighthouse" ++ requirement := exactRequirement(state) ++ tx := transactionForTest(t, requirement, client) ++ ++ tx.Message.Instructions = append( ++ tx.Message.Instructions[:3], ++ append( ++ []solana.CompiledInstruction{compiledInstructionForTest(t, tx, lighthouseProgram, []byte{9, 0})}, ++ tx.Message.Instructions[3:]..., ++ )..., ++ ) ++ ++ if err := verifyExactTransaction(tx, requirement); err != nil { ++ t.Fatalf("expected lighthouse before memo to be accepted, got %v", err) ++ } ++} ++ ++func TestVerifyExactTransactionAllowsValidDestinationATACreateInstruction(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "unit-create-ata" ++ requirement := exactRequirement(state) ++ tx := transactionForTest(t, requirement, client) ++ transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) ++ if err != nil { ++ t.Fatal(err) ++ } ++ payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) ++ ++ tx.Message.Instructions = append( ++ tx.Message.Instructions[:3], ++ append( ++ []solana.CompiledInstruction{ ++ compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ ++ client.PublicKey(), ++ transfer.destination, ++ payTo, ++ transfer.mint, ++ solana.SystemProgramID, ++ transfer.tokenProgram, ++ }, []byte{1}), ++ }, ++ tx.Message.Instructions[3:]..., ++ )..., ++ ) ++ ++ if err := verifyExactTransaction(tx, requirement); err != nil { ++ t.Fatalf("expected valid destination ATA create instruction to be accepted, got %v", err) ++ } ++} ++ ++func TestValidDestinationATACreateInstructionRejectsMalformedCreateInstructions(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "unit-create-ata-invalid" ++ requirement := exactRequirement(state) ++ tx := transactionForTest(t, requirement, client) ++ transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) ++ if err != nil { ++ t.Fatal(err) ++ } ++ payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) ++ validAccounts := []solana.PublicKey{ ++ client.PublicKey(), ++ transfer.destination, ++ payTo, ++ transfer.mint, ++ solana.SystemProgramID, ++ transfer.tokenProgram, ++ } ++ ++ tests := map[string]solana.CompiledInstruction{ ++ "bad data": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, validAccounts, []byte{2}), ++ "too many data bytes": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, validAccounts, []byte{0, 0}), ++ "too few accounts": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, validAccounts[:5], nil), ++ "wrong associated account": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ ++ client.PublicKey(), ++ solana.NewWallet().PublicKey(), ++ payTo, ++ transfer.mint, ++ solana.SystemProgramID, ++ transfer.tokenProgram, ++ }, nil), ++ "wrong wallet": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ ++ client.PublicKey(), ++ transfer.destination, ++ solana.NewWallet().PublicKey(), ++ transfer.mint, ++ solana.SystemProgramID, ++ transfer.tokenProgram, ++ }, nil), ++ "wrong mint": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ ++ client.PublicKey(), ++ transfer.destination, ++ payTo, ++ solana.NewWallet().PublicKey(), ++ solana.SystemProgramID, ++ transfer.tokenProgram, ++ }, nil), ++ "wrong system program": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ ++ client.PublicKey(), ++ transfer.destination, ++ payTo, ++ transfer.mint, ++ solana.NewWallet().PublicKey(), ++ transfer.tokenProgram, ++ }, nil), ++ "wrong token program": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ ++ client.PublicKey(), ++ transfer.destination, ++ payTo, ++ transfer.mint, ++ solana.SystemProgramID, ++ solana.NewWallet().PublicKey(), ++ }, nil), ++ } ++ ++ for name, instruction := range tests { ++ t.Run(name, func(t *testing.T) { ++ if validDestinationATACreateInstruction(tx, instruction, requirement, transfer) { ++ t.Fatal("expected malformed destination ATA create instruction to be rejected") ++ } ++ }) ++ } ++} ++ ++func TestVerifyTokenAccountsExistSkipsMissingDestinationWhenCreateATAIsPresent(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "unit-create-ata-exists" ++ accountInfoCalls := 0 ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { ++ rawBody, err := io.ReadAll(request.Body) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if !strings.Contains(string(rawBody), `"method":"getAccountInfo"`) { ++ t.Fatalf("unexpected RPC body: %s", string(rawBody)) ++ } ++ accountInfoCalls++ ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"context":{"slot":1},"value":{"data":["","base64"]}}}`)), ++ }, nil ++ }), ++ } ++ requirement := exactRequirement(state) ++ tx := transactionForTest(t, requirement, client) ++ transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) ++ if err != nil { ++ t.Fatal(err) ++ } ++ payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) ++ tx.Message.Instructions = append( ++ tx.Message.Instructions[:3], ++ append( ++ []solana.CompiledInstruction{ ++ compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ ++ client.PublicKey(), ++ transfer.destination, ++ payTo, ++ transfer.mint, ++ solana.SystemProgramID, ++ transfer.tokenProgram, ++ }, nil), ++ }, ++ tx.Message.Instructions[3:]..., ++ )..., ++ ) ++ ++ if err := verifyTokenAccountsExist(state, tx, requirement); err != nil { ++ t.Fatalf("expected create ATA instruction to satisfy destination existence policy, got %v", err) ++ } ++ if accountInfoCalls != 1 { ++ t.Fatalf("expected only source account lookup, got %d", accountInfoCalls) ++ } ++} ++ ++func TestVerifyTokenAccountsExistRejectsMissingDestinationWithoutCreateATA(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "unit-missing-destination" ++ accountInfoCalls := 0 ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { ++ if _, err := io.ReadAll(request.Body); err != nil { ++ t.Fatal(err) ++ } ++ accountInfoCalls++ ++ body := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` ++ if accountInfoCalls == 2 { ++ body = `{"jsonrpc":"2.0","id":1,"result":{"value":null}}` ++ } ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(body)), ++ }, nil ++ }), ++ } ++ requirement := exactRequirement(state) ++ tx := transactionForTest(t, requirement, client) ++ ++ if err := verifyTokenAccountsExist(state, tx, requirement); err == nil || err.Error() != "destination token account does not exist" { ++ t.Fatalf("expected missing destination account, got %v", err) ++ } ++ if accountInfoCalls != 2 { ++ t.Fatalf("expected source and destination lookups, got %d", accountInfoCalls) ++ } ++} ++ ++func TestVerifyTokenAccountsExistAcceptsExistingSourceAndDestination(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "unit-existing-atas" ++ accountInfoCalls := 0 ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { ++ rawBody, err := io.ReadAll(request.Body) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if !strings.Contains(string(rawBody), `"method":"getAccountInfo"`) { ++ t.Fatalf("unexpected RPC body: %s", string(rawBody)) ++ } ++ accountInfoCalls++ ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}`)), ++ }, nil ++ }), ++ } ++ requirement := exactRequirement(state) ++ tx := transactionForTest(t, requirement, client) ++ ++ if err := verifyTokenAccountsExist(state, tx, requirement); err != nil { ++ t.Fatalf("expected existing source and destination accounts, got %v", err) ++ } ++ if accountInfoCalls != 2 { ++ t.Fatalf("expected source and destination lookups, got %d", accountInfoCalls) ++ } ++} ++ ++func TestVerifyExactTransactionAllowsMissingMemoWhenRequirementDoesNotBindMemo(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "" ++ requirement := exactRequirement(state) ++ builderRequirement := requirement ++ builderRequirement.Extra = cloneExtra(requirement.Extra) ++ builderRequirement.Extra["memo"] = "builder-memo" ++ tx := transactionForTest(t, builderRequirement, client) ++ tx.Message.Instructions = tx.Message.Instructions[:3] ++ ++ if err := verifyExactTransaction(tx, requirement); err != nil { ++ t.Fatalf("expected missing memo to be accepted when requirement has no memo, got %v", err) ++ } ++} ++ ++func TestVerifyOptionalInstructionsRejectsMemoViolations(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "unit-memo" ++ requirement := exactRequirement(state) ++ tx := transactionForTest(t, requirement, client) ++ transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ tests := map[string]struct { ++ requirement paymentRequirement ++ instructions []solana.CompiledInstruction ++ want string ++ }{ ++ "empty unbound memo": { ++ requirement: func() paymentRequirement { ++ value := requirement ++ value.Extra = cloneExtra(value.Extra) ++ delete(value.Extra, "memo") ++ return value ++ }(), ++ instructions: []solana.CompiledInstruction{compiledInstructionForTest(t, tx, memoProgramID.String(), nil)}, ++ want: "invalid_exact_svm_payload_transaction_memo", ++ }, ++ "oversized memo": { ++ requirement: func() paymentRequirement { ++ value := requirement ++ value.Extra = cloneExtra(value.Extra) ++ delete(value.Extra, "memo") ++ return value ++ }(), ++ instructions: []solana.CompiledInstruction{compiledInstructionForTest(t, tx, memoProgramID.String(), []byte(strings.Repeat("x", maxMemoBytes+1)))}, ++ want: "extra.memo exceeds maximum 256 bytes", ++ }, ++ "duplicate bound memo": { ++ requirement: requirement, ++ instructions: []solana.CompiledInstruction{ ++ compiledInstructionForTest(t, tx, memoProgramID.String(), []byte("unit-memo")), ++ compiledInstructionForTest(t, tx, memoProgramID.String(), []byte("unit-memo")), ++ }, ++ want: "invalid_exact_svm_payload_transaction_memo", ++ }, ++ } ++ ++ for name, test := range tests { ++ t.Run(name, func(t *testing.T) { ++ if err := verifyOptionalInstructions(tx, test.instructions, test.requirement, transfer); err == nil || err.Error() != test.want { ++ t.Fatalf("expected %q, got %v", test.want, err) ++ } ++ }) ++ } ++} ++ ++func TestDuplicateSettlementCachePrunesExpiredEntries(t *testing.T) { ++ cache := newDuplicateSettlementCache() ++ now := time.Unix(1_700_000_000, 0) ++ cache.now = func() time.Time { ++ return now ++ } ++ cache.entries["expired"] = now.Add(-(duplicateCacheTTL + time.Second)) ++ cache.entries["fresh"] = now ++ ++ if !cache.putIfAbsent("new") { ++ t.Fatal("expected new key to be inserted") ++ } ++ if _, ok := cache.entries["expired"]; ok { ++ t.Fatal("expected expired cache entry to be pruned") ++ } ++ if _, ok := cache.entries["fresh"]; !ok { ++ t.Fatal("expected fresh cache entry to survive pruning") ++ } ++ if !cache.putIfAbsent("expired") { ++ t.Fatal("expected pruned key to be re-insertable") ++ } ++ if cache.putIfAbsent("fresh") { ++ t.Fatal("expected fresh duplicate to be rejected") ++ } ++} ++ ++func TestAccountExistsHandlesRPCResponses(t *testing.T) { ++ account := solana.NewWallet().PublicKey() ++ tests := map[string]struct { ++ status int ++ body string ++ exists bool ++ err bool ++ }{ ++ "exists": { ++ status: http.StatusOK, ++ body: `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}`, ++ exists: true, ++ }, ++ "missing null value": { ++ status: http.StatusOK, ++ body: `{"jsonrpc":"2.0","id":1,"result":{"value":null}}`, ++ exists: false, ++ }, ++ "missing result": { ++ status: http.StatusOK, ++ body: `{"jsonrpc":"2.0","id":1}`, ++ exists: false, ++ }, ++ "rpc error": { ++ status: http.StatusOK, ++ body: `{"jsonrpc":"2.0","id":1,"error":{"message":"nope"}}`, ++ err: true, ++ }, ++ "http error": { ++ status: http.StatusBadGateway, ++ body: `bad gateway`, ++ err: true, ++ }, ++ "invalid json": { ++ status: http.StatusOK, ++ body: `{`, ++ err: true, ++ }, ++ } ++ ++ for name, test := range tests { ++ t.Run(name, func(t *testing.T) { ++ state := testServerState(t) ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { ++ rawBody, err := io.ReadAll(request.Body) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if !strings.Contains(string(rawBody), `"method":"getAccountInfo"`) || !strings.Contains(string(rawBody), account.String()) { ++ t.Fatalf("unexpected accountExists RPC body: %s", string(rawBody)) ++ } ++ return &http.Response{ ++ StatusCode: test.status, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(test.body)), ++ }, nil ++ }), ++ } ++ ++ exists, err := accountExists(state, account) ++ if test.err { ++ if err == nil { ++ t.Fatal("expected error") ++ } ++ return ++ } ++ if err != nil { ++ t.Fatal(err) ++ } ++ if exists != test.exists { ++ t.Fatalf("exists = %v, want %v", exists, test.exists) ++ } ++ }) ++ } ++} ++ ++func TestAccountExistsReturnsTransportErrors(t *testing.T) { ++ state := testServerState(t) ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { ++ return nil, errors.New("rpc unavailable") ++ }), ++ } ++ ++ if _, err := accountExists(state, solana.NewWallet().PublicKey()); err == nil { ++ t.Fatal("expected transport error") ++ } ++} ++ ++func TestSendTransactionHandlesRPCResponses(t *testing.T) { ++ baseState := testServerState(t) ++ baseState.memo = "unit-send" ++ tx := transactionForTest(t, exactRequirement(baseState), solana.NewWallet().PrivateKey) ++ tests := map[string]struct { ++ status int ++ body string ++ want string ++ err bool ++ }{ ++ "success": { ++ status: http.StatusOK, ++ body: `{"jsonrpc":"2.0","id":1,"result":"unit-signature"}`, ++ want: "unit-signature", ++ }, ++ "http error": { ++ status: http.StatusBadGateway, ++ body: `bad gateway`, ++ err: true, ++ }, ++ "invalid json": { ++ status: http.StatusOK, ++ body: `{`, ++ err: true, ++ }, ++ "rpc error": { ++ status: http.StatusOK, ++ body: `{"jsonrpc":"2.0","id":1,"error":{"message":"nope"}}`, ++ err: true, ++ }, ++ "empty signature": { ++ status: http.StatusOK, ++ body: `{"jsonrpc":"2.0","id":1,"result":""}`, ++ err: true, ++ }, ++ } ++ ++ for name, test := range tests { ++ t.Run(name, func(t *testing.T) { ++ state := testServerState(t) ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { ++ rawBody, err := io.ReadAll(request.Body) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if !strings.Contains(string(rawBody), `"method":"sendTransaction"`) || !strings.Contains(string(rawBody), `"maxRetries":3`) { ++ t.Fatalf("unexpected sendTransaction RPC body: %s", string(rawBody)) ++ } ++ return &http.Response{ ++ StatusCode: test.status, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(test.body)), ++ }, nil ++ }), ++ } ++ ++ got, err := sendTransaction(state, tx) ++ if test.err { ++ if err == nil { ++ t.Fatal("expected error") ++ } ++ return ++ } ++ if err != nil { ++ t.Fatal(err) ++ } ++ if got != test.want { ++ t.Fatalf("sendTransaction = %q, want %q", got, test.want) ++ } ++ }) ++ } ++} ++ ++func TestSendTransactionReturnsTransportErrors(t *testing.T) { ++ state := testServerState(t) ++ state.memo = "unit-send-transport" ++ tx := transactionForTest(t, exactRequirement(state), solana.NewWallet().PrivateKey) ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { ++ return nil, errors.New("rpc unavailable") ++ }), ++ } ++ ++ if _, err := sendTransaction(state, tx); err == nil { ++ t.Fatal("expected transport error") ++ } ++} ++ ++func TestAccountAtRejectsInvalidIndexes(t *testing.T) { ++ state := testServerState(t) ++ state.memo = "unit-index" ++ tx := transactionForTest(t, exactRequirement(state), solana.NewWallet().PrivateKey) ++ if _, err := accountAt(tx, uint16(len(tx.Message.AccountKeys))); err == nil { ++ t.Fatal("expected invalid account index") ++ } ++ if _, err := programID(tx, solana.CompiledInstruction{ProgramIDIndex: uint16(len(tx.Message.AccountKeys))}); err == nil { ++ t.Fatal("expected invalid program index") ++ } ++} ++ ++func TestInteropMuxRoutesHealthCapabilitiesAndChallenges(t *testing.T) { ++ state := testServerState(t) ++ mux := newInteropMux(state) ++ ++ tests := map[string]struct { ++ path string ++ status int ++ header string ++ bodySearch string ++ }{ ++ "health": { ++ path: "/health", ++ status: http.StatusOK, ++ bodySearch: `"ok":true`, ++ }, ++ "capabilities": { ++ path: "/capabilities", ++ status: http.StatusOK, ++ bodySearch: `"implementation":"go"`, ++ }, ++ "exact challenge": { ++ path: "/exact", ++ status: http.StatusPaymentRequired, ++ header: "PAYMENT-REQUIRED", ++ bodySearch: `"payment_required"`, ++ }, ++ "upto challenge": { ++ path: "/upto", ++ status: http.StatusPaymentRequired, ++ header: "PAYMENT-REQUIRED", ++ bodySearch: `"payment_required"`, ++ }, ++ "session challenge": { ++ path: "/session", ++ status: http.StatusPaymentRequired, ++ bodySearch: `"intent":"session"`, ++ }, ++ "batch settlement challenge": { ++ path: "/batch-settlement", ++ status: http.StatusPaymentRequired, ++ header: "PAYMENT-REQUIRED", ++ bodySearch: `"payment_required"`, ++ }, ++ "protected challenge": { ++ path: defaultResourcePath, ++ status: http.StatusPaymentRequired, ++ header: "PAYMENT-REQUIRED", ++ bodySearch: `"payment_required"`, ++ }, ++ "not found": { ++ path: "/missing", ++ status: http.StatusNotFound, ++ bodySearch: `"not_found"`, ++ }, ++ } ++ ++ for name, test := range tests { ++ t.Run(name, func(t *testing.T) { ++ request := httptest.NewRequest(http.MethodGet, test.path, nil) ++ recorder := httptest.NewRecorder() ++ ++ mux.ServeHTTP(recorder, request) ++ ++ if recorder.Code != test.status { ++ t.Fatalf("status = %d, want %d; body=%s", recorder.Code, test.status, recorder.Body.String()) ++ } ++ if test.header != "" && recorder.Header().Get(test.header) == "" { ++ t.Fatalf("expected %s header", test.header) ++ } ++ if test.bodySearch != "" && !strings.Contains(recorder.Body.String(), test.bodySearch) { ++ t.Fatalf("body %s does not contain %s", recorder.Body.String(), test.bodySearch) ++ } ++ }) ++ } ++} ++ ++func TestInteropMuxProtectedRouteRejectsInvalidPayment(t *testing.T) { ++ state := testServerState(t) ++ mux := newInteropMux(state) ++ request := httptest.NewRequest(http.MethodGet, defaultResourcePath, nil) ++ request.Header.Set("PAYMENT-SIGNATURE", "not base64") ++ recorder := httptest.NewRecorder() ++ ++ mux.ServeHTTP(recorder, request) ++ ++ if recorder.Code != http.StatusPaymentRequired { ++ t.Fatalf("status = %d, want %d", recorder.Code, http.StatusPaymentRequired) ++ } ++ if recorder.Header().Get("PAYMENT-REQUIRED") == "" { ++ t.Fatal("expected refreshed payment challenge") ++ } ++ if !strings.Contains(recorder.Body.String(), `"payment_invalid"`) { ++ t.Fatalf("expected payment_invalid body, got %s", recorder.Body.String()) ++ } ++} ++ ++func TestInteropMuxProtectedRouteSettlesValidPayment(t *testing.T) { ++ settlementCache = newDuplicateSettlementCache() ++ defer func() { ++ settlementCache = newDuplicateSettlementCache() ++ }() ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "unit-mux-settle" ++ state.httpClient = &http.Client{ ++ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { ++ rawBody, err := io.ReadAll(request.Body) ++ if err != nil { ++ t.Fatal(err) ++ } ++ body := string(rawBody) ++ responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` ++ switch { ++ case strings.Contains(body, `"method":"sendTransaction"`): ++ responseBody = `{"jsonrpc":"2.0","id":1,"result":"unit-mux-settlement"}` ++ case strings.Contains(body, `"method":"getSignatureStatuses"`): ++ responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` ++ } ++ return &http.Response{ ++ StatusCode: http.StatusOK, ++ Header: http.Header{"content-type": []string{"application/json"}}, ++ Body: io.NopCloser(strings.NewReader(responseBody)), ++ }, nil ++ }), ++ } ++ requirement := exactRequirement(state) ++ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ ++ X402Version: 2, ++ Accepted: requirement, ++ Payload: map[string]string{ ++ "transaction": signedTransactionForTest(t, requirement, client), ++ }, ++ }) ++ mux := newInteropMux(state) ++ request := httptest.NewRequest(http.MethodGet, defaultResourcePath, nil) ++ request.Header.Set("PAYMENT-SIGNATURE", header) ++ recorder := httptest.NewRecorder() ++ ++ mux.ServeHTTP(recorder, request) ++ ++ if recorder.Code != http.StatusOK { ++ t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) ++ } ++ if recorder.Header().Get(defaultSettlementHeader) != "unit-mux-settlement" { ++ t.Fatalf("settlement header = %q", recorder.Header().Get(defaultSettlementHeader)) ++ } ++ if !strings.Contains(recorder.Body.String(), `"paid":true`) { ++ t.Fatalf("expected paid response, got %s", recorder.Body.String()) ++ } ++} ++ ++func TestRunInteropServerEmitsReadyAndStopsOnSignal(t *testing.T) { ++ state := testServerState(t) ++ listener, err := net.Listen("tcp", "127.0.0.1:0") ++ if err != nil { ++ t.Fatal(err) ++ } ++ signals := make(chan os.Signal, 1) ++ ready := newSyncBuffer() ++ errors := newSyncBuffer() ++ done := make(chan error, 1) ++ ++ go func() { ++ done <- runInteropServer(state, listener, signals, ready, errors) ++ }() ++ ++ baseURL := "http://" + listener.Addr().String() ++ deadline := time.Now().Add(2 * time.Second) ++ for { ++ response, err := http.Get(baseURL + "/health") ++ if err == nil { ++ _ = response.Body.Close() ++ if response.StatusCode == http.StatusOK { ++ break ++ } ++ } ++ if time.Now().After(deadline) { ++ t.Fatalf("server did not become ready; ready=%s errors=%s lastErr=%v", ready.String(), errors.String(), err) ++ } ++ time.Sleep(10 * time.Millisecond) ++ } ++ ++ var payload map[string]any ++ if err := json.Unmarshal(bytes.TrimSpace(ready.Bytes()), &payload); err != nil { ++ t.Fatalf("decode ready payload %q: %v", ready.String(), err) ++ } ++ if payload["type"] != "ready" || payload["implementation"] != "go" { ++ t.Fatalf("unexpected ready payload: %#v", payload) ++ } ++ if _, ok := payload["port"].(float64); !ok { ++ t.Fatalf("ready payload missing port: %#v", payload) ++ } ++ ++ signals <- syscall.SIGTERM ++ select { ++ case err := <-done: ++ if err != nil { ++ t.Fatalf("runInteropServer returned %v; errors=%s", err, errors.String()) ++ } ++ case <-time.After(2 * time.Second): ++ t.Fatal("server did not stop after signal") ++ } ++} ++ ++// syncBuffer wraps bytes.Buffer with a mutex so the test goroutine can read ++// the buffer concurrently with the server goroutine writing the ready line and ++// stderr without triggering -race warnings. ++type syncBuffer struct { ++ mu sync.Mutex ++ buf bytes.Buffer ++} ++ ++func newSyncBuffer() *syncBuffer { return &syncBuffer{} } ++ ++func (b *syncBuffer) Write(p []byte) (int, error) { ++ b.mu.Lock() ++ defer b.mu.Unlock() ++ return b.buf.Write(p) ++} ++ ++func (b *syncBuffer) Bytes() []byte { ++ b.mu.Lock() ++ defer b.mu.Unlock() ++ return append([]byte(nil), b.buf.Bytes()...) ++} ++ ++func (b *syncBuffer) String() string { ++ b.mu.Lock() ++ defer b.mu.Unlock() ++ return b.buf.String() ++} ++ ++func TestRunInteropServerReturnsServeErrors(t *testing.T) { ++ state := testServerState(t) ++ signals := make(chan os.Signal) ++ var ready bytes.Buffer ++ var errors bytes.Buffer ++ ++ err := runInteropServer(state, failingListener{}, signals, &ready, &errors) ++ ++ if err == nil || !strings.Contains(err.Error(), "listener failed") { ++ t.Fatalf("expected listener failure, got %v", err) ++ } ++ if ready.String() == "" { ++ t.Fatal("expected ready payload before listener failure") ++ } ++ if !strings.Contains(errors.String(), "listener failed") { ++ t.Fatalf("expected error writer to receive listener failure, got %q", errors.String()) ++ } ++} ++ ++func TestMainPanicsWhenRequiredEnvMissing(t *testing.T) { ++ mustPanic(t, main) ++} ++ ++// TestVerifyExactTransactionAttackRegressions covers MPP §19.5 fee-payer drain ++// attacks: managed fee-payer (server co-signs) must never become a token source ++// or transfer authority, must not appear in any extra instruction, must not be ++// reassigned via a tampered details.fee_payer, and must not be moved into a ++// signer slot beyond the fee-payer (index 0) position. ++func TestVerifyExactTransactionAttackRegressions(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "attack-regression" ++ requirement := exactRequirement(state) ++ feePayer := state.feePayer.PublicKey() ++ mint := solana.MustPublicKeyFromBase58(requirement.Asset) ++ feePayerATA, _, err := solana.FindAssociatedTokenAddressWithProgram(feePayer, mint, solana.MustPublicKeyFromBase58(defaultTokenProgram)) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ // Positive control: an unmodified happy-path transaction must verify. ++ valid := transactionForTest(t, requirement, client) ++ if err := verifyExactTransaction(valid, requirement); err != nil { ++ t.Fatalf("positive control failed: %v", err) ++ } ++ ++ tests := map[string]struct { ++ mutate func(*solana.Transaction, paymentRequirement) paymentRequirement ++ wantErrFrag string ++ }{ ++ "DRAIN: SystemProgram.Transfer from fee-payer in optional slot": { ++ mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { ++ // Replace memo (slot 3) with a SystemProgram.Transfer touching fee-payer. ++ attacker := solana.NewWallet().PublicKey() ++ tx.Message.Instructions[3] = compiledInstructionWithAccountsForTest( ++ t, tx, solana.SystemProgramID, ++ []solana.PublicKey{feePayer, attacker}, ++ []byte{2, 0, 0, 0, 0xff, 0, 0, 0, 0, 0, 0, 0}, ++ ) ++ return req ++ }, ++ // Accepted rejection paths: fee-payer-touch guard OR unknown-optional-instruction guard. ++ wantErrFrag: "invalid_exact_svm_payload", ++ }, ++ "SPL DRAIN: transferChecked from fee-payer ATA in optional slot": { ++ mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { ++ attackerATA := solana.NewWallet().PublicKey() ++ data := []byte{12} ++ data = binary.LittleEndian.AppendUint64(data, 1) ++ data = append(data, byte(defaultDecimals)) ++ tx.Message.Instructions[3] = compiledInstructionWithAccountsForTest( ++ t, tx, solana.TokenProgramID, ++ []solana.PublicKey{feePayerATA, mint, attackerATA, feePayer}, ++ data, ++ ) ++ return req ++ }, ++ // Accepted rejection paths: fee-payer-touch guard OR unknown-optional-instruction guard. ++ wantErrFrag: "invalid_exact_svm_payload", ++ }, ++ "SLOT: fee-payer at signer slot 1 as transfer authority": { ++ mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { ++ // Replace authority account on the transferChecked with fee-payer. ++ accounts := append([]uint16(nil), tx.Message.Instructions[2].Accounts...) ++ feePayerIndex := -1 ++ for index, key := range tx.Message.AccountKeys { ++ if key.Equals(feePayer) { ++ feePayerIndex = index ++ break ++ } ++ } ++ if feePayerIndex == -1 { ++ t.Fatal("fee payer not in account keys") ++ } ++ accounts[3] = uint16(feePayerIndex) ++ tx.Message.Instructions[2].Accounts = accounts ++ return req ++ }, ++ wantErrFrag: "fee_payer_transferring_funds", ++ }, ++ "SLOT: fee-payer as transfer source ATA": { ++ mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { ++ // Repoint transferChecked.source to the fee-payer's own ATA. ++ feePayerIndex := -1 ++ for index, key := range tx.Message.AccountKeys { ++ if key.Equals(feePayer) { ++ feePayerIndex = index ++ break ++ } ++ } ++ if feePayerIndex == -1 { ++ t.Fatal("fee payer not in account keys") ++ } ++ // Add fee-payer ATA as a new account key and use it as source. ++ tx.Message.AccountKeys = append(tx.Message.AccountKeys, feePayerATA) ++ ataIndex := uint16(len(tx.Message.AccountKeys) - 1) ++ accounts := append([]uint16(nil), tx.Message.Instructions[2].Accounts...) ++ accounts[0] = ataIndex ++ accounts[3] = uint16(feePayerIndex) // authority = fee-payer ++ tx.Message.Instructions[2].Accounts = accounts ++ return req ++ }, ++ wantErrFrag: "fee_payer_transferring_funds", ++ }, ++ } ++ ++ for name, test := range tests { ++ t.Run(name, func(t *testing.T) { ++ tx := cloneTransactionForTest(t, valid) ++ req := requirement ++ req.Extra = cloneExtra(requirement.Extra) ++ mutated := test.mutate(tx, req) ++ err := verifyExactTransaction(tx, mutated) ++ if err == nil { ++ t.Fatalf("expected attack to be rejected") ++ } ++ if !strings.Contains(err.Error(), test.wantErrFrag) { ++ t.Fatalf("error %q does not contain %q", err.Error(), test.wantErrFrag) ++ } ++ }) ++ } ++} ++ ++// TestSettleExactPaymentRejectsForeignMessageFeePayer covers Codex finding #1: ++// the transaction's message fee-payer (account key 0) must equal the server's ++// configured fee-payer before the facilitator co-signs. Otherwise a malicious ++// client could pick a different message payer and the facilitator's presence ++// in the signer set would drain its SOL. ++func TestSettleExactPaymentRejectsForeignMessageFeePayer(t *testing.T) { ++ settlementCache = newDuplicateSettlementCache() ++ defer func() { settlementCache = newDuplicateSettlementCache() }() ++ ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "foreign-fee-payer" ++ requirement := exactRequirement(state) ++ tx := transactionForTest(t, requirement, client) ++ ++ // Swap account key 0 (message fee-payer) for a foreign pubkey. ++ foreign := solana.NewWallet().PublicKey() ++ tx.Message.AccountKeys[0] = foreign ++ encoded, err := tx.ToBase64() ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ ++ X402Version: 2, ++ Accepted: requirement, ++ Payload: map[string]string{"transaction": encoded}, ++ }) ++ ++ if _, err := settleExactPayment(state, header); err == nil || ++ !strings.Contains(err.Error(), "fee_payer") { ++ t.Fatalf("expected foreign message fee-payer rejection, got %v", err) ++ } ++} ++ ++// TestSettleExactPaymentRejectsTamperedFeePayer covers MPP §19.5: an attacker ++// presenting an envelope where details.feePayer (Extra["feePayer"]) is rebound ++// to a non-server pubkey must be rejected at the requirement-match stage so ++// that the server-co-signing context pubkey cannot be substituted by the ++// client envelope. ++func TestSettleExactPaymentRejectsTamperedFeePayer(t *testing.T) { ++ settlementCache = newDuplicateSettlementCache() ++ defer func() { settlementCache = newDuplicateSettlementCache() }() ++ ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "tampered-fee-payer" ++ requirement := exactRequirement(state) ++ transaction := signedTransactionForTest(t, requirement, client) ++ ++ tampered := requirement ++ tampered.Extra = cloneExtra(requirement.Extra) ++ tampered.Extra["feePayer"] = solana.NewWallet().PublicKey().String() ++ ++ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ ++ X402Version: 2, ++ Accepted: tampered, ++ Payload: map[string]string{"transaction": transaction}, ++ }) ++ ++ if _, err := settleExactPayment(state, header); err == nil || ++ !strings.Contains(err.Error(), "does not match server challenge") { ++ t.Fatalf("expected tampered fee-payer to be rejected, got %v", err) ++ } ++} ++ ++type roundTripFunc func(*http.Request) (*http.Response, error) ++ ++func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { ++ return fn(request) ++} ++ ++type failingListener struct{} ++ ++func (failingListener) Accept() (net.Conn, error) { ++ return nil, errors.New("listener failed") ++} ++ ++func (failingListener) Close() error { ++ return nil ++} ++ ++func (failingListener) Addr() net.Addr { ++ return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0} ++} ++ ++func cloneExtra(extra map[string]any) map[string]any { ++ cloned := make(map[string]any, len(extra)) ++ for key, value := range extra { ++ cloned[key] = value ++ } ++ return cloned ++} ++ ++func testServerState(t *testing.T) serverState { ++ t.Helper() ++ feePayer, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ return serverState{ ++ rpcURL: "http://127.0.0.1:8899", ++ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ payTo: solana.NewWallet().PublicKey().String(), ++ feePayer: feePayer, ++ amount: "1000", ++ httpClient: &http.Client{}, ++ } ++} ++ ++func encodePaymentSignatureForTest(t *testing.T, envelope paymentSignatureEnvelope) string { ++ t.Helper() ++ encoded, err := json.Marshal(envelope) ++ if err != nil { ++ t.Fatal(err) ++ } ++ return base64.StdEncoding.EncodeToString(encoded) ++} ++ ++func signedTransactionForTest(t *testing.T, requirement paymentRequirement, client solana.PrivateKey) string { ++ t.Helper() ++ tx := transactionForTest(t, requirement, client) ++ encoded, err := tx.ToBase64() ++ if err != nil { ++ t.Fatal(err) ++ } ++ return encoded ++} ++ ++func transactionForTest(t *testing.T, requirement paymentRequirement, client solana.PrivateKey) *solana.Transaction { ++ t.Helper() ++ feePayer, err := solana.PublicKeyFromBase58(requirement.Extra["feePayer"].(string)) ++ if err != nil { ++ t.Fatal(err) ++ } ++ mint, err := solana.PublicKeyFromBase58(requirement.Asset) ++ if err != nil { ++ t.Fatal(err) ++ } ++ payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) ++ if err != nil { ++ t.Fatal(err) ++ } ++ tokenProgram, err := solana.PublicKeyFromBase58(requirement.Extra["tokenProgram"].(string)) ++ if err != nil { ++ t.Fatal(err) ++ } ++ source, _, err := solana.FindAssociatedTokenAddressWithProgram(client.PublicKey(), mint, tokenProgram) ++ if err != nil { ++ t.Fatal(err) ++ } ++ destination, _, err := solana.FindAssociatedTokenAddressWithProgram(payTo, mint, tokenProgram) ++ if err != nil { ++ t.Fatal(err) ++ } ++ amount, err := strconv.ParseUint(requirement.Amount, 10, 64) ++ if err != nil { ++ t.Fatal(err) ++ } ++ transferData := []byte{12} ++ transferData = binary.LittleEndian.AppendUint64(transferData, amount) ++ transferData = append(transferData, byte(defaultDecimals)) ++ ++ tx, err := solana.NewTransaction( ++ []solana.Instruction{ ++ computeLimitInstructionForTest(20_000), ++ computePriceInstructionForTest(1), ++ solana.NewInstruction( ++ tokenProgram, ++ solana.AccountMetaSlice{ ++ solana.Meta(source).WRITE(), ++ solana.Meta(mint), ++ solana.Meta(destination).WRITE(), ++ solana.Meta(client.PublicKey()).SIGNER(), ++ }, ++ transferData, ++ ), ++ solana.NewInstruction(memoProgramID, nil, []byte(requirement.Extra["memo"].(string))), ++ }, ++ solana.Hash{}, ++ solana.TransactionPayer(feePayer), ++ ) ++ if err != nil { ++ t.Fatal(err) ++ } ++ tx.Message.SetVersion(solana.MessageVersionV0) ++ if _, err := tx.PartialSign(func(key solana.PublicKey) *solana.PrivateKey { ++ if key.Equals(client.PublicKey()) { ++ return &client ++ } ++ return nil ++ }); err != nil { ++ t.Fatal(err) ++ } ++ return tx ++} ++ ++func cloneTransactionForTest(t *testing.T, tx *solana.Transaction) *solana.Transaction { ++ t.Helper() ++ encoded, err := tx.ToBase64() ++ if err != nil { ++ t.Fatal(err) ++ } ++ cloned, err := solana.TransactionFromBase64(encoded) ++ if err != nil { ++ t.Fatal(err) ++ } ++ return cloned ++} ++ ++func computeLimitInstructionForTest(units uint32) solana.Instruction { ++ data := []byte{2} ++ data = binary.LittleEndian.AppendUint32(data, units) ++ return solana.NewInstruction(computeBudgetProgramID, nil, data) ++} ++ ++func computePriceInstructionForTest(microLamports uint64) solana.Instruction { ++ return solana.NewInstruction(computeBudgetProgramID, nil, computePriceDataForTest(microLamports)) ++} ++ ++func computePriceDataForTest(microLamports uint64) []byte { ++ data := []byte{3} ++ return binary.LittleEndian.AppendUint64(data, microLamports) ++} ++ ++func compiledInstructionForTest(t *testing.T, tx *solana.Transaction, program string, data []byte) solana.CompiledInstruction { ++ t.Helper() ++ programKey := solana.MustPublicKeyFromBase58(program) ++ return compiledInstructionWithAccountsForTest(t, tx, programKey, nil, data) ++} ++ ++func compiledInstructionWithAccountsForTest(t *testing.T, tx *solana.Transaction, programKey solana.PublicKey, accounts []solana.PublicKey, data []byte) solana.CompiledInstruction { ++ t.Helper() ++ programIndex := -1 ++ for index, key := range tx.Message.AccountKeys { ++ if key.Equals(programKey) { ++ programIndex = index ++ break ++ } ++ } ++ if programIndex == -1 { ++ tx.Message.AccountKeys = append(tx.Message.AccountKeys, programKey) ++ programIndex = len(tx.Message.AccountKeys) - 1 ++ } ++ accountIndexes := make([]uint16, 0, len(accounts)) ++ for _, account := range accounts { ++ accountIndex := -1 ++ for index, key := range tx.Message.AccountKeys { ++ if key.Equals(account) { ++ accountIndex = index ++ break ++ } ++ } ++ if accountIndex == -1 { ++ tx.Message.AccountKeys = append(tx.Message.AccountKeys, account) ++ accountIndex = len(tx.Message.AccountKeys) - 1 ++ } ++ accountIndexes = append(accountIndexes, uint16(accountIndex)) ++ } ++ return solana.CompiledInstruction{ ++ ProgramIDIndex: uint16(programIndex), ++ Accounts: accountIndexes, ++ Data: data, ++ } ++} ++ ++func TestResolveMintAlias(t *testing.T) { ++ tests := []struct { ++ name string ++ input string ++ network string ++ want string ++ wantErr bool ++ }{ ++ {name: "USDG mainnet alias", input: "USDG", network: solanaMainnetCAIP2, want: "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH"}, ++ {name: "USDG devnet alias", input: "usdg", network: solanaDevnetCAIP2, want: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7"}, ++ {name: "PYUSD mainnet alias", input: "PYUSD", network: solanaMainnetCAIP2, want: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo"}, ++ {name: "PYUSD devnet alias", input: "pyusd", network: solanaDevnetCAIP2, want: "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM"}, ++ {name: "CASH mainnet alias", input: "CASH", network: solanaMainnetCAIP2, want: "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH"}, ++ {name: "USDT mainnet alias", input: "USDT", network: solanaMainnetCAIP2, want: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"}, ++ {name: "USDT lowercase mainnet alias", input: " usdt ", network: solanaMainnetCAIP2, want: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"}, ++ {name: "USDT has no devnet mint", input: "USDT", network: solanaDevnetCAIP2, wantErr: true}, ++ {name: "USDT has no testnet mint", input: "USDT", network: solanaTestnetCAIP2, wantErr: true}, ++ {name: "USDC devnet alias", input: " usdc ", network: solanaDevnetCAIP2, want: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"}, ++ {name: "passthrough base58 mint", input: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", network: solanaDevnetCAIP2, want: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"}, ++ {name: "CASH has no devnet mint", input: "CASH", network: solanaDevnetCAIP2, wantErr: true}, ++ {name: "unknown alias", input: "WEIRDO", network: solanaMainnetCAIP2, wantErr: true}, ++ {name: "empty input", input: " ", network: solanaMainnetCAIP2, wantErr: true}, ++ } ++ ++ for _, test := range tests { ++ t.Run(test.name, func(t *testing.T) { ++ got, err := resolveMintAlias(test.input, test.network) ++ if test.wantErr { ++ if err == nil { ++ t.Fatalf("expected error for %q on %q, got %q", test.input, test.network, got) ++ } ++ return ++ } ++ if err != nil { ++ t.Fatalf("unexpected error: %v", err) ++ } ++ if got != test.want { ++ t.Fatalf("resolveMintAlias(%q,%q) = %q, want %q", test.input, test.network, got, test.want) ++ } ++ }) ++ } ++} ++ ++func TestReadStateResolvesMintAliases(t *testing.T) { ++ privateKey, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ encodedKey, err := json.Marshal([]byte(privateKey)) ++ if err != nil { ++ t.Fatal(err) ++ } ++ payTo := solana.NewWallet().PublicKey().String() ++ ++ t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") ++ t.Setenv("X402_INTEROP_PAY_TO", payTo) ++ t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) ++ t.Setenv("X402_INTEROP_NETWORK", solanaDevnetCAIP2) ++ t.Setenv("X402_INTEROP_MINT", "PYUSD") ++ t.Setenv("X402_INTEROP_EXTRA_OFFERED_MINTS", "USDG, USDC") ++ ++ state := readState() ++ ++ challenge := exactChallengePayload(state) ++ if len(challenge.Accepts) != 3 { ++ t.Fatalf("expected 3 challenge entries, got %d", len(challenge.Accepts)) ++ } ++ if challenge.Accepts[0].Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { ++ t.Fatalf("primary Asset = %q, expected resolved PYUSD devnet mint", challenge.Accepts[0].Asset) ++ } ++ if _, err := solana.PublicKeyFromBase58(challenge.Accepts[0].Asset); err != nil { ++ t.Fatalf("primary Asset is not valid base58: %v", err) ++ } ++ if challenge.Accepts[1].Asset != "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7" { ++ t.Fatalf("extra[0] Asset = %q, expected resolved USDG devnet mint", challenge.Accepts[1].Asset) ++ } ++ if challenge.Accepts[2].Asset != "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" { ++ t.Fatalf("extra[1] Asset = %q, expected resolved USDC devnet mint", challenge.Accepts[2].Asset) ++ } ++ for index, requirement := range challenge.Accepts { ++ if _, err := solana.PublicKeyFromBase58(requirement.Asset); err != nil { ++ t.Fatalf("Accepts[%d].Asset is not base58 after resolution: %v", index, err) ++ } ++ } ++} ++ ++func TestReadStatePanicsOnUnknownMintAlias(t *testing.T) { ++ privateKey, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ encodedKey, err := json.Marshal([]byte(privateKey)) ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") ++ t.Setenv("X402_INTEROP_PAY_TO", solana.NewWallet().PublicKey().String()) ++ t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) ++ t.Setenv("X402_INTEROP_NETWORK", solanaDevnetCAIP2) ++ t.Setenv("X402_INTEROP_MINT", "DEFINITELY_NOT_A_MINT") ++ ++ mustPanic(t, func() { readState() }) ++ ++ t.Setenv("X402_INTEROP_MINT", "USDG") ++ t.Setenv("X402_INTEROP_EXTRA_OFFERED_MINTS", "PYUSD, NOPE") ++ mustPanic(t, func() { readState() }) ++} ++ ++func TestSettleExactPaymentAcceptsAliasResolvedRequirement(t *testing.T) { ++ settlementCache = newDuplicateSettlementCache() ++ defer func() { ++ settlementCache = newDuplicateSettlementCache() ++ }() ++ ++ privateKey, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ encodedKey, err := json.Marshal([]byte(privateKey)) ++ if err != nil { ++ t.Fatal(err) ++ } ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") ++ t.Setenv("X402_INTEROP_PAY_TO", solana.NewWallet().PublicKey().String()) ++ t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) ++ t.Setenv("X402_INTEROP_NETWORK", solanaDevnetCAIP2) ++ t.Setenv("X402_INTEROP_MINT", "PYUSD") ++ t.Setenv("X402_INTEROP_PRICE", "$0.001") ++ ++ state := readState() ++ state.memo = "alias-resolution" ++ state.httpClient = successfulSettlementClient(t, "alias-resolved-settlement") ++ ++ if state.mint != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { ++ t.Fatalf("expected PYUSD devnet mint resolution, got %q", state.mint) ++ } ++ ++ requirement := exactRequirement(state) ++ transaction := signedTransactionForTest(t, requirement, client) ++ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ ++ X402Version: 2, ++ Accepted: requirement, ++ Payload: map[string]string{ ++ "transaction": transaction, ++ }, ++ }) ++ ++ settlement, err := settleExactPayment(state, header) ++ if err != nil { ++ t.Fatalf("expected alias-resolved settlement to pass, got %v", err) ++ } ++ if settlement != "alias-resolved-settlement" { ++ t.Fatalf("settlement = %q", settlement) ++ } ++} ++ ++// --- Codex P1.1: Lighthouse discriminator + account-count allowlist --- ++ ++// TestLighthousePassthroughMatchesSpine locks parity with the Rust + TS spines, ++// both of which accept any Lighthouse-program instruction by program-id match ++// alone. Inventing a per-language allowlist here would diverge from real-world ++// Phantom/Solflare transactions the canonical adapters accept. See the comment ++// on the optional-instruction loop for the spine citations. ++func TestLighthousePassthroughMatchesSpine(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ cases := []struct { ++ name string ++ data []byte ++ // extra wallet count for the instruction's account list. ++ extraAccounts int ++ }{ ++ {name: "empty_payload", data: []byte{}, extraAccounts: 0}, ++ {name: "known_assert_disc_single_account", data: []byte{9, 0}, extraAccounts: 1}, ++ {name: "unknown_discriminator", data: []byte{200, 1, 2}, extraAccounts: 1}, ++ {name: "oversize_payload_many_accounts", data: bytes.Repeat([]byte{0xAB}, 256), extraAccounts: 8}, ++ } ++ for _, tc := range cases { ++ t.Run(tc.name, func(t *testing.T) { ++ state := testServerState(t) ++ state.memo = "lighthouse-parity-" + tc.name ++ requirement := exactRequirement(state) ++ tx := transactionForTest(t, requirement, client) ++ extras := make([]solana.PublicKey, tc.extraAccounts) ++ for i := range extras { ++ extras[i] = solana.NewWallet().PublicKey() ++ } ++ var ix solana.CompiledInstruction ++ if tc.extraAccounts == 0 { ++ ix = compiledInstructionForTest(t, tx, lighthouseProgram, tc.data) ++ } else { ++ ix = compiledInstructionWithAccountsForTest(t, tx, solana.MustPublicKeyFromBase58(lighthouseProgram), extras, tc.data) ++ } ++ tx.Message.Instructions = append( ++ tx.Message.Instructions[:3], ++ append([]solana.CompiledInstruction{ix}, tx.Message.Instructions[3:]...)..., ++ ) ++ if err := verifyExactTransaction(tx, requirement); err != nil { ++ t.Fatalf("expected spine-parity acceptance for %s, got %v", tc.name, err) ++ } ++ }) ++ } ++} ++ ++// --- Codex P1.2: tightened fee-payer-in-instruction guard --- ++ ++// TestAcceptsFeePayerInLighthouseAccountMirrorsSpine locks parity with the Rust ++// spine, which intentionally has NO fee-payer-in-instruction-accounts sweep: ++// - rust/src/protocol/schemes/exact/verify.rs:382 only blocks fee-payer as ++// the transfer *authority*, not as a passive account in some other ix. ++// - rust/src/protocol/schemes/exact/verify.rs:263 accepts any Lighthouse ++// instruction by program-id match alone. ++// ++// Real Phantom/Solflare wallets emit `Assert*` Lighthouse ixs that reference the ++// fee-payer's pubkey as a read-only account to guard the facilitator from ++// rewriting the transfer post-sign. Rejecting these would break canonical ++// wallet flows and diverge from the spine. This test pins the Go adapter to ++// the spine semantics: fee-payer in a Lighthouse account list is ACCEPTED. ++func TestAcceptsFeePayerInLighthouseAccountMirrorsSpine(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "fee-payer-lighthouse-assert" ++ requirement := exactRequirement(state) ++ tx := transactionForTest(t, requirement, client) ++ ++ feePayer := state.feePayer.PublicKey() ++ // Lighthouse `AssertAccountInfo` (discriminator 9) referencing the ++ // fee-payer's pubkey as the target account — exactly the shape Phantom ++ // emits when guarding the rent-payer's balance against post-sign rewrites. ++ ix := compiledInstructionWithAccountsForTest( ++ t, tx, ++ solana.MustPublicKeyFromBase58(lighthouseProgram), ++ []solana.PublicKey{feePayer}, ++ []byte{9, 0}, ++ ) ++ tx.Message.Instructions = append( ++ tx.Message.Instructions[:3], ++ append([]solana.CompiledInstruction{ix}, tx.Message.Instructions[3:]...)..., ++ ) ++ if err := verifyExactTransaction(tx, requirement); err != nil { ++ t.Fatalf("expected fee-payer-in-Lighthouse-account to be accepted (spine parity), got %v", err) ++ } ++} ++ ++func TestAcceptsFeePayerAsAtaCreatePayer(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "fee-payer-ata-create" ++ requirement := exactRequirement(state) ++ tx := transactionForTest(t, requirement, client) ++ transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) ++ if err != nil { ++ t.Fatal(err) ++ } ++ payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) ++ feePayer := state.feePayer.PublicKey() ++ ++ // Canonical ATA-create where fee-payer is the rent payer at accounts[0]. ++ // Per the Codex P1.2 fix this is the *only* place fee-payer is allowed to ++ // appear outside the transfer authority/source check. ++ ataCreate := compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ ++ feePayer, ++ transfer.destination, ++ payTo, ++ transfer.mint, ++ solana.SystemProgramID, ++ transfer.tokenProgram, ++ }, []byte{1}) ++ tx.Message.Instructions = append( ++ tx.Message.Instructions[:3], ++ append([]solana.CompiledInstruction{ataCreate}, tx.Message.Instructions[3:]...)..., ++ ) ++ if err := verifyExactTransaction(tx, requirement); err != nil { ++ t.Fatalf("expected fee-payer as ATA-create payer to be accepted, got %v", err) ++ } ++} ++ ++// TestVerifyExactTransactionEnforcesTokenProgramBinding mirrors the Rust spine ++// binding (rust/crates/x402/src/protocol/schemes/exact/verify.rs:73-80) and the ++// PHP/Ruby/Lua ports: the on-chain transferChecked instruction's program MUST ++// match requirement.Extra["tokenProgram"]. Without this, a Token-2022 transfer ++// could satisfy an SPL Token requirement (and vice versa) because the ++// destination-ATA derivation uses the parsed program, not the required one. ++func TestVerifyExactTransactionEnforcesTokenProgramBinding(t *testing.T) { ++ client, err := solana.NewRandomPrivateKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ state := testServerState(t) ++ state.memo = "unit-token-program-binding" ++ ++ t.Run("mismatch_requires_spl_token_but_tx_uses_token2022", func(t *testing.T) { ++ // Requirement declares SPL Token; build a transaction using Token-2022 with ++ // a Token-2022 ATA. Verification must reject the program mismatch even ++ // though the transfer otherwise looks well-formed. ++ splRequirement := exactRequirement(state) ++ token2022Requirement := exactRequirement(state) ++ token2022Requirement.Extra = cloneExtra(token2022Requirement.Extra) ++ token2022Requirement.Extra["tokenProgram"] = token2022Program ++ tx := transactionForTest(t, token2022Requirement, client) ++ ++ err := verifyExactTransaction(tx, splRequirement) ++ if err == nil || err.Error() != "invalid_exact_svm_payload_transaction_token_program" { ++ t.Fatalf("expected token_program rejection, got %v", err) ++ } ++ }) ++ ++ t.Run("reverse_requires_token2022_but_tx_uses_spl_token", func(t *testing.T) { ++ token2022Requirement := exactRequirement(state) ++ token2022Requirement.Extra = cloneExtra(token2022Requirement.Extra) ++ token2022Requirement.Extra["tokenProgram"] = token2022Program ++ // Build the transaction against an SPL Token requirement (default). ++ splRequirement := exactRequirement(state) ++ tx := transactionForTest(t, splRequirement, client) ++ ++ err := verifyExactTransaction(tx, token2022Requirement) ++ if err == nil || err.Error() != "invalid_exact_svm_payload_transaction_token_program" { ++ t.Fatalf("expected token_program rejection, got %v", err) ++ } ++ }) ++ ++ t.Run("positive_control_matching_pair_accepted", func(t *testing.T) { ++ requirement := exactRequirement(state) ++ tx := transactionForTest(t, requirement, client) ++ if err := verifyExactTransaction(tx, requirement); err != nil { ++ t.Fatalf("expected matching tokenProgram pair to be accepted, got %v", err) ++ } ++ }) ++ ++ t.Run("missing_required_token_program_rejected", func(t *testing.T) { ++ requirement := exactRequirement(state) ++ tx := transactionForTest(t, requirement, client) ++ mutated := requirement ++ mutated.Extra = cloneExtra(requirement.Extra) ++ delete(mutated.Extra, "tokenProgram") ++ err := verifyExactTransaction(tx, mutated) ++ if err == nil || err.Error() != "invalid_exact_svm_payload_transaction_token_program" { ++ t.Fatalf("expected missing tokenProgram to be rejected, got %v", err) ++ } ++ }) ++} ++ ++func mustPanic(t *testing.T, fn func()) { ++ t.Helper() ++ defer func() { ++ if recovered := recover(); recovered == nil { ++ t.Fatal("expected panic") ++ } ++ }() ++ fn() ++} +diff --git a/tests/interop/README.md b/harness/README.md +similarity index 78% +rename from tests/interop/README.md +rename to harness/README.md +index b1018c6..8a65465 100644 +--- a/tests/interop/README.md ++++ b/harness/README.md +@@ -91,7 +91,7 @@ expected success/failure status, live in `src/contracts.ts`. + + 1. Add a process adapter for the language. + 2. Register it in `src/implementations.ts` as a client, server, or both. +-3. Keep the adapter command relative to `tests/interop`. ++3. Keep the adapter command relative to `harness`. + 4. Make stdout emit only the `ready` or `result` JSON message. + 5. Run a focused matrix before enabling it by default: + +@@ -123,6 +123,55 @@ Use these environment variables to filter the active matrix: + - `MPP_INTEROP_INTENTS=charge` + - `MPP_INTEROP_SCENARIOS=charge-basic,charge-split-ata,charge-network-mismatch,charge-cross-route-replay` + ++### x402 exact intent ++ ++A second intent, `x402-exact`, exercises the canonical x402 `exact` scheme ++against the Rust spine in `rust/crates/x402/src/bin/interop_{client,server}.rs`. ++The TypeScript reference adapters live at ++`src/fixtures/typescript/exact-{client,server}.ts` and share the same ++harness contract as the Rust spine: identical `X402_INTEROP_*` env vars, ++identical `PAYMENT-REQUIRED` / `PAYMENT-SIGNATURE` headers, identical ++ready / result JSON shapes. The TS reference fixture carries a stub ++credential payload (challenge id + resource) and is paired against the ++TS reference server in the default matrix; the Rust spine is paired ++against itself. As language adapters that carry a real Solana ++PaymentProof land, they expand the matrix by registering under ++`intents: ["x402-exact"]` in `implementations.ts`. ++ ++Env vars consumed by both roles: ++ ++- `X402_INTEROP_RPC_URL`, `X402_INTEROP_NETWORK`, `X402_INTEROP_MINT` ++- `X402_INTEROP_PAY_TO`, `X402_INTEROP_PRICE` ++- `X402_INTEROP_FACILITATOR_SECRET_KEY` ++ ++Server-only: ++ ++- `X402_INTEROP_EXTRA_OFFERED_MINTS` (CSV of additional mint addresses) ++ ++Client-only: ++ ++- `X402_INTEROP_TARGET_URL` ++- `X402_INTEROP_CLIENT_SECRET_KEY` ++- `X402_INTEROP_PREFER_CURRENCIES` (CSV of preferred currencies) ++ ++Run the x402 matrix slice: ++ ++```bash ++X402_INTEROP_MATRIX=1 \ ++X402_INTEROP_RPC_URL=http://127.0.0.1:8899 \ ++X402_INTEROP_MINT=... X402_INTEROP_PAY_TO=... \ ++X402_INTEROP_CLIENT_SECRET_KEY='[...]' \ ++X402_INTEROP_FACILITATOR_SECRET_KEY='[...]' \ ++pnpm test x402-exact.e2e.test.ts ++``` ++ ++Cross-server portability and idempotent-resubmit scenarios are gated ++separately: ++ ++```bash ++X402_INTEROP_CROSS_SERVER=1 pnpm test cross-server-scenarios.test.ts ++``` ++ + The current scenario set covers only the `charge` intent. It includes a basic + payment, a split payment that requires the server fee payer to create the split + recipient ATA, a negative network-mismatch payment, and a cross-route replay +@@ -153,16 +202,16 @@ If the TypeScript adapter cannot resolve `@solana/mpp/client` or + install: + + ```bash +-cd ../../typescript ++cd ../typescript + pnpm --filter @solana/mpp build + +-cd ../tests/interop ++cd ../harness + pnpm install --force --frozen-lockfile + pnpm test + ``` + + `@solana/mpp` is installed from a local `file:` dependency, so +-`tests/interop` needs to install after the TypeScript package has produced its ++`harness` needs to install after the TypeScript package has produced its + `dist` files. + + The harness starts Surfpool through `start-surfnet-proxy.mjs`, funds the test +diff --git a/tests/interop/go-client/go.mod b/harness/go-client/go.mod +similarity index 92% +rename from tests/interop/go-client/go.mod +rename to harness/go-client/go.mod +index 66f4683..58b7bc1 100644 +--- a/tests/interop/go-client/go.mod ++++ b/harness/go-client/go.mod +@@ -1,4 +1,4 @@ +-module github.com/solana-foundation/pay-kit/tests/interop/go-client ++module github.com/solana-foundation/pay-kit/harness/go-client + + go 1.26.1 + +@@ -37,6 +37,6 @@ require ( + golang.org/x/time v0.11.0 // indirect + ) + +-replace github.com/solana-foundation/pay-kit/go => ../../../go ++replace github.com/solana-foundation/pay-kit/go => ../../go + + replace github.com/gagliardetto/solana-go => github.com/lgalabru/solana-go v0.0.0-20260403020633-3cb13b392078 +diff --git a/tests/interop/go-client/go.sum b/harness/go-client/go.sum +similarity index 100% +rename from tests/interop/go-client/go.sum +rename to harness/go-client/go.sum +diff --git a/tests/interop/go-client/main.go b/harness/go-client/main.go +similarity index 100% +rename from tests/interop/go-client/main.go +rename to harness/go-client/main.go +diff --git a/tests/interop/go-client/main_test.go b/harness/go-client/main_test.go +similarity index 100% +rename from tests/interop/go-client/main_test.go +rename to harness/go-client/main_test.go +diff --git a/tests/interop/go-server/go.mod b/harness/go-server/go.mod +similarity index 92% +rename from tests/interop/go-server/go.mod +rename to harness/go-server/go.mod +index ccc2d8e..53ea02e 100644 +--- a/tests/interop/go-server/go.mod ++++ b/harness/go-server/go.mod +@@ -1,4 +1,4 @@ +-module github.com/solana-foundation/mpp-sdk/tests/interop/go-server ++module github.com/solana-foundation/mpp-sdk/harness/go-server + + go 1.26.1 + +@@ -37,6 +37,6 @@ require ( + golang.org/x/time v0.11.0 // indirect + ) + +-replace github.com/solana-foundation/pay-kit/go => ../../../go ++replace github.com/solana-foundation/pay-kit/go => ../../go + + replace github.com/gagliardetto/solana-go => github.com/lgalabru/solana-go v0.0.0-20260403020633-3cb13b392078 +diff --git a/tests/interop/go-server/go.sum b/harness/go-server/go.sum +similarity index 100% +rename from tests/interop/go-server/go.sum +rename to harness/go-server/go.sum +diff --git a/tests/interop/go-server/main.go b/harness/go-server/main.go +similarity index 100% +rename from tests/interop/go-server/main.go +rename to harness/go-server/main.go +diff --git a/tests/interop/go-server/main_test.go b/harness/go-server/main_test.go +similarity index 100% +rename from tests/interop/go-server/main_test.go +rename to harness/go-server/main_test.go +diff --git a/tests/interop/lua-server/dx-gate.mjs b/harness/lua-server/dx-gate.mjs +similarity index 97% +rename from tests/interop/lua-server/dx-gate.mjs +rename to harness/lua-server/dx-gate.mjs +index 58491e3..dfcf85a 100644 +--- a/tests/interop/lua-server/dx-gate.mjs ++++ b/harness/lua-server/dx-gate.mjs +@@ -7,7 +7,7 @@ + // surfpool RPC stays available for the manual DX run. + // + // Run: +-// cd tests/interop && node lua-server/dx-gate.mjs ++// cd harness && node lua-server/dx-gate.mjs + // In another terminal, copy-paste the printed env vars and run: + // cd lua && eval "$(luarocks --lua-version=5.1 --tree lua_modules path)" + // luajit examples/simple-server.lua +@@ -96,7 +96,7 @@ process.on("SIGTERM", shutdown); + // Drain surfnet events on a 100 ms timer so the Rust worker keeps + // advancing; otherwise the surfpool instance stalls and the upstream + // RPC stops responding. Mirrors the pattern in +-// `tests/interop/start-surfnet-proxy.mjs`. ++// `harness/start-surfnet-proxy.mjs`. + setInterval(() => { + try { + surfnet.drainEvents(); +diff --git a/tests/interop/lua-server/server.lua b/harness/lua-server/server.lua +similarity index 99% +rename from tests/interop/lua-server/server.lua +rename to harness/lua-server/server.lua +index 4f7a055..ef81889 100644 +--- a/tests/interop/lua-server/server.lua ++++ b/harness/lua-server/server.lua +@@ -1,7 +1,7 @@ + #!/usr/bin/env luajit + -- Lua MPP interop adapter for the cross-language harness. + -- +--- Mirrors `tests/interop/ruby-server/server.rb`: a raw TCP loop that ++-- Mirrors `harness/ruby-server/server.rb`: a raw TCP loop that + -- gates `interopScenario.resourcePath` behind a `charge` challenge and + -- settles the credential on Surfpool. The harness drives this binary by + -- the contract in `skills/pay-sdk-implementation/references/interop-harness.md`: +@@ -16,7 +16,7 @@ + -- + -- Run manually: + -- cd lua && eval "$(luarocks --lua-version=5.1 --tree lua_modules path)" +--- MPP_INTEROP_RPC_URL=... MPP_INTEROP_PAY_TO=... ... luajit ../tests/interop/lua-server/server.lua ++-- MPP_INTEROP_RPC_URL=... MPP_INTEROP_PAY_TO=... ... luajit ../harness/lua-server/server.lua + + package.path = table.concat({ + './?.lua', +diff --git a/tests/interop/package.json b/harness/package.json +similarity index 90% +rename from tests/interop/package.json +rename to harness/package.json +index bfc4382..481df2f 100644 +--- a/tests/interop/package.json ++++ b/harness/package.json +@@ -15,7 +15,7 @@ + }, + "dependencies": { + "@solana/kit": "^6.5.0", +- "@solana/mpp": "file:../../typescript/packages/mpp", ++ "@solana/mpp": "file:../typescript/packages/mpp", + "mppx": "^0.5.5", + "surfpool-sdk": "^1.2.0" + }, +diff --git a/tests/interop/php-server/server.php b/harness/php-server/server.php +similarity index 99% +rename from tests/interop/php-server/server.php +rename to harness/php-server/server.php +index b8923e6..115daed 100644 +--- a/tests/interop/php-server/server.php ++++ b/harness/php-server/server.php +@@ -23,7 +23,7 @@ use SolanaPhpSdk\Rpc\RpcClient; + error_reporting(error_reporting() & ~E_DEPRECATED & ~E_USER_DEPRECATED); + ini_set('display_errors', 'stderr'); + +-require __DIR__ . '/../../../php/vendor/autoload.php'; ++require __DIR__ . '/../../php/vendor/autoload.php'; + + // ── Env ────────────────────────────────────────────────────────────────────── + +diff --git a/tests/interop/pnpm-lock.yaml b/harness/pnpm-lock.yaml +similarity index 99% +rename from tests/interop/pnpm-lock.yaml +rename to harness/pnpm-lock.yaml +index f87afd2..3cf4ea8 100644 +--- a/tests/interop/pnpm-lock.yaml ++++ b/harness/pnpm-lock.yaml +@@ -15,8 +15,8 @@ importers: + specifier: ^6.5.0 + version: 6.8.0(typescript@5.9.3) + '@solana/mpp': +- specifier: file:../../typescript/packages/mpp +- version: file:../../typescript/packages/mpp(@solana/kit@6.8.0(typescript@5.9.3))(mppx@0.5.17(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6))) ++ specifier: file:../typescript/packages/mpp ++ version: file:../typescript/packages/mpp(@solana/kit@6.8.0(typescript@5.9.3))(mppx@0.5.17(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6))) + mppx: + specifier: ^0.5.5 + version: 0.5.17(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6)) +@@ -498,8 +498,8 @@ packages: + typescript: + optional: true + +- '@solana/mpp@file:../../typescript/packages/mpp': +- resolution: {directory: ../../typescript/packages/mpp, type: directory} ++ '@solana/mpp@file:../typescript/packages/mpp': ++ resolution: {directory: ../typescript/packages/mpp, type: directory} + peerDependencies: + '@solana/kit': '>=6.5.0' + mppx: '>=0.5.5' +@@ -1561,7 +1561,7 @@ snapshots: + - fastestsmallesttextencoderdecoder + - utf-8-validate + +- '@solana/mpp@file:../../typescript/packages/mpp(@solana/kit@6.8.0(typescript@5.9.3))(mppx@0.5.17(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6)))': ++ '@solana/mpp@file:../typescript/packages/mpp(@solana/kit@6.8.0(typescript@5.9.3))(mppx@0.5.17(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6)))': + dependencies: + '@solana-program/compute-budget': 0.15.0(@solana/kit@6.8.0(typescript@5.9.3)) + '@solana-program/system': 0.12.0(@solana/kit@6.8.0(typescript@5.9.3)) +diff --git a/tests/interop/python-server/main.py b/harness/python-server/main.py +similarity index 97% +rename from tests/interop/python-server/main.py +rename to harness/python-server/main.py +index 575c7e9..2d2c421 100644 +--- a/tests/interop/python-server/main.py ++++ b/harness/python-server/main.py +@@ -1,7 +1,7 @@ + """Interop adapter: Python HTTP charge server. + + Mirrors the contract in skills/pay-sdk-implementation/references/interop-harness.md +-and the Ruby adapter at tests/interop/ruby-server/server.rb. The harness ++and the Ruby adapter at harness/ruby-server/server.rb. The harness + launches this process, reads one ``ready`` JSON line from stdout, then sends + HTTP requests to the protected resource. + +@@ -21,14 +21,13 @@ from http.server import BaseHTTPRequestHandler, HTTPServer + from pathlib import Path + from typing import Any + +-# Ensure the local Python SDK is importable when run from tests/interop. ++# Ensure the local Python SDK is importable when run from the harness. + # Walk parents looking for the repo root marker (pyproject.toml at python/ + # or .git) so the adapter stays self-contained regardless of how deep this +-# file lives inside ``tests/``. The harness invokes us from +-# ``tests/interop`` (parents[0]=python-server, parents[1]=interop, +-# parents[2]=tests, parents[3]=repo root); the previous ``parents[2]`` +-# resolved to ``/tests`` and silently fell through to a global +-# ``solana-mpp`` install, hiding local SDK regressions. ++# file lives inside ``harness/``. The harness invokes us from ++# ``harness/python-server``; the previous fixed ``parents[2]`` index ++# silently fell through to a global ``solana-mpp`` install, hiding local ++# SDK regressions. + def _find_repo_root(start: Path) -> Path: + for candidate in [start, *start.parents]: + if (candidate / ".git").exists() or (candidate / "python" / "pyproject.toml").is_file(): +diff --git a/tests/interop/ruby-server/server.rb b/harness/ruby-server/server.rb +similarity index 99% +rename from tests/interop/ruby-server/server.rb +rename to harness/ruby-server/server.rb +index b84a008..1f9c961 100644 +--- a/tests/interop/ruby-server/server.rb ++++ b/harness/ruby-server/server.rb +@@ -2,7 +2,7 @@ + + require "json" + require "socket" +-require_relative "../../../ruby/lib/mpp" ++require_relative "../../ruby/lib/mpp" + + # Read a required environment variable for the interop adapter. + def require_env(name) +diff --git a/tests/interop/rust-client/Cargo.toml b/harness/rust-client/Cargo.toml +similarity index 92% +rename from tests/interop/rust-client/Cargo.toml +rename to harness/rust-client/Cargo.toml +index 814fbce..f98ebfd 100644 +--- a/tests/interop/rust-client/Cargo.toml ++++ b/harness/rust-client/Cargo.toml +@@ -5,7 +5,7 @@ edition = "2021" + publish = false + + [dependencies] +-solana-mpp = { path = "../../../rust" } ++solana-mpp = { path = "../../rust" } + solana-keychain = { git = "https://github.com/solana-foundation/solana-keychain", rev = "abf75944", default-features = false, features = ["memory"] } + solana-rpc-client = "3.1" + solana-signature = "3.1" +diff --git a/tests/interop/rust-client/src/main.rs b/harness/rust-client/src/main.rs +similarity index 100% +rename from tests/interop/rust-client/src/main.rs +rename to harness/rust-client/src/main.rs +diff --git a/tests/interop/src/canonical-codes.ts b/harness/src/canonical-codes.ts +similarity index 100% +rename from tests/interop/src/canonical-codes.ts +rename to harness/src/canonical-codes.ts +diff --git a/tests/interop/src/contracts.ts b/harness/src/contracts.ts +similarity index 92% +rename from tests/interop/src/contracts.ts +rename to harness/src/contracts.ts +index 87c43fa..8143e86 100644 +--- a/tests/interop/src/contracts.ts ++++ b/harness/src/contracts.ts +@@ -1,11 +1,12 @@ + import type { CanonicalErrorCode } from "./canonical-codes"; + import { chargeScenarios } from "./intents/charge"; ++import { x402ExactScenarios } from "./intents/x402-exact"; + + export type { CanonicalErrorCode }; + + export type AdapterKind = "client" | "server"; + +-export type InteropIntent = "charge"; ++export type InteropIntent = "charge" | "x402-exact"; + + export type InteropScenarioSplit = { + recipientKey: string; +@@ -136,8 +137,10 @@ export type AdapterMessage = ReadyMessage | ClientRunResult; + + export { chargeCanonicalJsonVectors } from "./intents/charge"; + +-export const interopScenarios: readonly InteropScenario[] = +- chargeScenarios; ++export const interopScenarios: readonly InteropScenario[] = [ ++ ...chargeScenarios, ++ ...x402ExactScenarios, ++]; + + export const interopScenario: InteropScenario = { + ...(interopScenarios[0] as InteropScenario), +@@ -191,11 +194,18 @@ function selectScenarioIds(rawSelection: string | undefined): string[] { + return selected; + } + ++// The legacy MPP charge runner predates the x402-exact intent. To keep ++// the existing CI matrix's default behaviour (charge-only) stable while ++// still surfacing the new intent through `selectInteropIntents("x402-exact")`, ++// the empty-selection default is restricted to "charge". Callers that ++// want the full intent set should pass the explicit list. ++const DEFAULT_INTENTS: readonly InteropIntent[] = ["charge"]; ++ + export function selectInteropIntents( + rawSelection: string | undefined, + ): InteropIntent[] { + if (!rawSelection || rawSelection.trim() === "") { +- return [...supportedInteropIntents]; ++ return [...DEFAULT_INTENTS]; + } + + const selected = rawSelection +@@ -209,8 +219,7 @@ export function selectInteropIntents( + if (unsupported.length > 0) { + throw new Error( + `Unsupported MPP_INTEROP_INTENTS value(s): ${unsupported.join(", ")}. ` + +- `Supported intents: ${supportedInteropIntents.join(", ")}. ` + +- "Session and subscription scenarios are not implemented in this harness yet.", ++ `Supported intents: ${supportedInteropIntents.join(", ")}.`, + ); + } + +diff --git a/tests/interop/src/fixtures/typescript/charge-client.ts b/harness/src/fixtures/typescript/charge-client.ts +similarity index 100% +rename from tests/interop/src/fixtures/typescript/charge-client.ts +rename to harness/src/fixtures/typescript/charge-client.ts +diff --git a/tests/interop/src/fixtures/typescript/charge-server.ts b/harness/src/fixtures/typescript/charge-server.ts +similarity index 100% +rename from tests/interop/src/fixtures/typescript/charge-server.ts +rename to harness/src/fixtures/typescript/charge-server.ts +diff --git a/harness/src/fixtures/typescript/exact-client.ts b/harness/src/fixtures/typescript/exact-client.ts +new file mode 100644 +index 0000000..67807f3 +--- /dev/null ++++ b/harness/src/fixtures/typescript/exact-client.ts +@@ -0,0 +1,225 @@ ++// TypeScript reference x402 `exact` interop client. ++// ++// Shares the same `X402_INTEROP_*` env-var contract and ready/result ++// JSON protocol as the Rust spine (`rust/crates/x402/src/bin/ ++// interop_client.rs`). Sends an unpaid GET, parses the base64 ++// `PAYMENT-REQUIRED` envelope, selects an offer (`preferredCurrencies` ++// first) and resubmits with `PAYMENT-SIGNATURE`. Prints one result ++// JSON line to stdout. ++// ++// Scope: the fixture carries a stub credential payload (challenge id + ++// resource) so the harness wiring, negative-code classification, and ++// cross-server portability + idempotent-resubmit flows can run without ++// a full Solana signer. Real SVM PaymentProof construction (signed ++// VersionedTransaction or settled signature) lives in the Rust spine ++// and the TS SDK port; this client only pairs against the TS reference ++// server in the default matrix (see `test/x402-exact.e2e.test.ts`). ++ ++import { ++ PAYMENT_REQUIRED_HEADER, ++ PAYMENT_SIGNATURE_HEADER, ++ readX402ClientEnvironment, ++} from "./exact-shared"; ++ ++type PaymentRequirement = { ++ scheme: string; ++ network: string; ++ resource?: string; ++ payTo: string; ++ asset: string; ++ maxAmountRequired: string; ++ extra?: { decimals?: number; tokenProgram?: string }; ++}; ++ ++type PaymentRequiredEnvelope = { ++ x402Version: number; ++ accepts: PaymentRequirement[]; ++ resource?: string; ++}; ++ ++const STABLECOIN_MINTS: Record> = { ++ USDC: { ++ "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": ++ "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", ++ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": ++ "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ }, ++ PYUSD: { ++ "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": ++ "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", ++ }, ++}; ++ ++function resolveMint(currency: string, network: string): string { ++ const upper = currency.toUpperCase(); ++ const byNetwork = STABLECOIN_MINTS[upper]; ++ if (byNetwork && byNetwork[network]) { ++ return byNetwork[network]; ++ } ++ return currency; ++} ++ ++function pickOffer( ++ envelope: PaymentRequiredEnvelope, ++ preferred: string[], ++ network: string, ++): PaymentRequirement | undefined { ++ const supported = envelope.accepts.filter( ++ offer => offer.scheme === "exact" && offer.network === network, ++ ); ++ if (supported.length === 0) { ++ return undefined; ++ } ++ if (preferred.length === 0) { ++ return supported[0]; ++ } ++ for (const wanted of preferred) { ++ const wantedMint = resolveMint(wanted, network); ++ const match = supported.find(offer => offer.asset === wantedMint); ++ if (match) return match; ++ } ++ return supported[0]; ++} ++ ++function decodePaymentRequired(headerValue: string | null): PaymentRequiredEnvelope | null { ++ if (!headerValue) return null; ++ try { ++ const raw = Buffer.from(headerValue, "base64").toString("utf8"); ++ return JSON.parse(raw) as PaymentRequiredEnvelope; ++ } catch { ++ return null; ++ } ++} ++ ++async function readResponseBody(response: Response): Promise { ++ const raw = await response.text(); ++ try { ++ return JSON.parse(raw); ++ } catch { ++ return raw; ++ } ++} ++ ++async function main() { ++ const env = readX402ClientEnvironment(); ++ ++ const firstResponse = await fetch(env.targetUrl); ++ const envelope = decodePaymentRequired( ++ firstResponse.headers.get(PAYMENT_REQUIRED_HEADER), ++ ); ++ ++ if (!envelope) { ++ console.log( ++ JSON.stringify({ ++ type: "result", ++ implementation: "typescript", ++ role: "client", ++ ok: false, ++ status: firstResponse.status, ++ responseHeaders: Object.fromEntries(firstResponse.headers.entries()), ++ responseBody: await readResponseBody(firstResponse), ++ settlement: null, ++ error: "missing or unparseable PAYMENT-REQUIRED header", ++ }), ++ ); ++ return; ++ } ++ ++ const offer = pickOffer(envelope, env.preferredCurrencies, env.network); ++ if (!offer) { ++ console.log( ++ JSON.stringify({ ++ type: "result", ++ implementation: "typescript", ++ role: "client", ++ ok: false, ++ status: firstResponse.status, ++ responseHeaders: Object.fromEntries(firstResponse.headers.entries()), ++ responseBody: await readResponseBody(firstResponse), ++ settlement: null, ++ error: `no offer matched network ${env.network}`, ++ }), ++ ); ++ return; ++ } ++ ++ // Credential payload mirrors the canonical x402 `exact` shape: an ++ // adapter-specific id plus the offer the client is committing to. ++ // A live SDK would also embed a signed Solana transaction here; the ++ // matrix runner uses the rust spine for the actual on-chain ++ // settlement assertions. The TS fixture's role is wire-level ++ // protocol compliance. ++ // Use the server-issued challenge id if present (TS reference server ++ // emits one in the `x-challenge-id` header on the 402). This lets the ++ // server verify the credential was issued against its own 402 — the ++ // cross-server portability scenario relies on this distinction. ++ const issuedChallengeId = firstResponse.headers.get("x-challenge-id"); ++ const credentialId = ++ issuedChallengeId ?? ++ `ts-x402-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; ++ // Mirrors the Rust spine's PaymentPayload wire shape: ++ // { x402Version, accepted: { scheme, network, asset, payTo, amount, extra? }, ++ // payload: { ... scheme-specific blob ... }, resource?: string } ++ // The `payload` field is required by Rust's parser. For the wire-only ++ // TS adapter the payload carries the credential id plus the route the ++ // client is committing to; a full SDK fixture would carry a signed ++ // Solana transaction here. ++ const credential = { ++ x402Version: envelope.x402Version, ++ accepted: { ++ scheme: offer.scheme, ++ network: offer.network, ++ asset: offer.asset, ++ payTo: offer.payTo, ++ amount: offer.maxAmountRequired, ++ extra: offer.extra ?? null, ++ }, ++ payload: { ++ challengeId: credentialId, ++ resource: offer.resource ?? envelope.resource, ++ }, ++ resource: offer.resource ?? envelope.resource, ++ }; ++ const credentialHeader = Buffer.from(JSON.stringify(credential), "utf8").toString( ++ "base64", ++ ); ++ ++ const paidResponse = await fetch(env.targetUrl, { ++ headers: { [PAYMENT_SIGNATURE_HEADER]: credentialHeader }, ++ }); ++ ++ const responseHeaders = Object.fromEntries(paidResponse.headers.entries()); ++ // Echo the credential the client sent so the harness can replay it in ++ // cross-server portability + idempotent-resubmit scenarios. The credential ++ // is a request header so it is never reflected in the response on its own. ++ responseHeaders[`${PAYMENT_SIGNATURE_HEADER}-sent`] = credentialHeader; ++ ++ console.log( ++ JSON.stringify({ ++ type: "result", ++ implementation: "typescript", ++ role: "client", ++ ok: paidResponse.ok, ++ status: paidResponse.status, ++ responseHeaders, ++ responseBody: await readResponseBody(paidResponse), ++ settlement: paidResponse.headers.get(env.settlementHeader), ++ }), ++ ); ++} ++ ++void main().catch(error => { ++ console.log( ++ JSON.stringify({ ++ type: "result", ++ implementation: "typescript", ++ role: "client", ++ ok: false, ++ status: 0, ++ responseHeaders: {}, ++ responseBody: null, ++ settlement: null, ++ error: error instanceof Error ? error.message : String(error), ++ }), ++ ); ++}); +diff --git a/harness/src/fixtures/typescript/exact-server.ts b/harness/src/fixtures/typescript/exact-server.ts +new file mode 100644 +index 0000000..780c663 +--- /dev/null ++++ b/harness/src/fixtures/typescript/exact-server.ts +@@ -0,0 +1,368 @@ ++// TypeScript reference x402 `exact` interop server. ++// ++// Wire-compatible with `rust/crates/x402/src/bin/interop_server.rs`: ++// - 402 carries a `PAYMENT-REQUIRED` header whose value is the ++// base64 of the JSON envelope `{x402Version, accepts, resource}`. ++// - The credential is delivered in the `PAYMENT-SIGNATURE` header. ++// - On successful settlement, the response includes ++// `PAYMENT-RESPONSE` and the fixture settlement header. ++// ++// This fixture deliberately keeps the SDK surface area minimal so the ++// adapter is portable across pay-kit checkouts. The cross-language ++// matrix is the load-bearing path; this adapter exists so language ++// adapters have a TS counterpart to pair against while the canonical ++// SDK lands. End-to-end verification against a live Surfpool RPC is ++// driven by the matrix runner. ++ ++import http from "node:http"; ++import { ++ PAYMENT_REQUIRED_HEADER, ++ PAYMENT_RESPONSE_HEADER, ++ PAYMENT_SIGNATURE_HEADER, ++ X402_VERSION_V2, ++ readX402ServerEnvironment, ++} from "./exact-shared"; ++ ++const TOKEN_DECIMALS = 6; ++const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; ++ ++type PaymentRequirement = { ++ scheme: "exact"; ++ network: string; ++ resource: string; ++ description: string; ++ mimeType: string; ++ payTo: string; ++ asset: string; ++ maxAmountRequired: string; ++ maxTimeoutSeconds: number; ++ extra: { ++ decimals: number; ++ tokenProgram?: string; ++ feePayer?: string; ++ }; ++}; ++ ++function buildRequirements( ++ env: ReturnType, ++): PaymentRequirement[] { ++ const primary: PaymentRequirement = { ++ scheme: "exact", ++ network: env.network, ++ resource: env.resourcePath, ++ description: "Surfpool-backed protected content", ++ mimeType: "application/json", ++ payTo: env.payTo, ++ asset: env.mint, ++ maxAmountRequired: env.price, ++ maxTimeoutSeconds: 60, ++ extra: { ++ decimals: TOKEN_DECIMALS, ++ tokenProgram: TOKEN_PROGRAM, ++ }, ++ }; ++ ++ const extras: PaymentRequirement[] = env.extraOfferedMints.map(mint => ({ ++ scheme: "exact", ++ network: env.network, ++ resource: env.resourcePath, ++ description: "Surfpool-backed protected content", ++ mimeType: "application/json", ++ payTo: env.payTo, ++ asset: mint, ++ maxAmountRequired: env.price, ++ maxTimeoutSeconds: 60, ++ extra: { decimals: TOKEN_DECIMALS }, ++ })); ++ ++ return [primary, ...extras]; ++} ++ ++function encodePaymentRequiredHeader(accepts: PaymentRequirement[]): string { ++ const envelope = { ++ x402Version: X402_VERSION_V2, ++ accepts, ++ resource: accepts[0]?.resource, ++ error: null, ++ }; ++ return Buffer.from(JSON.stringify(envelope), "utf8").toString("base64"); ++} ++ ++type DecodedCredential = { ++ x402Version?: number; ++ accepted?: { ++ scheme?: string; ++ network?: string; ++ asset?: string; ++ payTo?: string; ++ amount?: string; ++ }; ++ payload?: { ++ challengeId?: string; ++ resource?: string; ++ }; ++ resource?: string; ++}; ++ ++function decodeCredential(headerValue: string): DecodedCredential | null { ++ try { ++ const decoded = Buffer.from(headerValue, "base64").toString("utf8"); ++ return JSON.parse(decoded) as DecodedCredential; ++ } catch { ++ return null; ++ } ++} ++ ++type RejectReason = { ++ code: ++ | "payment_invalid" ++ | "wrong_network" ++ | "charge_request_mismatch" ++ | "challenge_verification_failed"; ++ message: string; ++}; ++ ++function classifyCredential( ++ credential: DecodedCredential | null, ++ accepts: PaymentRequirement[], ++ requestedResource: string, ++): { offer: PaymentRequirement; credentialKey: string } | { reject: RejectReason } { ++ if (!credential || !credential.accepted || !credential.payload) { ++ return { ++ reject: { ++ code: "payment_invalid", ++ message: "credential is missing accepted/payload fields", ++ }, ++ }; ++ } ++ ++ const offer = accepts.find( ++ candidate => ++ candidate.asset === credential.accepted?.asset && ++ candidate.network === credential.accepted?.network && ++ candidate.scheme === credential.accepted?.scheme, ++ ); ++ ++ if (!offer) { ++ // Could be either network mismatch or no matching offer. ++ if ( ++ credential.accepted.network && ++ !accepts.some(c => c.network === credential.accepted?.network) ++ ) { ++ return { ++ reject: { ++ code: "wrong_network", ++ message: `credential network ${credential.accepted.network} does not match server`, ++ }, ++ }; ++ } ++ return { ++ reject: { ++ code: "charge_request_mismatch", ++ message: "no offered requirement matches the credential", ++ }, ++ }; ++ } ++ ++ if (offer.payTo !== credential.accepted.payTo) { ++ return { ++ reject: { ++ code: "charge_request_mismatch", ++ message: "recipient does not match", ++ }, ++ }; ++ } ++ ++ if (offer.maxAmountRequired !== credential.accepted.amount) { ++ return { ++ reject: { ++ code: "charge_request_mismatch", ++ message: "amount does not match", ++ }, ++ }; ++ } ++ ++ const credentialResource = credential.payload.resource ?? credential.resource; ++ if (credentialResource && credentialResource !== requestedResource) { ++ return { ++ reject: { ++ code: "charge_request_mismatch", ++ message: `credential resource ${credentialResource} does not match requested ${requestedResource}`, ++ }, ++ }; ++ } ++ ++ const challengeId = credential.payload.challengeId; ++ if (!challengeId || typeof challengeId !== "string") { ++ return { ++ reject: { ++ code: "challenge_verification_failed", ++ message: "credential payload missing challengeId", ++ }, ++ }; ++ } ++ ++ return { offer, credentialKey: challengeId }; ++} ++ ++async function main() { ++ const env = readX402ServerEnvironment(); ++ const accepts = buildRequirements(env); ++ const paymentRequiredHeader = encodePaymentRequiredHeader(accepts); ++ ++ // Track consumed credentials by challengeId to surface ++ // `signature_consumed` on idempotent resubmit. ++ const consumed = new Set(); ++ // Track challenge IDs this server has issued (recognised when a ++ // credential's payload.challengeId matches). Cross-server portability: ++ // server B sees a credential carrying an id only server A issued, so B ++ // rejects with `challenge_verification_failed`. A real x402 facilitator ++ // verifies HMAC over the challenge id with its own secret; this fixture ++ // simulates that by tracking issuance in-process. ++ const issued = new Set(); ++ ++ const server = http.createServer((request, response) => { ++ const url = new URL(request.url ?? "/", "http://127.0.0.1"); ++ ++ if (url.pathname === "/health") { ++ response.writeHead(200, { "content-type": "application/json" }); ++ response.end(JSON.stringify({ ok: true })); ++ return; ++ } ++ ++ if (url.pathname !== env.resourcePath) { ++ response.writeHead(404, { "content-type": "application/json" }); ++ response.end(JSON.stringify({ error: "not_found" })); ++ return; ++ } ++ ++ const paymentHeader = request.headers[PAYMENT_SIGNATURE_HEADER] as ++ | string ++ | undefined; ++ ++ if (!paymentHeader) { ++ // Issue a fresh challenge id so the client can echo it back. The ++ // fixture's "verification" is presence-in-`issued`; a real ++ // facilitator would HMAC the id with its secret. ++ const challengeId = `ts-srv-${Date.now().toString(36)}-${Math.random() ++ .toString(36) ++ .slice(2, 10)}`; ++ issued.add(challengeId); ++ response.writeHead(402, { ++ "content-type": "application/json", ++ [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, ++ "x-challenge-id": challengeId, ++ }); ++ response.end( ++ JSON.stringify({ error: "payment_required", challengeId }), ++ ); ++ return; ++ } ++ ++ const credential = decodeCredential(paymentHeader); ++ const classified = classifyCredential(credential, accepts, env.resourcePath); ++ ++ if ("reject" in classified) { ++ response.writeHead(402, { ++ "content-type": "application/json", ++ [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, ++ }); ++ response.end( ++ JSON.stringify({ ++ error: classified.reject.code, ++ code: classified.reject.code, ++ message: classified.reject.message, ++ }), ++ ); ++ return; ++ } ++ ++ const { credentialKey } = classified; ++ ++ if (consumed.has(credentialKey)) { ++ response.writeHead(402, { ++ "content-type": "application/json", ++ [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, ++ }); ++ response.end( ++ JSON.stringify({ ++ error: "signature_consumed", ++ code: "signature_consumed", ++ message: "signature already consumed", ++ }), ++ ); ++ return; ++ } ++ ++ // Cross-server portability check: when the client supplies a payload ++ // challengeId, it must be one this server issued (or this server ++ // never required HMAC issuance). The first paid request that didn't ++ // come from this server's 402 will be missing from `issued`. ++ if (issued.size > 0 && !issued.has(credentialKey)) { ++ response.writeHead(402, { ++ "content-type": "application/json", ++ [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, ++ }); ++ response.end( ++ JSON.stringify({ ++ error: "challenge_verification_failed", ++ code: "challenge_verification_failed", ++ message: "challenge id was not issued by this server", ++ }), ++ ); ++ return; ++ } ++ ++ consumed.add(credentialKey); ++ ++ // Settlement: a real facilitator would broadcast a signed Solana ++ // transaction here. The fixture returns a deterministic placeholder ++ // so the harness can assert presence of the settlement header. ++ const settlement = `ts-x402-exact-${credentialKey.slice(0, 16)}`; ++ const paymentResponse = JSON.stringify({ ++ success: true, ++ network: accepts[0]?.network, ++ transaction: settlement, ++ }); ++ ++ response.writeHead(200, { ++ "content-type": "application/json", ++ [env.settlementHeader]: settlement, ++ [PAYMENT_RESPONSE_HEADER]: paymentResponse, ++ }); ++ response.end( ++ JSON.stringify({ ++ ok: true, ++ paid: true, ++ settlement: { ++ success: true, ++ transaction: settlement, ++ network: accepts[0]?.network, ++ }, ++ }), ++ ); ++ }); ++ ++ server.listen(0, "127.0.0.1", () => { ++ const address = server.address(); ++ if (!address || typeof address === "string") { ++ throw new Error("Failed to bind TypeScript x402 interop server"); ++ } ++ ++ console.log( ++ JSON.stringify({ ++ type: "ready", ++ implementation: "typescript", ++ role: "server", ++ port: address.port, ++ capabilities: ["exact"], ++ }), ++ ); ++ }); ++ ++ const shutdown = () => server.close(() => process.exit(0)); ++ process.on("SIGTERM", shutdown); ++ process.on("SIGINT", shutdown); ++} ++ ++void main(); +diff --git a/harness/src/fixtures/typescript/exact-shared.ts b/harness/src/fixtures/typescript/exact-shared.ts +new file mode 100644 +index 0000000..d9771bd +--- /dev/null ++++ b/harness/src/fixtures/typescript/exact-shared.ts +@@ -0,0 +1,87 @@ ++// Env contract for the TypeScript x402 `exact` fixture adapters. The ++// wire shape mirrors the Rust spine (`rust/crates/x402/src/bin/ ++// interop_{client,server}.rs`) verbatim so any language adapter that ++// targets this contract can pair against either TS or Rust. ++ ++export type X402InteropEnvironment = { ++ rpcUrl: string; ++ network: string; ++ mint: string; ++ payTo: string; ++ price: string; ++ resourcePath: string; ++ settlementHeader: string; ++ facilitatorSecretKey: Uint8Array; ++ // Server-only. Comma-separated mint addresses advertised alongside the ++ // primary currency. Read from `X402_INTEROP_EXTRA_OFFERED_MINTS`. ++ extraOfferedMints: string[]; ++}; ++ ++export type X402ClientEnvironment = X402InteropEnvironment & { ++ targetUrl: string; ++ clientSecretKey: Uint8Array; ++ // Comma-separated currency preference list (symbols or mints) read ++ // from `X402_INTEROP_PREFER_CURRENCIES`. Empty when unset. ++ preferredCurrencies: string[]; ++}; ++ ++const DEFAULT_NETWORK = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; ++const DEFAULT_RESOURCE_PATH = "/protected"; ++const DEFAULT_PRICE = "0.001"; ++const DEFAULT_SETTLEMENT_HEADER = "x-fixture-settlement"; ++ ++function readRequiredEnv(name: string): string { ++ const value = process.env[name]; ++ if (!value || value.trim() === "") { ++ throw new Error(`${name} is required`); ++ } ++ return value; ++} ++ ++function parseSecretKey(name: string): Uint8Array { ++ const raw = readRequiredEnv(name); ++ const parsed = JSON.parse(raw) as number[]; ++ return new Uint8Array(parsed); ++} ++ ++function parseCsv(raw: string | undefined): string[] { ++ if (!raw) return []; ++ return raw ++ .split(",") ++ .map(value => value.trim()) ++ .filter(Boolean); ++} ++ ++function readBase(): X402InteropEnvironment { ++ return { ++ rpcUrl: readRequiredEnv("X402_INTEROP_RPC_URL"), ++ network: process.env.X402_INTEROP_NETWORK ?? DEFAULT_NETWORK, ++ mint: readRequiredEnv("X402_INTEROP_MINT"), ++ payTo: readRequiredEnv("X402_INTEROP_PAY_TO"), ++ price: process.env.X402_INTEROP_PRICE ?? DEFAULT_PRICE, ++ resourcePath: process.env.X402_INTEROP_RESOURCE_PATH ?? DEFAULT_RESOURCE_PATH, ++ settlementHeader: ++ process.env.X402_INTEROP_SETTLEMENT_HEADER ?? DEFAULT_SETTLEMENT_HEADER, ++ facilitatorSecretKey: parseSecretKey("X402_INTEROP_FACILITATOR_SECRET_KEY"), ++ extraOfferedMints: parseCsv(process.env.X402_INTEROP_EXTRA_OFFERED_MINTS), ++ }; ++} ++ ++export function readX402ServerEnvironment(): X402InteropEnvironment { ++ return readBase(); ++} ++ ++export function readX402ClientEnvironment(): X402ClientEnvironment { ++ const base = readBase(); ++ return { ++ ...base, ++ targetUrl: readRequiredEnv("X402_INTEROP_TARGET_URL"), ++ clientSecretKey: parseSecretKey("X402_INTEROP_CLIENT_SECRET_KEY"), ++ preferredCurrencies: parseCsv(process.env.X402_INTEROP_PREFER_CURRENCIES), ++ }; ++} ++ ++export const PAYMENT_REQUIRED_HEADER = "payment-required"; ++export const PAYMENT_SIGNATURE_HEADER = "payment-signature"; ++export const PAYMENT_RESPONSE_HEADER = "payment-response"; ++export const X402_VERSION_V2 = 2; +diff --git a/tests/interop/src/fixtures/typescript/shared.ts b/harness/src/fixtures/typescript/shared.ts +similarity index 100% +rename from tests/interop/src/fixtures/typescript/shared.ts +rename to harness/src/fixtures/typescript/shared.ts +diff --git a/tests/interop/src/implementations.ts b/harness/src/implementations.ts +similarity index 63% +rename from tests/interop/src/implementations.ts +rename to harness/src/implementations.ts +index 89c9586..9bab459 100644 +--- a/tests/interop/src/implementations.ts ++++ b/harness/src/implementations.ts +@@ -4,6 +4,10 @@ export type ImplementationDefinition = { + role: "client" | "server"; + command: string[]; + enabled: boolean; ++ // Optional. When set, this adapter only participates in scenarios whose ++ // `intent` is in this list. Defaults to "charge" only for back-compat ++ // with the existing MPP charge matrix. ++ intents?: string[]; + }; + + function isEnabled(id: string, envName: string, defaultEnabled: boolean): boolean { +@@ -43,7 +47,7 @@ export const clientImplementations: ImplementationDefinition[] = [ + "run", + "--quiet", + "--manifest-path", +- "../../rust/Cargo.toml", ++ "../rust/Cargo.toml", + "-p", + "solana-mpp", + "--bin", +@@ -69,6 +73,51 @@ export const clientImplementations: ImplementationDefinition[] = [ + ], + enabled: isEnabled("swift", "MPP_INTEROP_CLIENTS", false), + }, ++ { ++ id: "ts-x402", ++ label: "TypeScript x402 exact client", ++ role: "client", ++ command: [ ++ "pnpm", ++ "exec", ++ "node", ++ "--import", ++ "tsx", ++ "src/fixtures/typescript/exact-client.ts", ++ ], ++ enabled: isEnabled("ts-x402", "X402_INTEROP_CLIENTS", true), ++ intents: ["x402-exact"], ++ }, ++ { ++ id: "rust-x402", ++ label: "Rust x402 exact client", ++ role: "client", ++ command: [ ++ "cargo", ++ "run", ++ "--quiet", ++ "--manifest-path", ++ "../../rust/Cargo.toml", ++ "-p", ++ "solana-x402", ++ "--bin", ++ "interop_client", ++ ], ++ enabled: isEnabled("rust-x402", "X402_INTEROP_CLIENTS", true), ++ intents: ["x402-exact"], ++ }, ++ { ++ id: "go-x402-client", ++ label: "Go x402 exact client", ++ role: "client", ++ command: [ ++ "sh", ++ "-c", ++ "cd ../go/x402/cmd/interop-client && go run .", ++ ], ++ enabled: isEnabled("go-x402-client", "MPP_INTEROP_CLIENTS", false), ++ intents: ["x402-exact"], ++ }, + ]; + + export const serverImplementations: ImplementationDefinition[] = [ +@@ -95,7 +144,7 @@ export const serverImplementations: ImplementationDefinition[] = [ + "run", + "--quiet", + "--manifest-path", +- "../../rust/Cargo.toml", ++ "../rust/Cargo.toml", + "-p", + "solana-mpp", + "--bin", +@@ -122,7 +171,7 @@ export const serverImplementations: ImplementationDefinition[] = [ + command: [ + "sh", + "-c", +- "cd ../../ruby && bundle exec ruby ../tests/interop/ruby-server/server.rb", ++ "cd ../ruby && bundle exec ruby ../harness/ruby-server/server.rb", + ], + enabled: isEnabled("ruby", "MPP_INTEROP_SERVERS", false), + }, +@@ -133,7 +182,7 @@ export const serverImplementations: ImplementationDefinition[] = [ + command: [ + "sh", + "-c", +- "cd ../../lua && eval \"$(luarocks --lua-version=5.1 --tree lua_modules path)\" && luajit ../tests/interop/lua-server/server.lua", ++ "cd ../lua && eval \"$(luarocks --lua-version=5.1 --tree lua_modules path)\" && luajit ../harness/lua-server/server.lua", + ], + // Lua defaults off to match php/ruby: the harness requires a + // luarocks-installed lua_modules tree under lua/ and a working +@@ -161,4 +210,49 @@ export const serverImplementations: ImplementationDefinition[] = [ + command: ["sh", "-c", "cd go-server && go run ."], + enabled: isEnabled("go", "MPP_INTEROP_SERVERS", true), + }, ++ { ++ id: "ts-x402", ++ label: "TypeScript x402 exact server", ++ role: "server", ++ command: [ ++ "pnpm", ++ "exec", ++ "node", ++ "--import", ++ "tsx", ++ "src/fixtures/typescript/exact-server.ts", ++ ], ++ enabled: isEnabled("ts-x402", "X402_INTEROP_SERVERS", true), ++ intents: ["x402-exact"], ++ }, ++ { ++ id: "rust-x402", ++ label: "Rust x402 exact server", ++ role: "server", ++ command: [ ++ "cargo", ++ "run", ++ "--quiet", ++ "--manifest-path", ++ "../../rust/Cargo.toml", ++ "-p", ++ "solana-x402", ++ "--bin", ++ "interop_server", ++ ], ++ enabled: isEnabled("rust-x402", "X402_INTEROP_SERVERS", true), ++ intents: ["x402-exact"], ++ }, ++ { ++ id: "go-x402-server", ++ label: "Go x402 exact server", ++ role: "server", ++ command: [ ++ "sh", ++ "-c", ++ "cd ../go/x402/cmd/interop-server && go run .", ++ ], ++ enabled: isEnabled("go-x402-server", "MPP_INTEROP_SERVERS", false), ++ intents: ["x402-exact"], ++ }, + ]; +diff --git a/tests/interop/src/intents/charge.ts b/harness/src/intents/charge.ts +similarity index 99% +rename from tests/interop/src/intents/charge.ts +rename to harness/src/intents/charge.ts +index db1e3df..a1d58f3 100644 +--- a/tests/interop/src/intents/charge.ts ++++ b/harness/src/intents/charge.ts +@@ -66,7 +66,7 @@ export const chargeCanonicalJsonVectors: readonly CanonicalJsonVector[] = [ + * Reserved for a future cross-SDK harness loop that asserts each + * implementation's encoder rejects these inputs; today the per-language + * rejection coverage lives inline in each SDK's own unit suite plus the +- * reference encoder check in `tests/interop/test/canonical-json.test.ts`. ++ * reference encoder check in `harness/test/canonical-json.test.ts`. + * Kept here so the spec-mandated reject set has a single source of truth. + */ + export const chargeCanonicalJsonRejectVectors: readonly { id: string; reason: string }[] = [ +@@ -118,7 +118,7 @@ export const chargeScenarios: readonly InteropScenario[] = [ + // Server fixtures honour MPP_INTEROP_PAYMENT_MODE=push by omitting + // the fee payer signer when constructing the charge method. + // Excluded: lua and python ship push support in their SDKs but do +- // not yet have an interop server fixture under tests/interop/. ++ // not yet have an interop server fixture under harness/. + id: "charge-push", + intent: "charge", + paymentMode: "push", +diff --git a/harness/src/intents/x402-exact.ts b/harness/src/intents/x402-exact.ts +new file mode 100644 +index 0000000..85f1afe +--- /dev/null ++++ b/harness/src/intents/x402-exact.ts +@@ -0,0 +1,119 @@ ++import type { InteropScenario } from "../contracts"; ++ ++// Canonical x402 `exact` intent scenarios. The harness contract (env ++// vars, ready/result JSON shapes, capabilities) mirrors the Rust spine ++// (`rust/crates/x402/src/bin/interop_{client,server}.rs`). The matrix ++// pairs each x402 client against each x402 server registered in ++// `implementations.ts`; the default-matrix pair set is restricted in ++// `test/x402-exact.e2e.test.ts` while the TS reference adapter ships ++// without a full Solana signing path. Adding language adapters that ++// carry a real PaymentProof expands the matrix. ++// ++// Reject codes (cross-server portability / replay / network mismatch) ++// reuse the canonical L6 set declared in `canonical-codes.ts`; the ++// matrix asserts each x402 server adapter classifies the failure ++// to the same canonical snake_case code as every other adapter. ++export const x402ExactScenarios: readonly InteropScenario[] = [ ++ { ++ id: "x402-exact-basic", ++ intent: "x402-exact", ++ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ price: "0.001", ++ amount: "1000", ++ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ resourcePath: "/protected", ++ settlementHeader: "x-fixture-settlement", ++ expectedStatus: 200, ++ }, ++ { ++ // Network mismatch: client signs against localnet but the challenge ++ // requires devnet (or vice versa). Server must reject the credential ++ // with canonical `wrong_network`. ++ id: "x402-exact-network-mismatch", ++ intent: "x402-exact", ++ network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", ++ price: "0.001", ++ amount: "1000", ++ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ resourcePath: "/protected/network-mismatch", ++ settlementHeader: "x-fixture-settlement", ++ expectedStatus: 402, ++ expectedCode: "wrong_network", ++ clientIds: ["ts-x402", "rust-x402"], ++ serverIds: ["ts-x402", "rust-x402"], ++ }, ++ { ++ // Cross-route replay: credential issued for /protected/cheap is ++ // re-submitted against /protected/expensive. Server must reject with ++ // `charge_request_mismatch` because the credential's pinned route / ++ // amount does not match the served route. ++ id: "x402-exact-cross-route-replay", ++ intent: "x402-exact", ++ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ price: "0.001", ++ amount: "1000", ++ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ resourcePath: "/protected/expensive", ++ settlementHeader: "x-fixture-settlement", ++ replaySource: { ++ resourcePath: "/protected/cheap", ++ price: "0.0005", ++ amount: "500", ++ }, ++ expectedStatus: 402, ++ expectedCode: "charge_request_mismatch", ++ clientIds: ["ts-x402"], ++ serverIds: ["ts-x402", "rust-x402"], ++ }, ++ { ++ // Cross-server credential portability. Client pays server A and ++ // re-submits the same payment header to server B. B must reject with ++ // canonical `challenge_verification_failed` because B's verifier ++ // does not accept A's challenge issuance. ++ id: "x402-exact-cross-server-portability", ++ intent: "x402-exact", ++ kind: "cross-server-portability", ++ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ price: "0.001", ++ amount: "1000", ++ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ resourcePath: "/protected", ++ settlementHeader: "x-fixture-settlement", ++ expectedStatus: 402, ++ expectedCode: "challenge_verification_failed", ++ clientIds: ["ts-x402"], ++ serverIds: ["ts-x402", "rust-x402"], ++ // Cross-server portability requires the client adapter to expose the ++ // credential it sent so the runner can replay it. The TS reference ++ // client echoes `payment-signature-sent`; the Rust spine adapter does ++ // not (and is preserved as the canonical settlement-signing path ++ // rather than a credential-capturing one). Pairs that use the TS ++ // client cover the asymmetric direction too: TS pays server A, then ++ // replays the captured credential against server B. ++ crossServerPairs: [["ts-x402", "rust-x402"]], ++ }, ++ { ++ // Same-server idempotent resubmit. Client pays server A, then ++ // re-submits the same payment header. Server must reject with ++ // `signature_consumed`. ++ id: "x402-exact-idempotent-resubmit", ++ intent: "x402-exact", ++ kind: "idempotent-resubmit", ++ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ++ price: "0.001", ++ amount: "1000", ++ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", ++ resourcePath: "/protected", ++ settlementHeader: "x-fixture-settlement", ++ expectedStatus: 402, ++ expectedCode: "signature_consumed", ++ // Driven by the TS client (the only one that echoes the sent ++ // credential back to the harness). The first paid request must ++ // reach 200, which constrains us to the TS reference server in ++ // the default matrix because that server is what speaks the TS ++ // client's stub payload. Rust server coverage of `signature_consumed` ++ // lives in the Rust crate's own integration tests. ++ clientIds: ["ts-x402"], ++ serverIds: ["ts-x402"], ++ }, ++] as const; +diff --git a/tests/interop/src/process.ts b/harness/src/process.ts +similarity index 100% +rename from tests/interop/src/process.ts +rename to harness/src/process.ts +diff --git a/tests/interop/start-surfnet-proxy.mjs b/harness/start-surfnet-proxy.mjs +similarity index 100% +rename from tests/interop/start-surfnet-proxy.mjs +rename to harness/start-surfnet-proxy.mjs +diff --git a/tests/interop/swift-client/.gitignore b/harness/swift-client/.gitignore +similarity index 100% +rename from tests/interop/swift-client/.gitignore +rename to harness/swift-client/.gitignore +diff --git a/tests/interop/swift-client/Package.swift b/harness/swift-client/Package.swift +similarity index 90% +rename from tests/interop/swift-client/Package.swift +rename to harness/swift-client/Package.swift +index 777c1bd..553eee7 100644 +--- a/tests/interop/swift-client/Package.swift ++++ b/harness/swift-client/Package.swift +@@ -8,7 +8,7 @@ let package = Package( + .macOS(.v13), + ], + dependencies: [ +- .package(path: "../../../swift"), ++ .package(path: "../../swift"), + ], + targets: [ + .executableTarget( +diff --git a/tests/interop/swift-client/Sources/SwiftInteropClient/main.swift b/harness/swift-client/Sources/SwiftInteropClient/main.swift +similarity index 100% +rename from tests/interop/swift-client/Sources/SwiftInteropClient/main.swift +rename to harness/swift-client/Sources/SwiftInteropClient/main.swift +diff --git a/tests/interop/test/canonical-json.test.ts b/harness/test/canonical-json.test.ts +similarity index 100% +rename from tests/interop/test/canonical-json.test.ts +rename to harness/test/canonical-json.test.ts +diff --git a/tests/interop/test/compute-budget-caps.test.ts b/harness/test/compute-budget-caps.test.ts +similarity index 99% +rename from tests/interop/test/compute-budget-caps.test.ts +rename to harness/test/compute-budget-caps.test.ts +index bd870d1..2055994 100644 +--- a/tests/interop/test/compute-budget-caps.test.ts ++++ b/harness/test/compute-budget-caps.test.ts +@@ -24,7 +24,7 @@ import { describe, expect, it } from "vitest"; + * Issue: #109. + */ + +-const REPO_ROOT = resolve(__dirname, "..", "..", ".."); ++const REPO_ROOT = resolve(__dirname, "..", ".."); + + const CANONICAL_LIMIT = 200_000; + const CANONICAL_PRICE_MICROLAMPORTS = 5_000_000; +diff --git a/harness/test/cross-server-scenarios.test.ts b/harness/test/cross-server-scenarios.test.ts +new file mode 100644 +index 0000000..4dad528 +--- /dev/null ++++ b/harness/test/cross-server-scenarios.test.ts +@@ -0,0 +1,210 @@ ++// Cross-server portability + idempotent-resubmit scenarios for the x402 ++// `exact` intent. Mirrors MPP §19.6: ++// ++// - Cross-server portability: the client pays server A and re-submits the ++// same payment-signature header to server B. B must reject with the ++// canonical `challenge_verification_failed` code because B's verifier ++// does not accept A's challenge. ++// ++// - Idempotent resubmit: the client pays server A, then re-submits the ++// same payment-signature header to server A. A must reject with ++// `signature_consumed`. ++// ++// Gated behind `X402_INTEROP_CROSS_SERVER=1` because the matrix needs ++// two long-lived servers and live RPC credentials, neither of which the ++// default `pnpm test` run wires up. ++ ++import { afterAll, describe, expect, it } from "vitest"; ++import { interopScenarios } from "../src/contracts"; ++import { classifyMessageToCanonicalCode } from "../src/canonical-codes"; ++import { ++ clientImplementations, ++ serverImplementations, ++} from "../src/implementations"; ++import { runClient, startServer, stopServer } from "../src/process"; ++ ++const CROSS_SERVER_ENABLED = process.env.X402_INTEROP_CROSS_SERVER === "1"; ++ ++const requiredEnvs = [ ++ "X402_INTEROP_RPC_URL", ++ "X402_INTEROP_MINT", ++ "X402_INTEROP_PAY_TO", ++ "X402_INTEROP_CLIENT_SECRET_KEY", ++ "X402_INTEROP_FACILITATOR_SECRET_KEY", ++]; ++ ++function missingEnvs(): string[] { ++ return requiredEnvs.filter( ++ name => !process.env[name] || process.env[name]?.trim() === "", ++ ); ++} ++ ++const portabilityScenario = interopScenarios.find( ++ scenario => scenario.id === "x402-exact-cross-server-portability", ++); ++const resubmitScenario = interopScenarios.find( ++ scenario => scenario.id === "x402-exact-idempotent-resubmit", ++); ++ ++const serversById = new Map(serverImplementations.map(s => [s.id, s])); ++const clientsById = new Map(clientImplementations.map(c => [c.id, c])); ++ ++type RunningServer = Awaited>; ++const runningServers: RunningServer[] = []; ++ ++afterAll(async () => { ++ for (const server of runningServers.splice(0)) { ++ await stopServer(server); ++ } ++}); ++ ++function extractCanonicalCode(body: unknown): string | undefined { ++ if (body && typeof body === "object" && !Array.isArray(body)) { ++ const record = body as Record; ++ if (typeof record.code === "string") return record.code; ++ const source = ++ (typeof record.error === "string" && record.error) || ++ (typeof record.message === "string" && record.message) || ++ undefined; ++ if (source) return classifyMessageToCanonicalCode(source); ++ } ++ if (typeof body === "string") { ++ return classifyMessageToCanonicalCode(body); ++ } ++ return undefined; ++} ++ ++describe("x402 exact — cross-server portability + idempotent resubmit", () => { ++ if (!CROSS_SERVER_ENABLED) { ++ it.skip("cross-server suite is gated behind X402_INTEROP_CROSS_SERVER=1", () => {}); ++ return; ++ } ++ ++ const missing = missingEnvs(); ++ if (missing.length > 0) { ++ it.skip(`missing required env vars: ${missing.join(", ")}`, () => {}); ++ return; ++ } ++ ++ if (portabilityScenario && portabilityScenario.crossServerPairs) { ++ for (const [serverAId, serverBId] of portabilityScenario.crossServerPairs) { ++ const serverA = serversById.get(serverAId); ++ const serverB = serversById.get(serverBId); ++ // Use the TS reference client to drive the pay-then-replay flow ++ // because it echoes the sent credential under `payment-signature-sent`. ++ // The Rust spine client does not surface the captured credential to ++ // the harness; its portability coverage is exercised by the Rust ++ // crate's own integration tests. ++ const client = clientsById.get("ts-x402"); ++ if (!serverA?.enabled || !serverB?.enabled || !client?.enabled) { ++ it.skip(`portability ${serverAId} -> ${serverBId}: adapter not enabled`, () => {}); ++ continue; ++ } ++ ++ it(`portability: pay ${serverAId} then resubmit credential to ${serverBId}`, async () => { ++ const env = { ++ X402_INTEROP_NETWORK: portabilityScenario.network, ++ X402_INTEROP_PRICE: portabilityScenario.price, ++ X402_INTEROP_RESOURCE_PATH: portabilityScenario.resourcePath, ++ X402_INTEROP_SETTLEMENT_HEADER: portabilityScenario.settlementHeader, ++ }; ++ ++ const runningA = await startServer(serverA, env); ++ runningServers.push(runningA); ++ const runningB = await startServer(serverB, env); ++ runningServers.push(runningB); ++ ++ try { ++ const urlA = `http://127.0.0.1:${runningA.ready.port}${portabilityScenario.resourcePath}`; ++ const payA = await runClient(client, urlA, { ++ X402_INTEROP_TARGET_URL: urlA, ++ ...env, ++ }); ++ expect(payA.status).toBe(200); ++ ++ // Re-submit the captured payment-signature header to server B. ++ // Adapters echo the credential they sent under `*-sent` so the ++ // harness can replay it. Falls back to the live payment-signature ++ // header for adapters that don't echo (rust spine). ++ const headers = payA.responseHeaders as Record; ++ const credential = ++ headers["payment-signature-sent"] ?? headers["payment-signature"]; ++ const urlB = `http://127.0.0.1:${runningB.ready.port}${portabilityScenario.resourcePath}`; ++ const replay = await fetch(urlB, { ++ headers: credential ++ ? { "payment-signature": String(credential) } ++ : {}, ++ }); ++ const body = await replay.json().catch(() => null); ++ ++ expect(replay.status).toBe(portabilityScenario.expectedStatus); ++ if (portabilityScenario.expectedCode) { ++ expect(extractCanonicalCode(body)).toBe(portabilityScenario.expectedCode); ++ } ++ } finally { ++ await stopServer(runningA); ++ await stopServer(runningB); ++ runningServers.splice(runningServers.indexOf(runningA), 1); ++ runningServers.splice(runningServers.indexOf(runningB), 1); ++ } ++ }, 180_000); ++ } ++ } else { ++ it.skip("portability scenario missing crossServerPairs", () => {}); ++ } ++ ++ if (resubmitScenario) { ++ const serverIds = resubmitScenario.serverIds ?? ["ts-x402"]; ++ for (const sid of serverIds) { ++ const server = serversById.get(sid); ++ // Same rationale as portability above: drive with the TS client so ++ // the harness can replay the captured credential. ++ const client = clientsById.get("ts-x402"); ++ if (!server?.enabled || !client?.enabled) { ++ it.skip(`idempotent-resubmit on ${sid}: adapter not enabled`, () => {}); ++ continue; ++ } ++ ++ it(`idempotent resubmit against ${sid}`, async () => { ++ const env = { ++ X402_INTEROP_NETWORK: resubmitScenario.network, ++ X402_INTEROP_PRICE: resubmitScenario.price, ++ X402_INTEROP_RESOURCE_PATH: resubmitScenario.resourcePath, ++ X402_INTEROP_SETTLEMENT_HEADER: resubmitScenario.settlementHeader, ++ }; ++ ++ const running = await startServer(server, env); ++ runningServers.push(running); ++ ++ try { ++ const url = `http://127.0.0.1:${running.ready.port}${resubmitScenario.resourcePath}`; ++ const first = await runClient(client, url, { ++ X402_INTEROP_TARGET_URL: url, ++ ...env, ++ }); ++ expect(first.status).toBe(200); ++ ++ const headers = first.responseHeaders as Record; ++ const credential = ++ headers["payment-signature-sent"] ?? headers["payment-signature"]; ++ const replay = await fetch(url, { ++ headers: credential ++ ? { "payment-signature": String(credential) } ++ : {}, ++ }); ++ const body = await replay.json().catch(() => null); ++ ++ expect(replay.status).toBe(resubmitScenario.expectedStatus); ++ if (resubmitScenario.expectedCode) { ++ expect(extractCanonicalCode(body)).toBe(resubmitScenario.expectedCode); ++ } ++ } finally { ++ await stopServer(running); ++ runningServers.splice(runningServers.indexOf(running), 1); ++ } ++ }, 180_000); ++ } ++ } else { ++ it.skip("idempotent-resubmit scenario missing", () => {}); ++ } ++}); +diff --git a/tests/interop/test/e2e.test.ts b/harness/test/e2e.test.ts +similarity index 97% +rename from tests/interop/test/e2e.test.ts +rename to harness/test/e2e.test.ts +index e9e7e53..706f4bc 100644 +--- a/tests/interop/test/e2e.test.ts ++++ b/harness/test/e2e.test.ts +@@ -174,7 +174,7 @@ beforeAll(async () => { + // surfpool's RPC stops responding to subsequent simulate/broadcast + // calls, which surfaces as a 120s adapter-output timeout on + // charge-idempotent-resubmit (the matrix's tail scenario). The +- // 1s cadence matches tests/interop/start-surfnet-proxy.mjs, which ++ // 1s cadence matches harness/start-surfnet-proxy.mjs, which + // already does this for the proxy-mode launcher. See Ludo-7 / PR #102. + surfnetDrainTimer = setInterval(() => { + surfnet?.drainEvents(); +@@ -320,13 +320,23 @@ describe("mpp interop", () => { + ) { + continue; + } ++ // The x402-exact intent has its own runner in ++ // `test/x402-exact.e2e.test.ts` that emits `X402_INTEROP_*` env vars. ++ // The legacy MPP runner builds `MPP_INTEROP_*` env, which the x402 ++ // adapters do not consume, so we hard-skip the new intent here even ++ // when MPP_INTEROP_INTENTS explicitly selects it. ++ if (scenario.intent === "x402-exact") { ++ continue; ++ } + const scenarioServers = activeServers.filter( + (implementation) => +- !scenario.serverIds || scenario.serverIds.includes(implementation.id), ++ (!implementation.intents || implementation.intents.includes(scenario.intent)) && ++ (!scenario.serverIds || scenario.serverIds.includes(implementation.id)), + ); + const scenarioClients = activeClients.filter( + (implementation) => +- !scenario.clientIds || scenario.clientIds.includes(implementation.id), ++ (!implementation.intents || implementation.intents.includes(scenario.intent)) && ++ (!scenario.clientIds || scenario.clientIds.includes(implementation.id)), + ); + + for (const serverImplementation of scenarioServers) { +diff --git a/tests/interop/test/intent-selection.test.ts b/harness/test/intent-selection.test.ts +similarity index 62% +rename from tests/interop/test/intent-selection.test.ts +rename to harness/test/intent-selection.test.ts +index 6e86602..1dcef68 100644 +--- a/tests/interop/test/intent-selection.test.ts ++++ b/harness/test/intent-selection.test.ts +@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; + import { selectInteropIntents, selectInteropScenarios } from "../src/contracts"; + + describe("interop intent selection", () => { +- it("defaults to the implemented charge scenario", () => { ++ it("defaults to the legacy charge intent for CI stability", () => { ++ // x402-exact is opt-in via MPP_INTEROP_INTENTS=x402-exact (or ++ // comma-list) so the canonical MPP charge matrix in the existing ++ // runner is not perturbed by the new intent's enabled-by-default ++ // adapters. + expect(selectInteropIntents(undefined)).toEqual(["charge"]); + }); + +@@ -10,6 +14,17 @@ describe("interop intent selection", () => { + expect(selectInteropIntents(" charge ")).toEqual(["charge"]); + }); + ++ it("accepts the implemented x402-exact intent", () => { ++ expect(selectInteropIntents("x402-exact")).toEqual(["x402-exact"]); ++ }); ++ ++ it("accepts both intents at once", () => { ++ expect(selectInteropIntents("charge,x402-exact")).toEqual([ ++ "charge", ++ "x402-exact", ++ ]); ++ }); ++ + it("rejects scenarios that are not implemented yet", () => { + expect(() => selectInteropIntents("session")).toThrow( + /Unsupported MPP_INTEROP_INTENTS/, +@@ -42,6 +57,20 @@ describe("interop scenario selection", () => { + ]); + }); + ++ it("returns x402-exact scenarios when explicitly requested", () => { ++ expect( ++ selectInteropScenarios("x402-exact", undefined).map( ++ (scenario) => scenario.id, ++ ), ++ ).toEqual([ ++ "x402-exact-basic", ++ "x402-exact-network-mismatch", ++ "x402-exact-cross-route-replay", ++ "x402-exact-cross-server-portability", ++ "x402-exact-idempotent-resubmit", ++ ]); ++ }); ++ + it("runs one requested scenario", () => { + expect( + selectInteropScenarios("charge", "charge-split-ata").map( +diff --git a/tests/interop/test/process.test.ts b/harness/test/process.test.ts +similarity index 100% +rename from tests/interop/test/process.test.ts +rename to harness/test/process.test.ts +diff --git a/harness/test/x402-exact.e2e.test.ts b/harness/test/x402-exact.e2e.test.ts +new file mode 100644 +index 0000000..03aeb26 +--- /dev/null ++++ b/harness/test/x402-exact.e2e.test.ts +@@ -0,0 +1,128 @@ ++// Cross-language matrix for the x402 `exact` intent. Iterates every ++// active x402 client × every active x402 server registered in ++// `src/implementations.ts` and asserts the happy-path scenario reaches ++// HTTP 200 with the fixture settlement header populated. ++// ++// Gated behind `X402_INTEROP_MATRIX=1` so the default `pnpm test` run ++// in pay-kit does not require cargo or a live Surfpool RPC. The ++// canonical CI invocation is: ++// ++// X402_INTEROP_MATRIX=1 \ ++// X402_INTEROP_RPC_URL=... \ ++// X402_INTEROP_PAY_TO=... \ ++// X402_INTEROP_CLIENT_SECRET_KEY=[...] \ ++// X402_INTEROP_FACILITATOR_SECRET_KEY=[...] \ ++// pnpm test x402-exact.e2e.test.ts ++ ++import { afterAll, describe, expect, it } from "vitest"; ++import { interopScenarios } from "../src/contracts"; ++import { ++ clientImplementations, ++ serverImplementations, ++} from "../src/implementations"; ++import { runClient, startServer, stopServer } from "../src/process"; ++ ++const MATRIX_ENABLED = process.env.X402_INTEROP_MATRIX === "1"; ++ ++const requiredEnvs = [ ++ "X402_INTEROP_RPC_URL", ++ "X402_INTEROP_MINT", ++ "X402_INTEROP_PAY_TO", ++ "X402_INTEROP_CLIENT_SECRET_KEY", ++ "X402_INTEROP_FACILITATOR_SECRET_KEY", ++]; ++ ++function missingEnvs(): string[] { ++ return requiredEnvs.filter( ++ name => !process.env[name] || process.env[name]?.trim() === "", ++ ); ++} ++ ++const happyPath = interopScenarios.find( ++ scenario => scenario.id === "x402-exact-basic", ++); ++ ++const x402Clients = clientImplementations.filter( ++ impl => impl.enabled && (impl.intents ?? ["charge"]).includes("x402-exact"), ++); ++const x402Servers = serverImplementations.filter( ++ impl => impl.enabled && (impl.intents ?? ["charge"]).includes("x402-exact"), ++); ++ ++type RunningServer = Awaited>; ++const runningServers: RunningServer[] = []; ++ ++afterAll(async () => { ++ for (const server of runningServers.splice(0)) { ++ await stopServer(server); ++ } ++}); ++ ++describe("x402 exact intent — cross-language matrix", () => { ++ if (!MATRIX_ENABLED) { ++ it.skip("matrix is gated behind X402_INTEROP_MATRIX=1", () => {}); ++ return; ++ } ++ ++ const missing = missingEnvs(); ++ if (missing.length > 0) { ++ it.skip(`missing required env vars: ${missing.join(", ")}`, () => {}); ++ return; ++ } ++ ++ if (!happyPath) { ++ it.fails("happy-path scenario x402-exact-basic missing from registry", () => { ++ throw new Error("x402-exact-basic scenario not found in interopScenarios"); ++ }); ++ return; ++ } ++ ++ // Pair restriction: the TS reference adapters speak a stub payload ++ // (no real signed Solana transaction in the fixture) so they only ++ // interoperate with each other. The Rust spine adapters carry the ++ // canonical PaymentProof and are exercised end-to-end by the rust ++ // crate's own integration tests (`cargo test -p solana-x402`). ++ // The cross-language matrix asserts the harness wiring and the ++ // ready/result protocol; full TS<->Rust on-chain settlement parity ++ // arrives with the TS SDK port (tracked separately). ++ const allowedPair = (clientId: string, serverId: string): boolean => { ++ if (clientId === "ts-x402" && serverId === "ts-x402") return true; ++ if (clientId === "rust-x402" && serverId === "rust-x402") return true; ++ return false; ++ }; ++ ++ for (const server of x402Servers) { ++ for (const client of x402Clients) { ++ if (!allowedPair(client.id, server.id)) { ++ it.skip(`${client.id} client ↔ ${server.id} server: pair not in default matrix`, () => {}); ++ continue; ++ } ++ it(`${client.id} client ↔ ${server.id} server: happy path`, async () => { ++ const env = { ++ X402_INTEROP_NETWORK: happyPath.network, ++ X402_INTEROP_PRICE: happyPath.price, ++ X402_INTEROP_RESOURCE_PATH: happyPath.resourcePath, ++ X402_INTEROP_SETTLEMENT_HEADER: happyPath.settlementHeader, ++ } satisfies Record; ++ ++ const running = await startServer(server, env); ++ runningServers.push(running); ++ ++ try { ++ const targetUrl = `http://127.0.0.1:${running.ready.port}${happyPath.resourcePath}`; ++ const result = await runClient(client, targetUrl, { ++ X402_INTEROP_TARGET_URL: targetUrl, ++ ...env, ++ }); ++ ++ expect(result.status).toBe(happyPath.expectedStatus); ++ expect(result.ok).toBe(true); ++ expect(result.settlement).toBeTruthy(); ++ } finally { ++ await stopServer(running); ++ runningServers.splice(runningServers.indexOf(running), 1); ++ } ++ }, 120_000); ++ } ++ } ++}); +diff --git a/tests/interop/ts-client/main.ts b/harness/ts-client/main.ts +similarity index 100% +rename from tests/interop/ts-client/main.ts +rename to harness/ts-client/main.ts +diff --git a/tests/interop/ts-client/package-lock.json b/harness/ts-client/package-lock.json +similarity index 100% +rename from tests/interop/ts-client/package-lock.json +rename to harness/ts-client/package-lock.json +diff --git a/tests/interop/ts-client/package.json b/harness/ts-client/package.json +similarity index 100% +rename from tests/interop/ts-client/package.json +rename to harness/ts-client/package.json +diff --git a/tests/interop/tsconfig.json b/harness/tsconfig.json +similarity index 100% +rename from tests/interop/tsconfig.json +rename to harness/tsconfig.json +diff --git a/tests/interop/vitest.config.ts b/harness/vitest.config.ts +similarity index 100% +rename from tests/interop/vitest.config.ts +rename to harness/vitest.config.ts +diff --git a/lua/README.md b/lua/README.md +index 3ff9a89..d604f59 100644 +--- a/lua/README.md ++++ b/lua/README.md +@@ -214,7 +214,7 @@ same structural transaction verifier as pull mode, consumes the + signature through replay storage, and emits the same receipt shape. + + The direct Lua interop server at +-[`tests/interop/lua-server/server.lua`](../tests/interop/lua-server/server.lua) ++[`harness/lua-server/server.lua`](../harness/lua-server/server.lua) + exercises this end-to-end through Surfpool in CI. + + ## Examples +@@ -316,11 +316,11 @@ replay rejection, transaction failures, missing metadata, timeouts. + ## Interop + + The Lua interop server at +-[`tests/interop/lua-server/server.lua`](../tests/interop/lua-server/server.lua) ++[`harness/lua-server/server.lua`](../harness/lua-server/server.lua) + participates in the cross-language harness. Focused commands: + + ```bash +-cd tests/interop ++cd harness + MPP_INTEROP_CLIENTS=typescript MPP_INTEROP_SERVERS=lua pnpm exec vitest run test/e2e.test.ts + MPP_INTEROP_CLIENTS=rust MPP_INTEROP_SERVERS=lua pnpm exec vitest run test/e2e.test.ts + ``` +@@ -328,7 +328,7 @@ MPP_INTEROP_CLIENTS=rust MPP_INTEROP_SERVERS=lua pnpm exec vitest run test + For a local DX run that mirrors the harness's Surfpool fixture: + + ```bash +-cd tests/interop && node lua-server/dx-gate.mjs # one terminal ++cd harness && node lua-server/dx-gate.mjs # one terminal + cd lua && # second terminal + eval "$(luarocks --lua-version=5.1 --tree lua_modules path)" + luajit examples/simple-server.lua +diff --git a/lua/mpp/protocol/core/error_codes.lua b/lua/mpp/protocol/core/error_codes.lua +index 745f0a9..6f68e5b 100644 +--- a/lua/mpp/protocol/core/error_codes.lua ++++ b/lua/mpp/protocol/core/error_codes.lua +@@ -1,7 +1,7 @@ + --[[ + Canonical structured error codes for the Lua MPP server. + +-Mirrors `python/src/solana_mpp/_errors.py` (M1 closure / L6 audit row). ++Mirrors `python/src/solana_mpp/_errors.py`. + Every server-side rejection raises through `raise(code, message)` + which throws an `error({code = code, message = message})` table the + HTTP boundary then translates into a JSON 402 body carrying `code`, +diff --git a/notes/codex-review-go-x402-r4.md b/notes/codex-review-go-x402-r4.md +new file mode 100644 +index 0000000..57ca5bb +--- /dev/null ++++ b/notes/codex-review-go-x402-r4.md +@@ -0,0 +1,36 @@ ++# Codex Round 4 — Go x402 exact port ++ ++Carried from `solana-foundation/x402-sdk` PR #18, tip `e3bf746`. ++ ++## Summary ++ ++- **Real P1 findings**: 0 ++- **Confidence**: 4/5 ++- **Coverage**: server 90.9%, client 91.9% (`go test ./... -cover -race`) ++- **Lint**: `gofmt -l` clean, `go vet ./...` clean ++ ++## Source provenance ++ ++The Go binaries under `go/x402/cmd/interop-{client,server}/` are a mechanical ++re-org of the upstream x402-sdk PR #18 final state. No upstream behavior ++changes; only the module path was rewritten when copying into mpp-sdk's ++single-module `go/` tree. ++ ++## Regression coverage carried over ++ ++- Fee-payer attack regression suite (5 attack shapes + positive control) ++- Multi-mint `extra.offered` support ++- Lighthouse instruction passthrough (spine-parity per ++ `notes/lighthouse-allowlist-tracking.md` in x402-sdk) ++- `extra.tokenProgram` mint allowlist enforcement ++- Token alias → base58 resolve at env boundary ++- Cross-envelope preference fallback ++- Idempotent resubmit / replay protection via Solana per-signature native ++ + scheme-namespaced cache ++ ++## Interop matrix evidence ++ ++90/90 pass on the seven-language sweep in x402-sdk PR #18. ++ ++MPP §19.6 cross-server scenarios: portability + idempotent-resubmit clean — ++the Go server rejects cross-server credentials with the canonical token. +diff --git a/php/.php-cs-fixer.dist.php b/php/.php-cs-fixer.dist.php +index fe48bd1..d054ba3 100644 +--- a/php/.php-cs-fixer.dist.php ++++ b/php/.php-cs-fixer.dist.php +@@ -18,7 +18,7 @@ return (new Config()) + __DIR__ . '/src', + __DIR__ . '/tests', + __DIR__ . '/examples', +- __DIR__ . '/../tests/interop/php-server', ++ __DIR__ . '/../harness/php-server', + ]) + ->exclude(['laravel']) + ->ignoreVCS(true) +diff --git a/php/README.md b/php/README.md +index b02276e..6abd3bd 100644 +--- a/php/README.md ++++ b/php/README.md +@@ -129,7 +129,7 @@ transactions on non-localnet networks, fee-payer co-sign (when configured), + broadcast via `sendTransaction`, poll `getSignatureStatuses` to + `confirmed`/`finalized`, and emit `payment-receipt` with the on-chain + signature. The pure-PHP interop server at +-[`tests/interop/php-server/server.php`](../tests/interop/php-server/server.php) ++[`harness/php-server/server.php`](../harness/php-server/server.php) + exercises this end-to-end through Surfpool in CI for both TypeScript and Rust + clients. + +diff --git a/php/composer.json b/php/composer.json +index 548bbfe..7ae912c 100644 +--- a/php/composer.json ++++ b/php/composer.json +@@ -30,8 +30,8 @@ + }, + "scripts": { + "format:check": "php-cs-fixer fix --dry-run --diff --using-cache=no --sequential", +- "lint:syntax": "find src tests examples ../tests/interop/php-server \\( -path examples/laravel -prune \\) -o -name '*.php' -print0 | xargs -0 -n1 php -l", +- "lint:static": "phpstan analyse --level=max --debug --memory-limit=1G src tests examples/simple-server ../tests/interop/php-server", ++ "lint:syntax": "find src tests examples ../harness/php-server \\( -path examples/laravel -prune \\) -o -name '*.php' -print0 | xargs -0 -n1 php -l", ++ "lint:static": "phpstan analyse --level=max --debug --memory-limit=1G src tests examples/simple-server ../harness/php-server", + "lint": [ + "@lint:syntax", + "@format:check", +diff --git a/python/README.md b/python/README.md +index 5503f2f..589f79f 100644 +--- a/python/README.md ++++ b/python/README.md +@@ -188,7 +188,7 @@ signature in replay storage only after the on-chain shape is known to + be correct, and emits the same receipt shape. + + The direct Python interop server at +-[`tests/interop/python-server/main.py`](../tests/interop/python-server/main.py) ++[`harness/python-server/main.py`](../harness/python-server/main.py) + exercises this end to end through Surfpool in CI for both TypeScript + and Rust clients. + +@@ -270,7 +270,7 @@ percent, `_types` 99 percent, `_headers` 89 percent. + ## Interop + + The Python server has a direct harness adapter at +-[`tests/interop/python-server/main.py`](../tests/interop/python-server/main.py) ++[`harness/python-server/main.py`](../harness/python-server/main.py) + mirroring the Ruby and PHP adapters. It is server-side only in this + pass (no client adapter; the Python client ships as a library and is + exercised through unit tests in `python/tests/test_client_charge.py`). +@@ -278,7 +278,7 @@ exercised through unit tests in `python/tests/test_client_charge.py`). + Focused harness commands: + + ```bash +-cd tests/interop ++cd harness + MPP_INTEROP_CLIENTS=typescript MPP_INTEROP_SERVERS=python pnpm test + MPP_INTEROP_CLIENTS=rust MPP_INTEROP_SERVERS=python pnpm test + ``` +diff --git a/python/pyproject.toml b/python/pyproject.toml +index a6c46b1..8b07a8a 100644 +--- a/python/pyproject.toml ++++ b/python/pyproject.toml +@@ -44,8 +44,8 @@ testpaths = ["tests"] + + [tool.coverage.run] + source = ["solana_mpp"] +-# Line coverage is the M1 baseline gate (90%). Branch coverage is M2 work +-# tracked in issue #108. ++# Line coverage gate is 90%. Branch coverage is follow-up work tracked in ++# issue #108. + branch = false + + [tool.coverage.report] +diff --git a/python/tests/test_interop_adapter.py b/python/tests/test_interop_adapter.py +index 9036e05..c7889ae 100644 +--- a/python/tests/test_interop_adapter.py ++++ b/python/tests/test_interop_adapter.py +@@ -1,5 +1,5 @@ + """Regression tests for the Python interop adapter at +-``tests/interop/python-server/main.py``. ++``harness/python-server/main.py``. + + Spawns the adapter as a subprocess, reads the ``ready`` handshake JSON + from stdout, hits the protected resource without credentials, and +diff --git a/ruby/README.md b/ruby/README.md +index 46068c5..ed52a64 100644 +--- a/ruby/README.md ++++ b/ruby/README.md +@@ -185,7 +185,7 @@ transaction verifier as pull mode, consumes the signature through replay + storage, and emits the same receipt shape. + + The direct Ruby interop server at +-[`tests/interop/ruby-server/server.rb`](../tests/interop/ruby-server/server.rb) ++[`harness/ruby-server/server.rb`](../harness/ruby-server/server.rb) + exercises this end-to-end through Surfpool in CI for both TypeScript and Rust + clients. + +@@ -261,12 +261,12 @@ and replay consumption. + ## Interop + + The Ruby server has a direct harness adapter at +-`tests/interop/ruby-server/server.rb`. It is server-side only in this pass. ++`harness/ruby-server/server.rb`. It is server-side only in this pass. + + Focused harness commands: + + ```bash +-cd tests/interop ++cd harness + MPP_INTEROP_CLIENTS=typescript MPP_INTEROP_SERVERS=ruby pnpm test + MPP_INTEROP_CLIENTS=rust MPP_INTEROP_SERVERS=ruby pnpm test + ``` +diff --git a/ruby/lib/mpp/error_codes.rb b/ruby/lib/mpp/error_codes.rb +index 691cb3a..c22a747 100644 +--- a/ruby/lib/mpp/error_codes.rb ++++ b/ruby/lib/mpp/error_codes.rb +@@ -89,7 +89,7 @@ module Mpp + # (verify_instruction_allowlist). The message originates as + # "Unexpected program instruction ..." in the verifier and must + # map to charge_request_mismatch to stay byte-identical with the +- # TS/Rust/Lua canonical classifiers (tests/interop/src/canonical-codes.ts ++ # TS/Rust/Lua canonical classifiers (harness/src/canonical-codes.ts + # and rust/src/bin/interop_server.rs::classify_canonical_code). + # Without this entry the rescue chain in verify_transaction_payload + # silently downgrades allowlist rejections to payment_invalid which +diff --git a/rust/README.md b/rust/README.md +index 35a75a3..3342d4f 100644 +--- a/rust/README.md ++++ b/rust/README.md +@@ -52,9 +52,9 @@ solana-pay-kit = { version = "0.1", default-features = false, features = ["mpp"] + ## Interop + + The TypeScript interop harness can run the Rust server and client adapters from +-`../tests/interop`. ++`../harness`. + + ```bash +-cd ../tests/interop ++cd ../harness + pnpm test + ``` +diff --git a/rust/crates/mpp/src/bin/interop_server.rs b/rust/crates/mpp/src/bin/interop_server.rs +index 34a5a74..387dd1e 100644 +--- a/rust/crates/mpp/src/bin/interop_server.rs ++++ b/rust/crates/mpp/src/bin/interop_server.rs +@@ -375,7 +375,7 @@ fn read_memory_signer( + } + + /// Classify a free-text error message into a canonical L6 structured +-/// error code. Mirrors tests/interop/src/canonical-codes.ts and the ++/// error code. Mirrors harness/src/canonical-codes.ts and the + /// Python / Ruby SDK helpers. The G39 fault matrix asserts cross-SDK + /// agreement on this code. + fn classify_canonical_code(message: &str) -> &'static str { +diff --git a/skills/pay-sdk-implementation/SKILL.md b/skills/pay-sdk-implementation/SKILL.md +index ea09814..375d581 100644 +--- a/skills/pay-sdk-implementation/SKILL.md ++++ b/skills/pay-sdk-implementation/SKILL.md +@@ -67,9 +67,9 @@ the directory skeleton and CI from earlier ones. + Rust file paths cited in the leaf to disambiguate anything that's + under-specified. + 6. **Add the interop adapter.** Read `references/interop-harness.md`, +- create `tests/interop/-client/` (and a `bin/interop_server` if ++ create `harness/-client/` (and a `bin/interop_server` if + you're shipping a server), and register it in +- `tests/interop/src/implementations.ts`. Run the focused matrix ++ `harness/src/implementations.ts`. Run the focused matrix + (`MPP_INTEROP_CLIENTS= MPP_INTEROP_SERVERS=rust pnpm test` and + the inverse) before flipping `enabled: true`. + 7. **Write the README last.** Read `references/readme-template.md` and +diff --git a/skills/pay-sdk-implementation/references/intents/mpp-charge-pull.md b/skills/pay-sdk-implementation/references/intents/mpp-charge-pull.md +index 9eacca8..1dbda8c 100644 +--- a/skills/pay-sdk-implementation/references/intents/mpp-charge-pull.md ++++ b/skills/pay-sdk-implementation/references/intents/mpp-charge-pull.md +@@ -201,5 +201,5 @@ Integration test: + splits with ATA creation, fee-payer mode. + + Interop scenario: `charge-basic` and `charge-split-ata` in +-`tests/interop/src/contracts.ts`. Both must pass against the Rust ++`harness/src/contracts.ts`. Both must pass against the Rust + server before the new SDK is enabled by default. +diff --git a/skills/pay-sdk-implementation/references/intents/mpp-charge-push.md b/skills/pay-sdk-implementation/references/intents/mpp-charge-push.md +index cf74684..046ce16 100644 +--- a/skills/pay-sdk-implementation/references/intents/mpp-charge-push.md ++++ b/skills/pay-sdk-implementation/references/intents/mpp-charge-push.md +@@ -128,7 +128,7 @@ Unit tests (mirror Rust's `verify_push`-adjacent tests): + Interop scenarios: scaffold a `charge-basic-push` variant. The current + default scenario (`charge-basic`) exercises pull because the TS server + is fee-payer; once the new SDK enables push for the client adapter, +-add an explicit push-mode variant to `tests/interop/src/contracts.ts`. ++add an explicit push-mode variant to `harness/src/contracts.ts`. + + E2E: the Playwright tests in `html/tests` exercise the push flow via a + browser wallet. The new-language server must run this suite (see +diff --git a/skills/pay-sdk-implementation/references/intents/mpp-session.md b/skills/pay-sdk-implementation/references/intents/mpp-session.md +index 9d20211..01372cc 100644 +--- a/skills/pay-sdk-implementation/references/intents/mpp-session.md ++++ b/skills/pay-sdk-implementation/references/intents/mpp-session.md +@@ -206,6 +206,6 @@ Integration: + Interop: + + - The harness does not have session scenarios shipped today. Add one +- to `tests/interop/src/contracts.ts` (intent `session`) before ++ to `harness/src/contracts.ts` (intent `session`) before + enabling the cell. Pattern after `charge-basic`; reuse the same + Surfpool fixtures. +diff --git a/skills/pay-sdk-implementation/references/intents/x402-exact.md b/skills/pay-sdk-implementation/references/intents/x402-exact.md +index 12170c8..df4fe64 100644 +--- a/skills/pay-sdk-implementation/references/intents/x402-exact.md ++++ b/skills/pay-sdk-implementation/references/intents/x402-exact.md +@@ -25,7 +25,7 @@ Wait for the user to confirm: + 2. The MPP `charge` cells are already passing interop in the new SDK + (x402 reuses much of the same Solana primitives — splits, fee + payer, replay store — so MPP-first is the correct order). +-3. The x402 scheme strings in `tests/interop/src/implementations.ts` ++3. The x402 scheme strings in `harness/src/implementations.ts` + have been agreed (likely `"x402:exact"` or similar; do not invent). + + If any are missing, leave the row at `—` in the README matrix and +diff --git a/skills/pay-sdk-implementation/references/interop-harness.md b/skills/pay-sdk-implementation/references/interop-harness.md +index ce68baa..e392dc9 100644 +--- a/skills/pay-sdk-implementation/references/interop-harness.md ++++ b/skills/pay-sdk-implementation/references/interop-harness.md +@@ -1,8 +1,8 @@ + # Interop harness adapter + + Cross-language compatibility is enforced by the TypeScript/Vitest harness +-at `mpp-sdk/tests/interop`. Read its README first +-(`tests/interop/README.md`) — that is the contract; this file summarizes ++at `mpp-sdk/harness`. Read its README first ++(`harness/README.md`) — that is the contract; this file summarizes + the bits that bite when adding a new language. + + ## What you must build +@@ -19,10 +19,10 @@ Reference adapters: + + - `rust/src/bin/interop_client.rs` (94 lines — copy it). + - `rust/src/bin/interop_server.rs` (317 lines — copy it). +-- `tests/interop/rust-client/` — Cargo manifest wrapper used by the ++- `harness/rust-client/` — Cargo manifest wrapper used by the + harness command. + +-## The contract (verbatim from `tests/interop/README.md`) ++## The contract (verbatim from `harness/README.md`) + + ### Server `ready` message + +@@ -33,7 +33,7 @@ Reference adapters: + Fields: + + - `type`: `"ready"` +-- `implementation`: stable id (matches `tests/interop/src/implementations.ts`) ++- `implementation`: stable id (matches `harness/src/implementations.ts`) + - `role`: `"server"` + - `port`: local TCP port the protected resource is served on + +@@ -105,7 +105,7 @@ base58 — the harness does not encode them in base58. + + ## Registering the adapter + +-Add an entry to `tests/interop/src/implementations.ts` — one each for ++Add an entry to `harness/src/implementations.ts` — one each for + client and server: + + ```ts +@@ -137,10 +137,10 @@ export const serverImplementations: ImplementationDefinition[] = [ + Default `enabled: false`. Only flip to `true` once the focused matrix + below passes locally. + +-Then drop an adapter wrapper in `tests/interop/-client/` with ++Then drop an adapter wrapper in `harness/-client/` with + whatever scaffold the language needs (e.g. a `Cargo.toml` that + path-depends on `../../`, or a `package.json` with a single +-`start` script). The harness command is relative to `tests/interop`. ++`start` script). The harness command is relative to `harness`. + + ## Focused matrix command + +diff --git a/skills/pay-sdk-implementation/references/readme-template.md b/skills/pay-sdk-implementation/references/readme-template.md +index 5ad0c64..a495f72 100644 +--- a/skills/pay-sdk-implementation/references/readme-template.md ++++ b/skills/pay-sdk-implementation/references/readme-template.md +@@ -147,7 +147,7 @@ same structural transaction verifier as pull mode, consumes the + signature through replay storage, and emits the same receipt shape. + + The direct `` interop server at +-[`tests/interop/-server/server.`](../tests/interop/-server/server.) ++[`harness/-server/server.`](../harness/-server/server.) + exercises this end-to-end through Surfpool in CI. + + ## Examples +@@ -210,7 +210,7 @@ State the harness adapter path and any focused harness commands the + language ships in this pass: + + `​``bash +-cd tests/interop ++cd harness + MPP_INTEROP_CLIENTS=typescript MPP_INTEROP_SERVERS= pnpm test + MPP_INTEROP_CLIENTS=rust MPP_INTEROP_SERVERS= pnpm test + `​`` +diff --git a/skills/pay-sdk-implementation/references/repo-layout.md b/skills/pay-sdk-implementation/references/repo-layout.md +index 52c3a86..70b82a2 100644 +--- a/skills/pay-sdk-implementation/references/repo-layout.md ++++ b/skills/pay-sdk-implementation/references/repo-layout.md +@@ -11,7 +11,7 @@ mpp-sdk/ + ├── python/ + ├── lua/ + ├── / ← what you are creating +-├── tests/interop/ ++├── harness/ + │ └── -client/ ← interop adapter (see interop-harness.md) + ├── .github/workflows/ci.yml ← add a job (see ci-quality-coverage.md) + └── justfile ← add recipes (see "justfile recipes" below) +diff --git a/swift/Examples/README.md b/swift/Examples/README.md +index 3b39ee1..16ca3eb 100644 +--- a/swift/Examples/README.md ++++ b/swift/Examples/README.md +@@ -6,6 +6,6 @@ Sample clients exercising the `SolanaMpp` package. + a 402-protected endpoint. Mirrors `rust/examples/payment_link_server.rs` + on the client side. + +-Planned (M2): `iOSDemo/` — SwiftUI app targeting the Solana Seeker dev ++Planned: `iOSDemo/` — SwiftUI app targeting the Solana Seeker dev + kit, end-to-end charge intent flow against `https://402.surfnet.dev`. + Tracked as a separate deliverable to keep the SDK PR focused. +diff --git a/swift/README.md b/swift/README.md +index 38ff3a2..8a47f4b 100644 +--- a/swift/README.md ++++ b/swift/README.md +@@ -39,7 +39,7 @@ swift/ + │ ├── Instructions.swift # System, SPL, ATA, compute budget, memo + │ └── Ata.swift # Associated Token Account PDA derivation + ├── Tests/SolanaMppTests/ # XCTest / swift-testing suite +-└── Examples/ # Sample clients (M2: Solana Seeker demo app) ++└── Examples/ # Sample clients (planned: Solana Seeker demo app) + ``` + + Mirrors the Rust layout (`rust/src/{client,protocol}/`) so cross-language +@@ -47,10 +47,10 @@ contributors can navigate by feature, not file name. + + ## Scope + +-Swift is **client-only** across every milestone in the MPP roadmap. +-This package ships the charge client; an MPP server in Swift is not +-in scope. The session and subscription intents add to this package +-in M2 and M3. ++Swift is **client-only** in the MPP SDK. This package ships the charge ++client; an MPP server in Swift is not in scope. The session and ++subscription intents will be added to this package as the protocol ++surface for those intents stabilizes. + + ## Quick start, client + +@@ -100,21 +100,21 @@ Then add `SolanaMpp` to your target dependencies. + + ## Client compatibility matrix + +-Swift is client-only across the MPP roadmap. ++Swift is client-only in the MPP SDK. + + | Intent | Status | + |---|:---:| +-| `x402/exact` | planned (M2) | ++| `x402/exact` | planned | + | `x402/upto` | --- | + | `x402/batch-settlement` | --- | + | `mpp/charge/pull` | available | + | `mpp/charge/push` | planned | +-| `mpp/session` | planned (M2) | +-| `mpp/subscription` | planned (M3) | ++| `mpp/session` | planned | ++| `mpp/subscription` | planned | + + ## Server compatibility matrix + +-Swift does not ship a server in any milestone. ++Swift does not ship a server. + + | Intent | Status | + |---|:---:| +@@ -185,8 +185,8 @@ them as the `swift-coverage` artifact. The harness covers: + ## Interop + + The Swift interop adapter lives at +-[`tests/interop/swift-client`](../tests/interop/swift-client) and is +-registered in `tests/interop/src/implementations.ts`. Default on after ++[`harness/swift-client`](../harness/swift-client) and is ++registered in `harness/src/implementations.ts`. Default on after + the focused TS-to-Swift matrix passes locally (this PR ships both the + default-off registration and the default-on flip atop the same diff, + per the roadmap's sequential-rebase rule on the +@@ -195,7 +195,7 @@ per the roadmap's sequential-rebase rule on the + Focused matrix commands: + + ```bash +-cd tests/interop ++cd harness + MPP_INTEROP_CLIENTS=swift MPP_INTEROP_SERVERS=typescript pnpm exec vitest run + MPP_INTEROP_CLIENTS=swift MPP_INTEROP_SERVERS=rust pnpm exec vitest run + ``` +diff --git a/typescript/README.md b/typescript/README.md +index 681f885..9b60685 100644 +--- a/typescript/README.md ++++ b/typescript/README.md +@@ -51,10 +51,10 @@ pay curl http://localhost:4567/paid + + ## Interop + +-The cross-language interop harness lives in `../tests/interop`. ++The cross-language interop harness lives in `../harness`. + + ```bash +-cd ../tests/interop ++cd ../harness + pnpm install + pnpm test + ``` + +2026-05-25T22:44:21.922787Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when AuthRequired(AuthRequiredError { www_authenticate_header: "Bearer resource_metadata=\"https://mcp.figma.com/.well-known/oauth-protected-resource\",scope=\"mcp:connect\",authorization_uri=\"https://api.figma.com/.well-known/oauth-authorization-server\"" }) +2026-05-25T22:44:22.081040Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when AuthRequired(AuthRequiredError { www_authenticate_header: "Bearer resource_metadata=\"https://api.aidesigner.ai/.well-known/oauth-protected-resource\", scope=\"aidesigner:design aidesigner:credits profile:read\", error=\"invalid_token\", error_description=\"Missing bearer token.\"" }) +2026-05-25T22:44:22.518666Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when Deserialize(Error("data did not match any variant of untagged enum JsonRpcMessage", line: 0, column: 0)) +2026-05-25T22:44:23.530135Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when Deserialize(Error("data did not match any variant of untagged enum JsonRpcMessage", line: 0, column: 0)) +hook: SessionStart +hook: SessionStart Completed +hook: UserPromptSubmit +hook: UserPromptSubmit Completed +codex +I’ll check the actual touched Go server path rather than rely only on the pasted diff, then I’ll rate the specific L8/replay-key claim. +exec +/bin/zsh -lc "git status --short && rg -n \"replayKeyNamespace|putIfAbsent|settleExactPayment|sendTransaction\\(|awaitSignatureConfirmation|release|claim\" go/x402/cmd/interop-server -S" in /private/tmp/pay-kit-124-l8 + succeeded in 0ms: +?? notes/codex-review/ +go/x402/cmd/interop-server/main_test.go:328: if _, err := settleExactPayment(state, header); err == nil { +go/x402/cmd/interop-server/main_test.go:351: if _, err := settleExactPayment(state, header); err == nil { +go/x402/cmd/interop-server/main_test.go:367: if _, err := settleExactPayment(state, versionHeader); err == nil || err.Error() != "unsupported x402Version: 1" { +go/x402/cmd/interop-server/main_test.go:378: if _, err := settleExactPayment(state, driftHeader); err == nil || err.Error() != "accepted payment requirement does not match server challenge" { +go/x402/cmd/interop-server/main_test.go:432: settlement, err := settleExactPayment(state, header) +go/x402/cmd/interop-server/main_test.go:486: if settlement, err := settleExactPayment(state, header); err != nil || settlement != "unit-settlement" { +go/x402/cmd/interop-server/main_test.go:489: if _, err := settleExactPayment(state, header); err == nil || err.Error() != "duplicate_settlement" { +go/x402/cmd/interop-server/main_test.go:497: // rejected at putIfAbsent because the signature was already +go/x402/cmd/interop-server/main_test.go:510:// release-on-failure path; correctness follows from "never inserted in +go/x402/cmd/interop-server/main_test.go:549: if _, err := settleExactPayment(state, header); err == nil || err.Error() != "source token account does not exist" { +go/x402/cmd/interop-server/main_test.go:552: if _, err := settleExactPayment(state, header); err == nil || err.Error() != "source token account does not exist" { +go/x402/cmd/interop-server/main_test.go:562:// activity ordered after putIfAbsent. +go/x402/cmd/interop-server/main_test.go:605: signature, err := settleExactPayment(state, header) +go/x402/cmd/interop-server/main_test.go:624: if _, ok := settlementCache.entries[replayKeyNamespace+signature]; !ok { +go/x402/cmd/interop-server/main_test.go:625: t.Fatalf("expected replay key %q to be present after confirmation", replayKeyNamespace+signature) +go/x402/cmd/interop-server/main_test.go:667: if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "sendTransaction RPC error") { +go/x402/cmd/interop-server/main_test.go:717: if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "transaction failed on-chain") { +go/x402/cmd/interop-server/main_test.go:720: if _, ok := settlementCache.entries[replayKeyNamespace+"reverted-sig"]; ok { +go/x402/cmd/interop-server/main_test.go:726:// the L8 invariant that a putIfAbsent collision (signature already +go/x402/cmd/interop-server/main_test.go:741: settlementCache.entries[replayKeyNamespace+"pre-consumed-sig"] = time.Now() +go/x402/cmd/interop-server/main_test.go:749: if _, err := settleExactPayment(state, header); err == nil || err.Error() != "duplicate_settlement" { +go/x402/cmd/interop-server/main_test.go:758:// effect is single; the putIfAbsent collision in the replay store +go/x402/cmd/interop-server/main_test.go:785: signatures[idx], results[idx] = settleExactPayment(state, header) +go/x402/cmd/interop-server/main_test.go:861: err := awaitSignatureConfirmation(state, "sig") +go/x402/cmd/interop-server/main_test.go:882: if err := awaitSignatureConfirmation(state, "sig"); err == nil || !strings.Contains(err.Error(), "getSignatureStatuses transport") { +go/x402/cmd/interop-server/main_test.go:901: if err := awaitSignatureConfirmation(state, "sig"); err == nil || !strings.Contains(err.Error(), "getSignatureStatuses HTTP 500") { +go/x402/cmd/interop-server/main_test.go:1488: if !cache.putIfAbsent("new") { +go/x402/cmd/interop-server/main_test.go:1497: if !cache.putIfAbsent("expired") { +go/x402/cmd/interop-server/main_test.go:1500: if cache.putIfAbsent("fresh") { +go/x402/cmd/interop-server/main_test.go:1652: got, err := sendTransaction(state, tx) +go/x402/cmd/interop-server/main_test.go:1679: if _, err := sendTransaction(state, tx); err == nil { +go/x402/cmd/interop-server/main_test.go:2109: if _, err := settleExactPayment(state, header); err == nil || +go/x402/cmd/interop-server/main_test.go:2143: if _, err := settleExactPayment(state, header); err == nil || +go/x402/cmd/interop-server/main_test.go:2503: settlement, err := settleExactPayment(state, header) +go/x402/cmd/interop-server/main.go:36: // replayKeyNamespace MUST match the scheme-namespaced canonical key +go/x402/cmd/interop-server/main.go:40: replayKeyNamespace = "x402-svm-exact:consumed:" +go/x402/cmd/interop-server/main.go:178:// putIfAbsent reserves `key` in the replay cache. Returns true if the key +go/x402/cmd/interop-server/main.go:183:// confirmation → `putIfAbsent(signature)`. There is no release-on-failure +go/x402/cmd/interop-server/main.go:187:// window. The release path of the prior claim-first design has been +go/x402/cmd/interop-server/main.go:188:// removed to close the partial-failure race where a release after a timed- +go/x402/cmd/interop-server/main.go:190:func (cache *duplicateSettlementCache) putIfAbsent(key string) bool { +go/x402/cmd/interop-server/main.go:498:func settleExactPayment(state serverState, headerValue string) (string, error) { +go/x402/cmd/interop-server/main.go:551: // await_pull_confirmation, consume_signature). No claim-first, no +go/x402/cmd/interop-server/main.go:552: // release-on-failure. See x402 PR-readiness reference §"L8 +go/x402/cmd/interop-server/main.go:554: signature, err := sendTransaction(state, transaction) +go/x402/cmd/interop-server/main.go:558: if err := awaitSignatureConfirmation(state, signature); err != nil { +go/x402/cmd/interop-server/main.go:561: if !settlementCache.putIfAbsent(replayKeyNamespace + signature) { +go/x402/cmd/interop-server/main.go:961:func sendTransaction(state serverState, transaction *solana.Transaction) (string, error) { +go/x402/cmd/interop-server/main.go:1013:// awaitSignatureConfirmation polls `getSignatureStatuses` until the +go/x402/cmd/interop-server/main.go:1021:func awaitSignatureConfirmation(state serverState, signature string) error { +go/x402/cmd/interop-server/main.go:1111: settlement, err := settleExactPayment(state, paymentSignature) + +codex +The repo has an unrelated untracked `notes/codex-review/` directory. I’m ignoring it and focusing only on the Go x402 server implementation and its tests. +exec +/bin/zsh -lc "nl -ba go/x402/cmd/interop-server/main.go | sed -n '30,65p;170,205p;520,570p;1010,1068p'" in /private/tmp/pay-kit-124-l8 +exec +/bin/zsh -lc "nl -ba go/x402/cmd/interop-server/main_test.go | sed -n '560,630p;640,725p;726,755p'" in /private/tmp/pay-kit-124-l8 + succeeded in 0ms: + 560 // insert. The replay store insert is observable through a duplicate retry + 561 // returning duplicate_settlement on the SAME signature, without any RPC + 562 // activity ordered after putIfAbsent. + 563 func TestSettleExactPaymentL8OrderingObserved(t *testing.T) { + 564 settlementCache = newDuplicateSettlementCache() + 565 defer func() { settlementCache = newDuplicateSettlementCache() }() + 566 client, err := solana.NewRandomPrivateKey() + 567 if err != nil { + 568 t.Fatal(err) + 569 } + 570 state := testServerState(t) + 571 state.memo = "l8-ordering" + 572 var rpcCalls []string + 573 state.httpClient = &http.Client{ + 574 Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + 575 rawBody, err := io.ReadAll(request.Body) + 576 if err != nil { + 577 t.Fatal(err) + 578 } + 579 body := string(rawBody) + 580 responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + 581 switch { + 582 case strings.Contains(body, `"method":"sendTransaction"`): + 583 rpcCalls = append(rpcCalls, "sendTransaction") + 584 responseBody = `{"jsonrpc":"2.0","id":1,"result":"l8-sig"}` + 585 case strings.Contains(body, `"method":"getSignatureStatuses"`): + 586 rpcCalls = append(rpcCalls, "getSignatureStatuses") + 587 responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` + 588 case strings.Contains(body, `"method":"getAccountInfo"`): + 589 rpcCalls = append(rpcCalls, "getAccountInfo") + 590 } + 591 return &http.Response{ + 592 StatusCode: http.StatusOK, + 593 Header: http.Header{"content-type": []string{"application/json"}}, + 594 Body: io.NopCloser(strings.NewReader(responseBody)), + 595 }, nil + 596 }), + 597 } + 598 requirement := exactRequirement(state) + 599 header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + 600 X402Version: 2, + 601 Accepted: requirement, + 602 Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, + 603 }) + 604 + 605 signature, err := settleExactPayment(state, header) + 606 if err != nil { + 607 t.Fatalf("expected first settlement to succeed, got %v", err) + 608 } + 609 if signature != "l8-sig" { + 610 t.Fatalf("signature = %q", signature) + 611 } + 612 // Drop pre-broadcast getAccountInfo calls; the load-bearing assertion + 613 // is that broadcast precedes confirmation polling, which precedes the + 614 // replay-store insert (proven by the subsequent duplicate_settlement). + 615 var phaseOrder []string + 616 for _, call := range rpcCalls { + 617 if call == "sendTransaction" || call == "getSignatureStatuses" { + 618 phaseOrder = append(phaseOrder, call) + 619 } + 620 } + 621 if len(phaseOrder) < 2 || phaseOrder[0] != "sendTransaction" || phaseOrder[1] != "getSignatureStatuses" { + 622 t.Fatalf("expected sendTransaction before getSignatureStatuses, got %v", phaseOrder) + 623 } + 624 if _, ok := settlementCache.entries[replayKeyNamespace+signature]; !ok { + 625 t.Fatalf("expected replay key %q to be present after confirmation", replayKeyNamespace+signature) + 626 } + 627 } + 628 + 629 // TestSettleExactPaymentDoesNotConsumeReplayKeyOnBroadcastFailure covers + 630 // the L8 invariant that an RPC failure during broadcast (before + 640 state := testServerState(t) + 641 state.memo = "l8-broadcast-fail" + 642 state.httpClient = &http.Client{ + 643 Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + 644 rawBody, err := io.ReadAll(request.Body) + 645 if err != nil { + 646 t.Fatal(err) + 647 } + 648 body := string(rawBody) + 649 responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + 650 if strings.Contains(body, `"method":"sendTransaction"`) { + 651 responseBody = `{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"blockhash not found"}}` + 652 } + 653 return &http.Response{ + 654 StatusCode: http.StatusOK, + 655 Header: http.Header{"content-type": []string{"application/json"}}, + 656 Body: io.NopCloser(strings.NewReader(responseBody)), + 657 }, nil + 658 }), + 659 } + 660 requirement := exactRequirement(state) + 661 header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + 662 X402Version: 2, + 663 Accepted: requirement, + 664 Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, + 665 }) + 666 + 667 if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "sendTransaction RPC error") { + 668 t.Fatalf("expected broadcast RPC error, got %v", err) + 669 } + 670 if len(settlementCache.entries) != 0 { + 671 t.Fatalf("expected empty replay cache after broadcast failure, got %d entries", len(settlementCache.entries)) + 672 } + 673 } + 674 + 675 // TestSettleExactPaymentDoesNotConsumeReplayKeyOnConfirmationFailure + 676 // covers the L8 invariant that an on-chain failure surfaced via + 677 // getSignatureStatuses (e.g. tx landed but reverted) MUST NOT insert + 678 // the replay key — a future re-broadcast under a fresh blockhash is the + 679 // caller's option, not a duplicate. + 680 func TestSettleExactPaymentDoesNotConsumeReplayKeyOnConfirmationFailure(t *testing.T) { + 681 settlementCache = newDuplicateSettlementCache() + 682 defer func() { settlementCache = newDuplicateSettlementCache() }() + 683 client, err := solana.NewRandomPrivateKey() + 684 if err != nil { + 685 t.Fatal(err) + 686 } + 687 state := testServerState(t) + 688 state.memo = "l8-confirm-fail" + 689 state.httpClient = &http.Client{ + 690 Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + 691 rawBody, err := io.ReadAll(request.Body) + 692 if err != nil { + 693 t.Fatal(err) + 694 } + 695 body := string(rawBody) + 696 responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + 697 switch { + 698 case strings.Contains(body, `"method":"sendTransaction"`): + 699 responseBody = `{"jsonrpc":"2.0","id":1,"result":"reverted-sig"}` + 700 case strings.Contains(body, `"method":"getSignatureStatuses"`): + 701 responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":{"InstructionError":[0,"Custom"]},"confirmationStatus":"confirmed"}]}}` + 702 } + 703 return &http.Response{ + 704 StatusCode: http.StatusOK, + 705 Header: http.Header{"content-type": []string{"application/json"}}, + 706 Body: io.NopCloser(strings.NewReader(responseBody)), + 707 }, nil + 708 }), + 709 } + 710 requirement := exactRequirement(state) + 711 header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + 712 X402Version: 2, + 713 Accepted: requirement, + 714 Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, + 715 }) + 716 + 717 if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "transaction failed on-chain") { + 718 t.Fatalf("expected on-chain failure, got %v", err) + 719 } + 720 if _, ok := settlementCache.entries[replayKeyNamespace+"reverted-sig"]; ok { + 721 t.Fatalf("expected replay key NOT to be consumed when confirmation surfaces on-chain failure") + 722 } + 723 } + 724 + 725 // TestSettleExactPaymentReturnsCanonicalErrorForConsumedSignature covers + 726 // the L8 invariant that a putIfAbsent collision (signature already + 727 // consumed) surfaces the canonical duplicate_settlement error and does + 728 // not echo a fresh PAYMENT-RESPONSE. + 729 func TestSettleExactPaymentReturnsCanonicalErrorForConsumedSignature(t *testing.T) { + 730 settlementCache = newDuplicateSettlementCache() + 731 defer func() { settlementCache = newDuplicateSettlementCache() }() + 732 client, err := solana.NewRandomPrivateKey() + 733 if err != nil { + 734 t.Fatal(err) + 735 } + 736 state := testServerState(t) + 737 state.memo = "l8-pre-consumed" + 738 state.httpClient = successfulSettlementClient(t, "pre-consumed-sig") + 739 // Simulate a prior successful settlement having already inserted + 740 // the canonical replay key for this signature. + 741 settlementCache.entries[replayKeyNamespace+"pre-consumed-sig"] = time.Now() + 742 + 743 requirement := exactRequirement(state) + 744 header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + 745 X402Version: 2, + 746 Accepted: requirement, + 747 Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, + 748 }) + 749 if _, err := settleExactPayment(state, header); err == nil || err.Error() != "duplicate_settlement" { + 750 t.Fatalf("expected duplicate_settlement on already-consumed signature, got %v", err) + 751 } + 752 } + 753 + 754 // TestSettleExactPaymentConcurrentDuplicatesCollapse asserts that two + 755 // concurrent settlements producing the same signature collapse to a + + succeeded in 0ms: + 30 token2022Program = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + 31 lighthouseProgram = "L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95" + 32 defaultMaxTimeout = 60 + 33 duplicateCacheTTL = 120 * time.Second + 34 maxComputeUnitPrice = 5_000_000 + 35 maxMemoBytes = 256 + 36 // replayKeyNamespace MUST match the scheme-namespaced canonical key + 37 // documented in the x402 PR-readiness reference and mirrors the MPP + 38 // `solana-charge:consumed:` shape but scoped to x402 svm-exact so + 39 // settled signatures across schemes (and against MPP) do not collide. + 40 replayKeyNamespace = "x402-svm-exact:consumed:" + 41 ) + 42 + 43 // confirmationPollAttempts × confirmationPollInterval bounds the + 44 // post-broadcast confirmation wait. Defaults mirror the MPP + 45 // `server/charge.rs:769` 30×200ms = ~6s window. These are vars (not + 46 // consts) so tests can shrink the poll budget to keep timeout coverage + 47 // fast. + 48 var ( + 49 confirmationPollAttempts = 60 + 50 confirmationPollInterval = 200 * time.Millisecond + 51 ) + 52 + 53 var ( + 54 computeBudgetProgramID = solana.MustPublicKeyFromBase58("ComputeBudget111111111111111111111111111111") + 55 memoProgramID = solana.MustPublicKeyFromBase58("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr") + 56 ) + 57 + 58 // Lighthouse instructions are passed through by program-ID match alone, matching + 59 // the canonical spines: + 60 // - rust/src/protocol/schemes/exact/verify.rs:266 — `if program == LIGHTHOUSE_PROGRAM || program == MEMO_PROGRAM { continue; }` + 61 // - typescript/packages/x402/src/facilitator/exact/scheme.ts:300 — same shape + 62 // No discriminator or account-count allowlist is enforced here: inventing one + 63 // in a single language port would diverge from real-world Phantom/Solflare + 64 // transactions that the Rust + TypeScript adapters accept. Tightening this is + 65 // a protocol-wide decision that must land in the Rust spine first; tracked at + 170 + 171 func newDuplicateSettlementCache() *duplicateSettlementCache { + 172 return &duplicateSettlementCache{ + 173 entries: map[string]time.Time{}, + 174 now: time.Now, + 175 } + 176 } + 177 + 178 // putIfAbsent reserves `key` in the replay cache. Returns true if the key + 179 // was newly inserted, false if a prior settlement already consumed it. + 180 // + 181 // L8 ordering (see x402 PR-readiness reference and MPP + 182 // `server/charge.rs:535-556`): callers MUST broadcast → await on-chain + 183 // confirmation → `putIfAbsent(signature)`. There is no release-on-failure + 184 // path: a crash or RPC failure before this call simply never inserts a + 185 // key, and Solana's per-signature replay protection prevents a re-broadcast + 186 // of the same signed transaction from settling twice within its blockhash + 187 // window. The release path of the prior claim-first design has been + 188 // removed to close the partial-failure race where a release after a timed- + 189 // out confirmation would permit a double-pay if the original later landed. + 190 func (cache *duplicateSettlementCache) putIfAbsent(key string) bool { + 191 cache.mu.Lock() + 192 defer cache.mu.Unlock() + 193 + 194 now := cache.now() + 195 for cached, seenAt := range cache.entries { + 196 if now.Sub(seenAt) > duplicateCacheTTL { + 197 delete(cache.entries, cached) + 198 } + 199 } + 200 if _, ok := cache.entries[key]; ok { + 201 return false + 202 } + 203 cache.entries[key] = now + 204 return true + 205 } + 520 if err := verifyExactTransaction(transaction, requirement); err != nil { + 521 return "", err + 522 } + 523 // Bind the transaction's message fee-payer (account key 0) to the + 524 // server's configured fee-payer. Without this guard a malicious client + 525 // could nominate a different message payer and rely on the facilitator + 526 // being in the signer set to drain SOL via co-signing. + 527 if len(transaction.Message.AccountKeys) == 0 { + 528 return "", fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_missing") + 529 } + 530 if !transaction.Message.AccountKeys[0].Equals(state.feePayer.PublicKey()) { + 531 return "", fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_mismatch") + 532 } + 533 if err := verifyTokenAccountsExist(state, transaction, requirement); err != nil { + 534 return "", err + 535 } + 536 + 537 if _, err := transaction.PartialSign(func(key solana.PublicKey) *solana.PrivateKey { + 538 if key.Equals(state.feePayer.PublicKey()) { + 539 return &state.feePayer + 540 } + 541 return nil + 542 }); err != nil { + 543 return "", err + 544 } + 545 if err := transaction.VerifySignatures(); err != nil { + 546 return "", err + 547 } + 548 + 549 // L8 ordering: broadcast → confirm → put_if_absent(signature). + 550 // Mirrors MPP `server/charge.rs:535-556` (broadcast_pull, + 551 // await_pull_confirmation, consume_signature). No claim-first, no + 552 // release-on-failure. See x402 PR-readiness reference §"L8 + 553 // broadcast-then-confirm-then-mark ordering (SVM-specific)". + 554 signature, err := sendTransaction(state, transaction) + 555 if err != nil { + 556 return "", err + 557 } + 558 if err := awaitSignatureConfirmation(state, signature); err != nil { + 559 return "", err + 560 } + 561 if !settlementCache.putIfAbsent(replayKeyNamespace + signature) { + 562 // Canonical `signature_consumed` surface (see MPP + 563 // `VerificationError::signature_consumed`, + 564 // rust/src/server/charge.rs:589-593). The interop server's + 565 // existing error vocabulary maps this to "duplicate_settlement"; + 566 // keep that wire token so existing clients are not broken, but + 567 // the semantic is now "this confirmed signature was already + 568 // consumed by an earlier successful settlement", not "we saw + 569 // this encoded transaction blob before broadcast". + 570 return "", fmt.Errorf("duplicate_settlement") + 1010 return payload.Result, nil + 1011 } + 1012 + 1013 // awaitSignatureConfirmation polls `getSignatureStatuses` until the + 1014 // signature reaches `confirmed` or `finalized` commitment. It returns an + 1015 // error on explicit RPC error, an on-chain transaction failure + 1016 // (status.err non-null), or when the poll budget elapses (the bounded + 1017 // stand-in for blockhash-window expiry; a signature that has not been + 1018 // observed within this window is treated as not landed so the caller + 1019 // MUST NOT mark the signature as consumed). Mirrors the canonical loop + 1020 // in MPP `server/charge.rs:761-784`. + 1021 func awaitSignatureConfirmation(state serverState, signature string) error { + 1022 requestBody, err := json.Marshal(map[string]any{ + 1023 "jsonrpc": "2.0", + 1024 "id": 1, + 1025 "method": "getSignatureStatuses", + 1026 "params": []any{ + 1027 []string{signature}, + 1028 map[string]any{"searchTransactionHistory": false}, + 1029 }, + 1030 }) + 1031 if err != nil { + 1032 return err + 1033 } + 1034 for attempt := 0; attempt < confirmationPollAttempts; attempt++ { + 1035 response, err := state.httpClient.Post(state.rpcURL, "application/json", bytes.NewReader(requestBody)) + 1036 if err != nil { + 1037 return fmt.Errorf("getSignatureStatuses transport: %w", err) + 1038 } + 1039 rawBody, readErr := io.ReadAll(response.Body) + 1040 _ = response.Body.Close() + 1041 if readErr != nil { + 1042 return readErr + 1043 } + 1044 if response.StatusCode < 200 || response.StatusCode >= 300 { + 1045 return fmt.Errorf("getSignatureStatuses HTTP %d: %s", response.StatusCode, string(rawBody)) + 1046 } + 1047 var payload struct { + 1048 Result *struct { + 1049 Value []*struct { + 1050 Confirmations *uint64 `json:"confirmations"` + 1051 ConfirmationStatus string `json:"confirmationStatus"` + 1052 Err any `json:"err"` + 1053 } `json:"value"` + 1054 } `json:"result"` + 1055 Error any `json:"error"` + 1056 } + 1057 if err := json.Unmarshal(rawBody, &payload); err != nil { + 1058 return err + 1059 } + 1060 if payload.Error != nil { + 1061 return fmt.Errorf("getSignatureStatuses RPC error: %v", payload.Error) + 1062 } + 1063 if payload.Result != nil && len(payload.Result.Value) > 0 && payload.Result.Value[0] != nil { + 1064 status := payload.Result.Value[0] + 1065 if status.Err != nil { + 1066 return fmt.Errorf("transaction failed on-chain: %v", status.Err) + 1067 } + 1068 if status.ConfirmationStatus == "confirmed" || status.ConfirmationStatus == "finalized" { + +codex +The implementation itself has the requested sequence. I’m running the focused Go server tests now because the claim includes race-sensitive replay semantics, and there are targeted tests for the failure paths. +exec +/bin/zsh -lc 'go test ./x402/cmd/interop-server' in /private/tmp/pay-kit-124-l8/go + succeeded in 459ms: +ok github.com/solana-foundation/pay-kit/go/x402/cmd/interop-server (cached) + +codex +Verified: **5/5**. + +The Go x402 server implements L8 as requested: + +- Replay key namespace is exactly `x402-svm-exact:consumed:` in [main.go](/private/tmp/pay-kit-124-l8/go/x402/cmd/interop-server/main.go:40). +- Settlement order is `sendTransaction` → `awaitSignatureConfirmation` → `settlementCache.putIfAbsent(replayKeyNamespace + signature)` in [main.go](/private/tmp/pay-kit-124-l8/go/x402/cmd/interop-server/main.go:554). +- No claim-first path is present before broadcast/confirmation. +- No release path is present; failures before `putIfAbsent` just return without consuming a key. +- Tests cover ordering, broadcast failure, confirmation failure, pre-consumed key, and concurrent duplicate collapse in [main_test.go](/private/tmp/pay-kit-124-l8/go/x402/cmd/interop-server/main_test.go:563). + +Verification run: + +`go test ./x402/cmd/interop-server` from `go/` passed, cached. + +Residual note: duplicate retries intentionally broadcast again before the replay-store collision, relying on Solana per-signature idempotence, then rejecting post-confirm with `duplicate_settlement`. +hook: Stop +hook: Stop Completed +tokens used +121,937 +Verified: **5/5**. + +The Go x402 server implements L8 as requested: + +- Replay key namespace is exactly `x402-svm-exact:consumed:` in [main.go](/private/tmp/pay-kit-124-l8/go/x402/cmd/interop-server/main.go:40). +- Settlement order is `sendTransaction` → `awaitSignatureConfirmation` → `settlementCache.putIfAbsent(replayKeyNamespace + signature)` in [main.go](/private/tmp/pay-kit-124-l8/go/x402/cmd/interop-server/main.go:554). +- No claim-first path is present before broadcast/confirmation. +- No release path is present; failures before `putIfAbsent` just return without consuming a key. +- Tests cover ordering, broadcast failure, confirmation failure, pre-consumed key, and concurrent duplicate collapse in [main_test.go](/private/tmp/pay-kit-124-l8/go/x402/cmd/interop-server/main_test.go:563). + +Verification run: + +`go test ./x402/cmd/interop-server` from `go/` passed, cached. + +Residual note: duplicate retries intentionally broadcast again before the replay-store collision, relying on Solana per-signature idempotence, then rejecting post-confirm with `duplicate_settlement`. From dcfeca86a23e72e7b443e9c7bd8ff3b37619601b Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 26 May 2026 14:03:29 +0300 Subject: [PATCH 09/10] chore(notes): untrack loose codex review artifacts --- .gitignore | 1 + notes/codex-review-go-x402-r4.md | 36 -------------------------------- 2 files changed, 1 insertion(+), 36 deletions(-) delete mode 100644 notes/codex-review-go-x402-r4.md diff --git a/.gitignore b/.gitignore index a170fb4f7..ac1599a4d 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ harness/go-client/go-client mpp-sdk-self-learning/ .build/ go/coverage.out +notes/codex-review-*.md diff --git a/notes/codex-review-go-x402-r4.md b/notes/codex-review-go-x402-r4.md deleted file mode 100644 index 57ca5bb8c..000000000 --- a/notes/codex-review-go-x402-r4.md +++ /dev/null @@ -1,36 +0,0 @@ -# Codex Round 4 — Go x402 exact port - -Carried from `solana-foundation/x402-sdk` PR #18, tip `e3bf746`. - -## Summary - -- **Real P1 findings**: 0 -- **Confidence**: 4/5 -- **Coverage**: server 90.9%, client 91.9% (`go test ./... -cover -race`) -- **Lint**: `gofmt -l` clean, `go vet ./...` clean - -## Source provenance - -The Go binaries under `go/x402/cmd/interop-{client,server}/` are a mechanical -re-org of the upstream x402-sdk PR #18 final state. No upstream behavior -changes; only the module path was rewritten when copying into mpp-sdk's -single-module `go/` tree. - -## Regression coverage carried over - -- Fee-payer attack regression suite (5 attack shapes + positive control) -- Multi-mint `extra.offered` support -- Lighthouse instruction passthrough (spine-parity per - `notes/lighthouse-allowlist-tracking.md` in x402-sdk) -- `extra.tokenProgram` mint allowlist enforcement -- Token alias → base58 resolve at env boundary -- Cross-envelope preference fallback -- Idempotent resubmit / replay protection via Solana per-signature native - + scheme-namespaced cache - -## Interop matrix evidence - -90/90 pass on the seven-language sweep in x402-sdk PR #18. - -MPP §19.6 cross-server scenarios: portability + idempotent-resubmit clean — -the Go server rejects cross-server credentials with the canonical token. From 040f67d06c295b17f42bf186f3f102c213273584 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 26 May 2026 14:05:14 +0300 Subject: [PATCH 10/10] chore(notes): re-untrack pr-124-r7.md after rebase brought it back --- .gitignore | 1 + notes/codex-review/pr-124-r7.md | 9345 ------------------------------- 2 files changed, 1 insertion(+), 9345 deletions(-) delete mode 100644 notes/codex-review/pr-124-r7.md diff --git a/.gitignore b/.gitignore index ac1599a4d..231eacb8c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ mpp-sdk-self-learning/ .build/ go/coverage.out notes/codex-review-*.md +notes/codex-review/ diff --git a/notes/codex-review/pr-124-r7.md b/notes/codex-review/pr-124-r7.md deleted file mode 100644 index 427098107..000000000 --- a/notes/codex-review/pr-124-r7.md +++ /dev/null @@ -1,9345 +0,0 @@ -Reading additional input from stdin... -2026-05-25T22:44:21.549613Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when Client(HttpRequest(HttpRequest("http/request failed: error sending request for url (http://127.0.0.1:29979/mcp)"))) -OpenAI Codex v0.133.0 --------- -workdir: /private/tmp/pay-kit-124-l8 -model: gpt-5.5 -provider: openai -approval: never -sandbox: workspace-write [workdir, /tmp, $TMPDIR] -reasoning effort: medium -reasoning summaries: none -session id: 019e614f-7ecf-7ec2-86f8-9062dd107d6b --------- -user -Verify L8 ordering is broadcast→confirm→put_if_absent (no claim-first, no release path) and replay key is x402-svm-exact:consumed:. Rate 1-5. - - -diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml -index dbce008..dc55cb6 100644 ---- a/.github/workflows/ci.yml -+++ b/.github/workflows/ci.yml -@@ -230,12 +230,12 @@ jobs: - with: - name: html-assets - - name: Install Surfnet helper dependencies -- working-directory: tests/interop -+ working-directory: harness - run: pnpm install --frozen-lockfile - - name: Start Surfnet - working-directory: . - run: | -- node tests/interop/start-surfnet-proxy.mjs & -+ node harness/start-surfnet-proxy.mjs & - ready=0 - for i in $(seq 1 50); do - if curl -sf -X POST http://localhost:8899 \ -@@ -270,12 +270,12 @@ jobs: - with: - name: html-assets - - name: Install Surfnet helper dependencies -- working-directory: tests/interop -+ working-directory: harness - run: pnpm install --frozen-lockfile - - name: Start Surfnet - working-directory: . - run: | -- node tests/interop/start-surfnet-proxy.mjs & -+ node harness/start-surfnet-proxy.mjs & - ready=0 - for i in $(seq 1 50); do - if curl -sf -X POST http://localhost:8899 \ -@@ -344,12 +344,12 @@ jobs: - with: - name: html-assets - - name: Install Surfnet helper dependencies -- working-directory: tests/interop -+ working-directory: harness - run: pnpm install --frozen-lockfile - - name: Start Surfnet - working-directory: . - run: | -- node tests/interop/start-surfnet-proxy.mjs & -+ node harness/start-surfnet-proxy.mjs & - ready=0 - for i in $(seq 1 50); do - if curl -sf -X POST http://localhost:8899 \ -@@ -419,7 +419,7 @@ jobs: - working-directory: rust - run: cargo build -p solana-mpp --bin interop_client --bin interop_server - - name: Install interop harness -- working-directory: tests/interop -+ working-directory: harness - run: pnpm install --frozen-lockfile - - uses: ruby/setup-ruby@v1 - with: -@@ -427,34 +427,34 @@ jobs: - bundler-cache: true - working-directory: ruby - - name: Typecheck interop harness -- working-directory: tests/interop -+ working-directory: harness - run: pnpm typecheck - - name: Run Rust client interop smoke -- working-directory: tests/interop -+ working-directory: harness - run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "rust client pays typescript server" - env: - MPP_INTEROP_CLIENTS: rust - MPP_INTEROP_SERVERS: typescript - - name: Run Rust server interop smoke -- working-directory: tests/interop -+ working-directory: harness - run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "typescript client pays rust server" - env: - MPP_INTEROP_CLIENTS: typescript - MPP_INTEROP_SERVERS: rust - - name: Run Rust end-to-end interop smoke -- working-directory: tests/interop -+ working-directory: harness - run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "rust client pays rust server" - env: - MPP_INTEROP_CLIENTS: rust - MPP_INTEROP_SERVERS: rust - - name: Run Ruby server interop smoke -- working-directory: tests/interop -+ working-directory: harness - run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "typescript client pays ruby server" - env: - MPP_INTEROP_CLIENTS: typescript - MPP_INTEROP_SERVERS: ruby - - name: Run Rust client to Ruby server interop smoke -- working-directory: tests/interop -+ working-directory: harness - run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "rust client pays ruby server" - env: - MPP_INTEROP_CLIENTS: rust -diff --git a/.github/workflows/lua.yml b/.github/workflows/lua.yml -index e11a983..9bdb53e 100644 ---- a/.github/workflows/lua.yml -+++ b/.github/workflows/lua.yml -@@ -142,11 +142,11 @@ jobs: - run: cargo build --bin interop_client - - - name: Install interop harness deps -- working-directory: tests/interop -+ working-directory: harness - run: pnpm install --frozen-lockfile - - - name: TS-to-Lua focused matrix -- working-directory: tests/interop -+ working-directory: harness - env: - MPP_INTEROP_CLIENTS: typescript - MPP_INTEROP_SERVERS: lua -@@ -154,7 +154,7 @@ jobs: - run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "typescript client pays lua server" - - - name: Rust-to-Lua focused matrix -- working-directory: tests/interop -+ working-directory: harness - env: - MPP_INTEROP_CLIENTS: rust - MPP_INTEROP_SERVERS: lua -diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml -index d365cce..7fd7f5c 100644 ---- a/.github/workflows/php.yml -+++ b/.github/workflows/php.yml -@@ -70,23 +70,23 @@ jobs: - working-directory: rust - run: cargo build -p solana-mpp --bin interop_client - - name: Install interop harness -- working-directory: tests/interop -+ working-directory: harness - run: pnpm install --frozen-lockfile - - name: Install PHP interop dependencies - working-directory: php - run: composer install --no-interaction --no-progress - - name: Typecheck interop harness -- working-directory: tests/interop -+ working-directory: harness - run: pnpm typecheck - - name: Run PHP server interop smoke -- working-directory: tests/interop -+ working-directory: harness - run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "typescript client pays php server" - env: - MPP_INTEROP_CLIENTS: typescript - MPP_INTEROP_SERVERS: php - MPP_INTEROP_SCENARIOS: charge-basic,charge-split-ata,charge-network-mismatch,charge-cross-route-replay - - name: Run Rust client PHP server interop smoke -- working-directory: tests/interop -+ working-directory: harness - run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "rust client pays php server" - env: - MPP_INTEROP_CLIENTS: rust -diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml -index f9b5083..a0c45b3 100644 ---- a/.github/workflows/python.yml -+++ b/.github/workflows/python.yml -@@ -29,7 +29,7 @@ jobs: - run: pyright - - name: Run tests with coverage - working-directory: python -- # Coverage gate: line coverage at 90% (M1 baseline). Branch coverage gate is M2-followup, tracked in #108. -+ # Coverage gate: line coverage at 90%. Branch coverage gate is follow-up work, tracked in #108. - run: | - pytest \ - --cov=solana_mpp \ -@@ -77,7 +77,7 @@ jobs: - python -m pip install --upgrade pip - pip install -e ".[dev]" - # Critical order: install + build the TypeScript workspace BEFORE -- # installing the interop harness. tests/interop has -+ # installing the interop harness. harness has - # ``"@solana/mpp": "file:../../typescript/packages/mpp"`` which - # pnpm copies into node_modules at install time. If the typescript - # package has no dist/ at that moment, the TS interop client crashes -@@ -95,16 +95,16 @@ jobs: - working-directory: rust - run: cargo build --bin interop_client --bin interop_server - - name: Install interop harness -- working-directory: tests/interop -+ working-directory: harness - run: pnpm install --frozen-lockfile - - name: Focused TS-to-Python interop -- working-directory: tests/interop -+ working-directory: harness - env: - MPP_INTEROP_CLIENTS: typescript - MPP_INTEROP_SERVERS: python - run: pnpm exec vitest run test/e2e.test.ts - - name: Focused Rust-to-Python interop -- working-directory: tests/interop -+ working-directory: harness - env: - MPP_INTEROP_CLIENTS: rust - MPP_INTEROP_SERVERS: python -diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml -index c2e3a75..6fd110c 100644 ---- a/.github/workflows/swift.yml -+++ b/.github/workflows/swift.yml -@@ -44,25 +44,25 @@ jobs: - working-directory: typescript - run: pnpm --filter @solana/mpp build - - name: Install interop harness -- working-directory: tests/interop -+ working-directory: harness - run: pnpm install --frozen-lockfile - - name: Build Swift interop client -- working-directory: tests/interop/swift-client -+ working-directory: harness/swift-client - run: swift build - - name: Build Rust interop server - working-directory: rust - run: cargo build --bin interop_server - - name: Typecheck interop harness -- working-directory: tests/interop -+ working-directory: harness - run: pnpm typecheck - - name: Run Swift client interop smoke against TypeScript server -- working-directory: tests/interop -+ working-directory: harness - env: - MPP_INTEROP_CLIENTS: swift - MPP_INTEROP_SERVERS: typescript - run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "swift client pays typescript server" - - name: Run Swift client interop smoke against Rust server -- working-directory: tests/interop -+ working-directory: harness - env: - MPP_INTEROP_CLIENTS: swift - MPP_INTEROP_SERVERS: rust -diff --git a/.gitignore b/.gitignore -index 64b7fc7..a170fb4 100644 ---- a/.gitignore -+++ b/.gitignore -@@ -28,7 +28,7 @@ __pycache__/ - .coverage - .venv/ - *.pyc --tests/interop/go-client/go-client -+harness/go-client/go-client - .claude/ - .gocache - mpp-sdk-self-learning/ -diff --git a/README.md b/README.md -index 7dcfe2f..52236a0 100644 ---- a/README.md -+++ b/README.md -@@ -62,9 +62,9 @@ The interop harness can run a full client/server cross-product, but CI keeps the - | Python | ![Python](https://img.shields.io/badge/coverage-87%25-green) | `just py-test` | - | Lua | ![Lua](https://img.shields.io/badge/coverage-41_tests-blue) | `just lua-test` | - | Ruby | ![Ruby](https://img.shields.io/badge/coverage-98%25-green) | `just rb-test-cover` | --| Interop | ![Interop](https://img.shields.io/badge/interop-TypeScript_harness-brightgreen) | `cd tests/interop && pnpm test` | -+| Interop | ![Interop](https://img.shields.io/badge/interop-TypeScript_harness-brightgreen) | `cd harness && pnpm test` | - --See [`tests/interop/README.md`](tests/interop/README.md) for the process adapter contract used by the Surfpool-backed client/server matrix. -+See [`harness/README.md`](harness/README.md) for the process adapter contract used by the Surfpool-backed client/server matrix. - - ## Install - -diff --git a/docs/security/compute-budget-caps.md b/docs/security/compute-budget-caps.md -index b2d971e..5616813 100644 ---- a/docs/security/compute-budget-caps.md -+++ b/docs/security/compute-budget-caps.md -@@ -53,7 +53,7 @@ this monorepo. - | Go (#101) | `go/server/server.go` (`maxComputeUnitLimit`) | pending PR #101 merge | - | Python (#106) | `python/src/solana_mpp/server/mpp.py` | pending PR #106 merge | - --`tests/interop/test/compute-budget-caps.test.ts` parses each file above -+`harness/test/compute-budget-caps.test.ts` parses each file above - and asserts byte-identical literals against the canonical pair. Go and - Python rows are marked `optional: true` until their PRs land, then - flip to required and surface drift the same way as the other SDKs. -@@ -66,8 +66,8 @@ flip to required and surface drift the same way as the other SDKs. - code when either limit is exceeded; include the cap value in the - reason string for parity with the existing SDKs. - 3. Append a row to `SDKS` in -- `tests/interop/test/compute-budget-caps.test.ts` and to the table -+ `harness/test/compute-budget-caps.test.ts` and to the table - above. Append a fixture row to - `charge-compute-budget-over-cap` in -- `tests/interop/src/intents/charge.ts` once the SDK is wired into the -+ `harness/src/intents/charge.ts` once the SDK is wired into the - interop harness. -diff --git a/go/README.md b/go/README.md -index 744bbd1..72c7daf 100644 ---- a/go/README.md -+++ b/go/README.md -@@ -128,7 +128,7 @@ localnet fixture. - ## Running the interop adapters - - ```bash --cd tests/interop/go-server -+cd harness/go-server - go run . # starts a Surfpool-backed protected endpoint on a random port - - cd ../go-client -@@ -193,7 +193,7 @@ same structural transaction verifier as pull mode, consumes the - signature through replay storage, and emits the same receipt shape. - - The direct Go interop server at --[`tests/interop/go-server/main.go`](../tests/interop/go-server/main.go) -+[`harness/go-server/main.go`](../harness/go-server/main.go) - exercises this end-to-end through Surfpool for both TypeScript and Rust - clients. - -@@ -258,15 +258,15 @@ The CI Go job runs the SDK packages with `-coverprofile` and enforces a - - ## Interop - --The cross-language interop harness lives in `../tests/interop`. The Go --SDK ships both a client (`tests/interop/go-client`) and a server --(`tests/interop/go-server`) adapter. Both are opt-in via the -+The cross-language interop harness lives in `../harness`. The Go -+SDK ships both a client (`harness/go-client`) and a server -+(`harness/go-server`) adapter. Both are opt-in via the - `MPP_INTEROP_CLIENTS` and `MPP_INTEROP_SERVERS` env vars. - - Focused harness commands: - - ```bash --cd tests/interop -+cd harness - MPP_INTEROP_CLIENTS=go MPP_INTEROP_SERVERS=rust pnpm test - MPP_INTEROP_CLIENTS=typescript MPP_INTEROP_SERVERS=go pnpm test - MPP_INTEROP_CLIENTS=rust MPP_INTEROP_SERVERS=go pnpm test -diff --git a/go/mpp.go b/go/mpp.go -index e618006..4f8243b 100644 ---- a/go/mpp.go -+++ b/go/mpp.go -@@ -8,7 +8,7 @@ - // transaction builders live in the `client` subpackage. The wire format - // and module split mirror the Rust reference crate documented in - // skills/pay-sdk-implementation; cross-language behavior is locked via --// the interop harness at tests/interop. -+// the interop harness at harness. - package mpp - - import ( -diff --git a/go/x402/README.md b/go/x402/README.md -new file mode 100644 -index 0000000..0f46eda ---- /dev/null -+++ b/go/x402/README.md -@@ -0,0 +1,81 @@ -+# Go x402 SDK -+ -+Go implementation of the x402 `exact` scheme (client + server) for Solana. -+ -+This sub-package mirrors the canonical Rust spine at `rust/crates/x402/` -+and ships the interop adapters used by the cross-language harness. -+ -+## Layout -+ -+```text -+go/x402/ -+├── cmd/ -+│ ├── interop-client/ interop harness client binary -+│ └── interop-server/ interop harness server binary -+└── README.md -+``` -+ -+The exact-scheme protocol types, verifier, and settler live inline in -+the two `main.go` files. The Rust crate keeps a separate -+`protocol/schemes/exact/`, `server/exact.rs`, `client/exact/payment.rs` -+split; the Go port keeps them inline because both binaries are -+self-contained and there is no third caller. The spine's wire format, -+constants, and pipeline ordering are mirrored 1:1. -+ -+## Test -+ -+```bash -+cd go -+go test ./x402/... -cover -race -+``` -+ -+Expected coverage: server ≥ 90 %, client ≥ 90 %. -+ -+## Format and vet -+ -+```bash -+gofmt -l go/x402/ -+go vet ./x402/... -+``` -+ -+## Parity with the Rust spine -+ -+The Go port matches `rust/crates/x402/` on: -+ -+- CAIP-2 network identifiers (`solana:5eykt...`, `solana:EtWTR...`, -+ `solana:4uhc...`) — verbatim. -+- Program IDs (Token, Token-2022, Associated Token, Compute Budget, -+ System, Memo, Lighthouse) — verbatim. -+- Stablecoin mint addresses per network (USDC/USDT/USDG/PYUSD/CASH) — -+ verbatim. -+- Constants: `EXACT_SCHEME = "exact"`, `maxMemoBytes = 256`. -+- Instruction allowlist: ComputeBudget (Set CU Limit + Price), SPL -+ Token / Token-2022 `TransferChecked`, plus optional Lighthouse + -+ Memo + ATA-create. -+- Lighthouse passthrough by program-ID match only (no discriminator -+ allowlist, no account-count cap) — spine parity. -+- Fee-payer-in-instruction-accounts sweep with the legitimate -+ ATA-create payer slot exception. -+- Destination ATA re-derived from `(payTo, mint, tokenProgram)` and -+ compared against the transaction's destination index. -+- L8 settlement ordering: broadcast → confirm → mark. -+- Cross-server credential rejection with canonical 4xx + token in body. -+- Env-var contract: `X402_INTEROP_TARGET_URL`, -+ `X402_INTEROP_RPC_URL`, `X402_INTEROP_NETWORK`, -+ `X402_INTEROP_CLIENT_SECRET_KEY`, -+ `X402_INTEROP_FACILITATOR_SECRET_KEY`, `X402_INTEROP_PAY_TO`, -+ `X402_INTEROP_MINT`, `X402_INTEROP_PRICE`, -+ `X402_INTEROP_EXTRA_OFFERED_MINTS` (CSV), -+ `X402_INTEROP_PREFER_CURRENCIES` (CSV). -+- Client `result` and server `ready` stdout JSON shapes. -+ -+Intentional Go-side specifics (not divergences): -+ -+- Mint alias resolution happens at the env-read boundary -+ (`X402_INTEROP_MINT` may be a symbol or base58); the rest of the -+ code sees canonical base58. The spine accepts the same pattern. -+- Duplicate-settlement cache keys are SHA-256 of the encoded -+ transaction, in addition to Solana's native per-signature -+ uniqueness — defense-in-depth, matches the upstream reference. -+ -+No upstream behavior changes vs the reference port (tip `e3bf746`). -diff --git a/go/x402/cmd/interop-client/challenge_test.go b/go/x402/cmd/interop-client/challenge_test.go -new file mode 100644 -index 0000000..6d22442 ---- /dev/null -+++ b/go/x402/cmd/interop-client/challenge_test.go -@@ -0,0 +1,1369 @@ -+package main -+ -+import ( -+ "encoding/base64" -+ "encoding/json" -+ "errors" -+ "io" -+ "net/http" -+ "os" -+ "strings" -+ "testing" -+ -+ "github.com/gagliardetto/solana-go" -+) -+ -+func TestSelectSVMRequirementFromPaymentRequiredHeader(t *testing.T) { -+ requirement := map[string]any{ -+ "scheme": "exact", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ "amount": "1000", -+ } -+ envelope, err := json.Marshal(map[string]any{ -+ "x402Version": 2, -+ "accepts": []map[string]any{requirement}, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ selected := selectSVMRequirement( -+ map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(envelope)}, -+ "", -+ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "exact", -+ ) -+ -+ if selected == nil { -+ t.Fatal("expected selected requirement") -+ } -+ if selected.Asset != requirement["asset"] { -+ t.Fatalf("unexpected asset: %s", selected.Asset) -+ } -+} -+ -+func TestSelectSVMRequirementFromBody(t *testing.T) { -+ body, err := json.Marshal(map[string]any{ -+ "accepts": []map[string]any{ -+ { -+ "scheme": "exact", -+ "network": "eip155:8453", -+ "asset": "0x0000000000000000000000000000000000000000", -+ "amount": "1000", -+ }, -+ { -+ "scheme": "exact", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ "amount": "1000", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ selected := selectSVMRequirement( -+ map[string]string{}, -+ string(body), -+ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "exact", -+ ) -+ -+ if selected == nil { -+ t.Fatal("expected selected requirement") -+ } -+ if selected.Network != "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" { -+ t.Fatalf("unexpected network: %s", selected.Network) -+ } -+} -+ -+func TestSelectSVMRequirementIgnoresUnsupportedScheme(t *testing.T) { -+ body, err := json.Marshal(map[string]any{ -+ "accepts": []map[string]any{ -+ { -+ "scheme": "upto", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ "amount": "1000", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ selected := selectSVMRequirement( -+ map[string]string{}, -+ string(body), -+ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "exact", -+ ) -+ -+ if selected != nil { -+ t.Fatalf("expected no selected requirement, got %+v", selected) -+ } -+} -+ -+func TestSelectSVMRequirementSupportsRequestedUptoScheme(t *testing.T) { -+ body, err := json.Marshal(map[string]any{ -+ "accepts": []map[string]any{ -+ { -+ "scheme": "upto", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ "amount": "1000", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ selected := selectSVMRequirement( -+ map[string]string{}, -+ string(body), -+ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "upto", -+ ) -+ -+ if selected == nil { -+ t.Fatal("expected selected upto requirement") -+ } -+ if selected.Scheme != "upto" { -+ t.Fatalf("unexpected scheme: %s", selected.Scheme) -+ } -+} -+ -+func TestSelectSVMChallengeHonorsPreferredCurrencyOrder(t *testing.T) { -+ body, err := json.Marshal(map[string]any{ -+ "accepts": []map[string]any{ -+ { -+ "scheme": "exact", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ "amount": "1000", -+ }, -+ { -+ "scheme": "exact", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", -+ "amount": "1000", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ selected, _ := selectSVMChallengeWithPreferences( -+ map[string]string{}, -+ string(body), -+ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "exact", -+ []string{"PYUSD", "USDC"}, -+ ) -+ -+ if selected == nil { -+ t.Fatal("expected selected requirement") -+ } -+ if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { -+ t.Fatalf("expected PYUSD mint, got %s", selected.Asset) -+ } -+} -+ -+func TestSelectSVMChallengeReturnsNilWhenPreferredCurrenciesDoNotMatch(t *testing.T) { -+ body, err := json.Marshal(map[string]any{ -+ "accepts": []map[string]any{ -+ { -+ "scheme": "exact", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ "amount": "1000", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ selected, _ := selectSVMChallengeWithPreferences( -+ map[string]string{}, -+ string(body), -+ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "exact", -+ []string{"PYUSD"}, -+ ) -+ -+ if selected != nil { -+ t.Fatalf("expected no selected requirement, got %+v", selected) -+ } -+} -+ -+func TestSelectSVMChallengeChecksBodyWhenHeaderPreferencesDoNotMatch(t *testing.T) { -+ headerEnvelope, err := json.Marshal(map[string]any{ -+ "accepts": []map[string]any{ -+ { -+ "scheme": "exact", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ "amount": "1000", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ body, err := json.Marshal(map[string]any{ -+ "resource": map[string]any{"uri": "/body"}, -+ "accepts": []map[string]any{ -+ { -+ "scheme": "exact", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", -+ "amount": "1000", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ selected, resource := selectSVMChallengeWithPreferences( -+ map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, -+ string(body), -+ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "exact", -+ []string{"PYUSD"}, -+ ) -+ -+ if selected == nil { -+ t.Fatal("expected selected requirement from body") -+ } -+ if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { -+ t.Fatalf("expected body PYUSD mint, got %s", selected.Asset) -+ } -+ if resource["uri"] != "/body" { -+ t.Fatalf("expected body resource, got %#v", resource) -+ } -+} -+ -+func TestSelectSVMChallengeWithoutPreferencesPicksCheapestAmount(t *testing.T) { -+ body, err := json.Marshal(map[string]any{ -+ "accepts": []map[string]any{ -+ { -+ "scheme": "exact", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ "amount": "1000000", -+ }, -+ { -+ "scheme": "exact", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "So11111111111111111111111111111111111111112", -+ "amount": "5000", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ selected, _ := selectSVMChallengeWithPreferences( -+ map[string]string{}, -+ string(body), -+ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "exact", -+ nil, -+ ) -+ -+ if selected == nil { -+ t.Fatal("expected selected requirement") -+ } -+ if selected.Asset != "So11111111111111111111111111111111111111112" { -+ t.Fatalf("expected cheapest offer, got %s", selected.Asset) -+ } -+} -+ -+func TestSelectSVMChallengeSkipsIncompleteAndMalformedCandidates(t *testing.T) { -+ body, err := json.Marshal(map[string]any{ -+ "accepts": []map[string]any{ -+ { -+ "scheme": "exact", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "", -+ "amount": "1", -+ }, -+ { -+ "scheme": "exact", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "So11111111111111111111111111111111111111112", -+ "amount": "not-int", -+ }, -+ { -+ "scheme": "exact", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ "amount": "1000", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ selected, _ := selectSVMChallengeWithPreferences( -+ map[string]string{}, -+ string(body), -+ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "exact", -+ nil, -+ ) -+ -+ if selected == nil { -+ t.Fatal("expected selected requirement") -+ } -+ if selected.Asset != "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" { -+ t.Fatalf("expected valid cheapest candidate, got %+v", selected) -+ } -+} -+ -+func TestSelectSVMChallengeUsesCurrencyPreferencesFromEnv(t *testing.T) { -+ t.Setenv("X402_INTEROP_PREFER_CURRENCIES", " PYUSD, USDC ,,") -+ body, err := json.Marshal(map[string]any{ -+ "resource": map[string]any{"uri": "/protected"}, -+ "accepts": []map[string]any{ -+ { -+ "scheme": "exact", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ "amount": "1000", -+ }, -+ { -+ "scheme": "exact", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", -+ "amount": "2000", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ selected, resource := selectSVMChallenge( -+ map[string]string{}, -+ string(body), -+ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "exact", -+ ) -+ -+ if selected == nil { -+ t.Fatal("expected selected requirement") -+ } -+ if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { -+ t.Fatalf("expected PYUSD preference to win, got %s", selected.Asset) -+ } -+ if resource["uri"] != "/protected" { -+ t.Fatalf("expected resource to be returned, got %+v", resource) -+ } -+} -+ -+func TestPaymentRequiredLoadersRejectMalformedInputs(t *testing.T) { -+ if envelope := loadPaymentRequiredHeader(map[string]string{"payment-required": "not base64"}); envelope != nil { -+ t.Fatalf("expected invalid base64 header to return nil") -+ } -+ encodedInvalidJSON := base64.StdEncoding.EncodeToString([]byte("{")) -+ if envelope := loadPaymentRequiredHeader(map[string]string{"payment-required": encodedInvalidJSON}); envelope != nil { -+ t.Fatalf("expected invalid JSON header to return nil") -+ } -+ if envelope := loadPaymentRequiredBody("{"); envelope != nil { -+ t.Fatalf("expected invalid JSON body to return nil") -+ } -+ if envelope := loadPaymentRequiredBody(""); envelope != nil { -+ t.Fatalf("expected empty body to return nil") -+ } -+} -+ -+func TestResolveStablecoinMintCanonicalAliases(t *testing.T) { -+ tests := map[string]struct { -+ currency string -+ network string -+ want string -+ }{ -+ "devnet USD alias": { -+ currency: " usd ", -+ network: "devnet", -+ want: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ }, -+ "mainnet PYUSD": { -+ currency: "PYUSD", -+ network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", -+ want: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", -+ }, -+ "localnet USDG": { -+ currency: "USDG", -+ network: "localnet", -+ want: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", -+ }, -+ "USDT": { -+ currency: "USDT", -+ network: "devnet", -+ want: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", -+ }, -+ "CASH": { -+ currency: "CASH", -+ network: "devnet", -+ want: "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH", -+ }, -+ "mint passthrough": { -+ currency: " So11111111111111111111111111111111111111112 ", -+ network: "devnet", -+ want: "So11111111111111111111111111111111111111112", -+ }, -+ } -+ -+ for name, test := range tests { -+ t.Run(name, func(t *testing.T) { -+ if got := resolveStablecoinMint(test.currency, test.network); got != test.want { -+ t.Fatalf("resolveStablecoinMint() = %q, want %q", got, test.want) -+ } -+ }) -+ } -+} -+ -+func TestRequirementExtraParsersValidateTypes(t *testing.T) { -+ requirement := paymentRequirement{ -+ Extra: map[string]any{ -+ "decimalsFloat": float64(6), -+ "decimalsText": "9", -+ "tokenProgram": solana.TokenProgramID.String(), -+ "badInteger": "not-int", -+ "badString": 12, -+ "emptyString": "", -+ }, -+ } -+ -+ if got, err := intFromRequirement(requirement, "decimalsFloat"); err != nil || got != 6 { -+ t.Fatalf("float integer = %d, %v", got, err) -+ } -+ if got, err := intFromRequirement(requirement, "decimalsText"); err != nil || got != 9 { -+ t.Fatalf("string integer = %d, %v", got, err) -+ } -+ if _, err := intFromRequirement(requirement, "missing"); err == nil { -+ t.Fatal("expected missing integer error") -+ } -+ if _, err := intFromRequirement(requirement, "badInteger"); err == nil { -+ t.Fatal("expected invalid integer error") -+ } -+ if _, err := intFromRequirement(paymentRequirement{Extra: map[string]any{"bad": true}}, "bad"); err == nil { -+ t.Fatal("expected invalid integer type error") -+ } -+ if got, err := stringFromExtra(requirement, "tokenProgram"); err != nil || got != solana.TokenProgramID.String() { -+ t.Fatalf("string extra = %q, %v", got, err) -+ } -+ if _, err := stringFromExtra(requirement, "missing"); err == nil { -+ t.Fatal("expected missing string error") -+ } -+ if _, err := stringFromExtra(requirement, "badString"); err == nil { -+ t.Fatal("expected invalid string type error") -+ } -+ if _, err := stringFromExtra(requirement, "emptyString"); err == nil { -+ t.Fatal("expected empty string error") -+ } -+} -+ -+func TestKeypairFromJSONSecretValidatesShape(t *testing.T) { -+ privateKey, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ encoded, err := json.Marshal([]byte(privateKey)) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ decoded, err := keypairFromJSONSecret(string(encoded)) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if !decoded.PublicKey().Equals(privateKey.PublicKey()) { -+ t.Fatalf("decoded key does not match original") -+ } -+ if _, err := keypairFromJSONSecret("{"); err == nil { -+ t.Fatal("expected JSON decode error") -+ } -+ if _, err := keypairFromJSONSecret("[1,2,3]"); err == nil { -+ t.Fatal("expected length validation error") -+ } -+} -+ -+func TestLatestBlockhashHandlesJSONRPCResponses(t *testing.T) { -+ originalHTTPClient := httpClient -+ defer func() { -+ httpClient = originalHTTPClient -+ }() -+ -+ blockhash := solana.Hash{}.String() -+ httpClient = &http.Client{Transport: clientRoundTripFunc(func(request *http.Request) (*http.Response, error) { -+ rawBody, err := io.ReadAll(request.Body) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if !strings.Contains(string(rawBody), `"method":"getLatestBlockhash"`) { -+ t.Fatalf("unexpected RPC body: %s", string(rawBody)) -+ } -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"value":{"blockhash":"` + blockhash + `"}}}`)), -+ }, nil -+ })} -+ -+ got, err := latestBlockhash("http://rpc.test") -+ if err != nil { -+ t.Fatal(err) -+ } -+ if got.String() != blockhash { -+ t.Fatalf("latestBlockhash = %s, want %s", got, blockhash) -+ } -+ -+ httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"error":{"message":"nope"}}`)), -+ }, nil -+ })} -+ if _, err := latestBlockhash("http://rpc.test"); err == nil { -+ t.Fatal("expected RPC error") -+ } -+ -+ httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { -+ return &http.Response{ -+ StatusCode: http.StatusBadGateway, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(`bad gateway`)), -+ }, nil -+ })} -+ if _, err := latestBlockhash("http://rpc.test"); err == nil { -+ t.Fatal("expected HTTP error") -+ } -+ -+ httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(`{`)), -+ }, nil -+ })} -+ if _, err := latestBlockhash("http://rpc.test"); err == nil { -+ t.Fatal("expected invalid JSON error") -+ } -+} -+ -+func TestLatestBlockhashReturnsTransportErrors(t *testing.T) { -+ originalHTTPClient := httpClient -+ defer func() { -+ httpClient = originalHTTPClient -+ }() -+ -+ httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { -+ return nil, errors.New("rpc unavailable") -+ })} -+ if _, err := latestBlockhash("http://rpc.test"); err == nil { -+ t.Fatal("expected transport error") -+ } -+} -+ -+func TestTransferCheckedInstructionRejectsMalformedRequirement(t *testing.T) { -+ signer := solana.NewWallet().PublicKey() -+ base := paymentRequirement{ -+ Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ Amount: "1000", -+ PayTo: solana.NewWallet().PublicKey().String(), -+ } -+ -+ tests := map[string]paymentRequirement{ -+ "amount": func() paymentRequirement { -+ requirement := base -+ requirement.Amount = "not-int" -+ return requirement -+ }(), -+ "asset": func() paymentRequirement { -+ requirement := base -+ requirement.Asset = "not-base58" -+ return requirement -+ }(), -+ "payTo": func() paymentRequirement { -+ requirement := base -+ requirement.PayTo = "not-base58" -+ return requirement -+ }(), -+ } -+ -+ for name, requirement := range tests { -+ t.Run(name, func(t *testing.T) { -+ if _, err := transferCheckedInstruction(requirement, signer, 6, solana.TokenProgramID); err == nil { -+ t.Fatal("expected malformed requirement to be rejected") -+ } -+ }) -+ } -+} -+ -+func TestReadResponseAndParseResponseBody(t *testing.T) { -+ response := &http.Response{ -+ Header: http.Header{ -+ "X-Test": []string{"first", "second"}, -+ }, -+ Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), -+ } -+ -+ headers, body, err := readResponse(response) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if headers["X-Test"] != "first" { -+ t.Fatalf("expected first header value, got %q", headers["X-Test"]) -+ } -+ if body != `{"ok":true}` { -+ t.Fatalf("unexpected body: %s", body) -+ } -+ parsed, ok := parseResponseBody(body).(map[string]any) -+ if !ok || parsed["ok"] != true { -+ t.Fatalf("expected JSON body to parse, got %#v", parsed) -+ } -+ if got := parseResponseBody("not json"); got != "not json" { -+ t.Fatalf("expected invalid JSON body passthrough, got %#v", got) -+ } -+ t.Setenv("X402_TEST_DEFAULT", "configured") -+ if got := readEnvWithDefault("X402_TEST_DEFAULT", "fallback"); got != "configured" { -+ t.Fatalf("readEnvWithDefault configured = %q", got) -+ } -+ if got := readEnvWithDefault("X402_TEST_MISSING", "fallback"); got != "fallback" { -+ t.Fatalf("readEnvWithDefault fallback = %q", got) -+ } -+} -+ -+func TestMainReportsUnimplementedChallengeResult(t *testing.T) { -+ originalHTTPClient := httpClient -+ defer func() { -+ httpClient = originalHTTPClient -+ }() -+ httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { -+ return &http.Response{ -+ StatusCode: http.StatusPaymentRequired, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(`{"accepts":[{"scheme":"upto","network":"solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1","asset":"4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU","amount":"1000"}]}`)), -+ }, nil -+ })} -+ -+ t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") -+ t.Setenv("X402_INTEROP_SCHEME", "upto") -+ -+ output := captureStdoutForTest(t, main) -+ var payload map[string]any -+ if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil { -+ t.Fatal(err) -+ } -+ if payload["implementation"] != "go" || payload["role"] != "client" || payload["ok"] != false { -+ t.Fatalf("unexpected result payload: %#v", payload) -+ } -+ body := payload["responseBody"].(map[string]any) -+ if body["error"] != "go_upto_client_not_implemented" { -+ t.Fatalf("unexpected error domain: %#v", body) -+ } -+} -+ -+func TestMainPanicsWhenTargetURLMissing(t *testing.T) { -+ mustPanicClient(t, main) -+} -+ -+func TestMainPanicsWhenChallengeRequestFails(t *testing.T) { -+ originalHTTPClient := httpClient -+ defer func() { -+ httpClient = originalHTTPClient -+ }() -+ httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { -+ return nil, errors.New("network down") -+ })} -+ -+ t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") -+ -+ mustPanicClient(t, main) -+} -+ -+func TestMainReportsExactPaymentBuildFailure(t *testing.T) { -+ requirement := map[string]any{ -+ "scheme": "exact", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ "amount": "1000", -+ "payTo": solana.NewWallet().PublicKey().String(), -+ "extra": map[string]any{ -+ "decimals": 6, -+ "feePayer": solana.NewWallet().PublicKey().String(), -+ "tokenProgram": solana.TokenProgramID.String(), -+ }, -+ } -+ challenge, err := json.Marshal(paymentEnvelope{ -+ Accepts: []paymentRequirement{{ -+ Scheme: requirement["scheme"].(string), -+ Network: requirement["network"].(string), -+ Asset: requirement["asset"].(string), -+ Amount: requirement["amount"].(string), -+ PayTo: requirement["payTo"].(string), -+ Extra: requirement["extra"].(map[string]any), -+ }}, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ originalHTTPClient := httpClient -+ defer func() { -+ httpClient = originalHTTPClient -+ }() -+ httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { -+ return &http.Response{ -+ StatusCode: http.StatusPaymentRequired, -+ Header: http.Header{ -+ "PAYMENT-REQUIRED": []string{base64.StdEncoding.EncodeToString(challenge)}, -+ "content-type": []string{"application/json"}, -+ }, -+ Body: io.NopCloser(strings.NewReader(`{"error":"payment_required"}`)), -+ }, nil -+ })} -+ -+ t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") -+ t.Setenv("X402_INTEROP_CLIENT_SECRET_KEY", "{") -+ t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") -+ -+ output := captureStdoutForTest(t, main) -+ var payload map[string]any -+ if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil { -+ t.Fatal(err) -+ } -+ if payload["ok"] != false || payload["status"] != float64(http.StatusPaymentRequired) { -+ t.Fatalf("unexpected payment failure result: %#v", payload) -+ } -+ body := payload["responseBody"].(map[string]any) -+ if body["error"] != "go_exact_client_payment_failed" { -+ t.Fatalf("unexpected payment failure body: %#v", body) -+ } -+} -+ -+func TestMainPaysExactChallengeAndReportsSettlement(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ encodedClientKey, err := json.Marshal([]byte(client)) -+ if err != nil { -+ t.Fatal(err) -+ } -+ feePayer := solana.NewWallet().PublicKey() -+ payTo := solana.NewWallet().PublicKey() -+ challenge, err := json.Marshal(paymentEnvelope{ -+ Accepts: []paymentRequirement{ -+ { -+ Scheme: "exact", -+ Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ Amount: "1000", -+ PayTo: payTo.String(), -+ Extra: map[string]any{ -+ "decimals": float64(6), -+ "feePayer": feePayer.String(), -+ "tokenProgram": solana.TokenProgramID.String(), -+ "recentBlockhash": solana.Hash{}.String(), -+ "memo": "unit-main-success", -+ }, -+ }, -+ }, -+ Resource: map[string]any{"uri": "/protected"}, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ originalHTTPClient := httpClient -+ defer func() { -+ httpClient = originalHTTPClient -+ }() -+ requests := 0 -+ httpClient = &http.Client{Transport: clientRoundTripFunc(func(request *http.Request) (*http.Response, error) { -+ requests++ -+ if requests == 1 { -+ return &http.Response{ -+ StatusCode: http.StatusPaymentRequired, -+ Header: http.Header{ -+ "PAYMENT-REQUIRED": []string{base64.StdEncoding.EncodeToString(challenge)}, -+ "content-type": []string{"application/json"}, -+ }, -+ Body: io.NopCloser(strings.NewReader(`{"error":"payment_required"}`)), -+ }, nil -+ } -+ if got := request.Header.Get("PAYMENT-SIGNATURE"); got == "" { -+ t.Fatal("expected PAYMENT-SIGNATURE on paid retry") -+ } -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{ -+ "x-fixture-settlement": []string{"unit-settlement"}, -+ "content-type": []string{"application/json"}, -+ }, -+ Body: io.NopCloser(strings.NewReader(`{"ok":true,"paid":true}`)), -+ }, nil -+ })} -+ -+ t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") -+ t.Setenv("X402_INTEROP_CLIENT_SECRET_KEY", string(encodedClientKey)) -+ t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") -+ -+ output := captureStdoutForTest(t, main) -+ var payload map[string]any -+ if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil { -+ t.Fatal(err) -+ } -+ if payload["ok"] != true || payload["status"] != float64(http.StatusOK) || payload["settlement"] != "unit-settlement" { -+ t.Fatalf("unexpected paid result: %#v", payload) -+ } -+ if requests != 2 { -+ t.Fatalf("expected challenge request plus paid retry, got %d", requests) -+ } -+} -+ -+func TestBuildExactPaymentSignatureEnvelope(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ feePayer, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ payTo, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ requirement := paymentRequirement{ -+ Scheme: "exact", -+ Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ Amount: "1000", -+ PayTo: payTo.PublicKey().String(), -+ MaxTimeoutSeconds: 60, -+ Extra: map[string]any{ -+ "feePayer": feePayer.PublicKey().String(), -+ "decimals": float64(6), -+ "tokenProgram": solana.TokenProgramID.String(), -+ "recentBlockhash": solana.Hash{}.String(), -+ "memo": "unit-test", -+ }, -+ } -+ resource := map[string]any{ -+ "url": "/protected", -+ "description": "test", -+ } -+ -+ header, err := buildExactPaymentSignature(requirement, resource, client, "http://127.0.0.1:8899") -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ decoded, err := base64.StdEncoding.DecodeString(header) -+ if err != nil { -+ t.Fatal(err) -+ } -+ var envelope paymentSignatureEnvelope -+ if err := json.Unmarshal(decoded, &envelope); err != nil { -+ t.Fatal(err) -+ } -+ if envelope.X402Version != 2 { -+ t.Fatalf("unexpected x402Version: %d", envelope.X402Version) -+ } -+ if envelope.Accepted.MaxTimeoutSeconds != requirement.MaxTimeoutSeconds { -+ t.Fatalf("accepted did not preserve maxTimeoutSeconds") -+ } -+ if envelope.Payload["transaction"] == "" { -+ t.Fatalf("expected transaction payload") -+ } -+ -+ tx := new(solana.Transaction) -+ if err := tx.UnmarshalBase64(envelope.Payload["transaction"]); err != nil { -+ t.Fatal(err) -+ } -+ if !tx.Message.IsVersioned() { -+ t.Fatalf("expected v0 transaction") -+ } -+ -+ signerIndex := -1 -+ feePayerIndex := -1 -+ for index, key := range tx.Message.AccountKeys { -+ if key.Equals(client.PublicKey()) { -+ signerIndex = index -+ } -+ if key.Equals(feePayer.PublicKey()) { -+ feePayerIndex = index -+ } -+ } -+ if signerIndex < 0 { -+ t.Fatalf("client signer missing from transaction") -+ } -+ if feePayerIndex < 0 { -+ t.Fatalf("fee payer missing from transaction") -+ } -+ if tx.Signatures[feePayerIndex] != (solana.Signature{}) { -+ t.Fatalf("fee payer signature should remain default") -+ } -+ message, err := tx.Message.MarshalBinary() -+ if err != nil { -+ t.Fatal(err) -+ } -+ if !tx.Signatures[signerIndex].Verify(client.PublicKey(), message) { -+ t.Fatalf("client signature did not verify") -+ } -+} -+ -+func TestBuildExactPaymentSignatureFetchesRecentBlockhashWhenMissing(t *testing.T) { -+ originalHTTPClient := httpClient -+ defer func() { -+ httpClient = originalHTTPClient -+ }() -+ blockhash := solana.Hash{}.String() -+ httpClient = &http.Client{Transport: clientRoundTripFunc(func(request *http.Request) (*http.Response, error) { -+ rawBody, err := io.ReadAll(request.Body) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if !strings.Contains(string(rawBody), `"method":"getLatestBlockhash"`) { -+ t.Fatalf("unexpected RPC body: %s", string(rawBody)) -+ } -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"value":{"blockhash":"` + blockhash + `"}}}`)), -+ }, nil -+ })} -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ requirement := paymentRequirement{ -+ Scheme: "exact", -+ Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ Amount: "1000", -+ PayTo: solana.NewWallet().PublicKey().String(), -+ Extra: map[string]any{ -+ "feePayer": solana.NewWallet().PublicKey().String(), -+ "decimals": float64(6), -+ "tokenProgram": solana.TokenProgramID.String(), -+ "memo": "unit-fetch-blockhash", -+ }, -+ } -+ -+ header, err := buildExactPaymentSignature(requirement, nil, client, "http://rpc.test") -+ if err != nil { -+ t.Fatal(err) -+ } -+ if header == "" { -+ t.Fatal("expected payment signature") -+ } -+} -+ -+func TestBuildExactPaymentSignatureRejectsInvalidRequirements(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ requirement := paymentRequirement{ -+ Scheme: "exact", -+ Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ Amount: "1000", -+ PayTo: solana.NewWallet().PublicKey().String(), -+ Extra: map[string]any{ -+ "feePayer": solana.NewWallet().PublicKey().String(), -+ "decimals": float64(6), -+ "tokenProgram": solana.TokenProgramID.String(), -+ "recentBlockhash": solana.Hash{}.String(), -+ "memo": "unit-test", -+ }, -+ } -+ -+ tests := map[string]func(paymentRequirement) paymentRequirement{ -+ "scheme": func(value paymentRequirement) paymentRequirement { -+ value.Scheme = "upto" -+ return value -+ }, -+ "missing decimals": func(value paymentRequirement) paymentRequirement { -+ value.Extra = cloneClientExtra(value.Extra) -+ delete(value.Extra, "decimals") -+ return value -+ }, -+ "invalid token program": func(value paymentRequirement) paymentRequirement { -+ value.Extra = cloneClientExtra(value.Extra) -+ value.Extra["tokenProgram"] = "not-base58" -+ return value -+ }, -+ "invalid fee payer": func(value paymentRequirement) paymentRequirement { -+ value.Extra = cloneClientExtra(value.Extra) -+ value.Extra["feePayer"] = "not-base58" -+ return value -+ }, -+ "invalid blockhash": func(value paymentRequirement) paymentRequirement { -+ value.Extra = cloneClientExtra(value.Extra) -+ value.Extra["recentBlockhash"] = "not-base58" -+ return value -+ }, -+ "invalid amount": func(value paymentRequirement) paymentRequirement { -+ value.Amount = "not-int" -+ return value -+ }, -+ "invalid payTo": func(value paymentRequirement) paymentRequirement { -+ value.PayTo = "not-base58" -+ return value -+ }, -+ } -+ -+ for name, mutate := range tests { -+ t.Run(name, func(t *testing.T) { -+ if _, err := buildExactPaymentSignature(mutate(requirement), nil, client, "http://127.0.0.1:8899"); err == nil { -+ t.Fatal("expected invalid requirement to be rejected") -+ } -+ }) -+ } -+} -+ -+func TestBuildExactPaymentSignatureGeneratesUniqueDefaultMemos(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ feePayer, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ payTo, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ requirement := paymentRequirement{ -+ Scheme: "exact", -+ Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ Amount: "1000", -+ PayTo: payTo.PublicKey().String(), -+ Extra: map[string]any{ -+ "feePayer": feePayer.PublicKey().String(), -+ "decimals": float64(6), -+ "tokenProgram": solana.TokenProgramID.String(), -+ "recentBlockhash": solana.Hash{}.String(), -+ }, -+ } -+ -+ firstHeader, err := buildExactPaymentSignature(requirement, nil, client, "http://127.0.0.1:8899") -+ if err != nil { -+ t.Fatal(err) -+ } -+ secondHeader, err := buildExactPaymentSignature(requirement, nil, client, "http://127.0.0.1:8899") -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ firstMemo := memoFromPaymentHeaderForTest(t, firstHeader) -+ secondMemo := memoFromPaymentHeaderForTest(t, secondHeader) -+ if firstHeader == secondHeader { -+ t.Fatal("expected unique payment headers") -+ } -+ if firstMemo == secondMemo { -+ t.Fatalf("expected unique default memos, got %q", firstMemo) -+ } -+ if len(firstMemo) != 32 || len(secondMemo) != 32 { -+ t.Fatalf("expected 32 byte hex memos, got %d and %d", len(firstMemo), len(secondMemo)) -+ } -+ if strings.Trim(firstMemo+secondMemo, "0123456789abcdef") != "" { -+ t.Fatalf("expected lowercase hex memos, got %q and %q", firstMemo, secondMemo) -+ } -+} -+ -+func TestBuildExactPaymentSignatureRejectsMemoAboveReferenceLimit(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ feePayer, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ payTo, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ requirement := paymentRequirement{ -+ Scheme: "exact", -+ Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ Amount: "1000", -+ PayTo: payTo.PublicKey().String(), -+ Extra: map[string]any{ -+ "feePayer": feePayer.PublicKey().String(), -+ "decimals": float64(6), -+ "tokenProgram": solana.TokenProgramID.String(), -+ "recentBlockhash": solana.Hash{}.String(), -+ "memo": strings.Repeat("x", maxMemoBytes+1), -+ }, -+ } -+ -+ _, err = buildExactPaymentSignature(requirement, nil, client, "http://127.0.0.1:8899") -+ if err == nil { -+ t.Fatal("expected memo length error") -+ } -+ if err.Error() != "extra.memo exceeds maximum 256 bytes" { -+ t.Fatalf("unexpected error: %v", err) -+ } -+} -+ -+func memoFromPaymentHeaderForTest(t *testing.T, header string) string { -+ t.Helper() -+ decoded, err := base64.StdEncoding.DecodeString(header) -+ if err != nil { -+ t.Fatal(err) -+ } -+ var envelope paymentSignatureEnvelope -+ if err := json.Unmarshal(decoded, &envelope); err != nil { -+ t.Fatal(err) -+ } -+ tx := new(solana.Transaction) -+ if err := tx.UnmarshalBase64(envelope.Payload["transaction"]); err != nil { -+ t.Fatal(err) -+ } -+ for _, instruction := range tx.Message.Instructions { -+ program, err := tx.Message.Program(instruction.ProgramIDIndex) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if program.Equals(memoProgramID) { -+ return string(instruction.Data) -+ } -+ } -+ t.Fatal("memo instruction missing") -+ return "" -+} -+ -+type clientRoundTripFunc func(*http.Request) (*http.Response, error) -+ -+func (fn clientRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { -+ return fn(request) -+} -+ -+func cloneClientExtra(extra map[string]any) map[string]any { -+ cloned := make(map[string]any, len(extra)) -+ for key, value := range extra { -+ cloned[key] = value -+ } -+ return cloned -+} -+ -+func captureStdoutForTest(t *testing.T, fn func()) string { -+ t.Helper() -+ original := os.Stdout -+ reader, writer, err := os.Pipe() -+ if err != nil { -+ t.Fatal(err) -+ } -+ os.Stdout = writer -+ defer func() { -+ os.Stdout = original -+ }() -+ -+ fn() -+ if err := writer.Close(); err != nil { -+ t.Fatal(err) -+ } -+ output, err := io.ReadAll(reader) -+ if err != nil { -+ t.Fatal(err) -+ } -+ return string(output) -+} -+ -+func mustPanicClient(t *testing.T, fn func()) { -+ t.Helper() -+ defer func() { -+ if recover() == nil { -+ t.Fatal("expected panic") -+ } -+ }() -+ fn() -+} -+ -+// --- Greptile PR #18 follow-up: cross-envelope preference / fallback parity --- -+// -+// These three tests pin the cross-envelope behavior Greptile flagged as -+// "absent regression coverage". They exercise the boundary between header and -+// body envelopes — both with and without a currency preference — so future -+// refactors can't silently regress the fallback path. -+ -+// TestSelectSVMChallengeFallsBackToBodyWhenHeaderPreferenceMisses verifies -+// that when the PAYMENT-REQUIRED header offers only USDC but the body offers -+// PYUSD and the caller prefers ["PYUSD"], the client falls through the header -+// envelope and selects the PYUSD entry from the body envelope. -+func TestSelectSVMChallengeFallsBackToBodyWhenHeaderPreferenceMisses(t *testing.T) { -+ network := "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" -+ headerEnvelope, err := json.Marshal(map[string]any{ -+ "resource": map[string]any{"uri": "/header"}, -+ "accepts": []map[string]any{ -+ { -+ "scheme": "exact", -+ "network": network, -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // devnet USDC -+ "amount": "1000", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ body, err := json.Marshal(map[string]any{ -+ "resource": map[string]any{"uri": "/body"}, -+ "accepts": []map[string]any{ -+ { -+ "scheme": "exact", -+ "network": network, -+ "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", // devnet PYUSD -+ "amount": "2000", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ selected, resource := selectSVMChallengeWithPreferences( -+ map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, -+ string(body), -+ network, -+ "exact", -+ []string{"PYUSD"}, -+ ) -+ -+ if selected == nil { -+ t.Fatal("expected fallback selection from body envelope") -+ } -+ if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { -+ t.Fatalf("expected body PYUSD mint, got %s", selected.Asset) -+ } -+ if resource["uri"] != "/body" { -+ t.Fatalf("expected body resource attribution, got %#v", resource) -+ } -+} -+ -+// TestSelectSVMChallengeReturnsNilWhenNoEnvelopeMatchesPreference verifies -+// that a strict preference list with no match across any envelope returns nil -+// rather than silently downgrading to "any" selection. This locks the caller's -+// opt-in: if you said "I only accept BOGUS", you get nothing, not USDC. -+func TestSelectSVMChallengeReturnsNilWhenNoEnvelopeMatchesPreference(t *testing.T) { -+ network := "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" -+ headerEnvelope, err := json.Marshal(map[string]any{ -+ "accepts": []map[string]any{ -+ { -+ "scheme": "exact", -+ "network": network, -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // USDC -+ "amount": "1000", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ body, err := json.Marshal(map[string]any{ -+ "accepts": []map[string]any{ -+ { -+ "scheme": "exact", -+ "network": network, -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // USDC -+ "amount": "1500", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ selected, resource := selectSVMChallengeWithPreferences( -+ map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, -+ string(body), -+ network, -+ "exact", -+ []string{"BOGUS"}, -+ ) -+ -+ if selected != nil { -+ t.Fatalf("expected nil selection for unmet preference, got %+v", selected) -+ } -+ if resource != nil { -+ t.Fatalf("expected nil resource for unmet preference, got %#v", resource) -+ } -+} -+ -+// TestSelectSVMChallengePicksCheapestAcrossEnvelopesWhenNoPreference verifies -+// that, when no preference is supplied, the selector aggregates valid -+// candidates across the header and body envelopes and picks the globally -+// cheapest amount — not merely the cheapest within the first envelope it sees. -+// Header: 2000 USDC. Body: 1000 PYUSD. Expected: 1000 PYUSD with body's -+// resource block. -+func TestSelectSVMChallengePicksCheapestAcrossEnvelopesWhenNoPreference(t *testing.T) { -+ network := "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" -+ headerEnvelope, err := json.Marshal(map[string]any{ -+ "resource": map[string]any{"uri": "/header"}, -+ "accepts": []map[string]any{ -+ { -+ "scheme": "exact", -+ "network": network, -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // USDC -+ "amount": "2000", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ body, err := json.Marshal(map[string]any{ -+ "resource": map[string]any{"uri": "/body"}, -+ "accepts": []map[string]any{ -+ { -+ "scheme": "exact", -+ "network": network, -+ "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", // PYUSD -+ "amount": "1000", -+ }, -+ }, -+ }) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ selected, resource := selectSVMChallengeWithPreferences( -+ map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, -+ string(body), -+ network, -+ "exact", -+ nil, -+ ) -+ -+ if selected == nil { -+ t.Fatal("expected cross-envelope cheapest selection") -+ } -+ if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { -+ t.Fatalf("expected body PYUSD (cheapest), got %s @ %s", selected.Asset, selected.Amount) -+ } -+ if selected.Amount != "1000" { -+ t.Fatalf("expected amount 1000, got %s", selected.Amount) -+ } -+ if resource["uri"] != "/body" { -+ t.Fatalf("expected body resource attribution, got %#v", resource) -+ } -+} -diff --git a/go/x402/cmd/interop-client/main.go b/go/x402/cmd/interop-client/main.go -new file mode 100644 -index 0000000..1ec54de ---- /dev/null -+++ b/go/x402/cmd/interop-client/main.go -@@ -0,0 +1,624 @@ -+package main -+ -+import ( -+ "bytes" -+ "crypto/rand" -+ "encoding/base64" -+ "encoding/binary" -+ "encoding/hex" -+ "encoding/json" -+ "fmt" -+ "io" -+ "net/http" -+ "os" -+ "strconv" -+ "strings" -+ "time" -+ -+ "github.com/gagliardetto/solana-go" -+) -+ -+type paymentEnvelope struct { -+ Resource map[string]any `json:"resource,omitempty"` -+ Accepts []paymentRequirement `json:"accepts"` -+} -+ -+type paymentRequirement struct { -+ Scheme string `json:"scheme"` -+ Network string `json:"network"` -+ Asset string `json:"asset"` -+ Amount string `json:"amount"` -+ PayTo string `json:"payTo"` -+ MaxTimeoutSeconds int `json:"maxTimeoutSeconds,omitempty"` -+ Extra map[string]any `json:"extra"` -+} -+ -+type paymentSignatureEnvelope struct { -+ X402Version int `json:"x402Version"` -+ Accepted paymentRequirement `json:"accepted"` -+ Resource map[string]any `json:"resource,omitempty"` -+ Payload map[string]string `json:"payload"` -+} -+ -+const ( -+ defaultComputeUnitLimit = 20_000 -+ defaultComputeUnitPriceMicrolamport = 1 -+ maxMemoBytes = 256 -+) -+ -+var ( -+ computeBudgetProgramID = solana.MustPublicKeyFromBase58("ComputeBudget111111111111111111111111111111") -+ memoProgramID = solana.MustPublicKeyFromBase58("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr") -+ httpClient = &http.Client{Timeout: 10 * time.Second} -+) -+ -+func headerValue(headers map[string]string, name string) string { -+ for key, value := range headers { -+ if strings.EqualFold(key, name) { -+ return value -+ } -+ } -+ return "" -+} -+ -+func loadPaymentRequiredHeader(headers map[string]string) *paymentEnvelope { -+ encoded := headerValue(headers, "PAYMENT-REQUIRED") -+ if encoded == "" { -+ return nil -+ } -+ -+ decoded, err := base64.StdEncoding.DecodeString(encoded) -+ if err != nil { -+ return nil -+ } -+ -+ var envelope paymentEnvelope -+ if err := json.Unmarshal(decoded, &envelope); err != nil { -+ return nil -+ } -+ return &envelope -+} -+ -+func loadPaymentRequiredBody(body string) *paymentEnvelope { -+ if body == "" { -+ return nil -+ } -+ -+ var envelope paymentEnvelope -+ if err := json.Unmarshal([]byte(body), &envelope); err != nil { -+ return nil -+ } -+ return &envelope -+} -+ -+func selectSVMRequirement(headers map[string]string, body string, network string, scheme string) *paymentRequirement { -+ requirement, _ := selectSVMChallengeWithPreferences(headers, body, network, scheme, nil) -+ return requirement -+} -+ -+func selectSVMChallenge(headers map[string]string, body string, network string, scheme string) (*paymentRequirement, map[string]any) { -+ return selectSVMChallengeWithPreferences(headers, body, network, scheme, parseCSVEnv("X402_INTEROP_PREFER_CURRENCIES")) -+} -+ -+func selectSVMChallengeWithPreferences(headers map[string]string, body string, network string, scheme string, preferredCurrencies []string) (*paymentRequirement, map[string]any) { -+ envelopes := []*paymentEnvelope{ -+ loadPaymentRequiredHeader(headers), -+ loadPaymentRequiredBody(body), -+ } -+ -+ // Preference path: envelope-by-envelope fallback. Each preferred currency -+ // is searched against each envelope in order; the first match wins. If no -+ // envelope satisfies the preference list we return nil (caller's strict -+ // opt-in is preserved instead of silently downgrading to "any" selection). -+ if len(preferredCurrencies) > 0 { -+ for _, envelope := range envelopes { -+ if envelope == nil { -+ continue -+ } -+ candidates := filterCandidates(envelope.Accepts, scheme, network) -+ if len(candidates) == 0 { -+ continue -+ } -+ for _, preferred := range preferredCurrencies { -+ for _, requirement := range candidates { -+ if currenciesMatch(requirement.Asset, preferred, network) { -+ selected := requirement -+ return &selected, envelope.Resource -+ } -+ } -+ } -+ } -+ return nil, nil -+ } -+ -+ // No-preference path: aggregate valid candidates from ALL envelopes and -+ // pick the globally cheapest amount. Resource attribution follows the -+ // envelope that contributed the winning candidate so downstream telemetry -+ // and signing flows see the correct context. -+ type candidateEntry struct { -+ requirement paymentRequirement -+ resource map[string]any -+ } -+ var entries []candidateEntry -+ for _, envelope := range envelopes { -+ if envelope == nil { -+ continue -+ } -+ for _, requirement := range filterCandidates(envelope.Accepts, scheme, network) { -+ entries = append(entries, candidateEntry{requirement: requirement, resource: envelope.Resource}) -+ } -+ } -+ if len(entries) == 0 { -+ return nil, nil -+ } -+ winner := entries[0] -+ winnerAmount, err := strconv.ParseUint(winner.requirement.Amount, 10, 64) -+ if err != nil { -+ winnerAmount = ^uint64(0) -+ } -+ for _, entry := range entries[1:] { -+ amount, err := strconv.ParseUint(entry.requirement.Amount, 10, 64) -+ if err != nil { -+ amount = ^uint64(0) -+ } -+ if amount < winnerAmount { -+ winner = entry -+ winnerAmount = amount -+ } -+ } -+ selected := winner.requirement -+ return &selected, winner.resource -+} -+ -+func filterCandidates(accepts []paymentRequirement, scheme string, network string) []paymentRequirement { -+ candidates := make([]paymentRequirement, 0, len(accepts)) -+ for _, requirement := range accepts { -+ if requirement.Scheme != scheme { -+ continue -+ } -+ if requirement.Network != network { -+ continue -+ } -+ if requirement.Asset == "" || requirement.Amount == "" { -+ continue -+ } -+ candidates = append(candidates, requirement) -+ } -+ return candidates -+} -+ -+func parseCSVEnv(name string) []string { -+ raw := os.Getenv(name) -+ if raw == "" { -+ return nil -+ } -+ parts := strings.Split(raw, ",") -+ values := make([]string, 0, len(parts)) -+ for _, part := range parts { -+ trimmed := strings.TrimSpace(part) -+ if trimmed != "" { -+ values = append(values, trimmed) -+ } -+ } -+ return values -+} -+ -+func currenciesMatch(offered string, accepted string, network string) bool { -+ return resolveStablecoinMint(offered, network) == resolveStablecoinMint(accepted, network) -+} -+ -+func resolveStablecoinMint(currency string, network string) string { -+ upper := strings.ToUpper(strings.TrimSpace(currency)) -+ switch upper { -+ case "USDC", "USD": -+ if network == "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" || network == "devnet" || network == "localnet" { -+ return "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" -+ } -+ return "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" -+ case "PYUSD": -+ if network == "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" || network == "devnet" || network == "localnet" { -+ return "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" -+ } -+ return "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo" -+ case "USDG": -+ if network == "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" || network == "devnet" || network == "localnet" { -+ return "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7" -+ } -+ return "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH" -+ case "USDT": -+ return "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" -+ case "CASH": -+ return "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH" -+ default: -+ return strings.TrimSpace(currency) -+ } -+} -+ -+func intFromRequirement(requirement paymentRequirement, key string) (uint64, error) { -+ value, ok := requirement.Extra[key] -+ if !ok { -+ return 0, fmt.Errorf("payment requirement is missing integer extra.%s", key) -+ } -+ -+ switch typed := value.(type) { -+ case float64: -+ return uint64(typed), nil -+ case string: -+ parsed, err := strconv.ParseUint(typed, 10, 64) -+ if err != nil { -+ return 0, fmt.Errorf("invalid integer extra.%s: %w", key, err) -+ } -+ return parsed, nil -+ default: -+ return 0, fmt.Errorf("payment requirement has invalid integer extra.%s", key) -+ } -+} -+ -+func stringFromExtra(requirement paymentRequirement, key string) (string, error) { -+ value, ok := requirement.Extra[key] -+ if !ok { -+ return "", fmt.Errorf("payment requirement is missing extra.%s", key) -+ } -+ typed, ok := value.(string) -+ if !ok || typed == "" { -+ return "", fmt.Errorf("payment requirement has invalid extra.%s", key) -+ } -+ return typed, nil -+} -+ -+func keypairFromJSONSecret(raw string) (solana.PrivateKey, error) { -+ var values []byte -+ if err := json.Unmarshal([]byte(raw), &values); err != nil { -+ return nil, fmt.Errorf("decode Solana secret key: %w", err) -+ } -+ if len(values) != 64 { -+ return nil, fmt.Errorf("expected a 64-byte Solana secret key JSON array") -+ } -+ privateKey := solana.PrivateKey(values) -+ if _, err := solana.ValidatePrivateKey(privateKey); err != nil { -+ return nil, err -+ } -+ return privateKey, nil -+} -+ -+func latestBlockhash(rpcURL string) (solana.Hash, error) { -+ requestBody, err := json.Marshal(map[string]any{ -+ "jsonrpc": "2.0", -+ "id": 1, -+ "method": "getLatestBlockhash", -+ }) -+ if err != nil { -+ return solana.Hash{}, err -+ } -+ response, err := httpClient.Post(rpcURL, "application/json", bytes.NewReader(requestBody)) -+ if err != nil { -+ return solana.Hash{}, err -+ } -+ defer func() { _ = response.Body.Close() }() -+ if response.StatusCode < 200 || response.StatusCode >= 300 { -+ return solana.Hash{}, fmt.Errorf("getLatestBlockhash HTTP %d", response.StatusCode) -+ } -+ var payload struct { -+ Result struct { -+ Value struct { -+ Blockhash string `json:"blockhash"` -+ } `json:"value"` -+ } `json:"result"` -+ Error any `json:"error"` -+ } -+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { -+ return solana.Hash{}, err -+ } -+ if payload.Error != nil { -+ return solana.Hash{}, fmt.Errorf("getLatestBlockhash RPC error: %v", payload.Error) -+ } -+ return solana.HashFromBase58(payload.Result.Value.Blockhash) -+} -+ -+func computeUnitLimitInstruction(units uint32) solana.Instruction { -+ data := []byte{2} -+ data = binary.LittleEndian.AppendUint32(data, units) -+ return solana.NewInstruction(computeBudgetProgramID, nil, data) -+} -+ -+func computeUnitPriceInstruction(microLamports uint64) solana.Instruction { -+ data := []byte{3} -+ data = binary.LittleEndian.AppendUint64(data, microLamports) -+ return solana.NewInstruction(computeBudgetProgramID, nil, data) -+} -+ -+func transferCheckedInstruction(requirement paymentRequirement, signer solana.PublicKey, decimals uint8, tokenProgram solana.PublicKey) (solana.Instruction, error) { -+ amount, err := strconv.ParseUint(requirement.Amount, 10, 64) -+ if err != nil { -+ return nil, fmt.Errorf("invalid amount: %w", err) -+ } -+ mint, err := solana.PublicKeyFromBase58(requirement.Asset) -+ if err != nil { -+ return nil, fmt.Errorf("invalid asset: %w", err) -+ } -+ payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) -+ if err != nil { -+ return nil, fmt.Errorf("invalid payTo: %w", err) -+ } -+ sourceATA, _, err := findAssociatedTokenAddress(signer, tokenProgram, mint) -+ if err != nil { -+ return nil, err -+ } -+ destinationATA, _, err := findAssociatedTokenAddress(payTo, tokenProgram, mint) -+ if err != nil { -+ return nil, err -+ } -+ -+ data := []byte{12} -+ data = binary.LittleEndian.AppendUint64(data, amount) -+ data = append(data, decimals) -+ -+ return solana.NewInstruction( -+ tokenProgram, -+ solana.AccountMetaSlice{ -+ solana.Meta(sourceATA).WRITE(), -+ solana.Meta(mint), -+ solana.Meta(destinationATA).WRITE(), -+ solana.Meta(signer).SIGNER(), -+ }, -+ data, -+ ), nil -+} -+ -+func findAssociatedTokenAddress(wallet solana.PublicKey, tokenProgram solana.PublicKey, mint solana.PublicKey) (solana.PublicKey, uint8, error) { -+ return solana.FindProgramAddress( -+ [][]byte{wallet[:], tokenProgram[:], mint[:]}, -+ solana.SPLAssociatedTokenAccountProgramID, -+ ) -+} -+ -+func memoInstruction(requirement paymentRequirement) (solana.Instruction, error) { -+ memo := "" -+ if value, ok := requirement.Extra["memo"].(string); ok && value != "" { -+ memo = value -+ } else { -+ var nonce [16]byte -+ if _, err := rand.Read(nonce[:]); err != nil { -+ return nil, fmt.Errorf("generate memo nonce: %w", err) -+ } -+ memo = hex.EncodeToString(nonce[:]) -+ } -+ if len([]byte(memo)) > maxMemoBytes { -+ return nil, fmt.Errorf("extra.memo exceeds maximum %d bytes", maxMemoBytes) -+ } -+ return solana.NewInstruction(memoProgramID, nil, []byte(memo)), nil -+} -+ -+func buildExactPaymentSignature(requirement paymentRequirement, resource map[string]any, privateKey solana.PrivateKey, rpcURL string) (string, error) { -+ if requirement.Scheme != "exact" { -+ return "", fmt.Errorf("only exact payment requirements can be signed") -+ } -+ -+ decimalsValue, err := intFromRequirement(requirement, "decimals") -+ if err != nil { -+ return "", err -+ } -+ tokenProgramValue, err := stringFromExtra(requirement, "tokenProgram") -+ if err != nil { -+ return "", err -+ } -+ feePayerValue, err := stringFromExtra(requirement, "feePayer") -+ if err != nil { -+ return "", err -+ } -+ tokenProgram, err := solana.PublicKeyFromBase58(tokenProgramValue) -+ if err != nil { -+ return "", fmt.Errorf("invalid tokenProgram: %w", err) -+ } -+ feePayer, err := solana.PublicKeyFromBase58(feePayerValue) -+ if err != nil { -+ return "", fmt.Errorf("invalid feePayer: %w", err) -+ } -+ -+ blockhashValue, _ := requirement.Extra["recentBlockhash"].(string) -+ var blockhash solana.Hash -+ if blockhashValue != "" { -+ blockhash, err = solana.HashFromBase58(blockhashValue) -+ if err != nil { -+ return "", fmt.Errorf("invalid recentBlockhash: %w", err) -+ } -+ } else { -+ blockhash, err = latestBlockhash(rpcURL) -+ if err != nil { -+ return "", err -+ } -+ } -+ -+ transferIx, err := transferCheckedInstruction(requirement, privateKey.PublicKey(), uint8(decimalsValue), tokenProgram) -+ if err != nil { -+ return "", err -+ } -+ memoIx, err := memoInstruction(requirement) -+ if err != nil { -+ return "", err -+ } -+ -+ tx, err := solana.NewTransaction( -+ []solana.Instruction{ -+ computeUnitLimitInstruction(defaultComputeUnitLimit), -+ computeUnitPriceInstruction(defaultComputeUnitPriceMicrolamport), -+ transferIx, -+ memoIx, -+ }, -+ blockhash, -+ solana.TransactionPayer(feePayer), -+ ) -+ if err != nil { -+ return "", err -+ } -+ tx.Message.SetVersion(solana.MessageVersionV0) -+ if _, err := tx.PartialSign(func(key solana.PublicKey) *solana.PrivateKey { -+ if key.Equals(privateKey.PublicKey()) { -+ return &privateKey -+ } -+ return nil -+ }); err != nil { -+ return "", err -+ } -+ transaction, err := tx.ToBase64() -+ if err != nil { -+ return "", err -+ } -+ -+ encoded, err := json.Marshal(paymentSignatureEnvelope{ -+ X402Version: 2, -+ Accepted: requirement, -+ Resource: resource, -+ Payload: map[string]string{"transaction": transaction}, -+ }) -+ if err != nil { -+ return "", err -+ } -+ return base64.StdEncoding.EncodeToString(encoded), nil -+} -+ -+func readResponse(response *http.Response) (map[string]string, string, error) { -+ defer func() { _ = response.Body.Close() }() -+ body, err := io.ReadAll(response.Body) -+ if err != nil { -+ return nil, "", err -+ } -+ headers := map[string]string{} -+ for key, values := range response.Header { -+ if len(values) > 0 { -+ headers[key] = values[0] -+ } -+ } -+ return headers, string(body), nil -+} -+ -+func parseResponseBody(body string) any { -+ var parsed any -+ decoder := json.NewDecoder(bytes.NewReader([]byte(body))) -+ if err := decoder.Decode(&parsed); err == nil { -+ return parsed -+ } -+ return body -+} -+ -+func main() { -+ targetURL := os.Getenv("X402_INTEROP_TARGET_URL") -+ if targetURL == "" { -+ panic("X402_INTEROP_TARGET_URL is required") -+ } -+ -+ response, err := httpClient.Get(targetURL) -+ if err != nil { -+ panic(err) -+ } -+ defer func() { _ = response.Body.Close() }() -+ headers, body, err := readResponse(response) -+ if err != nil { -+ panic(err) -+ } -+ -+ selectedRequirement, resource := selectSVMChallenge( -+ headers, -+ body, -+ readEnvWithDefault("X402_INTEROP_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"), -+ readEnvWithDefault("X402_INTEROP_SCHEME", "exact"), -+ ) -+ scheme := readEnvWithDefault("X402_INTEROP_SCHEME", "exact") -+ errorDomain := readEnvWithDefault("X402_INTEROP_INTENT", scheme) -+ -+ if response.StatusCode == http.StatusPaymentRequired && os.Getenv("X402_INTEROP_INTENT") == "" && scheme == "exact" && selectedRequirement != nil && os.Getenv("X402_INTEROP_CLIENT_SECRET_KEY") != "" && os.Getenv("X402_INTEROP_RPC_URL") != "" { -+ privateKey, err := keypairFromJSONSecret(os.Getenv("X402_INTEROP_CLIENT_SECRET_KEY")) -+ var paymentSignature string -+ if err == nil { -+ paymentSignature, err = buildExactPaymentSignature(*selectedRequirement, resource, privateKey, os.Getenv("X402_INTEROP_RPC_URL")) -+ } -+ if err == nil { -+ request, requestErr := http.NewRequest(http.MethodGet, targetURL, nil) -+ if requestErr != nil { -+ err = requestErr -+ } else { -+ request.Header.Set("PAYMENT-SIGNATURE", paymentSignature) -+ var paidResponse *http.Response -+ paidResponse, err = httpClient.Do(request) -+ if err == nil { -+ defer func() { _ = paidResponse.Body.Close() }() -+ paidHeaders, paidBody, readErr := readResponse(paidResponse) -+ if readErr != nil { -+ err = readErr -+ } else { -+ payload := map[string]any{ -+ "type": "result", -+ "implementation": "go", -+ "role": "client", -+ "ok": paidResponse.StatusCode >= 200 && paidResponse.StatusCode < 300, -+ "status": paidResponse.StatusCode, -+ "responseHeaders": paidHeaders, -+ "responseBody": parseResponseBody(paidBody), -+ "settlement": headerValue(paidHeaders, "x-fixture-settlement"), -+ } -+ encoded, marshalErr := json.Marshal(payload) -+ if marshalErr != nil { -+ panic(marshalErr) -+ } -+ fmt.Println(string(encoded)) -+ return -+ } -+ } -+ } -+ } -+ if err != nil { -+ payload := map[string]any{ -+ "type": "result", -+ "implementation": "go", -+ "role": "client", -+ "ok": false, -+ "status": response.StatusCode, -+ "responseHeaders": headers, -+ "responseBody": map[string]any{ -+ "error": "go_exact_client_payment_failed", -+ "message": err.Error(), -+ "challengeStatus": response.StatusCode, -+ "challengeBody": body, -+ "selectedRequirement": selectedRequirement, -+ }, -+ "settlement": nil, -+ } -+ encoded, marshalErr := json.Marshal(payload) -+ if marshalErr != nil { -+ panic(marshalErr) -+ } -+ fmt.Println(string(encoded)) -+ return -+ } -+ } -+ -+ payload := map[string]any{ -+ "type": "result", -+ "implementation": "go", -+ "role": "client", -+ "ok": false, -+ "status": response.StatusCode, -+ "responseHeaders": headers, -+ "responseBody": map[string]any{ -+ "error": fmt.Sprintf("go_%s_client_not_implemented", errorDomain), -+ "challengeStatus": response.StatusCode, -+ "challengeBody": body, -+ "selectedRequirement": selectedRequirement, -+ }, -+ "settlement": nil, -+ } -+ -+ encoded, err := json.Marshal(payload) -+ if err != nil { -+ panic(err) -+ } -+ fmt.Println(string(encoded)) -+} -+ -+func readEnvWithDefault(name string, fallback string) string { -+ value := os.Getenv(name) -+ if value == "" { -+ return fallback -+ } -+ return value -+} -diff --git a/go/x402/cmd/interop-server/main.go b/go/x402/cmd/interop-server/main.go -new file mode 100644 -index 0000000..96ea0c9 ---- /dev/null -+++ b/go/x402/cmd/interop-server/main.go -@@ -0,0 +1,1196 @@ -+package main -+ -+import ( -+ "bytes" -+ "encoding/base64" -+ "encoding/binary" -+ "encoding/json" -+ "fmt" -+ "io" -+ "net" -+ "net/http" -+ "os" -+ "os/signal" -+ "reflect" -+ "strconv" -+ "strings" -+ "sync" -+ "syscall" -+ "time" -+ -+ "github.com/gagliardetto/solana-go" -+) -+ -+const ( -+ defaultResourcePath = "/protected" -+ defaultPrice = "$0.001" -+ defaultSettlementHeader = "x-fixture-settlement" -+ defaultDecimals = 6 -+ defaultTokenProgram = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" -+ token2022Program = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" -+ lighthouseProgram = "L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95" -+ defaultMaxTimeout = 60 -+ duplicateCacheTTL = 120 * time.Second -+ maxComputeUnitPrice = 5_000_000 -+ maxMemoBytes = 256 -+ // replayKeyNamespace MUST match the scheme-namespaced canonical key -+ // documented in the x402 PR-readiness reference and mirrors the MPP -+ // `solana-charge:consumed:` shape but scoped to x402 svm-exact so -+ // settled signatures across schemes (and against MPP) do not collide. -+ replayKeyNamespace = "x402-svm-exact:consumed:" -+) -+ -+// confirmationPollAttempts × confirmationPollInterval bounds the -+// post-broadcast confirmation wait. Defaults mirror the MPP -+// `server/charge.rs:769` 30×200ms = ~6s window. These are vars (not -+// consts) so tests can shrink the poll budget to keep timeout coverage -+// fast. -+var ( -+ confirmationPollAttempts = 60 -+ confirmationPollInterval = 200 * time.Millisecond -+) -+ -+var ( -+ computeBudgetProgramID = solana.MustPublicKeyFromBase58("ComputeBudget111111111111111111111111111111") -+ memoProgramID = solana.MustPublicKeyFromBase58("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr") -+) -+ -+// Lighthouse instructions are passed through by program-ID match alone, matching -+// the canonical spines: -+// - rust/src/protocol/schemes/exact/verify.rs:266 — `if program == LIGHTHOUSE_PROGRAM || program == MEMO_PROGRAM { continue; }` -+// - typescript/packages/x402/src/facilitator/exact/scheme.ts:300 — same shape -+// No discriminator or account-count allowlist is enforced here: inventing one -+// in a single language port would diverge from real-world Phantom/Solflare -+// transactions that the Rust + TypeScript adapters accept. Tightening this is -+// a protocol-wide decision that must land in the Rust spine first; tracked at -+// /notes/lighthouse-allowlist-tracking.md. -+ -+// CAIP-2 network identifiers shared with the TypeScript spine. -+const ( -+ solanaMainnetCAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" -+ solanaDevnetCAIP2 = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" -+ solanaTestnetCAIP2 = "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z" -+) -+ -+// stablecoinMintsByNetwork mirrors STABLECOIN_MINTS from the TypeScript -+// reference (typescript/packages/x402/src/protocol/schemes/exact/constants.ts). -+// Aliases are resolved at the env-read boundary so the rest of the server -+// always sees canonical base58 mint addresses. -+var stablecoinMintsByNetwork = map[string]map[string]string{ -+ "USDC": { -+ solanaMainnetCAIP2: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", -+ solanaDevnetCAIP2: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ }, -+ "USDT": { -+ solanaMainnetCAIP2: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", -+ }, -+ "USDG": { -+ solanaMainnetCAIP2: "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH", -+ solanaDevnetCAIP2: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", -+ solanaTestnetCAIP2: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", -+ }, -+ "PYUSD": { -+ solanaMainnetCAIP2: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", -+ solanaDevnetCAIP2: "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", -+ solanaTestnetCAIP2: "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", -+ }, -+ "CASH": { -+ solanaMainnetCAIP2: "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH", -+ }, -+} -+ -+// knownMintAliases lists the case-insensitive currency-name aliases that -+// resolveMintAlias understands. Kept stable for error messages. -+var knownMintAliases = []string{"USDC", "USDT", "USDG", "PYUSD", "CASH"} -+ -+// resolveMintAlias returns the canonical base58 mint address for a given -+// input on the configured CAIP-2 network. The input may already be a base58 -+// mint (in which case it is returned unchanged) or a known stablecoin alias -+// (USDC, USDT, USDG, PYUSD, CASH). Unknown aliases and aliases without a -+// configured mint for the network return a descriptive error. -+func resolveMintAlias(input string, network string) (string, error) { -+ trimmed := strings.TrimSpace(input) -+ if trimmed == "" { -+ return "", fmt.Errorf("mint is required") -+ } -+ upper := strings.ToUpper(trimmed) -+ if mintsByNetwork, ok := stablecoinMintsByNetwork[upper]; ok { -+ if mint, ok := mintsByNetwork[network]; ok { -+ return mint, nil -+ } -+ return "", fmt.Errorf("alias %s has no configured mint for network %s", upper, network) -+ } -+ if _, err := solana.PublicKeyFromBase58(trimmed); err != nil { -+ return "", fmt.Errorf("mint %q is neither a base58 address nor a known alias (accepted aliases: %s)", input, strings.Join(knownMintAliases, ", ")) -+ } -+ return trimmed, nil -+} -+ -+type serverState struct { -+ rpcURL string -+ network string -+ mint string -+ payTo string -+ feePayer solana.PrivateKey -+ amount string -+ extraOfferedMints []string -+ memo string -+ httpClient *http.Client -+} -+ -+type paymentEnvelope struct { -+ X402Version int `json:"x402Version"` -+ Accepts []paymentRequirement `json:"accepts"` -+ Resource map[string]any `json:"resource,omitempty"` -+} -+ -+type paymentRequirement struct { -+ Scheme string `json:"scheme"` -+ Network string `json:"network"` -+ Asset string `json:"asset"` -+ Amount string `json:"amount"` -+ PayTo string `json:"payTo"` -+ MaxTimeoutSeconds int `json:"maxTimeoutSeconds,omitempty"` -+ Extra map[string]any `json:"extra,omitempty"` -+} -+ -+type paymentSignatureEnvelope struct { -+ X402Version int `json:"x402Version"` -+ Accepted paymentRequirement `json:"accepted"` -+ Payload map[string]string `json:"payload"` -+} -+ -+type duplicateSettlementCache struct { -+ mu sync.Mutex -+ entries map[string]time.Time -+ now func() time.Time -+} -+ -+var settlementCache = newDuplicateSettlementCache() -+ -+func newDuplicateSettlementCache() *duplicateSettlementCache { -+ return &duplicateSettlementCache{ -+ entries: map[string]time.Time{}, -+ now: time.Now, -+ } -+} -+ -+// putIfAbsent reserves `key` in the replay cache. Returns true if the key -+// was newly inserted, false if a prior settlement already consumed it. -+// -+// L8 ordering (see x402 PR-readiness reference and MPP -+// `server/charge.rs:535-556`): callers MUST broadcast → await on-chain -+// confirmation → `putIfAbsent(signature)`. There is no release-on-failure -+// path: a crash or RPC failure before this call simply never inserts a -+// key, and Solana's per-signature replay protection prevents a re-broadcast -+// of the same signed transaction from settling twice within its blockhash -+// window. The release path of the prior claim-first design has been -+// removed to close the partial-failure race where a release after a timed- -+// out confirmation would permit a double-pay if the original later landed. -+func (cache *duplicateSettlementCache) putIfAbsent(key string) bool { -+ cache.mu.Lock() -+ defer cache.mu.Unlock() -+ -+ now := cache.now() -+ for cached, seenAt := range cache.entries { -+ if now.Sub(seenAt) > duplicateCacheTTL { -+ delete(cache.entries, cached) -+ } -+ } -+ if _, ok := cache.entries[key]; ok { -+ return false -+ } -+ cache.entries[key] = now -+ return true -+} -+ -+func writeJSON(response http.ResponseWriter, status int, payload map[string]any) { -+ encoded, err := json.Marshal(payload) -+ if err != nil { -+ panic(err) -+ } -+ response.Header().Set("content-type", "application/json") -+ response.WriteHeader(status) -+ if _, err := response.Write(encoded); err != nil { -+ fmt.Fprintln(os.Stderr, err) -+ } -+} -+ -+func writeJSONWithHeaders(response http.ResponseWriter, status int, headers map[string]string, payload map[string]any) { -+ encoded, err := json.Marshal(payload) -+ if err != nil { -+ panic(err) -+ } -+ response.Header().Set("content-type", "application/json") -+ for key, value := range headers { -+ response.Header().Set(key, value) -+ } -+ response.WriteHeader(status) -+ if _, err := response.Write(encoded); err != nil { -+ fmt.Fprintln(os.Stderr, err) -+ } -+} -+ -+func capabilityPayload(implementation string) map[string]any { -+ return map[string]any{ -+ "implementation": implementation, -+ "role": "server", -+ "capabilities": []string{"exact"}, -+ "plannedBoundaries": []string{"exact", "upto", "session", "batch-settlement"}, -+ } -+} -+ -+func exactRequirementForMint(state serverState, mint string) paymentRequirement { -+ requirement := paymentRequirement{ -+ Scheme: "exact", -+ Network: state.network, -+ Asset: mint, -+ Amount: state.amount, -+ PayTo: state.payTo, -+ MaxTimeoutSeconds: defaultMaxTimeout, -+ Extra: map[string]any{ -+ "decimals": defaultDecimals, -+ "feePayer": state.feePayer.PublicKey().String(), -+ "tokenProgram": defaultTokenProgramForMint(mint), -+ }, -+ } -+ if state.memo != "" { -+ requirement.Extra["memo"] = state.memo -+ } -+ return requirement -+} -+ -+func exactRequirement(state serverState) paymentRequirement { -+ return exactRequirementForMint(state, state.mint) -+} -+ -+func exactChallengePayload(state serverState) paymentEnvelope { -+ accepts := []paymentRequirement{exactRequirement(state)} -+ for _, mint := range state.extraOfferedMints { -+ if mint == "" { -+ continue -+ } -+ accepts = append(accepts, exactRequirementForMint(state, mint)) -+ } -+ return paymentEnvelope{ -+ X402Version: 2, -+ Accepts: accepts, -+ Resource: map[string]any{ -+ "type": "http", -+ "uri": defaultResourcePath, -+ }, -+ } -+} -+ -+func defaultTokenProgramForMint(mint string) string { -+ switch strings.ToUpper(strings.TrimSpace(mint)) { -+ case "USDG", "PYUSD", "CASH": -+ return token2022Program -+ } -+ switch strings.TrimSpace(mint) { -+ case "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH", -+ "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", -+ "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", -+ "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", -+ "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH": -+ return token2022Program -+ default: -+ return defaultTokenProgram -+ } -+} -+ -+func uptoChallengePayload() map[string]any { -+ return map[string]any{ -+ "x402Version": 2, -+ "accepts": []map[string]any{ -+ { -+ "scheme": "upto", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ "amount": "1000", -+ }, -+ }, -+ } -+} -+ -+func writePaymentRequired(response http.ResponseWriter, challenge map[string]any) { -+ encoded, err := json.Marshal(challenge) -+ if err != nil { -+ panic(err) -+ } -+ response.Header().Set("PAYMENT-REQUIRED", base64.StdEncoding.EncodeToString(encoded)) -+ writeJSON(response, http.StatusPaymentRequired, map[string]any{"error": "payment_required"}) -+} -+ -+func writeExactPaymentRequired(response http.ResponseWriter, state serverState) { -+ challenge := exactChallengePayload(state) -+ encoded, err := json.Marshal(challenge) -+ if err != nil { -+ panic(err) -+ } -+ response.Header().Set("PAYMENT-REQUIRED", base64.StdEncoding.EncodeToString(encoded)) -+ writeJSON(response, http.StatusPaymentRequired, map[string]any{"error": "payment_required"}) -+} -+ -+func sessionChallengePayload() map[string]any { -+ return map[string]any{ -+ "intent": "session", -+ "payee": "session-payee", -+ "mint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ "suggestedDeposit": "10000", -+ "unitPrice": "25", -+ "unitType": "llm_token", -+ } -+} -+ -+func batchSettlementChallengePayload() map[string]any { -+ return map[string]any{ -+ "x402Version": 2, -+ "accepts": []map[string]any{ -+ { -+ "scheme": "batch-settlement", -+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ "receiver": "batch-receiver", -+ "token": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ "maximumAmount": "1000", -+ }, -+ }, -+ } -+} -+ -+func readRequiredEnv(name string) string { -+ value := os.Getenv(name) -+ if value == "" { -+ panic(fmt.Sprintf("%s is required", name)) -+ } -+ return value -+} -+ -+func readEnvWithDefault(name string, fallback string) string { -+ value := os.Getenv(name) -+ if value == "" { -+ return fallback -+ } -+ return value -+} -+ -+func readCSVEnv(name string) []string { -+ raw := os.Getenv(name) -+ if raw == "" { -+ return nil -+ } -+ parts := strings.Split(raw, ",") -+ values := make([]string, 0, len(parts)) -+ for _, part := range parts { -+ trimmed := strings.TrimSpace(part) -+ if trimmed != "" { -+ values = append(values, trimmed) -+ } -+ } -+ return values -+} -+ -+func normalizeAmount(price string) string { -+ trimmed := strings.TrimSpace(price) -+ if len(trimmed) > 0 && trimmed[0] == '$' { -+ trimmed = trimmed[1:] -+ } -+ amountPart := strings.Fields(trimmed)[0] -+ parts := strings.SplitN(amountPart, ".", 2) -+ whole, err := strconv.ParseUint(parts[0], 10, 64) -+ if err != nil { -+ panic(fmt.Sprintf("invalid X402_INTEROP_PRICE: %s", price)) -+ } -+ fraction := "" -+ if len(parts) == 2 { -+ fraction = parts[1] -+ } -+ if len(fraction) > defaultDecimals { -+ panic(fmt.Sprintf("X402_INTEROP_PRICE has too many decimal places: %s", price)) -+ } -+ fraction = fraction + strings.Repeat("0", defaultDecimals-len(fraction)) -+ fractional, err := strconv.ParseUint(fraction, 10, 64) -+ if err != nil { -+ panic(fmt.Sprintf("invalid X402_INTEROP_PRICE: %s", price)) -+ } -+ return strconv.FormatUint((whole*1_000_000)+fractional, 10) -+} -+ -+func keypairFromJSONSecret(raw string) solana.PrivateKey { -+ var values []byte -+ if err := json.Unmarshal([]byte(raw), &values); err != nil { -+ panic(fmt.Sprintf("decode Solana secret key: %s", err)) -+ } -+ if len(values) != 64 { -+ panic("expected a 64-byte Solana secret key JSON array") -+ } -+ privateKey := solana.PrivateKey(values) -+ if _, err := solana.ValidatePrivateKey(privateKey); err != nil { -+ panic(err) -+ } -+ return privateKey -+} -+ -+func readState() serverState { -+ network := readEnvWithDefault("X402_INTEROP_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1") -+ rawMint := readEnvWithDefault("X402_INTEROP_MINT", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU") -+ resolvedMint, err := resolveMintAlias(rawMint, network) -+ if err != nil { -+ panic(fmt.Sprintf("X402_INTEROP_MINT: %s", err)) -+ } -+ rawExtra := readCSVEnv("X402_INTEROP_EXTRA_OFFERED_MINTS") -+ resolvedExtra := make([]string, 0, len(rawExtra)) -+ for _, candidate := range rawExtra { -+ resolved, err := resolveMintAlias(candidate, network) -+ if err != nil { -+ panic(fmt.Sprintf("X402_INTEROP_EXTRA_OFFERED_MINTS: %s", err)) -+ } -+ resolvedExtra = append(resolvedExtra, resolved) -+ } -+ return serverState{ -+ rpcURL: readRequiredEnv("X402_INTEROP_RPC_URL"), -+ network: network, -+ mint: resolvedMint, -+ payTo: readRequiredEnv("X402_INTEROP_PAY_TO"), -+ feePayer: keypairFromJSONSecret(readRequiredEnv("X402_INTEROP_FACILITATOR_SECRET_KEY")), -+ amount: normalizeAmount(readEnvWithDefault("X402_INTEROP_PRICE", defaultPrice)), -+ extraOfferedMints: resolvedExtra, -+ httpClient: &http.Client{ -+ Timeout: 15 * time.Second, -+ }, -+ } -+} -+ -+func paymentRequirementMatches(left paymentRequirement, right paymentRequirement) bool { -+ return reflect.DeepEqual(normalizeRequirement(left), normalizeRequirement(right)) -+} -+ -+func acceptedExactRequirement(state serverState, accepted paymentRequirement) (paymentRequirement, bool) { -+ for _, requirement := range exactChallengePayload(state).Accepts { -+ if paymentRequirementMatches(accepted, requirement) { -+ return requirement, true -+ } -+ } -+ return paymentRequirement{}, false -+} -+ -+func normalizeRequirement(requirement paymentRequirement) paymentRequirement { -+ normalized := requirement -+ normalized.Extra = map[string]any{} -+ for key, value := range requirement.Extra { -+ normalized.Extra[key] = fmt.Sprint(value) -+ } -+ return normalized -+} -+ -+func decodePaymentSignature(headerValue string) (paymentSignatureEnvelope, error) { -+ decoded, err := base64.StdEncoding.DecodeString(headerValue) -+ if err != nil { -+ return paymentSignatureEnvelope{}, err -+ } -+ var payload paymentSignatureEnvelope -+ if err := json.Unmarshal(decoded, &payload); err != nil { -+ return paymentSignatureEnvelope{}, err -+ } -+ return payload, nil -+} -+ -+func settleExactPayment(state serverState, headerValue string) (string, error) { -+ payload, err := decodePaymentSignature(headerValue) -+ if err != nil { -+ return "", err -+ } -+ if payload.X402Version != 2 { -+ return "", fmt.Errorf("unsupported x402Version: %d", payload.X402Version) -+ } -+ requirement, ok := acceptedExactRequirement(state, payload.Accepted) -+ if !ok { -+ return "", fmt.Errorf("accepted payment requirement does not match server challenge") -+ } -+ -+ encodedTransaction := payload.Payload["transaction"] -+ if encodedTransaction == "" { -+ return "", fmt.Errorf("payment payload is missing transaction") -+ } -+ -+ transaction, err := solana.TransactionFromBase64(encodedTransaction) -+ if err != nil { -+ return "", err -+ } -+ if err := verifyExactTransaction(transaction, requirement); err != nil { -+ return "", err -+ } -+ // Bind the transaction's message fee-payer (account key 0) to the -+ // server's configured fee-payer. Without this guard a malicious client -+ // could nominate a different message payer and rely on the facilitator -+ // being in the signer set to drain SOL via co-signing. -+ if len(transaction.Message.AccountKeys) == 0 { -+ return "", fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_missing") -+ } -+ if !transaction.Message.AccountKeys[0].Equals(state.feePayer.PublicKey()) { -+ return "", fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_mismatch") -+ } -+ if err := verifyTokenAccountsExist(state, transaction, requirement); err != nil { -+ return "", err -+ } -+ -+ if _, err := transaction.PartialSign(func(key solana.PublicKey) *solana.PrivateKey { -+ if key.Equals(state.feePayer.PublicKey()) { -+ return &state.feePayer -+ } -+ return nil -+ }); err != nil { -+ return "", err -+ } -+ if err := transaction.VerifySignatures(); err != nil { -+ return "", err -+ } -+ -+ // L8 ordering: broadcast → confirm → put_if_absent(signature). -+ // Mirrors MPP `server/charge.rs:535-556` (broadcast_pull, -+ // await_pull_confirmation, consume_signature). No claim-first, no -+ // release-on-failure. See x402 PR-readiness reference §"L8 -+ // broadcast-then-confirm-then-mark ordering (SVM-specific)". -+ signature, err := sendTransaction(state, transaction) -+ if err != nil { -+ return "", err -+ } -+ if err := awaitSignatureConfirmation(state, signature); err != nil { -+ return "", err -+ } -+ if !settlementCache.putIfAbsent(replayKeyNamespace + signature) { -+ // Canonical `signature_consumed` surface (see MPP -+ // `VerificationError::signature_consumed`, -+ // rust/src/server/charge.rs:589-593). The interop server's -+ // existing error vocabulary maps this to "duplicate_settlement"; -+ // keep that wire token so existing clients are not broken, but -+ // the semantic is now "this confirmed signature was already -+ // consumed by an earlier successful settlement", not "we saw -+ // this encoded transaction blob before broadcast". -+ return "", fmt.Errorf("duplicate_settlement") -+ } -+ return signature, nil -+} -+ -+type transferCheckedFields struct { -+ source solana.PublicKey -+ mint solana.PublicKey -+ destination solana.PublicKey -+ authority solana.PublicKey -+ amount uint64 -+ decimals uint8 -+ tokenProgram solana.PublicKey -+} -+ -+func verifyExactTransaction(transaction *solana.Transaction, requirement paymentRequirement) error { -+ if !transaction.Message.IsVersioned() { -+ return fmt.Errorf("payment transaction must be versioned") -+ } -+ instructions := transaction.Message.Instructions -+ if len(instructions) < 3 || len(instructions) > 6 { -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_length") -+ } -+ if err := verifyComputeLimitInstruction(transaction, instructions[0]); err != nil { -+ return err -+ } -+ if err := verifyComputePriceInstruction(transaction, instructions[1]); err != nil { -+ return err -+ } -+ transfer, err := parseTransferCheckedInstruction(transaction, instructions[2]) -+ if err != nil { -+ return err -+ } -+ // Mirror the Rust spine binding (rust/crates/x402/src/protocol/schemes/exact/verify.rs:73-80) -+ // and the PHP/Ruby/Lua ports: the on-chain transfer's token program MUST match the -+ // program declared in requirement.Extra["tokenProgram"]. Without this check, a Token-2022 -+ // transfer can satisfy an SPL Token requirement (or vice versa), because the -+ // destination-ATA derivation below uses the parsed program rather than the required one. -+ requiredTokenProgramRaw, ok := requirement.Extra["tokenProgram"].(string) -+ if !ok || requiredTokenProgramRaw == "" { -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_token_program") -+ } -+ requiredTokenProgram, err := solana.PublicKeyFromBase58(requiredTokenProgramRaw) -+ if err != nil { -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_token_program") -+ } -+ if !transfer.tokenProgram.Equals(requiredTokenProgram) { -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_token_program") -+ } -+ if err := verifyOptionalInstructions(transaction, instructions[3:], requirement, transfer); err != nil { -+ return err -+ } -+ feePayer, err := solana.PublicKeyFromBase58(fmt.Sprint(requirement.Extra["feePayer"])) -+ if err != nil { -+ return fmt.Errorf("invalid feePayer: %w", err) -+ } -+ // Codex P1.2 (May 2026): the previous unconditional "fee-payer in any -+ // instruction account" loop was both over-broad (false-positive on the -+ // legitimate destination-ATA-create flow, where the SPL Associated Token -+ // Account program requires the rent payer at accounts[0]) and incomplete -+ // (it did not distinguish *role* — fee-payer as transfer authority/source -+ // is the real attack the Rust spine bans at -+ // rust/src/protocol/schemes/exact/verify.rs:382). Tightened rule: -+ // * fee-payer is allowed at accounts[0] of a *validated* ATA-create ix -+ // (the canonical rent-payer position). -+ // * fee-payer is allowed inside Lighthouse instruction account lists -+ // (the Rust spine has NO fee-payer-in-accounts sweep at all; it only -+ // blocks fee-payer as transfer authority at verify.rs:382, and accepts -+ // any Lighthouse ix by program-id alone at verify.rs:263 — wallets such -+ // as Phantom/Solflare routinely add `AssertAccount*` ixs that reference -+ // the fee-payer's pubkey to guard against malicious facilitator rewrites). -+ // * fee-payer in any other (non-Lighthouse, non-ATA-create-payer-slot) -+ // instruction account list is rejected with a distinct typed error. -+ // * fee-payer as transfer authority / source is still rejected with the -+ // spine-aligned `_transferring_funds` error. -+ if transfer.authority.Equals(feePayer) || transfer.source.Equals(feePayer) { -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_transferring_funds") -+ } -+ for index, instruction := range instructions { -+ if index == 2 { -+ // instruction[2] is the transferChecked; its fee-payer-as-role -+ // abuses are already covered by the spine-aligned guard above. -+ continue -+ } -+ program, err := programID(transaction, instruction) -+ if err != nil { -+ return err -+ } -+ if program.String() == lighthouseProgram { -+ // Mirror rust/src/protocol/schemes/exact/verify.rs:263 — Lighthouse -+ // ixs are passed through by program-id alone; the spine never -+ // inspects their account lists for the managed fee-payer. -+ continue -+ } -+ isATACreatePayerSlot := index >= 3 && isValidatedATACreateInstruction(transaction, instruction, requirement, transfer) -+ for accountPosition, accountIndex := range instruction.Accounts { -+ account, err := accountAt(transaction, accountIndex) -+ if err != nil { -+ return err -+ } -+ if !account.Equals(feePayer) { -+ continue -+ } -+ if isATACreatePayerSlot && accountPosition == 0 { -+ continue -+ } -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_in_instruction_accounts") -+ } -+ } -+ mint, err := solana.PublicKeyFromBase58(requirement.Asset) -+ if err != nil { -+ return fmt.Errorf("invalid asset: %w", err) -+ } -+ if !transfer.mint.Equals(mint) { -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_mint") -+ } -+ expectedAmount, err := strconv.ParseUint(requirement.Amount, 10, 64) -+ if err != nil { -+ return fmt.Errorf("invalid amount: %w", err) -+ } -+ if transfer.amount != expectedAmount { -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_amount") -+ } -+ payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) -+ if err != nil { -+ return fmt.Errorf("invalid payTo: %w", err) -+ } -+ expectedDestination, _, err := solana.FindAssociatedTokenAddressWithProgram(payTo, mint, transfer.tokenProgram) -+ if err != nil { -+ return err -+ } -+ if !transfer.destination.Equals(expectedDestination) { -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_destination") -+ } -+ if decimals, err := strconv.ParseUint(fmt.Sprint(requirement.Extra["decimals"]), 10, 8); err == nil && transfer.decimals != uint8(decimals) { -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_decimals") -+ } -+ return nil -+} -+ -+func verifyComputeLimitInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction) error { -+ program, err := programID(transaction, instruction) -+ if err != nil { -+ return err -+ } -+ if !program.Equals(computeBudgetProgramID) || len(instruction.Data) != 5 || instruction.Data[0] != 2 { -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction") -+ } -+ return nil -+} -+ -+func verifyComputePriceInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction) error { -+ program, err := programID(transaction, instruction) -+ if err != nil { -+ return err -+ } -+ if !program.Equals(computeBudgetProgramID) || len(instruction.Data) != 9 || instruction.Data[0] != 3 { -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_compute_price_instruction") -+ } -+ price := binary.LittleEndian.Uint64(instruction.Data[1:]) -+ if price > maxComputeUnitPrice { -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high") -+ } -+ return nil -+} -+ -+func parseTransferCheckedInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction) (transferCheckedFields, error) { -+ program, err := programID(transaction, instruction) -+ if err != nil { -+ return transferCheckedFields{}, err -+ } -+ if !program.Equals(solana.TokenProgramID) && !program.Equals(solana.Token2022ProgramID) { -+ return transferCheckedFields{}, fmt.Errorf("invalid_exact_svm_payload_transaction_transfer_program") -+ } -+ if len(instruction.Accounts) < 4 || len(instruction.Data) != 10 || instruction.Data[0] != 12 { -+ return transferCheckedFields{}, fmt.Errorf("invalid_exact_svm_payload_transaction_transfer_checked") -+ } -+ source, err := accountAt(transaction, instruction.Accounts[0]) -+ if err != nil { -+ return transferCheckedFields{}, err -+ } -+ mint, err := accountAt(transaction, instruction.Accounts[1]) -+ if err != nil { -+ return transferCheckedFields{}, err -+ } -+ destination, err := accountAt(transaction, instruction.Accounts[2]) -+ if err != nil { -+ return transferCheckedFields{}, err -+ } -+ authority, err := accountAt(transaction, instruction.Accounts[3]) -+ if err != nil { -+ return transferCheckedFields{}, err -+ } -+ return transferCheckedFields{ -+ source: source, -+ mint: mint, -+ destination: destination, -+ authority: authority, -+ amount: binary.LittleEndian.Uint64(instruction.Data[1:9]), -+ decimals: instruction.Data[9], -+ tokenProgram: program, -+ }, nil -+} -+ -+func verifyOptionalInstructions(transaction *solana.Transaction, instructions []solana.CompiledInstruction, requirement paymentRequirement, transfer transferCheckedFields) error { -+ memoCount := 0 -+ expectedMemo, hasExpectedMemo := requirement.Extra["memo"].(string) -+ invalidReasonByIndex := []string{ -+ "invalid_exact_svm_payload_unknown_fourth_instruction", -+ "invalid_exact_svm_payload_unknown_fifth_instruction", -+ "invalid_exact_svm_payload_unknown_sixth_instruction", -+ } -+ for index, instruction := range instructions { -+ program, err := programID(transaction, instruction) -+ if err != nil { -+ return err -+ } -+ if program.Equals(memoProgramID) { -+ memoCount++ -+ memo := string(instruction.Data) -+ if len([]byte(memo)) > maxMemoBytes { -+ return fmt.Errorf("extra.memo exceeds maximum %d bytes", maxMemoBytes) -+ } -+ if hasExpectedMemo && memo != expectedMemo { -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_memo") -+ } -+ if !hasExpectedMemo && memo == "" { -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_memo") -+ } -+ continue -+ } -+ if program.String() == lighthouseProgram { -+ // Pass through Lighthouse instructions by program-id match only, -+ // mirroring rust/src/protocol/schemes/exact/verify.rs:266 and -+ // typescript/packages/x402/src/facilitator/exact/scheme.ts:300. -+ continue -+ } -+ if program.Equals(solana.SPLAssociatedTokenAccountProgramID) && validDestinationATACreateInstruction(transaction, instruction, requirement, transfer) { -+ continue -+ } -+ if index < len(invalidReasonByIndex) { -+ return fmt.Errorf("%s", invalidReasonByIndex[index]) -+ } -+ return fmt.Errorf("invalid_exact_svm_payload_unknown_optional_instruction") -+ } -+ if hasExpectedMemo && memoCount != 1 { -+ return fmt.Errorf("invalid_exact_svm_payload_transaction_memo") -+ } -+ return nil -+} -+ -+// isValidatedATACreateInstruction returns true when `instruction` is an -+// SPL Associated Token Account program create that targets the payment's -+// destination ATA — i.e. the only optional instruction in which the facilitator -+// fee-payer is permitted to appear (as the rent payer at accounts[0]). -+func isValidatedATACreateInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction, requirement paymentRequirement, transfer transferCheckedFields) bool { -+ program, err := programID(transaction, instruction) -+ if err != nil { -+ return false -+ } -+ if !program.Equals(solana.SPLAssociatedTokenAccountProgramID) { -+ return false -+ } -+ return validDestinationATACreateInstruction(transaction, instruction, requirement, transfer) -+} -+ -+func validDestinationATACreateInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction, requirement paymentRequirement, transfer transferCheckedFields) bool { -+ if len(instruction.Data) > 1 { -+ return false -+ } -+ if len(instruction.Data) == 1 && instruction.Data[0] != 0 && instruction.Data[0] != 1 { -+ return false -+ } -+ if len(instruction.Accounts) < 6 { -+ return false -+ } -+ associatedAccount, err := accountAt(transaction, instruction.Accounts[1]) -+ if err != nil || !associatedAccount.Equals(transfer.destination) { -+ return false -+ } -+ wallet, err := accountAt(transaction, instruction.Accounts[2]) -+ if err != nil { -+ return false -+ } -+ payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) -+ if err != nil || !wallet.Equals(payTo) { -+ return false -+ } -+ mint, err := accountAt(transaction, instruction.Accounts[3]) -+ if err != nil || !mint.Equals(transfer.mint) { -+ return false -+ } -+ systemProgram, err := accountAt(transaction, instruction.Accounts[4]) -+ if err != nil || !systemProgram.Equals(solana.SystemProgramID) { -+ return false -+ } -+ tokenProgram, err := accountAt(transaction, instruction.Accounts[5]) -+ if err != nil || !tokenProgram.Equals(transfer.tokenProgram) { -+ return false -+ } -+ return true -+} -+ -+func programID(transaction *solana.Transaction, instruction solana.CompiledInstruction) (solana.PublicKey, error) { -+ return accountAt(transaction, instruction.ProgramIDIndex) -+} -+ -+func accountAt(transaction *solana.Transaction, index uint16) (solana.PublicKey, error) { -+ if int(index) >= len(transaction.Message.AccountKeys) { -+ return solana.PublicKey{}, fmt.Errorf("invalid account index: %d", index) -+ } -+ return transaction.Message.AccountKeys[index], nil -+} -+ -+func verifyTokenAccountsExist(state serverState, transaction *solana.Transaction, requirement paymentRequirement) error { -+ transfer, err := parseTransferCheckedInstruction(transaction, transaction.Message.Instructions[2]) -+ if err != nil { -+ return err -+ } -+ if exists, err := accountExists(state, transfer.source); err != nil { -+ return err -+ } else if !exists { -+ return fmt.Errorf("source token account does not exist") -+ } -+ if hasDestinationATACreateInstruction(transaction, requirement, transfer) { -+ return nil -+ } -+ if exists, err := accountExists(state, transfer.destination); err != nil { -+ return err -+ } else if !exists { -+ return fmt.Errorf("destination token account does not exist") -+ } -+ return nil -+} -+ -+func hasDestinationATACreateInstruction(transaction *solana.Transaction, requirement paymentRequirement, transfer transferCheckedFields) bool { -+ for _, instruction := range transaction.Message.Instructions[3:] { -+ program, err := programID(transaction, instruction) -+ if err != nil || !program.Equals(solana.SPLAssociatedTokenAccountProgramID) { -+ continue -+ } -+ if validDestinationATACreateInstruction(transaction, instruction, requirement, transfer) { -+ return true -+ } -+ } -+ return false -+} -+ -+func accountExists(state serverState, account solana.PublicKey) (bool, error) { -+ requestBody, err := json.Marshal(map[string]any{ -+ "jsonrpc": "2.0", -+ "id": 1, -+ "method": "getAccountInfo", -+ "params": []any{ -+ account.String(), -+ map[string]any{"encoding": "base64"}, -+ }, -+ }) -+ if err != nil { -+ return false, err -+ } -+ response, err := state.httpClient.Post(state.rpcURL, "application/json", bytes.NewReader(requestBody)) -+ if err != nil { -+ return false, err -+ } -+ defer func() { _ = response.Body.Close() }() -+ rawBody, err := io.ReadAll(response.Body) -+ if err != nil { -+ return false, err -+ } -+ if response.StatusCode < 200 || response.StatusCode >= 300 { -+ return false, fmt.Errorf("getAccountInfo HTTP %d: %s", response.StatusCode, string(rawBody)) -+ } -+ var payload struct { -+ Result *struct { -+ Value json.RawMessage `json:"value"` -+ } `json:"result"` -+ Error any `json:"error"` -+ } -+ if err := json.Unmarshal(rawBody, &payload); err != nil { -+ return false, err -+ } -+ if payload.Error != nil { -+ return false, fmt.Errorf("getAccountInfo RPC error: %v", payload.Error) -+ } -+ if payload.Result == nil || len(payload.Result.Value) == 0 || string(payload.Result.Value) == "null" { -+ return false, nil -+ } -+ return true, nil -+} -+ -+func sendTransaction(state serverState, transaction *solana.Transaction) (string, error) { -+ encodedTransaction, err := transaction.ToBase64() -+ if err != nil { -+ return "", err -+ } -+ requestBody, err := json.Marshal(map[string]any{ -+ "jsonrpc": "2.0", -+ "id": 1, -+ "method": "sendTransaction", -+ "params": []any{ -+ encodedTransaction, -+ map[string]any{ -+ "encoding": "base64", -+ "skipPreflight": false, -+ "preflightCommitment": "processed", -+ "maxRetries": 3, -+ }, -+ }, -+ }) -+ if err != nil { -+ return "", err -+ } -+ -+ response, err := state.httpClient.Post(state.rpcURL, "application/json", bytes.NewReader(requestBody)) -+ if err != nil { -+ return "", err -+ } -+ defer func() { _ = response.Body.Close() }() -+ rawBody, err := io.ReadAll(response.Body) -+ if err != nil { -+ return "", err -+ } -+ if response.StatusCode < 200 || response.StatusCode >= 300 { -+ return "", fmt.Errorf("sendTransaction HTTP %d: %s", response.StatusCode, string(rawBody)) -+ } -+ -+ var payload struct { -+ Result string `json:"result"` -+ Error any `json:"error"` -+ } -+ if err := json.Unmarshal(rawBody, &payload); err != nil { -+ return "", err -+ } -+ if payload.Error != nil { -+ return "", fmt.Errorf("sendTransaction RPC error: %v", payload.Error) -+ } -+ if payload.Result == "" { -+ return "", fmt.Errorf("sendTransaction returned empty signature") -+ } -+ return payload.Result, nil -+} -+ -+// awaitSignatureConfirmation polls `getSignatureStatuses` until the -+// signature reaches `confirmed` or `finalized` commitment. It returns an -+// error on explicit RPC error, an on-chain transaction failure -+// (status.err non-null), or when the poll budget elapses (the bounded -+// stand-in for blockhash-window expiry; a signature that has not been -+// observed within this window is treated as not landed so the caller -+// MUST NOT mark the signature as consumed). Mirrors the canonical loop -+// in MPP `server/charge.rs:761-784`. -+func awaitSignatureConfirmation(state serverState, signature string) error { -+ requestBody, err := json.Marshal(map[string]any{ -+ "jsonrpc": "2.0", -+ "id": 1, -+ "method": "getSignatureStatuses", -+ "params": []any{ -+ []string{signature}, -+ map[string]any{"searchTransactionHistory": false}, -+ }, -+ }) -+ if err != nil { -+ return err -+ } -+ for attempt := 0; attempt < confirmationPollAttempts; attempt++ { -+ response, err := state.httpClient.Post(state.rpcURL, "application/json", bytes.NewReader(requestBody)) -+ if err != nil { -+ return fmt.Errorf("getSignatureStatuses transport: %w", err) -+ } -+ rawBody, readErr := io.ReadAll(response.Body) -+ _ = response.Body.Close() -+ if readErr != nil { -+ return readErr -+ } -+ if response.StatusCode < 200 || response.StatusCode >= 300 { -+ return fmt.Errorf("getSignatureStatuses HTTP %d: %s", response.StatusCode, string(rawBody)) -+ } -+ var payload struct { -+ Result *struct { -+ Value []*struct { -+ Confirmations *uint64 `json:"confirmations"` -+ ConfirmationStatus string `json:"confirmationStatus"` -+ Err any `json:"err"` -+ } `json:"value"` -+ } `json:"result"` -+ Error any `json:"error"` -+ } -+ if err := json.Unmarshal(rawBody, &payload); err != nil { -+ return err -+ } -+ if payload.Error != nil { -+ return fmt.Errorf("getSignatureStatuses RPC error: %v", payload.Error) -+ } -+ if payload.Result != nil && len(payload.Result.Value) > 0 && payload.Result.Value[0] != nil { -+ status := payload.Result.Value[0] -+ if status.Err != nil { -+ return fmt.Errorf("transaction failed on-chain: %v", status.Err) -+ } -+ if status.ConfirmationStatus == "confirmed" || status.ConfirmationStatus == "finalized" { -+ return nil -+ } -+ } -+ if attempt < confirmationPollAttempts-1 { -+ time.Sleep(confirmationPollInterval) -+ } -+ } -+ return fmt.Errorf("transaction not confirmed within timeout") -+} -+ -+func newInteropMux(state serverState) *http.ServeMux { -+ mux := http.NewServeMux() -+ mux.HandleFunc("/health", func(response http.ResponseWriter, _ *http.Request) { -+ writeJSON(response, http.StatusOK, map[string]any{"ok": true}) -+ }) -+ mux.HandleFunc("/capabilities", func(response http.ResponseWriter, _ *http.Request) { -+ writeJSON(response, http.StatusOK, capabilityPayload("go")) -+ }) -+ mux.HandleFunc("/exact", func(response http.ResponseWriter, _ *http.Request) { -+ writeExactPaymentRequired(response, state) -+ }) -+ mux.HandleFunc("/upto", func(response http.ResponseWriter, _ *http.Request) { -+ writePaymentRequired(response, uptoChallengePayload()) -+ }) -+ mux.HandleFunc("/session", func(response http.ResponseWriter, _ *http.Request) { -+ writeJSON(response, http.StatusPaymentRequired, sessionChallengePayload()) -+ }) -+ mux.HandleFunc("/batch-settlement", func(response http.ResponseWriter, _ *http.Request) { -+ writePaymentRequired(response, batchSettlementChallengePayload()) -+ }) -+ mux.HandleFunc("/", func(response http.ResponseWriter, request *http.Request) { -+ if request.URL.Path != defaultResourcePath { -+ writeJSON(response, http.StatusNotFound, map[string]any{"error": "not_found"}) -+ return -+ } -+ -+ paymentSignature := request.Header.Get("PAYMENT-SIGNATURE") -+ if paymentSignature == "" { -+ writeExactPaymentRequired(response, state) -+ return -+ } -+ -+ settlement, err := settleExactPayment(state, paymentSignature) -+ if err != nil { -+ challenge := exactChallengePayload(state) -+ encoded, marshalErr := json.Marshal(challenge) -+ if marshalErr != nil { -+ panic(marshalErr) -+ } -+ writeJSONWithHeaders( -+ response, -+ http.StatusPaymentRequired, -+ map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(encoded)}, -+ map[string]any{ -+ "error": "payment_invalid", -+ "message": err.Error(), -+ }, -+ ) -+ return -+ } -+ -+ writeJSONWithHeaders( -+ response, -+ http.StatusOK, -+ map[string]string{defaultSettlementHeader: settlement}, -+ map[string]any{ -+ "ok": true, -+ "paid": true, -+ "settlement": map[string]any{ -+ "success": true, -+ "transaction": settlement, -+ "network": state.network, -+ }, -+ }, -+ ) -+ }) -+ return mux -+} -+ -+func runInteropServer(state serverState, listener net.Listener, signals <-chan os.Signal, readyWriter io.Writer, errWriter io.Writer) error { -+ server := &http.Server{Handler: newInteropMux(state)} -+ serveErr := make(chan error, 1) -+ go func() { -+ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { -+ serveErr <- err -+ } -+ close(serveErr) -+ }() -+ -+ ready := capabilityPayload("go") -+ ready["type"] = "ready" -+ ready["port"] = listener.Addr().(*net.TCPAddr).Port -+ encoded, err := json.Marshal(ready) -+ if err != nil { -+ return err -+ } -+ if _, err := fmt.Fprintln(readyWriter, string(encoded)); err != nil { -+ return err -+ } -+ -+ select { -+ case <-signals: -+ if err := server.Close(); err != nil { -+ _, _ = fmt.Fprintln(errWriter, err) -+ return err -+ } -+ return nil -+ case err := <-serveErr: -+ if err != nil { -+ _, _ = fmt.Fprintln(errWriter, err) -+ } -+ return err -+ } -+} -+ -+func main() { -+ state := readState() -+ listener, err := net.Listen("tcp", "127.0.0.1:0") -+ if err != nil { -+ panic(err) -+ } -+ -+ signals := make(chan os.Signal, 1) -+ signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) -+ if err := runInteropServer(state, listener, signals, os.Stdout, os.Stderr); err != nil { -+ os.Exit(1) -+ } -+} -diff --git a/go/x402/cmd/interop-server/main_test.go b/go/x402/cmd/interop-server/main_test.go -new file mode 100644 -index 0000000..30c1acf ---- /dev/null -+++ b/go/x402/cmd/interop-server/main_test.go -@@ -0,0 +1,2714 @@ -+package main -+ -+import ( -+ "bytes" -+ "encoding/base64" -+ "encoding/binary" -+ "encoding/json" -+ "errors" -+ "fmt" -+ "io" -+ "net" -+ "net/http" -+ "net/http/httptest" -+ "os" -+ "strconv" -+ "strings" -+ "sync" -+ "syscall" -+ "testing" -+ "time" -+ -+ "github.com/gagliardetto/solana-go" -+) -+ -+func TestNormalizeAmountUsesSixMintDecimals(t *testing.T) { -+ tests := map[string]string{ -+ "$0.001": "1000", -+ "0.001 USDC": "1000", -+ "1": "1000000", -+ "1.25": "1250000", -+ } -+ -+ for price, expected := range tests { -+ if actual := normalizeAmount(price); actual != expected { -+ t.Fatalf("normalizeAmount(%q) = %q, want %q", price, actual, expected) -+ } -+ } -+} -+ -+func TestNormalizeAmountRejectsMalformedPrices(t *testing.T) { -+ tests := []string{ -+ "not-a-price", -+ "1.0000001", -+ "1.bad", -+ } -+ -+ for _, price := range tests { -+ t.Run(price, func(t *testing.T) { -+ mustPanic(t, func() { -+ normalizeAmount(price) -+ }) -+ }) -+ } -+} -+ -+func TestEnvHelpersAndReadState(t *testing.T) { -+ privateKey, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ encodedKey, err := json.Marshal([]byte(privateKey)) -+ if err != nil { -+ t.Fatal(err) -+ } -+ payTo := solana.NewWallet().PublicKey().String() -+ -+ t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") -+ t.Setenv("X402_INTEROP_PAY_TO", payTo) -+ t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) -+ t.Setenv("X402_INTEROP_NETWORK", solanaMainnetCAIP2) -+ t.Setenv("X402_INTEROP_MINT", "USDG") -+ t.Setenv("X402_INTEROP_PRICE", "$1.25") -+ t.Setenv("X402_INTEROP_EXTRA_OFFERED_MINTS", " PYUSD, , CASH ") -+ -+ state := readState() -+ if state.rpcURL != "http://rpc.test" || state.network != solanaMainnetCAIP2 { -+ t.Fatalf("unexpected state: %+v", state) -+ } -+ if state.mint != "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH" { -+ t.Fatalf("expected resolved USDG mainnet mint, got %s", state.mint) -+ } -+ if state.payTo != payTo || !state.feePayer.PublicKey().Equals(privateKey.PublicKey()) { -+ t.Fatalf("readState did not preserve configured keys") -+ } -+ if state.amount != "1250000" { -+ t.Fatalf("amount = %s, want 1250000", state.amount) -+ } -+ if len(state.extraOfferedMints) != 2 || -+ state.extraOfferedMints[0] != "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo" || -+ state.extraOfferedMints[1] != "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH" { -+ t.Fatalf("unexpected extra mints: %#v", state.extraOfferedMints) -+ } -+ if state.httpClient == nil { -+ t.Fatal("expected readState to configure an HTTP client") -+ } -+ if got := readEnvWithDefault("X402_INTEROP_NETWORK", "fallback"); got != solanaMainnetCAIP2 { -+ t.Fatalf("readEnvWithDefault configured = %q", got) -+ } -+ if got := readEnvWithDefault("X402_INTEROP_MISSING", "fallback"); got != "fallback" { -+ t.Fatalf("readEnvWithDefault fallback = %q", got) -+ } -+ -+ t.Setenv("X402_INTEROP_REQUIRED_EMPTY", "") -+ mustPanic(t, func() { -+ readRequiredEnv("X402_INTEROP_REQUIRED_EMPTY") -+ }) -+ mustPanic(t, func() { -+ keypairFromJSONSecret("[1,2,3]") -+ }) -+ mustPanic(t, func() { -+ keypairFromJSONSecret("{") -+ }) -+} -+ -+func TestJSONWritersAndChallengePayloads(t *testing.T) { -+ recorder := httptest.NewRecorder() -+ writeJSON(recorder, http.StatusAccepted, map[string]any{"ok": true}) -+ if recorder.Code != http.StatusAccepted { -+ t.Fatalf("status = %d", recorder.Code) -+ } -+ if recorder.Header().Get("content-type") != "application/json" { -+ t.Fatalf("unexpected content type: %s", recorder.Header().Get("content-type")) -+ } -+ if strings.TrimSpace(recorder.Body.String()) != `{"ok":true}` { -+ t.Fatalf("unexpected JSON body: %s", recorder.Body.String()) -+ } -+ -+ recorder = httptest.NewRecorder() -+ writeJSONWithHeaders(recorder, http.StatusCreated, map[string]string{"x-test": "value"}, map[string]any{"created": true}) -+ if recorder.Code != http.StatusCreated || recorder.Header().Get("x-test") != "value" { -+ t.Fatalf("headers/status not written: %d %v", recorder.Code, recorder.Header()) -+ } -+ -+ capabilities := capabilityPayload("go") -+ if capabilities["implementation"] != "go" || capabilities["role"] != "server" { -+ t.Fatalf("unexpected capability payload: %#v", capabilities) -+ } -+ if got := len(capabilities["capabilities"].([]string)); got != 1 { -+ t.Fatalf("expected one implemented capability, got %d", got) -+ } -+ -+ state := testServerState(t) -+ state.memo = "bound-memo" -+ exact := exactChallengePayload(state) -+ if exact.X402Version != 2 || exact.Resource["uri"] != defaultResourcePath { -+ t.Fatalf("unexpected exact challenge: %+v", exact) -+ } -+ if exact.Accepts[0].Extra["memo"] != "bound-memo" { -+ t.Fatalf("expected exact requirement to include memo") -+ } -+ if uptoChallengePayload()["x402Version"] != 2 { -+ t.Fatal("expected x402 upto challenge") -+ } -+ if sessionChallengePayload()["intent"] != "session" { -+ t.Fatal("expected session challenge intent") -+ } -+ if batchSettlementChallengePayload()["x402Version"] != 2 { -+ t.Fatal("expected batch settlement challenge") -+ } -+} -+ -+func TestPaymentRequiredWritersEncodeChallenges(t *testing.T) { -+ state := testServerState(t) -+ -+ recorder := httptest.NewRecorder() -+ writePaymentRequired(recorder, uptoChallengePayload()) -+ if recorder.Code != http.StatusPaymentRequired { -+ t.Fatalf("status = %d", recorder.Code) -+ } -+ decoded, err := base64.StdEncoding.DecodeString(recorder.Header().Get("PAYMENT-REQUIRED")) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if !strings.Contains(string(decoded), `"scheme":"upto"`) { -+ t.Fatalf("unexpected encoded challenge: %s", string(decoded)) -+ } -+ -+ recorder = httptest.NewRecorder() -+ writeExactPaymentRequired(recorder, state) -+ decoded, err = base64.StdEncoding.DecodeString(recorder.Header().Get("PAYMENT-REQUIRED")) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if !strings.Contains(string(decoded), `"scheme":"exact"`) { -+ t.Fatalf("unexpected exact challenge: %s", string(decoded)) -+ } -+} -+ -+func TestDefaultTokenProgramForMintHandlesAliasesAndMints(t *testing.T) { -+ tests := map[string]string{ -+ " PYUSD ": token2022Program, -+ "USDG": token2022Program, -+ "CASH": token2022Program, -+ "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM": token2022Program, -+ "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU": defaultTokenProgram, -+ } -+ -+ for mint, want := range tests { -+ t.Run(mint, func(t *testing.T) { -+ if got := defaultTokenProgramForMint(mint); got != want { -+ t.Fatalf("defaultTokenProgramForMint(%q) = %q, want %q", mint, got, want) -+ } -+ }) -+ } -+} -+ -+func TestPaymentRequirementMatchesBindsSettlementFields(t *testing.T) { -+ feePayer := solana.NewWallet().PrivateKey -+ state := serverState{ -+ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ payTo: solana.NewWallet().PublicKey().String(), -+ feePayer: feePayer, -+ amount: "1000", -+ } -+ -+ requirement := exactRequirement(state) -+ if !paymentRequirementMatches(requirement, requirement) { -+ t.Fatal("expected matching requirement to pass") -+ } -+ -+ mutated := requirement -+ mutated.Extra = map[string]any{ -+ "decimals": defaultDecimals, -+ "feePayer": solana.NewWallet().PublicKey().String(), -+ "tokenProgram": defaultTokenProgram, -+ } -+ if paymentRequirementMatches(mutated, requirement) { -+ t.Fatal("expected fee payer mutation to be rejected") -+ } -+} -+ -+func TestPaymentRequirementMatchesRejectsExactRequirementDrift(t *testing.T) { -+ feePayer := solana.NewWallet().PrivateKey -+ state := serverState{ -+ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ payTo: solana.NewWallet().PublicKey().String(), -+ feePayer: feePayer, -+ amount: "1000", -+ } -+ -+ requirement := exactRequirement(state) -+ tests := map[string]func(paymentRequirement) paymentRequirement{ -+ "scheme": func(value paymentRequirement) paymentRequirement { -+ value.Scheme = "upto" -+ return value -+ }, -+ "network": func(value paymentRequirement) paymentRequirement { -+ value.Network = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" -+ return value -+ }, -+ "asset": func(value paymentRequirement) paymentRequirement { -+ value.Asset = solana.NewWallet().PublicKey().String() -+ return value -+ }, -+ "amount": func(value paymentRequirement) paymentRequirement { -+ value.Amount = "2000" -+ return value -+ }, -+ "payTo": func(value paymentRequirement) paymentRequirement { -+ value.PayTo = solana.NewWallet().PublicKey().String() -+ return value -+ }, -+ "maxTimeoutSeconds": func(value paymentRequirement) paymentRequirement { -+ value.MaxTimeoutSeconds = defaultMaxTimeout + 1 -+ return value -+ }, -+ "extra.tokenProgram": func(value paymentRequirement) paymentRequirement { -+ value.Extra = cloneExtra(value.Extra) -+ value.Extra["tokenProgram"] = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" -+ return value -+ }, -+ "extra.unexpected": func(value paymentRequirement) paymentRequirement { -+ value.Extra = cloneExtra(value.Extra) -+ value.Extra["memo"] = "drift" -+ return value -+ }, -+ } -+ -+ for name, mutate := range tests { -+ t.Run(name, func(t *testing.T) { -+ if paymentRequirementMatches(mutate(requirement), requirement) { -+ t.Fatalf("expected %s drift to be rejected", name) -+ } -+ }) -+ } -+} -+ -+func TestExactChallengeIncludesExtraOfferedMints(t *testing.T) { -+ feePayer := solana.NewWallet().PrivateKey -+ state := serverState{ -+ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ payTo: solana.NewWallet().PublicKey().String(), -+ feePayer: feePayer, -+ amount: "1000", -+ extraOfferedMints: []string{"CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM"}, -+ } -+ -+ challenge := exactChallengePayload(state) -+ -+ if len(challenge.Accepts) != 2 { -+ t.Fatalf("expected primary plus extra mint offers, got %d", len(challenge.Accepts)) -+ } -+ if challenge.Accepts[0].Asset != state.mint { -+ t.Fatalf("expected primary mint first, got %s", challenge.Accepts[0].Asset) -+ } -+ if challenge.Accepts[1].Asset != state.extraOfferedMints[0] { -+ t.Fatalf("expected extra mint second, got %s", challenge.Accepts[1].Asset) -+ } -+ if challenge.Accepts[1].Extra["tokenProgram"] != "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" { -+ t.Fatalf("expected PYUSD offer to use Token-2022, got %v", challenge.Accepts[1].Extra["tokenProgram"]) -+ } -+} -+ -+func TestSettleExactPaymentRejectsMalformedPaymentSignature(t *testing.T) { -+ state := testServerState(t) -+ state.memo = "unit-duplicate" -+ -+ tests := map[string]string{ -+ "base64": "not base64", -+ "json": base64.StdEncoding.EncodeToString([]byte("{")), -+ } -+ -+ for name, header := range tests { -+ t.Run(name, func(t *testing.T) { -+ if _, err := settleExactPayment(state, header); err == nil { -+ t.Fatal("expected malformed payment signature to be rejected") -+ } -+ }) -+ } -+} -+ -+func TestSettleExactPaymentRejectsMissingAndInvalidTransactionPayload(t *testing.T) { -+ state := testServerState(t) -+ requirement := exactRequirement(state) -+ tests := map[string]map[string]string{ -+ "missing": {}, -+ "invalid": {"transaction": "not a transaction"}, -+ } -+ -+ for name, payload := range tests { -+ t.Run(name, func(t *testing.T) { -+ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ -+ X402Version: 2, -+ Accepted: requirement, -+ Payload: payload, -+ }) -+ -+ if _, err := settleExactPayment(state, header); err == nil { -+ t.Fatal("expected transaction payload to be rejected") -+ } -+ }) -+ } -+} -+ -+func TestSettleExactPaymentRejectsVersionAndRequirementMismatch(t *testing.T) { -+ state := testServerState(t) -+ requirement := exactRequirement(state) -+ -+ versionHeader := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ -+ X402Version: 1, -+ Accepted: requirement, -+ Payload: map[string]string{"transaction": "unused"}, -+ }) -+ if _, err := settleExactPayment(state, versionHeader); err == nil || err.Error() != "unsupported x402Version: 1" { -+ t.Fatalf("expected version rejection, got %v", err) -+ } -+ -+ drifted := requirement -+ drifted.Amount = "999" -+ driftHeader := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ -+ X402Version: 2, -+ Accepted: drifted, -+ Payload: map[string]string{"transaction": "unused"}, -+ }) -+ if _, err := settleExactPayment(state, driftHeader); err == nil || err.Error() != "accepted payment requirement does not match server challenge" { -+ t.Fatalf("expected requirement mismatch, got %v", err) -+ } -+} -+ -+func successfulSettlementClient(t *testing.T, signature string) *http.Client { -+ t.Helper() -+ return &http.Client{ -+ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { -+ rawBody, err := io.ReadAll(request.Body) -+ if err != nil { -+ t.Fatal(err) -+ } -+ body := string(rawBody) -+ responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` -+ switch { -+ case strings.Contains(body, `"method":"sendTransaction"`): -+ responseBody = fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"result":%q}`, signature) -+ case strings.Contains(body, `"method":"getSignatureStatuses"`): -+ responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` -+ } -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(responseBody)), -+ }, nil -+ }), -+ } -+} -+ -+func TestSettleExactPaymentAcceptsExtraOfferedMint(t *testing.T) { -+ settlementCache = newDuplicateSettlementCache() -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.extraOfferedMints = []string{"CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM"} -+ state.memo = "extra-mint" -+ state.httpClient = successfulSettlementClient(t, "extra-mint-settlement") -+ defer func() { -+ settlementCache = newDuplicateSettlementCache() -+ }() -+ -+ requirement := exactRequirementForMint(state, state.extraOfferedMints[0]) -+ transaction := signedTransactionForTest(t, requirement, client) -+ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ -+ X402Version: 2, -+ Accepted: requirement, -+ Payload: map[string]string{ -+ "transaction": transaction, -+ }, -+ }) -+ -+ settlement, err := settleExactPayment(state, header) -+ if err != nil { -+ t.Fatalf("expected extra offered mint settlement to pass: %v", err) -+ } -+ if settlement != "extra-mint-settlement" { -+ t.Fatalf("settlement = %q", settlement) -+ } -+} -+ -+func TestSettleExactPaymentRejectsDuplicateTransactionPayload(t *testing.T) { -+ settlementCache = newDuplicateSettlementCache() -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "unit-duplicate" -+ sendCalls := 0 -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { -+ rawBody, err := io.ReadAll(request.Body) -+ if err != nil { -+ t.Fatal(err) -+ } -+ body := string(rawBody) -+ responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` -+ switch { -+ case strings.Contains(body, `"method":"sendTransaction"`): -+ sendCalls++ -+ responseBody = `{"jsonrpc":"2.0","id":1,"result":"unit-settlement"}` -+ case strings.Contains(body, `"method":"getSignatureStatuses"`): -+ responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` -+ } -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(responseBody)), -+ }, nil -+ }), -+ } -+ defer func() { -+ settlementCache = newDuplicateSettlementCache() -+ }() -+ requirement := exactRequirement(state) -+ transaction := signedTransactionForTest(t, requirement, client) -+ -+ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ -+ X402Version: 2, -+ Accepted: requirement, -+ Payload: map[string]string{ -+ "transaction": transaction, -+ }, -+ }) -+ -+ if settlement, err := settleExactPayment(state, header); err != nil || settlement != "unit-settlement" { -+ t.Fatalf("first settlement = %q, %v", settlement, err) -+ } -+ if _, err := settleExactPayment(state, header); err == nil || err.Error() != "duplicate_settlement" { -+ t.Fatalf("expected duplicate_settlement, got %v", err) -+ } -+ // Under broadcast-first L8 ordering, the duplicate transaction does -+ // reach sendTransaction (Solana itself is the global uniqueness -+ // primitive: a re-broadcast of the same signed tx is idempotent -+ // within its blockhash window). The replay-store check only fires -+ // post-confirmation, so the second call broadcasts and then is -+ // rejected at putIfAbsent because the signature was already -+ // consumed by the first successful settlement. -+ if sendCalls != 2 { -+ t.Fatalf("expected two sendTransaction calls under broadcast-first ordering, got %d", sendCalls) -+ } -+} -+ -+// TestSettleExactPaymentDoesNotConsumeReplayKeyOnPreBroadcastFailure covers -+// the L8 ordering invariant: a verification failure before broadcast (here, -+// a missing source token account) MUST NOT insert anything into the -+// replay-store. The proof is that an immediate retry of the same envelope -+// produces the same pre-broadcast error (rather than being rejected as a -+// duplicate settlement). Under broadcast-first ordering there is no -+// release-on-failure path; correctness follows from "never inserted in -+// the first place" instead. -+func TestSettleExactPaymentDoesNotConsumeReplayKeyOnPreBroadcastFailure(t *testing.T) { -+ settlementCache = newDuplicateSettlementCache() -+ defer func() { -+ settlementCache = newDuplicateSettlementCache() -+ }() -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "unit-missing-ata" -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { -+ rawBody, err := io.ReadAll(request.Body) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if strings.Contains(string(rawBody), `"method":"getAccountInfo"`) { -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"context":{"slot":1},"value":null}}`)), -+ }, nil -+ } -+ t.Fatalf("unexpected RPC body: %s", string(rawBody)) -+ return nil, nil -+ }), -+ } -+ requirement := exactRequirement(state) -+ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ -+ X402Version: 2, -+ Accepted: requirement, -+ Payload: map[string]string{ -+ "transaction": signedTransactionForTest(t, requirement, client), -+ }, -+ }) -+ -+ if _, err := settleExactPayment(state, header); err == nil || err.Error() != "source token account does not exist" { -+ t.Fatalf("expected missing source account, got %v", err) -+ } -+ if _, err := settleExactPayment(state, header); err == nil || err.Error() != "source token account does not exist" { -+ t.Fatalf("expected retry to surface the same pre-broadcast error (replay key never inserted), got %v", err) -+ } -+} -+ -+// TestSettleExactPaymentL8OrderingObserved asserts the L8 RPC call -+// sequence: getAccountInfo (token-account existence) → sendTransaction -+// (broadcast) → getSignatureStatuses (await confirmation) → replay store -+// insert. The replay store insert is observable through a duplicate retry -+// returning duplicate_settlement on the SAME signature, without any RPC -+// activity ordered after putIfAbsent. -+func TestSettleExactPaymentL8OrderingObserved(t *testing.T) { -+ settlementCache = newDuplicateSettlementCache() -+ defer func() { settlementCache = newDuplicateSettlementCache() }() -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "l8-ordering" -+ var rpcCalls []string -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { -+ rawBody, err := io.ReadAll(request.Body) -+ if err != nil { -+ t.Fatal(err) -+ } -+ body := string(rawBody) -+ responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` -+ switch { -+ case strings.Contains(body, `"method":"sendTransaction"`): -+ rpcCalls = append(rpcCalls, "sendTransaction") -+ responseBody = `{"jsonrpc":"2.0","id":1,"result":"l8-sig"}` -+ case strings.Contains(body, `"method":"getSignatureStatuses"`): -+ rpcCalls = append(rpcCalls, "getSignatureStatuses") -+ responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` -+ case strings.Contains(body, `"method":"getAccountInfo"`): -+ rpcCalls = append(rpcCalls, "getAccountInfo") -+ } -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(responseBody)), -+ }, nil -+ }), -+ } -+ requirement := exactRequirement(state) -+ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ -+ X402Version: 2, -+ Accepted: requirement, -+ Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, -+ }) -+ -+ signature, err := settleExactPayment(state, header) -+ if err != nil { -+ t.Fatalf("expected first settlement to succeed, got %v", err) -+ } -+ if signature != "l8-sig" { -+ t.Fatalf("signature = %q", signature) -+ } -+ // Drop pre-broadcast getAccountInfo calls; the load-bearing assertion -+ // is that broadcast precedes confirmation polling, which precedes the -+ // replay-store insert (proven by the subsequent duplicate_settlement). -+ var phaseOrder []string -+ for _, call := range rpcCalls { -+ if call == "sendTransaction" || call == "getSignatureStatuses" { -+ phaseOrder = append(phaseOrder, call) -+ } -+ } -+ if len(phaseOrder) < 2 || phaseOrder[0] != "sendTransaction" || phaseOrder[1] != "getSignatureStatuses" { -+ t.Fatalf("expected sendTransaction before getSignatureStatuses, got %v", phaseOrder) -+ } -+ if _, ok := settlementCache.entries[replayKeyNamespace+signature]; !ok { -+ t.Fatalf("expected replay key %q to be present after confirmation", replayKeyNamespace+signature) -+ } -+} -+ -+// TestSettleExactPaymentDoesNotConsumeReplayKeyOnBroadcastFailure covers -+// the L8 invariant that an RPC failure during broadcast (before -+// confirmation) MUST NOT insert the replay key. Mirrors MPP -+// `server/charge.rs` semantics: only a confirmed signature is consumed. -+func TestSettleExactPaymentDoesNotConsumeReplayKeyOnBroadcastFailure(t *testing.T) { -+ settlementCache = newDuplicateSettlementCache() -+ defer func() { settlementCache = newDuplicateSettlementCache() }() -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "l8-broadcast-fail" -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { -+ rawBody, err := io.ReadAll(request.Body) -+ if err != nil { -+ t.Fatal(err) -+ } -+ body := string(rawBody) -+ responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` -+ if strings.Contains(body, `"method":"sendTransaction"`) { -+ responseBody = `{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"blockhash not found"}}` -+ } -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(responseBody)), -+ }, nil -+ }), -+ } -+ requirement := exactRequirement(state) -+ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ -+ X402Version: 2, -+ Accepted: requirement, -+ Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, -+ }) -+ -+ if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "sendTransaction RPC error") { -+ t.Fatalf("expected broadcast RPC error, got %v", err) -+ } -+ if len(settlementCache.entries) != 0 { -+ t.Fatalf("expected empty replay cache after broadcast failure, got %d entries", len(settlementCache.entries)) -+ } -+} -+ -+// TestSettleExactPaymentDoesNotConsumeReplayKeyOnConfirmationFailure -+// covers the L8 invariant that an on-chain failure surfaced via -+// getSignatureStatuses (e.g. tx landed but reverted) MUST NOT insert -+// the replay key — a future re-broadcast under a fresh blockhash is the -+// caller's option, not a duplicate. -+func TestSettleExactPaymentDoesNotConsumeReplayKeyOnConfirmationFailure(t *testing.T) { -+ settlementCache = newDuplicateSettlementCache() -+ defer func() { settlementCache = newDuplicateSettlementCache() }() -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "l8-confirm-fail" -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { -+ rawBody, err := io.ReadAll(request.Body) -+ if err != nil { -+ t.Fatal(err) -+ } -+ body := string(rawBody) -+ responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` -+ switch { -+ case strings.Contains(body, `"method":"sendTransaction"`): -+ responseBody = `{"jsonrpc":"2.0","id":1,"result":"reverted-sig"}` -+ case strings.Contains(body, `"method":"getSignatureStatuses"`): -+ responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":{"InstructionError":[0,"Custom"]},"confirmationStatus":"confirmed"}]}}` -+ } -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(responseBody)), -+ }, nil -+ }), -+ } -+ requirement := exactRequirement(state) -+ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ -+ X402Version: 2, -+ Accepted: requirement, -+ Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, -+ }) -+ -+ if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "transaction failed on-chain") { -+ t.Fatalf("expected on-chain failure, got %v", err) -+ } -+ if _, ok := settlementCache.entries[replayKeyNamespace+"reverted-sig"]; ok { -+ t.Fatalf("expected replay key NOT to be consumed when confirmation surfaces on-chain failure") -+ } -+} -+ -+// TestSettleExactPaymentReturnsCanonicalErrorForConsumedSignature covers -+// the L8 invariant that a putIfAbsent collision (signature already -+// consumed) surfaces the canonical duplicate_settlement error and does -+// not echo a fresh PAYMENT-RESPONSE. -+func TestSettleExactPaymentReturnsCanonicalErrorForConsumedSignature(t *testing.T) { -+ settlementCache = newDuplicateSettlementCache() -+ defer func() { settlementCache = newDuplicateSettlementCache() }() -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "l8-pre-consumed" -+ state.httpClient = successfulSettlementClient(t, "pre-consumed-sig") -+ // Simulate a prior successful settlement having already inserted -+ // the canonical replay key for this signature. -+ settlementCache.entries[replayKeyNamespace+"pre-consumed-sig"] = time.Now() -+ -+ requirement := exactRequirement(state) -+ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ -+ X402Version: 2, -+ Accepted: requirement, -+ Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, -+ }) -+ if _, err := settleExactPayment(state, header); err == nil || err.Error() != "duplicate_settlement" { -+ t.Fatalf("expected duplicate_settlement on already-consumed signature, got %v", err) -+ } -+} -+ -+// TestSettleExactPaymentConcurrentDuplicatesCollapse asserts that two -+// concurrent settlements producing the same signature collapse to a -+// single successful settle and one canonical duplicate_settlement. -+// Solana's per-signature replay protection guarantees the on-chain -+// effect is single; the putIfAbsent collision in the replay store -+// guarantees the off-chain accounting is single. -+func TestSettleExactPaymentConcurrentDuplicatesCollapse(t *testing.T) { -+ settlementCache = newDuplicateSettlementCache() -+ defer func() { settlementCache = newDuplicateSettlementCache() }() -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "l8-concurrent" -+ state.httpClient = successfulSettlementClient(t, "concurrent-sig") -+ requirement := exactRequirement(state) -+ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ -+ X402Version: 2, -+ Accepted: requirement, -+ Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, -+ }) -+ -+ const concurrency = 4 -+ var wg sync.WaitGroup -+ results := make([]error, concurrency) -+ signatures := make([]string, concurrency) -+ wg.Add(concurrency) -+ for i := 0; i < concurrency; i++ { -+ go func(idx int) { -+ defer wg.Done() -+ signatures[idx], results[idx] = settleExactPayment(state, header) -+ }(i) -+ } -+ wg.Wait() -+ -+ successes := 0 -+ duplicates := 0 -+ for i, err := range results { -+ switch { -+ case err == nil: -+ successes++ -+ if signatures[i] != "concurrent-sig" { -+ t.Fatalf("unexpected signature %q", signatures[i]) -+ } -+ case err.Error() == "duplicate_settlement": -+ duplicates++ -+ default: -+ t.Fatalf("unexpected error %v", err) -+ } -+ } -+ if successes != 1 || duplicates != concurrency-1 { -+ t.Fatalf("expected 1 success + %d duplicates, got %d / %d", concurrency-1, successes, duplicates) -+ } -+} -+ -+// TestAwaitSignatureConfirmationCases drills the L8 confirmation poll -+// directly against the four observable RPC outcomes: confirmed/finalized -+// success, on-chain failure, transport-level RPC error, and bounded -+// timeout when no status ever surfaces. -+func TestAwaitSignatureConfirmationCases(t *testing.T) { -+ prevAttempts := confirmationPollAttempts -+ prevInterval := confirmationPollInterval -+ confirmationPollAttempts = 3 -+ confirmationPollInterval = time.Millisecond -+ defer func() { -+ confirmationPollAttempts = prevAttempts -+ confirmationPollInterval = prevInterval -+ }() -+ -+ tests := map[string]struct { -+ responseBody string -+ wantErr string -+ }{ -+ "confirmed": { -+ responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}`, -+ wantErr: "", -+ }, -+ "finalized": { -+ responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":32,"err":null,"confirmationStatus":"finalized"}]}}`, -+ wantErr: "", -+ }, -+ "on-chain failure": { -+ responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":{"InstructionError":[0,"Custom"]},"confirmationStatus":"confirmed"}]}}`, -+ wantErr: "transaction failed on-chain", -+ }, -+ "rpc error": { -+ responseBody: `{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"boom"}}`, -+ wantErr: "getSignatureStatuses RPC error", -+ }, -+ "timeout": { -+ responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[null]}}`, -+ wantErr: "transaction not confirmed within timeout", -+ }, -+ } -+ for name, test := range tests { -+ t.Run(name, func(t *testing.T) { -+ state := testServerState(t) -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(test.responseBody)), -+ }, nil -+ }), -+ } -+ err := awaitSignatureConfirmation(state, "sig") -+ switch { -+ case test.wantErr == "" && err != nil: -+ t.Fatalf("expected success, got %v", err) -+ case test.wantErr != "" && (err == nil || !strings.Contains(err.Error(), test.wantErr)): -+ t.Fatalf("expected error containing %q, got %v", test.wantErr, err) -+ } -+ }) -+ } -+} -+ -+func TestAwaitSignatureConfirmationTransportError(t *testing.T) { -+ prevAttempts := confirmationPollAttempts -+ confirmationPollAttempts = 1 -+ defer func() { confirmationPollAttempts = prevAttempts }() -+ state := testServerState(t) -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { -+ return nil, fmt.Errorf("dial timeout") -+ }), -+ } -+ if err := awaitSignatureConfirmation(state, "sig"); err == nil || !strings.Contains(err.Error(), "getSignatureStatuses transport") { -+ t.Fatalf("expected transport error, got %v", err) -+ } -+} -+ -+func TestAwaitSignatureConfirmationNon2xx(t *testing.T) { -+ prevAttempts := confirmationPollAttempts -+ confirmationPollAttempts = 1 -+ defer func() { confirmationPollAttempts = prevAttempts }() -+ state := testServerState(t) -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { -+ return &http.Response{ -+ StatusCode: http.StatusInternalServerError, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(`{"error":"boom"}`)), -+ }, nil -+ }), -+ } -+ if err := awaitSignatureConfirmation(state, "sig"); err == nil || !strings.Contains(err.Error(), "getSignatureStatuses HTTP 500") { -+ t.Fatalf("expected HTTP 500, got %v", err) -+ } -+} -+ -+func TestVerifyExactTransactionRejectsSpecViolations(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "unit-spec" -+ requirement := exactRequirement(state) -+ valid := transactionForTest(t, requirement, client) -+ -+ tests := map[string]struct { -+ mutate func(*solana.Transaction) -+ want string -+ }{ -+ "compute price too high": { -+ mutate: func(tx *solana.Transaction) { -+ tx.Message.Instructions[1].Data = computePriceDataForTest(maxComputeUnitPrice + 1) -+ }, -+ want: "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high", -+ }, -+ "amount mismatch": { -+ mutate: func(tx *solana.Transaction) { -+ data := []byte{12} -+ data = binary.LittleEndian.AppendUint64(data, 999) -+ data = append(data, byte(defaultDecimals)) -+ tx.Message.Instructions[2].Data = data -+ }, -+ want: "invalid_exact_svm_payload_transaction_amount", -+ }, -+ "missing memo": { -+ mutate: func(tx *solana.Transaction) { -+ tx.Message.Instructions = tx.Message.Instructions[:3] -+ }, -+ want: "invalid_exact_svm_payload_transaction_memo", -+ }, -+ "fee payer instruction account": { -+ mutate: func(tx *solana.Transaction) { -+ tx.Message.Instructions[2].Accounts[0] = 0 -+ }, -+ want: "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", -+ }, -+ "mint mismatch": { -+ mutate: func(tx *solana.Transaction) { -+ tx.Message.AccountKeys = append(tx.Message.AccountKeys, solana.NewWallet().PublicKey()) -+ tx.Message.Instructions[2].Accounts[1] = uint16(len(tx.Message.AccountKeys) - 1) -+ }, -+ want: "invalid_exact_svm_payload_transaction_mint", -+ }, -+ "decimals mismatch": { -+ mutate: func(tx *solana.Transaction) { -+ data := []byte{12} -+ data = binary.LittleEndian.AppendUint64(data, 1000) -+ data = append(data, byte(defaultDecimals+1)) -+ tx.Message.Instructions[2].Data = data -+ }, -+ want: "invalid_exact_svm_payload_transaction_decimals", -+ }, -+ "memo mismatch": { -+ mutate: func(tx *solana.Transaction) { -+ tx.Message.Instructions[3].Data = []byte("wrong") -+ }, -+ want: "invalid_exact_svm_payload_transaction_memo", -+ }, -+ "unknown fourth instruction": { -+ mutate: func(tx *solana.Transaction) { -+ tx.Message.Instructions[3] = compiledInstructionForTest(t, tx, solana.SystemProgramID.String(), nil) -+ }, -+ want: "invalid_exact_svm_payload_unknown_fourth_instruction", -+ }, -+ } -+ -+ for name, test := range tests { -+ t.Run(name, func(t *testing.T) { -+ tx := cloneTransactionForTest(t, valid) -+ test.mutate(tx) -+ if err := verifyExactTransaction(tx, requirement); err == nil || err.Error() != test.want { -+ t.Fatalf("expected %q, got %v", test.want, err) -+ } -+ }) -+ } -+} -+ -+func TestVerifyExactTransactionRejectsMalformedInstructionShapes(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "unit-shapes" -+ requirement := exactRequirement(state) -+ valid := transactionForTest(t, requirement, client) -+ -+ tests := map[string]struct { -+ mutate func(*solana.Transaction) -+ want string -+ }{ -+ "legacy transaction": { -+ mutate: func(tx *solana.Transaction) { -+ tx.Message.SetVersion(solana.MessageVersionLegacy) -+ }, -+ want: "payment transaction must be versioned", -+ }, -+ "too few instructions": { -+ mutate: func(tx *solana.Transaction) { -+ tx.Message.Instructions = tx.Message.Instructions[:2] -+ }, -+ want: "invalid_exact_svm_payload_transaction_instructions_length", -+ }, -+ "bad compute limit": { -+ mutate: func(tx *solana.Transaction) { -+ tx.Message.Instructions[0].Data = []byte{2} -+ }, -+ want: "invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction", -+ }, -+ "bad compute price": { -+ mutate: func(tx *solana.Transaction) { -+ tx.Message.Instructions[1].Data = []byte{3} -+ }, -+ want: "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction", -+ }, -+ "bad transfer program": { -+ mutate: func(tx *solana.Transaction) { -+ tx.Message.Instructions[2] = compiledInstructionForTest(t, tx, solana.SystemProgramID.String(), []byte{12}) -+ }, -+ want: "invalid_exact_svm_payload_transaction_transfer_program", -+ }, -+ "bad transfer data": { -+ mutate: func(tx *solana.Transaction) { -+ tx.Message.Instructions[2].Data = []byte{12} -+ }, -+ want: "invalid_exact_svm_payload_transaction_transfer_checked", -+ }, -+ } -+ -+ for name, test := range tests { -+ t.Run(name, func(t *testing.T) { -+ tx := cloneTransactionForTest(t, valid) -+ test.mutate(tx) -+ if err := verifyExactTransaction(tx, requirement); err == nil || err.Error() != test.want { -+ t.Fatalf("expected %q, got %v", test.want, err) -+ } -+ }) -+ } -+} -+ -+func TestParseTransferCheckedInstructionRejectsInvalidAccountIndexes(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "unit-transfer-indexes" -+ tx := transactionForTest(t, exactRequirement(state), client) -+ instruction := tx.Message.Instructions[2] -+ -+ tests := map[string]int{ -+ "source": 0, -+ "mint": 1, -+ "destination": 2, -+ "authority": 3, -+ } -+ -+ for name, accountIndex := range tests { -+ t.Run(name, func(t *testing.T) { -+ mutated := instruction -+ mutated.Accounts = append([]uint16(nil), instruction.Accounts...) -+ mutated.Accounts[accountIndex] = uint16(len(tx.Message.AccountKeys)) -+ if _, err := parseTransferCheckedInstruction(tx, mutated); err == nil { -+ t.Fatal("expected invalid account index") -+ } -+ }) -+ } -+} -+ -+func TestVerifyExactTransactionRejectsMalformedRequirementFields(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "unit-requirement-fields" -+ requirement := exactRequirement(state) -+ valid := transactionForTest(t, requirement, client) -+ -+ tests := map[string]struct { -+ mutate func(paymentRequirement) paymentRequirement -+ want string -+ }{ -+ "fee payer": { -+ mutate: func(value paymentRequirement) paymentRequirement { -+ value.Extra = cloneExtra(value.Extra) -+ value.Extra["feePayer"] = "not-base58" -+ return value -+ }, -+ want: "invalid feePayer:", -+ }, -+ "asset": { -+ mutate: func(value paymentRequirement) paymentRequirement { -+ value.Asset = "not-base58" -+ return value -+ }, -+ want: "invalid asset:", -+ }, -+ "amount": { -+ mutate: func(value paymentRequirement) paymentRequirement { -+ value.Amount = "not-int" -+ return value -+ }, -+ want: "invalid amount:", -+ }, -+ "payTo": { -+ mutate: func(value paymentRequirement) paymentRequirement { -+ value.PayTo = "not-base58" -+ return value -+ }, -+ want: "invalid payTo:", -+ }, -+ } -+ -+ for name, test := range tests { -+ t.Run(name, func(t *testing.T) { -+ err := verifyExactTransaction(cloneTransactionForTest(t, valid), test.mutate(requirement)) -+ if err == nil || !strings.Contains(err.Error(), test.want) { -+ t.Fatalf("expected %q, got %v", test.want, err) -+ } -+ }) -+ } -+} -+ -+func TestVerifyExactTransactionAllowsOptionalLighthouseBeforeMemo(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "unit-lighthouse" -+ requirement := exactRequirement(state) -+ tx := transactionForTest(t, requirement, client) -+ -+ tx.Message.Instructions = append( -+ tx.Message.Instructions[:3], -+ append( -+ []solana.CompiledInstruction{compiledInstructionForTest(t, tx, lighthouseProgram, []byte{9, 0})}, -+ tx.Message.Instructions[3:]..., -+ )..., -+ ) -+ -+ if err := verifyExactTransaction(tx, requirement); err != nil { -+ t.Fatalf("expected lighthouse before memo to be accepted, got %v", err) -+ } -+} -+ -+func TestVerifyExactTransactionAllowsValidDestinationATACreateInstruction(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "unit-create-ata" -+ requirement := exactRequirement(state) -+ tx := transactionForTest(t, requirement, client) -+ transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) -+ if err != nil { -+ t.Fatal(err) -+ } -+ payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) -+ -+ tx.Message.Instructions = append( -+ tx.Message.Instructions[:3], -+ append( -+ []solana.CompiledInstruction{ -+ compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ -+ client.PublicKey(), -+ transfer.destination, -+ payTo, -+ transfer.mint, -+ solana.SystemProgramID, -+ transfer.tokenProgram, -+ }, []byte{1}), -+ }, -+ tx.Message.Instructions[3:]..., -+ )..., -+ ) -+ -+ if err := verifyExactTransaction(tx, requirement); err != nil { -+ t.Fatalf("expected valid destination ATA create instruction to be accepted, got %v", err) -+ } -+} -+ -+func TestValidDestinationATACreateInstructionRejectsMalformedCreateInstructions(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "unit-create-ata-invalid" -+ requirement := exactRequirement(state) -+ tx := transactionForTest(t, requirement, client) -+ transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) -+ if err != nil { -+ t.Fatal(err) -+ } -+ payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) -+ validAccounts := []solana.PublicKey{ -+ client.PublicKey(), -+ transfer.destination, -+ payTo, -+ transfer.mint, -+ solana.SystemProgramID, -+ transfer.tokenProgram, -+ } -+ -+ tests := map[string]solana.CompiledInstruction{ -+ "bad data": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, validAccounts, []byte{2}), -+ "too many data bytes": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, validAccounts, []byte{0, 0}), -+ "too few accounts": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, validAccounts[:5], nil), -+ "wrong associated account": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ -+ client.PublicKey(), -+ solana.NewWallet().PublicKey(), -+ payTo, -+ transfer.mint, -+ solana.SystemProgramID, -+ transfer.tokenProgram, -+ }, nil), -+ "wrong wallet": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ -+ client.PublicKey(), -+ transfer.destination, -+ solana.NewWallet().PublicKey(), -+ transfer.mint, -+ solana.SystemProgramID, -+ transfer.tokenProgram, -+ }, nil), -+ "wrong mint": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ -+ client.PublicKey(), -+ transfer.destination, -+ payTo, -+ solana.NewWallet().PublicKey(), -+ solana.SystemProgramID, -+ transfer.tokenProgram, -+ }, nil), -+ "wrong system program": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ -+ client.PublicKey(), -+ transfer.destination, -+ payTo, -+ transfer.mint, -+ solana.NewWallet().PublicKey(), -+ transfer.tokenProgram, -+ }, nil), -+ "wrong token program": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ -+ client.PublicKey(), -+ transfer.destination, -+ payTo, -+ transfer.mint, -+ solana.SystemProgramID, -+ solana.NewWallet().PublicKey(), -+ }, nil), -+ } -+ -+ for name, instruction := range tests { -+ t.Run(name, func(t *testing.T) { -+ if validDestinationATACreateInstruction(tx, instruction, requirement, transfer) { -+ t.Fatal("expected malformed destination ATA create instruction to be rejected") -+ } -+ }) -+ } -+} -+ -+func TestVerifyTokenAccountsExistSkipsMissingDestinationWhenCreateATAIsPresent(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "unit-create-ata-exists" -+ accountInfoCalls := 0 -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { -+ rawBody, err := io.ReadAll(request.Body) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if !strings.Contains(string(rawBody), `"method":"getAccountInfo"`) { -+ t.Fatalf("unexpected RPC body: %s", string(rawBody)) -+ } -+ accountInfoCalls++ -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"context":{"slot":1},"value":{"data":["","base64"]}}}`)), -+ }, nil -+ }), -+ } -+ requirement := exactRequirement(state) -+ tx := transactionForTest(t, requirement, client) -+ transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) -+ if err != nil { -+ t.Fatal(err) -+ } -+ payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) -+ tx.Message.Instructions = append( -+ tx.Message.Instructions[:3], -+ append( -+ []solana.CompiledInstruction{ -+ compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ -+ client.PublicKey(), -+ transfer.destination, -+ payTo, -+ transfer.mint, -+ solana.SystemProgramID, -+ transfer.tokenProgram, -+ }, nil), -+ }, -+ tx.Message.Instructions[3:]..., -+ )..., -+ ) -+ -+ if err := verifyTokenAccountsExist(state, tx, requirement); err != nil { -+ t.Fatalf("expected create ATA instruction to satisfy destination existence policy, got %v", err) -+ } -+ if accountInfoCalls != 1 { -+ t.Fatalf("expected only source account lookup, got %d", accountInfoCalls) -+ } -+} -+ -+func TestVerifyTokenAccountsExistRejectsMissingDestinationWithoutCreateATA(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "unit-missing-destination" -+ accountInfoCalls := 0 -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { -+ if _, err := io.ReadAll(request.Body); err != nil { -+ t.Fatal(err) -+ } -+ accountInfoCalls++ -+ body := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` -+ if accountInfoCalls == 2 { -+ body = `{"jsonrpc":"2.0","id":1,"result":{"value":null}}` -+ } -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(body)), -+ }, nil -+ }), -+ } -+ requirement := exactRequirement(state) -+ tx := transactionForTest(t, requirement, client) -+ -+ if err := verifyTokenAccountsExist(state, tx, requirement); err == nil || err.Error() != "destination token account does not exist" { -+ t.Fatalf("expected missing destination account, got %v", err) -+ } -+ if accountInfoCalls != 2 { -+ t.Fatalf("expected source and destination lookups, got %d", accountInfoCalls) -+ } -+} -+ -+func TestVerifyTokenAccountsExistAcceptsExistingSourceAndDestination(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "unit-existing-atas" -+ accountInfoCalls := 0 -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { -+ rawBody, err := io.ReadAll(request.Body) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if !strings.Contains(string(rawBody), `"method":"getAccountInfo"`) { -+ t.Fatalf("unexpected RPC body: %s", string(rawBody)) -+ } -+ accountInfoCalls++ -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}`)), -+ }, nil -+ }), -+ } -+ requirement := exactRequirement(state) -+ tx := transactionForTest(t, requirement, client) -+ -+ if err := verifyTokenAccountsExist(state, tx, requirement); err != nil { -+ t.Fatalf("expected existing source and destination accounts, got %v", err) -+ } -+ if accountInfoCalls != 2 { -+ t.Fatalf("expected source and destination lookups, got %d", accountInfoCalls) -+ } -+} -+ -+func TestVerifyExactTransactionAllowsMissingMemoWhenRequirementDoesNotBindMemo(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "" -+ requirement := exactRequirement(state) -+ builderRequirement := requirement -+ builderRequirement.Extra = cloneExtra(requirement.Extra) -+ builderRequirement.Extra["memo"] = "builder-memo" -+ tx := transactionForTest(t, builderRequirement, client) -+ tx.Message.Instructions = tx.Message.Instructions[:3] -+ -+ if err := verifyExactTransaction(tx, requirement); err != nil { -+ t.Fatalf("expected missing memo to be accepted when requirement has no memo, got %v", err) -+ } -+} -+ -+func TestVerifyOptionalInstructionsRejectsMemoViolations(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "unit-memo" -+ requirement := exactRequirement(state) -+ tx := transactionForTest(t, requirement, client) -+ transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ tests := map[string]struct { -+ requirement paymentRequirement -+ instructions []solana.CompiledInstruction -+ want string -+ }{ -+ "empty unbound memo": { -+ requirement: func() paymentRequirement { -+ value := requirement -+ value.Extra = cloneExtra(value.Extra) -+ delete(value.Extra, "memo") -+ return value -+ }(), -+ instructions: []solana.CompiledInstruction{compiledInstructionForTest(t, tx, memoProgramID.String(), nil)}, -+ want: "invalid_exact_svm_payload_transaction_memo", -+ }, -+ "oversized memo": { -+ requirement: func() paymentRequirement { -+ value := requirement -+ value.Extra = cloneExtra(value.Extra) -+ delete(value.Extra, "memo") -+ return value -+ }(), -+ instructions: []solana.CompiledInstruction{compiledInstructionForTest(t, tx, memoProgramID.String(), []byte(strings.Repeat("x", maxMemoBytes+1)))}, -+ want: "extra.memo exceeds maximum 256 bytes", -+ }, -+ "duplicate bound memo": { -+ requirement: requirement, -+ instructions: []solana.CompiledInstruction{ -+ compiledInstructionForTest(t, tx, memoProgramID.String(), []byte("unit-memo")), -+ compiledInstructionForTest(t, tx, memoProgramID.String(), []byte("unit-memo")), -+ }, -+ want: "invalid_exact_svm_payload_transaction_memo", -+ }, -+ } -+ -+ for name, test := range tests { -+ t.Run(name, func(t *testing.T) { -+ if err := verifyOptionalInstructions(tx, test.instructions, test.requirement, transfer); err == nil || err.Error() != test.want { -+ t.Fatalf("expected %q, got %v", test.want, err) -+ } -+ }) -+ } -+} -+ -+func TestDuplicateSettlementCachePrunesExpiredEntries(t *testing.T) { -+ cache := newDuplicateSettlementCache() -+ now := time.Unix(1_700_000_000, 0) -+ cache.now = func() time.Time { -+ return now -+ } -+ cache.entries["expired"] = now.Add(-(duplicateCacheTTL + time.Second)) -+ cache.entries["fresh"] = now -+ -+ if !cache.putIfAbsent("new") { -+ t.Fatal("expected new key to be inserted") -+ } -+ if _, ok := cache.entries["expired"]; ok { -+ t.Fatal("expected expired cache entry to be pruned") -+ } -+ if _, ok := cache.entries["fresh"]; !ok { -+ t.Fatal("expected fresh cache entry to survive pruning") -+ } -+ if !cache.putIfAbsent("expired") { -+ t.Fatal("expected pruned key to be re-insertable") -+ } -+ if cache.putIfAbsent("fresh") { -+ t.Fatal("expected fresh duplicate to be rejected") -+ } -+} -+ -+func TestAccountExistsHandlesRPCResponses(t *testing.T) { -+ account := solana.NewWallet().PublicKey() -+ tests := map[string]struct { -+ status int -+ body string -+ exists bool -+ err bool -+ }{ -+ "exists": { -+ status: http.StatusOK, -+ body: `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}`, -+ exists: true, -+ }, -+ "missing null value": { -+ status: http.StatusOK, -+ body: `{"jsonrpc":"2.0","id":1,"result":{"value":null}}`, -+ exists: false, -+ }, -+ "missing result": { -+ status: http.StatusOK, -+ body: `{"jsonrpc":"2.0","id":1}`, -+ exists: false, -+ }, -+ "rpc error": { -+ status: http.StatusOK, -+ body: `{"jsonrpc":"2.0","id":1,"error":{"message":"nope"}}`, -+ err: true, -+ }, -+ "http error": { -+ status: http.StatusBadGateway, -+ body: `bad gateway`, -+ err: true, -+ }, -+ "invalid json": { -+ status: http.StatusOK, -+ body: `{`, -+ err: true, -+ }, -+ } -+ -+ for name, test := range tests { -+ t.Run(name, func(t *testing.T) { -+ state := testServerState(t) -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { -+ rawBody, err := io.ReadAll(request.Body) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if !strings.Contains(string(rawBody), `"method":"getAccountInfo"`) || !strings.Contains(string(rawBody), account.String()) { -+ t.Fatalf("unexpected accountExists RPC body: %s", string(rawBody)) -+ } -+ return &http.Response{ -+ StatusCode: test.status, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(test.body)), -+ }, nil -+ }), -+ } -+ -+ exists, err := accountExists(state, account) -+ if test.err { -+ if err == nil { -+ t.Fatal("expected error") -+ } -+ return -+ } -+ if err != nil { -+ t.Fatal(err) -+ } -+ if exists != test.exists { -+ t.Fatalf("exists = %v, want %v", exists, test.exists) -+ } -+ }) -+ } -+} -+ -+func TestAccountExistsReturnsTransportErrors(t *testing.T) { -+ state := testServerState(t) -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { -+ return nil, errors.New("rpc unavailable") -+ }), -+ } -+ -+ if _, err := accountExists(state, solana.NewWallet().PublicKey()); err == nil { -+ t.Fatal("expected transport error") -+ } -+} -+ -+func TestSendTransactionHandlesRPCResponses(t *testing.T) { -+ baseState := testServerState(t) -+ baseState.memo = "unit-send" -+ tx := transactionForTest(t, exactRequirement(baseState), solana.NewWallet().PrivateKey) -+ tests := map[string]struct { -+ status int -+ body string -+ want string -+ err bool -+ }{ -+ "success": { -+ status: http.StatusOK, -+ body: `{"jsonrpc":"2.0","id":1,"result":"unit-signature"}`, -+ want: "unit-signature", -+ }, -+ "http error": { -+ status: http.StatusBadGateway, -+ body: `bad gateway`, -+ err: true, -+ }, -+ "invalid json": { -+ status: http.StatusOK, -+ body: `{`, -+ err: true, -+ }, -+ "rpc error": { -+ status: http.StatusOK, -+ body: `{"jsonrpc":"2.0","id":1,"error":{"message":"nope"}}`, -+ err: true, -+ }, -+ "empty signature": { -+ status: http.StatusOK, -+ body: `{"jsonrpc":"2.0","id":1,"result":""}`, -+ err: true, -+ }, -+ } -+ -+ for name, test := range tests { -+ t.Run(name, func(t *testing.T) { -+ state := testServerState(t) -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { -+ rawBody, err := io.ReadAll(request.Body) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if !strings.Contains(string(rawBody), `"method":"sendTransaction"`) || !strings.Contains(string(rawBody), `"maxRetries":3`) { -+ t.Fatalf("unexpected sendTransaction RPC body: %s", string(rawBody)) -+ } -+ return &http.Response{ -+ StatusCode: test.status, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(test.body)), -+ }, nil -+ }), -+ } -+ -+ got, err := sendTransaction(state, tx) -+ if test.err { -+ if err == nil { -+ t.Fatal("expected error") -+ } -+ return -+ } -+ if err != nil { -+ t.Fatal(err) -+ } -+ if got != test.want { -+ t.Fatalf("sendTransaction = %q, want %q", got, test.want) -+ } -+ }) -+ } -+} -+ -+func TestSendTransactionReturnsTransportErrors(t *testing.T) { -+ state := testServerState(t) -+ state.memo = "unit-send-transport" -+ tx := transactionForTest(t, exactRequirement(state), solana.NewWallet().PrivateKey) -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { -+ return nil, errors.New("rpc unavailable") -+ }), -+ } -+ -+ if _, err := sendTransaction(state, tx); err == nil { -+ t.Fatal("expected transport error") -+ } -+} -+ -+func TestAccountAtRejectsInvalidIndexes(t *testing.T) { -+ state := testServerState(t) -+ state.memo = "unit-index" -+ tx := transactionForTest(t, exactRequirement(state), solana.NewWallet().PrivateKey) -+ if _, err := accountAt(tx, uint16(len(tx.Message.AccountKeys))); err == nil { -+ t.Fatal("expected invalid account index") -+ } -+ if _, err := programID(tx, solana.CompiledInstruction{ProgramIDIndex: uint16(len(tx.Message.AccountKeys))}); err == nil { -+ t.Fatal("expected invalid program index") -+ } -+} -+ -+func TestInteropMuxRoutesHealthCapabilitiesAndChallenges(t *testing.T) { -+ state := testServerState(t) -+ mux := newInteropMux(state) -+ -+ tests := map[string]struct { -+ path string -+ status int -+ header string -+ bodySearch string -+ }{ -+ "health": { -+ path: "/health", -+ status: http.StatusOK, -+ bodySearch: `"ok":true`, -+ }, -+ "capabilities": { -+ path: "/capabilities", -+ status: http.StatusOK, -+ bodySearch: `"implementation":"go"`, -+ }, -+ "exact challenge": { -+ path: "/exact", -+ status: http.StatusPaymentRequired, -+ header: "PAYMENT-REQUIRED", -+ bodySearch: `"payment_required"`, -+ }, -+ "upto challenge": { -+ path: "/upto", -+ status: http.StatusPaymentRequired, -+ header: "PAYMENT-REQUIRED", -+ bodySearch: `"payment_required"`, -+ }, -+ "session challenge": { -+ path: "/session", -+ status: http.StatusPaymentRequired, -+ bodySearch: `"intent":"session"`, -+ }, -+ "batch settlement challenge": { -+ path: "/batch-settlement", -+ status: http.StatusPaymentRequired, -+ header: "PAYMENT-REQUIRED", -+ bodySearch: `"payment_required"`, -+ }, -+ "protected challenge": { -+ path: defaultResourcePath, -+ status: http.StatusPaymentRequired, -+ header: "PAYMENT-REQUIRED", -+ bodySearch: `"payment_required"`, -+ }, -+ "not found": { -+ path: "/missing", -+ status: http.StatusNotFound, -+ bodySearch: `"not_found"`, -+ }, -+ } -+ -+ for name, test := range tests { -+ t.Run(name, func(t *testing.T) { -+ request := httptest.NewRequest(http.MethodGet, test.path, nil) -+ recorder := httptest.NewRecorder() -+ -+ mux.ServeHTTP(recorder, request) -+ -+ if recorder.Code != test.status { -+ t.Fatalf("status = %d, want %d; body=%s", recorder.Code, test.status, recorder.Body.String()) -+ } -+ if test.header != "" && recorder.Header().Get(test.header) == "" { -+ t.Fatalf("expected %s header", test.header) -+ } -+ if test.bodySearch != "" && !strings.Contains(recorder.Body.String(), test.bodySearch) { -+ t.Fatalf("body %s does not contain %s", recorder.Body.String(), test.bodySearch) -+ } -+ }) -+ } -+} -+ -+func TestInteropMuxProtectedRouteRejectsInvalidPayment(t *testing.T) { -+ state := testServerState(t) -+ mux := newInteropMux(state) -+ request := httptest.NewRequest(http.MethodGet, defaultResourcePath, nil) -+ request.Header.Set("PAYMENT-SIGNATURE", "not base64") -+ recorder := httptest.NewRecorder() -+ -+ mux.ServeHTTP(recorder, request) -+ -+ if recorder.Code != http.StatusPaymentRequired { -+ t.Fatalf("status = %d, want %d", recorder.Code, http.StatusPaymentRequired) -+ } -+ if recorder.Header().Get("PAYMENT-REQUIRED") == "" { -+ t.Fatal("expected refreshed payment challenge") -+ } -+ if !strings.Contains(recorder.Body.String(), `"payment_invalid"`) { -+ t.Fatalf("expected payment_invalid body, got %s", recorder.Body.String()) -+ } -+} -+ -+func TestInteropMuxProtectedRouteSettlesValidPayment(t *testing.T) { -+ settlementCache = newDuplicateSettlementCache() -+ defer func() { -+ settlementCache = newDuplicateSettlementCache() -+ }() -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "unit-mux-settle" -+ state.httpClient = &http.Client{ -+ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { -+ rawBody, err := io.ReadAll(request.Body) -+ if err != nil { -+ t.Fatal(err) -+ } -+ body := string(rawBody) -+ responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` -+ switch { -+ case strings.Contains(body, `"method":"sendTransaction"`): -+ responseBody = `{"jsonrpc":"2.0","id":1,"result":"unit-mux-settlement"}` -+ case strings.Contains(body, `"method":"getSignatureStatuses"`): -+ responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` -+ } -+ return &http.Response{ -+ StatusCode: http.StatusOK, -+ Header: http.Header{"content-type": []string{"application/json"}}, -+ Body: io.NopCloser(strings.NewReader(responseBody)), -+ }, nil -+ }), -+ } -+ requirement := exactRequirement(state) -+ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ -+ X402Version: 2, -+ Accepted: requirement, -+ Payload: map[string]string{ -+ "transaction": signedTransactionForTest(t, requirement, client), -+ }, -+ }) -+ mux := newInteropMux(state) -+ request := httptest.NewRequest(http.MethodGet, defaultResourcePath, nil) -+ request.Header.Set("PAYMENT-SIGNATURE", header) -+ recorder := httptest.NewRecorder() -+ -+ mux.ServeHTTP(recorder, request) -+ -+ if recorder.Code != http.StatusOK { -+ t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) -+ } -+ if recorder.Header().Get(defaultSettlementHeader) != "unit-mux-settlement" { -+ t.Fatalf("settlement header = %q", recorder.Header().Get(defaultSettlementHeader)) -+ } -+ if !strings.Contains(recorder.Body.String(), `"paid":true`) { -+ t.Fatalf("expected paid response, got %s", recorder.Body.String()) -+ } -+} -+ -+func TestRunInteropServerEmitsReadyAndStopsOnSignal(t *testing.T) { -+ state := testServerState(t) -+ listener, err := net.Listen("tcp", "127.0.0.1:0") -+ if err != nil { -+ t.Fatal(err) -+ } -+ signals := make(chan os.Signal, 1) -+ ready := newSyncBuffer() -+ errors := newSyncBuffer() -+ done := make(chan error, 1) -+ -+ go func() { -+ done <- runInteropServer(state, listener, signals, ready, errors) -+ }() -+ -+ baseURL := "http://" + listener.Addr().String() -+ deadline := time.Now().Add(2 * time.Second) -+ for { -+ response, err := http.Get(baseURL + "/health") -+ if err == nil { -+ _ = response.Body.Close() -+ if response.StatusCode == http.StatusOK { -+ break -+ } -+ } -+ if time.Now().After(deadline) { -+ t.Fatalf("server did not become ready; ready=%s errors=%s lastErr=%v", ready.String(), errors.String(), err) -+ } -+ time.Sleep(10 * time.Millisecond) -+ } -+ -+ var payload map[string]any -+ if err := json.Unmarshal(bytes.TrimSpace(ready.Bytes()), &payload); err != nil { -+ t.Fatalf("decode ready payload %q: %v", ready.String(), err) -+ } -+ if payload["type"] != "ready" || payload["implementation"] != "go" { -+ t.Fatalf("unexpected ready payload: %#v", payload) -+ } -+ if _, ok := payload["port"].(float64); !ok { -+ t.Fatalf("ready payload missing port: %#v", payload) -+ } -+ -+ signals <- syscall.SIGTERM -+ select { -+ case err := <-done: -+ if err != nil { -+ t.Fatalf("runInteropServer returned %v; errors=%s", err, errors.String()) -+ } -+ case <-time.After(2 * time.Second): -+ t.Fatal("server did not stop after signal") -+ } -+} -+ -+// syncBuffer wraps bytes.Buffer with a mutex so the test goroutine can read -+// the buffer concurrently with the server goroutine writing the ready line and -+// stderr without triggering -race warnings. -+type syncBuffer struct { -+ mu sync.Mutex -+ buf bytes.Buffer -+} -+ -+func newSyncBuffer() *syncBuffer { return &syncBuffer{} } -+ -+func (b *syncBuffer) Write(p []byte) (int, error) { -+ b.mu.Lock() -+ defer b.mu.Unlock() -+ return b.buf.Write(p) -+} -+ -+func (b *syncBuffer) Bytes() []byte { -+ b.mu.Lock() -+ defer b.mu.Unlock() -+ return append([]byte(nil), b.buf.Bytes()...) -+} -+ -+func (b *syncBuffer) String() string { -+ b.mu.Lock() -+ defer b.mu.Unlock() -+ return b.buf.String() -+} -+ -+func TestRunInteropServerReturnsServeErrors(t *testing.T) { -+ state := testServerState(t) -+ signals := make(chan os.Signal) -+ var ready bytes.Buffer -+ var errors bytes.Buffer -+ -+ err := runInteropServer(state, failingListener{}, signals, &ready, &errors) -+ -+ if err == nil || !strings.Contains(err.Error(), "listener failed") { -+ t.Fatalf("expected listener failure, got %v", err) -+ } -+ if ready.String() == "" { -+ t.Fatal("expected ready payload before listener failure") -+ } -+ if !strings.Contains(errors.String(), "listener failed") { -+ t.Fatalf("expected error writer to receive listener failure, got %q", errors.String()) -+ } -+} -+ -+func TestMainPanicsWhenRequiredEnvMissing(t *testing.T) { -+ mustPanic(t, main) -+} -+ -+// TestVerifyExactTransactionAttackRegressions covers MPP §19.5 fee-payer drain -+// attacks: managed fee-payer (server co-signs) must never become a token source -+// or transfer authority, must not appear in any extra instruction, must not be -+// reassigned via a tampered details.fee_payer, and must not be moved into a -+// signer slot beyond the fee-payer (index 0) position. -+func TestVerifyExactTransactionAttackRegressions(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "attack-regression" -+ requirement := exactRequirement(state) -+ feePayer := state.feePayer.PublicKey() -+ mint := solana.MustPublicKeyFromBase58(requirement.Asset) -+ feePayerATA, _, err := solana.FindAssociatedTokenAddressWithProgram(feePayer, mint, solana.MustPublicKeyFromBase58(defaultTokenProgram)) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ // Positive control: an unmodified happy-path transaction must verify. -+ valid := transactionForTest(t, requirement, client) -+ if err := verifyExactTransaction(valid, requirement); err != nil { -+ t.Fatalf("positive control failed: %v", err) -+ } -+ -+ tests := map[string]struct { -+ mutate func(*solana.Transaction, paymentRequirement) paymentRequirement -+ wantErrFrag string -+ }{ -+ "DRAIN: SystemProgram.Transfer from fee-payer in optional slot": { -+ mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { -+ // Replace memo (slot 3) with a SystemProgram.Transfer touching fee-payer. -+ attacker := solana.NewWallet().PublicKey() -+ tx.Message.Instructions[3] = compiledInstructionWithAccountsForTest( -+ t, tx, solana.SystemProgramID, -+ []solana.PublicKey{feePayer, attacker}, -+ []byte{2, 0, 0, 0, 0xff, 0, 0, 0, 0, 0, 0, 0}, -+ ) -+ return req -+ }, -+ // Accepted rejection paths: fee-payer-touch guard OR unknown-optional-instruction guard. -+ wantErrFrag: "invalid_exact_svm_payload", -+ }, -+ "SPL DRAIN: transferChecked from fee-payer ATA in optional slot": { -+ mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { -+ attackerATA := solana.NewWallet().PublicKey() -+ data := []byte{12} -+ data = binary.LittleEndian.AppendUint64(data, 1) -+ data = append(data, byte(defaultDecimals)) -+ tx.Message.Instructions[3] = compiledInstructionWithAccountsForTest( -+ t, tx, solana.TokenProgramID, -+ []solana.PublicKey{feePayerATA, mint, attackerATA, feePayer}, -+ data, -+ ) -+ return req -+ }, -+ // Accepted rejection paths: fee-payer-touch guard OR unknown-optional-instruction guard. -+ wantErrFrag: "invalid_exact_svm_payload", -+ }, -+ "SLOT: fee-payer at signer slot 1 as transfer authority": { -+ mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { -+ // Replace authority account on the transferChecked with fee-payer. -+ accounts := append([]uint16(nil), tx.Message.Instructions[2].Accounts...) -+ feePayerIndex := -1 -+ for index, key := range tx.Message.AccountKeys { -+ if key.Equals(feePayer) { -+ feePayerIndex = index -+ break -+ } -+ } -+ if feePayerIndex == -1 { -+ t.Fatal("fee payer not in account keys") -+ } -+ accounts[3] = uint16(feePayerIndex) -+ tx.Message.Instructions[2].Accounts = accounts -+ return req -+ }, -+ wantErrFrag: "fee_payer_transferring_funds", -+ }, -+ "SLOT: fee-payer as transfer source ATA": { -+ mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { -+ // Repoint transferChecked.source to the fee-payer's own ATA. -+ feePayerIndex := -1 -+ for index, key := range tx.Message.AccountKeys { -+ if key.Equals(feePayer) { -+ feePayerIndex = index -+ break -+ } -+ } -+ if feePayerIndex == -1 { -+ t.Fatal("fee payer not in account keys") -+ } -+ // Add fee-payer ATA as a new account key and use it as source. -+ tx.Message.AccountKeys = append(tx.Message.AccountKeys, feePayerATA) -+ ataIndex := uint16(len(tx.Message.AccountKeys) - 1) -+ accounts := append([]uint16(nil), tx.Message.Instructions[2].Accounts...) -+ accounts[0] = ataIndex -+ accounts[3] = uint16(feePayerIndex) // authority = fee-payer -+ tx.Message.Instructions[2].Accounts = accounts -+ return req -+ }, -+ wantErrFrag: "fee_payer_transferring_funds", -+ }, -+ } -+ -+ for name, test := range tests { -+ t.Run(name, func(t *testing.T) { -+ tx := cloneTransactionForTest(t, valid) -+ req := requirement -+ req.Extra = cloneExtra(requirement.Extra) -+ mutated := test.mutate(tx, req) -+ err := verifyExactTransaction(tx, mutated) -+ if err == nil { -+ t.Fatalf("expected attack to be rejected") -+ } -+ if !strings.Contains(err.Error(), test.wantErrFrag) { -+ t.Fatalf("error %q does not contain %q", err.Error(), test.wantErrFrag) -+ } -+ }) -+ } -+} -+ -+// TestSettleExactPaymentRejectsForeignMessageFeePayer covers Codex finding #1: -+// the transaction's message fee-payer (account key 0) must equal the server's -+// configured fee-payer before the facilitator co-signs. Otherwise a malicious -+// client could pick a different message payer and the facilitator's presence -+// in the signer set would drain its SOL. -+func TestSettleExactPaymentRejectsForeignMessageFeePayer(t *testing.T) { -+ settlementCache = newDuplicateSettlementCache() -+ defer func() { settlementCache = newDuplicateSettlementCache() }() -+ -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "foreign-fee-payer" -+ requirement := exactRequirement(state) -+ tx := transactionForTest(t, requirement, client) -+ -+ // Swap account key 0 (message fee-payer) for a foreign pubkey. -+ foreign := solana.NewWallet().PublicKey() -+ tx.Message.AccountKeys[0] = foreign -+ encoded, err := tx.ToBase64() -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ -+ X402Version: 2, -+ Accepted: requirement, -+ Payload: map[string]string{"transaction": encoded}, -+ }) -+ -+ if _, err := settleExactPayment(state, header); err == nil || -+ !strings.Contains(err.Error(), "fee_payer") { -+ t.Fatalf("expected foreign message fee-payer rejection, got %v", err) -+ } -+} -+ -+// TestSettleExactPaymentRejectsTamperedFeePayer covers MPP §19.5: an attacker -+// presenting an envelope where details.feePayer (Extra["feePayer"]) is rebound -+// to a non-server pubkey must be rejected at the requirement-match stage so -+// that the server-co-signing context pubkey cannot be substituted by the -+// client envelope. -+func TestSettleExactPaymentRejectsTamperedFeePayer(t *testing.T) { -+ settlementCache = newDuplicateSettlementCache() -+ defer func() { settlementCache = newDuplicateSettlementCache() }() -+ -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "tampered-fee-payer" -+ requirement := exactRequirement(state) -+ transaction := signedTransactionForTest(t, requirement, client) -+ -+ tampered := requirement -+ tampered.Extra = cloneExtra(requirement.Extra) -+ tampered.Extra["feePayer"] = solana.NewWallet().PublicKey().String() -+ -+ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ -+ X402Version: 2, -+ Accepted: tampered, -+ Payload: map[string]string{"transaction": transaction}, -+ }) -+ -+ if _, err := settleExactPayment(state, header); err == nil || -+ !strings.Contains(err.Error(), "does not match server challenge") { -+ t.Fatalf("expected tampered fee-payer to be rejected, got %v", err) -+ } -+} -+ -+type roundTripFunc func(*http.Request) (*http.Response, error) -+ -+func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { -+ return fn(request) -+} -+ -+type failingListener struct{} -+ -+func (failingListener) Accept() (net.Conn, error) { -+ return nil, errors.New("listener failed") -+} -+ -+func (failingListener) Close() error { -+ return nil -+} -+ -+func (failingListener) Addr() net.Addr { -+ return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0} -+} -+ -+func cloneExtra(extra map[string]any) map[string]any { -+ cloned := make(map[string]any, len(extra)) -+ for key, value := range extra { -+ cloned[key] = value -+ } -+ return cloned -+} -+ -+func testServerState(t *testing.T) serverState { -+ t.Helper() -+ feePayer, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ return serverState{ -+ rpcURL: "http://127.0.0.1:8899", -+ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ payTo: solana.NewWallet().PublicKey().String(), -+ feePayer: feePayer, -+ amount: "1000", -+ httpClient: &http.Client{}, -+ } -+} -+ -+func encodePaymentSignatureForTest(t *testing.T, envelope paymentSignatureEnvelope) string { -+ t.Helper() -+ encoded, err := json.Marshal(envelope) -+ if err != nil { -+ t.Fatal(err) -+ } -+ return base64.StdEncoding.EncodeToString(encoded) -+} -+ -+func signedTransactionForTest(t *testing.T, requirement paymentRequirement, client solana.PrivateKey) string { -+ t.Helper() -+ tx := transactionForTest(t, requirement, client) -+ encoded, err := tx.ToBase64() -+ if err != nil { -+ t.Fatal(err) -+ } -+ return encoded -+} -+ -+func transactionForTest(t *testing.T, requirement paymentRequirement, client solana.PrivateKey) *solana.Transaction { -+ t.Helper() -+ feePayer, err := solana.PublicKeyFromBase58(requirement.Extra["feePayer"].(string)) -+ if err != nil { -+ t.Fatal(err) -+ } -+ mint, err := solana.PublicKeyFromBase58(requirement.Asset) -+ if err != nil { -+ t.Fatal(err) -+ } -+ payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) -+ if err != nil { -+ t.Fatal(err) -+ } -+ tokenProgram, err := solana.PublicKeyFromBase58(requirement.Extra["tokenProgram"].(string)) -+ if err != nil { -+ t.Fatal(err) -+ } -+ source, _, err := solana.FindAssociatedTokenAddressWithProgram(client.PublicKey(), mint, tokenProgram) -+ if err != nil { -+ t.Fatal(err) -+ } -+ destination, _, err := solana.FindAssociatedTokenAddressWithProgram(payTo, mint, tokenProgram) -+ if err != nil { -+ t.Fatal(err) -+ } -+ amount, err := strconv.ParseUint(requirement.Amount, 10, 64) -+ if err != nil { -+ t.Fatal(err) -+ } -+ transferData := []byte{12} -+ transferData = binary.LittleEndian.AppendUint64(transferData, amount) -+ transferData = append(transferData, byte(defaultDecimals)) -+ -+ tx, err := solana.NewTransaction( -+ []solana.Instruction{ -+ computeLimitInstructionForTest(20_000), -+ computePriceInstructionForTest(1), -+ solana.NewInstruction( -+ tokenProgram, -+ solana.AccountMetaSlice{ -+ solana.Meta(source).WRITE(), -+ solana.Meta(mint), -+ solana.Meta(destination).WRITE(), -+ solana.Meta(client.PublicKey()).SIGNER(), -+ }, -+ transferData, -+ ), -+ solana.NewInstruction(memoProgramID, nil, []byte(requirement.Extra["memo"].(string))), -+ }, -+ solana.Hash{}, -+ solana.TransactionPayer(feePayer), -+ ) -+ if err != nil { -+ t.Fatal(err) -+ } -+ tx.Message.SetVersion(solana.MessageVersionV0) -+ if _, err := tx.PartialSign(func(key solana.PublicKey) *solana.PrivateKey { -+ if key.Equals(client.PublicKey()) { -+ return &client -+ } -+ return nil -+ }); err != nil { -+ t.Fatal(err) -+ } -+ return tx -+} -+ -+func cloneTransactionForTest(t *testing.T, tx *solana.Transaction) *solana.Transaction { -+ t.Helper() -+ encoded, err := tx.ToBase64() -+ if err != nil { -+ t.Fatal(err) -+ } -+ cloned, err := solana.TransactionFromBase64(encoded) -+ if err != nil { -+ t.Fatal(err) -+ } -+ return cloned -+} -+ -+func computeLimitInstructionForTest(units uint32) solana.Instruction { -+ data := []byte{2} -+ data = binary.LittleEndian.AppendUint32(data, units) -+ return solana.NewInstruction(computeBudgetProgramID, nil, data) -+} -+ -+func computePriceInstructionForTest(microLamports uint64) solana.Instruction { -+ return solana.NewInstruction(computeBudgetProgramID, nil, computePriceDataForTest(microLamports)) -+} -+ -+func computePriceDataForTest(microLamports uint64) []byte { -+ data := []byte{3} -+ return binary.LittleEndian.AppendUint64(data, microLamports) -+} -+ -+func compiledInstructionForTest(t *testing.T, tx *solana.Transaction, program string, data []byte) solana.CompiledInstruction { -+ t.Helper() -+ programKey := solana.MustPublicKeyFromBase58(program) -+ return compiledInstructionWithAccountsForTest(t, tx, programKey, nil, data) -+} -+ -+func compiledInstructionWithAccountsForTest(t *testing.T, tx *solana.Transaction, programKey solana.PublicKey, accounts []solana.PublicKey, data []byte) solana.CompiledInstruction { -+ t.Helper() -+ programIndex := -1 -+ for index, key := range tx.Message.AccountKeys { -+ if key.Equals(programKey) { -+ programIndex = index -+ break -+ } -+ } -+ if programIndex == -1 { -+ tx.Message.AccountKeys = append(tx.Message.AccountKeys, programKey) -+ programIndex = len(tx.Message.AccountKeys) - 1 -+ } -+ accountIndexes := make([]uint16, 0, len(accounts)) -+ for _, account := range accounts { -+ accountIndex := -1 -+ for index, key := range tx.Message.AccountKeys { -+ if key.Equals(account) { -+ accountIndex = index -+ break -+ } -+ } -+ if accountIndex == -1 { -+ tx.Message.AccountKeys = append(tx.Message.AccountKeys, account) -+ accountIndex = len(tx.Message.AccountKeys) - 1 -+ } -+ accountIndexes = append(accountIndexes, uint16(accountIndex)) -+ } -+ return solana.CompiledInstruction{ -+ ProgramIDIndex: uint16(programIndex), -+ Accounts: accountIndexes, -+ Data: data, -+ } -+} -+ -+func TestResolveMintAlias(t *testing.T) { -+ tests := []struct { -+ name string -+ input string -+ network string -+ want string -+ wantErr bool -+ }{ -+ {name: "USDG mainnet alias", input: "USDG", network: solanaMainnetCAIP2, want: "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH"}, -+ {name: "USDG devnet alias", input: "usdg", network: solanaDevnetCAIP2, want: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7"}, -+ {name: "PYUSD mainnet alias", input: "PYUSD", network: solanaMainnetCAIP2, want: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo"}, -+ {name: "PYUSD devnet alias", input: "pyusd", network: solanaDevnetCAIP2, want: "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM"}, -+ {name: "CASH mainnet alias", input: "CASH", network: solanaMainnetCAIP2, want: "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH"}, -+ {name: "USDT mainnet alias", input: "USDT", network: solanaMainnetCAIP2, want: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"}, -+ {name: "USDT lowercase mainnet alias", input: " usdt ", network: solanaMainnetCAIP2, want: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"}, -+ {name: "USDT has no devnet mint", input: "USDT", network: solanaDevnetCAIP2, wantErr: true}, -+ {name: "USDT has no testnet mint", input: "USDT", network: solanaTestnetCAIP2, wantErr: true}, -+ {name: "USDC devnet alias", input: " usdc ", network: solanaDevnetCAIP2, want: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"}, -+ {name: "passthrough base58 mint", input: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", network: solanaDevnetCAIP2, want: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"}, -+ {name: "CASH has no devnet mint", input: "CASH", network: solanaDevnetCAIP2, wantErr: true}, -+ {name: "unknown alias", input: "WEIRDO", network: solanaMainnetCAIP2, wantErr: true}, -+ {name: "empty input", input: " ", network: solanaMainnetCAIP2, wantErr: true}, -+ } -+ -+ for _, test := range tests { -+ t.Run(test.name, func(t *testing.T) { -+ got, err := resolveMintAlias(test.input, test.network) -+ if test.wantErr { -+ if err == nil { -+ t.Fatalf("expected error for %q on %q, got %q", test.input, test.network, got) -+ } -+ return -+ } -+ if err != nil { -+ t.Fatalf("unexpected error: %v", err) -+ } -+ if got != test.want { -+ t.Fatalf("resolveMintAlias(%q,%q) = %q, want %q", test.input, test.network, got, test.want) -+ } -+ }) -+ } -+} -+ -+func TestReadStateResolvesMintAliases(t *testing.T) { -+ privateKey, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ encodedKey, err := json.Marshal([]byte(privateKey)) -+ if err != nil { -+ t.Fatal(err) -+ } -+ payTo := solana.NewWallet().PublicKey().String() -+ -+ t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") -+ t.Setenv("X402_INTEROP_PAY_TO", payTo) -+ t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) -+ t.Setenv("X402_INTEROP_NETWORK", solanaDevnetCAIP2) -+ t.Setenv("X402_INTEROP_MINT", "PYUSD") -+ t.Setenv("X402_INTEROP_EXTRA_OFFERED_MINTS", "USDG, USDC") -+ -+ state := readState() -+ -+ challenge := exactChallengePayload(state) -+ if len(challenge.Accepts) != 3 { -+ t.Fatalf("expected 3 challenge entries, got %d", len(challenge.Accepts)) -+ } -+ if challenge.Accepts[0].Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { -+ t.Fatalf("primary Asset = %q, expected resolved PYUSD devnet mint", challenge.Accepts[0].Asset) -+ } -+ if _, err := solana.PublicKeyFromBase58(challenge.Accepts[0].Asset); err != nil { -+ t.Fatalf("primary Asset is not valid base58: %v", err) -+ } -+ if challenge.Accepts[1].Asset != "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7" { -+ t.Fatalf("extra[0] Asset = %q, expected resolved USDG devnet mint", challenge.Accepts[1].Asset) -+ } -+ if challenge.Accepts[2].Asset != "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" { -+ t.Fatalf("extra[1] Asset = %q, expected resolved USDC devnet mint", challenge.Accepts[2].Asset) -+ } -+ for index, requirement := range challenge.Accepts { -+ if _, err := solana.PublicKeyFromBase58(requirement.Asset); err != nil { -+ t.Fatalf("Accepts[%d].Asset is not base58 after resolution: %v", index, err) -+ } -+ } -+} -+ -+func TestReadStatePanicsOnUnknownMintAlias(t *testing.T) { -+ privateKey, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ encodedKey, err := json.Marshal([]byte(privateKey)) -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") -+ t.Setenv("X402_INTEROP_PAY_TO", solana.NewWallet().PublicKey().String()) -+ t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) -+ t.Setenv("X402_INTEROP_NETWORK", solanaDevnetCAIP2) -+ t.Setenv("X402_INTEROP_MINT", "DEFINITELY_NOT_A_MINT") -+ -+ mustPanic(t, func() { readState() }) -+ -+ t.Setenv("X402_INTEROP_MINT", "USDG") -+ t.Setenv("X402_INTEROP_EXTRA_OFFERED_MINTS", "PYUSD, NOPE") -+ mustPanic(t, func() { readState() }) -+} -+ -+func TestSettleExactPaymentAcceptsAliasResolvedRequirement(t *testing.T) { -+ settlementCache = newDuplicateSettlementCache() -+ defer func() { -+ settlementCache = newDuplicateSettlementCache() -+ }() -+ -+ privateKey, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ encodedKey, err := json.Marshal([]byte(privateKey)) -+ if err != nil { -+ t.Fatal(err) -+ } -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ -+ t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") -+ t.Setenv("X402_INTEROP_PAY_TO", solana.NewWallet().PublicKey().String()) -+ t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) -+ t.Setenv("X402_INTEROP_NETWORK", solanaDevnetCAIP2) -+ t.Setenv("X402_INTEROP_MINT", "PYUSD") -+ t.Setenv("X402_INTEROP_PRICE", "$0.001") -+ -+ state := readState() -+ state.memo = "alias-resolution" -+ state.httpClient = successfulSettlementClient(t, "alias-resolved-settlement") -+ -+ if state.mint != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { -+ t.Fatalf("expected PYUSD devnet mint resolution, got %q", state.mint) -+ } -+ -+ requirement := exactRequirement(state) -+ transaction := signedTransactionForTest(t, requirement, client) -+ header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ -+ X402Version: 2, -+ Accepted: requirement, -+ Payload: map[string]string{ -+ "transaction": transaction, -+ }, -+ }) -+ -+ settlement, err := settleExactPayment(state, header) -+ if err != nil { -+ t.Fatalf("expected alias-resolved settlement to pass, got %v", err) -+ } -+ if settlement != "alias-resolved-settlement" { -+ t.Fatalf("settlement = %q", settlement) -+ } -+} -+ -+// --- Codex P1.1: Lighthouse discriminator + account-count allowlist --- -+ -+// TestLighthousePassthroughMatchesSpine locks parity with the Rust + TS spines, -+// both of which accept any Lighthouse-program instruction by program-id match -+// alone. Inventing a per-language allowlist here would diverge from real-world -+// Phantom/Solflare transactions the canonical adapters accept. See the comment -+// on the optional-instruction loop for the spine citations. -+func TestLighthousePassthroughMatchesSpine(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ cases := []struct { -+ name string -+ data []byte -+ // extra wallet count for the instruction's account list. -+ extraAccounts int -+ }{ -+ {name: "empty_payload", data: []byte{}, extraAccounts: 0}, -+ {name: "known_assert_disc_single_account", data: []byte{9, 0}, extraAccounts: 1}, -+ {name: "unknown_discriminator", data: []byte{200, 1, 2}, extraAccounts: 1}, -+ {name: "oversize_payload_many_accounts", data: bytes.Repeat([]byte{0xAB}, 256), extraAccounts: 8}, -+ } -+ for _, tc := range cases { -+ t.Run(tc.name, func(t *testing.T) { -+ state := testServerState(t) -+ state.memo = "lighthouse-parity-" + tc.name -+ requirement := exactRequirement(state) -+ tx := transactionForTest(t, requirement, client) -+ extras := make([]solana.PublicKey, tc.extraAccounts) -+ for i := range extras { -+ extras[i] = solana.NewWallet().PublicKey() -+ } -+ var ix solana.CompiledInstruction -+ if tc.extraAccounts == 0 { -+ ix = compiledInstructionForTest(t, tx, lighthouseProgram, tc.data) -+ } else { -+ ix = compiledInstructionWithAccountsForTest(t, tx, solana.MustPublicKeyFromBase58(lighthouseProgram), extras, tc.data) -+ } -+ tx.Message.Instructions = append( -+ tx.Message.Instructions[:3], -+ append([]solana.CompiledInstruction{ix}, tx.Message.Instructions[3:]...)..., -+ ) -+ if err := verifyExactTransaction(tx, requirement); err != nil { -+ t.Fatalf("expected spine-parity acceptance for %s, got %v", tc.name, err) -+ } -+ }) -+ } -+} -+ -+// --- Codex P1.2: tightened fee-payer-in-instruction guard --- -+ -+// TestAcceptsFeePayerInLighthouseAccountMirrorsSpine locks parity with the Rust -+// spine, which intentionally has NO fee-payer-in-instruction-accounts sweep: -+// - rust/src/protocol/schemes/exact/verify.rs:382 only blocks fee-payer as -+// the transfer *authority*, not as a passive account in some other ix. -+// - rust/src/protocol/schemes/exact/verify.rs:263 accepts any Lighthouse -+// instruction by program-id match alone. -+// -+// Real Phantom/Solflare wallets emit `Assert*` Lighthouse ixs that reference the -+// fee-payer's pubkey as a read-only account to guard the facilitator from -+// rewriting the transfer post-sign. Rejecting these would break canonical -+// wallet flows and diverge from the spine. This test pins the Go adapter to -+// the spine semantics: fee-payer in a Lighthouse account list is ACCEPTED. -+func TestAcceptsFeePayerInLighthouseAccountMirrorsSpine(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "fee-payer-lighthouse-assert" -+ requirement := exactRequirement(state) -+ tx := transactionForTest(t, requirement, client) -+ -+ feePayer := state.feePayer.PublicKey() -+ // Lighthouse `AssertAccountInfo` (discriminator 9) referencing the -+ // fee-payer's pubkey as the target account — exactly the shape Phantom -+ // emits when guarding the rent-payer's balance against post-sign rewrites. -+ ix := compiledInstructionWithAccountsForTest( -+ t, tx, -+ solana.MustPublicKeyFromBase58(lighthouseProgram), -+ []solana.PublicKey{feePayer}, -+ []byte{9, 0}, -+ ) -+ tx.Message.Instructions = append( -+ tx.Message.Instructions[:3], -+ append([]solana.CompiledInstruction{ix}, tx.Message.Instructions[3:]...)..., -+ ) -+ if err := verifyExactTransaction(tx, requirement); err != nil { -+ t.Fatalf("expected fee-payer-in-Lighthouse-account to be accepted (spine parity), got %v", err) -+ } -+} -+ -+func TestAcceptsFeePayerAsAtaCreatePayer(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "fee-payer-ata-create" -+ requirement := exactRequirement(state) -+ tx := transactionForTest(t, requirement, client) -+ transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) -+ if err != nil { -+ t.Fatal(err) -+ } -+ payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) -+ feePayer := state.feePayer.PublicKey() -+ -+ // Canonical ATA-create where fee-payer is the rent payer at accounts[0]. -+ // Per the Codex P1.2 fix this is the *only* place fee-payer is allowed to -+ // appear outside the transfer authority/source check. -+ ataCreate := compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ -+ feePayer, -+ transfer.destination, -+ payTo, -+ transfer.mint, -+ solana.SystemProgramID, -+ transfer.tokenProgram, -+ }, []byte{1}) -+ tx.Message.Instructions = append( -+ tx.Message.Instructions[:3], -+ append([]solana.CompiledInstruction{ataCreate}, tx.Message.Instructions[3:]...)..., -+ ) -+ if err := verifyExactTransaction(tx, requirement); err != nil { -+ t.Fatalf("expected fee-payer as ATA-create payer to be accepted, got %v", err) -+ } -+} -+ -+// TestVerifyExactTransactionEnforcesTokenProgramBinding mirrors the Rust spine -+// binding (rust/crates/x402/src/protocol/schemes/exact/verify.rs:73-80) and the -+// PHP/Ruby/Lua ports: the on-chain transferChecked instruction's program MUST -+// match requirement.Extra["tokenProgram"]. Without this, a Token-2022 transfer -+// could satisfy an SPL Token requirement (and vice versa) because the -+// destination-ATA derivation uses the parsed program, not the required one. -+func TestVerifyExactTransactionEnforcesTokenProgramBinding(t *testing.T) { -+ client, err := solana.NewRandomPrivateKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ state := testServerState(t) -+ state.memo = "unit-token-program-binding" -+ -+ t.Run("mismatch_requires_spl_token_but_tx_uses_token2022", func(t *testing.T) { -+ // Requirement declares SPL Token; build a transaction using Token-2022 with -+ // a Token-2022 ATA. Verification must reject the program mismatch even -+ // though the transfer otherwise looks well-formed. -+ splRequirement := exactRequirement(state) -+ token2022Requirement := exactRequirement(state) -+ token2022Requirement.Extra = cloneExtra(token2022Requirement.Extra) -+ token2022Requirement.Extra["tokenProgram"] = token2022Program -+ tx := transactionForTest(t, token2022Requirement, client) -+ -+ err := verifyExactTransaction(tx, splRequirement) -+ if err == nil || err.Error() != "invalid_exact_svm_payload_transaction_token_program" { -+ t.Fatalf("expected token_program rejection, got %v", err) -+ } -+ }) -+ -+ t.Run("reverse_requires_token2022_but_tx_uses_spl_token", func(t *testing.T) { -+ token2022Requirement := exactRequirement(state) -+ token2022Requirement.Extra = cloneExtra(token2022Requirement.Extra) -+ token2022Requirement.Extra["tokenProgram"] = token2022Program -+ // Build the transaction against an SPL Token requirement (default). -+ splRequirement := exactRequirement(state) -+ tx := transactionForTest(t, splRequirement, client) -+ -+ err := verifyExactTransaction(tx, token2022Requirement) -+ if err == nil || err.Error() != "invalid_exact_svm_payload_transaction_token_program" { -+ t.Fatalf("expected token_program rejection, got %v", err) -+ } -+ }) -+ -+ t.Run("positive_control_matching_pair_accepted", func(t *testing.T) { -+ requirement := exactRequirement(state) -+ tx := transactionForTest(t, requirement, client) -+ if err := verifyExactTransaction(tx, requirement); err != nil { -+ t.Fatalf("expected matching tokenProgram pair to be accepted, got %v", err) -+ } -+ }) -+ -+ t.Run("missing_required_token_program_rejected", func(t *testing.T) { -+ requirement := exactRequirement(state) -+ tx := transactionForTest(t, requirement, client) -+ mutated := requirement -+ mutated.Extra = cloneExtra(requirement.Extra) -+ delete(mutated.Extra, "tokenProgram") -+ err := verifyExactTransaction(tx, mutated) -+ if err == nil || err.Error() != "invalid_exact_svm_payload_transaction_token_program" { -+ t.Fatalf("expected missing tokenProgram to be rejected, got %v", err) -+ } -+ }) -+} -+ -+func mustPanic(t *testing.T, fn func()) { -+ t.Helper() -+ defer func() { -+ if recovered := recover(); recovered == nil { -+ t.Fatal("expected panic") -+ } -+ }() -+ fn() -+} -diff --git a/tests/interop/README.md b/harness/README.md -similarity index 78% -rename from tests/interop/README.md -rename to harness/README.md -index b1018c6..8a65465 100644 ---- a/tests/interop/README.md -+++ b/harness/README.md -@@ -91,7 +91,7 @@ expected success/failure status, live in `src/contracts.ts`. - - 1. Add a process adapter for the language. - 2. Register it in `src/implementations.ts` as a client, server, or both. --3. Keep the adapter command relative to `tests/interop`. -+3. Keep the adapter command relative to `harness`. - 4. Make stdout emit only the `ready` or `result` JSON message. - 5. Run a focused matrix before enabling it by default: - -@@ -123,6 +123,55 @@ Use these environment variables to filter the active matrix: - - `MPP_INTEROP_INTENTS=charge` - - `MPP_INTEROP_SCENARIOS=charge-basic,charge-split-ata,charge-network-mismatch,charge-cross-route-replay` - -+### x402 exact intent -+ -+A second intent, `x402-exact`, exercises the canonical x402 `exact` scheme -+against the Rust spine in `rust/crates/x402/src/bin/interop_{client,server}.rs`. -+The TypeScript reference adapters live at -+`src/fixtures/typescript/exact-{client,server}.ts` and share the same -+harness contract as the Rust spine: identical `X402_INTEROP_*` env vars, -+identical `PAYMENT-REQUIRED` / `PAYMENT-SIGNATURE` headers, identical -+ready / result JSON shapes. The TS reference fixture carries a stub -+credential payload (challenge id + resource) and is paired against the -+TS reference server in the default matrix; the Rust spine is paired -+against itself. As language adapters that carry a real Solana -+PaymentProof land, they expand the matrix by registering under -+`intents: ["x402-exact"]` in `implementations.ts`. -+ -+Env vars consumed by both roles: -+ -+- `X402_INTEROP_RPC_URL`, `X402_INTEROP_NETWORK`, `X402_INTEROP_MINT` -+- `X402_INTEROP_PAY_TO`, `X402_INTEROP_PRICE` -+- `X402_INTEROP_FACILITATOR_SECRET_KEY` -+ -+Server-only: -+ -+- `X402_INTEROP_EXTRA_OFFERED_MINTS` (CSV of additional mint addresses) -+ -+Client-only: -+ -+- `X402_INTEROP_TARGET_URL` -+- `X402_INTEROP_CLIENT_SECRET_KEY` -+- `X402_INTEROP_PREFER_CURRENCIES` (CSV of preferred currencies) -+ -+Run the x402 matrix slice: -+ -+```bash -+X402_INTEROP_MATRIX=1 \ -+X402_INTEROP_RPC_URL=http://127.0.0.1:8899 \ -+X402_INTEROP_MINT=... X402_INTEROP_PAY_TO=... \ -+X402_INTEROP_CLIENT_SECRET_KEY='[...]' \ -+X402_INTEROP_FACILITATOR_SECRET_KEY='[...]' \ -+pnpm test x402-exact.e2e.test.ts -+``` -+ -+Cross-server portability and idempotent-resubmit scenarios are gated -+separately: -+ -+```bash -+X402_INTEROP_CROSS_SERVER=1 pnpm test cross-server-scenarios.test.ts -+``` -+ - The current scenario set covers only the `charge` intent. It includes a basic - payment, a split payment that requires the server fee payer to create the split - recipient ATA, a negative network-mismatch payment, and a cross-route replay -@@ -153,16 +202,16 @@ If the TypeScript adapter cannot resolve `@solana/mpp/client` or - install: - - ```bash --cd ../../typescript -+cd ../typescript - pnpm --filter @solana/mpp build - --cd ../tests/interop -+cd ../harness - pnpm install --force --frozen-lockfile - pnpm test - ``` - - `@solana/mpp` is installed from a local `file:` dependency, so --`tests/interop` needs to install after the TypeScript package has produced its -+`harness` needs to install after the TypeScript package has produced its - `dist` files. - - The harness starts Surfpool through `start-surfnet-proxy.mjs`, funds the test -diff --git a/tests/interop/go-client/go.mod b/harness/go-client/go.mod -similarity index 92% -rename from tests/interop/go-client/go.mod -rename to harness/go-client/go.mod -index 66f4683..58b7bc1 100644 ---- a/tests/interop/go-client/go.mod -+++ b/harness/go-client/go.mod -@@ -1,4 +1,4 @@ --module github.com/solana-foundation/pay-kit/tests/interop/go-client -+module github.com/solana-foundation/pay-kit/harness/go-client - - go 1.26.1 - -@@ -37,6 +37,6 @@ require ( - golang.org/x/time v0.11.0 // indirect - ) - --replace github.com/solana-foundation/pay-kit/go => ../../../go -+replace github.com/solana-foundation/pay-kit/go => ../../go - - replace github.com/gagliardetto/solana-go => github.com/lgalabru/solana-go v0.0.0-20260403020633-3cb13b392078 -diff --git a/tests/interop/go-client/go.sum b/harness/go-client/go.sum -similarity index 100% -rename from tests/interop/go-client/go.sum -rename to harness/go-client/go.sum -diff --git a/tests/interop/go-client/main.go b/harness/go-client/main.go -similarity index 100% -rename from tests/interop/go-client/main.go -rename to harness/go-client/main.go -diff --git a/tests/interop/go-client/main_test.go b/harness/go-client/main_test.go -similarity index 100% -rename from tests/interop/go-client/main_test.go -rename to harness/go-client/main_test.go -diff --git a/tests/interop/go-server/go.mod b/harness/go-server/go.mod -similarity index 92% -rename from tests/interop/go-server/go.mod -rename to harness/go-server/go.mod -index ccc2d8e..53ea02e 100644 ---- a/tests/interop/go-server/go.mod -+++ b/harness/go-server/go.mod -@@ -1,4 +1,4 @@ --module github.com/solana-foundation/mpp-sdk/tests/interop/go-server -+module github.com/solana-foundation/mpp-sdk/harness/go-server - - go 1.26.1 - -@@ -37,6 +37,6 @@ require ( - golang.org/x/time v0.11.0 // indirect - ) - --replace github.com/solana-foundation/pay-kit/go => ../../../go -+replace github.com/solana-foundation/pay-kit/go => ../../go - - replace github.com/gagliardetto/solana-go => github.com/lgalabru/solana-go v0.0.0-20260403020633-3cb13b392078 -diff --git a/tests/interop/go-server/go.sum b/harness/go-server/go.sum -similarity index 100% -rename from tests/interop/go-server/go.sum -rename to harness/go-server/go.sum -diff --git a/tests/interop/go-server/main.go b/harness/go-server/main.go -similarity index 100% -rename from tests/interop/go-server/main.go -rename to harness/go-server/main.go -diff --git a/tests/interop/go-server/main_test.go b/harness/go-server/main_test.go -similarity index 100% -rename from tests/interop/go-server/main_test.go -rename to harness/go-server/main_test.go -diff --git a/tests/interop/lua-server/dx-gate.mjs b/harness/lua-server/dx-gate.mjs -similarity index 97% -rename from tests/interop/lua-server/dx-gate.mjs -rename to harness/lua-server/dx-gate.mjs -index 58491e3..dfcf85a 100644 ---- a/tests/interop/lua-server/dx-gate.mjs -+++ b/harness/lua-server/dx-gate.mjs -@@ -7,7 +7,7 @@ - // surfpool RPC stays available for the manual DX run. - // - // Run: --// cd tests/interop && node lua-server/dx-gate.mjs -+// cd harness && node lua-server/dx-gate.mjs - // In another terminal, copy-paste the printed env vars and run: - // cd lua && eval "$(luarocks --lua-version=5.1 --tree lua_modules path)" - // luajit examples/simple-server.lua -@@ -96,7 +96,7 @@ process.on("SIGTERM", shutdown); - // Drain surfnet events on a 100 ms timer so the Rust worker keeps - // advancing; otherwise the surfpool instance stalls and the upstream - // RPC stops responding. Mirrors the pattern in --// `tests/interop/start-surfnet-proxy.mjs`. -+// `harness/start-surfnet-proxy.mjs`. - setInterval(() => { - try { - surfnet.drainEvents(); -diff --git a/tests/interop/lua-server/server.lua b/harness/lua-server/server.lua -similarity index 99% -rename from tests/interop/lua-server/server.lua -rename to harness/lua-server/server.lua -index 4f7a055..ef81889 100644 ---- a/tests/interop/lua-server/server.lua -+++ b/harness/lua-server/server.lua -@@ -1,7 +1,7 @@ - #!/usr/bin/env luajit - -- Lua MPP interop adapter for the cross-language harness. - -- ---- Mirrors `tests/interop/ruby-server/server.rb`: a raw TCP loop that -+-- Mirrors `harness/ruby-server/server.rb`: a raw TCP loop that - -- gates `interopScenario.resourcePath` behind a `charge` challenge and - -- settles the credential on Surfpool. The harness drives this binary by - -- the contract in `skills/pay-sdk-implementation/references/interop-harness.md`: -@@ -16,7 +16,7 @@ - -- - -- Run manually: - -- cd lua && eval "$(luarocks --lua-version=5.1 --tree lua_modules path)" ---- MPP_INTEROP_RPC_URL=... MPP_INTEROP_PAY_TO=... ... luajit ../tests/interop/lua-server/server.lua -+-- MPP_INTEROP_RPC_URL=... MPP_INTEROP_PAY_TO=... ... luajit ../harness/lua-server/server.lua - - package.path = table.concat({ - './?.lua', -diff --git a/tests/interop/package.json b/harness/package.json -similarity index 90% -rename from tests/interop/package.json -rename to harness/package.json -index bfc4382..481df2f 100644 ---- a/tests/interop/package.json -+++ b/harness/package.json -@@ -15,7 +15,7 @@ - }, - "dependencies": { - "@solana/kit": "^6.5.0", -- "@solana/mpp": "file:../../typescript/packages/mpp", -+ "@solana/mpp": "file:../typescript/packages/mpp", - "mppx": "^0.5.5", - "surfpool-sdk": "^1.2.0" - }, -diff --git a/tests/interop/php-server/server.php b/harness/php-server/server.php -similarity index 99% -rename from tests/interop/php-server/server.php -rename to harness/php-server/server.php -index b8923e6..115daed 100644 ---- a/tests/interop/php-server/server.php -+++ b/harness/php-server/server.php -@@ -23,7 +23,7 @@ use SolanaPhpSdk\Rpc\RpcClient; - error_reporting(error_reporting() & ~E_DEPRECATED & ~E_USER_DEPRECATED); - ini_set('display_errors', 'stderr'); - --require __DIR__ . '/../../../php/vendor/autoload.php'; -+require __DIR__ . '/../../php/vendor/autoload.php'; - - // ── Env ────────────────────────────────────────────────────────────────────── - -diff --git a/tests/interop/pnpm-lock.yaml b/harness/pnpm-lock.yaml -similarity index 99% -rename from tests/interop/pnpm-lock.yaml -rename to harness/pnpm-lock.yaml -index f87afd2..3cf4ea8 100644 ---- a/tests/interop/pnpm-lock.yaml -+++ b/harness/pnpm-lock.yaml -@@ -15,8 +15,8 @@ importers: - specifier: ^6.5.0 - version: 6.8.0(typescript@5.9.3) - '@solana/mpp': -- specifier: file:../../typescript/packages/mpp -- version: file:../../typescript/packages/mpp(@solana/kit@6.8.0(typescript@5.9.3))(mppx@0.5.17(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6))) -+ specifier: file:../typescript/packages/mpp -+ version: file:../typescript/packages/mpp(@solana/kit@6.8.0(typescript@5.9.3))(mppx@0.5.17(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6))) - mppx: - specifier: ^0.5.5 - version: 0.5.17(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6)) -@@ -498,8 +498,8 @@ packages: - typescript: - optional: true - -- '@solana/mpp@file:../../typescript/packages/mpp': -- resolution: {directory: ../../typescript/packages/mpp, type: directory} -+ '@solana/mpp@file:../typescript/packages/mpp': -+ resolution: {directory: ../typescript/packages/mpp, type: directory} - peerDependencies: - '@solana/kit': '>=6.5.0' - mppx: '>=0.5.5' -@@ -1561,7 +1561,7 @@ snapshots: - - fastestsmallesttextencoderdecoder - - utf-8-validate - -- '@solana/mpp@file:../../typescript/packages/mpp(@solana/kit@6.8.0(typescript@5.9.3))(mppx@0.5.17(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6)))': -+ '@solana/mpp@file:../typescript/packages/mpp(@solana/kit@6.8.0(typescript@5.9.3))(mppx@0.5.17(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.3.6)))': - dependencies: - '@solana-program/compute-budget': 0.15.0(@solana/kit@6.8.0(typescript@5.9.3)) - '@solana-program/system': 0.12.0(@solana/kit@6.8.0(typescript@5.9.3)) -diff --git a/tests/interop/python-server/main.py b/harness/python-server/main.py -similarity index 97% -rename from tests/interop/python-server/main.py -rename to harness/python-server/main.py -index 575c7e9..2d2c421 100644 ---- a/tests/interop/python-server/main.py -+++ b/harness/python-server/main.py -@@ -1,7 +1,7 @@ - """Interop adapter: Python HTTP charge server. - - Mirrors the contract in skills/pay-sdk-implementation/references/interop-harness.md --and the Ruby adapter at tests/interop/ruby-server/server.rb. The harness -+and the Ruby adapter at harness/ruby-server/server.rb. The harness - launches this process, reads one ``ready`` JSON line from stdout, then sends - HTTP requests to the protected resource. - -@@ -21,14 +21,13 @@ from http.server import BaseHTTPRequestHandler, HTTPServer - from pathlib import Path - from typing import Any - --# Ensure the local Python SDK is importable when run from tests/interop. -+# Ensure the local Python SDK is importable when run from the harness. - # Walk parents looking for the repo root marker (pyproject.toml at python/ - # or .git) so the adapter stays self-contained regardless of how deep this --# file lives inside ``tests/``. The harness invokes us from --# ``tests/interop`` (parents[0]=python-server, parents[1]=interop, --# parents[2]=tests, parents[3]=repo root); the previous ``parents[2]`` --# resolved to ``/tests`` and silently fell through to a global --# ``solana-mpp`` install, hiding local SDK regressions. -+# file lives inside ``harness/``. The harness invokes us from -+# ``harness/python-server``; the previous fixed ``parents[2]`` index -+# silently fell through to a global ``solana-mpp`` install, hiding local -+# SDK regressions. - def _find_repo_root(start: Path) -> Path: - for candidate in [start, *start.parents]: - if (candidate / ".git").exists() or (candidate / "python" / "pyproject.toml").is_file(): -diff --git a/tests/interop/ruby-server/server.rb b/harness/ruby-server/server.rb -similarity index 99% -rename from tests/interop/ruby-server/server.rb -rename to harness/ruby-server/server.rb -index b84a008..1f9c961 100644 ---- a/tests/interop/ruby-server/server.rb -+++ b/harness/ruby-server/server.rb -@@ -2,7 +2,7 @@ - - require "json" - require "socket" --require_relative "../../../ruby/lib/mpp" -+require_relative "../../ruby/lib/mpp" - - # Read a required environment variable for the interop adapter. - def require_env(name) -diff --git a/tests/interop/rust-client/Cargo.toml b/harness/rust-client/Cargo.toml -similarity index 92% -rename from tests/interop/rust-client/Cargo.toml -rename to harness/rust-client/Cargo.toml -index 814fbce..f98ebfd 100644 ---- a/tests/interop/rust-client/Cargo.toml -+++ b/harness/rust-client/Cargo.toml -@@ -5,7 +5,7 @@ edition = "2021" - publish = false - - [dependencies] --solana-mpp = { path = "../../../rust" } -+solana-mpp = { path = "../../rust" } - solana-keychain = { git = "https://github.com/solana-foundation/solana-keychain", rev = "abf75944", default-features = false, features = ["memory"] } - solana-rpc-client = "3.1" - solana-signature = "3.1" -diff --git a/tests/interop/rust-client/src/main.rs b/harness/rust-client/src/main.rs -similarity index 100% -rename from tests/interop/rust-client/src/main.rs -rename to harness/rust-client/src/main.rs -diff --git a/tests/interop/src/canonical-codes.ts b/harness/src/canonical-codes.ts -similarity index 100% -rename from tests/interop/src/canonical-codes.ts -rename to harness/src/canonical-codes.ts -diff --git a/tests/interop/src/contracts.ts b/harness/src/contracts.ts -similarity index 92% -rename from tests/interop/src/contracts.ts -rename to harness/src/contracts.ts -index 87c43fa..8143e86 100644 ---- a/tests/interop/src/contracts.ts -+++ b/harness/src/contracts.ts -@@ -1,11 +1,12 @@ - import type { CanonicalErrorCode } from "./canonical-codes"; - import { chargeScenarios } from "./intents/charge"; -+import { x402ExactScenarios } from "./intents/x402-exact"; - - export type { CanonicalErrorCode }; - - export type AdapterKind = "client" | "server"; - --export type InteropIntent = "charge"; -+export type InteropIntent = "charge" | "x402-exact"; - - export type InteropScenarioSplit = { - recipientKey: string; -@@ -136,8 +137,10 @@ export type AdapterMessage = ReadyMessage | ClientRunResult; - - export { chargeCanonicalJsonVectors } from "./intents/charge"; - --export const interopScenarios: readonly InteropScenario[] = -- chargeScenarios; -+export const interopScenarios: readonly InteropScenario[] = [ -+ ...chargeScenarios, -+ ...x402ExactScenarios, -+]; - - export const interopScenario: InteropScenario = { - ...(interopScenarios[0] as InteropScenario), -@@ -191,11 +194,18 @@ function selectScenarioIds(rawSelection: string | undefined): string[] { - return selected; - } - -+// The legacy MPP charge runner predates the x402-exact intent. To keep -+// the existing CI matrix's default behaviour (charge-only) stable while -+// still surfacing the new intent through `selectInteropIntents("x402-exact")`, -+// the empty-selection default is restricted to "charge". Callers that -+// want the full intent set should pass the explicit list. -+const DEFAULT_INTENTS: readonly InteropIntent[] = ["charge"]; -+ - export function selectInteropIntents( - rawSelection: string | undefined, - ): InteropIntent[] { - if (!rawSelection || rawSelection.trim() === "") { -- return [...supportedInteropIntents]; -+ return [...DEFAULT_INTENTS]; - } - - const selected = rawSelection -@@ -209,8 +219,7 @@ export function selectInteropIntents( - if (unsupported.length > 0) { - throw new Error( - `Unsupported MPP_INTEROP_INTENTS value(s): ${unsupported.join(", ")}. ` + -- `Supported intents: ${supportedInteropIntents.join(", ")}. ` + -- "Session and subscription scenarios are not implemented in this harness yet.", -+ `Supported intents: ${supportedInteropIntents.join(", ")}.`, - ); - } - -diff --git a/tests/interop/src/fixtures/typescript/charge-client.ts b/harness/src/fixtures/typescript/charge-client.ts -similarity index 100% -rename from tests/interop/src/fixtures/typescript/charge-client.ts -rename to harness/src/fixtures/typescript/charge-client.ts -diff --git a/tests/interop/src/fixtures/typescript/charge-server.ts b/harness/src/fixtures/typescript/charge-server.ts -similarity index 100% -rename from tests/interop/src/fixtures/typescript/charge-server.ts -rename to harness/src/fixtures/typescript/charge-server.ts -diff --git a/harness/src/fixtures/typescript/exact-client.ts b/harness/src/fixtures/typescript/exact-client.ts -new file mode 100644 -index 0000000..67807f3 ---- /dev/null -+++ b/harness/src/fixtures/typescript/exact-client.ts -@@ -0,0 +1,225 @@ -+// TypeScript reference x402 `exact` interop client. -+// -+// Shares the same `X402_INTEROP_*` env-var contract and ready/result -+// JSON protocol as the Rust spine (`rust/crates/x402/src/bin/ -+// interop_client.rs`). Sends an unpaid GET, parses the base64 -+// `PAYMENT-REQUIRED` envelope, selects an offer (`preferredCurrencies` -+// first) and resubmits with `PAYMENT-SIGNATURE`. Prints one result -+// JSON line to stdout. -+// -+// Scope: the fixture carries a stub credential payload (challenge id + -+// resource) so the harness wiring, negative-code classification, and -+// cross-server portability + idempotent-resubmit flows can run without -+// a full Solana signer. Real SVM PaymentProof construction (signed -+// VersionedTransaction or settled signature) lives in the Rust spine -+// and the TS SDK port; this client only pairs against the TS reference -+// server in the default matrix (see `test/x402-exact.e2e.test.ts`). -+ -+import { -+ PAYMENT_REQUIRED_HEADER, -+ PAYMENT_SIGNATURE_HEADER, -+ readX402ClientEnvironment, -+} from "./exact-shared"; -+ -+type PaymentRequirement = { -+ scheme: string; -+ network: string; -+ resource?: string; -+ payTo: string; -+ asset: string; -+ maxAmountRequired: string; -+ extra?: { decimals?: number; tokenProgram?: string }; -+}; -+ -+type PaymentRequiredEnvelope = { -+ x402Version: number; -+ accepts: PaymentRequirement[]; -+ resource?: string; -+}; -+ -+const STABLECOIN_MINTS: Record> = { -+ USDC: { -+ "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": -+ "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", -+ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": -+ "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ }, -+ PYUSD: { -+ "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": -+ "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", -+ }, -+}; -+ -+function resolveMint(currency: string, network: string): string { -+ const upper = currency.toUpperCase(); -+ const byNetwork = STABLECOIN_MINTS[upper]; -+ if (byNetwork && byNetwork[network]) { -+ return byNetwork[network]; -+ } -+ return currency; -+} -+ -+function pickOffer( -+ envelope: PaymentRequiredEnvelope, -+ preferred: string[], -+ network: string, -+): PaymentRequirement | undefined { -+ const supported = envelope.accepts.filter( -+ offer => offer.scheme === "exact" && offer.network === network, -+ ); -+ if (supported.length === 0) { -+ return undefined; -+ } -+ if (preferred.length === 0) { -+ return supported[0]; -+ } -+ for (const wanted of preferred) { -+ const wantedMint = resolveMint(wanted, network); -+ const match = supported.find(offer => offer.asset === wantedMint); -+ if (match) return match; -+ } -+ return supported[0]; -+} -+ -+function decodePaymentRequired(headerValue: string | null): PaymentRequiredEnvelope | null { -+ if (!headerValue) return null; -+ try { -+ const raw = Buffer.from(headerValue, "base64").toString("utf8"); -+ return JSON.parse(raw) as PaymentRequiredEnvelope; -+ } catch { -+ return null; -+ } -+} -+ -+async function readResponseBody(response: Response): Promise { -+ const raw = await response.text(); -+ try { -+ return JSON.parse(raw); -+ } catch { -+ return raw; -+ } -+} -+ -+async function main() { -+ const env = readX402ClientEnvironment(); -+ -+ const firstResponse = await fetch(env.targetUrl); -+ const envelope = decodePaymentRequired( -+ firstResponse.headers.get(PAYMENT_REQUIRED_HEADER), -+ ); -+ -+ if (!envelope) { -+ console.log( -+ JSON.stringify({ -+ type: "result", -+ implementation: "typescript", -+ role: "client", -+ ok: false, -+ status: firstResponse.status, -+ responseHeaders: Object.fromEntries(firstResponse.headers.entries()), -+ responseBody: await readResponseBody(firstResponse), -+ settlement: null, -+ error: "missing or unparseable PAYMENT-REQUIRED header", -+ }), -+ ); -+ return; -+ } -+ -+ const offer = pickOffer(envelope, env.preferredCurrencies, env.network); -+ if (!offer) { -+ console.log( -+ JSON.stringify({ -+ type: "result", -+ implementation: "typescript", -+ role: "client", -+ ok: false, -+ status: firstResponse.status, -+ responseHeaders: Object.fromEntries(firstResponse.headers.entries()), -+ responseBody: await readResponseBody(firstResponse), -+ settlement: null, -+ error: `no offer matched network ${env.network}`, -+ }), -+ ); -+ return; -+ } -+ -+ // Credential payload mirrors the canonical x402 `exact` shape: an -+ // adapter-specific id plus the offer the client is committing to. -+ // A live SDK would also embed a signed Solana transaction here; the -+ // matrix runner uses the rust spine for the actual on-chain -+ // settlement assertions. The TS fixture's role is wire-level -+ // protocol compliance. -+ // Use the server-issued challenge id if present (TS reference server -+ // emits one in the `x-challenge-id` header on the 402). This lets the -+ // server verify the credential was issued against its own 402 — the -+ // cross-server portability scenario relies on this distinction. -+ const issuedChallengeId = firstResponse.headers.get("x-challenge-id"); -+ const credentialId = -+ issuedChallengeId ?? -+ `ts-x402-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; -+ // Mirrors the Rust spine's PaymentPayload wire shape: -+ // { x402Version, accepted: { scheme, network, asset, payTo, amount, extra? }, -+ // payload: { ... scheme-specific blob ... }, resource?: string } -+ // The `payload` field is required by Rust's parser. For the wire-only -+ // TS adapter the payload carries the credential id plus the route the -+ // client is committing to; a full SDK fixture would carry a signed -+ // Solana transaction here. -+ const credential = { -+ x402Version: envelope.x402Version, -+ accepted: { -+ scheme: offer.scheme, -+ network: offer.network, -+ asset: offer.asset, -+ payTo: offer.payTo, -+ amount: offer.maxAmountRequired, -+ extra: offer.extra ?? null, -+ }, -+ payload: { -+ challengeId: credentialId, -+ resource: offer.resource ?? envelope.resource, -+ }, -+ resource: offer.resource ?? envelope.resource, -+ }; -+ const credentialHeader = Buffer.from(JSON.stringify(credential), "utf8").toString( -+ "base64", -+ ); -+ -+ const paidResponse = await fetch(env.targetUrl, { -+ headers: { [PAYMENT_SIGNATURE_HEADER]: credentialHeader }, -+ }); -+ -+ const responseHeaders = Object.fromEntries(paidResponse.headers.entries()); -+ // Echo the credential the client sent so the harness can replay it in -+ // cross-server portability + idempotent-resubmit scenarios. The credential -+ // is a request header so it is never reflected in the response on its own. -+ responseHeaders[`${PAYMENT_SIGNATURE_HEADER}-sent`] = credentialHeader; -+ -+ console.log( -+ JSON.stringify({ -+ type: "result", -+ implementation: "typescript", -+ role: "client", -+ ok: paidResponse.ok, -+ status: paidResponse.status, -+ responseHeaders, -+ responseBody: await readResponseBody(paidResponse), -+ settlement: paidResponse.headers.get(env.settlementHeader), -+ }), -+ ); -+} -+ -+void main().catch(error => { -+ console.log( -+ JSON.stringify({ -+ type: "result", -+ implementation: "typescript", -+ role: "client", -+ ok: false, -+ status: 0, -+ responseHeaders: {}, -+ responseBody: null, -+ settlement: null, -+ error: error instanceof Error ? error.message : String(error), -+ }), -+ ); -+}); -diff --git a/harness/src/fixtures/typescript/exact-server.ts b/harness/src/fixtures/typescript/exact-server.ts -new file mode 100644 -index 0000000..780c663 ---- /dev/null -+++ b/harness/src/fixtures/typescript/exact-server.ts -@@ -0,0 +1,368 @@ -+// TypeScript reference x402 `exact` interop server. -+// -+// Wire-compatible with `rust/crates/x402/src/bin/interop_server.rs`: -+// - 402 carries a `PAYMENT-REQUIRED` header whose value is the -+// base64 of the JSON envelope `{x402Version, accepts, resource}`. -+// - The credential is delivered in the `PAYMENT-SIGNATURE` header. -+// - On successful settlement, the response includes -+// `PAYMENT-RESPONSE` and the fixture settlement header. -+// -+// This fixture deliberately keeps the SDK surface area minimal so the -+// adapter is portable across pay-kit checkouts. The cross-language -+// matrix is the load-bearing path; this adapter exists so language -+// adapters have a TS counterpart to pair against while the canonical -+// SDK lands. End-to-end verification against a live Surfpool RPC is -+// driven by the matrix runner. -+ -+import http from "node:http"; -+import { -+ PAYMENT_REQUIRED_HEADER, -+ PAYMENT_RESPONSE_HEADER, -+ PAYMENT_SIGNATURE_HEADER, -+ X402_VERSION_V2, -+ readX402ServerEnvironment, -+} from "./exact-shared"; -+ -+const TOKEN_DECIMALS = 6; -+const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; -+ -+type PaymentRequirement = { -+ scheme: "exact"; -+ network: string; -+ resource: string; -+ description: string; -+ mimeType: string; -+ payTo: string; -+ asset: string; -+ maxAmountRequired: string; -+ maxTimeoutSeconds: number; -+ extra: { -+ decimals: number; -+ tokenProgram?: string; -+ feePayer?: string; -+ }; -+}; -+ -+function buildRequirements( -+ env: ReturnType, -+): PaymentRequirement[] { -+ const primary: PaymentRequirement = { -+ scheme: "exact", -+ network: env.network, -+ resource: env.resourcePath, -+ description: "Surfpool-backed protected content", -+ mimeType: "application/json", -+ payTo: env.payTo, -+ asset: env.mint, -+ maxAmountRequired: env.price, -+ maxTimeoutSeconds: 60, -+ extra: { -+ decimals: TOKEN_DECIMALS, -+ tokenProgram: TOKEN_PROGRAM, -+ }, -+ }; -+ -+ const extras: PaymentRequirement[] = env.extraOfferedMints.map(mint => ({ -+ scheme: "exact", -+ network: env.network, -+ resource: env.resourcePath, -+ description: "Surfpool-backed protected content", -+ mimeType: "application/json", -+ payTo: env.payTo, -+ asset: mint, -+ maxAmountRequired: env.price, -+ maxTimeoutSeconds: 60, -+ extra: { decimals: TOKEN_DECIMALS }, -+ })); -+ -+ return [primary, ...extras]; -+} -+ -+function encodePaymentRequiredHeader(accepts: PaymentRequirement[]): string { -+ const envelope = { -+ x402Version: X402_VERSION_V2, -+ accepts, -+ resource: accepts[0]?.resource, -+ error: null, -+ }; -+ return Buffer.from(JSON.stringify(envelope), "utf8").toString("base64"); -+} -+ -+type DecodedCredential = { -+ x402Version?: number; -+ accepted?: { -+ scheme?: string; -+ network?: string; -+ asset?: string; -+ payTo?: string; -+ amount?: string; -+ }; -+ payload?: { -+ challengeId?: string; -+ resource?: string; -+ }; -+ resource?: string; -+}; -+ -+function decodeCredential(headerValue: string): DecodedCredential | null { -+ try { -+ const decoded = Buffer.from(headerValue, "base64").toString("utf8"); -+ return JSON.parse(decoded) as DecodedCredential; -+ } catch { -+ return null; -+ } -+} -+ -+type RejectReason = { -+ code: -+ | "payment_invalid" -+ | "wrong_network" -+ | "charge_request_mismatch" -+ | "challenge_verification_failed"; -+ message: string; -+}; -+ -+function classifyCredential( -+ credential: DecodedCredential | null, -+ accepts: PaymentRequirement[], -+ requestedResource: string, -+): { offer: PaymentRequirement; credentialKey: string } | { reject: RejectReason } { -+ if (!credential || !credential.accepted || !credential.payload) { -+ return { -+ reject: { -+ code: "payment_invalid", -+ message: "credential is missing accepted/payload fields", -+ }, -+ }; -+ } -+ -+ const offer = accepts.find( -+ candidate => -+ candidate.asset === credential.accepted?.asset && -+ candidate.network === credential.accepted?.network && -+ candidate.scheme === credential.accepted?.scheme, -+ ); -+ -+ if (!offer) { -+ // Could be either network mismatch or no matching offer. -+ if ( -+ credential.accepted.network && -+ !accepts.some(c => c.network === credential.accepted?.network) -+ ) { -+ return { -+ reject: { -+ code: "wrong_network", -+ message: `credential network ${credential.accepted.network} does not match server`, -+ }, -+ }; -+ } -+ return { -+ reject: { -+ code: "charge_request_mismatch", -+ message: "no offered requirement matches the credential", -+ }, -+ }; -+ } -+ -+ if (offer.payTo !== credential.accepted.payTo) { -+ return { -+ reject: { -+ code: "charge_request_mismatch", -+ message: "recipient does not match", -+ }, -+ }; -+ } -+ -+ if (offer.maxAmountRequired !== credential.accepted.amount) { -+ return { -+ reject: { -+ code: "charge_request_mismatch", -+ message: "amount does not match", -+ }, -+ }; -+ } -+ -+ const credentialResource = credential.payload.resource ?? credential.resource; -+ if (credentialResource && credentialResource !== requestedResource) { -+ return { -+ reject: { -+ code: "charge_request_mismatch", -+ message: `credential resource ${credentialResource} does not match requested ${requestedResource}`, -+ }, -+ }; -+ } -+ -+ const challengeId = credential.payload.challengeId; -+ if (!challengeId || typeof challengeId !== "string") { -+ return { -+ reject: { -+ code: "challenge_verification_failed", -+ message: "credential payload missing challengeId", -+ }, -+ }; -+ } -+ -+ return { offer, credentialKey: challengeId }; -+} -+ -+async function main() { -+ const env = readX402ServerEnvironment(); -+ const accepts = buildRequirements(env); -+ const paymentRequiredHeader = encodePaymentRequiredHeader(accepts); -+ -+ // Track consumed credentials by challengeId to surface -+ // `signature_consumed` on idempotent resubmit. -+ const consumed = new Set(); -+ // Track challenge IDs this server has issued (recognised when a -+ // credential's payload.challengeId matches). Cross-server portability: -+ // server B sees a credential carrying an id only server A issued, so B -+ // rejects with `challenge_verification_failed`. A real x402 facilitator -+ // verifies HMAC over the challenge id with its own secret; this fixture -+ // simulates that by tracking issuance in-process. -+ const issued = new Set(); -+ -+ const server = http.createServer((request, response) => { -+ const url = new URL(request.url ?? "/", "http://127.0.0.1"); -+ -+ if (url.pathname === "/health") { -+ response.writeHead(200, { "content-type": "application/json" }); -+ response.end(JSON.stringify({ ok: true })); -+ return; -+ } -+ -+ if (url.pathname !== env.resourcePath) { -+ response.writeHead(404, { "content-type": "application/json" }); -+ response.end(JSON.stringify({ error: "not_found" })); -+ return; -+ } -+ -+ const paymentHeader = request.headers[PAYMENT_SIGNATURE_HEADER] as -+ | string -+ | undefined; -+ -+ if (!paymentHeader) { -+ // Issue a fresh challenge id so the client can echo it back. The -+ // fixture's "verification" is presence-in-`issued`; a real -+ // facilitator would HMAC the id with its secret. -+ const challengeId = `ts-srv-${Date.now().toString(36)}-${Math.random() -+ .toString(36) -+ .slice(2, 10)}`; -+ issued.add(challengeId); -+ response.writeHead(402, { -+ "content-type": "application/json", -+ [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, -+ "x-challenge-id": challengeId, -+ }); -+ response.end( -+ JSON.stringify({ error: "payment_required", challengeId }), -+ ); -+ return; -+ } -+ -+ const credential = decodeCredential(paymentHeader); -+ const classified = classifyCredential(credential, accepts, env.resourcePath); -+ -+ if ("reject" in classified) { -+ response.writeHead(402, { -+ "content-type": "application/json", -+ [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, -+ }); -+ response.end( -+ JSON.stringify({ -+ error: classified.reject.code, -+ code: classified.reject.code, -+ message: classified.reject.message, -+ }), -+ ); -+ return; -+ } -+ -+ const { credentialKey } = classified; -+ -+ if (consumed.has(credentialKey)) { -+ response.writeHead(402, { -+ "content-type": "application/json", -+ [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, -+ }); -+ response.end( -+ JSON.stringify({ -+ error: "signature_consumed", -+ code: "signature_consumed", -+ message: "signature already consumed", -+ }), -+ ); -+ return; -+ } -+ -+ // Cross-server portability check: when the client supplies a payload -+ // challengeId, it must be one this server issued (or this server -+ // never required HMAC issuance). The first paid request that didn't -+ // come from this server's 402 will be missing from `issued`. -+ if (issued.size > 0 && !issued.has(credentialKey)) { -+ response.writeHead(402, { -+ "content-type": "application/json", -+ [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, -+ }); -+ response.end( -+ JSON.stringify({ -+ error: "challenge_verification_failed", -+ code: "challenge_verification_failed", -+ message: "challenge id was not issued by this server", -+ }), -+ ); -+ return; -+ } -+ -+ consumed.add(credentialKey); -+ -+ // Settlement: a real facilitator would broadcast a signed Solana -+ // transaction here. The fixture returns a deterministic placeholder -+ // so the harness can assert presence of the settlement header. -+ const settlement = `ts-x402-exact-${credentialKey.slice(0, 16)}`; -+ const paymentResponse = JSON.stringify({ -+ success: true, -+ network: accepts[0]?.network, -+ transaction: settlement, -+ }); -+ -+ response.writeHead(200, { -+ "content-type": "application/json", -+ [env.settlementHeader]: settlement, -+ [PAYMENT_RESPONSE_HEADER]: paymentResponse, -+ }); -+ response.end( -+ JSON.stringify({ -+ ok: true, -+ paid: true, -+ settlement: { -+ success: true, -+ transaction: settlement, -+ network: accepts[0]?.network, -+ }, -+ }), -+ ); -+ }); -+ -+ server.listen(0, "127.0.0.1", () => { -+ const address = server.address(); -+ if (!address || typeof address === "string") { -+ throw new Error("Failed to bind TypeScript x402 interop server"); -+ } -+ -+ console.log( -+ JSON.stringify({ -+ type: "ready", -+ implementation: "typescript", -+ role: "server", -+ port: address.port, -+ capabilities: ["exact"], -+ }), -+ ); -+ }); -+ -+ const shutdown = () => server.close(() => process.exit(0)); -+ process.on("SIGTERM", shutdown); -+ process.on("SIGINT", shutdown); -+} -+ -+void main(); -diff --git a/harness/src/fixtures/typescript/exact-shared.ts b/harness/src/fixtures/typescript/exact-shared.ts -new file mode 100644 -index 0000000..d9771bd ---- /dev/null -+++ b/harness/src/fixtures/typescript/exact-shared.ts -@@ -0,0 +1,87 @@ -+// Env contract for the TypeScript x402 `exact` fixture adapters. The -+// wire shape mirrors the Rust spine (`rust/crates/x402/src/bin/ -+// interop_{client,server}.rs`) verbatim so any language adapter that -+// targets this contract can pair against either TS or Rust. -+ -+export type X402InteropEnvironment = { -+ rpcUrl: string; -+ network: string; -+ mint: string; -+ payTo: string; -+ price: string; -+ resourcePath: string; -+ settlementHeader: string; -+ facilitatorSecretKey: Uint8Array; -+ // Server-only. Comma-separated mint addresses advertised alongside the -+ // primary currency. Read from `X402_INTEROP_EXTRA_OFFERED_MINTS`. -+ extraOfferedMints: string[]; -+}; -+ -+export type X402ClientEnvironment = X402InteropEnvironment & { -+ targetUrl: string; -+ clientSecretKey: Uint8Array; -+ // Comma-separated currency preference list (symbols or mints) read -+ // from `X402_INTEROP_PREFER_CURRENCIES`. Empty when unset. -+ preferredCurrencies: string[]; -+}; -+ -+const DEFAULT_NETWORK = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; -+const DEFAULT_RESOURCE_PATH = "/protected"; -+const DEFAULT_PRICE = "0.001"; -+const DEFAULT_SETTLEMENT_HEADER = "x-fixture-settlement"; -+ -+function readRequiredEnv(name: string): string { -+ const value = process.env[name]; -+ if (!value || value.trim() === "") { -+ throw new Error(`${name} is required`); -+ } -+ return value; -+} -+ -+function parseSecretKey(name: string): Uint8Array { -+ const raw = readRequiredEnv(name); -+ const parsed = JSON.parse(raw) as number[]; -+ return new Uint8Array(parsed); -+} -+ -+function parseCsv(raw: string | undefined): string[] { -+ if (!raw) return []; -+ return raw -+ .split(",") -+ .map(value => value.trim()) -+ .filter(Boolean); -+} -+ -+function readBase(): X402InteropEnvironment { -+ return { -+ rpcUrl: readRequiredEnv("X402_INTEROP_RPC_URL"), -+ network: process.env.X402_INTEROP_NETWORK ?? DEFAULT_NETWORK, -+ mint: readRequiredEnv("X402_INTEROP_MINT"), -+ payTo: readRequiredEnv("X402_INTEROP_PAY_TO"), -+ price: process.env.X402_INTEROP_PRICE ?? DEFAULT_PRICE, -+ resourcePath: process.env.X402_INTEROP_RESOURCE_PATH ?? DEFAULT_RESOURCE_PATH, -+ settlementHeader: -+ process.env.X402_INTEROP_SETTLEMENT_HEADER ?? DEFAULT_SETTLEMENT_HEADER, -+ facilitatorSecretKey: parseSecretKey("X402_INTEROP_FACILITATOR_SECRET_KEY"), -+ extraOfferedMints: parseCsv(process.env.X402_INTEROP_EXTRA_OFFERED_MINTS), -+ }; -+} -+ -+export function readX402ServerEnvironment(): X402InteropEnvironment { -+ return readBase(); -+} -+ -+export function readX402ClientEnvironment(): X402ClientEnvironment { -+ const base = readBase(); -+ return { -+ ...base, -+ targetUrl: readRequiredEnv("X402_INTEROP_TARGET_URL"), -+ clientSecretKey: parseSecretKey("X402_INTEROP_CLIENT_SECRET_KEY"), -+ preferredCurrencies: parseCsv(process.env.X402_INTEROP_PREFER_CURRENCIES), -+ }; -+} -+ -+export const PAYMENT_REQUIRED_HEADER = "payment-required"; -+export const PAYMENT_SIGNATURE_HEADER = "payment-signature"; -+export const PAYMENT_RESPONSE_HEADER = "payment-response"; -+export const X402_VERSION_V2 = 2; -diff --git a/tests/interop/src/fixtures/typescript/shared.ts b/harness/src/fixtures/typescript/shared.ts -similarity index 100% -rename from tests/interop/src/fixtures/typescript/shared.ts -rename to harness/src/fixtures/typescript/shared.ts -diff --git a/tests/interop/src/implementations.ts b/harness/src/implementations.ts -similarity index 63% -rename from tests/interop/src/implementations.ts -rename to harness/src/implementations.ts -index 89c9586..9bab459 100644 ---- a/tests/interop/src/implementations.ts -+++ b/harness/src/implementations.ts -@@ -4,6 +4,10 @@ export type ImplementationDefinition = { - role: "client" | "server"; - command: string[]; - enabled: boolean; -+ // Optional. When set, this adapter only participates in scenarios whose -+ // `intent` is in this list. Defaults to "charge" only for back-compat -+ // with the existing MPP charge matrix. -+ intents?: string[]; - }; - - function isEnabled(id: string, envName: string, defaultEnabled: boolean): boolean { -@@ -43,7 +47,7 @@ export const clientImplementations: ImplementationDefinition[] = [ - "run", - "--quiet", - "--manifest-path", -- "../../rust/Cargo.toml", -+ "../rust/Cargo.toml", - "-p", - "solana-mpp", - "--bin", -@@ -69,6 +73,51 @@ export const clientImplementations: ImplementationDefinition[] = [ - ], - enabled: isEnabled("swift", "MPP_INTEROP_CLIENTS", false), - }, -+ { -+ id: "ts-x402", -+ label: "TypeScript x402 exact client", -+ role: "client", -+ command: [ -+ "pnpm", -+ "exec", -+ "node", -+ "--import", -+ "tsx", -+ "src/fixtures/typescript/exact-client.ts", -+ ], -+ enabled: isEnabled("ts-x402", "X402_INTEROP_CLIENTS", true), -+ intents: ["x402-exact"], -+ }, -+ { -+ id: "rust-x402", -+ label: "Rust x402 exact client", -+ role: "client", -+ command: [ -+ "cargo", -+ "run", -+ "--quiet", -+ "--manifest-path", -+ "../../rust/Cargo.toml", -+ "-p", -+ "solana-x402", -+ "--bin", -+ "interop_client", -+ ], -+ enabled: isEnabled("rust-x402", "X402_INTEROP_CLIENTS", true), -+ intents: ["x402-exact"], -+ }, -+ { -+ id: "go-x402-client", -+ label: "Go x402 exact client", -+ role: "client", -+ command: [ -+ "sh", -+ "-c", -+ "cd ../go/x402/cmd/interop-client && go run .", -+ ], -+ enabled: isEnabled("go-x402-client", "MPP_INTEROP_CLIENTS", false), -+ intents: ["x402-exact"], -+ }, - ]; - - export const serverImplementations: ImplementationDefinition[] = [ -@@ -95,7 +144,7 @@ export const serverImplementations: ImplementationDefinition[] = [ - "run", - "--quiet", - "--manifest-path", -- "../../rust/Cargo.toml", -+ "../rust/Cargo.toml", - "-p", - "solana-mpp", - "--bin", -@@ -122,7 +171,7 @@ export const serverImplementations: ImplementationDefinition[] = [ - command: [ - "sh", - "-c", -- "cd ../../ruby && bundle exec ruby ../tests/interop/ruby-server/server.rb", -+ "cd ../ruby && bundle exec ruby ../harness/ruby-server/server.rb", - ], - enabled: isEnabled("ruby", "MPP_INTEROP_SERVERS", false), - }, -@@ -133,7 +182,7 @@ export const serverImplementations: ImplementationDefinition[] = [ - command: [ - "sh", - "-c", -- "cd ../../lua && eval \"$(luarocks --lua-version=5.1 --tree lua_modules path)\" && luajit ../tests/interop/lua-server/server.lua", -+ "cd ../lua && eval \"$(luarocks --lua-version=5.1 --tree lua_modules path)\" && luajit ../harness/lua-server/server.lua", - ], - // Lua defaults off to match php/ruby: the harness requires a - // luarocks-installed lua_modules tree under lua/ and a working -@@ -161,4 +210,49 @@ export const serverImplementations: ImplementationDefinition[] = [ - command: ["sh", "-c", "cd go-server && go run ."], - enabled: isEnabled("go", "MPP_INTEROP_SERVERS", true), - }, -+ { -+ id: "ts-x402", -+ label: "TypeScript x402 exact server", -+ role: "server", -+ command: [ -+ "pnpm", -+ "exec", -+ "node", -+ "--import", -+ "tsx", -+ "src/fixtures/typescript/exact-server.ts", -+ ], -+ enabled: isEnabled("ts-x402", "X402_INTEROP_SERVERS", true), -+ intents: ["x402-exact"], -+ }, -+ { -+ id: "rust-x402", -+ label: "Rust x402 exact server", -+ role: "server", -+ command: [ -+ "cargo", -+ "run", -+ "--quiet", -+ "--manifest-path", -+ "../../rust/Cargo.toml", -+ "-p", -+ "solana-x402", -+ "--bin", -+ "interop_server", -+ ], -+ enabled: isEnabled("rust-x402", "X402_INTEROP_SERVERS", true), -+ intents: ["x402-exact"], -+ }, -+ { -+ id: "go-x402-server", -+ label: "Go x402 exact server", -+ role: "server", -+ command: [ -+ "sh", -+ "-c", -+ "cd ../go/x402/cmd/interop-server && go run .", -+ ], -+ enabled: isEnabled("go-x402-server", "MPP_INTEROP_SERVERS", false), -+ intents: ["x402-exact"], -+ }, - ]; -diff --git a/tests/interop/src/intents/charge.ts b/harness/src/intents/charge.ts -similarity index 99% -rename from tests/interop/src/intents/charge.ts -rename to harness/src/intents/charge.ts -index db1e3df..a1d58f3 100644 ---- a/tests/interop/src/intents/charge.ts -+++ b/harness/src/intents/charge.ts -@@ -66,7 +66,7 @@ export const chargeCanonicalJsonVectors: readonly CanonicalJsonVector[] = [ - * Reserved for a future cross-SDK harness loop that asserts each - * implementation's encoder rejects these inputs; today the per-language - * rejection coverage lives inline in each SDK's own unit suite plus the -- * reference encoder check in `tests/interop/test/canonical-json.test.ts`. -+ * reference encoder check in `harness/test/canonical-json.test.ts`. - * Kept here so the spec-mandated reject set has a single source of truth. - */ - export const chargeCanonicalJsonRejectVectors: readonly { id: string; reason: string }[] = [ -@@ -118,7 +118,7 @@ export const chargeScenarios: readonly InteropScenario[] = [ - // Server fixtures honour MPP_INTEROP_PAYMENT_MODE=push by omitting - // the fee payer signer when constructing the charge method. - // Excluded: lua and python ship push support in their SDKs but do -- // not yet have an interop server fixture under tests/interop/. -+ // not yet have an interop server fixture under harness/. - id: "charge-push", - intent: "charge", - paymentMode: "push", -diff --git a/harness/src/intents/x402-exact.ts b/harness/src/intents/x402-exact.ts -new file mode 100644 -index 0000000..85f1afe ---- /dev/null -+++ b/harness/src/intents/x402-exact.ts -@@ -0,0 +1,119 @@ -+import type { InteropScenario } from "../contracts"; -+ -+// Canonical x402 `exact` intent scenarios. The harness contract (env -+// vars, ready/result JSON shapes, capabilities) mirrors the Rust spine -+// (`rust/crates/x402/src/bin/interop_{client,server}.rs`). The matrix -+// pairs each x402 client against each x402 server registered in -+// `implementations.ts`; the default-matrix pair set is restricted in -+// `test/x402-exact.e2e.test.ts` while the TS reference adapter ships -+// without a full Solana signing path. Adding language adapters that -+// carry a real PaymentProof expands the matrix. -+// -+// Reject codes (cross-server portability / replay / network mismatch) -+// reuse the canonical L6 set declared in `canonical-codes.ts`; the -+// matrix asserts each x402 server adapter classifies the failure -+// to the same canonical snake_case code as every other adapter. -+export const x402ExactScenarios: readonly InteropScenario[] = [ -+ { -+ id: "x402-exact-basic", -+ intent: "x402-exact", -+ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ price: "0.001", -+ amount: "1000", -+ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ resourcePath: "/protected", -+ settlementHeader: "x-fixture-settlement", -+ expectedStatus: 200, -+ }, -+ { -+ // Network mismatch: client signs against localnet but the challenge -+ // requires devnet (or vice versa). Server must reject the credential -+ // with canonical `wrong_network`. -+ id: "x402-exact-network-mismatch", -+ intent: "x402-exact", -+ network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", -+ price: "0.001", -+ amount: "1000", -+ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ resourcePath: "/protected/network-mismatch", -+ settlementHeader: "x-fixture-settlement", -+ expectedStatus: 402, -+ expectedCode: "wrong_network", -+ clientIds: ["ts-x402", "rust-x402"], -+ serverIds: ["ts-x402", "rust-x402"], -+ }, -+ { -+ // Cross-route replay: credential issued for /protected/cheap is -+ // re-submitted against /protected/expensive. Server must reject with -+ // `charge_request_mismatch` because the credential's pinned route / -+ // amount does not match the served route. -+ id: "x402-exact-cross-route-replay", -+ intent: "x402-exact", -+ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ price: "0.001", -+ amount: "1000", -+ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ resourcePath: "/protected/expensive", -+ settlementHeader: "x-fixture-settlement", -+ replaySource: { -+ resourcePath: "/protected/cheap", -+ price: "0.0005", -+ amount: "500", -+ }, -+ expectedStatus: 402, -+ expectedCode: "charge_request_mismatch", -+ clientIds: ["ts-x402"], -+ serverIds: ["ts-x402", "rust-x402"], -+ }, -+ { -+ // Cross-server credential portability. Client pays server A and -+ // re-submits the same payment header to server B. B must reject with -+ // canonical `challenge_verification_failed` because B's verifier -+ // does not accept A's challenge issuance. -+ id: "x402-exact-cross-server-portability", -+ intent: "x402-exact", -+ kind: "cross-server-portability", -+ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ price: "0.001", -+ amount: "1000", -+ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ resourcePath: "/protected", -+ settlementHeader: "x-fixture-settlement", -+ expectedStatus: 402, -+ expectedCode: "challenge_verification_failed", -+ clientIds: ["ts-x402"], -+ serverIds: ["ts-x402", "rust-x402"], -+ // Cross-server portability requires the client adapter to expose the -+ // credential it sent so the runner can replay it. The TS reference -+ // client echoes `payment-signature-sent`; the Rust spine adapter does -+ // not (and is preserved as the canonical settlement-signing path -+ // rather than a credential-capturing one). Pairs that use the TS -+ // client cover the asymmetric direction too: TS pays server A, then -+ // replays the captured credential against server B. -+ crossServerPairs: [["ts-x402", "rust-x402"]], -+ }, -+ { -+ // Same-server idempotent resubmit. Client pays server A, then -+ // re-submits the same payment header. Server must reject with -+ // `signature_consumed`. -+ id: "x402-exact-idempotent-resubmit", -+ intent: "x402-exact", -+ kind: "idempotent-resubmit", -+ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", -+ price: "0.001", -+ amount: "1000", -+ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", -+ resourcePath: "/protected", -+ settlementHeader: "x-fixture-settlement", -+ expectedStatus: 402, -+ expectedCode: "signature_consumed", -+ // Driven by the TS client (the only one that echoes the sent -+ // credential back to the harness). The first paid request must -+ // reach 200, which constrains us to the TS reference server in -+ // the default matrix because that server is what speaks the TS -+ // client's stub payload. Rust server coverage of `signature_consumed` -+ // lives in the Rust crate's own integration tests. -+ clientIds: ["ts-x402"], -+ serverIds: ["ts-x402"], -+ }, -+] as const; -diff --git a/tests/interop/src/process.ts b/harness/src/process.ts -similarity index 100% -rename from tests/interop/src/process.ts -rename to harness/src/process.ts -diff --git a/tests/interop/start-surfnet-proxy.mjs b/harness/start-surfnet-proxy.mjs -similarity index 100% -rename from tests/interop/start-surfnet-proxy.mjs -rename to harness/start-surfnet-proxy.mjs -diff --git a/tests/interop/swift-client/.gitignore b/harness/swift-client/.gitignore -similarity index 100% -rename from tests/interop/swift-client/.gitignore -rename to harness/swift-client/.gitignore -diff --git a/tests/interop/swift-client/Package.swift b/harness/swift-client/Package.swift -similarity index 90% -rename from tests/interop/swift-client/Package.swift -rename to harness/swift-client/Package.swift -index 777c1bd..553eee7 100644 ---- a/tests/interop/swift-client/Package.swift -+++ b/harness/swift-client/Package.swift -@@ -8,7 +8,7 @@ let package = Package( - .macOS(.v13), - ], - dependencies: [ -- .package(path: "../../../swift"), -+ .package(path: "../../swift"), - ], - targets: [ - .executableTarget( -diff --git a/tests/interop/swift-client/Sources/SwiftInteropClient/main.swift b/harness/swift-client/Sources/SwiftInteropClient/main.swift -similarity index 100% -rename from tests/interop/swift-client/Sources/SwiftInteropClient/main.swift -rename to harness/swift-client/Sources/SwiftInteropClient/main.swift -diff --git a/tests/interop/test/canonical-json.test.ts b/harness/test/canonical-json.test.ts -similarity index 100% -rename from tests/interop/test/canonical-json.test.ts -rename to harness/test/canonical-json.test.ts -diff --git a/tests/interop/test/compute-budget-caps.test.ts b/harness/test/compute-budget-caps.test.ts -similarity index 99% -rename from tests/interop/test/compute-budget-caps.test.ts -rename to harness/test/compute-budget-caps.test.ts -index bd870d1..2055994 100644 ---- a/tests/interop/test/compute-budget-caps.test.ts -+++ b/harness/test/compute-budget-caps.test.ts -@@ -24,7 +24,7 @@ import { describe, expect, it } from "vitest"; - * Issue: #109. - */ - --const REPO_ROOT = resolve(__dirname, "..", "..", ".."); -+const REPO_ROOT = resolve(__dirname, "..", ".."); - - const CANONICAL_LIMIT = 200_000; - const CANONICAL_PRICE_MICROLAMPORTS = 5_000_000; -diff --git a/harness/test/cross-server-scenarios.test.ts b/harness/test/cross-server-scenarios.test.ts -new file mode 100644 -index 0000000..4dad528 ---- /dev/null -+++ b/harness/test/cross-server-scenarios.test.ts -@@ -0,0 +1,210 @@ -+// Cross-server portability + idempotent-resubmit scenarios for the x402 -+// `exact` intent. Mirrors MPP §19.6: -+// -+// - Cross-server portability: the client pays server A and re-submits the -+// same payment-signature header to server B. B must reject with the -+// canonical `challenge_verification_failed` code because B's verifier -+// does not accept A's challenge. -+// -+// - Idempotent resubmit: the client pays server A, then re-submits the -+// same payment-signature header to server A. A must reject with -+// `signature_consumed`. -+// -+// Gated behind `X402_INTEROP_CROSS_SERVER=1` because the matrix needs -+// two long-lived servers and live RPC credentials, neither of which the -+// default `pnpm test` run wires up. -+ -+import { afterAll, describe, expect, it } from "vitest"; -+import { interopScenarios } from "../src/contracts"; -+import { classifyMessageToCanonicalCode } from "../src/canonical-codes"; -+import { -+ clientImplementations, -+ serverImplementations, -+} from "../src/implementations"; -+import { runClient, startServer, stopServer } from "../src/process"; -+ -+const CROSS_SERVER_ENABLED = process.env.X402_INTEROP_CROSS_SERVER === "1"; -+ -+const requiredEnvs = [ -+ "X402_INTEROP_RPC_URL", -+ "X402_INTEROP_MINT", -+ "X402_INTEROP_PAY_TO", -+ "X402_INTEROP_CLIENT_SECRET_KEY", -+ "X402_INTEROP_FACILITATOR_SECRET_KEY", -+]; -+ -+function missingEnvs(): string[] { -+ return requiredEnvs.filter( -+ name => !process.env[name] || process.env[name]?.trim() === "", -+ ); -+} -+ -+const portabilityScenario = interopScenarios.find( -+ scenario => scenario.id === "x402-exact-cross-server-portability", -+); -+const resubmitScenario = interopScenarios.find( -+ scenario => scenario.id === "x402-exact-idempotent-resubmit", -+); -+ -+const serversById = new Map(serverImplementations.map(s => [s.id, s])); -+const clientsById = new Map(clientImplementations.map(c => [c.id, c])); -+ -+type RunningServer = Awaited>; -+const runningServers: RunningServer[] = []; -+ -+afterAll(async () => { -+ for (const server of runningServers.splice(0)) { -+ await stopServer(server); -+ } -+}); -+ -+function extractCanonicalCode(body: unknown): string | undefined { -+ if (body && typeof body === "object" && !Array.isArray(body)) { -+ const record = body as Record; -+ if (typeof record.code === "string") return record.code; -+ const source = -+ (typeof record.error === "string" && record.error) || -+ (typeof record.message === "string" && record.message) || -+ undefined; -+ if (source) return classifyMessageToCanonicalCode(source); -+ } -+ if (typeof body === "string") { -+ return classifyMessageToCanonicalCode(body); -+ } -+ return undefined; -+} -+ -+describe("x402 exact — cross-server portability + idempotent resubmit", () => { -+ if (!CROSS_SERVER_ENABLED) { -+ it.skip("cross-server suite is gated behind X402_INTEROP_CROSS_SERVER=1", () => {}); -+ return; -+ } -+ -+ const missing = missingEnvs(); -+ if (missing.length > 0) { -+ it.skip(`missing required env vars: ${missing.join(", ")}`, () => {}); -+ return; -+ } -+ -+ if (portabilityScenario && portabilityScenario.crossServerPairs) { -+ for (const [serverAId, serverBId] of portabilityScenario.crossServerPairs) { -+ const serverA = serversById.get(serverAId); -+ const serverB = serversById.get(serverBId); -+ // Use the TS reference client to drive the pay-then-replay flow -+ // because it echoes the sent credential under `payment-signature-sent`. -+ // The Rust spine client does not surface the captured credential to -+ // the harness; its portability coverage is exercised by the Rust -+ // crate's own integration tests. -+ const client = clientsById.get("ts-x402"); -+ if (!serverA?.enabled || !serverB?.enabled || !client?.enabled) { -+ it.skip(`portability ${serverAId} -> ${serverBId}: adapter not enabled`, () => {}); -+ continue; -+ } -+ -+ it(`portability: pay ${serverAId} then resubmit credential to ${serverBId}`, async () => { -+ const env = { -+ X402_INTEROP_NETWORK: portabilityScenario.network, -+ X402_INTEROP_PRICE: portabilityScenario.price, -+ X402_INTEROP_RESOURCE_PATH: portabilityScenario.resourcePath, -+ X402_INTEROP_SETTLEMENT_HEADER: portabilityScenario.settlementHeader, -+ }; -+ -+ const runningA = await startServer(serverA, env); -+ runningServers.push(runningA); -+ const runningB = await startServer(serverB, env); -+ runningServers.push(runningB); -+ -+ try { -+ const urlA = `http://127.0.0.1:${runningA.ready.port}${portabilityScenario.resourcePath}`; -+ const payA = await runClient(client, urlA, { -+ X402_INTEROP_TARGET_URL: urlA, -+ ...env, -+ }); -+ expect(payA.status).toBe(200); -+ -+ // Re-submit the captured payment-signature header to server B. -+ // Adapters echo the credential they sent under `*-sent` so the -+ // harness can replay it. Falls back to the live payment-signature -+ // header for adapters that don't echo (rust spine). -+ const headers = payA.responseHeaders as Record; -+ const credential = -+ headers["payment-signature-sent"] ?? headers["payment-signature"]; -+ const urlB = `http://127.0.0.1:${runningB.ready.port}${portabilityScenario.resourcePath}`; -+ const replay = await fetch(urlB, { -+ headers: credential -+ ? { "payment-signature": String(credential) } -+ : {}, -+ }); -+ const body = await replay.json().catch(() => null); -+ -+ expect(replay.status).toBe(portabilityScenario.expectedStatus); -+ if (portabilityScenario.expectedCode) { -+ expect(extractCanonicalCode(body)).toBe(portabilityScenario.expectedCode); -+ } -+ } finally { -+ await stopServer(runningA); -+ await stopServer(runningB); -+ runningServers.splice(runningServers.indexOf(runningA), 1); -+ runningServers.splice(runningServers.indexOf(runningB), 1); -+ } -+ }, 180_000); -+ } -+ } else { -+ it.skip("portability scenario missing crossServerPairs", () => {}); -+ } -+ -+ if (resubmitScenario) { -+ const serverIds = resubmitScenario.serverIds ?? ["ts-x402"]; -+ for (const sid of serverIds) { -+ const server = serversById.get(sid); -+ // Same rationale as portability above: drive with the TS client so -+ // the harness can replay the captured credential. -+ const client = clientsById.get("ts-x402"); -+ if (!server?.enabled || !client?.enabled) { -+ it.skip(`idempotent-resubmit on ${sid}: adapter not enabled`, () => {}); -+ continue; -+ } -+ -+ it(`idempotent resubmit against ${sid}`, async () => { -+ const env = { -+ X402_INTEROP_NETWORK: resubmitScenario.network, -+ X402_INTEROP_PRICE: resubmitScenario.price, -+ X402_INTEROP_RESOURCE_PATH: resubmitScenario.resourcePath, -+ X402_INTEROP_SETTLEMENT_HEADER: resubmitScenario.settlementHeader, -+ }; -+ -+ const running = await startServer(server, env); -+ runningServers.push(running); -+ -+ try { -+ const url = `http://127.0.0.1:${running.ready.port}${resubmitScenario.resourcePath}`; -+ const first = await runClient(client, url, { -+ X402_INTEROP_TARGET_URL: url, -+ ...env, -+ }); -+ expect(first.status).toBe(200); -+ -+ const headers = first.responseHeaders as Record; -+ const credential = -+ headers["payment-signature-sent"] ?? headers["payment-signature"]; -+ const replay = await fetch(url, { -+ headers: credential -+ ? { "payment-signature": String(credential) } -+ : {}, -+ }); -+ const body = await replay.json().catch(() => null); -+ -+ expect(replay.status).toBe(resubmitScenario.expectedStatus); -+ if (resubmitScenario.expectedCode) { -+ expect(extractCanonicalCode(body)).toBe(resubmitScenario.expectedCode); -+ } -+ } finally { -+ await stopServer(running); -+ runningServers.splice(runningServers.indexOf(running), 1); -+ } -+ }, 180_000); -+ } -+ } else { -+ it.skip("idempotent-resubmit scenario missing", () => {}); -+ } -+}); -diff --git a/tests/interop/test/e2e.test.ts b/harness/test/e2e.test.ts -similarity index 97% -rename from tests/interop/test/e2e.test.ts -rename to harness/test/e2e.test.ts -index e9e7e53..706f4bc 100644 ---- a/tests/interop/test/e2e.test.ts -+++ b/harness/test/e2e.test.ts -@@ -174,7 +174,7 @@ beforeAll(async () => { - // surfpool's RPC stops responding to subsequent simulate/broadcast - // calls, which surfaces as a 120s adapter-output timeout on - // charge-idempotent-resubmit (the matrix's tail scenario). The -- // 1s cadence matches tests/interop/start-surfnet-proxy.mjs, which -+ // 1s cadence matches harness/start-surfnet-proxy.mjs, which - // already does this for the proxy-mode launcher. See Ludo-7 / PR #102. - surfnetDrainTimer = setInterval(() => { - surfnet?.drainEvents(); -@@ -320,13 +320,23 @@ describe("mpp interop", () => { - ) { - continue; - } -+ // The x402-exact intent has its own runner in -+ // `test/x402-exact.e2e.test.ts` that emits `X402_INTEROP_*` env vars. -+ // The legacy MPP runner builds `MPP_INTEROP_*` env, which the x402 -+ // adapters do not consume, so we hard-skip the new intent here even -+ // when MPP_INTEROP_INTENTS explicitly selects it. -+ if (scenario.intent === "x402-exact") { -+ continue; -+ } - const scenarioServers = activeServers.filter( - (implementation) => -- !scenario.serverIds || scenario.serverIds.includes(implementation.id), -+ (!implementation.intents || implementation.intents.includes(scenario.intent)) && -+ (!scenario.serverIds || scenario.serverIds.includes(implementation.id)), - ); - const scenarioClients = activeClients.filter( - (implementation) => -- !scenario.clientIds || scenario.clientIds.includes(implementation.id), -+ (!implementation.intents || implementation.intents.includes(scenario.intent)) && -+ (!scenario.clientIds || scenario.clientIds.includes(implementation.id)), - ); - - for (const serverImplementation of scenarioServers) { -diff --git a/tests/interop/test/intent-selection.test.ts b/harness/test/intent-selection.test.ts -similarity index 62% -rename from tests/interop/test/intent-selection.test.ts -rename to harness/test/intent-selection.test.ts -index 6e86602..1dcef68 100644 ---- a/tests/interop/test/intent-selection.test.ts -+++ b/harness/test/intent-selection.test.ts -@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; - import { selectInteropIntents, selectInteropScenarios } from "../src/contracts"; - - describe("interop intent selection", () => { -- it("defaults to the implemented charge scenario", () => { -+ it("defaults to the legacy charge intent for CI stability", () => { -+ // x402-exact is opt-in via MPP_INTEROP_INTENTS=x402-exact (or -+ // comma-list) so the canonical MPP charge matrix in the existing -+ // runner is not perturbed by the new intent's enabled-by-default -+ // adapters. - expect(selectInteropIntents(undefined)).toEqual(["charge"]); - }); - -@@ -10,6 +14,17 @@ describe("interop intent selection", () => { - expect(selectInteropIntents(" charge ")).toEqual(["charge"]); - }); - -+ it("accepts the implemented x402-exact intent", () => { -+ expect(selectInteropIntents("x402-exact")).toEqual(["x402-exact"]); -+ }); -+ -+ it("accepts both intents at once", () => { -+ expect(selectInteropIntents("charge,x402-exact")).toEqual([ -+ "charge", -+ "x402-exact", -+ ]); -+ }); -+ - it("rejects scenarios that are not implemented yet", () => { - expect(() => selectInteropIntents("session")).toThrow( - /Unsupported MPP_INTEROP_INTENTS/, -@@ -42,6 +57,20 @@ describe("interop scenario selection", () => { - ]); - }); - -+ it("returns x402-exact scenarios when explicitly requested", () => { -+ expect( -+ selectInteropScenarios("x402-exact", undefined).map( -+ (scenario) => scenario.id, -+ ), -+ ).toEqual([ -+ "x402-exact-basic", -+ "x402-exact-network-mismatch", -+ "x402-exact-cross-route-replay", -+ "x402-exact-cross-server-portability", -+ "x402-exact-idempotent-resubmit", -+ ]); -+ }); -+ - it("runs one requested scenario", () => { - expect( - selectInteropScenarios("charge", "charge-split-ata").map( -diff --git a/tests/interop/test/process.test.ts b/harness/test/process.test.ts -similarity index 100% -rename from tests/interop/test/process.test.ts -rename to harness/test/process.test.ts -diff --git a/harness/test/x402-exact.e2e.test.ts b/harness/test/x402-exact.e2e.test.ts -new file mode 100644 -index 0000000..03aeb26 ---- /dev/null -+++ b/harness/test/x402-exact.e2e.test.ts -@@ -0,0 +1,128 @@ -+// Cross-language matrix for the x402 `exact` intent. Iterates every -+// active x402 client × every active x402 server registered in -+// `src/implementations.ts` and asserts the happy-path scenario reaches -+// HTTP 200 with the fixture settlement header populated. -+// -+// Gated behind `X402_INTEROP_MATRIX=1` so the default `pnpm test` run -+// in pay-kit does not require cargo or a live Surfpool RPC. The -+// canonical CI invocation is: -+// -+// X402_INTEROP_MATRIX=1 \ -+// X402_INTEROP_RPC_URL=... \ -+// X402_INTEROP_PAY_TO=... \ -+// X402_INTEROP_CLIENT_SECRET_KEY=[...] \ -+// X402_INTEROP_FACILITATOR_SECRET_KEY=[...] \ -+// pnpm test x402-exact.e2e.test.ts -+ -+import { afterAll, describe, expect, it } from "vitest"; -+import { interopScenarios } from "../src/contracts"; -+import { -+ clientImplementations, -+ serverImplementations, -+} from "../src/implementations"; -+import { runClient, startServer, stopServer } from "../src/process"; -+ -+const MATRIX_ENABLED = process.env.X402_INTEROP_MATRIX === "1"; -+ -+const requiredEnvs = [ -+ "X402_INTEROP_RPC_URL", -+ "X402_INTEROP_MINT", -+ "X402_INTEROP_PAY_TO", -+ "X402_INTEROP_CLIENT_SECRET_KEY", -+ "X402_INTEROP_FACILITATOR_SECRET_KEY", -+]; -+ -+function missingEnvs(): string[] { -+ return requiredEnvs.filter( -+ name => !process.env[name] || process.env[name]?.trim() === "", -+ ); -+} -+ -+const happyPath = interopScenarios.find( -+ scenario => scenario.id === "x402-exact-basic", -+); -+ -+const x402Clients = clientImplementations.filter( -+ impl => impl.enabled && (impl.intents ?? ["charge"]).includes("x402-exact"), -+); -+const x402Servers = serverImplementations.filter( -+ impl => impl.enabled && (impl.intents ?? ["charge"]).includes("x402-exact"), -+); -+ -+type RunningServer = Awaited>; -+const runningServers: RunningServer[] = []; -+ -+afterAll(async () => { -+ for (const server of runningServers.splice(0)) { -+ await stopServer(server); -+ } -+}); -+ -+describe("x402 exact intent — cross-language matrix", () => { -+ if (!MATRIX_ENABLED) { -+ it.skip("matrix is gated behind X402_INTEROP_MATRIX=1", () => {}); -+ return; -+ } -+ -+ const missing = missingEnvs(); -+ if (missing.length > 0) { -+ it.skip(`missing required env vars: ${missing.join(", ")}`, () => {}); -+ return; -+ } -+ -+ if (!happyPath) { -+ it.fails("happy-path scenario x402-exact-basic missing from registry", () => { -+ throw new Error("x402-exact-basic scenario not found in interopScenarios"); -+ }); -+ return; -+ } -+ -+ // Pair restriction: the TS reference adapters speak a stub payload -+ // (no real signed Solana transaction in the fixture) so they only -+ // interoperate with each other. The Rust spine adapters carry the -+ // canonical PaymentProof and are exercised end-to-end by the rust -+ // crate's own integration tests (`cargo test -p solana-x402`). -+ // The cross-language matrix asserts the harness wiring and the -+ // ready/result protocol; full TS<->Rust on-chain settlement parity -+ // arrives with the TS SDK port (tracked separately). -+ const allowedPair = (clientId: string, serverId: string): boolean => { -+ if (clientId === "ts-x402" && serverId === "ts-x402") return true; -+ if (clientId === "rust-x402" && serverId === "rust-x402") return true; -+ return false; -+ }; -+ -+ for (const server of x402Servers) { -+ for (const client of x402Clients) { -+ if (!allowedPair(client.id, server.id)) { -+ it.skip(`${client.id} client ↔ ${server.id} server: pair not in default matrix`, () => {}); -+ continue; -+ } -+ it(`${client.id} client ↔ ${server.id} server: happy path`, async () => { -+ const env = { -+ X402_INTEROP_NETWORK: happyPath.network, -+ X402_INTEROP_PRICE: happyPath.price, -+ X402_INTEROP_RESOURCE_PATH: happyPath.resourcePath, -+ X402_INTEROP_SETTLEMENT_HEADER: happyPath.settlementHeader, -+ } satisfies Record; -+ -+ const running = await startServer(server, env); -+ runningServers.push(running); -+ -+ try { -+ const targetUrl = `http://127.0.0.1:${running.ready.port}${happyPath.resourcePath}`; -+ const result = await runClient(client, targetUrl, { -+ X402_INTEROP_TARGET_URL: targetUrl, -+ ...env, -+ }); -+ -+ expect(result.status).toBe(happyPath.expectedStatus); -+ expect(result.ok).toBe(true); -+ expect(result.settlement).toBeTruthy(); -+ } finally { -+ await stopServer(running); -+ runningServers.splice(runningServers.indexOf(running), 1); -+ } -+ }, 120_000); -+ } -+ } -+}); -diff --git a/tests/interop/ts-client/main.ts b/harness/ts-client/main.ts -similarity index 100% -rename from tests/interop/ts-client/main.ts -rename to harness/ts-client/main.ts -diff --git a/tests/interop/ts-client/package-lock.json b/harness/ts-client/package-lock.json -similarity index 100% -rename from tests/interop/ts-client/package-lock.json -rename to harness/ts-client/package-lock.json -diff --git a/tests/interop/ts-client/package.json b/harness/ts-client/package.json -similarity index 100% -rename from tests/interop/ts-client/package.json -rename to harness/ts-client/package.json -diff --git a/tests/interop/tsconfig.json b/harness/tsconfig.json -similarity index 100% -rename from tests/interop/tsconfig.json -rename to harness/tsconfig.json -diff --git a/tests/interop/vitest.config.ts b/harness/vitest.config.ts -similarity index 100% -rename from tests/interop/vitest.config.ts -rename to harness/vitest.config.ts -diff --git a/lua/README.md b/lua/README.md -index 3ff9a89..d604f59 100644 ---- a/lua/README.md -+++ b/lua/README.md -@@ -214,7 +214,7 @@ same structural transaction verifier as pull mode, consumes the - signature through replay storage, and emits the same receipt shape. - - The direct Lua interop server at --[`tests/interop/lua-server/server.lua`](../tests/interop/lua-server/server.lua) -+[`harness/lua-server/server.lua`](../harness/lua-server/server.lua) - exercises this end-to-end through Surfpool in CI. - - ## Examples -@@ -316,11 +316,11 @@ replay rejection, transaction failures, missing metadata, timeouts. - ## Interop - - The Lua interop server at --[`tests/interop/lua-server/server.lua`](../tests/interop/lua-server/server.lua) -+[`harness/lua-server/server.lua`](../harness/lua-server/server.lua) - participates in the cross-language harness. Focused commands: - - ```bash --cd tests/interop -+cd harness - MPP_INTEROP_CLIENTS=typescript MPP_INTEROP_SERVERS=lua pnpm exec vitest run test/e2e.test.ts - MPP_INTEROP_CLIENTS=rust MPP_INTEROP_SERVERS=lua pnpm exec vitest run test/e2e.test.ts - ``` -@@ -328,7 +328,7 @@ MPP_INTEROP_CLIENTS=rust MPP_INTEROP_SERVERS=lua pnpm exec vitest run test - For a local DX run that mirrors the harness's Surfpool fixture: - - ```bash --cd tests/interop && node lua-server/dx-gate.mjs # one terminal -+cd harness && node lua-server/dx-gate.mjs # one terminal - cd lua && # second terminal - eval "$(luarocks --lua-version=5.1 --tree lua_modules path)" - luajit examples/simple-server.lua -diff --git a/lua/mpp/protocol/core/error_codes.lua b/lua/mpp/protocol/core/error_codes.lua -index 745f0a9..6f68e5b 100644 ---- a/lua/mpp/protocol/core/error_codes.lua -+++ b/lua/mpp/protocol/core/error_codes.lua -@@ -1,7 +1,7 @@ - --[[ - Canonical structured error codes for the Lua MPP server. - --Mirrors `python/src/solana_mpp/_errors.py` (M1 closure / L6 audit row). -+Mirrors `python/src/solana_mpp/_errors.py`. - Every server-side rejection raises through `raise(code, message)` - which throws an `error({code = code, message = message})` table the - HTTP boundary then translates into a JSON 402 body carrying `code`, -diff --git a/notes/codex-review-go-x402-r4.md b/notes/codex-review-go-x402-r4.md -new file mode 100644 -index 0000000..57ca5bb ---- /dev/null -+++ b/notes/codex-review-go-x402-r4.md -@@ -0,0 +1,36 @@ -+# Codex Round 4 — Go x402 exact port -+ -+Carried from `solana-foundation/x402-sdk` PR #18, tip `e3bf746`. -+ -+## Summary -+ -+- **Real P1 findings**: 0 -+- **Confidence**: 4/5 -+- **Coverage**: server 90.9%, client 91.9% (`go test ./... -cover -race`) -+- **Lint**: `gofmt -l` clean, `go vet ./...` clean -+ -+## Source provenance -+ -+The Go binaries under `go/x402/cmd/interop-{client,server}/` are a mechanical -+re-org of the upstream x402-sdk PR #18 final state. No upstream behavior -+changes; only the module path was rewritten when copying into mpp-sdk's -+single-module `go/` tree. -+ -+## Regression coverage carried over -+ -+- Fee-payer attack regression suite (5 attack shapes + positive control) -+- Multi-mint `extra.offered` support -+- Lighthouse instruction passthrough (spine-parity per -+ `notes/lighthouse-allowlist-tracking.md` in x402-sdk) -+- `extra.tokenProgram` mint allowlist enforcement -+- Token alias → base58 resolve at env boundary -+- Cross-envelope preference fallback -+- Idempotent resubmit / replay protection via Solana per-signature native -+ + scheme-namespaced cache -+ -+## Interop matrix evidence -+ -+90/90 pass on the seven-language sweep in x402-sdk PR #18. -+ -+MPP §19.6 cross-server scenarios: portability + idempotent-resubmit clean — -+the Go server rejects cross-server credentials with the canonical token. -diff --git a/php/.php-cs-fixer.dist.php b/php/.php-cs-fixer.dist.php -index fe48bd1..d054ba3 100644 ---- a/php/.php-cs-fixer.dist.php -+++ b/php/.php-cs-fixer.dist.php -@@ -18,7 +18,7 @@ return (new Config()) - __DIR__ . '/src', - __DIR__ . '/tests', - __DIR__ . '/examples', -- __DIR__ . '/../tests/interop/php-server', -+ __DIR__ . '/../harness/php-server', - ]) - ->exclude(['laravel']) - ->ignoreVCS(true) -diff --git a/php/README.md b/php/README.md -index b02276e..6abd3bd 100644 ---- a/php/README.md -+++ b/php/README.md -@@ -129,7 +129,7 @@ transactions on non-localnet networks, fee-payer co-sign (when configured), - broadcast via `sendTransaction`, poll `getSignatureStatuses` to - `confirmed`/`finalized`, and emit `payment-receipt` with the on-chain - signature. The pure-PHP interop server at --[`tests/interop/php-server/server.php`](../tests/interop/php-server/server.php) -+[`harness/php-server/server.php`](../harness/php-server/server.php) - exercises this end-to-end through Surfpool in CI for both TypeScript and Rust - clients. - -diff --git a/php/composer.json b/php/composer.json -index 548bbfe..7ae912c 100644 ---- a/php/composer.json -+++ b/php/composer.json -@@ -30,8 +30,8 @@ - }, - "scripts": { - "format:check": "php-cs-fixer fix --dry-run --diff --using-cache=no --sequential", -- "lint:syntax": "find src tests examples ../tests/interop/php-server \\( -path examples/laravel -prune \\) -o -name '*.php' -print0 | xargs -0 -n1 php -l", -- "lint:static": "phpstan analyse --level=max --debug --memory-limit=1G src tests examples/simple-server ../tests/interop/php-server", -+ "lint:syntax": "find src tests examples ../harness/php-server \\( -path examples/laravel -prune \\) -o -name '*.php' -print0 | xargs -0 -n1 php -l", -+ "lint:static": "phpstan analyse --level=max --debug --memory-limit=1G src tests examples/simple-server ../harness/php-server", - "lint": [ - "@lint:syntax", - "@format:check", -diff --git a/python/README.md b/python/README.md -index 5503f2f..589f79f 100644 ---- a/python/README.md -+++ b/python/README.md -@@ -188,7 +188,7 @@ signature in replay storage only after the on-chain shape is known to - be correct, and emits the same receipt shape. - - The direct Python interop server at --[`tests/interop/python-server/main.py`](../tests/interop/python-server/main.py) -+[`harness/python-server/main.py`](../harness/python-server/main.py) - exercises this end to end through Surfpool in CI for both TypeScript - and Rust clients. - -@@ -270,7 +270,7 @@ percent, `_types` 99 percent, `_headers` 89 percent. - ## Interop - - The Python server has a direct harness adapter at --[`tests/interop/python-server/main.py`](../tests/interop/python-server/main.py) -+[`harness/python-server/main.py`](../harness/python-server/main.py) - mirroring the Ruby and PHP adapters. It is server-side only in this - pass (no client adapter; the Python client ships as a library and is - exercised through unit tests in `python/tests/test_client_charge.py`). -@@ -278,7 +278,7 @@ exercised through unit tests in `python/tests/test_client_charge.py`). - Focused harness commands: - - ```bash --cd tests/interop -+cd harness - MPP_INTEROP_CLIENTS=typescript MPP_INTEROP_SERVERS=python pnpm test - MPP_INTEROP_CLIENTS=rust MPP_INTEROP_SERVERS=python pnpm test - ``` -diff --git a/python/pyproject.toml b/python/pyproject.toml -index a6c46b1..8b07a8a 100644 ---- a/python/pyproject.toml -+++ b/python/pyproject.toml -@@ -44,8 +44,8 @@ testpaths = ["tests"] - - [tool.coverage.run] - source = ["solana_mpp"] --# Line coverage is the M1 baseline gate (90%). Branch coverage is M2 work --# tracked in issue #108. -+# Line coverage gate is 90%. Branch coverage is follow-up work tracked in -+# issue #108. - branch = false - - [tool.coverage.report] -diff --git a/python/tests/test_interop_adapter.py b/python/tests/test_interop_adapter.py -index 9036e05..c7889ae 100644 ---- a/python/tests/test_interop_adapter.py -+++ b/python/tests/test_interop_adapter.py -@@ -1,5 +1,5 @@ - """Regression tests for the Python interop adapter at --``tests/interop/python-server/main.py``. -+``harness/python-server/main.py``. - - Spawns the adapter as a subprocess, reads the ``ready`` handshake JSON - from stdout, hits the protected resource without credentials, and -diff --git a/ruby/README.md b/ruby/README.md -index 46068c5..ed52a64 100644 ---- a/ruby/README.md -+++ b/ruby/README.md -@@ -185,7 +185,7 @@ transaction verifier as pull mode, consumes the signature through replay - storage, and emits the same receipt shape. - - The direct Ruby interop server at --[`tests/interop/ruby-server/server.rb`](../tests/interop/ruby-server/server.rb) -+[`harness/ruby-server/server.rb`](../harness/ruby-server/server.rb) - exercises this end-to-end through Surfpool in CI for both TypeScript and Rust - clients. - -@@ -261,12 +261,12 @@ and replay consumption. - ## Interop - - The Ruby server has a direct harness adapter at --`tests/interop/ruby-server/server.rb`. It is server-side only in this pass. -+`harness/ruby-server/server.rb`. It is server-side only in this pass. - - Focused harness commands: - - ```bash --cd tests/interop -+cd harness - MPP_INTEROP_CLIENTS=typescript MPP_INTEROP_SERVERS=ruby pnpm test - MPP_INTEROP_CLIENTS=rust MPP_INTEROP_SERVERS=ruby pnpm test - ``` -diff --git a/ruby/lib/mpp/error_codes.rb b/ruby/lib/mpp/error_codes.rb -index 691cb3a..c22a747 100644 ---- a/ruby/lib/mpp/error_codes.rb -+++ b/ruby/lib/mpp/error_codes.rb -@@ -89,7 +89,7 @@ module Mpp - # (verify_instruction_allowlist). The message originates as - # "Unexpected program instruction ..." in the verifier and must - # map to charge_request_mismatch to stay byte-identical with the -- # TS/Rust/Lua canonical classifiers (tests/interop/src/canonical-codes.ts -+ # TS/Rust/Lua canonical classifiers (harness/src/canonical-codes.ts - # and rust/src/bin/interop_server.rs::classify_canonical_code). - # Without this entry the rescue chain in verify_transaction_payload - # silently downgrades allowlist rejections to payment_invalid which -diff --git a/rust/README.md b/rust/README.md -index 35a75a3..3342d4f 100644 ---- a/rust/README.md -+++ b/rust/README.md -@@ -52,9 +52,9 @@ solana-pay-kit = { version = "0.1", default-features = false, features = ["mpp"] - ## Interop - - The TypeScript interop harness can run the Rust server and client adapters from --`../tests/interop`. -+`../harness`. - - ```bash --cd ../tests/interop -+cd ../harness - pnpm test - ``` -diff --git a/rust/crates/mpp/src/bin/interop_server.rs b/rust/crates/mpp/src/bin/interop_server.rs -index 34a5a74..387dd1e 100644 ---- a/rust/crates/mpp/src/bin/interop_server.rs -+++ b/rust/crates/mpp/src/bin/interop_server.rs -@@ -375,7 +375,7 @@ fn read_memory_signer( - } - - /// Classify a free-text error message into a canonical L6 structured --/// error code. Mirrors tests/interop/src/canonical-codes.ts and the -+/// error code. Mirrors harness/src/canonical-codes.ts and the - /// Python / Ruby SDK helpers. The G39 fault matrix asserts cross-SDK - /// agreement on this code. - fn classify_canonical_code(message: &str) -> &'static str { -diff --git a/skills/pay-sdk-implementation/SKILL.md b/skills/pay-sdk-implementation/SKILL.md -index ea09814..375d581 100644 ---- a/skills/pay-sdk-implementation/SKILL.md -+++ b/skills/pay-sdk-implementation/SKILL.md -@@ -67,9 +67,9 @@ the directory skeleton and CI from earlier ones. - Rust file paths cited in the leaf to disambiguate anything that's - under-specified. - 6. **Add the interop adapter.** Read `references/interop-harness.md`, -- create `tests/interop/-client/` (and a `bin/interop_server` if -+ create `harness/-client/` (and a `bin/interop_server` if - you're shipping a server), and register it in -- `tests/interop/src/implementations.ts`. Run the focused matrix -+ `harness/src/implementations.ts`. Run the focused matrix - (`MPP_INTEROP_CLIENTS= MPP_INTEROP_SERVERS=rust pnpm test` and - the inverse) before flipping `enabled: true`. - 7. **Write the README last.** Read `references/readme-template.md` and -diff --git a/skills/pay-sdk-implementation/references/intents/mpp-charge-pull.md b/skills/pay-sdk-implementation/references/intents/mpp-charge-pull.md -index 9eacca8..1dbda8c 100644 ---- a/skills/pay-sdk-implementation/references/intents/mpp-charge-pull.md -+++ b/skills/pay-sdk-implementation/references/intents/mpp-charge-pull.md -@@ -201,5 +201,5 @@ Integration test: - splits with ATA creation, fee-payer mode. - - Interop scenario: `charge-basic` and `charge-split-ata` in --`tests/interop/src/contracts.ts`. Both must pass against the Rust -+`harness/src/contracts.ts`. Both must pass against the Rust - server before the new SDK is enabled by default. -diff --git a/skills/pay-sdk-implementation/references/intents/mpp-charge-push.md b/skills/pay-sdk-implementation/references/intents/mpp-charge-push.md -index cf74684..046ce16 100644 ---- a/skills/pay-sdk-implementation/references/intents/mpp-charge-push.md -+++ b/skills/pay-sdk-implementation/references/intents/mpp-charge-push.md -@@ -128,7 +128,7 @@ Unit tests (mirror Rust's `verify_push`-adjacent tests): - Interop scenarios: scaffold a `charge-basic-push` variant. The current - default scenario (`charge-basic`) exercises pull because the TS server - is fee-payer; once the new SDK enables push for the client adapter, --add an explicit push-mode variant to `tests/interop/src/contracts.ts`. -+add an explicit push-mode variant to `harness/src/contracts.ts`. - - E2E: the Playwright tests in `html/tests` exercise the push flow via a - browser wallet. The new-language server must run this suite (see -diff --git a/skills/pay-sdk-implementation/references/intents/mpp-session.md b/skills/pay-sdk-implementation/references/intents/mpp-session.md -index 9d20211..01372cc 100644 ---- a/skills/pay-sdk-implementation/references/intents/mpp-session.md -+++ b/skills/pay-sdk-implementation/references/intents/mpp-session.md -@@ -206,6 +206,6 @@ Integration: - Interop: - - - The harness does not have session scenarios shipped today. Add one -- to `tests/interop/src/contracts.ts` (intent `session`) before -+ to `harness/src/contracts.ts` (intent `session`) before - enabling the cell. Pattern after `charge-basic`; reuse the same - Surfpool fixtures. -diff --git a/skills/pay-sdk-implementation/references/intents/x402-exact.md b/skills/pay-sdk-implementation/references/intents/x402-exact.md -index 12170c8..df4fe64 100644 ---- a/skills/pay-sdk-implementation/references/intents/x402-exact.md -+++ b/skills/pay-sdk-implementation/references/intents/x402-exact.md -@@ -25,7 +25,7 @@ Wait for the user to confirm: - 2. The MPP `charge` cells are already passing interop in the new SDK - (x402 reuses much of the same Solana primitives — splits, fee - payer, replay store — so MPP-first is the correct order). --3. The x402 scheme strings in `tests/interop/src/implementations.ts` -+3. The x402 scheme strings in `harness/src/implementations.ts` - have been agreed (likely `"x402:exact"` or similar; do not invent). - - If any are missing, leave the row at `—` in the README matrix and -diff --git a/skills/pay-sdk-implementation/references/interop-harness.md b/skills/pay-sdk-implementation/references/interop-harness.md -index ce68baa..e392dc9 100644 ---- a/skills/pay-sdk-implementation/references/interop-harness.md -+++ b/skills/pay-sdk-implementation/references/interop-harness.md -@@ -1,8 +1,8 @@ - # Interop harness adapter - - Cross-language compatibility is enforced by the TypeScript/Vitest harness --at `mpp-sdk/tests/interop`. Read its README first --(`tests/interop/README.md`) — that is the contract; this file summarizes -+at `mpp-sdk/harness`. Read its README first -+(`harness/README.md`) — that is the contract; this file summarizes - the bits that bite when adding a new language. - - ## What you must build -@@ -19,10 +19,10 @@ Reference adapters: - - - `rust/src/bin/interop_client.rs` (94 lines — copy it). - - `rust/src/bin/interop_server.rs` (317 lines — copy it). --- `tests/interop/rust-client/` — Cargo manifest wrapper used by the -+- `harness/rust-client/` — Cargo manifest wrapper used by the - harness command. - --## The contract (verbatim from `tests/interop/README.md`) -+## The contract (verbatim from `harness/README.md`) - - ### Server `ready` message - -@@ -33,7 +33,7 @@ Reference adapters: - Fields: - - - `type`: `"ready"` --- `implementation`: stable id (matches `tests/interop/src/implementations.ts`) -+- `implementation`: stable id (matches `harness/src/implementations.ts`) - - `role`: `"server"` - - `port`: local TCP port the protected resource is served on - -@@ -105,7 +105,7 @@ base58 — the harness does not encode them in base58. - - ## Registering the adapter - --Add an entry to `tests/interop/src/implementations.ts` — one each for -+Add an entry to `harness/src/implementations.ts` — one each for - client and server: - - ```ts -@@ -137,10 +137,10 @@ export const serverImplementations: ImplementationDefinition[] = [ - Default `enabled: false`. Only flip to `true` once the focused matrix - below passes locally. - --Then drop an adapter wrapper in `tests/interop/-client/` with -+Then drop an adapter wrapper in `harness/-client/` with - whatever scaffold the language needs (e.g. a `Cargo.toml` that - path-depends on `../../`, or a `package.json` with a single --`start` script). The harness command is relative to `tests/interop`. -+`start` script). The harness command is relative to `harness`. - - ## Focused matrix command - -diff --git a/skills/pay-sdk-implementation/references/readme-template.md b/skills/pay-sdk-implementation/references/readme-template.md -index 5ad0c64..a495f72 100644 ---- a/skills/pay-sdk-implementation/references/readme-template.md -+++ b/skills/pay-sdk-implementation/references/readme-template.md -@@ -147,7 +147,7 @@ same structural transaction verifier as pull mode, consumes the - signature through replay storage, and emits the same receipt shape. - - The direct `` interop server at --[`tests/interop/-server/server.`](../tests/interop/-server/server.) -+[`harness/-server/server.`](../harness/-server/server.) - exercises this end-to-end through Surfpool in CI. - - ## Examples -@@ -210,7 +210,7 @@ State the harness adapter path and any focused harness commands the - language ships in this pass: - - `​``bash --cd tests/interop -+cd harness - MPP_INTEROP_CLIENTS=typescript MPP_INTEROP_SERVERS= pnpm test - MPP_INTEROP_CLIENTS=rust MPP_INTEROP_SERVERS= pnpm test - `​`` -diff --git a/skills/pay-sdk-implementation/references/repo-layout.md b/skills/pay-sdk-implementation/references/repo-layout.md -index 52c3a86..70b82a2 100644 ---- a/skills/pay-sdk-implementation/references/repo-layout.md -+++ b/skills/pay-sdk-implementation/references/repo-layout.md -@@ -11,7 +11,7 @@ mpp-sdk/ - ├── python/ - ├── lua/ - ├── / ← what you are creating --├── tests/interop/ -+├── harness/ - │ └── -client/ ← interop adapter (see interop-harness.md) - ├── .github/workflows/ci.yml ← add a job (see ci-quality-coverage.md) - └── justfile ← add recipes (see "justfile recipes" below) -diff --git a/swift/Examples/README.md b/swift/Examples/README.md -index 3b39ee1..16ca3eb 100644 ---- a/swift/Examples/README.md -+++ b/swift/Examples/README.md -@@ -6,6 +6,6 @@ Sample clients exercising the `SolanaMpp` package. - a 402-protected endpoint. Mirrors `rust/examples/payment_link_server.rs` - on the client side. - --Planned (M2): `iOSDemo/` — SwiftUI app targeting the Solana Seeker dev -+Planned: `iOSDemo/` — SwiftUI app targeting the Solana Seeker dev - kit, end-to-end charge intent flow against `https://402.surfnet.dev`. - Tracked as a separate deliverable to keep the SDK PR focused. -diff --git a/swift/README.md b/swift/README.md -index 38ff3a2..8a47f4b 100644 ---- a/swift/README.md -+++ b/swift/README.md -@@ -39,7 +39,7 @@ swift/ - │ ├── Instructions.swift # System, SPL, ATA, compute budget, memo - │ └── Ata.swift # Associated Token Account PDA derivation - ├── Tests/SolanaMppTests/ # XCTest / swift-testing suite --└── Examples/ # Sample clients (M2: Solana Seeker demo app) -+└── Examples/ # Sample clients (planned: Solana Seeker demo app) - ``` - - Mirrors the Rust layout (`rust/src/{client,protocol}/`) so cross-language -@@ -47,10 +47,10 @@ contributors can navigate by feature, not file name. - - ## Scope - --Swift is **client-only** across every milestone in the MPP roadmap. --This package ships the charge client; an MPP server in Swift is not --in scope. The session and subscription intents add to this package --in M2 and M3. -+Swift is **client-only** in the MPP SDK. This package ships the charge -+client; an MPP server in Swift is not in scope. The session and -+subscription intents will be added to this package as the protocol -+surface for those intents stabilizes. - - ## Quick start, client - -@@ -100,21 +100,21 @@ Then add `SolanaMpp` to your target dependencies. - - ## Client compatibility matrix - --Swift is client-only across the MPP roadmap. -+Swift is client-only in the MPP SDK. - - | Intent | Status | - |---|:---:| --| `x402/exact` | planned (M2) | -+| `x402/exact` | planned | - | `x402/upto` | --- | - | `x402/batch-settlement` | --- | - | `mpp/charge/pull` | available | - | `mpp/charge/push` | planned | --| `mpp/session` | planned (M2) | --| `mpp/subscription` | planned (M3) | -+| `mpp/session` | planned | -+| `mpp/subscription` | planned | - - ## Server compatibility matrix - --Swift does not ship a server in any milestone. -+Swift does not ship a server. - - | Intent | Status | - |---|:---:| -@@ -185,8 +185,8 @@ them as the `swift-coverage` artifact. The harness covers: - ## Interop - - The Swift interop adapter lives at --[`tests/interop/swift-client`](../tests/interop/swift-client) and is --registered in `tests/interop/src/implementations.ts`. Default on after -+[`harness/swift-client`](../harness/swift-client) and is -+registered in `harness/src/implementations.ts`. Default on after - the focused TS-to-Swift matrix passes locally (this PR ships both the - default-off registration and the default-on flip atop the same diff, - per the roadmap's sequential-rebase rule on the -@@ -195,7 +195,7 @@ per the roadmap's sequential-rebase rule on the - Focused matrix commands: - - ```bash --cd tests/interop -+cd harness - MPP_INTEROP_CLIENTS=swift MPP_INTEROP_SERVERS=typescript pnpm exec vitest run - MPP_INTEROP_CLIENTS=swift MPP_INTEROP_SERVERS=rust pnpm exec vitest run - ``` -diff --git a/typescript/README.md b/typescript/README.md -index 681f885..9b60685 100644 ---- a/typescript/README.md -+++ b/typescript/README.md -@@ -51,10 +51,10 @@ pay curl http://localhost:4567/paid - - ## Interop - --The cross-language interop harness lives in `../tests/interop`. -+The cross-language interop harness lives in `../harness`. - - ```bash --cd ../tests/interop -+cd ../harness - pnpm install - pnpm test - ``` - -2026-05-25T22:44:21.922787Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when AuthRequired(AuthRequiredError { www_authenticate_header: "Bearer resource_metadata=\"https://mcp.figma.com/.well-known/oauth-protected-resource\",scope=\"mcp:connect\",authorization_uri=\"https://api.figma.com/.well-known/oauth-authorization-server\"" }) -2026-05-25T22:44:22.081040Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when AuthRequired(AuthRequiredError { www_authenticate_header: "Bearer resource_metadata=\"https://api.aidesigner.ai/.well-known/oauth-protected-resource\", scope=\"aidesigner:design aidesigner:credits profile:read\", error=\"invalid_token\", error_description=\"Missing bearer token.\"" }) -2026-05-25T22:44:22.518666Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when Deserialize(Error("data did not match any variant of untagged enum JsonRpcMessage", line: 0, column: 0)) -2026-05-25T22:44:23.530135Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when Deserialize(Error("data did not match any variant of untagged enum JsonRpcMessage", line: 0, column: 0)) -hook: SessionStart -hook: SessionStart Completed -hook: UserPromptSubmit -hook: UserPromptSubmit Completed -codex -I’ll check the actual touched Go server path rather than rely only on the pasted diff, then I’ll rate the specific L8/replay-key claim. -exec -/bin/zsh -lc "git status --short && rg -n \"replayKeyNamespace|putIfAbsent|settleExactPayment|sendTransaction\\(|awaitSignatureConfirmation|release|claim\" go/x402/cmd/interop-server -S" in /private/tmp/pay-kit-124-l8 - succeeded in 0ms: -?? notes/codex-review/ -go/x402/cmd/interop-server/main_test.go:328: if _, err := settleExactPayment(state, header); err == nil { -go/x402/cmd/interop-server/main_test.go:351: if _, err := settleExactPayment(state, header); err == nil { -go/x402/cmd/interop-server/main_test.go:367: if _, err := settleExactPayment(state, versionHeader); err == nil || err.Error() != "unsupported x402Version: 1" { -go/x402/cmd/interop-server/main_test.go:378: if _, err := settleExactPayment(state, driftHeader); err == nil || err.Error() != "accepted payment requirement does not match server challenge" { -go/x402/cmd/interop-server/main_test.go:432: settlement, err := settleExactPayment(state, header) -go/x402/cmd/interop-server/main_test.go:486: if settlement, err := settleExactPayment(state, header); err != nil || settlement != "unit-settlement" { -go/x402/cmd/interop-server/main_test.go:489: if _, err := settleExactPayment(state, header); err == nil || err.Error() != "duplicate_settlement" { -go/x402/cmd/interop-server/main_test.go:497: // rejected at putIfAbsent because the signature was already -go/x402/cmd/interop-server/main_test.go:510:// release-on-failure path; correctness follows from "never inserted in -go/x402/cmd/interop-server/main_test.go:549: if _, err := settleExactPayment(state, header); err == nil || err.Error() != "source token account does not exist" { -go/x402/cmd/interop-server/main_test.go:552: if _, err := settleExactPayment(state, header); err == nil || err.Error() != "source token account does not exist" { -go/x402/cmd/interop-server/main_test.go:562:// activity ordered after putIfAbsent. -go/x402/cmd/interop-server/main_test.go:605: signature, err := settleExactPayment(state, header) -go/x402/cmd/interop-server/main_test.go:624: if _, ok := settlementCache.entries[replayKeyNamespace+signature]; !ok { -go/x402/cmd/interop-server/main_test.go:625: t.Fatalf("expected replay key %q to be present after confirmation", replayKeyNamespace+signature) -go/x402/cmd/interop-server/main_test.go:667: if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "sendTransaction RPC error") { -go/x402/cmd/interop-server/main_test.go:717: if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "transaction failed on-chain") { -go/x402/cmd/interop-server/main_test.go:720: if _, ok := settlementCache.entries[replayKeyNamespace+"reverted-sig"]; ok { -go/x402/cmd/interop-server/main_test.go:726:// the L8 invariant that a putIfAbsent collision (signature already -go/x402/cmd/interop-server/main_test.go:741: settlementCache.entries[replayKeyNamespace+"pre-consumed-sig"] = time.Now() -go/x402/cmd/interop-server/main_test.go:749: if _, err := settleExactPayment(state, header); err == nil || err.Error() != "duplicate_settlement" { -go/x402/cmd/interop-server/main_test.go:758:// effect is single; the putIfAbsent collision in the replay store -go/x402/cmd/interop-server/main_test.go:785: signatures[idx], results[idx] = settleExactPayment(state, header) -go/x402/cmd/interop-server/main_test.go:861: err := awaitSignatureConfirmation(state, "sig") -go/x402/cmd/interop-server/main_test.go:882: if err := awaitSignatureConfirmation(state, "sig"); err == nil || !strings.Contains(err.Error(), "getSignatureStatuses transport") { -go/x402/cmd/interop-server/main_test.go:901: if err := awaitSignatureConfirmation(state, "sig"); err == nil || !strings.Contains(err.Error(), "getSignatureStatuses HTTP 500") { -go/x402/cmd/interop-server/main_test.go:1488: if !cache.putIfAbsent("new") { -go/x402/cmd/interop-server/main_test.go:1497: if !cache.putIfAbsent("expired") { -go/x402/cmd/interop-server/main_test.go:1500: if cache.putIfAbsent("fresh") { -go/x402/cmd/interop-server/main_test.go:1652: got, err := sendTransaction(state, tx) -go/x402/cmd/interop-server/main_test.go:1679: if _, err := sendTransaction(state, tx); err == nil { -go/x402/cmd/interop-server/main_test.go:2109: if _, err := settleExactPayment(state, header); err == nil || -go/x402/cmd/interop-server/main_test.go:2143: if _, err := settleExactPayment(state, header); err == nil || -go/x402/cmd/interop-server/main_test.go:2503: settlement, err := settleExactPayment(state, header) -go/x402/cmd/interop-server/main.go:36: // replayKeyNamespace MUST match the scheme-namespaced canonical key -go/x402/cmd/interop-server/main.go:40: replayKeyNamespace = "x402-svm-exact:consumed:" -go/x402/cmd/interop-server/main.go:178:// putIfAbsent reserves `key` in the replay cache. Returns true if the key -go/x402/cmd/interop-server/main.go:183:// confirmation → `putIfAbsent(signature)`. There is no release-on-failure -go/x402/cmd/interop-server/main.go:187:// window. The release path of the prior claim-first design has been -go/x402/cmd/interop-server/main.go:188:// removed to close the partial-failure race where a release after a timed- -go/x402/cmd/interop-server/main.go:190:func (cache *duplicateSettlementCache) putIfAbsent(key string) bool { -go/x402/cmd/interop-server/main.go:498:func settleExactPayment(state serverState, headerValue string) (string, error) { -go/x402/cmd/interop-server/main.go:551: // await_pull_confirmation, consume_signature). No claim-first, no -go/x402/cmd/interop-server/main.go:552: // release-on-failure. See x402 PR-readiness reference §"L8 -go/x402/cmd/interop-server/main.go:554: signature, err := sendTransaction(state, transaction) -go/x402/cmd/interop-server/main.go:558: if err := awaitSignatureConfirmation(state, signature); err != nil { -go/x402/cmd/interop-server/main.go:561: if !settlementCache.putIfAbsent(replayKeyNamespace + signature) { -go/x402/cmd/interop-server/main.go:961:func sendTransaction(state serverState, transaction *solana.Transaction) (string, error) { -go/x402/cmd/interop-server/main.go:1013:// awaitSignatureConfirmation polls `getSignatureStatuses` until the -go/x402/cmd/interop-server/main.go:1021:func awaitSignatureConfirmation(state serverState, signature string) error { -go/x402/cmd/interop-server/main.go:1111: settlement, err := settleExactPayment(state, paymentSignature) - -codex -The repo has an unrelated untracked `notes/codex-review/` directory. I’m ignoring it and focusing only on the Go x402 server implementation and its tests. -exec -/bin/zsh -lc "nl -ba go/x402/cmd/interop-server/main.go | sed -n '30,65p;170,205p;520,570p;1010,1068p'" in /private/tmp/pay-kit-124-l8 -exec -/bin/zsh -lc "nl -ba go/x402/cmd/interop-server/main_test.go | sed -n '560,630p;640,725p;726,755p'" in /private/tmp/pay-kit-124-l8 - succeeded in 0ms: - 560 // insert. The replay store insert is observable through a duplicate retry - 561 // returning duplicate_settlement on the SAME signature, without any RPC - 562 // activity ordered after putIfAbsent. - 563 func TestSettleExactPaymentL8OrderingObserved(t *testing.T) { - 564 settlementCache = newDuplicateSettlementCache() - 565 defer func() { settlementCache = newDuplicateSettlementCache() }() - 566 client, err := solana.NewRandomPrivateKey() - 567 if err != nil { - 568 t.Fatal(err) - 569 } - 570 state := testServerState(t) - 571 state.memo = "l8-ordering" - 572 var rpcCalls []string - 573 state.httpClient = &http.Client{ - 574 Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { - 575 rawBody, err := io.ReadAll(request.Body) - 576 if err != nil { - 577 t.Fatal(err) - 578 } - 579 body := string(rawBody) - 580 responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` - 581 switch { - 582 case strings.Contains(body, `"method":"sendTransaction"`): - 583 rpcCalls = append(rpcCalls, "sendTransaction") - 584 responseBody = `{"jsonrpc":"2.0","id":1,"result":"l8-sig"}` - 585 case strings.Contains(body, `"method":"getSignatureStatuses"`): - 586 rpcCalls = append(rpcCalls, "getSignatureStatuses") - 587 responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` - 588 case strings.Contains(body, `"method":"getAccountInfo"`): - 589 rpcCalls = append(rpcCalls, "getAccountInfo") - 590 } - 591 return &http.Response{ - 592 StatusCode: http.StatusOK, - 593 Header: http.Header{"content-type": []string{"application/json"}}, - 594 Body: io.NopCloser(strings.NewReader(responseBody)), - 595 }, nil - 596 }), - 597 } - 598 requirement := exactRequirement(state) - 599 header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ - 600 X402Version: 2, - 601 Accepted: requirement, - 602 Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, - 603 }) - 604 - 605 signature, err := settleExactPayment(state, header) - 606 if err != nil { - 607 t.Fatalf("expected first settlement to succeed, got %v", err) - 608 } - 609 if signature != "l8-sig" { - 610 t.Fatalf("signature = %q", signature) - 611 } - 612 // Drop pre-broadcast getAccountInfo calls; the load-bearing assertion - 613 // is that broadcast precedes confirmation polling, which precedes the - 614 // replay-store insert (proven by the subsequent duplicate_settlement). - 615 var phaseOrder []string - 616 for _, call := range rpcCalls { - 617 if call == "sendTransaction" || call == "getSignatureStatuses" { - 618 phaseOrder = append(phaseOrder, call) - 619 } - 620 } - 621 if len(phaseOrder) < 2 || phaseOrder[0] != "sendTransaction" || phaseOrder[1] != "getSignatureStatuses" { - 622 t.Fatalf("expected sendTransaction before getSignatureStatuses, got %v", phaseOrder) - 623 } - 624 if _, ok := settlementCache.entries[replayKeyNamespace+signature]; !ok { - 625 t.Fatalf("expected replay key %q to be present after confirmation", replayKeyNamespace+signature) - 626 } - 627 } - 628 - 629 // TestSettleExactPaymentDoesNotConsumeReplayKeyOnBroadcastFailure covers - 630 // the L8 invariant that an RPC failure during broadcast (before - 640 state := testServerState(t) - 641 state.memo = "l8-broadcast-fail" - 642 state.httpClient = &http.Client{ - 643 Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { - 644 rawBody, err := io.ReadAll(request.Body) - 645 if err != nil { - 646 t.Fatal(err) - 647 } - 648 body := string(rawBody) - 649 responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` - 650 if strings.Contains(body, `"method":"sendTransaction"`) { - 651 responseBody = `{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"blockhash not found"}}` - 652 } - 653 return &http.Response{ - 654 StatusCode: http.StatusOK, - 655 Header: http.Header{"content-type": []string{"application/json"}}, - 656 Body: io.NopCloser(strings.NewReader(responseBody)), - 657 }, nil - 658 }), - 659 } - 660 requirement := exactRequirement(state) - 661 header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ - 662 X402Version: 2, - 663 Accepted: requirement, - 664 Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, - 665 }) - 666 - 667 if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "sendTransaction RPC error") { - 668 t.Fatalf("expected broadcast RPC error, got %v", err) - 669 } - 670 if len(settlementCache.entries) != 0 { - 671 t.Fatalf("expected empty replay cache after broadcast failure, got %d entries", len(settlementCache.entries)) - 672 } - 673 } - 674 - 675 // TestSettleExactPaymentDoesNotConsumeReplayKeyOnConfirmationFailure - 676 // covers the L8 invariant that an on-chain failure surfaced via - 677 // getSignatureStatuses (e.g. tx landed but reverted) MUST NOT insert - 678 // the replay key — a future re-broadcast under a fresh blockhash is the - 679 // caller's option, not a duplicate. - 680 func TestSettleExactPaymentDoesNotConsumeReplayKeyOnConfirmationFailure(t *testing.T) { - 681 settlementCache = newDuplicateSettlementCache() - 682 defer func() { settlementCache = newDuplicateSettlementCache() }() - 683 client, err := solana.NewRandomPrivateKey() - 684 if err != nil { - 685 t.Fatal(err) - 686 } - 687 state := testServerState(t) - 688 state.memo = "l8-confirm-fail" - 689 state.httpClient = &http.Client{ - 690 Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { - 691 rawBody, err := io.ReadAll(request.Body) - 692 if err != nil { - 693 t.Fatal(err) - 694 } - 695 body := string(rawBody) - 696 responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` - 697 switch { - 698 case strings.Contains(body, `"method":"sendTransaction"`): - 699 responseBody = `{"jsonrpc":"2.0","id":1,"result":"reverted-sig"}` - 700 case strings.Contains(body, `"method":"getSignatureStatuses"`): - 701 responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":{"InstructionError":[0,"Custom"]},"confirmationStatus":"confirmed"}]}}` - 702 } - 703 return &http.Response{ - 704 StatusCode: http.StatusOK, - 705 Header: http.Header{"content-type": []string{"application/json"}}, - 706 Body: io.NopCloser(strings.NewReader(responseBody)), - 707 }, nil - 708 }), - 709 } - 710 requirement := exactRequirement(state) - 711 header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ - 712 X402Version: 2, - 713 Accepted: requirement, - 714 Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, - 715 }) - 716 - 717 if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "transaction failed on-chain") { - 718 t.Fatalf("expected on-chain failure, got %v", err) - 719 } - 720 if _, ok := settlementCache.entries[replayKeyNamespace+"reverted-sig"]; ok { - 721 t.Fatalf("expected replay key NOT to be consumed when confirmation surfaces on-chain failure") - 722 } - 723 } - 724 - 725 // TestSettleExactPaymentReturnsCanonicalErrorForConsumedSignature covers - 726 // the L8 invariant that a putIfAbsent collision (signature already - 727 // consumed) surfaces the canonical duplicate_settlement error and does - 728 // not echo a fresh PAYMENT-RESPONSE. - 729 func TestSettleExactPaymentReturnsCanonicalErrorForConsumedSignature(t *testing.T) { - 730 settlementCache = newDuplicateSettlementCache() - 731 defer func() { settlementCache = newDuplicateSettlementCache() }() - 732 client, err := solana.NewRandomPrivateKey() - 733 if err != nil { - 734 t.Fatal(err) - 735 } - 736 state := testServerState(t) - 737 state.memo = "l8-pre-consumed" - 738 state.httpClient = successfulSettlementClient(t, "pre-consumed-sig") - 739 // Simulate a prior successful settlement having already inserted - 740 // the canonical replay key for this signature. - 741 settlementCache.entries[replayKeyNamespace+"pre-consumed-sig"] = time.Now() - 742 - 743 requirement := exactRequirement(state) - 744 header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ - 745 X402Version: 2, - 746 Accepted: requirement, - 747 Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, - 748 }) - 749 if _, err := settleExactPayment(state, header); err == nil || err.Error() != "duplicate_settlement" { - 750 t.Fatalf("expected duplicate_settlement on already-consumed signature, got %v", err) - 751 } - 752 } - 753 - 754 // TestSettleExactPaymentConcurrentDuplicatesCollapse asserts that two - 755 // concurrent settlements producing the same signature collapse to a - - succeeded in 0ms: - 30 token2022Program = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" - 31 lighthouseProgram = "L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95" - 32 defaultMaxTimeout = 60 - 33 duplicateCacheTTL = 120 * time.Second - 34 maxComputeUnitPrice = 5_000_000 - 35 maxMemoBytes = 256 - 36 // replayKeyNamespace MUST match the scheme-namespaced canonical key - 37 // documented in the x402 PR-readiness reference and mirrors the MPP - 38 // `solana-charge:consumed:` shape but scoped to x402 svm-exact so - 39 // settled signatures across schemes (and against MPP) do not collide. - 40 replayKeyNamespace = "x402-svm-exact:consumed:" - 41 ) - 42 - 43 // confirmationPollAttempts × confirmationPollInterval bounds the - 44 // post-broadcast confirmation wait. Defaults mirror the MPP - 45 // `server/charge.rs:769` 30×200ms = ~6s window. These are vars (not - 46 // consts) so tests can shrink the poll budget to keep timeout coverage - 47 // fast. - 48 var ( - 49 confirmationPollAttempts = 60 - 50 confirmationPollInterval = 200 * time.Millisecond - 51 ) - 52 - 53 var ( - 54 computeBudgetProgramID = solana.MustPublicKeyFromBase58("ComputeBudget111111111111111111111111111111") - 55 memoProgramID = solana.MustPublicKeyFromBase58("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr") - 56 ) - 57 - 58 // Lighthouse instructions are passed through by program-ID match alone, matching - 59 // the canonical spines: - 60 // - rust/src/protocol/schemes/exact/verify.rs:266 — `if program == LIGHTHOUSE_PROGRAM || program == MEMO_PROGRAM { continue; }` - 61 // - typescript/packages/x402/src/facilitator/exact/scheme.ts:300 — same shape - 62 // No discriminator or account-count allowlist is enforced here: inventing one - 63 // in a single language port would diverge from real-world Phantom/Solflare - 64 // transactions that the Rust + TypeScript adapters accept. Tightening this is - 65 // a protocol-wide decision that must land in the Rust spine first; tracked at - 170 - 171 func newDuplicateSettlementCache() *duplicateSettlementCache { - 172 return &duplicateSettlementCache{ - 173 entries: map[string]time.Time{}, - 174 now: time.Now, - 175 } - 176 } - 177 - 178 // putIfAbsent reserves `key` in the replay cache. Returns true if the key - 179 // was newly inserted, false if a prior settlement already consumed it. - 180 // - 181 // L8 ordering (see x402 PR-readiness reference and MPP - 182 // `server/charge.rs:535-556`): callers MUST broadcast → await on-chain - 183 // confirmation → `putIfAbsent(signature)`. There is no release-on-failure - 184 // path: a crash or RPC failure before this call simply never inserts a - 185 // key, and Solana's per-signature replay protection prevents a re-broadcast - 186 // of the same signed transaction from settling twice within its blockhash - 187 // window. The release path of the prior claim-first design has been - 188 // removed to close the partial-failure race where a release after a timed- - 189 // out confirmation would permit a double-pay if the original later landed. - 190 func (cache *duplicateSettlementCache) putIfAbsent(key string) bool { - 191 cache.mu.Lock() - 192 defer cache.mu.Unlock() - 193 - 194 now := cache.now() - 195 for cached, seenAt := range cache.entries { - 196 if now.Sub(seenAt) > duplicateCacheTTL { - 197 delete(cache.entries, cached) - 198 } - 199 } - 200 if _, ok := cache.entries[key]; ok { - 201 return false - 202 } - 203 cache.entries[key] = now - 204 return true - 205 } - 520 if err := verifyExactTransaction(transaction, requirement); err != nil { - 521 return "", err - 522 } - 523 // Bind the transaction's message fee-payer (account key 0) to the - 524 // server's configured fee-payer. Without this guard a malicious client - 525 // could nominate a different message payer and rely on the facilitator - 526 // being in the signer set to drain SOL via co-signing. - 527 if len(transaction.Message.AccountKeys) == 0 { - 528 return "", fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_missing") - 529 } - 530 if !transaction.Message.AccountKeys[0].Equals(state.feePayer.PublicKey()) { - 531 return "", fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_mismatch") - 532 } - 533 if err := verifyTokenAccountsExist(state, transaction, requirement); err != nil { - 534 return "", err - 535 } - 536 - 537 if _, err := transaction.PartialSign(func(key solana.PublicKey) *solana.PrivateKey { - 538 if key.Equals(state.feePayer.PublicKey()) { - 539 return &state.feePayer - 540 } - 541 return nil - 542 }); err != nil { - 543 return "", err - 544 } - 545 if err := transaction.VerifySignatures(); err != nil { - 546 return "", err - 547 } - 548 - 549 // L8 ordering: broadcast → confirm → put_if_absent(signature). - 550 // Mirrors MPP `server/charge.rs:535-556` (broadcast_pull, - 551 // await_pull_confirmation, consume_signature). No claim-first, no - 552 // release-on-failure. See x402 PR-readiness reference §"L8 - 553 // broadcast-then-confirm-then-mark ordering (SVM-specific)". - 554 signature, err := sendTransaction(state, transaction) - 555 if err != nil { - 556 return "", err - 557 } - 558 if err := awaitSignatureConfirmation(state, signature); err != nil { - 559 return "", err - 560 } - 561 if !settlementCache.putIfAbsent(replayKeyNamespace + signature) { - 562 // Canonical `signature_consumed` surface (see MPP - 563 // `VerificationError::signature_consumed`, - 564 // rust/src/server/charge.rs:589-593). The interop server's - 565 // existing error vocabulary maps this to "duplicate_settlement"; - 566 // keep that wire token so existing clients are not broken, but - 567 // the semantic is now "this confirmed signature was already - 568 // consumed by an earlier successful settlement", not "we saw - 569 // this encoded transaction blob before broadcast". - 570 return "", fmt.Errorf("duplicate_settlement") - 1010 return payload.Result, nil - 1011 } - 1012 - 1013 // awaitSignatureConfirmation polls `getSignatureStatuses` until the - 1014 // signature reaches `confirmed` or `finalized` commitment. It returns an - 1015 // error on explicit RPC error, an on-chain transaction failure - 1016 // (status.err non-null), or when the poll budget elapses (the bounded - 1017 // stand-in for blockhash-window expiry; a signature that has not been - 1018 // observed within this window is treated as not landed so the caller - 1019 // MUST NOT mark the signature as consumed). Mirrors the canonical loop - 1020 // in MPP `server/charge.rs:761-784`. - 1021 func awaitSignatureConfirmation(state serverState, signature string) error { - 1022 requestBody, err := json.Marshal(map[string]any{ - 1023 "jsonrpc": "2.0", - 1024 "id": 1, - 1025 "method": "getSignatureStatuses", - 1026 "params": []any{ - 1027 []string{signature}, - 1028 map[string]any{"searchTransactionHistory": false}, - 1029 }, - 1030 }) - 1031 if err != nil { - 1032 return err - 1033 } - 1034 for attempt := 0; attempt < confirmationPollAttempts; attempt++ { - 1035 response, err := state.httpClient.Post(state.rpcURL, "application/json", bytes.NewReader(requestBody)) - 1036 if err != nil { - 1037 return fmt.Errorf("getSignatureStatuses transport: %w", err) - 1038 } - 1039 rawBody, readErr := io.ReadAll(response.Body) - 1040 _ = response.Body.Close() - 1041 if readErr != nil { - 1042 return readErr - 1043 } - 1044 if response.StatusCode < 200 || response.StatusCode >= 300 { - 1045 return fmt.Errorf("getSignatureStatuses HTTP %d: %s", response.StatusCode, string(rawBody)) - 1046 } - 1047 var payload struct { - 1048 Result *struct { - 1049 Value []*struct { - 1050 Confirmations *uint64 `json:"confirmations"` - 1051 ConfirmationStatus string `json:"confirmationStatus"` - 1052 Err any `json:"err"` - 1053 } `json:"value"` - 1054 } `json:"result"` - 1055 Error any `json:"error"` - 1056 } - 1057 if err := json.Unmarshal(rawBody, &payload); err != nil { - 1058 return err - 1059 } - 1060 if payload.Error != nil { - 1061 return fmt.Errorf("getSignatureStatuses RPC error: %v", payload.Error) - 1062 } - 1063 if payload.Result != nil && len(payload.Result.Value) > 0 && payload.Result.Value[0] != nil { - 1064 status := payload.Result.Value[0] - 1065 if status.Err != nil { - 1066 return fmt.Errorf("transaction failed on-chain: %v", status.Err) - 1067 } - 1068 if status.ConfirmationStatus == "confirmed" || status.ConfirmationStatus == "finalized" { - -codex -The implementation itself has the requested sequence. I’m running the focused Go server tests now because the claim includes race-sensitive replay semantics, and there are targeted tests for the failure paths. -exec -/bin/zsh -lc 'go test ./x402/cmd/interop-server' in /private/tmp/pay-kit-124-l8/go - succeeded in 459ms: -ok github.com/solana-foundation/pay-kit/go/x402/cmd/interop-server (cached) - -codex -Verified: **5/5**. - -The Go x402 server implements L8 as requested: - -- Replay key namespace is exactly `x402-svm-exact:consumed:` in [main.go](/private/tmp/pay-kit-124-l8/go/x402/cmd/interop-server/main.go:40). -- Settlement order is `sendTransaction` → `awaitSignatureConfirmation` → `settlementCache.putIfAbsent(replayKeyNamespace + signature)` in [main.go](/private/tmp/pay-kit-124-l8/go/x402/cmd/interop-server/main.go:554). -- No claim-first path is present before broadcast/confirmation. -- No release path is present; failures before `putIfAbsent` just return without consuming a key. -- Tests cover ordering, broadcast failure, confirmation failure, pre-consumed key, and concurrent duplicate collapse in [main_test.go](/private/tmp/pay-kit-124-l8/go/x402/cmd/interop-server/main_test.go:563). - -Verification run: - -`go test ./x402/cmd/interop-server` from `go/` passed, cached. - -Residual note: duplicate retries intentionally broadcast again before the replay-store collision, relying on Solana per-signature idempotence, then rejecting post-confirm with `duplicate_settlement`. -hook: Stop -hook: Stop Completed -tokens used -121,937 -Verified: **5/5**. - -The Go x402 server implements L8 as requested: - -- Replay key namespace is exactly `x402-svm-exact:consumed:` in [main.go](/private/tmp/pay-kit-124-l8/go/x402/cmd/interop-server/main.go:40). -- Settlement order is `sendTransaction` → `awaitSignatureConfirmation` → `settlementCache.putIfAbsent(replayKeyNamespace + signature)` in [main.go](/private/tmp/pay-kit-124-l8/go/x402/cmd/interop-server/main.go:554). -- No claim-first path is present before broadcast/confirmation. -- No release path is present; failures before `putIfAbsent` just return without consuming a key. -- Tests cover ordering, broadcast failure, confirmation failure, pre-consumed key, and concurrent duplicate collapse in [main_test.go](/private/tmp/pay-kit-124-l8/go/x402/cmd/interop-server/main_test.go:563). - -Verification run: - -`go test ./x402/cmd/interop-server` from `go/` passed, cached. - -Residual note: duplicate retries intentionally broadcast again before the replay-store collision, relying on Solana per-signature idempotence, then rejecting post-confirm with `duplicate_settlement`.