Skip to content

Latest commit

 

History

History
 
 

ios

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 

README.md

Fedimint iOS SDK

A Swift SDK generated with UniFFI from fedimint-sdk's uniffi feature — the same feature the Android SDK is generated from.

There is no hand-written Swift here (one build-shim file aside, which says so itself). The #[uniffi::export] blocks in fedimint-sdk export that crate's real methods as-is and hand out its own types — Sdk, Federation, Mnemonic, InviteCode, Notes, Ecash, Lightning, Onchain, the seven *Operation handles, SdkError, ErrorCode — so this SDK is a view of that API rather than a copy of it that could drift.

Installing

Add the package by path. There is no URL install yet: Frameworks/ is a build output and is gitignored, so a git tag carries no XCFramework for binaryTarget to resolve — see Publishing for what hosting one would take.

dependencies: [
    // `name:` is load bearing. Without it SwiftPM takes a path dependency's
    // identity from the directory name — `ios` — and the product reference
    // below fails with "unknown package 'FedimintSdk'". The `name:` in this
    // package's own manifest is a display name and does not set identity.
    .package(name: "FedimintSdk", path: "../fedimint-sdk/ios")
],
targets: [
    .target(name: "MyApp", dependencies: [
        .product(name: "FedimintSdk", package: "FedimintSdk")
    ])
]

In Xcode: File → Add Package Dependencies… → Add Local… and pick this directory.

Requires iOS 15+, or macOS 13+ on Apple Silicon — the XCFramework ships no Intel macOS slice, for the reason given beside platforms: in Package.swift. Nothing else is needed at the call site: the system frameworks the native library pulls in (SystemConfiguration, Security and Network) are declared in the XCFramework's own modulemap, and the C++ runtime rocksdb and aws-lc need is linked by the package itself, so there are no "Link Binary With Libraries" entries to add.

Scope

The whole surface of the Rust crate, unfiltered. The entry point opens storage and establishes the seed; Federation carries the rest.

Swift What it does
createFedimintSdk(dataDir:mnemonic:) Opens storage; establishes the seed
sdk.exportMnemonic() The instance's Mnemonic handle, to back up
sdk.preview(invite:) Reads a federation's config without joining
sdk.join(invite:) Joins it, returns a Federation handle
sdk.recover(invite:) / resumeRecovery(id:) / recoveryStatus(id:) Restore a wallet from its seed
Mnemonic.generate() / .fromWords(_:) / mnemonic.words() make / restore / read a seed
InviteCode.parse(code:) / .federationId() / .display() parse / inspect / render a code
Notes.parse(notes:) / .value() / .display() parse / inspect / render ecash

Mnemonic, InviteCode and Notes are opaque handles, not strings — an invite code is a bearer credential, ecash notes are a bearer token, and a seed is a secret the Rust type keeps behind a zeroizing buffer, so none of them is handed to Swift as a loggable value. Build one with its constructor, then pass the handle. Each throws on invalid input, before any real work runs.

The seed is established by createFedimintSdk: pass a Mnemonic to restore, or nil to load the seed the directory already holds — or, over an empty directory, generate and persist a fresh one. sdk.exportMnemonic() reads it back.

The Federation that join returns carries the rest: balance(), balanceUpdates(), capabilities(), meta(), activity(cursor:limit:), operation(id:), and the ecash(), lightning() and onchain() facades (each nil when the federation lacks that module). Their quote → send calls and the receive calls of ecash() and lightning() return operation handles to observe with state(), updates() and awaitFinal(). On-chain receive() is the exception: it returns an address to pay and no operation, because a deposit becomes an operation only once the federation's wallet finds the payment, and onchain().deposits() is where those operations arrive (see Receiving on-chain). The demo app (ios/Demo) drives each of them once.

Using it

import FedimintSdk

// 1. Open the SDK over an app-private directory. `nil` loads the seed already
//    there, or generates one when the directory is empty; pass a Mnemonic
//    (Mnemonic.fromWords(words:)) to restore.
//    Protect it before the first open; see "Protecting the wallet directory".
var dataDir = FileManager.default
    .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
    .appendingPathComponent("fedimint")
let protection: [FileAttributeKey: Any] =
    [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication]
try FileManager.default.createDirectory(
    at: dataDir, withIntermediateDirectories: true, attributes: protection)
try FileManager.default.setAttributes(protection, ofItemAtPath: dataDir.path)
var values = URLResourceValues()
values.isExcludedFromBackup = true
try dataDir.setResourceValues(values)

let sdk = try await createFedimintSdk(dataDir: dataDir.path, mnemonic: nil)

// 2. Show the seed so the user can write it down — `words()` is the deliberate
//    step that takes it out of the SDK's care as plain strings.
let words: [String] = sdk.exportMnemonic().words()

// 3. Show the user what they are about to join.
let invite = try InviteCode.parse(code: inviteString)   // throws on a malformed code
let preview = try await sdk.preview(invite: invite)     // a struct, not an object
print("\(preview.name ?? "unnamed") on \(preview.network)")
print("\(preview.guardians) guardians, modules \(preview.modules)")

// 4. Join, then use the facades it offers.
let federation = try await sdk.join(invite: invite)
let balanceMsats: UInt64 = try await federation.balance()

Every call that touches disk or the network is async throws, so it needs an await and a Task — there is no synchronous variant and no completion-handler variant.

Receiving on-chain

onchain.receive() returns an address to pay, and nothing else exists for it yet: no operation, no operation id and no state. A deposit becomes an operation when the federation's wallet finds the payment, which is once its transaction has the confirmations the federation requires. onchain.deposits() opens a subscription to those operations. Open it before the address is shown: it only yields the deposits found after it was opened, so one opened later can miss a payment that is found right away.

guard let onchain = federation.onchain() else { return }

let deposits = try await onchain.deposits()
let address = try await onchain.receive()
show("Send bitcoin to \(address)")

// Resolves with the next deposit found, to whichever of the wallet's addresses
// it was paid. The deposit's details name that address.
let operation = try await deposits.next()
let details = try await operation.details()
show("Deposit found at \(details.address)")

let updates = operation.updates()
while let state = try await updates.next() {
    switch state {
    case .confirmed(let txid, let sats): show("\(sats) sat found in \(txid)")
    case .claimed(_, _, let msats):      show("credited \(msats / 1_000) sat")
    case .failed(let reason):            show("failed: \(reason)")
    }
}

The first state an application sees is .confirmed, a deposit that was found and is being claimed, or .claimed if it looks later. .claimed and .failed are final. There is no state for an unpaid address, and none for a transaction that is still short of its confirmations.

next() never ends cleanly: it resolves with a deposit, or throws an SdkError whose code() is .federationClosed once the federation is closed or the SDK shut down. Cancelling the Task that awaits it does not cancel the call; see Cancelling a Task.

  • activity(cursor:limit:) lists every deposit found, including ones found while no subscription was open, and operation(id:) finds a deposit by id across restarts. An address nobody has paid has no activity row.
  • receive() promises only an address to pay. Depending on the federation, repeated calls return the same address until a payment to it has been found, or a new address every time. An application must not rely on either.
  • One address is for one payment: ask for a new one after a deposit rather than reusing a paid one. A second payment to an address that was already paid may go undetected for a long time on some federations.

Protecting the wallet directory

The SDK stores the seed phrase unencrypted inside its directory, and it does not protect that directory itself. That is the app's job, and the snippet above does both parts:

  • Data Protection class completeUntilFirstUserAuthentication, and not complete. complete locks the files whenever the device is locked, so an SDK still working in the background, or woken for a notification, fails its next read with .storage. Until-first-unlock keeps the store encrypted from boot until the first unlock. createDirectory's attributes only apply when it creates the directory, hence the separate setAttributes for an existing one.
  • isExcludedFromBackup, or the store goes into iCloud and into computer backups, which may not be encrypted. The written-down seed phrase is the backup. A restored device recovers from it rather than from a copied store.

Keep the directory in Application Support, not Documents, so it never shows up in the Files app. DemoModel.dataDirectory() in Demo/ is the worked example, and Storage in the Rust crate documents the reasoning.

App Store export compliance

App Store Connect asks every upload whether the app uses non-exempt encryption, and holds a TestFlight build until it is answered. ITSAppUsesNonExemptEncryption in Info.plist answers it up front. The demo declares false.

What the SDK brings into an app is standard encryption, not a proprietary scheme: TLS and QUIC to guardians and gateways, BLS threshold signatures for e-cash, and Bitcoin's secp256k1 signatures and hashes. Whether that is exempt for your app, and whether your app adds anything of its own, is a determination each wallet has to make for itself under Apple's export compliance guidance. The demo's false is not that determination.

Error handling

Every fallible call throws SdkError.

The Rust type is crate::Error, and UniFFI's Swift backend does not rename it — so the generated class is literally Error, which is ambiguous with Swift.Error in any file that writes catch let e as Error. SdkError is a type alias for it, declared in Version.swift; use that name. It is the direct counterpart of the import org.fedimint.sdk.Exception as SdkException that android/README.md tells Kotlin callers to write, for exactly the same reason. (Kotlin's backend renames every Rust *Error to *Exception on its own; Swift's renames nothing.)

It carries the Rust ErrorCode as a real Swift enum from code() and the human-readable message from reason():

do {
    _ = try await sdk.join(invite: InviteCode.parse(code: inviteString))
} catch let error as SdkError {
    switch error.code() {
    case .invalidInput:          show("That invite code isn't valid.")
    case .alreadyJoined:         show("You're already in this federation.")
    case .federationUnreachable,
         .timeout:               show("Couldn't reach the federation.")
    case .unsupportedFederation: show("This app can't work with that federation.")
    default:                     show(error.reason())
    }
}

An exception always means the call failed. It never means value moved badly — that distinction is the core convention of the Rust crate.

ErrorCode is #[non_exhaustive] in Rust, so a binding pinned to an older SDK cannot decode a code added since; the generated Swift enum is closed regardless, which is why the default: arm above is not optional. Regenerate the bindings alongside the crate.

Three things that will bite

Quotes are single use. In plain Rust send takes its quote by value, so the first attempt consumes it whatever the outcome. Swift only ever holds a shared handle, so the quote carries the guard instead: the second send on the same quote throws .quoteExpired. A failed attempt may already have moved funds, so the only safe retry is a fresh quote.

Amount and Sats are both UInt64. They are distinct Rust types that cross the boundary as their primitive shape, so the compiler cannot tell them apart for you. Amount is millisatoshis; Sats is satoshis. Only Onchain.quote(address:amount:) takes Sats — everything else takes Amount.

shutdown() is not enough to reopen a directory. It releases the storage lock, but the underlying store stays open until every Sdk and Federation handle over it has actually been dropped, and a createFedimintSdk against the same location before then waits rather than throwing. In Swift a handle is dropped by ARC, so shutdown() alone leaves it alive for the rest of the enclosing scope:

var sdk: Sdk? = try await createFedimintSdk(dataDir: path, mnemonic: nil)
// ... use it ...
try await sdk?.shutdown()
sdk = nil                      // <- this is what actually closes the store

Skipping the release only matters if the same process reopens the same directory; across process restarts, which is the normal case, there is nothing to do. .storageInUse is the other case — a second opener while the first still holds the lock — and that one throws immediately rather than waiting.

Cancelling a Task does not cancel an SDK call

Every export is async, but cancelling the Swift Task awaiting one does nothing to the call itself: it runs to completion and its result is delivered. This is a gap in the Swift that UniFFI 0.32 generates — the Rust side can cancel, the generated Swift never asks it to — and the fix belongs upstream rather than in a local patch to generated code. Until then, treat a call as running once started; a timeout can stop waiting for one, not stop it.

Logs

The SDK routes its own logs, and those of everything underneath it, to unified logging under the subsystem org.fedimint.sdk, starting at the first createFedimintSdk:

log stream --predicate 'subsystem == "org.fedimint.sdk"' --level debug
# or, after the fact
log show --last 10m --predicate 'subsystem == "org.fedimint.sdk"'

The default filter is warn,fm=info,fedimint=info. To change it, set FEDIMINT_SDK_LOG (same syntax as RUST_LOG) in the Xcode scheme's environment, with xcrun simctl launch --setenv FEDIMINT_SDK_LOG=debug …, or in the shell for swift test. The filter in force is logged once at startup as logging to os_log filter=…. Lines below info are logged as private, so they show as <private> unless the device has a logging profile that reveals them.

The filter is read from the environment once, at launch, and nothing on the device can change it afterwards. This is deliberate. On Android, debug.fedimint_sdk.log can be raised by anyone with adb access to the device. Here, raising verbosity takes a build or a launch you control.

A Rust panic is logged there too, with its thread, file and line, as a fault. It still terminates the app: the release build aborts on panic, by design. The log line is what tells you where.

Building

The native library and the Swift are generated, not committed. Building them is two steps, and the split is deliberate — the same split android/README.md describes:

  1. The native libraries. cargo cross-compiles libfedimint_sdk.a for each Apple target. This is the expensive half — rocksdb and aws-lc are built from C — and it is not specific to Swift.
  2. The bindings. uniffi-bindgen reads the UniFFI metadata out of the .a built in step 1, not out of the crate source, so the Swift cannot drift from the binary it will load on the device. This half takes seconds.

Step 1 goes through Nix and Cachix, exactly as Android's does — the derivations live in nix/ffi.nix as fedimint-sdk-ios-<triple>. Nothing compiles locally when the cache is warm.

Because Apple targets compile against proprietary SDKs bundled with Xcode rather than hermetic Nix packages, these derivations use relaxed sandboxing (__noChroot = true via sandbox = relaxed). This allows the build to access the host Xcode toolchain while still producing ordinary, substitutable Nix store paths, enabling true binary caching via Cachix for all Apple slices.

just build-ios-lib-nix    # nix/cachix -> libfedimint_sdk.a per target
just build-swift-bindings # uniffi-bindgen over that .a  -> FedimintSdk.swift
just build-swift          # both, then xcodebuild -create-xcframework
just test-swift           # build-swift, then `swift test` on the macOS slice
just build-ios-demo       # build-swift, then xcodegen + compile the demo

just build-swift-local    # same, but cross-compile with plain cargo instead
just build-ios-lib        # just the cargo half

build-swift-local (scripts/build-ios-sdk.sh --local) is the counterpart of build-android-sdk.sh --local: it cross-compiles with plain cargo in the .#ios shell instead of fetching from Cachix. Both producers write the same tree — per-triple archives, the lipo'd simulator slice, and target/apple-slices.txt — so everything downstream is identical either way.

A full build produces four targets. To iterate faster, narrow it:

IOS_TARGETS="aarch64-apple-darwin" just build-ios-lib   # host slice only

That one slice is enough for swift test, which runs on macOS and needs no simulator. CI (swift-sdk.yaml) does not narrow it: pull requests and the merge queue build all four.

A narrowed build produces a narrowed XCFramework — it does not keep slices from an earlier run. That is deliberate: build-ios-lib.sh records the triples it built in target/apple-slices.txt, and both the bindings step and the XCFramework assembly key off that record rather than off which archives happen to be on disk. Otherwise a populated target/ would let a subset build ship stale machine code, and — worse — let uniffi-bindgen read its metadata out of an archive that is not the one being shipped, which is the exact drift the two-step split exists to prevent. Run a full build when you want a full framework back.

Non-Nix escape hatch: --local cross-compiles with plain cargo and never calls nix, so it works on any machine whose toolchain already has the Apple targets — the counterpart of build-android-sdk.sh --local:

rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
./scripts/build-ios-sdk.sh --local

The --local is required, not decorative: without it the script takes the Nix path and needs nix on PATH, which is exactly what the machine this paragraph describes does not have.

.#ios exists for a machine that lacks the Apple Rust targets, and to pin the cmake/perl/go that aws-lc-sys and rocksdb need. CI does not take this route — ios-native.yaml builds the slices as Nix derivations and pushes them to Cachix, so a pull request substitutes them instead of compiling rocksdb and aws-lc again.

Requires macOS with Xcode either way. xcodebuild -checkFirstLaunchStatus must exit 0 — run xcodebuild -runFirstLaunch once if it does not.

Layout

ios/
├── Package.swift                     SPM manifest
├── README.md
├── Sources/FedimintSdk/
│   ├── FedimintSdk.swift             generated, gitignored
│   └── Version.swift                 the one committed Swift file; says why
├── Frameworks/
│   ├── Headers/                      generated, gitignored
│   │   ├── FedimintSdkFFI.h
│   │   └── module.modulemap
│   └── FedimintSdkFFI.xcframework/   generated, gitignored
│       ├── ios-arm64/                physical iPhone / iPad
│       ├── ios-arm64_x86_64-simulator/
│       └── macos-arm64/              what `swift test` runs against
├── Tests/FedimintSdkTests/           offline tests: no federation, no network
└── Demo/
    ├── project.yml                   XcodeGen spec (committed)
    ├── FedimintDemoApp.swift
    ├── ContentView.swift             every facade, one screen
    └── FedimintDemo.xcodeproj/       generated, gitignored

What's not here yet

No ergonomic Swift layer — no AsyncSequence over the *Updates cursors, no typed wrappers around Amount/Sats, no protocol unifying the seven *Operation classes. That is deliberate: the generated bindings are the API, Kotlin has no such layer either, and adding one on only one platform would put the two SDKs out of step. DECISION.md is where to revisit it.

Publishing

Not wired up yet. swift-sdk.yaml builds the XCFramework, runs the tests and compiles the demo — it does not publish. Shipping this as a versioned SPM package means hosting the XCFramework as a release asset and switching Package.swift's binaryTarget from path: to url:/checksum:; Version.swift's fedimintSdkVersion, which scripts/sdk-version.sh set writes together with the crate's version, names the version for whenever that happens.