open-standards/openpay/docs/08-ffi-swift-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

300 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase 8 — `op-ffi-swift` Complete (Swift / iOS / macOS bridge)
**Status**: Draft v0.8
**Date**: 2026-05-17
## What shipped
`op-ffi-swift`: the bridge that exposes the OpenPay Rust core to
Swift / iOS / macOS callers. Dual surface:
1. **`swift-bridge` 0.1.59** — idiomatic Swift API generated from a
single `#[swift_bridge::bridge]` declaration. Default build path.
2. **Plain C ABI** — `extern "C"` functions with raw-pointer ownership.
Fallback for consumers that don't want `swift-bridge-build` in
their Xcode workflow; they hand-roll Swift wrappers via
`@_silgen_name`.
## Why two surfaces
The compact summary distilled this from this turn's reasoning:
swift-bridge is purpose-built for Rust↔Swift and generates idiomatic
Swift directly, but adds a `swift-bridge-build` build dependency that
some teams will not accept (especially in monorepos with strict
build-tool policies). The plain C ABI is universally consumable —
Objective-C, Swift via `@_silgen_name`, C, even AppleScript via
Foundation's C bridge. Shipping both costs ~600 LOC and gives every
consumer a path.
## Why no card / A2A rails
Card and A2A rail crates are heavy on transport dependencies (ureq,
rustls, OAuth, mTLS). Most iOS apps don't submit payments directly —
they hand a tokenized credential to a PSP SDK (Stripe, Adyen, Braintree)
that handles the wire protocol. The Swift bridge therefore exposes
**vault + fraud + token handoff**, not the rail drivers. Apps that
need direct rail submission link the rail crates at the workspace
level and bridge them via a custom extension.
This is the standard architectural pattern in the iOS payment ecosystem:
the merchant app touches a tokenized credential only.
## Verified ground truth
| Claim | Source |
|---|---|
| `swift-bridge` 0.1.59 is latest stable | docs.rs/crate/swift-bridge May 2026 |
| Inspired by cxx-rs; emits idiomatic Swift directly | chinedufn/swift-bridge README |
| Supports `Option<OpaqueRust>`, `Result`, async, opaque types | swift-bridge release notes |
| Target triples: `aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios` (all tier-2) | rustc book platform-support/apple-ios |
| Simulator distinction via `-sim` suffix encoded into LC_BUILD_VERSION | rustc book; Apple Developer Forums thread 678075 |
| `xcodebuild -create-xcframework` is the canonical packaging command | Apple Developer Forums thread 673387 |
| Lipo simulator arches into a fat lib before xcodebuild | Apple Developer Forums; rhonabwy.com 2023 XCFramework guide |
| iOS Keychain `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` is the correct class for background-accessible non-migrating tokens | Apple Developer Documentation |
## Crate layout
```
crates/op-ffi-swift/
├── Cargo.toml # crate-type=[staticlib, cdylib, rlib]
├── build.rs # invokes swift_bridge_build::parse_bridges
├── src/
│ ├── lib.rs # crate root, modules, attrs
│ ├── error.rs # FfiError #[repr(i32)] (11 tests)
│ ├── bridge.rs # swift-bridge module + impls (15 tests)
│ ├── c_api.rs # plain C ABI (13 tests)
│ └── tests.rs # cross-surface integration (5 tests)
├── scripts/
│ └── build-xcframework.sh # full Apple toolchain workflow
└── swift/
├── Package.swift # SwiftPM manifest
├── README.md # consumer integration guide
└── OpenPay/OpenPay.swift # idiomatic Swift wrapper layer
```
## The bridge surface
```rust
#[swift_bridge::bridge]
mod ffi {
// Shared enums
enum FraudDecisionFfi { Approve, Review, Decline, Freeze }
enum TokenFormatFfi { Random, Deterministic }
enum TokenLifetimeFfi { Reusable, SingleUse }
// Shared struct (transparent both sides)
struct TokenizationPolicyFfi {
format: TokenFormatFfi,
lifetime: TokenLifetimeFfi,
ttl_seconds: u64,
}
extern "Rust" {
// CardData
type RustCardData;
#[swift_bridge(associated_to = "RustCardData")]
fn new(pan: &str, exp_month: u8, exp_year: u16) -> Option<RustCardData>;
fn first_six(&self) -> String;
fn last_four(&self) -> String;
fn exp_month(&self) -> u8;
fn exp_year(&self) -> u16;
// VaultRef
type RustVaultRef;
fn as_string(&self) -> String;
// Vault
type RustVault;
#[swift_bridge(associated_to = "RustVault")]
fn ephemeral(name: &str) -> RustVault;
fn tokenize(&self, card: RustCardData, policy: TokenizationPolicyFfi) -> Option<RustVaultRef>;
fn detokenize(&self, token: &RustVaultRef) -> Option<RustCardData>;
fn exists(&self, token: &RustVaultRef) -> bool;
fn delete(&self, token: &RustVaultRef) -> bool;
// Scorer
type RustHeuristicScorer;
#[swift_bridge(associated_to = "RustHeuristicScorer")]
fn default() -> RustHeuristicScorer;
fn name(&self) -> String;
// Per-thread last-error slots
fn last_error_vault(v: &RustVault) -> i32;
fn last_error_card() -> i32;
}
}
```
Wrapper types `RustCardData`, `RustVaultRef`, `RustVault`,
`RustHeuristicScorer` wrap the underlying `op-vault` and `op-fraud`
types. Method bodies live in plain Rust outside the macro. Errors
populate thread-local slots that Swift reads via `last_error_*`.
## Oracle discipline preserved at the FFI boundary
The `FfiError` enum collapses `op_vault::Error::NotFound`,
`AuthFailed`, and `InvalidToken` into a single `VaultLookupFailed`
discriminant — same rule as the vault layer, surfaced consistently to
Swift. A Swift caller cannot probe to distinguish "this token exists
with the wrong key" from "this token never existed." Tested in
`error.rs::vault_not_found_and_auth_failed_collapse_to_lookup_failed`.
## Thread-local last-error pattern
Each surface (bridge, C ABI) has its own `thread_local!` Cell of
`FfiError`. A method that returns `Option<T>` / `bool` / null pointer
on failure writes the discriminant to the thread-local before
returning. Swift / C reads the slot via `last_error_*()` immediately
after a failing call.
Why thread-locals rather than out-parameters: out-parameters would
require Swift to allocate scratch space per call, which is awkward
in the swift-bridge surface where `Option<T>` is the natural Swift
idiom. The C ABI also benefits — Swift's `@_silgen_name` callers
don't need to declare `inout` parameters.
Caveat: an iOS app that mixes UI work (main thread) and a worker
thread for tokenization must be careful to read `last_error_*` on the
same thread that made the failing call. This is a documented
contract in the README.
## C ABI ownership protocol
Documented at the module level in `c_api.rs`. Summary:
- `op_*_new` / `op_*_create` returns a `*mut T` allocated with
`Box::into_raw`. Caller frees via `op_*_free`.
- `op_*_free` on null is a no-op.
- `op_vault_tokenize` **consumes** the `OpCardData` pointer (the
card box is dropped on the Rust side, even on failure, to honor
the ownership contract Swift expects from `swift-bridge`'s API).
- Status-returning calls return `i32`: 0 = success, positive = data,
-1 = error (read `op_last_error`).
- Pointer-returning calls return null on error.
- All strings are NUL-terminated UTF-8, freed via `op_string_free`.
## Build flow
`scripts/build-xcframework.sh` produces `target/OpenPay.xcframework`:
1. `cargo build --target {aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios,aarch64-apple-darwin,x86_64-apple-darwin}` — five `.a` outputs.
2. `lipo -create aarch64-apple-ios-sim.a x86_64-apple-ios.a -output sim-fat/.a` — combine simulator arches.
3. `lipo -create aarch64-apple-darwin.a x86_64-apple-darwin.a -output mac-fat/.a` — combine macOS arches.
4. `xcodebuild -create-xcframework -library aarch64-apple-ios.a -library sim-fat.a -library mac-fat.a -output OpenPay.xcframework`.
5. Copy swift-bridge generated `OpenPay.swift`, `openpay-swift-bridge.h`, and `module.modulemap` into `target/swift/` for SwiftPM consumption.
Verified workflow against the Apple Developer Forums threads on
static library packaging (the canonical pattern is lipo simulator
slices first, then `xcodebuild -create-xcframework` with one library
per platform variant).
## Test coverage
| Module | Tests | Notes |
|---|---|---|
| `error.rs` | 11 | i32 round-trip, oracle collapse, message-non-leakage, all variants |
| `bridge.rs` | 15 | CardData construction (valid/invalid), vault round-trip, NotFound→VaultLookupFailed, malformed token collapse, policy decoding (single-use, ttl=0, deterministic), delete idempotency, exists, scorer name, thread-local isolation, success-clears-error |
| `c_api.rs` | 13 | Card lifecycle, invalid PAN, null PAN, vault round-trip, detokenize unknown, exists+delete, null pointer status returns, scorer lifecycle, string_free null, free null, tokenize null-card consumption, policy decode (single-use, unknown format) |
| `tests.rs` | 5 | Cross-surface coexistence, independent thread-local error slots, ephemeral vault on both surfaces, scorer name consistency, 100-iteration string-free loop |
| **Phase 8 total** | **44** | |
| **Cumulative Phases 1–8** | **391** | |
## Design decisions
### 1. Dual surface (swift-bridge primary, C ABI fallback)
Chose swift-bridge as the default because the deliverable is Swift,
and idiomatic Swift output is what merchant teams actually want to
read. cxx-rs would require an extra C++→Swift hop. Plain C ABI is
parallel for consumers without swift-bridge-build, which costs ~250
extra LOC and gives universal compatibility.
### 2. Thread-local errors over Result types
`swift-bridge` does support `Result<T, OpaqueErr>` returns, but Swift
would see them as throwing functions where the error type is an
opaque Rust struct. We want Swift callers to see a Swift `enum
OpenPayError`, not an opaque handle. Thread-local i32 codes + a
Swift wrapper layer that converts to `OpenPayError` is the
straightforward path.
### 3. No bridged `Vault` trait
We did NOT use `extern "Swift"` to let Swift code implement the Rust
`Vault` trait. The pattern would enable a Swift `KeychainVault` that
plugs into the Rust orchestrator. We considered it and deferred:
- iOS Keychain access is async at scale (background thread, biometric
prompts) and bridging async-Swift to sync-Rust is fragile.
- The accessibility class, biometric policy, and access control flags
are deployment decisions, not library decisions. We would have to
expose a configuration surface that captures all of them, which is
iOS-specific noise in a cross-platform crate.
The path forward (Phase 7.1+): ship a `KeychainVault` Swift class
that implements its own `Vault`-like protocol entirely in Swift, with
the Rust orchestrator calling into it via an `extern "Swift"` block.
Defer until merchant feedback says it's the right factoring.
### 4. Pinned `swift-bridge = "0.1"`
The chinedufn/swift-bridge README explicitly notes that the MSSV
policy is tight ("the short support window is disrupting projects").
We pin to the 0.1 series and treat minor upgrades as project events
rather than dependabot-driven background updates.
### 5. `unsafe_code` at module level
`lib.rs` declares `#![deny(unsafe_code)]` at crate level. The `c_api`
module annotates with `#[allow(unsafe_code)]` because raw-pointer FFI
requires unsafe. The bridge module remains safe code — swift-bridge's
macro generates the FFI code separately, outside of our crate's
namespace. The unsafe footprint is small, audited, and concentrated.
Note: an earlier version used `#![forbid(unsafe_code)]` which cannot
be relaxed at module level. Switched to `deny` for that reason.
## Bugs caught during construction
1. **`forbid(unsafe_code)` blocked the C ABI module.** `forbid` is a
hard limit that no inner `#[allow]` can override. Switched to
`deny` + module-level `#[allow]`.
2. **Initial directory creation used a literal brace pattern.** Bash
without `extglob` interpreted `{src,swift,scripts}` as a directory
name. Corrected.
3. **op-core direct dep was unused.** Pulled transitively through
op-vault and op-fraud. Removed.
4. **Bridge tests' `last_error_card` access was via `crate::bridge`.**
The `pub fn last_error_card()` lives at module scope in `bridge.rs`,
not inside the `mod ffi { ... }` macro block. The integration tests
reference it correctly.
5. **`Scorer` trait import oscillated.** First version called
`Scorer::name(&self.inner)` requiring the trait import; second
removed the trait import in favor of method-syntax; but
`HeuristicScorer` has no inherent `name` method, so the trait must
be in scope. Final: trait imported, called via `self.inner.name()`.
## What's NOT in this phase (explicitly deferred)
- **KeychainVault Swift implementation.** Belongs in consumer code or
a follow-up phase (Phase 7.1+).
- **Apple Pay PKPayment handling.** Apple Pay payment tokens are
network tokens, not PANs. They go to a PSP that resolves the
cryptogram. Out of scope for OpenPay.
- **Async APIs.** swift-bridge supports `async fn`, but the OpenPay
vault surface is synchronous by design (encryption is fast; the
bottleneck is the rail driver which lives outside the FFI). We can
add async wrappers in a future phase if merchant apps need them.
- **Network token bridging.** Visa VTS / Mastercard MDES. These are
rail-driver concerns, not FFI concerns.
- **Orchestrator bridging.** The orchestrator type itself isn't
exposed yet (no separate `op-orchestrator` crate exists). Phase 11
will add this.
## Next: Phase 9 — `op-ffi-jni` (Kotlin / Android)
Same architectural shape as Phase 8 but using the `jni` crate to bridge
to Kotlin/Java. The vault on Android maps to AndroidKeystore via
`EncryptedSharedPreferences` + `MasterKey` (AndroidX Security 1.1.x)
backed by TEE / StrongBox. Same `Vault` trait surface, different
platform adapter.