Hekate Kotlin
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.
Read the Hekate Kotlin sources on GitHub →
The wrapper is one suspending extension function. Two jobs and nothing more: hand the multi-second prove to a dispatcher
so it does not block the calling thread, and route coroutine cancellation through the FFI CancelToken so a structured
Job.cancel() actually reaches Rust.
Installation
The .aar is produced by cargo xtask android-build and lives at target/aar/<pkg>-release.aar. Consume it one of two
ways.
JitPack (tag-driven, no local publish step):
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
maven { url = uri("https://jitpack.io") }
}
}
// app/build.gradle.kts
dependencies {
implementation("com.github.oumuamua-labs:hekate-mobile:0.1.0")
}
Local Maven (in-tree development, pre-tag iteration, or air-gapped CI):
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenLocal()
}
}
// app/build.gradle.kts
dependencies {
implementation("dev.oumuamua.hekate:hekate:0.1.0")
}
The .aar already carries both lib<dev>.so and libhekate_prover_cdylib.so under jniLibs/<abi>/. No additional
native artifact needs to ride alongside.
Usage
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
CoroutineScopeis cancelled, ViewModelteardown viaviewModelScopewhen the host activity is destroyed,- an explicit
cancel()from aJobhandle.
Rust's prover loop polls the token at instruction boundaries inside the sumcheck rounds and unwinds with
ProveError.Cancelled. There is no manual cancel-token plumbing on the call site.
The propagation is cooperative on both sides: Rust observes the flag at the next polling boundary, finishes any
in-flight constraint evaluation, 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 impl Air<Block128>,
catch_unwind captures it, formats the panic payload, and returns Err(ProveError::Panic(message)). UniFFI maps that
variant to a typed Kotlin sealed class ProveError:
try {
val output = MyProver.prove(inputs, Dispatchers.Default)
} catch (e: ProveError.Panic) {
// log + surface; the prove call did NOT corrupt your process
} catch (e: ProveError.Cancelled) {
// user-initiated or structured cancel
} catch (e: ProveError) {
// every other typed prover error variant
}
No abort() crosses the FFI boundary. The Rust panic unwinds locally inside catch_unwind, the JNI return frame is
intact, and the Kotlin caller sees a typed exception instead of a SIGABRT that takes down the JVM.