Oumuamua Labs

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

./gradlew :lib:publishToMavenLocal
// 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:

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.