Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 @@ -970,6 +970,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 */; };
7A30001230A1000000000012 /* StuckAssetLockRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A30001130A1000000000011 /* StuckAssetLockRetryTests.swift */; };
7B18F4580CB3D24E417435DE /* StorageRecordDetailViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCBA057E553F81F54ACE6E1 /* StorageRecordDetailViews.swift */; };
7B4F28EE00BA451DA8675B06 /* SwapCoinIconLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2F0351CAD174EEB8E64012A /* SwapCoinIconLoader.swift */; };
Expand Down Expand Up @@ -3158,6 +3159,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>"; };
7A30001130A1000000000011 /* StuckAssetLockRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StuckAssetLockRetryTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
Expand Down Expand Up @@ -7370,6 +7372,7 @@
CB9000012FE1000000000001 /* CoinbaseTransactionMetadataTests.swift */,
CB9100012FE2000000000001 /* CoinbaseTransferAmountTests.swift */,
7A30000130A1000000000001 /* TransactionDirectionTests.swift */,
7A32000130A3000000000001 /* PooledSendableBalanceTests.swift */,
7A30001130A1000000000011 /* StuckAssetLockRetryTests.swift */,
CB9200012FE3000000000001 /* PassiveWalletStateUITailTests.swift */,
AA0003032CA0F58E00A1B402 /* SwapAddressValidatorTests.swift */,
Expand Down Expand Up @@ -10604,6 +10607,7 @@
CB9000022FE1000000000002 /* CoinbaseTransactionMetadataTests.swift in Sources */,
CB9100022FE2000000000002 /* CoinbaseTransferAmountTests.swift in Sources */,
7A30000230A1000000000002 /* TransactionDirectionTests.swift in Sources */,
7A32000230A3000000000002 /* PooledSendableBalanceTests.swift in Sources */,
7A30001230A1000000000012 /* StuckAssetLockRetryTests.swift in Sources */,
CB9200022FE3000000000002 /* PassiveWalletStateUITailTests.swift in Sources */,
AA0003042CA0F58E00A1B402 /* SwapAddressValidatorTests.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,70 @@ 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 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 +287,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 +414,43 @@ 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 else {
markPooledSpendableUnavailable(reason: "no active wallet")
return
}
let duffs: UInt64
do {
duffs = try wallet.coreWallet().pooledSpendableBalance()
} catch {
markPooledSpendableUnavailable(reason: String(describing: error))
Comment thread
romchornyi marked this conversation as resolved.
Outdated
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()
Expand Down Expand Up @@ -395,6 +507,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 +526,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
Original file line number Diff line number Diff line change
Expand Up @@ -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.sendableDuffs
return plainAmount > allAvailableFunds
Comment thread
romchornyi marked this conversation as resolved.
}

Expand All @@ -72,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)
Comment thread
romchornyi marked this conversation as resolved.
Outdated
.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 {
Expand All @@ -96,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
}

Expand Down
Loading
Loading