Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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/kotlin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ jobs:
- name: Typecheck harness
working-directory: harness
run: pnpm typecheck
- name: Build the Kotlin conformance runner
working-directory: harness/kotlin-conformance
run: gradle installDist --no-daemon
- name: Run Kotlin cross-SDK conformance vectors
working-directory: harness
env:
MPP_CONFORMANCE_LANGUAGES: kotlin
run: pnpm exec vitest run test/conformance.test.ts
- name: Pre-warm Gradle for the Kotlin harness client
working-directory: harness/kotlin-client
run: gradle installDist --no-daemon
Expand Down
2 changes: 2 additions & 0 deletions harness/kotlin-conformance/.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-conformance/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
plugins {
kotlin("jvm") version "2.3.21"
kotlin("plugin.serialization") 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")
}

kotlin {
jvmToolchain(17)
}

application {
mainClass.set("com.solana.paykit.conformance.MainKt")
}

tasks.named<JavaExec>("run") {
standardInput = System.`in`
}
16 changes: 16 additions & 0 deletions harness/kotlin-conformance/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-conformance"
includeBuild("../../kotlin")
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.solana.paykit.conformance

import com.solana.paykit.paycore.PaymentChannels
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.Base64

/**
* Cross-SDK conformance-vector runner for the Kotlin SDK.
*
* Honors the same stdin/stdout contract as the TypeScript reference runner
* (harness/src/conformance/ts-runner.ts), the Go runner (go/cmd/conformance),
* and the Swift runner (swift Sources/mpp-conformance): read one conformance
* vector as JSON on stdin, drive the real SDK path for the requested
* intent + mode, and emit one RunnerResult line as JSON on stdout. Anything
* else (JVM/BouncyCastle chatter) must go to stderr so the harness parses a
* single clean JSON line.
*
* The Kotlin SDK is CLIENT-only and the harness drives it for the `session`
* intent only (harness/runners/kotlin.json). The single session vector shipped
* today is the canonical-bytes 48-byte voucher preimage; this runner decodes
* input.voucherPreimage and emits the bytes via the real
* PaymentChannels.voucherMessageBytes encoder. Any other mode is reported as an
* unsupported-mode reject the driver skips.
*/

@Serializable
private data class VoucherPreimage(val channelId: String, val cumulativeAmount: String, val expiresAt: Long)

@Serializable
private data class VectorInput(val voucherPreimage: VoucherPreimage? = null)

@Serializable
private data class Vector(val id: String, val intent: String? = null, val mode: String, val input: VectorInput)

@Serializable
private data class ExactBytes(val bytes: List<Int>? = null, val base64Url: String? = null)

@Serializable
private data class RunnerResult(
val id: String,
val outcome: String,
val exactBytes: ExactBytes? = null,
val error: String? = null,
)

private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = false
explicitNulls = false
}

fun main() {
val raw = System.`in`.readBytes().decodeToString()
val result = try {
runVector(json.decodeFromString(Vector.serializer(), raw))
} catch (error: Throwable) {
System.err.println("kotlin conformance runner error: ${error.message}")
RunnerResult(id = "unknown", outcome = "reject", error = error.message ?: "unknown error")
}
println(json.encodeToString(RunnerResult.serializer(), result))
}

private fun runVector(vector: Vector): RunnerResult {
if (vector.mode != "canonical-bytes") {
return RunnerResult(
vector.id, "reject",
error = "unsupported-mode: kotlin conformance runner only implements canonical-bytes session vectors",
)
}
val preimage = vector.input.voucherPreimage
?: return RunnerResult(
vector.id, "reject",
error = "kotlin conformance runner only supports the session voucherPreimage canonical-bytes vector",
)
val cumulative = preimage.cumulativeAmount.toULongOrNull()
?: return RunnerResult(vector.id, "reject", error = "invalid cumulativeAmount ${preimage.cumulativeAmount}")

// Drive the real SDK encoder so the byte assertion exercises the same path
// the session voucher signer uses.
val bytes = PaymentChannels.voucherMessageBytes(preimage.channelId, cumulative, preimage.expiresAt)
return RunnerResult(
id = vector.id,
outcome = "accept",
exactBytes = ExactBytes(
bytes = bytes.map { it.toInt() and 0xff },
base64Url = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes),
),
)
}
6 changes: 6 additions & 0 deletions harness/runners/kotlin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"language": "kotlin",
"command": ["sh", "-c", "exec build/install/mpp-kotlin-conformance/bin/mpp-kotlin-conformance"],
"cwd": "harness/kotlin-conformance",
"intents": ["session"]
}
14 changes: 10 additions & 4 deletions kotlin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,15 @@ curl -i https://402.surfnet.dev/paid
MPP_CLIENT_SECRET_KEY_HEX=<hex> ChargeClient https://402.surfnet.dev/paid
```

`examples/AndroidDemo` is a full Seeker / Android demo app that gates one
endpoint behind a wallet signature; its README walks through running it on a
device or emulator.
`examples/AndroidDemo` is a Jetpack Compose app that mirrors the iOS demo: on
launch it fetches the playground's `/openapi.json`, renders the priced
endpoints as a tappable collection, generates a local signer, tops it up over
Surfpool cheatcodes, and consumes one over MPP. Its README walks through
running it on an emulator.

<div align="center">
<img alt="AndroidDemo app" width="320" src="https://github.com/solana-foundation/pay-kit/raw/main/kotlin/examples/AndroidDemo/docs/android-demo-screenshot.png">
</div>

## x402

Expand All @@ -135,7 +141,7 @@ The Machine Payments Protocol charge intent. The client parses the
|---|:---:|
| `mpp/charge/pull` | ✅ |
| `mpp/charge/push` | ✅ |
| `mpp/session` | |
| `mpp/session` | |
| `mpp/subscription` | — |

## Vocabulary
Expand Down
26 changes: 15 additions & 11 deletions kotlin/examples/AndroidDemo/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# MPP Charge Demo (Android)
# PayKit Demo (Android)

A minimal Jetpack Compose Android app that pays a 402-protected route
using the Kotlin MPP SDK at `kotlin/` and signs the transaction with a
real Solana wallet via Mobile Wallet Adapter.

Tracked under issue #114.
A Jetpack Compose app that mirrors the iOS PayKitDemo. On launch it
fetches the pay-kit playground's `/openapi.json` (over `10.0.2.2:3000`,
the emulator's host loopback), renders every priced operation (read from
each route's `x-payment-info` offers) as a tappable collection, generates
a local signer, tops it up over Surfpool cheatcodes, and consumes any
endpoint over MPP, surfacing each charge's settlement signature in an
append-only log.

## Layout

Expand All @@ -14,7 +16,7 @@ kotlin/examples/AndroidDemo
│ ├── build.gradle.kts AGP + Compose configuration
│ └── src/main/
│ ├── AndroidManifest.xml single launcher activity
│ └── java/com/solana/mpp/demo/MainActivity.kt
│ └── java/com/solana/paykit/demo/ MainActivity.kt + OpenApi.kt
├── build.gradle.kts root project, declares plugins
├── settings.gradle.kts single `:app` module
└── gradle/wrapper/ pinned Gradle 8.10.2
Expand Down Expand Up @@ -81,11 +83,13 @@ destinations remain HTTPS-only.

## End-to-end screenshot

End-to-end run in the Android 34 (arm64) emulator against local
Surfpool + the iOSDemo's `MerchantServer/serve.py`. App shows
"HTTP 200", the fortune body, and the on-chain settlement signature.
End-to-end run in the Android 34 (arm64) emulator against the pay-kit
playground (`10.0.2.2:3000`) + the hosted Surfpool sandbox. The
endpoints are rendered from the playground's OpenAPI spec; the log shows
a `Stock quote — 200 OK` consumed over MPP with its on-chain settlement
signature.

![Android emulator screenshot showing HTTP 200 and settlement signature](docs/android-demo-screenshot.png)
![Android emulator screenshot: OpenAPI endpoints and a settled MPP charge](docs/android-demo-screenshot.png)

## Expected UI state

Expand Down
4 changes: 4 additions & 0 deletions kotlin/examples/AndroidDemo/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ dependencies {
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
// Extended Material icon set (CreditCard, ShowChart, Verified, Dangerous,
// MonetizationOn, etc.) used by the endpoint cards and log rows to mirror
// the iOS demo's SF Symbols. Version is supplied by the Compose BOM above.
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.9.2")
Expand Down
Loading
Loading