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
8 changes: 8 additions & 0 deletions .github/workflows/harness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,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: Run Kotlin client harness smoke
working-directory: harness
run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "kotlin client pays typescript server" --testTimeout 180000
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ jobs:
env:
MPP_CONFORMANCE_LANGUAGES: swift
run: pnpm exec vitest run test/conformance.test.ts
- name: Build Swift protocol runner
run: swift build -c release --product mpp-protocol-runner
- name: Run Swift mpp-protocol conformance vectors
working-directory: harness
env:
MPP_CONFORMANCE_LANGUAGES: swift
run: pnpm exec vitest run test/protocol-conformance.test.ts
- name: Run Swift client harness smoke against TypeScript server
working-directory: harness
env:
Expand Down
10 changes: 10 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,20 @@ let package = Package(
dependencies: ["SolanaPayKit"],
path: "swift/Sources/mpp-conformance"
),
.executableTarget(
name: "mpp-protocol-runner",
dependencies: ["SolanaPayKit"],
path: "swift/Sources/mpp-protocol-runner"
),
.testTarget(
name: "SolanaPayKitTests",
dependencies: ["SolanaPayKit"],
path: "swift/Tests/SolanaPayKitTests"
),
.testTarget(
name: "MppProtocolRunnerTests",
dependencies: ["mpp-protocol-runner"],
path: "swift/Tests/MppProtocolRunnerTests"
),
]
)
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
},
)
}

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"
}
5 changes: 5 additions & 0 deletions harness/protocol-runners/swift.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"language": "swift",
"command": ["swift", "run", "-c", "release", "mpp-protocol-runner"],
"cwd": "swift"
}
10 changes: 9 additions & 1 deletion harness/src/protocol/runners/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,15 @@ export function spawnedProtocolAdapter(runner: DiscoveredProtocolRunner): Protoc
child.on("error", (err) => {
resolve({ success: false, error: `spawn failed: ${err.message}`, error_type: "runner_error" });
});
child.on("close", () => {
child.on("close", (code) => {
if (code !== 0) {
resolve({
success: false,
error: `runner exited ${code}; stderr: ${stderr.slice(-512)}`,
error_type: "runner_error",
});
return;
}
const line = stdout.trim().split("\n").filter(Boolean).pop();
if (!line) {
resolve({
Expand Down
Loading
Loading