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 f400328de5e1c826ebefd5f290672c49a52393ed Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Sat, 3 Oct 2026 06:52:57 -0600 Subject: [PATCH 02/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 03/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 04/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 05/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 06/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 07/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 08/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 09/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 10/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); From e831becd38c1ff0bc1ec61ebbeb5506a8012dafc Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Thu, 8 Oct 2026 11:19:13 -0600 Subject: [PATCH 11/11] test(harness): add Swift mpp-protocol conformance runner A stdin/stdout runner over SolanaPayKit's protocol functions, registered in harness/protocol-runners/swift.json so the spawned-runner block drives Swift. Ops the SDK has no public API for are recorded as known divergences with their exact response, next to Kotlin's. A runner that exits non-zero now reports runner_error instead of an empty line. --- .github/workflows/swift.yml | 7 + Package.swift | 10 ++ harness/protocol-runners/swift.json | 5 + harness/src/protocol/runners/spawn.ts | 10 +- harness/test/protocol-conformance.test.ts | 28 +++- swift/Sources/mpp-protocol-runner/main.swift | 158 ++++++++++++++++++ .../AbiFramingTests.swift | 90 ++++++++++ 7 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 harness/protocol-runners/swift.json create mode 100644 swift/Sources/mpp-protocol-runner/main.swift create mode 100644 swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index e164403d5..c84c9a394 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -41,6 +41,13 @@ jobs: env: MPP_CONFORMANCE_LANGUAGES: swift run: pnpm exec vitest run test/conformance.test.ts + - name: Build Swift protocol runner + run: swift build -c release --product mpp-protocol-runner + - name: Run Swift mpp-protocol conformance vectors + working-directory: harness + env: + MPP_CONFORMANCE_LANGUAGES: swift + run: pnpm exec vitest run test/protocol-conformance.test.ts - name: Run Swift client harness smoke against TypeScript server working-directory: harness env: diff --git a/Package.swift b/Package.swift index 2e7cccd6b..25aad4692 100644 --- a/Package.swift +++ b/Package.swift @@ -35,10 +35,20 @@ let package = Package( dependencies: ["SolanaPayKit"], path: "swift/Sources/mpp-conformance" ), + .executableTarget( + name: "mpp-protocol-runner", + dependencies: ["SolanaPayKit"], + path: "swift/Sources/mpp-protocol-runner" + ), .testTarget( name: "SolanaPayKitTests", dependencies: ["SolanaPayKit"], path: "swift/Tests/SolanaPayKitTests" ), + .testTarget( + name: "MppProtocolRunnerTests", + dependencies: ["mpp-protocol-runner"], + path: "swift/Tests/MppProtocolRunnerTests" + ), ] ) diff --git a/harness/protocol-runners/swift.json b/harness/protocol-runners/swift.json new file mode 100644 index 000000000..71a6e211d --- /dev/null +++ b/harness/protocol-runners/swift.json @@ -0,0 +1,5 @@ +{ + "language": "swift", + "command": ["swift", "run", "-c", "release", "mpp-protocol-runner"], + "cwd": "swift" +} diff --git a/harness/src/protocol/runners/spawn.ts b/harness/src/protocol/runners/spawn.ts index d6695979f..8815d525a 100644 --- a/harness/src/protocol/runners/spawn.ts +++ b/harness/src/protocol/runners/spawn.ts @@ -82,7 +82,15 @@ export function spawnedProtocolAdapter(runner: DiscoveredProtocolRunner): Protoc child.on("error", (err) => { resolve({ success: false, error: `spawn failed: ${err.message}`, error_type: "runner_error" }); }); - child.on("close", () => { + child.on("close", (code) => { + if (code !== 0) { + resolve({ + success: false, + error: `runner exited ${code}; stderr: ${stderr.slice(-512)}`, + error_type: "runner_error", + }); + return; + } const line = stdout.trim().split("\n").filter(Boolean).pop(); if (!line) { resolve({ diff --git a/harness/test/protocol-conformance.test.ts b/harness/test/protocol-conformance.test.ts index 1f0f06068..8d3b36e98 100644 --- a/harness/test/protocol-conformance.test.ts +++ b/harness/test/protocol-conformance.test.ts @@ -163,7 +163,7 @@ 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). // -// Only Kotlin's remain: every other SDK now conforms to the canonical receipt shape. The Go +// Only Kotlin's and Swift'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. Each entry maps to the exact response the @@ -179,6 +179,11 @@ const kotlinWithoutDescription = (scenario: string) => { const { description: _, ...result } = (parse as { golden: Record }).golden; return { success: true, result }; }; +const swiftUnsupported = (op: string, error_type: string, thing: string) => ({ + success: false, + error: `${op} unsupported: SolanaPayKit has no ${thing}`, + error_type, +}); const KNOWN_RUNNER_DIVERGENCES: Record> = { kotlin: new Map([ ["base64url.encode :: empty_string", kotlinUnsupported("base64url.encode", "encoding_error")], @@ -212,8 +217,29 @@ const KNOWN_RUNNER_DIVERGENCES: Record> = { { success: false, error: expect.stringContaining("unknown key 'hash'"), error_type: "format_error" }, ], ]), + swift: new Map([ + ["challenge.format :: basic_challenge", swiftUnsupported("challenge.format", "format_error", "WWW-Authenticate formatter")], + ["credential.parse :: basic_credential", swiftUnsupported("credential.parse", "parse_error", "Authorization parser")], + ["receipt.parse :: success_receipt", swiftUnsupported("receipt.parse", "parse_error", "Payment-Receipt parser")], + ["base64url.encode :: empty_string", swiftUnsupported("base64url.encode", "encoding_error", "public base64url encoder")], + ["base64url.decode :: empty_string", swiftUnsupported("base64url.decode", "encoding_error", "public base64url decoder")], + ["challenge.id :: required_fields_only", swiftUnsupported("challenge.id", "generation_error", "challenge-id generator")], + ]), }; +describe("mpp-protocol conformance (spawned runner failure)", () => { + it("a missing swift executable matches no known swift divergence", async () => { + const adapter = spawnedProtocolAdapter({ + language: "swift", + command: ["mpp-protocol-runner-missing"], + cwd: process.cwd(), + }); + const response = await adapter.runProtocolRequest({ op: "challenge.format", input: {} }); + expect(response).toMatchObject({ success: false, error_type: "runner_error" }); + expect([...KNOWN_RUNNER_DIVERGENCES.swift.values()]).not.toContainEqual(response); + }); +}); + // 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"]), diff --git a/swift/Sources/mpp-protocol-runner/main.swift b/swift/Sources/mpp-protocol-runner/main.swift new file mode 100644 index 000000000..65dc7cfca --- /dev/null +++ b/swift/Sources/mpp-protocol-runner/main.swift @@ -0,0 +1,158 @@ +// Command mpp-protocol-runner is the Swift mpp-protocol conformance runner: +// one adapter-ABI request on stdin, one response line on stdout, per the +// contract in harness/src/protocol/runners/spawn.ts. + +import Foundation +import SolanaPayKit + +struct RunnerError: Error, CustomStringConvertible { + var errorType: String? + let description: String +} + +func respond(to raw: Data) -> (line: String, exitCode: Int32) { + guard let request = (try? JSONSerialization.jsonObject(with: raw)) as? [String: Any], + let op = request["op"] as? String + else { + return (failure("request must be a JSON object with a string op", "runner_error"), 1) + } + do { + return (serialize(["success": true, "result": try run(op, request["input"])]), 0) + } catch { + return (failure(String(describing: error), (error as? RunnerError)?.errorType ?? family(of: op)), 0) + } +} + +func run(_ op: String, _ input: Any?) throws -> Any { + switch op { + case "challenge.parse": + return try parseChallenge(object(input, at: "input")) + case "credential.format": + return ["header": try MppHeaders.formatAuthorization(credential(from: object(input, at: "input")))] + case "challenge.format": + throw unsupported(op, "WWW-Authenticate formatter") + case "credential.parse": + throw unsupported(op, "Authorization parser") + case "receipt.parse": + throw unsupported(op, "Payment-Receipt parser") + case "receipt.format": + throw unsupported(op, "Payment-Receipt formatter") + case "base64url.encode": + throw unsupported(op, "public base64url encoder") + case "base64url.decode": + throw unsupported(op, "public base64url decoder") + case "challenge.id": + throw unsupported(op, "challenge-id generator") + default: + throw RunnerError(errorType: "unsupported_operation", description: "Unknown operation: \(op)") + } +} + +func family(of op: String) -> String { + if op.hasPrefix("base64url.") { return "encoding_error" } + if op == "challenge.id" { return "generation_error" } + return op.hasSuffix(".parse") ? "parse_error" : "format_error" +} + +func unsupported(_ op: String, _ thing: String) -> RunnerError { + RunnerError(description: "\(op) unsupported: SolanaPayKit has no \(thing)") +} + +enum ChallengeFields { + static let all: Set = ["id", "realm", "method", "intent", "request", "expires", "digest", "opaque"] + // Spec challenge params PaymentChallenge cannot carry; extension params are ignored per spec. + static let unrepresentable: Set = ["description"] +} + +func parseChallenge(_ input: [String: Any]) throws -> [String: Any] { + let header = try string(input, "header", at: "") ?? "" + let challenge = try MppHeaders.parseWWWAuthenticate(header) + let params = header.matches(of: #/[\s,]*([^=\s,"]+)\s*=\s*"(?:[^"\\]|\\.)*"/#).map { String($0.1) } + if let param = params.first(where: ChallengeFields.unrepresentable.contains) { + throw RunnerError(description: "unsupported field \(param)") + } + var result: [String: Any] = [ + "id": challenge.id, + "realm": challenge.realm, + "method": challenge.method, + "intent": challenge.intent, + "request": try decodeJSON(challenge.request), + ] + if let expires = challenge.expires { result["expires"] = expires } + if let digest = challenge.digest { result["digest"] = digest } + if let opaque = challenge.opaque { result["opaque"] = try decodeJSON(opaque) } + return result +} + +func credential(from input: [String: Any]) throws -> PaymentCredential { + try rejectUnknownKeys(input, ["challenge", "payload", "source"], at: "") + let challenge = try object(input["challenge"], at: "challenge") + try rejectUnknownKeys(challenge, ChallengeFields.all, at: "challenge.") + let payload = try object(input["payload"], at: "payload") + try rejectUnknownKeys(payload, ["type", "transaction", "signature"], at: "payload.") + guard let request = challenge["request"] else { throw RunnerError(description: "missing challenge.request") } + let echo = try PaymentChallenge( + id: required(challenge, "id", at: "challenge."), + realm: required(challenge, "realm", at: "challenge."), + method: required(challenge, "method", at: "challenge."), + intent: required(challenge, "intent", at: "challenge."), + request: encodeJSON(request), + expires: string(challenge, "expires", at: "challenge."), + digest: string(challenge, "digest", at: "challenge."), + opaque: challenge["opaque"].map(encodeJSON) + ).echo() + return PaymentCredential( + challenge: echo, + payload: try JSONDecoder().decode(CredentialPayload.self, from: JSONSerialization.data(withJSONObject: payload)), + source: try string(input, "source", at: "") + ) +} + +func object(_ value: Any?, at path: String) throws -> [String: Any] { + guard let object = value as? [String: Any] else { throw RunnerError(description: "\(path) must be a JSON object") } + return object +} + +func string(_ object: [String: Any], _ key: String, at path: String) throws -> String? { + guard let value = object[key] else { return nil } + guard let string = value as? String else { throw RunnerError(description: "\(path)\(key) must be a string") } + return string +} + +func required(_ object: [String: Any], _ key: String, at path: String) throws -> String { + guard let value = try string(object, key, at: path) else { throw RunnerError(description: "missing \(path)\(key)") } + return value +} + +func rejectUnknownKeys(_ object: [String: Any], _ allowed: Set, at path: String) throws { + if let key = object.keys.sorted().first(where: { !allowed.contains($0) }) { + throw RunnerError(description: "unsupported field \(path)\(key)") + } +} + +func decodeJSON(_ base64url: String) throws -> Any { + var base64 = base64url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + guard let data = Data(base64Encoded: base64) else { throw RunnerError(description: "invalid base64url: \(base64url)") } + return try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) +} + +func encodeJSON(_ value: Any) throws -> String { + try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys, .fragmentsAllowed]) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") +} + +func failure(_ message: String, _ errorType: String) -> String { + serialize(["success": false, "error": message, "error_type": errorType]) +} + +func serialize(_ response: [String: Any]) -> String { + String(decoding: try! JSONSerialization.data(withJSONObject: response, options: .sortedKeys), as: UTF8.self) +} + +let response = respond(to: FileHandle.standardInput.readDataToEndOfFile()) +FileHandle.standardOutput.write(Data((response.line + "\n").utf8)) +exit(response.exitCode) diff --git a/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift b/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift new file mode 100644 index 000000000..08f043468 --- /dev/null +++ b/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift @@ -0,0 +1,90 @@ +import Foundation +import XCTest + +@testable import mpp_protocol_runner + +final class AbiFramingTests: XCTestCase { + private let basicChallenge = #"Payment id="ch_abc123", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwMDAwIiwiY3VycmVuY3kiOiIweDIwYzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDEiLCJyZWNpcGllbnQiOiIweDEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVmMTIzNDU2NzgifQ""# + + private func call(_ request: Any) throws -> (response: [String: Any], exitCode: Int32) { + let (line, exitCode) = respond(to: try JSONSerialization.data(withJSONObject: request)) + XCTAssertFalse(line.contains("\n")) + let response = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any]) + return (response, exitCode) + } + + private func formatCredential(challenge: [String: Any] = [:], payload: [String: Any]) throws -> [String: Any] { + var fullChallenge: [String: Any] = [ + "id": "ch_abc123", "realm": "api.example.com", "method": "tempo", "intent": "charge", + "request": ["amount": "1000000"], + ] + fullChallenge.merge(challenge) { $1 } + let (response, exitCode) = try call([ + "op": "credential.format", "input": ["challenge": fullChallenge, "payload": payload], + ]) + XCTAssertEqual(exitCode, 0) + return response + } + + func testBasicChallengeParseIsOneLine() throws { + let (response, exitCode) = try call(["op": "challenge.parse", "input": ["header": basicChallenge]]) + XCTAssertEqual(exitCode, 0) + XCTAssertEqual(response["success"] as? Bool, true) + let result = try XCTUnwrap(response["result"] as? [String: Any]) + XCTAssertEqual(result["id"] as? String, "ch_abc123") + XCTAssertEqual((result["request"] as? [String: Any])?["amount"] as? String, "1000000") + } + + func testChallengeParseRejectsDescription() throws { + let header = basicChallenge + #", description="x""# + let (response, exitCode) = try call(["op": "challenge.parse", "input": ["header": header]]) + XCTAssertEqual(exitCode, 0) + XCTAssertEqual(response["error_type"] as? String, "parse_error") + XCTAssertEqual(response["error"] as? String, "unsupported field description") + } + + func testChallengeParseIgnoresExtensionParams() throws { + let header = basicChallenge + #", foo="bar", baz="qux""# + let (response, exitCode) = try call(["op": "challenge.parse", "input": ["header": header]]) + XCTAssertEqual(exitCode, 0) + XCTAssertEqual(response["success"] as? Bool, true) + let result = try XCTUnwrap(response["result"] as? [String: Any]) + XCTAssertEqual(Set(result.keys), ["id", "realm", "method", "intent", "request"]) + } + + func testMalformedRequestIsRunnerError() throws { + for raw in [Data("not json".utf8), Data(#"{"op":1}"#.utf8)] { + let (line, exitCode) = respond(to: raw) + XCTAssertEqual(exitCode, 1) + let response = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any]) + XCTAssertEqual(response["error_type"] as? String, "runner_error") + } + } + + func testUnknownOperationIsUnsupported() throws { + let (response, exitCode) = try call(["op": "voucher.sign", "input": [:]]) + XCTAssertEqual(exitCode, 0) + XCTAssertEqual(response["error_type"] as? String, "unsupported_operation") + XCTAssertEqual(response["error"] as? String, "Unknown operation: voucher.sign") + } + + func testCredentialFormatRejectsUnknownChallengeField() throws { + let response = try formatCredential( + challenge: ["description": "x"], payload: ["type": "transaction", "transaction": "AQ"] + ) + XCTAssertEqual(response["error_type"] as? String, "format_error") + XCTAssertEqual(response["error"] as? String, "unsupported field challenge.description") + } + + func testCredentialFormatRejectsUnknownPayloadField() throws { + let response = try formatCredential(payload: ["type": "hash", "hash": "0xabc"]) + XCTAssertEqual(response["error_type"] as? String, "format_error") + XCTAssertEqual(response["error"] as? String, "unsupported field payload.hash") + } + + func testCredentialFormatRequiresTransaction() throws { + let response = try formatCredential(payload: ["type": "transaction", "signature": "0xabc"]) + XCTAssertEqual(response["error_type"] as? String, "format_error") + XCTAssertTrue((response["error"] as? String ?? "").contains("transaction")) + } +}