Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions greptile.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"autoReview": []}
2 changes: 2 additions & 0 deletions harness/kotlin-protocol-runner/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.gradle/
build/
28 changes: 28 additions & 0 deletions harness/kotlin-protocol-runner/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<JavaExec>("run") {
standardInput = System.`in`
}

tasks.test {
useJUnitPlatform()
}
16 changes: 16 additions & 0 deletions harness/kotlin-protocol-runner/settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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)) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Plain opaque values fail parsing The SDK treats opaque as a pass-through string. When a valid challenge contains a plain value such as trace-123, decoding it as base64url JSON throws, so the runner returns parse_error instead of the parsed challenge.

Suggested change
challenge.opaque?.let { put("opaque", decodeJson(it)) }
challenge.opaque?.let { put("opaque", it) }

}
Comment on lines +57 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Challenge description is dropped When a valid header contains a top-level description, such as the canonical full_challenge, this response omits it. The parsed challenge no longer matches the expected object, and the basic-only runner test does not catch the difference.

}

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()
Original file line number Diff line number Diff line change
@@ -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"))
}
}
5 changes: 5 additions & 0 deletions harness/protocol-runners/kotlin.json
Original file line number Diff line number Diff line change
@@ -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"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Gradle runs for every request The spawned adapter starts a new process for each protocol case, so this command runs gradle installDist for every Kotlin request—about 13 times in the current suite. Even after the first build, repeated Gradle startup and build checks substantially slow the conformance run. Build the distribution once before running the cases, then launch the installed executable for each request.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

"cwd": "harness/kotlin-protocol-runner"
}
88 changes: 71 additions & 17 deletions harness/test/protocol-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, Set<string>> = {};
// 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<string, Record<string, KnownDivergence>> = {
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<void> {
const response = await adapter.runProtocolRequest({ op: testCase.op, input: testCase.input });
if ("missing" in divergence) {
const result = { ...(testCase.golden as Record<string, unknown>) };
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<string>();
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();
}
});
});
Loading