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.
14 KiB
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:
swift-bridge0.1.59 — idiomatic Swift API generated from a single#[swift_bridge::bridge]declaration. Default build path.- Plain C ABI —
extern "C"functions with raw-pointer ownership. Fallback for consumers that don't wantswift-bridge-buildin 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
#[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_*_createreturns a*mut Tallocated withBox::into_raw. Caller frees viaop_*_free.op_*_freeon null is a no-op.op_vault_tokenizeconsumes theOpCardDatapointer (the card box is dropped on the Rust side, even on failure, to honor the ownership contract Swift expects fromswift-bridge's API).- Status-returning calls return
i32: 0 = success, positive = data, -1 = error (readop_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:
cargo build --target {aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios,aarch64-apple-darwin,x86_64-apple-darwin}— five.aoutputs.lipo -create aarch64-apple-ios-sim.a x86_64-apple-ios.a -output sim-fat/.a— combine simulator arches.lipo -create aarch64-apple-darwin.a x86_64-apple-darwin.a -output mac-fat/.a— combine macOS arches.xcodebuild -create-xcframework -library aarch64-apple-ios.a -library sim-fat.a -library mac-fat.a -output OpenPay.xcframework.- Copy swift-bridge generated
OpenPay.swift,openpay-swift-bridge.h, andmodule.modulemapintotarget/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
forbid(unsafe_code)blocked the C ABI module.forbidis a hard limit that no inner#[allow]can override. Switched todeny+ module-level#[allow].- Initial directory creation used a literal brace pattern. Bash
without
extglobinterpreted{src,swift,scripts}as a directory name. Corrected. - op-core direct dep was unused. Pulled transitively through op-vault and op-fraud. Removed.
- Bridge tests'
last_error_cardaccess was viacrate::bridge. Thepub fn last_error_card()lives at module scope inbridge.rs, not inside themod ffi { ... }macro block. The integration tests reference it correctly. Scorertrait import oscillated. First version calledScorer::name(&self.inner)requiring the trait import; second removed the trait import in favor of method-syntax; butHeuristicScorerhas no inherentnamemethod, so the trait must be in scope. Final: trait imported, called viaself.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-orchestratorcrate 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.