diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index 12c06fb3702..c82bc92d893 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"] } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedWallet.swift index 6598c01b8f0..82f2ff54998 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 } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Transaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Transaction.swift index dea3b136737..1f537d375fc 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 +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SPV/SPVClient.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SPV/SPVClient.swift index 8e20b232758..a06d702e968 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 e274fb22537..138ba24f249 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 00000000000..7d7500e8f83 --- /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() + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/WalletManager.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Wallet/WalletManager.swift index 0144410c522..0e119270da4 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 {