diff --git a/BDKSwiftExampleWallet.xcodeproj/project.pbxproj b/BDKSwiftExampleWallet.xcodeproj/project.pbxproj index ef41cd63..27cde1df 100644 --- a/BDKSwiftExampleWallet.xcodeproj/project.pbxproj +++ b/BDKSwiftExampleWallet.xcodeproj/project.pbxproj @@ -11,7 +11,15 @@ 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 */; }; + 77DB13092DEBB4AC004B735D /* BDKClient+Kyoto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77DB13082DEBB4A9004B735D /* BDKClient+Kyoto.swift */; }; + 77DB130B2DEBB553004B735D /* BDKClient+Esplora.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77DB130A2DEBB550004B735D /* BDKClient+Esplora.swift */; }; 77F0FDC92DA9A93D00B30E4F /* Connection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F0FDC82DA9A93700B30E4F /* Connection+Extensions.swift */; }; + 77F6A9E32DE247B2003568F0 /* BDKSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F6A9E22DE247AD003568F0 /* BDKSyncService.swift */; }; + 77F6A9E52DE24841003568F0 /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F6A9E42DE24837003568F0 /* URL+Extensions.swift */; }; + 77F6A9E72DE248A2003568F0 /* EsploraService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F6A9E62DE248A1003568F0 /* EsploraService.swift */; }; + 77F6A9E92DE25C9C003568F0 /* KyotoService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F6A9E82DE25C98003568F0 /* KyotoService.swift */; }; + 77F6A9EB2DE5072E003568F0 /* AppStorageUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F6A9EA2DE50727003568F0 /* AppStorageUtil.swift */; }; + 77F80F582DFCC33D002ACBA2 /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F80F572DFCC33D002ACBA2 /* CircularProgressView.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 */; }; @@ -116,7 +124,15 @@ 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 = ""; }; + 77DB13082DEBB4A9004B735D /* BDKClient+Kyoto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BDKClient+Kyoto.swift"; sourceTree = ""; }; + 77DB130A2DEBB550004B735D /* BDKClient+Esplora.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BDKClient+Esplora.swift"; sourceTree = ""; }; 77F0FDC82DA9A93700B30E4F /* Connection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Connection+Extensions.swift"; sourceTree = ""; }; + 77F6A9E22DE247AD003568F0 /* BDKSyncService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BDKSyncService.swift; sourceTree = ""; }; + 77F6A9E42DE24837003568F0 /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = ""; }; + 77F6A9E62DE248A1003568F0 /* EsploraService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EsploraService.swift; sourceTree = ""; }; + 77F6A9E82DE25C98003568F0 /* KyotoService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KyotoService.swift; sourceTree = ""; }; + 77F6A9EA2DE50727003568F0 /* AppStorageUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorageUtil.swift; sourceTree = ""; }; + 77F80F572DFCC33D002ACBA2 /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.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 = ""; }; @@ -246,6 +262,16 @@ path = Home; sourceTree = ""; }; + 77F6A9E12DE247A3003568F0 /* BDKSyncService */ = { + isa = PBXGroup; + children = ( + 77F6A9E82DE25C98003568F0 /* KyotoService.swift */, + 77F6A9E62DE248A1003568F0 /* EsploraService.swift */, + 77F6A9E22DE247AD003568F0 /* BDKSyncService.swift */, + ); + path = BDKSyncService; + sourceTree = ""; + }; A7FBCE392A72944C007C960E /* Resources */ = { isa = PBXGroup; children = ( @@ -271,6 +297,7 @@ AE1C341E2A42440A008F807A /* Extensions */ = { isa = PBXGroup; children = ( + 77F6A9E42DE24837003568F0 /* URL+Extensions.swift */, AE18E9372A9528200019D2A4 /* Bundle+Extensions.swift */, AE3646252BEDB01200B04E25 /* FileManager+Extensions.swift */, A73F7A352A3B778E00B87FC6 /* Int+Extensions.swift */, @@ -302,6 +329,7 @@ AE1C34202A42441F008F807A /* Utilities */ = { isa = PBXGroup; children = ( + 77F6A9EA2DE50727003568F0 /* AppStorageUtil.swift */, AE79538D2A2D59F000CCB277 /* Constants.swift */, AE2F255C2BED0BFB002A9AC6 /* AppError.swift */, ); @@ -311,6 +339,8 @@ AE1C34212A424434008F807A /* BDK Service */ = { isa = PBXGroup; children = ( + 77DB130A2DEBB550004B735D /* BDKClient+Esplora.swift */, + 77DB13082DEBB4A9004B735D /* BDKClient+Kyoto.swift */, AED4CC092A1D297600CE1831 /* BDKService.swift */, AE79538F2A2D5B4400CCB277 /* BDKSwiftExampleWalletError.swift */, ); @@ -335,6 +365,7 @@ AE2381B62C61253200F6B00C /* Activity */ = { isa = PBXGroup; children = ( + 77F80F572DFCC33D002ACBA2 /* CircularProgressView.swift */, AE2381AC2C60578500F6B00C /* ActivityListView.swift */, AE0C30F62A804A2D008F1EAE /* TransactionListView.swift */, AE29ED102BBE318A00EB9C4F /* TransactionItemView.swift */, @@ -512,6 +543,7 @@ AEB905C52A7EECD900CD0337 /* Service */ = { isa = PBXGroup; children = ( + 77F6A9E12DE247A3003568F0 /* BDKSyncService */, AE1C34212A424434008F807A /* BDK Service */, AEB905C42A7EECAF00CD0337 /* Price Service */, AE6715FB2A9ABF30005C193F /* Fee Service */, @@ -690,6 +722,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 77F6A9E32DE247B2003568F0 /* BDKSyncService.swift in Sources */, AE83EFDB2C9D07B200B41244 /* ChainPosition+Extensions.swift in Sources */, AE2ADD782B61EFFF00C2A823 /* SettingsViewModel.swift in Sources */, AEAB03112ABDDB86000C9528 /* FeeViewModel.swift in Sources */, @@ -699,6 +732,7 @@ 774586B52DB7B2BC00A631E1 /* BalanceView.swift in Sources */, AEB905C32A7EEBF000CD0337 /* BackupInfo.swift in Sources */, AE783A072AB4F7C7005F0CBA /* FeeView.swift in Sources */, + 77F6A9E92DE25C9C003568F0 /* KyotoService.swift in Sources */, AE2B8C1D2A9678C900815B2F /* FeeService.swift in Sources */, AE8D001C2D19F1760029C4C9 /* UIScreen+Extensions.swift in Sources */, AEC2CF5A2ABFBA19008065E4 /* BuildTransactionViewModel.swift in Sources */, @@ -706,6 +740,7 @@ AE7F670C2A7451D700CED561 /* CurrencyCode.swift in Sources */, AE2ADD762B61EFEB00C2A823 /* HomeViewModel.swift in Sources */, AE783A032AB4ECC2005F0CBA /* AddressView.swift in Sources */, + 77F6A9E52DE24841003568F0 /* URL+Extensions.swift in Sources */, AEB159D52D51A8680006AE9E /* View+Extensions.swift in Sources */, AE7F67052A7446B600CED561 /* PriceService.swift in Sources */, AEAB03132ABDDBF4000C9528 /* AmountViewModel.swift in Sources */, @@ -717,6 +752,7 @@ AEB159D32D51A7E00006AE9E /* BalanceDisplayFormat.swift in Sources */, AE18E9382A9528200019D2A4 /* Bundle+Extensions.swift in Sources */, AE79538E2A2D59F000CCB277 /* Constants.swift in Sources */, + 77F80F582DFCC33D002ACBA2 /* CircularProgressView.swift in Sources */, AE2F255D2BED0BFB002A9AC6 /* AppError.swift in Sources */, AEE6C74F2ABCBA4600442ADD /* WalletSyncState.swift in Sources */, AE1C34242A424456008F807A /* ReceiveView.swift in Sources */, @@ -746,8 +782,11 @@ AE6716012A9AC089005C193F /* KeyServiceError.swift in Sources */, 77AD9F062DBB031D00182E65 /* ActivityHomeHeaderView.swift in Sources */, AE0C30FB2A804B95008F1EAE /* WalletViewModel.swift in Sources */, + 77DB130B2DEBB553004B735D /* BDKClient+Esplora.swift in Sources */, + 77F6A9EB2DE5072E003568F0 /* AppStorageUtil.swift in Sources */, AE49847C2A1BBBD6009951E2 /* BDKSwiftExampleWalletApp.swift in Sources */, AE6715FF2A9AC066005C193F /* FeeServiceError.swift in Sources */, + 77DB13092DEBB4AC004B735D /* BDKClient+Kyoto.swift in Sources */, AE2381AD2C60578500F6B00C /* ActivityListView.swift in Sources */, AE2381B32C60877600F6B00C /* LocalOutputListView.swift in Sources */, AE783A052AB4F51F005F0CBA /* String+Extensions.swift in Sources */, @@ -756,6 +795,7 @@ AE0C30F92A804B65008F1EAE /* OnboardingViewModel.swift in Sources */, AE3902A42A3B4CD900BEC318 /* HomeView.swift in Sources */, AE0C30FD2A804BC1008F1EAE /* ReceiveViewModel.swift in Sources */, + 77F6A9E72DE248A2003568F0 /* EsploraService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift b/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift index 7a054f50..d32f3b50 100644 --- a/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift +++ b/BDKSwiftExampleWallet/App/BDKSwiftExampleWalletApp.swift @@ -17,12 +17,19 @@ struct BDKSwiftExampleWalletApp: App { 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)) + if value != nil && !isOnboarding { + HomeView( + viewModel: .init( + bdkClient: .live + ), + navigationPath: $navigationPath + ) } else { - HomeView(viewModel: .init(bdkClient: .live), navigationPath: $navigationPath) + OnboardingView( + viewModel: .init( + bdkClient: .live + ) + ) } } .onChange(of: isOnboarding) { oldValue, newValue in diff --git a/BDKSwiftExampleWallet/Extensions/BDK+Extensions/Connection+Extensions.swift b/BDKSwiftExampleWallet/Extensions/BDK+Extensions/Connection+Extensions.swift index 3f09b059..fcad6623 100644 --- a/BDKSwiftExampleWallet/Extensions/BDK+Extensions/Connection+Extensions.swift +++ b/BDKSwiftExampleWallet/Extensions/BDK+Extensions/Connection+Extensions.swift @@ -2,6 +2,12 @@ import BitcoinDevKit import Foundation extension Connection { + static var dataDir: String { + let documentsDirectoryURL = URL.documentsDirectory + let walletDataDirectoryURL = documentsDirectoryURL.appendingPathComponent("wallet_data") + return walletDataDirectoryURL.path() + } + static func createConnection() throws -> Connection { let documentsDirectoryURL = URL.documentsDirectory let walletDataDirectoryURL = documentsDirectoryURL.appendingPathComponent("wallet_data") @@ -17,4 +23,10 @@ extension Connection { let connection = try Connection(path: 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/URL+Extensions.swift b/BDKSwiftExampleWallet/Extensions/URL+Extensions.swift new file mode 100644 index 00000000..5800fe4c --- /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..d66981d9 100644 --- a/BDKSwiftExampleWallet/Resources/Localizable.xcstrings +++ b/BDKSwiftExampleWallet/Resources/Localizable.xcstrings @@ -236,6 +236,9 @@ } } } + }, + "%lld%%" : { + }, "%llu" : { "localizations" : { @@ -451,6 +454,9 @@ } } } + }, + "Conecting" : { + }, "confirmed" : { "localizations" : { @@ -691,6 +697,9 @@ } } } + }, + "Kyoto" : { + }, "Navigation Title" : { "extractionState" : "stale", @@ -1143,6 +1152,9 @@ } } } + }, + "Sync type" : { + }, "Syncing" : { "extractionState" : "manual", diff --git a/BDKSwiftExampleWallet/Service/BDK Service/BDKClient+Esplora.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKClient+Esplora.swift new file mode 100644 index 00000000..5cfbb683 --- /dev/null +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKClient+Esplora.swift @@ -0,0 +1,99 @@ +// +// BDKClient+Esplora.swift +// BDKSwiftExampleWallet +// +// Created by Rubens Machion on 31/05/25. +// + +extension BDKClient { + static let esplora = Self( + loadWallet: { + try EsploraService.shared.loadWallet() + }, + deleteWallet: { + try EsploraService.shared.deleteWallet() + }, + createWalletFromSeed: { words in + try EsploraService.shared.createWallet(params: words) + }, + createWalletFromDescriptor: { descriptor in + try EsploraService.shared.createWallet(params: descriptor) + }, + createWalletFromXPub: { xpub in + try EsploraService.shared.createWallet(params: xpub) + }, + getBalance: { + try EsploraService.shared.getBalance() + }, + transactions: { + try EsploraService.shared.getTransactions() + }, + listUnspent: { + try EsploraService.shared.listUnspent() + }, + syncScanWithSyncScanProgress: { progress in + try await EsploraService.shared.startSync(progress: progress) + }, + fullScanWithFullScanProgress: { progress in + try await EsploraService.shared.startFullScan(progress: progress) + }, + getAddress: { + try EsploraService.shared.getAddress() + }, + send: { (address, amount, feeRate) in + Task { + try await EsploraService.shared.send( + address: address, + amount: amount, + feeRate: feeRate + ) + } + }, + calculateFee: { tx in + try EsploraService.shared.calculateFee(tx: tx) + }, + calculateFeeRate: { tx in + try EsploraService.shared.calculateFeeRate(tx: tx) + }, + sentAndReceived: { tx in + try EsploraService.shared.sentAndReceived(tx: tx) + }, + buildTransaction: { (address, amount, feeRate) in + try EsploraService.shared.buildTransaction( + address: address, + amount: amount, + feeRate: feeRate + ) + }, + getBackupInfo: { + try BDKService.shared.getBackupInfo() + }, + needsFullScan: { + BDKService.shared.needsFullScanOfWallet() + }, + setNeedsFullScan: { value in + BDKService.shared.setNeedsFullScan(value) + }, + getNetwork: { + BDKService.shared.network + }, + getEsploraURL: { + BDKService.shared.esploraURL + }, + updateNetwork: { newNetwork in + BDKService.shared.updateNetwork(newNetwork) + }, + updateEsploraURL: { newURL in + BDKService.shared.updateEsploraURL(newURL) + }, + stop: { + try await EsploraService.shared.stopService() + }, + upateSyncMode: { mode in + BDKService.shared.updateSyncMode(mode) + }, + getSyncMode: { + BDKService.shared.getSyncMode() + } + ) +} diff --git a/BDKSwiftExampleWallet/Service/BDK Service/BDKClient+Kyoto.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKClient+Kyoto.swift new file mode 100644 index 00000000..8b7f2a94 --- /dev/null +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKClient+Kyoto.swift @@ -0,0 +1,99 @@ +// +// BDKClient+.swift +// BDKSwiftExampleWallet +// +// Created by Rubens Machion on 31/05/25. +// + +extension BDKClient { + static let kyoto = Self( + loadWallet: { + try KyotoService.shared.loadWallet() + }, + deleteWallet: { + try KyotoService.shared.deleteWallet() + }, + createWalletFromSeed: { words in + try KyotoService.shared.createWallet(params: words) + }, + createWalletFromDescriptor: { descriptor in + try KyotoService.shared.createWallet(params: descriptor) + }, + createWalletFromXPub: { xpub in + try KyotoService.shared.createWallet(params: xpub) + }, + getBalance: { + try KyotoService.shared.getBalance() + }, + transactions: { + try KyotoService.shared.getTransactions() + }, + listUnspent: { + try KyotoService.shared.listUnspent() + }, + syncScanWithSyncScanProgress: { progress in + try await KyotoService.shared.startSync(progress: progress) + }, + fullScanWithFullScanProgress: { progress in + try await KyotoService.shared.startFullScan(progress: progress) + }, + getAddress: { + try KyotoService.shared.getAddress() + }, + send: { (address, amount, feeRate) in + Task { + try await KyotoService.shared.send( + address: address, + amount: amount, + feeRate: feeRate + ) + } + }, + calculateFee: { tx in + try KyotoService.shared.calculateFee(tx: tx) + }, + calculateFeeRate: { tx in + try KyotoService.shared.calculateFeeRate(tx: tx) + }, + sentAndReceived: { tx in + try KyotoService.shared.sentAndReceived(tx: tx) + }, + buildTransaction: { (address, amount, feeRate) in + try KyotoService.shared.buildTransaction( + address: address, + amount: amount, + feeRate: feeRate + ) + }, + getBackupInfo: { + try BDKService.shared.getBackupInfo() + }, + needsFullScan: { + BDKService.shared.needsFullScanOfWallet() + }, + setNeedsFullScan: { value in + BDKService.shared.setNeedsFullScan(value) + }, + getNetwork: { + BDKService.shared.network + }, + getEsploraURL: { + BDKService.shared.esploraURL + }, + updateNetwork: { newNetwork in + BDKService.shared.updateNetwork(newNetwork) + }, + updateEsploraURL: { newURL in + BDKService.shared.updateEsploraURL(newURL) + }, + stop: { + try await KyotoService.shared.stopService() + }, + upateSyncMode: { mode in + BDKService.shared.updateSyncMode(mode) + }, + getSyncMode: { + BDKService.shared.getSyncMode() + } + ) +} diff --git a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift index 1d4ac2e5..54bc9d5a 100644 --- a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift @@ -8,17 +8,14 @@ import BitcoinDevKit import Foundation -private class BDKService { +class BDKService { static var shared: BDKService = BDKService() - private var balance: Balance? - private var connection: Connection? - private var esploraClient: EsploraClient - private let keyClient: KeyClient + private var syncMode: SyncMode? + let keyClient: KeyClient private var needsFullScan: Bool = false private(set) var network: Network private(set) var esploraURL: String - private var wallet: Wallet? init(keyClient: KeyClient = .live) { self.keyClient = keyClient @@ -26,8 +23,6 @@ private class BDKService { self.network = Network(stringValue: storedNetworkString ?? "") ?? .signet self.esploraURL = (try? keyClient.getEsploraURL()) ?? self.network.url - - self.esploraClient = EsploraClient(url: self.esploraURL) } func updateNetwork(_ newNetwork: Network) { @@ -48,379 +43,33 @@ private class BDKService { if newURL != self.esploraURL { self.esploraURL = newURL try? keyClient.saveEsploraURL(newURL) - updateEsploraClient() } } - private func updateEsploraClient() { - self.esploraClient = EsploraClient(url: self.esploraURL) - } - - func getAddress() throws -> String { - guard let wallet = self.wallet else { - throw WalletError.walletNotFound - } - guard let connection = self.connection else { - throw WalletError.dbNotFound + func updateSyncMode(_ mode: SyncMode) { + if syncMode != mode { + self.syncMode = mode + try? keyClient.saveSyncMode(mode) } - let addressInfo = wallet.revealNextAddress(keychain: .external) - let _ = try wallet.persist(connection: connection) - return addressInfo.address.description } - func getBalance() throws -> Balance { - guard let wallet = self.wallet else { throw WalletError.walletNotFound } - let balance = wallet.balance() - return balance - } - - func transactions() 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 listUnspent() throws -> [LocalOutput] { - guard let wallet = self.wallet else { - throw WalletError.walletNotFound - } - let localOutputs = wallet.listUnspent() - return localOutputs - } - - func createWallet(words: String?) throws { - self.connection = try Connection.createConnection() - guard let connection = connection else { - throw WalletError.dbNotFound - } - - let savedURL = try? keyClient.getEsploraURL() - let baseUrl = savedURL ?? network.url - - var words12: String - if let words = words, !words.isEmpty { - words12 = words - needsFullScan = true - } else { - let mnemonic = Mnemonic(wordCount: WordCount.words12) - words12 = mnemonic.description - needsFullScan = false - } - let mnemonic = try Mnemonic.fromString(mnemonic: words12) - 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 - ) - let backupInfo = BackupInfo( - mnemonic: mnemonic.description, - descriptor: descriptor.toStringWithSecret(), - changeDescriptor: changeDescriptor.toStringWithSecret() - ) - - try keyClient.saveBackupInfo(backupInfo) - try keyClient.saveNetwork(self.network.description) - try keyClient.saveEsploraURL(baseUrl) - self.esploraURL = baseUrl - updateEsploraClient() - - let wallet = try Wallet( - descriptor: descriptor, - changeDescriptor: changeDescriptor, - network: network, - connection: connection - ) - self.wallet = wallet - } - - func createWallet(descriptor: String?) throws { - self.connection = try Connection.createConnection() - guard let connection = connection else { - throw WalletError.dbNotFound - } - - let savedURL = try? keyClient.getEsploraURL() - let baseUrl = savedURL ?? network.url - - guard let descriptorString = descriptor, !descriptorString.isEmpty else { - throw WalletError.walletNotFound - } - - let descriptorStrings = descriptorString.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 WalletError.walletNotFound - } - 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 WalletError.walletNotFound - } - - let backupInfo = BackupInfo( - mnemonic: "", - descriptor: descriptor.toStringWithSecret(), - changeDescriptor: changeDescriptor.toStringWithSecret() - ) - - try keyClient.saveBackupInfo(backupInfo) - try keyClient.saveNetwork(self.network.description) - try keyClient.saveEsploraURL(baseUrl) - - let wallet = try Wallet( - descriptor: descriptor, - changeDescriptor: changeDescriptor, - network: network, - connection: connection - ) - self.wallet = wallet - } - - func createWallet(xpub: String?) throws { - self.connection = try Connection.createConnection() - guard let connection = connection else { - throw WalletError.dbNotFound - } - - let savedURL = try? keyClient.getEsploraURL() - - let baseUrl = savedURL ?? network.url - - guard let xpubString = xpub, !xpubString.isEmpty else { - throw WalletError.walletNotFound - } - - let descriptorPublicKey = try DescriptorPublicKey.fromString(publicKey: xpubString) - 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 - ) - - let backupInfo = BackupInfo( - mnemonic: "", - descriptor: descriptor.toStringWithSecret(), - changeDescriptor: changeDescriptor.toStringWithSecret() - ) - - try keyClient.saveBackupInfo(backupInfo) - try keyClient.saveNetwork(self.network.description) - try keyClient.saveEsploraURL(baseUrl) - self.esploraURL = baseUrl - updateEsploraClient() - - let wallet = try Wallet( - descriptor: descriptor, - changeDescriptor: changeDescriptor, - network: network, - connection: connection - ) - self.wallet = wallet - } - - 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 - let connection = try Connection(path: persistenceBackendPath) - self.connection = connection - let wallet = try Wallet.load( - descriptor: descriptor, - changeDescriptor: changeDescriptor, - connection: connection - ) - self.wallet = wallet - } - - func loadWalletFromBackup() throws { - 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 - ) - try self.loadWallet(descriptor: descriptor, changeDescriptor: changeDescriptor) - } - - func deleteWallet() throws { - let savedURL = try? keyClient.getEsploraURL() - let savedNetwork = try? keyClient.getNetwork() - - if let bundleID = Bundle.main.bundleIdentifier { - UserDefaults.standard.removePersistentDomain(forName: bundleID) - } - - try self.keyClient.deleteBackupInfo() - - let documentsDirectoryURL = URL.documentsDirectory - let walletDataDirectoryURL = documentsDirectoryURL.appendingPathComponent("wallet_data") - if FileManager.default.fileExists(atPath: walletDataDirectoryURL.path) { - try FileManager.default.removeItem(at: walletDataDirectoryURL) - } - - if let savedURL = savedURL { - try keyClient.saveEsploraURL(savedURL) - } - if let savedNetwork = savedNetwork { - try keyClient.saveNetwork(savedNetwork) - } - - needsFullScan = true + func getSyncMode() -> SyncMode? { + try? keyClient.getSyncMode() } func getBackupInfo() throws -> BackupInfo { let backupInfo = try keyClient.getBackupInfo() return backupInfo } - - func send( - address: String, - amount: UInt64, - feeRate: UInt64 - ) async throws { - let psbt = try buildTransaction( - address: address, - amount: amount, - feeRate: feeRate - ) - try signAndBroadcast(psbt: psbt) - } - - func buildTransaction(address: String, amount: UInt64, feeRate: UInt64) throws - -> Psbt - { - guard let wallet = self.wallet else { throw WalletError.walletNotFound } - let script = try Address(address: address, network: self.network) - .scriptPubkey() - let txBuilder = try TxBuilder() - .addRecipient( - script: script, - amount: Amount.fromSat(satoshi: amount) - ) - .feeRate(feeRate: FeeRate.fromSatPerVb(satVb: feeRate)) - .finish(wallet: wallet) - return txBuilder - } - - private func signAndBroadcast(psbt: Psbt) throws { - guard let wallet = self.wallet else { throw WalletError.walletNotFound } - let isSigned = try wallet.sign(psbt: psbt) - if isSigned { - let transaction = try psbt.extractTx() - let client = self.esploraClient - try client.broadcast(transaction: transaction) - } else { - throw WalletError.notSigned - } - } - - 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 _ = try wallet.applyUpdate(update: update) - guard let connection = self.connection else { - throw WalletError.dbNotFound - } - let _ = try wallet.persist(connection: connection) - } - - func fullScanWithInspector(inspector: FullScanScriptInspector) async throws { - guard let wallet = self.wallet else { throw WalletError.walletNotFound } - let esploraClient = esploraClient - let fullScanRequest = try wallet.startFullScan() - .inspectSpksForAllKeychains(inspector: inspector) - .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 calculateFee(tx: Transaction) throws -> Amount { - guard let wallet = self.wallet else { - throw WalletError.walletNotFound - } - let fee = try wallet.calculateFee(tx: tx) - return fee - } - - func calculateFeeRate(tx: Transaction) throws -> UInt64 { - guard let wallet = self.wallet else { - throw WalletError.walletNotFound - } - let feeRate = try wallet.calculateFeeRate(tx: tx) - return feeRate.toSatPerVbCeil() - } - - func sentAndReceived(tx: Transaction) throws -> SentAndReceivedValues { - guard let wallet = self.wallet else { - throw WalletError.walletNotFound - } - let values = wallet.sentAndReceived(tx: tx) - return values - } } extension BDKService { func needsFullScanOfWallet() -> Bool { - return needsFullScan + return AppStorageUtil.shared.isNeedFullScan ?? true } func setNeedsFullScan(_ value: Bool) { - needsFullScan = value + AppStorageUtil.shared.isNeedFullScan = value } } @@ -433,8 +82,8 @@ struct BDKClient { let getBalance: () throws -> Balance let transactions: () throws -> [CanonicalTx] let listUnspent: () throws -> [LocalOutput] - let syncWithInspector: (SyncScriptInspector) async throws -> Void - let fullScanWithInspector: (FullScanScriptInspector) async throws -> Void + let syncScanWithSyncScanProgress: (@escaping SyncScanProgress) async throws -> Void + let fullScanWithFullScanProgress: (@escaping FullScanProgress) async throws -> Void let getAddress: () throws -> String let send: (String, UInt64, UInt64) throws -> Void let calculateFee: (Transaction) throws -> Amount @@ -448,60 +97,24 @@ struct BDKClient { let getEsploraURL: () -> String let updateNetwork: (Network) -> Void let updateEsploraURL: (String) -> Void + let stop: () async throws -> Void + let upateSyncMode: (SyncMode) -> Void + let getSyncMode: () -> SyncMode? } extension BDKClient { - static let live = Self( - loadWallet: { try BDKService.shared.loadWalletFromBackup() }, - deleteWallet: { try BDKService.shared.deleteWallet() }, - createWalletFromSeed: { words in try BDKService.shared.createWallet(words: words) }, - createWalletFromDescriptor: { descriptor in - try BDKService.shared.createWallet(descriptor: descriptor) - }, - createWalletFromXPub: { xpub in - try BDKService.shared.createWallet(xpub: xpub) - }, - getBalance: { try BDKService.shared.getBalance() }, - transactions: { try BDKService.shared.transactions() }, - listUnspent: { try BDKService.shared.listUnspent() }, - syncWithInspector: { inspector in - try await BDKService.shared.syncWithInspector(inspector: inspector) - }, - fullScanWithInspector: { inspector in - try await BDKService.shared.fullScanWithInspector(inspector: inspector) - }, - getAddress: { try BDKService.shared.getAddress() }, - send: { (address, amount, feeRate) in - Task { - try await BDKService.shared.send(address: address, amount: amount, feeRate: feeRate) + static var live: BDKClient { + do { + let syncMode = try BDKService.shared.keyClient.getSyncMode() + if syncMode == .kyoto { + return .kyoto + } else { + return .esplora } - }, - calculateFee: { tx in try BDKService.shared.calculateFee(tx: tx) }, - calculateFeeRate: { tx in try BDKService.shared.calculateFeeRate(tx: tx) }, - sentAndReceived: { tx in try BDKService.shared.sentAndReceived(tx: tx) }, - buildTransaction: { (address, amount, feeRate) in - try BDKService.shared.buildTransaction( - address: address, - amount: amount, - feeRate: feeRate - ) - }, - getBackupInfo: { try BDKService.shared.getBackupInfo() }, - needsFullScan: { BDKService.shared.needsFullScanOfWallet() }, - setNeedsFullScan: { value in BDKService.shared.setNeedsFullScan(value) }, - getNetwork: { - BDKService.shared.network - }, - getEsploraURL: { - BDKService.shared.esploraURL - }, - updateNetwork: { newNetwork in - BDKService.shared.updateNetwork(newNetwork) - }, - updateEsploraURL: { newURL in - BDKService.shared.updateEsploraURL(newURL) + } catch { + return .esplora } - ) + } } #if DEBUG @@ -523,8 +136,8 @@ extension BDKClient { .mock ] }, - syncWithInspector: { _ in }, - fullScanWithInspector: { _ in }, + syncScanWithSyncScanProgress: { _ in }, + fullScanWithFullScanProgress: { _ in }, getAddress: { "tb1pd8jmenqpe7rz2mavfdx7uc8pj7vskxv4rl6avxlqsw2u8u7d4gfs97durt" }, send: { _, _, _ in }, calculateFee: { _ in Amount.fromSat(satoshi: UInt64(615)) }, @@ -556,7 +169,10 @@ extension BDKClient { getNetwork: { .signet }, getEsploraURL: { Constants.Config.EsploraServerURLNetwork.Signet.mutiny }, updateNetwork: { _ in }, - updateEsploraURL: { _ in } + updateEsploraURL: { _ in }, + stop: {}, + upateSyncMode: { _ in }, + getSyncMode: { .esplora } ) } #endif diff --git a/BDKSwiftExampleWallet/Service/BDKSyncService/BDKSyncService.swift b/BDKSwiftExampleWallet/Service/BDKSyncService/BDKSyncService.swift new file mode 100644 index 00000000..2d1491f9 --- /dev/null +++ b/BDKSwiftExampleWallet/Service/BDKSyncService/BDKSyncService.swift @@ -0,0 +1,302 @@ +// +// BDKSyncService.swift +// BDKSwiftExampleWallet +// +// Created by Rubens Machion on 16/05/25. +// + +import BitcoinDevKit +import Foundation + +typealias FullScanProgress = (UInt64) -> Void +typealias SyncScanProgress = (UInt64, UInt64) -> Void + +enum SyncMode: String { + case esplora, kyoto +} + +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: @escaping SyncScanProgress) async throws + func startFullScan(progress: @escaping FullScanProgress) async throws + + func updateNetwork(network: Network) + func updateEsploraURL(_ url: String) + + func getTransactions() throws -> [CanonicalTx] + func getBalance() throws -> Balance + func sentAndReceived(tx: Transaction) throws -> SentAndReceivedValues + func calculateFeeRate(tx: Transaction) throws -> UInt64 + func calculateFee(tx: Transaction) throws -> Amount + func buildTransaction(address: String, amount: UInt64, feeRate: UInt64) throws -> Psbt + func send(address: String, amount: UInt64, feeRate: UInt64) async throws + func listUnspent() throws -> [LocalOutput] + func getAddress() throws -> String + func stopService() async throws +} + +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.toStringWithSecret(), + changeDescriptor: changeDescriptor.toStringWithSecret() + ) + } + + let words = !params.isEmpty ? params : Mnemonic(wordCount: WordCount.words12).description + guard let mnemonic = try? Mnemonic.fromString(mnemonic: words) 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.toStringWithSecret(), + changeDescriptor: changeDescriptor.toStringWithSecret() + ) + } + + func deleteWallet() throws { + Task { + try await stopService() + } + try deleteData() + } + + 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 + } + + func getBalance() throws -> Balance { + guard let wallet = self.wallet else { throw WalletError.walletNotFound } + let balance = wallet.balance() + return balance + } + + func sentAndReceived(tx: Transaction) throws -> SentAndReceivedValues { + guard let wallet = self.wallet else { + throw WalletError.walletNotFound + } + let values = wallet.sentAndReceived(tx: tx) + return values + } + + func calculateFeeRate(tx: Transaction) throws -> UInt64 { + guard let wallet = self.wallet else { + throw WalletError.walletNotFound + } + let feeRate = try wallet.calculateFeeRate(tx: tx) + return feeRate.toSatPerVbCeil() + } + + func calculateFee(tx: Transaction) throws -> Amount { + guard let wallet = self.wallet else { + throw WalletError.walletNotFound + } + let fee = try wallet.calculateFee(tx: tx) + return fee + } + + func buildTransaction( + address: String, + amount: UInt64, + feeRate: UInt64 + ) throws -> Psbt { + guard let wallet = self.wallet else { throw WalletError.walletNotFound } + let script = try Address(address: address, network: self.network) + .scriptPubkey() + let txBuilder = try TxBuilder() + .addRecipient( + script: script, + amount: Amount.fromSat(satoshi: amount) + ) + .feeRate(feeRate: FeeRate.fromSatPerVb(satVb: feeRate)) + .finish(wallet: wallet) + return txBuilder + } + + func listUnspent() throws -> [LocalOutput] { + guard let wallet = self.wallet else { + throw WalletError.walletNotFound + } + let localOutputs = wallet.listUnspent() + return localOutputs + } + + func getAddress() throws -> String { + guard let wallet = self.wallet else { + throw WalletError.walletNotFound + } + guard let connection = self.connection else { + throw WalletError.dbNotFound + } + let addressInfo = wallet.revealNextAddress(keychain: .external) + let _ = try wallet.persist(connection: connection) + return addressInfo.address.description + } + + 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 + } + + // MARK: - Optionals methods + + func updateEsploraURL(_ url: String) {} + + func updateNetwork(network: Network) {} + + func stopService() async throws {} + + // 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/EsploraService.swift b/BDKSwiftExampleWallet/Service/BDKSyncService/EsploraService.swift new file mode 100644 index 00000000..32266559 --- /dev/null +++ b/BDKSwiftExampleWallet/Service/BDKSyncService/EsploraService.swift @@ -0,0 +1,130 @@ +// +// Untitled.swift +// BDKSwiftExampleWallet +// +// Created by Rubens Machion on 16/05/25. +// + +import BitcoinDevKit +import Foundation + +extension EsploraService { + static var live: BDKSyncService = EsploraService() +} + +final class EsploraService: BDKSyncService { + + static let shared: EsploraService = .init() + + 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 updateNetwork(network: Network) { + self.network = network + } + + func updateEsploraURL(_ url: String) { + try? keyClient.saveEsploraURL(url) + self.esploraClient = .init(url: url) + } + + func startSync(progress: @escaping SyncScanProgress) async throws { + guard let wallet = self.wallet else { throw WalletError.walletNotFound } + let syncScanInspector = WalletSyncScriptInspector { scripts, total in + progress(scripts, total) + } + let esploraClient = self.esploraClient + let syncRequest = try wallet.startSyncWithRevealedSpks() + .inspectSpks(inspector: syncScanInspector) + .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: @escaping FullScanProgress) async throws { + guard let wallet = self.wallet else { throw WalletError.walletNotFound } + let fullScanInspector = WalletFullScanScriptInspector { inspected in + progress(inspected) + } + let esploraClient = esploraClient + let fullScanRequest = try wallet.startFullScan() + .inspectSpksForAllKeychains(inspector: fullScanInspector) + .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 send( + address: String, + amount: UInt64, + feeRate: UInt64 + ) async throws { + let psbt = try buildTransaction( + address: address, + amount: amount, + feeRate: feeRate + ) + try signAndBroadcast(psbt: psbt) + } + + // MARK: - Private + + private func signAndBroadcast(psbt: Psbt) throws { + guard let wallet = self.wallet else { throw WalletError.walletNotFound } + let isSigned = try wallet.sign(psbt: psbt) + if isSigned { + let transaction = try psbt.extractTx() + let client = self.esploraClient + try client.broadcast(transaction: transaction) + } else { + throw WalletError.notSigned + } + } +} diff --git a/BDKSwiftExampleWallet/Service/BDKSyncService/KyotoService.swift b/BDKSwiftExampleWallet/Service/BDKSyncService/KyotoService.swift new file mode 100644 index 00000000..91b11c5c --- /dev/null +++ b/BDKSwiftExampleWallet/Service/BDKSyncService/KyotoService.swift @@ -0,0 +1,204 @@ +// +// KyotoService.swift +// BDKSwiftExampleWallet +// +// Created by Rubens Machion on 24/05/25. +// + +import BitcoinDevKit +import Foundation + +extension KyotoService { + static var live: BDKSyncService = KyotoService() +} + +extension Notification.Name { + static let walletDidUpdate = Notification.Name("walletDidUpdate") + static let walletDidConnect = Notification.Name("walletDidConnect") + static let walletDidDisconnect = Notification.Name("walletDidDisconnect") +} + +final class KyotoService: BDKSyncService { + + static let shared: KyotoService = .init() + + var connection: Connection? + var keyClient: KeyClient + var network: Network + var wallet: Wallet? + + private var client: CbfClient? + private var node: CbfNode? + private var isConnected = false { + didSet { + isConnected + ? NotificationCenter.default.post(name: .walletDidConnect, object: nil) + : NotificationCenter.default.post(name: .walletDidDisconnect, object: nil) + } + } + private var isScanRunning = false + + private var fullScanProgress: FullScanProgress? + private var syncProgress: SyncScanProgress? + + 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.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 startSync(progress: @escaping SyncScanProgress) async throws { + if isScanRunning { return } + guard let wallet = self.wallet else { + throw WalletError.walletNotFound + } + let nodeComponents = try buildNode( + from: wallet, + scanType: .sync + ) + self.syncProgress = progress + self.client = nodeComponents.client + self.node = nodeComponents.node + isScanRunning = true + try await startNode() + } + + func startFullScan(progress: @escaping FullScanProgress) async throws { + if isScanRunning { return } + guard let wallet = self.wallet else { + throw WalletError.walletNotFound + } + let nodeComponents = try buildNode( + from: wallet, + scanType: .new + ) + + self.fullScanProgress = progress + self.client = nodeComponents.client + self.node = nodeComponents.node + isScanRunning = true + try await startNode() + } + + func send(address: String, amount: UInt64, feeRate: UInt64) async throws { + let psbt = try buildTransaction( + address: address, + amount: amount, + feeRate: feeRate + ) + try await signAndBroadcast(psbt: psbt) + } + + func stopService() async throws { + isScanRunning = false + try await client?.shutdown() + } + + // MARK: - Private + + private func signAndBroadcast(psbt: Psbt) async throws { + guard let wallet = self.wallet else { throw WalletError.walletNotFound } + let isSigned = try wallet.sign(psbt: psbt) + if isSigned { + let transaction = try psbt.extractTx() + try await client?.broadcast(transaction: transaction) + } else { + throw WalletError.notSigned + } + } + + private func buildNode(from wallet: Wallet, scanType: ScanType) throws -> CbfComponents { + try CbfBuilder() + .dataDir(dataDir: Connection.dataDir) + .logLevel(logLevel: .debug) + .scanType(scanType: scanType) + .build(wallet: wallet) + } + + private func startNode() async throws { + node?.run() + getNextLog() + updateWarn() + try await updateWallet() + startRealTimeWalletUpdate() + } + + @discardableResult + private func updateWallet() async throws -> Bool { + guard let update = await self.client?.update() else { + isScanRunning = false + return false + } + try self.wallet?.applyUpdate(update: update) + let _ = try self.wallet?.persist(connection: self.connection ?? Connection.loadConnection()) + isScanRunning = false + return true + } + + private func startRealTimeWalletUpdate() { + Task { + while true { + do { + if try await updateWallet() { + NotificationCenter.default.post(name: .walletDidUpdate, object: nil) + } + } catch { + print(error) + } + } + } + } + + private func getNextLog() { + Task { + while true { + if let log = try? await self.client?.nextLog() { + switch log { + case .connectionsMet: + self.isConnected = true + case .progress(let progress): + if let fullScanProgress = self.fullScanProgress { + let _progress = UInt64(progress * 100.0) + fullScanProgress(_progress) + } + print("Progress: \(progress)") + default: + break + } + } + } + } + } + + private func updateWarn() { + Task { + while true { + if let warn = try? await self.client?.nextWarning() { + switch warn { + case .needConnections: + self.isConnected = false + default: + #if DEBUG + print(warn) + #endif + } + } + } + } + } +} diff --git a/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift b/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift index 5ac41e8e..5dd7da57 100644 --- a/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift +++ b/BDKSwiftExampleWallet/Service/Key Service/KeyService.swift @@ -62,6 +62,23 @@ private struct KeyService { func saveNetwork(network: String) throws { keychain[string: "SelectedNetwork"] = network } + + func deleteAllData() throws { + try deleteNetwork() + try deleteBackupInfo() + try deleteEsploraURL() + } + + func saveSyncMode(_ mode: SyncMode) throws { + keychain[string: "SyncMode"] = mode.rawValue + } + + func getSyncMode() throws -> SyncMode? { + guard let mode = keychain[string: "SyncMode"] else { + return nil + } + return SyncMode(rawValue: mode) + } } struct KeyClient { @@ -74,6 +91,9 @@ struct KeyClient { let saveEsploraURL: (String) throws -> Void let saveBackupInfo: (BackupInfo) throws -> Void let saveNetwork: (String) throws -> Void + let deleteAllData: () throws -> Void + let saveSyncMode: (SyncMode) throws -> Void + let getSyncMode: () throws -> SyncMode? private init( deleteBackupInfo: @escaping () throws -> Void, @@ -84,7 +104,10 @@ 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, + saveSyncMode: @escaping (SyncMode) throws -> Void, + getSyncMode: @escaping () throws -> SyncMode? ) { self.deleteBackupInfo = deleteBackupInfo self.deleteEsplora = deleteEsplora @@ -95,6 +118,9 @@ struct KeyClient { self.saveBackupInfo = saveBackupInfo self.saveEsploraURL = saveEsploraURL self.saveNetwork = saveNetwork + self.deleteAllData = deleteAllData + self.saveSyncMode = saveSyncMode + self.getSyncMode = getSyncMode } } @@ -108,7 +134,10 @@ 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().deleteAllData() }, + saveSyncMode: { mode in try KeyService().saveSyncMode(mode) }, + getSyncMode: { try KeyService().getSyncMode() } ) } @@ -148,7 +177,10 @@ extension KeyClient { getNetwork: { nil }, saveBackupInfo: { _ in }, saveEsploraURL: { _ in }, - saveNetwork: { _ in } + saveNetwork: { _ in }, + deleteAllData: {}, + saveSyncMode: { _ in }, + getSyncMode: { .esplora } ) } #endif diff --git a/BDKSwiftExampleWallet/Utilities/AppStorageUtil.swift b/BDKSwiftExampleWallet/Utilities/AppStorageUtil.swift new file mode 100644 index 00000000..83af03c1 --- /dev/null +++ b/BDKSwiftExampleWallet/Utilities/AppStorageUtil.swift @@ -0,0 +1,14 @@ +// +// AppStorageUtil.swift +// BDKSwiftExampleWallet +// +// Created by Rubens Machion on 26/05/25. +// + +import SwiftUI + +struct AppStorageUtil { + @AppStorage("isNeedFullScan") var isNeedFullScan: Bool? + + static var shared = AppStorageUtil() +} diff --git a/BDKSwiftExampleWallet/Utilities/Constants.swift b/BDKSwiftExampleWallet/Utilities/Constants.swift index 173014c7..205ca8ae 100644 --- a/BDKSwiftExampleWallet/Utilities/Constants.swift +++ b/BDKSwiftExampleWallet/Utilities/Constants.swift @@ -29,7 +29,9 @@ struct Constants { struct Signet { static let bdk = "http://signet.bitcoindevkit.net" static let mutiny = "https://mutinynet.com/api" + static let mempoolspace = "https://mempool.space/signet/api" static let allValues = [ + mempoolspace, mutiny, bdk, ] diff --git a/BDKSwiftExampleWallet/View Model/Activity/ActivityListViewModel.swift b/BDKSwiftExampleWallet/View Model/Activity/ActivityListViewModel.swift index 5519e3d3..0e1d79ad 100644 --- a/BDKSwiftExampleWallet/View Model/Activity/ActivityListViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/Activity/ActivityListViewModel.swift @@ -72,32 +72,4 @@ class ActivityListViewModel { self.showingWalletViewErrorAlert = true } } - - private func startSyncWithProgress() async { - self.walletSyncState = .syncing - do { - let inspector = WalletSyncScriptInspector(updateProgress: updateProgress) - try await bdkClient.syncWithInspector(inspector) - self.walletSyncState = .synced - } catch let error as CannotConnectError { - self.walletViewError = .generic(message: error.localizedDescription) - self.showingWalletViewErrorAlert = true - } catch let error as EsploraError { - self.walletViewError = .generic(message: error.localizedDescription) - self.showingWalletViewErrorAlert = true - } catch let error as RequestBuilderError { - self.walletViewError = .generic(message: error.localizedDescription) - self.showingWalletViewErrorAlert = true - } catch let error as PersistenceError { - self.walletViewError = .generic(message: error.localizedDescription) - self.showingWalletViewErrorAlert = true - } catch { - self.walletSyncState = .error(error) - self.showingWalletViewErrorAlert = true - } - } - - func syncOrFullScan() async { - await startSyncWithProgress() - } } diff --git a/BDKSwiftExampleWallet/View Model/Activity/TransactionDetailViewModel.swift b/BDKSwiftExampleWallet/View Model/Activity/TransactionDetailViewModel.swift index e500e67e..46eeb69c 100644 --- a/BDKSwiftExampleWallet/View Model/Activity/TransactionDetailViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/Activity/TransactionDetailViewModel.swift @@ -28,7 +28,7 @@ class TransactionDetailViewModel { self.bdkClient = bdkClient } - func getCalulateFee(tx: BitcoinDevKit.Transaction) { + func getCalulateFee(tx: Transaction) { do { let calculateFee = try bdkClient.calculateFee(tx) let feeString = String(calculateFee.toSat()) @@ -45,11 +45,7 @@ class TransactionDetailViewModel { switch network { case "signet": - if savedEsploraURL == Constants.Config.EsploraServerURLNetwork.Signet.bdk { - self.esploraURL = "https://mempool.space/signet" - } else { - self.esploraURL = "https://mutinynet.com" - } + self.esploraURL = "https://mempool.space/signet" case "testnet": if savedEsploraURL == Constants.Config.EsploraServerURLNetwork.Testnet.blockstream { self.esploraURL = "https://blockstream.info/testnet" @@ -65,7 +61,7 @@ class TransactionDetailViewModel { self.network = bdkClient.getNetwork().description } - func getSentAndReceived(tx: BitcoinDevKit.Transaction) -> SentAndReceivedValues? { + func getSentAndReceived(tx: Transaction) -> SentAndReceivedValues? { do { let sentAndReceived = try bdkClient.sentAndReceived(tx) return sentAndReceived diff --git a/BDKSwiftExampleWallet/View Model/Activity/TransactionListViewModel.swift b/BDKSwiftExampleWallet/View Model/Activity/TransactionListViewModel.swift index 18a3d6a3..84991aef 100644 --- a/BDKSwiftExampleWallet/View Model/Activity/TransactionListViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/Activity/TransactionListViewModel.swift @@ -22,7 +22,7 @@ class TransactionListViewModel { self.bdkClient = bdkClient } - func getSentAndReceived(tx: BitcoinDevKit.Transaction) -> SentAndReceivedValues? { + func getSentAndReceived(tx: Transaction) -> SentAndReceivedValues? { do { let sentAndReceived = try bdkClient.sentAndReceived(tx) return sentAndReceived diff --git a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift index e503c8ea..3e23215e 100644 --- a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift @@ -16,6 +16,11 @@ class OnboardingViewModel: ObservableObject { let bdkClient: BDKClient @AppStorage("isOnboarding") var isOnboarding: Bool? + @Published var syncMode: SyncMode = .esplora { + didSet { + bdkClient.upateSyncMode(syncMode) + } + } @Published var createWithPersistError: CreateWithPersistError? var isDescriptor: Bool { words.hasPrefix("tr(") || words.hasPrefix("wpkh(") || words.hasPrefix("wsh(") @@ -76,11 +81,12 @@ class OnboardingViewModel: ObservableObject { } init( - bdkClient: BDKClient = .live + bdkClient: BDKClient = .esplora ) { self.bdkClient = bdkClient self.selectedNetwork = bdkClient.getNetwork() self.selectedURL = bdkClient.getEsploraURL() + self.syncMode = bdkClient.getSyncMode() ?? .esplora } func createWallet() { diff --git a/BDKSwiftExampleWallet/View Model/Send/BuildTransactionViewModel.swift b/BDKSwiftExampleWallet/View Model/Send/BuildTransactionViewModel.swift index 0d28276a..ce1b4fba 100644 --- a/BDKSwiftExampleWallet/View Model/Send/BuildTransactionViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/Send/BuildTransactionViewModel.swift @@ -40,7 +40,7 @@ class BuildTransactionViewModel { } } - func extractTransaction() -> BitcoinDevKit.Transaction? { + func extractTransaction() -> Transaction? { guard let psbt = self.psbt else { return nil } @@ -56,7 +56,7 @@ class BuildTransactionViewModel { } } - func getCalulateFee(tx: BitcoinDevKit.Transaction) { + func getCalulateFee(tx: Transaction) { do { let calculateFee = try bdkClient.calculateFee(tx) let feeString = String(calculateFee.toSat()) diff --git a/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift b/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift index 790fc87b..ec79f9b5 100644 --- a/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift @@ -21,13 +21,7 @@ class SettingsViewModel: ObservableObject { @Published var showingSettingsViewErrorAlert = false @Published var walletSyncState: WalletSyncState = .notStarted - private var updateProgressFullScan: @Sendable (UInt64) -> Void { - { [weak self] inspected in - DispatchQueue.main.async { - self?.inspectedScripts = inspected - } - } - } + let syncMode: SyncMode init( bdkClient: BDKClient = .live @@ -35,6 +29,7 @@ class SettingsViewModel: ObservableObject { self.bdkClient = bdkClient self.network = bdkClient.getNetwork().description self.esploraURL = bdkClient.getEsploraURL() + self.syncMode = bdkClient.getSyncMode() ?? .esplora } func delete() { @@ -52,8 +47,11 @@ class SettingsViewModel: ObservableObject { self.walletSyncState = .syncing } do { - let inspector = WalletFullScanScriptInspector(updateProgress: updateProgressFullScan) - try await bdkClient.fullScanWithInspector(inspector) + try await bdkClient.fullScanWithFullScanProgress { [weak self] progress in + DispatchQueue.main.async { + self?.inspectedScripts = progress + } + } DispatchQueue.main.async { NotificationCenter.default.post( name: Notification.Name("TransactionSent"), diff --git a/BDKSwiftExampleWallet/View Model/WalletViewModel.swift b/BDKSwiftExampleWallet/View Model/WalletViewModel.swift index d0e3a42d..7fbdad5c 100644 --- a/BDKSwiftExampleWallet/View Model/WalletViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/WalletViewModel.swift @@ -42,24 +42,10 @@ class WalletViewModel { var needsFullScan: Bool { bdkClient.needsFullScan() } - - private var updateProgress: @Sendable (UInt64, UInt64) -> Void { - { [weak self] inspected, total in - DispatchQueue.main.async { - self?.totalScripts = total - self?.inspectedScripts = inspected - self?.progress = total > 0 ? Float(inspected) / Float(total) : 0 - } - } - } - - private var updateProgressFullScan: @Sendable (UInt64) -> Void { - { [weak self] inspected in - DispatchQueue.main.async { - self?.inspectedScripts = inspected - } - } + var syncMode: SyncMode { + bdkClient.getSyncMode() ?? .esplora } + var isConnected: Bool = false init( bdkClient: BDKClient = .live, @@ -73,26 +59,52 @@ class WalletViewModel { self.priceClient = priceClient self.transactions = transactions self.walletSyncState = walletSyncState + addNotifications() } - private func fullScanWithProgress() async { - self.walletSyncState = .syncing - do { - let inspector = WalletFullScanScriptInspector(updateProgress: updateProgressFullScan) - try await bdkClient.fullScanWithInspector(inspector) - self.walletSyncState = .synced - } catch let error as CannotConnectError { - self.walletViewError = .generic(message: error.localizedDescription) - self.showingWalletViewErrorAlert = true - } catch let error as EsploraError { - self.walletViewError = .generic(message: error.localizedDescription) - self.showingWalletViewErrorAlert = true - } catch let error as PersistenceError { - self.walletViewError = .generic(message: error.localizedDescription) - self.showingWalletViewErrorAlert = true - } catch { - self.walletSyncState = .error(error) - self.showingWalletViewErrorAlert = true + deinit { + NotificationCenter.default.removeObserver(self) + } + + private func addNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(receiveNotification(_:)), + name: .walletDidUpdate, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(receiveNotification(_:)), + name: .walletDidConnect, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(receiveNotification(_:)), + name: .walletDidConnect, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(receiveNotification(_:)), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + @objc private func receiveNotification(_ notification: Notification) { + switch notification.name { + case .walletDidUpdate, UIApplication.willEnterForegroundNotification: + getBalance() + getTransactions() + case .walletDidConnect: + isConnected = true + + case .walletDidDisconnect: + isConnected = false + + default: break } } @@ -133,11 +145,21 @@ class WalletViewModel { } } + func syncOrFullScan() async { + if bdkClient.needsFullScan() { + await fullScanWithProgress() + bdkClient.setNeedsFullScan(false) + } else { + await startSyncWithProgress() + } + } + private func startSyncWithProgress() async { self.walletSyncState = .syncing do { - let inspector = WalletSyncScriptInspector(updateProgress: updateProgress) - try await bdkClient.syncWithInspector(inspector) + try await bdkClient.syncScanWithSyncScanProgress { [weak self] inspected, total in + self?.updateSyncProgress(inspected, total) + } self.walletSyncState = .synced } catch let error as CannotConnectError { self.walletViewError = .generic(message: error.localizedDescription) @@ -157,12 +179,39 @@ class WalletViewModel { } } - func syncOrFullScan() async { - if bdkClient.needsFullScan() { - await fullScanWithProgress() - bdkClient.setNeedsFullScan(false) - } else { - await startSyncWithProgress() + private func fullScanWithProgress() async { + self.walletSyncState = .syncing + do { + try await bdkClient.fullScanWithFullScanProgress { [weak self] progress in + self?.updateFullProgress(progress) + } + self.walletSyncState = .synced + } catch let error as CannotConnectError { + self.walletViewError = .generic(message: error.localizedDescription) + self.showingWalletViewErrorAlert = true + } catch let error as EsploraError { + self.walletViewError = .generic(message: error.localizedDescription) + self.showingWalletViewErrorAlert = true + } catch let error as PersistenceError { + self.walletViewError = .generic(message: error.localizedDescription) + self.showingWalletViewErrorAlert = true + } catch { + self.walletSyncState = .error(error) + self.showingWalletViewErrorAlert = true + } + } + + private func updateFullProgress(_ progress: UInt64) { + DispatchQueue.main.async { [weak self] in + self?.inspectedScripts = progress + } + } + + private func updateSyncProgress(_ inspected: UInt64, _ total: UInt64) { + DispatchQueue.main.async { [weak self] in + self?.totalScripts = total + self?.inspectedScripts = inspected + self?.progress = total > 0 ? Float(inspected) / Float(total) : 0 } } } diff --git a/BDKSwiftExampleWallet/View/Activity/CircularProgressView.swift b/BDKSwiftExampleWallet/View/Activity/CircularProgressView.swift new file mode 100644 index 00000000..da5bc255 --- /dev/null +++ b/BDKSwiftExampleWallet/View/Activity/CircularProgressView.swift @@ -0,0 +1,31 @@ +// +// CircularProgressView.swift +// BDKSwiftExampleWallet +// +// Created by Rubens Machion on 13/06/25. +// + +import SwiftUI + +struct CircularProgressView: View { + var progress: Float + private let lineWidth: CGFloat = 2.0 + var body: some View { + ZStack { + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: lineWidth) + + Circle() + .trim(from: 0.0, to: CGFloat(progress)) + .stroke(Color.blue, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.5), value: progress) + + Text("\(Int(progress * 100))%") + .contentTransition(.numericText()) + .font(.system(size: 9.0)) + .foregroundStyle(.secondary) + .fontWeight(.regular) + } + } +} diff --git a/BDKSwiftExampleWallet/View/Activity/TransactionListView.swift b/BDKSwiftExampleWallet/View/Activity/TransactionListView.swift index 43104481..b1512fa1 100644 --- a/BDKSwiftExampleWallet/View/Activity/TransactionListView.swift +++ b/BDKSwiftExampleWallet/View/Activity/TransactionListView.swift @@ -36,7 +36,7 @@ struct TransactionListView: View { .font(.subheadline) let mutinyFaucetURL = URL(string: "https://faucet.mutinynet.com") - let signetFaucetURL = URL(string: "https://signetfaucet.com") + let signetFaucetURL = URL(string: "https://signet25.bublina.eu.org/") if let mutinyFaucetURL, let signetFaucetURL, diff --git a/BDKSwiftExampleWallet/View/Home/ActivityHomeHeaderView.swift b/BDKSwiftExampleWallet/View/Home/ActivityHomeHeaderView.swift index 2309f52b..dc8bccbc 100644 --- a/BDKSwiftExampleWallet/View/Home/ActivityHomeHeaderView.swift +++ b/BDKSwiftExampleWallet/View/Home/ActivityHomeHeaderView.swift @@ -8,77 +8,98 @@ import SwiftUI struct ActivityHomeHeaderView: View { - + let walletSyncState: WalletSyncState let progress: Float let inspectedScripts: UInt64 let totalScripts: UInt64 let needsFullScan: Bool - + let syncMode: SyncMode + let showAllTransactions: () -> Void - + var body: some View { HStack { Text("Activity") Spacer() - + HStack { if needsFullScan { - Text("\(inspectedScripts)") - .padding(.trailing, -5.0) - .fontWeight(.semibold) - .contentTransition(.numericText()) - .transition(.opacity) + if syncMode == .esplora { + Text("\(inspectedScripts)") + .padding(.trailing, -5.0) + .fontWeight(.semibold) + .contentTransition(.numericText()) + .transition(.opacity) + .fontDesign(.monospaced) + .foregroundStyle(.secondary) + .font(.caption2) + .fontWeight(.thin) + .animation(.easeInOut, value: inspectedScripts) + } else if syncMode == .kyoto { + CircularProgressView( + progress: Float(inspectedScripts) / Float(100.0) + ) + .frame(width: 30.0, height: 30.0) + } + + } else if walletSyncState == .syncing { + if syncMode == .esplora { + HStack { + if progress < 1.0 { + Text("\(inspectedScripts)") + .padding(.trailing, -5.0) + .fontWeight(.semibold) + .contentTransition(.numericText()) + .transition(.opacity) + + Text("/") + .padding(.trailing, -5.0) + .transition(.opacity) + Text("\(totalScripts)") + .contentTransition(.numericText()) + .transition(.opacity) + } + Text( + String( + format: "%.0f%%", + progress * 100 + ) + ) + .contentTransition(.numericText()) + .transition(.opacity) + } .fontDesign(.monospaced) .foregroundStyle(.secondary) .font(.caption2) .fontWeight(.thin) .animation(.easeInOut, value: inspectedScripts) - } else if walletSyncState == .syncing { - HStack { - if progress < 1.0 { - Text("\(inspectedScripts)") - .padding(.trailing, -5.0) - .fontWeight(.semibold) - .contentTransition(.numericText()) - .transition(.opacity) - - Text("/") - .padding(.trailing, -5.0) - .transition(.opacity) - Text("\(totalScripts)") - .contentTransition(.numericText()) - .transition(.opacity) - } - - Text( - String( - format: "%.0f%%", - progress * 100 - ) - ) - .contentTransition(.numericText()) - .transition(.opacity) + .animation(.easeInOut, value: totalScripts) + .animation(.easeInOut, value: progress) + } else if syncMode == .kyoto { + Text("Conecting") + .font(.caption) + .foregroundStyle(.secondary) + .fontWeight(.regular) } - .fontDesign(.monospaced) - .foregroundStyle(.secondary) - .font(.caption2) - .fontWeight(.thin) - .animation(.easeInOut, value: inspectedScripts) - .animation(.easeInOut, value: totalScripts) - .animation(.easeInOut, value: progress) } } - HStack { - HStack(spacing: 5) { - self.syncImageIndicator() + switch syncMode { + case .esplora: + HStack { + HStack(spacing: 5) { + self.syncImageIndicator() + } + .contentTransition(.symbolEffect(.replace.offUp)) + } - .contentTransition(.symbolEffect(.replace.offUp)) + .foregroundStyle(.secondary) + .font(.caption) + case .kyoto: + EmptyView() } - .foregroundStyle(.secondary) - .font(.caption) - + if walletSyncState == .synced { Button { self.showAllTransactions() @@ -95,7 +116,7 @@ struct ActivityHomeHeaderView: View { } .fontWeight(.bold) } - + @ViewBuilder private func syncImageIndicator() -> some View { switch walletSyncState { @@ -104,7 +125,7 @@ struct ActivityHomeHeaderView: View { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) ) - + case .syncing: AnyView( Image(systemName: "slowmo") @@ -112,7 +133,7 @@ struct ActivityHomeHeaderView: View { .variableColor.cumulative ) ) - + case .notStarted: AnyView( Image(systemName: "arrow.clockwise") diff --git a/BDKSwiftExampleWallet/View/OnboardingView.swift b/BDKSwiftExampleWallet/View/OnboardingView.swift index 62ac3a9e..d8407f75 100644 --- a/BDKSwiftExampleWallet/View/OnboardingView.swift +++ b/BDKSwiftExampleWallet/View/OnboardingView.swift @@ -111,35 +111,46 @@ struct OnboardingView: View { .padding() Group { - Picker("Network", selection: $viewModel.selectedNetwork) { - Text("Signet").tag(Network.signet) - Text("Testnet").tag(Network.testnet) - Text("Testnet4").tag(Network.testnet4) + Picker("Sync type", selection: $viewModel.syncMode) { + Text("Esplora Server").tag(SyncMode.esplora) + Text("Kyoto").tag(SyncMode.kyoto) } .pickerStyle(.automatic) .tint(.primary) - .accessibilityLabel("Select Bitcoin Network") .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: "" + if viewModel.syncMode == .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) + .tag(url) + } + } + .pickerStyle(.automatic) + .tint(.primary) + .opacity(animateContent ? 1 : 0) + .animation(.easeOut(duration: 0.5).delay(1.5), value: animateContent) + + Picker("Network", selection: $viewModel.selectedNetwork) { + Text("Signet").tag(Network.signet) + Text("Testnet").tag(Network.testnet) + Text("Testnet4").tag(Network.testnet4) } + .pickerStyle(.automatic) + .tint(.primary) + .accessibilityLabel("Select Bitcoin Network") + .opacity(animateContent ? 1 : 0) + .animation(.easeOut(duration: 0.5).delay(1.5), value: animateContent) } - .pickerStyle(.automatic) - .tint(.primary) - .opacity(animateContent ? 1 : 0) - .animation(.easeOut(duration: 0.5).delay(1.5), value: animateContent) } if !viewModel.words.isEmpty { diff --git a/BDKSwiftExampleWallet/View/Settings/SettingsView.swift b/BDKSwiftExampleWallet/View/Settings/SettingsView.swift index df1aab97..9e54509d 100644 --- a/BDKSwiftExampleWallet/View/Settings/SettingsView.swift +++ b/BDKSwiftExampleWallet/View/Settings/SettingsView.swift @@ -35,16 +35,24 @@ struct SettingsView: View { .foregroundStyle(.secondary) Form { - - Section(header: Text("Network")) { - if let network = viewModel.network, let url = viewModel.esploraURL { - Text( - "\(network.capitalized) • \(url.replacingOccurrences(of: "https://", with: "").replacingOccurrences(of: "http://", with: ""))" - ) - .foregroundStyle(.primary) + Group { + if viewModel.syncMode == .kyoto { + Section(header: Text("Network")) { + Text("Kyoto") + .foregroundStyle(.primary) + } } else { - HStack { - Text("No Network") + Section(header: Text("Network")) { + if let network = viewModel.network, let url = viewModel.esploraURL { + Text( + "\(network.capitalized) • \(url.replacingOccurrences(of: "https://", with: "").replacingOccurrences(of: "http://", with: ""))" + ) + .foregroundStyle(.primary) + } else { + HStack { + Text("No Network") + } + } } } } @@ -52,25 +60,27 @@ struct SettingsView: View { colorScheme == .light ? Color.gray.opacity(0.1) : Color.black.opacity(0.2) ) - Section(header: Text("Wallet")) { - Button { - Task { - await viewModel.fullScanWithProgress() + if viewModel.syncMode == .esplora { + Section(header: Text("Wallet")) { + Button { + Task { + await viewModel.fullScanWithProgress() + } + } label: { + Text("Full Scan") + } + .foregroundStyle(Color.bitcoinOrange) + if viewModel.walletSyncState == .syncing { + Text("\(viewModel.inspectedScripts)") + .contentTransition(.numericText()) + .foregroundStyle(.primary) + .animation(.easeInOut, value: viewModel.inspectedScripts) } - } label: { - Text("Full Scan") - } - .foregroundStyle(Color.bitcoinOrange) - if viewModel.walletSyncState == .syncing { - Text("\(viewModel.inspectedScripts)") - .contentTransition(.numericText()) - .foregroundStyle(.primary) - .animation(.easeInOut, value: viewModel.inspectedScripts) } + .listRowBackground( + colorScheme == .light ? Color.gray.opacity(0.1) : Color.black.opacity(0.2) + ) } - .listRowBackground( - colorScheme == .light ? Color.gray.opacity(0.1) : Color.black.opacity(0.2) - ) Section(header: Text("Danger Zone")) { Button { diff --git a/BDKSwiftExampleWallet/View/WalletView.swift b/BDKSwiftExampleWallet/View/WalletView.swift index c6e59eff..2c264fe0 100644 --- a/BDKSwiftExampleWallet/View/WalletView.swift +++ b/BDKSwiftExampleWallet/View/WalletView.swift @@ -47,21 +47,29 @@ struct WalletView: View { progress: viewModel.progress, inspectedScripts: viewModel.inspectedScripts, totalScripts: viewModel.totalScripts, - needsFullScan: viewModel.needsFullScan + needsFullScan: viewModel.needsFullScan, + syncMode: viewModel.syncMode ) { showAllTransactions = true } - - TransactionListView( - viewModel: .init(), - transactions: viewModel.recentTransactions, - walletSyncState: viewModel.walletSyncState - ) - .refreshable { - await viewModel.syncOrFullScan() - viewModel.getBalance() - viewModel.getTransactions() - await viewModel.getPrices() + if viewModel.syncMode == .esplora { + TransactionListView( + viewModel: .init(), + transactions: viewModel.recentTransactions, + walletSyncState: viewModel.walletSyncState + ) + .refreshable { + await viewModel.syncOrFullScan() + viewModel.getBalance() + viewModel.getTransactions() + await viewModel.getPrices() + } + } else { + TransactionListView( + viewModel: .init(), + transactions: viewModel.recentTransactions, + walletSyncState: viewModel.walletSyncState + ) } HStack { @@ -174,6 +182,12 @@ struct WalletView: View { ) } .toolbar { + if viewModel.syncMode == .kyoto { + ToolbarItem(placement: .topBarLeading) { + Image(systemName: viewModel.isConnected ? "network" : "network.slash") + .foregroundStyle(viewModel.isConnected ? .blue : .red) + } + } ToolbarItem(placement: .navigationBarTrailing) { Button { showSettingsView = true