Files
self/specs/SPEC-MINIPAY-SAMPLE.md
Justin Hernandez 466fd5d8e7 update kmp specs (#1757)
* save new specs

* rename specs
2026-02-16 00:42:06 -08:00

22 KiB
Raw Permalink Blame History

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:


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:

data class HomeState(
    val isVerified: Boolean = false,
    val lastProofDate: String? = null,
    val verifiedClaims: Map<String, String>? = 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=<scope>&endpoint=<endpoint>&endpointType=<type>
  &chainId=<id>&userId=<userId>&disclosures=<json>&version=<v>
  &userDefinedData=<data>&selfDefinedData=<data>

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
// commonMain — expect declaration
expect class QrScanner {
    fun startScanning(onQrDetected: (String) -> Unit, onError: (String) -> Unit)
    fun stopScanning()
}

QR Parsing:

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 (07):
    • 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:

// Phase 1: MRZ detection via Camera bridge handler
val mrzResult = sdk.cameraMrz.scanMrz()
val mrzData = Json.decodeFromString<MrzData>(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 NfcProviderNfcPassportHelper.

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:

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

class MainViewModel {
    // Navigation state
    var currentScreen by mutableStateOf<Screen>(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
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

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.composeMultiplatform)
    alias(libs.plugins.composeCompiler)
}

composeApp/build.gradle.kts

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 PassportScanResultIDDocument (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):

// 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:

// 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)