From f75a96877ed94966e430511b4c392dfd22dad453 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 23 Jul 2026 11:59:16 +0800 Subject: [PATCH 1/3] feat(coinjoin): offer BIP44 or Shielded destination in post-sync move-funds prompt The first-sync "Move your mixed coins" popup previously always swept the leftover CoinJoin balance into the BIP44 spendable balance. When the balance is large enough to be worth shielding (>= 2x the shield pool fee + asset-lock base cost + L1 send-fee reserve, and the shielded sub-wallet is bound), a destination-choice sheet now offers: - Dash Wallet balance: the existing one-hop sweep. - Shielded balance: sweep CoinJoin -> own BIP44 receive address, wait for the swept outputs to become spendable, then asset-lock the net amount into the shielded pool (Type 18), with a step checklist. The user authenticates once: the sweep leg runs the spend gate and the asset lock skips its own via a new explicit alreadyAuthorized parameter on ShieldedTransferCoordinator.performAssetLock. Failure posture: the sweep-leg result is kept on the ViewModel, so "Try again" never re-sweeps an emptied CoinJoin account - it resumes from the UTXO wait, or resumes a committed asset lock on its exact outpoint (mirroring the transfer confirm sheet); a stuck lock that survives the session is picked up by the home tx list's ShieldedRecoverySheet. Every failure mode leaves the funds spendable in the BIP44 balance. Small balances keep the BIP44-only dialog, and the availability check fails closed to it. Plumbing: sweepCoinJoin(to:) now also returns the net swept duffs; the CrowdNode UTXO-wait helper is promoted for reuse (waitForFunds); ShieldedTransferStepList gains a positional (labels/currentIndex) init for stages the coordinator phases can't express; the wallet source's onMain trampoline is promoted for the availability check's host reads. Co-Authored-By: Claude Fable 5 --- DashWallet.xcodeproj/project.pbxproj | 6 + .../SwiftDashSDKTransactionSender.swift | 55 +- .../Transactions/WalletSendService.swift | 102 +++- .../Home/Views/CoinJoinMoveFundsSheet.swift | 472 ++++++++++++++++++ .../Sources/UI/Home/Views/HomeView.swift | 6 + .../Sources/UI/Home/Views/HomeViewModel.swift | 44 +- .../InternalTransferConfirmSheet.swift | 39 +- .../ShieldedTransferCoordinator.swift | 20 +- DashWallet/en.lproj/Localizable.strings | 30 ++ 9 files changed, 745 insertions(+), 29 deletions(-) create mode 100644 DashWallet/Sources/UI/Home/Views/CoinJoinMoveFundsSheet.swift diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 9b03a5162..56d32b9e2 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -688,6 +688,8 @@ 751C05DE2D3E39A800475E52 /* TransactionListDataItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 751C05DC2D3E39A600475E52 /* TransactionListDataItem.swift */; }; 5A1EC0FE2E29A10000000002 /* ShieldedActivityHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1EC0FE2E29A10000000001 /* ShieldedActivityHistory.swift */; }; 5A1EC0FE2E29A10000000003 /* ShieldedActivityHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1EC0FE2E29A10000000001 /* ShieldedActivityHistory.swift */; }; + 5A1EC0FE2E29A30000000002 /* CoinJoinMoveFundsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1EC0FE2E29A30000000001 /* CoinJoinMoveFundsSheet.swift */; }; + 5A1EC0FE2E29A30000000003 /* CoinJoinMoveFundsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1EC0FE2E29A30000000001 /* CoinJoinMoveFundsSheet.swift */; }; 5A1EC0FE2E29A20000000012 /* PlatformAddressActivityStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1EC0FE2E29A20000000011 /* PlatformAddressActivityStore.swift */; }; 5A1EC0FE2E29A20000000013 /* PlatformAddressActivityStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1EC0FE2E29A20000000011 /* PlatformAddressActivityStore.swift */; }; 5A1EC0FE2E29A20000000015 /* PlatformAddressHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1EC0FE2E29A20000000014 /* PlatformAddressHistory.swift */; }; @@ -2891,6 +2893,7 @@ 751C05DA2D3D0E7C00475E52 /* JoinDashPayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinDashPayViewModel.swift; sourceTree = ""; }; 751C05DC2D3E39A600475E52 /* TransactionListDataItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionListDataItem.swift; sourceTree = ""; }; 5A1EC0FE2E29A10000000001 /* ShieldedActivityHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShieldedActivityHistory.swift; sourceTree = ""; }; + 5A1EC0FE2E29A30000000001 /* CoinJoinMoveFundsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinJoinMoveFundsSheet.swift; sourceTree = ""; }; 5A1EC0FE2E29A20000000011 /* PlatformAddressActivityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformAddressActivityStore.swift; sourceTree = ""; }; 5A1EC0FE2E29A20000000014 /* PlatformAddressHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformAddressHistory.swift; sourceTree = ""; }; 7527720C2AA9B2630066557E /* SupportedTopperAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedTopperAssets.swift; sourceTree = ""; }; @@ -4425,6 +4428,7 @@ 754BEA112C0B6BD700E8C93C /* HomeViewModel.swift */, 751C05DC2D3E39A600475E52 /* TransactionListDataItem.swift */, 5A1EC0FE2E29A10000000001 /* ShieldedActivityHistory.swift */, + 5A1EC0FE2E29A30000000001 /* CoinJoinMoveFundsSheet.swift */, 5A1EC0FE2E29A20000000014 /* PlatformAddressHistory.swift */, 75D657662DF579F300ACE570 /* TransactionFilterDialog.swift */, ); @@ -9880,6 +9884,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 */, @@ -10696,6 +10701,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 ff9295fb0..1de58fd05 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift @@ -140,11 +140,13 @@ final class SwiftDashSDKTransactionSender: NSObject { /// receive address, resolved via `SwiftDashSDKReceiveAddressReader`). /// - Returns: The **wire-order** txids of the broadcast sweep transactions /// (one per chunk) — ready to record in `CoinJoinWithdrawalStore` - /// (matches `Transaction.txHashData`). - static func sweepCoinJoin(to address: String) throws -> [Data] { + /// (matches `Transaction.txHashData`) — plus the net duffs delivered to + /// `address` (Σ chunk inputs − Σ chunk fees, broadcast chunks only). The + /// CoinJoin → Shielded flow locks exactly this net amount afterwards. + static func sweepCoinJoin(to address: String) throws -> (txids: [Data], netDuffs: UInt64) { logger.info("💸 TXSEND :: sweeping CoinJoin account → spendable balance") - let sweep = { @MainActor () throws -> [Data] in + let sweep = { @MainActor () throws -> (txids: [Data], netDuffs: UInt64) in let host = SwiftDashSDKHost.shared guard let wallet = host.wallet, let manager = host.manager, let network = host.runningNetwork else { @@ -159,13 +161,13 @@ final class SwiftDashSDKTransactionSender: NSObject { guard let cjBalance = manager.accountBalances(for: walletId).first(where: { $0.typeTag == Self.coinJoinTypeTag && $0.index == Self.coinJoinAccountIndex }) else { - return [] + return ([], 0) } // Snapshot the account's spendable UTXOs (after the recovery scan has // materialized deep `/0/` + `/1/` addresses). let utxos = manager.accountUtxos(for: walletId, balance: cjBalance) - guard !utxos.isEmpty else { return [] } + guard !utxos.isEmpty else { return ([], 0) } // Drain each balanced ≤500-input chunk to `address`. `SelectionStrategy.all` // makes core compute output = Σinputs − fee with no change (the addOutput @@ -173,6 +175,7 @@ final class SwiftDashSDKTransactionSender: NSObject { // Partial-failure tolerant: keep the txs that broadcast, log the rest, and // throw only if nothing broadcast at all (a re-run sweeps the remainder). var txids: [Data] = [] + var netDuffs: UInt64 = 0 var firstError: Error? for (index, chunk) in Self.balancedChunks(utxos).enumerated() { do { @@ -193,6 +196,9 @@ final class SwiftDashSDKTransactionSender: NSObject { // `CoinJoinWithdrawalStore`: `computeTxHash` yields display order, // so reverse it back to wire order. txids.append(Data(Self.computeTxHash(from: tx.data).reversed())) + // `.all` drain: single output = Σ chunk inputs − exact fee. + let chunkInputs = chunk.reduce(UInt64(0)) { $0 + $1.valueDuffs } + netDuffs += chunkInputs > tx.fee ? chunkInputs - tx.fee : 0 } catch { firstError = firstError ?? error Self.logger.error( @@ -200,26 +206,49 @@ final class SwiftDashSDKTransactionSender: NSObject { } } if txids.isEmpty, let error = firstError { throw error } - return txids + return (txids, netDuffs) } - let txids: [Data] + let result: (txids: [Data], netDuffs: UInt64) if Thread.isMainThread { - txids = try MainActor.assumeIsolated { try sweep() } + result = try MainActor.assumeIsolated { try sweep() } } else { - var captured: Result<[Data], Error> = .failure(SendError.walletNotReady("uninitialized result")) + var captured: Result<(txids: [Data], netDuffs: UInt64), Error> = + .failure(SendError.walletNotReady("uninitialized result")) DispatchQueue.main.sync { captured = Result { try MainActor.assumeIsolated { try sweep() } } } - txids = try captured.get() + result = try captured.get() } // Log display-order hex (byte-reversed wire order) to match explorers. - let hexes = txids.map { txid -> String in + let hexes = result.txids.map { txid -> String in Data(txid.reversed()).map { String(format: "%02x", $0) }.joined() } - logger.info("💸 TXSEND :: coinjoin sweep broadcast — \(txids.count, privacy: .public) tx(s): \(hexes.joined(separator: ","), privacy: .public)") - return txids + logger.info("💸 TXSEND :: coinjoin sweep broadcast — \(result.txids.count, privacy: .public) tx(s), net \(result.netDuffs, privacy: .public) duffs: \(hexes.joined(separator: ","), privacy: .public)") + return result + } + + /// Wait for the BIP44 account-0 UTXOs sitting on `address` to reach + /// `minimumTotal` duffs, tolerating the SDK's async local-mempool apply of + /// just-broadcast transactions (same polling contract as the selected-input + /// send). Returns the observed total (which may be below `minimumTotal` if + /// the timeout elapsed — the caller decides sufficiency). + /// + /// Used by the CoinJoin → Shielded flow to wait for the sweep's outputs to + /// become spendable before funding the shield asset lock from them. + static func waitForFunds(onAddress address: String, minimumTotal: UInt64) async throws -> UInt64 { + let network: PaymentNetwork + do { + network = try PaymentNetworkResolver.current() + } catch { + throw SendError.walletNotReady("unsupported network for UTXO wait") + } + guard let script = ScriptAddressCodec.scriptPubKey(forAddress: address, network: network) else { + throw SendError.invalidInput("cannot derive scriptPubKey for the address") + } + let utxos = try await waitForAddressUtxos(script: script, minimumTotal: minimumTotal) + return utxos.reduce(UInt64(0)) { $0 + $1.valueDuffs } } // MARK: - Selected-input send (CrowdNode signal txs) diff --git a/DashWallet/Sources/Models/Transactions/WalletSendService.swift b/DashWallet/Sources/Models/Transactions/WalletSendService.swift index 807426010..b5b408f1c 100644 --- a/DashWallet/Sources/Models/Transactions/WalletSendService.swift +++ b/DashWallet/Sources/Models/Transactions/WalletSendService.swift @@ -268,7 +268,7 @@ final class WalletSendService: NSObject { } Self.logger.info("💸 TXSEND :: CJTEST CoinJoin sweep destination resolved \(destination, privacy: .public)") - let txids = try SwiftDashSDKTransactionSender.sweepCoinJoin(to: destination) + let (txids, _) = try SwiftDashSDKTransactionSender.sweepCoinJoin(to: destination) guard !txids.isEmpty else { // A reported-success sweep that produced no transaction is treated // as a failure, so the caller surfaces an error (the sweep alert) @@ -306,6 +306,106 @@ final class WalletSendService: NSObject { return amount } + /// Result of the sweep leg of the CoinJoin → Shielded flow: what landed on + /// the user's own BIP44 receive address and where, so the caller can wait + /// for the funds and asset-lock them (`waitForSweptCoinJoinFunds`). + struct CoinJoinShieldedSweep { + /// Gross CoinJoin balance at sweep time (duffs) — the popup's display amount. + let grossDuffs: UInt64 + /// Net duffs delivered to `destinationAddress` (gross − L1 sweep fees). + let netDuffs: UInt64 + /// The user's own BIP44 receive address the sweep paid. + let destinationAddress: String + } + + /// Leg 1 of the CoinJoin → Shielded flow: authorize (PIN/biometric) and + /// sweep the CoinJoin balance to the user's own BIP44 receive address — + /// the same sweep as `sweepCoinJoin()`, but returning what landed where so + /// leg 2 (`waitForSweptCoinJoinFunds` + the shield asset lock) can run on + /// exactly the swept funds. Split from leg 2 so a retry after a leg-2 + /// failure (e.g. UTXO-wait timeout) does NOT re-sweep: the CoinJoin + /// account is already empty and the funds sit safely in the BIP44 balance. + /// + /// The single authorization here covers the whole two-hop flow — the + /// follow-up asset lock is invoked with `alreadyAuthorized` so the user + /// isn't prompted twice for one user-visible action. + func sweepCoinJoinForShielding() async throws -> CoinJoinShieldedSweep { + // Fail loudly before moving anything: an offline broadcast would be + // silently queued and the follow-up UTXO wait would just time out. + try Self.ensureOnline() + let amount = await MainActor.run { SwiftDashSDKWalletState.shared.coinJoinBalanceDuffs } + guard amount > 0 else { + throw Self.makeError( + code: .coinJoinSweepUnavailable, + description: "No CoinJoin balance to move" + ) + } + + Self.logger.info("💸 TXSEND :: preparing CoinJoin → Shielded sweep — balance \(amount, privacy: .public) duffs") + try await sendAuthorizer.authorizeSend(spendAmount: amount) + + guard let destination = SwiftDashSDKReceiveAddressReader.receiveAddress() else { + throw Self.makeError( + code: .coinJoinSweepUnavailable, + description: "Could not resolve a destination address for the CoinJoin sweep" + ) + } + + let (txids, netDuffs) = try SwiftDashSDKTransactionSender.sweepCoinJoin(to: destination) + guard !txids.isEmpty, netDuffs > 0 else { + throw Self.makeError( + code: .coinJoinSweepUnavailable, + description: "CoinJoin sweep produced no transactions" + ) + } + // Same home-screen grouping as the BIP44-destination sweep: the L1 leg + // IS a CoinJoin withdrawal either way. + for txid in txids { + CoinJoinWithdrawalStore.shared.record(txid: txid) + } + + await MainActor.run { + SwiftDashSDKWalletState.shared.refreshCoinJoinBalance() + } + Self.logger.info("💸 TXSEND :: CoinJoin → Shielded sweep broadcast — net \(netDuffs, privacy: .public) of \(amount, privacy: .public) duffs → \(destination, privacy: .public)") + return CoinJoinShieldedSweep(grossDuffs: amount, netDuffs: netDuffs, destinationAddress: destination) + } + + /// Leg 2 (wait) of the CoinJoin → Shielded flow: wait for the sweep's + /// outputs to become spendable BIP44 UTXOs (the SDK applies its own + /// broadcasts to the local mempool asynchronously), then return the amount + /// to asset-lock: the swept net minus the standard send-fee reserve — + /// the same headroom `WalletBalance.maxSendable` keeps for the asset + /// lock's L1 fee (the internal transfer's Max uses the same envelope). + /// The reserve remainder stays in the spendable BIP44 balance. + /// + /// Throws `.coinJoinSweepUnavailable` if the funds don't appear within the + /// polling window — the swept funds are NOT lost (they're in the BIP44 + /// balance); the caller's retry re-runs this wait without re-sweeping. + func waitForSweptCoinJoinFunds(_ sweep: CoinJoinShieldedSweep) async throws -> UInt64 { + let available = try await SwiftDashSDKTransactionSender.waitForFunds( + onAddress: sweep.destinationAddress, minimumTotal: sweep.netDuffs) + guard available >= sweep.netDuffs else { + Self.logger.error("💸 TXSEND :: swept CoinJoin funds not yet spendable — saw \(available, privacy: .public) of \(sweep.netDuffs, privacy: .public) duffs") + throw Self.makeError( + code: .coinJoinSweepUnavailable, + description: NSLocalizedString( + "The moved funds are not spendable yet. Your Dash is safe in your wallet balance — try again in a moment.", + comment: "CoinJoin") + ) + } + let reserve = WalletBalance.sendFeeReserveDuffs + guard sweep.netDuffs > reserve else { + throw Self.makeError( + code: .coinJoinSweepUnavailable, + description: NSLocalizedString( + "The remaining balance is too small to move to the shielded balance.", + comment: "CoinJoin") + ) + } + return sweep.netDuffs - reserve + } + #if DASHPAY /// DashPay pay-to-contact (migration Row #18 phase 6). The caller /// shows its confirmation UI FIRST — the user's explicit "Pay" tap diff --git a/DashWallet/Sources/UI/Home/Views/CoinJoinMoveFundsSheet.swift b/DashWallet/Sources/UI/Home/Views/CoinJoinMoveFundsSheet.swift new file mode 100644 index 000000000..4a23ec2d6 --- /dev/null +++ b/DashWallet/Sources/UI/Home/Views/CoinJoinMoveFundsSheet.swift @@ -0,0 +1,472 @@ +// +// 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 two-hop flow: sweep the CoinJoin account to the +// user's own BIP44 receive address (`sweepCoinJoinForShielding`), wait for +// the swept outputs to become spendable (`waitForSweptCoinJoinFunds`), +// then asset-lock the net amount into the shielded pool via +// `ShieldedTransferCoordinator.performAssetLock` (authorized once, at the +// sweep — `alreadyAuthorized` skips the second PIN prompt). +// +// Failure posture of the two-hop flow: the sweep leg is recorded on the +// ViewModel, so "Try again" after a post-sweep failure never re-sweeps — it +// resumes from the wait (or, when the asset lock already committed, resumes +// that exact outpoint via `resumeAssetLock`, mirroring the internal transfer +// confirm sheet). A stuck lock that survives the session is picked up by the +// home tx list's `ShieldedRecoverySheet` on the next launch. +// + +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 + /// One-hop BIP44 sweep in flight. + case movingToWallet + /// Two-hop flow, leg 1–2: auth + sweep + wait for spendable UTXOs. + case sweepingForShield + /// Two-hop flow, leg 3: the coordinator drives the asset-lock shield; + /// 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 one-hop sweep, a failed shielded flow re-enters the two-hop + /// flow at the leg that failed. + 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() + + /// Sweep-leg result, kept across retries so "Try again" never re-sweeps + /// an already-emptied CoinJoin account. + private var sweep: WalletSendService.CoinJoinShieldedSweep? + /// Lock amount resolved by the wait leg, kept for the same reason. + private var lockAmountDuffs: UInt64? + + 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, .sweepingForShield, .shielding: + return true + default: + return false + } + } + + /// Row index for the shielded flow's positional step list + /// (Moving funds → Locking funds → Generating proof → Broadcasting). + var shieldStepIndex: Int? { + switch stage { + case .sweepingForShield: + return 0 + case .shielding: + switch coordinator.phase { + case .signing, .locking: + return 1 + case .proving: + return 2 + case .broadcasting: + return 3 + case .success: + return 4 + case .idle, .failed, .submittedUnconfirmed: + return nil + } + default: + return nil + } + } + + // 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: the two-hop CoinJoin → Shielded flow. Each leg's result + /// is kept so a retry resumes where the previous attempt failed instead of + /// repeating side-effectful work. + func moveToShielded() async { + guard !isInFlight else { return } + stage = .sweepingForShield + do { + if sweep == nil { + sweep = try await WalletSendService.shared.sweepCoinJoinForShielding() + } + if lockAmountDuffs == nil, let sweep { + lockAmountDuffs = try await WalletSendService.shared.waitForSweptCoinJoinFunds(sweep) + } + } catch { + if WalletSendService.isAuthenticationCancelledError(error as NSError), sweep == nil { + stage = .choice + } else { + stage = .failed(message: (error as NSError).localizedDescription, destination: .shielded) + } + return + } + guard let lockAmountDuffs else { + stage = .failed( + message: NSLocalizedString( + "Couldn't move your CoinJoin funds. Please try again.", comment: "CoinJoin"), + destination: .shielded) + return + } + + stage = .shielding + // A prior failed attempt leaves the coordinator terminal; reset so + // `beginTransfer()` doesn't silently bail. + if coordinator.phase != .idle { + coordinator.reset() + } + // The sweep leg already ran the spend authorization for this action. + await coordinator.performAssetLock(amountDuffs: lockAmountDuffs, alreadyAuthorized: true) + 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 the flow re-enters `moveToShielded`, which skips the + /// legs that already completed. + 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() { + switch coordinator.phase { + case .success: + stage = .success(.shielded) + case .submittedUnconfirmed: + stage = .submittedUnconfirmed + case .failed(let message): + 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 .sweepingForShield, .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) { + ShieldedTransferStepList( + labels: [ + NSLocalizedString("Moving funds", comment: "CoinJoin"), + NSLocalizedString("Locking funds", comment: ""), + NSLocalizedString("Generating proof", comment: ""), + NSLocalizedString("Broadcasting", comment: ""), + ], + currentIndex: viewModel.shieldStepIndex) + + 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 6a9576353..aedb7e7c6 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -361,6 +361,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 c7b381dc9..3eff0cf48 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -81,6 +81,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 = true /// Selected filter categories (multi-select checkboxes). Defaults to every @@ -712,18 +716,52 @@ 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 flow's fee overhead — + /// the shield pool fee (carved from the locked value) plus the L1 send-fee + /// reserve the asset lock keeps — 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 shieldedFeeCredits = try? PlatformWalletManager.estimateShieldedFee(kind: .transfer, numActions: 2) + else { return false } + // Pool fee = base shielded fee + the asset-lock processing base cost + // (same estimate the transfer confirm sheets show); credits → duffs + // is ÷ 1000. The L1 reserve mirrors `waitForSweptCoinJoinFunds`. + let overheadDuffs = (shieldedFeeCredits + InternalTransferConfirmSheet.assetLockBaseCostCredits) / 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("CJTEST HomeViewModel: sweep dialog check — \(coinJoinSweepAmountDuffs) duffs (\(String(format: "%.6f", Double(coinJoinSweepAmountDuffs) / Double(DUFFS))) 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 @@ -1254,7 +1292,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 11cbe9a94..6940b6b40 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift @@ -740,13 +740,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) @@ -758,11 +780,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 2bd747dce..10f680d31 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift @@ -146,7 +146,13 @@ final class ShieldedTransferCoordinator: ObservableObject { /// 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 { + /// + /// `alreadyAuthorized` skips the PIN/biometric gate — pass `true` ONLY when + /// the caller ran its own spend authorization for the same user-visible + /// action moments earlier (the CoinJoin → Shielded flow authorizes once via + /// `WalletSendService` before its sweep leg); every direct entry point + /// keeps the default `false`. + func performAssetLock(amountDuffs: UInt64, recipientRaw43 recipientOverride: Data? = nil, alreadyAuthorized: Bool = false) async { guard beginTransfer() else { return } lastAssetLockOutPoint = nil Self.logger.info("🛡️ SHIELD-TX :: asset-lock route amount=\(amountDuffs) external=\(recipientOverride != nil)") @@ -159,11 +165,13 @@ final class ShieldedTransferCoordinator: ObservableObject { return } - do { - try await authorize() - } catch { - handleFailure(error) - return + if !alreadyAuthorized { + do { + try await authorize() + } catch { + handleFailure(error) + return + } } phase = .locking diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 66aa860c1..5506bebf6 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -2163,6 +2163,36 @@ /* 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."; + +/* CoinJoin */ +"The moved funds are not spendable yet. Your Dash is safe in your wallet balance — try again in a moment." = "The moved funds are not spendable yet. Your Dash is safe in your wallet balance — try again in a moment."; + +/* CoinJoin */ +"The remaining balance is too small to move to the shielded balance." = "The remaining balance is too small to move to the shielded balance."; + /* No comment provided by engineer. */ "Moved from" = "Moved from"; From ba394ec72091b3733ab30e6eb0614f7948bf429b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 23 Jul 2026 17:43:45 +0800 Subject: [PATCH 2/3] feat(coinjoin): shield mixed coins via a direct CoinJoin-drain asset lock Replaces the two-hop shielded destination (sweep CoinJoin -> BIP44, wait, asset-lock) with a single CoinJoin-funded drain asset lock: every mixed-coin UTXO funds the Type 18 lock directly (lock value = sum(inputs) - L1 fee, computed SDK-side) and the shielded pool receives lock_value - pool_fee. The mixed coins never hop through a transparent BIP44 address, and the flow is one transaction with one PIN prompt. - ShieldedTransferCoordinator.performAssetLock gains an AssetLockFundingSource (.bip44(amountDuffs:) | .coinJoinDrain) routed to the new SDK wrapper shieldedFundFromCoinJoinDrain; the historical amountDuffs entry point delegates unchanged. The alreadyAuthorized seam is removed - the coordinator's own gate is the flow's single prompt again. Resume-by-outpoint covers drain locks identically. - CoinJoinMoveFundsSheet: the shielded path is one coordinator run with the same step checklist as the internal transfer; auth-cancel returns to the destination choice; the CoinJoin balance is re-tallied on completion so the popup/Settings surfaces self-clear. - The dead two-hop plumbing is removed (sweepCoinJoinForShielding, waitForSweptCoinJoinFunds, the sweep net-amount return, waitForFunds, and their error strings). Consumes SwiftDashSDK's shieldedFundFromCoinJoinDrain (platform feat/coinjoin-asset-lock-funding, on rust-dashcore key-wallet drain support) - rebuild DashSDKFFI.xcframework after pulling platform. Co-Authored-By: Claude Fable 5 --- .../SwiftDashSDKTransactionSender.swift | 55 ++----- .../Transactions/WalletSendService.swift | 102 +------------ .../Home/Views/CoinJoinMoveFundsSheet.swift | 138 ++++++------------ .../Sources/UI/Home/Views/HomeViewModel.swift | 13 +- .../ShieldedTransferCoordinator.swift | 58 +++++--- DashWallet/en.lproj/Localizable.strings | 6 - 6 files changed, 107 insertions(+), 265 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift index 1de58fd05..ff9295fb0 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift @@ -140,13 +140,11 @@ final class SwiftDashSDKTransactionSender: NSObject { /// receive address, resolved via `SwiftDashSDKReceiveAddressReader`). /// - Returns: The **wire-order** txids of the broadcast sweep transactions /// (one per chunk) — ready to record in `CoinJoinWithdrawalStore` - /// (matches `Transaction.txHashData`) — plus the net duffs delivered to - /// `address` (Σ chunk inputs − Σ chunk fees, broadcast chunks only). The - /// CoinJoin → Shielded flow locks exactly this net amount afterwards. - static func sweepCoinJoin(to address: String) throws -> (txids: [Data], netDuffs: UInt64) { + /// (matches `Transaction.txHashData`). + static func sweepCoinJoin(to address: String) throws -> [Data] { logger.info("💸 TXSEND :: sweeping CoinJoin account → spendable balance") - let sweep = { @MainActor () throws -> (txids: [Data], netDuffs: UInt64) in + let sweep = { @MainActor () throws -> [Data] in let host = SwiftDashSDKHost.shared guard let wallet = host.wallet, let manager = host.manager, let network = host.runningNetwork else { @@ -161,13 +159,13 @@ final class SwiftDashSDKTransactionSender: NSObject { guard let cjBalance = manager.accountBalances(for: walletId).first(where: { $0.typeTag == Self.coinJoinTypeTag && $0.index == Self.coinJoinAccountIndex }) else { - return ([], 0) + return [] } // Snapshot the account's spendable UTXOs (after the recovery scan has // materialized deep `/0/` + `/1/` addresses). let utxos = manager.accountUtxos(for: walletId, balance: cjBalance) - guard !utxos.isEmpty else { return ([], 0) } + guard !utxos.isEmpty else { return [] } // Drain each balanced ≤500-input chunk to `address`. `SelectionStrategy.all` // makes core compute output = Σinputs − fee with no change (the addOutput @@ -175,7 +173,6 @@ final class SwiftDashSDKTransactionSender: NSObject { // Partial-failure tolerant: keep the txs that broadcast, log the rest, and // throw only if nothing broadcast at all (a re-run sweeps the remainder). var txids: [Data] = [] - var netDuffs: UInt64 = 0 var firstError: Error? for (index, chunk) in Self.balancedChunks(utxos).enumerated() { do { @@ -196,9 +193,6 @@ final class SwiftDashSDKTransactionSender: NSObject { // `CoinJoinWithdrawalStore`: `computeTxHash` yields display order, // so reverse it back to wire order. txids.append(Data(Self.computeTxHash(from: tx.data).reversed())) - // `.all` drain: single output = Σ chunk inputs − exact fee. - let chunkInputs = chunk.reduce(UInt64(0)) { $0 + $1.valueDuffs } - netDuffs += chunkInputs > tx.fee ? chunkInputs - tx.fee : 0 } catch { firstError = firstError ?? error Self.logger.error( @@ -206,49 +200,26 @@ final class SwiftDashSDKTransactionSender: NSObject { } } if txids.isEmpty, let error = firstError { throw error } - return (txids, netDuffs) + return txids } - let result: (txids: [Data], netDuffs: UInt64) + let txids: [Data] if Thread.isMainThread { - result = try MainActor.assumeIsolated { try sweep() } + txids = try MainActor.assumeIsolated { try sweep() } } else { - var captured: Result<(txids: [Data], netDuffs: UInt64), Error> = - .failure(SendError.walletNotReady("uninitialized result")) + var captured: Result<[Data], Error> = .failure(SendError.walletNotReady("uninitialized result")) DispatchQueue.main.sync { captured = Result { try MainActor.assumeIsolated { try sweep() } } } - result = try captured.get() + txids = try captured.get() } // Log display-order hex (byte-reversed wire order) to match explorers. - let hexes = result.txids.map { txid -> String in + let hexes = txids.map { txid -> String in Data(txid.reversed()).map { String(format: "%02x", $0) }.joined() } - logger.info("💸 TXSEND :: coinjoin sweep broadcast — \(result.txids.count, privacy: .public) tx(s), net \(result.netDuffs, privacy: .public) duffs: \(hexes.joined(separator: ","), privacy: .public)") - return result - } - - /// Wait for the BIP44 account-0 UTXOs sitting on `address` to reach - /// `minimumTotal` duffs, tolerating the SDK's async local-mempool apply of - /// just-broadcast transactions (same polling contract as the selected-input - /// send). Returns the observed total (which may be below `minimumTotal` if - /// the timeout elapsed — the caller decides sufficiency). - /// - /// Used by the CoinJoin → Shielded flow to wait for the sweep's outputs to - /// become spendable before funding the shield asset lock from them. - static func waitForFunds(onAddress address: String, minimumTotal: UInt64) async throws -> UInt64 { - let network: PaymentNetwork - do { - network = try PaymentNetworkResolver.current() - } catch { - throw SendError.walletNotReady("unsupported network for UTXO wait") - } - guard let script = ScriptAddressCodec.scriptPubKey(forAddress: address, network: network) else { - throw SendError.invalidInput("cannot derive scriptPubKey for the address") - } - let utxos = try await waitForAddressUtxos(script: script, minimumTotal: minimumTotal) - return utxos.reduce(UInt64(0)) { $0 + $1.valueDuffs } + logger.info("💸 TXSEND :: coinjoin sweep broadcast — \(txids.count, privacy: .public) tx(s): \(hexes.joined(separator: ","), privacy: .public)") + return txids } // MARK: - Selected-input send (CrowdNode signal txs) diff --git a/DashWallet/Sources/Models/Transactions/WalletSendService.swift b/DashWallet/Sources/Models/Transactions/WalletSendService.swift index b5b408f1c..807426010 100644 --- a/DashWallet/Sources/Models/Transactions/WalletSendService.swift +++ b/DashWallet/Sources/Models/Transactions/WalletSendService.swift @@ -268,7 +268,7 @@ final class WalletSendService: NSObject { } Self.logger.info("💸 TXSEND :: CJTEST CoinJoin sweep destination resolved \(destination, privacy: .public)") - let (txids, _) = try SwiftDashSDKTransactionSender.sweepCoinJoin(to: destination) + let txids = try SwiftDashSDKTransactionSender.sweepCoinJoin(to: destination) guard !txids.isEmpty else { // A reported-success sweep that produced no transaction is treated // as a failure, so the caller surfaces an error (the sweep alert) @@ -306,106 +306,6 @@ final class WalletSendService: NSObject { return amount } - /// Result of the sweep leg of the CoinJoin → Shielded flow: what landed on - /// the user's own BIP44 receive address and where, so the caller can wait - /// for the funds and asset-lock them (`waitForSweptCoinJoinFunds`). - struct CoinJoinShieldedSweep { - /// Gross CoinJoin balance at sweep time (duffs) — the popup's display amount. - let grossDuffs: UInt64 - /// Net duffs delivered to `destinationAddress` (gross − L1 sweep fees). - let netDuffs: UInt64 - /// The user's own BIP44 receive address the sweep paid. - let destinationAddress: String - } - - /// Leg 1 of the CoinJoin → Shielded flow: authorize (PIN/biometric) and - /// sweep the CoinJoin balance to the user's own BIP44 receive address — - /// the same sweep as `sweepCoinJoin()`, but returning what landed where so - /// leg 2 (`waitForSweptCoinJoinFunds` + the shield asset lock) can run on - /// exactly the swept funds. Split from leg 2 so a retry after a leg-2 - /// failure (e.g. UTXO-wait timeout) does NOT re-sweep: the CoinJoin - /// account is already empty and the funds sit safely in the BIP44 balance. - /// - /// The single authorization here covers the whole two-hop flow — the - /// follow-up asset lock is invoked with `alreadyAuthorized` so the user - /// isn't prompted twice for one user-visible action. - func sweepCoinJoinForShielding() async throws -> CoinJoinShieldedSweep { - // Fail loudly before moving anything: an offline broadcast would be - // silently queued and the follow-up UTXO wait would just time out. - try Self.ensureOnline() - let amount = await MainActor.run { SwiftDashSDKWalletState.shared.coinJoinBalanceDuffs } - guard amount > 0 else { - throw Self.makeError( - code: .coinJoinSweepUnavailable, - description: "No CoinJoin balance to move" - ) - } - - Self.logger.info("💸 TXSEND :: preparing CoinJoin → Shielded sweep — balance \(amount, privacy: .public) duffs") - try await sendAuthorizer.authorizeSend(spendAmount: amount) - - guard let destination = SwiftDashSDKReceiveAddressReader.receiveAddress() else { - throw Self.makeError( - code: .coinJoinSweepUnavailable, - description: "Could not resolve a destination address for the CoinJoin sweep" - ) - } - - let (txids, netDuffs) = try SwiftDashSDKTransactionSender.sweepCoinJoin(to: destination) - guard !txids.isEmpty, netDuffs > 0 else { - throw Self.makeError( - code: .coinJoinSweepUnavailable, - description: "CoinJoin sweep produced no transactions" - ) - } - // Same home-screen grouping as the BIP44-destination sweep: the L1 leg - // IS a CoinJoin withdrawal either way. - for txid in txids { - CoinJoinWithdrawalStore.shared.record(txid: txid) - } - - await MainActor.run { - SwiftDashSDKWalletState.shared.refreshCoinJoinBalance() - } - Self.logger.info("💸 TXSEND :: CoinJoin → Shielded sweep broadcast — net \(netDuffs, privacy: .public) of \(amount, privacy: .public) duffs → \(destination, privacy: .public)") - return CoinJoinShieldedSweep(grossDuffs: amount, netDuffs: netDuffs, destinationAddress: destination) - } - - /// Leg 2 (wait) of the CoinJoin → Shielded flow: wait for the sweep's - /// outputs to become spendable BIP44 UTXOs (the SDK applies its own - /// broadcasts to the local mempool asynchronously), then return the amount - /// to asset-lock: the swept net minus the standard send-fee reserve — - /// the same headroom `WalletBalance.maxSendable` keeps for the asset - /// lock's L1 fee (the internal transfer's Max uses the same envelope). - /// The reserve remainder stays in the spendable BIP44 balance. - /// - /// Throws `.coinJoinSweepUnavailable` if the funds don't appear within the - /// polling window — the swept funds are NOT lost (they're in the BIP44 - /// balance); the caller's retry re-runs this wait without re-sweeping. - func waitForSweptCoinJoinFunds(_ sweep: CoinJoinShieldedSweep) async throws -> UInt64 { - let available = try await SwiftDashSDKTransactionSender.waitForFunds( - onAddress: sweep.destinationAddress, minimumTotal: sweep.netDuffs) - guard available >= sweep.netDuffs else { - Self.logger.error("💸 TXSEND :: swept CoinJoin funds not yet spendable — saw \(available, privacy: .public) of \(sweep.netDuffs, privacy: .public) duffs") - throw Self.makeError( - code: .coinJoinSweepUnavailable, - description: NSLocalizedString( - "The moved funds are not spendable yet. Your Dash is safe in your wallet balance — try again in a moment.", - comment: "CoinJoin") - ) - } - let reserve = WalletBalance.sendFeeReserveDuffs - guard sweep.netDuffs > reserve else { - throw Self.makeError( - code: .coinJoinSweepUnavailable, - description: NSLocalizedString( - "The remaining balance is too small to move to the shielded balance.", - comment: "CoinJoin") - ) - } - return sweep.netDuffs - reserve - } - #if DASHPAY /// DashPay pay-to-contact (migration Row #18 phase 6). The caller /// shows its confirmation UI FIRST — the user's explicit "Pay" tap diff --git a/DashWallet/Sources/UI/Home/Views/CoinJoinMoveFundsSheet.swift b/DashWallet/Sources/UI/Home/Views/CoinJoinMoveFundsSheet.swift index 4a23ec2d6..c446720a2 100644 --- a/DashWallet/Sources/UI/Home/Views/CoinJoinMoveFundsSheet.swift +++ b/DashWallet/Sources/UI/Home/Views/CoinJoinMoveFundsSheet.swift @@ -9,19 +9,17 @@ // // - Dash Wallet balance — the existing one-hop sweep // (`WalletSendService.sweepCoinJoin`), and -// - Shielded balance — a two-hop flow: sweep the CoinJoin account to the -// user's own BIP44 receive address (`sweepCoinJoinForShielding`), wait for -// the swept outputs to become spendable (`waitForSweptCoinJoinFunds`), -// then asset-lock the net amount into the shielded pool via -// `ShieldedTransferCoordinator.performAssetLock` (authorized once, at the -// sweep — `alreadyAuthorized` skips the second PIN prompt). +// - 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 two-hop flow: the sweep leg is recorded on the -// ViewModel, so "Try again" after a post-sweep failure never re-sweeps — it -// resumes from the wait (or, when the asset lock already committed, resumes -// that exact outpoint via `resumeAssetLock`, mirroring the internal transfer -// confirm sheet). A stuck lock that survives the session is picked up by the -// home tx list's `ShieldedRecoverySheet` on the next launch. +// 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 @@ -41,20 +39,18 @@ final class CoinJoinMoveFundsViewModel: ObservableObject { enum Stage: Equatable { /// Destination choice screen. case choice - /// One-hop BIP44 sweep in flight. + /// BIP44 sweep in flight. case movingToWallet - /// Two-hop flow, leg 1–2: auth + sweep + wait for spendable UTXOs. - case sweepingForShield - /// Two-hop flow, leg 3: the coordinator drives the asset-lock shield; - /// progress detail comes from `coordinator.phase`. + /// 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 one-hop sweep, a failed shielded flow re-enters the two-hop - /// flow at the leg that failed. + /// the sweep, a failed shielded flow retries (or resumes) the drain + /// asset lock. case failed(message: String, destination: Destination) } @@ -66,12 +62,6 @@ final class CoinJoinMoveFundsViewModel: ObservableObject { let coordinator = ShieldedTransferCoordinator() - /// Sweep-leg result, kept across retries so "Try again" never re-sweeps - /// an already-emptied CoinJoin account. - private var sweep: WalletSendService.CoinJoinShieldedSweep? - /// Lock amount resolved by the wait leg, kept for the same reason. - private var lockAmountDuffs: UInt64? - private var cancellables = Set() init(amountDuffs: UInt64) { @@ -85,37 +75,13 @@ final class CoinJoinMoveFundsViewModel: ObservableObject { var isInFlight: Bool { switch stage { - case .movingToWallet, .sweepingForShield, .shielding: + case .movingToWallet, .shielding: return true default: return false } } - /// Row index for the shielded flow's positional step list - /// (Moving funds → Locking funds → Generating proof → Broadcasting). - var shieldStepIndex: Int? { - switch stage { - case .sweepingForShield: - return 0 - case .shielding: - switch coordinator.phase { - case .signing, .locking: - return 1 - case .proving: - return 2 - case .broadcasting: - return 3 - case .success: - return 4 - case .idle, .failed, .submittedUnconfirmed: - return nil - } - default: - return nil - } - } - // MARK: Actions /// Destination 1: the existing one-hop sweep into the spendable BIP44 @@ -135,51 +101,27 @@ final class CoinJoinMoveFundsViewModel: ObservableObject { } } - /// Destination 2: the two-hop CoinJoin → Shielded flow. Each leg's result - /// is kept so a retry resumes where the previous attempt failed instead of - /// repeating side-effectful work. + /// 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 = .sweepingForShield - do { - if sweep == nil { - sweep = try await WalletSendService.shared.sweepCoinJoinForShielding() - } - if lockAmountDuffs == nil, let sweep { - lockAmountDuffs = try await WalletSendService.shared.waitForSweptCoinJoinFunds(sweep) - } - } catch { - if WalletSendService.isAuthenticationCancelledError(error as NSError), sweep == nil { - stage = .choice - } else { - stage = .failed(message: (error as NSError).localizedDescription, destination: .shielded) - } - return - } - guard let lockAmountDuffs else { - stage = .failed( - message: NSLocalizedString( - "Couldn't move your CoinJoin funds. Please try again.", comment: "CoinJoin"), - destination: .shielded) - return - } - stage = .shielding // A prior failed attempt leaves the coordinator terminal; reset so // `beginTransfer()` doesn't silently bail. if coordinator.phase != .idle { coordinator.reset() } - // The sweep leg already ran the spend authorization for this action. - await coordinator.performAssetLock(amountDuffs: lockAmountDuffs, alreadyAuthorized: true) + 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 the flow re-enters `moveToShielded`, which skips the - /// legs that already completed. + /// 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 { @@ -195,13 +137,27 @@ final class CoinJoinMoveFundsViewModel: ObservableObject { } 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): - stage = .failed(message: message, destination: .shielded) + // 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. @@ -241,7 +197,7 @@ struct CoinJoinMoveFundsSheet: View { choiceBody case .movingToWallet: walletInFlightBody - case .sweepingForShield, .shielding: + case .shielding: shieldInFlightBody case .success(let destination): successBody(destination: destination) @@ -359,14 +315,16 @@ struct CoinJoinMoveFundsSheet: View { 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( - labels: [ - NSLocalizedString("Moving funds", comment: "CoinJoin"), - NSLocalizedString("Locking funds", comment: ""), - NSLocalizedString("Generating proof", comment: ""), - NSLocalizedString("Broadcasting", comment: ""), - ], - currentIndex: viewModel.shieldStepIndex) + 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.", diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index 3eff0cf48..2ec4d430f 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -718,11 +718,11 @@ extension HomeViewModel { /// 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 flow's fee overhead — - /// the shield pool fee (carved from the locked value) plus the L1 send-fee - /// reserve the asset lock keeps — 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. + /// 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 @@ -735,7 +735,8 @@ extension HomeViewModel { else { return false } // Pool fee = base shielded fee + the asset-lock processing base cost // (same estimate the transfer confirm sheets show); credits → duffs - // is ÷ 1000. The L1 reserve mirrors `waitForSweptCoinJoinFunds`. + // is ÷ 1000. `sendFeeReserveDuffs` (0.001 DASH) allows for the L1 + // fee of a drain spending hundreds of mixed-coin inputs. let overheadDuffs = (shieldedFeeCredits + InternalTransferConfirmSheet.assetLockBaseCostCredits) / 1000 + WalletBalance.sendFeeReserveDuffs return balanceDuffs >= overheadDuffs * 2 diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift index 10f680d31..c4cac13d8 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift @@ -142,20 +142,33 @@ 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`. - /// - /// `alreadyAuthorized` skips the PIN/biometric gate — pass `true` ONLY when - /// the caller ran its own spend authorization for the same user-visible - /// action moments earlier (the CoinJoin → Shielded flow authorizes once via - /// `WalletSendService` before its sweep leg); every direct entry point - /// keeps the default `false`. - func performAssetLock(amountDuffs: UInt64, recipientRaw43 recipientOverride: Data? = nil, alreadyAuthorized: Bool = false) async { + 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)") let env: Environment do { @@ -165,13 +178,11 @@ final class ShieldedTransferCoordinator: ObservableObject { return } - if !alreadyAuthorized { - do { - try await authorize() - } catch { - handleFailure(error) - return - } + do { + try await authorize() + } catch { + handleFailure(error) + return } phase = .locking @@ -182,11 +193,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 5506bebf6..f74bc2ab4 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -2187,12 +2187,6 @@ /* CoinJoin */ "Your mixed coins were moved to your Dash Wallet balance." = "Your mixed coins were moved to your Dash Wallet balance."; -/* CoinJoin */ -"The moved funds are not spendable yet. Your Dash is safe in your wallet balance — try again in a moment." = "The moved funds are not spendable yet. Your Dash is safe in your wallet balance — try again in a moment."; - -/* CoinJoin */ -"The remaining balance is too small to move to the shielded balance." = "The remaining balance is too small to move to the shielded balance."; - /* No comment provided by engineer. */ "Moved from" = "Moved from"; From 67343da8eaebf5910fc94c625f77d8f2cba42376 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 7 Aug 2026 05:31:10 +0700 Subject: [PATCH 3/3] fix(build): repair swift-sdk-integration compile breaks surfaced by the merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four fixes are to upstream code that no longer compiled at swift-sdk-integration HEAD (the maya-swift-sdk merge, PR #916, landed after the finalizeAtomic refactor, PR #920, without rebasing over it): - CrowdNode error observers (OnlineAccountEmailController, CrowdNodePortalViewController): drop the `if let` on a value the preceding `compactMap { $0 }` already unwrapped — a non-optional binding is a compile error. - MAYA swap deposit (SwiftDashSDKTransactionSender.buildAndSignSwapDeposit): migrate from the removed split setFunding/buildSigned surface to finalizeAtomic, returning FinalizedCoreTransaction like every other send path; assertSwapDepositShape takes the serialized bytes + fee since the finalized handle exposes no raw `.data`. - WalletSendService.buildPreparedSwapDeposit: serialize via `serializedData()` (the PreparedStandardSend initializer now holds a FinalizedCoreTransaction). Candidate for cherry-pick to swift-sdk-integration — the base branch does not build without these. Co-Authored-By: Claude Fable 5 --- .../SwiftDashSDKTransactionSender.swift | 26 ++++++++++--------- .../Transactions/WalletSendService.swift | 2 +- .../Online/OnlineAccountEmailController.swift | 3 ++- .../CrowdNodePortalViewController.swift | 3 ++- 4 files changed, 19 insertions(+), 15 deletions(-) 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))