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) 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( 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 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()