Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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

Large diffs are not rendered by default.

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 @@ -15,6 +15,7 @@
// limitations under the License.
//

import Combine
import Foundation

// MARK: - SendAmountError
Expand Down Expand Up @@ -62,7 +63,11 @@ class SendAmountModel: BaseAmountModel {

var canShowInsufficientFunds: Bool {
let plainAmount = amount.plainAmount
let allAvailableFunds = SwiftDashSDKWalletState.shared.balance?.spendable ?? 0
// The accounts a send can actually draw on, not the whole wallet:
// `balance.spendable` also counts CoinJoin, which the funding pool
// excludes by design, so gating on it accepts amounts the builder then
// refuses with "insufficient unreserved core funds".
let allAvailableFunds = SwiftDashSDKWalletState.shared.sendableDuffs
return plainAmount > allAvailableFunds
Comment thread
romchornyi marked this conversation as resolved.
}

Expand All @@ -72,9 +77,38 @@ class SendAmountModel: BaseAmountModel {
super.init()

initializeSyncingActivityMonitor()
observeSendableCeiling()
checkAmountForErrors()
}

/// The ceiling can move while this screen is open and the amount is
/// untouched — a pooled read landing for the first time, or recovering from
/// an outage and replacing the wallet-wide fallback with a much smaller
/// transparent balance. `BaseAmountModel`'s balance subscription only
/// refreshes `walletBalance`, and the view refreshes its button off
/// `$amount`, so nothing revalidates an amount typed before the drop.
///
/// Both inputs matter, not just the pooled one: through a persistent pooled
/// outage that value stays `nil` and `removeDuplicates` swallows every
/// repeat, while `balance` keeps moving the fallback ceiling underneath.
/// Deduplicating the RESOLVED ceiling instead reacts to whichever half
/// changed, and still ignores updates that leave it where it was.
private func observeSendableCeiling() {
let state = SwiftDashSDKWalletState.shared
SwiftDashSDKWalletState
.sendableCeilingPublisher(
pooled: state.$pooledSpendableDuffs.eraseToAnyPublisher(),
walletSpendable: state.$balance.map { $0?.spendable }.eraseToAnyPublisher())
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self else { return }
self.error = nil
self.checkAmountForErrors()
self.validationDidChangeHandler?()
}
.store(in: &cancellableBag)
}

override func selectAllFunds() {
auth { [weak self] isAuthenticated in
if isAuthenticated {
Expand All @@ -96,11 +130,16 @@ class SendAmountModel: BaseAmountModel {
// small to also cover the fee were indistinguishable from a dead
// button. Same three states, same wording, as the internal
// transfer's Core Max.
let balance = SwiftDashSDKWalletState.shared.balance
let state = SwiftDashSDKWalletState.shared
let balance = state.balance
error = SendAmountError.maxUnavailable(
InternalTransferViewModel.coreZeroMaxMessage(
totalDuffs: balance?.total ?? 0,
confirmedSpendableDuffs: balance?.spendable ?? 0))
confirmedSpendableDuffs: balance?.spendable ?? 0,
// Without this a CoinJoin-only wallet is told its balance
// is too small to cover the fee, which is not why Max is
// empty — the pool simply cannot draw on mixed coins.
excludedFromPoolDuffs: state.excludedFromSendPoolDuffs))
return
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1906,7 +1906,8 @@ final class InternalTransferViewModel: ObservableObject {
if coreSpendableDuffs == 0 {
maxNotice = Self.coreZeroMaxMessage(
totalDuffs: coreBalanceDuffs,
confirmedSpendableDuffs: SwiftDashSDKWalletState.shared.balance?.spendable ?? 0)
confirmedSpendableDuffs: SwiftDashSDKWalletState.shared.balance?.spendable ?? 0,
excludedFromPoolDuffs: SwiftDashSDKWalletState.shared.excludedFromSendPoolDuffs)
} else if sourceDuffs == 0 {
maxNotice = Self.feeReserveExceedsBalanceMessage(route.source)
}
Expand All @@ -1927,7 +1928,8 @@ final class InternalTransferViewModel: ObservableObject {
if coreSpendableDuffs == 0 {
maxNotice = Self.coreZeroMaxMessage(
totalDuffs: coreBalanceDuffs,
confirmedSpendableDuffs: SwiftDashSDKWalletState.shared.balance?.spendable ?? 0)
confirmedSpendableDuffs: SwiftDashSDKWalletState.shared.balance?.spendable ?? 0,
excludedFromPoolDuffs: SwiftDashSDKWalletState.shared.excludedFromSendPoolDuffs)
} else if sourceDuffs == 0 {
// New with the fee-on-top reserve: a balance that cannot carry
// the reserve fills 0, which needs a reason like the shielded
Expand Down Expand Up @@ -2286,7 +2288,8 @@ final class InternalTransferViewModel: ObservableObject {
/// is not main-actor bound — can reach it; the body is pure string work.
nonisolated static func coreZeroMaxMessage(
totalDuffs: UInt64,
confirmedSpendableDuffs: UInt64
confirmedSpendableDuffs: UInt64,
excludedFromPoolDuffs: UInt64 = 0
) -> String {
guard totalDuffs > 0 else { return emptyBalanceMessage(.core) }
guard confirmedSpendableDuffs > 0 else {
Expand All @@ -2296,6 +2299,19 @@ final class InternalTransferViewModel: ObservableObject {
comment: "Core Max has nothing confirmed to spend"),
totalDuffs.formattedDashAmountWithoutCurrencySymbol)
}
// Confirmed, but none of it in an account a send draws on — the
// CoinJoin-only wallet. Saying the balance cannot cover the fee would
// be false: it is large enough, it is simply the wrong kind of money,
// and no amount of waiting changes that.
let poolable = confirmedSpendableDuffs
- min(excludedFromPoolDuffs, confirmedSpendableDuffs)
guard poolable > 0 else {
return String.localizedStringWithFormat(
NSLocalizedString(
"Your %@ DASH is in mixed coins, which a send cannot use — move them to your spendable balance first.",
comment: "Core Max has only CoinJoin funds, which the send pool excludes"),
confirmedSpendableDuffs.formattedDashAmountWithoutCurrencySymbol)
Comment thread
romchornyi marked this conversation as resolved.
}
return feeReserveExceedsBalanceMessage(.core)
}

Expand Down
Loading
Loading