diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVClient.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVClient.swift index a33193153b2..79ccb87b555 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVClient.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVClient.swift @@ -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,7 +266,7 @@ class SPVClient: @unchecked Sendable { func destroy() { dash_spv_ffi_client_destroy(client) dash_spv_ffi_config_destroy(config) - + client = nil config = nil } @@ -293,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. @@ -316,6 +334,7 @@ public enum SPVError: LocalizedError { case alreadySyncing case syncFailed(String) case storageOperationFailed(String) + case transactionBroadcastFailed(String) public var errorDescription: String? { switch self { @@ -335,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 5256e5f8fd6..664f0fb112e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVTypes.swift @@ -52,7 +52,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 @@ -61,7 +61,7 @@ public struct SPVBlockHeadersProgress: Sendable { public init(_ ffi: FFIBlockHeadersProgress) { state = SPVSyncState(rawValue: ffi.state.rawValue) ?? .unknown - currentHeight = ffi.tip_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/WalletService.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/WalletService.swift index da591c836b0..de77cc74903 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/WalletService.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/WalletService.swift @@ -84,9 +84,9 @@ 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, +// 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 +// 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 @@ -96,10 +96,10 @@ public class WalletService: ObservableObject { @Published public var masternodesEnabled = true @Published public var lastSyncError: Error? @Published var network: AppNetwork - + // Internal properties private var modelContainer: ModelContainer - + // SPV Client and Wallet wrappers private var spvClient: SPVClient public private(set) var walletManager: CoreWalletManager @@ -107,71 +107,71 @@ public class WalletService: ObservableObject { public init(modelContainer: ModelContainer, network: AppNetwork) { self.modelContainer = modelContainer self.network = network - + LoggingPreferences.configure() - + let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("SPV").appendingPathComponent(network.rawValue).path - + // For simplicity, lets unwrap the error. This can only fail due to - // IO errors when working with the internal storage system, I don't + // 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, ) - + self.spvClient = spvClient - + // 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) - + spvClient.setProgressUpdateEventHandler(SPVProgressUpdateEventHandlerImpl(walletService: self)) spvClient.setSyncEventsHandler(SPVSyncEventsHandlerImpl(walletService: self)) spvClient.setNetworkEventsHandler(SPVNetworkEventsHandlerImpl(walletService: self)) spvClient.setWalletEventsHandler(SPVWalletEventsHandlerImpl(walletService: self)) } - + deinit { spvClient.stopSync() spvClient.destroy() } - + private func initializeNewSPVClient() { 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.network.rawValue).path - + // 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() - + // For simplicity, lets unwrap the error. This can only fail due to - // IO errors when working with the internal storage system, I don't + // 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, ) - + self.spvClient.setProgressUpdateEventHandler(SPVProgressUpdateEventHandlerImpl(walletService: self)) self.spvClient.setSyncEventsHandler(SPVSyncEventsHandlerImpl(walletService: self)) self.spvClient.setNetworkEventsHandler(SPVNetworkEventsHandlerImpl(walletService: self)) self.spvClient.setWalletEventsHandler(SPVWalletEventsHandlerImpl(walletService: self)) - + 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 // TODO: Investigate this error self.walletManager = try! CoreWalletManager(spvClient: self.spvClient, modelContainer: self.modelContainer) - + SDKLogger.log("✅ WalletManager wrapper initialized successfully", minimumLevel: .medium) } - + // MARK: - Trusted Mode / Masternode Sync public func setMasternodesEnabled(_ enabled: Bool) { masternodesEnabled = enabled @@ -203,10 +203,14 @@ public class WalletService: ObservableObject { // 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 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") @@ -236,17 +240,17 @@ public class WalletService: ObservableObject { public func switchNetwork(to network: AppNetwork) async { guard network != self.network else { return } - + print("=== WalletService.switchNetwork START ===") print("Switching from \(self.network.rawValue) to \(network.rawValue)") - + self.network = network self.stopSync() - + print("=== WalletService.switchNetwork END ===") } - + // MARK: - SPV Event Handlers implementations internal final class SPVProgressUpdateEventHandlerImpl: SPVProgressUpdateEventHandler, Sendable { @@ -329,7 +333,7 @@ public class WalletService: ObservableObject { _ amount: Int64, _ addresses: [String] ) {} - + func onBalanceUpdated( _ walletId: String, _ spendable: UInt64, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/CoreWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/CoreWalletManager.swift index 3a2752da5a8..6464502ce65 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/CoreWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/CoreWalletManager.swift @@ -142,6 +142,15 @@ public class CoreWalletManager: ObservableObject { // MARK: - Account Management + /// Build a signed transaction + /// - Parameters: + /// - accountIndex: The account index to use + /// - outputs: The transaction outputs + /// - Returns: The signed transaction bytes + public func buildSignedTransaction(for wallet: HDWallet, accIndex: UInt32, outputs: [Transaction.Output]) throws -> (Data, UInt64) { + try sdkWalletManager.buildSignedTransaction(for: wallet, accIndex: accIndex, outputs: outputs) + } + /// Get transactions for a wallet /// - Parameters: /// - wallet: The wallet to get transactions for diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedAccount.swift index da24764a13c..4cd2207d49c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedAccount.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedAccount.swift @@ -50,8 +50,6 @@ public class ManagedAccount { return managed_core_account_get_utxo_count(handle) } - // MARK: - Transactions - /// Get all transactions for this account /// - Returns: Array of transactions public func getTransactions() -> [WalletTransaction] { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Transaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Transaction.swift index be4aa9824c3..e7d8f7b3875 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Transaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Transaction.swift @@ -1,14 +1,6 @@ import Foundation import DashSDKFFI -/// Result of building and signing a transaction -public struct BuildAndSignResult: Sendable { - /// The signed transaction bytes - public let transactionData: Data - /// The fee paid in duffs - public let fee: UInt64 -} - /// Transaction utilities for wallet operations public class Transaction { @@ -23,9 +15,11 @@ public class Transaction { } func toFFI() -> FFITxOutput { - return address.withCString { addressCStr in - FFITxOutput(address: addressCStr, amount: amount) - } + // TODO: This memory is not being freed, FFI must free FFITxOutput + // or expose a method to do it + let cString = strdup(address) + + return FFITxOutput(address: cString, amount: amount) } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift index 49c9e6821fe..5a72ccde2a2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift @@ -387,6 +387,64 @@ public class WalletManager { return success } + /// Build a signed transaction + /// - Parameters: + /// - accIndex: The account index to use + /// - outputs: The transaction outputs + /// - Returns: The signed transaction bytes and the fee + public func buildSignedTransaction(for wallet: HDWallet, accIndex: UInt32, outputs: [Transaction.Output]) 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 + + guard let wallet = try self.getWallet(id: wallet.walletId) else { + throw KeyWalletError.walletError("Wallet not found in manager") + } + + 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, + 1000, + &fee, + &txBytesPtr, + &txLen, + &error) + } + + defer { + if error.message != nil { + error_message_free(error.message) + } + for _ in ffiOutputs { + // TODO: Memory leak, FFI doesnt expose a way to free the address + } + 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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 9f40aaf059d..b651760ab29 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) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index 2925ff228cd..32a0f030ff2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -8,21 +8,31 @@ struct SendTransactionView: View { @State private var recipientAddress = "" @State private var amountString = "" - @State private var memo = "" - @State private var isSending = false + @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 } - return UInt64(double * 100_000_000) // Convert DASH to duffs + @State private var tx: Data? = nil + + private var feeString: String { + return "~\(formatDash(fee))" + } + + private var amount: UInt64 { + guard let dashAmount = Decimal(string: amountString) else { return 0 } + let duffAmount = dashAmount * 100_000_000 + return NSDecimalNumber(decimal: duffAmount).uint64Value } private var canSend: Bool { !recipientAddress.isEmpty && - amount != nil && - amount! > 0 && - amount! <= 99999 + amount > 0 && + amount <= balance.spendable && + fee <= balance.spendable - amount && + tx != nil + } + + private var balance: Balance { + walletService.walletManager.getBalance(for: wallet) } var body: some View { @@ -38,7 +48,7 @@ struct SendTransactionView: View { Section { HStack { - TextField("0.00000000", text: $amountString) + TextField("0", text: $amountString) .keyboardType(.decimalPad) Text("DASH") @@ -48,30 +58,24 @@ struct SendTransactionView: View { HStack { Text("Available:") Spacer() - Text(formatBalance(99999)) + Text(formatDash(balance.spendable)) .font(.caption) .foregroundColor(.secondary) } } header: { Text("Amount") } footer: { - if let amount = amount, amount > 99999 { + if amount > balance.spendable { Text("Insufficient balance") .foregroundColor(.red) } } - Section { - TextField("Optional message", text: $memo) - } header: { - Text("Memo (Optional)") - } - Section { HStack { Text("Network Fee:") Spacer() - Text("~0.00001000 DASH") + Text(feeString) .foregroundColor(.secondary) } } @@ -89,17 +93,14 @@ 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() } .alert("Error", isPresented: .constant(error != nil)) { Button("OK") { @@ -110,46 +111,63 @@ 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 sendTransaction() { - // TODO: Send transactions is not yet implemented + 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 formatBalance(_ amount: UInt64) -> String { - let dash = Double(amount) / 100_000_000.0 + private func sendTransaction() { + guard canSend else { return } + guard let tx else { return } + + do { + try walletService.broadcastTransaction(tx) + dismiss() - // Special case for zero - if dash == 0 { - return "0 DASH" + } catch { + self.error = error } + } - // 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 = "." + 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 + ) + } - if let formatted = formatter.string(from: NSNumber(value: dash)) { - return "\(formatted) DASH" + private func formatDash(_ dash: UInt64) -> String { + if dash == 0 { + return "0 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) } }