Skip to content
Closed
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)

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 Empty challenge IDs accepted When a challenge has id="", the SDK parser checks that the field exists but does not reject its empty value. This runner returns a successful parse, although the canonical vector requires parse_error, so it reports an invalid challenge as valid.

Suggested change
val challenge = MppHeaders.parseWWWAuthenticate(input.jsonObject.getValue("header").jsonPrimitive.content)
val challenge = MppHeaders.parseWWWAuthenticate(input.jsonObject.getValue("header").jsonPrimitive.content)
require(challenge.id.isNotEmpty()) { "challenge id must not be empty" }

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 Valid opaque values rejected A challenge can contain a plain-text opaque value. The SDK parses it as a string, but this runner tries to base64url-decode and JSON-parse it, returning parse_error for a valid challenge. Only request should be decoded this way.

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"],
"cwd": "harness/kotlin-protocol-runner"
}
Loading
Loading