From e16b3fa46a6410f2b0507fe009877774f56be7ab Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 1 Apr 2026 13:22:53 +0300 Subject: [PATCH 1/7] fix: avoid msat truncation when paying invoices with built-in amounts Bump bitkit-core to v0.1.56 which rounds up sub-satoshi invoice amounts. Additionally, stop overriding the amount for invoices that already have one. Pass null so LDK uses the invoice's native msat precision instead of our truncated sats value converted back to msat. Only pass the amount for zero-amount invoices where the user specifies it. Closes #877 --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 11 +++++++---- .../java/to/bitkit/viewmodels/QuickPayViewModel.kt | 12 +++++++----- gradle/libs.versions.toml | 2 +- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f40c21b58..41e88e17b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1756,8 +1756,11 @@ class AppViewModel @Inject constructor( val decodedInvoice = requireNotNull(_sendUiState.value.decodedInvoice) val bolt11 = decodedInvoice.bolt11 - // Determine if we should override amount - val paymentAmount = decodedInvoice.amountSatoshis.takeIf { it > 0uL } ?: amount + // When the invoice has a built-in amount, pass null so LDK uses the + // invoice's native msat precision (avoids truncation to whole sats). + val paymentAmount = if (decodedInvoice.amountSatoshis > 0uL) null else amount + // For display/UI purposes, use the invoice amount (in sats) when available. + val displayAmountSats = decodedInvoice.amountSatoshis.takeIf { it > 0uL } ?: amount ?: 0uL val tags = _sendUiState.value.selectedTags var createdMetadataPaymentId: String? = null @@ -1785,14 +1788,14 @@ class AppViewModel @Inject constructor( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.SENT, paymentHashOrTxId = actualPaymentHash, - sats = paymentAmount.toLong(), // TODO Add fee when available + sats = displayAmountSats.toLong(), // TODO Add fee when available ), ) }.onFailure { if (it is PaymentPendingException) { Logger.info("Lightning payment pending", context = TAG) pendingPaymentRepo.track(it.paymentHash) - setSendEffect(SendEffect.NavigateToPending(it.paymentHash, paymentAmount.toLong())) + setSendEffect(SendEffect.NavigateToPending(it.paymentHash, displayAmountSats.toLong())) return@onFailure } // Delete pre-activity metadata on failure diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index feda0ed8e..fd5209a1e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -39,10 +39,12 @@ class QuickPayViewModel @Inject constructor( fun pay(data: QuickPayData) { viewModelScope.launch { - val (bolt11, amount) = when (data) { + val (bolt11, amount, displaySats) = when (data) { is QuickPayData.Bolt11 -> { Logger.info("QuickPay: processing bolt11 invoice") - data.bolt11 to data.sats + // Pass null amount so LDK uses the invoice's native msat precision + // (avoids truncation to whole sats). data.sats is only for display. + Triple(data.bolt11, null, data.sats) } is QuickPayData.LnurlPay -> { @@ -54,7 +56,7 @@ class QuickPayViewModel @Inject constructor( } return@launch } - invoice.bolt11 to data.sats + Triple(invoice.bolt11, data.sats, data.sats) } } @@ -65,7 +67,7 @@ class QuickPayViewModel @Inject constructor( it.copy( result = QuickPayResult.Success( paymentHash = paymentHash, - amountWithFee = amount.toLong() // TODO GET FEE WHEN AVAILABLE + amountWithFee = displaySats.toLong() // TODO GET FEE WHEN AVAILABLE ) ) } @@ -77,7 +79,7 @@ class QuickPayViewModel @Inject constructor( it.copy( result = QuickPayResult.Pending( paymentHash = error.paymentHash, - amount = amount.toLong(), + amount = displaySats.toLong(), ) ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea7508a9a..8f19e76a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.38" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.56" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From 55dee91420e9902ffac5fdb87d54b45296a5198e Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 1 Apr 2026 15:10:36 +0300 Subject: [PATCH 2/7] fix: remove inline comments per CLAUDE.md guidelines Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 3 --- app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt | 2 -- 2 files changed, 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 41e88e17b..e47ffb414 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1756,10 +1756,7 @@ class AppViewModel @Inject constructor( val decodedInvoice = requireNotNull(_sendUiState.value.decodedInvoice) val bolt11 = decodedInvoice.bolt11 - // When the invoice has a built-in amount, pass null so LDK uses the - // invoice's native msat precision (avoids truncation to whole sats). val paymentAmount = if (decodedInvoice.amountSatoshis > 0uL) null else amount - // For display/UI purposes, use the invoice amount (in sats) when available. val displayAmountSats = decodedInvoice.amountSatoshis.takeIf { it > 0uL } ?: amount ?: 0uL val tags = _sendUiState.value.selectedTags diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index fd5209a1e..4fcb69890 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -42,8 +42,6 @@ class QuickPayViewModel @Inject constructor( val (bolt11, amount, displaySats) = when (data) { is QuickPayData.Bolt11 -> { Logger.info("QuickPay: processing bolt11 invoice") - // Pass null amount so LDK uses the invoice's native msat precision - // (avoids truncation to whole sats). data.sats is only for display. Triple(data.bolt11, null, data.sats) } From 03403b36f452bf923717acf96005e584cf080692 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 3 Apr 2026 09:26:31 +0300 Subject: [PATCH 3/7] fix: preserve msat precision for LNURL pay and withdraw callbacks LNURL protocol uses millisatoshis, but the app was converting to sats and back for callbacks, losing the fractional part. For fixed-amount LNURL-pay (e.g. 500500 msat), ceil(min)=501 > floor(max)=500 caused the UI to show 0/invalid amount. - Add isFixedAmount() helpers that detect sub-sat fixed amounts - Add callbackAmountMsats() to return original msat for fixed amounts - Change fetchLnurlInvoice to accept amountMsats directly - For fixed-amount LNURL-withdraw, use floor division for invoice amount - Fix validateAmount for withdraw: use <= instead of < for max bound - Add unit tests for all new helper functions Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/main/java/to/bitkit/ext/Lnurl.kt | 38 +++++++++ .../to/bitkit/repositories/LightningRepo.kt | 6 +- .../java/to/bitkit/services/LnurlService.kt | 4 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 56 +++++++++----- .../bitkit/viewmodels/ProbingToolViewModel.kt | 2 +- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 2 +- .../test/java/to/bitkit/ext/LnurlExtTest.kt | 77 +++++++++++++++++++ 7 files changed, 157 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/Lnurl.kt b/app/src/main/java/to/bitkit/ext/Lnurl.kt index 834aec505..757cf6f31 100644 --- a/app/src/main/java/to/bitkit/ext/Lnurl.kt +++ b/app/src/main/java/to/bitkit/ext/Lnurl.kt @@ -24,5 +24,43 @@ fun LnurlPayData.commentAllowed(): Boolean = commentAllowed?.let { it > 0u } == fun LnurlPayData.maxSendableSat(): ULong = maxSendable / MSATS_PER_SAT fun LnurlPayData.minSendableSat(): ULong = msatsToSatsCeil(minSendable) +/** + * True when the LNURL-pay endpoint specifies a single exact amount. + * + * This 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. `minSendable = 500500, maxSendable = 500500` → `minSendableSat() = 501, maxSendableSat() = 500`. + */ +fun LnurlPayData.isFixedAmount(): Boolean = + minSendable == maxSendable || (minSendable > 0u && minSendableSat() > maxSendableSat()) + +/** + * Returns the amount in millisatoshis to send in the LNURL-pay callback. + * + * For fixed-amount requests (including sub-sat ranges) the original msat value + * from the server 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. + */ +fun LnurlPayData.callbackAmountMsats(userSats: ULong? = null): ULong = + if (isFixedAmount()) minSendable else (userSats ?: minSendableSat()) * MSATS_PER_SAT + fun LnurlWithdrawData.minWithdrawableSat(): ULong = msatsToSatsCeil(minWithdrawable ?: 0u) fun LnurlWithdrawData.maxWithdrawableSat(): ULong = maxWithdrawable / MSATS_PER_SAT + +/** + * 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. + */ +fun LnurlWithdrawData.isFixedAmount(): Boolean { + val min = minWithdrawable ?: 0u + return min == maxWithdrawable || (min > 0u && minWithdrawableSat() > maxWithdrawableSat()) +} + +/** + * The amount in whole sats to use when creating a withdraw invoice for a fixed-amount request. + * + * Uses floor division so the invoice amount never exceeds `maxWithdrawable` in msats. + */ +fun LnurlWithdrawData.fixedWithdrawAmountSat(): ULong = maxWithdrawable / MSATS_PER_SAT diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index f5d2182b5..010be02b7 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -900,17 +900,17 @@ class LightningRepo @Inject constructor( @Suppress("ForbiddenComment") suspend fun fetchLnurlInvoice( callbackUrl: String, - amountSats: ULong, + amountMsats: ULong, comment: String? = null, ): Result { return runCatching { // TODO use bitkit-core getLnurlInvoice if it works with callbackUrl - val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountSats, comment).getOrThrow().pr + val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountMsats, comment).getOrThrow().pr val decoded = (coreService.decode(bolt11) as Scanner.Lightning).invoice return@runCatching decoded }.onFailure { Logger.error( - "fetchLnurlInvoice error, url: $callbackUrl, amount: $amountSats, comment: $comment", + "fetchLnurlInvoice error, url: $callbackUrl, amountMsats: $amountMsats, comment: $comment", it, context = TAG, ) diff --git a/app/src/main/java/to/bitkit/services/LnurlService.kt b/app/src/main/java/to/bitkit/services/LnurlService.kt index b5bc799ce..f917b5ce4 100644 --- a/app/src/main/java/to/bitkit/services/LnurlService.kt +++ b/app/src/main/java/to/bitkit/services/LnurlService.kt @@ -41,14 +41,14 @@ class LnurlService @Inject constructor( suspend fun fetchLnurlInvoice( callbackUrl: String, - amountSats: ULong, + amountMsats: ULong, comment: String? = null, ): Result = runCatching { Logger.debug("Fetching LNURL pay invoice from: $callbackUrl", context = TAG) val response = client.get(callbackUrl) { url { - parameters["amount"] = "${amountSats * 1000u}" // convert to msat + parameters["amount"] = "$amountMsats" comment?.takeIf { it.isNotBlank() }?.let { parameters["comment"] = it } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e47ffb414..fc3db3eab 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -77,6 +77,9 @@ import to.bitkit.ext.channelId import to.bitkit.ext.claimableAtHeight import to.bitkit.ext.getClipboardText import to.bitkit.ext.getSatsPerVByteFor +import to.bitkit.ext.callbackAmountMsats +import to.bitkit.ext.fixedWithdrawAmountSat +import to.bitkit.ext.isFixedAmount import to.bitkit.ext.maxSendableSat import to.bitkit.ext.maxWithdrawableSat import to.bitkit.ext.minSendableSat @@ -1161,7 +1164,7 @@ class AppViewModel @Inject constructor( val maxSendable = maxSendableLightningSats() when (val lnurl = _sendUiState.value.lnurl) { null -> amount <= maxSendable && lightningRepo.canSend(amount) - is LnurlParams.LnurlWithdraw -> amount < lnurl.data.maxWithdrawableSat() + is LnurlParams.LnurlWithdraw -> amount <= lnurl.data.maxWithdrawableSat() is LnurlParams.LnurlPay -> { val maxSat = lnurl.data.maxSendableSat() amount <= maxSat && amount <= maxSendable && lightningRepo.canSend(amount) @@ -1405,10 +1408,10 @@ class AppViewModel @Inject constructor( private suspend fun onScanLnurlPay(data: LnurlPayData) { Logger.debug("LNURL: $data", context = TAG) - val minSendable = data.minSendableSat() - val maxSendable = data.maxSendableSat() + val isFixed = data.isFixedAmount() + val displaySats = if (isFixed) data.maxSendableSat() else data.minSendableSat() - if (!lightningRepo.canSend(minSendable)) { + if (!lightningRepo.canSend(displaySats.coerceAtLeast(1u))) { toast( type = Toast.ToastType.WARNING, title = context.getString(R.string.other__lnurl_pay_error), @@ -1417,8 +1420,7 @@ class AppViewModel @Inject constructor( return } - val hasAmount = minSendable == maxSendable && minSendable > 0u - val initialAmount = if (hasAmount) minSendable else 0u + val initialAmount = if (isFixed) displaySats else 0u _sendUiState.update { it.copy( @@ -1428,10 +1430,10 @@ class AppViewModel @Inject constructor( ) } - if (hasAmount) { - Logger.info("Found amount $$minSendable in lnurlPay, proceeding with payment", context = TAG) + if (isFixed) { + Logger.info("Found fixed amount $displaySats sats in lnurlPay, proceeding with payment", context = TAG) - val quickPayHandled = handleQuickPayIfApplicable(amountSats = minSendable, lnurlPay = data) + val quickPayHandled = handleQuickPayIfApplicable(amountSats = displaySats, lnurlPay = data) if (quickPayHandled) return if (isMainScanner) { @@ -1453,10 +1455,11 @@ class AppViewModel @Inject constructor( private suspend fun onScanLnurlWithdraw(data: LnurlWithdrawData) { Logger.debug("LNURL: $data", context = TAG) + val isFixed = data.isFixedAmount() val minWithdrawable = data.minWithdrawableSat() val maxWithdrawable = data.maxWithdrawableSat() - if (minWithdrawable > maxWithdrawable) { + if (!isFixed && minWithdrawable > maxWithdrawable) { toast( type = Toast.ToastType.WARNING, title = context.getString(R.string.other__lnurl_withdr_error), @@ -1465,15 +1468,17 @@ class AppViewModel @Inject constructor( return } + val displayAmount = if (isFixed) data.fixedWithdrawAmountSat() else minWithdrawable + _sendUiState.update { it.copy( payMethod = SendMethod.LIGHTNING, - amount = minWithdrawable, + amount = displayAmount, lnurl = LnurlParams.LnurlWithdraw(data = data) ) } - if (minWithdrawable == maxWithdrawable) { + if (isFixed || minWithdrawable == maxWithdrawable) { delay(TRANSITION_SCREEN_MS) if (isMainScanner) { showSheet(Sheet.Send(SendRoute.WithdrawConfirm)) @@ -1582,7 +1587,11 @@ class AppViewModel @Inject constructor( val quickPayData: QuickPayData = when { lnurlPay != null -> { - QuickPayData.LnurlPay(sats = amountSats, callback = lnurlPay.callback) + QuickPayData.LnurlPay( + sats = amountSats, + callback = lnurlPay.callback, + amountMsats = lnurlPay.callbackAmountMsats(amountSats), + ) } else -> { @@ -1706,9 +1715,10 @@ class AppViewModel @Inject constructor( val isLnurlPay = lnurl is LnurlParams.LnurlPay if (isLnurlPay) { + val amountMsats = lnurl.data.callbackAmountMsats(amount) lightningRepo.fetchLnurlInvoice( callbackUrl = lnurl.data.callback, - amountSats = amount, + amountMsats = amountMsats, comment = _sendUiState.value.comment.takeIf { it.isNotEmpty() }, ).onSuccess { invoice -> _sendUiState.update { @@ -1817,16 +1827,20 @@ class AppViewModel @Inject constructor( return@launch } - _sendUiState.update { - it.copy( - amount = it.amount.coerceAtLeast( - (lnurl.data.minWithdrawable ?: 0u) / 1000u - ) + val withdrawAmountSats = if (lnurl.data.isFixedAmount()) { + lnurl.data.fixedWithdrawAmountSat() + } else { + _sendUiState.value.amount.coerceAtLeast( + (lnurl.data.minWithdrawable ?: 0u) / 1000u ) } + _sendUiState.update { + it.copy(amount = withdrawAmountSats) + } + val invoice = lightningRepo.createInvoice( - amountSats = _sendUiState.value.amount, + amountSats = withdrawAmountSats, description = lnurl.data.defaultDescription, expirySeconds = 3600u, ).getOrNull() @@ -2567,6 +2581,6 @@ sealed interface QuickPayData { data class Bolt11(override val sats: ULong, val bolt11: String) : QuickPayData @Stable - data class LnurlPay(override val sats: ULong, val callback: String) : QuickPayData + data class LnurlPay(override val sats: ULong, val callback: String, val amountMsats: ULong) : QuickPayData } // endregion diff --git a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt index f8eb3c204..ffe6cfb85 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt @@ -179,7 +179,7 @@ class ProbingToolViewModel @Inject constructor( is Scanner.LnurlPay -> { val amount = amountSats ?: return@runCatching null - lightningRepo.fetchLnurlInvoice(decoded.data.callback, amount).getOrThrow().bolt11 + lightningRepo.fetchLnurlInvoice(decoded.data.callback, amount * 1000u).getOrThrow().bolt11 } else -> null diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 4fcb69890..984bfdedd 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -47,7 +47,7 @@ class QuickPayViewModel @Inject constructor( is QuickPayData.LnurlPay -> { Logger.info("QuickPay: fetching LNURL Pay invoice from callback") - val invoice = lightningRepo.fetchLnurlInvoice(callbackUrl = data.callback, amountSats = data.sats) + val invoice = lightningRepo.fetchLnurlInvoice(callbackUrl = data.callback, amountMsats = data.amountMsats) .getOrElse { error -> _uiState.update { it.copy(result = QuickPayResult.Error(error.message.orEmpty())) diff --git a/app/src/test/java/to/bitkit/ext/LnurlExtTest.kt b/app/src/test/java/to/bitkit/ext/LnurlExtTest.kt index 16ab55c08..6606b7963 100644 --- a/app/src/test/java/to/bitkit/ext/LnurlExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/LnurlExtTest.kt @@ -74,4 +74,81 @@ class LnurlExtTest : BaseUnitTest() { val nonRoundMin = nullMin.copy(minWithdrawable = 1_500u) assertEquals(2u, nonRoundMin.minWithdrawableSat()) } + + @Test + fun `isFixedAmount returns true when min equals max`() { + val data = lnurlPayData(minSendable = 5_000u, maxSendable = 5_000u) + assertEquals(true, data.isFixedAmount()) + } + + @Test + fun `isFixedAmount returns true for sub-sat fixed amount`() { + val data = lnurlPayData(minSendable = 500_500u, maxSendable = 500_500u) + assertEquals(501u, data.minSendableSat()) + assertEquals(500u, data.maxSendableSat()) + assertEquals(true, data.isFixedAmount()) + } + + @Test + fun `isFixedAmount returns false for variable range`() { + val data = lnurlPayData(minSendable = 1_000u, maxSendable = 100_000u) + assertEquals(false, data.isFixedAmount()) + } + + @Test + fun `callbackAmountMsats returns original msats for fixed amount`() { + val data = lnurlPayData(minSendable = 500_500u, maxSendable = 500_500u) + assertEquals(500_500u, data.callbackAmountMsats(500u)) + } + + @Test + fun `callbackAmountMsats converts user sats for variable amount`() { + val data = lnurlPayData(minSendable = 1_000u, maxSendable = 100_000u) + assertEquals(50_000u, data.callbackAmountMsats(50u)) + } + + @Test + fun `withdraw isFixedAmount returns true for sub-sat fixed amount`() { + val data = withdrawData(minWithdrawable = 500_500u, maxWithdrawable = 500_500u) + assertEquals(true, data.isFixedAmount()) + } + + @Test + fun `withdraw isFixedAmount returns false for variable range`() { + val data = withdrawData(minWithdrawable = 1_000u, maxWithdrawable = 100_000u) + assertEquals(false, data.isFixedAmount()) + } + + @Test + fun `fixedWithdrawAmountSat floors to avoid exceeding max`() { + val data = withdrawData(minWithdrawable = 500_500u, maxWithdrawable = 500_500u) + assertEquals(500u, data.fixedWithdrawAmountSat()) + } + + private fun lnurlPayData( + minSendable: ULong = 1_000u, + maxSendable: ULong = 100_000u, + ) = LnurlPayData( + uri = "lnurl", + callback = "callback", + minSendable = minSendable, + maxSendable = maxSendable, + metadataStr = "[]", + commentAllowed = null, + allowsNostr = false, + nostrPubkey = null, + ) + + private fun withdrawData( + minWithdrawable: ULong? = null, + maxWithdrawable: ULong = 1_000u, + ) = LnurlWithdrawData( + uri = "lnurl", + callback = "callback", + k1 = "k1", + defaultDescription = "desc", + minWithdrawable = minWithdrawable, + maxWithdrawable = maxWithdrawable, + tag = "withdraw", + ) } From 5d7614dda0353fbf4b3863c55323a3ff870010c4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 3 Apr 2026 09:35:21 +0300 Subject: [PATCH 4/7] chore: add changelog entry and fix detekt line length Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 ++ app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 333a54e01..aaa75473c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,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 #879 +- Avoid msat truncation when paying invoices with built-in amounts #879 - Fix ANR on RGS server settings screen caused by catastrophic regex backtracking #880 - Fix crash when returning app to foreground on Receive screen #875 - Show loading state on Spending tab when node is not running #875 diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 984bfdedd..081b7e268 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -47,7 +47,10 @@ class QuickPayViewModel @Inject constructor( is QuickPayData.LnurlPay -> { Logger.info("QuickPay: fetching LNURL Pay invoice from callback") - val invoice = lightningRepo.fetchLnurlInvoice(callbackUrl = data.callback, amountMsats = data.amountMsats) + val invoice = lightningRepo.fetchLnurlInvoice( + callbackUrl = data.callback, + amountMsats = data.amountMsats, + ) .getOrElse { error -> _uiState.update { it.copy(result = QuickPayResult.Error(error.message.orEmpty())) From 7bfb20b7ed3de85a18f3937f1ebdf3a550920669 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 3 Apr 2026 09:55:04 +0300 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20LNURL?= =?UTF-8?q?=20quickpay=20null=20amount,=20log=20quotes,=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pass null instead of data.sats for LNURL quick-pay so LDK uses the invoice's native msat precision (same fix as Bolt11 path) - Wrap log parameter values in single quotes per CLAUDE.md - Consolidate duplicate changelog entries into one Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 3 +-- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 2 +- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64de6dd2c..02ccde42a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,7 @@ 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 #879 -- Avoid msat truncation when paying invoices with built-in amounts #879 +- Avoid msat truncation when paying invoices and LNURL callbacks #879 - Fix ANR on RGS server settings screen caused by catastrophic regex backtracking #880 - Fix crash when returning app to foreground on Receive screen #875 - Show loading state on Spending tab when node is not running #875 diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 010be02b7..b449ae946 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -910,7 +910,7 @@ class LightningRepo @Inject constructor( return@runCatching decoded }.onFailure { Logger.error( - "fetchLnurlInvoice error, url: $callbackUrl, amountMsats: $amountMsats, comment: $comment", + "fetchLnurlInvoice error, url: '$callbackUrl', amountMsats: '$amountMsats', comment: '$comment'", it, context = TAG, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8535e14be..7d40c8318 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1491,7 +1491,7 @@ class AppViewModel @Inject constructor( } if (isFixed) { - Logger.info("Found fixed amount $displaySats sats in lnurlPay, proceeding with payment", context = TAG) + Logger.info("Found fixed amount '$displaySats' sats in lnurlPay, proceeding with payment", context = TAG) val quickPayHandled = handleQuickPayIfApplicable(amountSats = displaySats, lnurlPay = data) if (quickPayHandled) return diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 081b7e268..24f39567e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -57,7 +57,7 @@ class QuickPayViewModel @Inject constructor( } return@launch } - Triple(invoice.bolt11, data.sats, data.sats) + Triple(invoice.bolt11, null, data.sats) } } From 74f39724ea58d4b1dde5ba2e7ff05f3ab0b49f0b Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 3 Apr 2026 10:27:12 +0300 Subject: [PATCH 6/7] fix: log message should start with verb per CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index b449ae946..e75576fdd 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -910,7 +910,7 @@ class LightningRepo @Inject constructor( return@runCatching decoded }.onFailure { Logger.error( - "fetchLnurlInvoice error, url: '$callbackUrl', amountMsats: '$amountMsats', comment: '$comment'", + "Failed to fetch LNURL invoice, url: '$callbackUrl', amountMsats: '$amountMsats', comment: '$comment'", it, context = TAG, ) From 2a8037d5aa325a2268fe351194410e470d06443f Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 3 Apr 2026 10:45:18 +0300 Subject: [PATCH 7/7] fix: use msat-precision invoices for fixed-amount LNURL withdraw For LNURL-withdraw with sub-sat precision (e.g. 222538 msat), neither floor (222 sats) nor ceiling (223 sats) matches the server's exact amount range. Add receiveMsats/createInvoiceMsats to create invoices with native msat precision, used for fixed-amount LNURL withdrawals. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../to/bitkit/repositories/LightningRepo.kt | 9 ++++++ .../to/bitkit/services/LightningService.kt | 8 ++++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 28 +++++++++---------- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index e75576fdd..9575864cd 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -897,6 +897,15 @@ class LightningRepo @Inject constructor( runCatching { lightningService.receive(amountSats, description, expirySeconds) } } + suspend fun createInvoiceMsats( + amountMsats: ULong, + description: String, + expirySeconds: UInt = 86_400u, + ): Result = executeWhenNodeRunning("createInvoiceMsats") { + updateGeoBlockState() + runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) } + } + @Suppress("ForbiddenComment") suspend fun fetchLnurlInvoice( callbackUrl: String, diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index e57505d64..86ddd0bc4 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -590,15 +590,19 @@ class LightningService @Inject constructor( } suspend fun receive(sat: ULong? = null, description: String, expirySecs: UInt = 3600u): String { + return receiveMsats(amountMsat = sat?.let { it * 1000u }, description = description, expirySecs = expirySecs) + } + + suspend fun receiveMsats(amountMsat: ULong? = null, description: String, expirySecs: UInt = 3600u): String { val node = this.node ?: throw ServiceError.NodeNotSetup() val message = description return ServiceQueue.LDK.background { - val bolt11Invoice: Bolt11Invoice = if (sat != null) { + val bolt11Invoice: Bolt11Invoice = if (amountMsat != null) { node.bolt11Payment() .receive( - amountMsat = sat * 1000u, + amountMsat = amountMsat, description = Bolt11InvoiceDescription.Direct(description = message), expirySecs = expirySecs, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 7d40c8318..60d3f73b6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1887,23 +1887,23 @@ class AppViewModel @Inject constructor( return@launch } - val withdrawAmountSats = if (lnurl.data.isFixedAmount()) { - lnurl.data.fixedWithdrawAmountSat() + val invoice = if (lnurl.data.isFixedAmount()) { + lightningRepo.createInvoiceMsats( + amountMsats = lnurl.data.maxWithdrawable, + description = lnurl.data.defaultDescription, + expirySeconds = 3600u, + ) } else { - _sendUiState.value.amount.coerceAtLeast( + val withdrawAmountSats = _sendUiState.value.amount.coerceAtLeast( (lnurl.data.minWithdrawable ?: 0u) / 1000u ) - } - - _sendUiState.update { - it.copy(amount = withdrawAmountSats) - } - - val invoice = lightningRepo.createInvoice( - amountSats = withdrawAmountSats, - description = lnurl.data.defaultDescription, - expirySeconds = 3600u, - ).getOrNull() + _sendUiState.update { it.copy(amount = withdrawAmountSats) } + lightningRepo.createInvoice( + amountSats = withdrawAmountSats, + description = lnurl.data.defaultDescription, + expirySeconds = 3600u, + ) + }.getOrNull() if (invoice == null) { setSendEffect(SendEffect.NavigateToWithdrawError)