diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift index 527aebc084d..4016e7dfd92 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift @@ -71,3 +71,22 @@ public final class PersistentShieldedNote { self.lastUpdated = now } } + +public extension PersistentShieldedNote { + /// Predicate for one wallet's unspent (spendable) notes — the + /// rows whose `value` sum IS that wallet's shielded balance. + /// Single home for the filter so every balance surface agrees on + /// what "unspent" means. + static func unspentPredicate(walletId: Data) -> Predicate { + #Predicate { + $0.walletId == walletId && $0.isSpent == false + } + } + + /// Predicate for ALL wallets' unspent notes; callers scope by + /// walletId in memory (multi-wallet surfaces like the identity + /// funding picker). + static var unspentPredicate: Predicate { + #Predicate { $0.isSpent == false } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformWalletManager+ShieldedDisplay.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformWalletManager+ShieldedDisplay.swift new file mode 100644 index 00000000000..7e020c7f6fa --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformWalletManager+ShieldedDisplay.swift @@ -0,0 +1,28 @@ +// PlatformWalletManager+ShieldedDisplay.swift +// SwiftExampleApp +// +// App-side display convenience over the per-wallet shielded engine +// lookup. Kept in the app (not the SDK) because it composes two SDK +// calls for UI display; the SDK stays persist/load/bridge-only. + +import Foundation +import SwiftDashSDK + +extension PlatformWalletManager { + /// Bech32m-encoded default Orchard payment address for `walletId` + /// (`account`), or `nil` when the wallet has no engine-bound + /// shielded sub-wallet yet (or the wallet is unknown to this + /// manager). Single home for the resolve-then-encode pattern the + /// per-wallet shielded surfaces share. + func shieldedDisplayAddress( + walletId: Data, + account: UInt32 = 0, + network: Network + ) -> String? { + guard let raw = try? shieldedDefaultAddress( + walletId: walletId, + account: account + ) else { return nil } + return DashAddress.encodeOrchard(rawBytes: raw, network: network) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedEngineBindPlan.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedEngineBindPlan.swift new file mode 100644 index 00000000000..f5223a22f71 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedEngineBindPlan.swift @@ -0,0 +1,47 @@ +// ShieldedEngineBindPlan.swift +// SwiftExampleApp +// +// Pure iteration seam for the multi-wallet shielded engine-bind that +// `SwiftExampleAppApp.rebindWalletScopedServices()` performs. Extracted +// so the "engine-bind every OTHER loaded wallet" contract can be +// unit-tested without a configured `PlatformWalletManager` (whose +// `bindEngine` path calls into FFI and needs a live handle). + +import Foundation + +/// Invoke `bindEngine` once for every wallet id in `allWalletIds` +/// EXCEPT `mirrorWalletId` (the app-level `firstWallet`, which is +/// engine-bound separately via `ShieldedService.bind(...)` when the UI +/// mirror is attached). +/// +/// The per-id closure is best-effort and independent: it must not +/// throw (each `ShieldedService.bindEngine` already swallows its own +/// errors), but even if a caller passes a throwing closure — as the +/// tests do to simulate one wallet's missing mnemonic — a failure for +/// one id must NOT stop the remaining ids from being bound. Each thrown +/// error is caught and dropped so the loop always visits every +/// non-mirror wallet. +/// +/// Order is not significant to the coordinator (it iterates +/// registrations each sync tick), so the caller may pass ids in any +/// order. +/// +/// `@MainActor`-isolated because the only caller +/// (`rebindWalletScopedServices`) is, and the `bindEngine` closure it +/// passes touches `@MainActor` `ShieldedService` state — keeping the +/// helper on the main actor lets that call be synchronous with no +/// actor hop under Swift 6 strict concurrency. +@MainActor +func engineBindOtherWallets( + allWalletIds: some Sequence, + mirrorWalletId: Data, + bindEngine: (Data) throws -> Void +) { + for walletId in allWalletIds where walletId != mirrorWalletId { + // Independent + best-effort: one id's failure can't block the + // rest. `ShieldedService.bindEngine` never throws in production; + // the `try?`-style catch here is belt-and-braces for the test + // seam and any future throwing binder. + try? bindEngine(walletId) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index f60eb18becb..59d109eee6b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -1,12 +1,26 @@ // ShieldedService.swift // SwiftExampleApp // -// Display-state surface for the Rust-owned shielded (Orchard) sync -// coordinator. The service binds to a single wallet, subscribes to -// the platform-wallet manager's shielded sync events, and exposes -// `@Published` properties for the UI. It does not own any of the -// shielded crypto: bind, sync, and persistence all live on the Rust -// `platform-wallet` side. +// Single UI mirror + multi-engine-bind for the Rust-owned shielded +// (Orchard) sync coordinator. +// +// The service mirrors exactly ONE wallet — the app-level +// `firstWallet` — for the GLOBAL Sync-status surface: `bind(...)` +// attaches the published mirror (`boundWalletId`, `shieldedBalance`, +// subscriptions, timing) to that wallet and drives the Sync tab. It +// does not own any of the shielded crypto: bind, sync, and +// persistence all live on the Rust `platform-wallet` side. +// +// `bindEngine(...)` is the additive companion used by +// `rebindWalletScopedServices()` to engine-register EVERY OTHER +// loaded wallet into the same network-scoped coordinator (no mirror +// repoint). A single shielded sync pass then trial-decrypts against +// the union of all wallets' viewing keys and routes note hits to each +// wallet's own persister (SH-14/15/16 cross-wallet flows). Per-wallet +// receive addresses and balances are read on demand +// (`walletManager.shieldedDefaultAddress(walletId:)`, +// `PersistentShieldedNote` rows) rather than from this singleton +// mirror. import Foundation import SwiftUI @@ -353,6 +367,71 @@ class ShieldedService: ObservableObject { } } + /// Register `walletId`'s shielded sub-wallet with the Rust + /// coordinator WITHOUT repointing this service's display mirror. + /// + /// `bind(...)` attaches the single UI mirror (boundWalletId, + /// shieldedBalance, subscriptions, …) to exactly one wallet — the + /// app-level `firstWallet`. `bindEngine(...)` is the additive + /// companion: it engine-binds EVERY OTHER loaded wallet into the + /// same network-scoped coordinator so a single shielded sync pass + /// trial-decrypts against the union of all wallets' viewing keys and + /// routes note hits to each wallet's own persister. Per-wallet + /// receive addresses and balances are then read on demand + /// (`walletManager.shieldedDefaultAddress(walletId:)`, + /// `PersistentShieldedNote` rows) rather than from this singleton + /// mirror. + /// + /// Best-effort and independent per wallet: a missing mnemonic / + /// declined resolver for one wallet logs and returns without + /// affecting the others or the mirror. Idempotent — safe to call + /// every rebind pass (`configureShielded` no-ops on the same path; + /// `bindShielded` replaces that wallet's registration). + /// + /// Returns whether the engine registration succeeded; existing + /// callers may ignore it. + @discardableResult + func bindEngine( + walletManager: PlatformWalletManager, + walletId: Data, + network: Network, + resolver: MnemonicResolver, + accounts: [UInt32] = [0] + ) -> Bool { + let dbPath = Self.dbPath(for: network) + let sortedAccounts = Array(Set(accounts)).sorted() + + // No "already bound" fast path on purpose: the only cheap probe, + // `shieldedDefaultAddress`, reflects the wallet-level sub-wallet + // binding — which SURVIVES `clearShielded` (Clear drops only the + // coordinator registrations; there is no sub-wallet unbind FFI). + // Skipping on that signal would silently leave post-Clear wallets + // unregistered (sync passes would never scan them again). Coordinator + // registration has no cheap query, so we always re-bind; the + // mnemonic read + ZIP-32 re-derivation is low-millisecond per wallet + // and rebind fires are rare (wallet-set change, network switch, + // Sync Now). + do { + try walletManager.configureShielded(dbPath: dbPath) + try walletManager.bindShielded( + walletId: walletId, + resolver: resolver, + accounts: sortedAccounts + ) + SDKLogger.log( + "Shielded engine-bound: walletId=\(walletId.prefix(4).map { String(format: "%02x", $0) }.joined())… network=\(network.networkName) accounts=\(sortedAccounts)", + minimumLevel: .medium + ) + return true + } catch { + SDKLogger.log( + "Shielded engine-bind failed for walletId=\(walletId.prefix(4).map { String(format: "%02x", $0) }.joined())…: \(error.localizedDescription)", + minimumLevel: .medium + ) + return false + } + } + /// Re-bind the singleton service to a different wallet using the /// `walletManager` / `resolver` / `network` stashed by the first /// `bind(...)`. Per-detail-view code paths call this when the @@ -421,12 +500,47 @@ class ShieldedService: ObservableObject { resolver: resolver, accounts: accounts ) - // `bind` is best-effort; if it failed (e.g. the - // mnemonic resolver was declined), `isBound` stays - // false and `lastError` is populated. Bail rather - // than chain a sync that will fail the same way. - guard isBound else { return } + // Mirror bind is best-effort — on failure `lastError` is + // already populated by `bind(...)`, and we still run the + // engine pass below so other wallets with intact mnemonics + // re-register (a mirror-only failure must not dark the whole + // fleet). + } + + // Re-register any loaded wallet that lost its engine binding — see + // prior comment (post-Clear recovery): `clearShielded` drops EVERY + // wallet, and a detail-view `switchTo` in between re-binds only the + // wallet being viewed, so the recovery branch above may not even + // run. This runs on every Sync Now: with no cheap + // coordinator-registration probe (see `bindEngine`), each pass + // re-derives every other wallet's keys (low-millisecond per wallet) + // — the price of correct post-Clear re-registration. Track whether + // ANYTHING is registered: if the mirror bind failed AND no other + // wallet bound, a sync pass would skip every wallet and produce a + // meaningless result over the bind error the user needs to see. + var anyWalletRegistered = isBound + if let mirrorWalletId = boundWalletId, let resolver, let network { + engineBindOtherWallets( + allWalletIds: walletManager.wallets.keys, + mirrorWalletId: mirrorWalletId + ) { otherWalletId in + if bindEngine( + walletManager: walletManager, + walletId: otherWalletId, + network: network, + resolver: resolver + ) { + anyWalletRegistered = true + } + } } + // Nothing registered (mirror failed + every other bind failed, or no + // bind credentials at all — the Sync Now button is disabled when + // `!canResume`, so this mainly covers the all-binds-failed case): + // bail rather than chain a sync that would skip every wallet — + // preserves the pre-existing "don't chain a sync that will fail the + // same way" intent, per-wallet-ized. + guard anyWalletRegistered else { return } isSyncing = true lastError = nil @@ -601,6 +715,34 @@ class ShieldedService: ObservableObject { // registries and the next sync re-saves notes via // the changeset path. Best-effort — failure logs but // doesn't abort the wipe. + // + // Re-binding scope after Clear: `clearShielded` drops + // EVERY wallet (not just the mirror's `firstWallet`) + // from the coordinator. "Sync Now" (`manualSync()`) + // UNCONDITIONALLY re-registers EVERY loaded wallet on each + // tap: the mirror wallet via `bind(...)` (in the recovery + // branch, only when unbound), and every OTHER loaded wallet + // via a `engineBindOtherWallets` / `bindEngine` pass that + // runs on every Sync Now regardless of the recovery branch. + // That unconditional pass matters because a detail-view + // `switchTo` between Clear and Sync Now re-binds only the + // viewed wallet (flipping `isBound` true and skipping the + // recovery branch), which would otherwise leave the other + // wallets engine-unregistered. The pass is also best-effort + // across the mirror: a mirror-bind FAILURE (missing mnemonic + // / declined resolver) no longer bails Sync Now — the engine + // pass still runs so every OTHER wallet with an intact + // mnemonic re-registers, and Sync Now only bails when NOTHING + // registered (mirror + every other bind failed). So + // cross-wallet shielded flows (SH-14/15/16) come back + // immediately on the first post-Clear Sync Now, not only on + // the next `rebindWalletScopedServices()` fire. + // `rebindWalletScopedServices` remains the recovery path for + // wallets loaded LATER (a wallet added after the Clear isn't + // in the manager's set at Sync-Now time); it re-`bindEngine`s + // every wallet on any wallet-set change or network switch. We + // keep the WIPE scope global on purpose (see the class-level + // doc below) — this note is about the re-BIND scope. if let managerForStop { do { try managerForStop.clearShielded() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index b4894ef145f..9e37e0d7b66 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -725,9 +725,15 @@ class SendViewModel: ObservableObject { // to self-shield only. let enteredRecipient = recipientAddress .trimmingCharacters(in: .whitespacesAndNewlines) - let ownShieldedAddress = - shieldedService.addressesByAccount[0] - ?? shieldedService.orchardDisplayAddress + // Resolve THIS wallet's own default Orchard address from + // the engine rather than the single-mirror + // `shieldedService` (which tracks `firstWallet`). Every + // loaded wallet is engine-bound, so `shieldedDefaultAddress` + // resolves for the wallet actually being sent from. + let ownShieldedAddress = walletManager.shieldedDisplayAddress( + walletId: wallet.walletId, + network: network + ) if !enteredRecipient.isEmpty, enteredRecipient != ownShieldedAddress { // Don't advertise "leave it blank": a blank recipient diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift index 245a6e7d1c9..bc581d73e9d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift @@ -6,7 +6,7 @@ import SwiftData struct AccountListView: View { let wallet: PersistentWallet @EnvironmentObject var walletManager: PlatformWalletManager - @EnvironmentObject var shieldedService: ShieldedService + @EnvironmentObject var platformState: AppState @Query private var accounts: [PersistentAccount] @@ -65,16 +65,26 @@ struct AccountListView: View { } /// Bound shielded accounts to render in their own section - /// below the Core / Platform accounts. Empty until - /// `ShieldedService.bind` has populated the list — which - /// happens once per wallet detail open. + /// below the Core / Platform accounts. Empty until this wallet's + /// engine binding lands (`rebindWalletScopedServices`). private var shieldedAccountsForThisWallet: [UInt32] { - // Gate on the service's currently-bound wallet id so navigating - // between wallet details doesn't briefly show the *previous* - // wallet's shielded accounts before the singleton service - // finishes rebinding to this wallet. - guard shieldedService.boundWalletId == wallet.walletId else { return [] } - return shieldedService.boundAccounts + // Engine-bound wallets expose account 0 by default. Resolve + // per-wallet from the engine (via `shieldedAddress(for:)`) rather + // than the single UI mirror so the section shows for ANY loaded + // wallet, not just `firstWallet`. + shieldedAddress(for: 0) != nil ? [0] : [] + } + + /// Bech32m Orchard receive address for `account` on the viewed + /// wallet, resolved per-wallet from the engine (rather than the + /// single UI mirror's `addressesByAccount`). `nil` until this + /// wallet's bind lands or if encoding fails. + private func shieldedAddress(for account: UInt32) -> String? { + walletManager.shieldedDisplayAddress( + walletId: wallet.walletId, + account: account, + network: platformState.currentNetwork + ) } var body: some View { @@ -118,7 +128,7 @@ struct AccountListView: View { ForEach(shieldedAccountsForThisWallet, id: \.self) { account in ShieldedAccountRowView( accountIndex: account, - address: shieldedService.addressesByAccount[account] + address: shieldedAddress(for: account) ) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift index cd35002476d..b940a64c979 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift @@ -14,7 +14,6 @@ struct ReceiveAddressView: View { @Environment(\.openURL) private var openURL @EnvironmentObject var walletManager: PlatformWalletManager @EnvironmentObject var platformState: AppState - @EnvironmentObject var shieldedService: ShieldedService let wallet: PersistentWallet /// The single primary BIP44 account for this wallet. @@ -99,6 +98,18 @@ struct ReceiveAddressView: View { return best } + /// Orchard receive address for THE WALLET BEING VIEWED (account 0), + /// resolved per-wallet from the engine rather than the single-mirror + /// `shieldedService`. Every loaded wallet is engine-bound + /// (`rebindWalletScopedServices`), so `shieldedDefaultAddress` + /// resolves for any of them; `nil` until this wallet's bind lands. + private var shieldedReceiveAddress: String? { + walletManager.shieldedDisplayAddress( + walletId: wallet.walletId, + network: platformState.currentNetwork + ) + } + private var currentAddress: String { switch selectedTab { case .core: @@ -108,7 +119,7 @@ struct ReceiveAddressView: View { return nextPlatformReceiveAddress?.address ?? "No Platform receive address available yet — create a wallet after enabling Platform address persistence." case .shielded: - return shieldedService.orchardDisplayAddress ?? "Not available" + return shieldedReceiveAddress ?? "Not available" } } @@ -145,7 +156,7 @@ struct ReceiveAddressView: View { case .platform: return nextPlatformReceiveAddress != nil case .shielded: - return shieldedService.orchardDisplayAddress != nil + return shieldedReceiveAddress != nil } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index 9f27c215072..f068a062b69 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -52,6 +52,11 @@ struct SendTransactionView: View { /// credits". @Query private var syncStates: [PersistentPlatformAddressesSyncState] + /// This wallet's unspent shielded (Orchard) notes. Summed into + /// `shieldedBalance` below so the shielded source row reflects THIS + /// wallet's own pool, not the single-mirror `shieldedService`. + @Query private var shieldedNotes: [PersistentShieldedNote] + init(wallet: PersistentWallet) { self.wallet = wallet _viewModel = StateObject(wrappedValue: SendViewModel(network: wallet.network ?? .testnet)) @@ -65,6 +70,9 @@ struct SendTransactionView: View { $0.networkRaw == walletNetworkRaw } ) + _shieldedNotes = Query( + filter: PersistentShieldedNote.unspentPredicate(walletId: walletId) + ) } var body: some View { @@ -456,8 +464,14 @@ struct SendTransactionView: View { .reduce(0) { $0 + $1.confirmed } } + /// Per-wallet shielded balance: sum of THIS wallet's unspent + /// `PersistentShieldedNote` values (Rust pushes note rows via the + /// shielded persister). Reads SwiftData rather than the + /// single-mirror `shieldedService.shieldedBalance`, so the shielded + /// send source is correct for a non-`firstWallet` wallet whose + /// engine binding is live but whose UI mirror is pointed elsewhere. private var shieldedBalance: UInt64 { - shieldedService.shieldedBalance + shieldedNotes.reduce(0) { $0 + $1.value } } /// Mirrors `WalletDetailView.platformBalance`: BLAST-synced diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 7f05ccee830..06bb2c6578c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -982,6 +982,7 @@ struct BalanceCardView: View { @Query private var addressBalances: [PersistentPlatformAddress] @Query private var syncStates: [PersistentPlatformAddressesSyncState] + @Query private var shieldedNotes: [PersistentShieldedNote] init( wallet: PersistentWallet, @@ -1003,6 +1004,19 @@ struct BalanceCardView: View { _syncStates = Query( filter: #Predicate { $0.networkRaw == walletNetworkRaw } ) + _shieldedNotes = Query( + filter: PersistentShieldedNote.unspentPredicate(walletId: walletId) + ) + } + + /// Per-wallet shielded balance: sum of this wallet's unspent + /// `PersistentShieldedNote` values. Reads SwiftData (Rust pushes + /// note rows via the shielded persister) rather than the single-mirror + /// `shieldedService.shieldedBalance`, so the card is correct for a + /// non-`firstWallet` wallet whose engine binding is live but whose UI + /// mirror is pointed elsewhere. + private var shieldedBalance: UInt64 { + shieldedNotes.reduce(0) { $0 + $1.value } } /// Confirmed core-chain balance summed from Rust's in-memory @@ -1088,7 +1102,7 @@ struct BalanceCardView: View { var body: some View { let totalCore = confirmedBalance + unconfirmedBalance - let allZero = totalCore == 0 && platformBalance == 0 && shieldedService.shieldedBalance == 0 + let allZero = totalCore == 0 && platformBalance == 0 && shieldedBalance == 0 VStack(spacing: 12) { if allZero { @@ -1144,7 +1158,7 @@ struct BalanceCardView: View { // → pool (Type 15, `shieldedShield`). WalletBalanceRow( label: "Shielded Balance", - amount: shieldedService.shieldedBalance, + amount: shieldedBalance, color: .purple, unit: .credits, showSyncIndicator: shieldedService.isSyncing, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 765421952d4..99d8459348e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -251,6 +251,39 @@ struct SwiftExampleAppApp: App { network: platformState.currentNetwork, resolver: shieldedResolver ) + + // Engine-bind every OTHER loaded wallet into the shared + // network-scoped shielded coordinator. `firstWallet` above + // already drives the UI mirror AND its own engine + // registration via `bind(...)`; this loop registers the + // remaining wallets so a single shielded sync pass + // trial-decrypts against the union of every wallet's viewing + // keys (SH-14/15/16 cross-wallet flows). Each bind is + // best-effort + independent — one wallet's missing mnemonic + // must not block the others. Reading each mnemonic is a + // device-unlock-only keychain read (no biometric prompt), so + // eager binding at startup is safe. The iteration seam is a + // pure free function (`engineBindOtherWallets`) so its + // "visit every non-mirror wallet" contract can be + // unit-tested without a configured manager. + // + // Runs BEFORE the shielded/DashPay start calls below: + // engine-binding must not depend on those fallible calls — a + // throw there (e.g. `startShieldedSync` failing) must not + // leave the non-mirror wallets unbound for the rest of the + // session. + engineBindOtherWallets( + allWalletIds: walletManager.wallets.keys, + mirrorWalletId: wallet.walletId + ) { otherWalletId in + shieldedService.bindEngine( + walletManager: walletManager, + walletId: otherWalletId, + network: platformState.currentNetwork, + resolver: shieldedResolver + ) + } + if try !walletManager.isShieldedSyncRunning() { try walletManager.startShieldedSync() } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index 215336b15ee..35c84f8622b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -52,11 +52,6 @@ struct CreateIdentityView: View { @Environment(\.modelContext) private var modelContext @EnvironmentObject var walletManager: PlatformWalletManager @EnvironmentObject var platformState: AppState - /// Display-state mirror of the Rust-owned shielded sync. Injected - /// at the app root (`SwiftExampleAppApp.swift`). Binds ONE wallet - /// at a time — the `.shieldedBalance` funding option is only - /// offered when its `boundWalletId` matches the selected wallet. - @EnvironmentObject var shieldedService: ShieldedService /// Default number of Platform identity keys to register in this /// first-pass flow. The Rust-owned per-slot policy @@ -211,6 +206,17 @@ struct CreateIdentityView: View { @Query(sort: [SortDescriptor(\PersistentAssetLock.updatedAt, order: .reverse)]) private var allAssetLocks: [PersistentAssetLock] + /// All wallets' unspent shielded notes. The shielded funding + /// picker spans multiple wallets, so this uses the all-wallets + /// predicate and `shieldedPoolBalance(for:)` scopes by walletId in + /// memory — a single-walletId `@Query` couldn't serve every row + /// the picker needs. Being a `@Query` (rather than a one-off + /// `modelContext.fetch`) makes the funding option and denomination + /// list update live: rows arriving while this sheet is open + /// re-render the view. + @Query(filter: PersistentShieldedNote.unspentPredicate) + private var unspentShieldedNotes: [PersistentShieldedNote] + // MARK: - Selection state /// The source wallet selection. `nil` encodes "pick nothing yet"; @@ -619,7 +625,7 @@ struct CreateIdentityView: View { } if showShielded { let shieldedText = Self.formatDash( - raw: shieldedService.shieldedBalance, + raw: shieldedPoolBalance(for: walletId), divisor: Double(Self.creditsPerDash) ) Text("Shielded Balance — \(shieldedText)") @@ -717,15 +723,21 @@ struct CreateIdentityView: View { /// path. The Type-20 transition spends one of the versioned /// denominations (`Self.shieldedIdentityCreateDenominations`), not /// a free-form amount — so this replaces the amount field. Only - /// denominations the bound shielded pool can actually cover - /// (`<= shieldedService.shieldedBalance`) are offered. + /// denominations the selected wallet's shielded pool can actually + /// cover (`<= shieldedPoolBalance(for:)`) are offered. @ViewBuilder private var shieldedDenominationSection: some View { + // Per-wallet pool balance for the selected source wallet. This + // section only renders when `fundingSelection == .shieldedBalance`, + // which requires a wallet selection, so `selectedWalletId` is + // non-nil here; the `?? 0` is a defensive fallback that yields an + // empty `affordable` list rather than a crash. + let poolBalance = selectedWalletId.map { shieldedPoolBalance(for: $0) } ?? 0 // Denominations the pool can cover. Computed off the live - // `shieldedService.shieldedBalance` so the list shrinks as the - // pool drains (e.g. after a prior shielded spend this session). + // per-wallet pool balance so the list shrinks as the pool + // drains (e.g. after a prior shielded spend this session). let affordable = Self.shieldedIdentityCreateDenominations - .filter { $0 <= shieldedService.shieldedBalance } + .filter { $0 <= poolBalance } Section { if affordable.isEmpty { // Defensive: the option is gated on `shieldedBalance > 0`, @@ -761,7 +773,7 @@ struct CreateIdentityView: View { Text("Denomination") } footer: { let available = Self.formatDash( - raw: shieldedService.shieldedBalance, + raw: poolBalance, divisor: Double(Self.creditsPerDash) ) Text( @@ -994,7 +1006,7 @@ struct CreateIdentityView: View { // shielded path isn't `.unusedAssetLock`). guard shieldedOptionAvailable(for: walletId) else { return false } guard let denomination = selectedDenomination else { return false } - return denomination <= shieldedService.shieldedBalance + return denomination <= shieldedPoolBalance(for: walletId) } return false default: @@ -1740,20 +1752,56 @@ struct CreateIdentityView: View { } /// Whether the `.shieldedBalance` funding option should be offered - /// for `walletId`. Requires, ALL of: - /// - the wallet is the one currently bound to `ShieldedService`, - /// - that service reports the wallet as bound (`isBound`), - /// - the bound shielded pool has a positive balance, + /// for `walletId`. Requires ALL of: + /// - the wallet is engine-bound (its default Orchard address + /// resolves), so ANY loaded+bound wallet qualifies — not just + /// the single UI mirror (`boundWalletId`), + /// - this wallet's own unspent pool balance > 0, /// - a fallback platform address exists (Type-20 requires it). /// Gating visibility on the fallback lets `submit` guard without a /// force-unwrap and surface a clear error path-free. private func shieldedOptionAvailable(for walletId: Data) -> Bool { - shieldedService.boundWalletId == walletId - && shieldedService.isBound - && shieldedService.shieldedBalance > 0 + // Engine-bound (address resolves) + this wallet's own unspent + // pool balance > 0 + a Type-20 fallback platform address exists. + // No longer gated on the single UI mirror (`boundWalletId`), so + // ANY loaded+bound wallet can fund from its own pool. + let engineBound = (try? walletManager.shieldedDefaultAddress( + walletId: walletId, + account: 0 + )) != nil + return engineBound + && shieldedPoolBalance(for: walletId) > 0 && shieldedFallbackAddressBytes(for: walletId) != nil } + /// Per-wallet shielded pool balance: sum of `walletId`'s unspent + /// `PersistentShieldedNote` values, filtered in memory from the + /// `unspentShieldedNotes` `@Query` (rather than the single-mirror + /// `shieldedService.shieldedBalance`). Correct for ANY loaded + /// wallet, not just the mirror's `firstWallet`. + /// + /// Reactive via `@Query`: note rows arriving while this sheet is + /// open re-render the view, so the funding option and denomination + /// list stay live (vs. the previous one-off `modelContext.fetch`, + /// which froze the balance at first render). One shared query + /// materialized once per render also removes the per-call-site + /// FetchDescriptor round-trip. + private func shieldedPoolBalance(for walletId: Data) -> UInt64 { + unspentShieldedNotes + .filter { $0.walletId == walletId } + .reduce(0) { $0 + $1.value } + } + + /// The wallet id currently chosen in the source-wallet picker, or + /// `nil` for the walletless / unselected states. Lets standalone + /// funding sub-sections (`shieldedDenominationSection`) resolve the + /// per-wallet shielded pool without each threading `walletId` + /// through the `@ViewBuilder` call chain. + private var selectedWalletId: Data? { + if case .wallet(let walletId) = walletSelection { return walletId } + return nil + } + /// Derive + Keychain-persist the DashPay encryption/decryption /// key pair (kid `firstKeyId` = ENCRYPTION, kid `firstKeyId+1` = /// DECRYPTION), build the matching `IdentityPubkey` rows with diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PersistentShieldedNotePredicateTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PersistentShieldedNotePredicateTests.swift new file mode 100644 index 00000000000..188e698c789 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PersistentShieldedNotePredicateTests.swift @@ -0,0 +1,84 @@ +import SwiftData +import XCTest +@testable import SwiftDashSDK +@testable import SwiftExampleApp + +/// Coverage for the `PersistentShieldedNote` unspent-note predicate +/// helpers — the single home every shielded-balance surface uses to +/// agree on what "unspent" means (per-wallet `@Query` filters in the +/// wallet detail / send views; the all-wallets variant filtered in +/// memory by the identity funding picker). +@MainActor +final class PersistentShieldedNotePredicateTests: XCTestCase { + + private func walletId(_ byte: UInt8) -> Data { + Data(repeating: byte, count: 32) + } + + /// Insert three notes across two wallets — one of them spent — and + /// assert both predicate variants filter as documented: the + /// per-wallet predicate returns only that wallet's UNSPENT rows, + /// and the all-wallets predicate returns every wallet's unspent + /// rows (leaving the spent one out in both cases). + func testUnspentPredicatesFilterByWalletAndSpentFlag() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + + let walletA = walletId(0xAA) + let walletB = walletId(0xBB) + + // Wallet A: one unspent (value 100) + one spent (value 999, + // must be excluded everywhere). + context.insert(makeNote(walletId: walletA, nullifier: 0x01, isSpent: false, value: 100)) + context.insert(makeNote(walletId: walletA, nullifier: 0x02, isSpent: true, value: 999)) + // Wallet B: one unspent (value 50). + context.insert(makeNote(walletId: walletB, nullifier: 0x03, isSpent: false, value: 50)) + try context.save() + + // Per-wallet predicate: only wallet A's UNSPENT row. + let walletANotes = try context.fetch( + FetchDescriptor( + predicate: PersistentShieldedNote.unspentPredicate(walletId: walletA) + ) + ) + XCTAssertEqual(walletANotes.count, 1) + XCTAssertEqual(walletANotes.first?.value, 100) + + // All-wallets predicate: both unspent rows, spent one dropped. + let allUnspent = try context.fetch( + FetchDescriptor( + predicate: PersistentShieldedNote.unspentPredicate + ) + ) + XCTAssertEqual(allUnspent.count, 2) + // In-memory per-wallet scoping (the identity funding picker's + // pattern) sums the right subset. + XCTAssertEqual( + allUnspent.filter { $0.walletId == walletA }.reduce(0) { $0 + $1.value }, + 100 + ) + XCTAssertEqual( + allUnspent.filter { $0.walletId == walletB }.reduce(0) { $0 + $1.value }, + 50 + ) + } + + private func makeNote( + walletId: Data, + nullifier: UInt8, + isSpent: Bool, + value: UInt64 + ) -> PersistentShieldedNote { + PersistentShieldedNote( + walletId: walletId, + accountIndex: 0, + position: 0, + cmx: Data(repeating: 0, count: 32), + nullifier: Data(repeating: nullifier, count: 32), + blockHeight: 1, + isSpent: isSpent, + value: value, + noteData: Data(repeating: 0, count: 115) + ) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ShieldedEngineBindPlanTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ShieldedEngineBindPlanTests.swift new file mode 100644 index 00000000000..01a1605898d --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ShieldedEngineBindPlanTests.swift @@ -0,0 +1,113 @@ +import XCTest +@testable import SwiftExampleApp + +/// Coverage for `engineBindOtherWallets` — the pure iteration seam +/// behind the multi-wallet shielded engine-bind in +/// `SwiftExampleAppApp.rebindWalletScopedServices()`. +/// +/// The app engine-binds EVERY loaded wallet into the shared +/// network-scoped shielded coordinator so a single sync pass +/// trial-decrypts against the union of all wallets' viewing keys +/// (SH-14/15/16 cross-wallet flows). The mirror wallet (`firstWallet`) +/// is bound separately via `ShieldedService.bind(...)`, so this seam +/// binds every OTHER wallet. +/// +/// The real `ShieldedService.bindEngine` calls into FFI and needs a +/// configured `PlatformWalletManager`, so the loop logic — "visit every +/// non-mirror id" and "one id's failure doesn't stop the rest" — is +/// factored into this pure helper and tested with a recording closure. +/// +/// `@MainActor` because `engineBindOtherWallets` is (its production +/// `bindEngine` closure touches `@MainActor` `ShieldedService` state). +@MainActor +final class ShieldedEngineBindPlanTests: XCTestCase { + + private func id(_ byte: UInt8) -> Data { + Data(repeating: byte, count: 32) + } + + /// Every non-mirror wallet is engine-bound exactly once; the mirror + /// wallet is skipped (it's bound separately via the UI-mirror path). + func testBindsEveryWalletExceptMirror() { + let mirror = id(0x01) + let others = [id(0x02), id(0x03), id(0x04)] + let all = [mirror] + others + + var bound: [Data] = [] + engineBindOtherWallets(allWalletIds: all, mirrorWalletId: mirror) { walletId in + bound.append(walletId) + } + + XCTAssertEqual( + Set(bound), + Set(others), + "every non-mirror wallet must be engine-bound" + ) + XCTAssertFalse( + bound.contains(mirror), + "the mirror wallet is bound separately and must be skipped here" + ) + XCTAssertEqual( + bound.count, + others.count, + "each non-mirror wallet must be bound exactly once" + ) + } + + /// A throwing bind for ONE wallet must not stop the others — the + /// production requirement that one wallet's missing mnemonic / + /// declined resolver can't dark every other wallet's shielded state. + func testOneFailureDoesNotStopTheRest() { + let mirror = id(0x01) + let failing = id(0x03) + let all = [mirror, id(0x02), failing, id(0x04)] + + struct BindError: Error {} + var attempted: [Data] = [] + engineBindOtherWallets(allWalletIds: all, mirrorWalletId: mirror) { walletId in + attempted.append(walletId) + if walletId == failing { throw BindError() } + } + + // Every non-mirror wallet is still ATTEMPTED even though the + // middle one threw. + XCTAssertEqual( + Set(attempted), + Set([id(0x02), failing, id(0x04)]), + "a throwing bind for one wallet must not skip the remaining wallets" + ) + } + + /// A single-wallet device (only the mirror loaded) binds nothing + /// extra — the mirror's own engine registration is the UI-mirror + /// path's job, not this seam's. + func testSingleMirrorWalletBindsNothing() { + let mirror = id(0x01) + + var bound: [Data] = [] + engineBindOtherWallets(allWalletIds: [mirror], mirrorWalletId: mirror) { walletId in + bound.append(walletId) + } + + XCTAssertTrue( + bound.isEmpty, + "with only the mirror wallet loaded there is nothing else to engine-bind" + ) + } + + /// The mirror is skipped even when it is not the first element of + /// the sequence — `Dictionary.Keys` has no defined order, so the + /// skip must be by identity, not position. + func testMirrorSkippedRegardlessOfPosition() { + let mirror = id(0x05) + let all = [id(0x02), id(0x05), id(0x08)] // mirror in the middle + + var bound: [Data] = [] + engineBindOtherWallets(allWalletIds: all, mirrorWalletId: mirror) { walletId in + bound.append(walletId) + } + + XCTAssertEqual(Set(bound), Set([id(0x02), id(0x08)])) + XCTAssertFalse(bound.contains(mirror)) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index 498068c6fe3..24ad1e6961a 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -135,7 +135,7 @@ The app is a full multi-wallet client: `PlatformWalletManager` holds N wallets c | CORE-18 | Per-wallet isolation (identities / addresses / balances / shielded) | Core | Thorough | ✅ | multiwallet | Confirm wallet A's identities, addresses, Core/Platform balances and shielded state never surface under wallet B (`@Query` predicates filtered by `walletId`). Key correctness check for multi-wallet. | | CORE-19 | Send between two on-device wallets | Core | Thorough | ✅ | multiwallet | Normal send from wallet A to wallet B's receive address (no intra-app picker — you must paste/scan B's address). B's balance increases after sync. Variants: identity→identity (`ID-04`) or shielded between two local wallets. | | CORE-20 | Concurrent SPV sync across all wallets | Core | Thorough | ✅ | multiwallet | One SPV runtime per network filters every wallet's addresses; `spvProgress` is manager-global, not per-wallet. With 2+ wallets, confirm each reaches the tip and detects its own funds. | -| CORE-21 | Multiple wallets bound to the shielded pool concurrently | Shielded | Uncommon | ✅ | multiwallet | `platform_wallet_manager_bind_shielded` is per `wallet_id`; the manager syncs all bound wallets. UI (`ShieldedService.boundWalletId`) displays one wallet's shielded state at a time — switching should swap cleanly, not merge balances. | +| CORE-21 | Multiple wallets bound to the shielded pool concurrently | Shielded | Uncommon | ✅ | multiwallet | `platform_wallet_manager_bind_shielded` is per `wallet_id`; the manager syncs all bound wallets. Every loaded wallet is now engine-bound automatically at rebind (`rebindWalletScopedServices` → `ShieldedService.bindEngine`). The global Sync tab mirror (`ShieldedService.boundWalletId`) still displays one wallet's shielded state at a time, but per-wallet Receive addresses and Balance rows now read each wallet directly (`shieldedDefaultAddress(walletId:)` / `PersistentShieldedNote`), so they no longer merge or mask non-mirror wallets. | | CORE-22 | Re-add a previously deleted wallet (same network) | Core | Uncommon | ✅ | multiwallet | After `CORE-17`, re-import the same mnemonic on the same network. Re-derives the same (network-scoped) `wallet_id`, re-creates the wallet, and must re-discover identities/addresses/balances cleanly — no stale Keychain keys or orphaned SwiftData rows left over from the delete. Verify the wallet is fully functional again, not a half-restored duplicate. | | CORE-23 | Re-add a deleted wallet that also exists on another network | Core | Uncommon | ✅ | multiwallet | Same mnemonic present as a wallet on two networks (e.g. testnet + devnet) → **distinct** network-scoped `wallet_id`s, each with its own Keychain mnemonic copy. Delete it on network X (`CORE-17`) and verify the network-Y wallet is untouched (still listed, mnemonic intact, functional); then re-add on X and confirm both coexist. Exercises the `walletRowCountAcrossNetworks` cross-network mnemonic-purge guard in `PlatformWalletManager.deleteWallet`. | @@ -270,11 +270,11 @@ Shielded notes/balance/activity have **no read-side FFI** by design — Rust pus | SH-09 | Prover warm-up / readiness | Shielded | Common | ✅ | | `warmUpShieldedProver` / `shieldedProverIsReady` (~30s Halo2 key build; precondition for spends). | | SH-10 | Seed shielded pool (anonymity set) | Shielded | Uncommon | ✅ | | `SeedShieldedPoolView` → `platform_wallet_manager_shielded_seed_pool_notes`. **Devnet/testnet only** — hard-errors on mainnet. | | SH-11 | Create identity from shielded pool (Type 20) | Cross | Common | ✅ | | `CreateIdentityView` → funding source **Shielded balance** (fixed denominations 0.1 / 0.3 / 0.5 / 1.0 DASH, gated on the bound pool's balance) → `IdentityRegistrationController` (`.shieldedPool`) → `shieldedIdentityCreateFromPool` → `platform_wallet_manager_shielded_identity_create_from_pool`. Requires a synced shielded pool with sufficient balance. | -| SH-12 | Clear shielded state (wipe notes + re-sync) | Shielded | Uncommon | ✅ | | "Clear" button on the Sync tab (`CoreContentView` → `ShieldedService.clearLocalState` → `clearShielded`). Stops sync, wipes every wallet's shielded notes + sync state, zeroes the Swift mirror; bind credentials are kept so "Sync Now" rebinds and re-scans. (On-disk SQLite tree is intentionally retained.) Verify balance/activity reset, then restore after Sync Now. | +| SH-12 | Clear shielded state (wipe notes + re-sync) | Shielded | Uncommon | ✅ | | "Clear" button on the Sync tab (`CoreContentView` → `ShieldedService.clearLocalState` → `clearShielded`). Stops sync, wipes every wallet's shielded notes + sync state, zeroes the Swift mirror; bind credentials are kept so "Sync Now" rebinds and re-scans. "Sync Now" after Clear now re-binds EVERY loaded wallet (the mirror via `bind`, others via `engineBindOtherWallets`), so cross-wallet rows (`SH-14/15/16`) work right after an SH-12 run without an app restart. (On-disk SQLite tree is intentionally retained.) Verify balance/activity reset, then restore after Sync Now. | | SH-13 | Display / share your shielded receive address | Shielded | Common | ✅ | read-only | "Receive Dash" sheet → **Shielded** tab (`ReceiveAddressView`, `ReceiveAddressTab.shielded`): QR + full `tdash1…`/`dash1…` bech32m address + Copy Address. Hand your shielded address to a payer, or grab wallet B's address for `SH-14`. | -| SH-14 | Shielded transfer between two on-device wallets | Shielded | Thorough | ✅ | multiwallet | Wallet A's pool → wallet B's shielded address (`SH-05`); copy B's address from its Receive → Shielded tab (`SH-13`). Both wallets must be bound + synced (`CORE-21`, `SH-01`); after syncing B, its shielded balance rises. NB only one wallet's shielded state is displayed at a time. | -| SH-15 | Unshield from A to a Platform address owned by B | Shielded | Uncommon | ✅ | multiwallet | A unshields (`SH-06`) to a Platform address belonging to wallet B; verify B receives the credits (subject to the `SYS-07` sync caveat). | -| SH-16 | Shielded withdraw from A to B's Core L1 address | Shielded | Uncommon | ✅ | multiwallet, withdrawal | Wallet A's pool → a Core L1 address owned by wallet B (`SH-08`). Completes the cross-wallet shielded exit set (→ shielded `SH-14`, → Platform `SH-15`, → Core `SH-16`). Verify B's Core balance rises after SPV sync. | +| SH-14 | Shielded transfer between two on-device wallets | Shielded | Thorough | ✅ | multiwallet | Wallet A's pool → wallet B's shielded address (`SH-05`); copy B's address from its Receive → Shielded tab (`SH-13`, now resolved per-wallet). Both wallets are bound automatically at rebind (no wallet-swap needed); B's shielded balance rises on the next sync pass. The global Sync tab still mirrors one wallet, but per-wallet Receive/Balance surfaces read B directly. | +| SH-15 | Unshield from A to a Platform address owned by B | Shielded | Uncommon | ✅ | multiwallet | A unshields (`SH-06`) to a Platform address belonging to wallet B; verify B receives the credits (subject to the `SYS-07` sync caveat). Both wallets are engine-bound automatically at rebind, so A can spend from its own pool without a wallet-swap. | +| SH-16 | Shielded withdraw from A to B's Core L1 address | Shielded | Uncommon | ✅ | multiwallet, withdrawal | Wallet A's pool → a Core L1 address owned by wallet B (`SH-08`). Completes the cross-wallet shielded exit set (→ shielded `SH-14`, → Platform `SH-15`, → Core `SH-16`). Both wallets are engine-bound automatically at rebind (no wallet-swap needed). Verify B's Core balance rises after SPV sync. | ### 4.10 DashPay — `Domain=DashPay`