From 8c59ab9490bf31e5efa0348c0b1ca0bfb3c7c492 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Fri, 2 Oct 2026 07:36:49 -0600 Subject: [PATCH 1/4] test(harness): Kotlin protocol runner in the mpp-protocol divergence matrix A Gradle stdin/stdout runner over the Kotlin SDK's protocol functions, plus `harness/protocol-runners/kotlin.json`, so the spawned-runner block drives Kotlin. No protocol-layer vector reaches Kotlin today. Milestone 1 of our harness proposal puts Kotlin in the divergence matrix. --- harness/kotlin-protocol-runner/.gitignore | 2 + .../kotlin-protocol-runner/build.gradle.kts | 28 +++++ .../settings.gradle.kts | 16 +++ .../com/solana/paykit/protocolrunner/Main.kt | 101 ++++++++++++++++++ .../solana/paykit/protocolrunner/MainTest.kt | 53 +++++++++ harness/protocol-runners/kotlin.json | 5 + harness/test/protocol-conformance.test.ts | 17 ++- 7 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 harness/kotlin-protocol-runner/.gitignore create mode 100644 harness/kotlin-protocol-runner/build.gradle.kts create mode 100644 harness/kotlin-protocol-runner/settings.gradle.kts create mode 100644 harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt create mode 100644 harness/kotlin-protocol-runner/src/test/kotlin/com/solana/paykit/protocolrunner/MainTest.kt create mode 100644 harness/protocol-runners/kotlin.json diff --git a/harness/kotlin-protocol-runner/.gitignore b/harness/kotlin-protocol-runner/.gitignore new file mode 100644 index 000000000..67bcc2f72 --- /dev/null +++ b/harness/kotlin-protocol-runner/.gitignore @@ -0,0 +1,2 @@ +.gradle/ +build/ diff --git a/harness/kotlin-protocol-runner/build.gradle.kts b/harness/kotlin-protocol-runner/build.gradle.kts new file mode 100644 index 000000000..2a70f9b9c --- /dev/null +++ b/harness/kotlin-protocol-runner/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + kotlin("jvm") version "2.3.21" + kotlin("plugin.serialization") version "2.3.21" + application +} + +dependencies { + // Path-included build, see settings.gradle.kts. + implementation("com.solana.paykit:solana-pay-kit-kotlin") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + testImplementation(kotlin("test")) +} + +kotlin { + jvmToolchain(17) +} + +application { + mainClass.set("com.solana.paykit.protocolrunner.MainKt") +} + +tasks.named("run") { + standardInput = System.`in` +} + +tasks.test { + useJUnitPlatform() +} diff --git a/harness/kotlin-protocol-runner/settings.gradle.kts b/harness/kotlin-protocol-runner/settings.gradle.kts new file mode 100644 index 000000000..80d7e332f --- /dev/null +++ b/harness/kotlin-protocol-runner/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = "mpp-kotlin-protocol-runner" +includeBuild("../../kotlin") diff --git a/harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt b/harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt new file mode 100644 index 000000000..9f26df0ca --- /dev/null +++ b/harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt @@ -0,0 +1,101 @@ +package com.solana.paykit.protocolrunner + +import com.solana.paykit.protocols.mpp.core.MppHeaders +import com.solana.paykit.protocols.mpp.core.PaymentCredential +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.util.Base64 +import kotlin.system.exitProcess + +private val json = Json { ignoreUnknownKeys = true } + +private val unsupported = setOf( + "challenge.format", + "credential.parse", + "receipt.parse", + "receipt.format", + "base64url.encode", + "base64url.decode", + "challenge.id", +) + +fun main() { + val response = respond(System.`in`.readBytes().decodeToString()) + println(response) + if (json.parseToJsonElement(response).jsonObject["error_type"] == JsonPrimitive("runner_error")) { + exitProcess(1) + } +} + +internal fun respond(line: String): String { + val request = try { + json.parseToJsonElement(line).jsonObject + } catch (error: IllegalArgumentException) { + return failure(error.message ?: "malformed request", "runner_error") + } + val op = (request["op"] as? JsonPrimitive)?.content.orEmpty() + val input = request["input"] ?: JsonNull + return try { + when (op) { + "challenge.parse" -> success(parseChallenge(input)) + "credential.format" -> success(buildJsonObject { put("header", formatCredential(input)) }) + in unsupported -> failure("$op unsupported by the Kotlin SDK", family(op)) + else -> failure("unknown operation: $op", "unsupported_operation") + } + } catch (error: Exception) { + failure(error.message ?: error.toString(), family(op)) + } +} + +private fun parseChallenge(input: JsonElement): JsonObject { + val challenge = MppHeaders.parseWWWAuthenticate(input.jsonObject.getValue("header").jsonPrimitive.content) + return buildJsonObject { + put("id", challenge.id) + put("realm", challenge.realm) + put("method", challenge.method) + put("intent", challenge.intent) + put("request", decodeJson(challenge.request)) + challenge.expires?.let { put("expires", it) } + challenge.digest?.let { put("digest", it) } + challenge.opaque?.let { put("opaque", decodeJson(it)) } + } +} + +private fun formatCredential(input: JsonElement): String { + val credential = input.jsonObject + val challenge = credential.getValue("challenge").jsonObject + val request = challenge["request"] ?: JsonObject(emptyMap()) + val encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(request.toString().encodeToByteArray()) + val wire = JsonObject(credential + ("challenge" to JsonObject(challenge + ("request" to JsonPrimitive(encoded))))) + return MppHeaders.formatAuthorization(json.decodeFromJsonElement(PaymentCredential.serializer(), wire)) +} + +private fun decodeJson(value: String): JsonElement = + json.parseToJsonElement(Base64.getUrlDecoder().decode(value).decodeToString()) + +private fun family(op: String): String = when { + op.endsWith(".parse") -> "parse_error" + op.endsWith(".format") -> "format_error" + op.startsWith("base64url.") -> "encoding_error" + else -> "generation_error" +} + +private fun success(result: JsonElement): String = + buildJsonObject { + put("success", true) + put("result", result) + }.toString() + +private fun failure(error: String, errorType: String): String = + buildJsonObject { + put("success", false) + put("error", error) + put("error_type", errorType) + }.toString() diff --git a/harness/kotlin-protocol-runner/src/test/kotlin/com/solana/paykit/protocolrunner/MainTest.kt b/harness/kotlin-protocol-runner/src/test/kotlin/com/solana/paykit/protocolrunner/MainTest.kt new file mode 100644 index 000000000..fabe3ee4b --- /dev/null +++ b/harness/kotlin-protocol-runner/src/test/kotlin/com/solana/paykit/protocolrunner/MainTest.kt @@ -0,0 +1,53 @@ +package com.solana.paykit.protocolrunner + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MainTest { + private fun respondTo(line: String): JsonObject { + val output = respond(line) + assertFalse(output.contains('\n'), output) + return Json.parseToJsonElement(output).jsonObject + } + + @Test + fun parsesBasicChallenge() { + val response = respondTo( + """{"op":"challenge.parse","input":{"header":"Payment id=\"ch_abc123\", realm=\"api.example.com\", method=\"tempo\", intent=\"charge\", request=\"eyJhbW91bnQiOiIxMDAwMDAwIiwiY3VycmVuY3kiOiIweDIwYzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDEiLCJyZWNpcGllbnQiOiIweDEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVmMTIzNDU2NzgifQ\""}}""", + ) + val golden = Json.parseToJsonElement( + """{"id":"ch_abc123","intent":"charge","method":"tempo","realm":"api.example.com","request":{"amount":"1000000","currency":"0x20c0000000000000000000000000000000000001","recipient":"0x1234567890abcdef1234567890abcdef12345678"}}""", + ) + assertEquals(JsonPrimitive(true), response["success"]) + assertEquals(golden, response["result"]) + } + + @Test + fun refusesNonJsonStdin() { + val response = respondTo("not json") + assertEquals(JsonPrimitive(false), response["success"]) + assertEquals(JsonPrimitive("runner_error"), response["error_type"]) + } + + @Test + fun reportsUnknownOperation() { + val response = respondTo("""{"op":"nope.op","input":{}}""") + assertEquals(JsonPrimitive(false), response["success"]) + assertEquals(JsonPrimitive("unsupported_operation"), response["error_type"]) + } + + @Test + fun reportsMissingSdkFunctionAsFamilyError() { + val response = respondTo("""{"op":"receipt.parse","input":{"header":"eyJ9"}}""") + assertEquals(JsonPrimitive(false), response["success"]) + assertEquals(JsonPrimitive("parse_error"), response["error_type"]) + assertTrue(response.getValue("error").jsonPrimitive.content.contains("unsupported")) + } +} diff --git a/harness/protocol-runners/kotlin.json b/harness/protocol-runners/kotlin.json new file mode 100644 index 000000000..c4fde3360 --- /dev/null +++ b/harness/protocol-runners/kotlin.json @@ -0,0 +1,5 @@ +{ + "language": "kotlin", + "command": ["sh", "-c", "exec build/install/mpp-kotlin-protocol-runner/bin/mpp-kotlin-protocol-runner"], + "cwd": "harness/kotlin-protocol-runner" +} diff --git a/harness/test/protocol-conformance.test.ts b/harness/test/protocol-conformance.test.ts index 5468dbaf9..4918b0597 100644 --- a/harness/test/protocol-conformance.test.ts +++ b/harness/test/protocol-conformance.test.ts @@ -162,12 +162,21 @@ const smokeCases = (() => { // Each entry is `${op} :: ${scenario}` and is asserted to STILL diverge so the // gap fails loudly the moment the SDK conforms (mirrors KNOWN_TS_DIVERGENCES). // -// Empty: every SDK now conforms to the canonical receipt shape. The Go +// Kotlin only: every other SDK now conforms to the canonical receipt shape. The Go // (`challengeId:""` injected) and Ruby (`challengeId` hard-required) schema // mismatches on `receipt.parse :: success_receipt` were both fixed in the -// per-SDK protocol-conformance round, so there are no remaining known runner -// divergences. -const KNOWN_RUNNER_DIVERGENCES: Record> = {}; +// per-SDK protocol-conformance round, so the remaining known runner +// divergences are the ops the Kotlin SDK has no public function for. +const KNOWN_RUNNER_DIVERGENCES: Record> = { + kotlin: new Set([ + "base64url.encode :: empty_string", + "base64url.decode :: empty_string", + "challenge.id :: required_fields_only", + "challenge.format :: basic_challenge", + "credential.parse :: basic_credential", + "receipt.parse :: success_receipt", + ]), +}; const runners = discoverProtocolRunners(); for (const runner of runners) { From 2291822c65f55b35df468c2f451006f82692eacf Mon Sep 17 00:00:00 2001 From: caliperforge Date: Fri, 2 Oct 2026 07:36:50 -0600 Subject: [PATCH 2/4] greptile.json: review on request only --- greptile.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 greptile.json diff --git a/greptile.json b/greptile.json new file mode 100644 index 000000000..3e653b565 --- /dev/null +++ b/greptile.json @@ -0,0 +1 @@ +{"autoReview": []} From 1aa8693079cd7d2f57857fd31ac6bf7453b3fb1a Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Fri, 2 Oct 2026 07:52:19 -0600 Subject: [PATCH 3/4] test(harness): Kotlin runner decodes credential.format strictly; Kotlin divergences pinned to exact responses, extended to the description and hash gaps, with a missing-binary test --- .../com/solana/paykit/protocolrunner/Main.kt | 10 +- harness/test/protocol-conformance.test.ts | 92 ++++++++++++++----- 2 files changed, 73 insertions(+), 29 deletions(-) diff --git a/harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt b/harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt index 9f26df0ca..2e1c35424 100644 --- a/harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt +++ b/harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt @@ -14,8 +14,6 @@ import kotlinx.serialization.json.put import java.util.Base64 import kotlin.system.exitProcess -private val json = Json { ignoreUnknownKeys = true } - private val unsupported = setOf( "challenge.format", "credential.parse", @@ -29,14 +27,14 @@ private val unsupported = setOf( fun main() { val response = respond(System.`in`.readBytes().decodeToString()) println(response) - if (json.parseToJsonElement(response).jsonObject["error_type"] == JsonPrimitive("runner_error")) { + if (Json.parseToJsonElement(response).jsonObject["error_type"] == JsonPrimitive("runner_error")) { exitProcess(1) } } internal fun respond(line: String): String { val request = try { - json.parseToJsonElement(line).jsonObject + Json.parseToJsonElement(line).jsonObject } catch (error: IllegalArgumentException) { return failure(error.message ?: "malformed request", "runner_error") } @@ -74,11 +72,11 @@ private fun formatCredential(input: JsonElement): String { val request = challenge["request"] ?: JsonObject(emptyMap()) val encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(request.toString().encodeToByteArray()) val wire = JsonObject(credential + ("challenge" to JsonObject(challenge + ("request" to JsonPrimitive(encoded))))) - return MppHeaders.formatAuthorization(json.decodeFromJsonElement(PaymentCredential.serializer(), wire)) + return MppHeaders.formatAuthorization(Json.decodeFromJsonElement(PaymentCredential.serializer(), wire)) } private fun decodeJson(value: String): JsonElement = - json.parseToJsonElement(Base64.getUrlDecoder().decode(value).decodeToString()) + Json.parseToJsonElement(Base64.getUrlDecoder().decode(value).decodeToString()) private fun family(op: String): String = when { op.endsWith(".parse") -> "parse_error" diff --git a/harness/test/protocol-conformance.test.ts b/harness/test/protocol-conformance.test.ts index 4918b0597..d633e83fb 100644 --- a/harness/test/protocol-conformance.test.ts +++ b/harness/test/protocol-conformance.test.ts @@ -7,8 +7,8 @@ // `ProtocolAdapter` into the same `runCase` driver. import { describe, expect, it } from "vitest"; -import { caseRunsOnAdapter, collectProtocolCases } from "../src/protocol/vectors"; -import { runCase } from "../src/protocol/driver"; +import { caseRunsOnAdapter, collectProtocolCases, type ProtocolCase } from "../src/protocol/vectors"; +import { runCase, type ProtocolAdapter } from "../src/protocol/driver"; import { typescriptProtocolAdapter } from "../src/protocol/runners/typescript"; import { discoverProtocolRunners, @@ -166,40 +166,86 @@ const smokeCases = (() => { // (`challengeId:""` injected) and Ruby (`challengeId` hard-required) schema // mismatches on `receipt.parse :: success_receipt` were both fixed in the // per-SDK protocol-conformance round, so the remaining known runner -// divergences are the ops the Kotlin SDK has no public function for. -const KNOWN_RUNNER_DIVERGENCES: Record> = { - kotlin: new Set([ - "base64url.encode :: empty_string", - "base64url.decode :: empty_string", - "challenge.id :: required_fields_only", - "challenge.format :: basic_challenge", - "credential.parse :: basic_credential", - "receipt.parse :: success_receipt", - ]), +// divergences are Kotlin SDK gaps, each pinned to the exact answer the runner +// gives so a runner that cannot start fails instead of passing as known. +type KnownDivergence = { error_type: string; error: string } | { missing: string }; + +const unsupported = (error_type: string): KnownDivergence => ({ error_type, error: "unsupported" }); + +const KNOWN_RUNNER_DIVERGENCES: Record> = { + kotlin: { + "base64url.encode :: empty_string": unsupported("encoding_error"), + "base64url.decode :: empty_string": unsupported("encoding_error"), + "challenge.id :: required_fields_only": unsupported("generation_error"), + "challenge.format :: basic_challenge": unsupported("format_error"), + "credential.parse :: basic_credential": unsupported("parse_error"), + "receipt.parse :: success_receipt": unsupported("parse_error"), + // PaymentChallenge has no `description` field. + "challenge.parse :: full_challenge": { missing: "description" }, + "challenge.parse :: escaped_quotes_in_description": { missing: "description" }, + "challenge.parse :: unescaped_quotes_in_description": { + error_type: "parse_error", + error: "invalid Payment header", + }, + // CredentialPayload has no `hash` field. + "credential.format :: credential_with_source": { error_type: "format_error", error: "'hash'" }, + }, }; +function caseFor(key: string): ProtocolCase { + const testCase = cases.find((c) => `${c.op} :: ${c.scenario}` === key); + if (!testCase) throw new Error(`${key} names no canonical case`); + return testCase; +} + +async function expectKnownDivergence( + adapter: ProtocolAdapter, + testCase: ProtocolCase, + divergence: KnownDivergence, +): Promise { + const response = await adapter.runProtocolRequest({ op: testCase.op, input: testCase.input }); + if ("missing" in divergence) { + const result = { ...(testCase.golden as Record) }; + delete result[divergence.missing]; + expect(response).toEqual({ success: true, result }); + return; + } + expect(response).toMatchObject({ success: false, error_type: divergence.error_type }); + expect((response as { error: string }).error).toContain(divergence.error); +} + const runners = discoverProtocolRunners(); for (const runner of runners) { - const known = KNOWN_RUNNER_DIVERGENCES[runner.language] ?? new Set(); + const known = KNOWN_RUNNER_DIVERGENCES[runner.language] ?? {}; describe(`mpp-protocol conformance (spawned ${runner.language} runner)`, () => { const adapter = spawnedProtocolAdapter(runner); for (const testCase of smokeCases) { if (!caseRunsOnAdapter(testCase, runner.language)) continue; const key = `${testCase.op} :: ${testCase.scenario}`; - if (known.has(key)) { - it(`KNOWN DIVERGENCE: ${key}`, async () => { - const result = await runCase(adapter, testCase); - expect( - result.ok, - `${key} now conforms — remove from KNOWN_RUNNER_DIVERGENCES[${runner.language}]`, - ).toBe(false); - }); - continue; - } + if (key in known) continue; it(key, async () => { const result = await runCase(adapter, testCase); expect(result.ok, result.detail).toBe(true); }); } + for (const [key, divergence] of Object.entries(known)) { + it(`KNOWN DIVERGENCE: ${key}`, async () => { + await expectKnownDivergence(adapter, caseFor(key), divergence); + }); + } }); } + +describe("mpp-protocol conformance (spawned kotlin runner that cannot start)", () => { + it("fails every known divergence", async () => { + const kotlin = runners.find((runner) => runner.language === "kotlin"); + if (!kotlin) throw new Error("no kotlin protocol runner manifest"); + const adapter = spawnedProtocolAdapter({ + ...kotlin, + command: ["sh", "-c", "exec build/install/missing/bin/missing"], + }); + for (const [key, divergence] of Object.entries(KNOWN_RUNNER_DIVERGENCES.kotlin)) { + await expect(expectKnownDivergence(adapter, caseFor(key), divergence), key).rejects.toThrow(); + } + }); +}); From 66449a5f8be441d4e8628194e57966ab0a0a0e82 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Fri, 2 Oct 2026 07:54:00 -0600 Subject: [PATCH 4/4] test(harness): Kotlin protocol runner manifest builds its own install before exec, so a clean checkout can run it; divergence-map comment trimmed to the changed claim --- harness/protocol-runners/kotlin.json | 2 +- harness/test/protocol-conformance.test.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/harness/protocol-runners/kotlin.json b/harness/protocol-runners/kotlin.json index c4fde3360..6f6f9489c 100644 --- a/harness/protocol-runners/kotlin.json +++ b/harness/protocol-runners/kotlin.json @@ -1,5 +1,5 @@ { "language": "kotlin", - "command": ["sh", "-c", "exec build/install/mpp-kotlin-protocol-runner/bin/mpp-kotlin-protocol-runner"], + "command": ["sh", "-c", "gradle -q installDist >&2 && exec build/install/mpp-kotlin-protocol-runner/bin/mpp-kotlin-protocol-runner"], "cwd": "harness/kotlin-protocol-runner" } diff --git a/harness/test/protocol-conformance.test.ts b/harness/test/protocol-conformance.test.ts index d633e83fb..24ead5aea 100644 --- a/harness/test/protocol-conformance.test.ts +++ b/harness/test/protocol-conformance.test.ts @@ -166,8 +166,7 @@ const smokeCases = (() => { // (`challengeId:""` injected) and Ruby (`challengeId` hard-required) schema // mismatches on `receipt.parse :: success_receipt` were both fixed in the // per-SDK protocol-conformance round, so the remaining known runner -// divergences are Kotlin SDK gaps, each pinned to the exact answer the runner -// gives so a runner that cannot start fails instead of passing as known. +// divergences are Kotlin SDK gaps. type KnownDivergence = { error_type: string; error: string } | { missing: string }; const unsupported = (error_type: string): KnownDivergence => ({ error_type, error: "unsupported" });