diff --git a/BDKSwiftExampleWallet/Extensions/BDK+Extensions/Outpoint+Extensions.swift b/BDKSwiftExampleWallet/Extensions/BDK+Extensions/Outpoint+Extensions.swift index 4e373df3..7dcdb565 100644 --- a/BDKSwiftExampleWallet/Extensions/BDK+Extensions/Outpoint+Extensions.swift +++ b/BDKSwiftExampleWallet/Extensions/BDK+Extensions/Outpoint+Extensions.swift @@ -8,7 +8,7 @@ import BitcoinDevKit import Foundation -extension OutPoint: Hashable { +extension OutPoint: @retroactive Hashable { public static func == (lhs: OutPoint, rhs: OutPoint) -> Bool { lhs.txid == rhs.txid && lhs.vout == rhs.vout } diff --git a/BDKSwiftExampleWallet/Extensions/Notification+Extensions.swift b/BDKSwiftExampleWallet/Extensions/Notification+Extensions.swift index ea6f42d7..b9a95341 100644 --- a/BDKSwiftExampleWallet/Extensions/Notification+Extensions.swift +++ b/BDKSwiftExampleWallet/Extensions/Notification+Extensions.swift @@ -8,6 +8,7 @@ import Foundation extension Notification.Name { + static let transactionSent = Notification.Name("TransactionSent") static let walletCreated = Notification.Name("walletCreated") static let walletDidUpdate = Notification.Name("walletDidUpdate") } diff --git a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift index ffe14e21..8f0519b2 100644 --- a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift @@ -571,9 +571,9 @@ final class BDKService { var sweptTxids: [Txid] = [] var lastWIFOperationError: Error? - + var destinationScript: Script? - + for descriptorString in candidates { guard let descriptor = try? Descriptor( @@ -855,7 +855,7 @@ struct BDKClient { let syncWithInspector: (SyncScriptInspector) async throws -> Void let fullScanWithInspector: (FullScanScriptInspector) async throws -> Void let getAddress: () throws -> String - let send: (String, UInt64, UInt64) throws -> Void + let send: (String, UInt64, UInt64) async throws -> Void let sweepWif: (String, UInt64) async throws -> [Txid] let calculateFee: (Transaction) throws -> Amount let calculateFeeRate: (Transaction) throws -> UInt64 @@ -897,9 +897,7 @@ extension BDKClient { }, getAddress: { try BDKService.shared.getAddress() }, send: { (address, amount, feeRate) in - Task { - try await BDKService.shared.send(address: address, amount: amount, feeRate: feeRate) - } + try await BDKService.shared.send(address: address, amount: amount, feeRate: feeRate) }, sweepWif: { (wif, feeRate) in try await BDKService.shared.sweepWif(wif: wif, feeRate: feeRate) diff --git a/BDKSwiftExampleWallet/View Model/Activity/ActivityListViewModel.swift b/BDKSwiftExampleWallet/View Model/Activity/ActivityListViewModel.swift index d47807fa..8c86eb8f 100644 --- a/BDKSwiftExampleWallet/View Model/Activity/ActivityListViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/Activity/ActivityListViewModel.swift @@ -26,7 +26,7 @@ class ActivityListViewModel { private var updateProgress: @Sendable (UInt64, UInt64) -> Void { { [weak self] inspected, total in - DispatchQueue.main.async { + Task { @MainActor [weak self] in self?.totalScripts = total self?.inspectedScripts = inspected self?.progress = total > 0 ? Float(inspected) / Float(total) : 0 diff --git a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift index 0ec7cefd..144b3d6e 100644 --- a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift @@ -128,10 +128,8 @@ class OnboardingViewModel: ObservableObject { func createWallet() { // Check if wallet already exists - if let existingBackup = try? bdkClient.getBackupInfo() { - DispatchQueue.main.async { - self.isOnboarding = false - } + if (try? bdkClient.getBackupInfo()) != nil { + self.isOnboarding = false return } @@ -139,42 +137,43 @@ class OnboardingViewModel: ObservableObject { return } - DispatchQueue.main.async { - self.isCreatingWallet = true - } + self.isCreatingWallet = true + let words = self.words + let isDescriptor = self.isDescriptor + let isXPub = self.isXPub Task { do { - if WifParser.extract(from: self.words) != nil { + if WifParser.extract(from: words) != nil { throw AppError.generic( message: "WIF is for sweep, not wallet creation. Open an existing wallet and use Send > Scan/Paste to sweep it." ) } - if self.isDescriptor { - try self.bdkClient.createWalletFromDescriptor(self.words) - } else if self.isXPub { - try self.bdkClient.createWalletFromXPub(self.words) + if isDescriptor { + try self.bdkClient.createWalletFromDescriptor(words) + } else if isXPub { + try self.bdkClient.createWalletFromXPub(words) } else { - try self.bdkClient.createWalletFromSeed(self.words) + try self.bdkClient.createWalletFromSeed(words) } - DispatchQueue.main.async { + await MainActor.run { self.isCreatingWallet = false self.isOnboarding = false NotificationCenter.default.post(name: .walletCreated, object: nil) } } catch let error as CreateWithPersistError { - DispatchQueue.main.async { + await MainActor.run { self.isCreatingWallet = false self.createWithPersistError = error } } catch let error as AppError { - DispatchQueue.main.async { + await MainActor.run { self.isCreatingWallet = false self.onboardingViewError = error } } catch { - DispatchQueue.main.async { + await MainActor.run { self.isCreatingWallet = false self.onboardingViewError = .generic(message: error.localizedDescription) } diff --git a/BDKSwiftExampleWallet/View Model/Receive/ReceiveViewModel.swift b/BDKSwiftExampleWallet/View Model/Receive/ReceiveViewModel.swift index 7bc403e4..59fa630f 100644 --- a/BDKSwiftExampleWallet/View Model/Receive/ReceiveViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/Receive/ReceiveViewModel.swift @@ -97,7 +97,7 @@ extension ReceiveViewModel { return } - DispatchQueue.main.async { + Task { @MainActor in self.receiveViewError = .generic(message: error.localizedDescription) self.showingReceiveViewErrorAlert = true } diff --git a/BDKSwiftExampleWallet/View Model/Send/BuildTransactionViewModel.swift b/BDKSwiftExampleWallet/View Model/Send/BuildTransactionViewModel.swift index 0d28276a..454e1a97 100644 --- a/BDKSwiftExampleWallet/View Model/Send/BuildTransactionViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/Send/BuildTransactionViewModel.swift @@ -68,11 +68,11 @@ class BuildTransactionViewModel { } } - func send(address: String, amount: UInt64, feeRate: UInt64) { + func send(address: String, amount: UInt64, feeRate: UInt64) async { do { - try bdkClient.send(address, amount, feeRate) + try await bdkClient.send(address, amount, feeRate) NotificationCenter.default.post( - name: Notification.Name("TransactionSent"), + name: .transactionSent, object: nil ) } catch let error as EsploraError { diff --git a/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift b/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift index 98615666..874ce3be 100644 --- a/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift @@ -24,7 +24,7 @@ class SettingsViewModel: ObservableObject { private var updateProgressFullScan: @Sendable (UInt64) -> Void { { [weak self] inspected in - DispatchQueue.main.async { + Task { @MainActor [weak self] in self?.inspectedScripts = inspected } } @@ -46,9 +46,7 @@ class SettingsViewModel: ObservableObject { } func getAddressType() { - DispatchQueue.main.async { - self.addressType = self.bdkClient.getAddressType() - } + self.addressType = self.bdkClient.getAddressType() } func delete() { @@ -66,33 +64,23 @@ class SettingsViewModel: ObservableObject { do { let inspector = WalletFullScanScriptInspector(updateProgress: updateProgressFullScan) try await bdkClient.fullScanWithInspector(inspector) - DispatchQueue.main.async { - NotificationCenter.default.post( - name: Notification.Name("TransactionSent"), - object: nil - ) - self.walletSyncState = .synced - } + NotificationCenter.default.post( + name: .transactionSent, + object: nil + ) + self.walletSyncState = .synced } catch let error as CannotConnectError { - DispatchQueue.main.async { - self.settingsError = .generic(message: error.localizedDescription) - self.showingSettingsViewErrorAlert = true - } + self.settingsError = .generic(message: error.localizedDescription) + self.showingSettingsViewErrorAlert = true } catch let error as EsploraError { - DispatchQueue.main.async { - self.settingsError = .generic(message: error.localizedDescription) - self.showingSettingsViewErrorAlert = true - } + self.settingsError = .generic(message: error.localizedDescription) + self.showingSettingsViewErrorAlert = true } catch let error as PersistenceError { - DispatchQueue.main.async { - self.settingsError = .generic(message: error.localizedDescription) - self.showingSettingsViewErrorAlert = true - } + self.settingsError = .generic(message: error.localizedDescription) + self.showingSettingsViewErrorAlert = true } catch { - DispatchQueue.main.async { - self.walletSyncState = .error(error) - self.showingSettingsViewErrorAlert = true - } + self.walletSyncState = .error(error) + self.showingSettingsViewErrorAlert = true } } diff --git a/BDKSwiftExampleWallet/View Model/WalletViewModel.swift b/BDKSwiftExampleWallet/View Model/WalletViewModel.swift index 629412e5..de5e330b 100644 --- a/BDKSwiftExampleWallet/View Model/WalletViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/WalletViewModel.swift @@ -50,7 +50,7 @@ class WalletViewModel { private var updateProgress: @Sendable (UInt64, UInt64) -> Void { { [weak self] inspected, total in - DispatchQueue.main.async { + Task { @MainActor [weak self] in // When using Kyoto, progress is provided separately as percent if self?.isKyotoClient == true { return } self?.totalScripts = total @@ -63,7 +63,7 @@ class WalletViewModel { private var updateKyotoProgress: @Sendable (Float) -> Void { { [weak self] rawProgress in - DispatchQueue.main.async { [weak self] in + Task { @MainActor [weak self] in guard let self else { return } let sanitized = rawProgress.isFinite ? min(max(rawProgress, 0), 100) : 0 self.progress = sanitized @@ -75,7 +75,7 @@ class WalletViewModel { private var updateProgressFullScan: @Sendable (UInt64) -> Void { { [weak self] inspected in - DispatchQueue.main.async { + Task { @MainActor [weak self] in self?.inspectedScripts = inspected } } @@ -99,25 +99,27 @@ class WalletViewModel { object: nil, queue: .main ) { [weak self] notification in - guard let self else { return } - // Ignore Kyoto updates unless client type is Kyoto - if self.bdkClient.getClientType() != .kyoto { return } - if let progress = notification.userInfo?["progress"] as? Float { - self.updateKyotoProgress(progress) - if let height = notification.userInfo?["height"] as? Int { - self.currentBlockHeight = UInt32(max(height, 0)) - } - // Consider any progress update as evidence of an active connection - // so the UI does not falsely show a red disconnected indicator while syncing. - if progress > 0 { - self.isKyotoConnected = true - } + Task { @MainActor [weak self] in + guard let self else { return } + // Ignore Kyoto updates unless client type is Kyoto + if self.bdkClient.getClientType() != .kyoto { return } + if let progress = notification.userInfo?["progress"] as? Float { + self.updateKyotoProgress(progress) + if let height = notification.userInfo?["height"] as? Int { + self.currentBlockHeight = UInt32(max(height, 0)) + } + // Consider any progress update as evidence of an active connection + // so the UI does not falsely show a red disconnected indicator while syncing. + if progress > 0 { + self.isKyotoConnected = true + } - // Update sync state based on Kyoto progress - if progress >= 100 { - self.walletSyncState = .synced - } else if progress > 0 { - self.walletSyncState = .syncing + // Update sync state based on Kyoto progress + if progress >= 100 { + self.walletSyncState = .synced + } else if progress > 0 { + self.walletSyncState = .syncing + } } } } @@ -127,16 +129,19 @@ class WalletViewModel { object: nil, queue: .main ) { [weak self] notification in - if let connected = notification.userInfo?["connected"] as? Bool { - self?.isKyotoConnected = connected + Task { @MainActor [weak self] in + guard let self else { return } + if let connected = notification.userInfo?["connected"] as? Bool { + self.isKyotoConnected = connected - // When Kyoto connects, update sync state if needed - if connected && self?.walletSyncState == .notStarted { - // Check current progress to determine state - if let progress = self?.progress, progress >= 100 { - self?.walletSyncState = .synced - } else { - self?.walletSyncState = .syncing + // When Kyoto connects, update sync state if needed + if connected && self.walletSyncState == .notStarted { + // Check current progress to determine state + if self.progress >= 100 { + self.walletSyncState = .synced + } else { + self.walletSyncState = .syncing + } } } } @@ -147,19 +152,19 @@ class WalletViewModel { object: nil, queue: .main ) { [weak self] notification in - guard let self else { return } - // Ignore Kyoto updates unless client type is Kyoto - if self.bdkClient.getClientType() != .kyoto { return } - if let height = notification.userInfo?["height"] as? Int { - self.currentBlockHeight = UInt32(max(height, 0)) - // Receiving chain height implies we have peer connectivity - self.isKyotoConnected = true - // Ensure UI reflects syncing as soon as we see chain activity - if self.walletSyncState == .notStarted { self.walletSyncState = .syncing } - // Auto-refresh wallet data when Kyoto receives new blocks - self.getBalance() - self.getTransactions() - Task { + Task { @MainActor [weak self] in + guard let self else { return } + // Ignore Kyoto updates unless client type is Kyoto + if self.bdkClient.getClientType() != .kyoto { return } + if let height = notification.userInfo?["height"] as? Int { + self.currentBlockHeight = UInt32(max(height, 0)) + // Receiving chain height implies we have peer connectivity + self.isKyotoConnected = true + // Ensure UI reflects syncing as soon as we see chain activity + if self.walletSyncState == .notStarted { self.walletSyncState = .syncing } + // Auto-refresh wallet data when Kyoto receives new blocks + self.getBalance() + self.getTransactions() await self.getPrices() } } @@ -170,10 +175,10 @@ class WalletViewModel { object: nil, queue: .main ) { [weak self] _ in - guard let self else { return } - self.getBalance() - self.getTransactions() - Task { + Task { @MainActor [weak self] in + guard let self else { return } + self.getBalance() + self.getTransactions() await self.getPrices() } } diff --git a/BDKSwiftExampleWallet/View/Send/AddressView.swift b/BDKSwiftExampleWallet/View/Send/AddressView.swift index a5f51cb8..0bce5829 100644 --- a/BDKSwiftExampleWallet/View/Send/AddressView.swift +++ b/BDKSwiftExampleWallet/View/Send/AddressView.swift @@ -127,7 +127,7 @@ extension AddressView { alertMessage = "Sweep broadcasted: \(txidText)" isShowingAlert = true NotificationCenter.default.post( - name: Notification.Name("TransactionSent"), + name: .transactionSent, object: nil ) } diff --git a/BDKSwiftExampleWallet/View/Send/BuildTransactionView.swift b/BDKSwiftExampleWallet/View/Send/BuildTransactionView.swift index 728c30a1..75576911 100644 --- a/BDKSwiftExampleWallet/View/Send/BuildTransactionView.swift +++ b/BDKSwiftExampleWallet/View/Send/BuildTransactionView.swift @@ -120,22 +120,26 @@ struct BuildTransactionView: View { if let amt = UInt64(amount) { viewModel.buildTransactionViewError = nil isError = false - viewModel.send( - address: address, - amount: amt, - feeRate: UInt64(fee) - ) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - if self.viewModel.buildTransactionViewError == nil { - self.isSent = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - self.navigationPath.removeLast( - self.navigationPath.count - ) + Task { @MainActor in + await viewModel.send( + address: address, + amount: amt, + feeRate: UInt64(fee) + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + if self.viewModel.buildTransactionViewError == nil { + self.isSent = true + DispatchQueue.main.asyncAfter( + deadline: .now() + 1.5 + ) { + self.navigationPath.removeLast( + self.navigationPath.count + ) + } + } else { + self.isSent = false + self.isError = true } - } else { - self.isSent = false - self.isError = true } } } else { diff --git a/BDKSwiftExampleWallet/View/WalletView.swift b/BDKSwiftExampleWallet/View/WalletView.swift index d4898160..74fbcf3b 100644 --- a/BDKSwiftExampleWallet/View/WalletView.swift +++ b/BDKSwiftExampleWallet/View/WalletView.swift @@ -88,7 +88,7 @@ struct WalletView: View { } } .onReceive( - NotificationCenter.default.publisher(for: Notification.Name("TransactionSent")), + NotificationCenter.default.publisher(for: .transactionSent), perform: { _ in newTransactionSent = true } diff --git a/BDKSwiftExampleWalletTests/View Model/BDKSwiftExampleWalletOnboardingViewModelTests.swift b/BDKSwiftExampleWalletTests/View Model/BDKSwiftExampleWalletOnboardingViewModelTests.swift new file mode 100644 index 00000000..cf08431d --- /dev/null +++ b/BDKSwiftExampleWalletTests/View Model/BDKSwiftExampleWalletOnboardingViewModelTests.swift @@ -0,0 +1,83 @@ +// +// BDKSwiftExampleWalletOnboardingViewModelTests.swift +// BDKSwiftExampleWalletTests +// +// Created by Codex on 4/9/26. +// + +import Foundation +import XCTest + +@testable import BDKSwiftExampleWallet + +final class BDKSwiftExampleWalletOnboardingViewModelTests: XCTestCase { + private enum TestOnboardingError: Error { + case noBackup + } + + private func makeBDKClient( + createWalletFromSeed: @escaping (String?) throws -> Void, + getBackupInfo: @escaping () throws -> BackupInfo = { throw TestOnboardingError.noBackup } + ) -> BDKClient { + BDKClient( + loadWallet: BDKClient.mock.loadWallet, + deleteWallet: BDKClient.mock.deleteWallet, + createWalletFromSeed: createWalletFromSeed, + createWalletFromDescriptor: BDKClient.mock.createWalletFromDescriptor, + createWalletFromXPub: BDKClient.mock.createWalletFromXPub, + getBalance: BDKClient.mock.getBalance, + transactions: BDKClient.mock.transactions, + listUnspent: BDKClient.mock.listUnspent, + syncWithInspector: BDKClient.mock.syncWithInspector, + fullScanWithInspector: BDKClient.mock.fullScanWithInspector, + getAddress: BDKClient.mock.getAddress, + send: BDKClient.mock.send, + sweepWif: BDKClient.mock.sweepWif, + calculateFee: BDKClient.mock.calculateFee, + calculateFeeRate: BDKClient.mock.calculateFeeRate, + sentAndReceived: BDKClient.mock.sentAndReceived, + txDetails: BDKClient.mock.txDetails, + buildTransaction: BDKClient.mock.buildTransaction, + getBackupInfo: getBackupInfo, + needsFullScan: BDKClient.mock.needsFullScan, + setNeedsFullScan: BDKClient.mock.setNeedsFullScan, + getNetwork: BDKClient.mock.getNetwork, + getEsploraURL: BDKClient.mock.getEsploraURL, + updateNetwork: BDKClient.mock.updateNetwork, + updateEsploraURL: BDKClient.mock.updateEsploraURL, + getAddressType: BDKClient.mock.getAddressType, + updateAddressType: BDKClient.mock.updateAddressType, + getClientType: BDKClient.mock.getClientType, + updateClientType: BDKClient.mock.updateClientType + ) + } + + @MainActor + func testCreateWalletIgnoresRepeatedCallsWhileCreationInProgress() async { + let started = expectation(description: "wallet creation started") + let created = XCTNSNotificationExpectation(name: .walletCreated) + let unblockCreation = DispatchSemaphore(value: 0) + var createCallCount = 0 + + let viewModel = OnboardingViewModel( + bdkClient: makeBDKClient(createWalletFromSeed: { _ in + createCallCount += 1 + started.fulfill() + unblockCreation.wait() + }) + ) + viewModel.words = + "abandon ability able about above absent absorb abstract absurd abuse access accident" + + viewModel.createWallet() + await fulfillment(of: [started], timeout: 1.0) + viewModel.createWallet() + + XCTAssertTrue(viewModel.isCreatingWallet) + XCTAssertEqual(createCallCount, 1) + + unblockCreation.signal() + await fulfillment(of: [created], timeout: 1.0) + XCTAssertFalse(viewModel.isCreatingWallet) + } +} diff --git a/BDKSwiftExampleWalletTests/View Model/BDKSwiftExampleWalletSendViewModelTests.swift b/BDKSwiftExampleWalletTests/View Model/BDKSwiftExampleWalletSendViewModelTests.swift index eb7b6718..b24dbaa1 100644 --- a/BDKSwiftExampleWalletTests/View Model/BDKSwiftExampleWalletSendViewModelTests.swift +++ b/BDKSwiftExampleWalletTests/View Model/BDKSwiftExampleWalletSendViewModelTests.swift @@ -5,11 +5,51 @@ // Created by Matthew Ramsden on 8/24/23. // +import BitcoinDevKit import XCTest @testable import BDKSwiftExampleWallet final class BDKSwiftExampleWalletSendViewModelTests: XCTestCase { + private enum TestSendError: Error { + case failed + } + + private func makeBDKClient( + send: @escaping (String, UInt64, UInt64) async throws -> Void + ) -> BDKClient { + BDKClient( + loadWallet: BDKClient.mock.loadWallet, + deleteWallet: BDKClient.mock.deleteWallet, + createWalletFromSeed: BDKClient.mock.createWalletFromSeed, + createWalletFromDescriptor: BDKClient.mock.createWalletFromDescriptor, + createWalletFromXPub: BDKClient.mock.createWalletFromXPub, + getBalance: BDKClient.mock.getBalance, + transactions: BDKClient.mock.transactions, + listUnspent: BDKClient.mock.listUnspent, + syncWithInspector: BDKClient.mock.syncWithInspector, + fullScanWithInspector: BDKClient.mock.fullScanWithInspector, + getAddress: BDKClient.mock.getAddress, + send: send, + sweepWif: BDKClient.mock.sweepWif, + calculateFee: BDKClient.mock.calculateFee, + calculateFeeRate: BDKClient.mock.calculateFeeRate, + sentAndReceived: BDKClient.mock.sentAndReceived, + txDetails: BDKClient.mock.txDetails, + buildTransaction: BDKClient.mock.buildTransaction, + getBackupInfo: BDKClient.mock.getBackupInfo, + needsFullScan: BDKClient.mock.needsFullScan, + setNeedsFullScan: BDKClient.mock.setNeedsFullScan, + getNetwork: BDKClient.mock.getNetwork, + getEsploraURL: BDKClient.mock.getEsploraURL, + updateNetwork: BDKClient.mock.updateNetwork, + updateEsploraURL: BDKClient.mock.updateEsploraURL, + getAddressType: BDKClient.mock.getAddressType, + updateAddressType: BDKClient.mock.updateAddressType, + getClientType: BDKClient.mock.getClientType, + updateClientType: BDKClient.mock.updateClientType + ) + } @MainActor func testAmountViewModel() async { @@ -52,4 +92,40 @@ final class BDKSwiftExampleWalletSendViewModelTests: XCTestCase { ) } + @MainActor + func testBuildTransactionViewModelSendPostsTransactionSentNotification() async { + let viewModel = BuildTransactionViewModel( + bdkClient: makeBDKClient(send: { _, _, _ in }) + ) + let expectation = XCTNSNotificationExpectation(name: .transactionSent) + + await viewModel.send( + address: "tb1pxg0lakl0x4jee73f38m334qsma7mn2yv764x9an5ylht6tx8ccdsxtktrt", + amount: 100_000, + feeRate: 17 + ) + + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertNil(viewModel.buildTransactionViewError) + XCTAssertFalse(viewModel.showingBuildTransactionViewErrorAlert) + } + + @MainActor + func testBuildTransactionViewModelSendSurfacesAsyncFailure() async { + let viewModel = BuildTransactionViewModel( + bdkClient: makeBDKClient(send: { _, _, _ in + throw TestSendError.failed + }) + ) + + await viewModel.send( + address: "tb1pxg0lakl0x4jee73f38m334qsma7mn2yv764x9an5ylht6tx8ccdsxtktrt", + amount: 100_000, + feeRate: 17 + ) + + XCTAssertNotNil(viewModel.buildTransactionViewError) + XCTAssertTrue(viewModel.showingBuildTransactionViewErrorAlert) + } + }