diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Models/CoreTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Models/CoreTransaction.swift deleted file mode 100644 index 08388423daf..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Models/CoreTransaction.swift +++ /dev/null @@ -1,150 +0,0 @@ -import Foundation - -public struct CoreTransaction: Identifiable, Equatable { - public let id: String // txid - public let amount: Int64 // positive for received, negative for sent - public let fee: UInt64 - public let timestamp: Date - public let blockHeight: Int64? - public let confirmations: Int - public let type: String // TransactionType is defined in HDTransaction.swift - public let memo: String? - public let inputs: [CoreTransactionInput] - public let outputs: [CoreTransactionOutput] - public let isInstantSend: Bool - public let isAssetLock: Bool - public let rawData: Data? - - public var isConfirmed: Bool { - confirmations >= 6 - } - - public var isPending: Bool { - confirmations == 0 - } - - public var formattedAmount: String { - let dash = Double(abs(amount)) / 100_000_000.0 - let sign = amount < 0 ? "-" : "+" - return "\(sign)\(String(format: "%.8f", dash)) DASH" - } - - public var formattedFee: String { - let dash = Double(fee) / 100_000_000.0 - return String(format: "%.8f DASH", dash) - } - - public init( - id: String, - amount: Int64, - fee: UInt64, - timestamp: Date, - blockHeight: Int64? = nil, - confirmations: Int = 0, - type: String, - memo: String? = nil, - inputs: [CoreTransactionInput] = [], - outputs: [CoreTransactionOutput] = [], - isInstantSend: Bool = false, - isAssetLock: Bool = false, - rawData: Data? = nil - ) { - self.id = id - self.amount = amount - self.fee = fee - self.timestamp = timestamp - self.blockHeight = blockHeight - self.confirmations = confirmations - self.type = type - self.memo = memo - self.inputs = inputs - self.outputs = outputs - self.isInstantSend = isInstantSend - self.isAssetLock = isAssetLock - self.rawData = rawData - } -} - -public struct CoreTransactionInput: Equatable { - public let previousTxid: String - public let previousOutputIndex: UInt32 - public let address: String? - public let amount: UInt64? - public let scriptSignature: Data - - public init( - previousTxid: String, - previousOutputIndex: UInt32, - address: String? = nil, - amount: UInt64? = nil, - scriptSignature: Data - ) { - self.previousTxid = previousTxid - self.previousOutputIndex = previousOutputIndex - self.address = address - self.amount = amount - self.scriptSignature = scriptSignature - } -} - -public struct CoreTransactionOutput: Equatable { - public let index: UInt32 - public let address: String - public let amount: UInt64 - public let scriptPubKey: Data - public let isChange: Bool - - public init( - index: UInt32, - address: String, - amount: UInt64, - scriptPubKey: Data, - isChange: Bool = false - ) { - self.index = index - self.address = address - self.amount = amount - self.scriptPubKey = scriptPubKey - self.isChange = isChange - } -} - -// Transaction builder for creating new transactions -public struct CoreTransactionBuilder { - public var inputs: [CoreTransactionInput] = [] - public var outputs: [CoreTransactionOutput] = [] - public var fee: UInt64 = 0 - public var isInstantSend: Bool = false - public var isAssetLock: Bool = false - public var memo: String? - - public init() {} - - public mutating func addInput(_ input: CoreTransactionInput) { - inputs.append(input) - } - - public mutating func addOutput(to address: String, amount: UInt64, isChange: Bool = false) { - let output = CoreTransactionOutput( - index: UInt32(outputs.count), - address: address, - amount: amount, - scriptPubKey: Data(), // Will be filled by SDK - isChange: isChange - ) - outputs.append(output) - } - - public var totalInputAmount: UInt64 { - inputs.compactMap { $0.amount }.reduce(0, +) - } - - public var totalOutputAmount: UInt64 { - outputs.reduce(0) { $0 + $1.amount } - } - - public var calculatedFee: UInt64 { - guard totalInputAmount >= totalOutputAmount else { return 0 } - return totalInputAmount - totalOutputAmount - } -} \ No newline at end of file diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Models/CoreTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Models/CoreTypes.swift deleted file mode 100644 index c4c529863a6..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Models/CoreTypes.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Foundation - -// Core SDK Types -// Note: These are now defined in their respective files: -// - DashNetwork is defined in WalletFFIBridge.swift -// - SPVClient is defined in SPVClient.swift -public typealias WalletFFI = Any - -// TransactionType is now defined in HDTransaction.swift - -// AddressType is now defined in HDWallet.swift - -// Sync state enum -public enum SyncState: String { - case notStarted = "not_started" - case syncing = "syncing" - case synced = "synced" - case error = "error" - - var displayName: String { - switch self { - case .notStarted: return "Not Started" - case .syncing: return "Syncing" - case .synced: return "Synced" - case .error: return "Error" - } - } -} - -// Watch status for addresses -public enum WatchStatus: String { - case active = "active" - case inactive = "inactive" - case error = "error" - - var displayName: String { - switch self { - case .active: return "Watching" - case .inactive: return "Not Watching" - case .error: return "Error" - } - } -} - -// InstantLock result -public struct InstantLock { - public let txid: String - public let isConfirmed: Bool - public let signature: Data? - public let confirmationTime: Date? - - public init(txid: String, isConfirmed: Bool, signature: Data? = nil, confirmationTime: Date? = nil) { - self.txid = txid - self.isConfirmed = isConfirmed - self.signature = signature - self.confirmationTime = confirmationTime - } -} - -// WalletError is now defined in WalletManager.swift - -// AssetLock errors -public enum AssetLockError: LocalizedError { - case insufficientBalance - case assetLockGenerationFailed - case instantLockTimeout - case broadcastFailed(String) - - public var errorDescription: String? { - switch self { - case .insufficientBalance: - return "Insufficient balance to create asset lock" - case .assetLockGenerationFailed: - return "Failed to generate asset lock transaction" - case .instantLockTimeout: - return "Timed out waiting for InstantLock confirmation" - case .broadcastFailed(let reason): - return "Failed to broadcast transaction: \(reason)" - } - } -} \ No newline at end of file diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Models/FilterMatch.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Models/FilterMatch.swift deleted file mode 100644 index fa847e70e9b..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Models/FilterMatch.swift +++ /dev/null @@ -1,173 +0,0 @@ -// -// FilterMatch.swift -// SwiftDashSDK -// -// Models for compact filters from SPV client -// - -import Foundation -import DashSDKFFI - -/// A single compact filter with its height and data -public struct CompactFilter: Identifiable { - public var id: UInt32 { height } - - /// Block height for this filter - public let height: UInt32 - - /// Filter data bytes - public let data: Data - - public init(height: UInt32, data: Data) { - self.height = height - self.data = data - } - - // NOTE: FFI initializer commented out - FFICompactFilter not available in current FFI - // /// Initialize from FFI struct - // public init(from ffiFilter: FFICompactFilter) { - // self.height = ffiFilter.height - // - // if let dataPtr = ffiFilter.data, ffiFilter.data_len > 0 { - // self.data = Data(bytes: dataPtr, count: Int(ffiFilter.data_len)) - // } else { - // self.data = Data() - // } - // } - - /// Get filter size in bytes - public var sizeInBytes: Int { - data.count - } -} - -/// Collection of compact filters -public struct CompactFilters { - public let filters: [CompactFilter] - - public init(filters: [CompactFilter]) { - self.filters = filters - } - - // NOTE: FFI initializer commented out - FFICompactFilters not available in current FFI - // /// Initialize from FFI struct - // public init(from ffiFilters: FFICompactFilters) { - // var filters: [CompactFilter] = [] - // - // if let filtersPtr = ffiFilters.filters { - // for i in 0.. - private let config: UnsafeMutablePointer + private var client: UnsafeMutablePointer? + private var config: UnsafeMutablePointer? // Sync tracking @@ -144,7 +144,7 @@ class SPVClient: @unchecked Sendable { } func getSyncProgress() -> SPVSyncProgress { - guard let ptr = dash_spv_ffi_client_get_sync_progress(client) else { + guard let ptr = dash_spv_ffi_client_get_manager_sync_progress(client) else { print("[SPV][GetSyncProgress] Failed to get sync progress (Should only fail if client is nil, but client is not nil)") return SPVSyncProgress.default() } @@ -266,6 +266,9 @@ class SPVClient: @unchecked Sendable { func destroy() { dash_spv_ffi_client_destroy(client) dash_spv_ffi_config_destroy(config) + + client = nil + config = nil } // MARK: - Synchronization @@ -290,6 +293,24 @@ class SPVClient: @unchecked Sendable { } } + // MARK: - Broadcast transactions + func broadcastTransaction(_ transactionData: Data) throws { + try transactionData.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) in + guard let txBytes = ptr.bindMemory(to: UInt8.self).baseAddress else { + throw SPVError.transactionBroadcastFailed("Invalid transaction data pointer") + } + let result = dash_spv_ffi_client_broadcast_transaction( + client, + txBytes, + UInt(transactionData.count), + ) + + if result != 0 { + throw SPVError.transactionBroadcastFailed(SPVClient.getLastDashFFIError()) + } + } + } + // MARK: - Wallet Manager Access /// Produce a Swift wallet manager that shares the SPV client's underlying wallet state. @@ -313,6 +334,7 @@ public enum SPVError: LocalizedError { case alreadySyncing case syncFailed(String) case storageOperationFailed(String) + case transactionBroadcastFailed(String) public var errorDescription: String? { switch self { @@ -332,6 +354,8 @@ public enum SPVError: LocalizedError { return "Sync failed: \(reason)" case let .storageOperationFailed(reason): return reason + case let .transactionBroadcastFailed(reason): + return "Transaction broadcast failed: \(reason)" } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVTypes.swift index d18293387b0..3736c926f54 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVTypes.swift @@ -53,7 +53,7 @@ public enum SPVSyncState: UInt32, Sendable { public struct SPVBlockHeadersProgress: Sendable { public let state: SPVSyncState - public let currentHeight: UInt32 + public let tipHeight: UInt32 public let targetHeight: UInt32 public let processed: UInt32 public let buffered: UInt32 @@ -62,7 +62,7 @@ public struct SPVBlockHeadersProgress: Sendable { public init(_ ffi: FFIBlockHeadersProgress) { state = SPVSyncState(rawValue: ffi.state.rawValue) ?? .unknown - currentHeight = ffi.current_height + tipHeight = ffi.tip_height targetHeight = ffi.target_height processed = ffi.processed buffered = ffi.buffered diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/FilterMatchService.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/FilterMatchService.swift deleted file mode 100644 index eb9648e7726..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/FilterMatchService.swift +++ /dev/null @@ -1,273 +0,0 @@ -// -// FilterMatchService.swift -// SwiftExampleApp -// -// Service for querying and batch-loading filter matches from SPV client -// - -import Foundation -import DashSDKFFI - -/// Service for managing compact filter queries with batch loading and caching -@MainActor -public class FilterMatchService: ObservableObject { - // MARK: - Published Properties - - /// All loaded compact filters (sorted by height descending) - @Published public private(set) var filters: [CompactFilter] = [] - - /// Matched filter heights (filters that matched wallet addresses) - @Published public private(set) var matchedHeights: Set = [] - - /// Loading state - @Published public private(set) var isLoading = false - - /// Error state - @Published public private(set) var error: FilterMatchError? - - /// Total height range available - @Published public private(set) var heightRange: ClosedRange? - - // MARK: - Private Properties - - /// Reference to wallet service for SPV client access - private weak var walletService: WalletService? - - /// Batch size for loading (must be ≤ 10,000 per FFI constraint) - private let batchSize: UInt32 = 1_000 - - /// Pre-fetch threshold (load more when this many rows from end) - private let prefetchThreshold: Int = 50 - - /// Cache of loaded height ranges to avoid duplicate queries - private var loadedRanges: [ClosedRange] = [] - - // MARK: - Initialization - - public init(walletService: WalletService) { - self.walletService = walletService - } - - // MARK: - Computed Properties - - /// Filters that matched wallet addresses - public var matchedFilters: [CompactFilter] { - filters.filter { matchedHeights.contains($0.height) } - } - - /// Check if a specific height has a matched filter - public func isFilterMatched(_ height: UInt32) -> Bool { - matchedHeights.contains(height) - } - - // MARK: - Public Methods - - /// Initialize the service and load the initial batch - public func initialize(endHeight: UInt32) async { - print("🔍 FilterMatchService: Initializing with endHeight=\(endHeight)") - self.heightRange = 0...endHeight - await loadMatchedHeights() - await loadInitialBatch() - } - - /// Update the height range (when sync progresses) - public func updateHeightRange(endHeight: UInt32) { - self.heightRange = 0...endHeight - } - - /// Jump to a specific height and load surrounding data - public func jumpTo(height: UInt32) async { - guard let range = heightRange, - range.contains(height) else { - error = .invalidRange("Height \(height) is outside valid range") - return - } - - // Clear existing filters and load around the target height - filters = [] - loadedRanges = [] - - // Load batch containing the target height, avoiding underflow - let startHeight: UInt32 - if height >= batchSize / 2 { - startHeight = height - batchSize / 2 - } else { - startHeight = range.lowerBound - } - await loadBatch(startHeight: startHeight) - } - - /// Check if we need to prefetch more data based on scroll position - public func checkPrefetch(displayedIndex: Int) async { - guard !isLoading, - let range = heightRange else { - return - } - - // Check if we're near the end of loaded data - if displayedIndex >= filters.count - prefetchThreshold { - // Load more data before the oldest loaded height - if let oldestLoaded = filters.last?.height, - oldestLoaded > range.lowerBound { - // Avoid underflow when calculating startHeight - let startHeight: UInt32 - if oldestLoaded >= batchSize { - startHeight = max(range.lowerBound, oldestLoaded - batchSize) - } else { - startHeight = range.lowerBound - } - await loadBatch(startHeight: startHeight) - } - } - - // Check if we're near the beginning and need newer data - if displayedIndex < prefetchThreshold { - if let newestLoaded = filters.first?.height, - newestLoaded < range.upperBound { - let startHeight = min(range.upperBound - batchSize + 1, newestLoaded + 1) - await loadBatch(startHeight: startHeight) - } - } - } - - /// Reload all data (useful after sync completes) - public func reload() async { - filters = [] - loadedRanges = [] - await loadInitialBatch() - } - - // MARK: - Private Methods - - /// Load matched filter heights from FFI - /// NOTE: FFI functions for filter matching are not yet available in current FFI - private func loadMatchedHeights() async { - guard let _ = walletService, - let _ = heightRange else { - print("❌ FilterMatchService: Cannot load matched heights - client not available") - return - } - - print("🔍 FilterMatchService: Filter matching FFI not available in current build") - - // NOTE: The following FFI functions are not available in the current FFI: - // - dash_spv_ffi_client_get_filter_matched_heights - // - dash_spv_ffi_filter_matches_destroy - // When these become available, uncomment and use: - // - // guard let client = walletService.spvClientHandle else { return } - // let matchesPtr = dash_spv_ffi_client_get_filter_matched_heights(client, range.lowerBound, range.upperBound + 1) - // defer { if let ptr = matchesPtr { dash_spv_ffi_filter_matches_destroy(ptr) } } - // guard let ptr = matchesPtr else { return } - // let ffiMatches = ptr.pointee - // let filterMatches = FilterMatches(from: ffiMatches) - // var heights = Set() - // for entry in filterMatches.entries { heights.insert(entry.height) } - // matchedHeights = heights - - matchedHeights = Set() - } - - private func loadInitialBatch() async { - guard let range = heightRange else { return } - - // Load the most recent batch, avoiding underflow - let startHeight: UInt32 - if range.upperBound >= batchSize - 1 { - startHeight = max(range.lowerBound, range.upperBound - batchSize + 1) - } else { - startHeight = range.lowerBound - } - await loadBatch(startHeight: startHeight) - } - - /// NOTE: FFI functions for loading compact filters are not yet available in current FFI - private func loadBatch(startHeight: UInt32) async { - guard let _ = walletService else { - print("❌ FilterMatchService: WalletService not available") - error = .clientNotAvailable - return - } - - guard let range = heightRange else { - print("❌ FilterMatchService: Height range not set") - error = .clientNotAvailable - return - } - - // Calculate end height (exclusive, max batchSize) - let endHeight = min(range.upperBound + 1, startHeight + batchSize) - - print("🔍 FilterMatchService: Loading filters from \(startHeight) to \(endHeight)") - print("🔍 FilterMatchService: Compact filter loading FFI not available in current build") - - // Check if this range is already loaded - let requestedRange = startHeight...endHeight - if loadedRanges.contains(where: { $0.overlaps(requestedRange) }) { - return - } - - isLoading = true - error = nil - - // NOTE: The following FFI functions are not available in the current FFI: - // - dash_spv_ffi_client_load_filters - // - dash_spv_ffi_compact_filters_destroy - // When these become available, uncomment and use: - // - // guard let client = walletService.spvClientHandle else { - // error = .clientNotAvailable - // isLoading = false - // return - // } - // let filtersPtr = dash_spv_ffi_client_load_filters(client, startHeight, endHeight) - // defer { if let ptr = filtersPtr { dash_spv_ffi_compact_filters_destroy(ptr) } } - // guard let ptr = filtersPtr else { - // if let errorCStr = dash_spv_ffi_get_last_error() { - // error = .ffiError(String(cString: errorCStr)) - // } else { - // error = .unknown - // } - // isLoading = false - // return - // } - // let ffiFilters = ptr.pointee - // let compactFilters = CompactFilters(from: ffiFilters) - // var allFilters = filters + compactFilters.filters - // allFilters.sort { $0.height > $1.height } - // var seenHeights = Set() - // allFilters = allFilters.filter { filter in - // if seenHeights.contains(filter.height) { return false } - // seenHeights.insert(filter.height) - // return true - // } - // filters = allFilters - - // Currently return empty filters since FFI is not available - loadedRanges.append(startHeight...(endHeight - 1)) - print("🔍 FilterMatchService: Stubbed - no filters loaded (FFI not available)") - - isLoading = false - } -} - -// MARK: - CompactFilter Hashable Conformance - -extension CompactFilter: Hashable { - public static func == (lhs: CompactFilter, rhs: CompactFilter) -> Bool { - lhs.height == rhs.height - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(height) - } -} - -// MARK: - Helper Extensions - -extension Data { - /// Convert Data to hex string for display - public func hexEncodedString() -> String { - return map { String(format: "%02hhx", $0) }.joined() - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/WalletService.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/WalletService.swift index b7416841bca..12c3cefafd6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/WalletService.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/WalletService.swift @@ -2,30 +2,6 @@ import Foundation import SwiftData import Combine -// MARK: - Timeout Helper - -struct TimeoutError: Error {} - -func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - // Add the actual operation - group.addTask { - try await operation() - } - - // Add timeout task - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw TimeoutError() - } - - // Return first result (either completion or timeout) - let result = try await group.next()! - group.cancelAll() - return result - } -} - // MARK: - Logging Preferences public enum LoggingPreset: String { @@ -108,213 +84,100 @@ func print(_ items: Any..., separator: String = " ", terminator: String = "\n") Swift.print(output, terminator: terminator) } +// DESIGN NOTE: This class feels like something that should be in the example app, +// we, as sdk developers, provide the tools and ffi wrappers, but how to +// use them depends on the sdk user, for example, by implementing the SPV event +// handlers, the user can decide what to do with the events, but if we implement them in the sdk +// we are taking that decision for them, and maybe not all users want the same thing @MainActor public class WalletService: ObservableObject { - // Sendable wrapper to move non-Sendable references across actor boundaries when safe - private final class SendableBox: @unchecked Sendable { let value: T; init(_ v: T) { self.value = v } } - public static let shared = WalletService() - // Published properties @Published public private(set) var syncProgress: SPVSyncProgress = SPVSyncProgress.default() - @Published var currentWallet: HDWallet? // Placeholder - use WalletManager instead - @Published public var balance = Balance(confirmed: 0, unconfirmed: 0, immature: 0) @Published public var masternodesEnabled = true - - // Absolute heights for header sync display (current/target) - @Published public var blocksHit: Int = 0 @Published public var lastSyncError: Error? - - private var activeSyncStartTimestamp: TimeInterval = 0 - @Published public var transactions: [CoreTransaction] = [] // Use HDTransaction from wallet - @Published var currentNetwork: AppNetwork = .testnet + @Published var network: AppNetwork // Internal properties - private var modelContainer: ModelContainer? + private var modelContainer: ModelContainer - // Exposed for WalletViewModel - read-only access to the properly initialized WalletManager - public private(set) var walletManager: CoreWalletManager? - - // SPV Client - new wrapper with proper sync support - private var spvClient: SPVClient? - - // Mock SDK for now - will be replaced with real SDK - private var sdk: Any? + // SPV Client and Wallet wrappers + private var spvClient: SPVClient + public private(set) var walletManager: CoreWalletManager - private init() {} - - deinit { - // Avoid capturing self across an async boundary; capture the client locally - guard let client = spvClient else { return } - Task { @MainActor in - client.stopSync() - client.destroy() - } - } - - public func configure(modelContainer: ModelContainer, network: AppNetwork = .testnet) { - LoggingPreferences.configure() - SDKLogger.log("=== WalletService.configure START ===", minimumLevel: .medium) + public init(modelContainer: ModelContainer, network: AppNetwork) { self.modelContainer = modelContainer - self.currentNetwork = network - SDKLogger.log("ModelContainer set: \(modelContainer)", minimumLevel: .high) - SDKLogger.log("Network set: \(network.rawValue)", minimumLevel: .medium) - - initializeNewSPVClient() + self.network = network + + LoggingPreferences.configure() - SDKLogger.log("Loading current wallet...", minimumLevel: .medium) + let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("SPV").appendingPathComponent(network.rawValue).path - guard modelContainer != nil else { return } + // For simplicity, lets unwrap the error. This can only fail due to + // IO errors when working with the internal storage system, I don't + // see how we can recover from that right now easily + let spvClient = try! SPVClient( + network: network.sdkNetwork, + dataDir: dataDir, + startHeight: 0, + ) - // The WalletManager will handle loading and restoring wallets from persistence - // It will restore the serialized wallet bytes to the FFI wallet manager - // This happens automatically in WalletManager.init() through loadWallets() + self.spvClient = spvClient - // Just sync the current wallet from WalletManager - if let walletManager = self.walletManager { - Task { - // WalletManager's loadWallets() is called in its init - // We just need to sync the current wallet - if let wallet = walletManager.currentWallet { - self.currentWallet = wallet - await loadWallet(wallet) - } else if let firstWallet = walletManager.wallets.first { - self.currentWallet = firstWallet - await loadWallet(firstWallet) - } - } - } + // Create the SDK wallet manager by reusing the SPV client's shared manager + // TODO: Investigate this error + self.walletManager = try! CoreWalletManager(spvClient: spvClient, modelContainer: modelContainer) - SDKLogger.log("=== WalletService.configure END ===", minimumLevel: .medium) + spvClient.setProgressUpdateEventHandler(SPVProgressUpdateEventHandlerImpl(walletService: self)) + spvClient.setSyncEventsHandler(SPVSyncEventsHandlerImpl(walletService: self)) + spvClient.setNetworkEventsHandler(SPVNetworkEventsHandlerImpl(walletService: self)) + spvClient.setWalletEventsHandler(SPVWalletEventsHandlerImpl(walletService: self)) } - - public func setSharedSDK(_ sdk: Any) { - self.sdk = sdk - SDKLogger.log("✅ WalletService configured with shared SDK", minimumLevel: .medium) + + deinit { + spvClient.stopSync() + spvClient.destroy() } private func initializeNewSPVClient() { - SDKLogger.log("Initializing SPV Client for \(self.currentNetwork.rawValue)...", minimumLevel: .medium) + SDKLogger.log("Initializing SPV Client for \(self.self.network.rawValue)...", minimumLevel: .medium) - let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("SPV").appendingPathComponent(self.currentNetwork.rawValue).path - // Currently always starting at 0 for simplicity. While this is - // currently configurable, the SPVClient should decide using the wallet - // creation time to determine the start height, removing usage complexity - // and possible missusage errors - let startHeight: UInt32 = 0 - let net = currentNetwork + let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("SPV").appendingPathComponent(self.network.rawValue).path - SDKLogger.log("[SPV][Baseline] Using baseline startFromHeight=\(startHeight) on \(net.rawValue) during initialize()", minimumLevel: .high) + // This ensures no memory leaks when creating a new client + // and unlocks the storage in case we are about to use the same (we probably are) + self.spvClient.destroy() - do { - // This ensures no memory leaks when creating a new client - // and unlocks the storage in case we are about to use the same (we are) - if self.spvClient != nil { - self.spvClient!.destroy() - } - - spvClient = try SPVClient( - network: self.currentNetwork.sdkNetwork, - dataDir: dataDir, - startHeight: startHeight, - ) - - spvClient?.setProgressUpdateEventHandler(SPVProgressUpdateEventHandlerImpl(walletService: self)) - spvClient?.setSyncEventsHandler(SPVSyncEventsHandlerImpl(walletService: self)) - spvClient?.setNetworkEventsHandler(SPVNetworkEventsHandlerImpl(walletService: self)) - spvClient?.setWalletEventsHandler(SPVWalletEventsHandlerImpl(walletService: self)) - - try spvClient?.setMasternodeSyncEnabled(self.masternodesEnabled) - } catch { - SDKLogger.error("Failed to initialize SPV Client: \(error)") - self.lastSyncError = error - return - } + // For simplicity, lets unwrap the error. This can only fail due to + // IO errors when working with the internal storage system, I don't + // see how we can recover from that right now easily + self.spvClient = try! SPVClient( + network: self.self.network.sdkNetwork, + dataDir: dataDir, + startHeight: 0, + ) - SDKLogger.log("✅ SPV Client initialized successfully for \(net.rawValue) (deferred start)", minimumLevel: .medium) + self.spvClient.setProgressUpdateEventHandler(SPVProgressUpdateEventHandlerImpl(walletService: self)) + self.spvClient.setSyncEventsHandler(SPVSyncEventsHandlerImpl(walletService: self)) + self.spvClient.setNetworkEventsHandler(SPVNetworkEventsHandlerImpl(walletService: self)) + self.spvClient.setWalletEventsHandler(SPVWalletEventsHandlerImpl(walletService: self)) - // Capture current references on the main actor to avoid cross-actor hops later - guard let client = spvClient, let mc = self.modelContainer else { return } + try! self.spvClient.setMasternodeSyncEnabled(self.masternodesEnabled) + + SDKLogger.log("✅ SPV Client initialized successfully for \(self.network.rawValue) (deferred start)", minimumLevel: .medium) // Create the SDK wallet manager by reusing the SPV client's shared manager - do { - let sdkWalletManager = try client.getWalletManager() - let wrapper = try CoreWalletManager(sdkWalletManager: sdkWalletManager, modelContainer: mc) - self.walletManager = wrapper - self.walletManager?.transactionService = TransactionService( - walletManager: wrapper, - modelContainer: mc, - spvClient: client - ) - SDKLogger.log("✅ WalletManager wrapper initialized successfully", minimumLevel: .medium) - } catch { - SDKLogger.error("❌ Failed to initialize WalletManager wrapper:\nError: \(error)") - } - } - - // MARK: - Wallet Management - - public func createWallet(label: String, mnemonic: String? = nil, pin: String = "1234", isImport: Bool = false) async throws -> HDWallet { - print("=== WalletService.createWallet START ===") - print("Label: \(label)") - print("Has mnemonic: \(mnemonic != nil)") - print("PIN: \(pin)") - print("ModelContainer available: \(modelContainer != nil)") - - guard let walletManager = walletManager else { - print("ERROR: WalletManager not initialized") - print("WalletManager is nil") - throw WalletError.notImplemented("WalletManager not initialized") - } - - do { - // Create wallet using our refactored WalletManager that wraps FFI - print("WalletManager available, creating wallet...") - let wallet = try await walletManager.createWallet( - label: label, - mnemonic: mnemonic, - pin: pin, - isImport: isImport - ) - - print("Wallet created by WalletManager, ID: \(wallet.id)") - print("Loading wallet...") - - // Load the newly created wallet - await loadWallet(wallet) - - // Persist sync-from changes - try modelContainer?.mainContext.save() - - print("=== WalletService.createWallet SUCCESS ===") - return wallet - } catch { - print("=== WalletService.createWallet FAILED ===") - print("Error type: \(type(of: error))") - print("Error: \(error)") - throw error - } - } - - public func loadWallet(_ wallet: HDWallet) async { - currentWallet = wallet - - // Load transactions - await loadTransactions() - - // Update balance - updateBalance() + // TODO: Investigate this error + self.walletManager = try! CoreWalletManager(spvClient: self.spvClient, modelContainer: self.modelContainer) + + SDKLogger.log("✅ WalletManager wrapper initialized successfully", minimumLevel: .medium) } - - // Placeholder for balance update logic (i think events manage - // this but have to confirm) - private func updateBalance() {} // MARK: - Trusted Mode / Masternode Sync public func setMasternodesEnabled(_ enabled: Bool) { masternodesEnabled = enabled // Try to apply immediately if the client exists - do { try spvClient?.setMasternodeSyncEnabled(enabled) } catch { /* ignore */ } + do { try spvClient.setMasternodeSyncEnabled(enabled) } catch { /* ignore */ } } public func disableMasternodeSync() { setMasternodesEnabled(false) @@ -326,11 +189,6 @@ public class WalletService: ObservableObject { // MARK: - Sync Management public func startSync() async { - guard let spvClient = spvClient else { - print("❌ SPV Client not initialized") - return - } - lastSyncError = nil do { @@ -344,22 +202,20 @@ public class WalletService: ObservableObject { public func stopSync() { // pausing and resuming is not supported so, the trick is the following, // stop the old client and create a new one in its initial state xd - guard let client = spvClient else { return } - - client.stopSync() - - initializeNewSPVClient() + spvClient.stopSync() + self.initializeNewSPVClient() } + public func broadcastTransaction(_ data: Data) throws { + try self.spvClient.broadcastTransaction(data) + } + public func clearSpvStorage() { if syncProgress.state.isRunning() { print("[SPV][Clear] Sync task is running, cannot clear storage") return } - - guard let spvClient = spvClient else { return } - print("[SPV][Clear] Starting storage clear operation...") @@ -383,150 +239,19 @@ public class WalletService: ObservableObject { // MARK: - Network Management public func switchNetwork(to network: AppNetwork) async { - guard network != currentNetwork else { return } - currentNetwork = network + guard network != self.network else { return } + self.network = network print("=== WalletService.switchNetwork START ===") - print("Switching from \(currentNetwork.rawValue) to \(network.rawValue)") + print("Switching from \(self.network.rawValue) to \(network.rawValue)") self.stopSync() - // Clear current wallet manager - walletManager = nil - currentWallet = nil - transactions = [] - balance = Balance(confirmed: 0, unconfirmed: 0, immature: 0) - - // Reconfigure with new network - if let modelContainer = modelContainer { - configure(modelContainer: modelContainer, network: network) - } + self.initializeNewSPVClient() print("=== WalletService.switchNetwork END ===") } - // MARK: - Address Management - - public func generateAddresses(for account: HDAccount, count: Int, type: AddressType) async throws { - guard let walletManager = self.walletManager else { - throw WalletError.notImplemented("WalletManager not available") - } - - try await walletManager.generateAddresses(for: account, count: count, type: type) - try? modelContainer?.mainContext.save() - } - - // MARK: - Transaction Management - - public func sendTransaction(to address: String, amount: UInt64, memo: String? = nil) async throws -> String { - 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 - let transaction = HDTransaction(txHash: txid, timestamp: Date()) - transaction.amount = -Int64(amount) - transaction.fee = 1000 - transaction.type = "sent" - transaction.wallet = wallet - - modelContainer?.mainContext.insert(transaction) - try? modelContainer?.mainContext.save() - - // Update balance - updateBalance() - - return txid - } - - private func loadTransactions() async { - guard let wallet = currentWallet else { return } - - // Convert HDTransaction to CoreTransaction - transactions = wallet.transactions.map { hdTx in - CoreTransaction( - id: hdTx.txHash, - amount: hdTx.amount, - fee: hdTx.fee, - timestamp: hdTx.timestamp, - blockHeight: hdTx.blockHeight != nil ? Int64(hdTx.blockHeight!) : nil, - confirmations: hdTx.confirmations, - type: hdTx.type, - memo: nil, - inputs: [], - outputs: [], - isInstantSend: hdTx.isInstantSend, - isAssetLock: false, - rawData: hdTx.rawTransaction - ) - }.sorted { $0.timestamp > $1.timestamp } - } - - // MARK: - Address Management - - public func getNewAddress() async throws -> String { - guard let wallet = currentWallet else { - throw WalletError.notImplemented("No active wallet") - } - - // Find next unused address or create new one - let currentAccount = wallet.accounts.first ?? wallet.createAccount() - let existingAddresses = currentAccount.externalAddresses - let nextIndex = UInt32(existingAddresses.count) - - // Mock address generation - let address = "yMockAddress\(nextIndex)" - - let hdAddress = HDAddress( - address: address, - index: nextIndex, - derivationPath: "m/44'/5'/0'/0/\(nextIndex)", - addressType: .external, - account: currentAccount - ) - - modelContainer?.mainContext.insert(hdAddress) - try? modelContainer?.mainContext.save() - - return address - } - - // MARK: - Wallet Deletion - - public func walletDeleted(_ wallet: HDWallet) async { - // If this was the current wallet, clear it - if currentWallet?.id == wallet.id { - currentWallet = nil - transactions = [] - balance = Balance(confirmed: 0, unconfirmed: 0, immature: 0) - } - - // Remove wallet from observable state BEFORE SwiftData delete - // This prevents "Never access a full future backing data" crash - if let walletManager = walletManager { - await walletManager.removeWalletFromObservableState(wallet) - - // Set a new current wallet if available - if currentWallet == nil, let firstWallet = walletManager.wallets.first { - await loadWallet(firstWallet) - } - } - } - - // MARK: - Helpers - - private func generateMnemonic() -> String { - // Mock mnemonic generation - let words = ["abandon", "ability", "able", "about", "above", "absent", - "absorb", "abstract", "absurd", "abuse", "access", "accident"] - return words.joined(separator: " ") - } - // MARK: - SPV Event Handlers implementations internal final class SPVProgressUpdateEventHandlerImpl: SPVProgressUpdateEventHandler, Sendable { @@ -554,18 +279,7 @@ public class WalletService: ObservableObject { SDKLogger.log("Sync started for manager: \(manager)", minimumLevel: .medium) } - func onComplete(_ headerTip: UInt32) { - Task { @MainActor in - SDKLogger.log("Sync completed, header tip: \(headerTip)", minimumLevel: .medium) - - if let wm = walletService.walletManager { - for wallet in wm.wallets { - await wm.syncWalletStateFromRust(for: wallet) - } - } - } - } - + func onComplete(_ headerTip: UInt32) {} func onBlockHeadersStored(_ tipHeight: UInt32) {} func onBlockHeadersSyncCompleted(_ tipHeight: UInt32) {} func onFilterHeadersStored(_ startHeight: UInt32, _ endHeight: UInt32, _ tipHeight: UInt32) {} @@ -573,11 +287,7 @@ public class WalletService: ObservableObject { func onFilterStored(_ startHeight: UInt32, _ endHeight: UInt32) {} func onFilterSyncCompleted(_ tipHeight: UInt32) {} func onBlocksNeeded(_ height: UInt32, _ hash: Data, _ count: UInt32) {} - func onBlocksProcessed(_ height: UInt32, _ hash: Data, _ newAddressCount: UInt32) { - Task { @MainActor in - walletService.blocksHit += 1 - } - } + func onBlocksProcessed(_ height: UInt32, _ hash: Data, _ newAddressCount: UInt32) {} func onMasternodeStateUpdated(_ height: UInt32) {} func onChainLockReceived(_ height: UInt32, _ hash: Data, _ signature: Data, _ validated: Bool) {} func onInstantLockReceived(_ txid: Data, _ instantLockData: Data, _ validated: Bool) {} @@ -623,19 +333,7 @@ public class WalletService: ObservableObject { _ txid: Data, _ amount: Int64, _ addresses: [String] - ) { - Task { @MainActor in - if let wm = walletService.walletManager { - for wallet in wm.wallets { - await wm.syncWalletStateFromRust(for: wallet) - } - } - - if walletService.currentWallet != nil { - await walletService.loadTransactions() - } - } - } + ) {} func onBalanceUpdated( _ walletId: String, @@ -643,15 +341,7 @@ public class WalletService: ObservableObject { _ unconfirmed: UInt64, _ immature: UInt64, _ locked: UInt64 - ) { - Task { @MainActor in - walletService.balance = Balance( - confirmed: spendable, - unconfirmed: unconfirmed, - immature: immature - ) - } - } + ) {} } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/ModelContainerHelper.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/ModelContainerHelper.swift index 758e56e5c5a..6b5b13e4e34 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/ModelContainerHelper.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/ModelContainerHelper.swift @@ -6,10 +6,6 @@ public struct ModelContainerHelper { let schema = Schema([ // Core models HDWallet.self, - HDAddress.self, - HDTransaction.self, - HDUTXO.self, - HDWatchedAddress.self, // Platform models PersistentIdentity.self, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/CoreWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/CoreWalletManager.swift index 2820cad7982..4a56f80befa 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/CoreWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/CoreWalletManager.swift @@ -12,42 +12,20 @@ import DashSDKFFI @MainActor public class CoreWalletManager: ObservableObject { @Published public private(set) var wallets: [HDWallet] = [] - @Published public private(set) var currentWallet: HDWallet? - @Published public private(set) var isLoading = false @Published public private(set) var error: WalletError? // SDK wallet manager - this is the real wallet manager from the SDK - private let sdkWalletManager: SwiftDashSDK.WalletManager + private let sdkWalletManager: WalletManager private let modelContainer: ModelContainer private let storage = WalletStorage() - // Services (initialize in WalletService when SPV is available) - var transactionService: TransactionService? - - /// Initialize with an SDK wallet manager - /// - Parameters: - /// - sdkWalletManager: The SDK wallet manager from SwiftDashSDK - /// - modelContainer: SwiftData model container for persistence - init(sdkWalletManager: SwiftDashSDK.WalletManager, modelContainer: ModelContainer? = nil) throws { + /// Initialize with a valid SPVClient instance + init(spvClient: SPVClient, modelContainer: ModelContainer) throws { print("=== WalletManager.init START ===") - self.sdkWalletManager = sdkWalletManager - - if let container = modelContainer { - print("Using provided ModelContainer") - self.modelContainer = container - } else { - do { - print("Creating ModelContainer...") - self.modelContainer = try ModelContainer(for: HDWallet.self, HDAccount.self, HDAddress.self, HDUTXO.self, HDTransaction.self) - print("✅ ModelContainer created") - } catch { - print("❌ Failed to create ModelContainer: \(error)") - throw error - } - } - - // Note: TransactionService is created in WalletService once SPV/UTXO context exists + self.sdkWalletManager = try spvClient.getWalletManager() + self.modelContainer = modelContainer + print("=== WalletManager.init SUCCESS ===") Task { @@ -56,29 +34,13 @@ public class CoreWalletManager: ObservableObject { } // MARK: - Wallet Management - func createWallet(label: String, mnemonic: String? = nil, pin: String, isImport: Bool = false) async throws -> HDWallet { + public func createWallet(label: String, mnemonic: String, pin: String, isImport: Bool = false) async throws -> HDWallet { print("WalletManager.createWallet called") - isLoading = true - defer { isLoading = false } - // Generate or validate mnemonic using SDK - let finalMnemonic: String - if let mnemonic = mnemonic { - print("Validating provided mnemonic...") - guard SwiftDashSDK.Mnemonic.validate(mnemonic) else { - print("Mnemonic validation failed") - throw WalletError.invalidMnemonic - } - finalMnemonic = mnemonic - } else { - print("Generating new mnemonic...") - do { - finalMnemonic = try SwiftDashSDK.Mnemonic.generate(wordCount: 12) - // Do not log mnemonic to console - } catch { - print("Failed to generate mnemonic: \(error)") - throw WalletError.seedGenerationFailed - } + print("Validating provided mnemonic...") + guard SwiftDashSDK.Mnemonic.validate(mnemonic) else { + print("Mnemonic validation failed") + throw WalletError.invalidMnemonic } // Add wallet through SDK (with bitfield networks) and capture serialized bytes for persistence @@ -87,13 +49,11 @@ public class CoreWalletManager: ObservableObject { do { // Calculate birthHeight based on wallet type // For imported wallets: use 730k for mainnet, 0 for test/devnets (need to sync from genesis) - // For new wallets: use 0 to signal "use latest checkpoint" (FFI interprets 0 as None) let birthHeight: UInt32 if isImport { // Imported wallet should sync from a reasonable historical point - birthHeight = sdkWalletManager.network == .mainnet ? 730_000 : 1 // Use 1 instead of 0 to avoid "latest checkpoint" interpretation + birthHeight = sdkWalletManager.network == .mainnet ? 730_000 : 0 } else { - // New wallet: pass 0 to use latest checkpoint (FFI converts 0 -> None -> latest) birthHeight = 0 } @@ -101,7 +61,7 @@ public class CoreWalletManager: ObservableObject { // Add wallet using SDK's WalletManager with combined network bitfield and serialize let result = try sdkWalletManager.addWalletAndSerialize( - mnemonic: finalMnemonic, + mnemonic: mnemonic, passphrase: nil, birthHeight: birthHeight, accountOptions: .default, @@ -118,39 +78,33 @@ public class CoreWalletManager: ObservableObject { } // Create HDWallet model for SwiftUI - let appNetwork = AppNetwork(network: sdkWalletManager.network) - let wallet = HDWallet(label: label, network: appNetwork, isImported: isImport) - wallet.walletId = walletId + let network = AppNetwork(network: sdkWalletManager.network) + let wallet = HDWallet(walletId: walletId, serializedWalletBytes: serializedBytes, label: label, network: network, isImported: isImport) - // Persist serialized wallet bytes for restoration on next launch - wallet.serializedWalletBytes = serializedBytes - - // Store encrypted seed (if needed for UI purposes) do { - let seed = try SwiftDashSDK.Mnemonic.toSeed(mnemonic: finalMnemonic) + let seed = try SwiftDashSDK.Mnemonic.toSeed(mnemonic: mnemonic) let encryptedSeed = try storage.storeSeed(seed, pin: pin) - wallet.encryptedSeed = encryptedSeed + // TODO: Disabled while refactoring wallet.encryptedSeed = encryptedSeed } catch { print("Failed to store seed: \(error)") // Continue anyway - wallet is already created } - // Insert wallet into context + // Insert wallet into context ans save it modelContainer.mainContext.insert(wallet) - - // Create default account model - _ = wallet.createAccount(at: 0) - - // Sync complete wallet state from Rust managed info - try await syncWalletFromManagedInfo(for: wallet) - - // Save to database try modelContainer.mainContext.save() - - await loadWallets() - currentWallet = wallet return wallet + } + + public func deleteWallet(_ wallet: HDWallet) async throws { + let walletId = wallet.id + + wallets.removeAll(where: { $0.id == walletId }) + + // Now safe to delete from SwiftData (cascade will delete accounts/addresses) + modelContainer.mainContext.delete(wallet) + try modelContainer.mainContext.save() } func importWallet(label: String, network: AppNetwork, mnemonic: String, pin: String) async throws -> HDWallet { @@ -160,52 +114,6 @@ public class CoreWalletManager: ObservableObject { return wallet } - /// Restore a wallet from serialized bytes via SDK - public func restoreWalletFromBytes(_ walletBytes: Data) throws -> Data { - try sdkWalletManager.importWallet(from: walletBytes) - } - - /// Sync wallet data using SwiftDashSDK wrappers (no direct FFI in app) - private func syncWalletFromManagedInfo(for wallet: HDWallet) async throws { - guard let walletId = wallet.walletId else { throw WalletError.walletError("Wallet ID not available") } - let collection = try sdkWalletManager.getManagedAccountCollection(walletId: walletId) - - for account in wallet.accounts { - if let managed = collection.getBIP44Account(at: account.accountNumber) { - if let bal = try? managed.getBalance() { - account.confirmedBalance = bal.confirmed - account.unconfirmedBalance = bal.unconfirmed - } - if let pool = managed.getExternalAddressPool(), let infos = try? pool.getAddresses(from: 0, to: 20) { - account.externalAddresses.removeAll() - for info in infos { - let hd = HDAddress(address: info.address, index: info.index, derivationPath: info.path, addressType: .external, account: account) - hd.isUsed = info.used - modelContainer.mainContext.insert(hd) - account.externalAddresses.append(hd) - } - account.externalAddressIndex = UInt32(infos.count) - } - if let pool = managed.getInternalAddressPool(), let infos = try? pool.getAddresses(from: 0, to: 10) { - account.internalAddresses.removeAll() - for info in infos { - let hd = HDAddress(address: info.address, index: info.index, derivationPath: info.path, addressType: .internal, account: account) - hd.isUsed = info.used - modelContainer.mainContext.insert(hd) - account.internalAddresses.append(hd) - } - account.internalAddressIndex = UInt32(infos.count) - } - } - } - } - - // Removed: replaced by syncAccountAddresses(using SDK) - - public func unlockWallet(with pin: String) async throws -> Data { - return try storage.retrieveSeed(pin: pin) - } - public func decryptSeed(_ encryptedSeed: Data?) -> Data? { // This method is used internally by other services // In a real implementation, this would decrypt using the current PIN @@ -213,12 +121,6 @@ public class CoreWalletManager: ObservableObject { return nil } - /// Get wallet IDs via SDK wrapper - func getWalletIds() throws -> [Data] { try sdkWalletManager.getWalletIds() } - - /// Get wallet balance via SDK wrapper - func getWalletBalance(walletId: Data) throws -> (confirmed: UInt64, unconfirmed: UInt64) { try sdkWalletManager.getWalletBalance(walletId: walletId) } - public func changeWalletPIN(currentPIN: String, newPIN: String) async throws { // Retrieve seed with current PIN let seed = try storage.retrieveSeed(pin: currentPIN) @@ -239,64 +141,27 @@ public class CoreWalletManager: ObservableObject { return try storage.retrieveSeedWithBiometric() } - func createWatchOnlyWallet(label: String, extendedPublicKey: String) async throws -> HDWallet { - isLoading = true - defer { isLoading = false } - - let appNetwork = AppNetwork(network: sdkWalletManager.network) - let wallet = HDWallet(label: label, network: appNetwork, isWatchOnly: true) - - // Create account with extended public key - let account = wallet.createAccount(at: 0) - account.extendedPublicKey = extendedPublicKey - - // Generate addresses from extended public key - try await generateWatchOnlyAddresses(for: account, count: 20, type: .external) - try await generateWatchOnlyAddresses(for: account, count: 10, type: .internal) - - // Save to database - modelContainer.mainContext.insert(wallet) - try modelContainer.mainContext.save() - - await loadWallets() - currentWallet = wallet - - return wallet - } - - public func deleteWallet(_ wallet: HDWallet) async throws { - let walletId = wallet.id - - // Update observable state FIRST to prevent UI from accessing deleted relationships - // This prevents the "Never access a full future backing data" crash - if currentWallet?.id == walletId { - currentWallet = wallets.first(where: { $0.id != walletId }) - } - wallets.removeAll(where: { $0.id == walletId }) - - // Now safe to delete from SwiftData (cascade will delete accounts/addresses) - modelContainer.mainContext.delete(wallet) - try modelContainer.mainContext.save() + // MARK: - Account Management - // Reload to ensure consistency - await loadWallets() + /// Build a signed transaction + /// - Parameters: + /// - accountIndex: The account index to use + /// - outputs: The transaction outputs + /// - feePerKB: Fee per kilobyte in satoshis + /// - Returns: The unsigned transaction bytes + public func buildSignedTransaction(for wallet: HDWallet, accIndex: UInt32, outputs: [Transaction.Output], feeRate: FeeRate) throws -> (Data, UInt64) { + try sdkWalletManager.buildSignedTransaction(for: wallet, accIndex: accIndex, outputs: outputs, feeRate: feeRate) } - // MARK: - Transaction Management - /// Get transactions for a wallet /// - Parameters: /// - wallet: The wallet to get transactions for /// - accountIndex: The account index (default 0) /// - Returns: Array of wallet transactions - public func getTransactions(for wallet: HDWallet, accountIndex: UInt32 = 0) async throws -> [WalletTransaction] { - guard let walletId = wallet.walletId else { - throw WalletError.walletError("Wallet ID not available") - } - + public func getTransactions(for wallet: HDWallet, accountIndex: UInt32 = 0) -> [WalletTransaction] { // Get managed account - let managedAccount = try sdkWalletManager.getManagedAccount( - walletId: walletId, + let managedAccount = try! sdkWalletManager.getManagedAccount( + walletId: wallet.walletId, accountIndex: accountIndex, accountType: .standardBIP44 ) @@ -304,19 +169,60 @@ public class CoreWalletManager: ObservableObject { // Get current height (TODO: get from SPV client when available) let currentHeight: UInt32 = 0 - return try managedAccount.getTransactions(currentHeight: currentHeight) + return try! managedAccount.getTransactions(currentHeight: currentHeight) } - // MARK: - Account Management - + public func getBalance(for wallet: HDWallet) -> Balance { + let accounts = self.getAccounts(for: wallet) + + var confirmed: UInt64 = 0 + var unconfirmed: UInt64 = 0 + var immature: UInt64 = 0 + var locked: UInt64 = 0 + + for account in accounts { + confirmed += account.balance.confirmed + unconfirmed += account.balance.unconfirmed + immature += account.balance.immature + locked += account.balance.locked + } + + return Balance( + confirmed: confirmed, + unconfirmed: unconfirmed, + immature: immature, + locked: locked + ) + } + + public func getBalance(for wallet: HDWallet, accType: AccountType, accIndex: UInt32) -> Balance { + let account = try! sdkWalletManager.getManagedAccount( + walletId: wallet.walletId, + accountIndex: accIndex, + accountType: accType, + ) + return try! account.getBalance() + } + + public func getWallet(for wallet: HDWallet) -> Wallet? { + return try? sdkWalletManager.getWallet(id: wallet.walletId) + } + + public func getReceiveAddress(for wallet: HDWallet, accountIndex: UInt32 = 0) -> String { + return try! sdkWalletManager.getReceiveAddress(walletId: wallet.walletId, accountIndex: accountIndex) + } + /// Get detailed account information including xpub and addresses /// - Parameters: /// - wallet: The wallet containing the account /// - accountInfo: The account info to get details for /// - Returns: Detailed account information - public func getAccountDetails(for wallet: HDWallet, accountInfo: AccountInfo) async throws -> AccountDetailInfo { - guard let walletId = wallet.walletId else { throw WalletError.walletError("Wallet ID not available") } - let collection = try sdkWalletManager.getManagedAccountCollection(walletId: walletId) + public func getAccountDetails(for wallet: HDWallet, accountInfo: AccountInfo) -> AccountDetailInfo { + let collection = sdkWalletManager.getManagedAccountCollection(walletId: wallet.walletId) + + guard let collection else { + assert(false, "The walletId is always valid") + } // Resolve managed account from category and optional index var managed: ManagedAccount? @@ -389,9 +295,8 @@ public class CoreWalletManager: ObservableObject { /// Derive a private key as WIF from seed using a specific path (deferred to SDK) public func derivePrivateKeyAsWIF(for wallet: HDWallet, accountInfo: AccountInfo, addressIndex: UInt32) async throws -> String { - guard let walletId = wallet.walletId else { throw WalletError.walletError("Wallet ID not available") } // Obtain a non-owning Wallet wrapper from manager - guard let sdkWallet = try sdkWalletManager.getWallet(id: walletId) else { + guard let sdkWallet = try sdkWalletManager.getWallet(id: wallet.walletId) else { throw WalletError.walletError("Wallet not found in manager") } @@ -470,26 +375,11 @@ public class CoreWalletManager: ObservableObject { /// - Parameters: /// - wallet: The wallet model /// - Returns: Account information including balances and address counts - public func getAccounts(for wallet: HDWallet) async throws -> [AccountInfo] { - guard let walletId = wallet.walletId else { throw WalletError.walletError("Wallet ID not available") } - let collection: ManagedAccountCollection - do { - collection = try sdkWalletManager.getManagedAccountCollection(walletId: walletId) - } catch let err as KeyWalletError { - // If the managed wallet info isn't found (e.g., after fresh start), try restoring from serialized bytes - if case .notFound = err, let bytes = wallet.serializedWalletBytes { - do { - let restoredId = try sdkWalletManager.importWallet(from: bytes) - if wallet.walletId != restoredId { wallet.walletId = restoredId } - // Retry once after import - collection = try sdkWalletManager.getManagedAccountCollection(walletId: wallet.walletId!) - } catch { - throw err - } - } else { - throw err - } - } + public func getAccounts(for wallet: HDWallet) -> [AccountInfo] { + let collection = sdkWalletManager.getManagedAccountCollection(walletId: wallet.walletId) + + guard let collection else { assert(false, "The walletId is always valid") } + var list: [AccountInfo] = [] func counts(_ m: ManagedAccount) -> (Int, Int) { @@ -502,63 +392,63 @@ public class CoreWalletManager: ObservableObject { // BIP44 for idx in collection.getBIP44Indices() { if let m = collection.getBIP44Account(at: idx) { - let b = try? m.getBalance() + let b = m.getBalance() let c = counts(m) - list.append(AccountInfo(category: .bip44, index: idx, label: "Account \(idx)", balance: (b?.confirmed ?? 0, b?.unconfirmed ?? 0), addressCount: (c.0, c.1), nextReceiveAddress: nil)) + list.append(AccountInfo(category: .bip44, index: idx, label: "Account \(idx)", balance: b, addressCount: c)) } } // BIP32 (5000+) for raw in collection.getBIP32Indices() { if let m = collection.getBIP32Account(at: raw) { - let b = try? m.getBalance() + let b = m.getBalance() let c = counts(m) - list.append(AccountInfo(category: .bip32, index: raw, label: "BIP32 \(raw)", balance: (b?.confirmed ?? 0, b?.unconfirmed ?? 0), addressCount: (c.0, c.1), nextReceiveAddress: nil)) + list.append(AccountInfo(category: .bip32, index: raw, label: "BIP32 \(raw)", balance: b, addressCount: c)) } } // CoinJoin (1000+) for raw in collection.getCoinJoinIndices() { if let m = collection.getCoinJoinAccount(at: raw) { - let b = try? m.getBalance() + let b = m.getBalance() var total = 0 if let p = m.getAddressPool(type: .single), let infos = try? p.getAddresses(from: 0, to: 1000) { total = infos.count } - list.append(AccountInfo(category: .coinjoin, index: raw, label: "CoinJoin \(raw)", balance: (b?.confirmed ?? 0, b?.unconfirmed ?? 0), addressCount: (total, 0), nextReceiveAddress: nil)) + list.append(AccountInfo(category: .coinjoin, index: raw, label: "CoinJoin \(raw)", balance: b, addressCount: (total, 0))) } } // Identity accounts if let m = collection.getIdentityRegistrationAccount() { - let b = try? m.getBalance() - list.append(AccountInfo(category: .identityRegistration, label: "Identity Registration", balance: (b?.confirmed ?? 0, b?.unconfirmed ?? 0), addressCount: (0, 0), nextReceiveAddress: nil)) + let b = m.getBalance() + list.append(AccountInfo(category: .identityRegistration, label: "Identity Registration", balance: b, addressCount: (0, 0))) } if let m = collection.getIdentityInvitationAccount() { - let b = try? m.getBalance() - list.append(AccountInfo(category: .identityInvitation, label: "Identity Invitation", balance: (b?.confirmed ?? 0, b?.unconfirmed ?? 0), addressCount: (0, 0), nextReceiveAddress: nil)) + let b = m.getBalance() + list.append(AccountInfo(category: .identityInvitation, label: "Identity Invitation", balance: b, addressCount: (0, 0))) } if let m = collection.getIdentityTopUpNotBoundAccount() { - let b = try? m.getBalance() - list.append(AccountInfo(category: .identityTopupNotBound, label: "Identity Topup (Not Bound)", balance: (b?.confirmed ?? 0, b?.unconfirmed ?? 0), addressCount: (0, 0), nextReceiveAddress: nil)) + let b = m.getBalance() + list.append(AccountInfo(category: .identityTopupNotBound, label: "Identity Topup (Not Bound)", balance: b, addressCount: (0, 0))) } for raw in collection.getIdentityTopUpIndices() { if let m = collection.getIdentityTopUpAccount(registrationIndex: raw) { - let b = try? m.getBalance() - list.append(AccountInfo(category: .identityTopup, index: raw, label: "Identity Topup \(raw)", balance: (b?.confirmed ?? 0, b?.unconfirmed ?? 0), addressCount: (0, 0), nextReceiveAddress: nil)) + let b = m.getBalance() + list.append(AccountInfo(category: .identityTopup, index: raw, label: "Identity Topup \(raw)", balance: b, addressCount: (0, 0))) } } // Provider if let m = collection.getProviderVotingKeysAccount() { - let b = try? m.getBalance() - list.append(AccountInfo(category: .providerVotingKeys, label: "Provider Voting Keys", balance: (b?.confirmed ?? 0, b?.unconfirmed ?? 0), addressCount: (0, 0), nextReceiveAddress: nil)) + let b = m.getBalance() + list.append(AccountInfo(category: .providerVotingKeys, label: "Provider Voting Keys", balance: b, addressCount: (0, 0))) } if let m = collection.getProviderOwnerKeysAccount() { - let b = try? m.getBalance() - list.append(AccountInfo(category: .providerOwnerKeys, label: "Provider Owner Keys", balance: (b?.confirmed ?? 0, b?.unconfirmed ?? 0), addressCount: (0, 0), nextReceiveAddress: nil)) + let b = m.getBalance() + list.append(AccountInfo(category: .providerOwnerKeys, label: "Provider Owner Keys", balance: b, addressCount: (0, 0))) } if let m = collection.getProviderOperatorKeysAccount() { - let b = try? m.getBalance() - list.append(AccountInfo(category: .providerOperatorKeys, label: "Provider Operator Keys (BLS)", balance: (b?.confirmed ?? 0, b?.unconfirmed ?? 0), addressCount: (0, 0), nextReceiveAddress: nil)) + let b = m.getBalance() + list.append(AccountInfo(category: .providerOperatorKeys, label: "Provider Operator Keys (BLS)", balance: b, addressCount: (0, 0))) } if let m = collection.getProviderPlatformKeysAccount() { - let b = try? m.getBalance() - list.append(AccountInfo(category: .providerPlatformKeys, label: "Provider Platform Keys (EdDSA)", balance: (b?.confirmed ?? 0, b?.unconfirmed ?? 0), addressCount: (0, 0), nextReceiveAddress: nil)) + let b = m.getBalance() + list.append(AccountInfo(category: .providerPlatformKeys, label: "Provider Platform Keys (EdDSA)", balance: b, addressCount: (0, 0))) } // Sort BIP44 by index first, then other types below @@ -571,258 +461,41 @@ public class CoreWalletManager: ObservableObject { return list } - public func createAccount(in wallet: HDWallet) async throws -> HDAccount { - guard !wallet.isWatchOnly else { - throw WalletError.watchOnlyWallet - } - - // Note: The FFI wallet manager handles account creation internally - // We're just creating UI models here to track them - let accountIndex = UInt32(wallet.accounts.count) - let account = wallet.createAccount(at: accountIndex) - - // Sync complete wallet state from Rust managed info - try await syncWalletFromManagedInfo(for: wallet) - - try modelContainer.mainContext.save() - - return account - } - - // MARK: - Address Management - - func generateAddresses(for account: HDAccount, count: Int, type: AddressType) async throws { - // Refresh address lists from SDK-managed pools (SDK maintains state) - guard let wallet = account.wallet else { throw WalletError.walletError("No wallet for account") } - try await syncWalletFromManagedInfo(for: wallet) - } - - private func generateWatchOnlyAddresses(for account: HDAccount, count: Int, type: AddressType) async throws { - // For watch-only wallets, we need to derive addresses from extended public key - // This would require implementing public key derivation - // For now, throw an error as this requires additional cryptographic operations - throw WalletError.notImplemented("Watch-only address generation") - } - - func getUnusedAddress(for account: HDAccount, type: AddressType = .external) async throws -> HDAddress { - let addresses: [HDAddress] - switch type { - case .external: - addresses = account.externalAddresses - case .internal: - addresses = account.internalAddresses - case .coinJoin: - addresses = account.coinJoinAddresses - case .identity: - addresses = account.identityFundingAddresses - } - - // Find first unused address - if let unusedAddress = addresses.first(where: { !$0.isUsed }) { - return unusedAddress - } - - // Generate new addresses if all are used - try await generateAddresses(for: account, count: 10, type: type) - - // Return the first newly generated address - guard let newAddress = addresses.first(where: { !$0.isUsed }) else { - throw WalletError.addressGenerationFailed - } - - return newAddress - } - - // MARK: - Balance Management - - func updateBalance(for account: HDAccount) async { - guard let wallet = account.wallet, - let walletId = wallet.walletId else { - return - } - - // Get balance via SDK wrappers - do { - let collection = try sdkWalletManager.getManagedAccountCollection(walletId: walletId) - if let managed = collection.getBIP44Account(at: account.accountNumber) { - if let bal = try? managed.getBalance() { - account.confirmedBalance = bal.confirmed - account.unconfirmedBalance = bal.unconfirmed - try? modelContainer.mainContext.save() - } - } - } catch { - print("Failed to update balance: \(error)") - } - } - - /// Sync all wallet state from Rust WalletManager to SwiftData - /// This should be called whenever the Rust wallet state changes (e.g., after processing a transaction) - func syncWalletStateFromRust(for wallet: HDWallet) async { - guard let walletId = wallet.walletId else { return } - - do { - let collection = try sdkWalletManager.getManagedAccountCollection(walletId: walletId) - - // Sync all accounts - for account in wallet.accounts { - if let managed = collection.getBIP44Account(at: account.accountNumber) { - // Sync balance from account - if let bal = try? managed.getBalance() { - account.confirmedBalance = bal.confirmed - account.unconfirmedBalance = bal.unconfirmed - } - - // Sync addresses (update isUsed flags and add new addresses) - // Use 0 to 0 to get all addresses from the pool - if let externalPool = managed.getExternalAddressPool() { - if let infos = try? externalPool.getAddresses(from: 0, to: 0) { - // Update existing addresses and add new ones if needed - for info in infos { - if let existingAddr = account.externalAddresses.first(where: { $0.address == info.address }) { - existingAddr.isUsed = info.used - } else { - // New address discovered - add it - let hd = HDAddress(address: info.address, index: info.index, derivationPath: info.path, addressType: .external, account: account) - hd.isUsed = info.used - modelContainer.mainContext.insert(hd) - account.externalAddresses.append(hd) - } - } - } - } - - if let internalPool = managed.getInternalAddressPool() { - if let infos = try? internalPool.getAddresses(from: 0, to: 0) { - for info in infos { - if let existingAddr = account.internalAddresses.first(where: { $0.address == info.address }) { - existingAddr.isUsed = info.used - } else { - let hd = HDAddress(address: info.address, index: info.index, derivationPath: info.path, addressType: .internal, account: account) - hd.isUsed = info.used - modelContainer.mainContext.insert(hd) - account.internalAddresses.append(hd) - } - } - } - } - } - } - - // Save all changes to SwiftData - try modelContainer.mainContext.save() - } catch { - print("❌ [WalletManager] Failed to sync wallet state: \(error)") - } - } - - // MARK: - Public Utility Methods - - func reloadWallets() async { - await loadWallets() - } - - /// Remove a wallet from the observable arrays without touching SwiftData - /// Call this BEFORE deleting from SwiftData to prevent UI from accessing deleted relationships - public func removeWalletFromObservableState(_ wallet: HDWallet) { - let walletId = wallet.id - - // Update observable state to prevent UI from accessing deleted relationships - if currentWallet?.id == walletId { - currentWallet = wallets.first(where: { $0.id != walletId }) - } - wallets.removeAll(where: { $0.id == walletId }) - } - // MARK: - Private Methods private func loadWallets() async { + var wallets: [HDWallet] = [] do { let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]) wallets = try modelContainer.mainContext.fetch(descriptor) - - // Restore each wallet to the FFI wallet manager - for wallet in wallets { - if let walletBytes = wallet.serializedWalletBytes { - do { - // Restore wallet to FFI and update the wallet ID - let restoredWalletId = try restoreWalletFromBytes(walletBytes) - - // Update wallet ID if it changed (shouldn't happen, but good to verify) - if wallet.walletId != restoredWalletId { - print("Warning: Wallet ID changed during restoration. Old: \(wallet.walletId?.hexString ?? "nil"), New: \(restoredWalletId.hexString)") - wallet.walletId = restoredWalletId - } - - print("Successfully restored wallet '\(wallet.label)' to FFI wallet manager") - } catch { - // Handle wallet format migration errors - // The wallet serialization format changed from multi-network to single-network. - // Old wallet bytes cannot be deserialized with the new format. - let errorString = String(describing: error) - let isFormatMigrationError = errorString.contains("UnexpectedVariant") || - errorString.contains("serialization") || - errorString.contains("deserialize") || - errorString.contains("Network") - - if isFormatMigrationError { - print("⚠️ Wallet '\(wallet.label)' has incompatible serialization format (likely from old multi-network format).") - print(" Clearing invalid serialized bytes. Please delete and re-import this wallet using your mnemonic.") - print(" Error details: \(errorString)") - - // Clear the invalid serialized bytes so future loads don't keep failing - wallet.serializedWalletBytes = nil - // Mark wallet as needing recreation so UI can indicate this to user - wallet.needsRecreation = true - } else { - print("Failed to restore wallet '\(wallet.label)': \(error)") - } - // Continue loading other wallets even if one fails - } - } else { - print("Warning: Wallet '\(wallet.label)' has no serialized bytes - cannot restore to FFI") - } - } - - if currentWallet == nil, let firstWallet = wallets.first { - currentWallet = firstWallet - } - - // Save any wallet ID updates - try? modelContainer.mainContext.save() } catch { self.error = WalletError.databaseError(error.localizedDescription) + return } - } -} + + // Try to import each wallet into the FFI wallet manager + // If it succeeds, we store the HDWallet for later querying. If it fails, + // we log the error and remove that wallet from the database. + for wallet in wallets { + do { + let restoredWalletId = try sdkWalletManager.importWallet(from: wallet.serializedWalletBytes) + // Update wallet ID if it changed (shouldn't happen, but good to verify) + if wallet.walletId != restoredWalletId { + print("Warning: Wallet ID changed during restoration. Old: \(wallet.walletId.hexString ?? "nil"), New: \(restoredWalletId.hexString)") + wallet.walletId = restoredWalletId + } -// MARK: - Keychain Wrapper + self.wallets.append(wallet) + + print("Successfully restored wallet '\(wallet.label)' to FFI wallet manager") + } catch { + let errorString = String(describing: error) + modelContainer.mainContext.delete(wallet) + } + } -private class KeychainWrapper { - func set(_ data: Data, forKey key: String) { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecValueData as String: data - ] - - SecItemDelete(query as CFDictionary) - SecItemAdd(query as CFDictionary, nil) - } - - func data(forKey key: String) -> Data? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecReturnData as String: true - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess else { return nil } - return result as? Data + try? modelContainer.mainContext.save() } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/HDTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/HDTransaction.swift deleted file mode 100644 index fbf44533740..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/HDTransaction.swift +++ /dev/null @@ -1,346 +0,0 @@ -import Foundation -import SwiftData - -// MARK: - HD Transaction - -@Model -public final class HDTransaction { - @Attribute(.unique) public var id: UUID - @Attribute(.unique) public var txHash: String - public var rawTransaction: Data? - public var blockHeight: Int? - public var blockHash: String? - public var timestamp: Date - public var confirmations: Int - public var size: Int - public var fee: UInt64 - public var type: String // "sent", "received", "self" - - // Inputs and outputs - public var inputsData: Data? // Serialized TransactionInput array - public var outputsData: Data? // Serialized TransactionOutput array - - // Relationships - @Relationship public var addresses: [HDAddress] = [] - @Relationship public var wallet: HDWallet? - - // Computed amount (positive for received, negative for sent) - public var amount: Int64 - - // Transaction status - public var isPending: Bool - public var isInstantSend: Bool - public var isChainLocked: Bool - - public init(txHash: String, timestamp: Date = Date()) { - self.id = UUID() - self.txHash = txHash - self.timestamp = timestamp - self.confirmations = 0 - self.size = 0 - self.fee = 0 - self.type = "received" - self.amount = 0 - self.isPending = true - self.isInstantSend = false - self.isChainLocked = false - } - - public var transactionType: TransactionType { - return TransactionType(rawValue: type) ?? .received - } -} - -public enum TransactionType: String { - case sent = "sent" - case received = "received" - case `self` = "self" -} - -// MARK: - Transaction Components - -public struct TransactionInput: Codable { - public let txHash: String - public let outputIndex: UInt32 - public let script: Data - public let sequence: UInt32 - public let amount: UInt64? - public let address: String? - - public init(txHash: String, outputIndex: UInt32, script: Data, sequence: UInt32 = 0xFFFFFFFF, amount: UInt64? = nil, address: String? = nil) { - self.txHash = txHash - self.outputIndex = outputIndex - self.script = script - self.sequence = sequence - self.amount = amount - self.address = address - } -} - -public struct TransactionOutput: Codable { - public let amount: UInt64 - public let script: Data - public let address: String? - public let isChange: Bool - - public init(amount: UInt64, script: Data, address: String? = nil, isChange: Bool = false) { - self.amount = amount - self.script = script - self.address = address - self.isChange = isChange - } -} - -// TransactionBuilder is now defined in TransactionBuilder.swift - -/* -public class TransactionBuilder { - private var inputs: [TransactionInput] = [] - private var outputs: [TransactionOutput] = [] - private let network: DashNetwork - private let feePerKB: UInt64 - - public init(network: DashNetwork, feePerKB: UInt64 = 1000) { - self.network = network - self.feePerKB = feePerKB - } - - // MARK: - Building Transaction - - public func addInput(utxo: HDUTXO, address: HDAddress) { - let input = TransactionInput( - txHash: utxo.txHash, - outputIndex: utxo.outputIndex, - script: Data(), // Will be filled during signing - amount: utxo.amount, - address: address.address - ) - inputs.append(input) - } - - public func addOutput(address: String, amount: UInt64) throws { - guard CoreSDKWrapper.shared.validateAddress(address, network: network) else { - throw TransactionError.invalidAddress - } - - let scriptPubKey = try createScriptPubKey(for: address) - let output = TransactionOutput( - amount: amount, - script: scriptPubKey, - address: address, - isChange: false - ) - outputs.append(output) - } - - public func addChangeOutput(address: String, amount: UInt64) throws { - guard CoreSDKWrapper.shared.validateAddress(address, network: network) else { - throw TransactionError.invalidAddress - } - - let scriptPubKey = try createScriptPubKey(for: address) - let output = TransactionOutput( - amount: amount, - script: scriptPubKey, - address: address, - isChange: true - ) - outputs.append(output) - } - - public func calculateFee() -> UInt64 { - // Estimate transaction size - let baseSize = 10 // Version (4) + locktime (4) + marker (2) - let inputSize = inputs.count * 148 // Approximate size per input with signature - let outputSize = outputs.count * 34 // Approximate size per output - let estimatedSize = baseSize + inputSize + outputSize - - // Calculate fee based on size - let fee = UInt64(estimatedSize) * feePerKB / 1000 - return max(fee, 1000) // Minimum fee of 1000 duffs - } - - public func build() throws -> RawTransaction { - guard !inputs.isEmpty else { - throw TransactionError.noInputs - } - - guard !outputs.isEmpty else { - throw TransactionError.noOutputs - } - - // Calculate total input and output amounts - let totalInput = inputs.compactMap { $0.amount }.reduce(0, +) - let totalOutput = outputs.reduce(0) { $0 + $1.amount } - let fee = calculateFee() - - guard totalInput >= totalOutput + fee else { - throw TransactionError.insufficientFunds - } - - // Create raw transaction - return RawTransaction( - version: 2, - inputs: inputs, - outputs: outputs, - lockTime: 0 - ) - } - - // MARK: - Signing - - public func sign(transaction: RawTransaction, with privateKeys: [String: Data]) throws -> Data { - // This should use actual transaction signing logic - // For now, return mock signed transaction - var signedInputs: [TransactionInput] = [] - - for (index, input) in transaction.inputs.enumerated() { - guard let address = input.address, - let privateKey = privateKeys[address] else { - throw TransactionError.missingPrivateKey - } - - // Create signature script - let signatureScript = try createSignatureScript( - for: transaction, - inputIndex: index, - privateKey: privateKey - ) - - let signedInput = TransactionInput( - txHash: input.txHash, - outputIndex: input.outputIndex, - script: signatureScript, - sequence: input.sequence, - amount: input.amount, - address: input.address - ) - signedInputs.append(signedInput) - } - - // Serialize signed transaction - let signedTx = RawTransaction( - version: transaction.version, - inputs: signedInputs, - outputs: transaction.outputs, - lockTime: transaction.lockTime - ) - - return try signedTx.serialize() - } - - // MARK: - Private Methods - - private func createScriptPubKey(for address: String) throws -> Data { - // This should create actual P2PKH script - // For now, return mock script - var script = Data() - script.append(0x76) // OP_DUP - script.append(0xa9) // OP_HASH160 - script.append(0x14) // Push 20 bytes - script.append(Data(repeating: 0, count: 20)) // Mock pubkey hash - script.append(0x88) // OP_EQUALVERIFY - script.append(0xac) // OP_CHECKSIG - return script - } - - private func createSignatureScript(for transaction: RawTransaction, inputIndex: Int, privateKey: Data) throws -> Data { - // This should create actual signature script - // For now, return mock script - let signature = CoreSDKWrapper.shared.signTransaction(Data(), with: privateKey) ?? Data() - let publicKey = CoreSDKWrapper.shared.derivePublicKey(from: privateKey) ?? Data() - - var script = Data() - script.append(UInt8(signature.count + 1)) // Signature length + hash type - script.append(signature) - script.append(0x01) // SIGHASH_ALL - script.append(UInt8(publicKey.count)) // Public key length - script.append(publicKey) - - return script - } -} -*/ - -// MARK: - Raw Transaction - -public struct RawTransaction { - public let version: UInt32 - public let inputs: [TransactionInput] - public let outputs: [TransactionOutput] - public let lockTime: UInt32 - - public func serialize() throws -> Data { - var data = Data() - - // Version - var versionLE = version.littleEndian - data.append(Data(bytes: &versionLE, count: 4)) - - // Input count (compact size) - data.append(compactSize(UInt64(inputs.count))) - - // Inputs - for input in inputs { - // Previous output - if let txHashData = Data(hex: input.txHash) { - data.append(contentsOf: txHashData.reversed()) // Little endian - } - var outputIndexLE = input.outputIndex.littleEndian - data.append(Data(bytes: &outputIndexLE, count: 4)) - - // Script - data.append(compactSize(UInt64(input.script.count))) - data.append(input.script) - - // Sequence - var sequenceLE = input.sequence.littleEndian - data.append(Data(bytes: &sequenceLE, count: 4)) - } - - // Output count - data.append(compactSize(UInt64(outputs.count))) - - // Outputs - for output in outputs { - // Amount - var amountLE = output.amount.littleEndian - data.append(Data(bytes: &amountLE, count: 8)) - - // Script - data.append(compactSize(UInt64(output.script.count))) - data.append(output.script) - } - - // Lock time - var lockTimeLE = lockTime.littleEndian - data.append(Data(bytes: &lockTimeLE, count: 4)) - - return data - } - - private func compactSize(_ value: UInt64) -> Data { - if value < 0xfd { - return Data([UInt8(value)]) - } else if value <= 0xffff { - var data = Data([0xfd]) - var valueLE = UInt16(value).littleEndian - data.append(Data(bytes: &valueLE, count: 2)) - return data - } else if value <= 0xffffffff { - var data = Data([0xfe]) - var valueLE = UInt32(value).littleEndian - data.append(Data(bytes: &valueLE, count: 4)) - return data - } else { - var data = Data([0xff]) - var valueLE = value.littleEndian - data.append(Data(bytes: &valueLE, count: 8)) - return data - } - } -} - -// TransactionError is now defined in TransactionBuilder.swift - -// Data hex extension is now defined in TransactionBuilder.swift \ No newline at end of file diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/HDWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/HDWallet.swift index de608cb8ee9..943d2fc7176 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/HDWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/HDWallet.swift @@ -4,271 +4,28 @@ import SwiftData // MARK: - HD Wallet @Model -public final class HDWallet: HDWalletModels { +public final class HDWallet { @Attribute(.unique) public var id: UUID public var label: String - public var network: String + public var network: AppNetwork public var createdAt: Date public var isWatchOnly: Bool public var isImported: Bool // FFI Wallet ID (32 bytes) - links to the rust-dashcore wallet - public var walletId: Data? + @Attribute(.unique) public var walletId: Data // Serialized wallet bytes from FFI - used to restore wallet on app restart - public var serializedWalletBytes: Data? + @Attribute(.unique) public var serializedWalletBytes: Data - // Encrypted seed (only for non-watch-only wallets) - public var encryptedSeed: Data? - - // Accounts - @Relationship(deleteRule: .cascade) public var accounts: [HDAccount] = [] - - // Current account index - public var currentAccountIndex: Int - - // Sync progress (0.0 to 1.0) - public var syncProgress: Double - - // Migration flag: true if wallet needs to be re-imported due to format change - // This happens when old multi-network wallet bytes can't be deserialized - public var needsRecreation: Bool = false - - init(label: String, network: AppNetwork, isWatchOnly: Bool = false, isImported: Bool = false) { + init(walletId: Data, serializedWalletBytes: Data, label: String, network: AppNetwork, isWatchOnly: Bool = false, isImported: Bool = false) { self.id = UUID() self.label = label - self.network = network.rawValue + self.network = network self.createdAt = Date() self.isWatchOnly = isWatchOnly - self.currentAccountIndex = 0 - self.syncProgress = 0.0 self.isImported = isImported + self.walletId = walletId + self.serializedWalletBytes = serializedWalletBytes } - - public var dashNetwork: AppNetwork { - return AppNetwork(rawValue: network) ?? .testnet - } - - // Total balance across all accounts - public var totalBalance: UInt64 { - return accounts.reduce(0) { $0 + $1.totalBalance } - } - - // Confirmed balance across all accounts - public var confirmedBalance: UInt64 { - return accounts.reduce(0) { $0 + $1.confirmedBalance } - } - - // Unconfirmed balance across all accounts - public var unconfirmedBalance: UInt64 { - return accounts.reduce(0) { $0 + $1.unconfirmedBalance } - } - - // All transactions across all accounts - public var transactions: [HDTransaction] { - return accounts.flatMap { account in - account.addresses.flatMap { $0.transactions } - } - } - - // Transaction count across all accounts - public var transactionCount: Int { - return transactions.count - } - - // All addresses across all accounts - public var addresses: [HDAddress] { - return accounts.flatMap { $0.addresses } - } - - // All UTXOs across all accounts - public var utxos: [HDUTXO] { - return addresses.flatMap { $0.utxos } - } - - public func createAccount(at index: UInt32? = nil) -> HDAccount { - let accountIndex = index ?? UInt32(accounts.count) - let account = HDAccount( - accountNumber: accountIndex, - label: "Account \(accountIndex)", - wallet: self - ) - accounts.append(account) - return account - } -} - -// MARK: - HD Account - -@Model -public final class HDAccount: HDWalletModels { - @Attribute(.unique) public var id: UUID - public var accountNumber: UInt32 - public var label: String - - // Extended public key for this account (watch-only capability) - public var extendedPublicKey: String? - - // Derivation paths - @Relationship(deleteRule: .cascade) public var externalAddresses: [HDAddress] = [] - @Relationship(deleteRule: .cascade) public var internalAddresses: [HDAddress] = [] - @Relationship(deleteRule: .cascade) public var coinJoinAddresses: [HDAddress] = [] - @Relationship(deleteRule: .cascade) public var identityFundingAddresses: [HDAddress] = [] - - // Indexes - public var externalAddressIndex: UInt32 - public var internalAddressIndex: UInt32 - public var coinJoinExternalIndex: UInt32 - public var coinJoinInternalIndex: UInt32 - public var identityFundingIndex: UInt32 - - // Balance tracking - public var confirmedBalance: UInt64 - public var unconfirmedBalance: UInt64 - - // Parent wallet - @Relationship(inverse: \HDWallet.accounts) public var wallet: HDWallet? - - public init(accountNumber: UInt32, label: String, wallet: HDWallet) { - self.id = UUID() - self.accountNumber = accountNumber - self.label = label - self.wallet = wallet - self.externalAddressIndex = 0 - self.internalAddressIndex = 0 - self.coinJoinExternalIndex = 0 - self.coinJoinInternalIndex = 0 - self.identityFundingIndex = 0 - self.confirmedBalance = 0 - self.unconfirmedBalance = 0 - } - - public var totalBalance: UInt64 { - return confirmedBalance + unconfirmedBalance - } - - // All addresses combined - public var addresses: [HDAddress] { - return externalAddresses + internalAddresses + coinJoinAddresses + identityFundingAddresses - } -} - -// MARK: - HD Address - -@Model -public final class HDAddress: HDWalletModels { - @Attribute(.unique) public var id: UUID - @Attribute(.unique) public var address: String - public var index: UInt32 - public var derivationPath: String - public var isUsed: Bool - public var balance: UInt64 - public var lastSeenTime: Date? - - // Address type - public var addressType: String // "external", "internal", "coinjoin", "identity" - - // Parent account - @Relationship public var account: HDAccount? - - // Associated transactions - @Relationship(deleteRule: .nullify) public var transactions: [HDTransaction] = [] - - // UTXOs - @Relationship(deleteRule: .cascade) public var utxos: [HDUTXO] = [] - - public init(address: String, index: UInt32, derivationPath: String, addressType: AddressType, account: HDAccount) { - self.id = UUID() - self.address = address - self.index = index - self.derivationPath = derivationPath - self.addressType = addressType.rawValue - self.isUsed = false - self.balance = 0 - self.account = account - } - - public var type: AddressType { - return AddressType(rawValue: addressType) ?? .external - } -} - -public enum AddressType: String { - case external = "external" - case `internal` = "internal" - case coinJoin = "coinjoin" - case identity = "identity" -} - -// MARK: - HD UTXO - -@Model -public final class HDUTXO: HDWalletModels { - @Attribute(.unique) public var id: UUID - public var txHash: String - public var outputIndex: UInt32 - public var amount: UInt64 - public var scriptPubKey: Data - public var blockHeight: Int? - public var isSpent: Bool - public var isCoinbase: Bool - - // Parent address - @Relationship(inverse: \HDAddress.utxos) public var address: HDAddress? - - // Spending transaction (if spent) - public var spendingTxHash: String? - public var spendingInputIndex: UInt32? - - public init(txHash: String, outputIndex: UInt32, amount: UInt64, scriptPubKey: Data, address: HDAddress) { - self.id = UUID() - self.txHash = txHash - self.outputIndex = outputIndex - self.amount = amount - self.scriptPubKey = scriptPubKey - self.address = address - self.isSpent = false - self.isCoinbase = false - } - - // Computed property to check if UTXO is confirmed - public var isConfirmed: Bool { - return blockHeight != nil - } - - // Alias for txHash - public var txid: String { - return txHash - } -} - -// MARK: - Watched Address (for import) - -@Model -public final class HDWatchedAddress: HDWalletModels { - @Attribute(.unique) public var id: UUID - @Attribute(.unique) public var address: String - public var label: String? - public var balance: UInt64 - public var lastSeenTime: Date? - - // Parent wallet - @Relationship public var wallet: HDWallet? - - // Associated transactions - @Relationship(deleteRule: .nullify) public var transactions: [HDTransaction] = [] - - public init(address: String, label: String? = nil, wallet: HDWallet) { - self.id = UUID() - self.address = address - self.label = label - self.balance = 0 - self.wallet = wallet - } -} - -// MARK: - Protocol for common functionality - -public protocol HDWalletModels: AnyObject { - var id: UUID { get set } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/TransactionErrors.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/TransactionErrors.swift deleted file mode 100644 index 8a1c74d3993..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/TransactionErrors.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Foundation - -public enum TransactionError: LocalizedError { - case invalidState - case noInputs - case noOutputs - case insufficientFunds - case invalidAddress - case invalidInput(String) - case invalidOutput(String) - case noChangeAddress - case signingFailed - case serializationFailed - case broadcastFailed(String) - case notSupported(String) - - public var errorDescription: String? { - switch self { - case .invalidState: - return "Transaction in invalid state" - case .noInputs: - return "Transaction has no inputs" - case .noOutputs: - return "Transaction has no outputs" - case .insufficientFunds: - return "Insufficient funds for transaction" - case .invalidAddress: - return "Invalid recipient address" - case .invalidInput(let message): - return "Invalid input: \(message)" - case .invalidOutput(let message): - return "Invalid output: \(message)" - case .noChangeAddress: - return "No change address specified" - case .signingFailed: - return "Failed to sign transaction" - case .serializationFailed: - return "Failed to serialize transaction" - case .broadcastFailed(let message): - return "Failed to broadcast: \(message)" - case .notSupported(let msg): - return msg - } - } -} - -// Transaction object used by the example app -public struct BuiltTransaction { - public let txid: String - public let rawTransaction: Data - public let fee: UInt64 - public let inputs: [HDUTXO] - public let changeAmount: UInt64 -} - -// Common hex initializer used by transaction code -extension Data { - init?(hex: String) { - let hex = hex.replacingOccurrences(of: " ", with: "") - guard hex.count % 2 == 0 else { return nil } - var data = Data(capacity: hex.count / 2) - var index = hex.startIndex - while index < hex.endIndex { - let next = hex.index(index, offsetBy: 2) - guard next <= hex.endIndex else { return nil } - let byteString = String(hex[index.. BuiltTransaction { - // Route to SDK transaction builder (stubbed for now) - guard let wallet = walletManager.currentWallet else { throw TransactionError.invalidState } - let builder = SwiftDashSDK.SDKTransactionBuilder(feePerKB: feePerKB) - // TODO: integrate coin selection + key derivation via SDK and add inputs/outputs - _ = builder // silence unused - throw TransactionError.notSupported("Transaction building is not yet wired to SwiftDashSDK") - } - - // MARK: - Transaction Broadcasting - - func broadcastTransaction(_ transaction: BuiltTransaction) async throws { - 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") - } catch { - lastError = error - throw TransactionError.broadcastFailed(error.localizedDescription) - } - } - - // MARK: - Transaction History - - public func loadTransactions() async { - isLoading = true - defer { isLoading = false } - - do { - let descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.timestamp, order: .reverse)] - ) - transactions = try modelContainer.mainContext.fetch(descriptor) - } catch { - print("Failed to load transactions: \(error)") - } - } - - public func processIncomingTransaction( - txid: String, - rawTx: Data, - blockHeight: Int?, - timestamp: Date = Date() - ) async throws { - // Check if transaction already exists - let existingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txHash == txid } - ) - - let existing = try modelContainer.mainContext.fetch(existingDescriptor) - if let existingTx = existing.first { - // Update existing transaction - existingTx.blockHeight = blockHeight - existingTx.confirmations = blockHeight != nil ? 1 : 0 - existingTx.isPending = blockHeight == nil - } else { - // Create new transaction - let hdTransaction = HDTransaction(txHash: txid, timestamp: timestamp) - hdTransaction.rawTransaction = rawTx - hdTransaction.blockHeight = blockHeight - hdTransaction.isPending = blockHeight == nil - hdTransaction.wallet = walletManager.currentWallet - - // TODO: Parse transaction to determine type and amount - // This would require deserializing the transaction and checking outputs - - modelContainer.mainContext.insert(hdTransaction) - } - - try modelContainer.mainContext.save() - await loadTransactions() - } - - // MARK: - SPV Integration - - public func syncWithSPV() async throws { - guard let wallet = walletManager.currentWallet else { - return - } - - // Watch all addresses - for account in wallet.accounts { - let allAddresses = account.externalAddresses + account.internalAddresses + - account.coinJoinAddresses + account.identityFundingAddresses - - for address in allAddresses { - // TODO: Implement watch address with new SPV client - // try await spvClient.watchAddress(address.address) - print("Would watch address: \(address.address)") - } - } - - // Start sync without blocking UI; inherit MainActor to avoid sending non-Sendable captures - let client = spvClient - Task(priority: .userInitiated) { - try? await client.startSync() - } - } - - // MARK: - Fee Estimation - - public func estimateFee(for amount: UInt64, account: HDAccount? = nil) throws -> UInt64 { - // Placeholder fixed fee until SDK fee estimator is wired - return 2000 - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedAccount.swift index cc188761fc5..e3f7cd74db9 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedAccount.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedAccount.swift @@ -49,9 +49,7 @@ public class ManagedAccount { public var utxoCount: UInt32 { return managed_core_account_get_utxo_count(handle) } - - // MARK: - Transactions - + /// Get all transactions for this account /// - Parameter currentHeight: Current blockchain height for calculating confirmations /// - Returns: Array of transactions @@ -135,13 +133,11 @@ public class ManagedAccount { // MARK: - Balance /// Get the balance for this account - public func getBalance() throws -> Balance { + public func getBalance() -> Balance { var ffiBalance = FFIBalance() let success = managed_core_account_get_balance(handle, &ffiBalance) - guard success else { - throw KeyWalletError.invalidState("Failed to get balance for managed account") - } + assert(success, "This can only fail if handle or ffiBalance are null") return Balance(ffiBalance: ffiBalance) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Transaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Transaction.swift index 90931f831ed..10b2980c3e4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Transaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Transaction.swift @@ -1,6 +1,20 @@ import Foundation import DashSDKFFI +public enum FeeRate { + case economy + case normal + case priority + + func intoFFI() -> FFIFeeRate { + switch self { + case .economy: return FFIFeeRate(0) + case .normal: return FFIFeeRate(1) + case .priority: return FFIFeeRate(2) + } + } +} + /// Transaction utilities for wallet operations public class Transaction { @@ -15,104 +29,9 @@ public class Transaction { } func toFFI() -> FFITxOutput { - return address.withCString { addressCStr in - FFITxOutput(address: addressCStr, amount: amount) - } - } - } - - /// Build a transaction - /// - Parameters: - /// - wallet: The wallet to build from - /// - accountIndex: The account index to use - /// - outputs: The transaction outputs - /// - feePerKB: Fee per kilobyte in satoshis - /// - Returns: The unsigned transaction bytes - public static func build(wallet: Wallet, - accountIndex: UInt32 = 0, - outputs: [Output], - feePerKB: UInt64) throws -> Data { - guard !outputs.isEmpty else { - throw KeyWalletError.invalidInput("Transaction must have at least one output") - } - - var error = FFIError() - var txBytesPtr: UnsafeMutablePointer? - var txLen: size_t = 0 - - // Convert outputs to FFI format - let ffiOutputs = outputs.map { $0.toFFI() } - - let success = ffiOutputs.withUnsafeBufferPointer { outputsPtr in - wallet_build_transaction( - wallet.ffiHandle, - accountIndex, - outputsPtr.baseAddress, - outputs.count, - feePerKB, - &txBytesPtr, - &txLen, - &error) - } - - defer { - 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) + let cString = strdup(address) + return FFITxOutput(address: cString, amount: amount) } - - // Copy the transaction data before freeing - let txData = Data(bytes: ptr, count: txLen) - - return txData - } - - /// Sign a transaction - /// - Parameters: - /// - wallet: The wallet to sign with - /// - transactionData: The unsigned transaction bytes - /// - Returns: The signed transaction bytes - public static func sign(wallet: Wallet, transactionData: Data) throws -> Data { - guard !wallet.isWatchOnly else { - throw KeyWalletError.invalidState("Cannot sign with watch-only wallet") - } - - var error = FFIError() - var signedTxPtr: UnsafeMutablePointer? - var signedLen: size_t = 0 - - let success = transactionData.withUnsafeBytes { txBytes in - let txPtr = txBytes.bindMemory(to: UInt8.self).baseAddress - return wallet_sign_transaction( - wallet.ffiHandle, - txPtr, transactionData.count, - &signedTxPtr, &signedLen, &error) - } - - defer { - if error.message != nil { - error_message_free(error.message) - } - if let ptr = signedTxPtr { - transaction_bytes_free(ptr) - } - } - - guard success, let ptr = signedTxPtr else { - throw KeyWalletError(ffiError: error) - } - - // Copy the signed transaction data before freeing - let signedData = Data(bytes: ptr, count: signedLen) - - return signedData } /// Check if a transaction belongs to a wallet diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Wallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Wallet.swift index 53e71635b89..1ed4ae7e7de 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Wallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Wallet.swift @@ -379,22 +379,6 @@ public class Wallet { return count } - // MARK: - Balance - - /// Get the wallet's total balance - public func getBalance() throws -> Balance { - // TODO: wallet_get_balance function no longer exists in FFI - throw KeyWalletError.notSupported("wallet_get_balance is not available in current FFI") - } - - /// Get balance for a specific account - /// - Parameter accountIndex: The account index - /// - Returns: The account balance - public func getAccountBalance(accountIndex: UInt32) throws -> Balance { - // TODO: wallet_get_account_balance function no longer exists in FFI - throw KeyWalletError.notSupported("wallet_get_account_balance is not available in current FFI") - } - // MARK: - Key Derivation /// Get the extended public key for an account diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift index 7b1164ce512..0ecdb612cd3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift @@ -387,6 +387,60 @@ public class WalletManager { return success } + /// Build a signed transaction + /// - Parameters: + /// - accountIndex: The account index to use + /// - outputs: The transaction outputs + /// - feePerKB: Fee per kilobyte in satoshis + /// - Returns: The unsigned transaction bytes and the fee + public func buildSignedTransaction(for wallet: HDWallet, accIndex: UInt32, outputs: [Transaction.Output], feeRate: FeeRate) throws -> (Data, UInt64) { + guard !outputs.isEmpty else { + throw KeyWalletError.invalidInput("Transaction must have at least one output") + } + + var error = FFIError() + var txBytesPtr: UnsafeMutablePointer? + var txLen: size_t = 0 + + var fee: UInt64 = 0 + + let wallet = try self.getWallet(id: wallet.walletId)! + + let ffiOutputs = outputs.map { $0.toFFI() } + + let success = ffiOutputs.withUnsafeBufferPointer { outputsPtr in + wallet_build_and_sign_transaction( + self.handle, + wallet.ffiHandle, + accIndex, + outputsPtr.baseAddress, + outputs.count, + feeRate.intoFFI(), + &fee, + &txBytesPtr, + &txLen, + &error) + } + + defer { + 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, fee) + } + // MARK: - Block Height Management /// Get the current block height for a network @@ -477,11 +531,7 @@ public class WalletManager { /// - Parameters: /// - walletId: The wallet ID /// - Returns: The managed account collection - public func getManagedAccountCollection(walletId: Data) throws -> ManagedAccountCollection { - guard walletId.count == 32 else { - throw KeyWalletError.invalidInput("Wallet ID must be exactly 32 bytes") - } - + public func getManagedAccountCollection(walletId: Data) -> ManagedAccountCollection? { var error = FFIError() let collectionHandle = walletId.withUnsafeBytes { idBytes in @@ -496,7 +546,7 @@ public class WalletManager { } guard let collection = collectionHandle else { - throw KeyWalletError(ffiError: error) + return nil } return ManagedAccountCollection(handle: collection, manager: self) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Shared/UnifiedStateManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Shared/UnifiedStateManager.swift index 9796bcab0bc..c3347e17dc5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Shared/UnifiedStateManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Shared/UnifiedStateManager.swift @@ -9,7 +9,6 @@ public class UnifiedStateManager: ObservableObject { // Core wallet state @Published public var coreBalance = Balance() - @Published public var coreTransactions: [CoreTransaction] = [] // Platform state @Published public var platformIdentities: [DPPIdentity] = [] diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift index f41772e0b29..9529d7f4fed 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift @@ -25,11 +25,7 @@ struct AccountDetailView: View { var body: some View { ScrollView { - if isLoading { - ProgressView("Loading account details...") - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = errorMessage { + if let error = errorMessage { ContentUnavailableView( "Failed to Load Details", systemImage: "exclamationmark.triangle", @@ -62,7 +58,7 @@ struct AccountDetailView: View { .navigationTitle(account.label) .navigationBarTitleDisplayMode(.large) .task { - await loadAccountDetails() + loadAccountDetails() } .sheet(isPresented: $showingPINPrompt) { PINPromptView( @@ -126,7 +122,7 @@ struct AccountDetailView: View { Text("Network:") .foregroundColor(.secondary) Spacer() - Text(wallet.dashNetwork.rawValue.capitalized) + Text(wallet.network.rawValue.capitalized) .fontWeight(.medium) } } @@ -604,10 +600,7 @@ struct AccountDetailView: View { private func derivePrivateKeyWithPIN(for detail: AddressDetail, pin: String) async { do { // Gate with PIN but derive via account-based FFI (no seed passage required) - guard let walletManager = walletService.walletManager else { - throw WalletError.walletError("Wallet manager not available") - } - let wifPrivateKey = try await walletManager.derivePrivateKeyAsWIF( + let wifPrivateKey = try await walletService.walletManager.derivePrivateKeyAsWIF( for: wallet, accountInfo: account, addressIndex: detail.index @@ -625,31 +618,11 @@ struct AccountDetailView: View { // MARK: - Data Loading - private func loadAccountDetails() async { - isLoading = true - errorMessage = nil - - do { - guard let walletManager = walletService.walletManager else { - throw WalletError.walletError("Wallet manager not available") - } - - // Get extended public key and other details - let details = try await walletManager.getAccountDetails( - for: wallet, - accountInfo: account - ) - - await MainActor.run { - self.detailInfo = details - self.isLoading = false - } - } catch { - await MainActor.run { - self.errorMessage = error.localizedDescription - self.isLoading = false - } - } + private func loadAccountDetails() { + self.detailInfo = walletService.walletManager.getAccountDetails( + for: wallet, + accountInfo: account + ) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift index a6e0fa006e1..69220eef0e7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift @@ -9,21 +9,10 @@ struct AccountListView: View { @EnvironmentObject var walletService: WalletService let wallet: HDWallet @State private var accounts: [AccountInfo] = [] - @State private var isLoading = true - @State private var errorMessage: String? var body: some View { ZStack { - if isLoading { - ProgressView("Loading accounts...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = errorMessage { - ContentUnavailableView( - "Failed to Load Accounts", - systemImage: "exclamationmark.triangle", - description: Text(error) - ) - } else if accounts.isEmpty { + if accounts.isEmpty { ContentUnavailableView( "No Accounts", systemImage: "folder", @@ -37,32 +26,16 @@ struct AccountListView: View { } .listStyle(.plain) .refreshable { - await loadAccounts() + loadAccounts() } } - } - .task { - await loadAccounts() + }.task { + loadAccounts() } } - private func loadAccounts() async { - isLoading = true - errorMessage = nil - - do { - // Get accounts from wallet manager - let fetchedAccounts = try await walletService.walletManager?.getAccounts(for: wallet) ?? [] - await MainActor.run { - self.accounts = fetchedAccounts - self.isLoading = false - } - } catch { - await MainActor.run { - self.errorMessage = error.localizedDescription - self.isLoading = false - } - } + private func loadAccounts() { + self.accounts = walletService.walletManager.getAccounts(for: wallet) } } @@ -209,33 +182,6 @@ struct AccountRowView: View { Spacer() } } - - // Next receive address (if available and appropriate for account type) - if shouldShowBalance, let address = account.nextReceiveAddress { - HStack { - Text("Receive:") - .font(.caption) - .foregroundColor(.secondary) - - Text(address) - .font(.system(.caption, design: .monospaced)) - .lineLimit(1) - .truncationMode(.middle) - .foregroundColor(.secondary) - - Button(action: { - // Copy address to clipboard - #if os(iOS) - UIPasteboard.general.string = address - #endif - }) { - Image(systemName: "doc.on.doc") - .font(.caption) - .foregroundColor(.secondary) - } - .buttonStyle(.plain) - } - } } .padding(.vertical, 8) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AddressManagementView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AddressManagementView.swift deleted file mode 100644 index fdec709d9fa..00000000000 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AddressManagementView.swift +++ /dev/null @@ -1,161 +0,0 @@ -import SwiftUI -import SwiftDashSDK - -struct AddressManagementView: View { - @EnvironmentObject var walletService: WalletService - let account: HDAccount - @State private var selectedType: AddressType = .external - @State private var isGenerating = false - @State private var error: Error? - - var body: some View { - VStack(spacing: 0) { - // Address Type Picker - Picker("Address Type", selection: $selectedType) { - Text("External").tag(AddressType.external) - Text("Internal").tag(AddressType.internal) - Text("CoinJoin").tag(AddressType.coinJoin) - Text("Identity").tag(AddressType.identity) - } - .pickerStyle(.segmented) - .padding() - - // Address List - List { - ForEach(addressesForType(selectedType)) { address in - AddressDetailRow(address: address) - } - - // Generate More Button - Section { - Button { - generateMoreAddresses() - } label: { - HStack { - Image(systemName: "plus.circle.fill") - Text("Generate More Addresses") - } - } - .disabled(isGenerating) - } - } - .listStyle(.grouped) - } - .navigationTitle("Address Management") - .navigationBarTitleDisplayMode(.inline) - .alert("Error", isPresented: .constant(error != nil)) { - Button("OK") { - error = nil - } - } message: { - if let error = error { - Text(error.localizedDescription) - } - } - } - - private func addressesForType(_ type: AddressType) -> [HDAddress] { - switch type { - case .external: - return account.externalAddresses - case .internal: - return account.internalAddresses - case .coinJoin: - return account.coinJoinAddresses - case .identity: - return account.identityFundingAddresses - } - } - - private func generateMoreAddresses() { - isGenerating = true - - Task { - do { - try await walletService.generateAddresses(for: account, count: 10, type: selectedType) - await MainActor.run { - isGenerating = false - } - } catch { - await MainActor.run { - self.error = error - isGenerating = false - } - } - } - } -} - -struct AddressDetailRow: View { - let address: HDAddress - @State private var copied = false - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Address #\(address.index)") - .font(.headline) - - Text(address.derivationPath) - .font(.caption2) - .foregroundColor(.secondary) - } - - Spacer() - - VStack(alignment: .trailing, spacing: 4) { - if address.isUsed { - Label("Used", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundColor(.green) - } - - if address.balance > 0 { - Text(formatBalance(address.balance)) - .font(.caption) - .fontWeight(.medium) - } - } - } - - HStack { - Text(address.address) - .font(.system(.caption, design: .monospaced)) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.middle) - - Button { - copyAddress() - } label: { - Image(systemName: copied ? "checkmark" : "doc.on.doc") - .foregroundColor(.accentColor) - } - .buttonStyle(.plain) - } - - if let lastSeenTime = address.lastSeenTime { - Text("Last seen: \(lastSeenTime, style: .relative)") - .font(.caption2) - .foregroundColor(.secondary) - } - } - .padding(.vertical, 4) - } - - private func copyAddress() { - UIPasteboard.general.string = address.address - copied = true - - Task { - try? await Task.sleep(nanoseconds: 2_000_000_000) - copied = false - } - } - - private func formatBalance(_ amount: UInt64) -> String { - let dash = Double(amount) / 100_000_000.0 - return String(format: "%.8f DASH", dash) - } -} \ No newline at end of file diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index af6543a76d6..6795784deb7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -18,7 +18,7 @@ struct CoreContentView: View { // Display helpers private var headerHeightsDisplay: String? { let headers = walletService.syncProgress.headers - let cur = (headers?.currentHeight ?? 0) + (headers?.buffered ?? 0) + let cur = (headers?.tipHeight ?? 0) + (headers?.buffered ?? 0) let tot = headers?.targetHeight ?? 0 return heightDisplay(numerator: cur, denominator: tot) @@ -90,10 +90,6 @@ var body: some View { // Controls row HStack(spacing: 8) { - Text("Blocks hit: \(walletService.blocksHit)") - .font(.caption2) - .foregroundColor(.secondary) - Spacer() Button(action: toggleSync) { @@ -414,28 +410,23 @@ struct SyncProgressRow: View { struct WalletRowView: View { let wallet: HDWallet @EnvironmentObject var unifiedAppState: UnifiedAppState + @EnvironmentObject var walletService: WalletService private func getNetworksList() -> String { // Wallets are now single-network, just return the wallet's network - return wallet.dashNetwork.rawValue.capitalized + return wallet.network.rawValue.capitalized } var platformBalance: UInt64 { // Only sum balances of identities that belong to this specific wallet // and are on the same network - // For now, if wallet doesn't have a walletId (not yet initialized with FFI), - // don't show any platform balance - guard let walletId = wallet.walletId else { - return 0 - } - return unifiedAppState.platformState.identities .filter { identity in // Check if identity belongs to this wallet and is on the same network // Only count identities that have been explicitly associated with this wallet - identity.walletId == walletId && - identity.network == wallet.dashNetwork.rawValue + identity.walletId == wallet.walletId && + identity.network == wallet.network.rawValue } .reduce(0) { sum, identity in sum + identity.balance @@ -447,13 +438,6 @@ struct WalletRowView: View { HStack { Text(wallet.label) .font(.headline) - - Spacer() - - if wallet.syncProgress < 1.0 { - ProgressView(value: min(max(wallet.syncProgress, 0.0), 1.0)) - .frame(width: 50) - } } HStack { @@ -473,12 +457,13 @@ struct WalletRowView: View { VStack(alignment: .trailing, spacing: 2) { // Show wallet balance or "Empty" - if wallet.totalBalance == 0 { + let balance = walletService.walletManager.getBalance(for: wallet) + if balance.total == 0 { Text("Empty") .font(.caption) .foregroundColor(.secondary) } else { - Text(formatBalance(wallet.totalBalance)) + Text(balance.formattedTotal) .font(.subheadline) .fontWeight(.medium) } @@ -488,7 +473,7 @@ struct WalletRowView: View { HStack(spacing: 3) { Image(systemName: "p.circle.fill") .font(.system(size: 9)) - Text(formatBalance(platformBalance)) + Text(platformBalance.formatted()) } .font(.caption2) .foregroundColor(.blue) @@ -498,33 +483,6 @@ struct WalletRowView: View { } .padding(.vertical, 4) } - - private func formatBalance(_ amount: UInt64) -> String { - let dash = Double(amount) / 100_000_000.0 - - // Special case for zero - if dash == 0 { - return "0 DASH" - } - - // Format with up to 8 decimal places, removing trailing zeros - let formatter = NumberFormatter() - formatter.minimumFractionDigits = 0 - formatter.maximumFractionDigits = 8 - formatter.numberStyle = .decimal - formatter.groupingSeparator = "," - formatter.decimalSeparator = "." - - if let formatted = formatter.string(from: NSNumber(value: dash)) { - return "\(formatted) DASH" - } - - // Fallback formatting - let formatted = String(format: "%.8f", dash) - let trimmed = formatted.replacingOccurrences(of: "0+$", with: "", options: .regularExpression) - .replacingOccurrences(of: "\\.$", with: "", options: .regularExpression) - return "\(trimmed) DASH" - } } // MARK: - Formatting Helpers diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift index 9a21af0c9f2..82f1af14b85 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift @@ -253,7 +253,7 @@ struct CreateWalletView: View { } } - private func createWallet(using mnemonic: String?) { + private func createWallet(using mnemonic: String) { guard !walletLabel.isEmpty, walletPin == confirmPin, walletPin.count >= 4 && walletPin.count <= 6 else { @@ -270,8 +270,8 @@ struct CreateWalletView: View { do { print("=== STARTING WALLET CREATION ===") - let mnemonic: String? = (showImportOption ? importMnemonic : mnemonic) - print("Has mnemonic: \(mnemonic != nil)") + let mnemonic = (showImportOption ? importMnemonic : mnemonic) + print("Has mnemonic: \(mnemonic)") print("PIN length: \(walletPin.count)") print("Import option enabled: \(showImportOption)") @@ -286,17 +286,13 @@ struct CreateWalletView: View { throw WalletError.walletError("No network selected") } - // Create exactly one wallet in the SDK; do not append network to label - let _ = try await walletService.createWallet( + let _ = try await walletService.walletManager.createWallet( label: walletLabel, mnemonic: mnemonic, pin: walletPin, isImport: showImportOption ) - // Update wallet.networks bitfield to reflect all user selections - try? modelContext.save() - print("=== WALLET CREATION SUCCESS - Created 1 wallet for \(primaryNetwork.displayName) ===") await MainActor.run { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/FilterMatchesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/FilterMatchesView.swift deleted file mode 100644 index dae748d138d..00000000000 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/FilterMatchesView.swift +++ /dev/null @@ -1,412 +0,0 @@ -// -// FilterMatchesView.swift -// SwiftExampleApp -// -// View for displaying compact filter matches with smooth scrolling and jump-to -// - -import SwiftUI -import SwiftDashSDK - -enum FilterDisplayMode: String, CaseIterable { - case all = "All Filters" - case matched = "Matched Filters" -} - -struct FilterMatchesView: View { - @EnvironmentObject var walletService: WalletService - @StateObject private var service: FilterMatchService - @State private var showJumpToAlert = false - @State private var jumpToHeight = "" - @State private var expandedHeights: Set = [] - @State private var displayMode: FilterDisplayMode = .all - - init(walletService: WalletService) { - _service = StateObject(wrappedValue: FilterMatchService(walletService: walletService)) - } - - // Computed property to filter based on selected mode - private var filteredFilters: [CompactFilter] { - switch displayMode { - case .all: - return service.filters - case .matched: - return service.matchedFilters - } - } - - // Wait for sync to complete before loading filters - private func waitForSyncToComplete() async { - // If sync is not running, return immediately - guard !walletService.syncProgress.state.isRunning() else { return } - - print("⏳ Waiting for sync to complete before loading filters...") - - // Poll until sync completes (check every 0.5 seconds) - while !walletService.syncProgress.state.isComplete() { - try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds - } - - // Give extra time for client to fully release locks - try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second - - print("✅ Sync completed, ready to load filters") - } - - var body: some View { - VStack(spacing: 0) { - // Mode selector - modeSelector - - // Jump-to bar - jumpToBar - - // Main content - if service.isLoading && service.filters.isEmpty { - loadingView - } else if let error = service.error { - errorView(error) - } else if filteredFilters.isEmpty { - emptyView - } else { - filtersList - } - } - .navigationTitle("Compact Filters") - .navigationBarTitleDisplayMode(.inline) - .task { - // Wait for sync to complete before loading filters - await waitForSyncToComplete() - - // Initialize with current sync height - let currentHeight = walletService.syncProgress.filters?.currentHeight ?? 0 - await service.initialize(endHeight: currentHeight) - } - .alert("Jump to Height", isPresented: $showJumpToAlert) { - TextField("Block Height", text: $jumpToHeight) - .keyboardType(.numberPad) - - Button("Cancel", role: .cancel) { - jumpToHeight = "" - } - - Button("Go") { - if let height = UInt32(jumpToHeight) { - Task { - await service.jumpTo(height: height) - } - } - jumpToHeight = "" - } - } message: { - if let range = service.heightRange { - Text("Enter a height between \(range.lowerBound) and \(range.upperBound)") - } else { - Text("Enter a block height") - } - } - } - - // MARK: - Mode Selector - - private var modeSelector: some View { - Picker("Display Mode", selection: $displayMode) { - ForEach(FilterDisplayMode.allCases, id: \.self) { mode in - Text(mode.rawValue).tag(mode) - } - } - .pickerStyle(.segmented) - .padding(.horizontal) - .padding(.vertical, 12) - .background(Color(.systemBackground)) - .overlay( - Rectangle() - .frame(height: 1) - .foregroundColor(Color(.separator)), - alignment: .bottom - ) - } - - // MARK: - Jump-to Bar - - private var jumpToBar: some View { - HStack(spacing: 12) { - Text(displayMode == .all ? "All Filters" : "Matched Filters") - .font(.headline) - .foregroundColor(.secondary) - - Spacer() - - if !filteredFilters.isEmpty { - Text("\(filteredFilters.count) filter\(filteredFilters.count == 1 ? "" : "s")") - .font(.caption) - .foregroundColor(.secondary) - } - - Button(action: { - showJumpToAlert = true - }) { - HStack(spacing: 4) { - Image(systemName: "location.magnifyingglass") - Text("Jump to") - } - .font(.caption) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(8) - } - - Button(action: { - Task { - await service.reload() - } - }) { - Image(systemName: "arrow.clockwise") - .font(.caption) - .padding(8) - .background(Color.gray.opacity(0.2)) - .cornerRadius(8) - } - } - .padding(.horizontal) - .padding(.vertical, 12) - .background(Color(.systemBackground)) - .overlay( - Rectangle() - .frame(height: 1) - .foregroundColor(Color(.separator)), - alignment: .bottom - ) - } - - // MARK: - Filters List - - private var filtersList: some View { - ScrollViewReader { proxy in - List { - ForEach(Array(filteredFilters.enumerated()), id: \.element.id) { index, filter in - FilterRow(filter: filter, isExpanded: expandedHeights.contains(filter.height), isMatched: displayMode == .matched || service.isFilterMatched(filter.height)) - .onTapGesture { - withAnimation { - if expandedHeights.contains(filter.height) { - expandedHeights.remove(filter.height) - } else { - expandedHeights.insert(filter.height) - } - } - } - .onAppear { - // Trigger prefetch when this row appears - // Only prefetch in "All" mode since matched filters are already loaded - if displayMode == .all { - Task { - await service.checkPrefetch(displayedIndex: index) - } - } - } - } - - // Loading indicator at bottom - if service.isLoading { - HStack { - Spacer() - ProgressView() - .padding() - Spacer() - } - } - } - .listStyle(.plain) - } - } - - // MARK: - State Views - - private var loadingView: some View { - VStack(spacing: 16) { - ProgressView() - .scaleEffect(1.5) - - if !walletService.syncProgress.state.isComplete() { - VStack(spacing: 8) { - Text("Waiting for sync to complete...") - .font(.headline) - .foregroundColor(.secondary) - - Text("Filters cannot be loaded while sync is in progress") - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } else { - Text("Loading compact filters...") - .font(.headline) - .foregroundColor(.secondary) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private func errorView(_ error: FilterMatchError) -> some View { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 48)) - .foregroundColor(.red) - - Text("Error") - .font(.headline) - - Text(error.localizedDescription) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Button("Retry") { - Task { - // Wait for sync to complete before retrying - await waitForSyncToComplete() - await service.reload() - } - } - .buttonStyle(.borderedProminent) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var emptyView: some View { - VStack(spacing: 16) { - Image(systemName: "tray") - .font(.system(size: 48)) - .foregroundColor(.gray) - - if displayMode == .matched { - Text("No Matched Filters") - .font(.headline) - - Text("No compact filters have matched any wallet addresses yet.\n\nFilters are checked during sync for relevant transactions.") - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } else { - Text("No Compact Filters") - .font(.headline) - - Text("No compact filters have been downloaded yet.\n\nMake sure SPV sync is running.") - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -// MARK: - Filter Row Component - -struct FilterRow: View { - let filter: CompactFilter - let isExpanded: Bool - let isMatched: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - // Header row - HStack { - Image(systemName: isMatched ? "checkmark.circle.fill" : "line.3.horizontal.decrease.circle") - .foregroundColor(isMatched ? .green : .blue) - .font(.caption) - - Text("Height: \(filter.height)") - .font(.headline) - - Spacer() - - if isMatched { - Image(systemName: "star.fill") - .font(.caption2) - .foregroundColor(.orange) - } - - Text("\(filter.sizeInBytes) bytes") - .font(.caption) - .foregroundColor(.secondary) - - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.caption) - .foregroundColor(.secondary) - } - - // Expanded filter data - if isExpanded { - VStack(alignment: .leading, spacing: 8) { - // Filter size info - HStack { - Text("Size:") - .font(.caption) - .foregroundColor(.secondary) - Text("\(filter.sizeInBytes) bytes") - .font(.caption) - .foregroundColor(.primary) - Spacer() - } - - // Filter data hex preview - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Filter Data:") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Button(action: { - UIPasteboard.general.string = filter.data.hexEncodedString() - }) { - Image(systemName: "doc.on.doc") - .font(.caption2) - .foregroundColor(.blue) - } - .buttonStyle(.borderless) - } - - // Show first 32 bytes as hex preview - let previewBytes = min(32, filter.data.count) - if previewBytes > 0 { - Text(filter.data.prefix(previewBytes).hexEncodedString() + (filter.data.count > previewBytes ? "..." : "")) - .font(.system(.caption2, design: .monospaced)) - .foregroundColor(.primary) - .lineLimit(nil) - .padding(8) - .background(Color(.secondarySystemBackground)) - .cornerRadius(4) - } else { - Text("(empty)") - .font(.caption2) - .foregroundColor(.secondary) - .italic() - } - } - } - .padding(.top, 4) - } - } - .padding(.vertical, 4) - } -} - -// MARK: - Preview - -#if DEBUG -struct FilterMatchesView_Previews: PreviewProvider { - static var previews: some View { - NavigationStack { - FilterMatchesView(walletService: WalletService.shared) - .environmentObject(WalletService.shared) - } - } -} -#endif diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift index 0d7767a7b61..1edca2ca83b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift @@ -7,64 +7,57 @@ struct ReceiveAddressView: View { @EnvironmentObject var walletService: WalletService let wallet: HDWallet - @State private var currentAddress: String = "" - @State private var isLoadingAddress = false @State private var copiedToClipboard = false var body: some View { NavigationStack { VStack(spacing: 24) { - if isLoadingAddress { - ProgressView("Generating address...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if !currentAddress.isEmpty { - VStack(spacing: 24) { - // QR Code - if let qrImage = generateQRCode(from: currentAddress) { - Image(uiImage: qrImage) - .interpolation(.none) - .resizable() - .scaledToFit() - .frame(width: 250, height: 250) - .padding() - .background(Color.white) - .cornerRadius(12) - } - - // Address - VStack(spacing: 12) { - Text("Your Dash Address") - .font(.subheadline) - .foregroundColor(.secondary) - - Text(currentAddress) - .font(.system(.body, design: .monospaced)) - .multilineTextAlignment(.center) - .padding() - .background(Color(UIColor.secondarySystemBackground)) - .cornerRadius(8) - .onTapGesture { - copyToClipboard() - } - } - .padding(.horizontal) - - // Copy Button - Button { - copyToClipboard() - } label: { - Label( - copiedToClipboard ? "Copied!" : "Copy Address", - systemImage: copiedToClipboard ? "checkmark" : "doc.on.doc" - ) - .frame(maxWidth: .infinity) + let currentAddress = walletService.walletManager.getReceiveAddress(for: wallet) + + // QR Code + if let qrImage = generateQRCode(from: currentAddress) { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 250, height: 250) + .padding() + .background(Color.white) + .cornerRadius(12) + } + + // Address + VStack(spacing: 12) { + Text("Your Dash Address") + .font(.subheadline) + .foregroundColor(.secondary) + + Text(currentAddress) + .font(.system(.body, design: .monospaced)) + .multilineTextAlignment(.center) + .padding() + .background(Color(UIColor.secondarySystemBackground)) + .cornerRadius(8) + .onTapGesture { + copyToClipboard(currentAddress) } - .buttonStyle(.borderedProminent) - .padding(.horizontal) - - Spacer() - } } + .padding(.horizontal) + + // Copy Button + Button { + copyToClipboard(currentAddress) + } label: { + Label( + copiedToClipboard ? "Copied!" : "Copy Address", + systemImage: copiedToClipboard ? "checkmark" : "doc.on.doc" + ) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal) + + Spacer() } .navigationTitle("Receive Dash") .navigationBarTitleDisplayMode(.inline) @@ -76,29 +69,6 @@ struct ReceiveAddressView: View { } } } - .task { - await loadAddress() - } - } - - private func loadAddress() async { - isLoadingAddress = true - - // Try to get existing receive address or generate new one - if let currentAccount = wallet.accounts.first, - let lastAddress = currentAccount.externalAddresses.last { - currentAddress = lastAddress.address - } else { - do { - currentAddress = try await walletService.getNewAddress() - } catch { - // Use a mock address for now - let addressCount = wallet.accounts.first?.externalAddresses.count ?? 0 - currentAddress = "yMockReceiveAddress\(addressCount)" - } - } - - isLoadingAddress = false } private func generateQRCode(from string: String) -> UIImage? { @@ -119,8 +89,8 @@ struct ReceiveAddressView: View { return nil } - private func copyToClipboard() { - UIPasteboard.general.string = currentAddress + private func copyToClipboard(_ string: String) { + UIPasteboard.general.string = string copiedToClipboard = true // Reset after 2 seconds diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index 5538b9bb964..2a51a3484c8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -8,21 +8,30 @@ struct SendTransactionView: View { @State private var recipientAddress = "" @State private var amountString = "" - @State private var memo = "" - @State private var isSending = false + @State private var feeRate: FeeRate = .normal + @State private var fee: UInt64 = 0 @State private var error: Error? - @State private var successTxid: String? - private var amount: UInt64? { - guard let double = Double(amountString) else { return nil } + @State private var tx: Data? = nil + + private var feeString: String { + return formatDash(fee) + } + + private var amount: UInt64 { + guard let double = Double(amountString) else { return 0 } return UInt64(double * 100_000_000) // Convert DASH to duffs } private var canSend: Bool { !recipientAddress.isEmpty && - amount != nil && - amount! > 0 && - amount! <= wallet.confirmedBalance + amount > 0 && + amount + fee <= balance.spendable && + tx != nil + } + + private var balance: Balance { + walletService.walletManager.getBalance(for: wallet, accType: .standardBIP44, accIndex: 0) } var body: some View { @@ -38,7 +47,7 @@ struct SendTransactionView: View { Section { HStack { - TextField("0.00000000", text: $amountString) + TextField("0", text: $amountString) .keyboardType(.decimalPad) Text("DASH") @@ -48,30 +57,35 @@ struct SendTransactionView: View { HStack { Text("Available:") Spacer() - Text(formatBalance(wallet.confirmedBalance)) + Text(formatDash(balance.spendable)) .font(.caption) .foregroundColor(.secondary) } } header: { Text("Amount") } footer: { - if let amount = amount, amount > wallet.confirmedBalance { + if amount > balance.spendable { Text("Insufficient balance") .foregroundColor(.red) } } Section { - TextField("Optional message", text: $memo) + Picker("Fee Speed", selection: $feeRate) { + Text("Economy").tag(FeeRate.economy) + Text("Normal").tag(FeeRate.normal) + Text("Priority").tag(FeeRate.priority) + } + .pickerStyle(.segmented) } header: { - Text("Memo (Optional)") + Text("Transaction Speed") } Section { HStack { Text("Network Fee:") Spacer() - Text("~0.00001000 DASH") + Text(feeString) .foregroundColor(.secondary) } } @@ -89,17 +103,17 @@ struct SendTransactionView: View { Button("Send") { sendTransaction() } - .disabled(!canSend || isSending) + .disabled(!canSend) } } - .disabled(isSending) - .overlay { - if isSending { - ProgressView("Sending transaction...") - .padding() - .background(Color.gray.opacity(0.9)) - .cornerRadius(10) - } + .onChange(of: recipientAddress) { + recalculateTransaction() + } + .onChange(of: amountString) { + recalculateTransaction() + } + .onChange(of: feeRate) { + recalculateTransaction() } .alert("Error", isPresented: .constant(error != nil)) { Button("OK") { @@ -110,67 +124,64 @@ struct SendTransactionView: View { Text(error.localizedDescription) } } - .alert("Success", isPresented: .constant(successTxid != nil)) { - Button("Done") { - dismiss() - } - } message: { - if successTxid != nil { - Text("Transaction sent successfully!") - } - } + } + } + + private func recalculateTransaction() { + guard !recipientAddress.isEmpty, + amount > 0, + amount <= balance.spendable + else { + self.fee = 0 + self.tx = nil + return + } + + do { + let (tx, fee) = try createTransaction() + + self.fee = fee + self.tx = tx + } catch { + self.fee = 0 + self.tx = nil } } private func sendTransaction() { - guard let amount = amount else { return } - - isSending = true - - Task { - do { - let txid = try await walletService.sendTransaction( - to: recipientAddress, - amount: amount, - memo: memo.isEmpty ? nil : memo - ) - - await MainActor.run { - successTxid = txid - } - } catch { - await MainActor.run { - self.error = error - isSending = false - } - } + guard canSend else { return } + guard let tx else { return } + + do { + try walletService.broadcastTransaction(tx) + dismiss() + + } catch { + self.error = error } } - private func formatBalance(_ amount: UInt64) -> String { - let dash = Double(amount) / 100_000_000.0 - - // Special case for zero + private func createTransaction() throws -> (Data, UInt64) { + let outputs = [ + Transaction.Output(address: recipientAddress, amount: amount) + ] + + return try walletService.walletManager + .buildSignedTransaction( + for: wallet, + accIndex: 0, + outputs: outputs, + feeRate: feeRate + ) + } + + private func formatDash(_ dash: UInt64) -> String { if dash == 0 { return "0 DASH" } - // Format with up to 8 decimal places, removing trailing zeros - let formatter = NumberFormatter() - formatter.minimumFractionDigits = 0 - formatter.maximumFractionDigits = 8 - formatter.numberStyle = .decimal - formatter.groupingSeparator = "," - formatter.decimalSeparator = "." - - if let formatted = formatter.string(from: NSNumber(value: dash)) { - return "\(formatted) DASH" - } - - // Fallback formatting - let formatted = String(format: "%.8f", dash) - let trimmed = formatted.replacingOccurrences(of: "0+$", with: "", options: .regularExpression) - .replacingOccurrences(of: "\\.$", with: "", options: .regularExpression) - return "\(trimmed) DASH" + let dashPart = dash / 100_000_000 + let decimalPart = dash % 100_000_000 + return String(format: "~%llu.%08llu DASH", dashPart, decimalPart) } } \ No newline at end of file diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift index 0247d924e3a..7f3f15502bb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift @@ -82,24 +82,11 @@ struct TransactionListView: View { isLoading = true defer { isLoading = false } - do { - // Get wallet manager - guard let walletManager = walletService.walletManager else { - throw NSError(domain: "TransactionListView", code: 1, - userInfo: [NSLocalizedDescriptionKey: "Wallet manager not initialized"]) - } - - // Get transactions from the wallet manager - let fetchedTransactions = try await walletManager.getTransactions(for: wallet) + // Get transactions from the wallet manager + let fetchedTransactions = walletService.walletManager.getTransactions(for: wallet) - await MainActor.run { - self.transactions = fetchedTransactions - } - } catch { - await MainActor.run { - self.errorMessage = error.localizedDescription - self.showError = true - } + await MainActor.run { + self.transactions = fetchedTransactions } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 0c3288bc517..b754a39e778 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -75,8 +75,10 @@ struct WalletDetailView: View { Spacer() - if wallet.transactionCount > 0 { - Text("\(wallet.transactionCount)") + let transactions = walletService.walletManager.getTransactions(for: wallet) + let transactionCount = transactions.count + if transactionCount > 0 { + Text("\(transactionCount)") .font(.caption) .foregroundColor(.secondary) .padding(.horizontal, 8) @@ -140,9 +142,6 @@ struct WalletDetailView: View { } .environmentObject(walletService) } - .task { - await walletService.loadWallet(wallet) - } .onAppear { unifiedAppState.showWalletsSyncDetails = false } } } @@ -280,16 +279,14 @@ struct WalletInfoView: View { .foregroundColor(.secondary) } - if let walletId = wallet.walletId { - HStack { - Text("Wallet ID") - Spacer() - Text(walletId.toHexString()) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.secondary) - .textSelection(.enabled) - .multilineTextAlignment(.trailing) - } + HStack { + Text("Wallet ID") + Spacer() + Text(wallet.walletId.toHexString()) + .font(.system(.footnote, design: .monospaced)) + .foregroundColor(.secondary) + .textSelection(.enabled) + .multilineTextAlignment(.trailing) } if mainnetEnabled { @@ -373,7 +370,7 @@ struct WalletInfoView: View { private func loadNetworkStates() { // TODO: Probably not needed this way anymore? - switch wallet.dashNetwork { + switch wallet.network { case .mainnet: mainnetEnabled = true case .testnet: @@ -388,23 +385,18 @@ struct WalletInfoView: View { private func loadAccountCounts() async { // TODO: This can probably be refactored now with with single network manager? - guard let manager = walletService.walletManager else { return } + let count = walletService.walletManager.getAccounts(for: wallet).count + if mainnetEnabled { - if let list = try? await manager.getAccounts(for: wallet) { - mainnetAccountCount = list.count - } + mainnetAccountCount = count } else { mainnetAccountCount = nil } if testnetEnabled { - if let list = try? await manager.getAccounts(for: wallet) { - testnetAccountCount = list.count - } + testnetAccountCount = count } else { testnetAccountCount = nil } if devnetEnabled { - if let list = try? await manager.getAccounts(for: wallet) { - devnetAccountCount = list.count - } + devnetAccountCount = count } else { devnetAccountCount = nil } } @@ -453,57 +445,32 @@ struct WalletInfoView: View { } private func deleteWallet() async { - isDeleting = true - defer { - Task { @MainActor in - isDeleting = false - } + // IMPORTANT: Dismiss views FIRST to prevent UI from accessing deleted relationships + // This prevents "Never access a full future backing data" crash + await MainActor.run { + dismiss() + onWalletDeleted() } - do { - // IMPORTANT: Dismiss views FIRST to prevent UI from accessing deleted relationships - // This prevents "Never access a full future backing data" crash - await MainActor.run { - dismiss() - onWalletDeleted() - } - - // Notify the wallet service (removes wallet from observable arrays) - await walletService.walletDeleted(wallet) - - // Now safe to delete from Core Data (cascade will delete accounts/addresses) - modelContext.delete(wallet) - try modelContext.save() - - } catch { - await MainActor.run { - errorMessage = "Failed to delete wallet: \(error.localizedDescription)" - showError = true - } - } + try! await walletService.walletManager.deleteWallet(wallet) } } struct BalanceCardView: View { let wallet: HDWallet @EnvironmentObject var unifiedAppState: UnifiedAppState + @EnvironmentObject var walletService: WalletService var platformBalance: UInt64 { // Only sum balances of identities that belong to this specific wallet // and are on the same network - // For now, if wallet doesn't have a walletId (not yet initialized with FFI), - // don't show any platform balance - guard let walletId = wallet.walletId else { - return 0 - } - return unifiedAppState.platformState.identities .filter { identity in // Check if identity belongs to this wallet and is on the same network // Only count identities that have been explicitly associated with this wallet - identity.walletId == walletId && - identity.network == wallet.dashNetwork.rawValue + identity.walletId == wallet.walletId && + identity.network == wallet.network.rawValue } .reduce(0) { sum, identity in sum + identity.balance @@ -513,7 +480,8 @@ struct BalanceCardView: View { var body: some View { VStack(spacing: 12) { // Show main balance or "Empty Wallet" - if wallet.totalBalance == 0 { + let balance = walletService.walletManager.getBalance(for: wallet) + if balance.total == 0 { Text("Empty Wallet") .font(.system(size: 28, weight: .medium, design: .rounded)) .foregroundColor(.secondary) @@ -522,7 +490,7 @@ struct BalanceCardView: View { .font(.subheadline) .foregroundColor(.secondary) - Text(formatBalance(wallet.totalBalance)) + Text(balance.formattedTotal) .font(.system(size: 36, weight: .bold, design: .rounded)) } @@ -532,8 +500,8 @@ struct BalanceCardView: View { Text("Incoming") .font(.caption) .foregroundColor(.secondary) - if wallet.unconfirmedBalance > 0 { - Text(formatBalance(wallet.unconfirmedBalance)) + if balance.unconfirmed > 0 { + Text(balance.formattedUnconfirmed) .font(.subheadline) .fontWeight(.medium) .foregroundColor(.orange) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift index 9d948ed80bc..5086fa5fe9f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift @@ -52,11 +52,8 @@ class UnifiedAppState: ObservableObject { } // Initialize services - self.walletService = WalletService.shared self.platformState = AppState() - - // Configure wallet service with the current network from platform state - self.walletService.configure(modelContainer: modelContainer, network: platformState.currentNetwork) + self.walletService = WalletService(modelContainer: modelContainer, network: platformState.currentNetwork) // Initialize unified state (will be updated with real SDKs during async init) self.unifiedState = UnifiedStateManager() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletTests/TransactionTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletTests/TransactionTests.swift index b34147e6f94..c78b3de05c1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletTests/TransactionTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletTests/TransactionTests.swift @@ -117,7 +117,6 @@ struct MockAddress: AddressProtocol { let address: String let derivationPath: String = "m/44'/5'/0'/0/0" let index: UInt32 = 0 - let type: AddressType = .external } // MARK: - Fee Calculator @@ -154,12 +153,8 @@ protocol AddressProtocol { var address: String { get } var derivationPath: String { get } var index: UInt32 { get } - var type: AddressType { get } } -extension HDUTXO: UTXOProtocol {} -extension HDAddress: AddressProtocol {} - // MARK: - Mock coin selection for testing (UTXOManager extension removed; type not in SDK) struct MockCoinSelection { let utxos: [any UTXOProtocol]