diff --git a/BDKSwiftExampleWallet.xcodeproj/project.pbxproj b/BDKSwiftExampleWallet.xcodeproj/project.pbxproj index aee50db9..d6c3d0ae 100644 --- a/BDKSwiftExampleWallet.xcodeproj/project.pbxproj +++ b/BDKSwiftExampleWallet.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ AE4984832A1BBBD7009951E2 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AE4984822A1BBBD7009951E2 /* Preview Assets.xcassets */; }; AE49848D2A1BBBD8009951E2 /* BDKSwiftExampleWalletTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE49848C2A1BBBD8009951E2 /* BDKSwiftExampleWalletTests.swift */; }; AE4984A62A1BBCB8009951E2 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = AE4984A52A1BBCB8009951E2 /* README.md */; }; + AE4D97572E3AFF2500E88A38 /* CbfClient+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE4D97562E3AFF2500E88A38 /* CbfClient+Extensions.swift */; }; AE6715FA2A9A9220005C193F /* BDKSwiftExampleWalletPriceServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE6715F92A9A9220005C193F /* BDKSwiftExampleWalletPriceServiceTests.swift */; }; AE6715FD2A9AC056005C193F /* PriceServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE6715FC2A9AC056005C193F /* PriceServiceError.swift */; }; AE6715FF2A9AC066005C193F /* FeeServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE6715FE2A9AC066005C193F /* FeeServiceError.swift */; }; @@ -163,6 +164,7 @@ AE4984882A1BBBD8009951E2 /* BDKSwiftExampleWalletTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BDKSwiftExampleWalletTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; AE49848C2A1BBBD8009951E2 /* BDKSwiftExampleWalletTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BDKSwiftExampleWalletTests.swift; sourceTree = ""; }; AE4984A52A1BBCB8009951E2 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + AE4D97562E3AFF2500E88A38 /* CbfClient+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CbfClient+Extensions.swift"; sourceTree = ""; }; AE6474732CE559E000A270C6 /* BDKSwiftExampleWallet.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BDKSwiftExampleWallet.entitlements; sourceTree = ""; }; AE6715F92A9A9220005C193F /* BDKSwiftExampleWalletPriceServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BDKSwiftExampleWalletPriceServiceTests.swift; sourceTree = ""; }; AE6715FC2A9AC056005C193F /* PriceServiceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceServiceError.swift; sourceTree = ""; }; @@ -572,6 +574,7 @@ isa = PBXGroup; children = ( 77EDA65A2E2A5B3800A5E3AD /* URL+Extensions.swift */, + AE4D97562E3AFF2500E88A38 /* CbfClient+Extensions.swift */, AE97E74C2E315A8F000A407D /* AddressType+Extensions.swift */, 77F0FDC82DA9A93700B30E4F /* Persister+Extensions.swift */, AEE6C74B2ABCB3E200442ADD /* Transaction+Extensions.swift */, @@ -756,6 +759,7 @@ AE73239B2DF9C00F00D9BAE2 /* TxId+Extensions.swift in Sources */, AE1390C72A7DB0AF0098127A /* KeyService.swift in Sources */, AED4CC0A2A1D297600CE1831 /* BDKService.swift in Sources */, + AE4D97572E3AFF2500E88A38 /* CbfClient+Extensions.swift in Sources */, AED4CC102A1D522100CE1831 /* WalletView.swift in Sources */, AE7F67092A7451AA00CED561 /* Price.swift in Sources */, AE184EFC2BFE52C800374362 /* Amount+Extensions.swift in Sources */, diff --git a/BDKSwiftExampleWallet/Extensions/BDK+Extensions/CbfClient+Extensions.swift b/BDKSwiftExampleWallet/Extensions/BDK+Extensions/CbfClient+Extensions.swift new file mode 100644 index 00000000..062b2a2b --- /dev/null +++ b/BDKSwiftExampleWallet/Extensions/BDK+Extensions/CbfClient+Extensions.swift @@ -0,0 +1,190 @@ +// +// CbfClient+Extensions.swift +// BDKSwiftExampleWallet +// +// Created by Matthew Ramsden on 7/30/25. +// + +import BitcoinDevKit +import Foundation + +extension CbfClient { + // Track monitoring tasks per client for clean cancellation + private static var monitoringTasks: [ObjectIdentifier: Task] = [:] + private static var warningTasks: [ObjectIdentifier: Task] = [:] + private static var logTasks: [ObjectIdentifier: Task] = [:] + private static var heartbeatTasks: [ObjectIdentifier: Task] = [:] + private static var lastInfoAt: [ObjectIdentifier: Date] = [:] + private static let monitoringTasksQueue = DispatchQueue(label: "cbf.monitoring.tasks") + + static func createComponents(wallet: Wallet) -> (client: CbfClient, node: CbfNode) { + do { + + let components = try CbfBuilder() + .logLevel(logLevel: .debug) + .scanType(scanType: .sync) + .dataDir(dataDir: Constants.Config.Kyoto.dbPath) + .peers(peers: Constants.Networks.Signet.Regular.kyotoPeers) + .build(wallet: wallet) + + components.node.run() + + components.client.startBackgroundMonitoring() + + return (client: components.client, node: components.node) + } catch { + fatalError("Failed to create CBF components: \(error)") + } + } + + func startBackgroundMonitoring() { + let id = ObjectIdentifier(self) + + let task = Task { [self] in + var hasEstablishedConnection = false + while true { + if Task.isCancelled { break } + do { + let info = try await self.nextInfo() + CbfClient.monitoringTasksQueue.sync { Self.lastInfoAt[id] = Date() } + switch info { + case let .progress(progress): + await MainActor.run { + NotificationCenter.default.post( + name: NSNotification.Name("KyotoProgressUpdate"), + object: nil, + userInfo: ["progress": progress] + ) + } + case let .newChainHeight(height): + await MainActor.run { + NotificationCenter.default.post( + name: NSNotification.Name("KyotoChainHeightUpdate"), + object: nil, + userInfo: ["height": height] + ) + if !hasEstablishedConnection { + hasEstablishedConnection = true + NotificationCenter.default.post( + name: NSNotification.Name("KyotoConnectionUpdate"), + object: nil, + userInfo: ["connected": true] + ) + } + } + case .connectionsMet, .successfulHandshake: + await MainActor.run { + if !hasEstablishedConnection { + hasEstablishedConnection = true + NotificationCenter.default.post( + name: NSNotification.Name("KyotoConnectionUpdate"), + object: nil, + userInfo: ["connected": true] + ) + } + } + default: + break + } + } catch is CancellationError { + break + } catch { + // ignore + } + } + } + + Self.monitoringTasksQueue.sync { + Self.monitoringTasks[id] = task + Self.lastInfoAt[id] = Date() + } + + // Heartbeat task to signal idleness while awaiting Info events + let heartbeat = Task { + while true { + if Task.isCancelled { break } + try? await Task.sleep(nanoseconds: 5_000_000_000) + if Task.isCancelled { break } + var idleFor: TimeInterval = 0 + CbfClient.monitoringTasksQueue.sync { + if let last = Self.lastInfoAt[id] { idleFor = Date().timeIntervalSince(last) } + } + } + } + + Self.monitoringTasksQueue.sync { + Self.heartbeatTasks[id] = heartbeat + } + + // Minimal warnings listener for visibility while syncing + let warnings = Task { [self] in + while true { + if Task.isCancelled { break } + do { + let warning = try await self.nextWarning() + if case .needConnections = warning { + await MainActor.run { + NotificationCenter.default.post( + name: NSNotification.Name("KyotoConnectionUpdate"), + object: nil, + userInfo: ["connected": false] + ) + } + } + } catch is CancellationError { + break + } catch { + // ignore + } + } + } + + Self.monitoringTasksQueue.sync { + Self.warningTasks[id] = warnings + } + + // Log listener for detailed debugging + let logs = Task { [self] in + while true { + if Task.isCancelled { break } + do { + let log = try await self.nextLog() + } catch is CancellationError { + break + } catch { + // ignore + } + } + } + + Self.monitoringTasksQueue.sync { + Self.logTasks[id] = logs + } + } + + func stopBackgroundMonitoring() { + let id = ObjectIdentifier(self) + Self.monitoringTasksQueue.sync { + guard let task = Self.monitoringTasks.removeValue(forKey: id) else { return } + task.cancel() + if let hb = Self.heartbeatTasks.removeValue(forKey: id) { hb.cancel() } + if let wt = Self.warningTasks.removeValue(forKey: id) { wt.cancel() } + if let lt = Self.logTasks.removeValue(forKey: id) { lt.cancel() } + Self.lastInfoAt.removeValue(forKey: id) + } + } + + static func cancelAllMonitoring() { + Self.monitoringTasksQueue.sync { + for (_, task) in Self.monitoringTasks { task.cancel() } + for (_, wt) in Self.warningTasks { wt.cancel() } + for (_, lt) in Self.logTasks { lt.cancel() } + for (_, hb) in Self.heartbeatTasks { hb.cancel() } + Self.monitoringTasks.removeAll() + Self.warningTasks.removeAll() + Self.logTasks.removeAll() + Self.heartbeatTasks.removeAll() + Self.lastInfoAt.removeAll() + } + } +} diff --git a/BDKSwiftExampleWallet/Resources/Localizable.xcstrings b/BDKSwiftExampleWallet/Resources/Localizable.xcstrings index 295efa1b..d601fe7b 100644 --- a/BDKSwiftExampleWallet/Resources/Localizable.xcstrings +++ b/BDKSwiftExampleWallet/Resources/Localizable.xcstrings @@ -146,6 +146,9 @@ } } } + }, + "%" : { + }, "%@ • %@" : { "localizations" : { @@ -210,6 +213,9 @@ } } } + }, + "%lld" : { + }, "%lld Output%@" : { "localizations" : { @@ -467,6 +473,9 @@ } } } + }, + "Block %u" : { + }, "Build Transaction Error" : { "localizations" : { @@ -483,6 +492,9 @@ } } } + }, + "Client" : { + }, "Coldcard Verify Address" : { @@ -623,6 +635,9 @@ } } } + }, + "Esplora" : { + }, "Esplora Server" : { "localizations" : { @@ -733,6 +748,9 @@ } } } + }, + "Kyoto" : { + }, "Navigation Title" : { "extractionState" : "stale", @@ -1049,6 +1067,9 @@ } } } + }, + "Select Client Type" : { + }, "Select Fee" : { "localizations" : { diff --git a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift index 45ca24b5..59aa95cc 100644 --- a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift @@ -10,13 +10,13 @@ import Foundation enum BlockchainClientType: String, CaseIterable { case esplora = "esplora" - case kyoto = "kyoto" // future + case kyoto = "kyoto" case electrum = "electrum" // future } struct BlockchainClient { - let sync: @Sendable (SyncRequest, UInt64) throws -> Update - let fullScan: @Sendable (FullScanRequest, UInt64, UInt64) throws -> Update + let sync: @Sendable (SyncRequest, UInt64) async throws -> Update + let fullScan: @Sendable (FullScanRequest, UInt64, UInt64) async throws -> Update let broadcast: @Sendable (Transaction) throws -> Void let getUrl: @Sendable () -> String let getType: @Sendable () -> BlockchainClientType @@ -40,6 +40,45 @@ extension BlockchainClient { getType: { .esplora } ) } + + static func kyoto(peer: String) -> Self { + var cbfComponents: (client: CbfClient, node: CbfNode)? = nil + + func getOrCreateComponents() throws -> (client: CbfClient, node: CbfNode) { + if let existing = cbfComponents { + return existing + } + + guard let wallet = BDKService.shared.wallet else { + throw WalletError.walletNotFound + } + + try FileManager.default.ensureDirectoryExists(at: Constants.Config.Kyoto.dbDirectoryURL) + + let components = CbfClient.createComponents(wallet: wallet) + cbfComponents = components + return components + } + + return Self( + sync: { request, _ in + let components = try getOrCreateComponents() + let upd = try await components.client.update() + return upd + }, + fullScan: { request, stopGap, _ in + let components = try getOrCreateComponents() + let upd = try await components.client.update() + return upd + }, + broadcast: { tx in + let components = try getOrCreateComponents() + try components.client.broadcast(transaction: tx) + }, + getUrl: { peer }, + getType: { .kyoto } + ) + } } private class BDKService { @@ -53,7 +92,7 @@ private class BDKService { private var needsFullScan: Bool = false private(set) var network: Network private var blockchainURL: String - private var wallet: Wallet? + internal private(set) var wallet: Wallet? init(keyClient: KeyClient = .live) { self.keyClient = keyClient @@ -63,7 +102,16 @@ private class BDKService { let storedClientType = try? keyClient.getClientType() self.clientType = storedClientType ?? .esplora - self.blockchainURL = (try? keyClient.getEsploraURL()) ?? self.network.url + // No init-time coercion; backend selection handles constraints + + if self.clientType == .kyoto { + self.blockchainURL = Constants.Config.Kyoto.getDefaultPeer(for: self.network) + } else { + self.blockchainURL = (try? keyClient.getEsploraURL()) ?? "" + if self.blockchainURL.isEmpty { + self.blockchainURL = self.network.url + } + } self.blockchainClient = BlockchainClient.esplora(url: self.blockchainURL) updateBlockchainClient() } @@ -77,32 +125,46 @@ private class BDKService { self.network = newNetwork try? keyClient.saveNetwork(newNetwork.description) - let newURL = newNetwork.url - updateBlockchainURL(newURL) + // Only update URL for Esplora clients, Kyoto uses peer addresses + if self.clientType == .esplora { + let newURL = newNetwork.url + updateBlockchainURL(newURL) + } } } func updateBlockchainURL(_ newURL: String) { - if newURL != self.blockchainURL { - self.blockchainURL = newURL - try? keyClient.saveEsploraURL(newURL) // TODO: Future - saveURL(newURL, for: clientType) - updateBlockchainClient() - } + if newURL == self.blockchainURL { return } + self.blockchainURL = newURL + try? keyClient.saveEsploraURL(newURL) + updateBlockchainClient() } internal func updateBlockchainClient() { do { switch clientType { case .esplora: + // Cancel any Kyoto background tasks when switching to Esplora + CbfClient.cancelAllMonitoring() self.blockchainClient = .esplora(url: self.blockchainURL) case .kyoto: - throw WalletError.backendNotImplemented + if self.network != .signet { + self.clientType = .esplora + CbfClient.cancelAllMonitoring() + self.blockchainClient = .esplora(url: self.blockchainURL) + } else { + let peer = + self.blockchainURL.isEmpty + ? Constants.Config.Kyoto.getDefaultPeer(for: self.network) + : self.blockchainURL + self.blockchainClient = .kyoto(peer: peer) + } case .electrum: throw WalletError.backendNotImplemented } } catch { - // Fallback to esplora if selected backend not implemented self.clientType = .esplora + CbfClient.cancelAllMonitoring() self.blockchainClient = .esplora(url: self.blockchainURL) } } @@ -227,8 +289,13 @@ private class BDKService { throw WalletError.dbNotFound } - let savedURL = try? keyClient.getEsploraURL() - let baseUrl = savedURL ?? network.url + let baseUrl: String + if self.clientType == .kyoto { + baseUrl = Constants.Config.Kyoto.getDefaultPeer(for: network) + } else { + let savedURL = try? keyClient.getEsploraURL() + baseUrl = savedURL ?? network.url + } var words12: String if let words = words, !words.isEmpty { @@ -335,9 +402,13 @@ private class BDKService { throw WalletError.dbNotFound } - let savedURL = try? keyClient.getEsploraURL() - - let baseUrl = savedURL ?? network.url + let baseUrl: String + if self.clientType == .kyoto { + baseUrl = Constants.Config.Kyoto.getDefaultPeer(for: network) + } else { + let savedURL = try? keyClient.getEsploraURL() + baseUrl = savedURL ?? network.url + } guard let xpubString = xpub, !xpubString.isEmpty else { throw WalletError.walletNotFound @@ -434,20 +505,14 @@ private class BDKService { } 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() try Persister.deleteConnection() - if let savedURL = savedURL { - try keyClient.saveEsploraURL(savedURL) - } - if let savedNetwork = savedNetwork { - try keyClient.saveNetwork(savedNetwork) - } + // Clear persisted network and esplora URL to avoid cross-network carryover + try? keyClient.deleteNetwork() + try? keyClient.deleteEsplora() needsFullScan = true } @@ -467,7 +532,7 @@ private class BDKService { amount: amount, feeRate: feeRate ) - try signAndBroadcast(psbt: psbt) + try await signAndBroadcast(psbt: psbt) } func buildTransaction(address: String, amount: UInt64, feeRate: UInt64) throws @@ -486,7 +551,7 @@ private class BDKService { return txBuilder } - private func signAndBroadcast(psbt: Psbt) throws { + 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 { @@ -502,7 +567,7 @@ private class BDKService { let syncRequest = try wallet.startSyncWithRevealedSpks() .inspectSpks(inspector: inspector) .build() - let update = try self.blockchainClient.sync( + let update = try await self.blockchainClient.sync( syncRequest, UInt64(5) ) @@ -521,7 +586,7 @@ private class BDKService { let fullScanRequest = try wallet.startFullScan() .inspectSpksForAllKeychains(inspector: inspector) .build() - let update = try self.blockchainClient.fullScan( + let update = try await self.blockchainClient.fullScan( fullScanRequest, // using https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#address-gap-limit UInt64(20), @@ -594,6 +659,23 @@ extension BDKService { func updateClientType(_ newType: BlockchainClientType) { self.clientType = newType try? keyClient.saveClientType(newType) + + // Update URL to match the new client type + if newType == .kyoto { + // Force Signet network for Kyoto and persist the corrected network + if self.network != .signet { + self.network = .signet + try? keyClient.saveNetwork(Network.signet.description) + } + self.blockchainURL = Constants.Config.Kyoto.getDefaultPeer(for: .signet) + } else if newType == .esplora { + // Keep existing URL if it's valid for this network, otherwise use default + let defaultEsploraURL = self.network.url + if self.blockchainURL.isEmpty || self.blockchainURL.starts(with: "127.0.0.1") { + self.blockchainURL = defaultEsploraURL + } + } + updateBlockchainClient() } @@ -753,7 +835,7 @@ extension BDKClient { needsFullScan: { true }, setNeedsFullScan: { _ in }, getNetwork: { .signet }, - getEsploraURL: { Constants.Config.EsploraServerURLNetwork.Signet.mutiny }, + getEsploraURL: { Constants.Networks.Signet.Mutiny.esploraServers.first ?? "" }, updateNetwork: { _ in }, updateEsploraURL: { _ in }, getAddressType: { .bip86 }, diff --git a/BDKSwiftExampleWallet/Utilities/Constants.swift b/BDKSwiftExampleWallet/Utilities/Constants.swift index 173014c7..fba90c5f 100644 --- a/BDKSwiftExampleWallet/Utilities/Constants.swift +++ b/BDKSwiftExampleWallet/Utilities/Constants.swift @@ -10,43 +10,152 @@ import Foundation import SwiftUI struct Constants { - struct Config { - struct EsploraServerURLNetwork { - struct Bitcoin { - private static let blockstream = "https://blockstream.info/api" - private static let mempoolspace = "https://mempool.space/api" - static let allValues = [ - mempoolspace, - blockstream, - ] + struct Networks { + struct Bitcoin { + static let esploraServers = [ + "https://mempool.space/api", + "https://blockstream.info/api", + ] + } + + struct Testnet { + static let esploraServers = [ + "https://mempool.space/testnet/api/", + "https://blockstream.info/testnet/api/", + ] + } + + struct Testnet4 { + static let esploraServers = [ + "https://mempool.space/testnet4/api/" + ] + + enum Faucet: String, CaseIterable { + case mempool = "https://mempool.space/testnet4/faucet" + + var url: URL? { URL(string: self.rawValue) } + + var displayName: String { + switch self { + case .mempool: return "Mempool Faucet" + } + } } - struct Regtest { - private static let local = "http://127.0.0.1:3002" - static let allValues = [ - local + } + + struct Regtest { + static let esploraServers = [ + "http://127.0.0.1:3002" + ] + } + + struct Signet { + struct Regular { + static let esploraServers = [ + "https://mempool.space/signet/api" ] - } - struct Signet { - static let bdk = "http://signet.bitcoindevkit.net" - static let mutiny = "https://mutinynet.com/api" - static let allValues = [ - mutiny, - bdk, + + enum Faucet: String, CaseIterable { + case bublina = "https://signet25.bublina.eu.org/" + case signetfaucet = "https://signetfaucet.com" + + var url: URL? { URL(string: self.rawValue) } + + var displayName: String { + switch self { + case .bublina: return "Bublina Faucet" + case .signetfaucet: return "Signet Faucet" + } + } + } + + static let kyotoPeerStrings = [ + "seed.signet.bitcoin.sprovoost.nl:38333", + "signet-seed.achownodes.xyz:38333", ] - } - struct Testnet { - static let blockstream = "https://blockstream.info/testnet/api/" - static let mempoolspace = "https://mempool.space/testnet/api/" - static let allValues = [ - mempoolspace, - blockstream, + + static let kyotoPeers = [ + // seed.signet.bitcoin.sprovoost.nl:38333 -> 45.79.52.207:38333 + Peer( + address: IpAddress.fromIpv4(q1: 45, q2: 79, q3: 52, q4: 207), + port: 38333, + v2Transport: false + ), + // signet-seed.achownodes.xyz:38333 -> 192.3.169.35:38333 + Peer( + address: IpAddress.fromIpv4(q1: 192, q2: 3, q3: 169, q4: 35), + port: 38333, + v2Transport: false + ), ] } - struct Testnet4 { - static let mempoolspace = "https://mempool.space/testnet4/api/" - static let allValues = [ - mempoolspace + + struct Mutiny { + static let esploraServers = [ + "https://mutinynet.com/api" ] + + enum Faucet: String, CaseIterable { + case mutiny = "https://faucet.mutinynet.com" + + var url: URL? { URL(string: self.rawValue) } + + var displayName: String { + switch self { + case .mutiny: return "Mutiny Faucet" + } + } + } + } + + // Convenience computed properties for backward compatibility + static var allEsploraServers: [String] { + Mutiny.esploraServers + Regular.esploraServers + } + } + } + + struct Config { + struct Kyoto { + static let dbDirectoryName = "kyoto" + + static var dbDirectoryURL: URL { + URL.walletDataDirectoryURL.appendingPathComponent(dbDirectoryName) + } + + static var dbPath: String { + dbDirectoryURL.path + } + + static func getDefaultPeer(for network: Network) -> String { + switch network { + case .signet: + return Networks.Signet.Regular.kyotoPeerStrings.first + ?? "seed.signet.bitcoin.sprovoost.nl:38333" + default: + // Kyoto only supports Signet for now + return Networks.Signet.Regular.kyotoPeerStrings.first + ?? "seed.signet.bitcoin.sprovoost.nl:38333" + } + } + } + + enum SignetNetwork { + case regular + case custom + + var defaultFaucet: URL? { + switch self { + case .regular: + return Networks.Signet.Regular.Faucet.bublina.url + case .custom: + return Networks.Signet.Mutiny.Faucet.mutiny.url + } + } + + static func from(esploraURL: String) -> SignetNetwork { + return Networks.Signet.Mutiny.esploraServers.contains(esploraURL) + ? .custom : .regular } } } @@ -82,15 +191,15 @@ extension Network { var url: String { switch self { case .bitcoin: - Constants.Config.EsploraServerURLNetwork.Bitcoin.allValues.first ?? "" + Constants.Networks.Bitcoin.esploraServers.first ?? "" case .testnet: - Constants.Config.EsploraServerURLNetwork.Testnet.allValues.first ?? "" + Constants.Networks.Testnet.esploraServers.first ?? "" case .signet: - Constants.Config.EsploraServerURLNetwork.Signet.allValues.first ?? "" + Constants.Networks.Signet.allEsploraServers.first ?? "" case .regtest: - Constants.Config.EsploraServerURLNetwork.Regtest.allValues.first ?? "" + Constants.Networks.Regtest.esploraServers.first ?? "" case .testnet4: - Constants.Config.EsploraServerURLNetwork.Testnet4.allValues.first ?? "" + Constants.Networks.Testnet4.esploraServers.first ?? "" } } } diff --git a/BDKSwiftExampleWallet/View Model/Activity/ActivityListViewModel.swift b/BDKSwiftExampleWallet/View Model/Activity/ActivityListViewModel.swift index 5519e3d3..6a6c7195 100644 --- a/BDKSwiftExampleWallet/View Model/Activity/ActivityListViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/Activity/ActivityListViewModel.swift @@ -46,6 +46,19 @@ class ActivityListViewModel { self.bdkClient = bdkClient self.transactions = transactions self.walletSyncState = walletSyncState + + // Preload cached data synchronously so UI has content before first render + // transactions + listUnspent items are available from the persisted wallet db + if self.transactions.isEmpty { + if let cached = try? bdkClient.transactions() { + self.transactions = cached + } + } + if self.localOutputs.isEmpty { + if let cachedUtxos = try? bdkClient.listUnspent() { + self.localOutputs = cachedUtxos + } + } } func getTransactions() { @@ -98,6 +111,7 @@ class ActivityListViewModel { } func syncOrFullScan() async { + self.walletSyncState = .syncing await startSyncWithProgress() } } diff --git a/BDKSwiftExampleWallet/View Model/Activity/TransactionDetailViewModel.swift b/BDKSwiftExampleWallet/View Model/Activity/TransactionDetailViewModel.swift index 76b88893..c3c42059 100644 --- a/BDKSwiftExampleWallet/View Model/Activity/TransactionDetailViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/Activity/TransactionDetailViewModel.swift @@ -31,13 +31,13 @@ class TransactionDetailViewModel { switch network { case "signet": - if savedEsploraURL == Constants.Config.EsploraServerURLNetwork.Signet.bdk { + if savedEsploraURL == Constants.Networks.Signet.Regular.esploraServers.first { self.esploraURL = "https://mempool.space/signet" } else { self.esploraURL = "https://mutinynet.com" } case "testnet": - if savedEsploraURL == Constants.Config.EsploraServerURLNetwork.Testnet.blockstream { + if savedEsploraURL == Constants.Networks.Testnet.esploraServers.last { self.esploraURL = "https://blockstream.info/testnet" } else { self.esploraURL = "https://mempool.space/testnet" diff --git a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift index 907edfc1..9ac30db2 100644 --- a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift @@ -9,7 +9,6 @@ import BitcoinDevKit import Foundation import SwiftUI - // Can't make @Observable yet // https://developer.apple.com/forums/thread/731187 // Feature or Bug? @@ -30,21 +29,48 @@ class OnboardingViewModel: ObservableObject { @Published var onboardingViewError: AppError? @Published var selectedNetwork: Network = .signet { didSet { + guard !isInitializing else { return } bdkClient.updateNetwork(selectedNetwork) - selectedURL = availableURLs.first ?? "" - bdkClient.updateEsploraURL(selectedURL) + // If switching away from Signet and Kyoto is selected, switch to Esplora + if selectedNetwork != .signet && selectedClientType == .kyoto { + selectedClientType = .esplora + } + if selectedClientType == .esplora { + selectedURL = availableURLs.first ?? "" + } else if selectedClientType == .kyoto { + // Set to a valid Esplora URL to avoid picker warnings, even though Kyoto won't use it + selectedURL = availableURLs.first ?? "" + } } } @Published var selectedURL: String = "" { didSet { - bdkClient.updateEsploraURL(selectedURL) + guard !isInitializing else { return } + // Only update Esplora URL for Esplora clients + if selectedClientType == .esplora { + bdkClient.updateEsploraURL(selectedURL) + } } } @Published var selectedAddressType: AddressType = .bip86 { didSet { + guard !isInitializing else { return } bdkClient.updateAddressType(selectedAddressType) } } + @Published var selectedClientType: BlockchainClientType = .esplora { + didSet { + guard !isInitializing else { return } + bdkClient.updateClientType(selectedClientType) + // When switching client types, update URL appropriately + if selectedClientType == .kyoto { + // Set to a valid Esplora URL to avoid picker warnings, even though Kyoto won't use it + selectedURL = availableURLs.first ?? "" + } else if selectedClientType == .esplora { + selectedURL = availableURLs.first ?? "" + } + } + } @Published var words: String = "" var wordArray: [String] { if words.hasPrefix("xpub") || words.hasPrefix("tpub") || words.hasPrefix("vpub") { @@ -56,15 +82,15 @@ class OnboardingViewModel: ObservableObject { var availableURLs: [String] { switch selectedNetwork { case .bitcoin: - return Constants.Config.EsploraServerURLNetwork.Bitcoin.allValues + return Constants.Networks.Bitcoin.esploraServers case .testnet: - return Constants.Config.EsploraServerURLNetwork.Testnet.allValues + return Constants.Networks.Testnet.esploraServers case .regtest: - return Constants.Config.EsploraServerURLNetwork.Regtest.allValues + return Constants.Networks.Regtest.esploraServers case .signet: - return Constants.Config.EsploraServerURLNetwork.Signet.allValues + return Constants.Networks.Signet.allEsploraServers case .testnet4: - return Constants.Config.EsploraServerURLNetwork.Testnet4.allValues + return Constants.Networks.Testnet4.esploraServers } } var buttonColor: Color { @@ -82,13 +108,22 @@ class OnboardingViewModel: ObservableObject { } } + private var isInitializing = true + init( bdkClient: BDKClient = .live ) { self.bdkClient = bdkClient + + // Set properties during initialization to avoid didSet side effects self.selectedNetwork = bdkClient.getNetwork() - self.selectedURL = bdkClient.getEsploraURL() self.selectedAddressType = bdkClient.getAddressType() + self.selectedClientType = bdkClient.getClientType() + + // Always set to Esplora URL for UI consistency (Kyoto will use peer internally) + self.selectedURL = bdkClient.getEsploraURL() + + isInitializing = false } func createWallet() { diff --git a/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift b/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift index 7446a082..98615666 100644 --- a/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/Settings/SettingsViewModel.swift @@ -35,11 +35,20 @@ class SettingsViewModel: ObservableObject { ) { self.bdkClient = bdkClient self.network = bdkClient.getNetwork().description - self.esploraURL = bdkClient.getEsploraURL() + self.addressType = bdkClient.getAddressType() + + let clientType = bdkClient.getClientType() + if clientType == .kyoto { + self.esploraURL = "Kyoto (P2P)" + } else { + self.esploraURL = bdkClient.getEsploraURL() + } } func getAddressType() { - self.addressType = bdkClient.getAddressType() + DispatchQueue.main.async { + self.addressType = self.bdkClient.getAddressType() + } } func delete() { @@ -53,9 +62,7 @@ class SettingsViewModel: ObservableObject { } func fullScanWithProgress() async { - DispatchQueue.main.async { - self.walletSyncState = .syncing - } + self.walletSyncState = .syncing do { let inspector = WalletFullScanScriptInspector(updateProgress: updateProgressFullScan) try await bdkClient.fullScanWithInspector(inspector) @@ -94,6 +101,11 @@ class SettingsViewModel: ObservableObject { } func getEsploraUrl() { - self.esploraURL = bdkClient.getEsploraURL() + let clientType = bdkClient.getClientType() + if clientType == .kyoto { + self.esploraURL = "Kyoto (P2P)" + } else { + self.esploraURL = bdkClient.getEsploraURL() + } } } diff --git a/BDKSwiftExampleWallet/View Model/Settings/WalletRecoveryViewModel.swift b/BDKSwiftExampleWallet/View Model/Settings/WalletRecoveryViewModel.swift index 0d613d2b..6a799a0f 100644 --- a/BDKSwiftExampleWallet/View Model/Settings/WalletRecoveryViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/Settings/WalletRecoveryViewModel.swift @@ -34,7 +34,8 @@ class WalletRecoveryViewModel { func getNetwork() -> Network { let savedNetwork = bdkClient.getNetwork() - return savedNetwork + let clientType = bdkClient.getClientType() + return clientType == .kyoto ? .signet : savedNetwork } func getBackupInfo(network: Network) { diff --git a/BDKSwiftExampleWallet/View Model/WalletViewModel.swift b/BDKSwiftExampleWallet/View Model/WalletViewModel.swift index d0e3a42d..be5e2399 100644 --- a/BDKSwiftExampleWallet/View Model/WalletViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/WalletViewModel.swift @@ -42,13 +42,32 @@ class WalletViewModel { var needsFullScan: Bool { bdkClient.needsFullScan() } + var isKyotoClient: Bool { + bdkClient.getClientType() == .kyoto + } + var isKyotoConnected: Bool = false + var currentBlockHeight: UInt32 = 0 private var updateProgress: @Sendable (UInt64, UInt64) -> Void { { [weak self] inspected, total in DispatchQueue.main.async { + // When using Kyoto, progress is provided separately as percent + if self?.isKyotoClient == true { return } self?.totalScripts = total self?.inspectedScripts = inspected - self?.progress = total > 0 ? Float(inspected) / Float(total) : 0 + let fraction = total > 0 ? Float(inspected) / Float(total) : 0 + self?.progress = fraction + } + } + } + + private var updateKyotoProgress: @Sendable (Float) -> Void { + { [weak self] progress in + DispatchQueue.main.async { + self?.progress = progress + let progressPercent = UInt64(progress) + self?.inspectedScripts = progressPercent + self?.totalScripts = 100 } } } @@ -73,6 +92,74 @@ class WalletViewModel { self.priceClient = priceClient self.transactions = transactions self.walletSyncState = walletSyncState + + NotificationCenter.default.addObserver( + forName: NSNotification.Name("KyotoProgressUpdate"), + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + // Ignore Kyoto updates unless client type is Kyoto + if self.bdkClient.getClientType() != .kyoto { return } + if let progress = notification.userInfo?["progress"] as? Float { + self.updateKyotoProgress(progress) + // Consider any progress update as evidence of an active connection + // so the UI does not falsely show a red disconnected indicator while syncing. + if progress > 0 { + self.isKyotoConnected = true + } + + // Update sync state based on Kyoto progress + if progress >= 100 { + self.walletSyncState = .synced + } else if progress > 0 { + self.walletSyncState = .syncing + } + } + } + + NotificationCenter.default.addObserver( + forName: NSNotification.Name("KyotoConnectionUpdate"), + object: nil, + queue: .main + ) { [weak self] notification in + if let connected = notification.userInfo?["connected"] as? Bool { + self?.isKyotoConnected = connected + + // When Kyoto connects, update sync state if needed + if connected && self?.walletSyncState == .notStarted { + // Check current progress to determine state + if let progress = self?.progress, progress >= 100 { + self?.walletSyncState = .synced + } else { + self?.walletSyncState = .syncing + } + } + } + } + + NotificationCenter.default.addObserver( + forName: NSNotification.Name("KyotoChainHeightUpdate"), + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + // Ignore Kyoto updates unless client type is Kyoto + if self.bdkClient.getClientType() != .kyoto { return } + if let height = notification.userInfo?["height"] as? UInt32 { + self.currentBlockHeight = height + // Receiving chain height implies we have peer connectivity + self.isKyotoConnected = true + // Ensure UI reflects syncing as soon as we see chain activity + if self.walletSyncState == .notStarted { self.walletSyncState = .syncing } + // Auto-refresh wallet data when Kyoto receives new blocks + self.getBalance() + self.getTransactions() + Task { + await self.getPrices() + } + } + } } private func fullScanWithProgress() async { @@ -158,6 +245,7 @@ class WalletViewModel { } func syncOrFullScan() async { + self.walletSyncState = .syncing if bdkClient.needsFullScan() { await fullScanWithProgress() bdkClient.setNeedsFullScan(false) diff --git a/BDKSwiftExampleWallet/View/Activity/TransactionListView.swift b/BDKSwiftExampleWallet/View/Activity/TransactionListView.swift index 96c48935..c32bab03 100644 --- a/BDKSwiftExampleWallet/View/Activity/TransactionListView.swift +++ b/BDKSwiftExampleWallet/View/Activity/TransactionListView.swift @@ -31,20 +31,17 @@ struct TransactionListView: View { Text("No Transactions") .font(.subheadline) - let mutinyFaucetURL = URL(string: "https://faucet.mutinynet.com") - let signetFaucetURL = URL(string: "https://signetfaucet.com") + let signetNetwork = Constants.Config.SignetNetwork.from( + esploraURL: viewModel.getEsploraURL() + ) - if let mutinyFaucetURL, - let signetFaucetURL, - viewModel.getNetwork() != Network.testnet.description - && viewModel.getNetwork() != Network.testnet4.description + if viewModel.getNetwork() != Network.testnet.description + && viewModel.getNetwork() != Network.testnet4.description { Button { - UIApplication.shared.open( - viewModel.getEsploraURL() - == Constants.Config.EsploraServerURLNetwork.Signet.mutiny - ? mutinyFaucetURL : signetFaucetURL - ) + if let faucetURL = signetNetwork.defaultFaucet { + UIApplication.shared.open(faucetURL) + } } label: { HStack(spacing: 2) { Text("Get sats from faucet") @@ -57,7 +54,7 @@ struct TransactionListView: View { .buttonStyle(.plain) } - let testnet4FaucetURL = URL(string: "https://mempool.space/testnet4/faucet") + let testnet4FaucetURL = Constants.Networks.Testnet4.Faucet.mempool.url if let testnet4FaucetURL, viewModel.getNetwork() == Network.testnet4.description diff --git a/BDKSwiftExampleWallet/View/Home/ActivityHomeHeaderView.swift b/BDKSwiftExampleWallet/View/Home/ActivityHomeHeaderView.swift index 2da5b4f8..0f9faa6c 100644 --- a/BDKSwiftExampleWallet/View/Home/ActivityHomeHeaderView.swift +++ b/BDKSwiftExampleWallet/View/Home/ActivityHomeHeaderView.swift @@ -14,6 +14,9 @@ struct ActivityHomeHeaderView: View { let inspectedScripts: UInt64 let totalScripts: UInt64 let needsFullScan: Bool + let isKyotoClient: Bool + let isKyotoConnected: Bool + let currentBlockHeight: UInt32 let showAllTransactions: () -> Void @@ -23,7 +26,7 @@ struct ActivityHomeHeaderView: View { Spacer() HStack { - if needsFullScan { + if needsFullScan && !isKyotoClient { Text("\(inspectedScripts)") .padding(.trailing, -5.0) .fontWeight(.semibold) @@ -36,7 +39,23 @@ struct ActivityHomeHeaderView: View { .animation(.easeInOut, value: inspectedScripts) } else if walletSyncState == .syncing { HStack { - if progress < 1.0 { + if isKyotoClient { + if progress < 100.0 { // Kyoto progress is percent + if currentBlockHeight > 0 { + Text("Block \(currentBlockHeight)") + .padding(.trailing, -5.0) + .fontWeight(.semibold) + .contentTransition(.numericText()) + .transition(.opacity) + } else { + Text("Syncing") + .padding(.trailing, -5.0) + .fontWeight(.semibold) + .contentTransition(.numericText()) + .transition(.opacity) + } + } + } else if progress < 1.0 { // Esplora progress is fraction Text("\(inspectedScripts)") .padding(.trailing, -5.0) .fontWeight(.semibold) @@ -51,14 +70,18 @@ struct ActivityHomeHeaderView: View { .transition(.opacity) } - Text( - String( - format: "%.0f%%", - progress * 100 - ) - ) - .contentTransition(.numericText()) - .transition(.opacity) + if !isKyotoClient || (isKyotoClient && progress > 0) { + let percent: Int = + isKyotoClient + ? Int(progress.rounded()) + : Int((progress * 100).rounded()) + HStack(spacing: 0) { + Text("\(percent)") + .contentTransition(.numericText()) + Text("%") + } + .transition(.opacity) + } } .fontDesign(.monospaced) .foregroundStyle(.secondary) @@ -67,11 +90,24 @@ struct ActivityHomeHeaderView: View { .animation(.easeInOut, value: inspectedScripts) .animation(.easeInOut, value: totalScripts) .animation(.easeInOut, value: progress) + } else if walletSyncState == .synced && isKyotoClient && currentBlockHeight > 0 { + Text("Block \(currentBlockHeight)") + .padding(.trailing, -5.0) + .fontWeight(.semibold) + .contentTransition(.numericText()) + .transition(.opacity) + .fontDesign(.monospaced) + .foregroundStyle(.secondary) + .font(.caption2) + .fontWeight(.thin) } } HStack { HStack(spacing: 5) { self.syncImageIndicator() + if isKyotoClient { + self.networkConnectionIndicator() + } } .contentTransition(.symbolEffect(.replace.offUp)) @@ -100,18 +136,31 @@ struct ActivityHomeHeaderView: View { private func syncImageIndicator() -> some View { switch walletSyncState { case .synced: - AnyView( - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - ) + if !isKyotoClient { + AnyView( + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + ) + } else { + AnyView(EmptyView()) + } case .syncing: - AnyView( - Image(systemName: "slowmo") - .symbolEffect( - .variableColor.cumulative - ) - ) + if isKyotoClient && progress > 0 { + AnyView( + ProgressView(value: Double(progress), total: 100) + .foregroundStyle(.green) + .frame(width: 50) + .animation(.interactiveSpring, value: progress) + ) + } else { + AnyView( + Image(systemName: "slowmo") + .symbolEffect( + .variableColor.cumulative + ) + ) + } case .notStarted: AnyView( @@ -125,4 +174,27 @@ struct ActivityHomeHeaderView: View { ) } } + + @ViewBuilder + private func networkConnectionIndicator() -> some View { + // Tri-state indicator for Kyoto peer connectivity + // - Green: actively connected OR showing sync activity + // - Gray (secondary): synced but not currently connected + // - Red: not synced, no activity, and not connected + let isFullySynced = walletSyncState == .synced + let hasSyncActivity = (progress > 0) || (currentBlockHeight > 0) + + if isFullySynced { + AnyView( + Image(systemName: "network") + .foregroundStyle(isKyotoConnected ? .green : .secondary) + ) + } else { + let ok = isKyotoConnected || hasSyncActivity + AnyView( + Image(systemName: ok ? "network" : "network.slash") + .foregroundStyle(ok ? .green : .red) + ) + } + } } diff --git a/BDKSwiftExampleWallet/View/OnboardingView.swift b/BDKSwiftExampleWallet/View/OnboardingView.swift index 90d875e7..d4d49909 100644 --- a/BDKSwiftExampleWallet/View/OnboardingView.swift +++ b/BDKSwiftExampleWallet/View/OnboardingView.swift @@ -122,25 +122,39 @@ 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("Client", selection: $viewModel.selectedClientType) { + Text("Esplora").tag(BlockchainClientType.esplora) + if viewModel.selectedNetwork == .signet { + Text("Kyoto").tag(BlockchainClientType.kyoto) } } .pickerStyle(.automatic) .tint(.primary) + .accessibilityLabel("Select Client Type") .opacity(animateContent ? 1 : 0) .animation(.easeOut(duration: 0.5).delay(1.5), value: animateContent) + if viewModel.selectedClientType == .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) + } + Picker("Address Type", selection: $viewModel.selectedAddressType) { ForEach(AddressType.allCases, id: \.self) { type in Text(type.displayName).tag(type) diff --git a/BDKSwiftExampleWallet/View/Settings/SettingsView.swift b/BDKSwiftExampleWallet/View/Settings/SettingsView.swift index e6fdb707..1f4649ee 100644 --- a/BDKSwiftExampleWallet/View/Settings/SettingsView.swift +++ b/BDKSwiftExampleWallet/View/Settings/SettingsView.swift @@ -18,6 +18,9 @@ struct SettingsView: View { var isSmallDevice: Bool { UIScreen.main.isPhoneSE } + private var isKyotoClient: Bool { + viewModel.bdkClient.getClientType() == .kyoto + } var body: some View { @@ -65,25 +68,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 !isKyotoClient { + 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 { @@ -118,7 +123,6 @@ struct SettingsView: View { .onAppear { viewModel.getNetwork() viewModel.getEsploraUrl() - viewModel.getAddressType() } .padding(.top, 40.0) diff --git a/BDKSwiftExampleWallet/View/WalletView.swift b/BDKSwiftExampleWallet/View/WalletView.swift index 010a6223..695a00c9 100644 --- a/BDKSwiftExampleWallet/View/WalletView.swift +++ b/BDKSwiftExampleWallet/View/WalletView.swift @@ -11,6 +11,7 @@ import SwiftUI struct WalletView: View { @AppStorage("balanceDisplayFormat") private var balanceFormat: BalanceDisplayFormat = .bitcoinSats + @AppStorage("KyotoLastBlockHeight") private var kyotoLastHeight: Int = 0 @Bindable var viewModel: WalletViewModel @Binding var sendNavigationPath: NavigationPath @State private var isFirstAppear = true @@ -47,7 +48,10 @@ struct WalletView: View { progress: viewModel.progress, inspectedScripts: viewModel.inspectedScripts, totalScripts: viewModel.totalScripts, - needsFullScan: viewModel.needsFullScan + needsFullScan: viewModel.needsFullScan, + isKyotoClient: viewModel.isKyotoClient, + isKyotoConnected: viewModel.isKyotoConnected, + currentBlockHeight: viewModel.currentBlockHeight ) { showAllTransactions = true } @@ -58,10 +62,16 @@ struct WalletView: View { walletSyncState: viewModel.walletSyncState ) .refreshable { - await viewModel.syncOrFullScan() - viewModel.getBalance() - viewModel.getTransactions() - await viewModel.getPrices() + if viewModel.isKyotoClient { + viewModel.getBalance() + viewModel.getTransactions() + await viewModel.getPrices() + } else { + await viewModel.syncOrFullScan() + viewModel.getBalance() + viewModel.getTransactions() + await viewModel.getPrices() + } } HStack { @@ -100,6 +110,11 @@ struct WalletView: View { ), perform: { _ in Task { + // Show cached state first + viewModel.getBalance() + viewModel.getTransactions() + + // Then sync and refresh await viewModel.syncOrFullScan() viewModel.getBalance() viewModel.getTransactions() @@ -109,15 +124,30 @@ struct WalletView: View { ) .task { viewModel.getBalance() + viewModel.getTransactions() if isFirstAppear || newTransactionSent { await viewModel.syncOrFullScan() isFirstAppear = false newTransactionSent = false viewModel.getBalance() + viewModel.getTransactions() } - viewModel.getTransactions() await viewModel.getPrices() } + .onAppear { + // Seed height from AppStorage on first show to avoid displaying 0 when Kyoto is active + if viewModel.isKyotoClient, + viewModel.currentBlockHeight == 0, + kyotoLastHeight > 0 + { + viewModel.currentBlockHeight = UInt32(kyotoLastHeight) + } + } + .onChange(of: viewModel.currentBlockHeight) { _, newValue in + if newValue > 0 { + kyotoLastHeight = Int(newValue) + } + } } .navigationDestination(isPresented: $showAllTransactions) {