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.
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.
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.
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.
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, andoperation(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.
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 notcomplete.completelocks 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'sattributesonly apply when it creates the directory, hence the separatesetAttributesfor 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 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.
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.
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 storeSkipping 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.
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.
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.
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:
- The native libraries.
cargocross-compileslibfedimint_sdk.afor each Apple target. This is the expensive half — rocksdb and aws-lc are built from C — and it is not specific to Swift. - The bindings.
uniffi-bindgenreads the UniFFI metadata out of the.abuilt 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 halfbuild-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 onlyThat 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 --localThe --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.
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
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.
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.