Transaction Science open standards monorepo. Each subdirectory is a self-contained reference protocol with its own README and licence. - openpay/ Apache-2.0 payment-acceptance stack (Rust, 26 crates) - smart-byte/ CC-BY-4.0 content-addressed value carrier (spec) - eoc/ CC-BY-4.0 energy-optimised compute (spec) The wire format and the right to fork are public. The optional hosted services are the offer; the protocols are not.
18 KiB
Phase 9 — op-ffi-jni Complete (Kotlin / Java / Android JNI bridge)
Status: Draft v0.9 Date: 2026-05-17
What shipped
op-ffi-jni: the bridge that exposes the OpenPay Rust core to
Kotlin / Java / Android callers. Same dual-surface architecture as
Phase 8, different platform mechanics:
- JNI bridge —
#[no_mangle] pub extern "system" fnfunctions namedJava_dev_openpay_<class>_<method>that the JVM dispatches to fromexternal fundeclarations in matching Kotlin classes. Default surface; consumers see idiomatic Kotlin with typed exceptions. - Plain C ABI — identical surface to Phase 8's
c_api. Same function names so a single hand-rolled C++ wrapper can drive both iOS and Android builds — important for shops with a unified cross-platform C++ core.
Plus a KeystoreVault Kotlin class — the real production vault
backed by EncryptedSharedPreferences + the Android Keystore. This is
what merchant apps actually deploy; RustVault is for tests.
Why dual surfaces (again)
Same reasoning as Phase 8: JNI is the idiomatic and best-tested path
for Kotlin/Java consumers and gives them typed exceptions and
auto-loaded .so files. The C ABI is universally consumable and lets
cross-platform C++ codebases share a single wrapper layer.
Why a real KeystoreVault and not just RustVault
RustVault is an in-memory reference vault. Its tokens vanish on app
restart, which is exactly what most merchant apps need least.
Production Android apps want persistent encrypted card-on-file
storage backed by hardware-resident keys. That's what the Android
Keystore provides.
KeystoreVault writes the validated PAN into
EncryptedSharedPreferences, which encrypts under a master key
managed by MasterKey.Builder(...).setKeyScheme(AES256_GCM).build().
On devices with TEE / StrongBox, the master key never leaves secure
hardware. This is the same pattern Stripe-android, Adyen-android,
and Braintree-android use internally.
Verified ground truth
| Claim | Source |
|---|---|
jni = "0.21" is the stable widely-deployed version |
crates.io/crates/jni; Medium "Rust in Android" May 2026 |
jni 0.22 exists but has breaking changes (EnvUnowned, mandatory &mut self); not yet settled |
jni-rs/jni-rs releases page; CHANGELOG.md |
JNI native signature: pub extern "system" fn Java_<pkg>_<class>_<method>(mut env: JNIEnv, _class: JClass, ...) -> jstring |
docs.rs/jni; google.github.io/comprehensive-rust |
env.get_string(&jstr)?.into() for JString→Rust; env.new_string(s)?.into_raw() for Rust→jstring |
Vegapit + Tweede golf + Medium guides 2025-2026 |
env.throw_new(class, msg) for typed exceptions; class names use / not . |
Oracle JNI design spec; jni-rs docs |
cargo install cargo-ndk + rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android |
Android Developers / NDK guides |
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 build --release |
cargo-ndk README; Medium Rust-Android guides |
Output .so goes under app/src/main/jniLibs/<ABI>/ |
Android NDK docs developer.android.com/ndk/guides/abis |
androidx.security:security-crypto deprecated April 2025 at 1.1.0-alpha07 |
github.com/ed-george/encrypted-shared-preferences README; AndroidX deprecation notices |
MasterKey.Builder(ctx).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() is the current Kotlin API |
Android Developers reference; GeeksforGeeks Dec 2025 |
EncryptedSharedPreferences.create(ctx, file, key, AES256_SIV, AES256_GCM) |
same |
dev.spght:encryptedprefs-ktx is the maintained community fork preserving the same API |
ed-george/encrypted-shared-preferences GitHub README |
| Tink 1.18+ (pulled by security-crypto 1.1.x) requires min SDK 23 | same |
| Encrypted prefs MUST be excluded from Auto Backup because Keystore key is device-bound | same; AndroidX docs on EncryptedSharedPreferences |
| AGP 9.1.0 is current stable (Feb 2026); built-in Kotlin support since 9.0; requires Gradle 9.1+ JDK 17+ | developer.android.com/build/releases/agp-9-1-0-release-notes |
| Compile SDK 36 supported by AGP 9.1.1 | same release notes |
Architecture
crates/op-ffi-jni/
├── Cargo.toml — cdylib + rlib; jni 0.21 + op-vault + op-fraud
├── src/
│ ├── lib.rs — #![deny(unsafe_code)] crate root; jni_bridge/c_api with per-module allowance
│ ├── error.rs — FfiError #[repr(i32)] mirroring Phase 8; exception_class() returns JNI slash-form names
│ ├── jni_bridge.rs — Java_dev_openpay_* native functions; handle helpers; throw_ffi_error()
│ ├── c_api.rs — Plain C ABI identical to Phase 8 (same function names: op_*)
│ └── tests.rs — Cross-surface integration tests
├── scripts/
│ └── build-android.sh — cargo-ndk runner for all 4 ABIs; stages .so into jniLibs/
└── kotlin/
├── settings.gradle.kts — AGP 9.1.0 + Gradle 9.1+ + JDK 17
├── build.gradle.kts — root project
├── gradle.properties — useAndroidX, parallel, caching, config-cache
└── openpay/
├── build.gradle.kts — library module; compileSdk 36, minSdk 23
├── consumer-rules.pro — -keepclasseswithmembernames for native methods
└── src/
├── main/
│ ├── AndroidManifest.xml
│ ├── jniLibs/<ABI>/ — empty until build-android.sh stages .so
│ ├── res/xml/backup_rules.xml — example Auto Backup exclusion
│ └── java/dev/openpay/
│ ├── OpenPayException.kt — sealed class hierarchy
│ ├── CardData.kt — AutoCloseable + AtomicLong handle
│ ├── VaultRef.kt — AutoCloseable + AtomicLong handle
│ ├── TokenizationPolicy.kt — data class + enums + helpers
│ ├── Vault.kt — interface + RustVault implementation
│ ├── HeuristicScorer.kt — AutoCloseable wrapper
│ └── KeystoreVault.kt — production vault (does NOT impl Vault)
├── test/java/dev/openpay/
│ └── CoreTest.kt — JVM unit tests (~15 tests)
└── androidTest/java/dev/openpay/
└── KeystoreVaultInstrumentedTest.kt — device/emulator tests (~12 tests)
JNI handle protocol
The cross-boundary state is held in jlong handles. Each Kotlin
wrapper class owns one AtomicLong field:
Kotlin JNI Rust
──────── ───────── ──────────────
new CardData(pan, m, y) ──► nativeNew ──► Box::into_raw(Box::new(CardData::new(...))) as jlong
◄────────────── jlong
card.firstSix ─────────────► nativeFirstSix(h) ─► &*(h as *const CardData) ──► card.first_six()
◄──── &str
◄─ env.new_string(s).into_raw() ─ jstring
card.close() ───────────► nativeFree(h) ──► Box::from_raw(h as *mut CardData) [drop]
Three helpers carry this:
box_to_handle<T>(b: Box<T>) -> jlong— wrap a box for return.unsafe handle_as_ref<'a, T>(h: jlong) -> Option<&'a T>— borrow. ReturnsNonefor the zero handle (Kotlin's "uninitialized" sentinel), saving the caller from having to throw before calling JNI.unsafe handle_drop<T>(h: jlong)— free viaBox::from_raw.unsafe handle_take<T>(h: jlong) -> Option<T>— consume; used bytokenizebecause the CardData must move into the vault and the Kotlin handle must be invalidated.
AtomicLong on the Kotlin side guards against the rare double-close
race. getAndSet(0L) is the only mutation; subsequent reads see
zero and throw InvalidInput.
Throwing exceptions correctly
Per JNI: throwing an exception via env.throw_new(class, msg) does
not abort the native function. The function must return a sensible
default value (0, null, JNI_FALSE) after throwing; the JVM
materializes the exception once the function returns. Subsequent JNI
calls in the same native body would fail because of the pending
exception — you'd need env.exception_clear() first.
throw_ffi_error(env, FfiError, msg):
- Look up the class name from
FfiError::exception_class()(uses/not.). - Call
env.throw_new(...). On success, the JVM has a pending exception ready to fire on return. - On
Err(class not found — e.g., running outside an Android app with stripped resources, or under a host JVM without our Kotlin classes), callenv.exception_clear()then fall back tojava/lang/RuntimeException. We never leave the JVM in an inconsistent state.
KeystoreVault design notes
The architecturally honest design decision: KeystoreVault does
not implement the Vault interface.
The interface requires tokenize(card: CardData), but CardData
deliberately hides PAN bytes from Kotlin — they live inside the Rust
heap behind a pub(crate) accessor. That's the PCI scope boundary
by design. KeystoreVault therefore offers
tokenizeFromString(pan, ...), which:
- Validates the PAN by constructing a transient
CardDataand immediately closing it. - Generates a token id (
tok_v7_+ UUID v4). - Writes a JSON record (
pan,exp_month,exp_year,format,lifetime,ttl_seconds,issued_at,consumed) intoEncryptedSharedPreferences. - Returns a
VaultRef.
Detokenize reads the JSON back, enforces TTL + single-use, and
reconstructs a fresh CardData (which re-runs Luhn — defense in
depth against tampering with the stored record).
This matches what real PSP SDKs do on Android. The interface fiction
that the same Vault shape works on every platform would require
either bundling all crypto inside Rust (forfeiting hardware-backed
keys) or exposing PAN bytes to Kotlin (busting the PCI boundary).
Neither tradeoff is worth it.
Observability invariant: error codes match Phase 8
FfiError discriminants are byte-identical to
op-ffi-swift::FfiError:
| Discriminant | Variant | iOS exception/code | Android exception |
|---|---|---|---|
| 0 | Ok | (n/a) | (n/a) |
| 1 | InvalidInput | swift .invalidInput |
OpenPayException$InvalidInput |
| 2 | VaultLookupFailed | .vaultLookupFailed |
OpenPayException$VaultLookupFailed |
| 3 | TokenExpired | .tokenExpired |
OpenPayException$TokenExpired |
| 4 | TokenAlreadyConsumed | .tokenAlreadyConsumed |
OpenPayException$TokenAlreadyConsumed |
| 5 | FraudDeclined | .fraudDeclined |
OpenPayException$FraudDeclined |
| 6 | FraudReviewRequired | .fraudReviewRequired |
OpenPayException$FraudReviewRequired |
| 7 | Backend | .backend |
OpenPayException$Backend |
| 8 | Internal | (n/a) | base OpenPayException |
| 9 | Capacity | .capacity |
OpenPayException$Capacity |
A single observability backend can correlate failures across platforms by matching the i32 code, which is the whole point of Phase 8 + Phase 9 having identical discriminants.
Oracle discipline preserved across boundary
Phase 7's op-vault collapses NotFound | AuthFailed | InvalidToken
into a single externally-visible discriminant. Phase 8's Swift bridge
preserves it. Phase 9's JNI bridge preserves it too — all three Rust
errors throw OpenPayException.VaultLookupFailed. Tested explicitly
in KeystoreVaultInstrumentedTest.keystoreVault_malformedToken_collapsesToLookupFailed
and in CoreTest.rustVault_malformedToken_alsoCollapsesToLookupFailed.
Build matrix
scripts/build-android.sh runs cargo ndk with four ABI targets:
| Android ABI | Rust target | Notes |
|---|---|---|
arm64-v8a |
aarch64-linux-android |
All current Android devices. Primary. |
armeabi-v7a |
armv7-linux-androideabi |
Pre-2019 devices. Optional but typical. |
x86_64 |
x86_64-linux-android |
Emulator on Intel/AMD dev hosts. |
x86 |
i686-linux-android |
Legacy 32-bit emulator. Included for completeness. |
Default --min-sdk 23 matches Tink's floor. Adjust via
--min-sdk N to raise it; lowering is not supported.
Backup rules (the biggest gotcha)
Android Auto Backup will silently copy the
EncryptedSharedPreferences file to Google's servers and restore it
on a new device. The new device has a different Keystore master
key. Decryption silently fails the first time the user opens the
vault. Tests pass; production crashes.
Mitigation:
- Ship a
backup_rules.xml(we ship one atkotlin/openpay/src/main/res/xml/backup_rules.xml). - Reference it from
AndroidManifest.xmlviaandroid:fullBackupContent="@xml/backup_rules". - On Android 12+, also configure
android:dataExtractionRules.
Documented prominently in kotlin/README.md and at the top of
KeystoreVault.kt.
AndroidX deprecation note
androidx.security:security-crypto was deprecated at 1.1.0-alpha07
in April 2025. The API and underlying Tink crypto are still
maintained; only the wrapper library is in deprecation. Two
go-forward paths, both documented in the Kotlin README:
- Pin to
1.1.0-alpha06(what this module ships). - Switch to
dev.spght:encryptedprefs-ktx— identical API, imports change fromandroidx.security.crypto.*todev.spght.encryptedprefs.*.
The KeystoreVault uses the AndroidX class names; a one-line import change is all that's needed to switch.
Test count
| Module | Rust unit | Kotlin host | Kotlin device | Total |
|---|---|---|---|---|
error.rs |
11 | 11 | ||
c_api.rs |
13 | 13 | ||
jni_bridge.rs |
10 | 10 | ||
tests.rs (cross-surface) |
6 | 6 | ||
Kotlin CoreTest.kt |
16 | 16 | ||
Kotlin KeystoreVaultInstrumentedTest.kt |
13 | 13 | ||
| Phase 9 total | 40 | 16 | 13 | 69 |
Rust tests run under cargo test -p op-ffi-jni. The two Kotlin
suites run under Gradle (./gradlew :openpay:test for the host
JVM tests, ./gradlew :openpay:connectedAndroidTest for the
device tests). Gradle invocations require a host-architecture .so
to be staged on java.library.path; the host tests document this
prerequisite in the README.
Cumulative
| Phase | Tests | LOC |
|---|---|---|
| 1 op-core | 19 | ~600 |
| 2 op-iso20022 | 43 | ~1,400 |
| 3 op-emv | 50 | ~1,800 |
| 4 op-rails-card | 46 | ~2,100 |
| 5 op-rails-a2a | 73 | ~3,200 |
| 6 op-fraud | 65 | ~2,400 |
| 7 op-vault | 51 | ~2,600 |
| 8 op-ffi-swift | 44 | ~2,700 |
| 9 op-ffi-jni | 40 Rust + 29 Kotlin = 69 | ~2,800 |
| Total | ~460 | ~19,600 |
Bugs caught during this phase
-
First draft of
KeystoreVaultimplementedVault. Writing thetokenize(card: CardData)body forced a confrontation with the fact thatCardDatadeliberately hides its PAN bytes. There is no way to read the PAN out of a CardData handle from Kotlin — nor should there be. Resolved by havingKeystoreVaulttake a PAN string directly viatokenizeFromStringand not implementVault. Documented prominently in both the class doc and the README. -
First draft of
CardData.fromHandleallocated a dummy then replaced. The original code went: this("4242...", 12, 2030)to call the public constructor, then atomically swapped the handle. That works but allocates and immediately frees a throwaway Rust box on every detokenize. Resolved by making the primary constructorprivateand taking aLongdirectly; the public PAN-validating constructor is a secondary: this(nativeNew(...)).fromHandleis now an internal companion factory. -
tokenize-from-card onRustVaulthad to consume two handles. First draft passedcard.requireHandle()followed bycard.close(). That worked but left a window where another thread could read the handle. Resolved by addingCardData.consumeHandle()which atomically reads-and-zeros. -
JNI class names must use
/not.. Caught byFfiError::exception_classtest that asserts the path starts withdev/openpay/. The Oracle JNI design spec documents this but it's an easy mistake to make. -
androidx.security:security-cryptodeprecation discovered live. Initially planned to use it without comment. Verified via search the library was deprecated April 2025; documented both the AndroidX-stable path and thedev.spghtfork path prominently in the README and KeystoreVault class doc. -
jni 0.22vs0.21choice. Initially considered usingjni 0.22since it's the latest. Verified via the jni-rs releases page that 0.22 was just released and has significant breaking changes (EnvUnowned+ mandatory&mut selfon most JNIEnv methods). Pinned to 0.21 which is the stable widely-deployed choice and is what the Medium guides from May 2026 and the Android source-tree examples all use.
What's next
Phase 10 — op-wasm: wasm-bindgen surface for the browser. Will
expose vault + scorer to JavaScript via wasm-pack, using the Web
Crypto API where it makes sense.
Phase 11 — op-orchestrator + kiosk-linux + e2e harness:
cross-rail decision logic (route a payment through card → A2A
fallback based on amount, country, scorer output), and an example
unattended-checkout kiosk demonstrating the full stack working
end-to-end on a Linux device.
Skipping the 5.1 / 6.1 / 7.1 sub-phases per the user-approved scope: those are refinement on already-shipped surfaces and don't unblock anything. They become well-scoped maintenance once the architectural completion of Phase 11 lands.