diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 69254a852..8257997fb 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -681,6 +681,8 @@ 751C05DB2D3D0E8C00475E52 /* JoinDashPayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 751C05DA2D3D0E7C00475E52 /* JoinDashPayViewModel.swift */; }; 751C05DD2D3E39A800475E52 /* TransactionListDataItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 751C05DC2D3E39A600475E52 /* TransactionListDataItem.swift */; }; 751C05DE2D3E39A800475E52 /* TransactionListDataItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 751C05DC2D3E39A600475E52 /* TransactionListDataItem.swift */; }; + 5A1EC0FE2E29A30000000002 /* CoinJoinMoveFundsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1EC0FE2E29A30000000001 /* CoinJoinMoveFundsSheet.swift */; }; + 5A1EC0FE2E29A30000000003 /* CoinJoinMoveFundsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1EC0FE2E29A30000000001 /* CoinJoinMoveFundsSheet.swift */; }; 7527720D2AA9B2630066557E /* SupportedTopperAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7527720C2AA9B2630066557E /* SupportedTopperAssets.swift */; }; 7527720F2AA9F58E0066557E /* TopperViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7527720E2AA9F58E0066557E /* TopperViewModel.swift */; }; 752772122AAA1CE30066557E /* Coinbase-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 752772112AAA1CE30066557E /* Coinbase-Info.plist */; }; @@ -2840,6 +2842,7 @@ 751C05D82D3BB6A200475E52 /* CurrentUserProfileModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentUserProfileModel.swift; sourceTree = ""; }; 751C05DA2D3D0E7C00475E52 /* JoinDashPayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinDashPayViewModel.swift; sourceTree = ""; }; 751C05DC2D3E39A600475E52 /* TransactionListDataItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionListDataItem.swift; sourceTree = ""; }; + 5A1EC0FE2E29A30000000001 /* CoinJoinMoveFundsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinJoinMoveFundsSheet.swift; sourceTree = ""; }; 7527720C2AA9B2630066557E /* SupportedTopperAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedTopperAssets.swift; sourceTree = ""; }; 7527720E2AA9F58E0066557E /* TopperViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopperViewModel.swift; sourceTree = ""; }; 752772112AAA1CE30066557E /* Coinbase-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Coinbase-Info.plist"; sourceTree = ""; }; @@ -4331,6 +4334,7 @@ 754BEA112C0B6BD700E8C93C /* HomeViewModel.swift */, 751C05DC2D3E39A600475E52 /* TransactionListDataItem.swift */, 5A1EC0FE2E29A10000000001 /* ShieldedActivityHistory.swift */, + 5A1EC0FE2E29A30000000001 /* CoinJoinMoveFundsSheet.swift */, 5A1EC0FE2E29A20000000014 /* PlatformAddressHistory.swift */, 75D657662DF579F300ACE570 /* TransactionFilterDialog.swift */, 4E55DD28C4EBE049216291A2 /* HomeUsernameRow.swift */, @@ -9626,6 +9630,7 @@ 470AE1882926600A001A0514 /* PaymentController.swift in Sources */, 751C05DE2D3E39A800475E52 /* TransactionListDataItem.swift in Sources */, 5A1EC0FE2E29A10000000003 /* ShieldedActivityHistory.swift in Sources */, + 5A1EC0FE2E29A30000000003 /* CoinJoinMoveFundsSheet.swift in Sources */, 5A1EC0FE2E29A20000000013 /* PlatformAddressActivityStore.swift in Sources */, 5A1EC0FE2E29A20000000016 /* PlatformAddressHistory.swift in Sources */, 2ADB396C242615C200A6F898 /* CALayer+MBAnimationPersistence.m in Sources */, @@ -10440,6 +10445,7 @@ C9D2C95E2A386D7E00D15901 /* DWBasePressableControl.m in Sources */, 751C05DD2D3E39A800475E52 /* TransactionListDataItem.swift in Sources */, 5A1EC0FE2E29A10000000002 /* ShieldedActivityHistory.swift in Sources */, + 5A1EC0FE2E29A30000000002 /* CoinJoinMoveFundsSheet.swift in Sources */, 5A1EC0FE2E29A20000000012 /* PlatformAddressActivityStore.swift in Sources */, 5A1EC0FE2E29A20000000015 /* PlatformAddressHistory.swift in Sources */, C9D2C70B2A320AA000D15901 /* DWDemoAppRootViewController.m in Sources */, diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift index e566ce1c6..aa1c5f8c7 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift @@ -134,7 +134,7 @@ final class SwiftDashSDKTransactionSender: NSObject { vaultAddress: String, amountDuffs: UInt64, memo: String - ) throws -> (tx: CoreTransaction, txHash: Data) { + ) throws -> (tx: FinalizedCoreTransaction, txHash: Data) { let memoData = Data(memo.utf8) guard memoData.count <= Self.maxSwapMemoBytes else { throw SendError.invalidSwapMemo("Swap memo is too long. Please refresh and try again.") @@ -142,7 +142,7 @@ final class SwiftDashSDKTransactionSender: NSObject { logger.info("๐Ÿ’ธ TXSEND :: building+signing MAYA swap deposit via PlatformWalletManager.coreWallet") - let build = { @MainActor () throws -> (tx: CoreTransaction, network: Network) in + let build = { @MainActor () throws -> (tx: FinalizedCoreTransaction, network: Network) in guard let wallet = SwiftDashSDKHost.shared.wallet, let network = SwiftDashSDKHost.shared.runningNetwork else { throw SendError.walletNotReady("PlatformWalletManager wallet is not available") @@ -153,16 +153,15 @@ final class SwiftDashSDKTransactionSender: NSObject { try builder.addOpReturn(memoData) try builder.preserveOutputOrder() try builder.changeToFirstInput() - try builder.setFunding(wallet: wallet, accountType: .bip44, accountIndex: 0) - let tx = try builder.buildSigned(wallet: wallet, accountType: .bip44, accountIndex: 0) + let tx = try builder.finalizeAtomic(wallet: wallet, accountType: .bip44, accountIndex: 0) return (tx, network) } - let built: (tx: CoreTransaction, network: Network) + let built: (tx: FinalizedCoreTransaction, network: Network) if Thread.isMainThread { built = try MainActor.assumeIsolated { try build() } } else { - var captured: Result<(tx: CoreTransaction, network: Network), Error> = + var captured: Result<(tx: FinalizedCoreTransaction, network: Network), Error> = .failure(SendError.walletNotReady("uninitialized result")) DispatchQueue.main.sync { captured = Result { try MainActor.assumeIsolated { try build() } } @@ -170,16 +169,18 @@ final class SwiftDashSDKTransactionSender: NSObject { built = try captured.get() } + let txData = try built.tx.serializedData() try assertSwapDepositShape( - tx: built.tx, + txData: txData, + feeDuffs: built.tx.fee, network: built.network, vaultAddress: vaultAddress, amountDuffs: amountDuffs, memoData: memoData ) - let txHash = computeTxHash(from: built.tx.data) - logger.info("๐Ÿ’ธ TXSEND :: built+signed MAYA swap deposit โ€” txHash=\(txHash.map { String(format: "%02x", $0) }.joined(), privacy: .public) fee=\(built.tx.fee, privacy: .public) duffs size=\(built.tx.data.count, privacy: .public) bytes") + let txHash = computeTxHash(from: txData) + logger.info("๐Ÿ’ธ TXSEND :: built+signed MAYA swap deposit โ€” txHash=\(txHash.map { String(format: "%02x", $0) }.joined(), privacy: .public) fee=\(built.tx.fee, privacy: .public) duffs size=\(txData.count, privacy: .public) bytes") return (built.tx, txHash) } @@ -591,13 +592,14 @@ final class SwiftDashSDKTransactionSender: NSObject { } private static func assertSwapDepositShape( - tx: CoreTransaction, + txData: Data, + feeDuffs: UInt64, network: Network, vaultAddress: String, amountDuffs: UInt64, memoData: Data ) throws { - let decoded = try TransactionDecoder.decode(tx.data, network: network) + let decoded = try TransactionDecoder.decode(txData, network: network) guard decoded.outputs.count >= 2, decoded.outputs.count <= 3 else { throw SendError.invalidInput("swap deposit must have 2 or 3 outputs") } @@ -631,7 +633,7 @@ final class SwiftDashSDKTransactionSender: NSObject { } } - guard tx.fee >= UInt64(tx.data.count) else { + guard feeDuffs >= UInt64(txData.count) else { throw SendError.invalidInput("swap deposit fee rate fell below the 1 duff/byte relay minimum") } } diff --git a/DashWallet/Sources/Models/Transactions/WalletSendService.swift b/DashWallet/Sources/Models/Transactions/WalletSendService.swift index cd41a7138..d1d5b0cbe 100644 --- a/DashWallet/Sources/Models/Transactions/WalletSendService.swift +++ b/DashWallet/Sources/Models/Transactions/WalletSendService.swift @@ -543,7 +543,7 @@ final class WalletSendService: NSObject { ) return PreparedStandardSend( - txData: tx.data, + txData: try tx.serializedData(), txHash: txHash, fee: tx.fee, address: vaultAddress, diff --git a/DashWallet/Sources/UI/CrowdNode/Online/OnlineAccountEmailController.swift b/DashWallet/Sources/UI/CrowdNode/Online/OnlineAccountEmailController.swift index dd9a290ff..7e32b0819 100644 --- a/DashWallet/Sources/UI/CrowdNode/Online/OnlineAccountEmailController.swift +++ b/DashWallet/Sources/UI/CrowdNode/Online/OnlineAccountEmailController.swift @@ -101,7 +101,8 @@ final class OnlineAccountEmailController: UIViewController { .receive(on: DispatchQueue.main) .compactMap { $0 } .sink { [weak self] error in - if let error, error is CrowdNode.Error { + // `compactMap` already unwrapped the published optional. + if error is CrowdNode.Error { self?.viewModel.clearError() self?.navigationController?.toErrorScreen( error: error as? CrowdNode.Error ?? .messageStatus(error: error.localizedDescription)) diff --git a/DashWallet/Sources/UI/CrowdNode/Portal/CrowdNodePortalViewController.swift b/DashWallet/Sources/UI/CrowdNode/Portal/CrowdNodePortalViewController.swift index db0d4531b..72d9d93a3 100644 --- a/DashWallet/Sources/UI/CrowdNode/Portal/CrowdNodePortalViewController.swift +++ b/DashWallet/Sources/UI/CrowdNode/Portal/CrowdNodePortalViewController.swift @@ -185,7 +185,8 @@ extension CrowdNodePortalController { .receive(on: DispatchQueue.main) .compactMap { $0 } .sink(receiveValue: { [weak self] error in - if let error, error is CrowdNode.Error { + // `compactMap` already unwrapped the published optional. + if error is CrowdNode.Error { self?.viewModel.clearError() self?.navigationController?.toErrorScreen( error: error as? CrowdNode.Error ?? .messageStatus(error: error.localizedDescription)) diff --git a/DashWallet/Sources/UI/Home/Views/CoinJoinMoveFundsSheet.swift b/DashWallet/Sources/UI/Home/Views/CoinJoinMoveFundsSheet.swift new file mode 100644 index 000000000..c446720a2 --- /dev/null +++ b/DashWallet/Sources/UI/Home/Views/CoinJoinMoveFundsSheet.swift @@ -0,0 +1,430 @@ +// +// CoinJoinMoveFundsSheet.swift +// DashWallet +// +// Post-sync "move your mixed coins" destination sheet. Shown instead of the +// plain BIP44-only popup (HomeViewController.showCoinJoinSweepDialog) when +// the leftover CoinJoin balance is large enough to shield +// (HomeViewModel.coinJoinShieldDestinationAvailable): the user picks between +// +// - Dash Wallet balance โ€” the existing one-hop sweep +// (`WalletSendService.sweepCoinJoin`), and +// - Shielded balance โ€” a single CoinJoin-drain asset lock +// (`ShieldedTransferCoordinator.performAssetLock(funding: .coinJoinDrain)`): +// every mixed-coin UTXO funds the Type 18 lock directly and the shielded +// pool receives `lock_value โˆ’ pool_fee` โ€” no transparent intermediate hop. +// +// Failure posture of the shielded flow: a committed asset lock is resumed on +// its exact outpoint via `resumeAssetLock` (mirroring the internal transfer +// confirm sheet's "Try again"); a stuck lock that survives the session is +// picked up by the home tx list's `ShieldedRecoverySheet` on the next +// launch. A pre-broadcast failure moves nothing โ€” the coins stay in the +// CoinJoin account and the popup/Settings surfaces keep offering the move. +// + +import Combine +import DashUIKit +import SwiftUI + +// MARK: - ViewModel + +@MainActor +final class CoinJoinMoveFundsViewModel: ObservableObject { + + enum Destination: Equatable { + case wallet + case shielded + } + + enum Stage: Equatable { + /// Destination choice screen. + case choice + /// BIP44 sweep in flight. + case movingToWallet + /// CoinJoin-drain asset lock in flight; progress detail comes from + /// `coordinator.phase`. + case shielding + case success(Destination) + /// Shield broadcast accepted but unconfirmed โ€” terminal, non-retryable + /// (see `ShieldedTransferCoordinator.Phase.submittedUnconfirmed`). + case submittedUnconfirmed + /// `destination` picks the retry path: a failed wallet sweep retries + /// the sweep, a failed shielded flow retries (or resumes) the drain + /// asset lock. + case failed(message: String, destination: Destination) + } + + @Published private(set) var stage: Stage = .choice + + /// CoinJoin balance snapshot at presentation time โ€” the sweep zeroes the + /// live balance mid-flow, but the sheet keeps showing what's being moved. + let amountDuffs: UInt64 + + let coordinator = ShieldedTransferCoordinator() + + private var cancellables = Set() + + init(amountDuffs: UInt64) { + self.amountDuffs = amountDuffs + // The step checklist reads `coordinator.phase` โ€” republish its + // changes so SwiftUI re-renders while `stage` itself is stable. + coordinator.objectWillChange + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) + } + + var isInFlight: Bool { + switch stage { + case .movingToWallet, .shielding: + return true + default: + return false + } + } + + // MARK: Actions + + /// Destination 1: the existing one-hop sweep into the spendable BIP44 + /// balance. Auth-cancel returns to the choice screen silently. + func moveToWallet() async { + guard !isInFlight else { return } + stage = .movingToWallet + do { + _ = try await WalletSendService.shared.sweepCoinJoin() + stage = .success(.wallet) + } catch { + if let message = WalletSendService.coinJoinSweepUserMessage(for: error) { + stage = .failed(message: message, destination: .wallet) + } else { + stage = .choice + } + } + } + + /// Destination 2: one CoinJoin-drain asset lock โ€” every mixed-coin UTXO + /// funds the Type 18 lock directly (no transparent hop) and the shielded + /// pool receives the drained value minus the pool fee. The coordinator + /// runs its own PIN/biometric gate, so this is the flow's single prompt. + func moveToShielded() async { + guard !isInFlight else { return } + stage = .shielding + // A prior failed attempt leaves the coordinator terminal; reset so + // `beginTransfer()` doesn't silently bail. + if coordinator.phase != .idle { + coordinator.reset() + } + await coordinator.performAssetLock(funding: .coinJoinDrain) + finishFromCoordinatorPhase() + } + + /// "Try again" from a failed shielded attempt. Mirrors + /// `InternalTransferConfirmSheet.tryAgain`: a committed asset lock is + /// RESUMED on its exact outpoint (building a second lock would strand the + /// first); otherwise a fresh drain build runs โ€” a pre-broadcast failure + /// left the CoinJoin account untouched. + func retryShielded() async { + guard !isInFlight else { return } + if let op = coordinator.lastAssetLockOutPoint { + stage = .shielding + coordinator.reset() + // Resume re-authorizes on its own (fresh user action). + await coordinator.resumeAssetLock(outPointTxidWire: op.txidWire, outPointVout: op.vout) + finishFromCoordinatorPhase() + return + } + coordinator.reset() + await moveToShielded() + } + + private func finishFromCoordinatorPhase() { + // The drain consumed (or may have consumed) the CoinJoin balance โ€” + // re-tally so the popup/Settings surfaces self-clear without waiting + // for the next SPV balance event. Also correct for failures: the + // re-tally just reads the SDK's current UTXO set. + SwiftDashSDKWalletState.shared.refreshCoinJoinBalance() + switch coordinator.phase { + case .success: + stage = .success(.shielded) + case .submittedUnconfirmed: + stage = .submittedUnconfirmed + case .failed(let message): + // A cancelled PIN prompt is a user decision, not an error โ€” + // return to the destination choice (mirrors the wallet leg's + // auth-cancel handling). Message-compare against the same + // localized source the coordinator maps the cancel to. + if message == ShieldedTransferCoordinator.CoordinatorError.authCancelled.errorDescription { + coordinator.reset() + stage = .choice + } else { + stage = .failed(message: message, destination: .shielded) + } + default: + // The coordinator's single-flight gate refused the call (phase + // wasn't idle). Surface a retryable failure rather than hang. + stage = .failed( + message: NSLocalizedString( + "Couldn't move your CoinJoin funds. Please try again.", comment: "CoinJoin"), + destination: .shielded) + } + } +} + +// MARK: - Sheet + +struct CoinJoinMoveFundsSheet: View { + + @StateObject private var viewModel: CoinJoinMoveFundsViewModel + var onDismiss: () -> Void + + init(amountDuffs: UInt64, onDismiss: @escaping () -> Void) { + _viewModel = StateObject(wrappedValue: CoinJoinMoveFundsViewModel(amountDuffs: amountDuffs)) + self.onDismiss = onDismiss + } + + var body: some View { + VStack(spacing: 0) { + dragHandle + .padding(.top, 8) + + Text(NSLocalizedString("Move your mixed coins", comment: "CoinJoin")) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.dash.primaryText) + .padding(.top, 20) + + switch viewModel.stage { + case .choice: + choiceBody + case .movingToWallet: + walletInFlightBody + case .shielding: + shieldInFlightBody + case .success(let destination): + successBody(destination: destination) + case .submittedUnconfirmed: + ShieldedSubmittedUnconfirmedView(onDone: onDismiss) + case .failed(let message, let destination): + failedBody(message: message, destination: destination) + } + } + .background(Color.dash.primaryBackground) + .interactiveDismissDisabled(viewModel.isInFlight) + } + + // MARK: Choice + + private var choiceBody: some View { + VStack(spacing: 0) { + DashAmount( + amount: Int64(viewModel.amountDuffs), + font: .largeTitle, + dashSymbolFactor: 0.7, + showDirection: false) + .padding(.top, 14) + + Text(NSLocalizedString( + "CoinJoin is no longer supported โ€” choose where to move your mixed coins.", + comment: "CoinJoin")) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.top, 12) + + VStack(spacing: 10) { + destinationCard( + icon: "wallet.pass.fill", + title: NSLocalizedString("Dash Wallet balance", comment: "CoinJoin"), + subtitle: NSLocalizedString( + "Move to your regular spendable balance.", comment: "CoinJoin"), + action: { Task { await viewModel.moveToWallet() } }) + destinationCard( + icon: "shield.fill", + title: NSLocalizedString("Shielded balance", comment: "CoinJoin"), + subtitle: NSLocalizedString( + "Keep these coins private. Network and privacy fees apply.", + comment: "CoinJoin"), + action: { Task { await viewModel.moveToShielded() } }) + } + .padding(.horizontal, 16) + .padding(.top, 20) + + Spacer(minLength: 12) + + DashButton( + text: NSLocalizedString("Later", comment: "CoinJoin"), + style: .plain, + stretch: true, + action: onDismiss) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + + private func destinationCard( + icon: String, title: String, subtitle: String, action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.dash.blue) + .frame(width: 34, height: 34) + Image(systemName: icon) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Color.dash.whiteText) + } + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.dash.primaryText) + Text(subtitle) + .font(.system(size: 13)) + .foregroundColor(.dash.secondaryText) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.dash.secondaryText) + } + .padding(14) + .background(Color.dash.secondaryBackground) + .cornerRadius(12) + } + .buttonStyle(.plain) + } + + // MARK: In flight + + private var walletInFlightBody: some View { + VStack(alignment: .leading, spacing: 16) { + ShieldedTransferStepList( + labels: [NSLocalizedString("Moving funds", comment: "CoinJoin")], + currentIndex: 0) + Spacer(minLength: 12) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.vertical, 24) + } + + private var shieldInFlightBody: some View { + VStack(alignment: .leading, spacing: 16) { + // Same steps as the internal transfer's Core โ†’ Shielded route โ€” + // the drain is one asset lock, driven by the same coordinator. + ShieldedTransferStepList( + currentPhase: viewModel.coordinator.phase, + steps: [ + .init(label: NSLocalizedString("Authorizing", comment: ""), phase: .signing), + .init(label: NSLocalizedString("Locking funds", comment: ""), phase: .locking), + .init(label: NSLocalizedString("Generating proof", comment: ""), phase: .proving), + .init(label: NSLocalizedString("Broadcasting", comment: ""), phase: .broadcasting), + ]) + + Text(NSLocalizedString( + "Building the privacy proof can take up to a minute. Keep the app open.", + comment: "InternalTransfer recovery")) + .font(.caption) + .foregroundColor(.dash.secondaryText) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 12) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.vertical, 24) + } + + // MARK: Terminal + + private func successBody(destination: CoinJoinMoveFundsViewModel.Destination) -> some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 64, height: 64) + .foregroundColor(.green) + .padding(.top, 24) + + Text(NSLocalizedString("Funds moved", comment: "CoinJoin")) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.dash.primaryText) + + Text(destination == .shielded + ? NSLocalizedString( + "Your mixed coins were moved to your Shielded balance. For best privacy, wait at least 2 hours before using these funds.", + comment: "CoinJoin") + : NSLocalizedString( + "Your mixed coins were moved to your Dash Wallet balance.", + comment: "CoinJoin")) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + + Spacer(minLength: 12) + + DashButton( + text: NSLocalizedString("Done", comment: ""), + style: .filled, + stretch: true, + action: onDismiss) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + + private func failedBody(message: String, destination: CoinJoinMoveFundsViewModel.Destination) -> some View { + VStack(spacing: 0) { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 48, height: 48) + .foregroundColor(.orange) + .padding(.top, 24) + + Text(message) + .font(.system(size: 14)) + .foregroundColor(.dash.primaryText) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.top, 16) + + Spacer(minLength: 12) + + ButtonsGroup( + orientation: .horizontal, + size: .large, + positiveButtonText: NSLocalizedString("Try again", comment: ""), + positiveButtonAction: { + Task { + switch destination { + case .wallet: + await viewModel.moveToWallet() + case .shielded: + await viewModel.retryShielded() + } + } + }, + negativeButtonText: NSLocalizedString("Close", comment: ""), + negativeButtonAction: onDismiss) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + + // MARK: Pieces + + private var dragHandle: some View { + Rectangle() + .fill(Color.dash.grabberFill) + .frame(width: 36, height: 5) + .cornerRadius(2.5) + } +} diff --git a/DashWallet/Sources/UI/Home/Views/HomeView.swift b/DashWallet/Sources/UI/Home/Views/HomeView.swift index 23ebbf58f..0f9e6815b 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -445,6 +445,12 @@ struct HomeViewContent: View { pendingShieldedRecovery = nil } } + .sheet(isPresented: $viewModel.showCoinJoinMoveFundsSheet) { + CoinJoinMoveFundsSheet(amountDuffs: viewModel.coinJoinSweepAmountDuffs) { + viewModel.showCoinJoinMoveFundsSheet = false + } + .presentationDetents([.medium, .large]) + } .sheet(isPresented: $showFilterDialog) { let dialog = TransactionFilterDialog( selectedFilters: $viewModel.selectedFilters, diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index 12605ee9f..98c0b073a 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -95,6 +95,10 @@ class HomeViewModel: ObservableObject { @Published var shortcutItems: [ShortcutAction] = [] @Published var showTimeSkewAlertDialog: Bool = false @Published var showCoinJoinSweepDialog: Bool = false + /// Post-sync destination-choice sheet (`CoinJoinMoveFundsSheet`) โ€” + /// presented instead of `showCoinJoinSweepDialog` when the CoinJoin + /// balance is large enough to offer the Shielded destination. + @Published var showCoinJoinMoveFundsSheet: Bool = false @Published private(set) var timeSkew: TimeInterval = 0 @Published private(set) var showJoinDashpay: Bool = false /// Selected filter categories (multi-select checkboxes). Defaults to every @@ -762,18 +766,51 @@ extension HomeViewModel { .store(in: &cancellableBag) } + /// Whether the post-sync popup can offer the Shielded-balance destination: + /// the wallet's shielded sub-wallet is bound (an Orchard address resolves) + /// AND the CoinJoin balance is comfortably above the drain's fee overhead + /// โ€” the Type 18 pool fee (carved from the locked value) plus an L1-fee + /// allowance for the many-input drain transaction โ€” so at least half the + /// moved amount survives the fees. Fails closed (BIP44-only popup) when + /// the fee estimate or the shielded binding is unavailable. + var coinJoinShieldDestinationAvailable: Bool { + let balanceDuffs = coinJoinSweepAmountDuffs + // Host + manager are `@MainActor`-isolated โ€” reuse the wallet source's + // main-thread trampoline (same file). + return SwiftDashSDKWalletSource.onMain { + guard let manager = SwiftDashSDKHost.shared.manager, + let wallet = SwiftDashSDKHost.shared.wallet, + ((try? manager.shieldedDefaultAddress(walletId: wallet.walletId)) ?? nil) != nil, + let poolFeeCredits = CoreToShieldedAmountPolicy.poolFeeCredits + else { return false } + // The shared Type 18 pool-fee estimate (credits โ†’ duffs is รท 1000); + // `sendFeeReserveDuffs` (0.001 DASH) allows for the L1 fee of a + // drain spending hundreds of mixed-coin inputs. + let overheadDuffs = poolFeeCredits / 1000 + WalletBalance.sendFeeReserveDuffs + return balanceDuffs >= overheadDuffs * 2 + } + } + /// Proactively surface the "move your mixed coins" popup once per session /// after sync completes, while a recoverable CoinJoin balance exists. /// Bound to the live balance (not a persistent flag): it re-prompts each /// launch until the user sweeps, then self-stops (balance โ†’ 0). The durable /// Settings row covers the same action for users who dismiss it. + /// + /// When the balance is large enough to shield, the destination-choice + /// sheet (`CoinJoinMoveFundsSheet`) is shown instead of the BIP44-only + /// dialog. func maybeShowCoinJoinSweepDialog() { DWLogger.log("HomeViewModel: sweep dialog check โ€” \(coinJoinSweepAmountDuffs) duffs (\(String(format: "%.6f", Double(coinJoinSweepAmountDuffs) / Double(kOneDash))) DASH), threshold \(CoinJoinRecovery.recoveryDustThresholdDuffs), above=\(coinJoinSweepAmountDuffs > CoinJoinRecovery.recoveryDustThresholdDuffs), syncDone=\(syncModel.state == .syncDone), alreadyShown=\(coinJoinSweepDialogShown)") guard !coinJoinSweepDialogShown, syncModel.state == .syncDone, coinJoinSweepAmountDuffs > CoinJoinRecovery.recoveryDustThresholdDuffs else { return } coinJoinSweepDialogShown = true - showCoinJoinSweepDialog = true + if coinJoinShieldDestinationAvailable { + showCoinJoinMoveFundsSheet = true + } else { + showCoinJoinSweepDialog = true + } } /// Sweep the leftover CoinJoin balance into the user's spendable balance @@ -1705,7 +1742,9 @@ class SwiftDashSDKWalletSource: TransactionSource { } /// Main-thread trampoline for the `@MainActor`-isolated host reads. - private static func onMain(_ body: @MainActor () -> T) -> T { + /// Internal: `HomeViewModel.coinJoinShieldDestinationAvailable` reuses it + /// for its host/manager reads. + static func onMain(_ body: @MainActor () -> T) -> T { if Thread.isMainThread { return MainActor.assumeIsolated(body) } diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift index 2d7b83c9c..bd8038e4c 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift @@ -735,13 +735,35 @@ struct ShieldedTransferStepList: View { var id: String { label } } - let currentPhase: ShieldedTransferCoordinator.Phase - let steps: [Step] + /// Pre-resolved (label, state) rows โ€” both inits reduce to this. + private let rows: [(label: String, state: StepState)] + + /// Phase-based rows: each step's done/active/pending state derives from + /// where `currentPhase` sits in the canonical ordering (see type doc). + init(currentPhase: ShieldedTransferCoordinator.Phase, steps: [Step]) { + rows = steps.map { step -> (label: String, state: StepState) in + (label: step.label, state: Self.state(for: step.phase, current: currentPhase)) + } + } + + /// Positional rows, for flows with stages the coordinator phases can't + /// express (e.g. the CoinJoin โ†’ Shielded flow's sweep leg): rows before + /// `currentIndex` are complete, the row at it active, the rest pending. + /// `nil` renders every row pending (idle/failed โ€” the host sheet surfaces + /// the error separately); an index past the last row renders all complete. + init(labels: [String], currentIndex: Int?) { + rows = labels.enumerated().map { index, label -> (label: String, state: StepState) in + guard let current = currentIndex else { return (label: label, state: .pending) } + if index < current { return (label: label, state: .complete) } + if index == current { return (label: label, state: .active) } + return (label: label, state: .pending) + } + } var body: some View { VStack(alignment: .leading, spacing: 14) { - ForEach(steps) { step in - stepRow(label: step.label, state: state(for: step.phase)) + ForEach(Array(rows.enumerated()), id: \.element.label) { _, row in + stepRow(label: row.label, state: row.state) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -753,11 +775,14 @@ struct ShieldedTransferStepList: View { case complete } - /// Where `phase` sits relative to `currentPhase`. The phase enum is ordered + /// Where `phase` sits relative to `current`. The phase enum is ordered /// .signing โ†’ .locking โ†’ .proving โ†’ .broadcasting โ†’ .success, so a numeric /// comparison drives the state. - private func state(for phase: ShieldedTransferCoordinator.Phase) -> StepState { - guard let currentIdx = Self.phaseIndex(currentPhase), + private static func state( + for phase: ShieldedTransferCoordinator.Phase, + current: ShieldedTransferCoordinator.Phase + ) -> StepState { + guard let currentIdx = Self.phaseIndex(current), let targetIdx = Self.phaseIndex(phase) else { return .pending } diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift index b80bd5654..36040fe08 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift @@ -360,19 +360,42 @@ final class ShieldedTransferCoordinator: ObservableObject { /// The intermediate stages are polled from `PersistentAssetLock.statusRaw`; /// the SDK returns `Void` only on `Consumed`/success. /// + /// Which balance funds the asset lock. + enum AssetLockFundingSource: Equatable { + /// Exact amount, coin-selected from the BIP44 spendable balance โ€” + /// the historical route behind the internal transfer and Send. + case bip44(amountDuffs: UInt64) + /// Whole-balance drain of the CoinJoin account: every mixed-coin + /// UTXO funds the lock directly (lock value = ฮฃ inputs โˆ’ L1 fee, + /// computed SDK-side) โ€” the post-migration "move mixed coins to + /// Shielded" path. No transparent intermediate hop. + case coinJoinDrain + } + /// `recipientRaw43` nil = the wallet's own default Orchard address (the /// internal transfer); an external Send passes the recipient's raw /// 43-byte payload. Type 18's remainder semantics apply either way: the /// recipient receives `lock_value โˆ’ pool_fee`. func performAssetLock(amountDuffs: UInt64, recipientRaw43 recipientOverride: Data? = nil) async { + await performAssetLock(funding: .bip44(amountDuffs: amountDuffs), recipientRaw43: recipientOverride) + } + + /// Funding-parameterized form of `performAssetLock(amountDuffs:)` โ€” same + /// stages, polling, and resume semantics for both funding sources (a + /// stuck lock resumes by outpoint regardless of what funded it). + func performAssetLock(funding: AssetLockFundingSource, recipientRaw43 recipientOverride: Data? = nil) async { guard beginTransfer() else { return } lastAssetLockOutPoint = nil - Self.logger.info("๐Ÿ›ก๏ธ SHIELD-TX :: asset-lock route amount=\(amountDuffs) external=\(recipientOverride != nil)") + Self.logger.info("๐Ÿ›ก๏ธ SHIELD-TX :: asset-lock route funding=\(String(describing: funding), privacy: .public) external=\(recipientOverride != nil)") // Backstop both amount screens at the execution boundary. Besides // protecting programmatic callers, this keeps a stale Confirm sheet // from surfacing the Rust SDK's raw ShieldFromAssetLock build error. - if let poolFeeCredits = CoreToShieldedAmountPolicy.poolFeeCredits { + // Only the BIP44 exact-amount form carries a caller amount; the + // CoinJoin drain's lock value is computed SDK-side, which preflights + // it against the pool fee before broadcasting. + if case .bip44(let amountDuffs) = funding, + let poolFeeCredits = CoreToShieldedAmountPolicy.poolFeeCredits { let minimumDuffs = CoreToShieldedAmountPolicy.minimumAmountDuffs( poolFeeCredits: poolFeeCredits) guard amountDuffs >= minimumDuffs else { @@ -405,11 +428,18 @@ final class ShieldedTransferCoordinator: ObservableObject { let recipient = ShieldedFundFromAssetLockRecipient( recipientRaw43: recipientOverride ?? env.shieldedRecipient, credits: nil) - try await env.manager.shieldedFundFromAssetLock( - walletId: env.walletId, - fundingAccountIndex: 0, - amountDuffs: amountDuffs, - recipients: [recipient]) + switch funding { + case .bip44(let amountDuffs): + try await env.manager.shieldedFundFromAssetLock( + walletId: env.walletId, + fundingAccountIndex: 0, + amountDuffs: amountDuffs, + recipients: [recipient]) + case .coinJoinDrain: + try await env.manager.shieldedFundFromCoinJoinDrain( + walletId: env.walletId, + recipients: [recipient]) + } } catch { stopAssetLockPolling() // Last-ditch outpoint capture: if the FFI threw before polling diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 66aa860c1..f74bc2ab4 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -2163,6 +2163,30 @@ /* CoinJoin */ "Move your mixed coins" = "Move your mixed coins"; +/* CoinJoin */ +"CoinJoin is no longer supported โ€” choose where to move your mixed coins." = "CoinJoin is no longer supported โ€” choose where to move your mixed coins."; + +/* CoinJoin */ +"Dash Wallet balance" = "Dash Wallet balance"; + +/* CoinJoin */ +"Move to your regular spendable balance." = "Move to your regular spendable balance."; + +/* CoinJoin */ +"Keep these coins private. Network and privacy fees apply." = "Keep these coins private. Network and privacy fees apply."; + +/* CoinJoin */ +"Moving funds" = "Moving funds"; + +/* CoinJoin */ +"Funds moved" = "Funds moved"; + +/* CoinJoin */ +"Your mixed coins were moved to your Shielded balance. For best privacy, wait at least 2 hours before using these funds." = "Your mixed coins were moved to your Shielded balance. For best privacy, wait at least 2 hours before using these funds."; + +/* CoinJoin */ +"Your mixed coins were moved to your Dash Wallet balance." = "Your mixed coins were moved to your Dash Wallet balance."; + /* No comment provided by engineer. */ "Moved from" = "Moved from";