diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbce008a7..dc55cb63f 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 e11a98331..9bdb53e64 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 d365cce79..7fd7f5c0b 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 f9b5083c3..a0c45b389 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 c2e3a75a8..6fd110c17 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 64b7fc73e..a170fb4f7 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 7dcfe2f7b..52236a0ce 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 b2d971e69..561681370 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 744bbd19a..72c7daf0c 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 e618006af..4f8243bb7 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/tests/interop/README.md b/harness/README.md similarity index 54% rename from tests/interop/README.md rename to harness/README.md index b1018c649..1525e56e0 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,125 @@ 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 +``` + +#### x402-exact test tiers + +The x402-exact intent splits its coverage across three tiers: + +1. **Wire compat (`test/x402-exact.compat.test.ts`)** — runs in the + default `pnpm test` invocation. No live RPC, no cargo build, no + funded keypair. Drives each registered x402-exact adapter (gated by + `COMPAT_INCLUDE_IDS`) against the canonical fixtures in + `harness/fixtures/x402-exact/`: + - **canonical-challenge.json** — the 402 envelope every client must + parse. + - **canonical-payment-signature.json** — the TS-wire credential every + server must parse (accept or reject with a known token). Wire-only + adapters may emit `payment_invalid` as fallback. + - **canonical-payment-signature-rust.json** — Rust-spine canonical + `PaymentSignatureEnvelope` with a `PaymentProof::Transaction` + payload. Asserts the Rust serde envelope parser accepts a + well-formed envelope and the verifier rejects with a specific + `invalid_exact_svm_payload_*` token (used by the live matrix + against the Rust spine server). + - **canonical-reject-tokens.json** — the union of taxonomy-aligned + reject tokens (high-level + `invalid_exact_svm_payload_*` family, + mirrored from `rust/crates/x402/src/protocol/schemes/exact/verify.rs`). + - **attack-scenarios.json** — tampered credential overrides; each + scenario enumerates the reject tokens a spec-compliant server may + emit. Wire-only adapters may emit `payment_invalid` as fallback. + +2. **Self-pair + spine cross-pair (`test/x402-exact.e2e.test.ts`)** — + the canonical cross-language matrix, env-gated behind + `X402_INTEROP_MATRIX=1`. Enumerates every same-language self-pair + plus every adapter ↔ Rust spine cross-pair. + +3. **Live full matrix (`test/x402-exact.live.matrix.test.ts`)** — + superset of tier 2: every `allowedPair` from the policy in + `implementations.ts`. Also env-gated. Designed to widen + automatically as new x402-exact adapters register; no test edit + required to pick them up. + +To extend with a new language adapter: +- Register `{id, label, role, command, intents: ["x402-exact"], enabled}` + in `harness/src/implementations.ts`. +- Add the adapter id to `COMPAT_INCLUDE_IDS` in + `test/x402-exact.compat.test.ts` once the adapter has a fast startup + cost (no cargo build per test); otherwise leave it out and rely on + the live matrix. +- The live matrix picks up the adapter automatically via the + `allowedPair` policy. + +Why the Rust spine is excluded from the compat suite: the spine +deserializes `payload` as `PaymentProof::Transaction | Signature` +(rust/crates/x402/src/protocol/schemes/exact/types.rs), so the TS-wire +canonical credential — which carries `payload.challengeId/resource` — +is rejected at the proof layer with a generic `payment_invalid` before +the per-scenario assertions can run. Rust spine coverage is provided +by the live matrix (tier 3), which builds real signed transactions +and exercises the full structural verifier. + +Optional opt-in flag: + +- `X402_COMPAT_STUB_ACCEPT=` — declares that the listed + full-verifier adapters intentionally accept the TS-wire stub + credential. Without this, a full-verifier adapter that returns 200 + on the canonical credential is flagged as a verifier bypass. +- `X402_COMPAT_REPLAY_TRUST=` — declares that the listed + adapters' verifier accepts the canonical stub credential and is + therefore eligible for the replay assertion. Without this, only + adapters in `WIRE_ONLY_ADAPTER_IDS` run the replay test (other + adapters cover replay via the live matrix with a real signed + transaction). + +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 +272,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/harness/fixtures/x402-exact/attack-scenarios.json b/harness/fixtures/x402-exact/attack-scenarios.json new file mode 100644 index 000000000..47941c5df --- /dev/null +++ b/harness/fixtures/x402-exact/attack-scenarios.json @@ -0,0 +1,119 @@ +{ + "_description": "Attack scenarios for the x402-exact verifier surface. Each scenario provides a malformed PAYMENT-SIGNATURE credential the server MUST reject with one of `expectedRejectTokens`. Wire-only adapters (no SVM transaction decoder, listed in WIRE_ONLY_ADAPTER_IDS in the test runner) get an automatic `payment_invalid` fallback — full verifiers do NOT and must emit a specific token. The harness extractRejectToken searches every response field for a known taxonomy token before falling back, so a server that buries a specific token in `message` is still credited.", + "scenarios": [ + { + "name": "missing_accepted_block", + "description": "Credential lacks `accepted` block entirely. Structural reject — every adapter must reject. The TS reference server hits the challenge-verification path first when the `payload.challengeId` is foreign-issued; the rust spine rejects at envelope deserialization. Both are surfaced as canonical tokens.", + "credentialOverride": {}, + "deleteFields": ["accepted"], + "expectedRejectTokens": ["challenge_verification_failed"] + }, + { + "name": "missing_payload_block", + "description": "Credential lacks `payload`. Structural reject. Rust spine rejects at PaymentSignatureEnvelope deserialization (payload is required); TS reference server's classifyCredential rejects when payload is absent. Both surface a canonical token.", + "credentialOverride": {}, + "deleteFields": ["payload"], + "expectedRejectTokens": ["challenge_verification_failed"] + }, + { + "name": "tampered_amount", + "description": "Credential `accepted.amount` diverges from offered requirement. The TS reference compares `accepted` to offered requirements and emits charge_request_mismatch. Full SVM verifiers (rust spine) require a real signed transaction here; this scenario therefore targets the wire-binding check, not the on-chain amount check (which lives in the live matrix and emits invalid_exact_svm_payload_amount_mismatch).", + "credentialOverride": { + "accepted": { + "amount": "1" + } + }, + "expectedRejectTokens": [ + "charge_request_mismatch", + "invalid_exact_svm_payload_amount_mismatch" + ] + }, + { + "name": "tampered_recipient", + "description": "Credential `accepted.payTo` diverges from offered requirement. Wire-binding check; on-chain recipient parity is asserted in the live matrix.", + "credentialOverride": { + "accepted": { + "payTo": "11111111111111111111111111111112" + } + }, + "expectedRejectTokens": [ + "charge_request_mismatch", + "invalid_exact_svm_payload_recipient_mismatch" + ] + }, + { + "name": "tampered_mint", + "description": "Credential `accepted.asset` diverges from offered requirement. Wire-binding check.", + "credentialOverride": { + "accepted": { + "asset": "So11111111111111111111111111111111111111112" + } + }, + "expectedRejectTokens": [ + "charge_request_mismatch", + "invalid_exact_svm_payload_mint_mismatch" + ] + }, + { + "name": "wrong_network", + "description": "Credential `accepted.network` diverges from server's offered network. The TS reference returns the canonical wrong_network token; full verifiers may also reject earlier with charge_request_mismatch when the requirement-binding check fails before the network check.", + "credentialOverride": { + "accepted": { + "network": "solana:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + } + }, + "expectedRejectTokens": [ + "wrong_network", + "charge_request_mismatch" + ] + }, + { + "name": "tokenProgram_mismatch", + "description": "Credential carries an `extra.tokenProgram` that does not match the offered token program (SPL Token-2022 vs legacy SPL confusion class). Wire-only adapters cannot catch this without decoding the transaction blob (wireOnlyMayAccept) — full-verifier adapters MUST reject with the mint_mismatch token (the tokenProgram is bound to the mint at verify time).", + "credentialOverride": { + "accepted": { + "extra": { + "decimals": 6, + "tokenProgram": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + } + } + }, + "wireOnlyMayAccept": true, + "expectedRejectTokens": [ + "invalid_exact_svm_payload_mint_mismatch", + "charge_request_mismatch" + ] + }, + { + "name": "missing_challenge_id", + "description": "Payload missing challengeId. The TS reference server's facilitator-fixture rejects with challenge_verification_failed; the rust spine handles the absence via its own facilitator/idempotency layer and emits a sibling canonical token.", + "credentialOverride": { + "payload": { + "resource": "/protected" + } + }, + "replaceFields": ["payload"], + "expectedRejectTokens": [ + "challenge_verification_failed" + ] + }, + { + "name": "resource_mismatch", + "description": "Payload claims a different resource than the one requested. Cross-route replay attempt at the credential layer.", + "credentialOverride": { + "payload": { + "challengeId": "canonical-fixture-challenge-0001", + "resource": "/some-other-route" + } + }, + "expectedRejectTokens": [ + "charge_request_mismatch", + "challenge_route_mismatch" + ] + } + ], + "replayScenario": { + "_description": "Replay: submit the same canonical-payment-signature.json twice. Server MUST accept the first and reject the second with `signature_consumed`. Wire-only adapters get the payment_invalid fallback via WIRE_ONLY_ADAPTER_IDS.", + "expectedRejectTokens": ["signature_consumed"] + } +} diff --git a/harness/fixtures/x402-exact/canonical-challenge.json b/harness/fixtures/x402-exact/canonical-challenge.json new file mode 100644 index 000000000..218fec893 --- /dev/null +++ b/harness/fixtures/x402-exact/canonical-challenge.json @@ -0,0 +1,23 @@ +{ + "_description": "TS-wire x402 `exact` 402 challenge envelope. Mirrors the shape the TS reference server emits in harness/src/fixtures/typescript/exact-server.ts::encodePaymentRequiredHeader. NOTE on Rust-spine parity: the Rust spine (rust/crates/x402/src/protocol/schemes/exact/types.rs::PaymentRequiredEnvelope) serializes the top-level `resource` as a `ResourceInfo` object instead of a string, and Rust's PaymentRequirements is the canonical superset. Adapters that target ONLY this fixture pass the wire-compat tier; full Rust-spine compatibility is asserted by the live matrix in test/x402-exact.live.matrix.test.ts (which exchanges the actual Rust envelope), not by this fixture. See canonical-payment-signature-rust.json for the Rust-spine PaymentProof shape.", + "x402Version": 2, + "accepts": [ + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "resource": "/protected", + "description": "Surfpool-backed protected content", + "mimeType": "application/json", + "payTo": "5xYbHvVQfTUyzCzKx5KjVxyqXqQ4Ujm5SbqQXJ5w8nVA", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "maxAmountRequired": "1000", + "maxTimeoutSeconds": 60, + "extra": { + "decimals": 6, + "tokenProgram": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + } + } + ], + "resource": "/protected", + "error": null +} diff --git a/harness/fixtures/x402-exact/canonical-payment-signature-rust.json b/harness/fixtures/x402-exact/canonical-payment-signature-rust.json new file mode 100644 index 000000000..9612f3465 --- /dev/null +++ b/harness/fixtures/x402-exact/canonical-payment-signature-rust.json @@ -0,0 +1,16 @@ +{ + "_description": "Rust-spine canonical PAYMENT-SIGNATURE envelope. Wire-mirrors rust/crates/x402/src/protocol/schemes/exact/types.rs::PaymentSignatureEnvelope. The `payload` field deserializes as PaymentProof::Transaction with a base64-encoded serialized signed VersionedTransaction. The transaction blob below is the SHORTEST recognisable placeholder — base64 of the bytes `not-a-real-signed-transaction-but-valid-base64` — so the JSON envelope parses cleanly. Bincode-deserialization of the placeholder bytes fails BEFORE verify_exact_versioned_transaction runs, so the spine rejects with the generic `payment_invalid` + a deserialization message (not an `invalid_exact_svm_payload_*` token). The fixture's purpose is therefore (a) the JSON envelope parser accepts a well-formed PaymentSignatureEnvelope and (b) the spine emits a structured 402 with a deserialization diagnostic — NOT a process crash. Asserting `invalid_exact_svm_payload_*` tokens against the spine requires a real signed transaction, which lives in the live matrix.", + "x402Version": 2, + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "accepted": { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "payTo": "5xYbHvVQfTUyzCzKx5KjVxyqXqQ4Ujm5SbqQXJ5w8nVA", + "amount": "1000" + }, + "payload": { + "transaction": "bm90LWEtcmVhbC1zaWduZWQtdHJhbnNhY3Rpb24tYnV0LXZhbGlkLWJhc2U2NA==" + } +} diff --git a/harness/fixtures/x402-exact/canonical-payment-signature.json b/harness/fixtures/x402-exact/canonical-payment-signature.json new file mode 100644 index 000000000..fa17d8165 --- /dev/null +++ b/harness/fixtures/x402-exact/canonical-payment-signature.json @@ -0,0 +1,20 @@ +{ + "_description": "TS-wire PAYMENT-SIGNATURE credential. Mirrors the shape the TS reference client emits in harness/src/fixtures/typescript/exact-client.ts. Used by the wire-compat tier (test/x402-exact.compat.test.ts) to assert every registered adapter's parser handles a foreign-issued credential gracefully (accept, or reject with a parseable token). NOTE on Rust-spine parity: the Rust spine deserializes `payload` as PaymentProof — `{ \"transaction\": base64(...) }` or `{ \"signature\": \"...\" }` (rust/crates/x402/src/protocol/schemes/exact/types.rs::PaymentProof). See canonical-payment-signature-rust.json for the Rust-canonical PaymentProof variant the live matrix exchanges. This stub payload uses `challengeId/resource` and is treated by the rust spine as an unknown payload (early reject at deserialization or at verify time, both acceptable for the compat tier).", + "x402Version": 2, + "accepted": { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "payTo": "5xYbHvVQfTUyzCzKx5KjVxyqXqQ4Ujm5SbqQXJ5w8nVA", + "amount": "1000", + "extra": { + "decimals": 6, + "tokenProgram": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + } + }, + "payload": { + "challengeId": "canonical-fixture-challenge-0001", + "resource": "/protected" + }, + "resource": "/protected" +} diff --git a/harness/fixtures/x402-exact/canonical-reject-tokens.json b/harness/fixtures/x402-exact/canonical-reject-tokens.json new file mode 100644 index 000000000..258fa700a --- /dev/null +++ b/harness/fixtures/x402-exact/canonical-reject-tokens.json @@ -0,0 +1,29 @@ +{ + "_description": "Canonical reject tokens that every x402-exact server adapter MUST be capable of emitting on the appropriate failure class. The high-level (`payment_invalid`, `signature_consumed`, `wrong_network`, etc.) tokens are shared with the MPP charge intent and live in harness/src/canonical-codes.ts. The `invalid_exact_svm_payload_*` family is x402-exact-specific and is enumerated from rust/crates/x402/src/protocol/schemes/exact/verify.rs — adapters that ship a real SVM verifier (Rust spine + any language port that wires a full transaction structural verifier) MUST be able to emit every token in this list for the corresponding attack class. Wire-only TS reference adapters may emit `payment_invalid` instead, since they don't decode the signed transaction blob.", + "highLevelTokens": [ + "payment_invalid", + "signature_consumed", + "wrong_network", + "charge_request_mismatch", + "challenge_verification_failed", + "challenge_route_mismatch", + "challenge_expired" + ], + "exactSvmPayloadTokens": [ + "invalid_exact_svm_payload_amount_mismatch", + "invalid_exact_svm_payload_memo_count", + "invalid_exact_svm_payload_memo_mismatch", + "invalid_exact_svm_payload_mint_mismatch", + "invalid_exact_svm_payload_no_transfer_instruction", + "invalid_exact_svm_payload_recipient_mismatch", + "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", + "invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction", + "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction", + "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high", + "invalid_exact_svm_payload_transaction_instructions_length", + "invalid_exact_svm_payload_unknown_fifth_instruction", + "invalid_exact_svm_payload_unknown_fourth_instruction", + "invalid_exact_svm_payload_unknown_optional_instruction", + "invalid_exact_svm_payload_unknown_sixth_instruction" + ] +} 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 66f4683a5..58b7bc112 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 ccc2d8e8b..53ea02ecd 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 58491e3f6..dfcf85abb 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 4f7a055f0..ef81889a0 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 bfc438213..481df2f1f 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 b8923e6a3..115daed0f 100644 --- a/tests/interop/php-server/server.php +++ b/harness/php-server/server.php @@ -23,7 +23,7 @@ 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 f87afd2f3..3cf4ea82f 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 575c7e999..2d2c42142 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 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 b84a00851..1f9c9616b 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 814fbce5b..f98ebfd54 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 87c43fa77..8143e863b 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 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..36f92e9e8 --- /dev/null +++ b/harness/src/fixtures/typescript/exact-server.ts @@ -0,0 +1,373 @@ +// 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: the payload challengeId MUST be + // one this server issued. The previous guard was `issued.size > 0 && + // ...`, which let a freshly started server settle any credential + // until it had issued its first 402. Codex r8 P2: a direct replay + // of another server's payment-signature to a brand-new TS server + // would settle successfully, which is the opposite of canonical + // Rust behavior (`challenge_verification_failed`). Drop the size + // gate so any unrecognised credential is rejected immediately, + // including the first request after startup. + if (!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..c76412ec3 --- /dev/null +++ b/harness/src/fixtures/typescript/exact-shared.ts @@ -0,0 +1,121 @@ +// 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; + // Optional in the TS reference fixture because the stub credential + // path does not actually sign anything. Real-signing language + // adapters read their own keypair env. Kept on the type so any future + // wire-through (settlement signing on the facilitator side) remains + // backwards-compatible. + facilitatorSecretKey: Uint8Array | null; + // 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; + // Optional in the TS reference fixture (stub credential, no signing). + // Real-signing adapters require their own keypair env. + clientSecretKey: Uint8Array | null; + // 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"; +// TS reference fixture defaults: the negative-scenario suite runs the +// verifier surface without a live RPC or funded keypair. The live +// matrix overrides every one of these via env. Constants chosen to +// match harness/fixtures/x402-exact/canonical-challenge.json so +// hand-crafted credentials in the negative suite are wire-compatible. +const DEFAULT_RPC_URL = "http://127.0.0.1:8899"; +const DEFAULT_MINT = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; +const DEFAULT_PAY_TO = "5xYbHvVQfTUyzCzKx5KjVxyqXqQ4Ujm5SbqQXJ5w8nVA"; + +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 parseOptionalSecretKey(name: string): Uint8Array | null { + const raw = process.env[name]; + if (!raw || raw.trim() === "") return null; + try { + const parsed = JSON.parse(raw) as number[]; + return new Uint8Array(parsed); + } catch { + return null; + } +} + +function parseCsv(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split(",") + .map(value => value.trim()) + .filter(Boolean); +} + +function readBase(): X402InteropEnvironment { + return { + rpcUrl: process.env.X402_INTEROP_RPC_URL ?? DEFAULT_RPC_URL, + network: process.env.X402_INTEROP_NETWORK ?? DEFAULT_NETWORK, + mint: process.env.X402_INTEROP_MINT ?? DEFAULT_MINT, + payTo: process.env.X402_INTEROP_PAY_TO ?? DEFAULT_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, + // TS reference fixture: credential is a stub blob, no on-chain + // signing. Real-signing adapters parse this env themselves via + // parseSecretKey. Keeping the parse optional unblocks the + // negative-scenario suite, which exercises the verifier surface + // without standing up a Surfpool RPC or a funded keypair. + facilitatorSecretKey: parseOptionalSecretKey("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"), + // Same rationale as `facilitatorSecretKey`: TS reference client + // emits a stub credential and never signs. Real-signing adapters + // read this env via their own parser. + clientSecretKey: parseOptionalSecretKey("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 68% rename from tests/interop/src/implementations.ts rename to harness/src/implementations.ts index 89c9586dc..85b9d8212 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,39 @@ 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"], + }, ]; export const serverImplementations: ImplementationDefinition[] = [ @@ -95,7 +132,7 @@ export const serverImplementations: ImplementationDefinition[] = [ "run", "--quiet", "--manifest-path", - "../../rust/Cargo.toml", + "../rust/Cargo.toml", "-p", "solana-mpp", "--bin", @@ -122,7 +159,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 +170,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 +198,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/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 db1e3dfa7..a1d58f35e 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 000000000..364ddb83f --- /dev/null +++ b/harness/src/intents/x402-exact.ts @@ -0,0 +1,136 @@ +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. + // + // Symmetry: the TS-to-TS pair is the control case — two independent + // TS reference server instances issue disjoint challenge id sets, so + // server B rejects A's credential with `challenge_verification_failed` + // through the same code path real adapters exercise. The TS-to-Rust + // pair widens the assertion onto the rust spine (which classifies + // the stub credential at the proof layer; the harness accepts any + // canonical 402 reject token for that pair via its message + // classifier). The reverse Rust-to-TS direction requires a credential + // capture path that the Rust spine adapter intentionally does not + // expose (settlement-signing only, not credential-echoing); the + // canonical Rust→TS portability assertion lives in the live matrix + // (`x402-exact.live.matrix.test.ts`) where a real signed transaction + // is exchanged end-to-end. + crossServerPairs: [ + ["ts-x402", "ts-x402"], + ["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/harness/src/x402-pair-policy.ts b/harness/src/x402-pair-policy.ts new file mode 100644 index 000000000..03f6c1919 --- /dev/null +++ b/harness/src/x402-pair-policy.ts @@ -0,0 +1,37 @@ +// Shared `allowedPair` policy for x402-exact matrix tests. Keeping the +// policy in one place prevents the e2e and live-matrix tests from +// drifting apart (which would silently create matrix false-negatives). + +export const TS_REFERENCE_ID = "ts-x402"; +export const RUST_SPINE_PREFIX = "rust-x402"; + +export function isTsReference(id: string): boolean { + return id === TS_REFERENCE_ID; +} + +export function isRustSpine(id: string): boolean { + return ( + id === RUST_SPINE_PREFIX || + id === `${RUST_SPINE_PREFIX}-client` || + id === `${RUST_SPINE_PREFIX}-server` + ); +} + +export function baseLang(id: string): string { + return id.replace(/-client$/, "").replace(/-server$/, ""); +} + +// Pair restriction: the TS reference adapters speak a stub payload, so +// they only interop with each other. Every other x402-exact adapter +// (Rust spine + language ports) pairs with itself and with the Rust +// spine on either side. Pure language-to-language pairings without +// the spine on one side are out of scope for this matrix. +export function allowedX402Pair(clientId: string, serverId: string): boolean { + if (isTsReference(clientId) || isTsReference(serverId)) { + return isTsReference(clientId) && isTsReference(serverId); + } + if (isRustSpine(clientId) && isRustSpine(serverId)) return true; + if (baseLang(clientId) === baseLang(serverId)) return true; + if (isRustSpine(clientId) || isRustSpine(serverId)) return true; + return false; +} 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 777c1bd2b..553eee735 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 bd870d172..205599433 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 000000000..b47e0de2b --- /dev/null +++ b/harness/test/cross-server-scenarios.test.ts @@ -0,0 +1,215 @@ +// 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; + // Codex r8 #133 P2: the Rust x402 interop server wraps verifier + // failures as `{ error: "payment_invalid", message: "" }`. Searching `error` first then `message` + // would resolve to the generic `payment_invalid` and discard the + // specific verifier token. Search both fields combined so the + // taxonomy classifier sees the richer string. + const errorPart = typeof record.error === "string" ? record.error : ""; + const messagePart = typeof record.message === "string" ? record.message : ""; + const combined = [errorPart, messagePart].filter(Boolean).join(" "); + if (combined) return classifyMessageToCanonicalCode(combined); + } + 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 e9e7e53b0..706f4bcde 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 6e8660278..1dcef686f 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.compat.test.ts b/harness/test/x402-exact.compat.test.ts new file mode 100644 index 000000000..26ea4e4a0 --- /dev/null +++ b/harness/test/x402-exact.compat.test.ts @@ -0,0 +1,673 @@ +// Wire-level compatibility matrix for the x402 `exact` intent. +// +// Three test groups run WITHOUT live RPC, surfpool, or funded keypairs: +// +// 1. Client-emit compatibility: each registered `x402-exact` client +// adapter is spawned against a thin fixture HTTP server that +// replies with the canonical-challenge.json 402 envelope. The +// adapter MUST parse the envelope and resubmit a credential whose +// `accepted` block round-trips through `JSON.parse` and matches +// one of the offers from the envelope. This catches wire-format +// drift between language adapters before the live matrix runs. +// +// 2. Server-accept compatibility: each registered `x402-exact` server +// adapter is spawned against the canonical-payment-signature.json +// credential. Because the wire-only TS reference fixture validates +// semantic fields (challengeId issued by this server, asset/payTo +// matching offer) it will reject a foreign-issued credential — but +// it MUST do so with a parseable JSON response on the 402 boundary, +// never with a process crash or unparseable body. SVM-verifier +// adapters are gated by capability (see CAPABILITY_GATE below). +// +// 3. Attack-rejection compatibility: each registered `x402-exact` +// server adapter is fed every credential in attack-scenarios.json. +// For each scenario the response body's `error` / `code` / `message` +// MUST match one of the scenario's `expectedRejectTokens`. Adapters +// that don't decode the full SVM transaction blob are allowed the +// fallback `payment_invalid` token (see canonical-reject-tokens.json). +// +// These tests are NOT env-gated; they run in the default `pnpm test` +// invocation. They require no cargo toolchain — the rust spine is +// excluded from the compat suite (capability filter) and exercised in +// the live matrix instead. + +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + clientImplementations, + serverImplementations, + type ImplementationDefinition, +} from "../src/implementations"; +import { runClient, startServer, stopServer } from "../src/process"; + +type CanonicalChallenge = { + x402Version: number; + accepts: Array<{ + scheme: string; + network: string; + resource: string; + payTo: string; + asset: string; + maxAmountRequired: string; + extra?: { decimals?: number; tokenProgram?: string }; + }>; + resource: string; +}; + +type CanonicalCredential = { + x402Version: number; + accepted: { + scheme: string; + network: string; + asset: string; + payTo: string; + amount: string; + extra?: { decimals?: number; tokenProgram?: string }; + }; + payload: { challengeId?: string; resource?: string }; + resource?: string; +}; + +type AttackScenario = { + name: string; + description: string; + credentialOverride: Record; + expectedRejectTokens: string[]; + // When set, listed top-level fields on the merged credential are + // REPLACED (shallow) by the override value instead of deep-merged. + // Use this when an attack scenario wants to drop subfields like + // payload.challengeId — a deep merge would otherwise re-inject them + // from the base credential. + replaceFields?: string[]; + // When set, listed top-level fields are deleted from the merged + // credential entirely. Used for structural-malformation attacks + // (missing `accepted`, missing `payload`). + deleteFields?: string[]; + // When true, wire-only adapters (no full SVM transaction decoder) + // are allowed to accept this credential (status 200). Full-verifier + // adapters are still required to reject with one of the + // expectedRejectTokens. Used for attacks that target subfields a + // wire-only adapter cannot validate without decoding the transaction + // blob (tokenProgram, fee-payer-in-accounts, etc.). + wireOnlyMayAccept?: boolean; +}; + +type AttackSuite = { + scenarios: AttackScenario[]; + replayScenario: { expectedRejectTokens: string[] }; +}; + +const FIXTURE_DIR = path.resolve(__dirname, "../fixtures/x402-exact"); + +function loadJson(name: string): T { + const raw = fs.readFileSync(path.join(FIXTURE_DIR, name), "utf8"); + return JSON.parse(raw) as T; +} + +const challenge = loadJson("canonical-challenge.json"); +const credential = loadJson( + "canonical-payment-signature.json", +); +type RustCredential = { + x402Version: number; + scheme: string; + network: string; + accepted: Record; + payload: { transaction?: string; signature?: string }; +}; +const rustCredential = loadJson( + "canonical-payment-signature-rust.json", +); +const rejectTokens = loadJson<{ + highLevelTokens: string[]; + exactSvmPayloadTokens: string[]; +}>("canonical-reject-tokens.json"); +const attackSuite = loadJson("attack-scenarios.json"); + +// Capability gate — adapters that require external toolchains (cargo, +// go, swift) we cannot reasonably exercise in the wire-compat suite +// because their startup cost dwarfs the wire test. They re-enter via +// the live matrix once env is set. The gate is keyed off adapter ids so +// new language adapters automatically opt in. +// The compat suite covers fast in-process adapters only. The rust +// spine is intentionally NOT in this suite — its verifier deserializes +// `payload` as `PaymentProof::Transaction|Signature` +// (rust/crates/x402/src/protocol/schemes/exact/types.rs) and would +// reject the TS-wire `payload.challengeId/resource` stub at the proof +// layer with generic `payment_invalid`, defeating the per-scenario +// specific-token assertions that make this suite robust. Rust spine +// coverage lives in the live matrix where real signed transactions +// are built (test/x402-exact.live.matrix.test.ts). New fast in-process +// adapters that share the TS-wire credential shape can be added here. +const COMPAT_INCLUDE_IDS = new Set(["ts-x402"]); + +// Adapters that don't decode the full SVM transaction blob and therefore +// can't catch some attack classes (e.g. tokenProgram mismatch inside +// the signed transaction). For these adapters, attack scenarios marked +// `wireOnlyMayAccept: true` are allowed to return 200. +const WIRE_ONLY_ADAPTER_IDS = new Set(["ts-x402"]); + +function activeClients(): ImplementationDefinition[] { + return clientImplementations.filter( + impl => + impl.enabled && + (impl.intents ?? []).includes("x402-exact") && + COMPAT_INCLUDE_IDS.has(impl.id), + ); +} + +function activeServers(): ImplementationDefinition[] { + return serverImplementations.filter( + impl => + impl.enabled && + (impl.intents ?? []).includes("x402-exact") && + COMPAT_INCLUDE_IDS.has(impl.id), + ); +} + +const offer = challenge.accepts[0]; +if (!offer) throw new Error("canonical-challenge fixture has no offers"); + +function buildCompatEnv(extra: Record = {}): Record { + // Every required X402_INTEROP_* env, with a deterministic dummy + // facilitator/client keypair (the adapters parse but never use them + // in the wire compat path). + const stubKey = JSON.stringify(new Array(64).fill(7)); + return { + X402_INTEROP_RPC_URL: "http://127.0.0.1:65535", + X402_INTEROP_NETWORK: offer.network, + X402_INTEROP_MINT: offer.asset, + X402_INTEROP_PAY_TO: offer.payTo, + X402_INTEROP_PRICE: offer.maxAmountRequired, + X402_INTEROP_RESOURCE_PATH: offer.resource, + X402_INTEROP_SETTLEMENT_HEADER: "x-fixture-settlement", + X402_INTEROP_FACILITATOR_SECRET_KEY: stubKey, + X402_INTEROP_CLIENT_SECRET_KEY: stubKey, + ...extra, + }; +} + +// In-process fixture HTTP server that mimics a canonical x402 402 +// response. Drives the client-side wire parser test without spawning a +// real x402 server adapter. +async function startCanonicalFixtureServer(): Promise<{ url: string; close: () => Promise; received: { credential: string | null } }> { + const received = { credential: null as string | null }; + const envelope = Buffer.from(JSON.stringify(challenge), "utf8").toString( + "base64", + ); + const server = http.createServer((req, res) => { + const credentialHeader = req.headers["payment-signature"] as + | string + | undefined; + if (!credentialHeader) { + res.writeHead(402, { + "content-type": "application/json", + "payment-required": envelope, + "x-challenge-id": "canonical-fixture-challenge-0001", + }); + res.end(JSON.stringify({ error: "payment_required" })); + return; + } + received.credential = credentialHeader; + res.writeHead(200, { + "content-type": "application/json", + "payment-response": Buffer.from( + JSON.stringify({ success: true, transaction: "fixture-tx" }), + "utf8", + ).toString("base64"), + "x-fixture-settlement": "fixture-tx", + }); + res.end(JSON.stringify({ ok: true })); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.removeListener("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("fixture server failed to bind"); + } + return { + url: `http://127.0.0.1:${address.port}${offer.resource}`, + received, + close: () => + new Promise(resolve => + server.close(() => resolve()), + ), + }; +} + +function decodeCredentialHeader(headerValue: string): CanonicalCredential { + return JSON.parse( + Buffer.from(headerValue, "base64").toString("utf8"), + ) as CanonicalCredential; +} + +// Pull a reject token from a 402 response body. The Rust spine wraps +// verifier failures as `{ error: "payment_invalid", message: ": ..." }` +// (rust/crates/x402/src/bin/interop_server.rs ~L246) so the most-specific +// token is in `message`, not `error`. We search every field for a known +// reject token before falling back to the high-level `error` field. Order +// of preference: `code` (canonical), then any field with a substring match +// against the known reject taxonomy, then `error`. +function extractRejectToken(body: unknown): string | undefined { + if (!body || typeof body !== "object") return undefined; + const record = body as Record; + // Canonical structured code wins outright. + if (typeof record.code === "string" && record.code.length > 0) { + return record.code; + } + // Then look in every string field for the most-specific token from + // the known taxonomy. `invalid_exact_svm_payload_*` and the + // high-level set are checked; first match wins by specificity + // (svm-payload tokens before high-level fallbacks). + const candidates: string[] = []; + for (const field of ["message", "error", "detail"] as const) { + const value = record[field]; + if (typeof value === "string") candidates.push(value); + } + // Sort longest-first so suffixed tokens (e.g. + // `..._compute_price_instruction_too_high`) match before their + // shorter prefix (`..._compute_price_instruction`) — otherwise the + // shorter token would greedily credit the wrong reject class. + const taxonomy = [ + ...rejectTokens.exactSvmPayloadTokens, + ...rejectTokens.highLevelTokens, + ].sort((a, b) => b.length - a.length); + for (const token of taxonomy) { + for (const candidate of candidates) { + if (candidate.includes(token)) return token; + } + } + // No taxonomy match — return whatever `error` or `message` says so + // the assertion can show the unrecognised string. + for (const field of ["error", "message"] as const) { + const value = record[field]; + if (typeof value === "string" && value.length > 0) return value; + } + return undefined; +} + +function deepMerge>( + base: T, + override: Record, +): T { + const result: Record = { ...base }; + for (const [k, v] of Object.entries(override)) { + if ( + v !== null && + typeof v === "object" && + !Array.isArray(v) && + typeof result[k] === "object" && + result[k] !== null && + !Array.isArray(result[k]) + ) { + result[k] = deepMerge( + result[k] as Record, + v as Record, + ); + } else { + result[k] = v; + } + } + return result as T; +} + +function encodeCredential(payload: unknown): string { + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); +} + +async function postCredential( + targetUrl: string, + credentialHeader: string, +): Promise<{ status: number; body: unknown }> { + const response = await fetch(targetUrl, { + headers: { "payment-signature": credentialHeader }, + }); + const text = await response.text(); + let body: unknown = text; + try { + body = JSON.parse(text); + } catch { + // leave as text + } + return { status: response.status, body }; +} + +describe("x402-exact compat: registered adapters", () => { + const clients = activeClients(); + const servers = activeServers(); + + it("at least one x402-exact client adapter is registered", () => { + expect(clients.length).toBeGreaterThan(0); + }); + + it("at least one x402-exact server adapter is registered", () => { + expect(servers.length).toBeGreaterThan(0); + }); + + it("rust-canonical fixture matches the rust spine PaymentSignatureEnvelope shape", () => { + // Wire shape lock: every field the rust spine's + // PaymentSignatureEnvelope (rust/crates/x402/src/protocol/schemes/exact/types.rs) + // requires must be present. `payload` must deserialize as + // PaymentProof::Transaction OR PaymentProof::Signature — i.e. exactly + // one of `transaction` / `signature` keys, both base-encoded strings. + expect(rustCredential.x402Version).toBe(2); + expect(typeof rustCredential.scheme).toBe("string"); + expect(typeof rustCredential.network).toBe("string"); + expect(rustCredential.accepted).toBeDefined(); + const proofKeys = Object.keys(rustCredential.payload); + expect(proofKeys).toHaveLength(1); + const proofKey = proofKeys[0]; + expect(["transaction", "signature"]).toContain(proofKey); + const proofValue = (rustCredential.payload as Record)[ + proofKey + ]; + expect(typeof proofValue).toBe("string"); + expect((proofValue as string).length).toBeGreaterThan(0); + if (proofKey === "transaction") { + // base64 round-trip — the spine's first step. + const decoded = Buffer.from(proofValue as string, "base64"); + const reEncoded = decoded.toString("base64"); + expect(reEncoded).toBe(proofValue); + } + }); + + it("canonical fixtures are wire-consistent with each other", () => { + expect(credential.accepted.scheme).toBe(offer.scheme); + expect(credential.accepted.network).toBe(offer.network); + expect(credential.accepted.asset).toBe(offer.asset); + expect(credential.accepted.payTo).toBe(offer.payTo); + expect(credential.accepted.amount).toBe(offer.maxAmountRequired); + expect(credential.payload.resource ?? credential.resource).toBe( + offer.resource, + ); + }); + + it("canonical reject tokens are exactly the rust spine reject taxonomy", () => { + // Strict parity lock: grep the rust spine for every + // `"invalid_exact_svm_payload_*"` literal and assert the fixture + // lists EXACTLY those tokens (no missing, no stale). When the rust + // spine adds, removes, or renames a token, this test fails and + // points at the divergence — no silent drift. + const verifyPath = path.resolve( + __dirname, + "../../rust/crates/x402/src/protocol/schemes/exact/verify.rs", + ); + if (!fs.existsSync(verifyPath)) { + // Rust source not vendored in this checkout (e.g. minimal CI image + // without the rust workspace). Fall back to a non-empty floor. + expect(rejectTokens.exactSvmPayloadTokens.length).toBeGreaterThan(0); + return; + } + const verifySource = fs.readFileSync(verifyPath, "utf8"); + const spineTokens = new Set(); + for (const match of verifySource.matchAll( + /"(invalid_exact_svm_payload_[a-z_]+)"/g, + )) { + spineTokens.add(match[1]); + } + const fixtureSet = new Set(rejectTokens.exactSvmPayloadTokens); + const missing = [...spineTokens].filter(t => !fixtureSet.has(t)); + const stale = [...fixtureSet].filter(t => !spineTokens.has(t)); + expect( + missing, + `tokens in rust spine but missing from canonical-reject-tokens.json: ${missing.join(", ")}`, + ).toEqual([]); + expect( + stale, + `tokens in canonical-reject-tokens.json but no longer in rust spine: ${stale.join(", ")}`, + ).toEqual([]); + }); +}); + +describe("x402-exact compat: client → canonical challenge", () => { + const clients = activeClients(); + + type Fixture = Awaited>; + let fixture: Fixture | undefined; + afterEach(async () => { + if (fixture) { + await fixture.close(); + fixture = undefined; + } + }); + + for (const client of clients) { + it(`${client.id} parses canonical 402 envelope and resubmits a wire-valid credential`, async () => { + fixture = await startCanonicalFixtureServer(); + const env = buildCompatEnv({ X402_INTEROP_TARGET_URL: fixture.url }); + const result = await runClient(client, fixture.url, env); + + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + + // Adapter must have submitted a credential the fixture server + // saw and recorded. + expect(fixture.received.credential).toBeTruthy(); + const parsed = decodeCredentialHeader( + fixture.received.credential as string, + ); + expect(parsed.accepted.scheme).toBe(offer.scheme); + expect(parsed.accepted.network).toBe(offer.network); + expect(parsed.accepted.asset).toBe(offer.asset); + expect(parsed.accepted.payTo).toBe(offer.payTo); + expect(parsed.accepted.amount).toBe(offer.maxAmountRequired); + }, 60_000); + } +}); + +describe("x402-exact compat: server → canonical credential", () => { + const servers = activeServers(); + type Running = Awaited>; + let running: Running | undefined; + afterEach(async () => { + if (running) { + await stopServer(running); + running = undefined; + } + }); + + for (const server of servers) { + it(`${server.id} accepts a wire-valid credential or returns a parseable rejection`, async () => { + const env = buildCompatEnv(); + running = await startServer(server, env); + const targetUrl = `http://127.0.0.1:${running.ready.port}${offer.resource}`; + + // First: prime the server by hitting it without a credential. + // Some adapters (TS reference) HMAC-track issued challenge IDs + // and reject foreign-issued ids; for those, capture the issued + // challenge and retry with that credential id substituted in. + const primeResponse = await fetch(targetUrl); + expect(primeResponse.status).toBe(402); + const issuedChallengeId = primeResponse.headers.get("x-challenge-id"); + + const credentialToSend = issuedChallengeId + ? deepMerge(credential, { + payload: { challengeId: issuedChallengeId }, + }) + : credential; + const header = encodeCredential(credentialToSend); + const { status, body } = await postCredential(targetUrl, header); + + // Wire-only adapters may accept the stub credential (200). Full + // verifiers MUST reject — the canonical credential carries a + // `payload.challengeId/resource` shape, not a real + // PaymentProof::Transaction, so accepting it would be a verifier + // bypass. Adapters opting in via X402_COMPAT_STUB_ACCEPT (CSV of + // ids) declare their verifier accepts the stub on purpose. + const stubAcceptAllowed = + WIRE_ONLY_ADAPTER_IDS.has(server.id) || + (process.env.X402_COMPAT_STUB_ACCEPT ?? "") + .split(",") + .map(s => s.trim()) + .includes(server.id); + if (status === 200) { + expect( + stubAcceptAllowed, + `full verifier ${server.id} accepted the TS-wire stub credential (verifier bypass risk)`, + ).toBe(true); + expect(body).toBeDefined(); + } else { + expect(status).toBe(402); + const token = extractRejectToken(body); + expect(token).toBeTruthy(); + const allTokens = new Set([ + ...rejectTokens.highLevelTokens, + ...rejectTokens.exactSvmPayloadTokens, + ]); + expect(allTokens.has(token as string)).toBe(true); + } + }, 60_000); + } +}); + +describe("x402-exact compat: server → attack scenarios", () => { + const servers = activeServers(); + type Running = Awaited>; + let running: Running | undefined; + afterEach(async () => { + if (running) { + await stopServer(running); + running = undefined; + } + }); + + for (const server of servers) { + for (const scenario of attackSuite.scenarios) { + it(`${server.id} rejects ${scenario.name}`, async () => { + const env = buildCompatEnv(); + running = await startServer(server, env); + const targetUrl = `http://127.0.0.1:${running.ready.port}${offer.resource}`; + + // Prime to get a server-issued challenge id where applicable. + const primeResponse = await fetch(targetUrl); + const issuedChallengeId = primeResponse.headers.get("x-challenge-id"); + const baseCredential = issuedChallengeId + ? deepMerge(credential, { + payload: { challengeId: issuedChallengeId }, + }) + : credential; + + let attackCredential = deepMerge( + baseCredential, + scenario.credentialOverride, + ); + if (scenario.replaceFields) { + const replaced: Record = { + ...(attackCredential as unknown as Record), + }; + for (const field of scenario.replaceFields) { + if (field in scenario.credentialOverride) { + replaced[field] = scenario.credentialOverride[field]; + } + } + attackCredential = replaced as unknown as CanonicalCredential; + } + if (scenario.deleteFields) { + const stripped: Record = { + ...(attackCredential as unknown as Record), + }; + for (const field of scenario.deleteFields) { + delete stripped[field]; + } + attackCredential = stripped as unknown as CanonicalCredential; + } + const header = encodeCredential(attackCredential); + const { status, body } = await postCredential(targetUrl, header); + + if ( + status === 200 && + scenario.wireOnlyMayAccept && + WIRE_ONLY_ADAPTER_IDS.has(server.id) + ) { + // Acceptable for wire-only adapters; nothing further to assert. + return; + } + expect(status).toBe(402); + const token = extractRejectToken(body); + expect( + token, + `attack ${scenario.name} produced no reject token in ${JSON.stringify(body)}`, + ).toBeTruthy(); + // The token must be one of the scenario-expected tokens. + // Wire-only adapters (no SVM transaction decoder) may also emit + // the generic `payment_invalid` fallback — full verifiers must + // emit a specific token. This prevents a full verifier from + // silently regressing to a generic error and still passing the + // parity lock. + const allowed = new Set(scenario.expectedRejectTokens); + if (WIRE_ONLY_ADAPTER_IDS.has(server.id)) { + allowed.add("payment_invalid"); + } + expect( + allowed.has(token as string), + `attack ${scenario.name}: token ${token} not in allowed set ${[...allowed].join(",")}`, + ).toBe(true); + }, 60_000); + } + + // Replay assertion requires the canonical credential to be accepted + // on first submission. Adapters whose verifier needs a real signed + // transaction blob (rust spine) reject the stub canonical credential + // at bincode-deserialization, so replay against them is covered by + // the live matrix where a real PaymentProof::Transaction is built. + const replayCapable = + WIRE_ONLY_ADAPTER_IDS.has(server.id) || + process.env.X402_COMPAT_REPLAY_TRUST?.split(",").includes(server.id); + if (!replayCapable) { + it.skip(`${server.id} replay test requires a real signed transaction (covered by live matrix)`, () => {}); + continue; + } + it(`${server.id} rejects replay (signature_consumed)`, async () => { + const env = buildCompatEnv(); + running = await startServer(server, env); + const targetUrl = `http://127.0.0.1:${running.ready.port}${offer.resource}`; + + const primeResponse = await fetch(targetUrl); + const issuedChallengeId = primeResponse.headers.get("x-challenge-id"); + const sendCredential = issuedChallengeId + ? deepMerge(credential, { + payload: { challengeId: issuedChallengeId }, + }) + : credential; + const header = encodeCredential(sendCredential); + + const first = await postCredential(targetUrl, header); + // Replay semantics REQUIRE the first submission to be accepted — + // otherwise the "second submit must produce signature_consumed" + // assertion is vacuous (a server that rejects every credential + // would trivially pass). Wire-only adapters that semantically + // reject the canonical credential (because the challenge id + // wasn't issued by this process, etc.) are not the right vehicle + // for the replay assertion; they exercise the + // `challenge_verification_failed` path under canonical credential + // test above. The replay test therefore fails if the first + // submission was rejected — that's a wiring bug, not a feature. + expect( + first.status, + `replay test requires first submit to be accepted; got ${first.status}: ${JSON.stringify(first.body)}`, + ).toBe(200); + + const second = await postCredential(targetUrl, header); + expect(second.status).toBe(402); + const token = extractRejectToken(second.body); + const replayAllowed = new Set( + attackSuite.replayScenario.expectedRejectTokens, + ); + // No payment_invalid fallback for replay: once the first + // submission was accepted (asserted above), the second MUST be + // classified as signature_consumed by every adapter. A generic + // rejection here would be a real replay-detection regression. + expect( + replayAllowed.has(token as string), + `replay token ${token} not in allowed ${[...replayAllowed].join(",")}`, + ).toBe(true); + }, 60_000); + } +}); diff --git a/harness/test/x402-exact.e2e.test.ts b/harness/test/x402-exact.e2e.test.ts new file mode 100644 index 000000000..1300d280f --- /dev/null +++ b/harness/test/x402-exact.e2e.test.ts @@ -0,0 +1,166 @@ +// 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"; +import { + allowedX402Pair, + baseLang, +} from "../src/x402-pair-policy"; + +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 and never with a real-signing language + // adapter. Every other `x402-exact` adapter (Rust spine plus any + // language port registered in `implementations.ts`) carries the + // canonical PaymentProof and can interop with the Rust spine on + // either side, plus its own same-language self-pair. Pure + // language-to-language pairings without the spine on one side are + // out of scope for this matrix — they are exercised in each + // language's own integration suite. + // + // The pair selector is data-driven so that as new language adapters + // land (rebased onto this PR), the matrix widens automatically + // without further test edits. + // Pair policy lives in src/x402-pair-policy.ts so the e2e and live + // matrix tests cannot drift apart silently. + const allowedPair = allowedX402Pair; + + // Explicit per-language self-pair group: each registered x402-exact + // language adapter (client + server of the same baseLang) gets a + // documented self-pair test. The `allowedPair` filter below already + // covers same-baseLang via the generic loop, but enumerating + // self-pairs explicitly makes regressions easier to spot in the + // vitest output ("`ts-x402 self-pair` failed" reads more clearly + // than "client ts-x402 ↔ server ts-x402 failed" buried in the + // full cross-product log). + const selfPairLangs = Array.from( + new Set(x402Clients.map(impl => baseLang(impl.id))), + ).filter(lang => + x402Servers.some(impl => baseLang(impl.id) === lang), + ); + + describe("self-pair (each language ↔ itself)", () => { + if (selfPairLangs.length === 0) { + it.skip("no x402-exact adapters registered", () => {}); + return; + } + for (const lang of selfPairLangs) { + it(`${lang} self-pair is enumerated`, () => { + const client = x402Clients.find(impl => baseLang(impl.id) === lang); + const server = x402Servers.find(impl => baseLang(impl.id) === lang); + expect(client).toBeTruthy(); + expect(server).toBeTruthy(); + expect(allowedPair(client!.id, server!.id)).toBe(true); + }); + } + }); + + 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/harness/test/x402-exact.live.matrix.test.ts b/harness/test/x402-exact.live.matrix.test.ts new file mode 100644 index 000000000..1ca8af28f --- /dev/null +++ b/harness/test/x402-exact.live.matrix.test.ts @@ -0,0 +1,144 @@ +// Live on-chain x402 `exact` cross-language matrix. +// +// Env-gated. Required: +// X402_INTEROP_MATRIX=1 +// X402_INTEROP_RPC_URL=... (running surfpool / devnet RPC) +// X402_INTEROP_MINT=... +// X402_INTEROP_PAY_TO=... +// X402_INTEROP_CLIENT_SECRET_KEY=[...] +// X402_INTEROP_FACILITATOR_SECRET_KEY=[...] +// +// When all required env is set, this test enumerates every `allowedPair` +// (client × server) from the x402-exact intent registration and runs +// each pair against the happy-path scenario. When env is missing, the +// suite skips with a single explanatory test so CI is loud about why +// the live matrix is not running. +// +// This file is intentionally separate from `x402-exact.e2e.test.ts`: +// - `x402-exact.e2e.test.ts` is the canonical entrypoint and +// enumerates same-language self-pairs + spine cross-pairs. +// - `x402-exact.live.matrix.test.ts` is the explicit "every active +// pair, including newly-landed language adapters" enumeration. +// Designed to widen automatically as new x402-exact adapters +// register; no test-edit required to pick them up. + +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"; +import { allowedX402Pair } from "../src/x402-pair-policy"; + +const MATRIX_ENABLED = process.env.X402_INTEROP_MATRIX === "1"; + +const REQUIRED_ENVS = [ + "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 REQUIRED_ENVS.filter( + name => !process.env[name] || process.env[name]?.trim() === "", + ); +} + +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"), +); + +function enumeratePairs(): Array<{ clientId: string; serverId: string }> { + const out: Array<{ clientId: string; serverId: string }> = []; + for (const server of x402Servers) { + for (const client of x402Clients) { + if (allowedX402Pair(client.id, server.id)) { + out.push({ clientId: client.id, serverId: server.id }); + } + } + } + return out; +} + +const happyPath = interopScenarios.find( + scenario => scenario.id === "x402-exact-basic", +); + +type RunningServer = Awaited>; +const runningServers: RunningServer[] = []; + +afterAll(async () => { + for (const server of runningServers.splice(0)) { + await stopServer(server); + } +}); + +describe("x402-exact live matrix (env-gated)", () => { + if (!MATRIX_ENABLED) { + it.skip("matrix is gated behind X402_INTEROP_MATRIX=1", () => {}); + return; + } + const missing = missingEnvs(); + if (missing.length > 0) { + // Loud stderr so CI matrix misconfiguration is visible in the + // job log even though vitest only renders skip in green. Per spec + // this is `skip` not `fail` (the matrix is opt-in by env), but + // the warning surfaces the missing envs without silencing them. + // eslint-disable-next-line no-console + console.warn( + `\n[x402-live-matrix] SKIP: X402_INTEROP_MATRIX=1 set but required env vars are missing: ${missing.join(", ")}\n`, + ); + it.skip( + `live matrix skipped: missing required env vars: ${missing.join(", ")}`, + () => {}, + ); + return; + } + if (!happyPath) { + it("happy-path scenario x402-exact-basic must be in the registry", () => { + throw new Error("x402-exact-basic scenario not found in interopScenarios"); + }); + return; + } + + const pairs = enumeratePairs(); + it(`enumerates ${pairs.length} allowed x402-exact pair(s)`, () => { + expect(pairs.length).toBeGreaterThan(0); + }); + + for (const { clientId, serverId } of pairs) { + const client = x402Clients.find(impl => impl.id === clientId); + const server = x402Servers.find(impl => impl.id === serverId); + if (!client || !server) continue; + it(`${clientId} client ↔ ${serverId} server: live 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/harness/test/x402-exact.negative.test.ts b/harness/test/x402-exact.negative.test.ts new file mode 100644 index 000000000..252205e5c --- /dev/null +++ b/harness/test/x402-exact.negative.test.ts @@ -0,0 +1,177 @@ +// Negative-scenario coverage for the x402 `exact` intent. +// +// The cross-language matrix in `x402-exact.e2e.test.ts` exercises the +// happy path and is gated behind `X402_INTEROP_MATRIX=1` plus a live +// Surfpool RPC and funded keypair. The verifier surface (network +// mismatch, cross-route replay) is independent of any of that: the +// rejection happens at the wire layer before the server would touch +// the chain. This file exercises the TS reference server's verifier +// directly with hand-crafted credentials so the negative scenarios +// registered in `src/intents/x402-exact.ts` are actually run on every +// default `pnpm test` invocation, not merely declared. +// +// Network-mismatch coverage uses two distinct `X402_INTEROP_NETWORK` +// values: the server advertises offers for network A, the credential +// claims `accepted.network = B`. The TS reference verifier returns the +// canonical `wrong_network` token. + +import { afterEach, describe, expect, it } from "vitest"; +import { interopScenarios } from "../src/contracts"; +import { + serverImplementations, +} from "../src/implementations"; +import { startServer, stopServer } from "../src/process"; + +const PAYMENT_SIGNATURE_HEADER = "payment-signature"; +const TS_NETWORK_A = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; +const TS_NETWORK_B = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"; + +const tsServer = serverImplementations.find(s => s.id === "ts-x402"); + +const networkMismatch = interopScenarios.find( + scenario => scenario.id === "x402-exact-network-mismatch", +); +const crossRouteReplay = interopScenarios.find( + scenario => scenario.id === "x402-exact-cross-route-replay", +); + +type RunningServer = Awaited>; +let currentServer: RunningServer | null = null; + +afterEach(async () => { + if (currentServer) { + await stopServer(currentServer); + currentServer = null; + } +}); + +function encodeCredential(payload: unknown): string { + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); +} + +async function bootstrapChallengeId(port: number, resourcePath: string): Promise { + // The TS reference server issues a fresh challenge id on each 402. + // Cross-route replay must pass server-side challenge verification, + // so we acquire a valid id by hitting the resource without a + // credential. The id is bound to the issuing route only at the + // payload.resource layer; resource-mismatch fires before the + // signature-consumed check, so reusing the id across routes is fine. + const response = await fetch(`http://127.0.0.1:${port}${resourcePath}`); + const challengeId = response.headers.get("x-challenge-id"); + if (!challengeId) { + throw new Error("TS reference server did not issue an x-challenge-id"); + } + return challengeId; +} + +describe("x402 exact — verifier negative scenarios (TS reference)", () => { + if (!tsServer || !tsServer.enabled) { + it.skip("ts-x402 server adapter not enabled", () => {}); + return; + } + + if (networkMismatch) { + it("network-mismatch credential is rejected with canonical `wrong_network`", async () => { + // Distinct networks: server advertises offers on network A; + // client tampers credential to claim network B. This is the + // failure shape the codex r8 negative-scenario item asks for + // (the previous declaration used the same scenario.network for + // both sides, which could never trigger the verifier branch). + const serverEnv = { + X402_INTEROP_NETWORK: TS_NETWORK_A, + X402_INTEROP_RESOURCE_PATH: networkMismatch.resourcePath, + X402_INTEROP_PRICE: networkMismatch.price, + }; + currentServer = await startServer(tsServer, serverEnv); + const port = currentServer.ready.port; + if (!port) throw new Error("server did not report a port"); + const url = `http://127.0.0.1:${port}${networkMismatch.resourcePath}`; + + const challengeId = await bootstrapChallengeId(port, networkMismatch.resourcePath); + + const credential = encodeCredential({ + x402Version: 2, + accepted: { + scheme: "exact", + // Network B: distinct from what the server advertises. + network: TS_NETWORK_B, + asset: networkMismatch.asset, + payTo: "5xYbHvVQfTUyzCzKx5KjVxyqXqQ4Ujm5SbqQXJ5w8nVA", + amount: networkMismatch.amount, + extra: null, + }, + payload: { + challengeId, + resource: networkMismatch.resourcePath, + }, + resource: networkMismatch.resourcePath, + }); + + const response = await fetch(url, { + headers: { [PAYMENT_SIGNATURE_HEADER]: credential }, + }); + const body = (await response.json()) as Record; + + expect(response.status).toBe(networkMismatch.expectedStatus); + expect(body.code).toBe(networkMismatch.expectedCode); + }, 30_000); + } else { + it.skip("x402-exact-network-mismatch scenario missing", () => {}); + } + + if (crossRouteReplay) { + it("cross-route replay credential is rejected with canonical `charge_request_mismatch`", async () => { + // The credential's payload.resource pins it to the issuing route + // (the cheap source). Replaying against the expensive route must + // surface `charge_request_mismatch` at the verifier, not settle + // and not surface `signature_consumed` (the signature has not + // been consumed yet on the target route). + const serverEnv = { + X402_INTEROP_NETWORK: TS_NETWORK_A, + // Server resource path = the expensive (target) route. The + // server only knows one route at a time in this fixture; + // cross-route replay is asserted by sending a credential whose + // payload.resource diverges from the server's advertised + // route. + X402_INTEROP_RESOURCE_PATH: crossRouteReplay.resourcePath, + X402_INTEROP_PRICE: crossRouteReplay.price, + }; + currentServer = await startServer(tsServer, serverEnv); + const port = currentServer.ready.port; + if (!port) throw new Error("server did not report a port"); + const url = `http://127.0.0.1:${port}${crossRouteReplay.resourcePath}`; + + const challengeId = await bootstrapChallengeId(port, crossRouteReplay.resourcePath); + + const sourcePath = crossRouteReplay.replaySource?.resourcePath ?? "/protected/cheap"; + const credential = encodeCredential({ + x402Version: 2, + accepted: { + scheme: "exact", + network: TS_NETWORK_A, + asset: crossRouteReplay.asset, + payTo: "5xYbHvVQfTUyzCzKx5KjVxyqXqQ4Ujm5SbqQXJ5w8nVA", + amount: crossRouteReplay.amount, + extra: null, + }, + payload: { + challengeId, + // Pinned to the cheap source route; the server is serving + // the expensive route — mismatch. + resource: sourcePath, + }, + resource: sourcePath, + }); + + const response = await fetch(url, { + headers: { [PAYMENT_SIGNATURE_HEADER]: credential }, + }); + const body = (await response.json()) as Record; + + expect(response.status).toBe(crossRouteReplay.expectedStatus); + expect(body.code).toBe(crossRouteReplay.expectedCode); + }, 30_000); + } else { + it.skip("x402-exact-cross-route-replay scenario missing", () => {}); + } +}); 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 3ff9a896c..d604f59a7 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 745f0a9c5..6f68e5b4e 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/php/.php-cs-fixer.dist.php b/php/.php-cs-fixer.dist.php index fe48bd1f9..d054ba365 100644 --- a/php/.php-cs-fixer.dist.php +++ b/php/.php-cs-fixer.dist.php @@ -18,7 +18,7 @@ __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 b02276e02..6abd3bdce 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 548bbfe80..7ae912c65 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 5503f2f06..589f79f44 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 a6c46b17e..8b07a8a6a 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 9036e0572..a4ccbe4d7 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 @@ -23,7 +23,7 @@ import pytest _REPO_ROOT = Path(__file__).resolve().parents[2] -_ADAPTER = _REPO_ROOT / "tests" / "interop" / "python-server" / "main.py" +_ADAPTER = _REPO_ROOT / "harness" / "python-server" / "main.py" def _wait_for_port(port: int, timeout: float = 5.0) -> None: diff --git a/ruby/README.md b/ruby/README.md index 46068c53e..ed52a6410 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 691cb3ad4..c22a74747 100644 --- a/ruby/lib/mpp/error_codes.rb +++ b/ruby/lib/mpp/error_codes.rb @@ -89,7 +89,7 @@ module ErrorCodes # (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 35a75a320..3342d4ff6 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 34a5a74e8..387dd1e60 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 ea098143b..375d581d8 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 9eacca898..1dbda8c9f 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 cf74684c4..046ce16f8 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 9d20211e6..01372ccea 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 12170c860..df4fe6440 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 ce68baa29..e392dc9e1 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 5ad0c6441..a495f7231 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 52c3a8638..70b82a244 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 3b39ee16a..16ca3eb01 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 38ff3a21b..8a47f4b1d 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 681f885b4..9b606854c 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 ```