Skip to content

Kuira SDK — Integration Guide

Alpha — 0.1.0-alpha05

Build a Midnight zero-knowledge dApp on Android by adding one dependency, declaring your own passkey domain, and hosting one tiny JSON file on it. That's the whole on-ramp.

This guide walks the recipe end-to-end. Two public reference apps consume the SDK exactly as described below — clone either and read along:

  • Kuira Starter — the minimal counter dApp. Sigil identity + wallet + a one-circuit Compact contract. Clone it, set your applicationId + rpId, run. (Also a GitHub template.)
  • BBoard — a shared on-chain bulletin board (post / take-down) showing the deploy → call → read flow.

The snippets below are extracted from those apps; when in doubt, the linked source is the ground truth.


What the SDK gives you

Adding the one line below brings:

  • Sigil identity — a passkey-derived DID (did:key + Ed25519). One biometric, no recovery phrase, no maintainer dependency. Backed up to Google Block Store, encrypted with a key you can't read.
  • Wallet — Midnight HD wallet (unshielded + shielded NIGHT, Dust gas), with live balance, send, receive, and a drop-in Compose wallet panel if you want it.
  • Contract surface — deploy / call / read state on any .compact contract; ZK proof generation runs on-device (no proof-server hop required).
  • Indexer + chain client — block / state / event subscription, backed by Midnight's official indexer.
  • App-state cloud backup — your dApp's per-user data rides the sigil's Block Store backup automatically. The backup blob is PRF-encrypted client-side before Google's Block Store touches it.

Drop in the prebuilt UI, build your own on the same contracts, or go fully headless — see Choose your level below.


1. Prerequisites

Android Gradle Plugin ≥ 8.13 (matches the SDK build)
Kotlin ≥ 2.3
compileSdk 35+
minSdk 30 — required (the SDK uses keystore APIs added in API 30: biometric ⊕ device-credential)
Hilt + KSP Required — the SDK is Hilt-first; your app applies both
A web domain you control Required for the passkey relying party (GitHub Pages is fine for dev)
Your app's signing-cert SHA-256 Needed for assetlinks.json (step 4)

Full toolchain (JDK 17, Android Studio, Docker, Node 20+, the mn CLI, compactc) — see Prerequisites.


2. Add the dependency — one line

Choose your level

Kuira meets you at the level you want — and the dependency you pick reflects it:

  • Drop in the prebuilt UIio.github.kuiralabs:dapp-ui. Ready-made, themeable Compose components — the wallet pill, the sigil pill, and the Settings panel (network, recovery phrase, lock, sign-out), all in one PanelBar — wired to SDK state out of the box with safe security defaults (FLAG_SECURE, biometric gates, an auto-clearing clipboard). Restyle with a colors object; you write no wallet logic. The fastest path — see Set up Sigil identity.
  • Build your own experience — also dapp-ui (its ViewModels) or midnight-sdk. Render your own screens on the same public contracts the pills are built on — WalletRecovery, WalletPanelViewModel, SigilPanelViewModel / SigilSession, MidnightContract, MidnightSdk. The SDK keeps the hard parts (crypto, secure vault, on-device proving, sync, session-lock); the screens are yours. Every bundled component is just the first consumer of a contract you can call directly — e.g. Reveal & restore the recovery phrase.
  • Go headlessio.github.kuiralabs:midnight-sdk. The full wallet, identity, and contract surface with no Compose pulled in; you build the UI layer top-to-bottom. See § 7 Going headless.

Theming today, more tuning coming

The prebuilt components expose theming now plus a small set of behavioral knobs; richer per-component tuning hooks are on the roadmap. Until a knob exists, the build-your-own path gives full control of that surface — the contracts carry no UI policy.

Maven Central is already in every Android project's defaults, so there's no repo config to add. In app/build.gradle.kts:

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("org.jetbrains.kotlin.plugin.compose")          // only if your own UI is Compose
    id("com.google.devtools.ksp")
    id("com.google.dagger.hilt.android")
}

android {
    namespace = "com.example.mydapp"
    compileSdk = 36
    defaultConfig {
        applicationId = "com.example.mydapp"
        minSdk = 30
        targetSdk = 36
    }
}

dependencies {
    // ── Pick ONE Kuira entry (see "Choose your level" above) ──
    //
    // Prebuilt pills + the ViewModels behind them (Compose). Drop the wallet
    // and sigil panels in as-is, OR build your own UI on the same contracts.
    implementation("io.github.kuiralabs:dapp-ui:0.1.0-alpha05")
    //
    // OR — headless core, no Compose pulled in. For dApps building the whole
    // UI layer top-to-bottom.
    // implementation("io.github.kuiralabs:midnight-sdk:0.1.0-alpha05")

    // Hilt processor — required, the SDK is Hilt-first
    ksp("com.google.dagger:hilt-compiler:2.58")

    // your own app code, your own Compose deps, etc.
}

That single Kuira line brings the whole consumer surface — passkey, sigil, wallet, ZK proving, contract deploy/call, indexer, panel UI (if you picked dapp-ui) — onto your compile classpath transitively. No per-module redeclaration.


3. Declare YOUR passkey domain

The SDK provides no default PasskeyConfig — that's deliberate. A passkey is bound to a domain, and that domain must be yours. If the SDK baked in a default, every consumer would silently route through the maintainer's domain and PRF would only work after the maintainer added them to a maintainer-hosted assetlinks.json — i.e. the "open" SDK would actually be permissioned.

Add a tiny Hilt module (anywhere in your di/):

package com.example.mydapp.di

import com.midnight.kuira.core.identity.passkey.PasskeyConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object IdentityConfigModule {

    @Provides
    @Singleton
    fun providePasskeyConfig() = PasskeyConfig(
        rpId   = "mydapp.example.com",   // YOUR domain (matches assetlinks.json below)
        rpName = "My dApp",              // shown in the biometric prompt
    )
}

If you forget this, you'll get a clear Dagger missing-binding error at build time. That's intentional — it forces you to declare your domain before shipping.


4. Host assetlinks.json on YOUR domain

Android's CredentialManager checks Digital Asset Links to verify your app may use passkeys for your domain. Place this file at:

https://<your-domain>/.well-known/assetlinks.json
[{
  "relation": [
    "delegate_permission/common.get_login_creds",
    "delegate_permission/common.handle_all_urls"
  ],
  "target": {
    "namespace": "android_app",
    "package_name": "com.example.mydapp",
    "sha256_cert_fingerprints": ["AB:CD:EF:01:23:45:67:89:..."]
  }
}]

Get your cert SHA-256:

keytool -list -v -keystore <your-keystore> -alias <your-alias> | grep SHA256
For debug builds, the default keystore is ~/.android/debug.keystore (alias androiddebugkey, password android).

Without this file (or with a wrong package name / SHA-256), the biometric prompt fails and PRF can't derive your sigil. This is a one-time host; updates are needed only when you change signing certs.


5. Develop against a localnet

You don't need testnet tokens or a deployed contract to build. The UNDEPLOYED network is a full Midnight stack — node + indexer + proof server — running locally in Docker: instant, free, and ephemeral (reset it any time). It's the recommended loop for day-to-day development; switch to PREPROD only when you want the hosted chain.

PreProd's first sync replays from genesis — set up cloud backup first

Localnet is instant. PreProd is a long-lived chain with a large history, so a wallet's first sync there replays from genesis — minutes, not seconds. To make it a fast delta instead, set up cloud backup before you switch to PreProd. It needs a one-time Google OAuth client (your app's package + signing SHA-1); without it, cloud sync silently fails (UNREGISTERED_ON_API_CONSOLE) and PreProd keeps replaying from genesis on every fresh sync — the wallet works, it's just slow.

The recommended way to run and fund a localnet is the Midnight Wallet CLI (mn), installed from npm as midnight-wallet-cli. It wraps the Docker stack and wallet funding behind a few commands:

# one-time — needs Docker running + Node 20+
npm install -g midnight-wallet-cli          # installs `midnight` (alias `mn`)

mn localnet up                              # start node + indexer + proof server (Docker)
mn localnet status                          # check it's healthy
# … develop …
mn localnet down                            # tear it down

Tested localnet stack — pin the node to 0.22.5

Tested localnet stack: midnight-node:0.22.5 (on-chain Compact runtime 0.16.0). Older nodes (≤0.22.3) ship runtime 0.15.0 and reject a contract compiled for 0.16.0. mn pulls the node image; if you run Docker by hand, pin the node tag to 0.22.5.

Windows — mn localnet up can time out

On Windows, mn localnet up can fail with spawnSync … cmd.exe ETIMEDOUT. Bring the stack up directly:

docker compose -f ~/.midnight/localnet/compose.yml up -d

Fund the wallet your app shows (copy its address from the wallet panel's receive screen):

mn airdrop 10000 --wallet mn_addr_undeployed1… --network undeployed   # NIGHT for fees

Dust (gas) for the app's embedded wallet is enabled in-app — tap Register dust in the wallet panel.

mn dust register targets a named CLI wallet, not the app's wallet

mn dust register --wallet <name> needs a named wallet from mn wallet generate; it cannot target the app's embedded wallet address.

Localnet is ephemeral — mn localnet down (or a Docker restart) wipes all state, including funded balances. Re-airdrop after each fresh up.

5b. Let the app reach the localnet

The SDK already maps UNDEPLOYED to the right host per device type (NetworkConfig):

  • Emulator10.0.2.2 (the emulator's alias for your machine's localhost). Nothing to do.

!!! note "Emulator ABI — x86_64 works on 0.1.0-alpha04+" x86_64 (Intel) and arm64 emulators both work on 0.1.0-alpha04+ (the native lib ships arm64-v8a and x86_64). On alpha03 and earlier the lib is arm64-only — an x86_64 AVD fails at launch with java.lang.UnsatisfiedLinkError; use alpha04+ or an arm64 AVD. - Physical device127.0.0.1, so the three localnet ports must be tunnelled over the debug bridge. The SDK ships a Gradle plugin that does this automatically on every installDebug — apply it once, no configuration:

// app/build.gradle.kts
plugins {
    id("io.github.kuiralabs.localnet") version "<sdk-version>"
}

It registers an adbReverseLocalnet task wired ahead of installDebug, so deploying to a connected physical device forwards ports 9944 (node RPC), 8088 (indexer), and 6300 (proof server) for you. Emulators use 10.0.2.2 and are skipped automatically.

The example apps apply it exactly this way — see them for a working reference: BBoard and the starter.

Required for physical devices — the app can't reach the localnet without these three port forwards (the plugin runs them for you on installDebug; run them by hand if you're not using the plugin):

adb reverse tcp:9944 tcp:9944   # node RPC
adb reverse tcp:8088 tcp:8088   # indexer
adb reverse tcp:6300 tcp:6300   # proof server

No "Install via USB" on the phone? Some devices hide it. Build and push the APK over adb instead:

./gradlew :app:assembleDebug
adb push app/build/outputs/apk/debug/app-debug.apk /data/local/tmp/app.apk
adb shell pm install -r /data/local/tmp/app.apk

5c. Allow cleartext to the localnet (debug only)

Production hits HTTPS. Localnet is plain HTTP, so declare cleartext in a debug-only manifest — release builds stay HTTPS-clean:

<!-- app/src/debug/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:usesCleartextTraffic="true" />
</manifest>

Why debug-only: the SDK's release variants are HTTPS-clean and don't grant any cleartext allowance, so each app declares its own in a debug-only manifest — release stays cleartext-free.


6. Minimal "Hello World" — deploy + call

Your Application is @HiltAndroidApp, and your activity is a Hilt'd FragmentActivityAppCompatActivity is the usual choice, since the panel hosts a biometric prompt that needs a FragmentActivity host:

@HiltAndroidApp
class MyApp : Application()

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Box(modifier = Modifier.fillMaxSize()) {
                MyDappScreen()  // your UI underneath

                // Drop-in combined sigil + wallet UI: a pair of draggable
                // floating chips that own their own state. The wallet pill
                // builds the SDK (one biometric) the first time it shows, and
                // owns network selection — its pick drives the SDK's durable
                // NetworkPreferenceStore, which your screen can follow.
                PanelBar(
                    floating = true,
                    network = selectedNetwork,
                    onNetworkChange = ::selectNetwork,
                )
            }
        }
    }
}

This is the canonical integration every reference app uses — see Set up Sigil identity. (The lower-level SigilStatusPanel / WalletStatusPanel are still available if you want to place each panel inline yourself.)

Your dApp logic gets the SDK from the injected MidnightSdkProvider — observe sdkProvider.sdk (a StateFlow<MidnightSdk?>, non-null once the wallet panel has bootstrapped) or suspend on awaitSdk():

@HiltViewModel
class MyDappViewModel @Inject constructor(
    @ApplicationContext private val context: Context,
    private val sdkProvider: MidnightSdkProvider,
) : ViewModel() {

    fun deployAndCall() = viewModelScope.launch {
        val sdk = sdkProvider.awaitSdk()   // waits for the panel to bootstrap it

        // 1. Install your contract's proving keys — required before the first
        //    deploy/call (see §6.1), or proving fails.
        ProvingKeyManager(context).installCircuitKeysFromAssets()

        // 2. Build a write-capable handle. create() takes the SDK's config;
        //    contractJs is an InputStream from assets; deploy/call need the
        //    coin public key + each circuit's verifier-key bytes.
        val verifier = context.assets
            .open("keys/myCircuit.verifier").use { it.readBytes() }
        val contract = MidnightContract.create(sdk.config) {
            name = "mycontract"
            contractJs = context.assets.open("runtime/mycontract-contract.js")
            coinPublicKey = sdk.coinPublicKey
            circuitVerifierKeys = mapOf("myCircuit" to verifier)
        }

        // 3. Deploy, then call a circuit. `call(name, …args)` is stringly-typed;
        //    the contract Gradle plugin also generates a typed `MyContract`
        //    facade so `MyContract(contract).myCircuit()` is checked at compile
        //    time (see the deploy-and-call recipe).
        val address = contract.deploy().contractAddress
        contract.call("myCircuit")

        // 4. Read typed ledger state (lossless — no cell-hex parsing).
        val readOnly = MidnightContract.create(sdk.config) {
            contractJs = context.assets.open("runtime/mycontract-contract.js")
            this.address = address
        }
        val count = MyContract(readOnly).ledger().count   // typed facade; or untyped: readOnly.ledger().getUint64("count")
    }
}

6.1 Install your contract's proving keys (required)

ZK proofs run on the device, so every circuit your contract calls needs its proving keys + a BLS parameter set present before the first deploy/call — otherwise the call fails at the proving step. Ship them in assets/ — sync them in with the io.github.kuiralabs.contract Gradle plugin (0.1.0-alpha05), the recommended path, which stages the contract runtime and circuit keys into assets/ for you. Without the plugin, use the hand-rolled Copy task shown in the deploy-and-call recipe. Then install once at runtime — it's idempotent, so call it before each action:

import com.midnight.kuira.core.compact.proving.ProvingKeyManager

// Discovers the circuit keys bundled in assets and stages them
// where the on-device prover looks.
ProvingKeyManager(context).installCircuitKeysFromAssets()

Imports — use the core.compact.proving package

ProvingKeyManager and ProvingMode both live in com.midnight.kuira.core.compact.proving. There is a same-named ProvingMode typealias in com.midnight.kuira.core.crypto.proving — do not import that one; always reference com.midnight.kuira.core.compact.proving.ProvingMode.

The shared wallet proving keys ship the full BLS parameter set (bls_midnight_2p52p15), so a small contract circuit (e.g. a counter that needs 2p5) finds the size it requires from the wallet keys — you do not bundle BLS per contract. Just make sure the wallet keys are provisioned: they are, during SDK bootstrap, and you can ship them in the APK to skip the first-run download with kuiraContract { bundleWalletKeys = true } (see On-device proving). The Kuira Starter's CounterContract shows the full pattern end-to-end.

For a larger, multi-step contract (commit / reveal, witness packing, indexer-state polling, retry, force-resync), the BBoard reference app shows the deploy → call → read flow end-to-end.


7. Going headless (no panel)

If you don't want the Compose wallet panel, swap the dep:

implementation("io.github.kuiralabs:midnight-sdk:0.1.0-alpha05")  // no dapp-ui

You still own bootstrap + sigil forging — MidnightSdkProvider.ensureSdk(...) throws SigilRequiredException until a sigil exists. Forge it through PasskeyManager directly, or use the SigilSession helper.

midnight-sdk does not pull Compose — the headless entry stays small.


8. Shipping a release build

The starter ships isMinifyEnabled = false so newcomers see exactly what compiles. For a production release, turn on R8 and publish an App Bundle — both matter a lot, because the SDK carries a native on-device ZK-proving core.

app/build.gradle.kts
android {
    buildTypes {
        release {
            isMinifyEnabled = true
            // The SDK ships its own consumer ProGuard rules — AGP applies them automatically.
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
        }
    }
}
./gradlew :app:bundleRelease   # an .aab for Play — not a universal .apk

What it costs in size

Measured on the counter starter (one small circuit):

Build Size Why
Universal APK, minify off ~152 MB both ABIs + an unstripped dex — worst case, never shipped
Universal APK, R8 on ~93 MB R8 strips the dex from ~65 MB → ~6 MB (most was SDK code your app never calls)
Per-device (App Bundle + R8) ~60 MB Play delivers one ABI per device, not both

A real release lands around ~60 MB per device — comfortably inside Google Play's 200 MB app-bundle limit. The floor is the native proving library (~20–24 MB of Rust — the BLS/halo2 prover + QuickJS): R8 can't shrink it, because it's the cost of proving on the device rather than on a server. Bundling the wallet proving keys (bundleWalletKeys = true) adds ~33 MB on top; the default runtime download keeps them out of the binary.

Two settings do almost all the work

isMinifyEnabled = true (kills the dex bloat) + bundleRelease (one ABI per device). Everything after that is single-digit MB.


Common pitfalls

Symptom Fix
Dagger: "PasskeyConfig cannot be provided without an @Provides-annotated method" Step 3 — declare your own PasskeyConfig module.
Runtime: "CLEARTEXT communication to 10.0.2.2 not permitted by network security policy" Step 5 — add the debug manifest.
Biometric prompt fails / PRF returns null Step 4 — assetlinks.json missing, wrong package_name, or wrong cert SHA-256.
Contract call fails at the proving step / "circuit keys not found" / "BLS params" §6.1 — install your contract's proving keys before the first deploy/call (and bundle the right BLS set for small circuits).
App balance stays at 0 after an airdrop The wallet's background subscription is live; check adb logcat for indexer connectivity. On localnet, state is ephemeral — restarting the localnet wipes funds.
Balance stays 0 / "Loading…" on a physical device Run the three adb reverse commands (9944 node, 8088 indexer, 6300 proof server) or apply the io.github.kuiralabs.localnet plugin (§5b).
java.lang.UnsatisfiedLinkError at launch on an x86_64 emulator The native lib is arm64-only before 0.1.0-alpha04. Upgrade to alpha04+ (ships x86_64) or use an arm64 AVD.
Custom error: 168 or a dust-fee / time-to-dismiss failure on dust registration or the first tx Fixed in SDK 0.1.0-alpha04 — upgrade. An unexpected Custom error: N not in the known set usually means a client↔node ledger-version mismatch — confirm your node is 0.22.5.
Could not resolve io.github.kuiralabs:… Ensure mavenCentral() is in your repositories and the version is actually published — check central.sonatype.com/artifact/io.github.kuiralabs/dapp-ui.

Known limitations (alpha)

The alpha ships with one known dependency you should be aware of. It isn't a blocker for building, but it shapes what "alpha" means in practice and will evolve as Midnight matures.

Proving infrastructure downloads from a Midnight dev URL

On first launch the SDK fetches Midnight's protocol-level proving assets — the universal BLS parameters and the wallet's shielded-spend / Dust circuit keys — from https://midnight-s3-fileshare-dev-eu-west-1.s3.eu-west-1.amazonaws.com. This is the same bucket Midnight's own tooling uses; it's Midnight's bucket, not the SDK maintainer's, and the URL is labeled -dev-. There is no production SLA on it.

What this means in practice:

  • If Midnight retires, renames, or restricts that bucket, every alpha dApp briefly can't generate proofs until the SDK is updated.
  • The right party to publish a production URL is Midnight (the asset owner), not the SDK maintainer. So the alpha documents this dependency rather than self-hosting.

Your own .compact contract's proving keys are NOT affected by this. Those download from a URL you supply when you call ProvingKeyManager.downloadCircuitKeys(baseUrl = …, …) — e.g. BBoard hosts BBoard's keys. You always own your contract's keys. This limitation is only about the protocol-wide keys that every Midnight dApp shares.

How it will evolve. When Midnight publishes a production URL — or, if that's not in their immediate roadmap, when the SDK mirrors these files under a kuiralabs-controlled URL with a version pin (ProvingKeyManager.CURRENT_VERSION tracks the protocol version, currently 9) — the SDK swaps the constant and re-publishes. The expected migration cost for a consumer is a single SDK version bump; no app-side code change.


See also

  • Kuira Starter — clone-and-run minimal dApp (also a GitHub template).
  • BBoard — on-chain bulletin board; the deploy → call → read flow end-to-end.
  • Home — install instructions and the full module list.
  • Security — threat model, vulnerability reporting, signature verification.
  • Maven Central — io.github.kuiralabs — every published artifact (binary AAR, sources jar, javadoc jar, POM, PGP signature).