From 45acbc6b6d99d00750fc8fa0145653f694ddfb01 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 28 Jul 2025 10:00:10 -0500 Subject: [PATCH 1/9] wip client --- .../Service/BDK Service/BDKService.swift | 131 ++++++++++++++---- .../BDKSwiftExampleWalletError.swift | 2 + .../Service/Key Service/KeyService.swift | 28 +++- 3 files changed, 129 insertions(+), 32 deletions(-) diff --git a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift index fee546af..79f6deb1 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() 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 - - self.esploraClient = EsploraClient(url: self.esploraURL) + let storedClientType = try? keyClient.getClientType() + self.clientType = storedClientType ?? .esplora + + 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, @@ -441,8 +492,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 +500,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 +516,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 { @@ -534,6 +585,20 @@ extension BDKService { func updateAddressType(_ newAddressType: AddressType) { try? keyClient.saveAddressType(newAddressType.description) } + + func updateClientType(_ newType: BlockchainClientType) { + self.clientType = newType + try? keyClient.saveClientType(newType) + updateBlockchainClient() + } + + var esploraURL: String { + return blockchainURL + } + + func updateEsploraURL(_ newURL: String) { + updateBlockchainURL(newURL) + } } struct BDKClient { @@ -563,6 +628,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 +689,10 @@ extension BDKClient { }, updateAddressType: { newAddressType in BDKService.shared.updateAddressType(newAddressType) + }, + getClientType: { BDKService.shared.clientType }, + updateClientType: { newType in + BDKService.shared.updateClientType(newType) } ) } @@ -681,7 +752,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..80499e01 100644 --- a/BDKSwiftExampleWallet/Service/BDK Service/BDKSwiftExampleWalletError.swift +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKSwiftExampleWalletError.swift @@ -12,4 +12,6 @@ enum WalletError: Error { case dbNotFound case notSigned case walletNotFound + case fullScanUnsupported + case backendNotImplemented } diff --git a/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift b/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift index bbc61c5b..e3086dca 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 } } @@ -124,7 +138,13 @@ extension KeyClient { saveBackupInfo: { backupInfo in try KeyService().saveBackupInfo(backupInfo: backupInfo) }, saveEsploraURL: { url in try KeyService().saveEsploraURL(url: url) }, saveNetwork: { network in try KeyService().saveNetwork(network: network) }, - saveAddressType: { addressType in try KeyService().saveAddressType(addressType: addressType) + 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 +187,9 @@ extension KeyClient { saveBackupInfo: { _ in }, saveEsploraURL: { _ in }, saveNetwork: { _ in }, - saveAddressType: { _ in } + saveAddressType: { _ in }, + getClientType: { .esplora }, + saveClientType: { _ in } ) } #endif From 9ede7edce5884c6b6d6405d00b6925c5ab5e98ff Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 28 Jul 2025 10:04:50 -0500 Subject: [PATCH 2/9] nits and tests --- .../Service/BDK Service/BDKService.swift | 2 +- .../BDKSwiftExampleWalletError.swift | 19 ++++++++++ ...BDKSwiftExampleWalletBDKServiceTests.swift | 35 +++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift index 79f6deb1..48e358d5 100644 --- a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift @@ -43,7 +43,7 @@ extension BlockchainClient { } private class BDKService { - static var shared: BDKService = BDKService() + static let shared: BDKService = BDKService() private var balance: Balance? private var persister: Persister? diff --git a/BDKSwiftExampleWallet/Service/BDK Service/BDKSwiftExampleWalletError.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKSwiftExampleWalletError.swift index 80499e01..60f12fa4 100644 --- a/BDKSwiftExampleWallet/Service/BDK Service/BDKSwiftExampleWalletError.swift +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKSwiftExampleWalletError.swift @@ -15,3 +15,22 @@ enum WalletError: Error { 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/BDKSwiftExampleWalletTests/Service/BDKSwiftExampleWalletBDKServiceTests.swift b/BDKSwiftExampleWalletTests/Service/BDKSwiftExampleWalletBDKServiceTests.swift index 9fb3b624..97b82a7b 100644 --- a/BDKSwiftExampleWalletTests/Service/BDKSwiftExampleWalletBDKServiceTests.swift +++ b/BDKSwiftExampleWalletTests/Service/BDKSwiftExampleWalletBDKServiceTests.swift @@ -32,5 +32,40 @@ final class BDKSwiftExampleWalletBDKServiceTests: XCTestCase { Transaction.mock?.transactionID ) } + + 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()) + } } From 71824e0bf9fd4ee3c9d515712a905648d2c6a179 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 28 Jul 2025 10:05:07 -0500 Subject: [PATCH 3/9] format --- .../Service/BDK Service/BDKService.swift | 12 ++++++------ .../Service/Key Service/KeyService.swift | 7 ++++--- .../BDKSwiftExampleWalletBDKServiceTests.swift | 14 +++++++------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift index 48e358d5..9397da81 100644 --- a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift @@ -10,8 +10,8 @@ import Foundation enum BlockchainClientType: String, CaseIterable { case esplora = "esplora" - case kyoto = "kyoto" // future - case electrum = "electrum" // future + case kyoto = "kyoto" // future + case electrum = "electrum" // future } struct BlockchainClient { @@ -62,7 +62,7 @@ private class BDKService { let storedClientType = try? keyClient.getClientType() self.clientType = storedClientType ?? .esplora - + self.blockchainURL = (try? keyClient.getEsploraURL()) ?? self.network.url self.blockchainClient = BlockchainClient.esplora(url: self.blockchainURL) updateBlockchainClient() @@ -585,17 +585,17 @@ extension BDKService { func updateAddressType(_ newAddressType: AddressType) { try? keyClient.saveAddressType(newAddressType.description) } - + func updateClientType(_ newType: BlockchainClientType) { self.clientType = newType try? keyClient.saveClientType(newType) updateBlockchainClient() } - + var esploraURL: String { return blockchainURL } - + func updateEsploraURL(_ newURL: String) { updateBlockchainURL(newURL) } diff --git a/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift b/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift index e3086dca..4ba5b546 100644 --- a/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift +++ b/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift @@ -70,11 +70,11 @@ 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 } @@ -138,7 +138,8 @@ extension KeyClient { saveBackupInfo: { backupInfo in try KeyService().saveBackupInfo(backupInfo: backupInfo) }, saveEsploraURL: { url in try KeyService().saveEsploraURL(url: url) }, saveNetwork: { network in try KeyService().saveNetwork(network: network) }, - saveAddressType: { addressType in try KeyService().saveAddressType(addressType: addressType) }, + saveAddressType: { addressType in try KeyService().saveAddressType(addressType: addressType) + }, getClientType: { let raw = try KeyService().getClientType() return BlockchainClientType(rawValue: raw ?? "") ?? .esplora diff --git a/BDKSwiftExampleWalletTests/Service/BDKSwiftExampleWalletBDKServiceTests.swift b/BDKSwiftExampleWalletTests/Service/BDKSwiftExampleWalletBDKServiceTests.swift index 97b82a7b..4edbdd42 100644 --- a/BDKSwiftExampleWalletTests/Service/BDKSwiftExampleWalletBDKServiceTests.swift +++ b/BDKSwiftExampleWalletTests/Service/BDKSwiftExampleWalletBDKServiceTests.swift @@ -32,23 +32,23 @@ final class BDKSwiftExampleWalletBDKServiceTests: XCTestCase { Transaction.mock?.transactionID ) } - + 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 @@ -57,12 +57,12 @@ final class BDKSwiftExampleWalletBDKServiceTests: XCTestCase { 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()) From e1a621cff20044f59b3fcfa469d3e74b7d6e6b23 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 28 Jul 2025 10:05:23 -0500 Subject: [PATCH 4/9] localize --- .../Resources/Localizable.xcstrings | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/BDKSwiftExampleWallet/Resources/Localizable.xcstrings b/BDKSwiftExampleWallet/Resources/Localizable.xcstrings index 81efd666..dac6805e 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" : { @@ -665,8 +668,12 @@ } } } + }, + "Fee Priority" : { + }, "Fees" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -723,9 +730,6 @@ } } } - }, - "Med: %lld" : { - }, "Navigation Title" : { "extractionState" : "stale", @@ -923,8 +927,12 @@ } } } + }, + "powered by BDK" : { + }, "powered by Bitcoin Dev Kit" : { + "extractionState" : "stale", "localizations" : { "pt-BR" : { "stringUnit" : { From ee90b03524001f578d050c30e4b20a397b9eacee Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 28 Jul 2025 14:20:30 -0500 Subject: [PATCH 5/9] fix: address switch refresh --- .../App/BDKSwiftExampleWalletApp.swift | 20 ++++- .../Resources/Localizable.xcstrings | 3 + .../Service/BDK Service/BDKService.swift | 7 +- .../View Model/OnboardingViewModel.swift | 81 +++++++++++++++---- .../View/OnboardingView.swift | 14 +++- 5 files changed, 102 insertions(+), 23 deletions(-) diff --git a/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift b/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift index 7a054f50..c9578454 100644 --- a/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift +++ b/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift @@ -8,19 +8,31 @@ import BitcoinDevKit import SwiftUI +extension Notification.Name { + static let walletCreated = Notification.Name("walletCreated") +} + @main struct BDKSwiftExampleWalletApp: App { @AppStorage("isOnboarding") var isOnboarding: Bool = true @State private var navigationPath = NavigationPath() + @State private var refreshTrigger = UUID() + + private var walletExists: Bool { + // Force re-evaluation by reading refreshTrigger and isOnboarding + let _ = refreshTrigger + let _ = isOnboarding + return (try? KeyClient.live.getBackupInfo()) != nil + } 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) } diff --git a/BDKSwiftExampleWallet/Resources/Localizable.xcstrings b/BDKSwiftExampleWallet/Resources/Localizable.xcstrings index dac6805e..295efa1b 100644 --- a/BDKSwiftExampleWallet/Resources/Localizable.xcstrings +++ b/BDKSwiftExampleWallet/Resources/Localizable.xcstrings @@ -535,6 +535,9 @@ } } } + }, + "Creating..." : { + }, "Danger Zone" : { "localizations" : { diff --git a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift index 9397da81..e54963fd 100644 --- a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift @@ -408,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() @@ -583,7 +582,13 @@ 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) { diff --git a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift index d1aa5bb9..74e6de3b 100644 --- a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift @@ -9,14 +9,34 @@ import BitcoinDevKit import Foundation import SwiftUI +struct TimeoutError: Error {} + +func withTimeout(seconds: TimeInterval, operation: @escaping () throws -> T) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + return try operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TimeoutError() + } + + let result = try await group.next()! + group.cancelAll() + return result + } +} + // Can't make @Observable yet // https://developer.apple.com/forums/thread/731187 // Feature or Bug? class OnboardingViewModel: ObservableObject { let bdkClient: BDKClient - @AppStorage("isOnboarding") var isOnboarding: Bool? + @AppStorage("isOnboarding") var isOnboarding: Bool = true @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 +110,53 @@ 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 { + try await withTimeout(seconds: 30) { + 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 is TimeoutError { + DispatchQueue.main.async { + self.isCreatingWallet = false + self.onboardingViewError = .generic(message: "Wallet creation timed out. Please try again.") + } + } 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..97e428ed 100644 --- a/BDKSwiftExampleWallet/View/OnboardingView.swift +++ b/BDKSwiftExampleWallet/View/OnboardingView.swift @@ -10,7 +10,7 @@ import BitcoinUI import SwiftUI struct OnboardingView: View { - @AppStorage("isOnboarding") var isOnboarding: Bool? + @AppStorage("isOnboarding") var isOnboarding: Bool = true @ObservedObject var viewModel: OnboardingViewModel @State private var showingOnboardingViewErrorAlert = false @State private var showingImportView = false @@ -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, From 81fe2bad02ac3951a907e7b5373b00b93d3b4f2c Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 28 Jul 2025 14:56:51 -0500 Subject: [PATCH 6/9] refactor: notification extensions --- BDKSwiftExampleWallet.xcodeproj/project.pbxproj | 4 ++++ .../App/BDKSwiftExampleWalletApp.swift | 4 ---- .../Extensions/Notification+Extensions.swift | 12 ++++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 BDKSwiftExampleWallet/Extensions/Notification+Extensions.swift 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 c9578454..149cd154 100644 --- a/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift +++ b/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift @@ -8,10 +8,6 @@ import BitcoinDevKit import SwiftUI -extension Notification.Name { - static let walletCreated = Notification.Name("walletCreated") -} - @main struct BDKSwiftExampleWalletApp: App { @AppStorage("isOnboarding") var isOnboarding: Bool = true 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") +} From 159f3f5f10a561870dc997a6b2c2f39f46e60d4f Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 28 Jul 2025 14:58:26 -0500 Subject: [PATCH 7/9] format --- .../App/BDKSwiftExampleWalletApp.swift | 16 +++++++++------- .../Service/BDK Service/BDKService.swift | 2 +- .../View Model/OnboardingViewModel.swift | 14 ++++++++------ 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift b/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift index 149cd154..7d7520ac 100644 --- a/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift +++ b/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift @@ -13,13 +13,6 @@ struct BDKSwiftExampleWalletApp: App { @AppStorage("isOnboarding") var isOnboarding: Bool = true @State private var navigationPath = NavigationPath() @State private var refreshTrigger = UUID() - - private var walletExists: Bool { - // Force re-evaluation by reading refreshTrigger and isOnboarding - let _ = refreshTrigger - let _ = isOnboarding - return (try? KeyClient.live.getBackupInfo()) != nil - } var body: some Scene { WindowGroup { @@ -40,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/Service/BDK Service/BDKService.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift index e54963fd..45ca24b5 100644 --- a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift @@ -584,7 +584,7 @@ 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 diff --git a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift index 74e6de3b..1ae07527 100644 --- a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift @@ -16,12 +16,12 @@ func withTimeout(seconds: TimeInterval, operation: @escaping () throws -> T) group.addTask { return try operation() } - + group.addTask { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) throw TimeoutError() } - + let result = try await group.next()! group.cancelAll() return result @@ -117,15 +117,15 @@ class OnboardingViewModel: ObservableObject { } return } - + guard !isCreatingWallet else { return } - + DispatchQueue.main.async { self.isCreatingWallet = true } - + Task { do { try await withTimeout(seconds: 30) { @@ -150,7 +150,9 @@ class OnboardingViewModel: ObservableObject { } catch is TimeoutError { DispatchQueue.main.async { self.isCreatingWallet = false - self.onboardingViewError = .generic(message: "Wallet creation timed out. Please try again.") + self.onboardingViewError = .generic( + message: "Wallet creation timed out. Please try again." + ) } } catch { DispatchQueue.main.async { From d0591cabbf363b62527253fd961f4dc4ed8bc510 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 28 Jul 2025 15:04:50 -0500 Subject: [PATCH 8/9] small --- BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift | 2 +- BDKSwiftExampleWallet/View/OnboardingView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift index 1ae07527..a698ce20 100644 --- a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift @@ -34,7 +34,7 @@ func withTimeout(seconds: TimeInterval, operation: @escaping () throws -> T) class OnboardingViewModel: ObservableObject { let bdkClient: BDKClient - @AppStorage("isOnboarding") var isOnboarding: Bool = true + @AppStorage("isOnboarding") var isOnboarding: Bool? @Published var createWithPersistError: CreateWithPersistError? @Published var isCreatingWallet = false var isDescriptor: Bool { diff --git a/BDKSwiftExampleWallet/View/OnboardingView.swift b/BDKSwiftExampleWallet/View/OnboardingView.swift index 97e428ed..90d875e7 100644 --- a/BDKSwiftExampleWallet/View/OnboardingView.swift +++ b/BDKSwiftExampleWallet/View/OnboardingView.swift @@ -10,7 +10,7 @@ import BitcoinUI import SwiftUI struct OnboardingView: View { - @AppStorage("isOnboarding") var isOnboarding: Bool = true + @AppStorage("isOnboarding") var isOnboarding: Bool? @ObservedObject var viewModel: OnboardingViewModel @State private var showingOnboardingViewErrorAlert = false @State private var showingImportView = false From 27d4b573314d312f9fc8277a0098e11db9aea916 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 28 Jul 2025 15:07:08 -0500 Subject: [PATCH 9/9] remove timeout --- .../View Model/OnboardingViewModel.swift | 39 +++---------------- 1 file changed, 6 insertions(+), 33 deletions(-) diff --git a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift index a698ce20..907edfc1 100644 --- a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift @@ -9,24 +9,6 @@ import BitcoinDevKit import Foundation import SwiftUI -struct TimeoutError: Error {} - -func withTimeout(seconds: TimeInterval, operation: @escaping () throws -> T) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - return try operation() - } - - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw TimeoutError() - } - - let result = try await group.next()! - group.cancelAll() - return result - } -} // Can't make @Observable yet // https://developer.apple.com/forums/thread/731187 @@ -128,14 +110,12 @@ class OnboardingViewModel: ObservableObject { Task { do { - try await withTimeout(seconds: 30) { - 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) - } + 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 @@ -147,13 +127,6 @@ class OnboardingViewModel: ObservableObject { self.isCreatingWallet = false self.createWithPersistError = error } - } catch is TimeoutError { - DispatchQueue.main.async { - self.isCreatingWallet = false - self.onboardingViewError = .generic( - message: "Wallet creation timed out. Please try again." - ) - } } catch { DispatchQueue.main.async { self.isCreatingWallet = false