diff --git a/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift b/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift index c56a6dc03e..7eeea3521d 100644 --- a/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift +++ b/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift @@ -136,7 +136,7 @@ class SyncingActivityMonitor: NSObject, NetworkReachabilityHandling { @objc public var progress: Double = 0 { didSet { - observers.forEach { $0.syncingActivityMonitorProgressDidChange(progress) } + observerSnapshot().forEach { $0.syncingActivityMonitorProgressDidChange(progress) } } } @@ -157,7 +157,7 @@ class SyncingActivityMonitor: NSObject, NetworkReachabilityHandling { kSyncStateChangedNewStateKey: state, ]) - observers.forEach { $0.syncingActivityMonitorStateDidChange(previousState: oldValue, state: state) } + observerSnapshot().forEach { $0.syncingActivityMonitorStateDidChange(previousState: oldValue, state: state) } } } @@ -178,7 +178,8 @@ class SyncingActivityMonitor: NSObject, NetworkReachabilityHandling { private var lastPeakDate: Date? private var cancellables = Set() - private lazy var observers: [SyncingActivityMonitorObserver] = [] + private var observers: [SyncingActivityMonitorObserver] = [] + private let observersLock = NSLock() override init() { super.init() @@ -196,19 +197,32 @@ class SyncingActivityMonitor: NSObject, NetworkReachabilityHandling { @objc(addObserver:) public func add(observer: SyncingActivityMonitorObserver) { + observersLock.lock() + defer { observersLock.unlock() } observers.append(observer) } @objc(removeObserver:) public func remove(observer: SyncingActivityMonitorObserver) { + observersLock.lock() + defer { observersLock.unlock() } if let idx = observers.firstIndex(where: { $0 === observer }) { observers.remove(at: idx) } } + private func observerSnapshot() -> [SyncingActivityMonitorObserver] { + observersLock.lock() + defer { observersLock.unlock() } + return observers + } + deinit { NotificationCenter.default.removeObserver(self) - NotificationCenter.default.removeObserver(reachabilityObserver!) + if let observer = reachabilityObserver { + NotificationCenter.default.removeObserver(observer) + reachabilityObserver = nil + } } /// ObjC-visible accessor for `.syncStateChangedNotification` (same diff --git a/DashWallet/Sources/Categories/NumberFormatter+DashWallet.swift b/DashWallet/Sources/Categories/NumberFormatter+DashWallet.swift index 0f570d5bb1..a6e9384052 100644 --- a/DashWallet/Sources/Categories/NumberFormatter+DashWallet.swift +++ b/DashWallet/Sources/Categories/NumberFormatter+DashWallet.swift @@ -44,27 +44,25 @@ extension NumberFormatter { return formattedString } - var currencySymbolRange: Range! = formattedString.range(of: currencySymbol) + var currencySymbolRange = formattedString.range(of: currencySymbol) if currencySymbolRange == nil && currencySymbol.count != numberFormatter.currencySymbol.count { - assertionFailure("Invalid formatted string") + return nil } else if currencySymbolRange == nil { currencySymbolRange = formattedString.range(of: numberFormatter.currencySymbol) } guard let currencySymbolRange else { - fatalError("Invalid formatted string") + return nil } let isCurrencySymbolAtTheBeginning = currencySymbolRange.lowerBound == formattedString.startIndex var currencySymbolNumberSeparator: String if isCurrencySymbolAtTheBeginning { - currencySymbolNumberSeparator = - String(formattedString[ - currencySymbolRange.upperBound.. [Migration] { diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift index d96a825e46..18528cfe96 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift @@ -203,6 +203,10 @@ final class SwiftDashSDKKeyMigrator: NSObject { network: Network, isImported: Bool ) throws -> Data { + guard !Thread.isMainThread else { + throw MigrationError.hostCreateOnMainThread + } + let semaphore = DispatchSemaphore(value: 0) var result: Result? @@ -228,6 +232,7 @@ final class SwiftDashSDKKeyMigrator: NSObject { private enum MigrationError: LocalizedError { case hostCreateDidNotReturn + case hostCreateOnMainThread } // MARK: - Helpers diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift index 64970ebd56..36361e11ea 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift @@ -369,7 +369,7 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { gapLimit: CoinJoinRecovery.recoveryGapLimit) coinJoinRecoveryWidenedNetwork = network Self.logger.info( - "🛰️ SPVCOORD :: CJTEST coinjoin recovery gap widened on \(network.rawValue, privacy: .public) to \(CoinJoinRecovery.recoveryGapLimit, privacy: .public)") + "🛰️ SPVCOORD :: coinjoin recovery gap widened on \(network.rawValue, privacy: .public) to \(CoinJoinRecovery.recoveryGapLimit, privacy: .public)") } catch { Self.logger.error( "🛰️ SPVCOORD :: coinjoin recovery widen failed: \(String(describing: error), privacy: .public)") @@ -452,7 +452,7 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { let network = coinJoinRecoveryWidenedNetwork, network == runningNetwork else { return } - Self.logger.info("🛰️ SPVCOORD :: CJTEST coinjoin recovery scan reached .synced on \(network.rawValue, privacy: .public) — marking recovered") + Self.logger.info("🛰️ SPVCOORD :: coinjoin recovery scan reached .synced on \(network.rawValue, privacy: .public) — marking recovered") CoinJoinRecovery.shared.markRecovered(for: network) coinJoinRecoveryWidenedNetwork = nil } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletCreator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletCreator.swift index 696054df27..d71521b986 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletCreator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletCreator.swift @@ -154,6 +154,10 @@ final class SwiftDashSDKWalletCreator: NSObject { network: Network, isImported: Bool ) throws -> Data { + guard !Thread.isMainThread else { + throw CreateError.hostCreateOnMainThread + } + let semaphore = DispatchSemaphore(value: 0) var result: Result? @@ -179,5 +183,6 @@ final class SwiftDashSDKWalletCreator: NSObject { private enum CreateError: LocalizedError { case hostCreateDidNotReturn + case hostCreateOnMainThread } } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift index 933cfc5233..3a3f9be765 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift @@ -229,6 +229,14 @@ final class SwiftDashSDKWalletWiper: NSObject { private static func deleteWalletsFromSDK( _ storedWalletIdsByNetwork: [Network: Set] ) -> Bool { + // `finished.wait()` below blocks this thread until the `@MainActor` + // task signals it. On the main thread that task could never be + // scheduled, so the wait would deadlock rather than fail. + guard !Thread.isMainThread else { + logger.error("Refusing synchronous wallet deletion on the main thread") + return false + } + let finished = DispatchSemaphore(value: 0) let result = WalletWipeResultAccumulator() Task { @MainActor in diff --git a/DashWallet/Sources/Models/Coinbase/Accounts/Account/CBAccount.swift b/DashWallet/Sources/Models/Coinbase/Accounts/Account/CBAccount.swift index 2498745ef7..5b4eddc6d0 100644 --- a/DashWallet/Sources/Models/Coinbase/Accounts/Account/CBAccount.swift +++ b/DashWallet/Sources/Models/Coinbase/Accounts/Account/CBAccount.swift @@ -109,7 +109,7 @@ extension CBAccount { idem: UUID?) async throws -> (transaction: CoinbaseTransaction, walletId: Data) { // NOTE: Maybe better to get the address once and use it during the tx flow guard let destination = SwiftDashSDKReceiveAddressReader.receiveDestination() else { - fatalError("No wallet") + throw Coinbase.Error.unknownError } // TODO: disabled until Coinbase changes are clear diff --git a/DashWallet/Sources/Models/Coinbase/Infrastructure/API/DTOs/Account/CoinbaseUserAccountData.swift b/DashWallet/Sources/Models/Coinbase/Infrastructure/API/DTOs/Account/CoinbaseUserAccountData.swift index 6c1936c810..2f08a3dc27 100644 --- a/DashWallet/Sources/Models/Coinbase/Infrastructure/API/DTOs/Account/CoinbaseUserAccountData.swift +++ b/DashWallet/Sources/Models/Coinbase/Infrastructure/API/DTOs/Account/CoinbaseUserAccountData.swift @@ -37,7 +37,7 @@ struct CoinbaseUserAccountData: Codable, Identifiable { var iconURL: URL { let code = currency.code.lowercased() let urlString = "https://raw.githubusercontent.com/jsupa/crypto-icons/main/icons/\(code).png" - return URL(string: urlString)! + return URL(string: urlString) ?? URL(fileURLWithPath: "/") } var balanceString: String { @@ -68,7 +68,7 @@ struct CoinbaseUserAccountData: Codable, Identifiable { } let nf = NumberFormatter.fiatFormatter(currencyCode: App.fiatCurrency) - return nf.string(from: fiatAmount as NSNumber)! + return nf.string(from: fiatAmount as NSNumber) ?? "—" } var plainAmount: UInt64 { @@ -83,7 +83,8 @@ struct CoinbaseUserAccountData: Codable, Identifiable { var plainAmountInDash: UInt64 { if currencyCode == kDashCurrency { return plainAmount } - guard let dashAmount = try? Coinbase.shared.currencyExchanger.convertToDash(amount: balance.amount.decimal()!, currency: currencyCode) else { + guard let amount = balance.amount.decimal(), + let dashAmount = try? Coinbase.shared.currencyExchanger.convertToDash(amount: amount, currency: currencyCode) else { return 0 } diff --git a/DashWallet/Sources/Models/Coinbase/Infrastructure/API/DTOs/Base/CoinbaseAmount.swift b/DashWallet/Sources/Models/Coinbase/Infrastructure/API/DTOs/Base/CoinbaseAmount.swift index 815e89b9fe..8bc0d7c31a 100644 --- a/DashWallet/Sources/Models/Coinbase/Infrastructure/API/DTOs/Base/CoinbaseAmount.swift +++ b/DashWallet/Sources/Models/Coinbase/Infrastructure/API/DTOs/Base/CoinbaseAmount.swift @@ -19,13 +19,13 @@ extension Amount { assert(currency != kDashCurrency) guard let decimal = amount.decimal() else { - fatalError("Trying to convert non number string") + return "—" } let numberFormatter = NumberFormatter.fiatFormatter(currencyCode: currency) guard let string = numberFormatter.string(from: decimal as NSNumber) else { - fatalError("Trying to convert non number string") + return "—" } return string @@ -35,11 +35,11 @@ extension Amount { assert(currency == kDashCurrency) guard let decimal = amount.decimal() else { - fatalError("Trying to convert non number string") + return "—" } guard let string = NumberFormatter.dashFormatter.string(from: decimal as NSNumber) else { - fatalError("Trying to convert non number string") + return "—" } return string diff --git a/DashWallet/Sources/Models/Coinbase/Infrastructure/Currency Exchanger/CoinbaseRatesProvider.swift b/DashWallet/Sources/Models/Coinbase/Infrastructure/Currency Exchanger/CoinbaseRatesProvider.swift index 861c3b7996..f06d038c9e 100644 --- a/DashWallet/Sources/Models/Coinbase/Infrastructure/Currency Exchanger/CoinbaseRatesProvider.swift +++ b/DashWallet/Sources/Models/Coinbase/Infrastructure/Currency Exchanger/CoinbaseRatesProvider.swift @@ -45,6 +45,7 @@ extension CoinbaseRatesProvider { private func fetchPrices() { Task { + do { let response: BaseDataResponse = try await httpClient.request(.exchangeRates(kDashCurrency)) guard let rates = response.data.rates else { return } @@ -53,11 +54,14 @@ extension CoinbaseRatesProvider { for rate in rates { let key = rate.key - let price = Decimal(string: rate.value)! + guard let price = Decimal(string: rate.value) else { continue } array.append(.init(code: key, name: key, price: price)) } self.updateHandler?(array) + } catch { + DWLogger.log("Coinbase exchange rates request failed: \(error.localizedDescription)") + } } } } diff --git a/DashWallet/Sources/Models/Transactions/WalletSendService.swift b/DashWallet/Sources/Models/Transactions/WalletSendService.swift index 2d722289a8..01c9cb46af 100644 --- a/DashWallet/Sources/Models/Transactions/WalletSendService.swift +++ b/DashWallet/Sources/Models/Transactions/WalletSendService.swift @@ -333,7 +333,7 @@ final class WalletSendService: NSObject { ) } - Self.logger.info("💸 TXSEND :: CJTEST preparing CoinJoin sweep — balance \(amount, privacy: .public) duffs (\(Double(amount) / 1e8, privacy: .public) DASH)") + Self.logger.info("💸 TXSEND :: preparing CoinJoin sweep — balance \(amount, privacy: .public) duffs (\(Double(amount) / 1e8, privacy: .public) DASH)") try await sendAuthorizer.authorizeSend(spendAmount: amount) guard let destination = SwiftDashSDKReceiveAddressReader.receiveAddress() else { @@ -343,13 +343,13 @@ final class WalletSendService: NSObject { ) } - Self.logger.info("💸 TXSEND :: CJTEST CoinJoin sweep destination resolved \(destination, privacy: .public)") + Self.logger.info("💸 TXSEND :: CoinJoin sweep destination resolved \(destination, privacy: .public)") let txids = try SwiftDashSDKTransactionSender.sweepCoinJoin(to: destination) guard !txids.isEmpty else { // A reported-success sweep that produced no transaction is treated // as a failure, so the caller surfaces an error (the sweep alert) // rather than silently "succeeding" with the balance unchanged. - Self.logger.error("💸 TXSEND :: CJTEST CoinJoin sweep returned no transactions for \(amount, privacy: .public) duffs — treating as failure") + Self.logger.error("💸 TXSEND :: CoinJoin sweep returned no transactions for \(amount, privacy: .public) duffs — treating as failure") throw Self.makeError( code: .coinJoinSweepUnavailable, description: "CoinJoin sweep produced no transactions" @@ -366,12 +366,12 @@ final class WalletSendService: NSObject { let recordedHexes: [String] = txids.map { (txid: Data) in txid.reversed().map { String(format: "%02x", $0) }.joined() } - Self.logger.info("💸 TXSEND :: CJTEST recorded \(txids.count, privacy: .public) sweep txid(s) in CoinJoinWithdrawalStore: \(recordedHexes.joined(separator: ","), privacy: .public)") + Self.logger.info("💸 TXSEND :: recorded \(txids.count, privacy: .public) sweep txid(s) in CoinJoinWithdrawalStore: \(recordedHexes.joined(separator: ","), privacy: .public)") await MainActor.run { SwiftDashSDKWalletState.shared.refreshCoinJoinBalance() let post = SwiftDashSDKWalletState.shared.coinJoinBalanceDuffs - Self.logger.info("💸 TXSEND :: CJTEST post-sweep CoinJoin balance \(post, privacy: .public) duffs (was \(amount, privacy: .public))") + Self.logger.info("💸 TXSEND :: post-sweep CoinJoin balance \(post, privacy: .public) duffs (was \(amount, privacy: .public))") // The per-network recovery flag is owned solely by the recovery scan- // completion path (SwiftDashSDKSPVCoordinator.maybeCompleteCoinJoinRecovery, // which marks recovered once the one-time wide scan reaches .synced). A diff --git a/DashWallet/Sources/UI/Coinbase/Base/ViewModel+Coinbase.swift b/DashWallet/Sources/UI/Coinbase/Base/ViewModel+Coinbase.swift index 9cd6b07efd..e486ae4361 100644 --- a/DashWallet/Sources/UI/Coinbase/Base/ViewModel+Coinbase.swift +++ b/DashWallet/Sources/UI/Coinbase/Base/ViewModel+Coinbase.swift @@ -65,10 +65,9 @@ extension CoinbaseTransactionSendable { } } catch { await MainActor.run { - self.transactionDelegate?.transferFromCoinbaseToWalletDidFail(with: error as! Coinbase.Error) + self.transactionDelegate?.transferFromCoinbaseToWalletDidFail(with: error as? Coinbase.Error ?? .unknownError) } } } } - diff --git a/DashWallet/Sources/UI/Coinbase/Custodial Swaps/Order Preview/Model/ConvertCryptoOrderPreviewModel.swift b/DashWallet/Sources/UI/Coinbase/Custodial Swaps/Order Preview/Model/ConvertCryptoOrderPreviewModel.swift index c50bd540c1..2e39d8566e 100644 --- a/DashWallet/Sources/UI/Coinbase/Custodial Swaps/Order Preview/Model/ConvertCryptoOrderPreviewModel.swift +++ b/DashWallet/Sources/UI/Coinbase/Custodial Swaps/Order Preview/Model/ConvertCryptoOrderPreviewModel.swift @@ -91,18 +91,22 @@ extension ConvertCryptoOrderPreviewModel { case .origin, .purchaseAmount: let formatter = NumberFormatter.cryptoFormatter(currencyCode: selectedAccount.info.currencyCode, exponent: selectedAccount.info.currency.exponent) formatter.minimumFractionDigits = 1 - value = formatter.string(from: Decimal(string: order.inputAmount.amount)! as NSDecimalNumber) ?? "NaN" + guard let amount = Decimal(string: order.inputAmount.amount) else { return "—" } + value = formatter.string(from: amount as NSDecimalNumber) ?? "—" case .destination: let formatter = NumberFormatter.dashFormatter - value = formatter.string(from: Decimal(string: order.outputAmount.amount)! as NSDecimalNumber) ?? "NaN" + guard let amount = Decimal(string: order.outputAmount.amount) else { return "—" } + value = formatter.string(from: amount as NSDecimalNumber) ?? "—" case .feeAmount: value = order.fee.formattedFiatAmount case .totalAmount: - let total = Decimal(string: order.fee.amount)! + Decimal(string: order.displayInputAmount.amount)! + guard let fee = Decimal(string: order.fee.amount), + let input = Decimal(string: order.displayInputAmount.amount) else { return "—" } + let total = fee + input let numberFormatter = NumberFormatter.fiatFormatter(currencyCode: order.unitPrice.targetToFiat.currency) guard let string = numberFormatter.string(from: total as NSNumber) else { - fatalError("Trying to convert non number string") + return "—" } value = string diff --git a/DashWallet/Sources/UI/Coinbase/Transfer Amount/Model/TransferAmountModel.swift b/DashWallet/Sources/UI/Coinbase/Transfer Amount/Model/TransferAmountModel.swift index ad1bf8b73e..39164ebd86 100644 --- a/DashWallet/Sources/UI/Coinbase/Transfer Amount/Model/TransferAmountModel.swift +++ b/DashWallet/Sources/UI/Coinbase/Transfer Amount/Model/TransferAmountModel.swift @@ -125,7 +125,7 @@ final class TransferAmountModel: CoinbaseAmountModel, CoinbaseTransactionSendabl } } catch let error { await MainActor.run { - self.delegate?.transferFromCoinbaseToWalletDidFail(with: error as! Coinbase.Error) + self.delegate?.transferFromCoinbaseToWalletDidFail(with: error as? Coinbase.Error ?? .unknownError) } } } diff --git a/DashWallet/Sources/UI/CrowdNode/Online/OnlineAccountEmailController.swift b/DashWallet/Sources/UI/CrowdNode/Online/OnlineAccountEmailController.swift index 76ebc93790..3dcc13f7a3 100644 --- a/DashWallet/Sources/UI/CrowdNode/Online/OnlineAccountEmailController.swift +++ b/DashWallet/Sources/UI/CrowdNode/Online/OnlineAccountEmailController.swift @@ -102,7 +102,7 @@ final class OnlineAccountEmailController: UIViewController { .sink { [weak self] error in if error is CrowdNode.Error { self?.viewModel.clearError() - self?.navigationController?.toErrorScreen(error: error as! CrowdNode.Error) + self?.navigationController?.toErrorScreen(error: error as? CrowdNode.Error ?? .messageStatus(error: error.localizedDescription)) } } .store(in: &cancellableBag) diff --git a/DashWallet/Sources/UI/CrowdNode/Portal/CrowdNodePortalViewController.swift b/DashWallet/Sources/UI/CrowdNode/Portal/CrowdNodePortalViewController.swift index 5f5afd0c87..3ce47eb57d 100644 --- a/DashWallet/Sources/UI/CrowdNode/Portal/CrowdNodePortalViewController.swift +++ b/DashWallet/Sources/UI/CrowdNode/Portal/CrowdNodePortalViewController.swift @@ -187,7 +187,7 @@ extension CrowdNodePortalController { .sink(receiveValue: { [weak self] error in if error is CrowdNode.Error { self?.viewModel.clearError() - self?.navigationController?.toErrorScreen(error: error as! CrowdNode.Error) + self?.navigationController?.toErrorScreen(error: error as? CrowdNode.Error ?? .messageStatus(error: error.localizedDescription)) } }) .store(in: &cancellableBag) diff --git a/DashWallet/Sources/UI/Explore Dash/Merchants & ATMs/List/ExplorePointOfUseListViewController.swift b/DashWallet/Sources/UI/Explore Dash/Merchants & ATMs/List/ExplorePointOfUseListViewController.swift index 97daeb1f57..0391f1b514 100644 --- a/DashWallet/Sources/UI/Explore Dash/Merchants & ATMs/List/ExplorePointOfUseListViewController.swift +++ b/DashWallet/Sources/UI/Explore Dash/Merchants & ATMs/List/ExplorePointOfUseListViewController.swift @@ -190,22 +190,16 @@ class ExplorePointOfUseListViewController: UIViewController { wSelf.updateEmptyResultsForFilters() } - model.nextPageDidLoaded = { [weak self] offset, count in + model.nextPageDidLoaded = { [weak self] _, _ in guard let wSelf = self else { return } - var indexPathes: [IndexPath] = Array() - indexPathes.reserveCapacity(count) - - let start = offset - let total = (offset+count) - for i in start..= 0 else { + throw DashSpendError.paymentProcessingError("Invalid gift card amount") + } + + let satoshis = NSDecimalNumber(decimal: Decimal(giftCardInfo.amount) * Decimal(100_000_000)) + guard satoshis.compare(NSDecimalNumber.zero) != .orderedAscending, + satoshis.compare(NSDecimalNumber(value: UInt64.max)) != .orderedDescending else { + throw DashSpendError.paymentProcessingError("Gift card amount is out of range") + } + let dashAmountInSatoshis = satoshis.uint64Value // Use sendCoins directly with address and amount // This will properly trigger PIN authorization diff --git a/DashWallet/Sources/UI/Home/HomeViewController.swift b/DashWallet/Sources/UI/Home/HomeViewController.swift index 9bfb12a2be..0a7cf66346 100644 --- a/DashWallet/Sources/UI/Home/HomeViewController.swift +++ b/DashWallet/Sources/UI/Home/HomeViewController.swift @@ -523,7 +523,7 @@ class HomeViewController: DWBasePayViewController, NavigationBarDisplayable { textBlock1: String(format: NSLocalizedString("You have %@ in CoinJoin mixed coins. CoinJoin is no longer supported — move them to your spendable balance.", comment: "CoinJoin"), amount), positiveButtonText: NSLocalizedString("Move funds", comment: "CoinJoin"), positiveButtonAction: { - DWLogger.log("CJTEST HomeViewController: sweep invoked from Home popup (\(amount))") + DWLogger.log("HomeViewController: sweep invoked from Home popup (\(amount))") self.viewModel.showCoinJoinSweepDialog = false Task { @MainActor in // The post-sync popup (ModalDialog) is mid-dismissal here — diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index bb560f9b74..12605ee9f2 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -372,6 +372,11 @@ class HomeViewModel: ObservableObject { self.isReloading = true DWLogger.log("HomeViewModel: Starting full transaction reload") + // SwiftUI mutates the filter on the main thread. Snapshot it once + // before doing the worker-queue computation instead of reading the + // published property concurrently during the reload. + let selectedFilters = DispatchQueue.main.sync { self.selectedFilters } + let transactions = transactionSource.allTransactions // Reconcile restored Shielded → Core destinations before Core rows // are filtered/classified. The activity projection can recover a @@ -397,7 +402,7 @@ class HomeViewModel: ObservableObject { var items: [TransactionListDataItem] = transactions.compactMap { wrappedTx -> TransactionListDataItem? in Tx.shared.updateRateIfNeeded(for: wrappedTx) - if !self.passesFilter(transaction: wrappedTx, selected: self.selectedFilters, hasRewards: hasRewards, hasMasternodes: hasMasternodes, giftCardTxIds: giftCardTxIds) { + if !self.passesFilter(transaction: wrappedTx, selected: selectedFilters, hasRewards: hasRewards, hasMasternodes: hasMasternodes, giftCardTxIds: giftCardTxIds) { return nil } @@ -435,7 +440,7 @@ class HomeViewModel: ObservableObject { for shielded in shieldedItems { guard self.passesShieldedFilter( item: shielded, - selected: self.selectedFilters, + selected: selectedFilters, hasRewards: hasRewards, hasMasternodes: hasMasternodes) else { continue } @@ -450,7 +455,7 @@ class HomeViewModel: ObservableObject { for platform in platformItems { guard self.passesCategoryFilter( categories: [.received], - selected: self.selectedFilters, + selected: selectedFilters, hasRewards: hasRewards, hasMasternodes: hasMasternodes) else { continue } @@ -530,20 +535,23 @@ class HomeViewModel: ObservableObject { return } + let selectedFilters = DispatchQueue.main.sync { self.selectedFilters } + let historyFlags = DispatchQueue.main.sync { (self.hasRewardsHistory, self.hasMasternodeHistory) } + // A coinbase / masternode tx arriving incrementally unlocks its // filter row without waiting for the next full reload. - if tx.isCoinbaseTransaction && !self.hasRewardsHistory { + if tx.isCoinbaseTransaction && !historyFlags.0 { DispatchQueue.main.async { self.hasRewardsHistory = true } } - if tx.isMasternodeTransaction && !self.hasMasternodeHistory { + if tx.isMasternodeTransaction && !historyFlags.1 { DispatchQueue.main.async { self.hasMasternodeHistory = true } } - if !self.passesFilter(transaction: tx, selected: self.selectedFilters, hasRewards: self.hasRewardsHistory, hasMasternodes: self.hasMasternodeHistory) { + if !self.passesFilter(transaction: tx, selected: selectedFilters, hasRewards: historyFlags.0, hasMasternodes: historyFlags.1) { return } @@ -570,7 +578,8 @@ class HomeViewModel: ObservableObject { var oldItemIndex: Int? = nil var oldDateKey: String? = nil - for (gIdx, group) in self.txItems.enumerated() { + let currentGroups = DispatchQueue.main.sync { self.txItems } + for (gIdx, group) in currentGroups.enumerated() { if let iIdx = group.items.firstIndex(where: { $0.id == itemId }) { oldGroupIndex = gIdx oldItemIndex = iIdx @@ -629,7 +638,7 @@ class HomeViewModel: ObservableObject { DispatchQueue.main.async { guard groupIndex < self.txItems.count, itemIndex < self.txItems[groupIndex].items.count else { return } - let updatedGroup = self.txItems[groupIndex] + var updatedGroup = self.txItems[groupIndex] var updatedItems = updatedGroup.items updatedItems[itemIndex] = txItem updatedGroup.items = updatedItems @@ -642,26 +651,22 @@ class HomeViewModel: ObservableObject { self.txByHash[itemId] = txItem let shouldShowReclassify = self.shouldDisplayReclassifyTransaction && tx.date > reclassifyTransactionsActivatedAt - if let groupIndex = self.txItems.firstIndex(where: { $0.id == newDateKey }) { - // Add to an existing date group - DispatchQueue.main.async { - self.txItems[groupIndex].items.append(txItem) - self.txItems[groupIndex].items.sort { $0.date > $1.date } - self.showReclassifyTransaction = shouldShowReclassify ? tx : nil - } - } else { - // Create a new date group - let newGroup = TransactionGroup(id: newDateKey, date: txItem.date, items: [txItem]) - let insertIndex = self.txItems.firstIndex(where: { $0.date < txItem.date }) - - DispatchQueue.main.async { + // Re-check the current data source inside the main-queue hop; + // a full reload may replace all groups between these queues. + DispatchQueue.main.async { + if let currentGroupIndex = self.txItems.firstIndex(where: { $0.id == newDateKey }) { + self.txItems[currentGroupIndex].items.append(txItem) + self.txItems[currentGroupIndex].items.sort { $0.date > $1.date } + } else { + let newGroup = TransactionGroup(id: newDateKey, date: txItem.date, items: [txItem]) + let insertIndex = self.txItems.firstIndex(where: { $0.date < txItem.date }) if let index = insertIndex { self.txItems.insert(newGroup, at: index) } else { self.txItems.append(newGroup) } - self.showReclassifyTransaction = shouldShowReclassify ? tx : nil } + self.showReclassifyTransaction = shouldShowReclassify ? tx : nil } } } @@ -763,7 +768,7 @@ extension HomeViewModel { /// launch until the user sweeps, then self-stops (balance → 0). The durable /// Settings row covers the same action for users who dismiss it. func maybeShowCoinJoinSweepDialog() { - DWLogger.log("CJTEST HomeViewModel: sweep dialog check — \(coinJoinSweepAmountDuffs) duffs (\(String(format: "%.6f", Double(coinJoinSweepAmountDuffs) / Double(kOneDash))) DASH), threshold \(CoinJoinRecovery.recoveryDustThresholdDuffs), above=\(coinJoinSweepAmountDuffs > CoinJoinRecovery.recoveryDustThresholdDuffs), syncDone=\(syncModel.state == .syncDone), alreadyShown=\(coinJoinSweepDialogShown)") + DWLogger.log("HomeViewModel: sweep dialog check — \(coinJoinSweepAmountDuffs) duffs (\(String(format: "%.6f", Double(coinJoinSweepAmountDuffs) / Double(kOneDash))) DASH), threshold \(CoinJoinRecovery.recoveryDustThresholdDuffs), above=\(coinJoinSweepAmountDuffs > CoinJoinRecovery.recoveryDustThresholdDuffs), syncDone=\(syncModel.state == .syncDone), alreadyShown=\(coinJoinSweepDialogShown)") guard !coinJoinSweepDialogShown, syncModel.state == .syncDone, coinJoinSweepAmountDuffs > CoinJoinRecovery.recoveryDustThresholdDuffs else { return } @@ -780,7 +785,7 @@ extension HomeViewModel { _ = try await WalletSendService.shared.sweepCoinJoin() return nil } catch { - DWLogger.log("CJTEST HomeViewModel: sweep (home popup) failed: \(error)") + DWLogger.log("HomeViewModel: sweep (home popup) failed: \(error)") // nil when the user cancelled auth; a message on real failures. return WalletSendService.coinJoinSweepUserMessage(for: error) } diff --git a/DashWallet/Sources/UI/Home/Views/TransactionListDataItem.swift b/DashWallet/Sources/UI/Home/Views/TransactionListDataItem.swift index 3eac685456..3999ab83dd 100644 --- a/DashWallet/Sources/UI/Home/Views/TransactionListDataItem.swift +++ b/DashWallet/Sources/UI/Home/Views/TransactionListDataItem.swift @@ -15,7 +15,7 @@ // limitations under the License. // -class TransactionGroup: Identifiable { +struct TransactionGroup: Identifiable { let id: String let date: Date var items: [TransactionListDataItem] diff --git a/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewModel.swift b/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewModel.swift index e061dac7fa..4c50d1bc7e 100644 --- a/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewModel.swift @@ -178,11 +178,11 @@ class SettingsMenuViewModel: ObservableObject { /// BIP44 address → sweep → balance refresh). The "Move CoinJoin Funds" /// row self-removes once the refreshed balance drops below the threshold. func performCoinJoinSweep() async { - DWLogger.log("CJTEST SettingsMenuViewModel: sweep invoked from Settings menu (\(coinJoinLeftoverFormatted))") + DWLogger.log("SettingsMenuViewModel: sweep invoked from Settings menu (\(coinJoinLeftoverFormatted))") do { _ = try await WalletSendService.shared.sweepCoinJoin() } catch { - DWLogger.log("CJTEST SettingsMenuViewModel: sweep failed: \(error)") + DWLogger.log("SettingsMenuViewModel: sweep failed: \(error)") // Auth-cancel is an expected no-op (nil message); a real failure // surfaces an alert. The row stays visible so the user can retry. coinJoinSweepErrorMessage = WalletSendService.coinJoinSweepUserMessage(for: error) diff --git a/DashWallet/Sources/UI/Payments/ScanQR/DWCaptureSessionManager.m b/DashWallet/Sources/UI/Payments/ScanQR/DWCaptureSessionManager.m index acbecdf9b5..ba13804f88 100644 --- a/DashWallet/Sources/UI/Payments/ScanQR/DWCaptureSessionManager.m +++ b/DashWallet/Sources/UI/Payments/ScanQR/DWCaptureSessionManager.m @@ -26,10 +26,10 @@ @interface DWCaptureSessionManager () -@property (null_resettable, nonatomic, strong) AVCaptureSession *captureSession; -@property (null_resettable, nonatomic, strong) dispatch_queue_t sessionQueue; -@property (null_resettable, nonatomic, strong) dispatch_queue_t metadataQueue; -@property (null_resettable, nonatomic, strong) dispatch_queue_t framesOutputQueue; +@property (nullable, nonatomic, strong) AVCaptureSession *captureSession; +@property (nullable, nonatomic, strong) dispatch_queue_t sessionQueue; +@property (nullable, nonatomic, strong) dispatch_queue_t metadataQueue; +@property (nullable, nonatomic, strong) dispatch_queue_t framesOutputQueue; @property (nonatomic, assign, getter=isCaptureSessionConfigured) BOOL captureSessionConfigured; @property (atomic, assign, getter=isActive) BOOL active; @@ -62,20 +62,28 @@ - (void)startPreviewCompletion:(void (^)(void))completion { [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(stopPreviewInternal) object:nil]; - [NSObject cancelPreviousPerformRequestsWithTarget:self - selector:@selector(tearDown) - object:nil]; void (^doStartPreview)(void) = ^{ -#if !TARGET_OS_SIMULATOR + // Mark the session active before any setup/start work. A teardown + // already scheduled by an earlier `stopPreview` cannot be cancelled + // (`dispatch_after` has no cancel), so it reads this flag on main and + // bails instead — see `tearDown`. + self.active = YES; [self setupCaptureSessionIfNeeded]; -#endif /* TARGET_OS_SIMULATOR */ - dispatch_async(self.sessionQueue, ^{ - self.active = YES; + // Bind the queue and session once. `setupCaptureSessionIfNeeded` is a + // no-op when a session is already configured, so these can only be nil + // if the camera is unavailable (no capture device — simulator, or a + // device whose camera the system won't hand over). + dispatch_queue_t queue = self.sessionQueue; + AVCaptureSession *session = self.captureSession; + if (!queue || !session) { + return; + } - if (!self.captureSession.isRunning) { - [self.captureSession startRunning]; + dispatch_async(queue, ^{ + if (!session.isRunning) { + [session startRunning]; } dispatch_async(dispatch_get_main_queue(), ^{ @@ -123,8 +131,14 @@ - (void)switchTorch { return; } - dispatch_async(self.sessionQueue, ^{ - if (!self.captureSession.isRunning) { + dispatch_queue_t queue = self.sessionQueue; + AVCaptureSession *session = self.captureSession; + if (!queue || !session) { + return; + } + + dispatch_async(queue, ^{ + if (!session.isRunning) { return; } @@ -175,15 +189,32 @@ - (void)captureOutput:(AVCaptureOutput *)output - (void)stopPreviewInternal { DWLog(@"DWCaptureSessionManager: Stopping preview..."); - dispatch_async(self.sessionQueue, ^{ - if (self.captureSession.isRunning) { - [self.captureSession stopRunning]; + dispatch_queue_t queue = self.sessionQueue; + AVCaptureSession *session = self.captureSession; + if (!queue || !session) { + return; + } - dispatch_async(dispatch_get_main_queue(), ^{ - DWLog(@"DWCaptureSessionManager: Preview has been stopped"); - [self performSelector:@selector(tearDown) withObject:nil afterDelay:SESSION_KEEPALIVE]; - }); + dispatch_async(queue, ^{ + if (session.isRunning) { + [session stopRunning]; } + + // The delayed teardown is scheduled back on MAIN, not on the session + // queue: `captureSession`, the three queues and `captureSessionConfigured` + // are only ever read/written from main (startPreview / stopPreview are + // both main-thread entry points), so keeping the state transition there + // is what makes it race-free. Tearing down on the session queue instead + // let `tearDown` nil `sessionQueue` while a concurrent `startPreview` + // on main had already passed the `isCaptureSessionConfigured` check — + // which then dispatched onto a NULL queue. + dispatch_async(dispatch_get_main_queue(), ^{ + DWLog(@"DWCaptureSessionManager: Preview has been stopped"); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(SESSION_KEEPALIVE * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [self tearDown]; + }); + }); }); } @@ -191,6 +222,24 @@ - (void)setupCaptureSessionIfNeeded { if (self.isCaptureSessionConfigured) { return; } + + // No capture device means no session to configure — the simulator, or a + // device that won't vend the camera. `+[AVCaptureDeviceInput + // deviceInputWithDevice:error:]` raises NSInvalidArgumentException on a nil + // device, so bail before anything is allocated and leave + // `captureSessionConfigured` NO. `startPreviewCompletion:` sees the nil + // session and no-ops; the scan screen renders without a preview instead of + // crashing. (This replaces the previous `#if !TARGET_OS_SIMULATOR` guard, + // which only covered the simulator case.) + if ([AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo] == nil) { + DWLog(@"DWCaptureSessionManager: no video capture device available"); + return; + } + + self.captureSession = [[AVCaptureSession alloc] init]; + self.sessionQueue = dispatch_queue_create("DWQRScanViewModel.CaptureSession.queue", DISPATCH_QUEUE_SERIAL); + self.metadataQueue = dispatch_queue_create("DWQRScanViewModel.CaptureMetadataOutput.queue", DISPATCH_QUEUE_SERIAL); + self.framesOutputQueue = dispatch_queue_create("DWQRScanViewModel.VideoFramesOutput.queue", DISPATCH_QUEUE_SERIAL); self.captureSessionConfigured = YES; dispatch_async(self.sessionQueue, ^{ @@ -252,44 +301,42 @@ - (void)setupCaptureSessionIfNeeded { }); } +/// Releases the session and its queues. Runs on MAIN — the same thread as +/// `startPreviewCompletion:` / `stopPreview`, so `active` and the session state +/// below are never read and written concurrently. - (void)tearDown { DWLog(@"DWCaptureSessionManager: Tearing down..."); - self.captureSession = nil; - self.sessionQueue = nil; - self.metadataQueue = nil; - self.framesOutputQueue = nil; - self.captureSessionConfigured = NO; -} -- (AVCaptureSession *)captureSession { - if (!_captureSession) { - _captureSession = [[AVCaptureSession alloc] init]; - } - - return _captureSession; -} - -- (dispatch_queue_t)sessionQueue { - if (!_sessionQueue) { - _sessionQueue = dispatch_queue_create("DWQRScanViewModel.CaptureSession.queue", DISPATCH_QUEUE_SERIAL); + // The scanner was reopened inside the keepalive window; the live session is + // in use. `active` is set on main by `doStartPreview`, so this check cannot + // race the assignment. + if (self.active) { + return; } - return _sessionQueue; -} - -- (dispatch_queue_t)metadataQueue { - if (!_metadataQueue) { - _metadataQueue = dispatch_queue_create("DWQRScanViewModel.CaptureMetadataOutput.queue", DISPATCH_QUEUE_SERIAL); + AVCaptureSession *session = self.captureSession; + if (session) { + [session beginConfiguration]; + for (AVCaptureOutput *output in [session.outputs copy]) { + if ([output isKindOfClass:[AVCaptureMetadataOutput class]]) { + [(AVCaptureMetadataOutput *)output setMetadataObjectsDelegate:nil queue:NULL]; + } + else if ([output isKindOfClass:[AVCaptureVideoDataOutput class]]) { + [(AVCaptureVideoDataOutput *)output setSampleBufferDelegate:nil queue:NULL]; + } + [session removeOutput:output]; + } + for (AVCaptureInput *input in [session.inputs copy]) { + [session removeInput:input]; + } + [session commitConfiguration]; } - return _metadataQueue; -} - -- (dispatch_queue_t)framesOutputQueue { - if (!_framesOutputQueue) { - _framesOutputQueue = dispatch_queue_create("DWQRScanViewModel.VideoFramesOutput.queue", DISPATCH_QUEUE_SERIAL); - } - return _framesOutputQueue; + self.captureSession = nil; + self.sessionQueue = nil; + self.metadataQueue = nil; + self.framesOutputQueue = nil; + self.captureSessionConfigured = NO; } @end diff --git a/DashWallet/Sources/UI/Uphold/Transfer/Model/UpholdAmountModel.swift b/DashWallet/Sources/UI/Uphold/Transfer/Model/UpholdAmountModel.swift index f05a656347..d3962f5528 100644 --- a/DashWallet/Sources/UI/Uphold/Transfer/Model/UpholdAmountModel.swift +++ b/DashWallet/Sources/UI/Uphold/Transfer/Model/UpholdAmountModel.swift @@ -84,7 +84,8 @@ final class UpholdAmountModel: BaseAmountModel { private func createTransaction(for amount: String, feeWasDeductedFromAmount: Bool, otpToken: String?) { guard let receiveAddress = SwiftDashSDKReceiveAddressReader.receiveAddress() else { - fatalError("Address should exist") + state = .fail + return } state = .loading @@ -110,7 +111,10 @@ final class UpholdAmountModel: BaseAmountModel { let notSufficientFunds = tx.total.compare(card.available) == .orderedDescending guard !notSufficientFunds else { - let amountNumber = Decimal(string: amount)! + guard let amountNumber = Decimal(string: amount) else { + self.state = .fail + return + } let correctedAmountNumber = amountNumber - tx.fee.decimalValue let correctedAmount = String(describing: correctedAmountNumber) diff --git a/DashWallet/Sources/UI/Views/BaseController/BaseViewController+NetworkReachability.swift b/DashWallet/Sources/UI/Views/BaseController/BaseViewController+NetworkReachability.swift index 60665e6b23..09f687e1bc 100644 --- a/DashWallet/Sources/UI/Views/BaseController/BaseViewController+NetworkReachability.swift +++ b/DashWallet/Sources/UI/Views/BaseController/BaseViewController+NetworkReachability.swift @@ -50,7 +50,9 @@ extension NetworkReachabilityHandling { } public func stopNetworkMonitoring() { - NotificationCenter.default.removeObserver(reachabilityObserver!) + guard let observer = reachabilityObserver else { return } + NotificationCenter.default.removeObserver(observer) + reachabilityObserver = nil } private func updateNetworkStatus() {