diff --git a/BDKSwiftExampleWallet.xcodeproj/project.pbxproj b/BDKSwiftExampleWallet.xcodeproj/project.pbxproj index 5b4f299e..aee50db9 100644 --- a/BDKSwiftExampleWallet.xcodeproj/project.pbxproj +++ b/BDKSwiftExampleWallet.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ AE96F6622A424C400055623C /* BDKSwiftExampleWalletReceiveViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE96F6612A424C400055623C /* BDKSwiftExampleWalletReceiveViewModelTests.swift */; }; AE97E74D2E315A8F000A407D /* AddressType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE97E74C2E315A8F000A407D /* AddressType+Extensions.swift */; }; AEA0A6272E297203008A525B /* BitcoinDevKit in Frameworks */ = {isa = PBXBuildFile; productRef = AEA0A6262E297203008A525B /* BitcoinDevKit */; }; + AEAA61BF2E380D62006ED2D0 /* Notification+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEAA61BE2E380D62006ED2D0 /* Notification+Extensions.swift */; }; AEAB03112ABDDB86000C9528 /* FeeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEAB03102ABDDB86000C9528 /* FeeViewModel.swift */; }; AEAB03132ABDDBF4000C9528 /* AmountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEAB03122ABDDBF4000C9528 /* AmountViewModel.swift */; }; AEAF83B62B7BD4D10019B23B /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = AEAF83B52B7BD4D10019B23B /* CodeScanner */; }; @@ -189,6 +190,7 @@ AE91CEEE2C0FDBC7000AAD20 /* CanonicalTx+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CanonicalTx+Extensions.swift"; sourceTree = ""; }; AE96F6612A424C400055623C /* BDKSwiftExampleWalletReceiveViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BDKSwiftExampleWalletReceiveViewModelTests.swift; sourceTree = ""; }; AE97E74C2E315A8F000A407D /* AddressType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AddressType+Extensions.swift"; sourceTree = ""; }; + AEAA61BE2E380D62006ED2D0 /* Notification+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Extensions.swift"; sourceTree = ""; }; AEAB03102ABDDB86000C9528 /* FeeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeeViewModel.swift; sourceTree = ""; }; AEAB03122ABDDBF4000C9528 /* AmountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmountViewModel.swift; sourceTree = ""; }; AEB130C82A44E4850087785B /* TransactionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDetailView.swift; sourceTree = ""; }; @@ -293,6 +295,7 @@ AE7F67062A744CE200CED561 /* Double+Extensions.swift */, AEB159D42D51A8680006AE9E /* View+Extensions.swift */, AE8D001B2D19F1760029C4C9 /* UIScreen+Extensions.swift */, + AEAA61BE2E380D62006ED2D0 /* Notification+Extensions.swift */, AEE6C74D2ABCB48600442ADD /* BDK+Extensions */, ); path = Extensions; @@ -745,6 +748,7 @@ AEB130C92A44E4850087785B /* TransactionDetailView.swift in Sources */, AE287E772C0F6D200036A748 /* Array+Extensions.swift in Sources */, AE6715FD2A9AC056005C193F /* PriceServiceError.swift in Sources */, + AEAA61BF2E380D62006ED2D0 /* Notification+Extensions.swift in Sources */, AE34DDAC2B6B31ED00F04AD4 /* WalletRecoveryView.swift in Sources */, AE2ADD742B61E8F500C2A823 /* SettingsView.swift in Sources */, AE2381AF2C605B1D00F6B00C /* ActivityListViewModel.swift in Sources */, diff --git a/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift b/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift index 7a054f50..7d7520ac 100644 --- a/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift +++ b/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift @@ -12,15 +12,16 @@ import SwiftUI struct BDKSwiftExampleWalletApp: App { @AppStorage("isOnboarding") var isOnboarding: Bool = true @State private var navigationPath = NavigationPath() + @State private var refreshTrigger = UUID() var body: some Scene { WindowGroup { NavigationStack(path: $navigationPath) { - let value = try? KeyClient.live.getBackupInfo() - if isOnboarding && (value == nil) { - OnboardingView(viewModel: .init(bdkClient: .live)) - } else if !isOnboarding && (value == nil) { + if !walletExists { OnboardingView(viewModel: .init(bdkClient: .live)) + .onReceive(NotificationCenter.default.publisher(for: .walletCreated)) { _ in + refreshTrigger = UUID() + } } else { HomeView(viewModel: .init(bdkClient: .live), navigationPath: $navigationPath) } @@ -32,3 +33,12 @@ struct BDKSwiftExampleWalletApp: App { } } } + +extension BDKSwiftExampleWalletApp { + private var walletExists: Bool { + // Force re-evaluation by reading refreshTrigger and isOnboarding + let _ = refreshTrigger + let _ = isOnboarding + return (try? KeyClient.live.getBackupInfo()) != nil + } +} diff --git a/BDKSwiftExampleWallet/Extensions/Notification+Extensions.swift b/BDKSwiftExampleWallet/Extensions/Notification+Extensions.swift new file mode 100644 index 00000000..fdfc2c08 --- /dev/null +++ b/BDKSwiftExampleWallet/Extensions/Notification+Extensions.swift @@ -0,0 +1,12 @@ +// +// Notification+Extensions.swift +// BDKSwiftExampleWallet +// +// Created by Matthew Ramsden on 7/28/25. +// + +import Foundation + +extension Notification.Name { + static let walletCreated = Notification.Name("walletCreated") +} diff --git a/BDKSwiftExampleWallet/Resources/Localizable.xcstrings b/BDKSwiftExampleWallet/Resources/Localizable.xcstrings index 81efd666..295efa1b 100644 --- a/BDKSwiftExampleWallet/Resources/Localizable.xcstrings +++ b/BDKSwiftExampleWallet/Resources/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + " High • %lld" : { + + }, " High Priority - %lld" : { "extractionState" : "stale", "localizations" : { @@ -18,7 +21,7 @@ } } }, - " High: %lld" : { + " Low • %lld" : { }, " Low Priority - %lld" : { @@ -37,9 +40,6 @@ } } } - }, - " Low: %lld" : { - }, " Med Priority - %lld" : { "extractionState" : "stale", @@ -57,6 +57,9 @@ } } } + }, + " Medium • %lld" : { + }, " No Priority - %lld" : { "extractionState" : "stale", @@ -75,7 +78,7 @@ } } }, - " None: %lld" : { + " None • %lld" : { }, "- %llu sats" : { @@ -532,6 +535,9 @@ } } } + }, + "Creating..." : { + }, "Danger Zone" : { "localizations" : { @@ -665,8 +671,12 @@ } } } + }, + "Fee Priority" : { + }, "Fees" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -723,9 +733,6 @@ } } } - }, - "Med: %lld" : { - }, "Navigation Title" : { "extractionState" : "stale", @@ -923,8 +930,12 @@ } } } + }, + "powered by BDK" : { + }, "powered by Bitcoin Dev Kit" : { + "extractionState" : "stale", "localizations" : { "pt-BR" : { "stringUnit" : { diff --git a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift index fee546af..45ca24b5 100644 --- a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift @@ -8,16 +8,51 @@ import BitcoinDevKit import Foundation +enum BlockchainClientType: String, CaseIterable { + case esplora = "esplora" + case kyoto = "kyoto" // future + case electrum = "electrum" // future +} + +struct BlockchainClient { + let sync: @Sendable (SyncRequest, UInt64) throws -> Update + let fullScan: @Sendable (FullScanRequest, UInt64, UInt64) throws -> Update + let broadcast: @Sendable (Transaction) throws -> Void + let getUrl: @Sendable () -> String + let getType: @Sendable () -> BlockchainClientType + let supportsFullScan: @Sendable () -> Bool = { true } +} + +extension BlockchainClient { + static func esplora(url: String) -> Self { + let client = EsploraClient(url: url) + return Self( + sync: { request, parallel in + try client.sync(request: request, parallelRequests: parallel) + }, + fullScan: { request, stopGap, parallel in + try client.fullScan(request: request, stopGap: stopGap, parallelRequests: parallel) + }, + broadcast: { tx in + try client.broadcast(transaction: tx) + }, + getUrl: { url }, + getType: { .esplora } + ) + } +} + private class BDKService { - static var shared: BDKService = BDKService() + static let shared: BDKService = BDKService() private var balance: Balance? private var persister: Persister? - private var esploraClient: EsploraClient + private var blockchainClient: BlockchainClient + internal private(set) var clientType: BlockchainClientType private let keyClient: KeyClient private var needsFullScan: Bool = false private(set) var network: Network - private(set) var esploraURL: String + private var blockchainURL: String private var wallet: Wallet? init(keyClient: KeyClient = .live) { @@ -25,9 +60,12 @@ private class BDKService { let storedNetworkString = try? keyClient.getNetwork() ?? Network.signet.description self.network = Network(stringValue: storedNetworkString ?? "") ?? .signet - self.esploraURL = (try? keyClient.getEsploraURL()) ?? self.network.url + let storedClientType = try? keyClient.getClientType() + self.clientType = storedClientType ?? .esplora - self.esploraClient = EsploraClient(url: self.esploraURL) + self.blockchainURL = (try? keyClient.getEsploraURL()) ?? self.network.url + self.blockchainClient = BlockchainClient.esplora(url: self.blockchainURL) + updateBlockchainClient() } func updateNetwork(_ newNetwork: Network) { @@ -40,20 +78,33 @@ private class BDKService { try? keyClient.saveNetwork(newNetwork.description) let newURL = newNetwork.url - updateEsploraURL(newURL) + updateBlockchainURL(newURL) } } - func updateEsploraURL(_ newURL: String) { - if newURL != self.esploraURL { - self.esploraURL = newURL - try? keyClient.saveEsploraURL(newURL) - updateEsploraClient() + func updateBlockchainURL(_ newURL: String) { + if newURL != self.blockchainURL { + self.blockchainURL = newURL + try? keyClient.saveEsploraURL(newURL) // TODO: Future - saveURL(newURL, for: clientType) + updateBlockchainClient() } } - private func updateEsploraClient() { - self.esploraClient = EsploraClient(url: self.esploraURL) + internal func updateBlockchainClient() { + do { + switch clientType { + case .esplora: + self.blockchainClient = .esplora(url: self.blockchainURL) + case .kyoto: + throw WalletError.backendNotImplemented + case .electrum: + throw WalletError.backendNotImplemented + } + } catch { + // Fallback to esplora if selected backend not implemented + self.clientType = .esplora + self.blockchainClient = .esplora(url: self.blockchainURL) + } } private func getCurrentAddressType() -> AddressType { @@ -211,8 +262,8 @@ private class BDKService { try keyClient.saveBackupInfo(backupInfo) try keyClient.saveNetwork(self.network.description) try keyClient.saveEsploraURL(baseUrl) - self.esploraURL = baseUrl - updateEsploraClient() + self.blockchainURL = baseUrl + updateBlockchainClient() let wallet = try Wallet( descriptor: descriptor, @@ -313,8 +364,8 @@ private class BDKService { try keyClient.saveBackupInfo(backupInfo) try keyClient.saveNetwork(self.network.description) try keyClient.saveEsploraURL(baseUrl) - self.esploraURL = baseUrl - updateEsploraClient() + self.blockchainURL = baseUrl + updateBlockchainClient() let wallet = try Wallet( descriptor: descriptor, @@ -357,7 +408,6 @@ private class BDKService { self.wallet = wallet } catch is LoadWithPersistError { // Database is corrupted or incompatible, delete and recreate - print("Wallet database is corrupted, recreating...") try Persister.deleteConnection() let persister = try Persister.createConnection() @@ -441,8 +491,7 @@ private class BDKService { let isSigned = try wallet.sign(psbt: psbt) if isSigned { let transaction = try psbt.extractTx() - let client = self.esploraClient - try client.broadcast(transaction: transaction) + try self.blockchainClient.broadcast(transaction) } else { throw WalletError.notSigned } @@ -450,13 +499,12 @@ private class BDKService { func syncWithInspector(inspector: SyncScriptInspector) async throws { guard let wallet = self.wallet else { throw WalletError.walletNotFound } - let esploraClient = self.esploraClient let syncRequest = try wallet.startSyncWithRevealedSpks() .inspectSpks(inspector: inspector) .build() - let update = try esploraClient.sync( - request: syncRequest, - parallelRequests: UInt64(5) + let update = try self.blockchainClient.sync( + syncRequest, + UInt64(5) ) let _ = try wallet.applyUpdate(update: update) guard let persister = self.persister else { @@ -467,16 +515,18 @@ private class BDKService { func fullScanWithInspector(inspector: FullScanScriptInspector) async throws { guard let wallet = self.wallet else { throw WalletError.walletNotFound } - let esploraClient = esploraClient + guard self.blockchainClient.supportsFullScan() else { + throw WalletError.fullScanUnsupported + } let fullScanRequest = try wallet.startFullScan() .inspectSpksForAllKeychains(inspector: inspector) .build() - let update = try esploraClient.fullScan( - request: fullScanRequest, + let update = try self.blockchainClient.fullScan( + fullScanRequest, // using https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#address-gap-limit - stopGap: UInt64(20), + UInt64(20), // using https://github.com/bitcoindevkit/bdk/blob/master/example-crates/example_wallet_esplora_blocking/src/main.rs - parallelRequests: UInt64(5) + UInt64(5) ) let _ = try wallet.applyUpdate(update: update) guard let persister = self.persister else { @@ -532,7 +582,27 @@ extension BDKService { } func updateAddressType(_ newAddressType: AddressType) { + let currentType = getCurrentAddressType() try? keyClient.saveAddressType(newAddressType.description) + + // If address type changed, we need a full scan to find transactions with new derivation paths + if currentType != newAddressType { + needsFullScan = true + } + } + + func updateClientType(_ newType: BlockchainClientType) { + self.clientType = newType + try? keyClient.saveClientType(newType) + updateBlockchainClient() + } + + var esploraURL: String { + return blockchainURL + } + + func updateEsploraURL(_ newURL: String) { + updateBlockchainURL(newURL) } } @@ -563,6 +633,8 @@ struct BDKClient { let updateEsploraURL: (String) -> Void let getAddressType: () -> AddressType let updateAddressType: (AddressType) -> Void + let getClientType: () -> BlockchainClientType + let updateClientType: (BlockchainClientType) -> Void } extension BDKClient { @@ -622,6 +694,10 @@ extension BDKClient { }, updateAddressType: { newAddressType in BDKService.shared.updateAddressType(newAddressType) + }, + getClientType: { BDKService.shared.clientType }, + updateClientType: { newType in + BDKService.shared.updateClientType(newType) } ) } @@ -681,7 +757,9 @@ extension BDKClient { updateNetwork: { _ in }, updateEsploraURL: { _ in }, getAddressType: { .bip86 }, - updateAddressType: { _ in } + updateAddressType: { _ in }, + getClientType: { .esplora }, + updateClientType: { _ in } ) } #endif diff --git a/BDKSwiftExampleWallet/Service/BDK Service/BDKSwiftExampleWalletError.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKSwiftExampleWalletError.swift index 56699856..60f12fa4 100644 --- a/BDKSwiftExampleWallet/Service/BDK Service/BDKSwiftExampleWalletError.swift +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKSwiftExampleWalletError.swift @@ -12,4 +12,25 @@ enum WalletError: Error { case dbNotFound case notSigned case walletNotFound + case fullScanUnsupported + case backendNotImplemented +} + +extension WalletError: LocalizedError { + var errorDescription: String? { + switch self { + case .blockchainConfigNotFound: + return "Blockchain configuration not found" + case .dbNotFound: + return "Database not found" + case .notSigned: + return "Transaction not signed" + case .walletNotFound: + return "Wallet not found" + case .fullScanUnsupported: + return "Full scan is not supported by the selected blockchain client" + case .backendNotImplemented: + return "The selected blockchain backend is not yet implemented" + } + } } diff --git a/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift b/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift index bbc61c5b..4ba5b546 100644 --- a/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift +++ b/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift @@ -70,6 +70,14 @@ private struct KeyService { func saveAddressType(addressType: String) throws { keychain[string: "SelectedAddressType"] = addressType } + + func getClientType() throws -> String? { + return keychain[string: "SelectedClientType"] + } + + func saveClientType(_ clientType: String) throws { + keychain[string: "SelectedClientType"] = clientType + } } struct KeyClient { @@ -84,6 +92,8 @@ struct KeyClient { let saveBackupInfo: (BackupInfo) throws -> Void let saveNetwork: (String) throws -> Void let saveAddressType: (String) throws -> Void + let getClientType: () throws -> BlockchainClientType + let saveClientType: (BlockchainClientType) throws -> Void private init( deleteBackupInfo: @escaping () throws -> Void, @@ -96,7 +106,9 @@ struct KeyClient { saveBackupInfo: @escaping (BackupInfo) throws -> Void, saveEsploraURL: @escaping (String) throws -> Void, saveNetwork: @escaping (String) throws -> Void, - saveAddressType: @escaping (String) throws -> Void + saveAddressType: @escaping (String) throws -> Void, + getClientType: @escaping () throws -> BlockchainClientType, + saveClientType: @escaping (BlockchainClientType) throws -> Void ) { self.deleteBackupInfo = deleteBackupInfo self.deleteEsplora = deleteEsplora @@ -109,6 +121,8 @@ struct KeyClient { self.saveEsploraURL = saveEsploraURL self.saveNetwork = saveNetwork self.saveAddressType = saveAddressType + self.getClientType = getClientType + self.saveClientType = saveClientType } } @@ -125,6 +139,13 @@ extension KeyClient { saveEsploraURL: { url in try KeyService().saveEsploraURL(url: url) }, saveNetwork: { network in try KeyService().saveNetwork(network: network) }, saveAddressType: { addressType in try KeyService().saveAddressType(addressType: addressType) + }, + getClientType: { + let raw = try KeyService().getClientType() + return BlockchainClientType(rawValue: raw ?? "") ?? .esplora + }, + saveClientType: { type in + try KeyService().saveClientType(type.rawValue) } ) } @@ -167,7 +188,9 @@ extension KeyClient { saveBackupInfo: { _ in }, saveEsploraURL: { _ in }, saveNetwork: { _ in }, - saveAddressType: { _ in } + saveAddressType: { _ in }, + getClientType: { .esplora }, + saveClientType: { _ in } ) } #endif diff --git a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift index d1aa5bb9..907edfc1 100644 --- a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift @@ -9,6 +9,7 @@ import BitcoinDevKit import Foundation import SwiftUI + // Can't make @Observable yet // https://developer.apple.com/forums/thread/731187 // Feature or Bug? @@ -17,6 +18,7 @@ class OnboardingViewModel: ObservableObject { @AppStorage("isOnboarding") var isOnboarding: Bool? @Published var createWithPersistError: CreateWithPersistError? + @Published var isCreatingWallet = false var isDescriptor: Bool { words.hasPrefix("tr(") || words.hasPrefix("wpkh(") || words.hasPrefix("wsh(") || words.hasPrefix("sh(") @@ -90,24 +92,46 @@ class OnboardingViewModel: ObservableObject { } func createWallet() { - do { - if isDescriptor { - try bdkClient.createWalletFromDescriptor(words) - } else if isXPub { - try bdkClient.createWalletFromXPub(words) - } else { - try bdkClient.createWalletFromSeed(words) - } + // Check if wallet already exists + if let existingBackup = try? bdkClient.getBackupInfo() { DispatchQueue.main.async { self.isOnboarding = false } - } catch let error as CreateWithPersistError { - DispatchQueue.main.async { - self.createWithPersistError = error - } - } catch { - DispatchQueue.main.async { - self.onboardingViewError = .generic(message: error.localizedDescription) + return + } + + guard !isCreatingWallet else { + return + } + + DispatchQueue.main.async { + self.isCreatingWallet = true + } + + Task { + do { + if self.isDescriptor { + try self.bdkClient.createWalletFromDescriptor(self.words) + } else if self.isXPub { + try self.bdkClient.createWalletFromXPub(self.words) + } else { + try self.bdkClient.createWalletFromSeed(self.words) + } + DispatchQueue.main.async { + self.isCreatingWallet = false + self.isOnboarding = false + NotificationCenter.default.post(name: .walletCreated, object: nil) + } + } catch let error as CreateWithPersistError { + DispatchQueue.main.async { + self.isCreatingWallet = false + self.createWithPersistError = error + } + } catch { + DispatchQueue.main.async { + self.isCreatingWallet = false + self.onboardingViewError = .generic(message: error.localizedDescription) + } } } } diff --git a/BDKSwiftExampleWallet/View/OnboardingView.swift b/BDKSwiftExampleWallet/View/OnboardingView.swift index 8d5be0ea..90d875e7 100644 --- a/BDKSwiftExampleWallet/View/OnboardingView.swift +++ b/BDKSwiftExampleWallet/View/OnboardingView.swift @@ -176,9 +176,19 @@ struct OnboardingView: View { Spacer() - Button("Create Wallet") { + Button(action: { viewModel.createWallet() + }) { + HStack { + if viewModel.isCreatingWallet { + ProgressView() + .scaleEffect(0.8) + .foregroundColor(Color(uiColor: .systemBackground)) + } + Text(viewModel.isCreatingWallet ? "Creating..." : "Create Wallet") + } } + .disabled(viewModel.isCreatingWallet) .buttonStyle( BitcoinFilled( tintColor: .primary, diff --git a/BDKSwiftExampleWalletTests/Service/BDKSwiftExampleWalletBDKServiceTests.swift b/BDKSwiftExampleWalletTests/Service/BDKSwiftExampleWalletBDKServiceTests.swift index 9fb3b624..4edbdd42 100644 --- a/BDKSwiftExampleWalletTests/Service/BDKSwiftExampleWalletBDKServiceTests.swift +++ b/BDKSwiftExampleWalletTests/Service/BDKSwiftExampleWalletBDKServiceTests.swift @@ -33,4 +33,39 @@ final class BDKSwiftExampleWalletBDKServiceTests: XCTestCase { ) } + func testBDKClientGetClientType() { + let clientType = BDKClient.mock.getClientType() + + XCTAssertEqual(clientType, .esplora) + } + + func testBDKClientUpdateClientType() { + // This test verifies that updateClientType doesn't crash with mock client + // In the real implementation, setting an unimplemented type would fallback to esplora + BDKClient.mock.updateClientType(.kyoto) + + // Verify it still returns esplora (since mock doesn't actually change) + let clientType = BDKClient.mock.getClientType() + XCTAssertEqual(clientType, .esplora) + } + + func testBlockchainClientTypeEnumValues() { + // Test that all expected client types are available + let allCases = BlockchainClientType.allCases + XCTAssertTrue(allCases.contains(.esplora)) + XCTAssertTrue(allCases.contains(.kyoto)) + XCTAssertTrue(allCases.contains(.electrum)) + XCTAssertEqual(allCases.count, 3) + } + + func testBlockchainClientEsploraFactory() { + // Test that the esplora factory method works correctly + let testURL = "https://blockstream.info/testnet/api" + let client = BlockchainClient.esplora(url: testURL) + + XCTAssertEqual(client.getUrl(), testURL) + XCTAssertEqual(client.getType(), .esplora) + XCTAssertTrue(client.supportsFullScan()) + } + }