# MiniPay Sample App — Headless KMP SDK Demo ## Overview A native Compose Multiplatform app demonstrating the **headless SDK flow** — no WebView. This is the reference implementation for integrating the Self KMP SDK into a crypto wallet (MiniPay) or any app that needs native proof generation. The app scans a passport, generates a zero-knowledge proof using the native `ProvingClient`, and displays the result — all without launching a WebView. **Prerequisites**: - [SPEC-KMP-SDK.md](./SPEC-KMP-SDK.md) — Bridge protocol, common models - [SPEC-IOS-HANDLERS.md](./SPEC-IOS-HANDLERS.md) — iOS native handlers (NFC, Camera via Swift providers) - [SPEC-PROVING-CLIENT.md](./SPEC-PROVING-CLIENT.md) — Native proving client (`ProvingClient`) --- ## Architecture ``` ┌─────────────────────────────────────────────┐ │ MiniPay Sample App (Compose Multiplatform) │ ├─────────────────────────────────────────────┤ │ Screens: │ │ HomeScreen → QrScanScreen → │ │ DocumentScanScreen → ProvingScreen → │ │ ResultScreen │ ├─────────────────────────────────────────────┤ │ ViewModel Layer: │ │ MainViewModel (navigation + state) │ ├─────────────────────────────────────────────┤ │ KMP SDK (Native APIs — no WebView): │ │ NFC scan → ProvingClient.prove() → result │ │ SecureStorage for secrets │ │ Crypto for key management │ └─────────────────────────────────────────────┘ ``` ### Key Difference from Test App | | Test App (`kmp-test-app`) | MiniPay Sample | |---|---|---| | Proof generation | WebView (Person 1's Vite bundle) | Native `ProvingClient` | | UI | Compose Multiplatform + WebView overlay | Pure Compose Multiplatform | | SDK entry point | `SelfSdk.launch()` → WebView | `ProvingClient.prove()` → native | | Use case | Full Self verification flow | Wallet integration demo | --- ## Directory Structure ``` packages/kmp-minipay-sample/ build.gradle.kts composeApp/ build.gradle.kts src/ commonMain/kotlin/xyz/self/minipay/ App.kt # Root composable + navigation MainViewModel.kt # App state management screens/ HomeScreen.kt # Landing screen with "Verify" button QrScanScreen.kt # QR code scanner DocumentScanScreen.kt # MRZ camera + NFC passport scan ProvingScreen.kt # Proving progress UI ResultScreen.kt # Success/failure display models/ AppState.kt # Navigation state VerificationRequest.kt # Parsed QR code data theme/ Theme.kt # MiniPay-style theming androidMain/kotlin/xyz/self/minipay/ MainActivity.kt # Android entry point QrScannerAndroid.kt # CameraX QR scanner (expect/actual) iosMain/kotlin/xyz/self/minipay/ MainViewController.kt # iOS entry point QrScannerIos.kt # AVFoundation QR scanner (expect/actual) androidApp/ build.gradle.kts src/main/ AndroidManifest.xml java/.../MainApplication.kt iosApp/ iosApp/ iOSApp.swift # SwiftUI wrapper ContentView.swift iosApp.xcodeproj/ ``` --- ## Screens ### 1. HomeScreen **Purpose**: Landing page with verification status and "Verify Identity" button. **UI**: - App title: "MiniPay" with Self branding - Status card: Shows current verification state (unverified / verified / expired) - "Verify Identity" button → navigates to QR scanner - Previously verified proof summary (if any) **State**: ```kotlin data class HomeState( val isVerified: Boolean = false, val lastProofDate: String? = null, val verifiedClaims: Map? = null, ) ``` ### 2. QrScanScreen **Purpose**: Scan a QR code containing a verification request URL. **QR Code Format**: The QR code encodes a URL with verification parameters: ``` https://self.xyz/verify?scope=&endpoint=&endpointType= &chainId=&userId=&disclosures=&version= &userDefinedData=&selfDefinedData= ``` **UI**: - Full-screen camera preview with QR code overlay - "Scan a verification QR code" instruction text - Cancel button to return to home **Platform Implementation**: - Android: CameraX + ML Kit `BarcodeScanning` - iOS: AVFoundation `AVCaptureMetadataOutput` with `.qr` metadata type ```kotlin // commonMain — expect declaration expect class QrScanner { fun startScanning(onQrDetected: (String) -> Unit, onError: (String) -> Unit) fun stopScanning() } ``` **QR Parsing**: ```kotlin fun parseVerificationUrl(url: String): ProvingRequest { val uri = Url(url) return ProvingRequest( circuitType = CircuitType.DISCLOSE, // QR codes are always disclosure requests scope = uri.parameters["scope"], endpoint = uri.parameters["endpoint"], endpointType = EndpointType.valueOf(uri.parameters["endpointType"]?.uppercase() ?: "CELO"), chainId = uri.parameters["chainId"]?.toIntOrNull(), userId = uri.parameters["userId"], disclosures = parseDisclosures(uri.parameters["disclosures"]), version = uri.parameters["version"]?.toIntOrNull() ?: 1, userDefinedData = uri.parameters["userDefinedData"] ?: "", selfDefinedData = uri.parameters["selfDefinedData"] ?: "", ) } ``` ### 3. DocumentScanScreen **Purpose**: Two-phase document scanning — MRZ camera detection, then NFC passport read. **Phase 1 — MRZ Camera Scan**: - Camera preview focused on passport MRZ zone - Visual overlay showing the MRZ detection region - Progress states: NO_TEXT → TEXT_DETECTED → ONE_MRZ_LINE → TWO_MRZ_LINES - Auto-transitions to Phase 2 when MRZ is detected **Phase 2 — NFC Passport Scan**: - Instruction: "Hold your phone against the passport" - Progress animation showing NFC scan states (0–7): - 0: "Hold your phone near the passport" - 1: "Passport detected..." - 2: "Authenticating..." - 3: "Reading passport data..." - 4: "Reading security data..." - 5: "Verifying passport..." - 6: "Processing..." - 7: "Scan complete!" - Progress bar reflecting percentage - Cancel button **SDK Integration**: ```kotlin // Phase 1: MRZ detection via Camera bridge handler val mrzResult = sdk.cameraMrz.scanMrz() val mrzData = Json.decodeFromString(mrzResult) // Phase 2: NFC scan using MRZ data for BAC/PACE authentication val scanResult = sdk.nfc.scanPassport( passportNumber = mrzData.documentNumber, dateOfBirth = mrzData.dateOfBirth, dateOfExpiry = mrzData.dateOfExpiry, ) ``` On Android, the NFC handler uses JMRTD directly. On iOS, it calls through the Swift `NfcProvider` → `NfcPassportHelper`. ### 4. ProvingScreen **Purpose**: Show proving progress as the native `ProvingClient` runs. **UI**: - Stepper/progress indicator showing current state - Each state maps to a user-friendly label: - `FetchingData` → "Fetching verification data..." - `ValidatingDocument` → "Validating your document..." - `ConnectingTee` → "Connecting to secure enclave..." - `Proving` → "Generating proof..." (with spinner) - `PostProving` → "Finalizing..." - Animated progress bar - Cancel button (cancels the coroutine) **SDK Integration**: ```kotlin val provingClient = ProvingClient(ProvingConfig( environment = if (request.endpointType == EndpointType.STAGING_CELO) Environment.STG else Environment.PROD, )) // Load secret from secure storage val secret = sdk.secureStorage.get("user_secret") ?: throw IllegalStateException("No user secret found") // Load parsed document from previous scan val document = parsePassportScanResult(scanResult) // Run proving with state callbacks try { val result = provingClient.prove( document = document, request = request, secret = secret, onStateChange = { state -> // Update UI with current state viewModel.updateProvingState(state) }, ) viewModel.navigateToResult(result) } catch (e: ProvingException) { viewModel.navigateToResult(ProofResult(success = false), error = e) } ``` ### 5. ResultScreen **Purpose**: Display proof result — success or failure. **Success UI**: - Checkmark animation - "Identity Verified" title - Disclosed claims list (name, nationality, age, etc. based on disclosure flags) - Proof UUID for reference - "Done" button → return to HomeScreen **Failure UI**: - Error icon - Error code and human-readable message - "Try Again" button → return to appropriate screen - Error-specific guidance: - `DOCUMENT_NOT_SUPPORTED` → "Your passport type is not yet supported" - `NOT_REGISTERED` → "Please register your passport first" - `TEE_CONNECT_FAILED` → "Connection failed. Check your internet and try again" - `PROVE_FAILED` → "Proof generation failed. Please try again" --- ## ViewModel ```kotlin class MainViewModel { // Navigation state var currentScreen by mutableStateOf(Screen.Home) // Data passed between screens var verificationRequest: ProvingRequest? = null var mrzData: MrzData? = null var passportScanResult: JsonElement? = null var provingState: ProvingState? = null var proofResult: ProofResult? = null var error: ProvingException? = null // Navigation fun navigateToQrScan() { currentScreen = Screen.QrScan } fun onQrScanned(url: String) { verificationRequest = parseVerificationUrl(url) currentScreen = Screen.DocumentScan } fun onMrzDetected(data: MrzData) { mrzData = data } fun onPassportScanned(result: JsonElement) { passportScanResult = result currentScreen = Screen.Proving } fun updateProvingState(state: ProvingState) { provingState = state } fun navigateToResult(result: ProofResult, error: ProvingException? = null) { proofResult = result this.error = error currentScreen = Screen.Result } fun returnToHome() { currentScreen = Screen.Home // Clear transient state } } sealed class Screen { data object Home : Screen() data object QrScan : Screen() data object DocumentScan : Screen() data object Proving : Screen() data object Result : Screen() } ``` --- ## Registration Flow Before disclosure, the user must register their passport. The sample app detects this automatically: 1. User scans QR code (disclosure request) 2. App loads passport data from secure storage (or scans if first time) 3. `ProvingClient.prove()` with `CircuitType.DISCLOSE` 4. `DocumentValidator` detects user is NOT registered 5. State machine throws `ProvingException("NOT_REGISTERED")` 6. App catches this and shows: "You need to register first. Register now?" 7. If yes: runs `ProvingClient.prove()` with `CircuitType.REGISTER` (and DSC if needed) 8. On success: re-runs the original disclosure request ```kotlin try { val result = provingClient.prove(document, request, secret, onStateChange) // Success — show result } catch (e: ProvingException) { if (e.code == "NOT_REGISTERED") { // Auto-register flow val registerRequest = ProvingRequest(circuitType = CircuitType.REGISTER) provingClient.prove(document, registerRequest, secret, onStateChange) // Retry original disclosure val result = provingClient.prove(document, request, secret, onStateChange) // Show result } else { // Show error } } ``` --- ## Build Configuration ### `packages/kmp-minipay-sample/build.gradle.kts` ```kotlin plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) } ``` ### `composeApp/build.gradle.kts` ```kotlin plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidApplication) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.kotlinSerialization) } kotlin { androidTarget { compilations.all { kotlinOptions { jvmTarget = "17" } } } iosArm64() iosSimulatorArm64() listOf(iosArm64(), iosSimulatorArm64()).forEach { it.binaries.framework { baseName = "ComposeApp" isStatic = true } } sourceSets { commonMain.dependencies { // Compose implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) implementation(compose.ui) implementation(compose.components.resources) // Navigation implementation(libs.navigation.compose) // KMP SDK (local project dependency) implementation(project(":kmp-sdk:shared")) // Serialization implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.core) } val androidMain by getting { dependencies { implementation(libs.compose.ui.tooling.preview) implementation(libs.androidx.activity.compose) // QR scanning implementation("com.google.mlkit:barcode-scanning:17.2.0") implementation("androidx.camera:camera-camera2:1.3.4") implementation("androidx.camera:camera-lifecycle:1.3.4") implementation("androidx.camera:camera-view:1.3.4") } } } } android { namespace = "xyz.self.minipay" compileSdk = 35 defaultConfig { applicationId = "xyz.self.minipay" minSdk = 24 targetSdk = 35 versionCode = 1 versionName = "1.0" } } ``` --- ## Chunking Guide ### Chunk 5A: Project Setup + Navigation Shell **Goal**: Create the Compose Multiplatform project with navigation between empty screens. **Steps**: 1. Create `packages/kmp-minipay-sample/` directory structure 2. Configure `build.gradle.kts` with Compose Multiplatform + KMP SDK dependency 3. Implement `App.kt` with navigation controller 4. Implement `MainViewModel.kt` with screen state 5. Create placeholder screens (HomeScreen, QrScanScreen, DocumentScanScreen, ProvingScreen, ResultScreen) 6. Android: `MainActivity.kt`, `AndroidManifest.xml` (NFC + Camera permissions) 7. iOS: `MainViewController.kt`, `iOSApp.swift`, `ContentView.swift` 8. Validate: App builds and launches with navigation between placeholder screens ### Chunk 5B: QR Scanner **Goal**: Camera-based QR code scanning with URL parsing. **Steps**: 1. Define `expect class QrScanner` in commonMain 2. Implement Android actual: CameraX + ML Kit BarcodeScanning 3. Implement iOS actual: AVFoundation metadata output (via Swift provider if needed) 4. Implement `QrScanScreen.kt` — camera preview with QR overlay 5. Implement URL parser: `parseVerificationUrl(url) → ProvingRequest` 6. Wire QR detection → ViewModel → navigate to DocumentScan 7. Validate: Scan a test QR code, verify parsed parameters ### Chunk 5C: Document Scanner (MRZ + NFC) **Goal**: Passport scanning using SDK native APIs. **Steps**: 1. Implement `DocumentScanScreen.kt` with two-phase UI 2. Phase 1: Camera MRZ detection — use SDK's `CameraMrzBridgeHandler` via native API 3. Phase 2: NFC passport scan — use SDK's `NfcBridgeHandler` via native API 4. Progress UI: Map scan states to visual indicators 5. Parse `PassportScanResult` into `IDDocument` model for proving 6. Validate: Full MRZ detect → NFC scan on physical device **Note**: On Android, the NFC scan uses the SDK's `NfcBridgeHandler` directly (JMRTD). On iOS, it calls through the Swift provider chain. The sample app calls the handler APIs directly rather than going through the WebView bridge — this is the "headless" pattern. ### Chunk 5D: Proving Screen + Integration **Goal**: Wire up `ProvingClient` and show progress. **Steps**: 1. Implement `ProvingScreen.kt` with state-based progress UI 2. Instantiate `ProvingClient` with config from parsed QR 3. Convert `PassportScanResult` → `IDDocument` (passport data model) 4. Load user secret from secure storage (or generate if first time) 5. Call `provingClient.prove()` with `onStateChange` callback 6. Handle success → navigate to ResultScreen 7. Handle registration requirement → auto-register flow 8. Handle errors → navigate to ResultScreen with error 9. Validate: Full end-to-end flow against staging TEE ### Chunk 5E: Result Screen + Polish **Goal**: Display results and polish the app. **Steps**: 1. Implement `ResultScreen.kt` with success/failure UI 2. Display disclosed claims based on verification request 3. Persist verification status for HomeScreen 4. Theme: MiniPay-style colors and typography 5. Error handling: User-friendly messages for each error code 6. iOS: Wire up `SelfSdkSwift.configure()` in `iOSApp.swift` 7. Validate: Full flow on both platforms, error cases handled --- ## Key SDK APIs Used The sample app demonstrates calling SDK APIs directly (no WebView bridge): ```kotlin // 1. MRZ Camera Scan val cameraMrzProvider = SdkProviderRegistry.cameraMrz // iOS // or direct call to CameraMrzBridgeHandler // Android // 2. NFC Passport Scan val nfcProvider = SdkProviderRegistry.nfc // iOS // or direct call to NfcBridgeHandler // Android // 3. Secure Storage (for user secret) val storageProvider = SdkProviderRegistry.secureStorage // iOS // or direct call to SecureStorageBridgeHandler // Android // 4. Native Proving val provingClient = ProvingClient(config) val result = provingClient.prove(document, request, secret, onStateChange) ``` For a cleaner API, the SDK should expose a unified interface in `commonMain`: ```kotlin // Future enhancement: SelfSdk headless API class SelfSdk { val nfc: NfcApi // Wraps NfcBridgeHandler / NfcProvider val camera: CameraApi // Wraps CameraMrzBridgeHandler / CameraMrzProvider val storage: StorageApi // Wraps SecureStorageBridgeHandler / SecureStorageProvider val proving: ProvingClient } ``` --- ## Testing ### Unit Tests (`commonTest/`) **QR URL Parsing** (~8 tests): - `parseVerificationUrl()` extracts all parameters correctly - Missing optional parameters use defaults - Malformed URL throws with clear error - URL with encoded characters decodes correctly - `disclosures` JSON parameter parses into `Disclosures` object **ViewModel Navigation** (~6 tests): - Initial screen is `Home` - `onQrScanned()` parses URL and navigates to `DocumentScan` - `onPassportScanned()` navigates to `Proving` - `navigateToResult()` stores result and navigates to `Result` - `returnToHome()` clears transient state ### Device Tests (manual, per-chunk) **Chunk 5A — Navigation Shell**: - App launches on Android emulator and iOS simulator - All 5 screens reachable via navigation - Back navigation works correctly **Chunk 5B — QR Scanner**: - Camera permission prompt appears on first launch - Camera preview renders full-screen - Scanning a test QR code extracts correct URL - Scanning a non-URL QR code shows error gracefully - Cancel returns to home **Chunk 5C — Document Scanner**: - MRZ camera phase: Progress states advance as passport is positioned (0 → 1 → 2 → 3) - MRZ camera phase: Auto-transitions to NFC phase when MRZ detected - NFC phase: Progress states advance during passport scan (0 → 7) - NFC phase: Cancel during scan returns to previous screen without crash - NFC phase: Bad MRZ data (wrong dates) produces clear error - Full scan produces valid `PassportScanResult` JSON **Chunk 5D — Proving**: - State callbacks fire in order: FetchingData → ValidatingDocument → ConnectingTee → Proving → PostProving → Completed - UI updates for each state transition (progress indicator advances) - Cancel during proving cancels the coroutine cleanly - NOT_REGISTERED error triggers auto-register flow - Other errors navigate to result screen with error details - Full end-to-end against staging TEE succeeds with mock passport **Chunk 5E — Result + Polish**: - Success screen shows disclosed claims matching the request's disclosures - Failure screen shows error code and human-readable message - "Try Again" navigates back to appropriate screen - "Done" returns to home, home shows verified status - Both platforms: identical behavior for same QR code + passport combo ### End-to-End Acceptance Test 1. Launch app → Home screen shows "Unverified" 2. Tap "Verify Identity" → QR scanner opens 3. Scan test QR code → navigates to document scanner 4. Position passport → MRZ detected → "Hold phone near passport" 5. Tap passport → NFC scan completes 6. Proving screen shows progress through all states 7. Result screen shows "Identity Verified" with correct claims 8. Return to Home → shows "Verified" with proof date Run on: Android physical device + iOS physical device. --- ## Dependencies - **SPEC-KMP-SDK.md**: NFC handler (Android), Camera handler (Android), SecureStorage handler - **SPEC-IOS-HANDLERS.md**: NFC provider (iOS), Camera provider (iOS), SecureStorage provider (iOS) - **SPEC-PROVING-CLIENT.md**: `ProvingClient` API — the core of this app - **SPEC-COMMON-LIB.md**: Passport data parsing, commitment generation (used by ProvingClient)