open-standards/openpay/docs/09-ffi-jni-progress.md
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
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.
2026-05-23 07:58:09 -04:00

362 lines
18 KiB
Markdown

# 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:
1. **JNI bridge** — `#[no_mangle] pub extern "system" fn` functions
named `Java_dev_openpay_<class>_<method>` that the JVM dispatches
to from `external fun` declarations in matching Kotlin classes.
Default surface; consumers see idiomatic Kotlin with typed
exceptions.
2. **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.
Returns `None` for the zero handle (Kotlin's "uninitialized" sentinel),
saving the caller from having to throw before calling JNI.
- `unsafe handle_drop<T>(h: jlong)` — free via `Box::from_raw`.
- `unsafe handle_take<T>(h: jlong) -> Option<T>` — consume; used by
`tokenize` because 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)`:
1. Look up the class name from `FfiError::exception_class()` (uses
`/` not `.`).
2. Call `env.throw_new(...)`. On success, the JVM has a pending
exception ready to fire on return.
3. On `Err` (class not found — e.g., running outside an Android app
with stripped resources, or under a host JVM without our Kotlin
classes), call `env.exception_clear()` then fall back to
`java/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:
1. Validates the PAN by constructing a transient `CardData` and
immediately closing it.
2. Generates a token id (`tok_v7_` + UUID v4).
3. Writes a JSON record (`pan`, `exp_month`, `exp_year`, `format`,
`lifetime`, `ttl_seconds`, `issued_at`, `consumed`) into
`EncryptedSharedPreferences`.
4. 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:
1. Ship a `backup_rules.xml` (we ship one at
`kotlin/openpay/src/main/res/xml/backup_rules.xml`).
2. Reference it from `AndroidManifest.xml` via
`android:fullBackupContent="@xml/backup_rules"`.
3. 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:
1. Pin to `1.1.0-alpha06` (what this module ships).
2. Switch to `dev.spght:encryptedprefs-ktx` — identical API,
imports change from `androidx.security.crypto.*` to
`dev.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
1. **First draft of `KeystoreVault` implemented `Vault`.** Writing
the `tokenize(card: CardData)` body forced a confrontation with
the fact that `CardData` deliberately 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 having `KeystoreVault` take a
PAN string directly via `tokenizeFromString` and not implement
`Vault`. Documented prominently in both the class doc and the
README.
2. **First draft of `CardData.fromHandle` allocated 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 constructor `private` and taking a `Long` directly;
the public PAN-validating constructor is a secondary `: this(nativeNew(...))`.
`fromHandle` is now an internal companion factory.
3. **`tokenize`-from-card on `RustVault` had to consume two
handles.** First draft passed `card.requireHandle()` followed
by `card.close()`. That worked but left a window where another
thread could read the handle. Resolved by adding
`CardData.consumeHandle()` which atomically reads-and-zeros.
4. **JNI class names must use `/` not `.`.** Caught by
`FfiError::exception_class` test that asserts the path starts
with `dev/openpay/`. The Oracle JNI design spec documents this
but it's an easy mistake to make.
5. **`androidx.security:security-crypto` deprecation 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 the `dev.spght` fork path
prominently in the README and KeystoreVault class doc.
6. **`jni 0.22` vs `0.21` choice.** Initially considered using
`jni 0.22` since 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 self` on 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.