From 06b4c8b6b1f92a1843c00717532a9e359837aee7 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 3 Nov 2025 15:38:22 -0600 Subject: [PATCH 1/6] chore: bump dash-spv-ffi to v0.41-dev branch --- packages/rs-sdk-ffi/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index 12c06fb370..c82bc92d89 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -22,7 +22,7 @@ rs-sdk-trusted-context-provider = { path = "../rs-sdk-trusted-context-provider", simple-signer = { path = "../simple-signer" } # Core SDK integration (always included for unified SDK) -dash-spv-ffi = { git = "https://github.com/dashpay/rust-dashcore", tag = "v0.40.0", optional = true } +dash-spv-ffi = { git = "https://github.com/dashpay/rust-dashcore", branch = "v0.41-dev", optional = true } # FFI and serialization serde = { version = "1.0", features = ["derive"] } From fe4761d84721fb736fa01fa05901d8eec34baa96 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 6 Nov 2025 11:40:13 -0600 Subject: [PATCH 2/6] swift-sdk: Prevent main-thread stalls during filter sync - Add walletId to SPVTransactionEvent; plumb through wallet-specific FFI callback - Introduce WalletSyncScheduler actor to debounce and coalesce per-wallet syncs - Remove per-block full sync; target wallet sync on tx events and periodic blocks - Reduce MainActor work by batching UI updates after sync - Fix actor isolation in scheduler Task (no optional self property access) --- .../Sources/SwiftDashSDK/SPV/SPVClient.swift | 376 +++++++++++------- .../Core/Services/WalletService.swift | 245 +++++++----- .../Core/Services/WalletSyncScheduler.swift | 51 +++ 3 files changed, 428 insertions(+), 244 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/WalletSyncScheduler.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SPV/SPVClient.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SPV/SPVClient.swift index 8e20b23275..a06d702e96 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SPV/SPVClient.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SPV/SPVClient.swift @@ -28,6 +28,63 @@ extension SPVClient { // MARK: - C Callback Functions // Use top-level C-compatible functions to avoid actor-isolation init issues +// Throttle progress notifications to avoid flooding the main thread. +// Keep the latest progress update instead of dropping them, and dispatch at a controlled rate. +actor SPVProgressDispatcher { + static let shared = SPVProgressDispatcher() + private var lastDispatchAt: TimeInterval = 0 + private let interval: TimeInterval = 1.0 // seconds - increased further to reduce computation frequency + private var pendingUpdate: (userDataPtr: UInt, snapshot: FFIDetailedSyncProgress)? + private var dispatchTask: Task? + + func enqueue(userDataPtr: UInt, snapshot: FFIDetailedSyncProgress) { + // Always keep the latest update + pendingUpdate = (userDataPtr, snapshot) + + // Start dispatch task if not already running + if dispatchTask == nil { + dispatchTask = Task { await self.dispatchLoop() } + } + } + + private func dispatchLoop() async { + while true { + let now = Date().timeIntervalSince1970 + let timeSinceLastDispatch = now - lastDispatchAt + + // Process update if enough time has passed and we have one + if timeSinceLastDispatch >= interval, let update = pendingUpdate { + // Dispatch the latest update + lastDispatchAt = now + // Clear pending before dispatching to allow new updates to queue + pendingUpdate = nil + + Task { @MainActor in + guard let userData = UnsafeMutableRawPointer(bitPattern: update.userDataPtr) else { return } + let context = Unmanaged.fromOpaque(userData).takeUnretainedValue() + context.handleProgressUpdate(update.snapshot) + } + } + + // Check if we should continue + guard let _ = pendingUpdate else { + // No pending update - check one more time after a short delay + // to catch any updates that came in during the check + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms + if pendingUpdate == nil { + dispatchTask = nil + break + } + continue + } + + // Sleep for remaining time or short interval + let sleepDuration = max(0.05, interval - timeSinceLastDispatch) + try? await Task.sleep(nanoseconds: UInt64(sleepDuration * 1_000_000_000)) + } + } +} + private func spvProgressCallback( progressPtr: UnsafePointer?, userData: UnsafeMutableRawPointer? @@ -36,11 +93,7 @@ private func spvProgressCallback( let userData = userData else { return } let snapshot = progressPtr.pointee let ptrVal = UInt(bitPattern: userData) - DispatchQueue.main.async { - guard let userData = UnsafeMutableRawPointer(bitPattern: ptrVal) else { return } - let context = Unmanaged.fromOpaque(userData).takeUnretainedValue() - context.handleProgressUpdate(snapshot) - } + Task { await SPVProgressDispatcher.shared.enqueue(userDataPtr: ptrVal, snapshot: snapshot) } } private func spvCompletionCallback( @@ -119,7 +172,8 @@ private func onTransactionCallbackC( confirmed: confirmed, amount: amount, addresses: addresses, - blockHeight: blockHeight > 0 ? blockHeight : nil + blockHeight: blockHeight > 0 ? blockHeight : nil, + walletId: nil ) } } @@ -194,6 +248,7 @@ public struct SPVTransactionEvent { public let amount: Int64 public let addresses: [String] public let blockHeight: UInt32? + public let walletId: String? } // MARK: - SPV Client Delegate @@ -247,7 +302,7 @@ public class SPVClient: ObservableObject { internal var syncCancelled = false fileprivate var currentSyncStartTimestamp: Int64 = 0 fileprivate var lastProgressUIUpdate: TimeInterval = 0 - fileprivate let progressUICoalesceInterval: TimeInterval = 0.2 + fileprivate let progressUICoalesceInterval: TimeInterval = 0.5 // Increased further to reduce UI update frequency fileprivate let swiftLoggingEnabled: Bool = { if let env = ProcessInfo.processInfo.environment["SPV_SWIFT_LOG"], env.lowercased() == "1" || env.lowercased() == "true" { return true @@ -484,6 +539,7 @@ public class SPVClient: ObservableObject { self.isSyncing = false self.syncProgress = nil self.lastError = nil + self.blocksHit = 0 } /// Clear only the persisted sync-state snapshot while keeping headers/filters. @@ -689,7 +745,8 @@ public class SPVClient: ObservableObject { confirmed: false, amount: amount, addresses: addresses, - blockHeight: nil + blockHeight: nil, + walletId: nil ) } } @@ -712,7 +769,8 @@ public class SPVClient: ObservableObject { confirmed: true, amount: 0, addresses: [], - blockHeight: blockHeight + blockHeight: blockHeight, + walletId: nil ) } } @@ -723,7 +781,7 @@ public class SPVClient: ObservableObject { } // Wallet-specific transaction callback (fires for our wallet, including mempool) - callbacks.on_wallet_transaction = { _walletId, _accountIndex, txidPtr, confirmed, amount, addressesPtr, blockHeight, _isOurs, userData in + callbacks.on_wallet_transaction = { walletIdPtr, _accountIndex, txidPtr, confirmed, amount, addressesPtr, blockHeight, _isOurs, userData in guard let userData = userData else { return } let context = Unmanaged.fromOpaque(userData).takeUnretainedValue() @@ -738,6 +796,11 @@ public class SPVClient: ObservableObject { addresses = addressesStr.components(separatedBy: ",") } + var walletId: String? = nil + if let walletIdPtr = walletIdPtr { + walletId = String(cString: walletIdPtr) + } + let clientRef = context.client Task { @MainActor [weak clientRef] in clientRef?.handleTransactionEvent( @@ -745,7 +808,8 @@ public class SPVClient: ObservableObject { confirmed: confirmed, amount: amount, addresses: addresses, - blockHeight: blockHeight > 0 ? blockHeight : nil + blockHeight: blockHeight > 0 ? blockHeight : nil, + walletId: walletId ) } } @@ -840,13 +904,14 @@ public class SPVClient: ObservableObject { } } - fileprivate func handleTransactionEvent(txid: Data, confirmed: Bool, amount: Int64, addresses: [String], blockHeight: UInt32?) { + fileprivate func handleTransactionEvent(txid: Data, confirmed: Bool, amount: Int64, addresses: [String], blockHeight: UInt32?, walletId: String?) { let transaction = SPVTransactionEvent( txid: txid, confirmed: confirmed, amount: amount, addresses: addresses, - blockHeight: blockHeight + blockHeight: blockHeight, + walletId: walletId ) delegate?.spvClient(self, didReceiveTransaction: transaction) @@ -1022,160 +1087,175 @@ private class CallbackContext { func handleProgressUpdate(_ ffiProgress: FFIDetailedSyncProgress) { guard let client = self.client else { return } + // Extract data we need from MainActor-isolated properties let overview = ffiProgress.overview - client.peerCount = Int(overview.peer_count) - - var stage = SPVSyncStage(ffiStage: ffiProgress.stage) - let estimatedTime: TimeInterval? = (ffiProgress.estimated_seconds_remaining > 0) - ? TimeInterval(ffiProgress.estimated_seconds_remaining) - : nil - - let syncStartTimestamp = ffiProgress.sync_start_timestamp - var previous = client.syncProgress - if syncStartTimestamp > 0 { - if syncStartTimestamp != client.currentSyncStartTimestamp { - client.currentSyncStartTimestamp = syncStartTimestamp - previous = nil - } else { - client.currentSyncStartTimestamp = syncStartTimestamp + let startFromHeight = client.startFromHeight + let currentSyncStartTimestamp = client.currentSyncStartTimestamp + let swiftLoggingEnabled = client.swiftLoggingEnabled + + // Extract previous progress values (Sendable types only) + let previousProgress: SPVSyncProgress? = client.syncProgress + let previousStage = previousProgress?.stage + let previousHeaderProgress = previousProgress?.headerProgress ?? 0.0 + let previousMasternodeProgress = previousProgress?.masternodeProgress ?? 0.0 + let previousTransactionProgress = previousProgress?.transactionProgress ?? 0.0 + + // Do all heavy computation off MainActor + Task.detached(priority: .userInitiated) { + var stage = SPVSyncStage(ffiStage: ffiProgress.stage) + let estimatedTime: TimeInterval? = (ffiProgress.estimated_seconds_remaining > 0) + ? TimeInterval(ffiProgress.estimated_seconds_remaining) + : nil + + let syncStartTimestamp = ffiProgress.sync_start_timestamp + + if swiftLoggingEnabled { + let pct = max(0.0, min(ffiProgress.percentage, 100.0)) + let cur = overview.header_height + let tot = ffiProgress.total_height + let rate = ffiProgress.headers_per_second + let eta = ffiProgress.estimated_seconds_remaining + let filterHeaders = overview.filter_header_height + let filters = overview.last_synced_filter_height + print("[SPV][Progress] stage=\(stage.rawValue) header=\(cur)/\(tot) filterHeaders=\(filterHeaders) filters=\(filters) pct=\(pct) rate=\(rate) eta=\(eta)") } - } else if client.currentSyncStartTimestamp != 0 { - // Keep previous timestamp when FFI does not expose it - } - if client.swiftLoggingEnabled { - let pct = max(0.0, min(ffiProgress.percentage, 100.0)) - let cur = overview.header_height - let tot = ffiProgress.total_height - let rate = ffiProgress.headers_per_second - let eta = ffiProgress.estimated_seconds_remaining - let filterHeaders = overview.filter_header_height - let filters = overview.last_synced_filter_height - print("[SPV][Progress] stage=\(stage.rawValue) header=\(cur)/\(tot) filterHeaders=\(filterHeaders) filters=\(filters) pct=\(pct) rate=\(rate) eta=\(eta)") - } + let safeBase: UInt32 = (startFromHeight > ffiProgress.total_height) ? 0 : startFromHeight - let safeBase: UInt32 = (client.startFromHeight > ffiProgress.total_height) ? 0 : client.startFromHeight + let reportedHeader = overview.header_height + let reportedTarget = max(ffiProgress.total_height, reportedHeader) + let usesAbsolute = reportedHeader >= safeBase && reportedTarget >= safeBase - let reportedHeader = overview.header_height - let reportedTarget = max(ffiProgress.total_height, reportedHeader) - let usesAbsolute = reportedHeader >= safeBase && reportedTarget >= safeBase + let absoluteHeader: UInt32 = usesAbsolute ? max(reportedHeader, safeBase) : safeBase &+ reportedHeader + let absoluteTarget: UInt32 = usesAbsolute ? max(reportedTarget, safeBase) : safeBase &+ reportedTarget - let absoluteHeader: UInt32 = usesAbsolute ? max(reportedHeader, safeBase) : safeBase &+ reportedHeader - let absoluteTarget: UInt32 = usesAbsolute ? max(reportedTarget, safeBase) : safeBase &+ reportedTarget + let reportedFilterHeader = overview.filter_header_height + var absoluteFilterHeader: UInt32 = usesAbsolute ? max(reportedFilterHeader, safeBase) : safeBase &+ reportedFilterHeader - let reportedFilterHeader = overview.filter_header_height - var absoluteFilterHeader: UInt32 = usesAbsolute ? max(reportedFilterHeader, safeBase) : safeBase &+ reportedFilterHeader + let reportedFilter = overview.last_synced_filter_height + var absoluteFilter: UInt32 = usesAbsolute ? max(reportedFilter, safeBase) : safeBase &+ reportedFilter - let reportedFilter = overview.last_synced_filter_height - var absoluteFilter: UInt32 = usesAbsolute ? max(reportedFilter, safeBase) : safeBase &+ reportedFilter + let range = max(1.0, Double(absoluteTarget) - Double(safeBase)) + var headerProgress = min(1.0, max(0.0, (Double(absoluteHeader) - Double(safeBase)) / range)) + let rawFilterHeaderProgress = min(1.0, max(0.0, (Double(absoluteFilterHeader) - Double(safeBase)) / range)) + let rawFilterProgress = min(1.0, max(0.0, (Double(absoluteFilter) - Double(safeBase)) / range)) - let range = max(1.0, Double(absoluteTarget) - Double(safeBase)) - var headerProgress = min(1.0, max(0.0, (Double(absoluteHeader) - Double(safeBase)) / range)) - let rawFilterHeaderProgress = min(1.0, max(0.0, (Double(absoluteFilterHeader) - Double(safeBase)) / range)) - let rawFilterProgress = min(1.0, max(0.0, (Double(absoluteFilter) - Double(safeBase)) / range)) + let filtersHeightAbsolute = absoluteFilter + let nearTarget: (UInt32, UInt32) -> Bool = { current, target in + guard target > 0 else { return false } + if current >= target { return true } + let remaining = target &- current + return remaining <= 1 + } - let filtersHeightAbsolute = absoluteFilter - let nearTarget: (UInt32, UInt32) -> Bool = { current, target in - guard target > 0 else { return false } - if current >= target { return true } - let remaining = target &- current - return remaining <= 1 - } + let headerDone = nearTarget(absoluteHeader, absoluteTarget) + let filterHeadersDone = nearTarget(absoluteFilterHeader, absoluteTarget) + let filtersStarted = (filtersHeightAbsolute > safeBase) || (overview.filters_downloaded > 0) + let filtersDone = filtersStarted && nearTarget(filtersHeightAbsolute, absoluteTarget) + + if stage != .complete { + if headerDone && filterHeadersDone && filtersDone { + stage = .complete + } else if headerDone && filterHeadersDone { + stage = .transactions + } else if headerDone { + stage = .masternodes + } else { + stage = .headers + } + } - let headerDone = nearTarget(absoluteHeader, absoluteTarget) - let filterHeadersDone = nearTarget(absoluteFilterHeader, absoluteTarget) - let filtersStarted = (filtersHeightAbsolute > safeBase) || (overview.filters_downloaded > 0) - let filtersDone = filtersStarted && nearTarget(filtersHeightAbsolute, absoluteTarget) - - if stage != .complete { - if headerDone && filterHeadersDone && filtersDone { - stage = .complete - } else if headerDone && filterHeadersDone { - stage = .transactions - } else if headerDone { - stage = .masternodes - } else { - stage = .headers + // Apply previous progress constraints + headerProgress = max(previousHeaderProgress, headerProgress) + if stage != .headers { + headerProgress = 1.0 } - } - if let prev = previous { - headerProgress = max(prev.headerProgress, headerProgress) - } - if stage != .headers { - headerProgress = 1.0 - } + var filterHeaderProgress = rawFilterHeaderProgress + var filterProgress = rawFilterProgress - var filterHeaderProgress = rawFilterHeaderProgress - var filterProgress = rawFilterProgress - - switch stage { - case .headers: - absoluteFilterHeader = safeBase - absoluteFilter = safeBase - filterHeaderProgress = 0.0 - filterProgress = 0.0 - case .masternodes: - if filterHeadersDone { - filterHeaderProgress = 1.0 - absoluteFilterHeader = max(absoluteFilterHeader, absoluteTarget) - } - absoluteFilter = safeBase - filterProgress = 0.0 - case .transactions: - if filterHeadersDone { - filterHeaderProgress = 1.0 - absoluteFilterHeader = max(absoluteFilterHeader, absoluteTarget) - } - if !filtersStarted { + switch stage { + case .headers: + absoluteFilterHeader = safeBase absoluteFilter = safeBase + filterHeaderProgress = 0.0 + filterProgress = 0.0 + case .masternodes: + if filterHeadersDone { + filterHeaderProgress = 1.0 + absoluteFilterHeader = max(absoluteFilterHeader, absoluteTarget) + } + absoluteFilter = safeBase + filterProgress = 0.0 + case .transactions: + if filterHeadersDone { + filterHeaderProgress = 1.0 + absoluteFilterHeader = max(absoluteFilterHeader, absoluteTarget) + } + if !filtersStarted { + absoluteFilter = safeBase + filterProgress = 0.0 + } + case .complete: + if filterHeadersDone { + filterHeaderProgress = 1.0 + absoluteFilterHeader = max(absoluteFilterHeader, absoluteTarget) + } + if filtersDone { + filterProgress = 1.0 + absoluteFilter = max(absoluteFilter, absoluteTarget) + } + case .idle: + absoluteFilterHeader = safeBase + absoluteFilter = safeBase + filterHeaderProgress = 0.0 filterProgress = 0.0 } - case .complete: - if filterHeadersDone { - filterHeaderProgress = 1.0 - absoluteFilterHeader = max(absoluteFilterHeader, absoluteTarget) - } - if filtersDone { - filterProgress = 1.0 - absoluteFilter = max(absoluteFilter, absoluteTarget) - } - case .idle: - absoluteFilterHeader = safeBase - absoluteFilter = safeBase - filterHeaderProgress = 0.0 - filterProgress = 0.0 - } - let previousStage = previous?.stage ?? .idle - let previousMasternode = (previousStage == .masternodes || previousStage == .transactions || previousStage == .complete) ? previous?.masternodeProgress ?? 0.0 : 0.0 - let previousTransaction = (previousStage == .transactions || previousStage == .complete) ? previous?.transactionProgress ?? 0.0 : 0.0 - - let masternodeProgress = max(previousMasternode, filterHeaderProgress) - let transactionProgress = max(previousTransaction, filterProgress) - - let progress = SPVSyncProgress( - stage: stage, - headerProgress: headerProgress, - masternodeProgress: masternodeProgress, - transactionProgress: transactionProgress, - currentHeight: absoluteHeader, - targetHeight: absoluteTarget, - filterHeaderHeight: min(absoluteFilterHeader, absoluteTarget), - filterHeight: min(absoluteFilter, absoluteTarget), - syncStartedAt: TimeInterval(syncStartTimestamp > 0 ? syncStartTimestamp : client.currentSyncStartTimestamp), - startHeight: safeBase, - rate: ffiProgress.headers_per_second, - estimatedTimeRemaining: estimatedTime - ) + // Apply previous progress constraints based on stage + let previousStageValue = previousStage ?? .idle + let previousMasternode = (previousStageValue == .masternodes || previousStageValue == .transactions || previousStageValue == .complete) ? previousMasternodeProgress : 0.0 + let previousTransaction = (previousStageValue == .transactions || previousStageValue == .complete) ? previousTransactionProgress : 0.0 + + let masternodeProgress = max(previousMasternode, filterHeaderProgress) + let transactionProgress = max(previousTransaction, filterProgress) + + let progress = SPVSyncProgress( + stage: stage, + headerProgress: headerProgress, + masternodeProgress: masternodeProgress, + transactionProgress: transactionProgress, + currentHeight: absoluteHeader, + targetHeight: absoluteTarget, + filterHeaderHeight: min(absoluteFilterHeader, absoluteTarget), + filterHeight: min(absoluteFilter, absoluteTarget), + syncStartedAt: TimeInterval(syncStartTimestamp > 0 ? syncStartTimestamp : currentSyncStartTimestamp), + startHeight: safeBase, + rate: ffiProgress.headers_per_second, + estimatedTimeRemaining: estimatedTime + ) - let now = Date().timeIntervalSince1970 - if now - client.lastProgressUIUpdate >= client.progressUICoalesceInterval { - client.lastProgressUIUpdate = now - client.syncProgress = progress - client.delegate?.spvClient(client, didUpdateSyncProgress: progress) - } else { - client.syncProgress = progress + // Update MainActor properties asynchronously + await MainActor.run { [weak client] in + guard let client = client else { return } + + // Update lightweight properties immediately + client.peerCount = Int(overview.peer_count) + if syncStartTimestamp > 0 { + client.currentSyncStartTimestamp = syncStartTimestamp + } + client.syncProgress = progress + + // Throttle delegate calls (more expensive) + let now = Date().timeIntervalSince1970 + if now - client.lastProgressUIUpdate >= client.progressUICoalesceInterval { + client.lastProgressUIUpdate = now + // Delegate call can be expensive, dispatch asynchronously to avoid blocking + Task { @MainActor in + client.delegate?.spvClient(client, didUpdateSyncProgress: progress) + } + } + } } } func handleSyncCompletion(success: Bool, error: String?) { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/WalletService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/WalletService.swift index e274fb2253..138ba24f24 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/WalletService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/WalletService.swift @@ -131,6 +131,9 @@ public class WalletService: ObservableObject { // SPV Client - new wrapper with proper sync support private var spvClient: SPVClient? + // Debounced wallet sync scheduler (created when WalletManager is ready) + private var walletSyncScheduler: WalletSyncScheduler? + // Mock SDK for now - will be replaced with real SDK private var sdk: Any? // Latest sync stats (for UI) @@ -257,6 +260,35 @@ public class WalletService: ObservableObject { let sdkWalletManager = try clientLocal.makeSharedWalletManager() let wrapper = try WalletManager(sdkWalletManager: sdkWalletManager, modelContainer: mc) WalletService.shared.walletManager = wrapper + // Initialize debounced wallet sync scheduler + WalletService.shared.walletSyncScheduler = WalletSyncScheduler(debounce: 0.5) { walletIds in + // Map walletIds (hex strings) to HDWallets and sync on MainActor + await MainActor.run { + guard let wm = WalletService.shared.walletManager else { return } + let walletsToSync: [HDWallet] = wm.wallets.filter { w in + if let id = w.walletId?.hexString { + return walletIds.contains(id) + } + return false + } + for wallet in walletsToSync { + Task { @MainActor in + await wm.syncWalletStateFromRust(for: wallet) + } + } + // Lightweight UI updates after batch + if let current = WalletService.shared.currentWallet, + let currentId = current.walletId?.hexString, + walletIds.contains(currentId) { + Task { @MainActor in + await WalletService.shared.loadTransactions() + WalletService.shared.updateBalance() + } + } else { + WalletService.shared.updateBalance() + } + } + } WalletService.shared.walletManager?.transactionService = TransactionService( walletManager: wrapper, modelContainer: mc, @@ -616,25 +648,64 @@ public class WalletService: ObservableObject { guard let wallet = currentWallet else { throw WalletError.notImplemented("No active wallet") } - + guard wallet.confirmedBalance >= amount else { throw WalletError.notImplemented("Insufficient funds") } - - // Mock transaction creation - let txid = UUID().uuidString + + guard let walletManager = self.walletManager else { + throw WalletError.notImplemented("WalletManager not available") + } + + // Get the FFI wallet and managed wallet from WalletManager + guard let ffiWallet = try? await walletManager.getFFIWallet(for: wallet), + let managedWallet = try? await walletManager.getManagedWallet(for: wallet) else { + throw WalletError.notImplemented("Unable to access wallet for transaction signing") + } + + // Get current blockchain height (default to 0 if not available) + let currentHeight = UInt32(wallet.lastSyncedHeight) + + // Build transaction outputs + let outputs = [SwiftDashSDK.Transaction.Output(address: address, amount: amount)] + + // Build and sign transaction using the FFI + let signedTxData = try SwiftDashSDK.Transaction.buildAndSign( + managedWallet: managedWallet, + wallet: ffiWallet, + accountIndex: 0, + outputs: outputs, + feePerKB: 1000, // TODO: Make this configurable or use fee estimation + currentHeight: currentHeight + ) + + // Extract TXID from the signed transaction + let txid = try SwiftDashSDK.Transaction.getTxid(from: signedTxData) + + // TODO: Broadcast transaction via SPV client + // For now, we'll just save it locally + // if let spvClient = spvClient { + // try await spvClient.broadcast(signedTxData) + // } + + // Create transaction record let transaction = HDTransaction(txHash: txid, timestamp: Date()) transaction.amount = -Int64(amount) - transaction.fee = 1000 + transaction.fee = 1000 // TODO: Extract actual fee from transaction transaction.type = "sent" + transaction.rawTransaction = signedTxData transaction.wallet = wallet - + transaction.isPending = true // Mark as pending until broadcast confirms + modelContainer?.mainContext.insert(transaction) try? modelContainer?.mainContext.save() - + // Update balance updateBalance() - + + print("Transaction built and signed: \(txid)") + print("Note: Broadcasting not yet implemented - transaction not sent to network") + return txid } @@ -753,101 +824,97 @@ extension WalletService: SPVClientDelegate { let reportedFilterHeight = progress.filterHeight let syncStart = progress.syncStartedAt - Task { @MainActor in + // Do heavy computation off MainActor, then update UI on MainActor + Task.detached(priority: .userInitiated) { + // Compute all the values off the main thread let baseHeight = Int(startHeight) - if syncStart > 0 && syncStart != self.activeSyncStartTimestamp { - self.activeSyncStartTimestamp = syncStart - self.latestFilterHeaderHeight = baseHeight - self.latestFilterHeight = baseHeight - self.filterHeaderProgress = 0 - self.transactionProgress = 0 - } let absHeader = max(Int(currentHeight), baseHeight) var absTarget = max(Int(targetHeight), baseHeight) - - let headerNumeratorRaw = max(0.0, Double(absHeader - baseHeight)) - let headerDenominatorRaw = max(1.0, Double(absTarget - baseHeight)) - var headerPct = min(1.0, max(0.0, headerNumeratorRaw / headerDenominatorRaw)) - - + let absFilterHeaderRaw = max(Int(reportedFilterHeaderHeight), baseHeight) var absFilterHeader = min(absFilterHeaderRaw, absTarget) - let absFilterRaw = max(Int(reportedFilterHeight), baseHeight) var absFilter = min(absFilterRaw, absTarget) - + if mappedStage == .headers { - // While headers are still syncing, clamp downstream stages to the base height. absFilterHeader = baseHeight absFilter = baseHeight } else if mappedStage == .filterHeaders { - // Do not surface compact filter progress until that stage is active. absFilter = baseHeight } - - let displayBaseline = max(baseHeight, WalletService.shared.currentDisplayBaseline()) - let normalizedCandidate = WalletService.shared.normalizedChainTip(absTarget, baseline: displayBaseline) - let storedHeaderHeight = WalletService.shared.latestHeaderHeight - + + // Normalize target - need to access MainActor properties + let displayBaseline = await MainActor.run { max(baseHeight, WalletService.shared.currentDisplayBaseline()) } + let normalizedCandidate = await MainActor.run { WalletService.shared.normalizedChainTip(absTarget, baseline: displayBaseline) } + let storedHeaderHeight = await MainActor.run { WalletService.shared.latestHeaderHeight } + let adjustedTarget = max(absHeader, normalizedCandidate) absTarget = adjustedTarget - WalletService.shared.headerTargetHeight = adjustedTarget - + var headerHeightForDisplay: Int if mappedStage == .headers { headerHeightForDisplay = max(storedHeaderHeight, absHeader) } else { headerHeightForDisplay = max(storedHeaderHeight, adjustedTarget) } - - WalletService.shared.latestHeaderHeight = headerHeightForDisplay - WalletService.shared.headerCurrentHeight = headerHeightForDisplay - + absFilterHeader = min(absFilterHeader, adjustedTarget) absFilter = min(absFilter, adjustedTarget) - + let headerDenominatorFinal = max(1.0, Double(adjustedTarget - baseHeight)) let headerNumeratorFinal = max(0.0, Double(headerHeightForDisplay - baseHeight)) + var headerPct = min(1.0, max(0.0, headerNumeratorFinal / headerDenominatorFinal)) if adjustedTarget <= headerHeightForDisplay { headerPct = 1.0 - } else { - headerPct = min(1.0, headerNumeratorFinal / headerDenominatorFinal) } if mappedStage != .headers { headerPct = 1.0 } - + let headerSpan = max(1.0, Double(max(headerHeightForDisplay, adjustedTarget) - baseHeight)) let filterHeaderNumerator = max(0.0, Double(absFilterHeader - baseHeight)) let filterNumerator = max(0.0, Double(absFilter - baseHeight)) - let filterHeaderPct = min(1.0, filterHeaderNumerator / headerSpan) let filterPct = min(1.0, filterNumerator / headerSpan) + + // Now update UI on MainActor (only the lightweight property updates) + await MainActor.run { + if syncStart > 0 && syncStart != self.activeSyncStartTimestamp { + self.activeSyncStartTimestamp = syncStart + self.latestFilterHeaderHeight = baseHeight + self.latestFilterHeight = baseHeight + self.filterHeaderProgress = 0 + self.transactionProgress = 0 + } + + WalletService.shared.headerTargetHeight = adjustedTarget + WalletService.shared.latestHeaderHeight = headerHeightForDisplay + WalletService.shared.headerCurrentHeight = headerHeightForDisplay + WalletService.shared.syncProgress = headerPct + WalletService.shared.headerProgress = headerPct + + if mappedStage == .headers { + WalletService.shared.filterHeaderProgress = 0 + WalletService.shared.transactionProgress = max(0, WalletService.shared.transactionProgress) + WalletService.shared.latestFilterHeaderHeight = baseHeight + WalletService.shared.latestFilterHeight = baseHeight + } else { + WalletService.shared.latestFilterHeaderHeight = max(WalletService.shared.latestFilterHeaderHeight, absFilterHeader) + WalletService.shared.latestFilterHeight = max(WalletService.shared.latestFilterHeight, absFilter) + WalletService.shared.filterHeaderProgress = filterHeaderPct + WalletService.shared.transactionProgress = max(WalletService.shared.transactionProgress, filterPct) + } - WalletService.shared.syncProgress = headerPct - WalletService.shared.headerProgress = headerPct + WalletService.shared.detailedSyncProgress = SyncProgress( + current: UInt64(absHeader), + total: UInt64(adjustedTarget), + rate: rate, + progress: headerPct, + stage: mappedStage + ) - if mappedStage == .headers { - WalletService.shared.filterHeaderProgress = 0 - WalletService.shared.transactionProgress = max(0, WalletService.shared.transactionProgress) - WalletService.shared.latestFilterHeaderHeight = baseHeight - WalletService.shared.latestFilterHeight = baseHeight - } else { - WalletService.shared.latestFilterHeaderHeight = max(WalletService.shared.latestFilterHeaderHeight, absFilterHeader) - WalletService.shared.latestFilterHeight = max(WalletService.shared.latestFilterHeight, absFilter) - WalletService.shared.filterHeaderProgress = filterHeaderPct - WalletService.shared.transactionProgress = max(WalletService.shared.transactionProgress, filterPct) + SDKLogger.log("📊 Sync progress: \(stageRawValue) - \(Int(overall * 100))%", minimumLevel: .high) } - - WalletService.shared.detailedSyncProgress = SyncProgress( - current: UInt64(absHeader), - total: UInt64(adjustedTarget), - rate: rate, - progress: headerPct, - stage: mappedStage - ) - - SDKLogger.log("📊 Sync progress: \(stageRawValue) - \(Int(overall * 100))%", minimumLevel: .high) } // Use event-driven transaction progress from SPVClient (no polling fallback) @@ -855,50 +922,36 @@ extension WalletService: SPVClientDelegate { public func spvClient(_ client: SPVClient, didReceiveBlock block: SPVBlockEvent) { SDKLogger.log("📦 New block: height=\(block.height)", minimumLevel: .high) - - // Sync wallet state after processing a block (which may contain relevant transactions) - Task { @MainActor in - if let wm = walletManager { - for wallet in wm.wallets { - await wm.syncWalletStateFromRust(for: wallet) - } - } - updateBalance() - } + // No per-block full wallet sync; transactions will trigger targeted sync via scheduler } public func spvClient(_ client: SPVClient, didReceiveTransaction transaction: SPVTransactionEvent) { - // Sync wallet state from Rust to SwiftData, then update UI - Task { @MainActor in - // Sync ALL wallets from Rust to SwiftData (transaction could belong to any wallet) - if let wm = walletManager { - for wallet in wm.wallets { - await wm.syncWalletStateFromRust(for: wallet) - } + // Targeted, debounced sync by walletId (if provided) + if let wid = transaction.walletId, !wid.isEmpty { + Task { [weak self] in + await self?.walletSyncScheduler?.enqueue(walletId: wid) } - - // Then update UI from the now-synchronized SwiftData (if viewing a wallet) - if currentWallet != nil { - await loadTransactions() - updateBalance() + } else { + // Fallback: schedule current wallet if available + if let id = currentWallet?.walletId?.hexString { + Task { [weak self] in + await self?.walletSyncScheduler?.enqueue(walletId: id) + } } } + // UI updates remain lightweight; detailed list reloads are handled elsewhere or on user view } public func spvClient(_ client: SPVClient, didUpdateBlocksHit count: Int) { blocksHit = count - // Sync wallet state periodically during sync (every 50 blocks processed) + // Instead of syncing all wallets on every N blocks, coalesce via scheduler if count > 0 && count % 50 == 0 { - Task { @MainActor [weak self] in - guard let self else { return } - // Sync ALL wallets - if let wm = self.walletManager { - for wallet in wm.wallets { - await wm.syncWalletStateFromRust(for: wallet) - } + if let wm = walletManager { + let ids = wm.wallets.compactMap { $0.walletId?.hexString } + Task { [weak self] in + await self?.walletSyncScheduler?.enqueueMany(walletIds: ids) } - self.updateBalance() } } @@ -973,7 +1026,7 @@ extension WalletService: SPVClientDelegate { public func spvClient(_ client: SPVClient, didChangeConnectionStatus connected: Bool, peers: Int) { SDKLogger.log("🌐 Connection status: \(connected ? "Connected" : "Disconnected") - \(peers) peers", minimumLevel: .high) - } + } nonisolated private static func mapSyncStage(_ stage: SPVSyncStage) -> SyncStage { switch stage { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/WalletSyncScheduler.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/WalletSyncScheduler.swift new file mode 100644 index 0000000000..7d7500e8f8 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/WalletSyncScheduler.swift @@ -0,0 +1,51 @@ +import Foundation + +// Debounces wallet sync requests and coalesces by walletId +public actor WalletSyncScheduler { + public typealias FlushHandler = @Sendable (Set) async -> Void + + private let debounceInterval: TimeInterval + private let handler: FlushHandler + + private var pending: Set = [] + private var scheduledTask: Task? + + public init(debounce: TimeInterval = 0.5, handler: @escaping FlushHandler) { + self.debounceInterval = debounce + self.handler = handler + } + + public func enqueue(walletId: String) { + pending.insert(walletId) + schedule() + } + + public func enqueueMany(walletIds: [String]) { + for id in walletIds { pending.insert(id) } + schedule() + } + + private func schedule() { + guard scheduledTask == nil else { return } + let nanos = UInt64(debounceInterval * 1_000_000_000) + scheduledTask = Task { [weak self] in + // Simple debounce: wait for interval, then flush on the actor + try? await Task.sleep(nanoseconds: nanos) + await self?.flushCurrent() + } + } + + private func flushCurrent() async { + let toFlush = pending + pending.removeAll() + scheduledTask = nil + if !toFlush.isEmpty { + await handler(toFlush) + } + } + + // Exposed manual flush (useful for tests) + public func flushNow() async { + await flushCurrent() + } +} From bb2c145d9b294eacb7816abd8245924bbaf660a2 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 6 Nov 2025 11:44:43 -0600 Subject: [PATCH 3/6] key-wallet: expose ManagedWallet.getInfoHandle() for internal use - Change visibility from private to internal to allow internal helpers to access the opaque FFI handle. - No functional behavior change. --- .../Sources/SwiftDashSDK/KeyWallet/ManagedWallet.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedWallet.swift index 6598c01b8f..82f2ff5499 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedWallet.swift @@ -419,9 +419,9 @@ public class ManagedWallet { return utxos } - // MARK: - Private Helpers - - private func getInfoHandle() -> UnsafeMutablePointer? { + // MARK: - Internal Helpers + + internal func getInfoHandle() -> UnsafeMutablePointer? { // The handle is an FFIManagedWalletInfo* (opaque C handle) return handle } From e731fb4fb6824de98de6651bc3498a05ffa8a0c4 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 6 Nov 2025 11:44:52 -0600 Subject: [PATCH 4/6] key-wallet: improve FFI safety in Transaction and add helpers - Use stable C strings for output addresses and free them to avoid dangling pointers - Switch to typed withUnsafeBytes pointers for clarity and safety - Add buildAndSign(managedWallet:wallet:...) convenience API - Add getTxid(from:) helper to extract transaction ID --- .../SwiftDashSDK/KeyWallet/Transaction.swift | 154 ++++++++++++++++-- 1 file changed, 139 insertions(+), 15 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Transaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Transaction.swift index dea3b13673..1f537d375f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Transaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Transaction.swift @@ -1,4 +1,5 @@ import Foundation +import Darwin import DashSDKFFI /// Transaction utilities for wallet operations @@ -39,10 +40,23 @@ public class Transaction { var error = FFIError() var txBytesPtr: UnsafeMutablePointer? var txLen: size_t = 0 - - // Convert outputs to FFI format - let ffiOutputs = outputs.map { $0.toFFI() } - + // Convert outputs to FFI format with stable C strings + var cStrings: [UnsafeMutablePointer] = [] + cStrings.reserveCapacity(outputs.count) + var ffiOutputs: [FFITxOutput] = [] + ffiOutputs.reserveCapacity(outputs.count) + + for output in outputs { + let cstr = output.address.withCString { strdup($0) } + guard let cstr else { + // Free any previously allocated strings before throwing + for ptr in cStrings { free(ptr) } + throw KeyWalletError.invalidInput("Failed to allocate C string for address") + } + cStrings.append(cstr) + ffiOutputs.append(FFITxOutput(address: UnsafePointer(cstr), amount: output.amount)) + } + let success = ffiOutputs.withUnsafeBufferPointer { outputsPtr in wallet_build_transaction( wallet.ffiHandle, @@ -57,6 +71,8 @@ public class Transaction { } defer { + // Free allocated C strings + for ptr in cStrings { free(ptr) } if error.message != nil { error_message_free(error.message) } @@ -89,8 +105,7 @@ public class Transaction { var signedTxPtr: UnsafeMutablePointer? var signedLen: size_t = 0 - let success = transactionData.withUnsafeBytes { txBytes in - let txPtr = txBytes.bindMemory(to: UInt8.self).baseAddress + let success = transactionData.withUnsafeBytes { (txPtr: UnsafePointer) in return wallet_sign_transaction( wallet.ffiHandle, NetworkSet(wallet.network).ffiNetworks, @@ -113,10 +128,122 @@ public class Transaction { // Copy the signed transaction data before freeing let signedData = Data(bytes: ptr, count: signedLen) - + return signedData } - + + /// Build and sign a transaction in one step using managed wallet + /// - Parameters: + /// - managedWallet: The managed wallet with UTXO information + /// - wallet: The wallet with private keys for signing + /// - accountIndex: The account index to use + /// - outputs: The transaction outputs + /// - feePerKB: Fee per kilobyte in satoshis + /// - currentHeight: Current blockchain height for UTXO selection + /// - Returns: The signed transaction bytes ready for broadcast + public static func buildAndSign(managedWallet: ManagedWallet, + wallet: Wallet, + accountIndex: UInt32 = 0, + outputs: [Output], + feePerKB: UInt64, + currentHeight: UInt32) throws -> Data { + guard !outputs.isEmpty else { + throw KeyWalletError.invalidInput("Transaction must have at least one output") + } + + guard !wallet.isWatchOnly else { + throw KeyWalletError.invalidState("Cannot sign with watch-only wallet") + } + + var error = FFIError() + var txBytesPtr: UnsafeMutablePointer? + var txLen: size_t = 0 + + // Get managed wallet handle + guard let managedHandle = managedWallet.getInfoHandle() else { + throw KeyWalletError.invalidState("Failed to get managed wallet handle") + } + + // Convert outputs to FFI format with stable C strings + var cStrings: [UnsafeMutablePointer] = [] + cStrings.reserveCapacity(outputs.count) + var ffiOutputs: [FFITxOutput] = [] + ffiOutputs.reserveCapacity(outputs.count) + + for output in outputs { + let cstr = output.address.withCString { strdup($0) } + guard let cstr else { + for ptr in cStrings { free(ptr) } + throw KeyWalletError.invalidInput("Failed to allocate C string for address") + } + cStrings.append(cstr) + ffiOutputs.append(FFITxOutput(address: UnsafePointer(cstr), amount: output.amount)) + } + + let success = ffiOutputs.withUnsafeBufferPointer { outputsPtr in + wallet_build_and_sign_transaction( + managedHandle, + wallet.ffiHandle, + wallet.network.ffiValue, + accountIndex, + outputsPtr.baseAddress, + outputs.count, + feePerKB, + currentHeight, + &txBytesPtr, + &txLen, + &error) + } + + defer { + for ptr in cStrings { free(ptr) } + if error.message != nil { + error_message_free(error.message) + } + if let ptr = txBytesPtr { + transaction_bytes_free(ptr) + } + } + + guard success, let ptr = txBytesPtr else { + throw KeyWalletError(ffiError: error) + } + + // Copy the transaction data before freeing + let txData = Data(bytes: ptr, count: txLen) + + return txData + } + + /// Extract TXID from raw transaction bytes + /// - Parameter transactionData: The transaction bytes + /// - Returns: The transaction ID as a hex string + public static func getTxid(from transactionData: Data) throws -> String { + var error = FFIError() + var txidPtr: UnsafeMutablePointer? + + let success = transactionData.withUnsafeBytes { txBytes in + let txPtr = txBytes.bindMemory(to: UInt8.self).baseAddress + txidPtr = transaction_get_txid_from_bytes(txPtr, transactionData.count, &error) + return txidPtr != nil + } + + defer { + if error.message != nil { + error_message_free(error.message) + } + if let ptr = txidPtr { + string_free(ptr) + } + } + + guard success, let ptr = txidPtr else { + throw KeyWalletError(ffiError: error) + } + + return String(cString: ptr) + } + /// Check if a transaction belongs to a wallet /// - Parameters: /// - wallet: The wallet to check against @@ -137,12 +264,10 @@ public class Transaction { var error = FFIError() var result = FFITransactionCheckResult() - let success = transactionData.withUnsafeBytes { txBytes in - let txPtr = txBytes.bindMemory(to: UInt8.self).baseAddress + let success = transactionData.withUnsafeBytes { (txPtr: UnsafePointer) in if let hash = blockHash { - return hash.withUnsafeBytes { hashBytes in - let hashPtr = hashBytes.bindMemory(to: UInt8.self).baseAddress + return hash.withUnsafeBytes { (hashPtr: UnsafePointer) in return wallet_check_transaction( wallet.ffiHandle, @@ -181,8 +306,7 @@ public class Transaction { public static func classify(_ transactionData: Data) throws -> String { var error = FFIError() - let classificationPtr = transactionData.withUnsafeBytes { txBytes in - let txPtr = txBytes.bindMemory(to: UInt8.self).baseAddress + let classificationPtr = transactionData.withUnsafeBytes { (txPtr: UnsafePointer) in return transaction_classify(txPtr, transactionData.count, &error) } @@ -201,4 +325,4 @@ public class Transaction { return classification } -} \ No newline at end of file +} From e68343bf74470ae729f7b96201c74a6df41684d4 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 6 Nov 2025 11:45:01 -0600 Subject: [PATCH 5/6] example: WalletManager helpers to access FFI wallet and managed wallet - Add getFFIWallet(for:) to fetch SwiftDashSDK.Wallet - Add getManagedWallet(for:) to create a ManagedWallet for UTXO operations --- .../Core/Wallet/WalletManager.swift | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/WalletManager.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/WalletManager.swift index 0144410c52..0e119270da 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/WalletManager.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/WalletManager.swift @@ -763,11 +763,43 @@ class WalletManager: ObservableObject { } // MARK: - Public Utility Methods - + func reloadWallets() async { await loadWallets() } - + + /// Get the FFI wallet object for transaction operations + /// - Parameter wallet: The HDWallet to get the FFI wallet for + /// - Returns: The SwiftDashSDK.Wallet instance + func getFFIWallet(for wallet: HDWallet) async throws -> SwiftDashSDK.Wallet { + guard let walletId = wallet.walletId else { + throw WalletError.walletError("Wallet ID not available") + } + + let network = wallet.dashNetwork.toKeyWalletNetwork() + + guard let ffiWallet = try? sdkWalletManager.getWallet(id: walletId, network: network) else { + throw WalletError.walletError("Unable to retrieve FFI wallet from SDK") + } + + return ffiWallet + } + + /// Get the managed wallet for transaction building + /// - Parameter wallet: The HDWallet to get the managed wallet for + /// - Returns: The ManagedWallet instance with UTXO information + func getManagedWallet(for wallet: HDWallet) async throws -> SwiftDashSDK.ManagedWallet { + // Get the FFI wallet first + let ffiWallet = try await getFFIWallet(for: wallet) + + // Create a managed wallet from it + // Note: This creates a new instance each time. In a production app, + // you might want to cache these and keep them in sync with UTXO updates + let managedWallet = try SwiftDashSDK.ManagedWallet(wallet: ffiWallet) + + return managedWallet + } + // MARK: - Private Methods private func loadWallets() async { From 664f68ea2c4575966a9ed86f38a2244295e4f835 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 23 Nov 2025 20:27:46 -0600 Subject: [PATCH 6/6] feat(swift-sdk): implement transaction broadcasting with proper UTXO management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add full transaction broadcasting support to Swift SDK: - Implement synchronous and async broadcast methods in SPVClient - Fix WalletManager handle ownership (SPV client owns the handle) - Add getManagedWalletWithCurrentState() to access SPV-managed UTXOs - Update transaction building to use current UTXO state from SPV - Add transaction rollback on broadcast failure - Remove debounced sync scheduler in favor of direct syncing - Update UI to show broadcast status and TXID 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../KeyWallet/ManagedWallet.swift | 17 ++- .../KeyWallet/WalletManager.swift | 68 ++++++--- .../Sources/SwiftDashSDK/SPV/SPVClient.swift | 59 +++++++- .../Core/Services/WalletService.swift | 135 +++++++++--------- .../Core/Views/SendTransactionView.swift | 9 +- .../Core/Wallet/HDWallet.swift | 1 + .../Core/Wallet/TransactionService.swift | 19 +-- .../Core/Wallet/WalletManager.swift | 20 ++- 8 files changed, 220 insertions(+), 108 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedWallet.swift index 82f2ff5499..add1f2cdb5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedWallet.swift @@ -10,7 +10,7 @@ public class ManagedWallet { /// - Parameter wallet: The wallet to manage public init(wallet: Wallet) throws { self.network = wallet.network - + var error = FFIError() guard let managedPointer = wallet_create_managed_wallet(wallet.ffiHandle, &error) else { defer { @@ -20,10 +20,21 @@ public class ManagedWallet { } throw KeyWalletError(ffiError: error) } - + self.handle = managedPointer } - + + /// Create a managed wallet wrapper from an existing managed wallet info pointer + /// This is used to wrap the wallet manager's existing managed wallet info (which has current UTXO state) + /// - Parameters: + /// - managedWalletInfo: Pointer to existing managed wallet info from wallet manager + /// - network: The network this wallet is for + /// - Note: The caller is responsible for ensuring the pointer remains valid + internal init(managedWalletInfo: UnsafeMutablePointer, network: KeyWalletNetwork) { + self.handle = managedWalletInfo + self.network = network + } + deinit { ffi_managed_wallet_free(handle) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift index 34aa3b1a36..1b4a4e4fc3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift @@ -25,13 +25,14 @@ public class WalletManager { /// Create a wallet manager from an SPV client /// - Parameter spvClient: The FFI SPV client handle to get the wallet manager from + /// - Note: The SPV client owns the wallet manager handle, this wrapper just borrows it public init(fromSPVClient spvClient: UnsafeMutablePointer) throws { guard let managerHandle = dash_spv_ffi_client_get_wallet_manager(spvClient) else { throw KeyWalletError.walletError("Failed to get wallet manager from SPV client") } - + self.handle = managerHandle - self.ownsHandle = true + self.ownsHandle = false // SPV client owns this handle, we're just borrowing it } /// Create a wallet manager wrapper from an existing handle (does not own the handle) @@ -203,7 +204,7 @@ public class WalletManager { } var error = FFIError() let walletPtr = walletId.withUnsafeBytes { idBytes in - let idPtr = idBytes.bindMemory(to: UInt8.self).baseAddress + let idPtr = idBytes.baseAddress return wallet_manager_get_wallet(handle, idPtr, &error) } defer { @@ -261,7 +262,7 @@ public class WalletManager { // First get the managed wallet info guard let managedInfo = walletId.withUnsafeBytes({ idBytes in - let idPtr = idBytes.bindMemory(to: UInt8.self).baseAddress + let idPtr = idBytes.baseAddress return wallet_manager_get_managed_wallet_info(handle, idPtr, &error) }) else { defer { @@ -278,7 +279,7 @@ public class WalletManager { // Get the wallet guard let wallet = walletId.withUnsafeBytes({ idBytes in - let idPtr = idBytes.bindMemory(to: UInt8.self).baseAddress + let idPtr = idBytes.baseAddress return wallet_manager_get_wallet(handle, idPtr, &error) }) else { defer { @@ -325,7 +326,7 @@ public class WalletManager { // First get the managed wallet info guard let managedInfo = walletId.withUnsafeBytes({ idBytes in - let idPtr = idBytes.bindMemory(to: UInt8.self).baseAddress + let idPtr = idBytes.baseAddress return wallet_manager_get_managed_wallet_info(handle, idPtr, &error) }) else { defer { @@ -342,7 +343,7 @@ public class WalletManager { // Get the wallet guard let wallet = walletId.withUnsafeBytes({ idBytes in - let idPtr = idBytes.bindMemory(to: UInt8.self).baseAddress + let idPtr = idBytes.baseAddress return wallet_manager_get_wallet(handle, idPtr, &error) }) else { defer { @@ -389,7 +390,7 @@ public class WalletManager { var unconfirmed: UInt64 = 0 let success = walletId.withUnsafeBytes { idBytes in - let idPtr = idBytes.bindMemory(to: UInt8.self).baseAddress + let idPtr = idBytes.baseAddress return wallet_manager_get_wallet_balance( handle, idPtr, &confirmed, &unconfirmed, &error) } @@ -505,7 +506,7 @@ public class WalletManager { } var result = walletId.withUnsafeBytes { idBytes in - let idPtr = idBytes.bindMemory(to: UInt8.self).baseAddress + let idPtr = idBytes.baseAddress return managed_wallet_get_account(handle, idPtr, network.ffiValue, accountIndex, accountType.ffiValue) } @@ -537,7 +538,7 @@ public class WalletManager { } var result = walletId.withUnsafeBytes { idBytes in - let idPtr = idBytes.bindMemory(to: UInt8.self).baseAddress + let idPtr = idBytes.baseAddress return managed_wallet_get_top_up_account_with_registration_index( handle, idPtr, network.ffiValue, registrationIndex) } @@ -565,27 +566,60 @@ public class WalletManager { guard walletId.count == 32 else { throw KeyWalletError.invalidInput("Wallet ID must be exactly 32 bytes") } - + var error = FFIError() - + let collectionHandle = walletId.withUnsafeBytes { idBytes in - let idPtr = idBytes.bindMemory(to: UInt8.self).baseAddress + let idPtr = idBytes.baseAddress return managed_wallet_get_account_collection(handle, idPtr, network.ffiValue, &error) } - + defer { if error.message != nil { error_message_free(error.message) } } - + guard let collection = collectionHandle else { throw KeyWalletError(ffiError: error) } - + return ManagedAccountCollection(handle: collection, manager: self) } - + + /// Get managed wallet with current UTXO state from wallet manager + /// This returns a ManagedWallet that wraps the existing managed wallet info from the wallet manager, + /// which has been updated with UTXOs as SPV finds transactions. + /// - Parameters: + /// - walletId: The wallet ID + /// - network: The network type + /// - Returns: ManagedWallet with current UTXO state + public func getManagedWalletWithCurrentState(walletId: Data, network: KeyWalletNetwork = .mainnet) throws -> ManagedWallet { + guard walletId.count == 32 else { + throw KeyWalletError.invalidInput("Wallet ID must be exactly 32 bytes") + } + + var error = FFIError() + + // Get the existing managed wallet info from the wallet manager + // This is the instance that has been updated by SPV with current UTXO state + guard let managedInfo = walletId.withUnsafeBytes({ idBytes in + let idPtr = idBytes.bindMemory(to: UInt8.self).baseAddress + return wallet_manager_get_managed_wallet_info(handle, idPtr, &error) + }) else { + defer { + if error.message != nil { + error_message_free(error.message) + } + } + throw KeyWalletError(ffiError: error) + } + + // Wrap the existing managed wallet info in a ManagedWallet + // Note: This uses the internal initializer that takes the pointer directly + return ManagedWallet(managedWalletInfo: managedInfo, network: network) + } + internal var ffiHandle: UnsafeMutablePointer { handle } // MARK: - Serialization diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SPV/SPVClient.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SPV/SPVClient.swift index a06d702e96..52c57ef62f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SPV/SPVClient.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SPV/SPVClient.swift @@ -950,7 +950,59 @@ public class SPVClient: ObservableObject { guard let client = client else { throw SPVError.notInitialized } return try WalletManager(fromSPVClient: client) } - + + // MARK: - Transaction Broadcasting + + /// Broadcast a signed transaction to the network. + /// - Parameter transactionHex: The serialized transaction as a hex string + /// - Returns: The transaction hex (same as input on success) + /// - Throws: SPVError.broadcastFailed if broadcast fails + public func broadcastTransaction(_ transactionHex: String) throws -> String { + guard let client = client else { + throw SPVError.notInitialized + } + + var txHexCString = transactionHex + let result = txHexCString.withCString { cstr in + dash_spv_ffi_client_broadcast_transaction(client, cstr) + } + + if result != 0 { + if let errorMsg = dash_spv_ffi_get_last_error() { + let error = String(cString: errorMsg) + throw SPVError.broadcastFailed(error) + } + throw SPVError.broadcastFailed("Broadcast failed with code \(result)") + } + + return transactionHex + } + + /// Asynchronously broadcast a signed transaction without blocking the main actor. + /// - Parameter transactionHex: The serialized transaction as a hex string. + /// - Throws: SPVError.broadcastFailed if broadcast fails. + public func broadcastTransactionAsync(_ transactionHex: String) async throws { + guard let client = client else { + throw SPVError.notInitialized + } + + let txHexCopy = transactionHex + try await Task.detached { + var txHexCString = txHexCopy + let result = txHexCString.withCString { cstr in + dash_spv_ffi_client_broadcast_transaction(client, cstr) + } + + if result != 0 { + if let errorMsg = dash_spv_ffi_get_last_error() { + let error = String(cString: errorMsg) + throw SPVError.broadcastFailed(error) + } + throw SPVError.broadcastFailed("Broadcast failed with code \(result)") + } + }.value + } + // MARK: - Statistics public func getStats() -> SPVStats? { @@ -1336,7 +1388,8 @@ public enum SPVError: LocalizedError { case alreadySyncing case syncFailed(String) case storageOperationFailed(String) - + case broadcastFailed(String) + public var errorDescription: String? { switch self { case .notInitialized: @@ -1355,6 +1408,8 @@ public enum SPVError: LocalizedError { return "Sync failed: \(reason)" case .storageOperationFailed(let reason): return reason + case .broadcastFailed(let reason): + return "Transaction broadcast failed: \(reason)" } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/WalletService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/WalletService.swift index 138ba24f24..ee87d18be1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/WalletService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/WalletService.swift @@ -131,9 +131,6 @@ public class WalletService: ObservableObject { // SPV Client - new wrapper with proper sync support private var spvClient: SPVClient? - // Debounced wallet sync scheduler (created when WalletManager is ready) - private var walletSyncScheduler: WalletSyncScheduler? - // Mock SDK for now - will be replaced with real SDK private var sdk: Any? // Latest sync stats (for UI) @@ -260,35 +257,6 @@ public class WalletService: ObservableObject { let sdkWalletManager = try clientLocal.makeSharedWalletManager() let wrapper = try WalletManager(sdkWalletManager: sdkWalletManager, modelContainer: mc) WalletService.shared.walletManager = wrapper - // Initialize debounced wallet sync scheduler - WalletService.shared.walletSyncScheduler = WalletSyncScheduler(debounce: 0.5) { walletIds in - // Map walletIds (hex strings) to HDWallets and sync on MainActor - await MainActor.run { - guard let wm = WalletService.shared.walletManager else { return } - let walletsToSync: [HDWallet] = wm.wallets.filter { w in - if let id = w.walletId?.hexString { - return walletIds.contains(id) - } - return false - } - for wallet in walletsToSync { - Task { @MainActor in - await wm.syncWalletStateFromRust(for: wallet) - } - } - // Lightweight UI updates after batch - if let current = WalletService.shared.currentWallet, - let currentId = current.walletId?.hexString, - walletIds.contains(currentId) { - Task { @MainActor in - await WalletService.shared.loadTransactions() - WalletService.shared.updateBalance() - } - } else { - WalletService.shared.updateBalance() - } - } - } WalletService.shared.walletManager?.transactionService = TransactionService( walletManager: wrapper, modelContainer: mc, @@ -657,14 +625,13 @@ public class WalletService: ObservableObject { throw WalletError.notImplemented("WalletManager not available") } - // Get the FFI wallet and managed wallet from WalletManager guard let ffiWallet = try? await walletManager.getFFIWallet(for: wallet), let managedWallet = try? await walletManager.getManagedWallet(for: wallet) else { throw WalletError.notImplemented("Unable to access wallet for transaction signing") } - // Get current blockchain height (default to 0 if not available) - let currentHeight = UInt32(wallet.lastSyncedHeight) + let bestHeight = max(headerCurrentHeight, wallet.lastSyncedHeight) + let currentHeight = bestHeight > 0 ? UInt32(bestHeight) : 0 // Build transaction outputs let outputs = [SwiftDashSDK.Transaction.Output(address: address, amount: amount)] @@ -682,16 +649,10 @@ public class WalletService: ObservableObject { // Extract TXID from the signed transaction let txid = try SwiftDashSDK.Transaction.getTxid(from: signedTxData) - // TODO: Broadcast transaction via SPV client - // For now, we'll just save it locally - // if let spvClient = spvClient { - // try await spvClient.broadcast(signedTxData) - // } - - // Create transaction record + // Create transaction record first (before broadcast so we don't lose it on broadcast failure) let transaction = HDTransaction(txHash: txid, timestamp: Date()) transaction.amount = -Int64(amount) - transaction.fee = 1000 // TODO: Extract actual fee from transaction + transaction.fee = 1000 // Fixed fee at 1 duff/byte transaction.type = "sent" transaction.rawTransaction = signedTxData transaction.wallet = wallet @@ -700,21 +661,49 @@ public class WalletService: ObservableObject { modelContainer?.mainContext.insert(transaction) try? modelContainer?.mainContext.save() + // Broadcast transaction via SPV client + do { + guard let spvClient = spvClient else { + throw WalletError.notImplemented("SPV client not initialized") + } + + // Convert transaction bytes to hex string + let txHex = signedTxData.map { String(format: "%02x", $0) }.joined() + + // Broadcast to network using async wrapper to avoid blocking the main actor + try await spvClient.broadcastTransactionAsync(txHex) + + print("Transaction broadcast to network: \(txid)") + } catch { + // On broadcast failure, remove the transaction record + modelContainer?.mainContext.delete(transaction) + try? modelContainer?.mainContext.save() + + // Re-throw the error so caller can handle it + throw error + } + // Update balance updateBalance() - print("Transaction built and signed: \(txid)") - print("Note: Broadcasting not yet implemented - transaction not sent to network") - return txid } private func loadTransactions() async { guard let wallet = currentWallet else { return } - // Convert HDTransaction to CoreTransaction + let tipHeight = headerCurrentHeight + transactions = wallet.transactions.map { hdTx in - CoreTransaction( + if let blockHeight = hdTx.blockHeight, blockHeight > 0, tipHeight > 0 { + let confirmations = max(0, tipHeight - blockHeight + 1) + if confirmations != hdTx.confirmations { + hdTx.confirmations = confirmations + hdTx.isPending = confirmations == 0 + } + } + + return CoreTransaction( id: hdTx.txHash, amount: hdTx.amount, fee: hdTx.fee, @@ -922,36 +911,50 @@ extension WalletService: SPVClientDelegate { public func spvClient(_ client: SPVClient, didReceiveBlock block: SPVBlockEvent) { SDKLogger.log("📦 New block: height=\(block.height)", minimumLevel: .high) - // No per-block full wallet sync; transactions will trigger targeted sync via scheduler + + // Sync wallet state after processing a block (which may contain relevant transactions) + Task { @MainActor in + if let wm = walletManager { + for wallet in wm.wallets { + await wm.syncWalletStateFromRust(for: wallet) + } + } + updateBalance() + } } public func spvClient(_ client: SPVClient, didReceiveTransaction transaction: SPVTransactionEvent) { - // Targeted, debounced sync by walletId (if provided) - if let wid = transaction.walletId, !wid.isEmpty { - Task { [weak self] in - await self?.walletSyncScheduler?.enqueue(walletId: wid) - } - } else { - // Fallback: schedule current wallet if available - if let id = currentWallet?.walletId?.hexString { - Task { [weak self] in - await self?.walletSyncScheduler?.enqueue(walletId: id) + // Sync wallet state from Rust to SwiftData, then update UI + Task { @MainActor in + // Sync ALL wallets from Rust to SwiftData (transaction could belong to any wallet) + if let wm = walletManager { + for wallet in wm.wallets { + await wm.syncWalletStateFromRust(for: wallet) } } + + // Then update UI from the now-synchronized SwiftData (if viewing a wallet) + if currentWallet != nil { + await loadTransactions() + updateBalance() + } } - // UI updates remain lightweight; detailed list reloads are handled elsewhere or on user view } public func spvClient(_ client: SPVClient, didUpdateBlocksHit count: Int) { blocksHit = count - // Instead of syncing all wallets on every N blocks, coalesce via scheduler + // Sync wallet state periodically during sync (every 50 blocks processed) if count > 0 && count % 50 == 0 { - if let wm = walletManager { - let ids = wm.wallets.compactMap { $0.walletId?.hexString } - Task { [weak self] in - await self?.walletSyncScheduler?.enqueueMany(walletIds: ids) + Task { @MainActor [weak self] in + guard let self else { return } + // Sync ALL wallets + if let wm = self.walletManager { + for wallet in wm.wallets { + await wm.syncWalletStateFromRust(for: wallet) + } } + self.updateBalance() } } @@ -1026,7 +1029,7 @@ extension WalletService: SPVClientDelegate { public func spvClient(_ client: SPVClient, didChangeConnectionStatus connected: Bool, peers: Int) { SDKLogger.log("🌐 Connection status: \(connected ? "Connected" : "Disconnected") - \(peers) peers", minimumLevel: .high) - } + } nonisolated private static func mapSyncStage(_ stage: SPVSyncStage) -> SyncStage { switch stage { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index d96291242c..b8191d9aef 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -94,7 +94,7 @@ struct SendTransactionView: View { .disabled(isSending) .overlay { if isSending { - ProgressView("Sending transaction...") + ProgressView("Building and broadcasting transaction...") .padding() .background(Color.gray.opacity(0.9)) .cornerRadius(10) @@ -114,8 +114,8 @@ struct SendTransactionView: View { dismiss() } } message: { - if successTxid != nil { - Text("Transaction sent successfully!") + if let txid = successTxid { + Text("Transaction broadcast successfully!\n\nTXID: \(txid.prefix(16))...") } } } @@ -136,6 +136,7 @@ struct SendTransactionView: View { await MainActor.run { successTxid = txid + isSending = false } } catch { await MainActor.run { @@ -172,4 +173,4 @@ struct SendTransactionView: View { .replacingOccurrences(of: "\\.$", with: "", options: .regularExpression) return "\(trimmed) DASH" } -} \ No newline at end of file +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/HDWallet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/HDWallet.swift index f3cf96638d..08384bde33 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/HDWallet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/HDWallet.swift @@ -140,6 +140,7 @@ public final class HDAccount: HDWalletModels { @Relationship(deleteRule: .cascade) public var identityFundingAddresses: [HDAddress] = [] // Indexes + public var externalAddressIndex: UInt32 public var internalAddressIndex: UInt32 public var coinJoinExternalIndex: UInt32 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/TransactionService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/TransactionService.swift index 070596c5a0..28365b6ae9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/TransactionService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/TransactionService.swift @@ -46,23 +46,24 @@ class TransactionService: ObservableObject { } // MARK: - Transaction Broadcasting - + func broadcastTransaction(_ transaction: BuiltTransaction) async throws { - guard let _ = spvClient else { + guard let spvClient = spvClient else { throw TransactionError.invalidState } - + isBroadcasting = true defer { isBroadcasting = false } - + do { - // Broadcast through SPV - // TODO: Implement broadcast with new SPV client - // try await spvClient.broadcastTransaction(transaction.rawTransaction) - throw TransactionError.broadcastFailed("SPV broadcast not yet implemented") + // Convert transaction bytes to hex string + let txHex = transaction.rawTransaction.map { String(format: "%02x", $0) }.joined() + + // Broadcast through SPV client + try await spvClient.broadcastTransaction(txHex) } catch { lastError = error - throw TransactionError.broadcastFailed(error.localizedDescription) + throw error } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/WalletManager.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/WalletManager.swift index 0e119270da..fee1c17a38 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/WalletManager.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/WalletManager.swift @@ -787,15 +787,21 @@ class WalletManager: ObservableObject { /// Get the managed wallet for transaction building /// - Parameter wallet: The HDWallet to get the managed wallet for - /// - Returns: The ManagedWallet instance with UTXO information + /// - Returns: The ManagedWallet instance with current UTXO information from SPV func getManagedWallet(for wallet: HDWallet) async throws -> SwiftDashSDK.ManagedWallet { - // Get the FFI wallet first - let ffiWallet = try await getFFIWallet(for: wallet) + guard let walletId = wallet.walletId else { + throw WalletError.walletError("Wallet ID not available") + } - // Create a managed wallet from it - // Note: This creates a new instance each time. In a production app, - // you might want to cache these and keep them in sync with UTXO updates - let managedWallet = try SwiftDashSDK.ManagedWallet(wallet: ffiWallet) + let network = wallet.dashNetwork.toKeyWalletNetwork() + + // Get the managed wallet with current state from the wallet manager + // This returns the existing managed wallet info that has been updated by SPV with current UTXOs + // instead of creating a new managed wallet info that would be empty + let managedWallet = try sdkWalletManager.getManagedWalletWithCurrentState( + walletId: walletId, + network: network + ) return managedWallet }