Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions DashWallet.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -3166,6 +3167,7 @@
7708BBFDD14AFF237FB72D71 /* MayaConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MayaConstants.swift; sourceTree = "<group>"; };
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 = "<group>"; };
7A32000130A3000000000001 /* PooledSendableBalanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PooledSendableBalanceTests.swift; sourceTree = "<group>"; };
7A30002130A2000000000021 /* CrowdNodeOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrowdNodeOwnershipTests.swift; sourceTree = "<group>"; };
7A31000130A2000000000001 /* CoinJoinMoveDestinationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinJoinMoveDestinationPolicyTests.swift; sourceTree = "<group>"; };
7A30001130A1000000000011 /* StuckAssetLockRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StuckAssetLockRetryTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,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
Expand Down Expand Up @@ -158,6 +168,91 @@ 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<UInt64?, Never>,
walletSpendable: AnyPublisher<UInt64?, Never>
) -> AnyPublisher<UInt64, Never> {
Publishers.CombineLatest(pooled, walletSpendable)
.map { sendableDuffs(pooled: $0, walletSpendable: $1) }
Comment thread
romchornyi marked this conversation as resolved.
.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 {
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

/// Notification posted on the main queue whenever the published
Expand Down Expand Up @@ -213,6 +308,7 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject {
MainActor.assumeIsolated {
self.refreshPlatformPaymentCredits()
self.refreshCoinJoinBalance()
self.refreshPooledSpendableBalance()
}
NotificationCenter.default.post(
name: SwiftDashSDKWalletState.balanceDidChangeNotification,
Expand Down Expand Up @@ -339,6 +435,92 @@ 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 {
pooledSpendableReadTask?.cancel()
pooledSpendableReadTask = nil
pooledSpendableRerunRequested = false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
markPooledSpendableUnavailable(reason: "no active wallet")
return
}
if pooledSpendableReadTask != nil {
pooledSpendableRerunRequested = true
return
}

let walletId = wallet.walletId
let network = SwiftDashSDKHost.shared.runningNetwork
pooledSpendableReadTask = Task { @MainActor [weak self] in
guard let self else { return }
let outcome: Result<UInt64, Error> = 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()
}
Comment thread
romchornyi marked this conversation as resolved.
}
}

/// 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<Void, Never>?
@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
/// 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()
Expand Down Expand Up @@ -395,6 +577,7 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject {
}
self?.platformPaymentCredits = 0
self?.coinJoinBalanceDuffs = 0
self?.pooledSpendableDuffs = nil
NotificationCenter.default.post(
name: SwiftDashSDKWalletState.balanceDidChangeNotification,
object: nil)
Expand All @@ -413,6 +596,7 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject {
}
self?.platformPaymentCredits = 0
self?.coinJoinBalanceDuffs = 0
self?.pooledSpendableDuffs = nil
NotificationCenter.default.post(
name: SwiftDashSDKWalletState.balanceDidChangeNotification,
object: nil)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading