diff --git a/Cargo.lock b/Cargo.lock index a6bcdc931dd..61b367df3cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1607,7 +1607,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6638745c27119778d4c78959003955f00bad373c#6638745c27119778d4c78959003955f00bad373c" dependencies = [ "anyhow", "async-trait", @@ -1640,7 +1640,7 @@ dependencies = [ [[package]] name = "dash-spv-ffi" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6638745c27119778d4c78959003955f00bad373c#6638745c27119778d4c78959003955f00bad373c" dependencies = [ "cbindgen 0.29.2", "clap", @@ -1665,7 +1665,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6638745c27119778d4c78959003955f00bad373c#6638745c27119778d4c78959003955f00bad373c" dependencies = [ "anyhow", "base64-compat", @@ -1690,12 +1690,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6638745c27119778d4c78959003955f00bad373c#6638745c27119778d4c78959003955f00bad373c" [[package]] name = "dashcore-rpc" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6638745c27119778d4c78959003955f00bad373c#6638745c27119778d4c78959003955f00bad373c" dependencies = [ "dashcore-rpc-json", "hex", @@ -1708,7 +1708,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6638745c27119778d4c78959003955f00bad373c#6638745c27119778d4c78959003955f00bad373c" dependencies = [ "bincode", "dashcore", @@ -1723,7 +1723,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6638745c27119778d4c78959003955f00bad373c#6638745c27119778d4c78959003955f00bad373c" dependencies = [ "bincode", "dashcore-private", @@ -3829,7 +3829,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6638745c27119778d4c78959003955f00bad373c#6638745c27119778d4c78959003955f00bad373c" dependencies = [ "aes", "async-trait", @@ -3857,7 +3857,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6638745c27119778d4c78959003955f00bad373c#6638745c27119778d4c78959003955f00bad373c" dependencies = [ "cbindgen 0.29.2", "dashcore", @@ -3872,7 +3872,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6638745c27119778d4c78959003955f00bad373c#6638745c27119778d4c78959003955f00bad373c" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 2a27563d527..80a21c2046e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,13 +47,13 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -dash-spv-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "6638745c27119778d4c78959003955f00bad373c" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "6638745c27119778d4c78959003955f00bad373c" } +dash-spv-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "6638745c27119778d4c78959003955f00bad373c" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "6638745c27119778d4c78959003955f00bad373c" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "6638745c27119778d4c78959003955f00bad373c" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "6638745c27119778d4c78959003955f00bad373c" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "6638745c27119778d4c78959003955f00bad373c" } # Optimize heavy crypto crates even in dev/test builds so that # Halo 2 proof generation and verification run at near-release speed. diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index 4d60eeb0ec6..bfea1fe335c 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -331,21 +331,47 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - ); // Create trusted context provider - let trusted_provider = match rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new( - network, - None, // Use default quorum lookup endpoints - std::num::NonZeroUsize::new(100).unwrap(), // Cache size - ) { - Ok(provider) => { - info!("dash_sdk_create_trusted: trusted context provider created"); - Arc::new(provider) + // For local/regtest, use the quorum sidecar at localhost:22444 (dashmate Docker default) + let is_local = matches!( + config.network, + DashSDKNetwork::SDKLocal | DashSDKNetwork::SDKRegtest + ); + let trusted_provider = if is_local { + info!("dash_sdk_create_trusted: using local quorum sidecar for regtest"); + match rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new_with_url( + network, + "http://127.0.0.1:22444".to_string(), + std::num::NonZeroUsize::new(100).unwrap(), + ) { + Ok(provider) => { + info!("dash_sdk_create_trusted: local trusted context provider created"); + Arc::new(provider) + } + Err(e) => { + error!(error = %e, "dash_sdk_create_trusted: failed to create local context provider"); + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InternalError, + format!("Failed to create local context provider: {}", e), + )); + } } - Err(e) => { - error!(error = %e, "dash_sdk_create_trusted: failed to create trusted context provider"); - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create trusted context provider: {}", e), - )); + } else { + match rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new( + network, + None, // Use default quorum lookup endpoints + std::num::NonZeroUsize::new(100).unwrap(), // Cache size + ) { + Ok(provider) => { + info!("dash_sdk_create_trusted: trusted context provider created"); + Arc::new(provider) + } + Err(e) => { + error!(error = %e, "dash_sdk_create_trusted: failed to create trusted context provider"); + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InternalError, + format!("Failed to create trusted context provider: {}", e), + )); + } } }; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Models/HDWalletModels.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Models/HDWalletModels.swift index 070a9d2ae0d..fb2fa1d6b51 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Models/HDWalletModels.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Models/HDWalletModels.swift @@ -21,6 +21,9 @@ public enum AccountCategory: Equatable, Hashable, Sendable { case providerOwnerKeys case providerOperatorKeys case providerPlatformKeys + case dashPayReceivingFunds + case dashPayExternalAccount + case platformPayment } // MARK: - Account Info diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVClient.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVClient.swift index 345fd6f7211..5f56aaa8dfd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVClient.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVClient.swift @@ -44,6 +44,9 @@ class SPVClient: @unchecked Sendable { return dash_spv_ffi_config_mainnet() case 1: return dash_spv_ffi_config_testnet() + case 2: + // Regtest (local Docker) + return dash_spv_ffi_config_new(FFINetwork(rawValue: 2)) case 3: // Map devnet to custom FFINetwork value 3 return dash_spv_ffi_config_new(FFINetwork(rawValue: 3)) @@ -58,6 +61,7 @@ class SPVClient: @unchecked Sendable { // If requested, prefer local core peers (defaults to 127.0.0.1 with network default port) let useLocalCore = UserDefaults.standard.bool(forKey: "useLocalhostCore") + || UserDefaults.standard.bool(forKey: "useDockerSetup") // Only restrict to configured peers when using local core, if not, allow DNS discovery let restrictToConfiguredPeers = useLocalCore if useLocalCore { @@ -65,6 +69,8 @@ class SPVClient: @unchecked Sendable { if swiftLoggingEnabled { print("[SPV][Config] Use Local Core enabled; peers=\(peers.joined(separator: ", "))") } + // Clear default peers before adding custom Docker peers + dash_spv_ffi_config_clear_peers(configPtr) // Add peers via FFI (supports "ip:port" or bare IP for network-default port) for addr in peers { addr.withCString { cstr in @@ -135,9 +141,9 @@ class SPVClient: @unchecked Sendable { } private static func readLocalCorePeers() -> [String] { - // If no override is set, default to 127.0.0.1 and let FFI pick port by network + // If no override is set, default to dashmate Docker Core P2P port let raw = UserDefaults.standard.string(forKey: "corePeerAddresses")?.trimmingCharacters(in: .whitespacesAndNewlines) - let list = (raw?.isEmpty == false ? raw! : "127.0.0.1") + let list = (raw?.isEmpty == false ? raw! : "127.0.0.1:20001") return list .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespaces) } @@ -190,6 +196,25 @@ class SPVClient: @unchecked Sendable { config = nil } + // 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: - Synchronization func startSync() async throws { @@ -235,6 +260,7 @@ public enum SPVError: LocalizedError { case alreadySyncing case syncFailed(String) case storageOperationFailed(String) + case transactionBroadcastFailed(String) public var errorDescription: String? { switch self { @@ -254,6 +280,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/Services/WalletService.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/WalletService.swift index ba19cdb52bb..aea6a23924d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/WalletService.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/WalletService.swift @@ -104,6 +104,9 @@ public class WalletService: ObservableObject { private var spvClient: SPVClient public private(set) var walletManager: CoreWalletManager + // InstantSend lock storage for asset lock flow + private let instantLockStore = InstantLockStore() + public init(modelContainer: ModelContainer, network: AppNetwork) { self.modelContainer = modelContainer self.network = network @@ -236,6 +239,19 @@ public class WalletService: ObservableObject { self.initializeNewSPVClient() } + // MARK: - Transaction Broadcasting + + /// Broadcast a raw transaction on the Core P2P network. + public func broadcastTransaction(_ transactionData: Data) throws { + try spvClient.broadcastTransaction(transactionData) + } + + /// Wait for an InstantSend lock for a specific transaction. + /// Returns the serialized IS lock bytes when received, or throws on timeout. + public func waitForInstantLock(txid: Data, timeout: TimeInterval = 30) async throws -> Data { + try await instantLockStore.waitForLock(txid: txid, timeout: timeout) + } + public func clearSpvStorage() { if syncProgress.state.isRunning() { print("[SPV][Clear] Sync task is running, cannot clear storage") @@ -314,7 +330,9 @@ public class WalletService: ObservableObject { 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) {} + func onInstantLockReceived(_ txid: Data, _ instantLockData: Data, _ validated: Bool) { + walletService.instantLockStore.store(txid: txid, lockData: instantLockData) + } func onSyncManagerError(_ manager: SPVSyncManager, _ errorMsg: String) { SDKLogger.error("Sync manager \(manager) error: \(errorMsg)") @@ -393,3 +411,45 @@ extension Data { return map { String(format: "%02hhx", $0) }.joined() } } + +// MARK: - InstantSend Lock Store + +/// Thread-safe store for InstantSend lock data, keyed by txid. +/// Supports async waiting for a specific txid's IS lock to arrive. +internal final class InstantLockStore: @unchecked Sendable { + private var locks: [Data: Data] = [:] + private var continuations: [Data: CheckedContinuation] = [:] + private let queue = DispatchQueue(label: "com.dash.instantlock-store") + + /// Store an IS lock. Resumes waiter if one exists for this txid. + func store(txid: Data, lockData: Data) { + var cont: CheckedContinuation? + queue.sync { + locks[txid] = lockData + cont = continuations.removeValue(forKey: txid) + } + cont?.resume(returning: lockData) + } + + /// Wait for an IS lock for a specific txid. + /// Returns immediately if already cached, otherwise polls until received or timeout. + func waitForLock(txid: Data, timeout: TimeInterval = 30) async throws -> Data { + // Check if already available + if let existing = queue.sync(execute: { locks[txid] }) { + return existing + } + + // Poll-based approach — simpler and avoids continuation resume races + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + try await Task.sleep(nanoseconds: 250_000_000) // 250ms + if let existing = queue.sync(execute: { locks[txid] }) { + return existing + } + } + + throw SPVError.transactionBroadcastFailed( + "InstantSend lock timeout after \(Int(timeout))s for txid \(txid.hexString)" + ) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/CoreWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/CoreWalletManager.swift index c9535a7e207..7638b7f439c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/CoreWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/CoreWalletManager.swift @@ -105,6 +105,159 @@ public class CoreWalletManager: ObservableObject { return wallet } + /// Add a new account to a wallet. + public func addAccount(to wallet: HDWallet, type: AccountType, index: UInt32, keyClass: UInt32 = 0) throws { + guard let sdkWallet = try sdkWalletManager.getWallet(id: wallet.walletId) else { + throw WalletError.walletError("Wallet not found") + } + if type == .platformPayment { + try sdkWallet.addPlatformPaymentAccount(accountIndex: index, keyClass: keyClass) + } else { + _ = try sdkWallet.addAccount(type: type, index: index) + } + } + + // MARK: - Asset Lock Transaction + + /// Result of building an asset lock transaction. + public struct AssetLockTransactionResult { + /// Serialized transaction bytes. + public let transactionBytes: Data + /// Index of the asset lock output in the transaction. + public let outputIndex: UInt32 + /// One-time private key for the asset lock proof (32 bytes). + public let privateKey: Data + /// Actual fee paid in duffs. + public let fee: UInt64 + } + + /// Asset lock funding type. + public enum AssetLockFundingType: UInt32 { + case identityRegistration = 0 + case identityTopUp = 1 + case identityTopUpNotBound = 2 + case identityInvitation = 3 + case assetLockAddressTopUp = 4 + case assetLockShieldedAddressTopUp = 5 + } + + /// Build and sign an asset lock transaction for Core → Platform transfers. + /// + /// Creates a Core special transaction (type 8) with AssetLockPayload that locks + /// Dash for Platform credits. + /// + /// - Parameters: + /// - wallet: The wallet to fund from. + /// - accountIndex: BIP44 account index (typically 0). + /// - fundingType: The type of asset lock funding account for key derivation. + /// - identityIndex: Identity index for key derivation (0 for new). + /// - creditOutputs: Array of (scriptPubKey, amount) pairs for platform credit outputs. + /// - feePerKb: Fee rate in duffs per kilobyte (0 for default). + /// - Returns: `AssetLockTransactionResult` with tx bytes, output index, private key, and fee. + public func buildAssetLockTransaction( + for wallet: HDWallet, + accountIndex: UInt32 = 0, + fundingType: AssetLockFundingType = .assetLockAddressTopUp, + identityIndex: UInt32 = 0, + creditOutputs: [(scriptPubKey: Data, amount: UInt64)], + feePerKb: UInt64 = 1000 + ) throws -> AssetLockTransactionResult { + guard let sdkWallet = try sdkWalletManager.getWallet(id: wallet.walletId) else { + throw WalletError.walletError("Wallet not found") + } + + let count = creditOutputs.count + guard count > 0 else { + throw WalletError.walletError("At least one credit output required") + } + + // Concatenate all scripts into a single contiguous buffer + // and build an array of pointers into it + var scriptLens: [Int] = creditOutputs.map { $0.scriptPubKey.count } + var amounts: [UInt64] = creditOutputs.map { $0.amount } + var concatenatedScripts = Data() + for output in creditOutputs { + concatenatedScripts.append(output.scriptPubKey) + } + + var feeOut: UInt64 = 0 + var txBytesOut: UnsafeMutablePointer? = nil + var txLenOut: Int = 0 + var outputIndexOut: UInt32 = 0 + var privateKeyOut: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) = + (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) + var ffiError = FFIError() + + // Build pointers inside withUnsafeBytes so they remain valid + let success = concatenatedScripts.withUnsafeBytes { allScriptsBuffer -> Bool in + guard let allScriptsBase = allScriptsBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return false + } + // Build array of pointers into the concatenated buffer + var scriptPtrs: [UnsafePointer?] = [] + var offset = 0 + for len in scriptLens { + scriptPtrs.append(allScriptsBase.advanced(by: offset)) + offset += len + } + + return scriptPtrs.withUnsafeMutableBufferPointer { scriptPtrsBuffer in + scriptLens.withUnsafeMutableBufferPointer { scriptLensBuffer in + amounts.withUnsafeMutableBufferPointer { amountsBuffer in + wallet_build_and_sign_asset_lock_transaction( + sdkWalletManager.handle, + sdkWallet.handle, + accountIndex, + fundingType.rawValue, + identityIndex, + scriptPtrsBuffer.baseAddress, + scriptLensBuffer.baseAddress, + amountsBuffer.baseAddress, + count, + feePerKb, + &feeOut, + &txBytesOut, + &txLenOut, + &outputIndexOut, + &privateKeyOut, + &ffiError + ) + } + } + } + } + + guard success else { + let msg = ffiError.message != nil ? String(cString: ffiError.message!) : "Unknown error" + if ffiError.message != nil { + error_message_free(ffiError.message) + } + throw WalletError.walletError("Asset lock transaction failed: \(msg)") + } + + // Copy transaction bytes + let txData: Data + if let ptr = txBytesOut, txLenOut > 0 { + txData = Data(bytes: ptr, count: txLenOut) + transaction_bytes_free(ptr) + } else { + throw WalletError.walletError("No transaction bytes returned") + } + + // Copy private key from tuple to Data + let privateKeyData = withUnsafeBytes(of: privateKeyOut) { Data($0) } + + return AssetLockTransactionResult( + transactionBytes: txData, + outputIndex: outputIndexOut, + privateKey: privateKeyData, + fee: feeOut + ) + } + public func deleteWallet(_ wallet: HDWallet) async throws { let walletId = wallet.id @@ -244,6 +397,11 @@ public class CoreWalletManager: ObservableObject { managed = collection.getProviderOperatorKeysAccount() case .providerPlatformKeys: managed = collection.getProviderPlatformKeysAccount() + case .dashPayReceivingFunds, .dashPayExternalAccount: + managed = nil + case .platformPayment: + // Platform Payment uses ManagedPlatformAccount, handled separately below + managed = nil } let appNetwork = AppNetwork(network: sdkWalletManager.network) @@ -252,7 +410,27 @@ public class CoreWalletManager: ObservableObject { var externalDetails: [AddressDetail] = [] var internalDetails: [AddressDetail] = [] var ffiType = FFIAccountType(rawValue: 0) - if let m = managed { + + // Special handling for Platform Payment accounts — encode as bech32m + if accountInfo.category == .platformPayment { + ffiType = FFIAccountType(rawValue: AccountType.platformPayment.rawValue) + let networkValue: UInt32 = { + switch appNetwork { + case .mainnet: return 0 + case .testnet: return 1 + case .regtest: return 2 + case .devnet: return 3 + } + }() + if let platformAccount = collection.getPlatformPaymentAccount(accountIndex: accountInfo.index ?? 0, keyClass: 0), + let pool = platformAccount.getAddressPool(), + let infos = try? pool.getAddresses(from: 0, to: 0) { + externalDetails = infos.compactMap { info in + let bech32Address = Self.encodePlatformAddress(scriptPubKey: info.scriptPubKey, networkValue: networkValue) ?? info.address + return AddressDetail(address: bech32Address, index: info.index, path: info.path, isUsed: info.used, publicKey: info.publicKey?.map { String(format: "%02x", $0) }.joined() ?? "") + } + } + } else if let m = managed { ffiType = FFIAccountType(rawValue: m.accountType?.rawValue ?? 0) // Query all generated addresses (0 to 0 means "all addresses" in FFI) if let pool = m.getExternalAddressPool(), let infos = try? pool.getAddresses(from: 0, to: 0) { @@ -316,7 +494,8 @@ public class CoreWalletManager: ObservableObject { case .coinjoin: let idx = (accountInfo.index ?? 1000) - 1000 return (.coinJoin, UInt32(idx), "m/9'/\(coinType)/4'/\(idx)'") - case .identityRegistration, .identityInvitation, .identityTopupNotBound, .identityTopup: + case .identityRegistration, .identityInvitation, .identityTopupNotBound, .identityTopup, + .dashPayReceivingFunds, .dashPayExternalAccount, .platformPayment: return nil } }() @@ -360,10 +539,33 @@ public class CoreWalletManager: ObservableObject { return "m/9'/\(coinType)/3'/3'/x" case .providerPlatformKeys: return "m/9'/\(coinType)/3'/4'/x" + case .dashPayReceivingFunds: + return "m/9'/\(coinType)/5'/0'/x" + case .dashPayExternalAccount: + return "m/9'/\(coinType)/5'/0'/x" + case .platformPayment: + return "m/9'/\(coinType)/15'/\(index ?? 0)'/x" } } + /// Encode a P2PKH scriptPubKey as a bech32m platform address (DIP-17/18). + private static func encodePlatformAddress(scriptPubKey: Data, networkValue: UInt32) -> String? { + let result = scriptPubKey.withUnsafeBytes { buffer -> DashSDKResult in + guard let base = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return DashSDKResult() + } + return dash_sdk_encode_platform_address(base, UInt32(scriptPubKey.count), networkValue) + } + guard result.error == nil, let dataPtr = result.data else { + if let error = result.error { dash_sdk_error_free(error) } + return nil + } + let str = String(cString: dataPtr.assumingMemoryBound(to: CChar.self)) + dash_sdk_string_free(dataPtr) + return str + } + // Removed old FFI-based helper; using SwiftDashSDK wrappers instead /// Get all accounts for a wallet from the FFI wallet manager @@ -446,6 +648,19 @@ public class CoreWalletManager: ObservableObject { list.append(AccountInfo(category: .providerPlatformKeys, label: "Provider Platform Keys (EdDSA)", balance: b, addressCount: (0, 0))) } + // Platform Payment (DIP-17) + if collection.hasPlatformPaymentAccounts { + for accountIdx in 0.. + internal let handle: UnsafeMutablePointer private let ownsHandle: Bool // MARK: - Static Methods diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift index 93137dd4ab4..ab34303e23b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift @@ -3,7 +3,7 @@ import DashSDKFFI /// Swift wrapper for wallet manager that manages multiple wallets public class WalletManager { - private let handle: UnsafeMutablePointer + internal let handle: UnsafeMutablePointer internal let network: KeyWalletNetwork private let ownsHandle: Bool diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index e2459e90175..5f0c88ace12 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -145,7 +145,7 @@ public final class SDK: @unchecked Sendable { if let override = UserDefaults.standard.string(forKey: "platformDAPIAddresses"), !override.isEmpty { return override } - return "http://127.0.0.1:1443" + return "http://127.0.0.1:2443" } /// Create a new SDK instance with trusted setup @@ -154,39 +154,27 @@ public final class SDK: @unchecked Sendable { /// data contracts from trusted HTTP endpoints instead of requiring proof verification. /// This is suitable for mobile applications where proof verification would be resource-intensive. public init(network: Network) throws { - print("🔵 SDK.init: Creating SDK with network: \(network)") var config = DashSDKConfig() - - // Map network - in C enums, Swift imports them as raw values config.network = network - print("🔵 SDK.init: Network config set to: \(config.network)") - - // Default to SDK-provided addresses; may override below config.dapi_addresses = nil - config.skip_asset_lock_proof_verification = false config.request_retry_count = 1 config.request_timeout_ms = 8000 // 8 seconds - // Create SDK with trusted setup - print("🔵 SDK.init: Creating SDK with trusted setup...") + // Create SDK with trusted setup — Rust side auto-detects local/regtest + // and uses the quorum sidecar at localhost:22444 instead of remote endpoints let result: DashSDKResult - // Force local DAPI regardless of selected network when enabled - let forceLocal = UserDefaults.standard.bool(forKey: "useLocalhostPlatform") + let forceLocal = UserDefaults.standard.bool(forKey: "useDockerSetup") if forceLocal { let localAddresses = Self.platformDAPIAddresses - print("🔵 SDK.init: Using local DAPI addresses: \(localAddresses)") result = localAddresses.withCString { addressesCStr -> DashSDKResult in var mutableConfig = config mutableConfig.dapi_addresses = addressesCStr - print("🔵 SDK.init: Calling dash_sdk_create_trusted...") return dash_sdk_create_trusted(&mutableConfig) } } else { - print("🔵 SDK.init: Using default network addresses") result = dash_sdk_create_trusted(&config) } - print("🔵 SDK.init: dash_sdk_create_trusted returned") // Check for errors if result.error != nil { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index a7816f3bb58..df6365d1078 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -26,21 +26,20 @@ class AppState: ObservableObject { @Published var dataStatistics: (identities: Int, documents: Int, contracts: Int, tokenBalances: Int)? - @Published var useLocalPlatform: Bool { + @Published var useDockerSetup: Bool { didSet { - UserDefaults.standard.set(useLocalPlatform, forKey: "useLocalhostPlatform") - // Maintain backward-compat key for older SDK builds - UserDefaults.standard.set(useLocalPlatform, forKey: "useLocalhost") + UserDefaults.standard.set(useDockerSetup, forKey: "useDockerSetup") + // Write to legacy keys so SDK.swift and SPVClient.swift pick them up + UserDefaults.standard.set(useDockerSetup, forKey: "useLocalhostPlatform") + UserDefaults.standard.set(useDockerSetup, forKey: "useLocalhostCore") + UserDefaults.standard.set(useDockerSetup, forKey: "useLocalhost") Task { await switchNetwork(to: currentNetwork) } } } - @Published var useLocalCore: Bool { - didSet { - UserDefaults.standard.set(useLocalCore, forKey: "useLocalhostCore") - // TODO: Reconfigure SPV client peers when supported - } - } + /// Backward-compat computed properties (read-only) + var useLocalPlatform: Bool { useDockerSetup } + var useLocalCore: Bool { useDockerSetup } private let testSigner = TestSigner() private var dataManager: DataManager? @@ -54,12 +53,17 @@ class AppState: ObservableObject { } else { self.currentNetwork = .testnet } - // Migration: if legacy key set and new keys absent, propagate - let legacyLocal = UserDefaults.standard.bool(forKey: "useLocalhost") - let hasPlatformKey = UserDefaults.standard.object(forKey: "useLocalhostPlatform") != nil - let hasCoreKey = UserDefaults.standard.object(forKey: "useLocalhostCore") != nil - self.useLocalPlatform = hasPlatformKey ? UserDefaults.standard.bool(forKey: "useLocalhostPlatform") : legacyLocal - self.useLocalCore = hasCoreKey ? UserDefaults.standard.bool(forKey: "useLocalhostCore") : legacyLocal + // Migration: if legacy keys set, propagate to new unified key + if let _ = UserDefaults.standard.object(forKey: "useDockerSetup") { + self.useDockerSetup = UserDefaults.standard.bool(forKey: "useDockerSetup") + } else { + // Fall back to legacy keys + let legacyLocal = UserDefaults.standard.bool(forKey: "useLocalhostPlatform") + || UserDefaults.standard.bool(forKey: "useLocalhost") + self.useDockerSetup = legacyLocal + // Persist so SDK.swift can read it (didSet doesn't fire in init) + UserDefaults.standard.set(legacyLocal, forKey: "useDockerSetup") + } } func initializeSDK(modelContext: ModelContext) { @@ -74,21 +78,14 @@ class AppState: ObservableObject { isLoading = true NSLog("🔵 AppState: Initializing SDK library...") - // Initialize the SDK library SDK.initialize() - - // Enable debug logging to see gRPC endpoints SDK.enableLogging(level: .debug) - NSLog("🔵 AppState: Enabled debug logging for gRPC requests") - NSLog("🔵 AppState: Creating SDK instance for network: \(currentNetwork)") - // Create SDK instance for current network let sdkNetwork: DashSDKNetwork = currentNetwork.sdkNetwork - NSLog("🔵 AppState: SDK network value: \(sdkNetwork)") - + NSLog("🔵 AppState: Creating SDK for network=\(currentNetwork), docker=\(useDockerSetup)") let newSDK = try SDK(network: sdkNetwork) sdk = newSDK - NSLog("✅ AppState: SDK created successfully with handle: \(newSDK.handle != nil ? "exists" : "nil")") + NSLog("✅ AppState: SDK created successfully") // Load known contracts into the SDK's trusted provider await loadKnownContractsIntoSDK(sdk: newSDK, modelContext: modelContext) @@ -98,7 +95,9 @@ class AppState: ObservableObject { isLoading = false } catch { + sdk = nil showError(message: "Failed to initialize SDK: \(error.localizedDescription)") + NSLog("❌ AppState.initializeSDK: \(error)") isLoading = false } } @@ -200,7 +199,9 @@ class AppState: ObservableObject { isLoading = false } catch { + sdk = nil showError(message: "Failed to switch network: \(error.localizedDescription)") + NSLog("❌ AppState.switchNetwork: \(error)") isLoading = false } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 80ae6121f40..883383edcfc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -2,7 +2,7 @@ import SwiftUI import SwiftDashSDK import SwiftData -enum RootTab: Hashable { +enum RootTab: String, Hashable { case sync, wallets, friends, platform, settings } @@ -10,7 +10,13 @@ struct ContentView: View { @EnvironmentObject var unifiedState: UnifiedAppState @EnvironmentObject var walletService: WalletService - @State private var selectedTab: RootTab = .sync + @State private var selectedTab: RootTab = { + if let saved = UserDefaults.standard.string(forKey: "selectedTab"), + let tab = RootTab(rawValue: saved) { + return tab + } + return .sync + }() var body: some View { if !unifiedState.isInitialized { @@ -82,6 +88,9 @@ struct ContentView: View { } .tag(RootTab.settings) } + .onChange(of: selectedTab) { _, newTab in + UserDefaults.standard.set(newTab.rawValue, forKey: "selectedTab") + } .overlay(alignment: .top) { if walletService.syncProgress.state.isSyncing() { GlobalSyncIndicator(showDetails: selectedTab == .sync && unifiedState.showWalletsSyncDetails) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Models/DashAddress.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Models/DashAddress.swift index 82fca710ea3..26383579677 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Models/DashAddress.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Models/DashAddress.swift @@ -20,18 +20,19 @@ struct DashAddress { let data = decoded.data // Check HRP validity - let validPlatformHrp = (network == .mainnet) ? "dashevo" : "tdashevo" - let validOrchardHrp = (network == .mainnet) ? "dash" : "tdash" + // Platform and Orchard share the same HRP: "dash" (mainnet) / "tdash" (testnet/regtest) + // Distinguished by type byte: 0x00/0xb0/0x80 = platform, 0x10 = orchard + let validHrp = (network == .mainnet) ? "dash" : "tdash" - if hrp == validPlatformHrp && data.count == 21 { - // Platform address: type byte 0xb0 or 0x80 + 20-byte hash + if hrp == validHrp && data.count == 21 { + // Platform address: type byte 0x00 (P2PKH) or 0xb0/0x80 + 20-byte hash let typeByte = data[0] - if typeByte == 0xb0 || typeByte == 0x80 { + if typeByte == 0x00 || typeByte == 0xb0 || typeByte == 0x80 { return DashAddress(type: .platform(data), displayString: input) } } - if hrp == validOrchardHrp && data.count >= 2 { + if hrp == validHrp && data.count >= 2 { let typeByte = data[0] if typeByte == 0x10 { // Orchard address: 0x10 type byte + 43 bytes raw address diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ZKSyncService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ZKSyncService.swift new file mode 100644 index 00000000000..fe924f84094 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ZKSyncService.swift @@ -0,0 +1,164 @@ +// ZKSyncService.swift +// SwiftExampleApp +// +// App-level service that performs periodic ZK shielded sync (notes + nullifiers) +// with UI status display. Follows the same pattern as PlatformBalanceSyncService. + +import Foundation +import SwiftUI +import SwiftDashSDK + +/// Observable service managing periodic ZK shielded pool sync. +/// +/// Syncs every 30 seconds while the app is active, or on manual pull-to-refresh. +/// Persists `shieldedBalance` and `orchardAddress` in UserDefaults for display across launches. +@MainActor +class ZKSyncService: ObservableObject { + // MARK: - Published State + + /// Whether a sync is currently in progress. + @Published var isSyncing: Bool = false + + /// Last successful sync time (local clock). + @Published var lastSyncTime: Date? + + /// Current shielded balance (in credits). + @Published var shieldedBalance: UInt64 = 0 + + /// Orchard display address (Bech32m-encoded). + @Published var orchardAddress: String? + + /// Number of new notes found in the most recent sync. + @Published var notesSynced: Int = 0 + + /// Number of nullifiers spent in the most recent sync. + @Published var nullifiersSpent: Int = 0 + + /// Cumulative notes synced since launch. + @Published var totalNotesSynced: Int = 0 + + /// Cumulative nullifiers spent since launch. + @Published var totalNullifiersSpent: Int = 0 + + /// Total number of successful syncs since launch. + @Published var syncCountSinceLaunch: Int = 0 + + /// Last error message, cleared on successful sync. + @Published var lastError: String? + + // MARK: - Persisted State + + /// Persisted shielded balance (credits). + private var persistedBalance: UInt64 { + get { UInt64(UserDefaults.standard.integer(forKey: "\(keyPrefix)_balance")) } + set { UserDefaults.standard.set(Int(newValue), forKey: "\(keyPrefix)_balance") } + } + + /// Persisted orchard address string. + private var persistedOrchardAddress: String? { + get { UserDefaults.standard.string(forKey: "\(keyPrefix)_orchardAddress") } + set { UserDefaults.standard.set(newValue, forKey: "\(keyPrefix)_orchardAddress") } + } + + /// UserDefaults key prefix scoped to network. + private var keyPrefix: String { + "zkSync_\(networkName)" + } + + private var networkName: String = "testnet" + + // MARK: - Lifecycle + + /// Initialize for a network. Restores persisted balance and address. + /// The actual periodic loop is managed by UnifiedAppState. + func startPeriodicSync(network: AppNetwork) { + networkName = network.rawValue + + // Restore persisted state from previous session + let savedBalance = persistedBalance + if savedBalance > 0 { + shieldedBalance = savedBalance + } + + let savedAddress = persistedOrchardAddress + if let addr = savedAddress, !addr.isEmpty { + orchardAddress = addr + } + } + + /// Perform a single ZK shielded sync (notes then nullifiers). + /// + /// - Parameters: + /// - sdk: The initialized SDK instance. + /// - shieldedService: The shielded service with an initialized pool client. + func performSync(sdk: SDK, shieldedService: ShieldedService) async { + guard !isSyncing else { return } + guard let poolClient = shieldedService.poolClient else { return } + + isSyncing = true + lastError = nil + + do { + // Step 1: Sync notes + let notesResult = try await poolClient.syncNotes(sdk: sdk) + let newNotes = notesResult.newNotes + + // Step 2: Sync nullifiers + let nullifiersResult = try await poolClient.syncNullifiers(sdk: sdk) + let spentCount = nullifiersResult.spentCount + let finalBalance = nullifiersResult.balance + + // Update per-sync stats + notesSynced = newNotes + nullifiersSpent = spentCount + + // Update cumulative stats + totalNotesSynced += newNotes + totalNullifiersSpent += spentCount + + // Update balance and address + shieldedBalance = finalBalance + orchardAddress = shieldedService.orchardDisplayAddress + + // Persist balance and address + persistedBalance = finalBalance + persistedOrchardAddress = shieldedService.orchardDisplayAddress + + // Update sync metadata + lastSyncTime = Date() + syncCountSinceLaunch += 1 + + SDKLogger.log( + "ZK sync complete: \(newNotes) notes, \(spentCount) spent, balance: \(finalBalance)", + minimumLevel: .medium + ) + + } catch { + lastError = error.localizedDescription + SDKLogger.log( + "ZK sync error: \(error.localizedDescription)", + minimumLevel: .medium + ) + } + + isSyncing = false + } + + /// Reset all state (e.g. on wallet deletion or network switch). + func reset() { + isSyncing = false + lastSyncTime = nil + shieldedBalance = 0 + orchardAddress = nil + notesSynced = 0 + nullifiersSpent = 0 + totalNotesSynced = 0 + totalNullifiersSpent = 0 + syncCountSinceLaunch = 0 + lastError = nil + + // Clear persisted state + persistedBalance = 0 + persistedOrchardAddress = nil + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 1d52e08105b..c2f0f583f7f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -1,8 +1,11 @@ import Foundation +import CommonCrypto import SwiftDashSDK /// Available send flow types based on source and destination. enum SendFlow: Equatable { + case coreToPlatform // Asset lock / transfer to platform address + case coreToCore // Standard Core transaction case platformToShielded // Shield credits case shieldedToShielded // Private transfer case shieldedToPlatform // Unshield @@ -10,6 +13,8 @@ enum SendFlow: Equatable { var displayName: String { switch self { + case .coreToPlatform: return "Transfer to Platform" + case .coreToCore: return "Core Transfer" case .platformToShielded: return "Shield Credits" case .shieldedToShielded: return "Shielded Transfer" case .shieldedToPlatform: return "Unshield" @@ -19,6 +24,8 @@ enum SendFlow: Equatable { var iconName: String { switch self { + case .coreToPlatform: return "arrow.up.to.line" + case .coreToCore: return "arrow.right" case .platformToShielded: return "lock.shield" case .shieldedToShielded: return "arrow.left.arrow.right" case .shieldedToPlatform: return "lock.open" @@ -26,9 +33,11 @@ enum SendFlow: Equatable { } } - /// Approximate fee in credits for this flow type. + /// Approximate fee in duffs for this flow type. var estimatedFee: UInt64 { switch self { + case .coreToPlatform: return 100_000 // ~0.001 DASH + case .coreToCore: return 100_000 // ~0.001 DASH case .platformToShielded: return 200_000 case .shieldedToShielded: return 300_000 case .shieldedToPlatform: return 300_000 @@ -58,8 +67,8 @@ class SendViewModel: ObservableObject { @Published var error: String? @Published var successMessage: String? - // Source preference (for demo UI) - @Published var preferShieldedSource = true + // Source preference (for demo UI — defaults to Core since shielded requires setup) + @Published var preferShieldedSource = false private let network: AppNetwork @@ -95,9 +104,10 @@ class SendViewModel: ObservableObject { case .orchard: detectedFlow = preferShieldedSource ? .shieldedToShielded : .platformToShielded case .platform: - detectedFlow = .shieldedToPlatform + // If we have shielded balance, unshield; otherwise transfer from Core + detectedFlow = preferShieldedSource ? .shieldedToPlatform : .coreToPlatform case .core: - detectedFlow = .shieldedToCore + detectedFlow = preferShieldedSource ? .shieldedToCore : .coreToCore case .unknown: detectedFlow = nil } @@ -110,13 +120,19 @@ class SendViewModel: ObservableObject { func executeSend( sdk: SDK, shieldedService: ShieldedService, + walletService: WalletService, platformState: AppState, wallet: HDWallet ) async { guard let flow = detectedFlow, let amount = amount else { return } - guard let poolClient = shieldedService.poolClient else { - error = "Shielded pool not initialized" - return + + // Shielded flows need pool client; Core flows don't + let needsPoolClient = flow != .coreToPlatform && flow != .coreToCore + if needsPoolClient { + guard shieldedService.poolClient != nil else { + error = "Shielded pool not initialized" + return + } } isSending = true @@ -127,7 +143,7 @@ class SendViewModel: ObservableObject { do { switch flow { case .platformToShielded: - let bundle = try await poolClient.buildShieldBundle(amount: amount) + let bundle = try await shieldedService.poolClient!.buildShieldBundle(amount: amount) // Find an identity with sufficient balance to fund the shield guard let identity = platformState.identities.first(where: { $0.walletId == wallet.walletId && @@ -159,7 +175,7 @@ class SendViewModel: ObservableObject { case .shieldedToShielded: let parsed = DashAddress.parse(recipientAddress, network: network) guard case .orchard(let rawAddress) = parsed.type else { return } - let bundle = try await poolClient.buildTransferBundle( + let bundle = try await shieldedService.poolClient!.buildTransferBundle( recipientAddress: rawAddress, amount: amount ) @@ -172,7 +188,7 @@ class SendViewModel: ObservableObject { case .shieldedToPlatform: let parsed = DashAddress.parse(recipientAddress, network: network) guard case .platform(let addressBytes) = parsed.type else { return } - let bundle = try await poolClient.buildUnshieldBundle( + let bundle = try await shieldedService.poolClient!.buildUnshieldBundle( outputAddress: addressBytes, amount: amount ) @@ -186,7 +202,7 @@ class SendViewModel: ObservableObject { case .shieldedToCore: let parsed = DashAddress.parse(recipientAddress, network: network) guard case .core(let outputScript) = parsed.type else { return } - let bundle = try await poolClient.buildWithdrawalBundle( + let bundle = try await shieldedService.poolClient!.buildWithdrawalBundle( outputScript: outputScript, amount: amount, coreFeePerByte: 1, @@ -200,6 +216,62 @@ class SendViewModel: ObservableObject { outputScript: outputScript ) successMessage = "Withdrawal submitted" + + case .coreToPlatform: + // Core → Platform via asset lock + let parsed = DashAddress.parse(recipientAddress, network: network) + guard case .platform(let addressBytes) = parsed.type else { + error = "Invalid platform address" + return + } + + // Convert platform address (21 bytes: type + hash) to P2PKH scriptPubKey (25 bytes) + // Platform type 0x00 = P2PKH: OP_DUP OP_HASH160 <20-byte-hash> OP_EQUALVERIFY OP_CHECKSIG + let creditScript: Data + if addressBytes.count == 21 && addressBytes[0] == 0x00 { + let pubkeyHash = addressBytes.dropFirst() // 20-byte hash + var script = Data([0x76, 0xa9, 0x14]) // OP_DUP OP_HASH160 PUSH20 + script.append(contentsOf: pubkeyHash) + script.append(contentsOf: [0x88, 0xac]) // OP_EQUALVERIFY OP_CHECKSIG + creditScript = script + } else { + // Pass through as-is for other address types + creditScript = addressBytes + } + + // 1. Build the asset lock transaction + let assetLockResult = try walletService.walletManager.buildAssetLockTransaction( + for: wallet, + creditOutputs: [(scriptPubKey: creditScript, amount: amount)] + ) + + // 2. Broadcast on Core network + try walletService.broadcastTransaction(assetLockResult.transactionBytes) + + // Compute txid from transaction bytes (double SHA256, reversed) + let txid = computeTxid(from: assetLockResult.transactionBytes) + + // 3. Wait for InstantSend lock + let isLockData = try await walletService.waitForInstantLock(txid: txid, timeout: 30) + + // 4. Submit to Platform + let outPoint = buildOutPoint(txid: txid, outputIndex: assetLockResult.outputIndex) + _ = try sdk.addresses.topUpAddressFromAssetLock( + proofType: .instant, + instantLockData: isLockData, + transactionData: assetLockResult.transactionBytes, + outputIndex: assetLockResult.outputIndex, + coreChainLockedHeight: 0, + outPoint: outPoint, + assetLockPrivateKey: assetLockResult.privateKey, + outputs: [Addresses.AddressTransferOutput(addressBytes: addressBytes, amount: amount)] + ) + + successMessage = "Transfer to Platform complete" + + case .coreToCore: + // TODO: Implement standard Core → Core transaction + error = "Core to Core transfer not yet implemented" } // Refresh shielded balance @@ -209,4 +281,33 @@ class SendViewModel: ObservableObject { self.error = error.localizedDescription } } + + // MARK: - Helpers + + /// Compute txid from raw transaction bytes (double SHA256, reversed). + private func computeTxid(from txBytes: Data) -> Data { + var hash1 = Data(count: Int(CC_SHA256_DIGEST_LENGTH)) + var hash2 = Data(count: Int(CC_SHA256_DIGEST_LENGTH)) + txBytes.withUnsafeBytes { ptr in + hash1.withUnsafeMutableBytes { out in + _ = CC_SHA256(ptr.baseAddress, CC_LONG(txBytes.count), out.bindMemory(to: UInt8.self).baseAddress) + } + } + hash1.withUnsafeBytes { ptr in + hash2.withUnsafeMutableBytes { out in + _ = CC_SHA256(ptr.baseAddress, CC_LONG(hash1.count), out.bindMemory(to: UInt8.self).baseAddress) + } + } + // Txid is the reversed double-SHA256 + return Data(hash2.reversed()) + } + + /// Build a 36-byte OutPoint (txid + output index as little-endian u32). + private func buildOutPoint(txid: Data, outputIndex: UInt32) -> Data { + // OutPoint = txid (32 bytes, internal byte order) + index (4 bytes LE) + var outPoint = Data(txid.reversed()) // reversed back to internal order + var idx = outputIndex.littleEndian + outPoint.append(Data(bytes: &idx, count: 4)) + return outPoint + } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift index ab14a281b0e..0e8abb6e0f9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift @@ -9,6 +9,7 @@ struct AccountListView: View { @EnvironmentObject var walletService: WalletService let wallet: HDWallet @State private var accounts: [AccountInfo] = [] + @State private var showAddAccount = false var body: some View { ZStack { @@ -29,7 +30,22 @@ struct AccountListView: View { loadAccounts() } } - }.task { + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showAddAccount = true + } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showAddAccount) { + AddAccountView(wallet: wallet) + .environmentObject(walletService) + .onDisappear { loadAccounts() } + } + .task { loadAccounts() } } @@ -46,7 +62,7 @@ struct AccountRowView: View { /// Determines if this account type should show balance in UI var shouldShowBalance: Bool { switch account.category { - case .bip44, .bip32, .coinjoin: + case .bip44, .bip32, .coinjoin, .platformPayment: return true default: return false @@ -65,7 +81,10 @@ struct AccountRowView: View { case .providerVotingKeys: return "Voting" case .providerOwnerKeys: return "Owner" case .providerOperatorKeys: return "Operator" - case .providerPlatformKeys: return "Platform" + case .providerPlatformKeys: return "Platform Keys" + case .dashPayReceivingFunds: return "DashPay" + case .dashPayExternalAccount: return "DashPay Ext" + case .platformPayment: return account.index.map { "Payment #\($0)" } ?? "Payment" } } @@ -81,6 +100,9 @@ struct AccountRowView: View { case .providerOwnerKeys: return "key.horizontal" case .providerOperatorKeys: return "wrench.and.screwdriver" case .providerPlatformKeys: return "network" + case .dashPayReceivingFunds: return "person.2.circle" + case .dashPayExternalAccount: return "person.crop.circle.badge.questionmark" + case .platformPayment: return "creditcard.fill" } } @@ -94,6 +116,8 @@ struct AccountRowView: View { case .providerOwnerKeys: return .pink case .providerOperatorKeys: return .indigo case .providerPlatformKeys: return .teal + case .dashPayReceivingFunds, .dashPayExternalAccount: return .cyan + case .platformPayment: return .green } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AddAccountView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AddAccountView.swift new file mode 100644 index 00000000000..1f835a4502d --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AddAccountView.swift @@ -0,0 +1,319 @@ +import SwiftUI +import SwiftDashSDK +import SwiftData + +/// View for adding a new account to a wallet +struct AddAccountView: View { + @EnvironmentObject var walletService: WalletService + @Environment(\.dismiss) private var dismiss + + let wallet: HDWallet + + @State private var selectedAccountType: AddableAccountType = .bip44 + @State private var accountIndex: String = "" + @State private var keyClass: String = "0" + @State private var isCreating = false + @State private var errorMessage: String? + @State private var showError = false + + /// Account types that can be added by the user + enum AddableAccountType: String, CaseIterable, Identifiable { + case bip44 = "BIP44 (Standard)" + case bip32 = "BIP32 (Legacy)" + case coinjoin = "CoinJoin (Privacy)" + case platformPayment = "Platform Payment" + case identityTopup = "Identity Top-up" + + var id: String { rawValue } + + var accountType: AccountType { + switch self { + case .bip44: return .standardBIP44 + case .bip32: return .standardBIP32 + case .coinjoin: return .coinJoin + case .platformPayment: return .platformPayment + case .identityTopup: return .identityTopUp + } + } + + var description: String { + switch self { + case .bip44: + return "Standard account for receiving and sending DASH. Recommended for most users." + case .bip32: + return "Legacy account type for compatibility with older systems." + case .coinjoin: + return "Privacy-enhanced account for mixing transactions." + case .platformPayment: + return "Platform payment account (DIP-17) for receiving credits to platform payment addresses." + case .identityTopup: + return "Account for topping up platform identity credits." + } + } + + var icon: String { + switch self { + case .bip44: return "folder.fill" + case .bip32: return "tray.full.fill" + case .coinjoin: return "shuffle.circle.fill" + case .platformPayment: return "creditcard.fill" + case .identityTopup: return "arrow.up.circle.fill" + } + } + + var color: Color { + switch self { + case .bip44: return .blue + case .bip32: return .teal + case .coinjoin: return .orange + case .platformPayment: return .green + case .identityTopup: return .purple + } + } + + var requiresIndex: Bool { + // All these account types require an index + return true + } + + var indexPlaceholder: String { + switch self { + case .bip44: return "e.g., 1, 2, 3..." + case .bip32: return "e.g., 0, 1, 2..." + case .coinjoin: return "e.g., 0, 1, 2..." + case .platformPayment: return "e.g., 0, 1, 2..." + case .identityTopup: return "Identity index (e.g., 0)" + } + } + + /// Returns true if this account type needs a key class parameter + var requiresKeyClass: Bool { + self == .platformPayment + } + + /// Returns the derivation path template for this account type + func derivationPath(index: UInt32, keyClass: UInt32, isTestnet: Bool) -> String { + let coinType = isTestnet ? "1'" : "5'" + switch self { + case .bip44: + return "m/44'/\(coinType)/\(index)'" + case .bip32: + return "m/\(index)'" + case .coinjoin: + return "m/9'/\(coinType)/4'/\(index)'" + case .platformPayment: + return "m/9'/\(coinType)/17'/\(index)'/\(keyClass)'/..." + case .identityTopup: + return "m/9'/\(coinType)/5'/2'/\(index)'/..." + } + } + } + + var body: some View { + NavigationView { + Form { + // Account Type Selection + Section { + Picker("Account Type", selection: $selectedAccountType) { + ForEach(AddableAccountType.allCases) { type in + HStack { + Image(systemName: type.icon) + .foregroundColor(type.color) + Text(type.rawValue) + } + .tag(type) + } + } + .pickerStyle(.navigationLink) + } header: { + Text("Account Type") + } footer: { + Text(selectedAccountType.description) + .foregroundColor(.secondary) + } + + // Account Index + Section { + TextField(selectedAccountType.indexPlaceholder, text: $accountIndex) + .keyboardType(.numberPad) + } header: { + Text("Account Index") + } footer: { + Text("Enter the account index number. Each account type can have multiple accounts with different indices.") + .foregroundColor(.secondary) + } + + // Key Class (for Platform Payment accounts) + if selectedAccountType.requiresKeyClass { + Section { + TextField("e.g., 0", text: $keyClass) + .keyboardType(.numberPad) + } header: { + Text("Key Class") + } footer: { + Text("The key class level in the DIP-17 derivation path. Typically 0 for main addresses.") + .foregroundColor(.secondary) + } + } + + // Preview + Section("Preview") { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: selectedAccountType.icon) + .foregroundColor(selectedAccountType.color) + .font(.title2) + + VStack(alignment: .leading, spacing: 4) { + Text(accountLabel) + .font(.headline) + + Text(selectedAccountType.rawValue) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + if let index = parsedIndex { + Text("#\(index)") + .font(.system(.body, design: .monospaced)) + .foregroundColor(.secondary) + } + } + + // Derivation Path + if parsedIndex != nil { + HStack { + Text("Path:") + .font(.caption) + .foregroundColor(.secondary) + Text(derivationPath) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.secondary) + } + } + } + .padding(.vertical, 4) + } + + // Create Button + Section { + Button(action: createAccount) { + HStack { + Spacer() + if isCreating { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(0.8) + Text("Creating...") + } else { + Image(systemName: "plus.circle.fill") + Text("Create Account") + } + Spacer() + } + } + .disabled(!canCreate || isCreating) + } + } + .navigationTitle("Add Account") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + } + .alert("Error", isPresented: $showError) { + Button("OK") { } + } message: { + Text(errorMessage ?? "An unknown error occurred") + } + } + } + + // MARK: - Computed Properties + + private var parsedIndex: UInt32? { + guard !accountIndex.isEmpty else { return nil } + return UInt32(accountIndex) + } + + private var parsedKeyClass: UInt32 { + UInt32(keyClass) ?? 0 + } + + private var canCreate: Bool { + parsedIndex != nil + } + + private var isTestnet: Bool { + wallet.network == .testnet || wallet.network == .regtest || wallet.network == .devnet + } + + private var derivationPath: String { + guard let index = parsedIndex else { return "" } + return selectedAccountType.derivationPath(index: index, keyClass: parsedKeyClass, isTestnet: isTestnet) + } + + private var accountLabel: String { + guard let index = parsedIndex else { + return "Account" + } + + switch selectedAccountType { + case .bip44: + return index == 0 ? "Main Account" : "Account \(index)" + case .bip32: + return "BIP32 Account \(index)" + case .coinjoin: + return "CoinJoin Account \(index)" + case .platformPayment: + return "Platform Payment \(index)" + case .identityTopup: + return "Top-up Account \(index)" + } + } + + // MARK: - Actions + + private func createAccount() { + guard let index = parsedIndex else { return } + + isCreating = true + errorMessage = nil + + Task { + do { + // Add the account via the wallet manager + try walletService.walletManager.addAccount( + to: wallet, + type: selectedAccountType.accountType, + index: index, + keyClass: parsedKeyClass + ) + + await MainActor.run { + isCreating = false + dismiss() + } + } catch { + await MainActor.run { + isCreating = false + errorMessage = error.localizedDescription + showError = true + } + } + } + } +} + +// MARK: - Preview + +struct AddAccountView_Previews: PreviewProvider { + static var previews: some View { + Text("Preview requires wallet context") + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 77dab8ee47b..aa128c9e70b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -6,7 +6,10 @@ struct CoreContentView: View { @EnvironmentObject var walletService: WalletService @EnvironmentObject var unifiedAppState: UnifiedAppState @EnvironmentObject var platformBalanceSyncService: PlatformBalanceSyncService + @EnvironmentObject var zkSyncService: ZKSyncService @State private var showProofDetail = false + @State private var showPlatformDetails = false + @State private var showZKDetails = false // Progress values come from WalletService (kept in sync with SPV callbacks) // Display helpers @@ -26,7 +29,7 @@ struct CoreContentView: View { } private var filterHeightsDisplay: String? { - let cur = walletService.syncProgress.filters?.currentHeight ?? 0 + let cur = walletService.syncProgress.filters?.storedHeight ?? 0 let tot = walletService.syncProgress.filters?.targetHeight ?? 0 return heightDisplay(numerator: cur, denominator: tot) @@ -78,7 +81,11 @@ var body: some View { CompactSyncRow( title: "Filters", - progress: walletService.syncProgress.filters?.percentage ?? 0.0, + progress: { + let stored = Double(walletService.syncProgress.filters?.storedHeight ?? 0) + let target = Double(walletService.syncProgress.filters?.targetHeight ?? 0) + return target > 0 ? stored / target : 0.0 + }(), value: filterHeightsDisplay ) @@ -139,6 +146,15 @@ var body: some View { .foregroundColor(.secondary) } Spacer() + // Expand/collapse chevron + Button { + showPlatformDetails.toggle() + } label: { + Image(systemName: showPlatformDetails ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) } // Balance summary @@ -158,145 +174,299 @@ var body: some View { } } - // Active addresses - HStack { - Text("Active Addresses") - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - Text("\(platformBalanceSyncService.activeAddressCount)") - .font(.subheadline) - .fontWeight(.medium) - } - - // Chain tip height - if platformBalanceSyncService.chainTipHeight > 0 { + // Expanded details + if showPlatformDetails { + // Active addresses HStack { - Text("Chain Tip Height") + Text("Active Addresses") .font(.subheadline) .foregroundColor(.secondary) Spacer() - Text(formattedHeight(UInt32(platformBalanceSyncService.chainTipHeight))) + Text("\(platformBalanceSyncService.activeAddressCount)") .font(.subheadline) .fontWeight(.medium) } - } - // Sync checkpoint (from tree scan) - if platformBalanceSyncService.checkpointHeight > 0 { - HStack { - Text("Sync Checkpoint") - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - Text(formattedHeight(UInt32(platformBalanceSyncService.checkpointHeight))) - .font(.subheadline) - .foregroundColor(.secondary) + // Chain tip height + if platformBalanceSyncService.chainTipHeight > 0 { + HStack { + Text("Chain Tip Height") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(formattedHeight(UInt32(platformBalanceSyncService.chainTipHeight))) + .font(.subheadline) + .fontWeight(.medium) + } } - } - // Last known recent block (for compaction detection) - HStack { - Text("Last Recent Block") - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - if platformBalanceSyncService.lastKnownRecentBlock > 0 { - Text(formattedHeight(UInt32(platformBalanceSyncService.lastKnownRecentBlock))) - .font(.subheadline) - .foregroundColor(.secondary) - } else { - Text("None found") - .font(.subheadline) - .foregroundColor(.blue) - .onTapGesture { - showProofDetail = true - } + // Sync checkpoint (from tree scan) + if platformBalanceSyncService.checkpointHeight > 0 { + HStack { + Text("Sync Checkpoint") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(formattedHeight(UInt32(platformBalanceSyncService.checkpointHeight))) + .font(.subheadline) + .foregroundColor(.secondary) + } } - } - // Block time - if let blockTime = platformBalanceSyncService.lastSyncBlockTime { + // Last known recent block (for compaction detection) HStack { - Text("Block Time") + Text("Last Recent Block") .font(.subheadline) .foregroundColor(.secondary) Spacer() - Text(blockTime, style: .date) - .font(.caption) - .foregroundColor(.secondary) - Text(blockTime, style: .time) - .font(.caption) - .foregroundColor(.secondary) + if platformBalanceSyncService.lastKnownRecentBlock > 0 { + Text(formattedHeight(UInt32(platformBalanceSyncService.lastKnownRecentBlock))) + .font(.subheadline) + .foregroundColor(.secondary) + } else { + Text("None found") + .font(.subheadline) + .foregroundColor(.blue) + .onTapGesture { + showProofDetail = true + } + } } - } - // Query counts since launch - if platformBalanceSyncService.syncCountSinceLaunch > 0 { - let svc = platformBalanceSyncService - VStack(spacing: 4) { + // Block time + if let blockTime = platformBalanceSyncService.lastSyncBlockTime { HStack { - Text("Queries Since Launch") + Text("Block Time") .font(.subheadline) .foregroundColor(.secondary) Spacer() - Text("\(svc.syncCountSinceLaunch) syncs") + Text(blockTime, style: .date) + .font(.caption) + .foregroundColor(.secondary) + Text(blockTime, style: .time) .font(.caption) .foregroundColor(.secondary) } - HStack(spacing: 12) { - QueryCountBadge(label: "Trunk", count: svc.totalTrunkQueries, color: .blue) - QueryCountBadge(label: "Branch", count: svc.totalBranchQueries, color: .indigo) - QueryCountBadge(label: "Compacted", count: svc.totalCompactedQueries, detail: svc.totalCompactedEntries, color: .orange) - QueryCountBadge(label: "Recent", count: svc.totalRecentQueries, detail: svc.totalRecentEntries, color: .green) + } + + // Query counts since launch + if platformBalanceSyncService.syncCountSinceLaunch > 0 { + let svc = platformBalanceSyncService + VStack(spacing: 4) { + HStack { + Text("Queries Since Launch") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text("\(svc.syncCountSinceLaunch) syncs") + .font(.caption) + .foregroundColor(.secondary) + } + HStack(spacing: 12) { + QueryCountBadge(label: "Trunk", count: svc.totalTrunkQueries, color: .blue) + QueryCountBadge(label: "Branch", count: svc.totalBranchQueries, color: .indigo) + QueryCountBadge(label: "Compacted", count: svc.totalCompactedQueries, detail: svc.totalCompactedEntries, color: .orange) + QueryCountBadge(label: "Recent", count: svc.totalRecentQueries, detail: svc.totalRecentEntries, color: .green) + } } } + + // Action buttons + HStack { + Spacer() + + Button { + Task { + await unifiedAppState.performPlatformBalanceSync() + } + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.clockwise") + Text("Sync Now") + } + .font(.caption) + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .controlSize(.mini) + .disabled(platformBalanceSyncService.isSyncing) + + Button { + platformBalanceSyncService.reset() + } label: { + Text("Clear") + .font(.caption) + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + .tint(.red) + .controlSize(.mini) + } } - // Error display + // Error display (always visible) if let error = platformBalanceSyncService.lastError { Text(error) .font(.caption) .foregroundColor(.red) .lineLimit(2) } + } + .padding(.vertical, 4) + } header: { + Text("Platform Sync Status") + } - // Action buttons + // Section 3: ZK Shielded Sync Status + Section { + VStack(spacing: 8) { + // Sync state row HStack { + if zkSyncService.isSyncing { + ProgressView() + .scaleEffect(0.7) + Text("Syncing...") + .font(.subheadline) + .foregroundColor(.secondary) + } else if let lastSync = zkSyncService.lastSyncTime { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.caption) + Text("Last sync: \(lastSync, style: .relative)") + .font(.caption) + .foregroundColor(.secondary) + } else { + Image(systemName: "circle.dashed") + .foregroundColor(.secondary) + .font(.caption) + Text("Not synced yet") + .font(.subheadline) + .foregroundColor(.secondary) + } Spacer() - + // Expand/collapse chevron Button { - Task { - await unifiedAppState.performPlatformBalanceSync() - } + showZKDetails.toggle() } label: { - HStack(spacing: 4) { - Image(systemName: "arrow.clockwise") - Text("Sync Now") + Image(systemName: showZKDetails ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + + // Shielded balance + HStack { + Text("Shielded Balance") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + if zkSyncService.shieldedBalance > 0 { + Text(formatCredits(zkSyncService.shieldedBalance)) + .font(.subheadline) + .fontWeight(.medium) + } else { + Text("0") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + // Expanded details + if showZKDetails { + // Orchard address (truncated) + if let address = zkSyncService.orchardAddress { + HStack { + Text("Orchard Address") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(String(address.prefix(12)) + "..." + String(address.suffix(6))) + .foregroundColor(.secondary) + .font(.system(.caption, design: .monospaced)) } - .font(.caption) - .fontWeight(.medium) } - .buttonStyle(.borderedProminent) - .tint(.blue) - .controlSize(.mini) - .disabled(platformBalanceSyncService.isSyncing) - Button { - platformBalanceSyncService.reset() - } label: { - Text("Clear") + // Last sync stats + HStack { + Text("Last Sync") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text("\(zkSyncService.notesSynced) notes, \(zkSyncService.nullifiersSpent) spent") + .font(.caption) + .foregroundColor(.secondary) + } + + // Cumulative totals + HStack { + Text("Total Synced") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text("\(zkSyncService.totalNotesSynced) notes, \(zkSyncService.totalNullifiersSpent) spent") + .font(.caption) + .foregroundColor(.secondary) + } + + // Sync count + HStack { + Text("Sync Count") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text("\(zkSyncService.syncCountSinceLaunch)") + .font(.caption) + .foregroundColor(.secondary) + } + + // Action buttons + HStack { + Spacer() + + Button { + Task { + await unifiedAppState.performZKSync() + } + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.clockwise") + Text("Sync Now") + } .font(.caption) .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .controlSize(.mini) + .disabled(zkSyncService.isSyncing) + + Button { + zkSyncService.reset() + } label: { + Text("Clear") + .font(.caption) + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + .tint(.red) + .controlSize(.mini) + .disabled(zkSyncService.isSyncing) } - .buttonStyle(.borderedProminent) - .tint(.red) - .controlSize(.mini) + } + + // Error display (always visible) + if let error = zkSyncService.lastError { + Text(error) + .font(.caption) + .foregroundColor(.red) + .lineLimit(2) } } .padding(.vertical, 4) } header: { - Text("Platform Sync Status") + Text("ZK Shielded Sync") } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift index 68bc0220e35..e1e7479ed42 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift @@ -38,11 +38,14 @@ struct CreateWalletView: View { unifiedAppState.platformState.currentNetwork } - // Only show devnet option if currently on devnet var shouldShowDevnet: Bool { currentNetwork == .devnet } + var shouldShowRegtest: Bool { + currentNetwork == .regtest + } + var body: some View { Form { Section { @@ -96,6 +99,19 @@ struct CreateWalletView: View { } .toggleStyle(CheckboxToggleStyle()) } + + // Only show Regtest/Local if currently on Local + if shouldShowRegtest { + Toggle(isOn: $createForRegtest) { + HStack { + Image(systemName: "network") + .foregroundColor(.purple) + Text("Local") + .font(.body) + } + } + .toggleStyle(CheckboxToggleStyle()) + } } .padding(.vertical, 4) } header: { @@ -221,7 +237,7 @@ struct CreateWalletView: View { } private var hasNetworkSelected: Bool { - createForMainnet || createForTestnet || createForDevnet + createForMainnet || createForTestnet || createForDevnet || createForRegtest } private func setupInitialNetworkSelection() { @@ -279,6 +295,7 @@ struct CreateWalletView: View { createForMainnet ? AppNetwork.mainnet : nil, createForTestnet ? AppNetwork.testnet : nil, (createForDevnet && shouldShowDevnet) ? AppNetwork.devnet : nil, + (createForRegtest && shouldShowRegtest) ? AppNetwork.regtest : nil, ].compactMap { $0 } guard let primaryNetwork = selectedNetworks.first else { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift index b4d677297ca..321b4561c3f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift @@ -17,6 +17,8 @@ struct ReceiveAddressView: View { @State private var selectedTab: ReceiveAddressTab = .core @State private var copiedToClipboard = false + @State private var faucetStatus: String? + @State private var isFaucetLoading = false private var currentAddress: String { switch selectedTab { @@ -143,6 +145,27 @@ struct ReceiveAddressView: View { .buttonStyle(.borderedProminent) .tint(tabColor) .padding(.horizontal) + + // Faucet button — only on local Docker, Core tab + if selectedTab == .core && unifiedAppState.platformState.useDockerSetup { + Button { + Task { await requestFromFaucet() } + } label: { + HStack { + if isFaucetLoading { + ProgressView().scaleEffect(0.8) + } else { + Image(systemName: "drop.fill") + } + Text(faucetStatus ?? "Get 10 DASH from Faucet") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.green) + .padding(.horizontal) + .disabled(isFaucetLoading) + } } else { Spacer() Text(unavailableMessage) @@ -217,4 +240,79 @@ struct ReceiveAddressView: View { copiedToClipboard = false } } + + /// Request 10 DASH from the local Docker faucet (seed node Core RPC). + private func requestFromFaucet() async { + isFaucetLoading = true + faucetStatus = nil + defer { isFaucetLoading = false } + + let address = currentAddress + guard !address.isEmpty else { + faucetStatus = "No address available" + return + } + + // Read RPC port and password from UserDefaults, with dashmate defaults + let rpcPort = UserDefaults.standard.string(forKey: "faucetRPCPort") ?? "20302" + let rpcUser = UserDefaults.standard.string(forKey: "faucetRPCUser") ?? "dashmate" + let rpcPassword = UserDefaults.standard.string(forKey: "faucetRPCPassword") ?? "dashmate" + + guard let url = URL(string: "http://127.0.0.1:\(rpcPort)/") else { + faucetStatus = "Invalid RPC URL" + return + } + + let body: [String: Any] = [ + "jsonrpc": "1.0", + "id": "faucet", + "method": "sendtoaddress", + "params": [address, 10] + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: body) else { + faucetStatus = "Failed to encode request" + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = jsonData + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + + let credentials = "\(rpcUser):\(rpcPassword)" + if let credData = credentials.data(using: .utf8) { + request.setValue("Basic \(credData.base64EncodedString())", forHTTPHeaderField: "Authorization") + } + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + faucetStatus = "Invalid response" + return + } + + if httpResponse.statusCode == 200 { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let txid = json["result"] as? String { + faucetStatus = "Sent! tx: \(txid.prefix(12))..." + } else { + faucetStatus = "Sent!" + } + } else if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + faucetStatus = "Auth failed — set faucetRPCPassword in UserDefaults" + } else { + let body = String(data: data, encoding: .utf8) ?? "" + faucetStatus = "RPC error \(httpResponse.statusCode): \(body.prefix(80))" + } + } catch { + faucetStatus = "Network error: \(error.localizedDescription)" + } + + // Clear status after 5 seconds + Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) + faucetStatus = nil + } + } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index 9b512ef62fa..a8d3af87804 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -28,6 +28,29 @@ struct SendTransactionView: View { if !viewModel.recipientAddress.isEmpty { AddressTypeBadge(type: viewModel.detectedAddressType) } + + // Quick-fill address buttons + let quickAddresses = buildQuickAddresses() + if !quickAddresses.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(quickAddresses, id: \.label) { qa in + Button { + viewModel.recipientAddress = qa.address + } label: { + Text(qa.label) + .font(.caption2) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(qa.color.opacity(0.15)) + .foregroundColor(qa.color) + .cornerRadius(12) + } + .buttonStyle(.plain) + } + } + } + } } header: { Text("Recipient") } @@ -103,25 +126,6 @@ struct SendTransactionView: View { } } - // Asset Lock (disabled) - Section { - HStack { - Image(systemName: "lock.fill") - .foregroundColor(.gray) - Text("Asset Lock") - .foregroundColor(.gray) - Spacer() - Text("Coming Soon") - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color(UIColor.tertiarySystemBackground)) - .cornerRadius(6) - } - } header: { - Text("Other Options") - } } .navigationTitle("Send Dash") .navigationBarTitleDisplayMode(.inline) @@ -136,6 +140,7 @@ struct SendTransactionView: View { await viewModel.executeSend( sdk: sdk, shieldedService: shieldedService, + walletService: walletService, platformState: unifiedAppState.platformState, wallet: wallet ) @@ -189,6 +194,8 @@ struct SendTransactionView: View { private func flowColor(for flow: SendFlow) -> Color { switch flow { + case .coreToPlatform: return .indigo + case .coreToCore: return .blue case .platformToShielded: return .purple case .shieldedToShielded: return .purple case .shieldedToPlatform: return .blue @@ -209,6 +216,61 @@ struct SendTransactionView: View { } return String(format: "%.8f DASH", dash) } + + // MARK: - Quick Address Buttons + + private struct QuickAddress { + let label: String + let address: String + let color: Color + } + + private func buildQuickAddresses() -> [QuickAddress] { + var addresses: [QuickAddress] = [] + let wallets = walletService.walletManager.wallets + + // Our wallet's internal addresses + let ownCoreAddress = walletService.walletManager.getReceiveAddress(for: wallet) + if !ownCoreAddress.isEmpty { + addresses.append(QuickAddress(label: "My Core", address: ownCoreAddress, color: .blue)) + } + + // Our platform address + if let collection = walletService.walletManager.getManagedAccountCollection(for: wallet), + let platformAccount = collection.getPlatformPaymentAccount(accountIndex: 0, keyClass: 0), + let pool = platformAccount.getAddressPool(), + let infos = try? pool.getAddresses(from: 0, to: 1), + let addrInfo = infos.first { + let networkValue: UInt32 = wallet.network == .mainnet ? 0 : 1 + let result = addrInfo.scriptPubKey.withUnsafeBytes { buffer -> DashSDKResult in + guard let base = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return DashSDKResult() + } + return dash_sdk_encode_platform_address(base, UInt32(addrInfo.scriptPubKey.count), networkValue) + } + if result.error == nil, let dataPtr = result.data { + let str = String(cString: dataPtr.assumingMemoryBound(to: CChar.self)) + dash_sdk_string_free(dataPtr) + addresses.append(QuickAddress(label: "My Platform", address: str, color: .indigo)) + } + } + + // Our shielded address + if let orchardAddress = shieldedService.orchardDisplayAddress { + addresses.append(QuickAddress(label: "My Shielded", address: orchardAddress, color: .purple)) + } + + // Other wallet's addresses (first wallet that isn't ours) + if let otherWallet = wallets.first(where: { $0.id != wallet.id }) { + let otherCore = walletService.walletManager.getReceiveAddress(for: otherWallet) + if !otherCore.isEmpty { + let name = otherWallet.label.isEmpty ? "Other" : otherWallet.label + addresses.append(QuickAddress(label: "\(name) Core", address: otherCore, color: .green)) + } + } + + return addresses + } } // MARK: - Subviews diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletsContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletsContentView.swift index 4a4199c0976..315283e80a6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletsContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletsContentView.swift @@ -126,6 +126,7 @@ struct WalletsContentView: View { } .refreshable { await unifiedAppState.performPlatformBalanceSync() + await unifiedAppState.performZKSync() } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index f53c7b7401f..b35e0251681 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -50,6 +50,7 @@ struct SwiftExampleAppApp: App { .environmentObject(unifiedState.unifiedState) .environmentObject(unifiedState.shieldedService) .environmentObject(unifiedState.platformBalanceSyncService) + .environmentObject(unifiedState.zkSyncService) .environment(\.modelContext, unifiedState.modelContainer.mainContext) .task { SDKLogger.log("🚀 SwiftExampleApp: Starting initialization...", minimumLevel: .medium) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift index f205bdef6c7..3b10c146cc2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift @@ -32,6 +32,9 @@ class UnifiedAppState: ObservableObject { // Platform address balance sync service (BLAST sync) let platformBalanceSyncService = PlatformBalanceSyncService() + // ZK shielded pool sync service + let zkSyncService = ZKSyncService() + // State from Platform let platformState: AppState @@ -47,6 +50,9 @@ class UnifiedAppState: ObservableObject { // Task for the periodic sync loop private var syncLoopTask: Task? + // Task for the periodic ZK sync loop + private var zkSyncLoopTask: Task? + // Computed property for easy SDK access var sdk: SDK? { platformState.sdk @@ -93,6 +99,9 @@ class UnifiedAppState: ObservableObject { // Start periodic BLAST address sync startPlatformBalanceSync() + // Start periodic ZK shielded sync + startZKSync() + isInitialized = true } @@ -106,6 +115,9 @@ class UnifiedAppState: ObservableObject { syncLoopTask?.cancel() syncLoopTask = nil platformBalanceSyncService.reset() + zkSyncLoopTask?.cancel() + zkSyncLoopTask = nil + zkSyncService.reset() // Reset platform state platformState.sdk = nil @@ -129,6 +141,9 @@ class UnifiedAppState: ObservableObject { // Restart BLAST sync for the new network startPlatformBalanceSync() + // Restart ZK sync for the new network + startZKSync() + // The platform state handles its own network switching in AppState.switchNetwork } @@ -189,6 +204,42 @@ class UnifiedAppState: ObservableObject { } } + /// Start periodic ZK shielded sync (every 30 seconds). + func startZKSync() { + // Cancel any previous sync loop + zkSyncLoopTask?.cancel() + + let network = platformState.currentNetwork + zkSyncService.startPeriodicSync(network: network) + + // Run a repeating async loop + zkSyncLoopTask = Task { [weak self] in + // Initial delay to allow SDK and shielded service to initialize + try? await Task.sleep(for: .seconds(5)) + await self?.performZKSync() + + // Repeat every 30 seconds + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(30)) + } catch { + break // Task was cancelled + } + await self?.performZKSync() + } + } + } + + /// Perform a single ZK shielded sync. Skips silently if no SDK or pool client. + func performZKSync() async { + guard let sdk = platformState.sdk else { return } + + // Skip silently if shielded pool client is not initialized + guard shieldedService.poolClient != nil else { return } + + await zkSyncService.performSync(sdk: sdk, shieldedService: shieldedService) + } + /// Initialize the shielded service using the first wallet's seed. /// Call after wallet seed becomes available or on network switch. func initializeShieldedService() { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 89258fca735..a953371e9ad 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -19,6 +19,11 @@ struct OptionsView: View { if newNetwork != appState.currentNetwork { isSwitchingNetwork = true Task { + // Auto-disable Docker when leaving Local + if newNetwork != .regtest && appState.useDockerSetup { + appState.useDockerSetup = false + } + // Update platform state (which will trigger SDK switch) appState.currentNetwork = newNetwork @@ -39,21 +44,27 @@ struct OptionsView: View { .pickerStyle(SegmentedPickerStyle()) .disabled(isSwitchingNetwork) - Toggle("Use Local DAPI (Platform)", isOn: $appState.useLocalPlatform) - .onChange(of: appState.useLocalPlatform) { _, _ in - isSwitchingNetwork = true - Task { - await appState.switchNetwork(to: appState.currentNetwork) - await MainActor.run { isSwitchingNetwork = false } + if appState.currentNetwork == .regtest { + Toggle("Use Docker Setup", isOn: $appState.useDockerSetup) + .onChange(of: appState.useDockerSetup) { _, _ in + isSwitchingNetwork = true + Task { + await appState.switchNetwork(to: appState.currentNetwork) + await MainActor.run { isSwitchingNetwork = false } + } } + .help("Connect to local dashmate Docker network.") + + if appState.useDockerSetup { + TextField("Faucet RPC Password", text: Binding( + get: { UserDefaults.standard.string(forKey: "faucetRPCPassword") ?? "" }, + set: { UserDefaults.standard.set($0, forKey: "faucetRPCPassword") } + )) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() } - .help("When enabled, Platform requests use local DAPI at 127.0.0.1:1443 (override via 'platformDAPIAddresses').") - - Toggle("Use Local Core (SPV)", isOn: $appState.useLocalCore) - .onChange(of: appState.useLocalCore) { _, _ in - // Core override will be applied when SPV peer overrides are supported - } - .help("When enabled, Core (SPV) connects only to configured peers (default 127.0.0.1 with network port). Override via 'corePeerAddresses'.") + } HStack { Text("Network Status") @@ -76,6 +87,7 @@ struct OptionsView: View { .foregroundColor(.red) } } + } Section("Data") {