- The Rust crate in
rust/is the single source of truth for wallet logic, networking, persistence, and hardware integrations. BDK is the main library powering all things bitcoin-related. - Model the domain correctly before optimizing for a small patch. Durable fixes should represent state, ownership, and invariants explicitly in Rust data structures and persisted records instead of compensating with temporary UI logic, string flags, or caller-specific conditionals.
- SwiftUI (iOS) and Jetpack Compose (Android) UIs talk to the Rust core through “Managers”, lightweight view-models that own the generated FFI objects, subscribe to reconciliation callbacks, and expose platform-friendly state.
- All cross-platform bindings are generated with UniFFI via custom scripts that live in
scripts/and Just recipes;just build-iosandjust build-androidrebuild the Rust core, regenerate bindings, and drop artifacts into the mobile projects.
Getting Started? See CONTRIBUTING.md for setup instructions and development workflow.
Layout. The top-level crate (rust/src/lib.rs) re-exports a collection of domain-focused modules (wallets, routing, hardware, fiat, etc.) plus internal crates under rust/crates/. Everything compiles into libcove.{a,so} and the coveffi cdylib specified in rust/uniffi.toml.
Data model first. Make impossible states impossible. When a feature or bug reveals that the current types do not describe the real domain, fix the model instead of spreading compensating logic through managers or platform code. Prefer typed states, enums, records, and persisted schema that encode valid states directly instead of allowing invalid field combinations. If the model has to change, update the UniFFI API, generated bindings, migrations, and Swift/Kotlin call sites together so every layer shares the same contract.
Internal crates (rust/crates/):
cove-bdk- BDK wallet functionality wrapperscove-bip39- BIP39 mnemonic handlingcove-common- Shared constants and utilitiescove-device- Platform abstraction for keychain and device featurescove-macros- Common Macros used by the other cratescove-nfc- NFC communication protocolscove-tap-card- TAPSIGNER/SATSCARD integrationcove-types- Shared type definitionscove-util- General utilities (formatting, logging, result extensions)uniffi_cli- Custom UniFFI CLI wrapper for binding generation
Async runtime. The core is async-first: a Tokio runtime is initialised from the host app (task::init_tokio) and reused via a global OnceLock. Host apps must invoke FfiApp::init_on_start once after creating the app object (see the .task { await app.rust.initOnStart() } block in ios/Cove/CoveApp.swift) before dispatching actions so background tasks and caches spin up correctly.
Actor system. Long-lived concurrent components use act-zero actors spawned onto the shared Tokio runtime. Use task::spawn_actor() to create actors.
Key actors include:
WalletActor(rust/src/manager/wallet_manager/actor.rs) - Manages wallet state and operationsWalletScanner- Handles blockchain scanning and syncing
Actors are ideal for components that need to process messages sequentially, maintain internal state, and send reconciliation updates back to the UI when work completes.
Responsive actor handlers. Act Zero actors process messages sequentially. An actor method that awaits slow network, wallet scan, cloud backup, filesystem, keychain, or persistence-heavy work inline can keep later actor messages waiting even though the awaited work is async. UI-facing manager actors should keep message handling short: validate inputs, snapshot state, update local authoritative state such as pending or scanning flags, emit immediate reconcile messages when useful, delegate slow work, then apply the result from a later typed actor message.
Use cove_tokio::task::spawn for narrow stateless one-shot work. When async work belongs to an actor and reports back to that same actor, prefer AddrLike::send_fut or send_fut_with over spawning a raw task and manually sending back through a captured address. Use task::spawn_actor() or an existing child actor pattern for queued, cancellable, stateful, retrying, coalesced, or ordered workflows. Existing examples include WalletScanner, WalletScanWorker, TransactionWatcher, and CloudBackupSupervisor.
Workers should report progress or completion through typed messages or an explicitly owned reconcile stream. They should not directly mutate a UI-facing manager's authoritative state or emit that manager's reconcile messages unless they own that state/reconcile path. The manager applies results, persists final state when needed, and emits targeted reconcile messages.
Rust closures are fine for narrow synchronous helpers, local transactions, and test hooks. They become a design smell when a lower-level service accepts a closure that mutates manager state, emits reconcile messages, dispatches actions, or drives a multi-step async workflow. Prefer an owned task or Act Zero actor with typed progress/completion messages so the owning manager remains responsible for state changes and reconciliation.
Singleton pattern. Many core components use singleton patterns for global access, implemented via OnceLock, LazyLock, or ArcSwap. Creating new instances returns cheap clones (typically Arc clones) of the global singleton, similar to how AppManager.shared works on iOS or AppManager.getInstance() on Android. Key singletons include:
Database::global()(rust/src/database.rs) - Database access viaOnceCell<ArcSwap<Database>>. CallingDatabase()from Kotlin/Swift returns anArcclone of the global instance.App::global()(rust/src/app.rs) - Application state and routing coordinator viaOnceCell<App>.FfiApp::global()(rust/src/app.rs) - FFI wrapper for app, always returns a newArc<Self>that accessesApp::global().AUTH_MANAGER(rust/src/manager/auth_manager.rs) - Authentication manager viaLazyLock<Arc<RustAuthManager>>.Keychain::global()(rust/crates/cove-device/src/keychain.rs) - Platform keychain access viaOnceCell, initialized once by the host app.Device::global()(rust/crates/cove-device/src/device.rs) - Device capabilities access viaOnceCell, initialized once by the host app.FIAT_CLIENT(rust/src/fiat/client.rs) - Price fetching client viaLazyLock<FiatClient>.FEE_CLIENT(rust/src/fee_client.rs) - Fee estimation client viaLazyLock<FeeClient>.PRICES&FEES- Thread-safe cached data viaLazyLock<ArcSwap<Option<T>>>for lock-free reads with atomic updates.
This pattern is used throughout the codebase for shared resources and is safe to use from any thread. Platform code mirrors this with AppManager.shared (iOS) and AppManager.getInstance() (Android).
Concurrency primitives. The codebase uses parking_lot::Mutex and parking_lot::RwLock everywhere — use these instead of std::sync equivalents to keep things consistent. Prefer scoped blocks to release locks or borrows instead of explicit drop(...) unless explicit drop is actually needed.
State reconciliation. Each manager module owns a flume channel pair. Rust emits typed …ReconcileMessage enums through the channel, and the generated FFI surface forwards them to the platform reconcilers. Platform managers should call listen_for_updates immediately after instantiating their Rust counterpart (e.g. AppManager in ios/Cove/AppManager.swift) so no reconciliation messages are missed. Long-lived managers (wallet, send flow, auth) also keep shared state in Arc<RwLock<_>> structures so the reconciler can request snapshots. When a manager exposes state(), treat it as an initial snapshot/bootstrap read. Ongoing reconciliation should use typed delta messages for individual fields or events rather than re-sending the whole state after each mutation. If one action updates several fields, compute the changes under the lock, then send the delta messages after releasing the lock.
Dispatching from Rust. When Rust code needs to dispatch AppAction, use DeferredDispatch<T> (rust/src/manager/deferred_dispatch.rs) instead of calling dispatch directly. This queues actions and dispatches them when the struct drops, ensuring dispatch happens after any locks are released and avoiding potential deadlocks. The ffi_dispatch method on FfiApp is intentionally named to discourage direct Rust usage—iOS/Android see it as dispatch via UniFFI renaming.
Routing & application shell. rust/src/app.rs defines App, the singleton that coordinates routing, fees/prices, network selection, and terms acceptance. Its FfiApp wrapper implements the UniFFI object exposed to the UI. Route updates use AppStateReconcileMessage callbacks to keep Kotlin/Swift state in sync.
Persistence. Non-sensitive data is stored with redb (rust/src/database.rs). Database file defaults to $ROOT_DATA_DIR/cove.db where ROOT_DATA_DIR is defined in cove-common/src/consts.rs.
redb stores typed table metadata for each table and validates the key and value TypeName on open_table. Treat changes to TableDefinition, Value::type_name(), persisted database type names, and module paths for persisted types as compatibility-sensitive. std::any::type_name::<T>() uses the type's defining module path, not a public re-export path, so moving a persisted type into or out of a nested module can change on-disk expectations even when serialized bytes stay identical. Preserve exact historical type names or add a compatibility/migration path, and test every install path that could have created the table, including short-lived beta/internal builds. See docs/redb.md for the redb compatibility checklist.
Database tables:
GlobalFlagTable- Feature flags and terms acceptance statusGlobalConfigTable- Network selection, node configuration, color scheme preferencesGlobalCacheTable- Cached data for performance optimizationWalletsTable- Wallet metadata and configurationUnsignedTransactionsTable- Pending transactions awaiting signatureHistoricalPriceTable- Historical price data for fiat conversions
Keychain. Secrets are stored in OS-native keychains via the KeychainAccess trait (rust/crates/cove-device/src/keychain.rs). This is a UniFFI callback interface that platform code implements - iOS provides the implementation using the iOS Keychain, and Android uses Android KeyStore. Rust code accesses it via Keychain::global(), which internally delegates to the platform implementation. The keychain supports encryption/decryption through the Cryptor from cove-util.
Wallet & hardware integrations. BDK powers transaction management (rust/src/wallet, rust/src/transaction). TAPSIGNER/SATSCARD + NFC flows live in rust/src/tap_card and the dedicated crates under rust/crates/. The utilities crate (cove-util) concentrates helpers such as result extensions, formatting, and logging.
Error conversions. Prefer From implementations for error conversions whenever possible, and avoid standalone conversion functions when From would do. This keeps conversion call sites idiomatic and lets ? perform the conversion directly.
rust/uniffi.tomlnames the shared library (coveffi) and the Kotlin package (org.bitcoinppl.cove_core).cargo run -p uniffi_cliinvokes the custom CLI wrapper that ships with this repo (rust/crates/uniffi_cli). The CLI understands both Swift and Kotlin targets and emits consistent module names (cove_core_ffi,cove.kt).- Binding generation flow:
- First, bindings are generated into
rust/bindings/(intermediate, temporary location) - Swift: Copied from
rust/bindings/*.swift→ios/CoveCore/Sources/CoveCore/generated/ - Kotlin: Copied from
rust/bindings/kotlin/→android/app/src/main/java/org/bitcoinppl/cove_core/ - The build scripts (
scripts/build-ios.sh,scripts/build-android.sh) handle this copying automatically
- First, bindings are generated into
Android-specific notes:
- UniFFI automatically transforms Rust error types ending in
ErrortoExceptionwhen generating Kotlin bindings (e.g.,SendFlowErrorbecomesSendFlowException). This is standard Kotlin convention where exceptions extendkotlin.Exception. - Rust enum variants use tuple-style (unnamed fields), which UniFFI translates to generic
v1,v2,v3field names in Kotlin (e.g.,RouteUpdated(Vec<Route>)becomesdata class RouteUpdated(val v1: List<Route>)). In contrast, struct-style variants with named fields preserve those names (e.g.,WrongNetwork { address: String, validFor: Network, current: Network }becomesdata class WrongNetwork(val address: String, val validFor: Network, val current: Network)). - Kotlin enum variant name collisions: Avoid naming an enum variant the same as a type that a method returns. In Kotlin, UniFFI generates enums as sealed classes where variants become nested data classes. If an enum has both a variant named
Fooand a methodfn foo() -> Option<Foo>(returning a differentFootype), Kotlin resolvesFoowithin the sealed class scope to the variant, not the external type, causing a compile error. Solution: use distinct variant names (e.g.,SignedPsbtinstead ofPsbtwhen there's also aPsbttype). - Data structures, UniFFI-derived Rust types, and exported API shapes may change when that directly serves the requested work. Do not avoid those changes just to keep the diff small; when exported Rust APIs change, regenerate the bindings and update all affected Swift and Kotlin call sites.
- When you change any exported API (new method, enum, record), rebuild bindings through the
justrecipes described below so the mobile projects pick up the new code.
We aim for shared structure and terminology across platforms (same manager names, reconcile shapes, etc.) while still embracing each platform's native idioms for UI, navigation, typography, and interactions. Think "consistent architecture, platform-native experience."
Platform-specific UI examples:
- Settings rows: iOS favors inset grouped lists with
NavigationLinkchevrons, while Android uses full-width Material list items with ripple feedback and trailing metadata icons instead of chevrons. - Toggles: SwiftUI
Togglemirrors the iOS switch with a circular thumb and elastic animation; Compose usesSwitchwith a rectangular track, Material colors, and larger touch ripples. - Typography: iOS leans on SF Pro text styles (Title, Body, Footnote) and tighter letter spacing; Android uses Material 3
TitleLarge,BodyMedium, etc., aligning baseline grids to 4dp spacing. - Background treatments: iOS often uses blurred/grouped surfaces floating above a tinted system background; Android prefers flat
colorSurfacebackgrounds with tonal elevation for cards or sections so dynamic color and dark theme transitions stay consistent.
- The Swift Package
ios/CoveCorewraps the generated bindings.scripts/build-ios.shcreates an XCFramework (cove_core_ffi.xcframework) and deposits generated Swift sources into the package. AppManager(ios/Cove/AppManager.swift) is a singleton@Observableclass that ownsFfiApp, manages routing, and lazily instantiates other managers (wallet, send flow, etc.). Each manager wraps itsRust…Managercounterpart, registers itself as a reconciler, and updates SwiftUI-observable state on the main actor.- UI modules inject managers with
@Environmentor direct initialisers and calldispatch(...)for Rust-side actions. Reconcilers run updates on the main actor to keep SwiftUI safe.
- Compose screens obtain managers via
remember { ImportWalletManager() }or DI and interact with them the same way SwiftUI does. The generated bindings (android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt) expose suspending functions and listener hooks. - Manager injection: When iOS uses
@Environmentto inject managers, Android should pass managers directly as composable parameters. Derive state from managers and dispatch actions through them—avoid wrapping in state holder data classes. This keeps Android code structurally close to iOS while remaining idiomatic Compose. - Each Kotlin manager implements the generated
FfiReconcileinterface and creates its own lifecycle-aware coroutine scope (Dispatchers.Main+SupervisorJob). Ininit, managers create their Rust counterpart, calllistenForUpdates(this), and implementreconcile(...)to update Compose state or emit side-effects. - Shared navigation mirrors the Rust router:
RouterManagerlistens forRouteUpdatedevents from the core and reconciles Compose navigation stacks.
State management patterns:
- Use callbacks, not MutableState parameters: While iOS uses
@Bindingextensively for two-way state binding, Android/Compose follows the "state down, events up" pattern with callbacks. Composables should accept value parameters (e.g.,value: String) and callback parameters (e.g.,onValueChange: (String) -> Unit), neverMutableState<T>parameters. - Why callbacks? This follows official Android guidelines, matches standard library components (
TextField,Switch, etc.), maintains unidirectional data flow, and makes previews easier. The codebase uses callbacks in 99% of components. - Bidirectional sync: When a child component needs to both read and write parent state, use
LaunchedEffect(parentValue) { childValue = parentValue }to sync changes from parent to child, and callbacks to notify parent of child changes. While this creates boilerplate (~8 lines per field), it's the idiomatic Android pattern. - Example pattern:
@Composable fun MyComponent( value: String, // state down onValueChange: (String) -> Unit, // events up ) { var localValue by remember { mutableStateOf(value) } LaunchedEffect(value) { localValue = value } // sync parent → child TextField( value = localValue, onValueChange = { localValue = it onValueChange(it) // notify parent } ) }
Manager ownership and cleanup: Managers obtained via app.getWalletManager() or app.getSendFlowManager() are owned by AppManager—components should not call .close() on them or clear them from route-level DisposableEffect cleanup. Compose navigation can keep an outgoing route alive while the incoming route has already reused the same cached manager, so route disposal is not the same thing as the flow ending. App-owned managers should be released by the app/session owner when the full route stack no longer contains the owning flow or wallet. Only close managers created locally (e.g., CoinControlManager, ImportWalletManager, TapSignerManager). For short-lived managers like LabelManager, use .use { } for single operations or DisposableEffect with .close() if stored in state. Database() returns an Arc clone of a global singleton and doesn't need closing.
Generated manager handles: Platform UI should call Kotlin/Swift manager wrapper methods instead of reaching into generated UniFFI objects such as manager.rust.*. Android managers should keep generated handles private, keep constructors that receive them internal, and route Rust access through wrapper methods backed by the shared RustHandleGuard, so close-after-dispose races become safe no-ops or controlled failures without scattering lifecycle checks through call sites.
iOS ↔ Android parity patterns: For detailed guidance on matching behavior across platforms (opacity, text colors, button centering, NFC scanning UI, etc.), see docs/ios_android_parity.md.
iCloud Drive, passkey, and KeyTeleport notes: Before changing iCloud Drive discovery, file coordination, passkey registration, passkey presence checks, Cloud Backup passkey confirmation, or KeyTeleport secret handling, read docs/icloud_drive.md, docs/passkeys.md, and docs/keyteleport.md.
- UI calls
manager.dispatch(action)or a helper method (e.g.importWallet). - The Swift/Kotlin manager forwards the call to
Rust…Managerthrough the generated bindings. - Rust mutates state, enqueues reconciliation messages, and optionally writes to redb or the keychain.
- The reconcilers on the UI thread receive the message and update observable state, triggering re-render.
This pattern keeps business logic and validation centralized in Rust while giving each platform idiomatic state containers.
- New manager / feature flow: Create a Rust manager module under
rust/src/manager/, define its state, actions, and reconcile messages, and export it with#[uniffi::export]. Implement the matching Swift/Kotlin manager classes that conform to the generated…Reconcilerprotocol/interface. - Routing additions: Extend
rust/src/router.rsenums, expose necessary helpers onFfiApp, and update theRouterManageron both platforms to handle the new routes. - Database schema changes: Update the relevant table builders under
rust/src/database/. Because redb uses typed tables, add migration logic or regenerate tables as needed, and expose read/write helpers through UniFFI. - Async work: Prefer spawning onto the shared Tokio runtime via
task::spawn. If the work belongs to a long-lived component, consider using anact-zeroactor so you can push reconciliation messages back to the UI on completion.
- Rust exports live in
rust/src/**and the supporting crates underrust/crates/. - Swift bindings land in
ios/CoveCore/Sources/CoveCore/generated/; Kotlin bindings live (after copy) underandroid/app/src/main/java/org/bitcoinppl/cove_core/. - Database file defaults to
$ROOT_DATA_DIR/cove.db(seecove_common::consts::ROOT_DATA_DIR). - Hardware / NFC helpers:
rust/crates/cove-device,rust/src/tap_card/, and platform shims inios/Cove/FFI/plus Android'sorg.bitcoinppl.cove_corepackage.
SwiftUI has a bug on iOS 26 where ToolbarPlacementEnvironment.updateValue() enters an infinite loop during _UINavigationParallaxTransition (back button / swipe back) when a .principal toolbar item exists at large accessibility font sizes (specifically accessibilityExtraExtraLarge). This causes 100% CPU freeze.
Workaround: For screens that need custom title content (icons, context menus, dynamic colors), use .navigationTitleView { } from ios/Cove/Views/NavigationTitleView.swift. This hosts SwiftUI content inside UIKit's navigationItem.titleView, bypassing SwiftUI's toolbar placement system entirely. For screens with plain static text titles, use .navigationTitle("Title").navigationBarTitleDisplayMode(.inline) instead.