diff --git a/.github/workflows/kotlin.yml b/.github/workflows/kotlin.yml index e983a8ef1..7a227dba4 100644 --- a/.github/workflows/kotlin.yml +++ b/.github/workflows/kotlin.yml @@ -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 diff --git a/harness/kotlin-conformance/.gitignore b/harness/kotlin-conformance/.gitignore new file mode 100644 index 000000000..67bcc2f72 --- /dev/null +++ b/harness/kotlin-conformance/.gitignore @@ -0,0 +1,2 @@ +.gradle/ +build/ diff --git a/harness/kotlin-conformance/build.gradle.kts b/harness/kotlin-conformance/build.gradle.kts new file mode 100644 index 000000000..2d52ee99e --- /dev/null +++ b/harness/kotlin-conformance/build.gradle.kts @@ -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("run") { + standardInput = System.`in` +} diff --git a/harness/kotlin-conformance/settings.gradle.kts b/harness/kotlin-conformance/settings.gradle.kts new file mode 100644 index 000000000..5c5849441 --- /dev/null +++ b/harness/kotlin-conformance/settings.gradle.kts @@ -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") diff --git a/harness/kotlin-conformance/src/main/kotlin/com/solana/paykit/conformance/Main.kt b/harness/kotlin-conformance/src/main/kotlin/com/solana/paykit/conformance/Main.kt new file mode 100644 index 000000000..ce31fd939 --- /dev/null +++ b/harness/kotlin-conformance/src/main/kotlin/com/solana/paykit/conformance/Main.kt @@ -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? = 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), + ), + ) +} diff --git a/harness/runners/kotlin.json b/harness/runners/kotlin.json new file mode 100644 index 000000000..2229028ef --- /dev/null +++ b/harness/runners/kotlin.json @@ -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"] +} diff --git a/kotlin/README.md b/kotlin/README.md index bf7828daa..367ab02ac 100644 --- a/kotlin/README.md +++ b/kotlin/README.md @@ -110,9 +110,15 @@ curl -i https://402.surfnet.dev/paid MPP_CLIENT_SECRET_KEY_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. + +
+ AndroidDemo app +
## x402 @@ -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 diff --git a/kotlin/examples/AndroidDemo/README.md b/kotlin/examples/AndroidDemo/README.md index a6f7fe711..082dd9409 100644 --- a/kotlin/examples/AndroidDemo/README.md +++ b/kotlin/examples/AndroidDemo/README.md @@ -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 @@ -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 @@ -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 diff --git a/kotlin/examples/AndroidDemo/app/build.gradle.kts b/kotlin/examples/AndroidDemo/app/build.gradle.kts index 73c641853..14793864d 100644 --- a/kotlin/examples/AndroidDemo/app/build.gradle.kts +++ b/kotlin/examples/AndroidDemo/app/build.gradle.kts @@ -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") diff --git a/kotlin/examples/AndroidDemo/app/src/main/java/com/solana/paykit/demo/MainActivity.kt b/kotlin/examples/AndroidDemo/app/src/main/java/com/solana/paykit/demo/MainActivity.kt index b0a2065b6..82cbcda2f 100644 --- a/kotlin/examples/AndroidDemo/app/src/main/java/com/solana/paykit/demo/MainActivity.kt +++ b/kotlin/examples/AndroidDemo/app/src/main/java/com/solana/paykit/demo/MainActivity.kt @@ -1,327 +1,1005 @@ package com.solana.paykit.demo +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowCircleDown +import androidx.compose.material.icons.filled.Dangerous +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.MonetizationOn +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.filled.VpnKey +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.solana.mobilewalletadapter.clientlib.ActivityResultSender -import com.solana.mobilewalletadapter.clientlib.ConnectionIdentity -import com.solana.mobilewalletadapter.clientlib.MobileWalletAdapter -import com.solana.mobilewalletadapter.clientlib.Solana -import com.solana.mobilewalletadapter.clientlib.TransactionResult -import com.solana.mobilewalletadapter.clientlib.successPayload +import androidx.compose.ui.unit.sp +import com.solana.paykit.client.PayKitClient +import com.solana.paykit.paycore.MemorySigner +import com.solana.paykit.paycore.Mints import com.solana.paykit.paycore.MppException +import com.solana.paykit.paycore.Pda +import com.solana.paykit.paycore.Programs import com.solana.paykit.paycore.PublicKey -import com.solana.paykit.protocols.mpp.client.Charge +import com.solana.paykit.paycore.SolanaSigner import com.solana.paykit.protocols.mpp.client.JsonRpcClient -import com.solana.paykit.protocols.mpp.core.CredentialPayload -import com.solana.paykit.protocols.mpp.core.MppHeaders -import com.solana.paykit.protocols.mpp.core.PaymentCredential import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.math.BigDecimal +import java.text.SimpleDateFormat import java.util.Base64 as JBase64 +import java.util.Date +import java.util.Locale /** - * MPP charge demo backed by Mobile Wallet Adapter. + * PayKit demo backed by a local in-memory signer, mirroring the iOS SwiftUI + * demo (`swift/Examples/PayKitDemo`). * - * Flow on Pay: - * 1. GET the merchant URL. Expect a 402 Payment Required with a - * WWW-Authenticate: Payment ... challenge. - * 2. Decode the Solana charge challenge. - * 3. Build the unsigned transaction wire bytes via - * Charge.buildUnsignedChargeTransaction with the connected wallet's - * pubkey as fee payer. - * 4. Hand the bytes to the wallet via - * walletAdapter.transact { signTransactions(...) }. - * 5. Base64 the signed bytes, format the Authorization header, replay - * the GET with the credential. + * Flow: + * 1. On launch, fetch `/openapi.json` from the playground and render every + * priced operation (those carrying an `x-payment-info` extension) as a + * tappable card collection. + * 2. "Setup Account" generates an Ed25519 signer and persists its 32 byte + * seed. "Topup" seeds SOL + USDC on the Surfpool sandbox via surfnet + * cheatcodes. + * 3. Tapping an endpoint runs the MPP 402 -> pay -> retry loop through the + * unified [PayKitClient] (charge interceptor) and appends the result to + * the Log. * - * The wallet must be installed on the device. For emulator testing, - * side-load solana-mobile/mock-mwa-wallet so MWA has a target to - * deep-link into. See README for setup. + * The playground (`examples/playground-api`, `pnpm dev`) serves its priced + * routes + `/openapi.json` discovery on :3000 and routes settlement through the + * hosted Surfpool sandbox at `402.surfnet.dev:8899`. The Android emulator + * reaches the host machine's localhost via 10.0.2.2 (allow-listed for cleartext + * in network_security_config.xml), so the playground base is + * `http://10.0.2.2:3000`. + * + * DEMO ONLY: [MemorySigner] holds the private key in app process memory / + * SharedPreferences. Production apps should delegate signing to Mobile Wallet + * Adapter or Seed Vault behind a custom [SolanaSigner]. */ class MainActivity : ComponentActivity() { - private val walletAdapter = MobileWalletAdapter( - connectionIdentity = ConnectionIdentity( - identityUri = Uri.parse("https://402.surfnet.dev"), - iconUri = Uri.parse("icon.png"), - identityName = "MPP Charge Demo", - ), - ).apply { - blockchain = Solana.Devnet - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val sender = ActivityResultSender(this) setContent { MaterialTheme { - Surface(modifier = Modifier.fillMaxSize()) { - DemoScreen(walletAdapter, sender) + Surface(modifier = Modifier.fillMaxSize(), color = IosColors.GroupedBackground) { + DemoScreen() } } } } } +// The playground serves priced routes + /openapi.json on :3000; the emulator +// reaches the host via 10.0.2.2. Settlement rides the hosted Surfpool sandbox. +private const val PLAYGROUND_BASE = "http://10.0.2.2:3000" +private const val RPC_URL = "https://402.surfnet.dev:8899" + @Composable -private fun DemoScreen( - walletAdapter: MobileWalletAdapter, - sender: ActivityResultSender, -) { - // Defaults point at the hosted 402.surfnet.dev demo server. For a local - // server running on the host machine, set the merchant/RPC fields to - // http://10.0.2.2: (the Android emulator's loopback alias, - // allow-listed in network_security_config.xml). - var merchantUrl by remember { mutableStateOf("https://402.surfnet.dev/protected") } - var rpcUrl by remember { mutableStateOf("https://402.surfnet.dev/rpc") } - var status by remember { mutableStateOf(Status.idle()) } - var walletPubkey by remember { mutableStateOf(null) } +private fun DemoScreen() { + val context = LocalContext.current + val store = remember { AccountStore(context) } val scope = rememberCoroutineScope() + var signer by remember { mutableStateOf(null) } + var usdcBalance by remember { mutableStateOf(null) } + var endpoints by remember { mutableStateOf>(emptyList()) } + var endpointsError by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(null) } + val log = remember { mutableStateListOf() } + + fun append(entry: LogEntry) = log.add(0, entry) + + suspend fun refreshBalance() { + val s = signer ?: return + runCatching { usdcBalance(RPC_URL, s.address) } + .onSuccess { usdcBalance = it } + .onFailure { append(LogEntry.system("Balance check failed: ${it.message}", success = false)) } + } + + // Load a persisted signer and the OpenAPI endpoint collection on launch. + LaunchedEffect(Unit) { + runCatching { store.loadSigner() } + .onSuccess { loaded -> + if (loaded != null) { + signer = loaded + refreshBalance() + } + } + .onFailure { append(LogEntry.system("Failed to load signer: ${it.message}", success = false)) } + + endpointsError = null + runCatching { fetchEndpoints() } + .onSuccess { loaded -> + endpoints = loaded + if (loaded.isEmpty()) endpointsError = "No priced endpoints in the OpenAPI spec." + } + .onFailure { endpointsError = "Could not load $PLAYGROUND_BASE/openapi.json: ${it.message}" } + } + Column( modifier = Modifier .fillMaxSize() - .padding(24.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), + .verticalScroll(rememberScrollState()) + .padding(vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(32.dp), ) { - Text(text = "MPP Charge Demo", style = MaterialTheme.typography.headlineSmall) Text( - text = "Pay a 402-protected route using a real wallet via Mobile Wallet Adapter.", - style = MaterialTheme.typography.bodyMedium, + text = "PayKit Demo", + fontSize = 34.sp, + fontWeight = FontWeight.Bold, + color = IosColors.Label, + modifier = Modifier.padding(start = 16.dp), ) - OutlinedTextField( - value = merchantUrl, - onValueChange = { merchantUrl = it }, - label = { Text("Merchant URL") }, - modifier = Modifier.fillMaxSize(), - ) - OutlinedTextField( - value = rpcUrl, - onValueChange = { rpcUrl = it }, - label = { Text("Solana RPC URL") }, - modifier = Modifier.fillMaxSize(), - ) - - Button( - enabled = !status.inFlight, - onClick = { - status = Status.running("Connecting to wallet ...") - scope.launch { - when (val result = walletAdapter.connect(sender)) { - is TransactionResult.Success -> { - val pk = PublicKey(result.authResult.publicKey) - walletPubkey = pk - status = Status( - inFlight = false, - message = "Connected. Wallet pubkey: ${pk.toBase58()}", - ) - } - is TransactionResult.NoWalletFound -> - status = Status( - false, - "No wallet found on device. Install Phantom, Solflare, or mock-mwa-wallet for emulator testing.", + // Account section. + Section(title = "Account") { + val s = signer + if (s != null) { + LabeledRow(label = "Address") { + Text( + text = shortAddress(s.address), + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + color = IosColors.SecondaryLabel, + ) + } + val balance = usdcBalance + if (balance != null) { + SectionDivider() + LabeledRow(label = "Balance") { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.MonetizationOn, + contentDescription = null, + tint = IosColors.Green, + modifier = Modifier.size(17.dp), ) - is TransactionResult.Failure -> - status = Status( - false, - "Connect failed: ${result.e.message ?: result.e::class.simpleName}", + Spacer(Modifier.width(6.dp)) + Text( + text = "${formatUsdc(balance)} USDC", + fontSize = 17.sp, + fontFamily = FontFamily.Monospace, + color = IosColors.Label, ) + } } + } else { + SectionDivider() + ActionRow( + title = "Topup 1000 USDC + 100 SOL", + icon = Icons.Filled.ArrowCircleDown, + active = busy == BusyKind.Topup, + enabled = busy == null, + onClick = { + busy = BusyKind.Topup + scope.launch { + runCatching { topup(RPC_URL, s.address) } + .onSuccess { + append(LogEntry.system("Topup ok: 1000 USDC + 100 SOL", success = true)) + refreshBalance() + } + .onFailure { + append(LogEntry.system("Topup failed: ${it.message}", success = false)) + } + busy = null + } + }, + ) } - }, + } else { + ActionRow( + title = "Setup Account", + icon = Icons.Filled.VpnKey, + active = false, + enabled = busy == null, + onClick = { + runCatching { store.setupSigner() } + .onSuccess { created -> + signer = created + usdcBalance = null + append(LogEntry.system("New account: ${created.address}", success = true)) + } + .onFailure { + append(LogEntry.system("Setup failed: ${it.message}", success = false)) + } + }, + ) + } + } + + // Endpoints section. The card carries no internal padding so the + // LazyRow can bleed its 12dp content insets to the card edges. + Section( + title = "Endpoints (${endpoints.size} from OpenAPI)", + contentPadding = PaddingValues(0.dp), ) { - Text(if (walletPubkey == null) "Connect Wallet" else "Reconnect Wallet") + val error = endpointsError + when { + error != null -> Text( + text = error, + fontSize = 13.sp, + color = IosColors.Orange, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 11.dp), + ) + endpoints.isEmpty() -> Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + Text( + text = "Loading $PLAYGROUND_BASE/openapi.json ...", + fontSize = 13.sp, + color = IosColors.SecondaryLabel, + ) + } + else -> LazyRow( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(endpoints, key = { it.id }) { endpoint -> + EndpointCard( + endpoint = endpoint, + busy = busy == BusyKind.Pay(endpoint.id), + enabled = busy == null && signer != null, + onClick = onClick@{ + val s = signer ?: return@onClick + when (endpoint.intent) { + // One-shot charge: the 402 -> sign -> retry MPP loop. + "charge" -> { + busy = BusyKind.Pay(endpoint.id) + scope.launch { + append(consume(s, endpoint)) + refreshBalance() + busy = null + } + } + // Session: open a payment channel, stream metered SSE + // deliveries, sign + commit a voucher, settle. + "session" -> { + busy = BusyKind.Pay(endpoint.id) + scope.launch { + append(consumeSession(s, endpoint)) + refreshBalance() + busy = null + } + } + // Other intents (subscription, x402 upto) use dedicated + // pay-kit APIs the tap demo doesn't drive. + else -> append( + LogEntry.failure( + endpoint, + "${endpoint.label} is an mpp/${endpoint.intent} flow this demo doesn't drive; use the matching pay-kit API.", + ) + ) + } + }, + ) + } + } + } + if (signer == null && endpoints.isNotEmpty()) { + Text( + text = "Tap Setup Account to enable these.", + fontSize = 13.sp, + color = IosColors.SecondaryLabel, + modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 11.dp), + ) + } } - Button( - enabled = !status.inFlight && walletPubkey != null, - onClick = { - val pk = walletPubkey ?: return@Button - status = Status.running("Building credential ...") - scope.launch { - status = runCharge(walletAdapter, sender, pk, merchantUrl, rpcUrl) + // Log section. + Section( + title = "Log", + trailing = { + if (log.isNotEmpty()) { + TextButton( + onClick = { log.clear() }, + contentPadding = PaddingValues(0.dp), + ) { + Text( + text = "Clear", + fontSize = 13.sp, + fontWeight = FontWeight.Normal, + color = IosColors.Blue, + ) + } } }, + contentPadding = PaddingValues(horizontal = 16.dp), ) { - Text(if (status.inFlight) "Paying ..." else "Pay") + if (log.isEmpty()) { + Text( + text = "Tap an endpoint above to send a charge.", + fontSize = 17.sp, + color = IosColors.SecondaryLabel, + modifier = Modifier.padding(vertical = 11.dp), + ) + } else { + log.forEachIndexed { index, entry -> + if (index > 0) SectionDivider() + LogRow(entry) + } + } } + } +} + +// region UI building blocks - Text(text = status.message, style = MaterialTheme.typography.bodyMedium) - status.signature?.let { signature -> +/** An iOS-style inset grouped section: a gray uppercase header + a white + * rounded card (10dp radius, 16dp inset, no shadow). The optional [trailing] + * sits at the header's trailing edge (the Log's "Clear" button). */ +@Composable +private fun Section( + title: String, + trailing: @Composable (() -> Unit)? = null, + contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp), + content: @Composable () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Text( - text = "Solana Explorer: https://explorer.solana.com/tx/$signature?cluster=devnet", - style = MaterialTheme.typography.bodySmall, + text = title.uppercase(Locale.US), + fontSize = 13.sp, + fontWeight = FontWeight.Normal, + color = IosColors.SecondaryLabel, + modifier = Modifier.weight(1f), + ) + trailing?.invoke() + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(10.dp)) + .background(IosColors.Card) + .padding(contentPadding), + ) { + content() + } + } +} + +/** Hairline separator between in-card rows (#C6C6C8). */ +@Composable +private fun SectionDivider() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(IosColors.Separator), + ) +} + +/** A label-left / value-right grouped row with 11dp vertical, 16dp horizontal + * padding handled by the caller's card insets (here only vertical). */ +@Composable +private fun LabeledRow(label: String, value: @Composable () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + fontSize = 17.sp, + fontWeight = FontWeight.Normal, + color = IosColors.Label, + modifier = Modifier.weight(1f), + ) + value() + } +} + +/** A full-width plain tinted row (icon + text left, spinner right when busy): + * Setup Account / Topup. No filled pill background, ~44dp tall. */ +@Composable +private fun ActionRow( + title: String, + icon: ImageVector, + active: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .clickableNoRipple(enabled = enabled, onClick = onClick), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = IosColors.Blue, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = title, + fontSize = 17.sp, + fontWeight = FontWeight.Normal, + color = IosColors.Blue, + modifier = Modifier.weight(1f), + ) + if (active) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = IosColors.Blue, ) } } } -private data class Status( - val inFlight: Boolean, - val message: String, +/** A rounded gradient card per endpoint, mirroring the iOS `EndpointCard`. */ +@Composable +private fun EndpointCard( + endpoint: Endpoint, + busy: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + val gradient = Brush.verticalGradient( + listOf(endpoint.tint, endpoint.tint.darkenBy(0.12f)), + ) + Column( + modifier = Modifier + .width(150.dp) + .height(130.dp) + .clip(RoundedCornerShape(14.dp)) + .background(gradient) + .clickableNoRipple(enabled = enabled, onClick = onClick) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = endpoint.icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(22.dp), + ) + if (busy) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = Color.White, + ) + } else { + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(Color.White.copy(alpha = 0.25f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = endpoint.method.uppercase(Locale.US), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + } + } + } + Spacer(Modifier.weight(1f)) + Text( + text = endpoint.label, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = endpoint.priceUsd, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = Color.White.copy(alpha = 0.9f), + ) + Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) { + endpoint.methods.forEachIndexed { index, method -> + if (index > 0) { + Text("·", fontSize = 11.sp, color = Color.White.copy(alpha = 0.45f)) + } + val selected = method == endpoint.selectedProtocol + Text( + text = method, + fontSize = 11.sp, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + color = Color.White.copy(alpha = if (selected) 1f else 0.55f), + ) + } + if (endpoint.intent != "charge") { + Text("·", fontSize = 11.sp, color = Color.White.copy(alpha = 0.45f)) + Text(endpoint.intent, fontSize = 11.sp, color = Color.White.copy(alpha = 0.55f)) + } + } + } +} + +@Composable +private fun LogRow(entry: LogEntry) { + val context = LocalContext.current + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = entry.statusIcon, + contentDescription = null, + tint = entry.statusTint, + modifier = Modifier.size(17.dp), + ) + Text( + text = entry.title, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = IosColors.Label, + modifier = Modifier.weight(1f), + ) + Text( + text = entry.time, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = IosColors.SecondaryLabel, + ) + } + entry.signature?.let { sig -> + Text( + text = sig, + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + color = IosColors.Label, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "View receipt on pay.sh", + fontSize = 13.sp, + fontWeight = FontWeight.Normal, + color = IosColors.Blue, + modifier = Modifier.clickableNoRipple { + val uri = Uri.parse("https://pay.sh/receipt/$sig?network=sandbox") + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) } + }, + ) + } + entry.detail?.let { detail -> + Text( + text = detail, + fontSize = 13.sp, + fontFamily = if (entry.monoDetail) FontFamily.Monospace else FontFamily.Default, + color = IosColors.SecondaryLabel, + maxLines = entry.detailMaxLines, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** Clickable without the Material ripple, to keep the surrounding cells clean. */ +@Composable +private fun Modifier.clickableNoRipple(enabled: Boolean = true, onClick: () -> Unit): Modifier { + val interactionSource = remember { MutableInteractionSource() } + return this.clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClick = onClick, + ) +} + +// endregion + +// region Log model + +private data class LogEntry( + val title: String, + val time: String, + val statusIcon: ImageVector, + val statusTint: Color, val signature: String? = null, + val detail: String? = null, + val monoDetail: Boolean = true, + val detailMaxLines: Int = 6, ) { companion object { - fun idle() = Status(false, "Idle. Press Connect Wallet to begin.") - fun running(message: String = "Working ...") = Status(true, message) + private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.US) + + private fun now(): String = timeFormat.format(Date()) + + fun success(endpoint: Endpoint, signature: String?, body: String): LogEntry = LogEntry( + title = "${endpoint.label} — 200 OK", + time = now(), + statusIcon = Icons.Filled.Verified, + statusTint = IosColors.Green, + signature = signature, + detail = when { + body.isNotBlank() -> body + signature == null -> "No Payment-Receipt header in response." + else -> null + }, + detailMaxLines = 4, + ) + + fun failure(endpoint: Endpoint?, message: String): LogEntry = LogEntry( + title = endpoint?.let { "${it.label} — failed" } ?: "Error", + time = now(), + statusIcon = Icons.Filled.Dangerous, + statusTint = IosColors.Red, + detail = message, + detailMaxLines = 6, + ) + + fun system(message: String, success: Boolean): LogEntry = LogEntry( + title = "System", + time = now(), + statusIcon = if (success) Icons.Filled.Info else Icons.Filled.Warning, + statusTint = if (success) IosColors.Blue else IosColors.Orange, + detail = message, + monoDetail = false, + ) } } +private sealed interface BusyKind { + data object Topup : BusyKind + data class Pay(val endpointId: String) : BusyKind +} + +// endregion + +// region iOS-like palette + +private object IosColors { + val GroupedBackground = Color(0xFFF2F2F7) + val Card = Color(0xFFFFFFFF) + val Label = Color(0xFF000000) + val SecondaryLabel = Color(0xFF8E8E93) + val Separator = Color(0xFFC6C6C8) + val Blue = Color(0xFF007AFF) + val Green = Color(0xFF34C759) + val Red = Color(0xFFFF3B30) + val Orange = Color(0xFFFF9500) +} + +// endregion + +// region Consume flow (SDK) + private val responseJson = Json { ignoreUnknownKeys = true } private val httpClient = OkHttpClient() /** - * Issues the initial unauthenticated GET, parses the 402 challenge, - * builds the unsigned transaction, asks the wallet to sign it, and - * replays the request with the MPP Authorization header. - * - * Runs the OkHttp/JSON-RPC calls on Dispatchers.IO so they do not block - * the compose recomposition thread. The walletAdapter.transact call - * itself suspends on the wallet's user-approval flow. + * Consumes a priced endpoint through the unified [PayKitClient], mirroring the + * iOS demo's `pay(_:)`. Builds an MPP-charge-enabled client bound to the + * persisted signer, issues the request against `PLAYGROUND_BASE + endpoint.path`, + * and lets the charge interceptor run the 402 -> pay -> retry loop. The on-chain + * signature is pulled from the `Payment-Receipt` envelope on success. */ -private suspend fun runCharge( - walletAdapter: MobileWalletAdapter, - sender: ActivityResultSender, - walletPubkey: PublicKey, - merchantUrl: String, - rpcUrl: String, -): Status { - return try { - // 1. Initial GET. Expect 402 with a Solana charge challenge. - // Read everything that touches the response body off the main thread - // so okhttp's lazy body consumption does not raise - // NetworkOnMainThreadException on resume. - data class InitialResponse(val code: Int, val body: String, val wwwAuth: List) - val initialParsed = withContext(Dispatchers.IO) { - httpClient.newCall(Request.Builder().url(merchantUrl).get().build()).execute().use { resp -> - InitialResponse( - code = resp.code, - body = resp.body?.string().orEmpty(), - wwwAuth = resp.headers("WWW-Authenticate"), - ) - } - } - if (initialParsed.code != 402) { - return Status(false, "Expected 402, got HTTP ${initialParsed.code}\n${initialParsed.body}") - } - val challengeHeaders = initialParsed.wwwAuth - val challenge = MppHeaders.selectSolanaChargeChallenge(challengeHeaders) - ?: throw MppException.InvalidPaymentScheme - - // 2. Build the unsigned transaction with the wallet's pubkey as - // signer. JsonRpcClient supplies the recent blockhash if the - // challenge does not pin one. - val unsignedTx = withContext(Dispatchers.IO) { - Charge.buildUnsignedChargeTransaction( - walletPublicKey = walletPubkey, - request = challenge.chargeRequest(), - blockhashProvider = JsonRpcClient(rpcUrl), +/** + * Drive the full payment-channel **session** for a session endpoint: open the + * channel, stream the metered SSE deliveries, sign + commit a voucher, and poll + * the settle signature (see [SessionStream]). Mirrors the iOS demo's session path. + */ +private suspend fun consumeSession(signer: SolanaSigner, endpoint: Endpoint): LogEntry = withContext(Dispatchers.IO) { + try { + val client = OkHttpClient.Builder() + .readTimeout(60, java.util.concurrent.TimeUnit.SECONDS) + .build() + val result = SessionStream.consume(client, "$PLAYGROUND_BASE${endpoint.path}", signer) + val paid = "$" + BigDecimal(result.totalPaidBaseUnits) + .divide(BigDecimal(1_000_000)) + .stripTrailingZeros() + .toPlainString() + LogEntry.success( + endpoint = endpoint, + signature = result.settleSignature, + body = "streamed ${result.chunks} chunks · paid $paid over a payment-channel session " + + "(cumulative ${result.cumulative} base units)", + ) + } catch (t: Throwable) { + android.util.Log.e("PayKitDemo", "session consume failed", t) + LogEntry.failure(endpoint, "${t.javaClass.simpleName}: ${t.message ?: "(no message)"}") + } +} + +private suspend fun consume(signer: SolanaSigner, endpoint: Endpoint): LogEntry = withContext(Dispatchers.IO) { + val url = "$PLAYGROUND_BASE${endpoint.path}" + try { + val client = PayKitClient.Builder() + .signer(signer) + .charge(blockhashProvider = JsonRpcClient(RPC_URL)) + .build() + + val payResponse = if (endpoint.method == "POST") { + client.post( + url = url, + body = "{}".toByteArray(), + contentType = "application/json", + headers = mapOf("Accept" to "application/json"), ) + } else { + client.get(url = url, headers = mapOf("Accept" to "application/json")) } - // 3. Ask the wallet to sign. MWA returns the signed transaction - // wire bytes directly; we base64-encode for the credential. - val signResult = walletAdapter.transact(sender) { authResult -> - // Confirm the connected account still matches the one we - // built the transaction against. If the user switched - // accounts inside the wallet, the wire bytes reference the - // wrong fee payer and the signature would be rejected on - // submission. - val authPubkey = PublicKey(authResult.accounts.first().publicKey) - check(authPubkey.bytes.contentEquals(walletPubkey.bytes)) { - "wallet returned a different account than the one used to build the transaction" - } - signTransactions(arrayOf(unsignedTx)) - } - val signedTxBytes = when (signResult) { - is TransactionResult.Success -> - signResult.successPayload?.signedPayloads?.firstOrNull() - ?: return Status(false, "Wallet returned no signed payload") - is TransactionResult.NoWalletFound -> - return Status(false, "No wallet found on device") - is TransactionResult.Failure -> - return Status( - false, - "Wallet sign failed: ${signResult.e.message ?: signResult.e::class.simpleName}", + val settlement = payResponse.settlement + payResponse.use { body -> + val text = body.orEmpty() + if (payResponse.status in 200..299) { + LogEntry.success( + endpoint = endpoint, + signature = settlement?.let { signatureFromReceiptHeader(it) }, + body = text, ) - } - val signedTxBase64 = JBase64.getEncoder().encodeToString(signedTxBytes) - - // 4. Replay the request with the Authorization header. - val authorization = MppHeaders.formatAuthorization( - PaymentCredential( - challenge = challenge.echo(), - payload = CredentialPayload.transaction(signedTxBase64), - ), - ) - data class AuthedResponse(val code: Int, val body: String) - val authed = withContext(Dispatchers.IO) { - httpClient.newCall( - Request.Builder().url(merchantUrl).get() - .header("Authorization", authorization) - .build(), - ).execute().use { resp -> - AuthedResponse(resp.code, resp.body?.string().orEmpty()) + } else { + LogEntry.failure(endpoint, "HTTP ${payResponse.status}\n$text") } } - val code = authed.code - val body = authed.body - Status( - inFlight = false, - message = "HTTP $code\n$body", - signature = extractSignature(body), - ) } catch (mpp: MppException) { - android.util.Log.e("MppDemo", "MPP error", mpp) - Status(false, "MPP error: ${mpp.message ?: mpp::class.simpleName}") + android.util.Log.e("PayKitDemo", "MPP error", mpp) + LogEntry.failure(endpoint, "MPP error: ${mpp.message ?: mpp::class.simpleName}") } catch (t: Throwable) { - android.util.Log.e("MppDemo", "runCharge failed", t) - Status(false, "Error: ${t.javaClass.simpleName}: ${t.message ?: "(no message)"}") + android.util.Log.e("PayKitDemo", "consume failed", t) + LogEntry.failure(endpoint, "${t.javaClass.simpleName}: ${t.message ?: "(no message)"}") } } -private fun extractSignature(body: String): String? { - if (body.isBlank()) return null +/** + * Decode a `Payment-Receipt` header (base64url-no-pad JSON envelope produced by + * the gateway's `format_receipt`) and return the `reference` field — the + * on-chain signature. Mirrors the iOS `signatureFromReceiptHeader`. + */ +private fun signatureFromReceiptHeader(header: String): String? { return try { - val parsed = responseJson.parseToJsonElement(body) - val obj = parsed as? JsonObject ?: return null - // Spine receipts surface the on-chain signature under different - // key names depending on the server. Try the common shapes. - listOf("signature", "tx_signature", "txSignature") - .firstNotNullOfOrNull { obj[it]?.jsonPrimitive?.content } - ?: obj["receipt"]?.let { (it as? JsonObject) }?.let { receipt -> - listOf("signature", "tx_signature", "txSignature") - .firstNotNullOfOrNull { receipt[it]?.jsonPrimitive?.content } - } + val decoded = JBase64.getUrlDecoder().decode(header.padBase64Url()) + val json = responseJson.parseToJsonElement(String(decoded)) as? JsonObject ?: return null + json["reference"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotEmpty() } } catch (_: Throwable) { null } } + +private fun String.padBase64Url(): String { + val pad = (4 - length % 4) % 4 + return this + "=".repeat(pad) +} + +// endregion + +// region OpenAPI fetch + +/** Fetch and parse the playground's `/openapi.json` off the main thread. */ +private suspend fun fetchEndpoints(): List = withContext(Dispatchers.IO) { + val request = Request.Builder().url("$PLAYGROUND_BASE/openapi.json").get().build() + httpClient.newCall(request).execute().use { resp -> + val body = resp.body?.string().orEmpty() + if (resp.code !in 200..299) { + throw OpenApiException("openapi.json returned HTTP ${resp.code}") + } + OpenApi.endpoints(body) + } +} + +// endregion + +// region Account store + topup/balance + +/** Surfpool sandbox cheatcode targets and demo topup amounts (mirror DemoSigner.swift). */ +private const val USDC_MINT = Mints.USDC_MAINNET +private const val SYSTEM_PROGRAM = "11111111111111111111111111111111" +private const val TOPUP_LAMPORTS = 100_000_000_000L +private const val TOPUP_USDC_BASE_UNITS = 1_000_000_000L + +/** + * Persists the demo signer's 32 byte Ed25519 seed in SharedPreferences (the + * Android analogue of the iOS Keychain store). DEMO ONLY: the key is in + * cleartext app storage; production apps should use the Android Keystore or a + * wallet-backed [SolanaSigner]. + */ +private class AccountStore(context: Context) { + private val prefs: SharedPreferences = + context.getSharedPreferences("paykit-demo", Context.MODE_PRIVATE) + + /** Load the persisted signer, or null when Setup Account has not run. */ + fun loadSigner(): SolanaSigner? { + val encoded = prefs.getString(SEED_KEY, null) ?: return null + val seed = JBase64.getDecoder().decode(encoded) + require(seed.size == 32) { "stored seed is not 32 bytes; reset the account" } + return MemorySigner.fromSeed(seed) + } + + /** Generate a fresh signer, persist its seed, and return it. */ + fun setupSigner(): SolanaSigner { + val signer = MemorySigner.generate() + prefs.edit().putString(SEED_KEY, JBase64.getEncoder().encodeToString(signer.seedBytes())).apply() + return signer + } + + private companion object { + const val SEED_KEY = "demo-signer-seed" + } +} + +/** + * Seed an account on Surfpool with SOL + USDC via the surfnet cheatcodes. + * Idempotent — re-running just resets the balances. Only works on a Surfpool + * RPC (the hosted sandbox or local Surfpool). + */ +private suspend fun topup(rpcUrl: String, pubkey: String) = withContext(Dispatchers.IO) { + rpcCall( + rpcUrl, + method = "surfnet_setAccount", + params = buildJsonArray { + add(JsonPrimitive(pubkey)) + add( + buildJsonObject { + put("lamports", TOPUP_LAMPORTS) + put("data", "") + put("executable", false) + put("owner", SYSTEM_PROGRAM) + }, + ) + }, + ) + rpcCall( + rpcUrl, + method = "surfnet_setTokenAccount", + params = buildJsonArray { + add(JsonPrimitive(pubkey)) + add(JsonPrimitive(USDC_MINT)) + add(buildJsonObject { put("amount", TOPUP_USDC_BASE_UNITS) }) + add(JsonPrimitive(Programs.TOKEN_PROGRAM)) + }, + ) +} + +/** + * Fetch the USDC balance (in decimal USDC, 6-decimal mint) for [pubkey] on the + * given Surfpool RPC. Returns null when the ATA does not exist yet (the account + * has not been topped up). Mirrors `DemoSigner.usdcBalance`. + */ +private suspend fun usdcBalance(rpcUrl: String, pubkey: String): BigDecimal? = withContext(Dispatchers.IO) { + val ata = Pda.associatedTokenAddress( + owner = PublicKey.fromBase58(pubkey), + mint = PublicKey.fromBase58(USDC_MINT), + tokenProgram = PublicKey.fromBase58(Programs.TOKEN_PROGRAM), + ) + val response = try { + rpcCall( + rpcUrl, + method = "getTokenAccountBalance", + params = buildJsonArray { add(JsonPrimitive(ata.toBase58())) }, + ) + } catch (e: Throwable) { + if (e.message?.contains("could not find account") == true) return@withContext null + throw e + } + val ui = response["result"]?.jsonObject?.get("value")?.jsonObject + ?.get("uiAmountString")?.jsonPrimitive?.contentOrNull + ?: return@withContext null + ui.toBigDecimalOrNull() +} + +/** Minimal JSON-RPC POST; throws on a JSON-RPC `error` field or non-2xx status. */ +private fun rpcCall( + rpcUrl: String, + method: String, + params: kotlinx.serialization.json.JsonArray, +): JsonObject { + val payload = buildJsonObject { + put("jsonrpc", "2.0") + put("id", 1) + put("method", method) + put("params", params) + } + val body = responseJson.encodeToString(JsonObject.serializer(), payload) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder().url(rpcUrl).post(body).build() + httpClient.newCall(request).execute().use { resp -> + val text = resp.body?.string().orEmpty() + if (resp.code !in 200..299) { + throw IllegalStateException("RPC HTTP ${resp.code}: $text") + } + val parsed = responseJson.parseToJsonElement(text) as? JsonObject + ?: throw IllegalStateException("non-object RPC response") + parsed["error"]?.let { throw IllegalStateException("RPC error: $it") } + return parsed + } +} + +private fun String.toBigDecimalOrNull(): BigDecimal? = + try { + BigDecimal(this) + } catch (_: NumberFormatException) { + null + } + +// region formatting helpers + +/** Truncate a base58 pubkey to `first6…last6`, like the iOS `shortAddress`. */ +private fun shortAddress(address: String): String = + if (address.length > 14) "${address.take(6)}…${address.takeLast(6)}" else address + +/** Format a USDC decimal, trimming trailing zeros (max 6 fraction digits). */ +private fun formatUsdc(amount: BigDecimal): String { + val stripped = amount.stripTrailingZeros() + return if (stripped.scale() <= 0) stripped.toBigInteger().toString() else stripped.toPlainString() +} + +// endregion diff --git a/kotlin/examples/AndroidDemo/app/src/main/java/com/solana/paykit/demo/OpenApi.kt b/kotlin/examples/AndroidDemo/app/src/main/java/com/solana/paykit/demo/OpenApi.kt new file mode 100644 index 000000000..11391176a --- /dev/null +++ b/kotlin/examples/AndroidDemo/app/src/main/java/com/solana/paykit/demo/OpenApi.kt @@ -0,0 +1,247 @@ +package com.solana.paykit.demo + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.filled.Sensors +import androidx.compose.material.icons.filled.Speed +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * A priced operation discovered from the playground's `/openapi.json`, + * rendered as a tappable card in the endpoints collection. Mirrors the + * iOS demo's `Endpoint` value type. + */ +data class Endpoint( + val id: String, + val label: String, + val method: String, + val path: String, + val priceUsd: String, + val icon: ImageVector, + val tint: Color, + /** Discovery intent of the first offer (`charge` / `session` / …). */ + val intent: String, + /** Accepted protocols in offer order, e.g. `["x402", "mpp"]`. */ + val methods: List, + /** The protocol this demo settles over (`mpp` for charge endpoints that + * advertise it); `null` for flows it doesn't consume. Rendered emphasized + * on the card so it's clear which offer is used. */ + val selectedProtocol: String?, +) + +/** Vertical-gradient bottom stop: the tint darkened ~12% (mirrors the iOS + * `Color.gradient` shading used on the endpoint cards). */ +fun Color.darkenBy(fraction: Float = 0.12f): Color { + val k = 1f - fraction + return Color(red = red * k, green = green * k, blue = blue * k, alpha = alpha) +} + +/** + * Decodes the playground's `GET /openapi.json` discovery document into the + * app's `List` collection. + * + * The document is an OpenAPI 3.1 doc whose every priced operation under + * `paths..` carries an `x-payment-info` extension with an + * `offers[]` list (the payment-discovery draft: `intent` / `method` / `amount` + * per offer, plus pay-kit extras like `scheme` / `network` / `payTo`). + * Operations with no `x-payment-info` (health, `/openapi.json`, docs) are free + * and excluded. + * + * Parsing goes through `kotlinx.serialization`'s untyped `JsonObject` tree + * (with `ignoreUnknownKeys`) rather than `@Serializable` data classes, because + * the hyphenated extension keys and arbitrarily-nested offers array are awkward + * for generated decoders. This mirrors the iOS demo's `JSONSerialization` path. + */ +object OpenApi { + private val json = Json { ignoreUnknownKeys = true } + + /** HTTP verbs that can carry an operation object in an OpenAPI path item. */ + private val HTTP_METHODS = setOf("get", "post", "put", "patch", "delete", "head", "options") + + /** + * Cycle through a fixed palette so each card gets a distinct tint. Matches + * the iOS palette order so the two demos read the same. + */ + private val PALETTE = listOf( + Color(0xFF007AFF), // blue + Color(0xFF5E5CE6), // indigo + Color(0xFFFF2D55), // pink/magenta + Color(0xFF34C759), // green + Color(0xFFFF9500), // orange + Color(0xFFAF52DE), // purple + Color(0xFFFF3B30), // red + Color(0xFF5AC8FA), // teal/cyan + ) + + /** + * Build the priced-endpoint collection from a raw `/openapi.json` body. + * Returns endpoints in a stable order (sorted by path then method) so the + * per-index tint and layout do not reshuffle between loads. + */ + fun endpoints(from: String): List { + val root = json.parseToJsonElement(from).jsonObject + val paths = root["paths"]?.jsonObject ?: throw OpenApiException("openapi.json had no `paths`.") + + data class Parsed(val path: String, val method: String, val operation: JsonObject) + + val parsed = mutableListOf() + for ((path, item) in paths) { + val operations = item as? JsonObject ?: continue + for ((method, op) in operations) { + if (method.lowercase() !in HTTP_METHODS) continue + val operation = op as? JsonObject ?: continue + // Only priced operations are tappable. Free routes (health, + // docs, /openapi.json) carry no `x-payment-info`. + operation["x-payment-info"] as? JsonObject ?: continue + parsed.add(Parsed(path, method.uppercase(), operation)) + } + } + + parsed.sortWith(compareBy({ it.path }, { it.method })) + + return parsed.mapIndexed { index, entry -> + endpoint(entry.path, entry.method, entry.operation, index) + } + } + + private fun endpoint(path: String, method: String, operation: JsonObject, index: Int): Endpoint { + val paymentInfo = operation["x-payment-info"]?.jsonObject + val offers = paymentInfo?.get("offers")?.jsonArray + val firstOffer = offers?.firstOrNull()?.jsonObject + + val summary = (operation["summary"] as? JsonPrimitive)?.contentOrNull?.trim() + val label = if (!summary.isNullOrEmpty()) summary else path + + val intent = (firstOffer?.get("intent") as? JsonPrimitive)?.contentOrNull + val scheme = (firstOffer?.get("scheme") as? JsonPrimitive)?.contentOrNull + val payMethod = (firstOffer?.get("method") as? JsonPrimitive)?.contentOrNull + + return Endpoint( + id = "$method $path", + label = label, + method = method, + path = requestPath(path), + priceUsd = priceString(firstOffer), + icon = iconFor(intent = intent, scheme = scheme, method = payMethod), + tint = PALETTE[index % PALETTE.size], + intent = if (!intent.isNullOrEmpty()) intent.lowercase() else "charge", + methods = methodsOf(offers), + selectedProtocol = selectedProtocol(offers, intent), + ) + } + + /** Accepted protocols (offer `method`s) de-duplicated in offer order. */ + private fun methodsOf(offers: kotlinx.serialization.json.JsonArray?): List { + val methods = LinkedHashSet() + offers?.forEach { offer -> + ((offer as? JsonObject)?.get("method") as? JsonPrimitive)?.contentOrNull + ?.takeIf { it.isNotEmpty() }?.let { methods.add(it) } + } + return methods.toList() + } + + /** The protocol the demo settles over: it drives charge endpoints through the + * MPP client, so `mpp` is selected when a charge endpoint advertises it. */ + private fun selectedProtocol(offers: kotlinx.serialization.json.JsonArray?, intent: String?): String? { + if ((intent?.lowercase() ?: "charge") != "charge") return null + val ms = methodsOf(offers) + return if (ms.contains("mpp")) "mpp" else ms.firstOrNull() + } + + /** + * Pick a Material icon by payment intent/scheme/method, mirroring the iOS + * demo's `systemImage(intent:scheme:method:)` so the cards carry the same + * glyph as Swift (every `charge` endpoint shows the credit-card icon): + * charge -> CreditCard, session -> Sensors, subscription -> Repeat, + * usage -> Speed; else by scheme (exact -> Bolt, upto -> Speed); + * else x402 -> Bolt, otherwise CreditCard. + */ + private fun iconFor(intent: String?, scheme: String?, method: String?): ImageVector { + when (intent?.lowercase()) { + "charge" -> return Icons.Filled.CreditCard + "session" -> return Icons.Filled.Sensors + "subscription" -> return Icons.Filled.Repeat + "usage" -> return Icons.Filled.Speed + } + when (scheme?.lowercase()) { + "exact" -> return Icons.Filled.Bolt + "upto" -> return Icons.Filled.Speed + "subscription" -> return Icons.Filled.Repeat + "session" -> return Icons.Filled.Sensors + } + return if (method?.lowercase() == "x402") Icons.Filled.Bolt else Icons.Filled.CreditCard + } + + /** + * Turn a templated OpenAPI path (`/api/v1/quote/{symbol}`) into a concrete + * request path by filling each `{param}` with a `demo` placeholder, so the + * URL reaches the mounted route instead of 404-ing on the literal + * `{symbol}` segment. + */ + fun requestPath(openApiPath: String): String { + if (!openApiPath.contains('{')) return openApiPath + val result = StringBuilder() + var insideParam = false + for (char in openApiPath) { + when (char) { + '{' -> { + insideParam = true + result.append("demo") + } + '}' -> insideParam = false + else -> if (!insideParam) result.append(char) + } + } + return result.toString() + } + + /** + * Format the offer price as a dollar string. The offer's `amount` is a + * base-unit integer string (USDC has 6 decimals); fall back to the + * human-readable `description` (e.g. `"0.01 USDC"`) and finally a dash. + */ + fun priceString(offer: JsonObject?): String { + val amount = (offer?.get("amount") as? JsonPrimitive)?.contentOrNull + val baseUnits = amount?.toBigDecimalOrNull() + if (baseUnits != null) { + val dollars = baseUnits + .divide(BigDecimal(1_000_000)) + .setScale(2, RoundingMode.HALF_UP) + .stripTrailingZerosOrTwo() + val prefix = if ((offer["scheme"] as? JsonPrimitive)?.contentOrNull == "upto") "up to " else "" + return "$prefix$$dollars" + } + val description = (offer?.get("description") as? JsonPrimitive)?.contentOrNull + if (!description.isNullOrEmpty()) return description + return "—" + } + + private fun String.toBigDecimalOrNull(): BigDecimal? = + try { + BigDecimal(this) + } catch (_: NumberFormatException) { + null + } + + /** Keep at least 2 fraction digits (so `$0.01`), but allow more when present. */ + private fun BigDecimal.stripTrailingZerosOrTwo(): String { + val stripped = stripTrailingZeros() + val scale = stripped.scale().coerceAtLeast(2) + return stripped.setScale(scale, RoundingMode.HALF_UP).toPlainString() + } +} + +/** Failure mode when decoding `/openapi.json`. */ +class OpenApiException(message: String) : Exception(message) diff --git a/kotlin/examples/AndroidDemo/app/src/main/java/com/solana/paykit/demo/SessionStream.kt b/kotlin/examples/AndroidDemo/app/src/main/java/com/solana/paykit/demo/SessionStream.kt new file mode 100644 index 000000000..259b48a34 --- /dev/null +++ b/kotlin/examples/AndroidDemo/app/src/main/java/com/solana/paykit/demo/SessionStream.kt @@ -0,0 +1,176 @@ +package com.solana.paykit.demo + +import com.solana.paykit.paycore.MemorySigner +import com.solana.paykit.paycore.SolanaSigner +import com.solana.paykit.protocols.mpp.client.CommitTransport +import com.solana.paykit.protocols.mpp.client.PaymentChannelSession +import com.solana.paykit.protocols.mpp.client.SessionConsumer +import com.solana.paykit.protocols.mpp.core.CommitPayload +import com.solana.paykit.protocols.mpp.core.CommitReceipt +import com.solana.paykit.protocols.mpp.core.MeteringDirective +import com.solana.paykit.protocols.mpp.core.MppHeaders +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.security.SecureRandom +import java.util.UUID + +/** + * Drives a full MPP payment-channel **session** against the playground's + * `/api/v1/stream` (a metered SSE endpoint) — the flow the one-shot charge + * client can't do. Mirrors the iOS `SessionStream`: + * + * 1. GET unauthenticated -> 402 `WWW-Authenticate` session challenge. + * 2. Open the channel: `PaymentChannelSession.open` builds the payer-signed + * open tx; the credential carries it and the server (operator) co-signs + + * broadcasts. Retrying the GET with that credential returns the SSE stream. + * 3. Read the SSE deliveries (`data: {chunk,cost}` ... `[DONE]`), summing cost. + * 4. Reserve a delivery on the side channel, then sign + commit one cumulative + * voucher through `SessionConsumer`. + * 5. Poll the receipt route for the on-chain settle signature. + * + * Synchronous (blocking OkHttp); call it off the main thread. + */ +object SessionStream { + data class Result( + val channelId: String, + val chunks: Int, + val totalPaidBaseUnits: Long, + val cumulative: String, + val settleSignature: String?, + ) + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = false + explicitNulls = false + } + private val JSON_MEDIA = "application/json".toMediaType() + + fun consume(client: OkHttpClient, streamUrl: String, payer: SolanaSigner): Result { + // 1. Unauthenticated GET -> 402 session challenge. + val challenge = client.newCall( + Request.Builder().url(streamUrl).header("Accept", "application/json").get().build() + ).execute().use { resp -> + if (resp.code != 402) error("expected 402 from stream, got ${resp.code}") + val header = resp.header("WWW-Authenticate") ?: error("402 had no WWW-Authenticate header") + MppHeaders.parseWWWAuthenticate(header) + } + PaymentChannelSession.requireSolanaSession(challenge) + val request = PaymentChannelSession.sessionRequest(challenge) + val blockhash = request.recentBlockhash + require(!blockhash.isNullOrEmpty()) { "session challenge did not carry a recentBlockhash" } + + // 2. Open the channel (pull + clientVoucher, server-broadcast). + val sessionSigner = MemorySigner.fromSeed(randomSeed()) + val opener = PaymentChannelSession.open(request, payer, sessionSigner, blockhash) + val channelId = opener.open.channelId.toBase58() + val credential = PaymentChannelSession.serializeSessionCredential(challenge.echo(), opener.action) + + // 3. Retry the GET with the open credential -> 200 SSE; read deliveries. + var chunks = 0 + var total = 0L + client.newCall( + Request.Builder().url(streamUrl) + .header("Authorization", credential) + .header("Accept", "text/event-stream") + .get().build() + ).execute().use { resp -> + if (!resp.isSuccessful) error("stream open failed: HTTP ${resp.code}") + val reader = resp.body?.charStream()?.buffered() ?: error("stream had no body") + while (true) { + val line = reader.readLine() ?: break + if (!line.startsWith("data:")) continue + val payload = line.removePrefix("data:").trim() + if (payload == "[DONE]") break + val obj = runCatching { json.parseToJsonElement(payload).jsonObject }.getOrNull() ?: continue + chunks++ + (obj["cost"] as? JsonPrimitive)?.contentOrNull?.toLongOrNull()?.let { total += it } + } + } + + // 4. Reserve one aggregate delivery, then sign + commit the voucher. + var cumulative = "0" + if (total > 0) { + val directive = reserveDelivery(client, sideChannel(streamUrl, "/__402/session/deliveries"), channelId, total, streamUrl) + val consumer = SessionConsumer(opener.session, HttpCommitTransport(client, sideChannel(streamUrl, "/__402/session/commit"))) + cumulative = consumer.commitDirective(directive).cumulative + } + + // 5. Best-effort receipt poll for the settle signature. + val settle = runCatching { pollReceipt(client, sideChannel(streamUrl, "/sessions/receipt/$channelId")) }.getOrNull() + + return Result(channelId, chunks, total, cumulative, settle) + } + + private fun reserveDelivery( + client: OkHttpClient, + url: String, + channelId: String, + amount: Long, + commitUrl: String, + ): MeteringDirective { + val body = buildJsonObject { + put("amount", amount.toString()) + put("sessionId", channelId) + put("deliveryId", "mpp-${UUID.randomUUID()}") + put("commitUrl", commitUrl) + } + val req = Request.Builder().url(url) + .header("Accept", "application/json") + .post(json.encodeToString(JsonObject.serializer(), body).toRequestBody(JSON_MEDIA)) + .build() + return client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) error("delivery reservation failed: HTTP ${resp.code}") + json.decodeFromString(MeteringDirective.serializer(), resp.body!!.string()) + } + } + + private fun pollReceipt(client: OkHttpClient, url: String): String? { + repeat(8) { + Thread.sleep(1_500) + val obj = client.newCall(Request.Builder().url(url).get().build()).execute().use { resp -> + if (!resp.isSuccessful) return@use null + runCatching { json.parseToJsonElement(resp.body!!.string()).jsonObject }.getOrNull() + } ?: return@repeat + val finalized = (obj["finalized"] as? JsonPrimitive)?.contentOrNull == "true" + val sig = (obj["settledSignature"] as? JsonPrimitive)?.contentOrNull + if (finalized && !sig.isNullOrEmpty()) return sig + } + return null + } + + private fun sideChannel(streamUrl: String, path: String): String = + streamUrl.toHttpUrl().newBuilder().encodedPath(path).query(null).build().toString() + + private fun randomSeed(): ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) } +} + +/** Posts signed vouchers to `POST /__402/session/commit` and decodes the receipt. */ +private class HttpCommitTransport( + private val client: OkHttpClient, + private val commitUrl: String, +) : CommitTransport { + private val json = Json { encodeDefaults = false; explicitNulls = false; ignoreUnknownKeys = true } + private val media = "application/json".toMediaType() + + override fun commit(directive: MeteringDirective, payload: CommitPayload): CommitReceipt { + val req = Request.Builder().url(commitUrl) + .header("Accept", "application/json") + .post(json.encodeToString(CommitPayload.serializer(), payload).toRequestBody(media)) + .build() + return client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) error("voucher commit failed: HTTP ${resp.code} ${resp.body?.string().orEmpty()}") + json.decodeFromString(CommitReceipt.serializer(), resp.body!!.string()) + } + } +} diff --git a/kotlin/examples/AndroidDemo/docs/android-demo-screenshot.png b/kotlin/examples/AndroidDemo/docs/android-demo-screenshot.png index b1903e38a..e6173f37e 100644 Binary files a/kotlin/examples/AndroidDemo/docs/android-demo-screenshot.png and b/kotlin/examples/AndroidDemo/docs/android-demo-screenshot.png differ diff --git a/kotlin/examples/AndroidDemo/docs/e2e-1-launch.png b/kotlin/examples/AndroidDemo/docs/e2e-1-launch.png deleted file mode 100644 index 5d0e6400d..000000000 Binary files a/kotlin/examples/AndroidDemo/docs/e2e-1-launch.png and /dev/null differ diff --git a/kotlin/examples/AndroidDemo/docs/e2e-2-authorized.png b/kotlin/examples/AndroidDemo/docs/e2e-2-authorized.png deleted file mode 100644 index c11698055..000000000 Binary files a/kotlin/examples/AndroidDemo/docs/e2e-2-authorized.png and /dev/null differ diff --git a/kotlin/examples/AndroidDemo/docs/e2e-3-success.png b/kotlin/examples/AndroidDemo/docs/e2e-3-success.png deleted file mode 100644 index 92e3e44ea..000000000 Binary files a/kotlin/examples/AndroidDemo/docs/e2e-3-success.png and /dev/null differ diff --git a/kotlin/examples/AndroidDemo/docs/e2e-signature.txt b/kotlin/examples/AndroidDemo/docs/e2e-signature.txt deleted file mode 100644 index e0b73a4b0..000000000 --- a/kotlin/examples/AndroidDemo/docs/e2e-signature.txt +++ /dev/null @@ -1 +0,0 @@ -5anzHR9SygbcVK12t6gyvjkAUaM3qX97TDErSFbcUGNTBb99XuJyUJEsqsceqPMKyVxSpxso8vMQQ8vb1JNFN3n2 diff --git a/kotlin/src/main/kotlin/com/solana/paykit/paycore/PaymentChannels.kt b/kotlin/src/main/kotlin/com/solana/paykit/paycore/PaymentChannels.kt new file mode 100644 index 000000000..b0ff0dd6a --- /dev/null +++ b/kotlin/src/main/kotlin/com/solana/paykit/paycore/PaymentChannels.kt @@ -0,0 +1,216 @@ +package com.solana.paykit.paycore + +import java.io.ByteArrayOutputStream +import java.util.Base64 + +/** + * Client-side payment-channels primitives: PDA/ATA derivation, the 48-byte + * voucher preimage, and the `open` instruction + payer-signed (operator-fee- + * payer-unsigned) open transaction the session client broadcasts via the + * operator. + * + * Mirrors the client-facing subset of `solana_pay_core::payment_channels` + * (`rust/crates/core/src/payment_channels.rs`). The server-only primitives + * (ed25519 verify precompile, settle/finalize/distribute, the BLAKE3 + * distribution hash) are intentionally omitted: this SDK is client-only and the + * channel `open` passes its recipients inline rather than hashed. + */ +object PaymentChannels { + /** Canonical payment-channels program ID deployed to Surfnet. */ + const val PROGRAM_ID = "GuoKrzaBiZnW5DvJ3yZVE7xHqbcBvaX9SH6P6Cn9gNvc" + + /** Rent sysvar account. */ + const val RENT_SYSVAR_ID = "SysvarRent111111111111111111111111111111111" + + /** Default payment-channel close grace period, in seconds. */ + const val DEFAULT_GRACE_PERIOD_SECONDS: UInt = 900u + + private const val OPEN_DISCRIMINATOR: Byte = 1 + private val CHANNEL_SEED = "channel".encodeToByteArray() + private val EVENT_AUTHORITY_SEED = "event_authority".encodeToByteArray() + + /** A recipient split: `bps` basis points of the settled balance. */ + data class Distribution(val recipient: PublicKey, val bps: Int) { + init { + require(bps in 0..0xFFFF) { "bps must fit in u16 (got $bps)" } + } + } + + /** Inputs to the channel `open` instruction. */ + data class OpenChannelParams( + val payer: PublicKey, + val payee: PublicKey, + val mint: PublicKey, + val authorizedSigner: PublicKey, + val salt: ULong, + val deposit: ULong, + val gracePeriod: UInt, + val recipients: List, + val tokenProgram: PublicKey, + val programId: PublicKey, + ) + + /** Derived channel PDA + base64 (payer-signed, fee-payer-unsigned) open tx. */ + data class OpenTransaction(val channelId: PublicKey, val transaction: String) + + // ── Voucher preimage ── + + /** + * The 48-byte Ed25519 voucher preimage: + * `channelId(32) || cumulativeAmount(u64 LE) || expiresAt(i64 LE)`. + */ + fun voucherMessageBytes(channelId: String, cumulative: ULong, expiresAt: Long): ByteArray { + val channel = PublicKey.fromBase58(channelId) + val out = ByteArray(48) + System.arraycopy(channel.bytes, 0, out, 0, 32) + System.arraycopy(u64Le(cumulative), 0, out, 32, 8) + // i64 little-endian shares the two's-complement bit pattern of u64 LE. + System.arraycopy(u64Le(expiresAt.toULong()), 0, out, 40, 8) + return out + } + + // ── PDA derivation ── + + fun findChannelPda( + payer: PublicKey, + payee: PublicKey, + mint: PublicKey, + authorizedSigner: PublicKey, + salt: ULong, + programId: PublicKey, + ): PublicKey { + val seeds = listOf( + CHANNEL_SEED, + payer.bytes, + payee.bytes, + mint.bytes, + authorizedSigner.bytes, + u64Le(salt), + ) + return Pda.findProgramAddress(seeds, programId).first + } + + fun findEventAuthorityPda(programId: PublicKey): PublicKey = + Pda.findProgramAddress(listOf(EVENT_AUTHORITY_SEED), programId).first + + /** Random u64 channel salt so concurrent opens derive distinct channel PDAs. */ + fun uniqueSalt(): ULong { + val bytes = Ed25519.generateSeed() // 32 CSPRNG bytes; take the low 8. + var value = 0uL + for (i in 0..7) value = value or (bytes[i].toULong() and 0xffuL shl (8 * i)) + return value + } + + // ── Open instruction + transaction ── + + fun buildOpenInstruction(params: OpenChannelParams): Instruction { + val channel = findChannelPda( + params.payer, params.payee, params.mint, params.authorizedSigner, params.salt, params.programId + ) + val payerTokenAccount = Pda.associatedTokenAddress(params.payer, params.mint, params.tokenProgram) + val channelTokenAccount = Pda.associatedTokenAddress(channel, params.mint, params.tokenProgram) + val eventAuthority = findEventAuthorityPda(params.programId) + + // Account order matches the codama-generated `Open` builder exactly. + val accounts = listOf( + AccountMeta.writable(params.payer.toBase58(), signer = true), + AccountMeta.readOnly(params.payee.toBase58()), + AccountMeta.readOnly(params.mint.toBase58()), + AccountMeta.readOnly(params.authorizedSigner.toBase58()), + AccountMeta.writable(channel.toBase58()), + AccountMeta.writable(payerTokenAccount.toBase58()), + AccountMeta.writable(channelTokenAccount.toBase58()), + AccountMeta.readOnly(params.tokenProgram.toBase58()), + AccountMeta.readOnly(Programs.SYSTEM_PROGRAM), + AccountMeta.readOnly(RENT_SYSVAR_ID), + AccountMeta.readOnly(Programs.ASSOCIATED_TOKEN_PROGRAM), + AccountMeta.readOnly(eventAuthority.toBase58()), + AccountMeta.readOnly(params.programId.toBase58()), + ) + + // data = discriminator(1) || borsh(OpenArgs { salt, deposit, gracePeriod, recipients }). + val data = ByteArrayOutputStream() + data.write(byteArrayOf(OPEN_DISCRIMINATOR)) + data.write(u64Le(params.salt)) + data.write(u64Le(params.deposit)) + data.write(u32Le(params.gracePeriod)) + data.write(u32Le(params.recipients.size.toUInt())) + for (entry in params.recipients) { + data.write(entry.recipient.bytes) + data.write(u16Le(entry.bps)) + } + + return Instruction(programId = params.programId.toBase58(), accounts = accounts, data = data.toByteArray()) + } + + /** + * Build a payer-signed (fee-payer-unsigned) channel `open` transaction. The + * `payer` signs to authorize the deposit; the `feePayer` (operator) slot is + * left empty for the server to co-sign before broadcast. + */ + fun buildOpenTransaction( + payer: SolanaSigner, + payee: PublicKey, + mint: PublicKey, + authorizedSigner: PublicKey, + salt: ULong, + deposit: ULong, + gracePeriod: UInt, + recipients: List, + tokenProgram: PublicKey, + programId: PublicKey, + feePayer: PublicKey, + recentBlockhash: ByteArray, + ): OpenTransaction { + val payerPubkey = PublicKey(payer.publicKeyBytes) + val params = OpenChannelParams( + payer = payerPubkey, payee = payee, mint = mint, authorizedSigner = authorizedSigner, + salt = salt, deposit = deposit, gracePeriod = gracePeriod, recipients = recipients, + tokenProgram = tokenProgram, programId = programId, + ) + val channelId = findChannelPda(payerPubkey, payee, mint, authorizedSigner, salt, programId) + val instruction = buildOpenInstruction(params) + val message = Transaction.buildLegacyMessage(feePayer, recentBlockhash, listOf(instruction)) + val signature = payer.sign(message.serialize()) + require(signature.size == 64) { "open signature must be 64 bytes (got ${signature.size})" } + val signerIndex = message.accountKeys.indexOfFirst { it.bytes.contentEquals(payerPubkey.bytes) } + if (signerIndex < 0) { + throw MppException.InvalidTransaction("payer is not in the open transaction account list") + } + val signatures = MutableList(message.header.numRequiredSignatures) { null } + // The payer must land in the signer prefix of the account list; guard the + // index so a non-signer slot throws instead of going out of bounds. + if (signerIndex >= signatures.size) { + throw MppException.InvalidTransaction("payer signer index $signerIndex is outside the required-signer range") + } + signatures[signerIndex] = signature + val txBytes = Transaction.serializeLegacyTransaction(message, signatures) + return OpenTransaction(channelId = channelId, transaction = Base64.getEncoder().encodeToString(txBytes)) + } + + // ── Little-endian helpers ── + + private fun u64Le(value: ULong): ByteArray { + val out = ByteArray(8) + var shift = 0 + for (i in 0..7) { + out[i] = ((value shr shift) and 0xffuL).toByte() + shift += 8 + } + return out + } + + private fun u32Le(value: UInt): ByteArray { + val out = ByteArray(4) + out[0] = (value and 0xffu).toByte() + out[1] = ((value shr 8) and 0xffu).toByte() + out[2] = ((value shr 16) and 0xffu).toByte() + out[3] = ((value shr 24) and 0xffu).toByte() + return out + } + + private fun u16Le(value: Int): ByteArray { + val v = value.toUInt() + return byteArrayOf((v and 0xffu).toByte(), ((v shr 8) and 0xffu).toByte()) + } +} diff --git a/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/client/SessionClient.kt b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/client/SessionClient.kt new file mode 100644 index 000000000..4a9ef8002 --- /dev/null +++ b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/client/SessionClient.kt @@ -0,0 +1,358 @@ +package com.solana.paykit.protocols.mpp.client + +import com.solana.paykit.paycore.Base58 +import com.solana.paykit.paycore.Base64Url +import com.solana.paykit.paycore.MppException +import com.solana.paykit.paycore.PaymentChannels +import com.solana.paykit.paycore.PublicKey +import com.solana.paykit.paycore.SolanaSigner +import com.solana.paykit.paycore.defaultTokenProgramForCurrency +import com.solana.paykit.paycore.resolveStablecoinMint +import com.solana.paykit.protocols.mpp.core.CanonicalJson +import com.solana.paykit.protocols.mpp.core.ChallengeEcho +import com.solana.paykit.protocols.mpp.core.ClosePayload +import com.solana.paykit.protocols.mpp.core.DEFAULT_SESSION_EXPIRES_AT +import com.solana.paykit.protocols.mpp.core.MppHeaders +import com.solana.paykit.protocols.mpp.core.OpenPayload +import com.solana.paykit.protocols.mpp.core.PaymentChallenge +import com.solana.paykit.protocols.mpp.core.SessionAction +import com.solana.paykit.protocols.mpp.core.SessionActionCodec +import com.solana.paykit.protocols.mpp.core.SessionMode +import com.solana.paykit.protocols.mpp.core.SessionPullVoucherStrategy +import com.solana.paykit.protocols.mpp.core.SessionRequest +import com.solana.paykit.protocols.mpp.core.SignedVoucher +import com.solana.paykit.protocols.mpp.core.TopUpPayload +import com.solana.paykit.protocols.mpp.core.VoucherData +import com.solana.paykit.protocols.mpp.core.VoucherPayload +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement + +/** + * A live metered session bound to one payment channel. + * + * Holds the cumulative watermark, request nonce, and voucher expiry, and signs + * monotonically-increasing vouchers with the session signer. Mirrors the Go + * reference `ActiveSession` including the `ReconcileSettled` lost-response + * clamp. Sessions are single-threaded (not thread-safe), matching Rust `&mut`. + */ +class ActiveSession( + val channelId: String, + private val signer: SolanaSigner, + cumulative: ULong = 0uL, + expiresAt: Long = DEFAULT_SESSION_EXPIRES_AT, +) { + var cumulative: ULong = cumulative + private set + var nonce: ULong = 0uL + private set + var expiresAt: Long = expiresAt + private set + + fun setExpiresAt(value: Long) { + expiresAt = value + } + + /** base58 of the session signer's public key (the on-chain authorized signer). */ + fun authorizedSigner(): String = signer.address + + fun channelIdString(): String = channelId + + // ── Voucher signing ── + + /** Sign a voucher at an absolute cumulative without advancing the watermark. */ + fun prepareVoucher(cumulative: ULong): SignedVoucher { + if (cumulative <= this.cumulative) { + throw MppException.InvalidTransaction( + "voucher cumulative $cumulative must exceed current watermark ${this.cumulative}" + ) + } + val data = VoucherData( + channelId = channelId, + cumulative = cumulative.toString(), + expiresAt = expiresAt, + nonce = (nonce + 1uL).toLong(), + ) + val signature = signer.sign(data.messageBytes()) + return SignedVoucher(data, Base58.encode(signature)) + } + + fun prepareIncrement(amount: ULong): SignedVoucher = prepareVoucher(addToWatermark(amount)) + + /** + * Advance the watermark to a recorded voucher: rejects a voucher bound to a + * different channel, a non-increasing cumulative, or an unparseable + * cumulative; advances the nonce to at least `nonce + 1` (or the voucher's + * nonce when higher). Mirrors Go `RecordVoucher`. + */ + fun recordVoucher(voucher: SignedVoucher) { + if (voucher.data.channelId != channelIdString()) { + throw MppException.InvalidTransaction( + "voucher channel ${voucher.data.channelId} does not match active session ${channelIdString()}" + ) + } + val cumulative = voucher.data.cumulative.toULongOrNull() + ?: throw MppException.InvalidTransaction("invalid voucher cumulative") + if (cumulative <= this.cumulative) { + throw MppException.InvalidTransaction( + "voucher cumulative $cumulative must exceed current watermark ${this.cumulative}" + ) + } + this.cumulative = cumulative + var candidate = this.nonce + 1uL + val voucherNonce = voucher.data.nonce?.toULong() + if (voucherNonce != null && voucherNonce > candidate) candidate = voucherNonce + this.nonce = candidate + } + + /** + * Reconcile the watermark to a server-settled cumulative (e.g. a replayed + * commit receipt): advance only when ahead, never regress, bump the nonce on + * advance. Mirrors Go `ReconcileSettled` (the #162 lost-response fix). + */ + fun reconcileSettled(settled: ULong) { + if (settled > cumulative) { + cumulative = settled + nonce += 1uL + } + } + + fun signVoucher(cumulative: ULong): SignedVoucher { + val voucher = prepareVoucher(cumulative) + recordVoucher(voucher) + return voucher + } + + fun signIncrement(amount: ULong): SignedVoucher = signVoucher(addToWatermark(amount)) + + // ── Action builders ── + + fun voucherAction(amount: ULong): SessionAction = SessionAction.Voucher(VoucherPayload(signIncrement(amount))) + + /** Cooperative close; a `finalIncrement` > 0 signs one last voucher first. */ + fun closeAction(finalIncrement: ULong?): SessionAction { + val voucher = if (finalIncrement != null && finalIncrement > 0uL) signIncrement(finalIncrement) else null + return SessionAction.Close(ClosePayload(channelId, voucher)) + } + + fun openAction(deposit: ULong, openTxSignature: String): SessionAction = + SessionAction.Open(OpenPayload.push(channelId, deposit.toString(), authorizedSigner(), openTxSignature)) + + fun openPaymentChannelAction( + mode: SessionMode = SessionMode.PUSH, + deposit: ULong, + payer: String, + payee: String, + mint: String, + salt: ULong, + gracePeriod: UInt, + signature: String, + ): SessionAction = SessionAction.Open( + OpenPayload.paymentChannel( + mode, channelId, deposit.toString(), payer, payee, mint, salt, gracePeriod, authorizedSigner(), signature + ) + ) + + fun openPullAction(approvedAmount: ULong, owner: String, approveTxSignature: String): SessionAction = + SessionAction.Open(OpenPayload.pull(channelId, approvedAmount.toString(), owner, authorizedSigner(), approveTxSignature)) + + fun topupAction(newDeposit: ULong, topupTxSignature: String): SessionAction = + SessionAction.TopUp(TopUpPayload(channelId, newDeposit.toString(), topupTxSignature)) + + private fun addToWatermark(amount: ULong): ULong { + val sum = cumulative + amount + if (sum < cumulative) { + throw MppException.InvalidTransaction("voucher cumulative overflow adding $amount to $cumulative") + } + return sum + } +} + +// ── Payment-channel session opener ── + +/** Placeholder operator signature; the server fills its fee-payer slot before broadcast. */ +const val PENDING_SERVER_SIGNATURE: String = + "1111111111111111111111111111111111111111111111111111111111111111" + +/** Derived channel parameters for an open. */ +data class PaymentChannelOpen( + val channelId: PublicKey, + val payer: PublicKey, + val payee: PublicKey, + val mint: PublicKey, + val authorizedSigner: PublicKey, + val salt: ULong, + val deposit: ULong, + val gracePeriod: UInt, + val recipients: List, + val tokenProgram: PublicKey, + val programId: PublicKey, +) { + fun openPayload(mode: SessionMode, signature: String): OpenPayload = + OpenPayload.paymentChannel( + mode = mode, + channelId = channelId.toBase58(), + deposit = deposit.toString(), + payer = payer.toBase58(), + payee = payee.toBase58(), + mint = mint.toBase58(), + salt = salt, + gracePeriod = gracePeriod, + authorizedSigner = authorizedSigner.toBase58(), + signature = signature, + ) +} + +/** Per-channel open overrides; unset fields fall back to challenge-derived defaults. */ +data class PaymentChannelOpenOptions( + val deposit: ULong? = null, + val gracePeriod: UInt? = null, + val programId: PublicKey? = null, + val recipients: List? = null, + val salt: ULong? = null, + val tokenProgram: PublicKey? = null, +) + +data class PaymentChannelSessionOpenOptions( + val open: PaymentChannelOpenOptions = PaymentChannelOpenOptions(), + val signature: String? = null, + val cumulative: ULong? = null, + val expiresAt: Long? = null, +) + +/** Result of opening a payment-channel session client-side. */ +data class PaymentChannelSessionOpen( + val open: PaymentChannelOpen, + val session: ActiveSession, + val action: SessionAction, +) + +object PaymentChannelSession { + private val json = Json { + encodeDefaults = false + explicitNulls = false + ignoreUnknownKeys = true + } + + /** + * Build a pull + clientVoucher payment-channel session open. The payer + * partial-signs the open transaction; the operator (fee payer) co-signs and + * broadcasts. `recentBlockhash` is base58. Mirrors + * `create_payment_channel_session_opener`. + */ + fun open( + request: SessionRequest, + payerSigner: SolanaSigner, + sessionSigner: SolanaSigner, + recentBlockhash: String, + options: PaymentChannelSessionOpenOptions = PaymentChannelSessionOpenOptions(), + ): PaymentChannelSessionOpen { + ensureClientVoucherPull(request) + val authorizedSigner = PublicKey(sessionSigner.publicKeyBytes) + val feePayer = PublicKey.fromBase58(request.operator) + val payer = PublicKey(payerSigner.publicKeyBytes) + val open = deriveOpen(request, payer, authorizedSigner, options.open) + + val blockhash = Base58.decode(recentBlockhash) + if (blockhash.size != 32) { + throw MppException.InvalidTransaction("recentBlockhash must decode to 32 bytes") + } + val tx = PaymentChannels.buildOpenTransaction( + payer = payerSigner, + payee = open.payee, + mint = open.mint, + authorizedSigner = open.authorizedSigner, + salt = open.salt, + deposit = open.deposit, + gracePeriod = open.gracePeriod, + recipients = open.recipients, + tokenProgram = open.tokenProgram, + programId = open.programId, + feePayer = feePayer, + recentBlockhash = blockhash, + ) + + val session = ActiveSession( + channelId = open.channelId.toBase58(), + signer = sessionSigner, + cumulative = options.cumulative ?: 0uL, + expiresAt = options.expiresAt ?: DEFAULT_SESSION_EXPIRES_AT, + ) + val signature = options.signature ?: PENDING_SERVER_SIGNATURE + val action = SessionAction.Open(open.openPayload(SessionMode.PULL, signature).withTransaction(tx.transaction)) + return PaymentChannelSessionOpen(open, session, action) + } + + private fun ensureClientVoucherPull(request: SessionRequest) { + if (!request.modes.contains(SessionMode.PULL)) { + throw MppException.InvalidTransaction("session challenge does not advertise pull mode") + } + if (request.pullVoucherStrategy != SessionPullVoucherStrategy.CLIENT_VOUCHER) { + throw MppException.InvalidTransaction("session challenge does not advertise pull + clientVoucher") + } + } + + private fun deriveOpen( + request: SessionRequest, + payer: PublicKey, + authorizedSigner: PublicKey, + options: PaymentChannelOpenOptions, + ): PaymentChannelOpen { + val mintString = resolveStablecoinMint(request.currency, request.network) + ?: throw MppException.InvalidTransaction("session payment channels require an SPL token") + val mint = PublicKey.fromBase58(mintString) + val payee = PublicKey.fromBase58(request.recipient) + val deposit = options.deposit + ?: request.cap.toULongOrNull() + ?: throw MppException.InvalidTransaction("invalid session cap: ${request.cap}") + val gracePeriod = options.gracePeriod ?: PaymentChannels.DEFAULT_GRACE_PERIOD_SECONDS + val programId = options.programId + ?: request.programId?.let { PublicKey.fromBase58(it) } + ?: PublicKey.fromBase58(PaymentChannels.PROGRAM_ID) + val tokenProgram = options.tokenProgram + ?: PublicKey.fromBase58(defaultTokenProgramForCurrency(request.currency, request.network)) + val recipients = options.recipients + ?: request.splits.map { PaymentChannels.Distribution(PublicKey.fromBase58(it.recipient), it.bps) } + val salt = options.salt ?: PaymentChannels.uniqueSalt() + val channelId = PaymentChannels.findChannelPda(payer, payee, mint, authorizedSigner, salt, programId) + return PaymentChannelOpen( + channelId = channelId, payer = payer, payee = payee, mint = mint, authorizedSigner = authorizedSigner, + salt = salt, deposit = deposit, gracePeriod = gracePeriod, recipients = recipients, + tokenProgram = tokenProgram, programId = programId, + ) + } + + /** + * Build an `Authorization: Payment ` value for a + * session action, echoing the challenge. Mirrors Go `SerializeSessionCredential`. + */ + fun serializeSessionCredential(challenge: ChallengeEcho, action: SessionAction): String { + val tree = buildJsonObject { + put("challenge", json.encodeToJsonElement(ChallengeEcho.serializer(), challenge)) + put("payload", SessionActionCodec.toJsonObject(action)) + } + val canonical = CanonicalJson.encode(tree) + return "${MppHeaders.PAYMENT_SCHEME} ${Base64Url.encode(canonical.encodeToByteArray())}" + } + + /** Decode the base64url-encoded session request carried by a challenge. */ + fun sessionRequest(challenge: PaymentChallenge): SessionRequest { + if (challenge.request.length > MppHeaders.MAX_TOKEN_LEN) { + throw MppException.InvalidHeader + } + return try { + json.decodeFromString(SessionRequest.serializer(), Base64Url.decode(challenge.request).decodeToString()) + } catch (error: IllegalArgumentException) { + throw MppException.InvalidJson(error) + } catch (error: kotlinx.serialization.SerializationException) { + throw MppException.InvalidJson(error) + } + } + + /** Require a `solana`/`session` challenge before opening a session. */ + fun requireSolanaSession(challenge: PaymentChallenge) { + if (challenge.method != "solana" || challenge.intent != "session") { + throw MppException.UnsupportedChallenge(challenge.method, challenge.intent) + } + } +} diff --git a/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/client/SessionConsumer.kt b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/client/SessionConsumer.kt new file mode 100644 index 000000000..aa654e57c --- /dev/null +++ b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/client/SessionConsumer.kt @@ -0,0 +1,83 @@ +package com.solana.paykit.protocols.mpp.client + +import com.solana.paykit.paycore.MppException +import com.solana.paykit.protocols.mpp.core.CommitPayload +import com.solana.paykit.protocols.mpp.core.CommitReceipt +import com.solana.paykit.protocols.mpp.core.CommitStatus +import com.solana.paykit.protocols.mpp.core.MeteredEnvelope +import com.solana.paykit.protocols.mpp.core.MeteringDirective + +/** Transport that commits a signed voucher to the session server and returns a receipt. */ +fun interface CommitTransport { + fun commit(directive: MeteringDirective, payload: CommitPayload): CommitReceipt +} + +/** + * Client side of metered delivery: signs a voucher per directive, commits it + * through the transport, and advances the local watermark only on success. + * Mirrors Go `SessionConsumer`. + */ +class SessionConsumer(val session: ActiveSession, val transport: CommitTransport) { + /** Validate the directive against the active session and wrap it for ack. */ + fun

accept(envelope: MeteredEnvelope

): MeteredDelivery

{ + validateDirective(envelope.metering) + return MeteredDelivery(this, envelope.payload, envelope.metering) + } + + /** + * Sign a voucher for the directive amount, commit it, and advance the + * watermark. Rejects a mismatched session, a non-integer amount, or a zero + * amount before committing. On a committed receipt the prepared voucher is + * recorded; on a replayed receipt the watermark reconciles to the settled + * cumulative, clamped to the just-prepared voucher (server untrusted) and + * never regressing. + */ + fun commitDirective(directive: MeteringDirective): CommitReceipt { + validateDirective(directive) + val amount = directive.amountBaseUnits() + if (amount == 0uL) { + throw MppException.InvalidTransaction("metered delivery amount must be greater than zero") + } + + val voucher = session.prepareIncrement(amount) + val payload = CommitPayload(directive.deliveryId, voucher) + val receipt = transport.commit(directive, payload) + + when (receipt.status) { + CommitStatus.REPLAYED -> { + val settled = receipt.cumulative.toULongOrNull() + ?: throw MppException.InvalidTransaction("invalid replayed receipt cumulative: ${receipt.cumulative}") + val prepared = voucher.data.cumulative.toULongOrNull() + ?: throw MppException.InvalidTransaction("invalid prepared voucher cumulative: ${voucher.data.cumulative}") + session.reconcileSettled(minOf(settled, prepared)) + } + CommitStatus.COMMITTED -> session.recordVoucher(voucher) + } + return receipt + } + + private fun validateDirective(directive: MeteringDirective) { + val channelId = session.channelIdString() + if (directive.sessionId != channelId) { + throw MppException.InvalidTransaction( + "metered delivery session ${directive.sessionId} does not match active session $channelId" + ) + } + } +} + +/** + * A validated metered delivery awaiting acknowledgement. `ack`/`commit` sign and + * commit the voucher; `intoParts` releases the payload without committing. + */ +class MeteredDelivery

( + private val consumer: SessionConsumer, + val payload: P, + val metering: MeteringDirective, +) { + fun ack(): CommitReceipt = consumer.commitDirective(metering) + + fun commit(): CommitReceipt = ack() + + fun intoParts(): Pair = payload to metering +} diff --git a/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/SessionActionCodec.kt b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/SessionActionCodec.kt new file mode 100644 index 000000000..b47741752 --- /dev/null +++ b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/SessionActionCodec.kt @@ -0,0 +1,59 @@ +package com.solana.paykit.protocols.mpp.core + +import com.solana.paykit.paycore.MppException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject + +/** + * Codec for the internally-tagged [SessionAction] union: encodes the payload to + * a JSON object and injects the `action` discriminator alongside the payload's + * own fields (flattened), and decodes by reading `action` then the remaining + * fields. Mirrors the Rust serde internally-tagged enum and the Go SessionAction + * JSON. The TopUp tag is camelCase `topUp`. + */ +object SessionActionCodec { + private val json = Json { + encodeDefaults = false + explicitNulls = false + ignoreUnknownKeys = true + } + + fun toJsonObject(action: SessionAction): JsonObject { + val (tag, element) = when (action) { + is SessionAction.Open -> "open" to json.encodeToJsonElement(action.payload) + is SessionAction.Voucher -> "voucher" to json.encodeToJsonElement(action.payload) + is SessionAction.Commit -> "commit" to json.encodeToJsonElement(action.payload) + is SessionAction.TopUp -> "topUp" to json.encodeToJsonElement(action.payload) + is SessionAction.Close -> "close" to json.encodeToJsonElement(action.payload) + } + return buildJsonObject { + put("action", JsonPrimitive(tag)) + for ((key, value) in element.jsonObject) put(key, value) + } + } + + fun fromJsonObject(obj: JsonObject): SessionAction { + val tag = (obj["action"] as? JsonPrimitive)?.content + ?: throw MppException.InvalidJson() + val rest = JsonObject(obj.filterKeys { it != "action" }) + return when (tag) { + "open" -> SessionAction.Open(json.decodeFromJsonElement(OpenPayload.serializer(), rest)) + "voucher" -> SessionAction.Voucher(json.decodeFromJsonElement(VoucherPayload.serializer(), rest)) + "commit" -> SessionAction.Commit(json.decodeFromJsonElement(CommitPayload.serializer(), rest)) + "topUp" -> SessionAction.TopUp(json.decodeFromJsonElement(TopUpPayload.serializer(), rest)) + "close" -> SessionAction.Close(json.decodeFromJsonElement(ClosePayload.serializer(), rest)) + else -> throw MppException.InvalidTransaction("unknown session action: $tag") + } + } + + fun encodeToString(action: SessionAction): String = + json.encodeToString(JsonObject.serializer(), toJsonObject(action)) + + fun decodeFromString(text: String): SessionAction = + fromJsonObject(json.parseToJsonElement(text).jsonObject) +} diff --git a/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/SessionTypes.kt b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/SessionTypes.kt new file mode 100644 index 000000000..5f29a75c4 --- /dev/null +++ b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/SessionTypes.kt @@ -0,0 +1,230 @@ +package com.solana.paykit.protocols.mpp.core + +import com.solana.paykit.paycore.MppException +import com.solana.paykit.paycore.PaymentChannels +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.nullable +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNames +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +/** + * MPP payment-channel session wire types, mirroring the Rust spine + * (`rust/crates/mpp/src/protocol/intents/session.rs`) and the Go reference + * tag-for-tag and key-for-key. JSON keys are camelCase; `salt` serializes as a + * decimal string (reads string-or-number); `VoucherData.cumulativeAmount` also + * reads the legacy `cumulative` alias; `SessionAction` is an internally-tagged + * union flattened onto the `action` key (handled by [SessionActionCodec]). + */ + +/** Default voucher/session expiry: 2100-01-01T00:00:00Z (under JS max-safe-int). */ +const val DEFAULT_SESSION_EXPIRES_AT: Long = 4_102_444_800L + +@Serializable +enum class SessionMode { + @SerialName("push") PUSH, + @SerialName("pull") PULL, +} + +@Serializable +enum class SessionPullVoucherStrategy { + @SerialName("clientVoucher") CLIENT_VOUCHER, + @SerialName("operatedVoucher") OPERATED_VOUCHER, +} + +@Serializable +enum class CommitStatus { + @SerialName("committed") COMMITTED, + @SerialName("replayed") REPLAYED, +} + +/** Serializes a nullable u64 salt as a decimal string; reads string or number. */ +object SaltStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Salt", PrimitiveKind.STRING).nullable + + override fun serialize(encoder: Encoder, value: ULong?) { + if (value == null) encoder.encodeNull() else encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): ULong? { + val input = decoder as? JsonDecoder + ?: return decoder.decodeString().toULongOrNull() + ?: throw MppException.InvalidJson() + return when (val element = input.decodeJsonElement()) { + is JsonNull -> null + is JsonPrimitive -> + // Read the raw content as ULong for both the string and number + // forms: a u64 salt above Long.MAX_VALUE (about half the range) + // would be lost via longOrNull when sent as a JSON number. + element.content.toULongOrNull() ?: throw MppException.InvalidJson() + else -> throw MppException.InvalidJson() + } + } +} + +@Serializable +data class SessionSplit(val recipient: String, val bps: Int) + +@Serializable +data class SessionRequest( + val cap: String, + val currency: String, + val decimals: Int? = null, + val network: String? = null, + val operator: String, + val recipient: String, + val splits: List = emptyList(), + val programId: String? = null, + val description: String? = null, + val externalId: String? = null, + val minVoucherDelta: String? = null, + val modes: List = emptyList(), + val pullVoucherStrategy: SessionPullVoucherStrategy? = null, + val recentBlockhash: String? = null, +) + +@Serializable +data class OpenPayload( + val mode: SessionMode, + val channelId: String? = null, + val deposit: String? = null, + val payer: String? = null, + val payee: String? = null, + val mint: String? = null, + @Serializable(with = SaltStringSerializer::class) val salt: ULong? = null, + val gracePeriod: Long? = null, + val transaction: String? = null, + val tokenAccount: String? = null, + val approvedAmount: String? = null, + val owner: String? = null, + val initMultiDelegateTx: String? = null, + val updateDelegationTx: String? = null, + val authorizedSigner: String, + val signature: String, +) { + companion object { + fun paymentChannel( + mode: SessionMode, + channelId: String, + deposit: String, + payer: String, + payee: String, + mint: String, + salt: ULong, + gracePeriod: UInt, + authorizedSigner: String, + signature: String, + ): OpenPayload = OpenPayload( + mode = mode, channelId = channelId, deposit = deposit, payer = payer, payee = payee, + mint = mint, salt = salt, gracePeriod = gracePeriod.toLong(), + authorizedSigner = authorizedSigner, signature = signature, + ) + + fun push(channelId: String, deposit: String, authorizedSigner: String, signature: String): OpenPayload = + OpenPayload( + mode = SessionMode.PUSH, channelId = channelId, deposit = deposit, + authorizedSigner = authorizedSigner, signature = signature, + ) + + fun pull( + tokenAccount: String, + approvedAmount: String, + owner: String, + authorizedSigner: String, + signature: String, + ): OpenPayload = OpenPayload( + mode = SessionMode.PULL, tokenAccount = tokenAccount, approvedAmount = approvedAmount, + owner = owner, authorizedSigner = authorizedSigner, signature = signature, + ) + } + + /** Attach the server/operator-broadcast open transaction (base64). */ + fun withTransaction(transaction: String): OpenPayload = copy(transaction = transaction) +} + +@Serializable +data class VoucherData( + val channelId: String, + @SerialName("cumulativeAmount") @JsonNames("cumulative") val cumulative: String, + val expiresAt: Long, + val nonce: Long? = null, +) { + /** The 48-byte Ed25519 preimage for this voucher. */ + fun messageBytes(): ByteArray { + val amount = cumulative.toULongOrNull() + ?: throw MppException.InvalidTransaction("invalid voucher cumulative: $cumulative") + return PaymentChannels.voucherMessageBytes(channelId, amount, expiresAt) + } +} + +@Serializable +data class SignedVoucher(val data: VoucherData, val signature: String) + +@Serializable +data class VoucherPayload(val voucher: SignedVoucher) + +@Serializable +data class CommitPayload(val deliveryId: String, val voucher: SignedVoucher) + +@Serializable +data class TopUpPayload(val channelId: String, val newDeposit: String, val signature: String) + +@Serializable +data class ClosePayload(val channelId: String, val voucher: SignedVoucher? = null) + +@Serializable +data class MeteringDirective( + val deliveryId: String, + val sessionId: String, + val amount: String, + val currency: String, + val sequence: Long, + val expiresAt: Long, + val commitUrl: String? = null, + val proof: String? = null, +) { + /** Parse `amount` as base units. */ + fun amountBaseUnits(): ULong = + amount.toULongOrNull() ?: throw MppException.InvalidTransaction("invalid metering amount: $amount") +} + +@Serializable +data class MeteringUsage(val deliveryId: String, val amount: String) { + fun amountBaseUnits(): ULong = + amount.toULongOrNull() ?: throw MppException.InvalidTransaction("invalid metering usage amount: $amount") +} + +@Serializable +data class CommitReceipt( + val deliveryId: String, + val sessionId: String, + val amount: String, + val cumulative: String, + val status: CommitStatus, +) + +data class MeteredEnvelope

(val payload: P, val metering: MeteringDirective) + +/** + * Internally-tagged session action union. The discriminator lives on the + * `action` key and the payload fields are flattened alongside it. The TopUp tag + * is camelCase `topUp`. Encode/decode via [SessionActionCodec]. + */ +sealed interface SessionAction { + data class Open(val payload: OpenPayload) : SessionAction + data class Voucher(val payload: VoucherPayload) : SessionAction + data class Commit(val payload: CommitPayload) : SessionAction + data class TopUp(val payload: TopUpPayload) : SessionAction + data class Close(val payload: ClosePayload) : SessionAction +} diff --git a/kotlin/src/test/kotlin/com/solana/paykit/paycore/SessionVoucherTest.kt b/kotlin/src/test/kotlin/com/solana/paykit/paycore/SessionVoucherTest.kt new file mode 100644 index 000000000..601b4524a --- /dev/null +++ b/kotlin/src/test/kotlin/com/solana/paykit/paycore/SessionVoucherTest.kt @@ -0,0 +1,70 @@ +package com.solana.paykit.paycore + +import com.solana.paykit.protocols.mpp.client.ActiveSession +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Byte-exact golden vectors for the payment-channel voucher preimage and the + * channel PDA. The preimage bytes are pinned against the frozen cross-SDK + * conformance vector (`harness/vectors/session-voucher.json`), since Kotlin has + * no conformance runner of its own. + */ +class SessionVoucherTest { + // channelId base58 of 32 bytes of 0x09, matching the frozen vector. + private val frozenChannel = "cGfHiC6Kgg3FpFZvgwGcswsCRtp4aBP2fzuXRQPizuN" + + @Test + fun voucherPreimageMatchesFrozenVector() { + val bytes = PaymentChannels.voucherMessageBytes(frozenChannel, 42uL, 1234L) + val expected = ByteArray(48) + for (i in 0..31) expected[i] = 9 + expected[32] = 42 // cumulative 42 LE u64 + expected[40] = 210.toByte() // 1234 = 0x04D2 LE i64 + expected[41] = 4 + assertTrue(bytes.contentEquals(expected)) + } + + @Test + fun voucherPreimageNearMaxCumulativeHasNoPrecisionLoss() { + // 18446744073709551607 = u64::MAX - 8. + val bytes = PaymentChannels.voucherMessageBytes(frozenChannel, 18446744073709551607uL, 4102444800L) + assertEquals(247, bytes[32].toInt() and 0xff) + for (i in 33..39) assertEquals(255, bytes[i].toInt() and 0xff) + // expiresAt 4102444800 = 0xF4865700 LE. + assertEquals(0, bytes[40].toInt() and 0xff) + assertEquals(87, bytes[41].toInt() and 0xff) + assertEquals(134, bytes[42].toInt() and 0xff) + assertEquals(244, bytes[43].toInt() and 0xff) + } + + @Test + fun channelPdaIsDeterministicAndSaltSensitive() { + val payer = PublicKey(ByteArray(32) { 1 }) + val payee = PublicKey(ByteArray(32) { 2 }) + val mint = PublicKey(ByteArray(32) { 3 }) + val signer = PublicKey(ByteArray(32) { 4 }) + val programId = PublicKey.fromBase58(PaymentChannels.PROGRAM_ID) + + val a = PaymentChannels.findChannelPda(payer, payee, mint, signer, 99uL, programId) + val b = PaymentChannels.findChannelPda(payer, payee, mint, signer, 99uL, programId) + val other = PaymentChannels.findChannelPda(payer, payee, mint, signer, 100uL, programId) + + assertEquals(a, b) + assertNotEquals(a, other) + } + + @Test + fun voucherSignatureVerifiesAgainstAuthorizedSigner() { + val signer = MemorySigner.fromSeed(ByteArray(32) { 42 }) + val channel = PublicKey(ByteArray(32) { 7 }).toBase58() + val session = ActiveSession(channel, signer) + + val voucher = session.signIncrement(100uL) + assertTrue(Ed25519.verify(signer.publicKeyBytes, voucher.data.messageBytes(), Base58.decode(voucher.signature))) + assertEquals(channel, voucher.data.channelId) + assertEquals("100", voucher.data.cumulative) + } +} diff --git a/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/ActiveSessionTest.kt b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/ActiveSessionTest.kt new file mode 100644 index 000000000..026813380 --- /dev/null +++ b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/ActiveSessionTest.kt @@ -0,0 +1,132 @@ +package com.solana.paykit.protocols.mpp.client + +import com.solana.paykit.paycore.MemorySigner +import com.solana.paykit.paycore.MppException +import com.solana.paykit.paycore.PublicKey +import com.solana.paykit.protocols.mpp.core.DEFAULT_SESSION_EXPIRES_AT +import com.solana.paykit.protocols.mpp.core.SessionAction +import com.solana.paykit.protocols.mpp.core.SessionMode +import com.solana.paykit.protocols.mpp.core.SignedVoucher +import com.solana.paykit.protocols.mpp.core.VoucherData +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class ActiveSessionTest { + private fun session(seed: Byte = 42, channel: Byte = 7): ActiveSession { + val signer = MemorySigner.fromSeed(ByteArray(32) { seed }) + return ActiveSession(PublicKey(ByteArray(32) { channel }).toBase58(), signer) + } + + @Test + fun prepareDoesNotAdvanceButRecordDoes() { + val session = session() + val prepared = session.prepareIncrement(75uL) + assertEquals("75", prepared.data.cumulative) + assertEquals(1L, prepared.data.nonce) + assertEquals(0uL, session.cumulative) + + session.recordVoucher(prepared) + assertEquals(75uL, session.cumulative) + assertFailsWith { session.recordVoucher(prepared) } + } + + @Test + fun signIncrementAdvancesWatermarkAndNonce() { + val session = session() + val first = session.signIncrement(100uL) + assertEquals("100", first.data.cumulative) + assertEquals(1L, first.data.nonce) + assertEquals(100uL, session.cumulative) + + val second = session.signIncrement(10uL) + assertEquals("110", second.data.cumulative) + assertEquals(2L, second.data.nonce) + assertEquals(110uL, session.cumulative) + } + + @Test + fun signVoucherRejectsNonIncreasingAndZero() { + val session = session() + session.signIncrement(100uL) + assertFailsWith { session.signVoucher(100uL) } + assertFailsWith { session.signVoucher(50uL) } + assertFailsWith { session(seed = 9, channel = 8).signVoucher(0uL) } + } + + @Test + fun recordVoucherRejectsInvalidCumulativeAndDefaultsNonce() { + val session = session() + val bad = SignedVoucher(VoucherData(session.channelIdString(), "not-a-number", DEFAULT_SESSION_EXPIRES_AT), "sig") + assertFailsWith { session.recordVoucher(bad) } + + val noNonce = SignedVoucher(VoucherData(session.channelIdString(), "15", DEFAULT_SESSION_EXPIRES_AT, null), "sig") + session.recordVoucher(noNonce) + assertEquals(15uL, session.cumulative) + assertEquals(1uL, session.nonce) + } + + @Test + fun recordVoucherRejectsForeignChannel() { + val session = session() + val foreign = SignedVoucher(VoucherData("11111111111111111111111111111112", "10", DEFAULT_SESSION_EXPIRES_AT), "sig") + assertFailsWith { session.recordVoucher(foreign) } + assertEquals(0uL, session.cumulative) + } + + @Test + fun reconcileSettledAdvancesAndNeverRegresses() { + val session = session() + session.reconcileSettled(300uL) + assertEquals(300uL, session.cumulative) + session.reconcileSettled(100uL) + assertEquals(300uL, session.cumulative) + } + + @Test + fun expiresAtControlsVoucherExpiry() { + val session = session() + session.setExpiresAt(1234L) + assertEquals(1234L, session.prepareIncrement(10uL).data.expiresAt) + session.setExpiresAt(5678L) + assertEquals(5678L, session.prepareIncrement(10uL).data.expiresAt) + } + + @Test + fun closeActionVoucherFollowsFinalIncrement() { + val session = session() + val emptyClose = session.closeAction(null) as SessionAction.Close + assertNull(emptyClose.payload.voucher) + + session.signIncrement(100uL) + val close = session.closeAction(50uL) as SessionAction.Close + assertEquals("150", close.payload.voucher?.data?.cumulative) + + val zeroClose = session.closeAction(0uL) as SessionAction.Close + assertNull(zeroClose.payload.voucher) + } + + @Test + fun openTopupAndPullActionFields() { + val session = session() + val open = session.openAction(1_000_000uL, "txsig123") as SessionAction.Open + assertEquals(SessionMode.PUSH, open.payload.mode) + assertEquals("1000000", open.payload.deposit) + assertEquals("txsig123", open.payload.signature) + assertEquals(session.channelIdString(), open.payload.channelId) + assertEquals(session.authorizedSigner(), open.payload.authorizedSigner) + + val pull = session.openPullAction(5_000_000uL, "wallet123", "approvesig") as SessionAction.Open + assertEquals(SessionMode.PULL, pull.payload.mode) + assertEquals("5000000", pull.payload.approvedAmount) + assertEquals("wallet123", pull.payload.owner) + assertEquals(session.channelIdString(), pull.payload.tokenAccount) + assertNull(pull.payload.channelId) + + val topUp = session.topupAction(5_000_000uL, "topuptx") as SessionAction.TopUp + assertEquals("5000000", topUp.payload.newDeposit) + assertEquals("topuptx", topUp.payload.signature) + assertEquals(session.channelIdString(), topUp.payload.channelId) + } +} diff --git a/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/SessionConsumerTest.kt b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/SessionConsumerTest.kt new file mode 100644 index 000000000..7d7948519 --- /dev/null +++ b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/SessionConsumerTest.kt @@ -0,0 +1,153 @@ +package com.solana.paykit.protocols.mpp.client + +import com.solana.paykit.paycore.MemorySigner +import com.solana.paykit.paycore.MppException +import com.solana.paykit.paycore.PublicKey +import com.solana.paykit.protocols.mpp.core.CommitPayload +import com.solana.paykit.protocols.mpp.core.CommitReceipt +import com.solana.paykit.protocols.mpp.core.CommitStatus +import com.solana.paykit.protocols.mpp.core.DEFAULT_SESSION_EXPIRES_AT +import com.solana.paykit.protocols.mpp.core.MeteredEnvelope +import com.solana.paykit.protocols.mpp.core.MeteringDirective +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class SessionConsumerTest { + /** Records each commit and dedupes by deliveryId (replayed on re-commit). */ + private class RecordingTransport : CommitTransport { + val commits = mutableListOf() + var fail = false + private val settled = mutableMapOf() + + override fun commit(directive: MeteringDirective, payload: CommitPayload): CommitReceipt { + if (fail) throw MppException.InvalidTransaction("commit failed") + settled[directive.deliveryId]?.let { prior -> + return CommitReceipt(directive.deliveryId, directive.sessionId, directive.amount, prior, CommitStatus.REPLAYED) + } + val cumulative = payload.voucher.data.cumulative + settled[directive.deliveryId] = cumulative + commits.add(payload) + return CommitReceipt(directive.deliveryId, directive.sessionId, directive.amount, cumulative, CommitStatus.COMMITTED) + } + } + + private class ReplayTransport(val settled: String) : CommitTransport { + override fun commit(directive: MeteringDirective, payload: CommitPayload): CommitReceipt = + CommitReceipt(directive.deliveryId, directive.sessionId, directive.amount, settled, CommitStatus.REPLAYED) + } + + private fun session(channel: Byte = 7): ActiveSession { + val signer = MemorySigner.fromSeed(ByteArray(32) { 42 }) + return ActiveSession(PublicKey(ByteArray(32) { channel }).toBase58(), signer) + } + + private fun directive(session: ActiveSession, amount: Int, deliveryId: String = "d1"): MeteringDirective = + MeteringDirective(deliveryId, session.channelIdString(), amount.toString(), "USDC", 1L, DEFAULT_SESSION_EXPIRES_AT) + + @Test + fun ackSendsCommitAndAdvancesWatermark() { + val session = session() + val transport = RecordingTransport() + val consumer = SessionConsumer(session, transport) + val delivery = consumer.accept(MeteredEnvelope("work", directive(session, 250))) + assertEquals("work", delivery.payload) + val receipt = delivery.ack() + + assertEquals("250", receipt.cumulative) + assertEquals(CommitStatus.COMMITTED, receipt.status) + assertEquals(250uL, session.cumulative) + assertEquals(1, transport.commits.size) + } + + @Test + fun commitAliasAndIntoParts() { + val session = session() + session.setExpiresAt(1234L) + val transport = RecordingTransport() + val consumer = SessionConsumer(session, transport) + + val first = consumer.accept(MeteredEnvelope("first", directive(session, 50))) + assertEquals("50", first.commit().cumulative) + assertEquals(1234L, transport.commits[0].voucher.data.expiresAt) + + val second = consumer.accept(MeteredEnvelope("second", directive(session, 75, "d2"))) + val (payload, metering) = second.intoParts() + assertEquals("second", payload) + assertEquals("75", metering.amount) + } + + @Test + fun invalidDirectivesRejectedBeforeCommit() { + val session = session() + val transport = RecordingTransport() + val consumer = SessionConsumer(session, transport) + + val wrong = MeteringDirective("d1", "other-session", "1", "USDC", 1L, DEFAULT_SESSION_EXPIRES_AT) + assertFailsWith { consumer.commitDirective(wrong) } + assertFailsWith { consumer.commitDirective(directive(session, 0)) } + val badAmount = MeteringDirective("d1", session.channelIdString(), "bad", "USDC", 1L, DEFAULT_SESSION_EXPIRES_AT) + assertFailsWith { consumer.commitDirective(badAmount) } + + assertTrue(transport.commits.isEmpty()) + assertEquals(0uL, session.cumulative) + } + + @Test + fun failedCommitDoesNotAdvanceWatermark() { + val session = session() + val transport = RecordingTransport().apply { fail = true } + val consumer = SessionConsumer(session, transport) + + assertFailsWith { consumer.commitDirective(directive(session, 250)) } + assertEquals(0uL, session.cumulative) + + transport.fail = false + assertEquals("250", consumer.commitDirective(directive(session, 250)).cumulative) + assertEquals(250uL, session.cumulative) + } + + @Test + fun duplicateDeliveryReplayDoesNotDoubleCount() { + val session = session() + val transport = RecordingTransport() + val consumer = SessionConsumer(session, transport) + val d = directive(session, 100) + + assertEquals(CommitStatus.COMMITTED, consumer.commitDirective(d).status) + assertEquals(100uL, session.cumulative) + + val r2 = consumer.commitDirective(d) + assertEquals(CommitStatus.REPLAYED, r2.status) + assertEquals("100", r2.cumulative) + assertEquals(100uL, session.cumulative) + assertEquals(1, transport.commits.size) + } + + @Test + fun replayedReceiptReconcilesToClampedSettled() { + val session = session() + val consumer = SessionConsumer(session, ReplayTransport("100")) + val receipt = consumer.commitDirective(directive(session, 250)) + assertEquals(CommitStatus.REPLAYED, receipt.status) + assertEquals(100uL, session.cumulative) + } + + @Test + fun replayedReceiptNeverRegressesWatermark() { + val session = session() + session.reconcileSettled(300uL) + val consumer = SessionConsumer(session, ReplayTransport("100")) + consumer.commitDirective(directive(session, 50)) + assertEquals(300uL, session.cumulative) + } + + @Test + fun replayedReceiptClampsInflatedServerCumulative() { + val session = session() + val consumer = SessionConsumer(session, ReplayTransport("1000000")) + consumer.commitDirective(directive(session, 250)) + assertEquals(250uL, session.cumulative) + } +} diff --git a/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/SessionOpenerTest.kt b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/SessionOpenerTest.kt new file mode 100644 index 000000000..a33937373 --- /dev/null +++ b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/SessionOpenerTest.kt @@ -0,0 +1,78 @@ +package com.solana.paykit.protocols.mpp.client + +import com.solana.paykit.paycore.Base58 +import com.solana.paykit.paycore.MemorySigner +import com.solana.paykit.paycore.Mints +import com.solana.paykit.paycore.MppException +import com.solana.paykit.paycore.PaymentChannels +import com.solana.paykit.paycore.PublicKey +import com.solana.paykit.protocols.mpp.core.SessionAction +import com.solana.paykit.protocols.mpp.core.SessionMode +import com.solana.paykit.protocols.mpp.core.SessionPullVoucherStrategy +import com.solana.paykit.protocols.mpp.core.SessionRequest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class SessionOpenerTest { + private val operatorAddress = Base58.encode(ByteArray(32) { 5 }) + private val recipient = Base58.encode(ByteArray(32) { 6 }) + private val blockhash = Base58.encode(ByteArray(32) { 0x11 }) + + private fun request( + modes: List = listOf(SessionMode.PULL), + strategy: SessionPullVoucherStrategy? = SessionPullVoucherStrategy.CLIENT_VOUCHER, + ) = SessionRequest( + cap = "1000000", currency = "USDC", decimals = 6, network = "localnet", + operator = operatorAddress, recipient = recipient, modes = modes, pullVoucherStrategy = strategy, + ) + + private fun payerSigner() = MemorySigner.fromSeed(ByteArray(32) { 1 }) + private fun sessionSigner() = MemorySigner.fromSeed(ByteArray(32) { 2 }) + + @Test + fun buildsPullClientVoucherOpenAction() { + val sessionSigner = sessionSigner() + val payer = payerSigner() + val opener = PaymentChannelSession.open(request(), payer, sessionSigner, blockhash) + + assertEquals(opener.open.channelId.toBase58(), opener.session.channelIdString()) + val open = opener.action as SessionAction.Open + assertEquals(SessionMode.PULL, open.payload.mode) + assertEquals(opener.open.channelId.toBase58(), open.payload.channelId) + assertEquals(PublicKey(payer.publicKeyBytes).toBase58(), open.payload.payer) + assertEquals(sessionSigner.address, open.payload.authorizedSigner) + assertEquals(PENDING_SERVER_SIGNATURE, open.payload.signature) + assertNotNull(open.payload.transaction) + // localnet USDC resolves to the mainnet mint on the MPP charge path. + assertEquals(Mints.USDC_MAINNET, opener.open.mint.toBase58()) + assertEquals(1_000_000uL, opener.open.deposit) + assertEquals(PaymentChannels.DEFAULT_GRACE_PERIOD_SECONDS, opener.open.gracePeriod) + } + + @Test + fun appliesSessionOptions() { + val opener = PaymentChannelSession.open( + request(), payerSigner(), sessionSigner(), blockhash, + PaymentChannelSessionOpenOptions(cumulative = 20uL, expiresAt = 1234L), + ) + val voucher = opener.session.prepareIncrement(5uL) + assertEquals("25", voucher.data.cumulative) + assertEquals(1234L, voucher.data.expiresAt) + } + + @Test + fun rejectsNonPullChallenge() { + assertFailsWith { + PaymentChannelSession.open(request(modes = listOf(SessionMode.PUSH), strategy = null), payerSigner(), sessionSigner(), blockhash) + } + } + + @Test + fun rejectsOperatedVoucherChallenge() { + assertFailsWith { + PaymentChannelSession.open(request(strategy = SessionPullVoucherStrategy.OPERATED_VOUCHER), payerSigner(), sessionSigner(), blockhash) + } + } +} diff --git a/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/core/SessionWireTest.kt b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/core/SessionWireTest.kt new file mode 100644 index 000000000..7c237a155 --- /dev/null +++ b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/core/SessionWireTest.kt @@ -0,0 +1,78 @@ +package com.solana.paykit.protocols.mpp.core + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SessionWireTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun openActionFlattensTagAndSerializesSaltAsString() { + val action = SessionAction.Open( + OpenPayload.paymentChannel( + SessionMode.PULL, "Chan", "1000", "Payer", "Payee", "Mint", 42uL, 900u, "Auth", "Sig" + ) + ) + val obj = SessionActionCodec.toJsonObject(action) + assertEquals("open", obj["action"]?.jsonPrimitive?.content) + assertEquals("pull", obj["mode"]?.jsonPrimitive?.content) + assertEquals("Chan", obj["channelId"]?.jsonPrimitive?.content) + // salt is a decimal string, not a number. + val salt = obj["salt"]?.jsonPrimitive + assertEquals("42", salt?.content) + assertTrue(salt?.isString == true) + assertEquals("Auth", obj["authorizedSigner"]?.jsonPrimitive?.content) + } + + @Test + fun topUpActionUsesCamelCaseTag() { + val obj = SessionActionCodec.toJsonObject(SessionAction.TopUp(TopUpPayload("Chan", "500", "Sig"))) + assertEquals("topUp", obj["action"]?.jsonPrimitive?.content) + assertEquals("500", obj["newDeposit"]?.jsonPrimitive?.content) + } + + @Test + fun sessionActionRoundTrips() { + val voucher = SignedVoucher(VoucherData("Chan", "250", 4_102_444_800L, 3L), "Sig") + val actions = listOf( + SessionAction.Open(OpenPayload.push("Chan", "1000", "Auth", "Sig")), + SessionAction.Voucher(VoucherPayload(voucher)), + SessionAction.Commit(CommitPayload("d1", voucher)), + SessionAction.TopUp(TopUpPayload("Chan", "500", "Sig")), + SessionAction.Close(ClosePayload("Chan", voucher)), + ) + for (action in actions) { + assertEquals(action, SessionActionCodec.decodeFromString(SessionActionCodec.encodeToString(action))) + } + } + + @Test + fun voucherDataEncodesCumulativeAmountAndReadsAlias() { + val encoded = json.encodeToString(VoucherData.serializer(), VoucherData("Chan", "250", 100L)) + val tree = json.parseToJsonElement(encoded) + assertEquals("250", (tree as kotlinx.serialization.json.JsonObject)["cumulativeAmount"]?.jsonPrimitive?.content) + assertNull(tree["cumulative"]) + + val canonical = json.decodeFromString(VoucherData.serializer(), """{"channelId":"Chan","cumulativeAmount":"7","expiresAt":1}""") + assertEquals("7", canonical.cumulative) + val alias = json.decodeFromString(VoucherData.serializer(), """{"channelId":"Chan","cumulative":"9","expiresAt":1}""") + assertEquals("9", alias.cumulative) + } + + @Test + fun openPayloadReadsSaltFromStringOrNumber() { + val fromString = json.decodeFromString( + OpenPayload.serializer(), """{"mode":"pull","salt":"42","authorizedSigner":"A","signature":"S"}""" + ) + assertEquals(42uL, fromString.salt) + val fromNumber = json.decodeFromString( + OpenPayload.serializer(), """{"mode":"pull","salt":42,"authorizedSigner":"A","signature":"S"}""" + ) + assertEquals(42uL, fromNumber.salt) + } +}