From bbda17c1e2ffb3e82c7b5e3ba31edfb44933fd73 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:03:04 +0300 Subject: [PATCH 1/7] fix(wallet): gate sends on what the funding pool can actually spend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The amount screen compared against `balance.spendable`, which is the whole wallet's confirmed balance — every funding account, CoinJoin included. A send draws on `SEND_FUNDING_SOURCES`: BIP44@0, BIP32@0 and the DashPay receiving accounts, with CoinJoin deliberately out. So the screen accepted amounts the builder then refused, and the user learned it only after committing. Support ticket 32081: 94 DASH on screen, 0.0054 actually spendable, the rest mixed. Entering 1 DASH gave "insufficient unreserved core funds … available Some(538503), required Some(100000000)" — 538503 duffs being exactly the transparent balance. Both the gate and Max now read `pooledSpendableDuffs`, which the SDK computes with the same account resolution the builder uses (dashpay/platform#4582) rather than the app mirroring the pooling rule. The mirror is what drifted: the comment in SwiftDashSDKTransactionSender still claims `.allSpendable` pools "the same set the home balance already totals", and it never did. Max is included deliberately. Leaving it on the wallet-wide figure would keep a button that fills in an amount guaranteed to fail. --- .../SwiftDashSDKWalletState.swift | 32 ++++++++++++++++++- .../Amount/Model/Send/SendAmountModel.swift | 6 +++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift index e32dd6636..6c592b125 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift @@ -119,7 +119,10 @@ 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 + // 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. + let spendable = pooledSpendableDuffs let reserve = SwiftDashSDKTransactionSender.maxSendFeeReserveDuffs() return spendable > reserve ? spendable - reserve : 0 } @@ -158,6 +161,18 @@ 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. + @Published public private(set) var pooledSpendableDuffs: UInt64 = 0 + // MARK: - Obj-C bridge /// Notification posted on the main queue whenever the published @@ -213,6 +228,7 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { MainActor.assumeIsolated { self.refreshPlatformPaymentCredits() self.refreshCoinJoinBalance() + self.refreshPooledSpendableBalance() } NotificationCenter.default.post( name: SwiftDashSDKWalletState.balanceDidChangeNotification, @@ -339,6 +355,20 @@ 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. + @MainActor + public func refreshPooledSpendableBalance() { + guard let wallet = SwiftDashSDKHost.shared.wallet, + let core = try? wallet.coreWallet(), + let duffs = try? core.pooledSpendableBalance() + else { return } + if pooledSpendableDuffs != duffs { + pooledSpendableDuffs = duffs + Self.logger.info("💰 WALLET :: pooledSpendableDuffs=\(duffs, privacy: .public)") + } + } + @MainActor public func refreshCoinJoinBalance() { let duffs = SwiftDashSDKCoinJoinBalanceReader.coinJoinSpendableDuffs() diff --git a/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift b/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift index 1b1a59976..e942d5307 100644 --- a/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift +++ b/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift @@ -62,7 +62,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.pooledSpendableDuffs return plainAmount > allAvailableFunds } From 5b6bdf66b327950c442ad6ad051e178b7fd46f28 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:23:50 +0300 Subject: [PATCH 2/7] fix(wallet): fall back to the wallet-wide balance when the pooled figure is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `refreshPooledSpendableBalance` swallowed every error from `pooledSpendableBalance()` behind a `try?` guard, so a failing read left `pooledSpendableDuffs` at its initial 0 with nothing in the log. On the 2026-09-03 QA build the SDK refused every call (it resolved the core-wallet handle in the wrong table, platform#4582), and that silent 0 became Max = 0 and a send gate that rejected every amount — for every wallet, mixed or not. Both QA and a support report landed on it within hours; the App Store build, which never asks for the pooled figure, worked on the same wallet. Make "unknown" a state of its own: `pooledSpendableDuffs` is `nil` until the SDK has answered and again whenever a read fails, and the gate and Max read `sendableDuffs`, which falls back to `balance.spendable` while it is nil. Over-offering there is the pre-#1107 behaviour — a build-time refusal the user can act on — whereas a silent 0 is a dead screen. A failed read is logged once per outage with the SDK's error and re-armed after the next success, so the next such regression shows up in the log instead of in a ticket. The figure is cleared with the rest of the balance state on `clearBalance` / `clearAllState`. --- .../SwiftDashSDKWalletState.swift | 52 ++++++++++++++++--- .../Amount/Model/Send/SendAmountModel.swift | 2 +- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift index 6c592b125..e4a8f14f7 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift @@ -122,7 +122,7 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { // 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. - let spendable = pooledSpendableDuffs + let spendable = sendableDuffs let reserve = SwiftDashSDKTransactionSender.maxSendFeeReserveDuffs() return spendable > reserve ? spendable - reserve : 0 } @@ -171,7 +171,22 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { /// 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. - @Published public private(set) var pooledSpendableDuffs: UInt64 = 0 + /// + /// `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 { + pooledSpendableDuffs ?? balance?.spendable ?? 0 + } // MARK: - Obj-C bridge @@ -359,16 +374,39 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { /// balance event, like the CoinJoin tally beside it. @MainActor public func refreshPooledSpendableBalance() { - guard let wallet = SwiftDashSDKHost.shared.wallet, - let core = try? wallet.coreWallet(), - let duffs = try? core.pooledSpendableBalance() - else { return } + guard let wallet = SwiftDashSDKHost.shared.wallet else { + markPooledSpendableUnavailable(reason: "no active wallet") + return + } + let duffs: UInt64 + do { + duffs = try wallet.coreWallet().pooledSpendableBalance() + } catch { + markPooledSpendableUnavailable(reason: String(describing: error)) + return + } + hasLoggedPooledSpendableOutage = false if pooledSpendableDuffs != duffs { pooledSpendableDuffs = duffs Self.logger.info("💰 WALLET :: pooledSpendableDuffs=\(duffs, privacy: .public)") } } + /// 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() @@ -425,6 +463,7 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { } self?.platformPaymentCredits = 0 self?.coinJoinBalanceDuffs = 0 + self?.pooledSpendableDuffs = nil NotificationCenter.default.post( name: SwiftDashSDKWalletState.balanceDidChangeNotification, object: nil) @@ -443,6 +482,7 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { } self?.platformPaymentCredits = 0 self?.coinJoinBalanceDuffs = 0 + self?.pooledSpendableDuffs = nil NotificationCenter.default.post( name: SwiftDashSDKWalletState.balanceDidChangeNotification, object: nil) diff --git a/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift b/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift index e942d5307..860a97963 100644 --- a/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift +++ b/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift @@ -66,7 +66,7 @@ class SendAmountModel: BaseAmountModel { // `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.pooledSpendableDuffs + let allAvailableFunds = SwiftDashSDKWalletState.shared.sendableDuffs return plainAmount > allAvailableFunds } From e94470b16b6d41eb46a8e3ddcb8bc991ec63ed22 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:56:31 +0300 Subject: [PATCH 3/7] fix(wallet): revalidate on the pooled ceiling, and explain what it excludes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems with gating on the pooled figure, both from review. The ceiling can move while the amount 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 an amount typed while the fallback applied stayed enabled after the lower ceiling arrived. `SendAmountModel` now observes `pooledSpendableDuffs` and drives a new `validationDidChangeHandler`, which the view treats exactly as an amount change — error text and button together. `ProvideAmountViewController` re-checks affordability at submit as well: a tap can still race the refresh, and forwarding the amount is what produces the late builder failure this change exists to prevent. The Max explanations then attributed the reduction to the wrong thing. A confirmed CoinJoin-only balance produced a pooled Max of zero and was told its balance was too low to cover the transfer fee — false, and it points the user at waiting rather than at the mixed-coins move. With some transparent funds, the whole difference was described as held back for fees and unconfirmed coins, which on the ticket-32081 wallet would misattribute about 94 DASH. `excludedFromSendPoolDuffs` names that part, `coreZeroMaxMessage` takes it and says so, and `coreHeldBackMessage` splits the shortfall into the excluded coins and the genuine fee-and-unconfirmed remainder, printing only the sentences that have a nonzero amount. Two single-argument sentences rather than one two-argument format: a translation that reorders positional specifiers crashes, and this string is on the path of every Max tap. Each decision is now split into a pure form taking its inputs as parameters and a thin wrapper reading the SDK singletons, which is what the new tests cover: a pool below the wallet total, a successful zero that must not fall back, an unavailable read that does, recovery lowering the ceiling again, Max flooring at the reserve, and the wording each shortfall produces. --- DashWallet.xcodeproj/project.pbxproj | 4 + .../SwiftDashSDKWalletState.swift | 52 ++++- .../ProvideAmountViewController.swift | 14 ++ .../Amount/BaseAmountViewController.swift | 4 + .../Amount/Model/BaseAmountModel.swift | 6 + .../Amount/Model/Send/SendAmountModel.swift | 29 ++- .../InternalTransferViewModel.swift | 75 ++++++-- .../PooledSendableBalanceTests.swift | 177 ++++++++++++++++++ 8 files changed, 341 insertions(+), 20 deletions(-) create mode 100644 DashWalletTests/PooledSendableBalanceTests.swift diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 763570dba..806df7666 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -940,6 +940,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 */; }; 7B18F4580CB3D24E417435DE /* StorageRecordDetailViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCBA057E553F81F54ACE6E1 /* StorageRecordDetailViews.swift */; }; 7B4F28EE00BA451DA8675B06 /* SwapCoinIconLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2F0351CAD174EEB8E64012A /* SwapCoinIconLoader.swift */; }; 7B9BBDE902964C57C44F68BD /* PinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FE91D9C90E736DB1634A5 /* PinStore.swift */; }; @@ -3088,6 +3089,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 = ""; }; 7B56B94951BA51EAC9E0A6E1 /* libPods-DashWalletScreenshotsUITests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-DashWalletScreenshotsUITests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 7B94B3697D7742BFA991B5CC /* BuySellPortalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuySellPortalScreen.swift; sourceTree = ""; }; 7C172F43568AE0A5AFDB5349 /* Secp256k1Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Secp256k1Tests.swift; path = DashConnect/Secp256k1Tests.swift; sourceTree = ""; }; @@ -7225,6 +7227,7 @@ CB9000012FE1000000000001 /* CoinbaseTransactionMetadataTests.swift */, CB9100012FE2000000000001 /* CoinbaseTransferAmountTests.swift */, 7A30000130A1000000000001 /* TransactionDirectionTests.swift */, + 7A32000130A3000000000001 /* PooledSendableBalanceTests.swift */, CB9200012FE3000000000001 /* PassiveWalletStateUITailTests.swift */, AA0003032CA0F58E00A1B402 /* SwapAddressValidatorTests.swift */, AA00F1002FF0A10000A1B402 /* ExchangeAddressLookupContextTests.swift */, @@ -10431,6 +10434,7 @@ CB9000022FE1000000000002 /* CoinbaseTransactionMetadataTests.swift in Sources */, CB9100022FE2000000000002 /* CoinbaseTransferAmountTests.swift in Sources */, 7A30000230A1000000000002 /* TransactionDirectionTests.swift in Sources */, + 7A32000230A3000000000002 /* PooledSendableBalanceTests.swift in Sources */, CB9200022FE3000000000002 /* PassiveWalletStateUITailTests.swift in Sources */, AA0003042CA0F58E00A1B402 /* SwapAddressValidatorTests.swift in Sources */, AA00F1012FF0A10000A1B402 /* ExchangeAddressLookupContextTests.swift in Sources */, diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift index e4a8f14f7..13b527c9d 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift @@ -122,9 +122,16 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { // 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. - let spendable = sendableDuffs - let reserve = SwiftDashSDKTransactionSender.maxSendFeeReserveDuffs() - return spendable > reserve ? spendable - reserve : 0 + 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 @@ -185,7 +192,44 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { /// The amount gates and Max should read: the pooled figure when the SDK /// has supplied one, else `balance.spendable`. public var sendableDuffs: UInt64 { - pooledSpendableDuffs ?? balance?.spendable ?? 0 + Self.sendableDuffs(pooled: pooledSpendableDuffs, walletSpendable: balance?.spendable) + } + + /// 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 { + pooled ?? walletSpendable ?? 0 + } + + /// 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 diff --git a/DashWallet/Sources/UI/Payment Controller/Enter Amount/ProvideAmountViewController.swift b/DashWallet/Sources/UI/Payment Controller/Enter Amount/ProvideAmountViewController.swift index c0865c6c4..aebf37042 100644 --- a/DashWallet/Sources/UI/Payment Controller/Enter Amount/ProvideAmountViewController.swift +++ b/DashWallet/Sources/UI/Payment Controller/Enter Amount/ProvideAmountViewController.swift @@ -52,6 +52,20 @@ final class ProvideAmountViewController: SendAmountViewController { override func actionButtonAction(sender: UIView) { guard validateInputAmount() else { return } + // The ceiling can have dropped since the amount was typed — a pooled + // read landing, or recovering from an outage and replacing the + // wallet-wide fallback. The button state is refreshed when that + // happens, but a tap can still race it, and forwarding the amount here + // is what produces the late builder failure this screen exists to + // prevent. So affordability is re-checked at the boundary, not trusted + // from the last edit. + guard !sendAmountModel.canShowInsufficientFunds else { + sendAmountModel.checkAmountForErrors() + actionButton?.isEnabled = sendAmountModel.isAllowedToContinue + showErrorIfNeeded() + return + } + checkLeftoverBalance { [weak self] canContinue in guard canContinue, let wSelf = self else { return } 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 860a97963..5f8ae1db9 100644 --- a/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift +++ b/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift @@ -76,9 +76,29 @@ 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. + private func observeSendableCeiling() { + SwiftDashSDKWalletState.shared.$pooledSpendableDuffs + .removeDuplicates() + .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 { @@ -100,11 +120,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 9d275db04..0ff4ad941 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift @@ -1038,13 +1038,16 @@ 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) } else { // The balance card shows the total, so a Max that lands below // it reads as a bug unless the held-back part is accounted for. - maxNotice = Self.coreHeldBackMessage(coreBalanceDuffs - sourceDuffs) + maxNotice = Self.coreHeldBackMessage( + heldBackDuffs: coreBalanceDuffs - sourceDuffs, + excludedDuffs: SwiftDashSDKWalletState.shared.excludedFromSendPoolDuffs) } case .coreToPlatform: // Fee-aware max: spendable minus the send fee reserve (mirrors @@ -1054,11 +1057,14 @@ final class InternalTransferViewModel: ObservableObject { if sourceDuffs == 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 < coreBalanceDuffs { // The balance card shows the total, so a Max that lands below // it reads as a bug unless the held-back part is accounted for. - maxNotice = Self.coreHeldBackMessage(coreBalanceDuffs - sourceDuffs) + maxNotice = Self.coreHeldBackMessage( + heldBackDuffs: coreBalanceDuffs - sourceDuffs, + excludedDuffs: SwiftDashSDKWalletState.shared.excludedFromSendPoolDuffs) } case .platformToShielded: // Handled above because an unresolved async preflight must preserve @@ -1365,15 +1371,42 @@ final class InternalTransferViewModel: ObservableObject { } } - /// The part of the Core balance Max cannot offer: unconfirmed/immature - /// coins plus the reserved L1 fee. - private static func coreHeldBackMessage(_ duffs: UInt64) -> String { - let formatted = duffs.formattedDashAmountWithoutCurrencySymbol - return String.localizedStringWithFormat( - NSLocalizedString( - "%@ DASH is held back for the network fee and unconfirmed coins.", - comment: "Core Max holds back fee and unconfirmed funds"), - formatted) + /// The part of the Core balance Max cannot offer, told apart by *why*. + /// + /// `excludedDuffs` is money in accounts the send pool does not draw on — + /// the CoinJoin account. It is neither reserved for a fee nor waiting on + /// confirmations, so folding it into the fee sentence misattributes it, and + /// at the scale this happens (94 of 94.6 DASH on the ticket-32081 wallet) + /// the sentence stops being an explanation and becomes a wrong one. The + /// user's next step for those coins is the mixed-coins move, not waiting. + /// + /// Two single-argument sentences rather than one two-argument format: a + /// translation that reorders positional specifiers crashes, and this string + /// is on a path every Max tap reaches. + /// + /// `nonisolated` for the same reason as `coreZeroMaxMessage`: the body is + /// pure string work, and the tests that pin the wording are not main-actor + /// bound. + nonisolated static func coreHeldBackMessage(heldBackDuffs: UInt64, excludedDuffs: UInt64) -> String { + let excluded = min(excludedDuffs, heldBackDuffs) + let forFeesAndUnconfirmed = heldBackDuffs - excluded + + var sentences: [String] = [] + if excluded > 0 { + sentences.append(String.localizedStringWithFormat( + NSLocalizedString( + "%@ DASH is in mixed coins, which a send cannot use — move them to your spendable balance first.", + comment: "Core Max holds back CoinJoin funds the send pool excludes"), + excluded.formattedDashAmountWithoutCurrencySymbol)) + } + if forFeesAndUnconfirmed > 0 { + sentences.append(String.localizedStringWithFormat( + NSLocalizedString( + "%@ DASH is held back for the network fee and unconfirmed coins.", + comment: "Core Max holds back fee and unconfirmed funds"), + forFeesAndUnconfirmed.formattedDashAmountWithoutCurrencySymbol)) + } + return sentences.joined(separator: " ") } /// Why a Core Max produced nothing, told apart by the three states that @@ -1386,7 +1419,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 { @@ -1396,6 +1430,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..b7985fa87 --- /dev/null +++ b/DashWalletTests/PooledSendableBalanceTests.swift @@ -0,0 +1,177 @@ +// +// 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 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 a shortfall + + 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)") + } + + func testHeldBackSplitsExcludedFundsFromTheFeeAndUnconfirmedPart() { + let message = InternalTransferViewModel.coreHeldBackMessage( + heldBackDuffs: 9_460_449_009 + 100_000, + excludedDuffs: 9_460_449_009) + XCTAssertTrue(message.contains("mixed coins"), "got: \(message)") + XCTAssertTrue(message.contains("network fee"), "got: \(message)") + } + + func testHeldBackSaysOnlyTheFeeSentenceWhenNothingIsExcluded() { + let message = InternalTransferViewModel.coreHeldBackMessage( + heldBackDuffs: 100_000, excludedDuffs: 0) + XCTAssertTrue(message.contains("network fee"), "got: \(message)") + XCTAssertFalse(message.contains("mixed coins"), "got: \(message)") + } + + func testHeldBackSaysOnlyTheMixedCoinsSentenceWhenThatIsAllOfIt() { + let message = InternalTransferViewModel.coreHeldBackMessage( + heldBackDuffs: 9_460_449_009, excludedDuffs: 9_460_449_009) + XCTAssertTrue(message.contains("mixed coins"), "got: \(message)") + XCTAssertFalse(message.contains("network fee"), "got: \(message)") + } + + func testExcludedFundsNeverExceedWhatIsActuallyHeldBack() { + // The two figures are read at different moments, so the excluded part + // can momentarily read larger than the whole shortfall. Clamped, or the + // subtraction underflows. + let message = InternalTransferViewModel.coreHeldBackMessage( + heldBackDuffs: 1_000, excludedDuffs: 5_000) + XCTAssertTrue(message.contains("mixed coins"), "got: \(message)") + XCTAssertFalse(message.contains("network fee"), "got: \(message)") + } +} From ca989cbe6905006d807b33a2427877827f519e6c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:02:07 +0300 Subject: [PATCH 4/7] fix(wallet): settle affordability after the leftover-balance alert, not before MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-check ran before `checkLeftoverBalance`, which — when the wallet has a CrowdNode balance — presents its own Continue/Cancel alert and calls back from the button handler. The ceiling can drop while that alert is open, and Continue then forwards an amount the funding pool can no longer fund, reaching exactly the late builder failure this screen exists to prevent. The check moved into `amountIsStillAffordable()` and now runs twice: once before the alert, so the user is not asked to confirm emptying their wallet for an amount that cannot be funded anyway, and again as the last statement before the amount leaves the screen. The completion fires from a `UIAlertAction` handler, so the button and error refresh inside it are already on the main thread. Only this caller is in scope: `BuyCreditsViewController` and `CrowdNodeTransferViewController` share the same `checkLeftoverBalance` shape but do not route through the pooled ceiling this PR introduces. --- .../ProvideAmountViewController.swift | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/DashWallet/Sources/UI/Payment Controller/Enter Amount/ProvideAmountViewController.swift b/DashWallet/Sources/UI/Payment Controller/Enter Amount/ProvideAmountViewController.swift index aebf37042..1c754dff3 100644 --- a/DashWallet/Sources/UI/Payment Controller/Enter Amount/ProvideAmountViewController.swift +++ b/DashWallet/Sources/UI/Payment Controller/Enter Amount/ProvideAmountViewController.swift @@ -51,23 +51,18 @@ final class ProvideAmountViewController: SendAmountViewController { override func actionButtonAction(sender: UIView) { guard validateInputAmount() else { return } - - // The ceiling can have dropped since the amount was typed — a pooled - // read landing, or recovering from an outage and replacing the - // wallet-wide fallback. The button state is refreshed when that - // happens, but a tap can still race it, and forwarding the amount here - // is what produces the late builder failure this screen exists to - // prevent. So affordability is re-checked at the boundary, not trusted - // from the last edit. - guard !sendAmountModel.canShowInsufficientFunds else { - sendAmountModel.checkAmountForErrors() - actionButton?.isEnabled = sendAmountModel.isAllowedToContinue - showErrorIfNeeded() - 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 @@ -78,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() From 2066770677ceb890e9cf8e3eb3625175a391d1bb Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:44:01 +0300 Subject: [PATCH 5/7] fix(wallet): address the pooled-balance review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The amount screen went blind to half its own ceiling.** It subscribed to `$pooledSpendableDuffs` alone, but through a pooled-read outage that value stays `nil` — `removeDuplicates` swallows every repeat — while `balance` keeps moving the fallback ceiling underneath. An amount typed before a drop stayed valid and the Send button stayed enabled. Both inputs now feed one `sendableCeilingPublisher`, deduplicated on the resolved ceiling, so it reacts to whichever half moved and stays quiet when neither changed the answer. **The pooled read was a main-thread stall.** `pooledSpendableBalance()` bridges synchronously into Rust, waits on the wallet-manager read lock and walks every funding account's UTXO set, and it ran on the main actor during exactly the balance-event bursts that trigger it — behind whatever writer holds the lock (block processing, a finalizing build). The wallet handle is captured on the main actor, the read runs off it, and the result is published back only if it still describes the same wallet and network. Overlapping requests coalesce the way the Platform-credit tally does: one read in flight, one re-run queued. **Tests.** The ceiling publisher is exercised directly — a pure-function test over `sendableDuffs` cannot tell a live subscription from a missing one. Three cases: the pooled figure replacing the fallback, the fallback moving alone during an outage, and an update that leaves the ceiling where it was. The fee-shortfall assertion now names the reason instead of only ruling out the wrong one. --- .../SwiftDashSDKWalletState.swift | 88 +++++++++++++++++-- .../Amount/Model/Send/SendAmountModel.swift | 14 ++- .../PooledSendableBalanceTests.swift | 64 ++++++++++++++ 3 files changed, 155 insertions(+), 11 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift index 13b527c9d..7bf9d48c9 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift @@ -195,6 +195,27 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { 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. /// @@ -416,26 +437,75 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { /// 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 { + pooledSpendableReadTask?.cancel() + pooledSpendableReadTask = nil + pooledSpendableRerunRequested = false markPooledSpendableUnavailable(reason: "no active wallet") return } - let duffs: UInt64 - do { - duffs = try wallet.coreWallet().pooledSpendableBalance() - } catch { - markPooledSpendableUnavailable(reason: String(describing: error)) + if pooledSpendableReadTask != nil { + pooledSpendableRerunRequested = true return } - hasLoggedPooledSpendableOutage = false - if pooledSpendableDuffs != duffs { - pooledSpendableDuffs = duffs - Self.logger.info("💰 WALLET :: pooledSpendableDuffs=\(duffs, privacy: .public)") + + 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 + + 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.pooledSpendableRerunRequested { + self.pooledSpendableRerunRequested = false + self.refreshPooledSpendableBalance() + } } } + /// Non-nil while a pooled read is in flight; requests arriving during that + /// window flip `pooledSpendableRerunRequested` instead of piling up. + @MainActor private var pooledSpendableReadTask: Task? + @MainActor private var pooledSpendableRerunRequested = false + /// 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 diff --git a/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift b/DashWallet/Sources/UI/Payments/Amount/Model/Send/SendAmountModel.swift index 5f8ae1db9..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 @@ -86,9 +87,18 @@ class SendAmountModel: BaseAmountModel { /// 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() { - SwiftDashSDKWalletState.shared.$pooledSpendableDuffs - .removeDuplicates() + 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 } diff --git a/DashWalletTests/PooledSendableBalanceTests.swift b/DashWalletTests/PooledSendableBalanceTests.swift index d99182b20..13f9a35b4 100644 --- a/DashWalletTests/PooledSendableBalanceTests.swift +++ b/DashWalletTests/PooledSendableBalanceTests.swift @@ -17,6 +17,7 @@ // limitations under the License. // +import Combine import XCTest @testable import dashpay @@ -141,6 +142,69 @@ final class PooledSendableBalanceTests: XCTestCase { 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)") + } + + // 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() } } From 7d8af665e9c36533f4245507004c798fcfae7f65 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:13:50 +0300 Subject: [PATCH 6/7] fix(wallet): stop a pooled read outliving the state it was issued for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings are on the off-main pooled read added earlier in this branch. **The wallet/network check was not enough to make a completion safe.** Through `prepareForNetworkSwitch` and the wipe paths the host still reports the same wallet and the same network while the published state has already been cleared, so a read issued before the clear passed `stillCurrent` and republished the outgoing wallet's ceiling into the new state. `clearBalance()` and `clearAllState()` nulled the value without invalidating the read that would overwrite it. They now cancel it, through the same `cancelPooledSpendableRead` the no-wallet branch uses — the shape `cancelPlatformCreditsTally` already established. **The ceiling trusted the pooled figure even when it was the older one.** They are independent snapshots: `applyBalance` publishes the wallet-wide value immediately while the pooled read is still in flight, so a pooled figure from before a spend can outlive a wallet-wide one that already reflects it. Gating on the higher stale number let the screen accept an amount the builder, selecting from current funds, then refuses. `sendableDuffs` takes the lower of the two when both are known. --- .../SwiftDashSDKWalletState.swift | 32 ++++++++++++++++--- .../PooledSendableBalanceTests.swift | 12 +++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift index 7bf9d48c9..002e8065b 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift @@ -241,7 +241,16 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { /// "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 { - pooled ?? walletSpendable ?? 0 + // 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, @@ -453,9 +462,7 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { @MainActor public func refreshPooledSpendableBalance() { guard let wallet = SwiftDashSDKHost.shared.wallet else { - pooledSpendableReadTask?.cancel() - pooledSpendableReadTask = nil - pooledSpendableRerunRequested = false + cancelPooledSpendableRead() markPooledSpendableUnavailable(reason: "no active wallet") return } @@ -506,6 +513,21 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { @MainActor private var pooledSpendableReadTask: Task? @MainActor private var pooledSpendableRerunRequested = false + /// 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 + pooledSpendableRerunRequested = false + } + /// 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 @@ -574,6 +596,7 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { self?.balance = nil MainActor.assumeIsolated { self?.cancelPlatformCreditsTally() + self?.cancelPooledSpendableRead() } self?.platformPaymentCredits = 0 self?.coinJoinBalanceDuffs = 0 @@ -593,6 +616,7 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { self?.balance = nil MainActor.assumeIsolated { self?.cancelPlatformCreditsTally() + self?.cancelPooledSpendableRead() } self?.platformPaymentCredits = 0 self?.coinJoinBalanceDuffs = 0 diff --git a/DashWalletTests/PooledSendableBalanceTests.swift b/DashWalletTests/PooledSendableBalanceTests.swift index 13f9a35b4..b292c9854 100644 --- a/DashWalletTests/PooledSendableBalanceTests.swift +++ b/DashWalletTests/PooledSendableBalanceTests.swift @@ -147,6 +147,18 @@ final class PooledSendableBalanceTests: XCTestCase { 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 From 9ab7a7efc9e80a22fac0964a05086570ac090aa4 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:37:42 +0300 Subject: [PATCH 7/7] fix(wallet): let only the owning pooled read release its slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancelling a read and starting its replacement leaves the cancelled task still scheduled, and when it resumes it looks exactly like the one that owns the slot: it clears `pooledSpendableReadTask` on its way out, which frees the slot while the replacement is still in flight. A third read can then start alongside the second, and the two publish in whatever order they finish — so the send ceiling can settle on the older answer. The cancelled task could also consume the replacement's rerun flag, or start a rerun that was never requested of it. The publish itself was already guarded by the wallet/network check and `Task.isCancelled`; the slot bookkeeping was not, and a `Task` reference cannot express ownership on its own. `PooledReadSlot` gives the question an answer: a generation that moves on every claim and every cancel, so a read can compare the one it was issued under against the current one and step aside when it is no longer the owner. The guard sits before the slot is cleared, so a superseded read publishes nothing, clears nothing, consumes no rerun and starts none. `cancelPooledSpendableRead` moves the generation too, which is what breaks the reported sequence. Kept as a value type so the rule is testable without a wallet, an SDK handle or a live task — including the reviewed order itself: cancel a read, start its replacement, then let the cancelled one finish last. --- .../SwiftDashSDKWalletState.swift | 82 ++++++++++++++++--- .../PooledSendableBalanceTests.swift | 59 +++++++++++++ 2 files changed, 131 insertions(+), 10 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift index 002e8065b..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) @@ -466,10 +525,7 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { markPooledSpendableUnavailable(reason: "no active wallet") return } - if pooledSpendableReadTask != nil { - pooledSpendableRerunRequested = true - return - } + guard let generation = pooledReadSlot.begin() else { return } let walletId = wallet.walletId let network = SwiftDashSDKHost.shared.runningNetwork @@ -483,6 +539,12 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { } }.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. @@ -501,17 +563,17 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { } } - if self.pooledSpendableRerunRequested { - self.pooledSpendableRerunRequested = false + if self.pooledReadSlot.finish(generation) { self.refreshPooledSpendableBalance() } } } - /// Non-nil while a pooled read is in flight; requests arriving during that - /// window flip `pooledSpendableRerunRequested` instead of piling up. + /// 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 pooledSpendableRerunRequested = false + @MainActor private var pooledReadSlot = PooledReadSlot() /// Drop any in-flight pooled read so it cannot publish after a clear. /// @@ -525,7 +587,7 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { private func cancelPooledSpendableRead() { pooledSpendableReadTask?.cancel() pooledSpendableReadTask = nil - pooledSpendableRerunRequested = false + pooledReadSlot.cancel() } /// Drop the pooled figure so `sendableDuffs` falls back to the wallet-wide diff --git a/DashWalletTests/PooledSendableBalanceTests.swift b/DashWalletTests/PooledSendableBalanceTests.swift index b292c9854..b08f67ee0 100644 --- a/DashWalletTests/PooledSendableBalanceTests.swift +++ b/DashWalletTests/PooledSendableBalanceTests.swift @@ -219,4 +219,63 @@ final class PooledSendableBalanceTests: XCTestCase { 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()) + } }