From b976cea7d14ea359aaae35d77c5dc51324e1d48a Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Sat, 3 Oct 2026 06:46:27 -0600 Subject: [PATCH 01/11] 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. --- .github/workflows/harness.yml | 8 ++ harness/kotlin-protocol-runner/.gitignore | 2 + .../kotlin-protocol-runner/build.gradle.kts | 23 +++++ .../settings.gradle.kts | 16 ++++ .../com/solana/paykit/protocolrunner/Main.kt | 91 +++++++++++++++++++ .../solana/paykit/protocolrunner/MainTest.kt | 73 +++++++++++++++ harness/protocol-runners/kotlin.json | 5 + harness/test/protocol-conformance.test.ts | 70 +++++++++++--- 8 files changed, 276 insertions(+), 12 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/.github/workflows/harness.yml b/.github/workflows/harness.yml index 80462416a..f5dfee41a 100644 --- a/.github/workflows/harness.yml +++ b/.github/workflows/harness.yml @@ -341,6 +341,14 @@ jobs: env: MPP_CONFORMANCE_LANGUAGES: kotlin run: pnpm exec vitest run test/conformance.test.ts + - name: Build the Kotlin protocol runner + working-directory: harness/kotlin-protocol-runner + run: gradle installDist --no-daemon + - name: Run Kotlin mpp-protocol conformance vectors + working-directory: harness + env: + MPP_CONFORMANCE_LANGUAGES: kotlin + run: pnpm exec vitest run test/protocol-conformance.test.ts - name: Pre-warm Gradle for the Kotlin harness client working-directory: harness/kotlin-client run: gradle installDist --no-daemon 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..3380edfe9 --- /dev/null +++ b/harness/kotlin-protocol-runner/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + kotlin("jvm") 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.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..58a415d3b --- /dev/null +++ b/harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt @@ -0,0 +1,91 @@ +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 families = mapOf( + "challenge.parse" to "parse_error", + "credential.parse" to "parse_error", + "receipt.parse" to "parse_error", + "challenge.format" to "format_error", + "credential.format" to "format_error", + "receipt.format" to "format_error", + "base64url.encode" to "encoding_error", + "base64url.decode" to "encoding_error", + "challenge.id" to "generation_error", +) + +private val sdkGaps = families.keys - setOf("challenge.parse", "credential.format") + +fun main() { + val response = reply(System.`in`.readBytes().decodeToString()) + println(response) + if (response["error_type"] == JsonPrimitive("runner_error")) exitProcess(1) +} + +internal fun respond(line: String): String = reply(line).toString() + +private fun reply(line: String): JsonObject { + val (op, input) = try { + val request = Json.parseToJsonElement(line).jsonObject + request["op"]?.jsonPrimitive?.content.orEmpty() to (request["input"] ?: JsonNull) + } catch (error: IllegalArgumentException) { + return fail(error.message, "runner_error") + } + val family = families[op] ?: return fail("unknown operation: $op", "unsupported_operation") + if (op in sdkGaps) return fail("$op unsupported by the Kotlin SDK", family) + return try { + val result = if (op == "challenge.parse") parseChallenge(input) else formatCredential(input) + buildJsonObject { + put("success", true) + put("result", result) + } + } catch (error: Exception) { + fail(error.message, family) + } +} + +private fun fail(error: String?, errorType: String) = buildJsonObject { + put("success", false) + put("error", error) + put("error_type", errorType) +} + +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?.takeIf { it.isNotEmpty() }?.let { put("expires", it) } + challenge.digest?.takeIf { it.isNotEmpty() }?.let { put("digest", it) } + challenge.opaque?.let { put("opaque", decodeJson(it)) } + } +} + +private fun decodeJson(base64Url: String): JsonElement = + Json.parseToJsonElement(Base64.getUrlDecoder().decode(base64Url).decodeToString()) + +private fun formatCredential(input: JsonElement): JsonObject { + val credential = input.jsonObject + val challenge = credential.getValue("challenge").jsonObject + val request = (challenge["request"] ?: JsonObject(emptyMap())).toString().encodeToByteArray() + val echo = challenge + ("request" to JsonPrimitive(Base64.getUrlEncoder().withoutPadding().encodeToString(request))) + val wire = JsonObject(credential + ("challenge" to JsonObject(echo))) + return buildJsonObject { + put("header", MppHeaders.formatAuthorization(Json.decodeFromJsonElement(PaymentCredential.serializer(), wire))) + } +} 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..c3ae43320 --- /dev/null +++ b/harness/kotlin-protocol-runner/src/test/kotlin/com/solana/paykit/protocolrunner/MainTest.kt @@ -0,0 +1,73 @@ +package com.solana.paykit.protocolrunner + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MainTest { + private fun answer(line: String): JsonObject { + val output = respond(line) + assertFalse('\n' in output) + return Json.parseToJsonElement(output).jsonObject + } + + private fun JsonObject.field(name: String) = getValue(name).jsonPrimitive.content + + @Test + fun parsesBasicChallengeWithRequestObject() { + val request = """{"amount":"1000000","currency":"0x20c0000000000000000000000000000000000001","recipient":"0x1234567890abcdef1234567890abcdef12345678"}""" + val encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(request.encodeToByteArray()) + val line = buildJsonObject { + put("op", "challenge.parse") + putJsonObject("input") { + put("header", """Payment id="ch_abc123", realm="api.example.com", method="tempo", intent="charge", request="$encoded"""") + } + }.toString() + + val response = answer(line) + + assertEquals("true", response.field("success")) + assertEquals( + Json.parseToJsonElement("""{"id":"ch_abc123","realm":"api.example.com","method":"tempo","intent":"charge","request":$request}"""), + response["result"], + ) + } + + @Test + fun refusesNonJson() { + assertEquals("runner_error", answer("not json").field("error_type")) + } + + @Test + fun unknownOpIsUnsupportedOperation() { + assertEquals("unsupported_operation", answer("""{"op":"nope.op","input":{}}""").field("error_type")) + } + + @Test + fun sdkGapAnswersItsFamilyErrorType() { + val response = answer("""{"op":"receipt.parse","input":{"header":"x"}}""") + + assertEquals("false", response.field("success")) + assertEquals("parse_error", response.field("error_type")) + assertTrue("unsupported" in response.field("error")) + } + + @Test + fun credentialPayloadHashIsAFormatError() { + val credential = """{"challenge":{"id":"ch","realm":"r","method":"tempo","intent":"charge","request":{}},"payload":{"type":"hash","hash":"0x12"}}""" + + val response = answer("""{"op":"credential.format","input":$credential}""") + + assertEquals("format_error", response.field("error_type")) + assertTrue("unknown key 'hash'" in response.field("error")) + } +} 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..4d9582e39 100644 --- a/harness/test/protocol-conformance.test.ts +++ b/harness/test/protocol-conformance.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest"; import { caseRunsOnAdapter, collectProtocolCases } from "../src/protocol/vectors"; import { runCase } from "../src/protocol/driver"; +import { parseLanguageAllowlist } from "../src/conformance/select"; import { typescriptProtocolAdapter } from "../src/protocol/runners/typescript"; import { discoverProtocolRunners, @@ -162,28 +163,61 @@ 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 +// Only Kotlin's remain: 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. Each entry maps to the exact response the +// runner gives, so a runner that cannot start never passes as a divergence. +const kotlinUnsupported = (op: string, error_type: string) => ({ + success: false, + error: `${op} unsupported by the Kotlin SDK`, + error_type, +}); +// Kotlin's PaymentChallenge has no `description` field. +const kotlinWithoutDescription = { + success: true, + result: expect.not.objectContaining({ description: expect.anything() }), +}; +const KNOWN_RUNNER_DIVERGENCES: Record> = { + kotlin: new Map([ + ["base64url.encode :: empty_string", kotlinUnsupported("base64url.encode", "encoding_error")], + ["base64url.decode :: empty_string", kotlinUnsupported("base64url.decode", "encoding_error")], + ["challenge.id :: required_fields_only", kotlinUnsupported("challenge.id", "generation_error")], + ["challenge.format :: basic_challenge", kotlinUnsupported("challenge.format", "format_error")], + ["credential.parse :: basic_credential", kotlinUnsupported("credential.parse", "parse_error")], + ["receipt.parse :: success_receipt", kotlinUnsupported("receipt.parse", "parse_error")], + ["challenge.parse :: full_challenge", kotlinWithoutDescription], + ["challenge.parse :: escaped_quotes_in_description", kotlinWithoutDescription], + [ + "challenge.parse :: unescaped_quotes_in_description", + { success: false, error: "invalid Payment header", error_type: "parse_error" }, + ], + // Kotlin's CredentialPayload has no `hash` field. + [ + "credential.format :: credential_with_source", + { success: false, error: expect.stringContaining("unknown key 'hash'"), error_type: "format_error" }, + ], + ]), +}; -const runners = discoverProtocolRunners(); +const allowlist = parseLanguageAllowlist(process.env.MPP_CONFORMANCE_LANGUAGES); +const runners = discoverProtocolRunners().filter( + (runner) => !allowlist || allowlist.has(runner.language), +); for (const runner of runners) { - const known = KNOWN_RUNNER_DIVERGENCES[runner.language] ?? new Set(); + const known = KNOWN_RUNNER_DIVERGENCES[runner.language] ?? new Map(); + const keyOf = (testCase: (typeof cases)[number]) => `${testCase.op} :: ${testCase.scenario}`; describe(`mpp-protocol conformance (spawned ${runner.language} runner)`, () => { const adapter = spawnedProtocolAdapter(runner); - for (const testCase of smokeCases) { + for (const testCase of cases.filter((c) => smokeCases.includes(c) || known.has(keyOf(c)))) { if (!caseRunsOnAdapter(testCase, runner.language)) continue; - const key = `${testCase.op} :: ${testCase.scenario}`; + const key = keyOf(testCase); 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); + await adapter.runProtocolRequest({ op: testCase.op, input: testCase.input }), + `${key} changed — update or remove KNOWN_RUNNER_DIVERGENCES[${runner.language}]`, + ).toEqual(known.get(key)); }); continue; } @@ -192,5 +226,17 @@ for (const runner of runners) { expect(result.ok, result.detail).toBe(true); }); } + if (known.size === 0) return; + it("a runner that cannot start fails its known divergences", async () => { + const missing = spawnedProtocolAdapter({ + ...runner, + command: ["sh", "-c", "exec build/install/missing/bin/missing"], + }); + const [key, expected] = [...known][0]; + const testCase = cases.find((c) => keyOf(c) === key)!; + expect(await missing.runProtocolRequest({ op: testCase.op, input: testCase.input })).not.toEqual( + expected, + ); + }); }); } From 208fbd71c18b11869de8fb326eadd72ef91a2e1a Mon Sep 17 00:00:00 2001 From: caliperforge Date: Sat, 3 Oct 2026 06:46:29 -0600 Subject: [PATCH 02/11] 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 f400328de5e1c826ebefd5f290672c49a52393ed Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Sat, 3 Oct 2026 06:52:57 -0600 Subject: [PATCH 03/11] test(harness): Kotlin protocol runner, its manifest, the CI build-and-run steps in the kotlin harness job, and asserted Kotlin divergences in the spawned-runner matrix --- harness/test/protocol-conformance.test.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/harness/test/protocol-conformance.test.ts b/harness/test/protocol-conformance.test.ts index 4d9582e39..673840c6f 100644 --- a/harness/test/protocol-conformance.test.ts +++ b/harness/test/protocol-conformance.test.ts @@ -174,9 +174,10 @@ const kotlinUnsupported = (op: string, error_type: string) => ({ error_type, }); // Kotlin's PaymentChallenge has no `description` field. -const kotlinWithoutDescription = { - success: true, - result: expect.not.objectContaining({ description: expect.anything() }), +const kotlinWithoutDescription = (scenario: string) => { + const parse = cases.find((c) => c.op === "challenge.parse" && c.scenario === scenario); + const { description: _, ...result } = (parse as { golden: Record }).golden; + return { success: true, result }; }; const KNOWN_RUNNER_DIVERGENCES: Record> = { kotlin: new Map([ @@ -186,8 +187,12 @@ const KNOWN_RUNNER_DIVERGENCES: Record> = { ["challenge.format :: basic_challenge", kotlinUnsupported("challenge.format", "format_error")], ["credential.parse :: basic_credential", kotlinUnsupported("credential.parse", "parse_error")], ["receipt.parse :: success_receipt", kotlinUnsupported("receipt.parse", "parse_error")], - ["challenge.parse :: full_challenge", kotlinWithoutDescription], - ["challenge.parse :: escaped_quotes_in_description", kotlinWithoutDescription], + ["challenge.parse :: full_challenge", kotlinWithoutDescription("full_challenge")], + [ + "challenge.parse :: escaped_quotes_in_description", + kotlinWithoutDescription("escaped_quotes_in_description"), + ], + // Kotlin's parser rejects text after a closing quote; the canonical parser truncates there. [ "challenge.parse :: unescaped_quotes_in_description", { success: false, error: "invalid Payment header", error_type: "parse_error" }, From 17dd9a290b5764bd9444e9ae95abcf0c813c5888 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Sat, 3 Oct 2026 13:46:12 -0600 Subject: [PATCH 04/11] test(harness): Rebuilt and retested the Kotlin protocol runner from scratch (--rerun-tasks); 5/5 MainTest cases pass, the vitest suite is still unrun --- harness/test/protocol-conformance.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harness/test/protocol-conformance.test.ts b/harness/test/protocol-conformance.test.ts index 673840c6f..8f03cada9 100644 --- a/harness/test/protocol-conformance.test.ts +++ b/harness/test/protocol-conformance.test.ts @@ -167,7 +167,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. Each entry maps to the exact response the -// runner gives, so a runner that cannot start never passes as a divergence. +// runner gives. const kotlinUnsupported = (op: string, error_type: string) => ({ success: false, error: `${op} unsupported by the Kotlin SDK`, From e2eeda59f1f1c2f0b096088674d71eb697a74b98 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Sat, 3 Oct 2026 13:51:36 -0600 Subject: [PATCH 05/11] test(harness): Kotlin block runs credential.format :: basic_credential as a must-pass case (TS reference re-parses Kotlin's header) and lists receipt.format as a known unsupported divergence --- harness/test/protocol-conformance.test.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/harness/test/protocol-conformance.test.ts b/harness/test/protocol-conformance.test.ts index 8f03cada9..41651fca9 100644 --- a/harness/test/protocol-conformance.test.ts +++ b/harness/test/protocol-conformance.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from "vitest"; import { caseRunsOnAdapter, collectProtocolCases } from "../src/protocol/vectors"; -import { runCase } from "../src/protocol/driver"; +import { runCase, type ProtocolAdapter } from "../src/protocol/driver"; import { parseLanguageAllowlist } from "../src/conformance/select"; import { typescriptProtocolAdapter } from "../src/protocol/runners/typescript"; import { @@ -187,6 +187,7 @@ const KNOWN_RUNNER_DIVERGENCES: Record> = { ["challenge.format :: basic_challenge", kotlinUnsupported("challenge.format", "format_error")], ["credential.parse :: basic_credential", kotlinUnsupported("credential.parse", "parse_error")], ["receipt.parse :: success_receipt", kotlinUnsupported("receipt.parse", "parse_error")], + ["receipt.format :: success_receipt", kotlinUnsupported("receipt.format", "format_error")], ["challenge.parse :: full_challenge", kotlinWithoutDescription("full_challenge")], [ "challenge.parse :: escaped_quotes_in_description", @@ -205,16 +206,29 @@ const KNOWN_RUNNER_DIVERGENCES: Record> = { ]), }; +// Format cases whose paired parse op the runner lacks: the TS reference re-parses its wire. +const REFERENCE_REPARSED_CASES: Record> = { + kotlin: new Set(["credential.format :: basic_credential"]), +}; +const withReferenceReparse = (adapter: ProtocolAdapter, op: string): ProtocolAdapter => ({ + name: adapter.name, + runProtocolRequest: (request) => + (request.op === op ? adapter : typescriptProtocolAdapter).runProtocolRequest(request), +}); + const allowlist = parseLanguageAllowlist(process.env.MPP_CONFORMANCE_LANGUAGES); const runners = discoverProtocolRunners().filter( (runner) => !allowlist || allowlist.has(runner.language), ); for (const runner of runners) { const known = KNOWN_RUNNER_DIVERGENCES[runner.language] ?? new Map(); + const reparsed = REFERENCE_REPARSED_CASES[runner.language] ?? new Set(); const keyOf = (testCase: (typeof cases)[number]) => `${testCase.op} :: ${testCase.scenario}`; describe(`mpp-protocol conformance (spawned ${runner.language} runner)`, () => { const adapter = spawnedProtocolAdapter(runner); - for (const testCase of cases.filter((c) => smokeCases.includes(c) || known.has(keyOf(c)))) { + for (const testCase of cases.filter( + (c) => smokeCases.includes(c) || known.has(keyOf(c)) || reparsed.has(keyOf(c)), + )) { if (!caseRunsOnAdapter(testCase, runner.language)) continue; const key = keyOf(testCase); if (known.has(key)) { @@ -227,7 +241,10 @@ for (const runner of runners) { continue; } it(key, async () => { - const result = await runCase(adapter, testCase); + const result = await runCase( + reparsed.has(key) ? withReferenceReparse(adapter, testCase.op) : adapter, + testCase, + ); expect(result.ok, result.detail).toBe(true); }); } From 00614e3b3c5a111939624a71dd5f283769080e01 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Mon, 5 Oct 2026 10:48:32 -0600 Subject: [PATCH 06/11] test(harness): CI builds and tests the Kotlin protocol runner (installDist test) and Main.kt gains its 3-line header comment --- .github/workflows/harness.yml | 4 ++-- .../src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml index f5dfee41a..5ab7faeb5 100644 --- a/.github/workflows/harness.yml +++ b/.github/workflows/harness.yml @@ -341,9 +341,9 @@ jobs: env: MPP_CONFORMANCE_LANGUAGES: kotlin run: pnpm exec vitest run test/conformance.test.ts - - name: Build the Kotlin protocol runner + - name: Build and test the Kotlin protocol runner working-directory: harness/kotlin-protocol-runner - run: gradle installDist --no-daemon + run: gradle installDist test --no-daemon - name: Run Kotlin mpp-protocol conformance vectors working-directory: harness env: 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 58a415d3b..ea32c2abc 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 @@ -1,3 +1,6 @@ +// Kotlin mpp-protocol conformance runner. +// Reads one adapter-ABI request on stdin and writes one response line on stdout, +// per the contract in harness/src/protocol/runners/spawn.ts. package com.solana.paykit.protocolrunner import com.solana.paykit.protocols.mpp.core.MppHeaders From 3da6cddc7696f5ee7ca29b5e438892a0d509c183 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Mon, 5 Oct 2026 12:02:39 -0600 Subject: [PATCH 07/11] test(harness): Kotlin mpp-protocol runner and manifest drive the spawned-runner block, with Kotlin's SDK gaps asserted by exact response --- .../com/solana/paykit/protocolrunner/Main.kt | 17 +++++++---------- .../solana/paykit/protocolrunner/MainTest.kt | 14 ++++++++++++++ harness/test/protocol-conformance.test.ts | 4 ++++ 3 files changed, 25 insertions(+), 10 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 ea32c2abc..f38d0e953 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 @@ -4,6 +4,7 @@ package com.solana.paykit.protocolrunner import com.solana.paykit.protocols.mpp.core.MppHeaders +import com.solana.paykit.protocols.mpp.core.PaymentChallenge import com.solana.paykit.protocols.mpp.core.PaymentCredential import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -67,16 +68,12 @@ private fun fail(error: String?, errorType: String) = buildJsonObject { 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?.takeIf { it.isNotEmpty() }?.let { put("expires", it) } - challenge.digest?.takeIf { it.isNotEmpty() }?.let { put("digest", it) } - challenge.opaque?.let { put("opaque", decodeJson(it)) } - } + val fields = Json.encodeToJsonElement(PaymentChallenge.serializer(), challenge).jsonObject + return JsonObject( + fields.mapValues { (name, value) -> + if (name == "request" || name == "opaque") decodeJson(value.jsonPrimitive.content) else value + }, + ) } private fun decodeJson(base64Url: String): JsonElement = 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 index c3ae43320..4a0ca636b 100644 --- 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 @@ -42,6 +42,20 @@ class MainTest { ) } + @Test + fun keepsEmptyOptionalField() { + val header = """Payment id="ch", realm="r", method="tempo", intent="charge", request="e30", expires=""""" + val line = buildJsonObject { + put("op", "challenge.parse") + putJsonObject("input") { put("header", header) } + }.toString() + + assertEquals( + Json.parseToJsonElement("""{"id":"ch","realm":"r","method":"tempo","intent":"charge","request":{},"expires":""}"""), + answer(line)["result"], + ) + } + @Test fun refusesNonJson() { assertEquals("runner_error", answer("not json").field("error_type")) diff --git a/harness/test/protocol-conformance.test.ts b/harness/test/protocol-conformance.test.ts index 41651fca9..f4d357419 100644 --- a/harness/test/protocol-conformance.test.ts +++ b/harness/test/protocol-conformance.test.ts @@ -220,6 +220,10 @@ const allowlist = parseLanguageAllowlist(process.env.MPP_CONFORMANCE_LANGUAGES); const runners = discoverProtocolRunners().filter( (runner) => !allowlist || allowlist.has(runner.language), ); +it("every language in MPP_CONFORMANCE_LANGUAGES has a protocol runner", () => { + const found = new Set(runners.map((runner) => runner.language)); + expect([...(allowlist ?? [])].filter((language) => !found.has(language))).toEqual([]); +}); for (const runner of runners) { const known = KNOWN_RUNNER_DIVERGENCES[runner.language] ?? new Map(); const reparsed = REFERENCE_REPARSED_CASES[runner.language] ?? new Set(); From fe3f19986c1c588c5a9211866b8692b5554b5ba2 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Mon, 5 Oct 2026 12:05:46 -0600 Subject: [PATCH 08/11] test(harness): Kotlin protocol runner drops empty-string challenge fields after whole-object serialization (Go omitempty), with its unit test asserting the omission --- .../src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt | 2 +- .../test/kotlin/com/solana/paykit/protocolrunner/MainTest.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 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 f38d0e953..099512d6c 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 @@ -70,7 +70,7 @@ private fun parseChallenge(input: JsonElement): JsonObject { val challenge = MppHeaders.parseWWWAuthenticate(input.jsonObject.getValue("header").jsonPrimitive.content) val fields = Json.encodeToJsonElement(PaymentChallenge.serializer(), challenge).jsonObject return JsonObject( - fields.mapValues { (name, value) -> + fields.filterValues { it != JsonPrimitive("") }.mapValues { (name, value) -> if (name == "request" || name == "opaque") decodeJson(value.jsonPrimitive.content) else value }, ) 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 index 4a0ca636b..00281b831 100644 --- 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 @@ -43,7 +43,7 @@ class MainTest { } @Test - fun keepsEmptyOptionalField() { + fun dropsEmptyOptionalField() { val header = """Payment id="ch", realm="r", method="tempo", intent="charge", request="e30", expires=""""" val line = buildJsonObject { put("op", "challenge.parse") @@ -51,7 +51,7 @@ class MainTest { }.toString() assertEquals( - Json.parseToJsonElement("""{"id":"ch","realm":"r","method":"tempo","intent":"charge","request":{},"expires":""}"""), + Json.parseToJsonElement("""{"id":"ch","realm":"r","method":"tempo","intent":"charge","request":{}}"""), answer(line)["result"], ) } From 8630c6f1622d88eabf2053b665e6ffed86095a52 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Mon, 5 Oct 2026 13:01:24 -0600 Subject: [PATCH 09/11] test(harness): parseChallenge drops empty strings only for expires and digest; an empty request now returns parse_error, with a MainTest case for it --- .../com/solana/paykit/protocolrunner/Main.kt | 2 +- .../com/solana/paykit/protocolrunner/MainTest.kt | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) 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 099512d6c..6836927c4 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 @@ -70,7 +70,7 @@ private fun parseChallenge(input: JsonElement): JsonObject { val challenge = MppHeaders.parseWWWAuthenticate(input.jsonObject.getValue("header").jsonPrimitive.content) val fields = Json.encodeToJsonElement(PaymentChallenge.serializer(), challenge).jsonObject return JsonObject( - fields.filterValues { it != JsonPrimitive("") }.mapValues { (name, value) -> + fields.filter { (name, value) -> name !in setOf("expires", "digest") || value != JsonPrimitive("") }.mapValues { (name, value) -> if (name == "request" || name == "opaque") decodeJson(value.jsonPrimitive.content) else value }, ) 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 index 00281b831..deac0121e 100644 --- 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 @@ -56,6 +56,20 @@ class MainTest { ) } + @Test + fun emptyRequestIsAParseError() { + val header = """Payment id="ch", realm="r", method="tempo", intent="charge", request=""""" + val line = buildJsonObject { + put("op", "challenge.parse") + putJsonObject("input") { put("header", header) } + }.toString() + + val response = answer(line) + + assertEquals("false", response.field("success")) + assertEquals("parse_error", response.field("error_type")) + } + @Test fun refusesNonJson() { assertEquals("runner_error", answer("not json").field("error_type")) From 58f7f48dd8313062a7a30c845e10db323d235c2b Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Mon, 5 Oct 2026 13:15:53 -0600 Subject: [PATCH 10/11] test(harness): Kotlin's acceptance of an empty challenge id is recorded as a known divergence with its exact response; the opaque and vector-coverage findings are answered with the earlier rulings --- harness/test/protocol-conformance.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/harness/test/protocol-conformance.test.ts b/harness/test/protocol-conformance.test.ts index f4d357419..e74247011 100644 --- a/harness/test/protocol-conformance.test.ts +++ b/harness/test/protocol-conformance.test.ts @@ -198,6 +198,14 @@ const KNOWN_RUNNER_DIVERGENCES: Record> = { "challenge.parse :: unescaped_quotes_in_description", { success: false, error: "invalid Payment header", error_type: "parse_error" }, ], + // Kotlin's parser accepts an empty `id`. + [ + "challenge.parse :: error_empty_id", + { + success: true, + result: { id: "", realm: "api", method: "tempo", intent: "charge", request: {} }, + }, + ], // Kotlin's CredentialPayload has no `hash` field. [ "credential.format :: credential_with_source", From 85236297027b999d49fa7c1d60a442d2ba0de934 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Mon, 5 Oct 2026 13:39:24 -0600 Subject: [PATCH 11/11] test(harness): The spawned Kotlin block runs every challenge.parse and credential.format case through the runner; the other seven SDKs keep their smoke cases --- harness/test/protocol-conformance.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/harness/test/protocol-conformance.test.ts b/harness/test/protocol-conformance.test.ts index e74247011..1f0f06068 100644 --- a/harness/test/protocol-conformance.test.ts +++ b/harness/test/protocol-conformance.test.ts @@ -214,6 +214,11 @@ const KNOWN_RUNNER_DIVERGENCES: Record> = { ]), }; +// Ops the runner implements run every case; the rest run only the smoke slice. +const IMPLEMENTED_OPS: Record> = { + kotlin: new Set(["challenge.parse", "credential.format"]), +}; + // Format cases whose paired parse op the runner lacks: the TS reference re-parses its wire. const REFERENCE_REPARSED_CASES: Record> = { kotlin: new Set(["credential.format :: basic_credential"]), @@ -235,11 +240,12 @@ it("every language in MPP_CONFORMANCE_LANGUAGES has a protocol runner", () => { for (const runner of runners) { const known = KNOWN_RUNNER_DIVERGENCES[runner.language] ?? new Map(); const reparsed = REFERENCE_REPARSED_CASES[runner.language] ?? new Set(); + const implemented = IMPLEMENTED_OPS[runner.language] ?? new Set(); const keyOf = (testCase: (typeof cases)[number]) => `${testCase.op} :: ${testCase.scenario}`; describe(`mpp-protocol conformance (spawned ${runner.language} runner)`, () => { const adapter = spawnedProtocolAdapter(runner); for (const testCase of cases.filter( - (c) => smokeCases.includes(c) || known.has(keyOf(c)) || reparsed.has(keyOf(c)), + (c) => smokeCases.includes(c) || implemented.has(c.op) || known.has(keyOf(c)), )) { if (!caseRunsOnAdapter(testCase, runner.language)) continue; const key = keyOf(testCase);