Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ harness/go-client/go-client
mpp-sdk-self-learning/
.build/
go/coverage.out
notes/codex-review/
notes/codex-review-*.md
21 changes: 15 additions & 6 deletions harness/src/contracts.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand All @@ -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(", ")}.`,
);
}

Expand Down
225 changes: 225 additions & 0 deletions harness/src/fixtures/typescript/exact-client.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, string>> = {
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<unknown> {
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),
}),
);
});
Loading
Loading