diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 9d20fc9c4..b967576a4 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -679,7 +679,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 190; + CURRENT_PROJECT_VERSION = 192; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitWidget/Info.plist; @@ -691,7 +691,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.0; + MARKETING_VERSION = 2.3.1; PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -712,7 +712,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 190; + CURRENT_PROJECT_VERSION = 192; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitWidget/Info.plist; @@ -724,7 +724,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.0; + MARKETING_VERSION = 2.3.1; PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -744,7 +744,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = BitkitNotification/BitkitNotification.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 190; + CURRENT_PROJECT_VERSION = 192; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitNotification/Info.plist; @@ -756,7 +756,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.0; + MARKETING_VERSION = 2.3.1; OTHER_LDFLAGS = ( "-framework", CoreBluetooth, @@ -776,7 +776,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = BitkitNotification/BitkitNotification.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 190; + CURRENT_PROJECT_VERSION = 192; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitNotification/Info.plist; @@ -788,7 +788,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.0; + MARKETING_VERSION = 2.3.1; OTHER_LDFLAGS = ( "-framework", CoreBluetooth, @@ -926,7 +926,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 190; + CURRENT_PROJECT_VERSION = 192; DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\""; DEVELOPMENT_TEAM = KYH47R284B; ENABLE_HARDENED_RUNTIME = YES; @@ -953,7 +953,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 2.3.0; + MARKETING_VERSION = 2.3.1; OTHER_LDFLAGS = ( "-framework", CoreBluetooth, @@ -975,7 +975,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 190; + CURRENT_PROJECT_VERSION = 192; DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\""; DEVELOPMENT_TEAM = KYH47R284B; ENABLE_HARDENED_RUNTIME = YES; @@ -1002,7 +1002,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 2.3.0; + MARKETING_VERSION = 2.3.1; OTHER_LDFLAGS = ( "-framework", CoreBluetooth, @@ -1201,7 +1201,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = 0.3.4; + version = 0.3.6; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f21c062d4..af46a1381 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "c098b41d961594ead9dfd474b8508e289f6be2de", - "version" : "0.3.4" + "revision" : "89632fe36d73c33ac5c783ee9560e211da3685cd", + "version" : "0.3.6" } }, { diff --git a/Bitkit/Utilities/Lnurl.swift b/Bitkit/Utilities/Lnurl.swift index bdff50594..e4c163e1c 100644 --- a/Bitkit/Utilities/Lnurl.swift +++ b/Bitkit/Utilities/Lnurl.swift @@ -1,13 +1,14 @@ import BitkitCore import Foundation -// MARK: - Response Models - -private struct LnurlPayResponse: Codable { - let pr: String - let routes: [String] +struct LnurlPayInvoiceMismatchError: LocalizedError { + var errorDescription: String? { + return "The invoice did not match the requested payment. Payment cancelled." + } } +// MARK: - Response Models + private struct LnurlWithdrawResponse: Codable { let status: String let reason: String? @@ -116,37 +117,42 @@ private extension LnurlHelper { throw NSError(domain: "LNURL", code: -1, userInfo: [NSLocalizedDescriptionKey: errorMessage]) } } + + static func mapLnurlPayInvoiceError(_ error: Error) -> Error { + if let lnurlError = error as? LnurlError { + switch lnurlError { + case .InvalidAmount, .AmountMismatch: + return LnurlPayInvoiceMismatchError() + default: + break + } + } + + return error + } } @MainActor struct LnurlHelper { - /// Fetches a Lightning invoice from an LNURL pay callback + /// Fetches a Lightning invoice for an LNURL pay request /// - Parameters: - /// - callbackUrl: The LNURL callback URL + /// - data: The LNURL pay data /// - 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, + data: LnurlPayData, amountMsats: UInt64, comment: String? = nil ) async throws -> String { - var queryItems = [ - URLQueryItem(name: "amount", value: String(amountMsats)), - ] - - // Add comment if provided - if let comment, !comment.isEmpty { - queryItems.append(URLQueryItem(name: "comment", value: comment)) + do { + let invoice = try await getLnurlInvoiceForPayData(data: data, amountMsats: amountMsats, comment: comment) + Logger.debug("Fetched LNURL pay invoice") + return invoice + } catch { + throw mapLnurlPayInvoiceError(error) } - - let callbackURL = try buildUrl(baseUrl: callbackUrl, queryItems: queryItems) - let responseString = try await makeHttpGetRequest(url: callbackURL) - let lnurlResponse = try parseJsonResponse(responseString, as: LnurlPayResponse.self) - - Logger.debug("Extracted bolt11 invoice: \(lnurlResponse.pr)") - return lnurlResponse.pr } /// Handles LNURL Withdraw Requests diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index 2161ed0da..bc34ed903 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -191,19 +191,19 @@ struct LnurlPayConfirm: View { } let amountMsats = lnurlPayData.callbackAmountMsats(userSats: wallet.sendAmountSats) - - // Fetch the Lightning invoice from LNURL - let bolt11 = try await LnurlHelper.fetchLnurlInvoice( - callbackUrl: lnurlPayData.callback, - amountMsats: amountMsats, - comment: comment.isEmpty ? nil : comment - ) - - let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) - let paymentHash = String(describing: parsedInvoice.paymentHash()) let contactPublicKey = app.contactPaymentContext?.publicKey do { + // Fetch the Lightning invoice from LNURL + let bolt11 = try await LnurlHelper.fetchLnurlInvoice( + data: lnurlPayData, + amountMsats: amountMsats, + comment: comment.isEmpty ? nil : comment + ) + + let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) + let paymentHash = String(describing: parsedInvoice.paymentHash()) + // 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. diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index d6b5c78e0..e23c1580a 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -34,38 +34,38 @@ struct SendQuickpay: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { Task { - try await performPayment() + await performPayment() } } } - private func performPayment() async throws { - var bolt11Invoice: String? - - // Handle LNURL Pay - if let lnurlPayData = app.lnurlPayData { - // Set the amount in sats for the success screen - wallet.sendAmountSats = lnurlPayData.minSendableSat + private func performPayment() async { + do { + var bolt11Invoice: String? - bolt11Invoice = try await LnurlHelper.fetchLnurlInvoice( - callbackUrl: lnurlPayData.callback, - amountMsats: lnurlPayData.callbackAmountMsats() - ) - } else if let scannedInvoice = app.scannedLightningInvoice { - wallet.sendAmountSats = scannedInvoice.amountSatoshis - bolt11Invoice = scannedInvoice.bolt11 - } + // Handle LNURL Pay + if let lnurlPayData = app.lnurlPayData { + // Set the amount in sats for the success screen + wallet.sendAmountSats = lnurlPayData.minSendableSat + + bolt11Invoice = try await LnurlHelper.fetchLnurlInvoice( + data: lnurlPayData, + amountMsats: lnurlPayData.callbackAmountMsats() + ) + } else if let scannedInvoice = app.scannedLightningInvoice { + wallet.sendAmountSats = scannedInvoice.amountSatoshis + bolt11Invoice = scannedInvoice.bolt11 + } - guard let bolt11 = bolt11Invoice else { - throw NSError( - domain: "Payment", code: -1, userInfo: [NSLocalizedDescriptionKey: "No Lightning invoice found"] - ) - } + guard let bolt11 = bolt11Invoice else { + throw NSError( + domain: "Payment", code: -1, userInfo: [NSLocalizedDescriptionKey: "No Lightning invoice found"] + ) + } - let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) - let paymentHash = String(describing: parsedInvoice.paymentHash()) + let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) + let paymentHash = String(describing: parsedInvoice.paymentHash()) - 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( @@ -82,15 +82,19 @@ struct SendQuickpay: View { // onTimeout callback already navigated to .pending; suppress throw return } catch { - Logger.error("Quickpay payment failed: \(error)") + handlePaymentError(error) + } + } - // TODO: remove toast and use failure screen instead - app.toast(error) + private func handlePaymentError(_ error: Error) { + Logger.error("Quickpay payment failed: \(error)") - // TODO: this is a hack to make sure the navigation binding is ready - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - navigationPath.append(.failure) - } + // TODO: remove toast and use failure screen instead + app.toast(error) + + // TODO: this is a hack to make sure the navigation binding is ready + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + navigationPath.append(.failure) } } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1261b8d0b..de6a117c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.3.1] - 2026-06-26 + +### Fixed +- Improved LNURL-pay invoice validation. #607 +- Improved LNURL-pay payment handling. #610 + ## [2.3.0] - 2026-06-04 ### Added @@ -53,7 +59,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix keyboard and UI issues in the calculator widget #513 - Preserve msat precision for LNURL pay, withdraw callbacks and bolt11 #512 -[Unreleased]: https://github.com/synonymdev/bitkit-ios/compare/v2.3.0...HEAD +[Unreleased]: https://github.com/synonymdev/bitkit-ios/compare/v2.3.1...HEAD +[2.3.1]: https://github.com/synonymdev/bitkit-ios/compare/v2.3.0...v2.3.1 [2.3.0]: https://github.com/synonymdev/bitkit-ios/compare/v2.2.1...v2.3.0 [2.2.1]: https://github.com/synonymdev/bitkit-ios/compare/v2.2.0...v2.2.1 [2.2.0]: https://github.com/synonymdev/bitkit-ios/compare/v2.1.2...v2.2.0