Skip to content
Oumuamua Labs

Hekate Kotlin

On-device zero-knowledge proofs on Android: one suspend Kotlin call runs your Rust prover on a dispatcher you choose, and Job.cancel() reaches Rust.

hekate-mobile 0.3.0 AGPL-3.0-only source kotlin/ Updated kotlin.md
On this page

Async / cancellation facade over the UniFFI-generated Kotlin bindings. Maps Kotlin coroutines (suspend, Job, structured cancellation) onto the synchronous prove(inputs, cancel) C-ABI call that the prover crate exports through UniFFI.

The wrapper is one suspending extension function. Two jobs and nothing more: hand the multi-second prove to a dispatcher you choose, and route coroutine cancellation through the FFI CancelToken to make a structured Job.cancel() actually reach Rust.

§ Installation

The .aar is produced by cargo xtask android-build and lives at target/aar/<pkg>-release.aar. To consume the Kotlin facade through Gradle, publish it from kotlin/ to the local Maven repository:

bash
./gradlew :lib:publishToMavenLocal
build.gradle.ktskotlin
// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        mavenLocal()
    }
}

// app/build.gradle.kts
dependencies {
    implementation("dev.oumuamua.hekate:hekate:<version>")
}

<version> is the version declared in kotlin/lib/build.gradle.kts.

The .aar carries the generated Kotlin bindings, lib<dev>.so and libhekate_prover_cdylib.so. The app adds it as a file dependency with JNA next to it and declares MyProver once over the bindings, as the package README shows.

§ Usage

kotlin
import dev.oumuamua.hekate.prove
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

viewModelScope.launch {
    val output = MyProver.prove(inputs, Dispatchers.Default)
}

That's the surface. No Thread, no Executor, no Handler, no JNI typed-buffer marshalling. The suspend extension hands the synchronous FFI call to the dispatcher and returns control to the coroutine when the proof is ready.

The dispatcher parameter is configurable for a reason. Dispatchers.Default is sized to CPU cores and is shared with every other CPU-bound coroutine in the process. A multi-second prove pinned to Default starves that pool when several proofs run concurrently or alongside other CPU work. For batch proving or background flows, pass Dispatchers.IO ( elastic) or a dedicated newFixedThreadPoolContext("prove", n) sized to the prover's parallelism budget.

§ Cancellation

Job.cancel() on the enclosing coroutine automatically calls CancelToken.request() through suspendCancellableCoroutine.invokeOnCancellation. That covers every form of cancellation the coroutine machinery produces:

  • parent-driven structured cancellation when a CoroutineScope is cancelled,
  • viewModelScope teardown when its ViewModel is cleared,
  • an explicit cancel() from a Job handle.

The Rust prover stops at its next poll of the token, and the coroutine ends with CancellationException, as a cancelled coroutine does. There is no manual cancel-token plumbing on the call site.

The propagation is cooperative on both sides: Rust observes the flag at its next poll and returns. The wrapper's invokeOnCancellation block is the only side that's free, the dispatch thread keeps running the prover until the next poll.

§ Panic isolation

The Rust prove entry point wraps the prover invocation in catch_unwind(AssertUnwindSafe(...)). If the prover panics for any reason, a debug assertion, an integer overflow in debug_assertions, a bug in the dev's circuit or witness builder, catch_unwind captures it, formats the panic payload, and returns Err(ProveError::Panic(message)). UniFFI names the Rust ProveError enum ProveException in Kotlin, a sealed class, and maps that variant to its Panic case:

kotlin
try {
    val output = MyProver.prove(inputs, Dispatchers.Default)
} catch (e: ProveException.Panic) {
    // log + surface; the prove call did NOT corrupt your process
} catch (e: ProveException) {
    // every other typed prover error variant
}

No abort() crosses the FFI boundary. The Rust panic unwinds locally inside catch_unwind, the FFI return frame is intact, and the Kotlin caller sees a typed exception instead of a SIGABRT that takes down the app.

§ Next steps