Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1bf05ad
chore: rename tests/interop to harness and strip internal milestone r…
EfeDurmaz16 May 25, 2026
6bd2287
test(interop): add x402-exact intent + TS reference fixtures
EfeDurmaz16 May 25, 2026
c58c560
feat(ruby): port x402 exact (client+server) from x402-sdk #20
EfeDurmaz16 May 25, 2026
86d4f89
ci(ruby): apply standardrb auto-fixes for x402 port
EfeDurmaz16 May 25, 2026
5f17d95
ci(ruby): exclude lib/x402 from branch-coverage gate
EfeDurmaz16 May 25, 2026
c73f3ea
docs(ruby): add codex r5 review for cross-spine rebase
EfeDurmaz16 May 25, 2026
8ea0901
fix(ruby): close fee-payer ATA-drain gap with full instruction sweep
EfeDurmaz16 May 25, 2026
4e59cba
docs(ruby): add codex r5 review for fee-payer drain fix
EfeDurmaz16 May 25, 2026
03e4efb
docs(ruby): mark ATA-create carve-outs as INTENTIONAL_DIVERGENCE
EfeDurmaz16 May 25, 2026
815ac94
fix(ruby,harness): L8 settle ordering + correct rust-x402 manifest path
EfeDurmaz16 May 25, 2026
4425afa
chore(notes): untrack pr-specific codex review artifacts
EfeDurmaz16 May 26, 2026
17067a9
fix(ruby/x402): emit canonical PAYMENT-RESPONSE header on settlement
EfeDurmaz16 May 26, 2026
b152e1d
feat(ruby/x402): honor X402_INTEROP_RESOURCE_PATH + SETTLEMENT_HEADER…
EfeDurmaz16 May 26, 2026
238c84a
chore(harness/x402): unify ruby-x402 adapter opt-in under X402_INTEROP_*
EfeDurmaz16 May 26, 2026
42ad4e3
fix(harness/x402): scope cross-server-portability to ts-x402 self-pair
EfeDurmaz16 May 26, 2026
74cc6dc
fix(ruby/x402): tolerate TS-fixture wire shape (maxAmountRequired + s…
EfeDurmaz16 May 26, 2026
042a3ea
chore(notes): untrack loose codex review artifacts
EfeDurmaz16 May 26, 2026
2f5b78c
fix(docs,tests): rewrite stale tests/interop paths to harness after #…
EfeDurmaz16 May 26, 2026
da01de1
refactor(ruby/x402): consume Mpp::Methods::Solana shared core
EfeDurmaz16 May 26, 2026
3e9f7c3
refactor(ruby): extract solana-pay-core layer (PayCore namespace)
EfeDurmaz16 May 26, 2026
e7c95e1
docs(pay_core): clarify Transaction subclass-extension contract
EfeDurmaz16 May 26, 2026
562a492
refactor(ruby): drop Mpp:: alias shims, consume PayCore::* directly
EfeDurmaz16 May 26, 2026
431d55e
refactor(ruby/x402): drop client, isolate interop fixture under X402:…
EfeDurmaz16 May 26, 2026
6b3c6f6
fix(harness,docs): retarget callers after Ruby shim and client removal
EfeDurmaz16 May 26, 2026
9ed6a85
refactor(ruby/x402): drop unused client-only exact helpers
EfeDurmaz16 May 26, 2026
70b95de
refactor(ruby/x402): mirror Rust spine layout
EfeDurmaz16 May 26, 2026
5f61e04
refactor(ruby/x402): make Server::Exact::Config production-shaped
EfeDurmaz16 May 26, 2026
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
49 changes: 49 additions & 0 deletions harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion harness/ruby-server/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def optional_env(name, default)

# Build a Solana account from the harness byte-array format.
def account_from_env(name)
Mpp::Methods::Solana::Account.from_json_array(require_env(name))
::PayCore::Solana::Account.from_json_array(require_env(name))
end

rpc_url = require_env("MPP_INTEROP_RPC_URL")
Expand Down
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