Repository navigation
CI rehearsal only (do not merge) #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b976cea
208fbd7
f400328
908e63f
17dd9a2
bdeefb4
e2eeda5
5610a5d
00614e3
3863c1b
3da6cdd
f1c930a
fe3f199
116ed77
8630c6f
d315fc1
58f7f48
591e486
8523629
3ad9edb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"autoReview": []} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| .gradle/ | ||
| build/ |
| 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() | ||
| } |
| 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")) | ||
| } | ||
| } |
| 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"], | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In a fresh checkout, |
||
| "cwd": "harness/kotlin-protocol-runner" | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 astrace-123therefore produces aparse_errorinstead of a parsed challenge; encoded JSON is returned in the wrong form.