From c72b74515cffe493c7bd0f06615df5e3c2988076 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 6 Apr 2026 02:45:51 +0200 Subject: [PATCH 1/4] 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 000000000..9098d85e0 --- /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 707375653..84b351a5a 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 21f94f3a5..48d7c8b78 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 65da93670..450c5b095 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 37eb0b9cb..0b40992fc 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 97196d437..a0ac94b3d 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 bd44c37fe..d4b625661 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 2/4] 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 db782f90a..36c9892b6 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 3/4] 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 027becdfe..85047f1bb 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 4/4] 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 3ee1421a2..cc430dc6d 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()