Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Comment thread
QuantumExplorer marked this conversation as resolved.
}

let tx: FinalizedCoreTransaction
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
Loading