Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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" */ = {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions Bitkit/Services/LightningService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
6 changes: 3 additions & 3 deletions Bitkit/Utilities/Lnurl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 4 additions & 17 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -542,7 +529,7 @@ extension AppViewModel {
return
}

lnurlWithdrawData = normalizedData
lnurlWithdrawData = data
}

private func handleLnurlChannel(_ data: LnurlChannelData) {
Expand Down
6 changes: 6 additions & 0 deletions Bitkit/ViewModels/WalletViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
5 changes: 3 additions & 2 deletions Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 22 additions & 14 deletions Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawConfirm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,22 @@ 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!
}

var body: some 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)

Expand Down Expand Up @@ -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(
Expand All @@ -84,7 +92,7 @@ struct LnurlWithdrawConfirm: View {

} catch {
await MainActor.run {
onFailure(amount)
onFailure(displayAmountSats)
isLoading = false
}
}
Expand Down
8 changes: 4 additions & 4 deletions Bitkit/Views/Wallets/Send/LnurlPayAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
)

Expand All @@ -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))
Expand Down
9 changes: 7 additions & 2 deletions Bitkit/Views/Wallets/Send/SendConfirmationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
}
Expand Down
13 changes: 6 additions & 7 deletions Bitkit/Views/Wallets/Send/SendQuickpay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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