diff --git a/greptile.json b/greptile.json new file mode 100644 index 000000000..3e653b565 --- /dev/null +++ b/greptile.json @@ -0,0 +1 @@ +{"autoReview": []} 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..2e1c35424 --- /dev/null +++ b/harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt @@ -0,0 +1,99 @@ +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 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..6f6f9489c --- /dev/null +++ b/harness/protocol-runners/kotlin.json @@ -0,0 +1,5 @@ +{ + "language": "kotlin", + "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 5468dbaf9..24ead5aea 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, @@ -162,35 +162,89 @@ 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 Kotlin SDK gaps. +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(); + } + }); +});