diff --git a/BDKSwiftExampleWallet.xcodeproj/project.pbxproj b/BDKSwiftExampleWallet.xcodeproj/project.pbxproj index ef41cd63..2c9805e8 100644 --- a/BDKSwiftExampleWallet.xcodeproj/project.pbxproj +++ b/BDKSwiftExampleWallet.xcodeproj/project.pbxproj @@ -8,10 +8,15 @@ /* Begin PBXBuildFile section */ 774586B52DB7B2BC00A631E1 /* BalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 774586B42DB7B2BC00A631E1 /* BalanceView.swift */; }; + 7745E1912DD7C47F00D52A40 /* BDKSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7745E1902DD7C47600D52A40 /* BDKSyncService.swift */; }; + 7745E1942DD7D2F900D52A40 /* EsploraServerSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7745E1932DD7D2F500D52A40 /* EsploraServerSyncService.swift */; }; + 7745E1962DD7D8FE00D52A40 /* KyotoSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7745E1952DD7D8FD00D52A40 /* KyotoSyncService.swift */; }; + 7745E1982DD8A0AF00D52A40 /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7745E1972DD8A0A600D52A40 /* URL+Extensions.swift */; }; 779E70872DB9C98A006E22D3 /* WalletSyncScriptInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 779E70862DB9C98A006E22D3 /* WalletSyncScriptInspector.swift */; }; 779E70892DB9C9AB006E22D3 /* WalletFullScanScriptInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 779E70882DB9C9AB006E22D3 /* WalletFullScanScriptInspector.swift */; }; 77AD9F062DBB031D00182E65 /* ActivityHomeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AD9F052DBB031D00182E65 /* ActivityHomeHeaderView.swift */; }; 77F0FDC92DA9A93D00B30E4F /* Connection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F0FDC82DA9A93700B30E4F /* Connection+Extensions.swift */; }; + 77F6A9E02DE23CA8003568F0 /* StorageUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F6A9DF2DE23CA5003568F0 /* StorageUtil.swift */; }; A733D6D02A81113000F333B4 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A733D6CF2A81113000F333B4 /* Localizable.xcstrings */; }; A73F7A362A3B778E00B87FC6 /* Int+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A73F7A352A3B778E00B87FC6 /* Int+Extensions.swift */; }; AE0C30F72A804A2D008F1EAE /* TransactionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0C30F62A804A2D008F1EAE /* TransactionListView.swift */; }; @@ -113,10 +118,15 @@ /* Begin PBXFileReference section */ 774586B42DB7B2BC00A631E1 /* BalanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceView.swift; sourceTree = ""; }; + 7745E1902DD7C47600D52A40 /* BDKSyncService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BDKSyncService.swift; sourceTree = ""; }; + 7745E1932DD7D2F500D52A40 /* EsploraServerSyncService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EsploraServerSyncService.swift; sourceTree = ""; }; + 7745E1952DD7D8FD00D52A40 /* KyotoSyncService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KyotoSyncService.swift; sourceTree = ""; }; + 7745E1972DD8A0A600D52A40 /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = ""; }; 779E70862DB9C98A006E22D3 /* WalletSyncScriptInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletSyncScriptInspector.swift; sourceTree = ""; }; 779E70882DB9C9AB006E22D3 /* WalletFullScanScriptInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletFullScanScriptInspector.swift; sourceTree = ""; }; 77AD9F052DBB031D00182E65 /* ActivityHomeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityHomeHeaderView.swift; sourceTree = ""; }; 77F0FDC82DA9A93700B30E4F /* Connection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Connection+Extensions.swift"; sourceTree = ""; }; + 77F6A9DF2DE23CA5003568F0 /* StorageUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageUtil.swift; sourceTree = ""; }; A733D6CF2A81113000F333B4 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; A73F7A352A3B778E00B87FC6 /* Int+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+Extensions.swift"; sourceTree = ""; }; AE0C30F62A804A2D008F1EAE /* TransactionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionListView.swift; sourceTree = ""; }; @@ -228,6 +238,16 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 7745E1922DD7D2E600D52A40 /* BDKSyncService */ = { + isa = PBXGroup; + children = ( + 7745E1902DD7C47600D52A40 /* BDKSyncService.swift */, + 7745E1952DD7D8FD00D52A40 /* KyotoSyncService.swift */, + 7745E1932DD7D2F500D52A40 /* EsploraServerSyncService.swift */, + ); + path = BDKSyncService; + sourceTree = ""; + }; 779E70832DB9C94C006E22D3 /* Actor */ = { isa = PBXGroup; children = ( @@ -271,6 +291,7 @@ AE1C341E2A42440A008F807A /* Extensions */ = { isa = PBXGroup; children = ( + 7745E1972DD8A0A600D52A40 /* URL+Extensions.swift */, AE18E9372A9528200019D2A4 /* Bundle+Extensions.swift */, AE3646252BEDB01200B04E25 /* FileManager+Extensions.swift */, A73F7A352A3B778E00B87FC6 /* Int+Extensions.swift */, @@ -302,6 +323,7 @@ AE1C34202A42441F008F807A /* Utilities */ = { isa = PBXGroup; children = ( + 77F6A9DF2DE23CA5003568F0 /* StorageUtil.swift */, AE79538D2A2D59F000CCB277 /* Constants.swift */, AE2F255C2BED0BFB002A9AC6 /* AppError.swift */, ); @@ -512,6 +534,7 @@ AEB905C52A7EECD900CD0337 /* Service */ = { isa = PBXGroup; children = ( + 7745E1922DD7D2E600D52A40 /* BDKSyncService */, AE1C34212A424434008F807A /* BDK Service */, AEB905C42A7EECAF00CD0337 /* Price Service */, AE6715FB2A9ABF30005C193F /* Fee Service */, @@ -712,7 +735,9 @@ AE7953902A2D5B4400CCB277 /* BDKSwiftExampleWalletError.swift in Sources */, AE91CEEF2C0FDBC7000AAD20 /* CanonicalTx+Extensions.swift in Sources */, AE2381B52C60878E00F6B00C /* LocalOutputItemView.swift in Sources */, + 7745E1912DD7C47F00D52A40 /* BDKSyncService.swift in Sources */, AE3646262BEDB01200B04E25 /* FileManager+Extensions.swift in Sources */, + 77F6A9E02DE23CA8003568F0 /* StorageUtil.swift in Sources */, AEB6C9D12B7E8529003AD704 /* TransactionDetailViewModel.swift in Sources */, AEB159D32D51A7E00006AE9E /* BalanceDisplayFormat.swift in Sources */, AE18E9382A9528200019D2A4 /* Bundle+Extensions.swift in Sources */, @@ -746,16 +771,19 @@ AE6716012A9AC089005C193F /* KeyServiceError.swift in Sources */, 77AD9F062DBB031D00182E65 /* ActivityHomeHeaderView.swift in Sources */, AE0C30FB2A804B95008F1EAE /* WalletViewModel.swift in Sources */, + 7745E1982DD8A0AF00D52A40 /* URL+Extensions.swift in Sources */, AE49847C2A1BBBD6009951E2 /* BDKSwiftExampleWalletApp.swift in Sources */, AE6715FF2A9AC066005C193F /* FeeServiceError.swift in Sources */, AE2381AD2C60578500F6B00C /* ActivityListView.swift in Sources */, AE2381B32C60877600F6B00C /* LocalOutputListView.swift in Sources */, AE783A052AB4F51F005F0CBA /* String+Extensions.swift in Sources */, + 7745E1942DD7D2F900D52A40 /* EsploraServerSyncService.swift in Sources */, AE29ED112BBE318A00EB9C4F /* TransactionItemView.swift in Sources */, AE34DDAE2B6B320F00F04AD4 /* WalletRecoveryViewModel.swift in Sources */, AE0C30F92A804B65008F1EAE /* OnboardingViewModel.swift in Sources */, AE3902A42A3B4CD900BEC318 /* HomeView.swift in Sources */, AE0C30FD2A804BC1008F1EAE /* ReceiveViewModel.swift in Sources */, + 7745E1962DD7D8FE00D52A40 /* KyotoSyncService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift b/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift index 7a054f50..5814c1e5 100644 --- a/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift +++ b/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift @@ -16,17 +16,30 @@ struct BDKSwiftExampleWalletApp: App { 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) { - OnboardingView(viewModel: .init(bdkClient: .live)) + let keyClient = KeyClient.live + let syncService: BDKSyncService = EsploraServerSyncService( + keyClient: keyClient, + network: .signet + ) + + if let _ = try? KeyClient.live.getBackupInfo() { + HomeView( + viewModel: .init( + bdkClient: .live, + bdkSyncService: syncService + ), + navigationPath: $navigationPath + ) } else { - HomeView(viewModel: .init(bdkClient: .live), navigationPath: $navigationPath) + OnboardingView( + viewModel: .init( + bdkSyncService: syncService + ) + ) } } .onChange(of: isOnboarding) { oldValue, newValue in - BDKClient.live.setNeedsFullScan(true) + StorageUtil.shared.isNeedFullScan = true navigationPath = NavigationPath() } } diff --git a/BDKSwiftExampleWallet/Extensions/BDK+Extensions/Connection+Extensions.swift b/BDKSwiftExampleWallet/Extensions/BDK+Extensions/Connection+Extensions.swift index 3f09b059..e3e370b3 100644 --- a/BDKSwiftExampleWallet/Extensions/BDK+Extensions/Connection+Extensions.swift +++ b/BDKSwiftExampleWallet/Extensions/BDK+Extensions/Connection+Extensions.swift @@ -3,17 +3,21 @@ import Foundation extension Connection { static func createConnection() throws -> Connection { - let documentsDirectoryURL = URL.documentsDirectory - let walletDataDirectoryURL = documentsDirectoryURL.appendingPathComponent("wallet_data") + let documentsDirectoryURL = URL.defaultWalletDirectory + let walletDataDirectoryURL = documentsDirectoryURL.appendingPathComponent(URL.walletDirectoryName) if FileManager.default.fileExists(atPath: walletDataDirectoryURL.path) { try FileManager.default.removeItem(at: walletDataDirectoryURL) } - + try FileManager.default.ensureDirectoryExists(at: walletDataDirectoryURL) try FileManager.default.removeOldFlatFileIfNeeded(at: documentsDirectoryURL) - let persistenceBackendPath = walletDataDirectoryURL.appendingPathComponent("wallet.sqlite") - .path + let connection = try Connection(path: URL.persistenceBackendPath) + return connection + } + + static func loadConnection() throws -> Connection { + let persistenceBackendPath = URL.persistenceBackendPath let connection = try Connection(path: persistenceBackendPath) return connection } diff --git a/BDKSwiftExampleWallet/Extensions/FileManager+Extensions.swift b/BDKSwiftExampleWallet/Extensions/FileManager+Extensions.swift index 6a6965cf..90ab1933 100644 --- a/BDKSwiftExampleWallet/Extensions/FileManager+Extensions.swift +++ b/BDKSwiftExampleWallet/Extensions/FileManager+Extensions.swift @@ -34,7 +34,7 @@ extension FileManager { } func removeOldFlatFileIfNeeded(at directoryURL: URL) throws { - let flatFileURL = directoryURL.appendingPathComponent("wallet_data") + let flatFileURL = directoryURL.appendingPathComponent(URL.walletDirectoryName) var isDir: ObjCBool = false if fileExists(atPath: flatFileURL.path, isDirectory: &isDir) { if !isDir.boolValue { diff --git a/BDKSwiftExampleWallet/Extensions/URL+Extensions.swift b/BDKSwiftExampleWallet/Extensions/URL+Extensions.swift new file mode 100644 index 00000000..fe1b54a6 --- /dev/null +++ b/BDKSwiftExampleWallet/Extensions/URL+Extensions.swift @@ -0,0 +1,31 @@ +// +// URL+Extensions.swift +// BDKSwiftExampleWallet +// +// Created by Rubens Machion on 17/05/25. +// + +import Foundation + +extension URL { + + static var defaultWalletDirectory: URL { + URL.documentsDirectory + } + + static var walletDirectoryName: String { + "wallet_data" + } + + static var walletDBName: String { + "wallet.sqlite" + } + + static var walletDataDirectoryURL: URL { + defaultWalletDirectory.appendingPathComponent(walletDirectoryName) + } + + static var persistenceBackendPath: String { + walletDataDirectoryURL.appendingPathComponent(walletDBName).path + } +} diff --git a/BDKSwiftExampleWallet/Resources/Localizable.xcstrings b/BDKSwiftExampleWallet/Resources/Localizable.xcstrings index 2744a873..5c35c10e 100644 --- a/BDKSwiftExampleWallet/Resources/Localizable.xcstrings +++ b/BDKSwiftExampleWallet/Resources/Localizable.xcstrings @@ -691,6 +691,12 @@ } } } + }, + "Kyoto" : { + + }, + "Main" : { + }, "Navigation Title" : { "extractionState" : "stale", @@ -1143,6 +1149,9 @@ } } } + }, + "Sync type" : { + }, "Syncing" : { "extractionState" : "manual", diff --git a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift index 1d4ac2e5..e328edcb 100644 --- a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift @@ -254,12 +254,9 @@ private class BDKService { } private func loadWallet(descriptor: Descriptor, changeDescriptor: Descriptor) throws { - let documentsDirectoryURL = URL.documentsDirectory - let walletDataDirectoryURL = documentsDirectoryURL.appendingPathComponent("wallet_data") - try FileManager.default.ensureDirectoryExists(at: walletDataDirectoryURL) - try FileManager.default.removeOldFlatFileIfNeeded(at: documentsDirectoryURL) - let persistenceBackendPath = walletDataDirectoryURL.appendingPathComponent("wallet.sqlite") - .path + try FileManager.default.ensureDirectoryExists(at: URL.walletDataDirectoryURL) + try FileManager.default.removeOldFlatFileIfNeeded(at: URL.defaultWalletDirectory) + let persistenceBackendPath = URL.persistenceBackendPath let connection = try Connection(path: persistenceBackendPath) self.connection = connection let wallet = try Wallet.load( @@ -290,8 +287,7 @@ private class BDKService { try self.keyClient.deleteBackupInfo() - let documentsDirectoryURL = URL.documentsDirectory - let walletDataDirectoryURL = documentsDirectoryURL.appendingPathComponent("wallet_data") + let walletDataDirectoryURL = URL.walletDataDirectoryURL if FileManager.default.fileExists(atPath: walletDataDirectoryURL.path) { try FileManager.default.removeItem(at: walletDataDirectoryURL) } diff --git a/BDKSwiftExampleWallet/Service/BDKSyncService/BDKSyncService.swift b/BDKSwiftExampleWallet/Service/BDKSyncService/BDKSyncService.swift new file mode 100644 index 00000000..6d0a3597 --- /dev/null +++ b/BDKSwiftExampleWallet/Service/BDKSyncService/BDKSyncService.swift @@ -0,0 +1,191 @@ +// +// BDKService2.swift +// BDKSwiftExampleWallet +// +// Created by Rubens Machion on 16/05/25. +// + +import BitcoinDevKit +import Foundation + +protocol BDKSyncService { + var connection: Connection? { get } + var keyClient: KeyClient { get } + var network: Network { get } + var wallet: Wallet? { get } + + func createWallet(params: String?) throws + func loadWallet() throws + func deleteWallet() throws + func startSync(progress: SyncScriptInspector) async throws + func startFullScan(progress: FullScanScriptInspector) async throws + + func updateNetwork(network: Network) + func updateEsploraURL(_ url: String) + + func getTransactions() throws -> [CanonicalTx] + func getBalance() throws -> Balance +} + +extension BDKSyncService { + func buildWallet(params: String?) throws -> Wallet { + guard let connection = self.connection else { + throw WalletError.dbNotFound + } + + let backupInfo = try buildBackupInfo(params: params ?? Mnemonic(wordCount: WordCount.words12).description) + + try keyClient.saveBackupInfo(backupInfo) + try keyClient.saveNetwork(self.network.description) + + let descriptor = try Descriptor(descriptor: backupInfo.descriptor, network: network) + let changeDescriptor = try Descriptor(descriptor: backupInfo.changeDescriptor, network: network) + + let wallet = try Wallet( + descriptor: descriptor, + changeDescriptor: changeDescriptor, + network: network, + connection: connection + ) + + return wallet + } + + func buildBackupInfo(params: String) throws -> BackupInfo { + if isXPub(params) { + let descriptorPublicKey = try DescriptorPublicKey.fromString(publicKey: params) + let fingerprint = descriptorPublicKey.masterFingerprint() + let descriptor = Descriptor.newBip86Public( + publicKey: descriptorPublicKey, + fingerprint: fingerprint, + keychain: .external, + network: network + ) + let changeDescriptor = Descriptor.newBip86Public( + publicKey: descriptorPublicKey, + fingerprint: fingerprint, + keychain: .internal, + network: network + ) + return .init( + mnemonic: "", + descriptor: descriptor.description, + changeDescriptor: changeDescriptor.description + ) + } + + if isDescriptor(params) { // is a descriptor? + + let descriptorStrings = params.components(separatedBy: "\n") + .map { $0.split(separator: "#").first?.trimmingCharacters(in: .whitespaces) ?? "" } + .filter { !$0.isEmpty } + let descriptor: Descriptor + let changeDescriptor: Descriptor + + if descriptorStrings.count == 1 { + let parsedDescriptor = try Descriptor( + descriptor: descriptorStrings[0], + network: network + ) + let singleDescriptors = try parsedDescriptor.toSingleDescriptors() + guard singleDescriptors.count >= 2 else { + throw AppError.generic(message: "Too many output descriptors to parse") + } + descriptor = singleDescriptors[0] + changeDescriptor = singleDescriptors[1] + } else if descriptorStrings.count == 2 { + descriptor = try Descriptor(descriptor: descriptorStrings[0], network: network) + changeDescriptor = try Descriptor(descriptor: descriptorStrings[1], network: network) + } else { + throw AppError.generic(message: "Descriptor parsing failed") + } + + return .init( + mnemonic: "", + descriptor: descriptor.description, + changeDescriptor: changeDescriptor.description + ) + } + + guard let mnemonic = try? Mnemonic.fromString(mnemonic: params) else { + throw AppError.generic(message: "Invalid mnemonic") + } + let secretKey = DescriptorSecretKey( + network: network, + mnemonic: mnemonic, + password: nil + ) + let descriptor = Descriptor.newBip86( + secretKey: secretKey, + keychain: .external, + network: network + ) + let changeDescriptor = Descriptor.newBip86( + secretKey: secretKey, + keychain: .internal, + network: network + ) + return .init( + mnemonic: mnemonic.description, + descriptor: descriptor.description, + changeDescriptor: changeDescriptor.description + ) + } + + func deleteData() throws { + do { + try keyClient.deleteAllData() + + if let bundleID = Bundle.main.bundleIdentifier { + UserDefaults.standard.removePersistentDomain(forName: bundleID) + } + + let walletDataDirectoryURL = URL.walletDataDirectoryURL + if FileManager.default.fileExists(atPath: walletDataDirectoryURL.path) { + try FileManager.default.removeItem(at: walletDataDirectoryURL) + } + + } catch { + throw AppError.generic(message: "Failed to remove Keychain data") + } + } + + func loadWalleFromBackup() throws -> Wallet { + guard let connection = self.connection else { + throw WalletError.dbNotFound + } + + let backupInfo = try keyClient.getBackupInfo() + let descriptor = try Descriptor(descriptor: backupInfo.descriptor, network: self.network) + let changeDescriptor = try Descriptor( + descriptor: backupInfo.changeDescriptor, + network: self.network + ) + let wallet = try Wallet.load( + descriptor: descriptor, + changeDescriptor: changeDescriptor, + connection: connection + ) + + return wallet + } + + // MARK: - Optionals methods + + func updateEsploraURL(_ url: String) { + // Optional implementation + } + + // MARK: - Private + + private func isDescriptor(_ param: String) -> Bool { + param.hasPrefix("tr(") || + param.hasPrefix("wpkh(") || + param.hasPrefix("wsh(") || + param.hasPrefix("sh(") + } + + private func isXPub(_ param: String) -> Bool { + param.hasPrefix("xpub") || param.hasPrefix("tpub") || param.hasPrefix("vpub") || param.hasPrefix("zpub") + } +} diff --git a/BDKSwiftExampleWallet/Service/BDKSyncService/EsploraServerSyncService.swift b/BDKSwiftExampleWallet/Service/BDKSyncService/EsploraServerSyncService.swift new file mode 100644 index 00000000..513ba6e4 --- /dev/null +++ b/BDKSwiftExampleWallet/Service/BDKSyncService/EsploraServerSyncService.swift @@ -0,0 +1,112 @@ +// +// Untitled.swift +// BDKSwiftExampleWallet +// +// Created by Rubens Machion on 16/05/25. +// + +import BitcoinDevKit +import Foundation + +final class EsploraServerSyncService: BDKSyncService { + + var connection: Connection? + var keyClient: KeyClient + var network: Network + var wallet: Wallet? + + private var esploraClient: EsploraClient + + init( + keyClient: KeyClient = .live, + network: Network = .signet, + connection: Connection? = nil + ) { + self.connection = connection + self.keyClient = keyClient + self.network = network + + let url = (try? keyClient.getEsploraURL()) ?? network.url + self.esploraClient = .init( + url: url + ) + } + + func createWallet(params: String?) throws { + self.connection = try Connection.createConnection() + self.wallet = try buildWallet(params: params) + } + + func loadWallet() throws { + self.connection = try Connection.loadConnection() + let wallet = try loadWalleFromBackup() + self.wallet = wallet + } + + func deleteWallet() throws { + try deleteData() + } + + func updateNetwork(network: Network) { + self.network = network + } + + func updateEsploraURL(_ url: String) { + try? keyClient.saveEsploraURL(url) + self.esploraClient = .init(url: url) + } + + func startSync(progress: SyncScriptInspector) async throws { + guard let wallet = self.wallet else { throw WalletError.walletNotFound } + let esploraClient = self.esploraClient + let syncRequest = try wallet.startSyncWithRevealedSpks() + .inspectSpks(inspector: progress) + .build() + let update = try esploraClient.sync( + request: syncRequest, + parallelRequests: UInt64(5) + ) + let _ = try wallet.applyUpdate(update: update) + guard let connection = self.connection else { + throw WalletError.dbNotFound + } + let _ = try wallet.persist(connection: connection) + } + + func startFullScan(progress: FullScanScriptInspector) async throws { + guard let wallet = self.wallet else { throw WalletError.walletNotFound } + let esploraClient = esploraClient + let fullScanRequest = try wallet.startFullScan() + .inspectSpksForAllKeychains(inspector: progress) + .build() + let update = try esploraClient.fullScan( + request: fullScanRequest, + // using https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#address-gap-limit + stopGap: UInt64(20), + // using https://github.com/bitcoindevkit/bdk/blob/master/example-crates/example_wallet_esplora_blocking/src/main.rs + parallelRequests: UInt64(5) + ) + let _ = try wallet.applyUpdate(update: update) + guard let connection = self.connection else { + throw WalletError.dbNotFound + } + let _ = try wallet.persist(connection: connection) + } + + func getTransactions() throws -> [CanonicalTx] { + guard let wallet = self.wallet else { + throw WalletError.walletNotFound + } + let transactions = wallet.transactions() + let sortedTransactions = transactions.sorted { (tx1, tx2) in + return tx1.chainPosition.isBefore(tx2.chainPosition) + } + return sortedTransactions + } + + func getBalance() throws -> Balance { + guard let wallet = self.wallet else { throw WalletError.walletNotFound } + let balance = wallet.balance() + return balance + } +} diff --git a/BDKSwiftExampleWallet/Service/BDKSyncService/KyotoSyncService.swift b/BDKSwiftExampleWallet/Service/BDKSyncService/KyotoSyncService.swift new file mode 100644 index 00000000..1f79671f --- /dev/null +++ b/BDKSwiftExampleWallet/Service/BDKSyncService/KyotoSyncService.swift @@ -0,0 +1,62 @@ +// +// KyotoSyncService.swift +// BDKSwiftExampleWallet +// +// Created by Rubens Machion on 16/05/25. +// + +import BitcoinDevKit +import Foundation + +final class KyotoSyncService: BDKSyncService { + + var connection: Connection? + var keyClient: KeyClient + var network: Network + var wallet: Wallet? + + private var node: CbfNode? + private var client: CbfClient? + + init( + keyClient: KeyClient = .live, + network: Network = .signet, + connection: Connection? = nil + ) { + self.connection = connection + self.keyClient = keyClient + self.network = network + } + + func createWallet(params: String?) throws { + self.wallet = try buildWallet(params: params) + } + + func loadWallet() throws { + + } + + func deleteWallet() throws { + + } + + func updateNetwork(network: Network) { + self.network = network + } + + func startSync(progress: SyncScriptInspector) async throws { + + } + + func startFullScan(progress: FullScanScriptInspector) async throws { + + } + + func getTransactions() throws -> [CanonicalTx] { + [] + } + + func getBalance() throws -> Balance { + fatalError("Missing implementation") + } +} diff --git a/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift b/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift index 5ac41e8e..2949bd2c 100644 --- a/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift +++ b/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift @@ -62,6 +62,12 @@ private struct KeyService { func saveNetwork(network: String) throws { keychain[string: "SelectedNetwork"] = network } + + func deletaAllData() throws { + try deleteNetwork() + try deleteBackupInfo() + try deleteEsploraURL() + } } struct KeyClient { @@ -74,6 +80,7 @@ struct KeyClient { let saveEsploraURL: (String) throws -> Void let saveBackupInfo: (BackupInfo) throws -> Void let saveNetwork: (String) throws -> Void + let deleteAllData: () throws -> Void private init( deleteBackupInfo: @escaping () throws -> Void, @@ -84,7 +91,8 @@ struct KeyClient { getNetwork: @escaping () throws -> String?, saveBackupInfo: @escaping (BackupInfo) throws -> Void, saveEsploraURL: @escaping (String) throws -> Void, - saveNetwork: @escaping (String) throws -> Void + saveNetwork: @escaping (String) throws -> Void, + deleteAllData: @escaping () throws -> Void ) { self.deleteBackupInfo = deleteBackupInfo self.deleteEsplora = deleteEsplora @@ -95,6 +103,7 @@ struct KeyClient { self.saveBackupInfo = saveBackupInfo self.saveEsploraURL = saveEsploraURL self.saveNetwork = saveNetwork + self.deleteAllData = deleteAllData } } @@ -108,7 +117,8 @@ extension KeyClient { getNetwork: { try KeyService().getNetwork() }, saveBackupInfo: { backupInfo in try KeyService().saveBackupInfo(backupInfo: backupInfo) }, saveEsploraURL: { url in try KeyService().saveEsploraURL(url: url) }, - saveNetwork: { network in try KeyService().saveNetwork(network: network) } + saveNetwork: { network in try KeyService().saveNetwork(network: network) }, + deleteAllData: { try KeyService().deletaAllData() } ) } @@ -148,7 +158,8 @@ extension KeyClient { getNetwork: { nil }, saveBackupInfo: { _ in }, saveEsploraURL: { _ in }, - saveNetwork: { _ in } + saveNetwork: { _ in }, + deleteAllData: { } ) } #endif diff --git a/BDKSwiftExampleWallet/Utilities/Constants.swift b/BDKSwiftExampleWallet/Utilities/Constants.swift index 173014c7..c282cfc6 100644 --- a/BDKSwiftExampleWallet/Utilities/Constants.swift +++ b/BDKSwiftExampleWallet/Utilities/Constants.swift @@ -16,8 +16,8 @@ struct Constants { private static let blockstream = "https://blockstream.info/api" private static let mempoolspace = "https://mempool.space/api" static let allValues = [ - mempoolspace, blockstream, + mempoolspace, ] } struct Regtest { diff --git a/BDKSwiftExampleWallet/Utilities/StorageUtil.swift b/BDKSwiftExampleWallet/Utilities/StorageUtil.swift new file mode 100644 index 00000000..43effb90 --- /dev/null +++ b/BDKSwiftExampleWallet/Utilities/StorageUtil.swift @@ -0,0 +1,17 @@ +// +// AppStorage.swift +// BDKSwiftExampleWallet +// +// Created by Rubens Machion on 24/05/25. +// + +import SwiftUI + +struct StorageUtil { + @AppStorage("isOnboarding") var isOnboarding: Bool? + @AppStorage("isNeedFullScan") var isNeedFullScan: Bool? + @AppStorage("balanceDisplayFormat") var balanceFormat: BalanceDisplayFormat = + .bitcoinSats + + static var shared = StorageUtil() +} diff --git a/BDKSwiftExampleWallet/View Model/HomeViewModel.swift b/BDKSwiftExampleWallet/View Model/HomeViewModel.swift index 1c2e2687..4ea21b16 100644 --- a/BDKSwiftExampleWallet/View Model/HomeViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/HomeViewModel.swift @@ -12,17 +12,25 @@ import Foundation @Observable class HomeViewModel: ObservableObject { let bdkClient: BDKClient + + let bdkSyncService: BDKSyncService var homeViewError: AppError? var isWalletLoaded = false var showingHomeViewErrorAlert = false - init(bdkClient: BDKClient = .live) { + init( + bdkClient: BDKClient = .live, + bdkSyncService: BDKSyncService + ) { self.bdkClient = bdkClient + self.bdkSyncService = bdkSyncService } func loadWallet() { do { + try bdkSyncService.loadWallet() + try bdkClient.loadWallet() isWalletLoaded = true } catch let error as DescriptorError { diff --git a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift index e503c8ea..1c09ca52 100644 --- a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift @@ -9,38 +9,48 @@ import BitcoinDevKit import Foundation import SwiftUI +enum WalletSyncType: Hashable { + case esplora + case kyoto +} + // 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? + + private var bdkSyncService: BDKSyncService + + @Published var walletSyncType: WalletSyncType = .esplora { + didSet { + updateWalletSyncType() + } + } + @Published var createWithPersistError: CreateWithPersistError? var isDescriptor: Bool { words.hasPrefix("tr(") || words.hasPrefix("wpkh(") || words.hasPrefix("wsh(") || words.hasPrefix("sh(") } var isXPub: Bool { - words.hasPrefix("xpub") || words.hasPrefix("tpub") || words.hasPrefix("vpub") + words.hasPrefix("xpub") || words.hasPrefix("tpub") || words.hasPrefix("vpub") || words.hasPrefix("zpub") } @Published var networkColor = Color.gray @Published var onboardingViewError: AppError? - @Published var selectedNetwork: Network = .signet { + @Published var selectedNetwork: Network { didSet { - bdkClient.updateNetwork(selectedNetwork) selectedURL = availableURLs.first ?? "" - bdkClient.updateEsploraURL(selectedURL) + bdkSyncService.updateNetwork(network: selectedNetwork) } } @Published var selectedURL: String = "" { didSet { - bdkClient.updateEsploraURL(selectedURL) +// bdkClient.updateEsploraURL(selectedURL) } } @Published var words: String = "" var wordArray: [String] { - if words.hasPrefix("xpub") || words.hasPrefix("tpub") || words.hasPrefix("vpub") { + if words.hasPrefix("xpub") || words.hasPrefix("tpub") || words.hasPrefix("vpub") || words.hasPrefix("zpub") { return [] } let trimmedWords = words.trimmingCharacters(in: .whitespacesAndNewlines) @@ -76,34 +86,45 @@ class OnboardingViewModel: ObservableObject { } init( - bdkClient: BDKClient = .live + bdkSyncService: BDKSyncService ) { - self.bdkClient = bdkClient - self.selectedNetwork = bdkClient.getNetwork() - self.selectedURL = bdkClient.getEsploraURL() + self.bdkSyncService = bdkSyncService + self.selectedNetwork = bdkSyncService.network + self.selectedURL = bdkSyncService.network.url } func createWallet() { do { - try bdkClient.deleteWallet() - if isDescriptor { - try bdkClient.createWalletFromDescriptor(words) - } else if isXPub { - try bdkClient.createWalletFromXPub(words) - } else { - try bdkClient.createWalletFromSeed(words) - } + try bdkSyncService.deleteWallet() + try bdkSyncService.createWallet(params: words.isEmpty ? nil : words) + StorageUtil.shared.isNeedFullScan = true + DispatchQueue.main.async { - self.isOnboarding = false + StorageUtil.shared.isOnboarding = false } + } catch let error as CreateWithPersistError { DispatchQueue.main.async { self.createWithPersistError = error } + } catch { DispatchQueue.main.async { self.onboardingViewError = .generic(message: error.localizedDescription) } } } + + private func updateWalletSyncType() { + switch walletSyncType { + case .esplora: + bdkSyncService = EsploraServerSyncService( + network: selectedNetwork + ) + case .kyoto: + bdkSyncService = KyotoSyncService( + network: selectedNetwork + ) + } + } } diff --git a/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift b/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift index 790fc87b..37d3d99a 100644 --- a/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift @@ -13,7 +13,6 @@ import SwiftUI class SettingsViewModel: ObservableObject { let bdkClient: BDKClient - @AppStorage("isOnboarding") var isOnboarding: Bool = true @Published var esploraURL: String? @Published var inspectedScripts: UInt64 = 0 @Published var network: String? @@ -40,7 +39,7 @@ class SettingsViewModel: ObservableObject { func delete() { do { try bdkClient.deleteWallet() - isOnboarding = true + StorageUtil.shared.isOnboarding = true } catch { self.settingsError = .generic(message: error.localizedDescription) self.showingSettingsViewErrorAlert = true diff --git a/BDKSwiftExampleWallet/View Model/WalletViewModel.swift b/BDKSwiftExampleWallet/View Model/WalletViewModel.swift index d0e3a42d..bfef5654 100644 --- a/BDKSwiftExampleWallet/View Model/WalletViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/WalletViewModel.swift @@ -13,7 +13,13 @@ import SwiftUI @MainActor @Observable class WalletViewModel { - let bdkClient: BDKClient + + private let bdkSyncService: BDKSyncService + private(set) var isNeedFullScan: Bool { + didSet { + StorageUtil.shared.isNeedFullScan = isNeedFullScan + } + } let keyClient: KeyClient let priceClient: PriceClient @@ -39,9 +45,6 @@ class WalletViewModel { var transactions: [CanonicalTx] var walletSyncState: WalletSyncState var walletViewError: AppError? - var needsFullScan: Bool { - bdkClient.needsFullScan() - } private var updateProgress: @Sendable (UInt64, UInt64) -> Void { { [weak self] inspected, total in @@ -60,26 +63,30 @@ class WalletViewModel { } } } - + init( - bdkClient: BDKClient = .live, keyClient: KeyClient = .live, priceClient: PriceClient = .live, transactions: [CanonicalTx] = [], - walletSyncState: WalletSyncState = .notStarted + walletSyncState: WalletSyncState = .notStarted, + bdkSyncService: BDKSyncService, + isNeedFullScan: Bool ) { - self.bdkClient = bdkClient self.keyClient = keyClient self.priceClient = priceClient self.transactions = transactions self.walletSyncState = walletSyncState + self.bdkSyncService = bdkSyncService + self.isNeedFullScan = isNeedFullScan } private func fullScanWithProgress() async { self.walletSyncState = .syncing do { let inspector = WalletFullScanScriptInspector(updateProgress: updateProgressFullScan) - try await bdkClient.fullScanWithInspector(inspector) + try await bdkSyncService.startFullScan(progress: inspector) + + isNeedFullScan = false self.walletSyncState = .synced } catch let error as CannotConnectError { self.walletViewError = .generic(message: error.localizedDescription) @@ -98,7 +105,7 @@ class WalletViewModel { func getBalance() { do { - let balance = try bdkClient.getBalance() + let balance = try bdkSyncService.getBalance() self.balanceTotal = balance.total.toSat() } catch let error as WalletError { self.walletViewError = .generic(message: error.localizedDescription) @@ -122,7 +129,7 @@ class WalletViewModel { func getTransactions() { do { - let transactionDetails = try bdkClient.transactions() + let transactionDetails = try bdkSyncService.getTransactions() self.transactions = transactionDetails } catch let error as WalletError { self.walletViewError = .generic(message: error.localizedDescription) @@ -137,7 +144,8 @@ class WalletViewModel { self.walletSyncState = .syncing do { let inspector = WalletSyncScriptInspector(updateProgress: updateProgress) - try await bdkClient.syncWithInspector(inspector) + try await bdkSyncService.startSync(progress: inspector) + self.walletSyncState = .synced } catch let error as CannotConnectError { self.walletViewError = .generic(message: error.localizedDescription) @@ -158,9 +166,8 @@ class WalletViewModel { } func syncOrFullScan() async { - if bdkClient.needsFullScan() { + if isNeedFullScan { await fullScanWithProgress() - bdkClient.setNeedsFullScan(false) } else { await startSyncWithProgress() } diff --git a/BDKSwiftExampleWallet/View/HomeView.swift b/BDKSwiftExampleWallet/View/HomeView.swift index 119e31b6..360d8f54 100644 --- a/BDKSwiftExampleWallet/View/HomeView.swift +++ b/BDKSwiftExampleWallet/View/HomeView.swift @@ -7,7 +7,7 @@ import SwiftUI -struct HomeView: View { +struct HomeView: View { @Bindable var viewModel: HomeViewModel @Binding var navigationPath: NavigationPath @@ -17,8 +17,9 @@ struct HomeView: View { WalletView( viewModel: .init( - bdkClient: .live, - priceClient: .live + priceClient: .live, + bdkSyncService: viewModel.bdkSyncService, + isNeedFullScan: StorageUtil.shared.isNeedFullScan ?? false ), sendNavigationPath: $navigationPath ) @@ -48,11 +49,11 @@ enum NavigationDestination: Hashable { case buildTransaction(amount: String, address: String, fee: Int) } -#if DEBUG - #Preview { - HomeView( - viewModel: .init(bdkClient: .mock), - navigationPath: .constant(.init()) - ) - } -#endif +//#if DEBUG +// #Preview { +// HomeView( +// viewModel: .init(bdkClient: .mock), +// navigationPath: .constant(.init()) +// ) +// } +//#endif diff --git a/BDKSwiftExampleWallet/View/OnboardingView.swift b/BDKSwiftExampleWallet/View/OnboardingView.swift index 62ac3a9e..55738bce 100644 --- a/BDKSwiftExampleWallet/View/OnboardingView.swift +++ b/BDKSwiftExampleWallet/View/OnboardingView.swift @@ -10,7 +10,6 @@ import BitcoinUI import SwiftUI struct OnboardingView: View { - @AppStorage("isOnboarding") var isOnboarding: Bool? @ObservedObject var viewModel: OnboardingViewModel @State private var showingOnboardingViewErrorAlert = false @State private var showingImportView = false @@ -112,6 +111,7 @@ struct OnboardingView: View { Group { Picker("Network", selection: $viewModel.selectedNetwork) { + Text("Main").tag(Network.bitcoin) Text("Signet").tag(Network.signet) Text("Testnet").tag(Network.testnet) Text("Testnet4").tag(Network.testnet4) @@ -122,24 +122,35 @@ struct OnboardingView: View { .opacity(animateContent ? 1 : 0) .animation(.easeOut(duration: 0.5).delay(1.5), value: animateContent) - Picker("Esplora Server", selection: $viewModel.selectedURL) { - ForEach(viewModel.availableURLs, id: \.self) { url in - Text( - url.replacingOccurrences( - of: "https://", - with: "" - ).replacingOccurrences( - of: "http://", - with: "" - ) - ) - .tag(url) - } + Picker("Sync type", selection: $viewModel.walletSyncType) { + Text("Esplora Server").tag(WalletSyncType.esplora) + Text("Kyoto").tag(WalletSyncType.kyoto) } .pickerStyle(.automatic) .tint(.primary) .opacity(animateContent ? 1 : 0) .animation(.easeOut(duration: 0.5).delay(1.5), value: animateContent) + + if viewModel.walletSyncType == .esplora { + Picker("Esplora Server", selection: $viewModel.selectedURL) { + ForEach(viewModel.availableURLs, id: \.self) { url in + Text( + url.replacingOccurrences( + of: "https://", + with: "" + ).replacingOccurrences( + of: "http://", + with: "" + ) + ) + .tag(url) + } + } + .pickerStyle(.automatic) + .tint(.primary) + .opacity(animateContent ? 1 : 0) + .animation(.easeOut(duration: 0.5).delay(1.5), value: animateContent) + } } if !viewModel.words.isEmpty { @@ -219,8 +230,8 @@ struct OnboardingView: View { } } -#if DEBUG - #Preview("OnboardingView - en") { - OnboardingView(viewModel: .init(bdkClient: .mock)) - } -#endif +//#if DEBUG +// #Preview("OnboardingView - en") { +// OnboardingView(viewModel: .init(bdkClient: .mock)) +// } +//#endif diff --git a/BDKSwiftExampleWallet/View/WalletView.swift b/BDKSwiftExampleWallet/View/WalletView.swift index c6e59eff..e4ee6b10 100644 --- a/BDKSwiftExampleWallet/View/WalletView.swift +++ b/BDKSwiftExampleWallet/View/WalletView.swift @@ -9,8 +9,7 @@ import BitcoinUI import SwiftUI struct WalletView: View { - @AppStorage("balanceDisplayFormat") private var balanceFormat: BalanceDisplayFormat = - .bitcoinSats + @Bindable var viewModel: WalletViewModel @Binding var sendNavigationPath: NavigationPath @State private var isFirstAppear = true @@ -29,14 +28,14 @@ struct WalletView: View { VStack(spacing: 20) { BalanceView( - format: balanceFormat, + format: StorageUtil.shared.balanceFormat, balance: viewModel.balanceTotal, fiatPrice: viewModel.price ).onTapGesture { withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - balanceFormat = + StorageUtil.shared.balanceFormat = BalanceDisplayFormat.allCases[ - (balanceFormat.index + 1) % BalanceDisplayFormat.allCases.count + (StorageUtil.shared.balanceFormat.index + 1) % BalanceDisplayFormat.allCases.count ] } } @@ -47,8 +46,10 @@ struct WalletView: View { progress: viewModel.progress, inspectedScripts: viewModel.inspectedScripts, totalScripts: viewModel.totalScripts, - needsFullScan: viewModel.needsFullScan + needsFullScan: viewModel.isNeedFullScan ) { + // TODO: fix bug + StorageUtil.shared.isNeedFullScan = false showAllTransactions = true } @@ -185,16 +186,16 @@ struct WalletView: View { } } -#if DEBUG - #Preview("WalletView - en") { - WalletView( - viewModel: .init( - bdkClient: .mock, - priceClient: .mock, - transactions: [.mock], - walletSyncState: .synced - ), - sendNavigationPath: .constant(.init()) - ) - } -#endif +//#if DEBUG +// #Preview("WalletView - en") { +// WalletView( +// viewModel: .init( +// bdkClient: .mock, +// priceClient: .mock, +// transactions: [.mock], +// walletSyncState: .synced +// ), +// sendNavigationPath: .constant(.init()) +// ) +// } +//#endif