Skip to content

Commit a1da9e5

Browse files
authored
feat(send): add onchain max send (#152)
1 parent dedb28b commit a1da9e5

File tree

4 files changed

+123
-16
lines changed

4 files changed

+123
-16
lines changed

Bitkit/Services/LightningService.swift

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -279,21 +279,37 @@ class LightningService {
279279
return .fromSatPerKwu(satKwu: max(satPerKwu, 253)) // FEERATE_FLOOR_SATS_PER_KW is 253 in LDK
280280
}
281281

282-
func send(address: String, sats: UInt64, satsPerVbyte: UInt32, utxosToSpend: [SpendableUtxo]? = nil) async throws -> Txid {
282+
func send(
283+
address: String,
284+
sats: UInt64,
285+
satsPerVbyte: UInt32,
286+
utxosToSpend: [SpendableUtxo]? = nil,
287+
isMaxAmount: Bool = false
288+
) async throws -> Txid {
283289
guard let node else {
284290
throw AppError(serviceError: .nodeNotSetup)
285291
}
286292

287-
Logger.info("Sending \(sats) sats to \(address) with fee rate \(satsPerVbyte) sats/vbyte")
293+
Logger.info("Sending \(sats) sats to \(address) with fee rate \(satsPerVbyte) sats/vbyte (isMaxAmount: \(isMaxAmount))")
288294

289295
do {
290296
return try await ServiceQueue.background(.ldk) {
291-
try node.onchainPayment().sendToAddress(
292-
address: address,
293-
amountSats: sats,
294-
feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVbyte),
295-
utxosToSpend: utxosToSpend
296-
)
297+
if isMaxAmount {
298+
// For max amount sends, use sendAllToAddress to send all available funds
299+
try node.onchainPayment().sendAllToAddress(
300+
address: address,
301+
retainReserve: true,
302+
feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVbyte)
303+
)
304+
} else {
305+
// For normal sends, use sendToAddress with specific amount
306+
try node.onchainPayment().sendToAddress(
307+
address: address,
308+
amountSats: sats,
309+
feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVbyte),
310+
utxosToSpend: utxosToSpend
311+
)
312+
}
297313
}
298314
} catch {
299315
dumpLdkLogs()

Bitkit/ViewModels/WalletViewModel.swift

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ class WalletViewModel: ObservableObject {
77
@Published var walletExists: Bool? = nil
88
@Published var isSyncingWallet = false // Syncing both LN and on chain
99
@AppStorage("totalBalanceSats") var totalBalanceSats: Int = 0 // Combined onchain and LN
10-
@AppStorage("totalOnchainSats") var totalOnchainSats: Int = 0 // Combined onchain
10+
@AppStorage("totalOnchainSats") var totalOnchainSats: Int = 0 // The total balance of our on-chain wallet
1111
@AppStorage("totalLightningSats") var totalLightningSats: Int = 0 // Combined LN
12+
@AppStorage("spendableOnchainBalanceSats") var spendableOnchainBalanceSats: Int = 0 // The spendable balance of our on-chain wallet
1213

1314
// Receive flow
1415
@AppStorage("onchainAddress") var onchainAddress = ""
@@ -22,6 +23,7 @@ class WalletViewModel: ObservableObject {
2223
@Published var selectedSpeed: TransactionSpeed = .normal
2324
@Published var selectedUtxos: [SpendableUtxo]?
2425
@Published var availableUtxos: [SpendableUtxo] = []
26+
@Published var isMaxAmountSend: Bool = false
2527

2628
// LNURL withdraw flow
2729
@Published var lnurlWithdrawAmount: UInt64?
@@ -200,9 +202,10 @@ class WalletViewModel: ObservableObject {
200202
/// - Parameters:
201203
/// - address: The bitcoin address to send to
202204
/// - sats: The amount in satoshis to send
205+
/// - isMaxAmount: Whether this is a max amount send (uses sendAllToAddress)
203206
/// - Returns: The transaction ID (txid) of the sent transaction
204207
/// - Throws: An error if the transaction fails or if fee rates cannot be retrieved
205-
func send(address: String, sats: UInt64) async throws -> Txid {
208+
func send(address: String, sats: UInt64, isMaxAmount: Bool = false) async throws -> Txid {
206209
guard let selectedFeeRateSatsPerVByte else {
207210
throw AppError(message: "Fee rate not set", debugMessage: "Please set a fee rate before selecting UTXOs.")
208211
}
@@ -217,7 +220,8 @@ class WalletViewModel: ObservableObject {
217220
address: address,
218221
sats: sats,
219222
satsPerVbyte: selectedFeeRateSatsPerVByte,
220-
utxosToSpend: selectedUtxos
223+
utxosToSpend: selectedUtxos,
224+
isMaxAmount: isMaxAmount
221225
)
222226

223227
Task {
@@ -330,6 +334,34 @@ class WalletViewModel: ObservableObject {
330334
)
331335
}
332336

337+
/// Calculates the maximum sendable amount for onchain transactions
338+
/// - Parameters:
339+
/// - address: The destination address
340+
/// - satsPerVByte: The fee rate in satoshis per virtual byte
341+
/// - Returns: The maximum amount that can be sent (balance minus fees)
342+
/// - Throws: Error if calculation fails
343+
func calculateMaxSendableAmount(
344+
address: String,
345+
satsPerVByte: UInt32
346+
) async throws -> UInt64 {
347+
let spendableBalance = UInt64(spendableOnchainBalanceSats)
348+
349+
availableUtxos = try await lightningService.listSpendableOutputs()
350+
351+
// Use LDK-Node's special handling - when we pass the spendable balance as amount,
352+
// it will automatically calculate the fee for sending all available funds
353+
// if the exact amount would result in insufficient funds due to fees
354+
let fee = try await lightningService.calculateTotalFee(
355+
address: address,
356+
amountSats: spendableBalance,
357+
satsPerVByte: satsPerVByte,
358+
utxosToSpend: availableUtxos
359+
)
360+
361+
// The max sendable amount is the spendable balance minus the fee
362+
return spendableBalance >= fee ? spendableBalance - fee : 0
363+
}
364+
333365
// NOTE: Let's keep this here for now until we are sure new version below is working
334366
// func send(
335367
// bolt11: String,
@@ -419,6 +451,7 @@ class WalletViewModel: ObservableObject {
419451
totalOnchainSats = Int(balanceDetails.totalOnchainBalanceSats)
420452
totalLightningSats = Int(balanceDetails.totalLightningBalanceSats)
421453
totalBalanceSats = Int(balanceDetails.totalLightningBalanceSats + balanceDetails.totalOnchainBalanceSats)
454+
spendableOnchainBalanceSats = Int(balanceDetails.spendableOnchainBalanceSats)
422455
}
423456
}
424457

@@ -527,6 +560,7 @@ class WalletViewModel: ObservableObject {
527560
selectedUtxos = nil
528561
availableUtxos = []
529562
selectedSpeed = speed
563+
isMaxAmountSend = false
530564
}
531565

532566
func wipe() async throws {

Bitkit/Views/Wallets/Send/SendAmountView.swift

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,7 @@ struct SendAmountView: View {
99
@Binding var navigationPath: [SendRoute]
1010

1111
@StateObject private var amountViewModel = AmountInputViewModel()
12-
13-
var availableAmount: UInt64 {
14-
app.selectedWalletToPayFrom == .lightning ? UInt64(wallet.totalLightningSats) : UInt64(wallet.totalOnchainSats)
15-
}
12+
@State private var maxSendableAmount: UInt64?
1613

1714
var amountSats: UInt64 {
1815
amountViewModel.amountSats
@@ -22,6 +19,24 @@ struct SendAmountView: View {
2219
app.scannedOnchainInvoice != nil && app.scannedLightningInvoice != nil
2320
}
2421

22+
/// The amount to display in the available balance section
23+
/// For onchain transactions, this shows the max sendable amount (balance minus fees)
24+
/// For lightning transactions, this shows the total balance
25+
var availableAmount: UInt64 {
26+
if app.selectedWalletToPayFrom == .lightning {
27+
return UInt64(wallet.totalLightningSats)
28+
} else {
29+
// For onchain, show max sendable amount if calculated, otherwise fall back to total balance
30+
return maxSendableAmount ?? UInt64(wallet.spendableOnchainBalanceSats)
31+
}
32+
}
33+
34+
/// Determines if the current amount is a max amount send
35+
var isMaxAmountSend: Bool {
36+
guard app.selectedWalletToPayFrom == .onchain else { return false }
37+
return amountSats == availableAmount && amountSats > 0
38+
}
39+
2540
var body: some View {
2641
VStack(spacing: 0) {
2742
SheetHeader(title: t("wallet__send_amount"), showBackButton: true)
@@ -97,12 +112,30 @@ struct SendAmountView: View {
97112
// Set the amount to the scanned onchain invoice amount if it exists
98113
amountViewModel.updateFromSats(invoice.amountSatoshis, currency: currency)
99114
}
115+
116+
// Calculate max sendable amount for onchain transactions
117+
if app.selectedWalletToPayFrom == .onchain {
118+
Task {
119+
await calculateMaxSendableAmount()
120+
}
121+
}
122+
}
123+
.onChange(of: app.selectedWalletToPayFrom) { newValue in
124+
// Recalculate max sendable amount when switching wallet types
125+
if newValue == .onchain {
126+
Task {
127+
await calculateMaxSendableAmount()
128+
}
129+
} else {
130+
maxSendableAmount = nil
131+
}
100132
}
101133
}
102134

103135
private func onContinue() async {
104136
do {
105137
wallet.sendAmountSats = amountSats
138+
wallet.isMaxAmountSend = isMaxAmountSend
106139

107140
// Lightning tx
108141
if app.selectedWalletToPayFrom == .lightning {
@@ -162,6 +195,30 @@ struct SendAmountView: View {
162195
app.toast(type: .error, title: "Send Error", description: error.localizedDescription)
163196
}
164197
}
198+
199+
private func calculateMaxSendableAmount() async {
200+
// Make sure we have everything we need to calculate the max sendable amount
201+
guard app.selectedWalletToPayFrom == .onchain else { return }
202+
guard let address = app.scannedOnchainInvoice?.address else { return }
203+
guard let feeRate = wallet.selectedFeeRateSatsPerVByte else { return }
204+
205+
do {
206+
let maxAmount = try await wallet.calculateMaxSendableAmount(
207+
address: address,
208+
satsPerVByte: feeRate
209+
)
210+
211+
await MainActor.run {
212+
maxSendableAmount = maxAmount
213+
}
214+
} catch {
215+
Logger.error("Failed to calculate max sendable amount: \(error)")
216+
await MainActor.run {
217+
// Fall back to total balance if calculation fails
218+
maxSendableAmount = UInt64(wallet.spendableOnchainBalanceSats)
219+
}
220+
}
221+
}
165222
}
166223

167224
#Preview {

Bitkit/Views/Wallets/Send/SendConfirmationView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ struct SendConfirmationView: View {
272272
navigationPath.append(.success(paymentHash))
273273
} else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice {
274274
let amount = wallet.sendAmountSats ?? invoice.amountSatoshis
275-
let txid = try await wallet.send(address: invoice.address, sats: amount)
275+
let txid = try await wallet.send(address: invoice.address, sats: amount, isMaxAmount: wallet.isMaxAmountSend)
276276

277277
// Set the amount for the success screen
278278
wallet.sendAmountSats = amount

0 commit comments

Comments
 (0)