diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 39c153d21..bdd00b0c8 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -974,6 +974,7 @@ 75FFD6C82BF495800032879E /* HomeViewController+Shortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FFD6C62BF495800032879E /* HomeViewController+Shortcuts.swift */; }; 76766A50AFDB7BC138F569DC /* NetworkUnavailableStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 487C4C2E6AC61005536FF85E /* NetworkUnavailableStateView.swift */; }; 7A30000230A1000000000002 /* TransactionDirectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A30000130A1000000000001 /* TransactionDirectionTests.swift */; }; + 7A32000230A3000000000002 /* PooledSendableBalanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A32000130A3000000000001 /* PooledSendableBalanceTests.swift */; }; 7A30002230A2000000000022 /* CrowdNodeOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A30002130A2000000000021 /* CrowdNodeOwnershipTests.swift */; }; 7A31000230A2000000000002 /* CoinJoinMoveDestinationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A31000130A2000000000001 /* CoinJoinMoveDestinationPolicyTests.swift */; }; 7A30001230A1000000000012 /* StuckAssetLockRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A30001130A1000000000011 /* StuckAssetLockRetryTests.swift */; }; @@ -3166,6 +3167,7 @@ 7708BBFDD14AFF237FB72D71 /* MayaConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MayaConstants.swift; sourceTree = ""; }; 797D9070BF54A3533190584E /* libPods-dashwallet.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-dashwallet.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 7A30000130A1000000000001 /* TransactionDirectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDirectionTests.swift; sourceTree = ""; }; + 7A32000130A3000000000001 /* PooledSendableBalanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PooledSendableBalanceTests.swift; sourceTree = ""; }; 7A30002130A2000000000021 /* CrowdNodeOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrowdNodeOwnershipTests.swift; sourceTree = ""; }; 7A31000130A2000000000001 /* CoinJoinMoveDestinationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinJoinMoveDestinationPolicyTests.swift; sourceTree = ""; }; 7A30001130A1000000000011 /* StuckAssetLockRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StuckAssetLockRetryTests.swift; sourceTree = ""; }; @@ -7390,6 +7392,7 @@ CB9000012FE1000000000001 /* CoinbaseTransactionMetadataTests.swift */, CB9100012FE2000000000001 /* CoinbaseTransferAmountTests.swift */, 7A30000130A1000000000001 /* TransactionDirectionTests.swift */, + 7A32000130A3000000000001 /* PooledSendableBalanceTests.swift */, 7A30002130A2000000000021 /* CrowdNodeOwnershipTests.swift */, 7A31000130A2000000000001 /* CoinJoinMoveDestinationPolicyTests.swift */, 7A30001130A1000000000011 /* StuckAssetLockRetryTests.swift */, @@ -10628,6 +10631,7 @@ CB9000022FE1000000000002 /* CoinbaseTransactionMetadataTests.swift in Sources */, CB9100022FE2000000000002 /* CoinbaseTransferAmountTests.swift in Sources */, 7A30000230A1000000000002 /* TransactionDirectionTests.swift in Sources */, + 7A32000230A3000000000002 /* PooledSendableBalanceTests.swift in Sources */, 7A30002230A2000000000022 /* CrowdNodeOwnershipTests.swift in Sources */, 7A31000230A2000000000002 /* CoinJoinMoveDestinationPolicyTests.swift in Sources */, 7A30001230A1000000000012 /* StuckAssetLockRetryTests.swift in Sources */, diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift index e32dd6636..c5f7356b6 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift @@ -96,6 +96,65 @@ public struct WalletBalance: Equatable, Sendable { public var maxSendable: UInt64 { spendable > Self.sendFeeReserveDuffs ? spendable - Self.sendFeeReserveDuffs : 0 } } +/// The single-flight bookkeeping behind +/// `SwiftDashSDKWalletState.refreshPooledSpendableBalance`, separated from the +/// `Task` machinery so the ownership rule can be exercised without a wallet, an +/// SDK handle or a live task. +/// +/// A `Task` reference alone cannot express ownership: cancelling a read and +/// starting its replacement leaves the cancelled read still scheduled, and when +/// it resumes it looks exactly like the read that owns the slot. Clearing the +/// slot from there frees it for a third read while the second is still in +/// flight, and the two then publish in whatever order they happen to finish — +/// which for this value means the send ceiling can settle on a stale figure. +/// +/// A generation makes "who owns the slot" answerable: it moves on every claim +/// and on every cancel, so a read can compare the generation it was issued +/// under with the current one and step aside when it is no longer the owner. +struct PooledReadSlot { + private(set) var generation: UInt64 = 0 + private(set) var isReading = false + private(set) var rerunRequested = false + + /// Claim the slot for a new read, or `nil` when one is already in flight — + /// in which case the request is recorded as a single pending rerun rather + /// than a second concurrent read. + mutating func begin() -> UInt64? { + guard !isReading else { + rerunRequested = true + return nil + } + generation &+= 1 + isReading = true + return generation + } + + /// Whether the read issued under `candidate` still owns the slot. One that + /// does not must publish nothing, clear nothing, consume no rerun and start + /// no rerun: all four belong to whoever holds the slot now. + func owns(_ candidate: UInt64) -> Bool { + isReading && candidate == generation + } + + /// Release the slot on behalf of the owning read. Returns `true` when a + /// request arrived while it ran and a rerun should now start. + mutating func finish(_ candidate: UInt64) -> Bool { + guard owns(candidate) else { return false } + isReading = false + let rerun = rerunRequested + rerunRequested = false + return rerun + } + + /// Abandon whatever is in flight. The generation moves, so the running read + /// cannot own the slot when it resumes — and cannot take a replacement's. + mutating func cancel() { + generation &+= 1 + isReading = false + rerunRequested = false + } +} + // MARK: - SwiftDashSDKWalletState @objc(DWSwiftDashSDKWalletState) @@ -119,9 +178,19 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { /// core-send "Max" call site so the sent amount tracks the real fee instead /// of stranding ~0.001 DASH as change. public func feeAwareMaxSendable() -> UInt64 { - let spendable = balance?.spendable ?? 0 - let reserve = SwiftDashSDKTransactionSender.maxSendFeeReserveDuffs() - return spendable > reserve ? spendable - reserve : 0 + // The pooled figure, not `balance.spendable`: Max filling from the + // wallet-wide balance is the same lie the amount gate told, one tap + // more convincing. + Self.feeAwareMax( + spendable: sendableDuffs, + reserve: SwiftDashSDKTransactionSender.maxSendFeeReserveDuffs()) + } + + /// `feeAwareMaxSendable`'s arithmetic, separated from the SDK reads so the + /// flooring is testable: a spendable balance at or below the reserve has no + /// Max at all, rather than wrapping or offering an unsendable amount. + static func feeAwareMax(spendable: UInt64, reserve: UInt64) -> UInt64 { + spendable > reserve ? spendable - reserve : 0 } /// Total DIP-17 Platform Payment credit balance across every @@ -158,6 +227,100 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { /// DashSync `CoinJoinService`, which is being removed. @Published public private(set) var coinJoinBalanceDuffs: UInt64 = 0 + /// What a plain send could actually draw on: the accounts `.allSpendable` + /// pools — BIP44@0, BIP32@0 and the DashPay receiving accounts — counting + /// only UTXOs coin selection accepts. + /// + /// `balance.spendable` is a strict superset: it sums every funding account + /// the wallet has, CoinJoin included. Gating on it offers money the builder + /// then refuses, which is what surfaced as "insufficient unreserved core + /// funds" against a visibly larger balance. Reservations are not subtracted + /// (the SDK cannot read them yet), so this stays optimistic by whatever an + /// in-flight build holds — transient, unlike the account-set difference. + /// + /// `nil` until the SDK has answered once, and again whenever a read fails. + /// It must never be 0 *because* the read failed: on the 2026-09-03 QA build + /// the FFI refused every call (it looked the core-wallet handle up in the + /// platform-wallet table), the failure was swallowed, and a permanent 0 + /// here zeroed Max and blocked every send in the app. Consumers read + /// `sendableDuffs`, which falls back to the wallet-wide figure while this + /// is unknown — over-offering is the pre-#1107 behaviour and recoverable; + /// a silent 0 is neither. + @Published public private(set) var pooledSpendableDuffs: UInt64? + + /// The amount gates and Max should read: the pooled figure when the SDK + /// has supplied one, else `balance.spendable`. + public var sendableDuffs: UInt64 { + Self.sendableDuffs(pooled: pooledSpendableDuffs, walletSpendable: balance?.spendable) + } + + /// The ceiling an open amount screen validates against, as a stream. + /// + /// Derived from BOTH inputs and deduplicated on the RESOLVED value: through + /// a pooled outage the pooled figure stays `nil` while the wallet balance + /// keeps moving the fallback, so a subscription to the pooled publisher + /// alone goes silent exactly when the ceiling is moving. Deduplicating the + /// result instead reacts to whichever half changed and stays quiet when + /// neither moved the answer. + /// + /// Takes its inputs as parameters so the wiring is testable without the + /// shared instance; `observeSendableCeiling` passes the published ones. + static func sendableCeilingPublisher( + pooled: AnyPublisher, + walletSpendable: AnyPublisher + ) -> AnyPublisher { + Publishers.CombineLatest(pooled, walletSpendable) + .map { sendableDuffs(pooled: $0, walletSpendable: $1) } + .removeDuplicates() + .eraseToAnyPublisher() + } + + /// The confirmed balance a plain send CANNOT draw on: what + /// `balance.spendable` counts and the funding pool does not. + /// + /// In practice this is the CoinJoin account, which the pool excludes by + /// design (spending mixed outputs alongside transparent ones undoes the + /// mixing). It matters to the Max explanations: these funds are not held + /// back for fees and are not waiting on confirmations, so saying either + /// misattributes them — on the wallet in ticket 32081 that would be ~94 of + /// the 94.6 DASH on screen. Getting them back needs the mixed-coins move, + /// which is a different instruction entirely. + /// + /// Zero while the pooled figure is unknown: an outage is not evidence that + /// anything is excluded, and `sendableDuffs` is falling back to the + /// wallet-wide number anyway, so nothing is being held back from Max either. + public var excludedFromSendPoolDuffs: UInt64 { + Self.excludedFromSendPool(pooled: pooledSpendableDuffs, walletSpendable: balance?.spendable) + } + + /// The fallback policy, as a function of its two inputs, so it can be + /// pinned by tests without a wallet, an SDK handle or the network. + /// + /// A *successful* zero must NOT fall back — that is the SDK answering + /// "nothing here", which is exactly the CoinJoin-only case this ticket is + /// about. Only `nil`, which means the read failed, falls back. + static func sendableDuffs(pooled: UInt64?, walletSpendable: UInt64?) -> UInt64 { + // The lower of the two when both are known. They are independent + // snapshots taken at different moments: `applyBalance` publishes the + // wallet-wide figure immediately while the pooled read is still in + // flight, so a pooled value from before a spend can outlive the + // wallet-wide one that already reflects it. Gating on the stale + // higher number lets the screen accept an amount the builder — which + // selects from current funds — then refuses. + guard let pooled else { return walletSpendable ?? 0 } + guard let walletSpendable else { return pooled } + return min(pooled, walletSpendable) + } + + /// The pooled shortfall, as a function of its two inputs. Never negative, + /// and zero whenever the pooled figure is unknown or is not the smaller of + /// the two (a pooled figure above the wallet-wide one would mean the two + /// were read at different moments, not that funds are excluded). + static func excludedFromSendPool(pooled: UInt64?, walletSpendable: UInt64?) -> UInt64 { + guard let pooled, let walletSpendable, walletSpendable > pooled else { return 0 } + return walletSpendable - pooled + } + // MARK: - Obj-C bridge /// Notification posted on the main queue whenever the published @@ -213,6 +376,7 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { MainActor.assumeIsolated { self.refreshPlatformPaymentCredits() self.refreshCoinJoinBalance() + self.refreshPooledSpendableBalance() } NotificationCenter.default.post( name: SwiftDashSDKWalletState.balanceDidChangeNotification, @@ -339,6 +503,108 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { /// /// `@MainActor` for symmetry with `refreshPlatformPaymentCredits`; the /// reader detects the main thread and reads synchronously. + /// Re-read the pooled spendable balance from the SDK. Refreshed with every + /// balance event, like the CoinJoin tally beside it. + /// + /// The read itself is NOT done here. `pooledSpendableBalance()` bridges + /// synchronously into Rust, waits on the wallet-manager read lock and walks + /// every funding account's UTXO set — on the main actor that is a stall + /// behind whatever writer holds the lock (block processing, a finalizing + /// build), during exactly the balance-event bursts that call this. The + /// wallet handle is captured here, the read runs off-main, and the result + /// is published back on the main actor only if it still describes the + /// wallet that asked for it. + /// + /// Overlapping requests coalesce the way the Platform-credit tally does: + /// one read in flight, and a request arriving during it schedules exactly + /// one re-run rather than another concurrent read. + @MainActor + public func refreshPooledSpendableBalance() { + guard let wallet = SwiftDashSDKHost.shared.wallet else { + cancelPooledSpendableRead() + markPooledSpendableUnavailable(reason: "no active wallet") + return + } + guard let generation = pooledReadSlot.begin() else { return } + + let walletId = wallet.walletId + let network = SwiftDashSDKHost.shared.runningNetwork + pooledSpendableReadTask = Task { @MainActor [weak self] in + guard let self else { return } + let outcome: Result = await Task.detached(priority: .utility) { + do { + return .success(try wallet.coreWallet().pooledSpendableBalance()) + } catch { + return .failure(error) + } + }.value + + // A read that was cancelled, or superseded by a later one, no + // longer owns any of what follows. Without this it would clear the + // REPLACEMENT read's task slot on its way out, which lets a third + // read start while the second is still running — and then the two + // publish in whatever order they finish. + guard self.pooledReadSlot.owns(generation) else { return } + self.pooledSpendableReadTask = nil + // A wallet switch or network change while the read was in flight + // makes its answer describe someone else's funding pool. + let stillCurrent = SwiftDashSDKHost.shared.wallet?.walletId == walletId + && SwiftDashSDKHost.shared.runningNetwork == network + if stillCurrent, !Task.isCancelled { + switch outcome { + case .success(let duffs): + self.hasLoggedPooledSpendableOutage = false + if self.pooledSpendableDuffs != duffs { + self.pooledSpendableDuffs = duffs + Self.logger.info("💰 WALLET :: pooledSpendableDuffs=\(duffs, privacy: .public)") + } + case .failure(let error): + self.markPooledSpendableUnavailable(reason: String(describing: error)) + } + } + + if self.pooledReadSlot.finish(generation) { + self.refreshPooledSpendableBalance() + } + } + } + + /// Non-nil while a pooled read is in flight, purely so it can be cancelled. + /// Which read is entitled to publish, clear the slot or start a rerun is + /// decided by `pooledReadSlot`, not by this reference. + @MainActor private var pooledSpendableReadTask: Task? + @MainActor private var pooledReadSlot = PooledReadSlot() + + /// Drop any in-flight pooled read so it cannot publish after a clear. + /// + /// The completion's wallet/network check is not enough on its own: through + /// `prepareForNetworkSwitch` and the wipe paths the host still reports the + /// same wallet and network while the published state has already been + /// cleared, so a read issued before the clear would pass that check and + /// republish the outgoing ceiling into the new state. Same shape as + /// `cancelPlatformCreditsTally`, and called from the same places. + @MainActor + private func cancelPooledSpendableRead() { + pooledSpendableReadTask?.cancel() + pooledSpendableReadTask = nil + pooledReadSlot.cancel() + } + + /// Drop the pooled figure so `sendableDuffs` falls back to the wallet-wide + /// balance, and say why — once per outage, not once per balance tick. A + /// failure that repeats every tick is the signature to look for when Max + /// or the amount gate misbehave. + @MainActor + private func markPooledSpendableUnavailable(reason: String) { + guard pooledSpendableDuffs != nil || !hasLoggedPooledSpendableOutage else { return } + hasLoggedPooledSpendableOutage = true + pooledSpendableDuffs = nil + Self.logger.error( + "💰 WALLET :: pooledSpendableDuffs unavailable, falling back to balance.spendable: \(reason, privacy: .public)") + } + + @MainActor private var hasLoggedPooledSpendableOutage = false + @MainActor public func refreshCoinJoinBalance() { let duffs = SwiftDashSDKCoinJoinBalanceReader.coinJoinSpendableDuffs() @@ -392,9 +658,11 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { self?.balance = nil MainActor.assumeIsolated { self?.cancelPlatformCreditsTally() + self?.cancelPooledSpendableRead() } self?.platformPaymentCredits = 0 self?.coinJoinBalanceDuffs = 0 + self?.pooledSpendableDuffs = nil NotificationCenter.default.post( name: SwiftDashSDKWalletState.balanceDidChangeNotification, object: nil) @@ -410,9 +678,11 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { self?.balance = nil MainActor.assumeIsolated { self?.cancelPlatformCreditsTally() + self?.cancelPooledSpendableRead() } self?.platformPaymentCredits = 0 self?.coinJoinBalanceDuffs = 0 + self?.pooledSpendableDuffs = nil NotificationCenter.default.post( name: SwiftDashSDKWalletState.balanceDidChangeNotification, object: nil) diff --git a/DashWallet/Sources/UI/Payment Controller/Enter Amount/ProvideAmountViewController.swift b/DashWallet/Sources/UI/Payment Controller/Enter Amount/ProvideAmountViewController.swift index c0865c6c4..1c754dff3 100644 --- a/DashWallet/Sources/UI/Payment Controller/Enter Amount/ProvideAmountViewController.swift +++ b/DashWallet/Sources/UI/Payment Controller/Enter Amount/ProvideAmountViewController.swift @@ -51,9 +51,18 @@ final class ProvideAmountViewController: SendAmountViewController { override func actionButtonAction(sender: UIView) { guard validateInputAmount() else { return } + // Cheap rejection before the leftover-balance alert: no reason to ask + // the user to confirm emptying their wallet for an amount that cannot + // be funded anyway. + guard amountIsStillAffordable() else { return } checkLeftoverBalance { [weak self] canContinue in guard canContinue, let wSelf = self else { return } + // ...and again here, because `checkLeftoverBalance` presents its own + // Continue/Cancel alert and the ceiling can drop while that alert is + // open. This is the last statement before the amount leaves the + // screen, so this is where affordability has to be settled. + guard wSelf.amountIsStillAffordable() else { return } wSelf.showActivityIndicator() let paymentCurrency: DWPaymentCurrency = wSelf.sendAmountModel.activeAmountType == .main ? .dash : .fiat @@ -64,6 +73,25 @@ final class ProvideAmountViewController: SendAmountViewController { } } + /// Whether the entered amount is still within what the funding pool can + /// spend, refreshing the validation message and the button when it is not. + /// + /// The ceiling moves on its own — a pooled read landing, or recovering from + /// an outage and replacing the wallet-wide fallback with a much smaller + /// transparent balance. `SendAmountModel` refreshes the button when that + /// happens, but the button is not the guarantee: a tap can race the + /// refresh, and any modal presented in between holds the flow open across + /// the change. Forwarding an amount the pool cannot fund is exactly the + /// late builder failure this screen exists to prevent, so every path out of + /// here asks again rather than trusting the last amount edit. + private func amountIsStillAffordable() -> Bool { + guard sendAmountModel.canShowInsufficientFunds else { return true } + sendAmountModel.checkAmountForErrors() + actionButton?.isEnabled = sendAmountModel.isAllowedToContinue + showErrorIfNeeded() + return false + } + override func configureHierarchy() { super.configureHierarchy() diff --git a/DashWallet/Sources/UI/Payments/Amount/BaseAmountViewController.swift b/DashWallet/Sources/UI/Payments/Amount/BaseAmountViewController.swift index 07b180105..f592f7ff6 100644 --- a/DashWallet/Sources/UI/Payments/Amount/BaseAmountViewController.swift +++ b/DashWallet/Sources/UI/Payments/Amount/BaseAmountViewController.swift @@ -85,6 +85,10 @@ class BaseAmountViewController: ActionButtonViewController, AmountProviding { model.amountInputItemsChangeHandler = { [weak self] in self?.amountView.inputTypeSwitcher.reloadData() } + + model.validationDidChangeHandler = { [weak self] in + self?.amountDidChange() + } } internal func errorInfoButtonDidTap() { diff --git a/DashWallet/Sources/UI/Payments/Amount/Model/BaseAmountModel.swift b/DashWallet/Sources/UI/Payments/Amount/Model/BaseAmountModel.swift index 2ef71a05d..f0d30b03c 100644 --- a/DashWallet/Sources/UI/Payments/Amount/Model/BaseAmountModel.swift +++ b/DashWallet/Sources/UI/Payments/Amount/Model/BaseAmountModel.swift @@ -84,6 +84,12 @@ class BaseAmountModel: ObservableObject { public var inputsSwappedHandler: ((AmountType) -> Void)? public var amountInputItemsChangeHandler: (() -> Void)? + /// Called when something OTHER than an amount edit changed whether the + /// current amount is valid — the funding ceiling moving under a screen that + /// is already open. The view's `$amount` subscription cannot see that, so + /// it refreshes the button and the error message from here. + public var validationDidChangeHandler: (() -> Void)? + public var isAllowedToContinue: Bool { isAmountValidForProceeding } diff --git a/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift b/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift index 1b1a59976..12b514877 100644 --- a/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift +++ b/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift @@ -15,6 +15,7 @@ // limitations under the License. // +import Combine import Foundation // MARK: - SendAmountError @@ -62,7 +63,11 @@ class SendAmountModel: BaseAmountModel { var canShowInsufficientFunds: Bool { let plainAmount = amount.plainAmount - let allAvailableFunds = SwiftDashSDKWalletState.shared.balance?.spendable ?? 0 + // The accounts a send can actually draw on, not the whole wallet: + // `balance.spendable` also counts CoinJoin, which the funding pool + // excludes by design, so gating on it accepts amounts the builder then + // refuses with "insufficient unreserved core funds". + let allAvailableFunds = SwiftDashSDKWalletState.shared.sendableDuffs return plainAmount > allAvailableFunds } @@ -72,9 +77,38 @@ class SendAmountModel: BaseAmountModel { super.init() initializeSyncingActivityMonitor() + observeSendableCeiling() checkAmountForErrors() } + /// The ceiling can move while this screen is open and the amount is + /// untouched — a pooled read landing for the first time, or recovering from + /// an outage and replacing the wallet-wide fallback with a much smaller + /// transparent balance. `BaseAmountModel`'s balance subscription only + /// refreshes `walletBalance`, and the view refreshes its button off + /// `$amount`, so nothing revalidates an amount typed before the drop. + /// + /// Both inputs matter, not just the pooled one: through a persistent pooled + /// outage that value stays `nil` and `removeDuplicates` swallows every + /// repeat, while `balance` keeps moving the fallback ceiling underneath. + /// Deduplicating the RESOLVED ceiling instead reacts to whichever half + /// changed, and still ignores updates that leave it where it was. + private func observeSendableCeiling() { + let state = SwiftDashSDKWalletState.shared + SwiftDashSDKWalletState + .sendableCeilingPublisher( + pooled: state.$pooledSpendableDuffs.eraseToAnyPublisher(), + walletSpendable: state.$balance.map { $0?.spendable }.eraseToAnyPublisher()) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self else { return } + self.error = nil + self.checkAmountForErrors() + self.validationDidChangeHandler?() + } + .store(in: &cancellableBag) + } + override func selectAllFunds() { auth { [weak self] isAuthenticated in if isAuthenticated { @@ -96,11 +130,16 @@ class SendAmountModel: BaseAmountModel { // small to also cover the fee were indistinguishable from a dead // button. Same three states, same wording, as the internal // transfer's Core Max. - let balance = SwiftDashSDKWalletState.shared.balance + let state = SwiftDashSDKWalletState.shared + let balance = state.balance error = SendAmountError.maxUnavailable( InternalTransferViewModel.coreZeroMaxMessage( totalDuffs: balance?.total ?? 0, - confirmedSpendableDuffs: balance?.spendable ?? 0)) + confirmedSpendableDuffs: balance?.spendable ?? 0, + // Without this a CoinJoin-only wallet is told its balance + // is too small to cover the fee, which is not why Max is + // empty — the pool simply cannot draw on mixed coins. + excludedFromPoolDuffs: state.excludedFromSendPoolDuffs)) return } diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift index 62c3446da..befc879e2 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift @@ -1906,7 +1906,8 @@ final class InternalTransferViewModel: ObservableObject { if coreSpendableDuffs == 0 { maxNotice = Self.coreZeroMaxMessage( totalDuffs: coreBalanceDuffs, - confirmedSpendableDuffs: SwiftDashSDKWalletState.shared.balance?.spendable ?? 0) + confirmedSpendableDuffs: SwiftDashSDKWalletState.shared.balance?.spendable ?? 0, + excludedFromPoolDuffs: SwiftDashSDKWalletState.shared.excludedFromSendPoolDuffs) } else if sourceDuffs == 0 { maxNotice = Self.feeReserveExceedsBalanceMessage(route.source) } @@ -1927,7 +1928,8 @@ final class InternalTransferViewModel: ObservableObject { if coreSpendableDuffs == 0 { maxNotice = Self.coreZeroMaxMessage( totalDuffs: coreBalanceDuffs, - confirmedSpendableDuffs: SwiftDashSDKWalletState.shared.balance?.spendable ?? 0) + confirmedSpendableDuffs: SwiftDashSDKWalletState.shared.balance?.spendable ?? 0, + excludedFromPoolDuffs: SwiftDashSDKWalletState.shared.excludedFromSendPoolDuffs) } else if sourceDuffs == 0 { // New with the fee-on-top reserve: a balance that cannot carry // the reserve fills 0, which needs a reason like the shielded @@ -2286,7 +2288,8 @@ final class InternalTransferViewModel: ObservableObject { /// is not main-actor bound — can reach it; the body is pure string work. nonisolated static func coreZeroMaxMessage( totalDuffs: UInt64, - confirmedSpendableDuffs: UInt64 + confirmedSpendableDuffs: UInt64, + excludedFromPoolDuffs: UInt64 = 0 ) -> String { guard totalDuffs > 0 else { return emptyBalanceMessage(.core) } guard confirmedSpendableDuffs > 0 else { @@ -2296,6 +2299,19 @@ final class InternalTransferViewModel: ObservableObject { comment: "Core Max has nothing confirmed to spend"), totalDuffs.formattedDashAmountWithoutCurrencySymbol) } + // Confirmed, but none of it in an account a send draws on — the + // CoinJoin-only wallet. Saying the balance cannot cover the fee would + // be false: it is large enough, it is simply the wrong kind of money, + // and no amount of waiting changes that. + let poolable = confirmedSpendableDuffs + - min(excludedFromPoolDuffs, confirmedSpendableDuffs) + guard poolable > 0 else { + return String.localizedStringWithFormat( + NSLocalizedString( + "Your %@ DASH is in mixed coins, which a send cannot use — move them to your spendable balance first.", + comment: "Core Max has only CoinJoin funds, which the send pool excludes"), + confirmedSpendableDuffs.formattedDashAmountWithoutCurrencySymbol) + } return feeReserveExceedsBalanceMessage(.core) } diff --git a/DashWalletTests/PooledSendableBalanceTests.swift b/DashWalletTests/PooledSendableBalanceTests.swift new file mode 100644 index 000000000..b08f67ee0 --- /dev/null +++ b/DashWalletTests/PooledSendableBalanceTests.swift @@ -0,0 +1,281 @@ +// +// PooledSendableBalanceTests.swift +// DashWalletTests +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import XCTest +@testable import dashpay + +/// The app-side half of the pooled-balance contract: which of the two figures +/// the send ceiling reads, what a shortfall between them means, and how Max +/// explains itself when it comes up short. The SDK's arithmetic is tested in +/// `platform-wallet`; none of that says anything about these decisions, and +/// each of them has already been wrong once — a silent 0 blocked every send on +/// the 2026-09-03 QA build, and the fee-and-unconfirmed wording misattributed +/// 94 DASH of mixed coins. +final class PooledSendableBalanceTests: XCTestCase { + + // MARK: The fallback policy + + func testPooledFigureIsPreferredWhenItIsSmallerThanTheWalletTotal() { + XCTAssertEqual( + SwiftDashSDKWalletState.sendableDuffs(pooled: 538_503, walletSpendable: 9_460_987_512), + 538_503, + "the ceiling is what the pool can fund, not what the wallet holds") + } + + func testASuccessfulZeroDoesNotFallBack() { + // The CoinJoin-only wallet: the SDK answered, and the answer is zero. + // Falling back here would re-offer the whole balance and put back the + // "insufficient unreserved core funds" failure this gate exists to stop. + XCTAssertEqual( + SwiftDashSDKWalletState.sendableDuffs(pooled: 0, walletSpendable: 9_460_987_512), + 0) + } + + func testAnUnavailableReadFallsBackToTheWalletWideFigure() { + // Over-offering is the pre-#1107 behaviour and recoverable. A silent 0 + // is not: on the 2026-09-03 QA build the FFI refused every call and a + // permanent 0 zeroed Max and blocked every send. + XCTAssertEqual( + SwiftDashSDKWalletState.sendableDuffs(pooled: nil, walletSpendable: 9_460_987_512), + 9_460_987_512) + } + + func testRecoveryFromAnUnavailableReadTakesTheLowerCeilingBack() { + let duringOutage = SwiftDashSDKWalletState.sendableDuffs( + pooled: nil, walletSpendable: 9_460_987_512) + let afterRecovery = SwiftDashSDKWalletState.sendableDuffs( + pooled: 538_503, walletSpendable: 9_460_987_512) + XCTAssertGreaterThan(duringOutage, afterRecovery, + "recovery must be able to lower the ceiling, not only raise it") + XCTAssertEqual(afterRecovery, 538_503) + } + + func testNeitherFigureAvailableIsZero() { + XCTAssertEqual( + SwiftDashSDKWalletState.sendableDuffs(pooled: nil, walletSpendable: nil), 0) + } + + // MARK: What the shortfall means + + func testExcludedFundsAreTheDifferenceBetweenTheTwoFigures() { + XCTAssertEqual( + SwiftDashSDKWalletState.excludedFromSendPool( + pooled: 538_503, walletSpendable: 9_460_987_512), + 9_460_449_009) + } + + func testNothingIsExcludedWhileThePooledFigureIsUnknown() { + // An outage is not evidence that funds are excluded — and the ceiling + // is falling back to the wallet-wide figure anyway, so nothing is being + // withheld from Max to explain. + XCTAssertEqual( + SwiftDashSDKWalletState.excludedFromSendPool( + pooled: nil, walletSpendable: 9_460_987_512), + 0) + } + + func testAPooledFigureAboveTheWalletWideOneExcludesNothing() { + // The two are read at different moments; a larger pooled figure means + // they disagree, not that the difference is negative. + XCTAssertEqual( + SwiftDashSDKWalletState.excludedFromSendPool(pooled: 200, walletSpendable: 100), 0) + } + + // MARK: Max flooring + + func testMaxFloorsAtTheFeeReserveRatherThanWrapping() { + XCTAssertEqual(SwiftDashSDKWalletState.feeAwareMax(spendable: 100, reserve: 100_000), 0) + XCTAssertEqual( + SwiftDashSDKWalletState.feeAwareMax(spendable: 100_000, reserve: 100_000), 0, + "a balance exactly equal to the reserve leaves nothing to send") + XCTAssertEqual( + SwiftDashSDKWalletState.feeAwareMax(spendable: 100_001, reserve: 100_000), 1) + } + + // MARK: How Max explains an empty result + + func testAConfirmedCoinJoinOnlyBalanceIsNotBlamedOnTheFee() { + // The wallet from ticket 32081: 94.6 DASH confirmed, 0.0054 of it + // transparent. Before this, Max said the balance was too low to cover + // the transfer fee — which is false, and points at waiting rather than + // at the mixed-coins move. + let message = InternalTransferViewModel.coreZeroMaxMessage( + totalDuffs: 9_460_987_512, + confirmedSpendableDuffs: 9_460_987_512, + excludedFromPoolDuffs: 9_460_987_512) + XCTAssertTrue(message.contains("mixed coins"), "got: \(message)") + XCTAssertFalse(message.lowercased().contains("fee"), "got: \(message)") + } + + func testStillConfirmingWinsOverTheMixedCoinsWording() { + let message = InternalTransferViewModel.coreZeroMaxMessage( + totalDuffs: 9_460_987_512, + confirmedSpendableDuffs: 0, + excludedFromPoolDuffs: 0) + XCTAssertTrue(message.contains("still confirming"), "got: \(message)") + } + + func testAnEmptyWalletIsStillAnEmptyWallet() { + let message = InternalTransferViewModel.coreZeroMaxMessage( + totalDuffs: 0, confirmedSpendableDuffs: 0, excludedFromPoolDuffs: 0) + XCTAssertFalse(message.contains("mixed coins"), "got: \(message)") + } + + func testAConfirmedTransparentBalanceTooSmallForTheFeeStillSaysSo() { + let message = InternalTransferViewModel.coreZeroMaxMessage( + totalDuffs: 50_000, confirmedSpendableDuffs: 50_000, excludedFromPoolDuffs: 0) + XCTAssertFalse(message.contains("mixed coins"), "got: \(message)") + // Naming the reason, not merely NOT naming the wrong one: without this + // the assertion above also passes for an empty or unrelated message. + XCTAssertTrue(message.lowercased().contains("fee"), "got: \(message)") + } + + func testTheCeilingTakesTheLowerOfTwoKnownFigures() { + // The two are independent snapshots: `applyBalance` publishes the + // wallet-wide figure while the pooled read is still in flight, so a + // pooled value from before a spend can outlive the wallet-wide one + // that already reflects it. Gating on the stale higher number accepts + // an amount the builder then refuses. + XCTAssertEqual( + SwiftDashSDKWalletState.sendableDuffs(pooled: 9_000_000, walletSpendable: 1_000_000), + 1_000_000, + "a pooled figure above the wallet-wide one is stale, not a larger pool") + } + + // MARK: - The ceiling publisher the amount screen validates against + + /// `SendAmountModel.observeSendableCeiling` subscribes to this. A test over + /// the pure `sendableDuffs` arithmetic cannot tell a live subscription from + /// a missing one, so the derived publisher is what these exercise. + + func testTheCeilingEmitsWhenThePooledFigureArrives() { + let pooled = CurrentValueSubject(nil) + let spendable = CurrentValueSubject(94_000_000) + var seen: [UInt64] = [] + let token = SwiftDashSDKWalletState + .sendableCeilingPublisher(pooled: pooled.eraseToAnyPublisher(), + walletSpendable: spendable.eraseToAnyPublisher()) + .sink { seen.append($0) } + + pooled.send(120_000) + + XCTAssertEqual(seen, [94_000_000, 120_000], + "the fallback ceiling, then the pooled one that replaces it") + token.cancel() + } + + func testTheCeilingEmitsWhenOnlyTheFallbackMovesDuringAPooledOutage() { + // The case a `$pooledSpendableDuffs`-only subscription misses: through + // an outage the pooled value stays nil and `removeDuplicates` swallows + // every repeat, while the wallet balance keeps moving the ceiling. + let pooled = CurrentValueSubject(nil) + let spendable = CurrentValueSubject(10_000) + var seen: [UInt64] = [] + let token = SwiftDashSDKWalletState + .sendableCeilingPublisher(pooled: pooled.eraseToAnyPublisher(), + walletSpendable: spendable.eraseToAnyPublisher()) + .sink { seen.append($0) } + + spendable.send(4_000) + pooled.send(nil) + + XCTAssertEqual(seen, [10_000, 4_000], + "the drop reaches the screen, and the repeated nil adds nothing") + token.cancel() + } + + func testTheCeilingIgnoresAnUpdateThatLeavesItWhereItWas() { + let pooled = CurrentValueSubject(120_000) + let spendable = CurrentValueSubject(94_000_000) + var seen: [UInt64] = [] + let token = SwiftDashSDKWalletState + .sendableCeilingPublisher(pooled: pooled.eraseToAnyPublisher(), + walletSpendable: spendable.eraseToAnyPublisher()) + .sink { seen.append($0) } + + // The wallet-wide figure moves, but the pooled one rules — the screen + // has nothing to revalidate. + spendable.send(80_000_000) + pooled.send(120_000) + + XCTAssertEqual(seen, [120_000], "a ceiling that did not move must not churn validation") + token.cancel() + } + + + // MARK: Single-flight ownership of the pooled read + + func testASecondRequestDuringAReadCoalescesIntoOneRerun() { + var slot = PooledReadSlot() + let first = slot.begin() + XCTAssertNotNil(first) + XCTAssertNil(slot.begin(), "a second read must not run concurrently") + XCTAssertNil(slot.begin(), "and a third must not queue a second rerun") + XCTAssertTrue(slot.finish(first!), "the pending request becomes exactly one rerun") + XCTAssertFalse(slot.rerunRequested) + } + + func testAFinishedReadWithNoRequestsStartsNoRerun() { + var slot = PooledReadSlot() + let generation = slot.begin()! + XCTAssertFalse(slot.finish(generation)) + } + + /// The reviewed race, in order: a read is cancelled, its replacement starts, + /// and the cancelled one completes last. Before the generation guard it + /// cleared the replacement's slot on its way out, which freed the slot for a + /// third read while the second was still in flight — and the two then + /// published in whatever order they finished. + func testACancelledReadCompletingLastCannotTakeItsReplacementsSlot() { + var slot = PooledReadSlot() + let cancelled = slot.begin()! + + slot.cancel() + let replacement = slot.begin()! + XCTAssertNotEqual(cancelled, replacement) + + // The cancelled read resumes here, after the replacement has started. + XCTAssertFalse(slot.owns(cancelled), "a cancelled read owns nothing") + XCTAssertFalse(slot.finish(cancelled), "and cannot release the slot") + XCTAssertTrue(slot.owns(replacement), "the replacement still holds it") + + XCTAssertFalse(slot.finish(replacement)) + XCTAssertNotNil(slot.begin(), "the slot is free once its owner releases it") + } + + func testACancelledReadCannotConsumeTheReplacementsRerun() { + var slot = PooledReadSlot() + let cancelled = slot.begin()! + slot.cancel() + let replacement = slot.begin()! + + // A request arrives while the replacement runs, and belongs to it. + XCTAssertNil(slot.begin()) + XCTAssertFalse(slot.finish(cancelled), "the cancelled read must not swallow it") + XCTAssertTrue(slot.rerunRequested) + XCTAssertTrue(slot.finish(replacement)) + } + + func testCancellingWithNothingInFlightLeavesTheSlotClaimable() { + var slot = PooledReadSlot() + slot.cancel() + XCTAssertNotNil(slot.begin()) + } +}