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..05d060858 100644
--- a/.github/workflows/python.yml
+++ b/.github/workflows/python.yml
@@ -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..b7392fbfd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,9 +28,11 @@ __pycache__/
.coverage
.venv/
*.pyc
-tests/interop/go-client/go-client
+harness/go-client/go-client
.claude/
.gocache
mpp-sdk-self-learning/
.build/
go/coverage.out
+notes/codex-review/
+notes/codex-review-*.md
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 |  | `just py-test` |
| Lua |  | `just lua-test` |
| Ruby |  | `just rb-test-cover` |
-| Interop |  | `cd tests/interop && pnpm test` |
+| Interop |  | `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/kotlin/.gitignore b/kotlin/.gitignore
new file mode 100644
index 000000000..a9b79fa21
--- /dev/null
+++ b/kotlin/.gitignore
@@ -0,0 +1,6 @@
+.gradle/
+build/
+!gradle-wrapper.jar
+local.properties
+*.iml
+.idea/
diff --git a/kotlin/README.md b/kotlin/README.md
new file mode 100644
index 000000000..69d7296b6
--- /dev/null
+++ b/kotlin/README.md
@@ -0,0 +1,224 @@
+
+
+
+
+# org.solana.x402.exact
+
+Kotlin client for the [x402](https://x402.org) `exact` payment scheme on
+Solana. Pay any HTTP endpoint that responds with `402 Payment Required`
+in JVM applications, with a small dependency footprint and a wire format
+that mirrors the Rust spine byte-for-byte.
+
+[]()
+[]()
+
+## Repo layout
+
+```text
+kotlin/
+├── build.gradle.kts # Kotlin JVM toolchain, gson, JUnit
+├── settings.gradle.kts
+├── src/main/kotlin/org/solana/x402/exact/
+│ ├── ExactChallenge.kt # 402 challenge parsing, SVM network table,
+│ │ # stablecoin mint resolution, selection logic
+│ ├── ExactPaymentClient.kt # Unsigned tx builder interface, signer
+│ │ # interface, X-PAYMENT header assembly
+│ ├── SolanaTransaction.kt # v0 message codec, instruction layout,
+│ │ # ATA derivation, signature framing
+│ └── InteropClient.kt # Command-line interop driver consumed by
+│ # harness
+└── src/test/kotlin/org/solana/x402/exact/
+ ├── ExactChallengeTest.kt
+ ├── ExactPaymentClientTest.kt
+ └── SolanaTransactionTest.kt
+```
+
+Package and directory layout follows the canonical Solana JVM convention
+(`org.solana.x402.exact`) so the namespace is stable across artifact
+publication, IDE navigation, and the JVM ecosystem at large.
+
+## Scope
+
+This module is **client-only**. It builds the `X-PAYMENT` header for a
+Solana `exact` payment requirement and re-issues the request. An x402
+server in Kotlin is not in scope; the Rust spine in `rust/` is the
+canonical server reference and is the facilitator for the interop
+harness.
+
+## Quick start, client
+
+```kotlin
+import org.solana.x402.exact.ExactChallenge
+import org.solana.x402.exact.ExactPaymentClient
+import org.solana.x402.exact.DefaultSolanaExactTransactionBuilder
+import org.solana.x402.exact.JsonRpcSolanaClient
+import org.solana.x402.exact.MemorySolanaTransactionSigner
+import java.net.HttpURLConnection
+import java.net.URI
+
+val signer = MemorySolanaTransactionSigner.fromJsonByteArray(secretKeyJson)
+val rpc = JsonRpcSolanaClient("https://api.mainnet-beta.solana.com")
+val client = ExactPaymentClient(DefaultSolanaExactTransactionBuilder(rpc), signer)
+
+val first = (URI("https://api.example.com/paid").toURL().openConnection() as HttpURLConnection)
+val challenge = ExactChallenge.selectSvmChallenge(
+ headers = first.headerFields.mapValues { it.value.joinToString(",") },
+ body = first.errorStream?.bufferedReader()?.readText() ?: "",
+ network = ExactChallenge.DEFAULT_NETWORK,
+ scheme = "exact",
+ preferredCurrencies = listOf("USDC"),
+) ?: error("no Solana exact challenge")
+
+val headers = client.createPaymentHeaders(challenge, signer.publicKey.base58)
+// Re-issue the request with `headers` attached. The facilitator returns
+// 200 plus an `X-FIXTURE-SETTLEMENT` header carrying the on-chain
+// signature once the transaction lands.
+```
+
+The `signer.publicKey.base58` argument is the on-chain payer; the
+builder fills in the fee payer slot, derives the source associated
+token account, and resolves the mint and decimals from the SVM
+stablecoin table embedded in `ExactChallenge`.
+
+## Install
+
+Add the module to your Gradle project. While the artifact is not
+published to Maven Central, depend on it through a composite build or
+`includeBuild`:
+
+```kotlin
+// settings.gradle.kts
+includeBuild("../mpp-sdk/kotlin")
+```
+
+```kotlin
+// build.gradle.kts
+dependencies {
+ implementation("org.solana.x402:exact")
+ implementation("com.google.code.gson:gson:2.13.2")
+}
+
+kotlin {
+ jvmToolchain(17)
+}
+```
+
+Runtime dependencies are intentionally lean: Gson for JSON, the Kotlin
+standard library, and the JVM. No `web3-solana`, no `multimult`, no
+umbrella SDK.
+
+## Client compatibility matrix
+
+The Kotlin client targets the x402 `exact` scheme on Solana. The Rust
+spine serves as the facilitator across the interop harness.
+
+| Intent | Status |
+|---|:---:|
+| `x402/exact` | available |
+| `x402/upto` | ___ |
+| `x402/batch-settlement` | ___ |
+| `mpp/charge/pull` | ___ |
+| `mpp/charge/push` | ___ |
+
+## Server compatibility matrix
+
+Kotlin does not ship a server. Pair this client with the Rust spine
+under `rust/` or any spec-compliant x402 facilitator.
+
+| Intent | Status |
+|---|:---:|
+| `x402/exact` | ___ |
+| `x402/upto` | ___ |
+| `x402/batch-settlement` | ___ |
+
+## Solana dependencies
+
+Solana primitives are vendored in `SolanaTransaction.kt` to keep the
+dependency footprint small and the on-wire bytes locked to the Rust
+spine.
+
+| Dependency | Why | Version |
+|---|---|---|
+| `kotlin-stdlib` | language runtime | 2.3.x |
+| `com.google.code.gson` | JSON encode and decode | 2.13.2 |
+| `java.net.HttpURLConnection` | JSON-RPC and HTTP | system |
+| `java.security.MessageDigest` | SHA-256 for PDA derivation | system |
+| Ed25519 (vendored) | signing, on-curve check | in-tree |
+
+There is no umbrella Solana JVM dependency. Base58, the v0 message
+codec, instruction encoding, associated-token-account derivation, and
+the Curve25519 on-curve check are all in-tree, with golden vectors
+pinned against the Rust spine in `src/test/kotlin/`.
+
+## Coding convention
+
+This module follows
+[Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html)
+and standard Kotlin JVM idioms: explicit visibility on the public
+surface, immutable `data class` wire types, `sealed class` for closed
+network enumerations, `fun interface` for small builder and signer
+SAMs, and `require(...)` for caller-controlled validation.
+
+JVM target is 17 (`kotlin { jvmToolchain(17) }`). Formatting and
+linting are not enforced by CI on the Kotlin module today; `ktlint`
+and `detekt` are reasonable defaults for contributors and are tracked
+in the broader Kotlin tooling backlog.
+
+The repo-level pay-sdk implementation guidance is the protocol source
+of truth: the Rust spine wire format first, Kotlin idioms second.
+
+## Tests and coverage
+
+```bash
+cd kotlin
+./gradlew test
+```
+
+The suite pins parity against the Rust spine through golden vectors:
+
+- base58 alphabet round-trip
+- v0 transaction codec (legacy SOL transfer, SPL `transferChecked`,
+ multi-instruction with compute-budget prefix)
+- ATA derivation across known mint and owner pairs
+- Curve25519 on-curve check for PDA candidates
+- Ed25519 signing length and verification
+- Challenge selection for multi-requirement 402 bodies, including
+ preferred-currency ordering and unknown-network rejection
+- End-to-end payment header build for `exact` with the in-tree memory
+ signer
+
+Test runs produce JUnit XML under `kotlin/build/test-results/test/`.
+The repository-level coverage policy targets a 90% line threshold for
+the `org.solana.x402.exact` package; the JaCoCo wiring is tracked
+separately.
+
+## Interop
+
+The Kotlin x402 client runs against the interop harness at
+`harness`, driven by the JVM entry point
+`org.solana.x402.exact.InteropClientKt` exposed through the
+`runInteropClient` Gradle task. Adapter registration lives alongside
+the other client adapters in `harness/src/`.
+
+Focused matrix command:
+
+```bash
+cd harness
+MPP_INTEROP_CLIENTS=kotlin MPP_INTEROP_SERVERS=rust pnpm exec vitest run
+```
+
+The Kotlin client is verified against the Rust spine, which is the
+canonical facilitator for the interop matrix.
+
+## Spec
+
+- [x402 protocol](https://x402.org)
+- [Machine Payments Protocol](https://mpp.dev)
+- [paymentauth.org](https://paymentauth.org), HTTP `402 Payment
+ Required` flow definition
+- Rust spine, `rust/crates/x402/`, is the on-wire reference for the
+ `exact` scheme on Solana
+
+## License
+
+Apache-2.0
diff --git a/kotlin/build.gradle.kts b/kotlin/build.gradle.kts
new file mode 100644
index 000000000..63c1c7ada
--- /dev/null
+++ b/kotlin/build.gradle.kts
@@ -0,0 +1,32 @@
+plugins {
+ kotlin("jvm") version "2.3.21"
+ application
+}
+
+group = "org.solana.x402"
+version = "0.0.0-local"
+
+kotlin {
+ jvmToolchain(17)
+}
+
+dependencies {
+ implementation("com.google.code.gson:gson:2.13.2")
+
+ testImplementation(kotlin("test"))
+}
+
+application {
+ mainClass.set("org.solana.x402.exact.InteropClientKt")
+}
+
+tasks.test {
+ useJUnitPlatform()
+}
+
+tasks.register("runInteropClient") {
+ group = "verification"
+ description = "Runs the Kotlin x402 exact interop client."
+ classpath = sourceSets.main.get().runtimeClasspath
+ mainClass.set("org.solana.x402.exact.InteropClientKt")
+}
diff --git a/kotlin/settings.gradle.kts b/kotlin/settings.gradle.kts
new file mode 100644
index 000000000..02cd35ef6
--- /dev/null
+++ b/kotlin/settings.gradle.kts
@@ -0,0 +1,15 @@
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ mavenCentral()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ mavenCentral()
+ }
+}
+
+rootProject.name = "mpp-x402-kotlin"
diff --git a/kotlin/src/main/kotlin/org/solana/x402/exact/ExactChallenge.kt b/kotlin/src/main/kotlin/org/solana/x402/exact/ExactChallenge.kt
new file mode 100644
index 000000000..dede8a022
--- /dev/null
+++ b/kotlin/src/main/kotlin/org/solana/x402/exact/ExactChallenge.kt
@@ -0,0 +1,316 @@
+package org.solana.x402.exact
+
+import com.google.gson.Gson
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import java.util.Base64
+
+data class PaymentRequirement(
+ val scheme: String,
+ val network: String,
+ val asset: String,
+ val amount: String,
+ val payTo: String? = null,
+ val maxTimeoutSeconds: Int? = null,
+ val extra: Map = emptyMap(),
+ val raw: JsonObject,
+)
+
+data class ResourceInfo(
+ val url: String? = null,
+ val description: String? = null,
+ val mimeType: String? = null,
+ val raw: JsonObject = JsonObject(),
+)
+
+data class SelectedChallenge(
+ val requirement: PaymentRequirement,
+ val resource: ResourceInfo? = null,
+)
+
+/**
+ * Closed enumeration of the Solana networks recognised by the exact resolver.
+ * Anything not in this set is treated as "unknown" and the resolver fails closed
+ * rather than silently producing a mainnet mint address.
+ */
+sealed class SolanaNetwork(val caip2: String) {
+ // Aligned to Rust spine SOLANA_MAINNET constant (32-char canonical prefix
+ // of the genesis hash); see rust/crates/x402/src/protocol/schemes/exact/types.rs.
+ object Mainnet : SolanaNetwork("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp")
+ object Devnet : SolanaNetwork("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1")
+ object Localnet : SolanaNetwork("solana:localnet")
+
+ companion object {
+ // Canonical CAIP-2 strings plus the historical "devnet" short string used by
+ // the harness fixture (which the implementation has always treated as devnet).
+ fun fromIdentifierOrNull(value: String): SolanaNetwork? = when (value) {
+ Mainnet.caip2,
+ "solana:mainnet",
+ "solana-mainnet",
+ "mainnet",
+ "mainnet-beta",
+ -> Mainnet
+ Devnet.caip2,
+ "solana:devnet",
+ "solana-devnet",
+ "devnet",
+ -> Devnet
+ Localnet.caip2,
+ "localnet",
+ -> Localnet
+ else -> null
+ }
+ }
+}
+
+object ExactChallenge {
+ // Default network used by the interop harness fixture — this is the Solana
+ // devnet CAIP-2 genesis hash. Kept as a string for backwards compatibility
+ // with callers that compare against it directly.
+ const val DEFAULT_NETWORK = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"
+ private val gson = Gson()
+
+ fun selectSvmChallenge(
+ headers: Map,
+ body: String?,
+ network: String = DEFAULT_NETWORK,
+ scheme: String = "exact",
+ preferredCurrencies: List = emptyList(),
+ ): SelectedChallenge? {
+ val envelopes = listOfNotNull(
+ paymentRequiredHeader(headers),
+ paymentRequiredBody(body),
+ )
+
+ for (envelope in envelopes) {
+ val candidates = accepts(envelope)
+ .filter { it.scheme == scheme && it.network == network }
+ .filter { it.asset.isNotBlank() && it.amount.isNotBlank() }
+ // Native SOL transfers are supported by the Rust spine
+ // (rust/crates/x402/src/client/exact/payment.rs builds a
+ // System Program transfer for `asset: "SOL"`) but this
+ // Kotlin client is SPL-only — the builder decodes `asset`
+ // as a base58 mint and emits transferChecked. Filter
+ // native SOL offers out at selection time so we fall
+ // through to a supported SPL candidate, or return null
+ // rather than crashing later inside Base58.decode("SOL").
+ .filter { !it.isNativeSol() }
+
+ if (candidates.isEmpty()) {
+ continue
+ }
+
+ val resource = resource(envelope)
+ if (preferredCurrencies.isNotEmpty()) {
+ for (currency in preferredCurrencies) {
+ val selected = candidates.firstOrNull {
+ currencyMatches(it.asset, currency, network) ||
+ currencyMatches(it.raw.string("currency"), currency, network)
+ }
+ if (selected != null) {
+ return SelectedChallenge(selected, resource)
+ }
+ }
+ continue
+ }
+
+ return SelectedChallenge(
+ candidates.minBy { it.amount.toULongOrNull() ?: ULong.MAX_VALUE },
+ resource,
+ )
+ }
+
+ return null
+ }
+
+ private fun paymentRequiredHeader(headers: Map): JsonObject? {
+ val encoded = headers.entries
+ .firstOrNull { it.key.equals("PAYMENT-REQUIRED", ignoreCase = true) }
+ ?.value
+ ?: return null
+
+ return try {
+ val decoded = String(Base64.getDecoder().decode(encoded), Charsets.UTF_8)
+ JsonParser.parseString(decoded).asJsonObjectOrNull()
+ } catch (_: RuntimeException) {
+ null
+ }
+ }
+
+ private fun paymentRequiredBody(body: String?): JsonObject? {
+ if (body.isNullOrBlank()) {
+ return null
+ }
+
+ return try {
+ JsonParser.parseString(body).asJsonObjectOrNull()
+ } catch (_: RuntimeException) {
+ null
+ }
+ }
+
+ private fun PaymentRequirement.isNativeSol(): Boolean = asset.equals("SOL", ignoreCase = true)
+
+ private fun accepts(envelope: JsonObject): List {
+ val accepts = envelope.get("accepts")?.asJsonArray ?: return emptyList()
+
+ return accepts.mapNotNull { entry ->
+ val obj = entry.asJsonObjectOrNull() ?: return@mapNotNull null
+ val scheme = obj.string("scheme") ?: return@mapNotNull null
+ val network = obj.string("network") ?: return@mapNotNull null
+ val asset = obj.string("asset") ?: return@mapNotNull null
+ // Accept both `amount` and the canonical x402 wire field
+ // `maxAmountRequired`. Rust spine canonicalises the same way at
+ // rust/crates/x402/src/protocol/schemes/exact/types.rs (see the
+ // `string_field(object, "amount").or_else(|| string_field(object,
+ // "maxAmountRequired"))` fallback). The TS fixture and other ports
+ // emit `maxAmountRequired`, so reading only `amount` would silently
+ // drop every spine-shaped challenge.
+ val amount = obj.string("amount")
+ ?: obj.string("maxAmountRequired")
+ ?: return@mapNotNull null
+ PaymentRequirement(
+ scheme = scheme,
+ network = network,
+ asset = asset,
+ amount = amount,
+ payTo = obj.string("payTo"),
+ maxTimeoutSeconds = obj.get("maxTimeoutSeconds")?.takeIf { it.isJsonPrimitive }?.asInt,
+ extra = obj.get("extra")?.asJsonObjectOrNull()?.entrySet()
+ ?.associate { it.key to it.value }
+ ?: emptyMap(),
+ raw = obj,
+ )
+ }
+ }
+
+ private fun resource(envelope: JsonObject): ResourceInfo? {
+ val obj = envelope.get("resource")?.asJsonObjectOrNull() ?: return null
+ return ResourceInfo(
+ url = obj.string("url"),
+ description = obj.string("description"),
+ mimeType = obj.string("mimeType"),
+ raw = obj,
+ )
+ }
+
+ private fun currencyMatches(offered: String?, accepted: String, network: String): Boolean {
+ if (offered.isNullOrBlank()) {
+ return false
+ }
+ // stablecoinMint fails closed on unknown networks for known symbols by
+ // throwing IllegalArgumentException. In the context of preference matching
+ // an unresolvable pair simply means "not a match" — never let the throw
+ // escape and break the entire challenge-selection loop for unrelated
+ // requirements.
+ val offeredMint = runCatching { stablecoinMint(offered, network) }.getOrNull() ?: return false
+ val acceptedMint = runCatching { stablecoinMint(accepted, network) }.getOrNull() ?: return false
+ return offeredMint == acceptedMint
+ }
+
+ /**
+ * Resolves a stablecoin symbol (USDC, PYUSD, USDG, USDT, CASH) to its mint address
+ * on the given Solana network. Fail-closed by design: only the canonical CAIP-2
+ * Solana network identifiers (mainnet, devnet, localnet) are accepted as network
+ * inputs. Any other string is treated as either (a) an already-resolved mint that
+ * gets returned verbatim, or (b) an unknown network that throws — never a silent
+ * mainnet fallback. This closes the "bare-string devnet leaks mainnet mint" bug.
+ */
+ fun stablecoinMint(currency: String, network: String): String {
+ val resolved = SolanaNetwork.fromIdentifierOrNull(network)
+ if (resolved == null) {
+ // Unknown network identifier — if the currency is already a non-symbolic
+ // address-shaped string, pass it through (legacy behaviour for callers
+ // that hand us a mint directly). Otherwise we must fail closed rather
+ // than silently picking a mainnet address.
+ val trimmed = currency.trim()
+ val upper = trimmed.uppercase()
+ if (upper in KNOWN_SYMBOLS) {
+ throw IllegalArgumentException(
+ "Cannot resolve stablecoin symbol '$trimmed' on unknown network '$network'; " +
+ "use a CAIP-2 Solana network identifier (solana:) or " +
+ "pass a mint address directly.",
+ )
+ }
+ return trimmed
+ }
+ return stablecoinMint(currency, resolved)
+ }
+
+ fun stablecoinMint(currency: String, network: SolanaNetwork): String {
+ val trimmed = currency.trim()
+ return when (trimmed.uppercase()) {
+ "USDC", "USD" -> when (network) {
+ SolanaNetwork.Mainnet -> "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
+ SolanaNetwork.Devnet, SolanaNetwork.Localnet ->
+ "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"
+ }
+ "PYUSD" -> when (network) {
+ SolanaNetwork.Mainnet -> "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo"
+ SolanaNetwork.Devnet, SolanaNetwork.Localnet ->
+ "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM"
+ }
+ "USDG" -> when (network) {
+ SolanaNetwork.Mainnet -> "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH"
+ SolanaNetwork.Devnet, SolanaNetwork.Localnet ->
+ "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7"
+ }
+ // USDT and CASH currently have no canonical devnet mint inside the
+ // x402 SVM test matrix; the interop harness only exercises them on
+ // mainnet, so we return the mainnet mint here and rely on the
+ // mainnet-only network resolver to fail closed on any other cluster.
+ "USDT" -> when (network) {
+ SolanaNetwork.Mainnet -> "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
+ SolanaNetwork.Devnet, SolanaNetwork.Localnet ->
+ throw IllegalArgumentException(
+ "USDT has no canonical mint on $network in this adapter; " +
+ "supply the mint address explicitly",
+ )
+ }
+ "CASH" -> when (network) {
+ SolanaNetwork.Mainnet -> "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH"
+ SolanaNetwork.Devnet, SolanaNetwork.Localnet ->
+ throw IllegalArgumentException(
+ "CASH has no canonical mint on $network in this adapter; " +
+ "supply the mint address explicitly",
+ )
+ }
+ else -> trimmed
+ }
+ }
+
+ private val KNOWN_SYMBOLS = setOf("USDC", "USD", "PYUSD", "USDG", "USDT", "CASH")
+
+ fun resultJson(
+ ok: Boolean,
+ status: Int,
+ responseHeaders: Map = emptyMap(),
+ responseBody: Any? = null,
+ settlement: Any? = null,
+ error: String? = null,
+ ): String {
+ val payload = linkedMapOf(
+ "type" to "result",
+ "implementation" to "kotlin",
+ "role" to "client",
+ "ok" to ok,
+ "status" to status,
+ "responseHeaders" to responseHeaders,
+ "responseBody" to responseBody,
+ )
+ if (error != null) {
+ payload["error"] = error
+ }
+ if (settlement != null) {
+ payload["settlement"] = settlement
+ }
+ return gson.toJson(payload)
+ }
+}
+
+private fun JsonElement.asJsonObjectOrNull(): JsonObject? =
+ if (isJsonObject) asJsonObject else null
+
+private fun JsonObject.string(name: String): String? =
+ get(name)?.takeIf { it.isJsonPrimitive }?.asString
diff --git a/kotlin/src/main/kotlin/org/solana/x402/exact/ExactPaymentClient.kt b/kotlin/src/main/kotlin/org/solana/x402/exact/ExactPaymentClient.kt
new file mode 100644
index 000000000..f1fc2a488
--- /dev/null
+++ b/kotlin/src/main/kotlin/org/solana/x402/exact/ExactPaymentClient.kt
@@ -0,0 +1,232 @@
+package org.solana.x402.exact
+
+import com.google.gson.Gson
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import java.util.Base64
+
+const val PAYMENT_SIGNATURE_HEADER = "PAYMENT-SIGNATURE"
+const val MAX_MEMO_BYTES = 256
+
+data class SolanaExactPaymentRequest(
+ val payer: String,
+ val network: String,
+ val asset: String,
+ val amount: String,
+ val payTo: String,
+ /**
+ * Optional managed fee payer. Mirrors the Rust spine client at
+ * rust/crates/x402/src/client/exact/payment.rs which falls back to the
+ * signer (`payer`) as the actual transaction fee payer when
+ * `requirements.fee_payer_key` is absent.
+ */
+ val feePayer: String?,
+ val memo: String?,
+ val maxTimeoutSeconds: Int?,
+ val accepted: JsonObject,
+)
+
+data class UnsignedSolanaTransaction(
+ val message: ByteArray,
+ val signatures: List,
+ val signerIndex: Int,
+) {
+ init {
+ require(message.isNotEmpty()) { "message is required" }
+ require(signatures.isNotEmpty()) { "at least one signature slot is required" }
+ require(signerIndex in signatures.indices) { "signerIndex is outside signature slots" }
+ signatures.forEach { signature ->
+ require(signature.size == SIGNATURE_LENGTH) { "signature slots must be 64 bytes" }
+ }
+ }
+
+ fun signedWith(signature: ByteArray): ByteArray {
+ require(signature.size == SIGNATURE_LENGTH) { "signature must be 64 bytes" }
+ val finalSignatures = signatures.toMutableList()
+ finalSignatures[signerIndex] = signature
+ return SolanaTransactionCodec.serializeTransaction(finalSignatures, message)
+ }
+
+ companion object {
+ const val SIGNATURE_LENGTH = 64
+ }
+}
+
+fun interface SolanaExactTransactionBuilder {
+ fun buildUnsignedTransaction(request: SolanaExactPaymentRequest): UnsignedSolanaTransaction
+}
+
+fun interface SolanaTransactionSigner {
+ fun signMessage(message: ByteArray): ByteArray
+}
+
+data class ExactPaymentPayload(
+ val x402Version: Int,
+ val accepted: JsonObject,
+ val transaction: String,
+ val resource: ResourceInfo?,
+)
+
+class ExactPaymentClient(
+ private val transactionBuilder: SolanaExactTransactionBuilder,
+ private val signer: SolanaTransactionSigner,
+) {
+ fun createPaymentHeaders(
+ selected: SelectedChallenge,
+ payer: String,
+ x402Version: Int = 2,
+ ): Map =
+ mapOf(PAYMENT_SIGNATURE_HEADER to createPaymentHeaderValue(selected, payer, x402Version))
+
+ fun createPaymentHeaderValue(
+ selected: SelectedChallenge,
+ payer: String,
+ x402Version: Int = 2,
+ ): String {
+ val payload = createPaymentPayload(selected, payer, x402Version)
+ val envelope = JsonObject().apply {
+ addProperty("x402Version", payload.x402Version)
+ add("accepted", payload.accepted)
+ payload.resource?.let { add("resource", it.toJsonObject()) }
+ add(
+ "payload",
+ JsonObject().apply {
+ addProperty("transaction", payload.transaction)
+ },
+ )
+ }
+
+ return Base64.getEncoder().encodeToString(gson.toJson(envelope).toByteArray(Charsets.UTF_8))
+ }
+
+ fun createPaymentPayload(
+ selected: SelectedChallenge,
+ payer: String,
+ x402Version: Int = 2,
+ ): ExactPaymentPayload {
+ require(x402Version == 2) { "Only x402Version 2 is supported by the Kotlin exact scaffold" }
+ require(payer.isNotBlank()) { "payer is required for SVM exact payment requests" }
+
+ val request = selected.toRequest(payer)
+ val unsignedTransaction = transactionBuilder.buildUnsignedTransaction(request)
+
+ val signedTransaction = unsignedTransaction.signedWith(signer.signMessage(unsignedTransaction.message))
+
+ return ExactPaymentPayload(
+ x402Version = x402Version,
+ accepted = request.accepted,
+ transaction = Base64.getEncoder().encodeToString(signedTransaction),
+ resource = selected.resource,
+ )
+ }
+
+ private fun SelectedChallenge.toRequest(payer: String): SolanaExactPaymentRequest {
+ val requirement = requirement
+ require(requirement.scheme == "exact") { "Only exact payment requirements are supported" }
+ require(requirement.network.startsWith("solana:")) {
+ "Only Solana CAIP-2 exact payment requirements are supported"
+ }
+ require(requirement.asset.isNotBlank()) { "asset is required for SVM exact payment requirements" }
+ require(requirement.amount.toULongOrNull() != null) {
+ "amount must be an unsigned integer string"
+ }
+
+ val payTo = requirement.payTo?.takeIf { it.isNotBlank() }
+ ?: throw IllegalArgumentException("payTo is required for SVM exact payment requirements")
+ // Fail-fast on a self-transfer challenge: when payTo equals the payer wallet
+ // the SPL Token program rejects the transfer on-chain (source and destination
+ // ATAs are identical). Catch this on the client before any Base58 decoding,
+ // ATA derivation, or RPC work happens.
+ require(payTo != payer) { "payTo must differ from payer (self-transfer)" }
+ // Managed fee payer is optional. Rust spine
+ // (rust/crates/x402/src/client/exact/payment.rs) treats
+ // `requirements.fee_payer_key` as optional and falls back to the
+ // signer (`payer`) as the actual transaction fee payer when absent.
+ // When present, it must be operationally distinct from the transfer
+ // authority and the recipient — otherwise a malicious server
+ // challenge could either drain the user's wallet via fee
+ // attribution or create a self-pay loop.
+ val feePayer = requirement.extra.string("feePayer")
+ if (feePayer != null) {
+ require(feePayer != payer) {
+ "managed fee payer must differ from the transfer authority (payer)"
+ }
+ require(payTo != feePayer) { "payTo must differ from the managed fee payer" }
+ }
+ // Reject server-supplied tokenProgram values that are not on the
+ // canonical SPL allowlist (classic SPL Token or Token-2022). Otherwise
+ // a malicious server can set extra.tokenProgram to an arbitrary
+ // executable program ID and have the user sign a transferChecked
+ // instruction routed through that program. Validate before any
+ // transaction-building, RPC or signing work happens.
+ requirement.extra.string("tokenProgram")?.let { requireAllowedTokenProgram(it) }
+ val memo = requirement.extra.string("memo")
+ if (memo != null && memo.toByteArray(Charsets.UTF_8).size > MAX_MEMO_BYTES) {
+ throw IllegalArgumentException("extra.memo exceeds maximum $MAX_MEMO_BYTES bytes")
+ }
+
+ return SolanaExactPaymentRequest(
+ payer = payer,
+ network = requirement.network,
+ asset = requirement.asset,
+ amount = requirement.amount,
+ payTo = payTo,
+ feePayer = feePayer,
+ memo = memo,
+ maxTimeoutSeconds = requirement.maxTimeoutSeconds,
+ accepted = requirement.toAcceptedJson(),
+ )
+ }
+
+ private fun PaymentRequirement.toAcceptedJson(): JsonObject {
+ // Canonical v2 accepted shape. Mirrors rust spine
+ // `PaymentRequirements::to_accepted_value` at
+ // rust/crates/x402/src/protocol/schemes/exact/types.rs so the
+ // credential's `accepted` round-trips identically when the rust
+ // server re-serialises both sides via the same Serialize impl
+ // inside `find_matching_requirement`. Echoing the raw offered
+ // object verbatim would leak deprecated aliases (`maxAmountRequired`,
+ // `currency`, `recipient`) into the credential and cause the
+ // structural-equality match to fail even though the underlying
+ // values agree.
+ val accepted = raw.deepCopy()
+ // Strip deprecated wire aliases that we have already promoted to
+ // canonical field names on the typed `PaymentRequirement`.
+ accepted.remove("maxAmountRequired")
+ accepted.remove("currency")
+ accepted.remove("recipient")
+ accepted.addProperty("scheme", scheme)
+ accepted.addProperty("network", network)
+ accepted.addProperty("asset", asset)
+ accepted.addProperty("amount", amount)
+ payTo?.let { accepted.addProperty("payTo", it) }
+ maxTimeoutSeconds?.let { accepted.addProperty("maxTimeoutSeconds", it) }
+ if (!accepted.has("extra")) {
+ accepted.add(
+ "extra",
+ JsonObject().apply {
+ extra.forEach { (key, value) -> add(key, value.deepCopy()) }
+ },
+ )
+ }
+ return accepted
+ }
+
+ private fun ResourceInfo.toJsonObject(): JsonObject {
+ val obj = raw.deepCopy()
+ url?.let { obj.addProperty("url", it) }
+ description?.let { obj.addProperty("description", it) }
+ mimeType?.let { obj.addProperty("mimeType", it) }
+ return obj
+ }
+
+ private fun Map.string(name: String): String? =
+ get(name)
+ ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
+ ?.asString
+ ?.takeIf { it.isNotBlank() }
+
+ private companion object {
+ val gson = Gson()
+ }
+}
diff --git a/kotlin/src/main/kotlin/org/solana/x402/exact/InteropClient.kt b/kotlin/src/main/kotlin/org/solana/x402/exact/InteropClient.kt
new file mode 100644
index 000000000..0eb71929b
--- /dev/null
+++ b/kotlin/src/main/kotlin/org/solana/x402/exact/InteropClient.kt
@@ -0,0 +1,113 @@
+package org.solana.x402.exact
+
+import java.net.HttpURLConnection
+import java.net.URI
+
+fun main() {
+ val targetUrl = System.getenv("X402_INTEROP_TARGET_URL")
+
+ if (targetUrl.isNullOrBlank()) {
+ println(
+ ExactChallenge.resultJson(
+ ok = false,
+ status = 0,
+ error = "X402_INTEROP_TARGET_URL is required",
+ ),
+ )
+ return
+ }
+
+ try {
+ val signer = MemorySolanaTransactionSigner.fromJsonByteArray(
+ System.getenv("X402_INTEROP_CLIENT_SECRET_KEY")
+ ?: throw IllegalArgumentException("X402_INTEROP_CLIENT_SECRET_KEY is required"),
+ )
+ val rpc = JsonRpcSolanaClient(
+ System.getenv("X402_INTEROP_RPC_URL")
+ ?: throw IllegalArgumentException("X402_INTEROP_RPC_URL is required"),
+ )
+ val paymentClient = ExactPaymentClient(DefaultSolanaExactTransactionBuilder(rpc), signer)
+
+ val firstResponse = get(targetUrl)
+ val selected = ExactChallenge.selectSvmChallenge(
+ headers = firstResponse.headers,
+ body = firstResponse.body,
+ network = System.getenv("X402_INTEROP_NETWORK") ?: ExactChallenge.DEFAULT_NETWORK,
+ scheme = System.getenv("X402_INTEROP_SCHEME") ?: "exact",
+ preferredCurrencies = System.getenv("X402_INTEROP_PREFER_CURRENCIES")
+ ?.split(",")
+ ?.map { it.trim() }
+ ?.filter { it.isNotEmpty() }
+ ?: emptyList(),
+ )
+
+ if (selected == null) {
+ println(
+ ExactChallenge.resultJson(
+ ok = false,
+ status = firstResponse.status,
+ responseHeaders = firstResponse.headers,
+ responseBody = firstResponse.body,
+ error = "No supported Solana exact payment requirement was found",
+ ),
+ )
+ return
+ }
+
+ val headers = paymentClient.createPaymentHeaders(selected, signer.publicKey.base58)
+ val paidResponse = get(targetUrl, headers)
+ println(
+ ExactChallenge.resultJson(
+ ok = paidResponse.status in 200..299,
+ status = paidResponse.status,
+ responseHeaders = paidResponse.headers,
+ responseBody = parseBody(paidResponse.body),
+ settlement = paidResponse.headers.entries
+ .firstOrNull { it.key.equals("x-fixture-settlement", ignoreCase = true) }
+ ?.value,
+ ),
+ )
+ } catch (error: Throwable) {
+ println(
+ ExactChallenge.resultJson(
+ ok = false,
+ status = 0,
+ error = error.message ?: error.toString(),
+ ),
+ )
+ }
+}
+
+private fun parseBody(body: String): Any? {
+ if (body.isBlank()) {
+ return null
+ }
+ return try {
+ com.google.gson.JsonParser.parseString(body)
+ } catch (_: RuntimeException) {
+ body
+ }
+}
+
+private data class HttpResponse(
+ val status: Int,
+ val headers: Map,
+ val body: String,
+)
+
+private fun get(url: String, headers: Map = emptyMap()): HttpResponse {
+ val connection = URI(url).toURL().openConnection() as HttpURLConnection
+ connection.requestMethod = "GET"
+ connection.connectTimeout = 10_000
+ connection.readTimeout = 10_000
+ headers.forEach { (name, value) -> connection.setRequestProperty(name, value) }
+
+ val status = connection.responseCode
+ val stream = if (status >= 400) connection.errorStream else connection.inputStream
+ val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } ?: ""
+ val responseHeaders = connection.headerFields
+ .filterKeys { it != null }
+ .mapValues { (_, values) -> values.joinToString(",") }
+
+ return HttpResponse(status, responseHeaders, body)
+}
diff --git a/kotlin/src/main/kotlin/org/solana/x402/exact/SolanaTransaction.kt b/kotlin/src/main/kotlin/org/solana/x402/exact/SolanaTransaction.kt
new file mode 100644
index 000000000..20ebea3dd
--- /dev/null
+++ b/kotlin/src/main/kotlin/org/solana/x402/exact/SolanaTransaction.kt
@@ -0,0 +1,536 @@
+package org.solana.x402.exact
+
+import com.google.gson.Gson
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import java.math.BigInteger
+import java.net.HttpURLConnection
+import java.net.URI
+import java.security.KeyFactory
+import java.security.MessageDigest
+import java.security.Signature
+import java.security.spec.EdECPrivateKeySpec
+import java.security.spec.NamedParameterSpec
+import kotlin.experimental.and
+
+internal const val TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
+internal const val TOKEN_2022_PROGRAM = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
+
+/**
+ * Canonical SPL token-program allowlist. The exact-svm scheme only supports
+ * the classic SPL Token program and Token-2022. Any other program ID coming
+ * in via `accepted.tokenProgram`, `accepted.extra.tokenProgram`, or the RPC
+ * mint-owner field is rejected — otherwise a malicious server could supply
+ * an arbitrary executable program ID and have the user sign a transaction
+ * routed through it under the guise of `transferChecked`.
+ */
+internal val ALLOWED_TOKEN_PROGRAMS = setOf(TOKEN_PROGRAM, TOKEN_2022_PROGRAM)
+
+internal fun requireAllowedTokenProgram(value: String): String {
+ require(value in ALLOWED_TOKEN_PROGRAMS) {
+ "unsupported tokenProgram: $value (must be SPL Token or Token-2022)"
+ }
+ return value
+}
+private const val ASSOCIATED_TOKEN_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
+private const val COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111"
+private const val MEMO_PROGRAM = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"
+private const val PROGRAM_DERIVED_ADDRESS_MARKER = "ProgramDerivedAddress"
+private const val DEFAULT_DECIMALS = 6
+
+data class SolanaTokenMetadata(
+ val tokenProgram: String,
+ val decimals: Int,
+)
+
+interface SolanaRpc {
+ fun latestBlockhash(): String
+ fun tokenMetadata(mint: String): SolanaTokenMetadata?
+}
+
+class JsonRpcSolanaClient(private val rpcUrl: String) : SolanaRpc {
+ private val gson = Gson()
+
+ override fun latestBlockhash(): String {
+ val result = rpc(
+ "getLatestBlockhash",
+ listOf(mapOf("commitment" to "confirmed")),
+ )
+ return result
+ .getAsJsonObject("value")
+ ?.get("blockhash")
+ ?.asString
+ ?: throw IllegalStateException("getLatestBlockhash response did not include value.blockhash")
+ }
+
+ override fun tokenMetadata(mint: String): SolanaTokenMetadata? {
+ val result = rpc(
+ "getAccountInfo",
+ listOf(mint, mapOf("encoding" to "base64", "commitment" to "confirmed")),
+ )
+ val value = result.getAsJsonObject("value") ?: return null
+ val owner = value.get("owner")?.asString ?: return null
+ val data = value.get("data")
+ ?.takeIf { it.isJsonArray }
+ ?.asJsonArray
+ ?.firstOrNull()
+ ?.asString
+ ?: return SolanaTokenMetadata(tokenProgram = owner, decimals = DEFAULT_DECIMALS)
+ val decoded = java.util.Base64.getDecoder().decode(data)
+ val decimals = decoded.getOrNull(44)?.toInt()?.and(0xff) ?: DEFAULT_DECIMALS
+ return SolanaTokenMetadata(tokenProgram = owner, decimals = decimals)
+ }
+
+ private fun rpc(method: String, params: List): JsonObject {
+ val connection = URI(rpcUrl).toURL().openConnection() as HttpURLConnection
+ connection.requestMethod = "POST"
+ connection.connectTimeout = 10_000
+ connection.readTimeout = 10_000
+ connection.doOutput = true
+ connection.setRequestProperty("content-type", "application/json")
+ val body = gson.toJson(
+ mapOf(
+ "jsonrpc" to "2.0",
+ "id" to "x402-kotlin",
+ "method" to method,
+ "params" to params,
+ ),
+ )
+ connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
+ val status = connection.responseCode
+ val stream = if (status >= 400) connection.errorStream else connection.inputStream
+ val response = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
+ val parsed = JsonParser.parseString(response).asJsonObject
+ parsed.get("error")?.let { error ->
+ throw IllegalStateException("$method RPC failed: $error")
+ }
+ return parsed.getAsJsonObject("result")
+ ?: throw IllegalStateException("$method RPC response did not include result")
+ }
+}
+
+class MemorySolanaTransactionSigner(secretKey: ByteArray) : SolanaTransactionSigner {
+ private val seed: ByteArray
+ val publicKey: SolanaPublicKey
+
+ init {
+ require(secretKey.size == 64 || secretKey.size == 32) {
+ "Solana secret key must be a 32-byte seed or 64-byte seed+public-key array"
+ }
+ seed = secretKey.copyOfRange(0, 32)
+ publicKey = if (secretKey.size == 64) {
+ SolanaPublicKey(secretKey.copyOfRange(32, 64))
+ } else {
+ // JDK Ed25519 exposes signing from a seed but not portable public-key
+ // derivation. Interop uses the Solana 64-byte secret-key shape.
+ throw IllegalArgumentException("32-byte seed cannot derive Solana public key in this adapter")
+ }
+ }
+
+ override fun signMessage(message: ByteArray): ByteArray {
+ val keyFactory = KeyFactory.getInstance("Ed25519")
+ val privateKey = keyFactory.generatePrivate(
+ EdECPrivateKeySpec(NamedParameterSpec("Ed25519"), seed),
+ )
+ return Signature.getInstance("Ed25519").run {
+ initSign(privateKey)
+ update(message)
+ sign()
+ }
+ }
+
+ companion object {
+ fun fromJsonByteArray(raw: String): MemorySolanaTransactionSigner {
+ val bytes = JsonParser.parseString(raw).asJsonArray.map { it.asInt.toByte() }.toByteArray()
+ return MemorySolanaTransactionSigner(bytes)
+ }
+ }
+}
+
+class DefaultSolanaExactTransactionBuilder(
+ private val rpc: SolanaRpc,
+) : SolanaExactTransactionBuilder {
+ override fun buildUnsignedTransaction(request: SolanaExactPaymentRequest): UnsignedSolanaTransaction {
+ val payer = SolanaPublicKey.fromBase58(request.payer)
+ // When the challenge does not supply a managed fee payer, the signer
+ // (payer) becomes the actual fee payer. Mirrors the rust spine fallback
+ // at rust/crates/x402/src/client/exact/payment.rs:
+ // let actual_fee_payer = fee_payer_pubkey.unwrap_or(signer_pubkey);
+ val feePayer = request.feePayer?.let { SolanaPublicKey.fromBase58(it) } ?: payer
+ val mint = SolanaPublicKey.fromBase58(request.asset)
+ val recipient = SolanaPublicKey.fromBase58(request.payTo)
+ if (request.feePayer != null) {
+ require(payer != feePayer) { "managed fee payer must not be the transfer authority" }
+ }
+
+ val metadata = rpc.tokenMetadata(request.asset)
+ val tokenProgramId = request.accepted.string("tokenProgram")
+ ?: request.accepted.extraString("tokenProgram")
+ ?: metadata?.tokenProgram
+ ?: stablecoinTokenProgram(request.asset)
+ // Defence in depth: even though `ExactPaymentClient` already validates
+ // tokenProgram from the server challenge, the builder is a public
+ // entry point and the RPC `owner` field is untrusted data from a
+ // remote node. Reject anything outside the canonical SPL allowlist
+ // before it becomes the programId of the transferChecked instruction.
+ val tokenProgram = SolanaPublicKey.fromBase58(requireAllowedTokenProgram(tokenProgramId))
+ val decimals = request.accepted.int("decimals")
+ ?: request.accepted.extraInt("decimals")
+ ?: metadata?.decimals
+ ?: DEFAULT_DECIMALS
+ // SPL token decimals is a u8 in the on-chain Mint account and is
+ // capped at 9 by the SPL Token program. Reject anything outside that
+ // range so a malicious or buggy server cannot smuggle a wrapping value
+ // (e.g. 256 → 0, -1 → 0xff) into the transferChecked instruction.
+ require(decimals in 0..9) {
+ "decimals $decimals is outside the SPL token range 0..9"
+ }
+ val amount = request.amount.toULongOrNull()
+ ?: throw IllegalArgumentException("amount must be an unsigned integer string")
+ // Spine parity: rust/crates/x402/src/protocol/schemes/exact/verify.rs
+ // parses the amount as `u64`, so the full unsigned-64-bit range
+ // (including values above Long.MAX_VALUE) is valid on the wire. The
+ // instruction encoder writes 8 little-endian bytes for the ULong, so
+ // there is no signed-Long narrowing in the transferChecked data.
+
+ val sourceAta = associatedTokenAddress(owner = payer, mint = mint, tokenProgram = tokenProgram)
+ val destinationAta = associatedTokenAddress(owner = recipient, mint = mint, tokenProgram = tokenProgram)
+ val blockhash = request.accepted.extraString("recentBlockhash") ?: rpc.latestBlockhash()
+
+ val instructions = listOfNotNull(
+ computeUnitLimitInstruction(20_000u),
+ computeUnitPriceInstruction(1u),
+ transferCheckedInstruction(
+ tokenProgram = tokenProgram,
+ source = sourceAta,
+ mint = mint,
+ destination = destinationAta,
+ owner = payer,
+ amount = amount,
+ decimals = decimals,
+ ),
+ memoInstruction(request.memo ?: randomMemo()),
+ )
+ val message = SolanaTransactionCodec.compileV0Message(
+ feePayer = feePayer,
+ signers = listOf(feePayer, payer),
+ instructions = instructions,
+ recentBlockhash = SolanaPublicKey.fromBase58(blockhash),
+ )
+ return UnsignedSolanaTransaction(
+ message = message.serialized,
+ signatures = List(message.requiredSignatures) { ByteArray(UnsignedSolanaTransaction.SIGNATURE_LENGTH) },
+ signerIndex = message.accountKeys.indexOf(payer).also {
+ require(it >= 0) { "payer signer was not included in transaction account keys" }
+ },
+ )
+ }
+}
+
+data class SolanaPublicKey(val bytes: ByteArray) {
+ init {
+ require(bytes.size == 32) { "Solana public keys must be 32 bytes" }
+ }
+
+ val base58: String get() = Base58.encode(bytes)
+
+ override fun equals(other: Any?): Boolean = other is SolanaPublicKey && bytes.contentEquals(other.bytes)
+ override fun hashCode(): Int = bytes.contentHashCode()
+ override fun toString(): String = base58
+
+ companion object {
+ fun fromBase58(value: String): SolanaPublicKey = SolanaPublicKey(Base58.decode(value))
+ }
+}
+
+data class AccountMeta(
+ val publicKey: SolanaPublicKey,
+ val signer: Boolean,
+ val writable: Boolean,
+)
+
+data class SolanaInstruction(
+ val programId: SolanaPublicKey,
+ val accounts: List,
+ val data: ByteArray,
+)
+
+data class CompiledMessage(
+ val serialized: ByteArray,
+ val accountKeys: List,
+ val requiredSignatures: Int,
+)
+
+object SolanaTransactionCodec {
+ fun compileV0Message(
+ feePayer: SolanaPublicKey,
+ signers: List,
+ instructions: List,
+ recentBlockhash: SolanaPublicKey,
+ ): CompiledMessage {
+ // Build role bits per public key, then place each key into exactly one
+ // of the four role sets. This guarantees no duplicate AccountMeta entries
+ // even when the same pubkey appears across instructions under different
+ // (signer, writable) classifications — the strongest role wins.
+ data class Role(var signer: Boolean, var writable: Boolean)
+
+ val firstSeen = linkedMapOf()
+ fun observe(key: SolanaPublicKey, signer: Boolean, writable: Boolean) {
+ val role = firstSeen.getOrPut(key) { Role(signer = false, writable = false) }
+ if (signer) role.signer = true
+ if (writable) role.writable = true
+ }
+
+ observe(feePayer, signer = true, writable = true)
+ signers.filter { it != feePayer }.forEach { observe(it, signer = true, writable = false) }
+ instructions.forEach { instruction ->
+ instruction.accounts.forEach { account ->
+ observe(account.publicKey, signer = account.signer, writable = account.writable)
+ }
+ observe(instruction.programId, signer = false, writable = false)
+ }
+
+ val writableSigners = linkedSetOf()
+ val readOnlySigners = linkedSetOf()
+ val writableNonSigners = linkedSetOf()
+ val readOnlyNonSigners = linkedSetOf()
+ firstSeen.forEach { (key, role) ->
+ when {
+ role.signer && role.writable -> writableSigners.add(key)
+ role.signer && !role.writable -> readOnlySigners.add(key)
+ !role.signer && role.writable -> writableNonSigners.add(key)
+ else -> readOnlyNonSigners.add(key)
+ }
+ }
+
+ val accountKeys = writableSigners.toList() + readOnlySigners.toList() +
+ writableNonSigners.toList() + readOnlyNonSigners.toList()
+ check(accountKeys.size == accountKeys.toSet().size) {
+ "internal error: duplicate account key in compiled v0 message"
+ }
+ val requiredSignatures = writableSigners.size + readOnlySigners.size
+ val out = ByteArrayBuilder()
+ out.byte(0x80)
+ out.byte(requiredSignatures)
+ out.byte(readOnlySigners.size)
+ out.byte(readOnlyNonSigners.size)
+ out.compactU16(accountKeys.size)
+ accountKeys.forEach { out.bytes(it.bytes) }
+ out.bytes(recentBlockhash.bytes)
+ out.compactU16(instructions.size)
+ instructions.forEach { instruction ->
+ out.byte(accountKeys.indexOf(instruction.programId))
+ out.compactU16(instruction.accounts.size)
+ instruction.accounts.forEach { out.byte(accountKeys.indexOf(it.publicKey)) }
+ out.compactU16(instruction.data.size)
+ out.bytes(instruction.data)
+ }
+ out.compactU16(0)
+ return CompiledMessage(out.toByteArray(), accountKeys, requiredSignatures)
+ }
+
+ fun serializeTransaction(signatures: List, message: ByteArray): ByteArray =
+ ByteArrayBuilder().apply {
+ compactU16(signatures.size)
+ signatures.forEach { bytes(it) }
+ bytes(message)
+ }.toByteArray()
+
+}
+
+private fun computeUnitLimitInstruction(units: UInt): SolanaInstruction =
+ SolanaInstruction(
+ programId = SolanaPublicKey.fromBase58(COMPUTE_BUDGET_PROGRAM),
+ accounts = emptyList(),
+ data = byteArrayOf(2) + units.toLittleEndianBytes(),
+ )
+
+private fun computeUnitPriceInstruction(microLamports: UInt): SolanaInstruction =
+ SolanaInstruction(
+ programId = SolanaPublicKey.fromBase58(COMPUTE_BUDGET_PROGRAM),
+ accounts = emptyList(),
+ data = byteArrayOf(3) + microLamports.toULong().toLittleEndianBytes(),
+ )
+
+private fun transferCheckedInstruction(
+ tokenProgram: SolanaPublicKey,
+ source: SolanaPublicKey,
+ mint: SolanaPublicKey,
+ destination: SolanaPublicKey,
+ owner: SolanaPublicKey,
+ amount: ULong,
+ decimals: Int,
+): SolanaInstruction =
+ SolanaInstruction(
+ programId = tokenProgram,
+ accounts = listOf(
+ AccountMeta(source, signer = false, writable = true),
+ AccountMeta(mint, signer = false, writable = false),
+ AccountMeta(destination, signer = false, writable = true),
+ AccountMeta(owner, signer = true, writable = false),
+ ),
+ data = byteArrayOf(12) + amount.toLittleEndianBytes() + byteArrayOf(decimals.toByte()),
+ )
+
+private fun memoInstruction(memo: String): SolanaInstruction {
+ val memoBytes = memo.toByteArray(Charsets.UTF_8)
+ require(memoBytes.size <= MAX_MEMO_BYTES) { "extra.memo exceeds maximum $MAX_MEMO_BYTES bytes" }
+ return SolanaInstruction(
+ programId = SolanaPublicKey.fromBase58(MEMO_PROGRAM),
+ accounts = emptyList(),
+ data = memoBytes,
+ )
+}
+
+fun associatedTokenAddress(
+ owner: SolanaPublicKey,
+ mint: SolanaPublicKey,
+ tokenProgram: SolanaPublicKey,
+): SolanaPublicKey =
+ findProgramAddress(
+ seeds = listOf(owner.bytes, tokenProgram.bytes, mint.bytes),
+ programId = SolanaPublicKey.fromBase58(ASSOCIATED_TOKEN_PROGRAM),
+ )
+
+private fun findProgramAddress(seeds: List, programId: SolanaPublicKey): SolanaPublicKey {
+ for (bump in 255 downTo 0) {
+ val candidate = createProgramAddress(seeds + byteArrayOf(bump.toByte()), programId)
+ if (!Ed25519Curve.isOnCurve(candidate.bytes)) {
+ return candidate
+ }
+ }
+ throw IllegalStateException("Unable to find a viable program address bump seed")
+}
+
+private fun createProgramAddress(seeds: List, programId: SolanaPublicKey): SolanaPublicKey {
+ val digest = MessageDigest.getInstance("SHA-256")
+ seeds.forEach { seed ->
+ require(seed.size <= 32) { "Solana PDA seeds must be at most 32 bytes" }
+ digest.update(seed)
+ }
+ digest.update(programId.bytes)
+ digest.update(PROGRAM_DERIVED_ADDRESS_MARKER.toByteArray(Charsets.UTF_8))
+ return SolanaPublicKey(digest.digest())
+}
+
+private fun stablecoinTokenProgram(asset: String): String = when (asset) {
+ "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM",
+ "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7",
+ "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo",
+ "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH",
+ "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH",
+ -> TOKEN_2022_PROGRAM
+ else -> TOKEN_PROGRAM
+}
+
+private fun randomMemo(): String {
+ val bytes = ByteArray(16)
+ java.security.SecureRandom().nextBytes(bytes)
+ return bytes.joinToString("") { "%02x".format(it) }
+}
+
+private object Ed25519Curve {
+ private val p = BigInteger.ONE.shiftLeft(255).subtract(BigInteger.valueOf(19))
+ private val d = BigInteger("-121665").multiply(BigInteger("121666").modInverse(p)).mod(p)
+
+ fun isOnCurve(compressed: ByteArray): Boolean {
+ if (compressed.size != 32) return false
+ val yBytes = compressed.copyOf()
+ yBytes[31] = yBytes[31] and 0x7f
+ val y = littleEndianToBigInteger(yBytes)
+ if (y >= p) return false
+ val y2 = y.multiply(y).mod(p)
+ val numerator = y2.subtract(BigInteger.ONE).mod(p)
+ val denominator = d.multiply(y2).add(BigInteger.ONE).mod(p)
+ if (denominator == BigInteger.ZERO) return false
+ val x2 = numerator.multiply(denominator.modInverse(p)).mod(p)
+ return x2 == BigInteger.ZERO || x2.modPow(p.subtract(BigInteger.ONE).divide(BigInteger.TWO), p) == BigInteger.ONE
+ }
+
+ private fun littleEndianToBigInteger(bytes: ByteArray): BigInteger =
+ BigInteger(1, bytes.reversedArray())
+}
+
+object Base58 {
+ private const val ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
+ private val indexes = IntArray(128) { -1 }.also { table ->
+ ALPHABET.forEachIndexed { index, c -> table[c.code] = index }
+ }
+
+ fun encode(bytes: ByteArray): String {
+ if (bytes.isEmpty()) return ""
+ var zeros = 0
+ while (zeros < bytes.size && bytes[zeros] == 0.toByte()) zeros++
+ var value = BigInteger(1, bytes)
+ val result = StringBuilder()
+ val base = BigInteger.valueOf(58)
+ while (value > BigInteger.ZERO) {
+ val divRem = value.divideAndRemainder(base)
+ result.append(ALPHABET[divRem[1].toInt()])
+ value = divRem[0]
+ }
+ repeat(zeros) { result.append('1') }
+ return result.reverse().toString()
+ }
+
+ fun decode(value: String): ByteArray {
+ require(value.isNotBlank()) { "base58 value is required" }
+ var result = BigInteger.ZERO
+ val base = BigInteger.valueOf(58)
+ value.forEach { char ->
+ require(char.code < indexes.size && indexes[char.code] >= 0) { "invalid base58 character: $char" }
+ result = result.multiply(base).add(BigInteger.valueOf(indexes[char.code].toLong()))
+ }
+ val raw = result.toByteArray().dropWhile { it == 0.toByte() }.toByteArray()
+ val zeros = value.takeWhile { it == '1' }.count()
+ return ByteArray(zeros) + raw
+ }
+}
+
+private class ByteArrayBuilder {
+ private val bytes = mutableListOf()
+
+ fun byte(value: Int) {
+ require(value in 0..255) { "byte value out of range" }
+ bytes.add(value.toByte())
+ }
+
+ fun bytes(value: ByteArray) {
+ value.forEach { bytes.add(it) }
+ }
+
+ fun compactU16(value: Int) {
+ var remaining = value
+ do {
+ var elem = remaining and 0x7f
+ remaining = remaining ushr 7
+ if (remaining != 0) elem = elem or 0x80
+ byte(elem)
+ } while (remaining != 0)
+ }
+
+ fun toByteArray(): ByteArray = bytes.toByteArray()
+}
+
+private fun UInt.toLittleEndianBytes(): ByteArray =
+ byteArrayOf(
+ (this and 0xffu).toByte(),
+ ((this shr 8) and 0xffu).toByte(),
+ ((this shr 16) and 0xffu).toByte(),
+ ((this shr 24) and 0xffu).toByte(),
+ )
+
+private fun ULong.toLittleEndianBytes(): ByteArray =
+ ByteArray(8) { index -> ((this shr (8 * index)) and 0xffu).toByte() }
+
+private fun JsonObject.string(name: String): String? =
+ get(name)?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString
+
+private fun JsonObject.int(name: String): Int? =
+ get(name)?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber }?.asInt
+
+private fun JsonObject.extraString(name: String): String? =
+ getAsJsonObject("extra")?.string(name)
+
+private fun JsonObject.extraInt(name: String): Int? =
+ getAsJsonObject("extra")?.int(name)
diff --git a/kotlin/src/test/kotlin/org/solana/x402/exact/ExactChallengeTest.kt b/kotlin/src/test/kotlin/org/solana/x402/exact/ExactChallengeTest.kt
new file mode 100644
index 000000000..bfb0d661f
--- /dev/null
+++ b/kotlin/src/test/kotlin/org/solana/x402/exact/ExactChallengeTest.kt
@@ -0,0 +1,366 @@
+package org.solana.x402.exact
+
+import java.util.Base64
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+class ExactChallengeTest {
+ @Test
+ fun `SolanaNetwork mainnet CAIP-2 matches Rust spine SOLANA_MAINNET constant`() {
+ // Regression: previous tip shipped the 44-char full base58 genesis hash,
+ // which broke interop with every spine-compliant mainnet challenge. The
+ // Rust spine constant lives at
+ // rust/crates/x402/src/protocol/schemes/exact/types.rs (SOLANA_MAINNET).
+ assertEquals(
+ "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
+ SolanaNetwork.Mainnet.caip2,
+ )
+ assertEquals(32, SolanaNetwork.Mainnet.caip2.removePrefix("solana:").length)
+ assertEquals(
+ SolanaNetwork.Mainnet,
+ SolanaNetwork.fromIdentifierOrNull("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"),
+ )
+ }
+
+ @Test
+ fun `selects Solana exact requirement from PAYMENT-REQUIRED header`() {
+ val envelope = """
+ {
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": "eip155:8453",
+ "asset": "0x0000000000000000000000000000000000000000",
+ "amount": "1000"
+ },
+ {
+ "scheme": "exact",
+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ "amount": "1000",
+ "payTo": "5T388jBjovy7d8mQ3emHxMDTbUF8b7nWvAnSiP3EAdFL",
+ "extra": { "feePayer": "HoCy8p5xxDDYTYWEbQZasEjVNM5rxvidx8AfyqA4ywBa" }
+ }
+ ],
+ "resource": {
+ "url": "http://127.0.0.1:3000/protected",
+ "description": "fixture"
+ }
+ }
+ """.trimIndent()
+ val header = Base64.getEncoder().encodeToString(envelope.toByteArray(Charsets.UTF_8))
+
+ val selected = ExactChallenge.selectSvmChallenge(
+ headers = mapOf("PAYMENT-REQUIRED" to header),
+ body = null,
+ )
+
+ assertNotNull(selected)
+ assertEquals("exact", selected.requirement.scheme)
+ assertEquals(ExactChallenge.DEFAULT_NETWORK, selected.requirement.network)
+ assertEquals("1000", selected.requirement.amount)
+ assertEquals("http://127.0.0.1:3000/protected", selected.resource?.url)
+ }
+
+ @Test
+ fun `prefers requested stablecoin by symbol or mint`() {
+ val body = """
+ {
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ "amount": "1000"
+ },
+ {
+ "scheme": "exact",
+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
+ "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM",
+ "amount": "1000"
+ }
+ ]
+ }
+ """.trimIndent()
+
+ val selected = ExactChallenge.selectSvmChallenge(
+ headers = emptyMap(),
+ body = body,
+ preferredCurrencies = listOf("PYUSD", "USDC"),
+ )
+
+ assertNotNull(selected)
+ assertEquals("CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", selected.requirement.asset)
+ }
+
+ @Test
+ fun `rejects network mismatch before payment construction`() {
+ val body = """
+ {
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": "solana:not-a-real-cluster",
+ "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
+ "amount": "1000"
+ }
+ ]
+ }
+ """.trimIndent()
+
+ val selected = ExactChallenge.selectSvmChallenge(headers = emptyMap(), body = body)
+
+ assertNull(selected)
+ }
+
+ @Test
+ fun `stablecoinMint resolves USDC per network without mainnet leak`() {
+ val mainnetUsdc = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
+ val devnetUsdc = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"
+
+ // Typed (sealed-class) resolver — the source of truth.
+ assertEquals(mainnetUsdc, ExactChallenge.stablecoinMint("USDC", SolanaNetwork.Mainnet))
+ assertEquals(devnetUsdc, ExactChallenge.stablecoinMint("USDC", SolanaNetwork.Devnet))
+ assertEquals(devnetUsdc, ExactChallenge.stablecoinMint("USDC", SolanaNetwork.Localnet))
+ assertNotEquals(mainnetUsdc, ExactChallenge.stablecoinMint("USDC", SolanaNetwork.Devnet))
+ assertNotEquals(mainnetUsdc, ExactChallenge.stablecoinMint("USDC", SolanaNetwork.Localnet))
+
+ // String shim — all canonical aliases route correctly.
+ assertEquals(devnetUsdc, ExactChallenge.stablecoinMint("USDC", "devnet"))
+ assertEquals(devnetUsdc, ExactChallenge.stablecoinMint("USDC", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"))
+ assertEquals(devnetUsdc, ExactChallenge.stablecoinMint("USDC", "localnet"))
+ assertEquals(mainnetUsdc, ExactChallenge.stablecoinMint("USDC", "mainnet-beta"))
+ // Aligned to Rust spine SOLANA_MAINNET constant (32-char canonical prefix).
+ assertEquals(mainnetUsdc, ExactChallenge.stablecoinMint("USDC", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"))
+ }
+
+ @Test
+ fun `stablecoinMint fails closed on unknown network for known symbol`() {
+ // Money-loss bug regression: passing an unrecognised network must NOT
+ // silently produce a mainnet mint address for a known stablecoin symbol.
+ val error = assertFailsWith {
+ ExactChallenge.stablecoinMint("USDC", "solana:not-a-real-cluster")
+ }
+ assertEquals(
+ true,
+ error.message?.contains("unknown network", ignoreCase = true) == true,
+ "expected fail-closed error, got: ${error.message}",
+ )
+ }
+
+ @Test
+ fun `stablecoinMint passes through unknown asset on unknown network`() {
+ // A caller may hand us a raw mint address as the "currency" — that's
+ // not a known symbol, so we should echo it back rather than throw.
+ val mint = "SomeArbitraryMintAddress1111111111111111111"
+ assertEquals(mint, ExactChallenge.stablecoinMint(mint, "solana:not-a-real-cluster"))
+ }
+
+ @Test
+ fun `currencyMatches_returns_false_when_network_is_unrecognized`() {
+ // currencyMatches is private; exercise it via selectSvmChallenge with a
+ // single candidate whose network is unrecognised. The preference loop
+ // must treat the unresolvable pair as "not a match" instead of letting
+ // the underlying IllegalArgumentException escape and break selection.
+ val body = """
+ {
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": "solana:not-a-real-cluster",
+ "asset": "SomeArbitraryMintAddress1111111111111111111",
+ "amount": "1000"
+ }
+ ]
+ }
+ """.trimIndent()
+
+ val selected = ExactChallenge.selectSvmChallenge(
+ headers = emptyMap(),
+ body = body,
+ network = "solana:not-a-real-cluster",
+ preferredCurrencies = listOf("USDC"),
+ )
+
+ // The candidate matched scheme + network filters but does not satisfy
+ // the USDC preference under an unresolvable network — no throw, no match.
+ assertNull(selected)
+ }
+
+ @Test
+ fun `selectSvmChallenge_returns_null_for_unrecognized_network_with_stablecoin_preference`() {
+ // Regression: previously an unrecognised network + a stablecoin symbol
+ // preference threw IllegalArgumentException out of selectSvmChallenge,
+ // breaking the entire challenge-selection loop. Must return null instead.
+ val body = """
+ {
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": "solana:not-a-real-cluster",
+ "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
+ "amount": "1000"
+ }
+ ]
+ }
+ """.trimIndent()
+
+ // No throw — just a null selection.
+ val selected = ExactChallenge.selectSvmChallenge(
+ headers = emptyMap(),
+ body = body,
+ network = "solana:not-a-real-cluster",
+ preferredCurrencies = listOf("PYUSD"),
+ )
+
+ assertNull(selected)
+ }
+
+ @Test
+ fun `stablecoinMint resolves PYUSD and USDG per network`() {
+ assertEquals(
+ "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo",
+ ExactChallenge.stablecoinMint("PYUSD", SolanaNetwork.Mainnet),
+ )
+ assertEquals(
+ "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM",
+ ExactChallenge.stablecoinMint("PYUSD", SolanaNetwork.Devnet),
+ )
+ assertEquals(
+ "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH",
+ ExactChallenge.stablecoinMint("USDG", SolanaNetwork.Mainnet),
+ )
+ assertEquals(
+ "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7",
+ ExactChallenge.stablecoinMint("USDG", SolanaNetwork.Devnet),
+ )
+ }
+
+ @Test
+ fun `accepts canonical maxAmountRequired field when amount is absent`() {
+ // Regression: prior tip read only `amount`, which silently dropped every
+ // spine-shaped challenge that uses the canonical `maxAmountRequired`
+ // wire field (TS fixture, Rust spine output, Go/Python/PHP ports).
+ // Rust spine fallback lives at
+ // rust/crates/x402/src/protocol/schemes/exact/types.rs.
+ val body = """
+ {
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ "maxAmountRequired": "1500",
+ "payTo": "5T388jBjovy7d8mQ3emHxMDTbUF8b7nWvAnSiP3EAdFL",
+ "extra": { "feePayer": "HoCy8p5xxDDYTYWEbQZasEjVNM5rxvidx8AfyqA4ywBa" }
+ }
+ ]
+ }
+ """.trimIndent()
+
+ val selected = ExactChallenge.selectSvmChallenge(
+ headers = emptyMap(),
+ body = body,
+ )
+
+ assertNotNull(selected)
+ assertEquals("exact", selected.requirement.scheme)
+ assertEquals("1500", selected.requirement.amount)
+ }
+
+ @Test
+ fun `prefers amount over maxAmountRequired when both are present`() {
+ // When a challenge carries both fields, `amount` wins to preserve
+ // back-compat with adapters that emit both for transitional reasons.
+ val body = """
+ {
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ "amount": "1000",
+ "maxAmountRequired": "9999"
+ }
+ ]
+ }
+ """.trimIndent()
+
+ val selected = ExactChallenge.selectSvmChallenge(
+ headers = emptyMap(),
+ body = body,
+ )
+
+ assertNotNull(selected)
+ assertEquals("1000", selected.requirement.amount)
+ }
+
+ @Test
+ fun `skips native SOL offers and prefers SPL candidate`() {
+ // Rust spine `rust/crates/x402/src/client/exact/payment.rs` supports
+ // native SOL via System Program transfer. This Kotlin client is
+ // SPL-only; selection must skip `asset: "SOL"` offers rather than
+ // crash later at Base58.decode("SOL") inside the builder.
+ val body = """
+ {
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
+ "asset": "SOL",
+ "amount": "1000",
+ "payTo": "5T388jBjovy7d8mQ3emHxMDTbUF8b7nWvAnSiP3EAdFL"
+ },
+ {
+ "scheme": "exact",
+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
+ "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ "amount": "2000",
+ "payTo": "5T388jBjovy7d8mQ3emHxMDTbUF8b7nWvAnSiP3EAdFL",
+ "extra": { "feePayer": "HoCy8p5xxDDYTYWEbQZasEjVNM5rxvidx8AfyqA4ywBa" }
+ }
+ ]
+ }
+ """.trimIndent()
+
+ val selected = ExactChallenge.selectSvmChallenge(
+ headers = emptyMap(),
+ body = body,
+ )
+
+ assertNotNull(selected)
+ assertEquals(
+ "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ selected.requirement.asset,
+ )
+ }
+
+ @Test
+ fun `returns null when only native SOL offer is available`() {
+ val body = """
+ {
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
+ "asset": "SOL",
+ "amount": "1000",
+ "payTo": "5T388jBjovy7d8mQ3emHxMDTbUF8b7nWvAnSiP3EAdFL"
+ }
+ ]
+ }
+ """.trimIndent()
+
+ val selected = ExactChallenge.selectSvmChallenge(
+ headers = emptyMap(),
+ body = body,
+ )
+
+ assertNull(selected)
+ }
+}
+
diff --git a/kotlin/src/test/kotlin/org/solana/x402/exact/ExactPaymentClientTest.kt b/kotlin/src/test/kotlin/org/solana/x402/exact/ExactPaymentClientTest.kt
new file mode 100644
index 000000000..8dbea7183
--- /dev/null
+++ b/kotlin/src/test/kotlin/org/solana/x402/exact/ExactPaymentClientTest.kt
@@ -0,0 +1,382 @@
+package org.solana.x402.exact
+
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import java.util.Base64
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class ExactPaymentClientTest {
+ @Test
+ fun `creates v2 payment signature header with injected transaction signer`() {
+ val builder = RecordingTransactionBuilder(byteArrayOf(1, 2, 3))
+ val signer = RecordingTransactionSigner(ByteArray(64) { 9 })
+ val client = ExactPaymentClient(builder, signer)
+
+ val headers = client.createPaymentHeaders(
+ selected = selectedRequirement(
+ extra = mapOf(
+ "feePayer" to "FeePayer1111111111111111111111111111",
+ "memo" to "order-123",
+ ),
+ ),
+ payer = "Payer11111111111111111111111111111111",
+ )
+
+ val encoded = assertNotNull(headers["PAYMENT-SIGNATURE"])
+ val envelope = JsonParser.parseString(
+ String(Base64.getDecoder().decode(encoded), Charsets.UTF_8),
+ ).asJsonObject
+
+ assertEquals(2, envelope["x402Version"].asInt)
+ assertEquals("exact", envelope["accepted"].asJsonObject["scheme"].asString)
+ assertEquals(ExactChallenge.DEFAULT_NETWORK, envelope["accepted"].asJsonObject["network"].asString)
+ assertEquals("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", envelope["accepted"].asJsonObject["asset"].asString)
+ assertEquals("PayTo111111111111111111111111111111111", envelope["accepted"].asJsonObject["payTo"].asString)
+ val transaction = Base64.getDecoder().decode(envelope["payload"].asJsonObject["transaction"].asString)
+ assertEquals(68, transaction.size)
+ assertEquals(1, transaction[0].toInt())
+ assertContentEquals(ByteArray(64) { 9 }, transaction.copyOfRange(1, 65))
+ assertContentEquals(byteArrayOf(1, 2, 3), transaction.copyOfRange(65, 68))
+ assertEquals("http://127.0.0.1:3000/protected", envelope["resource"].asJsonObject["url"].asString)
+
+ assertEquals(1, builder.requests.size)
+ assertEquals("Payer11111111111111111111111111111111", builder.requests.single().payer)
+ assertEquals("FeePayer1111111111111111111111111111", builder.requests.single().feePayer)
+ assertEquals("order-123", builder.requests.single().memo)
+ assertContentEquals(byteArrayOf(1, 2, 3), signer.inputs.single())
+ }
+
+ @Test
+ fun `falls back to payer as fee payer when feePayer is absent`() {
+ // Spine parity: rust/crates/x402/src/client/exact/payment.rs
+ // computes `let actual_fee_payer = fee_payer_pubkey.unwrap_or(signer_pubkey);`
+ // The Kotlin client mirrors this — when a challenge does not carry a
+ // managed `extra.feePayer`, the transfer authority (signer) pays its
+ // own network fees rather than the request being rejected.
+ val builder = RecordingTransactionBuilder(byteArrayOf(1))
+ val signer = RecordingTransactionSigner(ByteArray(64) { 5 })
+ val client = ExactPaymentClient(builder, signer)
+
+ client.createPaymentHeaders(
+ selected = selectedRequirement(extra = emptyMap()),
+ payer = "Payer11111111111111111111111111111111",
+ )
+
+ assertEquals(1, builder.requests.size)
+ val request = builder.requests.single()
+ assertEquals(null, request.feePayer)
+ assertEquals(1, signer.inputs.size)
+ }
+
+ @Test
+ fun `rejects missing payTo before constructing transaction`() {
+ val builder = RecordingTransactionBuilder(byteArrayOf(1))
+ val signer = RecordingTransactionSigner(byteArrayOf(2))
+ val client = ExactPaymentClient(builder, signer)
+
+ val error = assertFailsWith {
+ client.createPaymentHeaders(
+ selected = selectedRequirement(payTo = null),
+ payer = "Payer11111111111111111111111111111111",
+ )
+ }
+
+ assertEquals("payTo is required for SVM exact payment requirements", error.message)
+ assertEquals(0, builder.requests.size)
+ assertEquals(0, signer.inputs.size)
+ }
+
+ @Test
+ fun `rejects oversized memo before constructing transaction`() {
+ val builder = RecordingTransactionBuilder(byteArrayOf(1))
+ val signer = RecordingTransactionSigner(byteArrayOf(2))
+ val client = ExactPaymentClient(builder, signer)
+
+ val error = assertFailsWith {
+ client.createPaymentHeaders(
+ selected = selectedRequirement(
+ extra = mapOf(
+ "feePayer" to "FeePayer1111111111111111111111111111",
+ "memo" to "x".repeat(257),
+ ),
+ ),
+ payer = "Payer11111111111111111111111111111111",
+ )
+ }
+
+ assertEquals("extra.memo exceeds maximum 256 bytes", error.message)
+ assertEquals(0, builder.requests.size)
+ assertEquals(0, signer.inputs.size)
+ }
+
+ @Test
+ fun `rejects challenge whose feePayer equals payer wallet (managed fee-payer drain attack)`() {
+ // Defensive client-side validation: a malicious server may set the managed
+ // fee payer to the user's own wallet to make the wallet pay SVM fees on
+ // top of the transfer. The exact-svm scheme requires operational
+ // separation; reject before any RPC or signing work happens.
+ val builder = RecordingTransactionBuilder(byteArrayOf(1))
+ val signer = RecordingTransactionSigner(byteArrayOf(2))
+ val client = ExactPaymentClient(builder, signer)
+
+ val payer = "Payer11111111111111111111111111111111"
+ val error = assertFailsWith {
+ client.createPaymentHeaders(
+ selected = selectedRequirement(extra = mapOf("feePayer" to payer)),
+ payer = payer,
+ )
+ }
+ assertEquals(
+ "managed fee payer must differ from the transfer authority (payer)",
+ error.message,
+ )
+ assertEquals(0, builder.requests.size)
+ assertEquals(0, signer.inputs.size)
+ }
+
+ @Test
+ fun `client_rejects_self_transfer_when_payTo_equals_payer`() {
+ // Money-loss bug regression: when payTo collides with the payer wallet
+ // the SPL Token program rejects the transfer on-chain. Fail fast on the
+ // client before any Base58 decoding, ATA derivation, or RPC work runs.
+ val builder = RecordingTransactionBuilder(byteArrayOf(1))
+ val signer = RecordingTransactionSigner(byteArrayOf(2))
+ val client = ExactPaymentClient(builder, signer)
+
+ val payer = "Payer11111111111111111111111111111111"
+ val error = assertFailsWith {
+ client.createPaymentHeaders(
+ selected = selectedRequirement(payTo = payer),
+ payer = payer,
+ )
+ }
+ assertEquals("payTo must differ from payer (self-transfer)", error.message)
+ assertEquals(0, builder.requests.size)
+ assertEquals(0, signer.inputs.size)
+ }
+
+ @Test
+ fun `client_rejects_challenge_with_unsupported_tokenProgram`() {
+ // P1 security: a malicious server can set extra.tokenProgram to an
+ // arbitrary executable program ID. The client must reject anything
+ // outside the canonical SPL allowlist (TokenkegQ... / TokenzQd...)
+ // before any builder, RPC, or signing work runs.
+ val builder = RecordingTransactionBuilder(byteArrayOf(1))
+ val signer = RecordingTransactionSigner(byteArrayOf(2))
+ val client = ExactPaymentClient(builder, signer)
+
+ val error = assertFailsWith {
+ client.createPaymentHeaders(
+ selected = selectedRequirement(
+ extra = mapOf(
+ "feePayer" to "FeePayer1111111111111111111111111111",
+ "tokenProgram" to "EvilProgram1111111111111111111111111111",
+ ),
+ ),
+ payer = "Payer11111111111111111111111111111111",
+ )
+ }
+ assertTrue(
+ error.message?.contains("unsupported tokenProgram") == true,
+ "expected unsupported-tokenProgram rejection, got: ${error.message}",
+ )
+ assertEquals(0, builder.requests.size)
+ assertEquals(0, signer.inputs.size)
+ }
+
+ @Test
+ fun `client_accepts_challenge_with_canonical_spl_token_program`() {
+ val builder = RecordingTransactionBuilder(byteArrayOf(1, 2, 3))
+ val signer = RecordingTransactionSigner(ByteArray(64) { 9 })
+ val client = ExactPaymentClient(builder, signer)
+
+ client.createPaymentHeaders(
+ selected = selectedRequirement(
+ extra = mapOf(
+ "feePayer" to "FeePayer1111111111111111111111111111",
+ "tokenProgram" to "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
+ ),
+ ),
+ payer = "Payer11111111111111111111111111111111",
+ )
+ assertEquals(1, builder.requests.size)
+ }
+
+ @Test
+ fun `client_accepts_challenge_with_canonical_token_2022_program`() {
+ val builder = RecordingTransactionBuilder(byteArrayOf(1, 2, 3))
+ val signer = RecordingTransactionSigner(ByteArray(64) { 9 })
+ val client = ExactPaymentClient(builder, signer)
+
+ client.createPaymentHeaders(
+ selected = selectedRequirement(
+ extra = mapOf(
+ "feePayer" to "FeePayer1111111111111111111111111111",
+ "tokenProgram" to "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
+ ),
+ ),
+ payer = "Payer11111111111111111111111111111111",
+ )
+ assertEquals(1, builder.requests.size)
+ }
+
+ @Test
+ fun `rejects challenge whose payTo equals feePayer (self-pay loop attack)`() {
+ val builder = RecordingTransactionBuilder(byteArrayOf(1))
+ val signer = RecordingTransactionSigner(byteArrayOf(2))
+ val client = ExactPaymentClient(builder, signer)
+
+ val collidingAddress = "PayTo111111111111111111111111111111111"
+ val error = assertFailsWith {
+ client.createPaymentHeaders(
+ selected = selectedRequirement(
+ payTo = collidingAddress,
+ extra = mapOf("feePayer" to collidingAddress),
+ ),
+ payer = "Payer11111111111111111111111111111111",
+ )
+ }
+ assertEquals("payTo must differ from the managed fee payer", error.message)
+ assertEquals(0, builder.requests.size)
+ assertEquals(0, signer.inputs.size)
+ }
+
+ @Test
+ fun `canonical accepted strips deprecated wire aliases before signing`() {
+ // The Rust spine's `find_matching_requirement` round-trips the
+ // credential's `accepted` through the typed `PaymentRequirements`
+ // serializer and structurally compares the result against the
+ // route's offered requirement. Echoing the raw offered object
+ // verbatim (with deprecated aliases like `maxAmountRequired`,
+ // `currency`, `recipient`) would cause the structural match to
+ // fail even though the underlying values agree. Mirror
+ // `to_accepted_value` at
+ // rust/crates/x402/src/protocol/schemes/exact/types.rs by
+ // stripping those aliases and emitting canonical fields only.
+ val builder = RecordingTransactionBuilder(byteArrayOf(1))
+ val signer = RecordingTransactionSigner(ByteArray(64) { 7 })
+ val client = ExactPaymentClient(builder, signer)
+
+ // Build a requirement whose `raw` carries the legacy aliases as well.
+ val raw = JsonObject().apply {
+ addProperty("scheme", "exact")
+ addProperty("network", ExactChallenge.DEFAULT_NETWORK)
+ addProperty("asset", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU")
+ addProperty("currency", "USDC")
+ addProperty("amount", "1000")
+ addProperty("maxAmountRequired", "1000")
+ addProperty("payTo", "PayTo111111111111111111111111111111111")
+ addProperty("recipient", "PayTo111111111111111111111111111111111")
+ addProperty("maxTimeoutSeconds", 60)
+ add(
+ "extra",
+ JsonObject().apply {
+ addProperty("feePayer", "FeePayer1111111111111111111111111111")
+ },
+ )
+ }
+ val selected = SelectedChallenge(
+ requirement = PaymentRequirement(
+ scheme = "exact",
+ network = ExactChallenge.DEFAULT_NETWORK,
+ asset = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ amount = "1000",
+ payTo = "PayTo111111111111111111111111111111111",
+ maxTimeoutSeconds = 60,
+ extra = raw["extra"].asJsonObject.entrySet().associate { it.key to it.value },
+ raw = raw,
+ ),
+ resource = null,
+ )
+
+ val encoded = client.createPaymentHeaderValue(
+ selected = selected,
+ payer = "Payer11111111111111111111111111111111",
+ )
+ val envelope = JsonParser.parseString(
+ String(Base64.getDecoder().decode(encoded), Charsets.UTF_8),
+ ).asJsonObject
+ val accepted = envelope["accepted"].asJsonObject
+
+ assertEquals("1000", accepted["amount"].asString)
+ assertTrue(!accepted.has("maxAmountRequired"), "maxAmountRequired must be stripped")
+ assertTrue(!accepted.has("currency"), "currency must be stripped")
+ assertTrue(!accepted.has("recipient"), "recipient must be stripped")
+ }
+}
+
+private class RecordingTransactionBuilder(
+ private val message: ByteArray,
+) : SolanaExactTransactionBuilder {
+ val requests = mutableListOf()
+
+ override fun buildUnsignedTransaction(request: SolanaExactPaymentRequest): UnsignedSolanaTransaction {
+ requests.add(request)
+ return UnsignedSolanaTransaction(
+ message = message,
+ signatures = listOf(ByteArray(64)),
+ signerIndex = 0,
+ )
+ }
+}
+
+private class RecordingTransactionSigner(
+ private val signedTransaction: ByteArray,
+) : SolanaTransactionSigner {
+ val inputs = mutableListOf()
+
+ override fun signMessage(message: ByteArray): ByteArray {
+ inputs.add(message)
+ return signedTransaction
+ }
+}
+
+private fun selectedRequirement(
+ payTo: String? = "PayTo111111111111111111111111111111111",
+ extra: Map = mapOf("feePayer" to "FeePayer1111111111111111111111111111"),
+): SelectedChallenge {
+ val raw = JsonObject().apply {
+ addProperty("scheme", "exact")
+ addProperty("network", ExactChallenge.DEFAULT_NETWORK)
+ addProperty("asset", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU")
+ addProperty("amount", "1000")
+ if (payTo != null) {
+ addProperty("payTo", payTo)
+ }
+ addProperty("maxTimeoutSeconds", 60)
+ add(
+ "extra",
+ JsonObject().apply {
+ extra.forEach { (key, value) -> addProperty(key, value) }
+ },
+ )
+ }
+
+ return SelectedChallenge(
+ requirement = PaymentRequirement(
+ scheme = "exact",
+ network = ExactChallenge.DEFAULT_NETWORK,
+ asset = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ amount = "1000",
+ payTo = payTo,
+ maxTimeoutSeconds = 60,
+ extra = raw["extra"].asJsonObject.entrySet().associate { it.key to it.value },
+ raw = raw,
+ ),
+ resource = ResourceInfo(
+ url = "http://127.0.0.1:3000/protected",
+ description = "fixture",
+ mimeType = "application/json",
+ raw = JsonObject().apply {
+ addProperty("url", "http://127.0.0.1:3000/protected")
+ addProperty("description", "fixture")
+ addProperty("mimeType", "application/json")
+ },
+ ),
+ )
+}
diff --git a/kotlin/src/test/kotlin/org/solana/x402/exact/SolanaTransactionTest.kt b/kotlin/src/test/kotlin/org/solana/x402/exact/SolanaTransactionTest.kt
new file mode 100644
index 000000000..d064f23c8
--- /dev/null
+++ b/kotlin/src/test/kotlin/org/solana/x402/exact/SolanaTransactionTest.kt
@@ -0,0 +1,338 @@
+package org.solana.x402.exact
+
+import com.google.gson.JsonObject
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+class SolanaTransactionTest {
+ @Test
+ fun `base58 round trips public keys`() {
+ val key = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"
+ assertEquals(key, SolanaPublicKey.fromBase58(key).base58)
+ }
+
+ @Test
+ fun `derives canonical associated token accounts`() {
+ val mint = SolanaPublicKey.fromBase58("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU")
+ val tokenProgram = SolanaPublicKey.fromBase58("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
+
+ val source = associatedTokenAddress(
+ owner = SolanaPublicKey.fromBase58("11111111111111111111111111111112"),
+ mint = mint,
+ tokenProgram = tokenProgram,
+ )
+ val destination = associatedTokenAddress(
+ owner = SolanaPublicKey.fromBase58("11111111111111111111111111111115"),
+ mint = mint,
+ tokenProgram = tokenProgram,
+ )
+
+ assertEquals("4tRapEGgJZKuGoeeMRrpHsxAEuvo5YnDCzTXykqDhrK9", source.base58)
+ assertEquals("CFGbKktYnf4cVvvkVYXPCFfHKq6TE7zc9XdBKxqS5P4q", destination.base58)
+ }
+
+ @Test
+ fun `default builder creates partially signed exact transaction shape`() {
+ val accepted = JsonObject().apply {
+ addProperty("scheme", "exact")
+ addProperty("network", ExactChallenge.DEFAULT_NETWORK)
+ addProperty("asset", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU")
+ addProperty("amount", "1000")
+ addProperty("payTo", "11111111111111111111111111111115")
+ add(
+ "extra",
+ JsonObject().apply {
+ addProperty("feePayer", "11111111111111111111111111111111")
+ addProperty("decimals", 6)
+ addProperty("tokenProgram", "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
+ addProperty("memo", "order-123")
+ },
+ )
+ }
+ val request = SolanaExactPaymentRequest(
+ payer = "11111111111111111111111111111112",
+ network = ExactChallenge.DEFAULT_NETWORK,
+ asset = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ amount = "1000",
+ payTo = "11111111111111111111111111111115",
+ feePayer = "11111111111111111111111111111111",
+ memo = "order-123",
+ maxTimeoutSeconds = 60,
+ accepted = accepted,
+ )
+
+ val tx = DefaultSolanaExactTransactionBuilder(FixedRpc).buildUnsignedTransaction(request)
+
+ assertEquals(2, tx.signatures.size)
+ assertEquals(1, tx.signerIndex)
+ assertEquals(0x80, tx.message[0].toInt() and 0xff)
+ assertEquals(2, tx.message[1].toInt())
+ assertContentEquals(ByteArray(64), tx.signatures[0])
+ }
+
+ @Test
+ fun `builder uses signer as fee payer when challenge omits feePayer`() {
+ // Spine parity with rust/crates/x402/src/client/exact/payment.rs:
+ // when `requirements.fee_payer_key` is absent the signer pays its own
+ // network fees. The compiled v0 message must require exactly one
+ // signature (signer == feePayer) and place the signer first in the
+ // account-keys table.
+ val accepted = JsonObject().apply {
+ addProperty("scheme", "exact")
+ addProperty("network", ExactChallenge.DEFAULT_NETWORK)
+ addProperty("asset", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU")
+ addProperty("amount", "1000")
+ addProperty("payTo", "11111111111111111111111111111115")
+ add(
+ "extra",
+ JsonObject().apply {
+ addProperty("decimals", 6)
+ addProperty("tokenProgram", "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
+ },
+ )
+ }
+ val payerKey = "11111111111111111111111111111112"
+ val request = SolanaExactPaymentRequest(
+ payer = payerKey,
+ network = ExactChallenge.DEFAULT_NETWORK,
+ asset = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ amount = "1000",
+ payTo = "11111111111111111111111111111115",
+ feePayer = null,
+ memo = null,
+ maxTimeoutSeconds = 60,
+ accepted = accepted,
+ )
+
+ val tx = DefaultSolanaExactTransactionBuilder(FixedRpc).buildUnsignedTransaction(request)
+
+ // One required signature (the payer doubles as fee payer).
+ assertEquals(1, tx.message[1].toInt())
+ assertEquals(1, tx.signatures.size)
+ assertEquals(0, tx.signerIndex)
+ }
+
+ @Test
+ fun `compileV0Message dedupes accounts that appear in multiple instructions with different roles`() {
+ // Regression for Greptile P2: independent role sets used to allow the same
+ // pubkey to be emitted twice in accountKeys when two instructions reference
+ // it under different (signer, writable) classifications. The cross-set
+ // dedup now promotes to the strongest role and emits the key once.
+ val feePayer = SolanaPublicKey.fromBase58("11111111111111111111111111111111")
+ val payer = SolanaPublicKey.fromBase58("11111111111111111111111111111112")
+ val shared = SolanaPublicKey.fromBase58("11111111111111111111111111111115")
+ val program = SolanaPublicKey.fromBase58("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr")
+
+ // Instruction 1: shared is read-only, non-signer.
+ // Instruction 2: shared is writable, non-signer.
+ // Expected: shared appears exactly once, promoted to writable non-signer.
+ val instructions = listOf(
+ SolanaInstruction(
+ programId = program,
+ accounts = listOf(AccountMeta(shared, signer = false, writable = false)),
+ data = byteArrayOf(1),
+ ),
+ SolanaInstruction(
+ programId = program,
+ accounts = listOf(AccountMeta(shared, signer = false, writable = true)),
+ data = byteArrayOf(2),
+ ),
+ )
+
+ val compiled = SolanaTransactionCodec.compileV0Message(
+ feePayer = feePayer,
+ signers = listOf(feePayer, payer),
+ instructions = instructions,
+ recentBlockhash = SolanaPublicKey.fromBase58("11111111111111111111111111111111"),
+ )
+
+ assertEquals(
+ compiled.accountKeys.size,
+ compiled.accountKeys.toSet().size,
+ "accountKeys must contain no duplicates",
+ )
+ assertEquals(1, compiled.accountKeys.count { it == shared })
+ // shared must be in the writable-non-signer slice, i.e. after the
+ // signer slices (feePayer + payer = 2) but before the read-only-non-signers.
+ val sharedIndex = compiled.accountKeys.indexOf(shared)
+ assertTrue(sharedIndex >= compiled.requiredSignatures, "shared promoted to writable should follow signers")
+ }
+
+ @Test
+ fun `builder encodes full u64 amount range above Long MAX_VALUE`() {
+ // Spine parity: rust/crates/x402/src/protocol/schemes/exact/verify.rs
+ // parses amount as u64. The Kotlin builder previously rejected any
+ // value above Long.MAX_VALUE; now the ULong is encoded directly as 8
+ // little-endian bytes inside the transferChecked instruction data so
+ // the full u64 range is valid.
+ val accepted = JsonObject().apply {
+ addProperty("scheme", "exact")
+ addProperty("network", ExactChallenge.DEFAULT_NETWORK)
+ addProperty("asset", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU")
+ addProperty("amount", "1")
+ addProperty("payTo", "11111111111111111111111111111115")
+ add(
+ "extra",
+ JsonObject().apply {
+ addProperty("feePayer", "11111111111111111111111111111111")
+ addProperty("decimals", 6)
+ addProperty("tokenProgram", "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
+ },
+ )
+ }
+ // u64::MAX = 18446744073709551615 — well above Long.MAX_VALUE.
+ val u64Max = ULong.MAX_VALUE.toString()
+ val request = SolanaExactPaymentRequest(
+ payer = "11111111111111111111111111111112",
+ network = ExactChallenge.DEFAULT_NETWORK,
+ asset = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ amount = u64Max,
+ payTo = "11111111111111111111111111111115",
+ feePayer = "11111111111111111111111111111111",
+ memo = null,
+ maxTimeoutSeconds = 60,
+ accepted = accepted,
+ )
+
+ val unsigned = DefaultSolanaExactTransactionBuilder(FixedRpc).buildUnsignedTransaction(request)
+ // u64::MAX encodes to eight 0xFF bytes in little-endian. Search the
+ // compiled message bytes for the transferChecked discriminator (0x0c)
+ // followed by 0xFF * 8 + decimals=6.
+ val needle = byteArrayOf(12) + ByteArray(8) { 0xFF.toByte() } + byteArrayOf(6)
+ val found = (0..unsigned.message.size - needle.size).any { offset ->
+ (0 until needle.size).all { i -> unsigned.message[offset + i] == needle[i] }
+ }
+ assertTrue(found, "expected transferChecked amount 0xFF*8 (u64::MAX) + decimals=6 in compiled message")
+ }
+
+ @Test
+ fun `transferChecked_rejects_unsupported_program`() {
+ // P1 security: builder is a public entry point. If accepted.tokenProgram
+ // (or RPC owner) ever points at an arbitrary program, fail loudly
+ // before serializing transferChecked into the message.
+ val accepted = JsonObject().apply {
+ addProperty("scheme", "exact")
+ addProperty("network", ExactChallenge.DEFAULT_NETWORK)
+ addProperty("asset", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU")
+ addProperty("amount", "1")
+ addProperty("payTo", "11111111111111111111111111111115")
+ addProperty("tokenProgram", "EvilProgram1111111111111111111111111111")
+ add(
+ "extra",
+ JsonObject().apply {
+ addProperty("feePayer", "11111111111111111111111111111111")
+ addProperty("decimals", 6)
+ },
+ )
+ }
+ val request = SolanaExactPaymentRequest(
+ payer = "11111111111111111111111111111112",
+ network = ExactChallenge.DEFAULT_NETWORK,
+ asset = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ amount = "1",
+ payTo = "11111111111111111111111111111115",
+ feePayer = "11111111111111111111111111111111",
+ memo = null,
+ maxTimeoutSeconds = 60,
+ accepted = accepted,
+ )
+ val error = assertFailsWith {
+ DefaultSolanaExactTransactionBuilder(FixedRpc).buildUnsignedTransaction(request)
+ }
+ assertTrue(
+ error.message?.contains("unsupported tokenProgram") == true,
+ "expected unsupported-tokenProgram rejection, got: ${error.message}",
+ )
+ }
+
+ @Test
+ fun `transferChecked_rejects_unsupported_program_from_rpc_owner`() {
+ // Even if the server omits tokenProgram entirely, the RPC metadata
+ // owner is untrusted data — must also be on the SPL allowlist.
+ val accepted = JsonObject().apply {
+ addProperty("scheme", "exact")
+ addProperty("network", ExactChallenge.DEFAULT_NETWORK)
+ addProperty("asset", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU")
+ addProperty("amount", "1")
+ addProperty("payTo", "11111111111111111111111111111115")
+ add(
+ "extra",
+ JsonObject().apply {
+ addProperty("feePayer", "11111111111111111111111111111111")
+ addProperty("decimals", 6)
+ },
+ )
+ }
+ val request = SolanaExactPaymentRequest(
+ payer = "11111111111111111111111111111112",
+ network = ExactChallenge.DEFAULT_NETWORK,
+ asset = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ amount = "1",
+ payTo = "11111111111111111111111111111115",
+ feePayer = "11111111111111111111111111111111",
+ memo = null,
+ maxTimeoutSeconds = 60,
+ accepted = accepted,
+ )
+ val hostileRpc = object : SolanaRpc {
+ override fun latestBlockhash(): String = "11111111111111111111111111111111"
+ override fun tokenMetadata(mint: String): SolanaTokenMetadata =
+ SolanaTokenMetadata(
+ tokenProgram = "EvilProgram1111111111111111111111111111",
+ decimals = 6,
+ )
+ }
+ val error = assertFailsWith {
+ DefaultSolanaExactTransactionBuilder(hostileRpc).buildUnsignedTransaction(request)
+ }
+ assertTrue(
+ error.message?.contains("unsupported tokenProgram") == true,
+ "expected unsupported-tokenProgram rejection, got: ${error.message}",
+ )
+ }
+
+ @Test
+ fun `transferChecked_accepts_token_2022_program`() {
+ val accepted = JsonObject().apply {
+ addProperty("scheme", "exact")
+ addProperty("network", ExactChallenge.DEFAULT_NETWORK)
+ addProperty("asset", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU")
+ addProperty("amount", "1000")
+ addProperty("payTo", "11111111111111111111111111111115")
+ add(
+ "extra",
+ JsonObject().apply {
+ addProperty("feePayer", "11111111111111111111111111111111")
+ addProperty("decimals", 6)
+ addProperty("tokenProgram", "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb")
+ },
+ )
+ }
+ val request = SolanaExactPaymentRequest(
+ payer = "11111111111111111111111111111112",
+ network = ExactChallenge.DEFAULT_NETWORK,
+ asset = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ amount = "1000",
+ payTo = "11111111111111111111111111111115",
+ feePayer = "11111111111111111111111111111111",
+ memo = null,
+ maxTimeoutSeconds = 60,
+ accepted = accepted,
+ )
+ val tx = DefaultSolanaExactTransactionBuilder(FixedRpc).buildUnsignedTransaction(request)
+ assertEquals(2, tx.signatures.size)
+ }
+}
+
+private object FixedRpc : SolanaRpc {
+ override fun latestBlockhash(): String = "11111111111111111111111111111111"
+
+ override fun tokenMetadata(mint: String): SolanaTokenMetadata =
+ SolanaTokenMetadata(
+ tokenProgram = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
+ decimals = 6,
+ )
+}
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/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/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/README.md b/swift/README.md
index 38ff3a21b..fb10b1d0f 100644
--- a/swift/README.md
+++ b/swift/README.md
@@ -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/tests/interop/README.md b/tests/interop/README.md
index b1018c649..fd0d5065f 100644
--- a/tests/interop/README.md
+++ b/tests/interop/README.md
@@ -91,7 +91,7 @@ expected success/failure status, live in `src/contracts.ts`.
1. Add a process adapter for the language.
2. Register it in `src/implementations.ts` as a client, server, or both.
-3. Keep the adapter command relative to `tests/interop`.
+3. Keep the adapter command relative to `harness`.
4. Make stdout emit only the `ready` or `result` JSON message.
5. Run a focused matrix before enabling it by default:
@@ -123,6 +123,55 @@ Use these environment variables to filter the active matrix:
- `MPP_INTEROP_INTENTS=charge`
- `MPP_INTEROP_SCENARIOS=charge-basic,charge-split-ata,charge-network-mismatch,charge-cross-route-replay`
+### x402 exact intent
+
+A second intent, `x402-exact`, exercises the canonical x402 `exact` scheme
+against the Rust spine in `rust/crates/x402/src/bin/interop_{client,server}.rs`.
+The TypeScript reference adapters live at
+`src/fixtures/typescript/exact-{client,server}.ts` and share the same
+harness contract as the Rust spine: identical `X402_INTEROP_*` env vars,
+identical `PAYMENT-REQUIRED` / `PAYMENT-SIGNATURE` headers, identical
+ready / result JSON shapes. The TS reference fixture carries a stub
+credential payload (challenge id + resource) and is paired against the
+TS reference server in the default matrix; the Rust spine is paired
+against itself. As language adapters that carry a real Solana
+PaymentProof land, they expand the matrix by registering under
+`intents: ["x402-exact"]` in `implementations.ts`.
+
+Env vars consumed by both roles:
+
+- `X402_INTEROP_RPC_URL`, `X402_INTEROP_NETWORK`, `X402_INTEROP_MINT`
+- `X402_INTEROP_PAY_TO`, `X402_INTEROP_PRICE`
+- `X402_INTEROP_FACILITATOR_SECRET_KEY`
+
+Server-only:
+
+- `X402_INTEROP_EXTRA_OFFERED_MINTS` (CSV of additional mint addresses)
+
+Client-only:
+
+- `X402_INTEROP_TARGET_URL`
+- `X402_INTEROP_CLIENT_SECRET_KEY`
+- `X402_INTEROP_PREFER_CURRENCIES` (CSV of preferred currencies)
+
+Run the x402 matrix slice:
+
+```bash
+X402_INTEROP_MATRIX=1 \
+X402_INTEROP_RPC_URL=http://127.0.0.1:8899 \
+X402_INTEROP_MINT=... X402_INTEROP_PAY_TO=... \
+X402_INTEROP_CLIENT_SECRET_KEY='[...]' \
+X402_INTEROP_FACILITATOR_SECRET_KEY='[...]' \
+pnpm test x402-exact.e2e.test.ts
+```
+
+Cross-server portability and idempotent-resubmit scenarios are gated
+separately:
+
+```bash
+X402_INTEROP_CROSS_SERVER=1 pnpm test cross-server-scenarios.test.ts
+```
+
The current scenario set covers only the `charge` intent. It includes a basic
payment, a split payment that requires the server fee payer to create the split
recipient ATA, a negative network-mismatch payment, and a cross-route replay
@@ -156,13 +205,13 @@ install:
cd ../../typescript
pnpm --filter @solana/mpp build
-cd ../tests/interop
+cd ../harness
pnpm install --force --frozen-lockfile
pnpm test
```
`@solana/mpp` is installed from a local `file:` dependency, so
-`tests/interop` needs to install after the TypeScript package has produced its
+`harness` needs to install after the TypeScript package has produced its
`dist` files.
The harness starts Surfpool through `start-surfnet-proxy.mjs`, funds the test
diff --git a/tests/interop/go-client/go.mod b/tests/interop/go-client/go.mod
index 66f4683a5..6ef6132c2 100644
--- a/tests/interop/go-client/go.mod
+++ b/tests/interop/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
diff --git a/tests/interop/go-server/go.mod b/tests/interop/go-server/go.mod
index ccc2d8e8b..0aa9af7bf 100644
--- a/tests/interop/go-server/go.mod
+++ b/tests/interop/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
diff --git a/tests/interop/lua-server/dx-gate.mjs b/tests/interop/lua-server/dx-gate.mjs
index 58491e3f6..dfcf85abb 100644
--- a/tests/interop/lua-server/dx-gate.mjs
+++ b/tests/interop/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/tests/interop/lua-server/server.lua
index 4f7a055f0..ef81889a0 100644
--- a/tests/interop/lua-server/server.lua
+++ b/tests/interop/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/python-server/main.py b/tests/interop/python-server/main.py
index 575c7e999..2f39982fc 100644
--- a/tests/interop/python-server/main.py
+++ b/tests/interop/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,11 +21,11 @@
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 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,
+# ``harness`` (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.
diff --git a/tests/interop/src/contracts.ts b/tests/interop/src/contracts.ts
index 87c43fa77..8143e863b 100644
--- a/tests/interop/src/contracts.ts
+++ b/tests/interop/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/exact-client.ts b/tests/interop/src/fixtures/typescript/exact-client.ts
new file mode 100644
index 000000000..67807f376
--- /dev/null
+++ b/tests/interop/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/tests/interop/src/fixtures/typescript/exact-server.ts b/tests/interop/src/fixtures/typescript/exact-server.ts
new file mode 100644
index 000000000..780c6633e
--- /dev/null
+++ b/tests/interop/src/fixtures/typescript/exact-server.ts
@@ -0,0 +1,368 @@
+// TypeScript reference x402 `exact` interop server.
+//
+// Wire-compatible with `rust/crates/x402/src/bin/interop_server.rs`:
+// - 402 carries a `PAYMENT-REQUIRED` header whose value is the
+// base64 of the JSON envelope `{x402Version, accepts, resource}`.
+// - The credential is delivered in the `PAYMENT-SIGNATURE` header.
+// - On successful settlement, the response includes
+// `PAYMENT-RESPONSE` and the fixture settlement header.
+//
+// This fixture deliberately keeps the SDK surface area minimal so the
+// adapter is portable across pay-kit checkouts. The cross-language
+// matrix is the load-bearing path; this adapter exists so language
+// adapters have a TS counterpart to pair against while the canonical
+// SDK lands. End-to-end verification against a live Surfpool RPC is
+// driven by the matrix runner.
+
+import http from "node:http";
+import {
+ PAYMENT_REQUIRED_HEADER,
+ PAYMENT_RESPONSE_HEADER,
+ PAYMENT_SIGNATURE_HEADER,
+ X402_VERSION_V2,
+ readX402ServerEnvironment,
+} from "./exact-shared";
+
+const TOKEN_DECIMALS = 6;
+const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
+
+type PaymentRequirement = {
+ scheme: "exact";
+ network: string;
+ resource: string;
+ description: string;
+ mimeType: string;
+ payTo: string;
+ asset: string;
+ maxAmountRequired: string;
+ maxTimeoutSeconds: number;
+ extra: {
+ decimals: number;
+ tokenProgram?: string;
+ feePayer?: string;
+ };
+};
+
+function buildRequirements(
+ env: ReturnType,
+): PaymentRequirement[] {
+ const primary: PaymentRequirement = {
+ scheme: "exact",
+ network: env.network,
+ resource: env.resourcePath,
+ description: "Surfpool-backed protected content",
+ mimeType: "application/json",
+ payTo: env.payTo,
+ asset: env.mint,
+ maxAmountRequired: env.price,
+ maxTimeoutSeconds: 60,
+ extra: {
+ decimals: TOKEN_DECIMALS,
+ tokenProgram: TOKEN_PROGRAM,
+ },
+ };
+
+ const extras: PaymentRequirement[] = env.extraOfferedMints.map(mint => ({
+ scheme: "exact",
+ network: env.network,
+ resource: env.resourcePath,
+ description: "Surfpool-backed protected content",
+ mimeType: "application/json",
+ payTo: env.payTo,
+ asset: mint,
+ maxAmountRequired: env.price,
+ maxTimeoutSeconds: 60,
+ extra: { decimals: TOKEN_DECIMALS },
+ }));
+
+ return [primary, ...extras];
+}
+
+function encodePaymentRequiredHeader(accepts: PaymentRequirement[]): string {
+ const envelope = {
+ x402Version: X402_VERSION_V2,
+ accepts,
+ resource: accepts[0]?.resource,
+ error: null,
+ };
+ return Buffer.from(JSON.stringify(envelope), "utf8").toString("base64");
+}
+
+type DecodedCredential = {
+ x402Version?: number;
+ accepted?: {
+ scheme?: string;
+ network?: string;
+ asset?: string;
+ payTo?: string;
+ amount?: string;
+ };
+ payload?: {
+ challengeId?: string;
+ resource?: string;
+ };
+ resource?: string;
+};
+
+function decodeCredential(headerValue: string): DecodedCredential | null {
+ try {
+ const decoded = Buffer.from(headerValue, "base64").toString("utf8");
+ return JSON.parse(decoded) as DecodedCredential;
+ } catch {
+ return null;
+ }
+}
+
+type RejectReason = {
+ code:
+ | "payment_invalid"
+ | "wrong_network"
+ | "charge_request_mismatch"
+ | "challenge_verification_failed";
+ message: string;
+};
+
+function classifyCredential(
+ credential: DecodedCredential | null,
+ accepts: PaymentRequirement[],
+ requestedResource: string,
+): { offer: PaymentRequirement; credentialKey: string } | { reject: RejectReason } {
+ if (!credential || !credential.accepted || !credential.payload) {
+ return {
+ reject: {
+ code: "payment_invalid",
+ message: "credential is missing accepted/payload fields",
+ },
+ };
+ }
+
+ const offer = accepts.find(
+ candidate =>
+ candidate.asset === credential.accepted?.asset &&
+ candidate.network === credential.accepted?.network &&
+ candidate.scheme === credential.accepted?.scheme,
+ );
+
+ if (!offer) {
+ // Could be either network mismatch or no matching offer.
+ if (
+ credential.accepted.network &&
+ !accepts.some(c => c.network === credential.accepted?.network)
+ ) {
+ return {
+ reject: {
+ code: "wrong_network",
+ message: `credential network ${credential.accepted.network} does not match server`,
+ },
+ };
+ }
+ return {
+ reject: {
+ code: "charge_request_mismatch",
+ message: "no offered requirement matches the credential",
+ },
+ };
+ }
+
+ if (offer.payTo !== credential.accepted.payTo) {
+ return {
+ reject: {
+ code: "charge_request_mismatch",
+ message: "recipient does not match",
+ },
+ };
+ }
+
+ if (offer.maxAmountRequired !== credential.accepted.amount) {
+ return {
+ reject: {
+ code: "charge_request_mismatch",
+ message: "amount does not match",
+ },
+ };
+ }
+
+ const credentialResource = credential.payload.resource ?? credential.resource;
+ if (credentialResource && credentialResource !== requestedResource) {
+ return {
+ reject: {
+ code: "charge_request_mismatch",
+ message: `credential resource ${credentialResource} does not match requested ${requestedResource}`,
+ },
+ };
+ }
+
+ const challengeId = credential.payload.challengeId;
+ if (!challengeId || typeof challengeId !== "string") {
+ return {
+ reject: {
+ code: "challenge_verification_failed",
+ message: "credential payload missing challengeId",
+ },
+ };
+ }
+
+ return { offer, credentialKey: challengeId };
+}
+
+async function main() {
+ const env = readX402ServerEnvironment();
+ const accepts = buildRequirements(env);
+ const paymentRequiredHeader = encodePaymentRequiredHeader(accepts);
+
+ // Track consumed credentials by challengeId to surface
+ // `signature_consumed` on idempotent resubmit.
+ const consumed = new Set();
+ // Track challenge IDs this server has issued (recognised when a
+ // credential's payload.challengeId matches). Cross-server portability:
+ // server B sees a credential carrying an id only server A issued, so B
+ // rejects with `challenge_verification_failed`. A real x402 facilitator
+ // verifies HMAC over the challenge id with its own secret; this fixture
+ // simulates that by tracking issuance in-process.
+ const issued = new Set();
+
+ const server = http.createServer((request, response) => {
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
+
+ if (url.pathname === "/health") {
+ response.writeHead(200, { "content-type": "application/json" });
+ response.end(JSON.stringify({ ok: true }));
+ return;
+ }
+
+ if (url.pathname !== env.resourcePath) {
+ response.writeHead(404, { "content-type": "application/json" });
+ response.end(JSON.stringify({ error: "not_found" }));
+ return;
+ }
+
+ const paymentHeader = request.headers[PAYMENT_SIGNATURE_HEADER] as
+ | string
+ | undefined;
+
+ if (!paymentHeader) {
+ // Issue a fresh challenge id so the client can echo it back. The
+ // fixture's "verification" is presence-in-`issued`; a real
+ // facilitator would HMAC the id with its secret.
+ const challengeId = `ts-srv-${Date.now().toString(36)}-${Math.random()
+ .toString(36)
+ .slice(2, 10)}`;
+ issued.add(challengeId);
+ response.writeHead(402, {
+ "content-type": "application/json",
+ [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader,
+ "x-challenge-id": challengeId,
+ });
+ response.end(
+ JSON.stringify({ error: "payment_required", challengeId }),
+ );
+ return;
+ }
+
+ const credential = decodeCredential(paymentHeader);
+ const classified = classifyCredential(credential, accepts, env.resourcePath);
+
+ if ("reject" in classified) {
+ response.writeHead(402, {
+ "content-type": "application/json",
+ [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader,
+ });
+ response.end(
+ JSON.stringify({
+ error: classified.reject.code,
+ code: classified.reject.code,
+ message: classified.reject.message,
+ }),
+ );
+ return;
+ }
+
+ const { credentialKey } = classified;
+
+ if (consumed.has(credentialKey)) {
+ response.writeHead(402, {
+ "content-type": "application/json",
+ [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader,
+ });
+ response.end(
+ JSON.stringify({
+ error: "signature_consumed",
+ code: "signature_consumed",
+ message: "signature already consumed",
+ }),
+ );
+ return;
+ }
+
+ // Cross-server portability check: when the client supplies a payload
+ // challengeId, it must be one this server issued (or this server
+ // never required HMAC issuance). The first paid request that didn't
+ // come from this server's 402 will be missing from `issued`.
+ if (issued.size > 0 && !issued.has(credentialKey)) {
+ response.writeHead(402, {
+ "content-type": "application/json",
+ [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader,
+ });
+ response.end(
+ JSON.stringify({
+ error: "challenge_verification_failed",
+ code: "challenge_verification_failed",
+ message: "challenge id was not issued by this server",
+ }),
+ );
+ return;
+ }
+
+ consumed.add(credentialKey);
+
+ // Settlement: a real facilitator would broadcast a signed Solana
+ // transaction here. The fixture returns a deterministic placeholder
+ // so the harness can assert presence of the settlement header.
+ const settlement = `ts-x402-exact-${credentialKey.slice(0, 16)}`;
+ const paymentResponse = JSON.stringify({
+ success: true,
+ network: accepts[0]?.network,
+ transaction: settlement,
+ });
+
+ response.writeHead(200, {
+ "content-type": "application/json",
+ [env.settlementHeader]: settlement,
+ [PAYMENT_RESPONSE_HEADER]: paymentResponse,
+ });
+ response.end(
+ JSON.stringify({
+ ok: true,
+ paid: true,
+ settlement: {
+ success: true,
+ transaction: settlement,
+ network: accepts[0]?.network,
+ },
+ }),
+ );
+ });
+
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ throw new Error("Failed to bind TypeScript x402 interop server");
+ }
+
+ console.log(
+ JSON.stringify({
+ type: "ready",
+ implementation: "typescript",
+ role: "server",
+ port: address.port,
+ capabilities: ["exact"],
+ }),
+ );
+ });
+
+ const shutdown = () => server.close(() => process.exit(0));
+ process.on("SIGTERM", shutdown);
+ process.on("SIGINT", shutdown);
+}
+
+void main();
diff --git a/tests/interop/src/fixtures/typescript/exact-shared.ts b/tests/interop/src/fixtures/typescript/exact-shared.ts
new file mode 100644
index 000000000..d9771bd8c
--- /dev/null
+++ b/tests/interop/src/fixtures/typescript/exact-shared.ts
@@ -0,0 +1,87 @@
+// Env contract for the TypeScript x402 `exact` fixture adapters. The
+// wire shape mirrors the Rust spine (`rust/crates/x402/src/bin/
+// interop_{client,server}.rs`) verbatim so any language adapter that
+// targets this contract can pair against either TS or Rust.
+
+export type X402InteropEnvironment = {
+ rpcUrl: string;
+ network: string;
+ mint: string;
+ payTo: string;
+ price: string;
+ resourcePath: string;
+ settlementHeader: string;
+ facilitatorSecretKey: Uint8Array;
+ // Server-only. Comma-separated mint addresses advertised alongside the
+ // primary currency. Read from `X402_INTEROP_EXTRA_OFFERED_MINTS`.
+ extraOfferedMints: string[];
+};
+
+export type X402ClientEnvironment = X402InteropEnvironment & {
+ targetUrl: string;
+ clientSecretKey: Uint8Array;
+ // Comma-separated currency preference list (symbols or mints) read
+ // from `X402_INTEROP_PREFER_CURRENCIES`. Empty when unset.
+ preferredCurrencies: string[];
+};
+
+const DEFAULT_NETWORK = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1";
+const DEFAULT_RESOURCE_PATH = "/protected";
+const DEFAULT_PRICE = "0.001";
+const DEFAULT_SETTLEMENT_HEADER = "x-fixture-settlement";
+
+function readRequiredEnv(name: string): string {
+ const value = process.env[name];
+ if (!value || value.trim() === "") {
+ throw new Error(`${name} is required`);
+ }
+ return value;
+}
+
+function parseSecretKey(name: string): Uint8Array {
+ const raw = readRequiredEnv(name);
+ const parsed = JSON.parse(raw) as number[];
+ return new Uint8Array(parsed);
+}
+
+function parseCsv(raw: string | undefined): string[] {
+ if (!raw) return [];
+ return raw
+ .split(",")
+ .map(value => value.trim())
+ .filter(Boolean);
+}
+
+function readBase(): X402InteropEnvironment {
+ return {
+ rpcUrl: readRequiredEnv("X402_INTEROP_RPC_URL"),
+ network: process.env.X402_INTEROP_NETWORK ?? DEFAULT_NETWORK,
+ mint: readRequiredEnv("X402_INTEROP_MINT"),
+ payTo: readRequiredEnv("X402_INTEROP_PAY_TO"),
+ price: process.env.X402_INTEROP_PRICE ?? DEFAULT_PRICE,
+ resourcePath: process.env.X402_INTEROP_RESOURCE_PATH ?? DEFAULT_RESOURCE_PATH,
+ settlementHeader:
+ process.env.X402_INTEROP_SETTLEMENT_HEADER ?? DEFAULT_SETTLEMENT_HEADER,
+ facilitatorSecretKey: parseSecretKey("X402_INTEROP_FACILITATOR_SECRET_KEY"),
+ extraOfferedMints: parseCsv(process.env.X402_INTEROP_EXTRA_OFFERED_MINTS),
+ };
+}
+
+export function readX402ServerEnvironment(): X402InteropEnvironment {
+ return readBase();
+}
+
+export function readX402ClientEnvironment(): X402ClientEnvironment {
+ const base = readBase();
+ return {
+ ...base,
+ targetUrl: readRequiredEnv("X402_INTEROP_TARGET_URL"),
+ clientSecretKey: parseSecretKey("X402_INTEROP_CLIENT_SECRET_KEY"),
+ preferredCurrencies: parseCsv(process.env.X402_INTEROP_PREFER_CURRENCIES),
+ };
+}
+
+export const PAYMENT_REQUIRED_HEADER = "payment-required";
+export const PAYMENT_SIGNATURE_HEADER = "payment-signature";
+export const PAYMENT_RESPONSE_HEADER = "payment-response";
+export const X402_VERSION_V2 = 2;
diff --git a/tests/interop/src/implementations.ts b/tests/interop/src/implementations.ts
index 89c9586dc..b4d59b6bd 100644
--- a/tests/interop/src/implementations.ts
+++ b/tests/interop/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 {
@@ -69,6 +73,51 @@ export const clientImplementations: ImplementationDefinition[] = [
],
enabled: isEnabled("swift", "MPP_INTEROP_CLIENTS", false),
},
+ {
+ id: "ts-x402",
+ label: "TypeScript x402 exact client",
+ role: "client",
+ command: [
+ "pnpm",
+ "exec",
+ "node",
+ "--import",
+ "tsx",
+ "src/fixtures/typescript/exact-client.ts",
+ ],
+ enabled: isEnabled("ts-x402", "X402_INTEROP_CLIENTS", true),
+ intents: ["x402-exact"],
+ },
+ {
+ id: "rust-x402",
+ label: "Rust x402 exact client",
+ role: "client",
+ command: [
+ "cargo",
+ "run",
+ "--quiet",
+ "--manifest-path",
+ "../../rust/Cargo.toml",
+ "-p",
+ "solana-x402",
+ "--bin",
+ "interop_client",
+ ],
+ enabled: isEnabled("rust-x402", "X402_INTEROP_CLIENTS", true),
+ intents: ["x402-exact"],
+ },
+ {
+ id: "kotlin-x402-client",
+ label: "Kotlin x402 exact client",
+ role: "client",
+ command: [
+ "sh",
+ "-c",
+ "cd ../../kotlin && gradle --quiet --console=plain runInteropClient",
+ ],
+ enabled: isEnabled("kotlin-x402-client", "X402_INTEROP_CLIENTS", false),
+ intents: ["x402-exact"],
+ },
];
export const serverImplementations: ImplementationDefinition[] = [
@@ -122,7 +171,7 @@ export const serverImplementations: ImplementationDefinition[] = [
command: [
"sh",
"-c",
- "cd ../../ruby && bundle exec ruby ../tests/interop/ruby-server/server.rb",
+ "cd ../../ruby && bundle exec ruby ../harness/ruby-server/server.rb",
],
enabled: isEnabled("ruby", "MPP_INTEROP_SERVERS", false),
},
@@ -133,7 +182,7 @@ export const serverImplementations: ImplementationDefinition[] = [
command: [
"sh",
"-c",
- "cd ../../lua && eval \"$(luarocks --lua-version=5.1 --tree lua_modules path)\" && luajit ../tests/interop/lua-server/server.lua",
+ "cd ../../lua && eval \"$(luarocks --lua-version=5.1 --tree lua_modules path)\" && luajit ../harness/lua-server/server.lua",
],
// Lua defaults off to match php/ruby: the harness requires a
// luarocks-installed lua_modules tree under lua/ and a working
@@ -161,4 +210,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/tests/interop/src/intents/charge.ts
index db1e3dfa7..a1d58f35e 100644
--- a/tests/interop/src/intents/charge.ts
+++ b/tests/interop/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/tests/interop/src/intents/x402-exact.ts b/tests/interop/src/intents/x402-exact.ts
new file mode 100644
index 000000000..85f1afe93
--- /dev/null
+++ b/tests/interop/src/intents/x402-exact.ts
@@ -0,0 +1,119 @@
+import type { InteropScenario } from "../contracts";
+
+// Canonical x402 `exact` intent scenarios. The harness contract (env
+// vars, ready/result JSON shapes, capabilities) mirrors the Rust spine
+// (`rust/crates/x402/src/bin/interop_{client,server}.rs`). The matrix
+// pairs each x402 client against each x402 server registered in
+// `implementations.ts`; the default-matrix pair set is restricted in
+// `test/x402-exact.e2e.test.ts` while the TS reference adapter ships
+// without a full Solana signing path. Adding language adapters that
+// carry a real PaymentProof expands the matrix.
+//
+// Reject codes (cross-server portability / replay / network mismatch)
+// reuse the canonical L6 set declared in `canonical-codes.ts`; the
+// matrix asserts each x402 server adapter classifies the failure
+// to the same canonical snake_case code as every other adapter.
+export const x402ExactScenarios: readonly InteropScenario[] = [
+ {
+ id: "x402-exact-basic",
+ intent: "x402-exact",
+ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
+ price: "0.001",
+ amount: "1000",
+ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ resourcePath: "/protected",
+ settlementHeader: "x-fixture-settlement",
+ expectedStatus: 200,
+ },
+ {
+ // Network mismatch: client signs against localnet but the challenge
+ // requires devnet (or vice versa). Server must reject the credential
+ // with canonical `wrong_network`.
+ id: "x402-exact-network-mismatch",
+ intent: "x402-exact",
+ network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
+ price: "0.001",
+ amount: "1000",
+ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ resourcePath: "/protected/network-mismatch",
+ settlementHeader: "x-fixture-settlement",
+ expectedStatus: 402,
+ expectedCode: "wrong_network",
+ clientIds: ["ts-x402", "rust-x402"],
+ serverIds: ["ts-x402", "rust-x402"],
+ },
+ {
+ // Cross-route replay: credential issued for /protected/cheap is
+ // re-submitted against /protected/expensive. Server must reject with
+ // `charge_request_mismatch` because the credential's pinned route /
+ // amount does not match the served route.
+ id: "x402-exact-cross-route-replay",
+ intent: "x402-exact",
+ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
+ price: "0.001",
+ amount: "1000",
+ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ resourcePath: "/protected/expensive",
+ settlementHeader: "x-fixture-settlement",
+ replaySource: {
+ resourcePath: "/protected/cheap",
+ price: "0.0005",
+ amount: "500",
+ },
+ expectedStatus: 402,
+ expectedCode: "charge_request_mismatch",
+ clientIds: ["ts-x402"],
+ serverIds: ["ts-x402", "rust-x402"],
+ },
+ {
+ // Cross-server credential portability. Client pays server A and
+ // re-submits the same payment header to server B. B must reject with
+ // canonical `challenge_verification_failed` because B's verifier
+ // does not accept A's challenge issuance.
+ id: "x402-exact-cross-server-portability",
+ intent: "x402-exact",
+ kind: "cross-server-portability",
+ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
+ price: "0.001",
+ amount: "1000",
+ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ resourcePath: "/protected",
+ settlementHeader: "x-fixture-settlement",
+ expectedStatus: 402,
+ expectedCode: "challenge_verification_failed",
+ clientIds: ["ts-x402"],
+ serverIds: ["ts-x402", "rust-x402"],
+ // Cross-server portability requires the client adapter to expose the
+ // credential it sent so the runner can replay it. The TS reference
+ // client echoes `payment-signature-sent`; the Rust spine adapter does
+ // not (and is preserved as the canonical settlement-signing path
+ // rather than a credential-capturing one). Pairs that use the TS
+ // client cover the asymmetric direction too: TS pays server A, then
+ // replays the captured credential against server B.
+ crossServerPairs: [["ts-x402", "rust-x402"]],
+ },
+ {
+ // Same-server idempotent resubmit. Client pays server A, then
+ // re-submits the same payment header. Server must reject with
+ // `signature_consumed`.
+ id: "x402-exact-idempotent-resubmit",
+ intent: "x402-exact",
+ kind: "idempotent-resubmit",
+ network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
+ price: "0.001",
+ amount: "1000",
+ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
+ resourcePath: "/protected",
+ settlementHeader: "x-fixture-settlement",
+ expectedStatus: 402,
+ expectedCode: "signature_consumed",
+ // Driven by the TS client (the only one that echoes the sent
+ // credential back to the harness). The first paid request must
+ // reach 200, which constrains us to the TS reference server in
+ // the default matrix because that server is what speaks the TS
+ // client's stub payload. Rust server coverage of `signature_consumed`
+ // lives in the Rust crate's own integration tests.
+ clientIds: ["ts-x402"],
+ serverIds: ["ts-x402"],
+ },
+] as const;
diff --git a/tests/interop/test/cross-server-scenarios.test.ts b/tests/interop/test/cross-server-scenarios.test.ts
new file mode 100644
index 000000000..4dad52861
--- /dev/null
+++ b/tests/interop/test/cross-server-scenarios.test.ts
@@ -0,0 +1,210 @@
+// Cross-server portability + idempotent-resubmit scenarios for the x402
+// `exact` intent. Mirrors MPP §19.6:
+//
+// - Cross-server portability: the client pays server A and re-submits the
+// same payment-signature header to server B. B must reject with the
+// canonical `challenge_verification_failed` code because B's verifier
+// does not accept A's challenge.
+//
+// - Idempotent resubmit: the client pays server A, then re-submits the
+// same payment-signature header to server A. A must reject with
+// `signature_consumed`.
+//
+// Gated behind `X402_INTEROP_CROSS_SERVER=1` because the matrix needs
+// two long-lived servers and live RPC credentials, neither of which the
+// default `pnpm test` run wires up.
+
+import { afterAll, describe, expect, it } from "vitest";
+import { interopScenarios } from "../src/contracts";
+import { classifyMessageToCanonicalCode } from "../src/canonical-codes";
+import {
+ clientImplementations,
+ serverImplementations,
+} from "../src/implementations";
+import { runClient, startServer, stopServer } from "../src/process";
+
+const CROSS_SERVER_ENABLED = process.env.X402_INTEROP_CROSS_SERVER === "1";
+
+const requiredEnvs = [
+ "X402_INTEROP_RPC_URL",
+ "X402_INTEROP_MINT",
+ "X402_INTEROP_PAY_TO",
+ "X402_INTEROP_CLIENT_SECRET_KEY",
+ "X402_INTEROP_FACILITATOR_SECRET_KEY",
+];
+
+function missingEnvs(): string[] {
+ return requiredEnvs.filter(
+ name => !process.env[name] || process.env[name]?.trim() === "",
+ );
+}
+
+const portabilityScenario = interopScenarios.find(
+ scenario => scenario.id === "x402-exact-cross-server-portability",
+);
+const resubmitScenario = interopScenarios.find(
+ scenario => scenario.id === "x402-exact-idempotent-resubmit",
+);
+
+const serversById = new Map(serverImplementations.map(s => [s.id, s]));
+const clientsById = new Map(clientImplementations.map(c => [c.id, c]));
+
+type RunningServer = Awaited>;
+const runningServers: RunningServer[] = [];
+
+afterAll(async () => {
+ for (const server of runningServers.splice(0)) {
+ await stopServer(server);
+ }
+});
+
+function extractCanonicalCode(body: unknown): string | undefined {
+ if (body && typeof body === "object" && !Array.isArray(body)) {
+ const record = body as Record;
+ if (typeof record.code === "string") return record.code;
+ const source =
+ (typeof record.error === "string" && record.error) ||
+ (typeof record.message === "string" && record.message) ||
+ undefined;
+ if (source) return classifyMessageToCanonicalCode(source);
+ }
+ if (typeof body === "string") {
+ return classifyMessageToCanonicalCode(body);
+ }
+ return undefined;
+}
+
+describe("x402 exact — cross-server portability + idempotent resubmit", () => {
+ if (!CROSS_SERVER_ENABLED) {
+ it.skip("cross-server suite is gated behind X402_INTEROP_CROSS_SERVER=1", () => {});
+ return;
+ }
+
+ const missing = missingEnvs();
+ if (missing.length > 0) {
+ it.skip(`missing required env vars: ${missing.join(", ")}`, () => {});
+ return;
+ }
+
+ if (portabilityScenario && portabilityScenario.crossServerPairs) {
+ for (const [serverAId, serverBId] of portabilityScenario.crossServerPairs) {
+ const serverA = serversById.get(serverAId);
+ const serverB = serversById.get(serverBId);
+ // Use the TS reference client to drive the pay-then-replay flow
+ // because it echoes the sent credential under `payment-signature-sent`.
+ // The Rust spine client does not surface the captured credential to
+ // the harness; its portability coverage is exercised by the Rust
+ // crate's own integration tests.
+ const client = clientsById.get("ts-x402");
+ if (!serverA?.enabled || !serverB?.enabled || !client?.enabled) {
+ it.skip(`portability ${serverAId} -> ${serverBId}: adapter not enabled`, () => {});
+ continue;
+ }
+
+ it(`portability: pay ${serverAId} then resubmit credential to ${serverBId}`, async () => {
+ const env = {
+ X402_INTEROP_NETWORK: portabilityScenario.network,
+ X402_INTEROP_PRICE: portabilityScenario.price,
+ X402_INTEROP_RESOURCE_PATH: portabilityScenario.resourcePath,
+ X402_INTEROP_SETTLEMENT_HEADER: portabilityScenario.settlementHeader,
+ };
+
+ const runningA = await startServer(serverA, env);
+ runningServers.push(runningA);
+ const runningB = await startServer(serverB, env);
+ runningServers.push(runningB);
+
+ try {
+ const urlA = `http://127.0.0.1:${runningA.ready.port}${portabilityScenario.resourcePath}`;
+ const payA = await runClient(client, urlA, {
+ X402_INTEROP_TARGET_URL: urlA,
+ ...env,
+ });
+ expect(payA.status).toBe(200);
+
+ // Re-submit the captured payment-signature header to server B.
+ // Adapters echo the credential they sent under `*-sent` so the
+ // harness can replay it. Falls back to the live payment-signature
+ // header for adapters that don't echo (rust spine).
+ const headers = payA.responseHeaders as Record;
+ const credential =
+ headers["payment-signature-sent"] ?? headers["payment-signature"];
+ const urlB = `http://127.0.0.1:${runningB.ready.port}${portabilityScenario.resourcePath}`;
+ const replay = await fetch(urlB, {
+ headers: credential
+ ? { "payment-signature": String(credential) }
+ : {},
+ });
+ const body = await replay.json().catch(() => null);
+
+ expect(replay.status).toBe(portabilityScenario.expectedStatus);
+ if (portabilityScenario.expectedCode) {
+ expect(extractCanonicalCode(body)).toBe(portabilityScenario.expectedCode);
+ }
+ } finally {
+ await stopServer(runningA);
+ await stopServer(runningB);
+ runningServers.splice(runningServers.indexOf(runningA), 1);
+ runningServers.splice(runningServers.indexOf(runningB), 1);
+ }
+ }, 180_000);
+ }
+ } else {
+ it.skip("portability scenario missing crossServerPairs", () => {});
+ }
+
+ if (resubmitScenario) {
+ const serverIds = resubmitScenario.serverIds ?? ["ts-x402"];
+ for (const sid of serverIds) {
+ const server = serversById.get(sid);
+ // Same rationale as portability above: drive with the TS client so
+ // the harness can replay the captured credential.
+ const client = clientsById.get("ts-x402");
+ if (!server?.enabled || !client?.enabled) {
+ it.skip(`idempotent-resubmit on ${sid}: adapter not enabled`, () => {});
+ continue;
+ }
+
+ it(`idempotent resubmit against ${sid}`, async () => {
+ const env = {
+ X402_INTEROP_NETWORK: resubmitScenario.network,
+ X402_INTEROP_PRICE: resubmitScenario.price,
+ X402_INTEROP_RESOURCE_PATH: resubmitScenario.resourcePath,
+ X402_INTEROP_SETTLEMENT_HEADER: resubmitScenario.settlementHeader,
+ };
+
+ const running = await startServer(server, env);
+ runningServers.push(running);
+
+ try {
+ const url = `http://127.0.0.1:${running.ready.port}${resubmitScenario.resourcePath}`;
+ const first = await runClient(client, url, {
+ X402_INTEROP_TARGET_URL: url,
+ ...env,
+ });
+ expect(first.status).toBe(200);
+
+ const headers = first.responseHeaders as Record;
+ const credential =
+ headers["payment-signature-sent"] ?? headers["payment-signature"];
+ const replay = await fetch(url, {
+ headers: credential
+ ? { "payment-signature": String(credential) }
+ : {},
+ });
+ const body = await replay.json().catch(() => null);
+
+ expect(replay.status).toBe(resubmitScenario.expectedStatus);
+ if (resubmitScenario.expectedCode) {
+ expect(extractCanonicalCode(body)).toBe(resubmitScenario.expectedCode);
+ }
+ } finally {
+ await stopServer(running);
+ runningServers.splice(runningServers.indexOf(running), 1);
+ }
+ }, 180_000);
+ }
+ } else {
+ it.skip("idempotent-resubmit scenario missing", () => {});
+ }
+});
diff --git a/tests/interop/test/e2e.test.ts b/tests/interop/test/e2e.test.ts
index e9e7e53b0..706f4bcde 100644
--- a/tests/interop/test/e2e.test.ts
+++ b/tests/interop/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/tests/interop/test/intent-selection.test.ts
index 6e8660278..1dcef686f 100644
--- a/tests/interop/test/intent-selection.test.ts
+++ b/tests/interop/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/x402-exact.e2e.test.ts b/tests/interop/test/x402-exact.e2e.test.ts
new file mode 100644
index 000000000..eb4ed10f2
--- /dev/null
+++ b/tests/interop/test/x402-exact.e2e.test.ts
@@ -0,0 +1,137 @@
+// Cross-language matrix for the x402 `exact` intent. Iterates every
+// active x402 client × every active x402 server registered in
+// `src/implementations.ts` and asserts the happy-path scenario reaches
+// HTTP 200 with the fixture settlement header populated.
+//
+// Gated behind `X402_INTEROP_MATRIX=1` so the default `pnpm test` run
+// in pay-kit does not require cargo or a live Surfpool RPC. The
+// canonical CI invocation is:
+//
+// X402_INTEROP_MATRIX=1 \
+// X402_INTEROP_RPC_URL=... \
+// X402_INTEROP_PAY_TO=... \
+// X402_INTEROP_CLIENT_SECRET_KEY=[...] \
+// X402_INTEROP_FACILITATOR_SECRET_KEY=[...] \
+// pnpm test x402-exact.e2e.test.ts
+
+import { afterAll, describe, expect, it } from "vitest";
+import { interopScenarios } from "../src/contracts";
+import {
+ clientImplementations,
+ serverImplementations,
+} from "../src/implementations";
+import { runClient, startServer, stopServer } from "../src/process";
+
+const MATRIX_ENABLED = process.env.X402_INTEROP_MATRIX === "1";
+
+const requiredEnvs = [
+ "X402_INTEROP_RPC_URL",
+ "X402_INTEROP_MINT",
+ "X402_INTEROP_PAY_TO",
+ "X402_INTEROP_CLIENT_SECRET_KEY",
+ "X402_INTEROP_FACILITATOR_SECRET_KEY",
+];
+
+function missingEnvs(): string[] {
+ return requiredEnvs.filter(
+ name => !process.env[name] || process.env[name]?.trim() === "",
+ );
+}
+
+const happyPath = interopScenarios.find(
+ scenario => scenario.id === "x402-exact-basic",
+);
+
+const x402Clients = clientImplementations.filter(
+ impl => impl.enabled && (impl.intents ?? ["charge"]).includes("x402-exact"),
+);
+const x402Servers = serverImplementations.filter(
+ impl => impl.enabled && (impl.intents ?? ["charge"]).includes("x402-exact"),
+);
+
+type RunningServer = Awaited>;
+const runningServers: RunningServer[] = [];
+
+afterAll(async () => {
+ for (const server of runningServers.splice(0)) {
+ await stopServer(server);
+ }
+});
+
+describe("x402 exact intent — cross-language matrix", () => {
+ if (!MATRIX_ENABLED) {
+ it.skip("matrix is gated behind X402_INTEROP_MATRIX=1", () => {});
+ return;
+ }
+
+ const missing = missingEnvs();
+ if (missing.length > 0) {
+ it.skip(`missing required env vars: ${missing.join(", ")}`, () => {});
+ return;
+ }
+
+ if (!happyPath) {
+ it.fails("happy-path scenario x402-exact-basic missing from registry", () => {
+ throw new Error("x402-exact-basic scenario not found in interopScenarios");
+ });
+ return;
+ }
+
+ // Pair restriction: the TS reference adapters speak a stub payload
+ // (no real signed Solana transaction in the fixture) so they only
+ // interoperate with each other. The Rust spine adapters carry the
+ // canonical PaymentProof and are exercised end-to-end by the rust
+ // crate's own integration tests (`cargo test -p solana-x402`).
+ // The cross-language matrix asserts the harness wiring and the
+ // ready/result protocol; full TS<->Rust on-chain settlement parity
+ // arrives with the TS SDK port (tracked separately).
+ const allowedPair = (clientId: string, serverId: string): boolean => {
+ if (clientId === "ts-x402" && serverId === "ts-x402") return true;
+ if (clientId === "rust-x402" && serverId === "rust-x402") return true;
+ // Kotlin client speaks the canonical PaymentProof payload (signed
+ // Solana transaction in `payload.transaction`) and pairs against
+ // the Rust spine server for true cross-spine coverage. The TS
+ // reference server expects a stub `payload.challengeId` envelope
+ // and advertises requirements via `maxAmountRequired`, so the
+ // TS pair is intentionally excluded.
+ if (clientId === "kotlin-x402-client" && serverId === "rust-x402") {
+ return true;
+ }
+ return false;
+ };
+
+ for (const server of x402Servers) {
+ for (const client of x402Clients) {
+ if (!allowedPair(client.id, server.id)) {
+ it.skip(`${client.id} client ↔ ${server.id} server: pair not in default matrix`, () => {});
+ continue;
+ }
+ it(`${client.id} client ↔ ${server.id} server: happy path`, async () => {
+ const env = {
+ X402_INTEROP_NETWORK: happyPath.network,
+ X402_INTEROP_PRICE: happyPath.price,
+ X402_INTEROP_RESOURCE_PATH: happyPath.resourcePath,
+ X402_INTEROP_SETTLEMENT_HEADER: happyPath.settlementHeader,
+ } satisfies Record;
+
+ const running = await startServer(server, env);
+ runningServers.push(running);
+
+ try {
+ const targetUrl = `http://127.0.0.1:${running.ready.port}${happyPath.resourcePath}`;
+ const result = await runClient(client, targetUrl, {
+ X402_INTEROP_TARGET_URL: targetUrl,
+ ...env,
+ });
+
+ expect(result.status).toBe(happyPath.expectedStatus);
+ expect(result.ok).toBe(true);
+ expect(result.settlement).toBeTruthy();
+ } finally {
+ await stopServer(running);
+ runningServers.splice(runningServers.indexOf(running), 1);
+ }
+ }, 120_000);
+ }
+ }
+});
diff --git a/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
```