diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 05748e0dd..a16a01e29 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -930,15 +930,15 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = revision; - revision = ae38eadab70fceb5dbe242bc02bf895581cb7c3f; + revision = c5698d00066e0e50f33696afc562d71023da2373; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { 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 b1ca35741..f3c26d486 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" } }, { @@ -24,7 +23,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "ae38eadab70fceb5dbe242bc02bf895581cb7c3f" + "revision" : "c5698d00066e0e50f33696afc562d71023da2373" } }, { diff --git a/Bitkit/Extensions/PaymentDetails.swift b/Bitkit/Extensions/PaymentDetails.swift index 9798f929a..4889b76b2 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 diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 15c9c213d..899f7b1e8 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/Utilities/Lnurl.swift b/Bitkit/Utilities/Lnurl.swift index db72554c8..bdff50594 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 998f2036e..21f94f3a5 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) { @@ -739,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: _): diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index ea8b0f2fb..ac3b30570 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/LnurlWithdrawAmount.swift b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift index ba2c91df2..65da93670 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 e90f38895..37eb0b9cb 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 - if app.lnurlWithdrawData!.maxWithdrawable == app.lnurlWithdrawData!.minWithdrawable { - return app.lnurlWithdrawData!.maxWithdrawable - } + var isFixedAmount: Bool { + app.lnurlWithdrawData!.maxWithdrawable == app.lnurlWithdrawData!.minWithdrawable + } - // For variable amount, use the amount from the previous screen + var displayAmountSats: UInt64 { + if isFixedAmount { + return LightningAmountConversion.satsCeil(fromMsats: app.lnurlWithdrawData!.maxWithdrawable) + } 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 } } diff --git a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift index a3d4ed335..97196d437 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 5999401b8..bd44c37fe 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.satsCeil(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 ) @@ -200,9 +204,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 76b807163..e560b4e60 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -459,10 +459,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)) @@ -769,7 +772,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 24e0fdbcd..db782f90a 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.satsCeil(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 @@ -110,12 +108,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)) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72c889ad6..93b288141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,4 +7,8 @@ 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