From 4340a6b100fee28acd41690a7ebf3d1220a5a66a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 8 Jul 2026 05:26:21 +0700 Subject: [PATCH 1/4] feat(swift-example-app): bind every wallet's shielded sub-wallet for multi-wallet flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine-bind EVERY loaded wallet's shielded (Orchard) sub-wallet with the Rust sync coordinator, not just the lexicographically-smallest wallet. Unblocks SH-14/15/16 (cross-wallet shielded transfer / unshield / withdraw between two on-device wallets). App-side only — the Rust NetworkShieldedCoordinator was already fully multi-wallet. Design is "single UI mirror + multi-engine-bind": ShieldedService still mirrors one wallet (firstWallet) for the global Sync-status surface via bind(...); the new bindEngine(...) does engine-only registration for every other wallet. rebindWalletScopedServices() drives this through a pure, unit-testable seam (engineBindOtherWallets). Per-wallet surfaces (Receive->Shielded address, wallet balances, CreateIdentity funding gate, send self-shield guard) now read shieldedDefaultAddress(walletId:) / PersistentShieldedNote per wallet instead of the singleton mirror. Eager binding: each wallet's mnemonic is device-unlock-only in Keychain (no biometric access control), so N reads at startup trigger no prompts. Co-Authored-By: Claude Fable 5 --- .../Services/ShieldedEngineBindPlan.swift | 47 ++++++++ .../Core/Services/ShieldedService.swift | 92 +++++++++++++- .../Core/ViewModels/SendViewModel.swift | 15 ++- .../Core/Views/AccountListView.swift | 38 ++++-- .../Core/Views/ReceiveAddressView.swift | 21 +++- .../Core/Views/SendTransactionView.swift | 18 ++- .../Core/Views/WalletDetailView.swift | 20 +++- .../SwiftExampleApp/SwiftExampleAppApp.swift | 26 ++++ .../Views/CreateIdentityView.swift | 72 +++++++---- .../ShieldedEngineBindPlanTests.swift | 113 ++++++++++++++++++ .../swift-sdk/SwiftExampleApp/TEST_PLAN.md | 8 +- 11 files changed, 420 insertions(+), 50 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedEngineBindPlan.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ShieldedEngineBindPlanTests.swift 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..eb908bb20fc 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,54 @@ 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). + func bindEngine( + walletManager: PlatformWalletManager, + walletId: Data, + network: Network, + resolver: MnemonicResolver, + accounts: [UInt32] = [0] + ) { + let dbPath = Self.dbPath(for: network) + let sortedAccounts = Array(Set(accounts)).sorted() + 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 + ) + } catch { + SDKLogger.log( + "Shielded engine-bind failed for walletId=\(walletId.prefix(4).map { String(format: "%02x", $0) }.joined())…: \(error.localizedDescription)", + minimumLevel: .medium + ) + } + } + /// 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 @@ -601,6 +663,24 @@ 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. Only the mirror wallet gets + // re-registered on the fast path — the "Sync Now" + // button's `manualSync()` self-rebinds via `bind(...)`. + // The OTHER loaded wallets stay dark until the next + // `rebindWalletScopedServices()` fire, which now + // re-`bindEngine`s every wallet (not just the mirror). + // That rebind fires on any wallet-set change or network + // switch, so cross-wallet shielded flows (SH-14/15/16) + // recover on the next such event without a manual + // wallet-swap; the immediate post-Clear window covers + // the mirror wallet only. We keep the WIPE scope global + // on purpose (see the class-level doc below) — this note + // is about the re-BIND scope, which is deliberately left + // to the rebind path rather than duplicated here (the + // service doesn't hold the full wallet set + resolver). 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..a6f840802fc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -725,9 +725,18 @@ 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: String? = { + guard let raw = try? walletManager.shieldedDefaultAddress( + walletId: wallet.walletId, + account: 0 + ) else { return nil } + return DashAddress.encodeOrchard(rawBytes: raw, 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..b8317ee32b9 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,32 @@ 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 rather than the single UI mirror so + // the section shows for ANY loaded wallet, not just `firstWallet`. + let bound = (try? walletManager.shieldedDefaultAddress( + walletId: wallet.walletId, + account: 0 + )) != nil + return bound ? [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? { + guard let raw = try? walletManager.shieldedDefaultAddress( + walletId: wallet.walletId, + account: account + ) else { return nil } + return DashAddress.encodeOrchard( + rawBytes: raw, + network: platformState.currentNetwork + ) } var body: some View { @@ -118,7 +134,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..b5cd1dab4a9 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,22 @@ 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? { + guard let raw = try? walletManager.shieldedDefaultAddress( + walletId: wallet.walletId, + account: 0 + ) else { return nil } + return DashAddress.encodeOrchard( + rawBytes: raw, + network: platformState.currentNetwork + ) + } + private var currentAddress: String { switch selectedTab { case .core: @@ -108,7 +123,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 +160,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..a5686320762 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,11 @@ struct SendTransactionView: View { $0.networkRaw == walletNetworkRaw } ) + _shieldedNotes = Query( + filter: #Predicate { + $0.walletId == walletId && $0.isSpent == false + } + ) } var body: some View { @@ -456,8 +466,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..a7548b25be0 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,21 @@ struct BalanceCardView: View { _syncStates = Query( filter: #Predicate { $0.networkRaw == walletNetworkRaw } ) + _shieldedNotes = Query( + filter: #Predicate { + $0.walletId == walletId && $0.isSpent == false + } + ) + } + + /// 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 +1104,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 +1160,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..cbffc5981fc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -263,6 +263,32 @@ struct SwiftExampleAppApp: App { if try !walletManager.isDashPaySyncRunning() { try walletManager.startDashPaySync() } + + // 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. + engineBindOtherWallets( + allWalletIds: walletManager.wallets.keys, + mirrorWalletId: wallet.walletId + ) { otherWalletId in + shieldedService.bindEngine( + walletManager: walletManager, + walletId: otherWalletId, + network: platformState.currentNetwork, + resolver: shieldedResolver + ) + } } catch { SDKLogger.error( "Failed to bind wallet-scoped services: \(error.localizedDescription)" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index 215336b15ee..a18cf5f06dd 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 @@ -619,7 +614,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 +712,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 +762,7 @@ struct CreateIdentityView: View { Text("Denomination") } footer: { let available = Self.formatDash( - raw: shieldedService.shieldedBalance, + raw: poolBalance, divisor: Double(Self.creditsPerDash) ) Text( @@ -994,7 +995,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 +1741,51 @@ 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, fetched from `modelContext` + /// rather than the single-mirror `shieldedService.shieldedBalance`. + /// Correct for ANY loaded wallet, not just the mirror's + /// `firstWallet`. + private func shieldedPoolBalance(for walletId: Data) -> UInt64 { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId && $0.isSpent == false } + ) + let notes = (try? modelContext.fetch(descriptor)) ?? [] + return notes.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/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..0c0f6dd7078 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`. | @@ -272,9 +272,9 @@ Shielded notes/balance/activity have **no read-side FFI** by design — Rust pus | 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-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` From 89f256768b07abd091b19d6ebef7c25df6230315 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 8 Jul 2026 06:21:37 +0700 Subject: [PATCH 2/4] =?UTF-8?q?fix(swift-example-app):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20post-Clear=20rebind-all,=20dedup=20helpers,=20react?= =?UTF-8?q?ive=20pool=20balance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review + CodeRabbit follow-ups on the multi-wallet shielded bind: - Sync Now (manualSync) now unconditionally re-registers every loaded wallet's shielded sub-wallet. clearShielded drops EVERY wallet from the coordinator, and a detail-view switchTo in between re-binds only the viewed wallet, so the recovery branch alone left the other wallets unscanned until an app restart. Verified on-simulator: Clear -> visit wallet A -> Sync Now restores both wallets' ZPERSISTENTSHIELDEDSYNCSTATE rows. - Moved the rebind engine-bind loop ahead of the fallible shielded / DashPay start calls so an unrelated throw cannot skip it. - Deduplicated the resolve+encode address pattern (3 sites) into PlatformWalletManager.shieldedDisplayAddress, and the unspent-note predicate (4 sites) into PersistentShieldedNote.unspentPredicate. - CreateIdentityView pool balance is now @Query-backed (reactive while the sheet is open; one materialized query instead of a FetchDescriptor per call site) — addresses the CodeRabbit comment. - No "already bound" fast path in bindEngine on purpose: the only cheap probe (shieldedDefaultAddress) reflects the wallet-level sub-wallet binding, which survives clearShielded; skipping on it would leave post-Clear wallets permanently unregistered. - build_ios.sh: cargo clean the four bundled-header crates per target before building. Their build.rs writes cbindgen headers to the shared target include dir but only re-executes on fingerprint change, so a target dir reused across branches (CI runners, local branch switches) can pair one branch's Swift with another rev's header — the phantom wallet_manager_import_wallet_from_bytes signature mismatch seen on CI. Co-Authored-By: Claude Fable 5 --- .../Models/PersistentShieldedNote.swift | 19 +++++ ...latformWalletManager+ShieldedDisplay.swift | 28 +++++++ .../Core/Services/ShieldedService.swift | 68 +++++++++++---- .../Core/ViewModels/SendViewModel.swift | 11 +-- .../Core/Views/AccountListView.swift | 18 ++-- .../Core/Views/ReceiveAddressView.swift | 6 +- .../Core/Views/SendTransactionView.swift | 4 +- .../Core/Views/WalletDetailView.swift | 4 +- .../SwiftExampleApp/SwiftExampleAppApp.swift | 31 ++++--- .../Views/CreateIdentityView.swift | 34 ++++++-- ...PersistentShieldedNotePredicateTests.swift | 84 +++++++++++++++++++ .../swift-sdk/SwiftExampleApp/TEST_PLAN.md | 2 +- packages/swift-sdk/build_ios.sh | 28 +++++++ 13 files changed, 270 insertions(+), 67 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformWalletManager+ShieldedDisplay.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PersistentShieldedNotePredicateTests.swift 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/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index eb908bb20fc..df3873420e0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -396,6 +396,17 @@ class ShieldedService: ObservableObject { ) { 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( @@ -490,6 +501,28 @@ class ShieldedService: ObservableObject { guard isBound else { return } } + // Re-register any loaded wallet that lost its engine binding — + // post-Clear, `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. + if let mirrorWalletId = boundWalletId, let resolver, let network { + engineBindOtherWallets( + allWalletIds: walletManager.wallets.keys, + mirrorWalletId: mirrorWalletId + ) { otherWalletId in + bindEngine( + walletManager: walletManager, + walletId: otherWalletId, + network: network, + resolver: resolver + ) + } + } + isSyncing = true lastError = nil // Open the timing bracket here too, not just on the @@ -666,21 +699,26 @@ class ShieldedService: ObservableObject { // // Re-binding scope after Clear: `clearShielded` drops // EVERY wallet (not just the mirror's `firstWallet`) - // from the coordinator. Only the mirror wallet gets - // re-registered on the fast path — the "Sync Now" - // button's `manualSync()` self-rebinds via `bind(...)`. - // The OTHER loaded wallets stay dark until the next - // `rebindWalletScopedServices()` fire, which now - // re-`bindEngine`s every wallet (not just the mirror). - // That rebind fires on any wallet-set change or network - // switch, so cross-wallet shielded flows (SH-14/15/16) - // recover on the next such event without a manual - // wallet-swap; the immediate post-Clear window covers - // the mirror wallet only. We keep the WIPE scope global - // on purpose (see the class-level doc below) — this note - // is about the re-BIND scope, which is deliberately left - // to the rebind path rather than duplicated here (the - // service doesn't hold the full wallet set + resolver). + // 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. 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 a6f840802fc..9e37e0d7b66 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -730,13 +730,10 @@ class SendViewModel: ObservableObject { // `shieldedService` (which tracks `firstWallet`). Every // loaded wallet is engine-bound, so `shieldedDefaultAddress` // resolves for the wallet actually being sent from. - let ownShieldedAddress: String? = { - guard let raw = try? walletManager.shieldedDefaultAddress( - walletId: wallet.walletId, - account: 0 - ) else { return nil } - return DashAddress.encodeOrchard(rawBytes: raw, network: network) - }() + 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 b8317ee32b9..bc581d73e9d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift @@ -69,13 +69,10 @@ struct AccountListView: View { /// engine binding lands (`rebindWalletScopedServices`). private var shieldedAccountsForThisWallet: [UInt32] { // Engine-bound wallets expose account 0 by default. Resolve - // per-wallet from the engine rather than the single UI mirror so - // the section shows for ANY loaded wallet, not just `firstWallet`. - let bound = (try? walletManager.shieldedDefaultAddress( - walletId: wallet.walletId, - account: 0 - )) != nil - return bound ? [0] : [] + // 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 @@ -83,12 +80,9 @@ struct AccountListView: View { /// single UI mirror's `addressesByAccount`). `nil` until this /// wallet's bind lands or if encoding fails. private func shieldedAddress(for account: UInt32) -> String? { - guard let raw = try? walletManager.shieldedDefaultAddress( + walletManager.shieldedDisplayAddress( walletId: wallet.walletId, - account: account - ) else { return nil } - return DashAddress.encodeOrchard( - rawBytes: raw, + account: account, network: platformState.currentNetwork ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift index b5cd1dab4a9..b940a64c979 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift @@ -104,12 +104,8 @@ struct ReceiveAddressView: View { /// (`rebindWalletScopedServices`), so `shieldedDefaultAddress` /// resolves for any of them; `nil` until this wallet's bind lands. private var shieldedReceiveAddress: String? { - guard let raw = try? walletManager.shieldedDefaultAddress( + walletManager.shieldedDisplayAddress( walletId: wallet.walletId, - account: 0 - ) else { return nil } - return DashAddress.encodeOrchard( - rawBytes: raw, network: platformState.currentNetwork ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index a5686320762..f068a062b69 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -71,9 +71,7 @@ struct SendTransactionView: View { } ) _shieldedNotes = Query( - filter: #Predicate { - $0.walletId == walletId && $0.isSpent == false - } + filter: PersistentShieldedNote.unspentPredicate(walletId: walletId) ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index a7548b25be0..06bb2c6578c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -1005,9 +1005,7 @@ struct BalanceCardView: View { filter: #Predicate { $0.networkRaw == walletNetworkRaw } ) _shieldedNotes = Query( - filter: #Predicate { - $0.walletId == walletId && $0.isSpent == false - } + filter: PersistentShieldedNote.unspentPredicate(walletId: walletId) ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index cbffc5981fc..99d8459348e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -251,18 +251,6 @@ struct SwiftExampleAppApp: App { network: platformState.currentNetwork, resolver: shieldedResolver ) - if try !walletManager.isShieldedSyncRunning() { - try walletManager.startShieldedSync() - } - - // DashPay contact-request + profile sweep (background - // loop). Wallet-driven — every registered wallet is swept - // each pass — so manager scope is the right place to start - // it, same as the address / shielded loops above. - // Idempotent: starting while running is a no-op. - if try !walletManager.isDashPaySyncRunning() { - try walletManager.startDashPaySync() - } // Engine-bind every OTHER loaded wallet into the shared // network-scoped shielded coordinator. `firstWallet` above @@ -278,6 +266,12 @@ struct SwiftExampleAppApp: App { // 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 @@ -289,6 +283,19 @@ struct SwiftExampleAppApp: App { resolver: shieldedResolver ) } + + if try !walletManager.isShieldedSyncRunning() { + try walletManager.startShieldedSync() + } + + // DashPay contact-request + profile sweep (background + // loop). Wallet-driven — every registered wallet is swept + // each pass — so manager scope is the right place to start + // it, same as the address / shielded loops above. + // Idempotent: starting while running is a no-op. + if try !walletManager.isDashPaySyncRunning() { + try walletManager.startDashPaySync() + } } catch { SDKLogger.error( "Failed to bind wallet-scoped services: \(error.localizedDescription)" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index a18cf5f06dd..35c84f8622b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -206,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"; @@ -1764,16 +1775,21 @@ struct CreateIdentityView: View { } /// Per-wallet shielded pool balance: sum of `walletId`'s unspent - /// `PersistentShieldedNote` values, fetched from `modelContext` - /// rather than the single-mirror `shieldedService.shieldedBalance`. - /// Correct for ANY loaded wallet, not just the mirror's - /// `firstWallet`. + /// `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 { - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId && $0.isSpent == false } - ) - let notes = (try? modelContext.fetch(descriptor)) ?? [] - return notes.reduce(0) { $0 + $1.value } + unspentShieldedNotes + .filter { $0.walletId == walletId } + .reduce(0) { $0 + $1.value } } /// The wallet id currently chosen in the source-wallet picker, or 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/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index 0c0f6dd7078..24ad1e6961a 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -270,7 +270,7 @@ 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`, 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. | diff --git a/packages/swift-sdk/build_ios.sh b/packages/swift-sdk/build_ios.sh index eb6a8e41e96..6b978f6f4bb 100755 --- a/packages/swift-sdk/build_ios.sh +++ b/packages/swift-sdk/build_ios.sh @@ -201,10 +201,36 @@ if [ "$PROFILE" = "dev-ios" ]; then log_info " → tokio-metrics enabled (dev profile)" fi +# Force header regeneration for every bundled-header crate. Each +# crate's build.rs writes its cbindgen header to the SHARED +# `target///include//` path, but cargo only +# re-executes a build script when the crate's fingerprint changes — +# and it caches artifacts for MULTIPLE revs of the same crate side by +# side. On a target dir reused across branches (CI runners preserve +# it; local checkouts switch branches), a branch pinning a different +# rust-dashcore rev runs its build.rs and overwrites the shared +# header; switching back to a branch whose artifacts are still +# fingerprint-fresh skips build.rs entirely, so Swift compiles +# against the OTHER rev's header (seen as phantom FFI signature +# mismatches in `WalletManager.swift` on CI). Cleaning the header +# crates for the triple being built forces their build scripts to +# re-run so headers always match this checkout's pins. Costs a +# rebuild of the FFI crates only; the heavy dependency graph stays +# cached. NOTE: `cargo clean -p` must be scoped with --target and +# --profile — unscoped it only touches the host-default dirs and +# leaves the cross-compiled artifacts (and the stale header) intact. +clean_header_crates() { + local triple="$1" + log_info " → cleaning header crates for $triple/$PROFILE (forces cbindgen re-run)" + cargo clean -p dash-network -p key-wallet-ffi -p rs-sdk-ffi -p platform-wallet-ffi \ + --target "$triple" --profile "$PROFILE" +} + # iOS device if $BUILD_IOS; then IOS_TARGET="aarch64-apple-ios" log_info "Building iOS device ($IOS_TARGET)..." + clean_header_crates "$IOS_TARGET" cargo build -p "$PACKAGE" --profile "$PROFILE" --target "$IOS_TARGET" --features "$CARGO_FEATURES" IOS_LIB="$TARGET_DIR/$IOS_TARGET/$OUTPUT_DIR/librs_unified_sdk_ffi.a" IOS_HEADERS="$TARGET_DIR/$IOS_TARGET/$OUTPUT_DIR/include" @@ -215,6 +241,7 @@ fi if $BUILD_SIM; then SIM_TARGET="aarch64-apple-ios-sim" log_info "Building iOS simulator ($SIM_TARGET)..." + clean_header_crates "$SIM_TARGET" cargo build -p "$PACKAGE" --profile "$PROFILE" --target "$SIM_TARGET" --features "$CARGO_FEATURES" SIM_LIB="$TARGET_DIR/$SIM_TARGET/$OUTPUT_DIR/librs_unified_sdk_ffi.a" SIM_HEADERS="$TARGET_DIR/$SIM_TARGET/$OUTPUT_DIR/include" @@ -225,6 +252,7 @@ fi if $BUILD_MAC; then MAC_TARGET="aarch64-apple-darwin" log_info "Building macOS ($MAC_TARGET)..." + clean_header_crates "$MAC_TARGET" cargo build -p "$PACKAGE" --profile "$PROFILE" --target "$MAC_TARGET" --features "$CARGO_FEATURES" MAC_LIB="$TARGET_DIR/$MAC_TARGET/$OUTPUT_DIR/librs_unified_sdk_ffi.a" MAC_HEADERS="$TARGET_DIR/$MAC_TARGET/$OUTPUT_DIR/include" From 9632fc8f6b05fc373ea2d20a83ee7ed7bf1c4f76 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 8 Jul 2026 06:52:04 +0700 Subject: [PATCH 3/4] fix(swift-example-app): mirror rebind failure no longer skips other wallets' recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-Clear, manualSync's recovery branch bailed on a failed mirror bind (guard isBound) before the engine-bind pass ran, so a missing / declined / corrupt mirror mnemonic left every OTHER loaded wallet unregistered and skipped the sync — violating the documented best-effort contract. The mirror bind is now best-effort like the rest: the engine pass always runs (bindEngine reports success so the pass can tell), and the sync is skipped only when NO wallet at all could be registered, preserving the original "don't chain a doomed sync" intent per-wallet. Co-Authored-By: Claude Fable 5 --- .../Core/Services/ShieldedService.swift | 64 +++++++++++++------ 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index df3873420e0..59d109eee6b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -387,13 +387,17 @@ class ShieldedService: ObservableObject { /// 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() @@ -418,11 +422,13 @@ class ShieldedService: ObservableObject { "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 } } @@ -494,34 +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 — - // post-Clear, `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. + // 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 - bindEngine( + 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 @@ -709,10 +728,15 @@ class ShieldedService: ObservableObject { // `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. 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. + // 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 From f9465093e82281bb0d4be9e5f842f4a6355a120a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 8 Jul 2026 15:14:40 +0700 Subject: [PATCH 4/4] chore(swift-example-app): drop build_ios.sh header-crate clean per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stale-header mitigation (cargo clean of the bundled-header crates before each target build) is superseded by the header-pipeline rework in #3853, which owns forced regeneration. The underlying mechanism — build.rs writes cbindgen headers to the shared target///include/ path but is skipped when the crate's fingerprint is fresh, so a target dir reused across branches can pair one branch's Swift with another rust-dashcore rev's header — is left to that PR to solve at the build.rs level. Co-Authored-By: Claude Fable 5 --- packages/swift-sdk/build_ios.sh | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/packages/swift-sdk/build_ios.sh b/packages/swift-sdk/build_ios.sh index 6b978f6f4bb..eb6a8e41e96 100755 --- a/packages/swift-sdk/build_ios.sh +++ b/packages/swift-sdk/build_ios.sh @@ -201,36 +201,10 @@ if [ "$PROFILE" = "dev-ios" ]; then log_info " → tokio-metrics enabled (dev profile)" fi -# Force header regeneration for every bundled-header crate. Each -# crate's build.rs writes its cbindgen header to the SHARED -# `target///include//` path, but cargo only -# re-executes a build script when the crate's fingerprint changes — -# and it caches artifacts for MULTIPLE revs of the same crate side by -# side. On a target dir reused across branches (CI runners preserve -# it; local checkouts switch branches), a branch pinning a different -# rust-dashcore rev runs its build.rs and overwrites the shared -# header; switching back to a branch whose artifacts are still -# fingerprint-fresh skips build.rs entirely, so Swift compiles -# against the OTHER rev's header (seen as phantom FFI signature -# mismatches in `WalletManager.swift` on CI). Cleaning the header -# crates for the triple being built forces their build scripts to -# re-run so headers always match this checkout's pins. Costs a -# rebuild of the FFI crates only; the heavy dependency graph stays -# cached. NOTE: `cargo clean -p` must be scoped with --target and -# --profile — unscoped it only touches the host-default dirs and -# leaves the cross-compiled artifacts (and the stale header) intact. -clean_header_crates() { - local triple="$1" - log_info " → cleaning header crates for $triple/$PROFILE (forces cbindgen re-run)" - cargo clean -p dash-network -p key-wallet-ffi -p rs-sdk-ffi -p platform-wallet-ffi \ - --target "$triple" --profile "$PROFILE" -} - # iOS device if $BUILD_IOS; then IOS_TARGET="aarch64-apple-ios" log_info "Building iOS device ($IOS_TARGET)..." - clean_header_crates "$IOS_TARGET" cargo build -p "$PACKAGE" --profile "$PROFILE" --target "$IOS_TARGET" --features "$CARGO_FEATURES" IOS_LIB="$TARGET_DIR/$IOS_TARGET/$OUTPUT_DIR/librs_unified_sdk_ffi.a" IOS_HEADERS="$TARGET_DIR/$IOS_TARGET/$OUTPUT_DIR/include" @@ -241,7 +215,6 @@ fi if $BUILD_SIM; then SIM_TARGET="aarch64-apple-ios-sim" log_info "Building iOS simulator ($SIM_TARGET)..." - clean_header_crates "$SIM_TARGET" cargo build -p "$PACKAGE" --profile "$PROFILE" --target "$SIM_TARGET" --features "$CARGO_FEATURES" SIM_LIB="$TARGET_DIR/$SIM_TARGET/$OUTPUT_DIR/librs_unified_sdk_ffi.a" SIM_HEADERS="$TARGET_DIR/$SIM_TARGET/$OUTPUT_DIR/include" @@ -252,7 +225,6 @@ fi if $BUILD_MAC; then MAC_TARGET="aarch64-apple-darwin" log_info "Building macOS ($MAC_TARGET)..." - clean_header_crates "$MAC_TARGET" cargo build -p "$PACKAGE" --profile "$PROFILE" --target "$MAC_TARGET" --features "$CARGO_FEATURES" MAC_LIB="$TARGET_DIR/$MAC_TARGET/$OUTPUT_DIR/librs_unified_sdk_ffi.a" MAC_HEADERS="$TARGET_DIR/$MAC_TARGET/$OUTPUT_DIR/include"