Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b976cea
test(harness): Kotlin protocol runner in the mpp-protocol divergence …
michael-moffett Oct 3, 2026
208fbd7
greptile.json: review on request only
Oct 3, 2026
f400328
test(harness): Kotlin protocol runner, its manifest, the CI build-and…
michael-moffett Oct 3, 2026
908e63f
greptile.json: review on request only
Oct 3, 2026
17dd9a2
test(harness): Rebuilt and retested the Kotlin protocol runner from s…
michael-moffett Oct 3, 2026
bdeefb4
greptile.json: review on request only
Oct 3, 2026
e2eeda5
test(harness): Kotlin block runs credential.format :: basic_credentia…
michael-moffett Oct 3, 2026
5610a5d
greptile.json: review on request only
Oct 3, 2026
00614e3
test(harness): CI builds and tests the Kotlin protocol runner (instal…
michael-moffett Oct 5, 2026
3863c1b
greptile.json: review on request only
Oct 5, 2026
3da6cdd
test(harness): Kotlin mpp-protocol runner and manifest drive the spaw…
michael-moffett Oct 5, 2026
f1c930a
greptile.json: review on request only
Oct 5, 2026
fe3f199
test(harness): Kotlin protocol runner drops empty-string challenge fi…
michael-moffett Oct 5, 2026
116ed77
greptile.json: review on request only
Oct 5, 2026
8630c6f
test(harness): parseChallenge drops empty strings only for expires an…
michael-moffett Oct 5, 2026
d315fc1
greptile.json: review on request only
Oct 5, 2026
58f7f48
test(harness): Kotlin's acceptance of an empty challenge id is record…
michael-moffett Oct 5, 2026
591e486
greptile.json: review on request only
Oct 5, 2026
8523629
test(harness): The spawned Kotlin block runs every challenge.parse an…
michael-moffett Oct 5, 2026
3ad9edb
greptile.json: review on request only
Oct 5, 2026
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,91 @@
// 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.PaymentChallenge
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)
val fields = Json.encodeToJsonElement(PaymentChallenge.serializer(), challenge).jsonObject
return JsonObject(
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

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 Opaque challenge values are decoded

When a valid Payment challenge includes opaque, this runner treats it as base64url-encoded JSON, although the Kotlin SDK preserves it as a string and the runner contract expects the raw value. A value such as trace-123 therefore produces a parse_error instead of a parsed challenge; encoded JSON is returned in the wrong form.

Suggested change
if (name == "request" || name == "opaque") decodeJson(value.jsonPrimitive.content) else value
if (name == "request") decodeJson(value.jsonPrimitive.content) else value

},
)
}

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,101 @@
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 dropsEmptyOptionalField() {
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":{}}"""),
answer(line)["result"],
)
}

@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"))
}

@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"],

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 Default tests require an unbuilt runner

In a fresh checkout, pnpm test discovers this Kotlin runner, but the configured command points to an installDist executable that has not been built. Kotlin cases then fail with a runner startup error. The targeted Kotlin CI job builds the distribution first, but the default full-suite command has no such prerequisite.

"cwd": "harness/kotlin-protocol-runner"
}
Loading
Loading