diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift index aa1c5f8c7..72ae9232b 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKTransactionSender.swift @@ -60,6 +60,13 @@ final class SwiftDashSDKTransactionSender: NSObject { private static let bip44TypeTag: UInt8 = 0 /// `AccountBalance.standardTag` discriminant for BIP44 within Standard. private static let bip44StandardTag: UInt8 = 0 + /// `StandardAccountTypeTagFFI.Bip32` — the other half of the Standard + /// family that `.allSpendable` pools. + private static let bip32StandardTag: UInt8 = 1 + /// `AccountTypeTagFFI.DashpayReceivingFunds`. Its sibling tag 13 + /// (`DashpayExternalAccount`) is a contact's watch-only coins and is NOT + /// pooled — the local seed cannot sign them. + private static let dashpayReceivingTypeTag: UInt8 = 12 /// Signal sends spend from the primary BIP44 account. private static let bip44AccountIndex: UInt32 = 0 /// How long to wait for a just-broadcast funding output to appear in the @@ -98,7 +105,7 @@ final class SwiftDashSDKTransactionSender: NSObject { let network = SwiftDashSDKHost.shared.runningNetwork else { throw SendError.walletNotReady("PlatformWalletManager wallet is not available") } - // Build + sign a standard BIP44 payment via the core + // Build + sign a standard payment via the core // TransactionBuilder. `finalizeAtomic` selects + reserves the // inputs and routes change to the account's next internal address // in one native operation (single tx — normal sends don't need @@ -107,7 +114,14 @@ final class SwiftDashSDKTransactionSender: NSObject { for recipient in recipients { try builder.addOutput(address: recipient.address, amountDuffs: recipient.amountDuffs) } - return try builder.finalizeAtomic(wallet: wallet, accountType: .bip44, accountIndex: 0) + // `.allSpendable` pools BIP44 + BIP32 + every DashPay + // contact-receiving account — the same set the home balance + // already totals, so a send can spend everything the user is + // shown. CoinJoin is excluded by construction (spending mixed + // outputs beside transparent ones would undo the mixing), as are + // a contact's watch-only external coins. Change returns to BIP44, + // the first pooled source. + return try builder.finalizeAtomic(wallet: wallet, accountType: .allSpendable) } let tx: FinalizedCoreTransaction @@ -153,7 +167,12 @@ final class SwiftDashSDKTransactionSender: NSObject { try builder.addOpReturn(memoData) try builder.preserveOutputOrder() try builder.changeToFirstInput() - let tx = try builder.finalizeAtomic(wallet: wallet, accountType: .bip44, accountIndex: 0) + // Same pooled funding as a plain send — see `buildAndSign`. + // `changeToFirstInput` stays correct under pooling: it routes to + // whichever input BIP-69 puts at VIN0, whatever account it came + // from, and the builder sizes the change output for the largest + // eligible routing script. + let tx = try builder.finalizeAtomic(wallet: wallet, accountType: .allSpendable) return (tx, network) } @@ -440,7 +459,7 @@ final class SwiftDashSDKTransactionSender: NSObject { } /// Fee reserve for a "Max" / all-funds core send, sized from the BIP44 - /// account's actual spendable-UTXO count instead of a flat constant. + /// pooled accounts' actual spendable-UTXO count instead of a flat constant. /// /// The flat `WalletBalance.sendFeeReserveDuffs` (100_000 duffs) is a large /// worst-case over-estimate — a typical send costs ~1–2k duffs, so Max used @@ -449,23 +468,39 @@ final class SwiftDashSDKTransactionSender: NSObject { /// inputs) plus a **+50 % safety margin** so it can never *under*-reserve — /// an under-estimate would make the Max send fail to build. /// - /// Fail-safe: any inability to enumerate the account (no wallet/manager, no - /// UTXOs) falls back to the flat reserve, i.e. the previous behaviour — the - /// change never makes Max worse than before, only tighter when it can. + /// The enumerated set must stay in step with the `.allSpendable` funding + /// `finalizeAtomic` uses: BIP44 + BIP32 at the funding index plus every + /// DashPay receiving account, and never CoinJoin or a contact's watch-only + /// external coins. + /// + /// Fail-safe: any inability to enumerate the accounts (no wallet/manager, + /// no UTXOs) falls back to the flat reserve, i.e. the previous behaviour — + /// the change never makes Max worse than before, only tighter when it can. static func maxSendFeeReserveDuffs() -> UInt64 { let read = { @MainActor () -> UInt64? in let host = SwiftDashSDKHost.shared guard let manager = host.manager, let wallet = host.wallet else { return nil } let walletId = wallet.walletId - // `typeTag == 0` is the whole Standard family; account 0 can exist as - // both BIP44 (`standardTag == 0`) and BIP32 (`standardTag == 1`), so - // require the standard tag too — mirrors `addressUtxos(script:)`. - guard let bip44 = manager.accountBalances(for: walletId).first(where: { - $0.typeTag == Self.bip44TypeTag - && $0.standardTag == Self.bip44StandardTag - && $0.index == Self.bip44AccountIndex - }) else { return nil } - let count = manager.accountUtxos(for: walletId, balance: bip44).count + // Enumerate exactly what `.allSpendable` spends — BIP44 + BIP32 at + // the funding index, plus every DashPay receiving account. Sizing + // the reserve off BIP44 alone would under-reserve whenever the + // other sources contribute inputs, and Max would then price itself + // above what the builder can actually fund. + let pooled = manager.accountBalances(for: walletId).filter { balance in + if balance.typeTag == Self.bip44TypeTag { + // Standard family: BIP44 and BIP32 both resolve to the + // single account at the funding index. + let isStandardPooled = balance.standardTag == Self.bip44StandardTag + || balance.standardTag == Self.bip32StandardTag + return isStandardPooled && balance.index == Self.bip44AccountIndex + } + // Every DashPay receiving account, whatever its index. + return balance.typeTag == Self.dashpayReceivingTypeTag + } + guard !pooled.isEmpty else { return nil } + let count = pooled.reduce(0) { total, balance in + total + manager.accountUtxos(for: walletId, balance: balance).count + } guard count > 0 else { return nil } let base = estimatedSignalFee(inputCount: count) return base + base / 2 // +50 % margin — must never under-reserve @@ -618,10 +653,13 @@ final class SwiftDashSDKTransactionSender: NSObject { } if decoded.outputs.count == 3 { - // `DecodedTransaction.Input.address` is recovered from a P2PKH-shaped scriptSig and - // is nil for anything else. Every BIP44 account UTXO this builder can spend is - // P2PKH, so nil here means the transaction is not the shape we asked for — refuse - // rather than skip the check. + // `DecodedTransaction.Input.address` is recovered from a P2PKH-shaped scriptSig + // and is nil for anything else. Every UTXO the pooled funding set can spend — + // BIP44, BIP32, and DashPay contact-receiving — is P2PKH, so nil here means the + // transaction is not the shape we asked for: refuse rather than skip the check. + // Note VIN0 decides where MAYA sends a refund, so under pooling that can be a + // DashPay receiving address rather than a BIP44 one. Still this wallet's own + // seed-signable address, and still counted in its balance. guard let inputAddress = decoded.inputs.first?.address else { throw SendError.invalidInput("swap deposit VIN0 address could not be recovered") }