From f7fc9ae349961848a5c86c8d43f0a3c8a2c4d24f Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 1 Apr 2026 13:22:52 +0300 Subject: [PATCH 01/15] fix: avoid msat truncation when paying invoices with built-in amounts Bump bitkit-core to v0.1.56 which rounds up sub-satoshi invoice amounts. Additionally, stop overriding the amount for invoices that already have one. Pass sats: nil so LDK uses the invoice's native msat precision instead of our truncated sats value converted back to msat. Only pass sats for zero-amount invoices where the user specifies the amount. Closes #511 --- Bitkit.xcodeproj/project.pbxproj | 4 ++-- .../xcshareddata/swiftpm/Package.resolved | 3 +-- Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift | 4 +++- Bitkit/Views/Wallets/Send/SendConfirmationView.swift | 9 +++++++-- Bitkit/Views/Wallets/Send/SendQuickpay.swift | 5 +++-- 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 05748e0d..c52a179b 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -937,8 +937,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { - branch = master; - kind = branch; + kind = revision; + revision = 99bd86bb60c1f14e8ce8a6356cd2ab36f222fc69; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b1ca3574..3eca56f8 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "branch" : "master", - "revision" : "76a63a2654f717accde5268905897b73e4f7d3c4" + "revision" : "99bd86bb60c1f14e8ce8a6356cd2ab36f222fc69" } }, { diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index 5c823477..312eb2ec 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -220,9 +220,11 @@ struct LnurlPayConfirm: View { do { // Perform the Lightning payment (10s timeout → navigate to pending for hold invoices) + // LNURL server returns invoices with the amount baked in, so pass sats: nil + // to let LDK use the invoice's native millisatoshi precision. try await wallet.sendWithTimeout( bolt11: bolt11, - sats: wallet.sendAmountSats, + sats: nil, onTimeout: { app.addPendingPaymentHash(paymentHash) navigationPath.append(.pending(paymentHash: paymentHash)) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index d01b9330..585b1fa2 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -480,10 +480,13 @@ struct SendConfirmationView: View { await createPreActivityMetadata(paymentId: paymentHash, paymentHash: paymentHash) // Perform the Lightning payment (10s timeout → navigate to pending for hold invoices) + // For invoices with a built-in amount, pass sats: nil so LDK uses the invoice's + // native millisatoshi precision instead of our truncated satoshi value. + let paymentSats: UInt64? = invoice.amountSatoshis == 0 ? amount : nil do { try await wallet.sendWithTimeout( bolt11: invoice.bolt11, - sats: amount, + sats: paymentSats, onTimeout: { app.addPendingPaymentHash(paymentHash) navigationPath.append(.pending(paymentHash: paymentHash)) @@ -790,7 +793,9 @@ struct SendConfirmationView: View { } if canSwitchWallet || app.selectedWalletToPayFrom == .lightning { - await wallet.refreshRoutingFeeEstimate(bolt11: bolt11, amountSats: wallet.sendAmountSats) + // For invoices with a built-in amount, pass nil so LDK uses native msat precision + let amountSats: UInt64? = app.scannedLightningInvoice?.amountSatoshis == 0 ? wallet.sendAmountSats : nil + await wallet.refreshRoutingFeeEstimate(bolt11: bolt11, amountSats: amountSats) } else { wallet.routingFeeEstimateSats = 0 } diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index 24e0fdbc..a9311c82 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -110,12 +110,13 @@ struct SendQuickpay: View { let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) let paymentHash = String(describing: parsedInvoice.paymentHash()) - let amount = wallet.sendAmountSats do { + // Quickpay only triggers for invoices with built-in amounts, so pass sats: nil + // to let LDK use the invoice's native millisatoshi precision. try await wallet.sendWithTimeout( bolt11: bolt11, - sats: amount, + sats: nil, onTimeout: { app.addPendingPaymentHash(paymentHash) navigationPath.append(.pending(paymentHash: paymentHash)) From 548dcb11ae91b91a14c3a8e97725eee17f919f5a Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 1 Apr 2026 15:10:31 +0300 Subject: [PATCH 02/15] chore: add changelog entry for msat fix Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72c889ad..7219f879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,4 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Avoid msat truncation when paying invoices with built-in amounts #512 + [Unreleased]: https://github.com/synonymdev/bitkit-ios/compare/v2.1.2...HEAD From 617ecdcc71c95259ccdd718f7a48559ddc7c85ce Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 3 Apr 2026 09:25:41 +0300 Subject: [PATCH 03/15] fix: preserve msat precision for LNURL pay and withdraw callbacks LNURL protocol uses millisatoshis, but the app was normalizing msat values to sats in-place on the data structs. When those sats were later converted back to msats for callbacks, the fractional part was lost (e.g. 500500 msat -> 501 sats -> 501000 msat, rejected by server). - Stop normalizing LNURL data structs in-place; keep original msat values - Change fetchLnurlInvoice to accept amountMsats directly - Convert to sats only for display (floor) and validation (ceil for min) - For fixed-amount LNURL-pay, pass original msat value to callback - For fixed-amount LNURL-withdraw, use floor(msats/1000) for invoice amount Co-Authored-By: Claude Opus 4.6 (1M context) --- Bitkit/Utilities/Lnurl.swift | 6 +++--- Bitkit/ViewModels/AppViewModel.swift | 21 ++++--------------- .../LnurlWithdraw/LnurlWithdrawAmount.swift | 5 +++-- .../LnurlWithdraw/LnurlWithdrawConfirm.swift | 6 +++--- .../Views/Wallets/Send/LnurlPayAmount.swift | 8 +++---- .../Views/Wallets/Send/LnurlPayConfirm.swift | 10 ++++++--- Bitkit/Views/Wallets/Send/SendQuickpay.swift | 8 +++---- 7 files changed, 27 insertions(+), 37 deletions(-) diff --git a/Bitkit/Utilities/Lnurl.swift b/Bitkit/Utilities/Lnurl.swift index db72554c..bdff5059 100644 --- a/Bitkit/Utilities/Lnurl.swift +++ b/Bitkit/Utilities/Lnurl.swift @@ -123,17 +123,17 @@ struct LnurlHelper { /// Fetches a Lightning invoice from an LNURL pay callback /// - Parameters: /// - callbackUrl: The LNURL callback URL - /// - amount: The amount in satoshis to pay + /// - amountMsats: The amount in millisatoshis to pay /// - comment: Optional comment to include with the payment /// - Returns: The bolt11 invoice string /// - Throws: Network or parsing errors static func fetchLnurlInvoice( callbackUrl: String, - amount: UInt64, + amountMsats: UInt64, comment: String? = nil ) async throws -> String { var queryItems = [ - URLQueryItem(name: "amount", value: String(amount * 1000)), // Convert to millisatoshis + URLQueryItem(name: "amount", value: String(amountMsats)), ] // Add comment if provided diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 998f2036..c5421980 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -480,19 +480,14 @@ extension AppViewModel { } private func handleLnurlPayInvoice(_ data: LnurlPayData) { - // Check if lightning service is running guard lightningService.status?.isRunning == true else { toast(type: .error, title: "Lightning not running", description: "Please try again later.") return } - var normalizedData = data - normalizedData.minSendable = max(1, LightningAmountConversion.satsCeil(fromMsats: normalizedData.minSendable)) - normalizedData.maxSendable = max(normalizedData.minSendable, LightningAmountConversion.satsFloor(fromMsats: normalizedData.maxSendable)) - - // Check if user has enough lightning balance to pay the minimum amount + let minSats = max(1, LightningAmountConversion.satsCeil(fromMsats: data.minSendable)) let lightningBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 - if lightningBalance < normalizedData.minSendable { + if lightningBalance < minSats { toast( type: .warning, title: t("other__lnurl_pay_error"), @@ -502,11 +497,10 @@ extension AppViewModel { } selectedWalletToPayFrom = .lightning - lnurlPayData = normalizedData + lnurlPayData = data } private func handleLnurlWithdraw(_ data: LnurlWithdrawData) { - // Check if lightning service is running guard lightningService.status?.isRunning == true else { toast(type: .error, title: "Lightning not running", description: "Please try again later.") return @@ -515,7 +509,6 @@ extension AppViewModel { let minMsats = data.minWithdrawable ?? Env.msatsPerSat let maxMsats = data.maxWithdrawable - // Check if minWithdrawable > maxWithdrawable if minMsats > maxMsats { toast( type: .warning, @@ -525,13 +518,7 @@ extension AppViewModel { return } - var normalizedData = data let minSats = max(1, LightningAmountConversion.satsCeil(fromMsats: minMsats)) - let maxSats = max(minSats, LightningAmountConversion.satsFloor(fromMsats: maxMsats)) - normalizedData.minWithdrawable = minSats - normalizedData.maxWithdrawable = maxSats - - // Check if we have enough receiving capacity let lightningBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 if lightningBalance < minSats { toast( @@ -542,7 +529,7 @@ extension AppViewModel { return } - lnurlWithdrawData = normalizedData + lnurlWithdrawData = data } private func handleLnurlChannel(_ data: LnurlChannelData) { diff --git a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift index ba2c91df..65da9367 100644 --- a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift +++ b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift @@ -9,11 +9,12 @@ struct LnurlWithdrawAmount: View { @StateObject private var amountViewModel = AmountInputViewModel() var minAmount: Int { - Int(app.lnurlWithdrawData!.minWithdrawable ?? 1) + let minMsats = app.lnurlWithdrawData!.minWithdrawable ?? Env.msatsPerSat + return Int(max(1, LightningAmountConversion.satsCeil(fromMsats: minMsats))) } var maxAmount: Int { - Int(app.lnurlWithdrawData!.maxWithdrawable) + Int(LightningAmountConversion.satsFloor(fromMsats: app.lnurlWithdrawData!.maxWithdrawable)) } var amount: UInt64 { diff --git a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift index e90f3889..ae3c1d67 100644 --- a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift +++ b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift @@ -9,12 +9,12 @@ struct LnurlWithdrawConfirm: View { @State private var isLoading = false var amount: UInt64 { - // Fixed amount + // Fixed amount: floor ensures the invoice doesn't exceed the server's max if app.lnurlWithdrawData!.maxWithdrawable == app.lnurlWithdrawData!.minWithdrawable { - return app.lnurlWithdrawData!.maxWithdrawable + return LightningAmountConversion.satsFloor(fromMsats: app.lnurlWithdrawData!.maxWithdrawable) } - // For variable amount, use the amount from the previous screen + // For variable amount, use the amount from the previous screen (already in sats) return wallet.lnurlWithdrawAmount! } diff --git a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift index a3d4ed33..97196d43 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift @@ -11,7 +11,7 @@ struct LnurlPayAmount: View { var maxAmount: UInt64 { // TODO: subtract fee - min(app.lnurlPayData!.maxSendable, UInt64(wallet.totalLightningSats)) + min(LightningAmountConversion.satsFloor(fromMsats: app.lnurlPayData!.maxSendable), UInt64(wallet.totalLightningSats)) } var amount: UInt64 { @@ -80,12 +80,12 @@ struct LnurlPayAmount: View { } private func onContinue() { - let minSendable = app.lnurlPayData!.minSendable + let minSendableSats = max(1, LightningAmountConversion.satsCeil(fromMsats: app.lnurlPayData!.minSendable)) - if amount < minSendable { + if amount < minSendableSats { app.toast( type: .error, title: t("wallet__lnurl_pay__error_min__title"), - description: t("wallet__lnurl_pay__error_min__description", variables: ["amount": "\(minSendable)"]), + description: t("wallet__lnurl_pay__error_min__description", variables: ["amount": "\(minSendableSats)"]), accessibilityIdentifier: "LnurlPayAmountTooLowToast" ) return diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index 94204286..b7de19f3 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -29,7 +29,7 @@ struct LnurlPayConfirm: View { VStack(alignment: .leading) { MoneyStack( - sats: Int(wallet.sendAmountSats ?? app.lnurlPayData!.minSendable), + sats: Int(wallet.sendAmountSats ?? LightningAmountConversion.satsFloor(fromMsats: app.lnurlPayData!.minSendable)), showSymbol: true, testIdPrefix: "ReviewAmount" ) @@ -186,12 +186,16 @@ struct LnurlPayConfirm: View { throw NSError(domain: "LNURL", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing LNURL pay data"]) } - let amount = wallet.sendAmountSats ?? lnurlPayData.minSendable + let amountMsats: UInt64 = if let userSats = wallet.sendAmountSats { + userSats * 1000 + } else { + lnurlPayData.minSendable + } // Fetch the Lightning invoice from LNURL let bolt11 = try await LnurlHelper.fetchLnurlInvoice( callbackUrl: lnurlPayData.callback, - amount: amount, + amountMsats: amountMsats, comment: comment.isEmpty ? nil : comment ) diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index a9311c82..d9910bd4 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -88,14 +88,12 @@ struct SendQuickpay: View { // Handle LNURL Pay if let lnurlPayData = app.lnurlPayData { - let amount = lnurlPayData.minSendable - - // Set the amount for the success screen - wallet.sendAmountSats = amount + // Set the amount in sats for the success screen + wallet.sendAmountSats = LightningAmountConversion.satsFloor(fromMsats: lnurlPayData.minSendable) bolt11Invoice = try await LnurlHelper.fetchLnurlInvoice( callbackUrl: lnurlPayData.callback, - amount: amount + amountMsats: lnurlPayData.minSendable ) } else if let scannedInvoice = app.scannedLightningInvoice { wallet.sendAmountSats = scannedInvoice.amountSatoshis From 0723c7e983eb344053181e43d191eaa527f30864 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 3 Apr 2026 09:35:08 +0300 Subject: [PATCH 04/15] chore: update changelog with LNURL msat fix Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7219f879..93b28814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- Preserve msat precision for LNURL pay and withdraw callbacks #512 - Avoid msat truncation when paying invoices with built-in amounts #512 [Unreleased]: https://github.com/synonymdev/bitkit-ios/compare/v2.1.2...HEAD From ef88089ced8f89992daf0a842b85679fb5616d9a Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 3 Apr 2026 10:45:05 +0300 Subject: [PATCH 05/15] fix: use msat-precision invoices for fixed-amount LNURL withdraw For LNURL-withdraw with sub-sat precision (e.g. 222538 msat), neither floor (222 sats) nor ceiling (223 sats) matches the server's exact amount range. Add receiveMsats/createInvoiceMsats to create invoices with native msat precision, used for fixed-amount LNURL withdrawals. Co-Authored-By: Claude Opus 4.6 (1M context) --- Bitkit/Services/LightningService.swift | 8 +++-- Bitkit/ViewModels/WalletViewModel.swift | 6 ++++ .../LnurlWithdraw/LnurlWithdrawConfirm.swift | 34 ++++++++++++------- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 15c9c213..899f7b1e 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -499,16 +499,20 @@ class LightningService { } func receive(amountSats: UInt64? = nil, description: String, expirySecs: UInt32 = 3600) async throws -> String { + try await receiveMsats(amountMsats: amountSats.map { $0 * 1000 }, description: description, expirySecs: expirySecs) + } + + func receiveMsats(amountMsats: UInt64? = nil, description: String, expirySecs: UInt32 = 3600) async throws -> String { guard let node else { throw AppError(serviceError: .nodeNotSetup) } let bolt11 = try await ServiceQueue.background(.ldk) { - if let amountSats { + if let amountMsats { try node .bolt11Payment() .receive( - amountMsat: amountSats * 1000, + amountMsat: amountMsats, description: Bolt11InvoiceDescription.direct(description: description), expirySecs: expirySecs ) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index ea8b0f2f..ac3b3057 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -363,6 +363,12 @@ class WalletViewModel: ObservableObject { return invoice.lowercased() } + func createInvoiceMsats(amountMsats: UInt64, note: String, expirySecs: UInt32? = nil) async throws -> String { + let finalExpirySecs = expirySecs ?? 60 * 60 * 24 + let invoice = try await lightningService.receiveMsats(amountMsats: amountMsats, description: note, expirySecs: finalExpirySecs) + return invoice.lowercased() + } + @discardableResult func waitForNodeToRun(timeoutSeconds: Double = 10.0) async -> Bool { guard nodeLifecycleState != .running else { return true } diff --git a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift index ae3c1d67..4af80f12 100644 --- a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift +++ b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift @@ -8,13 +8,14 @@ struct LnurlWithdrawConfirm: View { let onFailure: (UInt64) -> Void @State private var isLoading = false - var amount: UInt64 { - // Fixed amount: floor ensures the invoice doesn't exceed the server's max - if app.lnurlWithdrawData!.maxWithdrawable == app.lnurlWithdrawData!.minWithdrawable { + var isFixedAmount: Bool { + app.lnurlWithdrawData!.maxWithdrawable == app.lnurlWithdrawData!.minWithdrawable + } + + var displayAmountSats: UInt64 { + if isFixedAmount { return LightningAmountConversion.satsFloor(fromMsats: app.lnurlWithdrawData!.maxWithdrawable) } - - // For variable amount, use the amount from the previous screen (already in sats) return wallet.lnurlWithdrawAmount! } @@ -22,7 +23,7 @@ struct LnurlWithdrawConfirm: View { VStack(spacing: 0) { SheetHeader(title: t("wallet__lnurl_w_title"), showBackButton: true) - MoneyStack(sats: Int(amount), showSymbol: true, testIdPrefix: "WithdrawAmount") + MoneyStack(sats: Int(displayAmountSats), showSymbol: true, testIdPrefix: "WithdrawAmount") .padding(.top, 16) .padding(.bottom, 42) @@ -58,12 +59,19 @@ struct LnurlWithdrawConfirm: View { throw NSError(domain: "LNURL", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing LNURL withdraw data"]) } - // Create a Lightning invoice for the withdraw - let invoice = try await wallet.createInvoice( - amountSats: amount, - note: withdrawData.defaultDescription, - expirySecs: 3600 - ) + let invoice: String = if isFixedAmount { + try await wallet.createInvoiceMsats( + amountMsats: withdrawData.maxWithdrawable, + note: withdrawData.defaultDescription, + expirySecs: 3600 + ) + } else { + try await wallet.createInvoice( + amountSats: displayAmountSats, + note: withdrawData.defaultDescription, + expirySecs: 3600 + ) + } // Perform the LNURL withdraw try await LnurlHelper.handleLnurlWithdraw( @@ -84,7 +92,7 @@ struct LnurlWithdrawConfirm: View { } catch { await MainActor.run { - onFailure(amount) + onFailure(displayAmountSats) isLoading = false } } From 851826fae556a994aade22cd3e01ad0e59615cd2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 3 Apr 2026 18:02:54 +0300 Subject: [PATCH 06/15] fix: use satsCeil for LNURL display amounts and revert LDK Node rev Use ceiling division for LNURL display amounts to match BOLT11 behavior. Previously LNURL-pay showed 222 sats on review but 223 after sending, and LNURL-withdraw showed 222 while BOLT11 showed 223 for the same 222222 msat amount. Also revert the LDK Node revision to the master-pinned version (c5698d00) which was inadvertently changed during package resolution. This fixes the BitkitNotification linker failure. Co-Authored-By: Claude Opus 4.6 (1M context) --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift | 2 +- Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift | 2 +- Bitkit/Views/Wallets/Send/SendQuickpay.swift | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index c52a179b..a16a01e2 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -930,7 +930,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = revision; - revision = ae38eadab70fceb5dbe242bc02bf895581cb7c3f; + revision = c5698d00066e0e50f33696afc562d71023da2373; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3eca56f8..f3c26d48 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,7 +23,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "ae38eadab70fceb5dbe242bc02bf895581cb7c3f" + "revision" : "c5698d00066e0e50f33696afc562d71023da2373" } }, { diff --git a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift index 4af80f12..37eb0b9c 100644 --- a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift +++ b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift @@ -14,7 +14,7 @@ struct LnurlWithdrawConfirm: View { var displayAmountSats: UInt64 { if isFixedAmount { - return LightningAmountConversion.satsFloor(fromMsats: app.lnurlWithdrawData!.maxWithdrawable) + return LightningAmountConversion.satsCeil(fromMsats: app.lnurlWithdrawData!.maxWithdrawable) } return wallet.lnurlWithdrawAmount! } diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index b7de19f3..bd44c37f 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -29,7 +29,7 @@ struct LnurlPayConfirm: View { VStack(alignment: .leading) { MoneyStack( - sats: Int(wallet.sendAmountSats ?? LightningAmountConversion.satsFloor(fromMsats: app.lnurlPayData!.minSendable)), + sats: Int(wallet.sendAmountSats ?? LightningAmountConversion.satsCeil(fromMsats: app.lnurlPayData!.minSendable)), showSymbol: true, testIdPrefix: "ReviewAmount" ) diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index d9910bd4..db782f90 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -89,7 +89,7 @@ struct SendQuickpay: View { // Handle LNURL Pay if let lnurlPayData = app.lnurlPayData { // Set the amount in sats for the success screen - wallet.sendAmountSats = LightningAmountConversion.satsFloor(fromMsats: lnurlPayData.minSendable) + wallet.sendAmountSats = LightningAmountConversion.satsCeil(fromMsats: lnurlPayData.minSendable) bolt11Invoice = try await LnurlHelper.fetchLnurlInvoice( callbackUrl: lnurlPayData.callback, From 91a35c576a426d032b6e8beac691799d92e918dc Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 5 Apr 2026 08:17:46 +0300 Subject: [PATCH 07/15] fix: use ceiling division for PaymentDetails.amountSats Activity list showed 222 sats while review screen showed 223 for a 222222 msat payment. The PaymentDetails extension used floor division (amountMsat / 1000). Use satsCeil to match all other display paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- Bitkit/Extensions/PaymentDetails.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/Extensions/PaymentDetails.swift b/Bitkit/Extensions/PaymentDetails.swift index 9798f929..4889b76b 100644 --- a/Bitkit/Extensions/PaymentDetails.swift +++ b/Bitkit/Extensions/PaymentDetails.swift @@ -4,7 +4,7 @@ import LDKNode extension PaymentDetails { var amountSats: UInt64? { if let amountMsat { - return amountMsat / 1000 + return LightningAmountConversion.satsCeil(fromMsats: amountMsat) } return nil From 36af4dafbd9ba42a5498706b82183f76c616397b Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 5 Apr 2026 08:36:16 +0300 Subject: [PATCH 08/15] fix: use ceiling for received payment notification amount The received transaction sheet used floor division for the msat amount. Co-Authored-By: Claude Opus 4.6 (1M context) --- Bitkit/ViewModels/AppViewModel.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index c5421980..21f94f3a 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -726,7 +726,8 @@ extension AppViewModel { } await MainActor.run { - sheetViewModel.showSheet(.receivedTx, data: ReceivedTxSheetDetails(type: .lightning, sats: amountMsat / 1000)) + let sats = LightningAmountConversion.satsCeil(fromMsats: amountMsat) + sheetViewModel.showSheet(.receivedTx, data: ReceivedTxSheetDetails(type: .lightning, sats: sats)) } } case .channelPending(channelId: _, userChannelId: _, formerTemporaryChannelId: _, counterpartyNodeId: _, fundingTxo: _): From fd1d1f88a16d0f8e1ffd93910b7261b19b693ff9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 5 Apr 2026 23:27:56 +0200 Subject: [PATCH 09/15] fix: restore LDK Node rev to v0.7.0-rc.36 and add LightningAmountConversion to notification target The previous commit (851826fa) reverted the LDK Node pin to c5698d0, thinking it was the master-pinned version. But master had already moved to ae38ead (v0.7.0-rc.36) via PR #502 (stale monitor recovery), which added BuildError.DangerousValue. This caused three build errors: - "Type 'BuildError' has no member 'DangerousValue'" (Bitkit, BitkitNotification, BitkitTests) - "Cannot find 'LightningAmountConversion' in scope" (BitkitNotification) The revision was hardcoded in the pbxproj package requirement (not just Package.resolved), so Xcode kept re-resolving to the old version on every build. Additionally, the project had two conflicting ldk-node package references: lightningdevkit/ldk-node (tracking main branch) and synonymdev/ldk-node (pinned revision). Both shared the same SPM identity, causing resolution failures after SPM cache resets. Fixes: - Update ldk-node revision in pbxproj to ae38ead (v0.7.0-rc.36) - Add LightningAmountConversion.swift to BitkitNotification target (PaymentDetails.swift was already in that target but referenced it) - Remove stale lightningdevkit/ldk-node package reference and rewire Bitkit target to use synonymdev/ldk-node (single source of truth) Co-Authored-By: Claude Opus 4.6 (1M context) --- Bitkit.xcodeproj/project.pbxproj | 13 +++---------- .../xcshareddata/swiftpm/Package.resolved | 2 +- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index a16a01e2..fab1389a 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -123,6 +123,7 @@ Utilities/Crypto.swift, Utilities/Errors.swift, Utilities/Keychain.swift, + Utilities/LightningAmountConversion.swift, Utilities/Logger.swift, Utilities/StateLocker.swift, ); @@ -917,20 +918,12 @@ minimumVersion = 2.1.2; }; }; - 962045C92DE998F1007BAA26 /* XCRemoteSwiftPackageReference "ldk-node" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/lightningdevkit/ldk-node"; - requirement = { - branch = main; - kind = branch; - }; - }; 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = revision; - revision = c5698d00066e0e50f33696afc562d71023da2373; + revision = ae38eadab70fceb5dbe242bc02bf895581cb7c3f; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { @@ -987,7 +980,7 @@ }; 9613018B2C5022D700878183 /* LDKNode */ = { isa = XCSwiftPackageProductDependency; - package = 962045C92DE998F1007BAA26 /* XCRemoteSwiftPackageReference "ldk-node" */; + package = 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */; productName = LDKNode; }; 968FE13F2DFB016B0053CD7F /* LDKNode */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f3c26d48..3eca56f8 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,7 +23,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "c5698d00066e0e50f33696afc562d71023da2373" + "revision" : "ae38eadab70fceb5dbe242bc02bf895581cb7c3f" } }, { From ec303d7008fe8865990d52bb05fd265be6318f7e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 6 Apr 2026 01:43:03 +0200 Subject: [PATCH 10/15] fix: link CoreBluetooth for BitkitCore iOS builds --- Bitkit.xcodeproj/project.pbxproj | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index fab1389a..73bf793f 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 18D65E002EB964B500252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65DFF2EB964B500252335 /* VssRustClientFfi */; }; 18D65E022EB964BD00252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65E012EB964BD00252335 /* VssRustClientFfi */; }; + 3D76260F4C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */; }; + 3D7626104C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */; }; 4AAB08CA2E1FE77600BA63DF /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 4AAB08C92E1FE77600BA63DF /* Lottie */; }; 4AFCA3702E05933800205CAE /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = 4AFCA36F2E05933800205CAE /* Zip */; }; 4AFCA3722E0596D900205CAE /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = 4AFCA3712E0596D900205CAE /* Zip */; }; @@ -62,6 +64,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBluetooth.framework; path = System/Library/Frameworks/CoreBluetooth.framework; sourceTree = SDKROOT; }; 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BitkitNotification.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 96FE1F612C2DE6AA006D0C8B /* Bitkit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bitkit.app; sourceTree = BUILT_PRODUCTS_DIR; }; 96FE1F722C2DE6AC006D0C8B /* BitkitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BitkitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -150,6 +153,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3D7626104C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */, 968FE1402DFB016B0053CD7F /* LDKNode in Frameworks */, 96DEA03C2DE8BBAB009932BF /* BitkitCore in Frameworks */, 4AFCA3722E0596D900205CAE /* Zip in Frameworks */, @@ -162,6 +166,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3D76260F4C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */, 4AFCA3702E05933800205CAE /* Zip in Frameworks */, 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */, 18D65E002EB964B500252335 /* VssRustClientFfi in Frameworks */, @@ -193,6 +198,7 @@ 961058EC2C35798C00E1F1D8 /* Frameworks */ = { isa = PBXGroup; children = ( + 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */, ); name = Frameworks; sourceTree = ""; From 6bf77732d848fb712a2dccdb318db2f2bda7e1c2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 6 Apr 2026 03:16:27 +0200 Subject: [PATCH 11/15] fix: add LightningAmountConversion to BitkitTests target PaymentDetails.swift references LightningAmountConversion and is included in the BitkitTests target, but the utility file itself was missing from that target's membership exceptions. Co-Authored-By: Claude Opus 4.6 (1M context) --- Bitkit.xcodeproj/project.pbxproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 73bf793f..387718ff 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -103,6 +103,7 @@ Utilities/Crypto.swift, Utilities/Errors.swift, Utilities/Keychain.swift, + Utilities/LightningAmountConversion.swift, Utilities/Logger.swift, Utilities/StateLocker.swift, ); From c72b74515cffe493c7bd0f06615df5e3c2988076 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 6 Apr 2026 02:45:51 +0200 Subject: [PATCH 12/15] fix: add LNURL amount extensions and fix sub-sat rounding checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port Android's LNURL amount helpers (isFixedAmount, callbackAmountMsats, minSendableSat, maxSendableSat, etc.) to iOS via extensions on LnurlPayData and LnurlWithdrawData. This fixes sub-sat edge cases where min/max msat values map to an inverted sat range after rounding (e.g. min=222222, max=222538 → minSat=223, maxSat=222). These are now correctly treated as fixed-amount requests and routed to the confirm screen instead of the amount picker. Also fixes LNURL quickpay guard that required scannedLightningInvoice (always nil for LNURL pay flows), and centralizes all msat→sat conversions for LNURL amounts in the new extension file. Co-Authored-By: Claude Opus 4.6 (1M context) --- Bitkit/Extensions/LnurlPayData+Amount.swift | 49 +++++++++++++++++++ .../Utilities/PaymentNavigationHelper.swift | 15 +++--- Bitkit/ViewModels/AppViewModel.swift | 11 ++--- .../LnurlWithdraw/LnurlWithdrawAmount.swift | 7 ++- .../LnurlWithdraw/LnurlWithdrawConfirm.swift | 10 ++-- .../Views/Wallets/Send/LnurlPayAmount.swift | 4 +- .../Views/Wallets/Send/LnurlPayConfirm.swift | 8 +-- 7 files changed, 69 insertions(+), 35 deletions(-) create mode 100644 Bitkit/Extensions/LnurlPayData+Amount.swift diff --git a/Bitkit/Extensions/LnurlPayData+Amount.swift b/Bitkit/Extensions/LnurlPayData+Amount.swift new file mode 100644 index 00000000..9098d85e --- /dev/null +++ b/Bitkit/Extensions/LnurlPayData+Amount.swift @@ -0,0 +1,49 @@ +import BitkitCore + +extension LnurlPayData { + var minSendableSat: UInt64 { + LightningAmountConversion.satsCeil(fromMsats: minSendable) + } + + var maxSendableSat: UInt64 { + LightningAmountConversion.satsFloor(fromMsats: maxSendable) + } + + /// True when the LNURL-pay endpoint specifies a single exact amount. + /// + /// Also covers the sub-sat edge case where `minSendable` and `maxSendable` + /// differ in their sub-sat fraction but map to the same (or inverted) sat + /// range after rounding, e.g. `min=222222, max=222538` → `minSat=223, maxSat=222`. + var isFixedAmount: Bool { + minSendable == maxSendable || (minSendable > 0 && minSendableSat > maxSendableSat) + } + + /// Returns the amount in millisatoshis for the LNURL-pay callback. + /// + /// For fixed-amount requests the original msat value is returned verbatim, + /// avoiding precision loss from the msat→sat→msat round-trip. + /// For variable-amount requests the user-selected sat amount is converted to msats. + func callbackAmountMsats(userSats: UInt64? = nil) -> UInt64 { + if isFixedAmount { + return minSendable + } + return (userSats ?? minSendableSat) * Env.msatsPerSat + } +} + +extension LnurlWithdrawData { + var minWithdrawableSat: UInt64 { + LightningAmountConversion.satsCeil(fromMsats: minWithdrawable ?? 0) + } + + var maxWithdrawableSat: UInt64 { + LightningAmountConversion.satsFloor(fromMsats: maxWithdrawable) + } + + /// True when the LNURL-withdraw endpoint specifies a single exact amount, + /// including the sub-sat edge case where rounding causes `min > max` in whole sats. + var isFixedAmount: Bool { + let min = minWithdrawable ?? 0 + return min == maxWithdrawable || (min > 0 && minWithdrawableSat > maxWithdrawableSat) + } +} diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index 70737565..84b351a5 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -19,8 +19,8 @@ struct PaymentNavigationHelper { return false } - // We need a lightning invoice to use quickpay - guard app.scannedLightningInvoice != nil else { + // We need a lightning invoice or LNURL pay data to use quickpay + guard app.scannedLightningInvoice != nil || app.lnurlPayData != nil else { return false } @@ -32,8 +32,7 @@ struct PaymentNavigationHelper { // Check LNURL pay if let lnurlPayData = app.lnurlPayData { - // For LNURL pay, check if it's a fixed amount and within quickpay threshold - return lnurlPayData.minSendable == lnurlPayData.maxSendable && lnurlPayData.minSendable <= quickpayAmountSats + return lnurlPayData.isFixedAmount && lnurlPayData.minSendableSat <= quickpayAmountSats } // Check regular lightning invoice @@ -50,7 +49,7 @@ struct PaymentNavigationHelper { // Handle LNURL withdraw if let lnurlWithdrawData = app.lnurlWithdrawData { Logger.info("LNURL withdraw data: \(lnurlWithdrawData)") - if lnurlWithdrawData.minWithdrawable == lnurlWithdrawData.maxWithdrawable { + if lnurlWithdrawData.isFixedAmount { sheetViewModel.showSheet(.lnurlWithdraw, data: LnurlWithdrawConfig(view: .confirm)) } else { sheetViewModel.showSheet(.lnurlWithdraw, data: LnurlWithdrawConfig(view: .amount)) @@ -64,7 +63,7 @@ struct PaymentNavigationHelper { if let lnurlPayData = app.lnurlPayData { if shouldUseQuickpay { sheetViewModel.showSheet(.send, data: SendConfig(view: .quickpay)) - } else if lnurlPayData.minSendable == lnurlPayData.maxSendable { + } else if lnurlPayData.isFixedAmount { sheetViewModel.showSheet(.send, data: SendConfig(view: .lnurlPayConfirm)) } else { sheetViewModel.showSheet(.send, data: SendConfig(view: .lnurlPayAmount)) @@ -104,7 +103,7 @@ struct PaymentNavigationHelper { settings: SettingsViewModel ) -> SendRoute? { if let lnurlWithdrawData = app.lnurlWithdrawData { - if lnurlWithdrawData.minWithdrawable == lnurlWithdrawData.maxWithdrawable { + if lnurlWithdrawData.isFixedAmount { return .lnurlWithdrawConfirm } else { return .lnurlWithdrawAmount @@ -117,7 +116,7 @@ struct PaymentNavigationHelper { if let lnurlPayData = app.lnurlPayData { if shouldUseQuickpay { return .quickpay - } else if lnurlPayData.minSendable == lnurlPayData.maxSendable { + } else if lnurlPayData.isFixedAmount { return .lnurlPayConfirm } else { return .lnurlPayAmount diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 21f94f3a..48d7c8b7 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -485,9 +485,8 @@ extension AppViewModel { return } - let minSats = max(1, LightningAmountConversion.satsCeil(fromMsats: data.minSendable)) let lightningBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 - if lightningBalance < minSats { + if lightningBalance < max(1, data.minSendableSat) { toast( type: .warning, title: t("other__lnurl_pay_error"), @@ -506,10 +505,7 @@ extension AppViewModel { return } - let minMsats = data.minWithdrawable ?? Env.msatsPerSat - let maxMsats = data.maxWithdrawable - - if minMsats > maxMsats { + if (data.minWithdrawable ?? 0) > data.maxWithdrawable { toast( type: .warning, title: t("other__lnurl_withdr_error"), @@ -518,9 +514,8 @@ extension AppViewModel { return } - let minSats = max(1, LightningAmountConversion.satsCeil(fromMsats: minMsats)) let lightningBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 - if lightningBalance < minSats { + if lightningBalance < max(1, data.minWithdrawableSat) { toast( type: .warning, title: t("other__lnurl_withdr_error"), diff --git a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift index 65da9367..450c5b09 100644 --- a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift +++ b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift @@ -9,12 +9,11 @@ struct LnurlWithdrawAmount: View { @StateObject private var amountViewModel = AmountInputViewModel() var minAmount: Int { - let minMsats = app.lnurlWithdrawData!.minWithdrawable ?? Env.msatsPerSat - return Int(max(1, LightningAmountConversion.satsCeil(fromMsats: minMsats))) + Int(max(1, app.lnurlWithdrawData!.minWithdrawableSat)) } var maxAmount: Int { - Int(LightningAmountConversion.satsFloor(fromMsats: app.lnurlWithdrawData!.maxWithdrawable)) + Int(app.lnurlWithdrawData!.maxWithdrawableSat) } var amount: UInt64 { @@ -22,7 +21,7 @@ struct LnurlWithdrawAmount: View { } var isValid: Bool { - amount <= maxAmount + amount >= minAmount && amount <= max(minAmount, maxAmount) } var body: some View { diff --git a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift index 37eb0b9c..0b40992f 100644 --- a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift +++ b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift @@ -8,13 +8,9 @@ struct LnurlWithdrawConfirm: View { let onFailure: (UInt64) -> Void @State private var isLoading = false - var isFixedAmount: Bool { - app.lnurlWithdrawData!.maxWithdrawable == app.lnurlWithdrawData!.minWithdrawable - } - var displayAmountSats: UInt64 { - if isFixedAmount { - return LightningAmountConversion.satsCeil(fromMsats: app.lnurlWithdrawData!.maxWithdrawable) + if app.lnurlWithdrawData!.isFixedAmount { + return app.lnurlWithdrawData!.minWithdrawableSat } return wallet.lnurlWithdrawAmount! } @@ -59,7 +55,7 @@ struct LnurlWithdrawConfirm: View { throw NSError(domain: "LNURL", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing LNURL withdraw data"]) } - let invoice: String = if isFixedAmount { + let invoice: String = if withdrawData.isFixedAmount { try await wallet.createInvoiceMsats( amountMsats: withdrawData.maxWithdrawable, note: withdrawData.defaultDescription, diff --git a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift index 97196d43..a0ac94b3 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift @@ -11,7 +11,7 @@ struct LnurlPayAmount: View { var maxAmount: UInt64 { // TODO: subtract fee - min(LightningAmountConversion.satsFloor(fromMsats: app.lnurlPayData!.maxSendable), UInt64(wallet.totalLightningSats)) + min(app.lnurlPayData!.maxSendableSat, UInt64(wallet.totalLightningSats)) } var amount: UInt64 { @@ -80,7 +80,7 @@ struct LnurlPayAmount: View { } private func onContinue() { - let minSendableSats = max(1, LightningAmountConversion.satsCeil(fromMsats: app.lnurlPayData!.minSendable)) + let minSendableSats = max(1, app.lnurlPayData!.minSendableSat) if amount < minSendableSats { app.toast( diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index bd44c37f..d4b62566 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -29,7 +29,7 @@ struct LnurlPayConfirm: View { VStack(alignment: .leading) { MoneyStack( - sats: Int(wallet.sendAmountSats ?? LightningAmountConversion.satsCeil(fromMsats: app.lnurlPayData!.minSendable)), + sats: Int(wallet.sendAmountSats ?? app.lnurlPayData!.minSendableSat), showSymbol: true, testIdPrefix: "ReviewAmount" ) @@ -186,11 +186,7 @@ struct LnurlPayConfirm: View { throw NSError(domain: "LNURL", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing LNURL pay data"]) } - let amountMsats: UInt64 = if let userSats = wallet.sendAmountSats { - userSats * 1000 - } else { - lnurlPayData.minSendable - } + let amountMsats = lnurlPayData.callbackAmountMsats(userSats: wallet.sendAmountSats) // Fetch the Lightning invoice from LNURL let bolt11 = try await LnurlHelper.fetchLnurlInvoice( From 127492950c97e4c528dba7aa60c0ef1bad913f86 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 6 Apr 2026 02:45:59 +0200 Subject: [PATCH 13/15] fix: show amount on LNURL quickpay screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quickpay view only displayed the amount for bolt11 invoices (scannedLightningInvoice). For LNURL pay, the invoice doesn't exist yet at that point — the amount comes from lnurlPayData. Co-Authored-By: Claude Opus 4.6 (1M context) --- Bitkit/Views/Wallets/Send/SendQuickpay.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index db782f90..36c9892b 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -59,7 +59,9 @@ struct SendQuickpay: View { VStack { SheetHeader(title: t("wallet__send_quickpay__nav_title")) - if let invoice = app.scannedLightningInvoice { + if let lnurlPayData = app.lnurlPayData { + MoneyStack(sats: Int(lnurlPayData.minSendableSat), showSymbol: true) + } else if let invoice = app.scannedLightningInvoice { MoneyStack(sats: Int(invoice.amountSatoshis), showSymbol: true) } @@ -89,11 +91,11 @@ struct SendQuickpay: View { // Handle LNURL Pay if let lnurlPayData = app.lnurlPayData { // Set the amount in sats for the success screen - wallet.sendAmountSats = LightningAmountConversion.satsCeil(fromMsats: lnurlPayData.minSendable) + wallet.sendAmountSats = lnurlPayData.minSendableSat bolt11Invoice = try await LnurlHelper.fetchLnurlInvoice( callbackUrl: lnurlPayData.callback, - amountMsats: lnurlPayData.minSendable + amountMsats: lnurlPayData.callbackAmountMsats() ) } else if let scannedInvoice = app.scannedLightningInvoice { wallet.sendAmountSats = scannedInvoice.amountSatoshis From e2d00561068b4e15a74b1be9eeb8720b822a0cda Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 6 Apr 2026 02:47:02 +0200 Subject: [PATCH 14/15] chore: enforce single changelog entry per PR rule Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 027becdf..85047f1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -276,7 +276,8 @@ Ensure accessibility modifiers and labels are added to custom components. ### Changelog -- ALWAYS add an entry under `## [Unreleased]` in `CHANGELOG.md` for `feat:` and `fix:` PRs; skip for `chore:`, `ci:`, `refactor:`, `test:`, `docs:` unless the change is user-facing +- ALWAYS add exactly ONE entry per PR under `## [Unreleased]` in `CHANGELOG.md` for `feat:` and `fix:` PRs; skip for `chore:`, `ci:`, `refactor:`, `test:`, `docs:` unless the change is user-facing +- NEVER add multiple changelog lines for the same PR — summarize all changes in a single concise entry - USE standard Keep a Changelog categories: `### Added`, `### Changed`, `### Deprecated`, `### Removed`, `### Fixed`, `### Security` - ALWAYS append `#PR_NUMBER` at the end of each changelog entry when the PR number is known - ALWAYS place new entries at the top of their category section (newest first) From c17ee1665a28954215ad84c017b8a6e84f0c2042 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 7 Apr 2026 13:54:33 +0200 Subject: [PATCH 15/15] fix: show send-success amount from lnurl and invoices --- Bitkit/Views/Wallets/Send/SendSuccess.swift | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendSuccess.swift b/Bitkit/Views/Wallets/Send/SendSuccess.swift index 3ee1421a..cc430dc6 100644 --- a/Bitkit/Views/Wallets/Send/SendSuccess.swift +++ b/Bitkit/Views/Wallets/Send/SendSuccess.swift @@ -13,6 +13,22 @@ struct SendSuccess: View { @State private var foundActivity: Activity? + private var successDisplaySats: Int? { + if let sendAmountSats = wallet.sendAmountSats { + return Int(sendAmountSats) + } + if let lnurlPayData = app.lnurlPayData { + return Int(lnurlPayData.minSendableSat) + } + if let invoice = app.scannedLightningInvoice, invoice.amountSatoshis > 0 { + return Int(invoice.amountSatoshis) + } + if let invoice = app.scannedOnchainInvoice, invoice.amountSatoshis > 0 { + return Int(invoice.amountSatoshis) + } + return nil + } + /// Load the confetti animation private var confettiAnimation: LottieAnimation? { let isOnchain = app.selectedWalletToPayFrom == .onchain @@ -42,8 +58,8 @@ struct SendSuccess: View { SheetHeader(title: t("wallet__send_sent"), showBackButton: false) .accessibilityIdentifier("SendSuccess") - if let sendAmountSats = wallet.sendAmountSats { - MoneyStack(sats: Int(sendAmountSats), showSymbol: true) + if let sats = successDisplaySats { + MoneyStack(sats: sats, showSymbol: true) } Spacer()