Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -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 = sendableDuffs
let reserve = SwiftDashSDKTransactionSender.maxSendFeeReserveDuffs()
return spendable > reserve ? spendable - reserve : 0
Comment thread
romchornyi marked this conversation as resolved.
Outdated
}
Expand Down Expand Up @@ -158,6 +161,33 @@ 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 {
pooledSpendableDuffs ?? balance?.spendable ?? 0
Comment thread
romchornyi marked this conversation as resolved.
Outdated
}

// MARK: - Obj-C bridge

/// Notification posted on the main queue whenever the published
Expand Down Expand Up @@ -213,6 +243,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 +370,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 +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)
Expand All @@ -413,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)
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 Down
Loading