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 | ![Python](https://img.shields.io/badge/coverage-87%25-green) | `just py-test` | | Lua | ![Lua](https://img.shields.io/badge/coverage-41_tests-blue) | `just lua-test` | | Ruby | ![Ruby](https://img.shields.io/badge/coverage-98%25-green) | `just rb-test-cover` | -| Interop | ![Interop](https://img.shields.io/badge/interop-TypeScript_harness-brightgreen) | `cd tests/interop && pnpm test` | +| Interop | ![Interop](https://img.shields.io/badge/interop-TypeScript_harness-brightgreen) | `cd harness && pnpm test` | -See [`tests/interop/README.md`](tests/interop/README.md) for the process adapter contract used by the Surfpool-backed client/server matrix. +See [`harness/README.md`](harness/README.md) for the process adapter contract used by the Surfpool-backed client/server matrix. ## Install diff --git a/docs/security/compute-budget-caps.md b/docs/security/compute-budget-caps.md index b2d971e69..561681370 100644 --- a/docs/security/compute-budget-caps.md +++ b/docs/security/compute-budget-caps.md @@ -53,7 +53,7 @@ this monorepo. | Go (#101) | `go/server/server.go` (`maxComputeUnitLimit`) | pending PR #101 merge | | Python (#106) | `python/src/solana_mpp/server/mpp.py` | pending PR #106 merge | -`tests/interop/test/compute-budget-caps.test.ts` parses each file above +`harness/test/compute-budget-caps.test.ts` parses each file above and asserts byte-identical literals against the canonical pair. Go and Python rows are marked `optional: true` until their PRs land, then flip to required and surface drift the same way as the other SDKs. @@ -66,8 +66,8 @@ flip to required and surface drift the same way as the other SDKs. code when either limit is exceeded; include the cap value in the reason string for parity with the existing SDKs. 3. Append a row to `SDKS` in - `tests/interop/test/compute-budget-caps.test.ts` and to the table + `harness/test/compute-budget-caps.test.ts` and to the table above. Append a fixture row to `charge-compute-budget-over-cap` in - `tests/interop/src/intents/charge.ts` once the SDK is wired into the + `harness/src/intents/charge.ts` once the SDK is wired into the interop harness. diff --git a/go/README.md b/go/README.md index 744bbd19a..72c7daf0c 100644 --- a/go/README.md +++ b/go/README.md @@ -128,7 +128,7 @@ localnet fixture. ## Running the interop adapters ```bash -cd tests/interop/go-server +cd harness/go-server go run . # starts a Surfpool-backed protected endpoint on a random port cd ../go-client @@ -193,7 +193,7 @@ same structural transaction verifier as pull mode, consumes the signature through replay storage, and emits the same receipt shape. The direct Go interop server at -[`tests/interop/go-server/main.go`](../tests/interop/go-server/main.go) +[`harness/go-server/main.go`](../harness/go-server/main.go) exercises this end-to-end through Surfpool for both TypeScript and Rust clients. @@ -258,15 +258,15 @@ The CI Go job runs the SDK packages with `-coverprofile` and enforces a ## Interop -The cross-language interop harness lives in `../tests/interop`. The Go -SDK ships both a client (`tests/interop/go-client`) and a server -(`tests/interop/go-server`) adapter. Both are opt-in via the +The cross-language interop harness lives in `../harness`. The Go +SDK ships both a client (`harness/go-client`) and a server +(`harness/go-server`) adapter. Both are opt-in via the `MPP_INTEROP_CLIENTS` and `MPP_INTEROP_SERVERS` env vars. Focused harness commands: ```bash -cd tests/interop +cd harness MPP_INTEROP_CLIENTS=go MPP_INTEROP_SERVERS=rust pnpm test MPP_INTEROP_CLIENTS=typescript MPP_INTEROP_SERVERS=go pnpm test MPP_INTEROP_CLIENTS=rust MPP_INTEROP_SERVERS=go pnpm test diff --git a/go/mpp.go b/go/mpp.go index e618006af..4f8243bb7 100644 --- a/go/mpp.go +++ b/go/mpp.go @@ -8,7 +8,7 @@ // transaction builders live in the `client` subpackage. The wire format // and module split mirror the Rust reference crate documented in // skills/pay-sdk-implementation; cross-language behavior is locked via -// the interop harness at tests/interop. +// the interop harness at harness. package mpp import ( diff --git a/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 @@ +

+ MPP +

+ +# 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. + +[![Kotlin](https://img.shields.io/badge/Kotlin-2.3%2B-blue)]() +[![JVM](https://img.shields.io/badge/JVM-17%2B-lightgrey)]() + +## 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 ```