Skip to content
Closed
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
8 changes: 8 additions & 0 deletions .github/workflows/harness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,14 @@ jobs:
env:
MPP_CONFORMANCE_LANGUAGES: kotlin
run: pnpm exec vitest run test/conformance.test.ts
- name: Build and test the Kotlin protocol runner
working-directory: harness/kotlin-protocol-runner
run: gradle installDist test --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
Expand Down
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/
23 changes: 23 additions & 0 deletions harness/kotlin-protocol-runner/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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()
}
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,94 @@
// 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
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)))
}
}
Original file line number Diff line number Diff line change
@@ -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"))
}
}
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", "exec build/install/mpp-kotlin-protocol-runner/bin/mpp-kotlin-protocol-runner"],
"cwd": "harness/kotlin-protocol-runner"
}
96 changes: 82 additions & 14 deletions harness/test/protocol-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

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 {
discoverProtocolRunners,
Expand Down Expand Up @@ -162,35 +163,102 @@ 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<string, Set<string>> = {};
// per-SDK protocol-conformance round. Each entry maps to the exact response the
// runner gives.
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 = (scenario: string) => {
const parse = cases.find((c) => c.op === "challenge.parse" && c.scenario === scenario);
const { description: _, ...result } = (parse as { golden: Record<string, unknown> }).golden;
return { success: true, result };
};
const KNOWN_RUNNER_DIVERGENCES: Record<string, Map<string, unknown>> = {
kotlin: new Map<string, unknown>([
["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")],
["receipt.format :: success_receipt", kotlinUnsupported("receipt.format", "format_error")],
["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" },
],
// Kotlin's CredentialPayload has no `hash` field.
[
"credential.format :: credential_with_source",
{ success: false, error: expect.stringContaining("unknown key 'hash'"), error_type: "format_error" },
],
]),
};

// Format cases whose paired parse op the runner lacks: the TS reference re-parses its wire.
const REFERENCE_REPARSED_CASES: Record<string, Set<string>> = {
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 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<string>();
const known = KNOWN_RUNNER_DIVERGENCES[runner.language] ?? new Map<string, unknown>();
const reparsed = REFERENCE_REPARSED_CASES[runner.language] ?? new Set<string>();
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)) || reparsed.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;
}
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);
});
}
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,
);
});
});
}
Loading