Skip to content

Commit a84e332

Browse files
authored
Merge pull request #879 from synonymdev/fix/msat-invoice-precision
fix: avoid msat truncation when paying invoices with built-in amounts
2 parents e9619ee + 2608e65 commit a84e332

13 files changed

Lines changed: 182 additions & 49 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Fixed
11+
- Avoid msat truncation when paying invoices and LNURL callbacks #879
1112
- Fix ANR on RGS server settings screen caused by catastrophic regex backtracking #880
1213
- Fix crash when returning app to foreground on Receive screen #875
1314
- Show loading state on Spending tab when node is not running #875

app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ class NotifyPaymentReceivedHandler @Inject constructor(
9797
is NotifyPaymentReceived.Command.Onchain -> command.event.txid
9898
},
9999
sats = when (command) {
100-
is NotifyPaymentReceived.Command.Lightning -> (command.event.amountMsat / 1000u).toLong()
100+
is NotifyPaymentReceived.Command.Lightning -> ((command.event.amountMsat + 999u) / 1000u).toLong()
101101
is NotifyPaymentReceived.Command.Onchain -> command.event.details.amountSats
102102
},
103103
)

app/src/main/java/to/bitkit/ext/Lnurl.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,36 @@ fun LnurlPayData.commentAllowed(): Boolean = commentAllowed?.let { it > 0u } ==
2424
fun LnurlPayData.maxSendableSat(): ULong = maxSendable / MSATS_PER_SAT
2525
fun LnurlPayData.minSendableSat(): ULong = msatsToSatsCeil(minSendable)
2626

27+
/**
28+
* True when the LNURL-pay endpoint specifies a single exact amount.
29+
*
30+
* This also covers the sub-sat edge case where `minSendable` and `maxSendable` differ
31+
* in their sub-sat fraction but map to the same (or inverted) sat range after rounding,
32+
* e.g. `minSendable = 500500, maxSendable = 500500` → `minSendableSat() = 501, maxSendableSat() = 500`.
33+
*/
34+
fun LnurlPayData.isFixedAmount(): Boolean =
35+
minSendable == maxSendable || (minSendable > 0u && minSendableSat() > maxSendableSat())
36+
37+
/**
38+
* Returns the amount in millisatoshis to send in the LNURL-pay callback.
39+
*
40+
* For fixed-amount requests (including sub-sat ranges) the original msat value
41+
* from the server is returned verbatim, avoiding precision loss from the
42+
* msat→sat→msat round-trip.
43+
*
44+
* For variable-amount requests the user-selected sat amount is converted to msats.
45+
*/
46+
fun LnurlPayData.callbackAmountMsats(userSats: ULong? = null): ULong =
47+
if (isFixedAmount()) minSendable else (userSats ?: minSendableSat()) * MSATS_PER_SAT
48+
2749
fun LnurlWithdrawData.minWithdrawableSat(): ULong = msatsToSatsCeil(minWithdrawable ?: 0u)
2850
fun LnurlWithdrawData.maxWithdrawableSat(): ULong = maxWithdrawable / MSATS_PER_SAT
51+
52+
/**
53+
* True when the LNURL-withdraw endpoint specifies a single exact amount,
54+
* including the sub-sat edge case where rounding causes `min > max` in whole sats.
55+
*/
56+
fun LnurlWithdrawData.isFixedAmount(): Boolean {
57+
val min = minWithdrawable ?: 0u
58+
return min == maxWithdrawable || (min > 0u && minWithdrawableSat() > maxWithdrawableSat())
59+
}

app/src/main/java/to/bitkit/ext/PaymentDetails.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ package to.bitkit.ext
22

33
import org.lightningdevkit.ldknode.PaymentDetails
44

5-
val PaymentDetails.amountSats: ULong? get() = amountMsat?.let { it / 1000u }
5+
val PaymentDetails.amountSats: ULong?
6+
get() = amountMsat?.let { (it + 999u) / 1000u }

app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ class WakeNodeWorker @AssistedInject constructor(
192192
showDetails: Boolean,
193193
hiddenBody: String,
194194
) {
195-
val sats = event.amountMsat / 1000u
195+
val sats = (event.amountMsat + 999u) / 1000u
196196
// Save for UI to pick up
197197
cacheStore.setBackgroundReceive(
198198
NewTransactionSheetDetails(

app/src/main/java/to/bitkit/repositories/LightningRepo.kt

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -897,20 +897,29 @@ class LightningRepo @Inject constructor(
897897
runCatching { lightningService.receive(amountSats, description, expirySeconds) }
898898
}
899899

900+
suspend fun createInvoiceMsats(
901+
amountMsats: ULong,
902+
description: String,
903+
expirySeconds: UInt = 86_400u,
904+
): Result<String> = executeWhenNodeRunning("createInvoiceMsats") {
905+
updateGeoBlockState()
906+
runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) }
907+
}
908+
900909
@Suppress("ForbiddenComment")
901910
suspend fun fetchLnurlInvoice(
902911
callbackUrl: String,
903-
amountSats: ULong,
912+
amountMsats: ULong,
904913
comment: String? = null,
905914
): Result<LightningInvoice> {
906915
return runCatching {
907916
// TODO use bitkit-core getLnurlInvoice if it works with callbackUrl
908-
val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountSats, comment).getOrThrow().pr
917+
val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountMsats, comment).getOrThrow().pr
909918
val decoded = (coreService.decode(bolt11) as Scanner.Lightning).invoice
910919
return@runCatching decoded
911920
}.onFailure {
912921
Logger.error(
913-
"fetchLnurlInvoice error, url: $callbackUrl, amount: $amountSats, comment: $comment",
922+
"Failed to fetch LNURL invoice, url: '$callbackUrl', amountMsats: '$amountMsats', comment: '$comment'",
914923
it,
915924
context = TAG,
916925
)

app/src/main/java/to/bitkit/services/LightningService.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -590,15 +590,19 @@ class LightningService @Inject constructor(
590590
}
591591

592592
suspend fun receive(sat: ULong? = null, description: String, expirySecs: UInt = 3600u): String {
593+
return receiveMsats(amountMsat = sat?.let { it * 1000u }, description = description, expirySecs = expirySecs)
594+
}
595+
596+
suspend fun receiveMsats(amountMsat: ULong? = null, description: String, expirySecs: UInt = 3600u): String {
593597
val node = this.node ?: throw ServiceError.NodeNotSetup()
594598

595599
val message = description
596600

597601
return ServiceQueue.LDK.background {
598-
val bolt11Invoice: Bolt11Invoice = if (sat != null) {
602+
val bolt11Invoice: Bolt11Invoice = if (amountMsat != null) {
599603
node.bolt11Payment()
600604
.receive(
601-
amountMsat = sat * 1000u,
605+
amountMsat = amountMsat,
602606
description = Bolt11InvoiceDescription.Direct(description = message),
603607
expirySecs = expirySecs,
604608
)

app/src/main/java/to/bitkit/services/LnurlService.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,14 @@ class LnurlService @Inject constructor(
4141

4242
suspend fun fetchLnurlInvoice(
4343
callbackUrl: String,
44-
amountSats: ULong,
44+
amountMsats: ULong,
4545
comment: String? = null,
4646
): Result<LnurlPayResponse> = runCatching {
4747
Logger.debug("Fetching LNURL pay invoice from: $callbackUrl", context = TAG)
4848

4949
val response = client.get(callbackUrl) {
5050
url {
51-
parameters["amount"] = "${amountSats * 1000u}" // convert to msat
51+
parameters["amount"] = "$amountMsats"
5252
comment?.takeIf { it.isNotBlank() }?.let {
5353
parameters["comment"] = it
5454
}

app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt

Lines changed: 44 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ import to.bitkit.ext.channelId
7777
import to.bitkit.ext.claimableAtHeight
7878
import to.bitkit.ext.getClipboardText
7979
import to.bitkit.ext.getSatsPerVByteFor
80+
import to.bitkit.ext.callbackAmountMsats
81+
import to.bitkit.ext.isFixedAmount
8082
import to.bitkit.ext.maxSendableSat
8183
import to.bitkit.ext.maxWithdrawableSat
8284
import to.bitkit.ext.minSendableSat
@@ -1219,7 +1221,7 @@ class AppViewModel @Inject constructor(
12191221
val maxSendable = maxSendableLightningSats()
12201222
when (val lnurl = _sendUiState.value.lnurl) {
12211223
null -> amount <= maxSendable && lightningRepo.canSend(amount)
1222-
is LnurlParams.LnurlWithdraw -> amount < lnurl.data.maxWithdrawableSat()
1224+
is LnurlParams.LnurlWithdraw -> amount <= lnurl.data.maxWithdrawableSat()
12231225
is LnurlParams.LnurlPay -> {
12241226
val maxSat = lnurl.data.maxSendableSat()
12251227
amount <= maxSat && amount <= maxSendable && lightningRepo.canSend(amount)
@@ -1465,10 +1467,10 @@ class AppViewModel @Inject constructor(
14651467
private suspend fun onScanLnurlPay(data: LnurlPayData) {
14661468
Logger.debug("LNURL: $data", context = TAG)
14671469

1468-
val minSendable = data.minSendableSat()
1469-
val maxSendable = data.maxSendableSat()
1470+
val isFixed = data.isFixedAmount()
1471+
val displaySats = data.minSendableSat()
14701472

1471-
if (!lightningRepo.canSend(minSendable)) {
1473+
if (!lightningRepo.canSend(displaySats.coerceAtLeast(1u))) {
14721474
toast(
14731475
type = Toast.ToastType.WARNING,
14741476
title = context.getString(R.string.other__lnurl_pay_error),
@@ -1477,8 +1479,7 @@ class AppViewModel @Inject constructor(
14771479
return
14781480
}
14791481

1480-
val hasAmount = minSendable == maxSendable && minSendable > 0u
1481-
val initialAmount = if (hasAmount) minSendable else 0u
1482+
val initialAmount = if (isFixed) displaySats else 0u
14821483

14831484
_sendUiState.update {
14841485
it.copy(
@@ -1488,10 +1489,10 @@ class AppViewModel @Inject constructor(
14881489
)
14891490
}
14901491

1491-
if (hasAmount) {
1492-
Logger.info("Found amount $$minSendable in lnurlPay, proceeding with payment", context = TAG)
1492+
if (isFixed) {
1493+
Logger.info("Found fixed amount '$displaySats' sats in lnurlPay, proceeding with payment", context = TAG)
14931494

1494-
val quickPayHandled = handleQuickPayIfApplicable(amountSats = minSendable, lnurlPay = data)
1495+
val quickPayHandled = handleQuickPayIfApplicable(amountSats = displaySats, lnurlPay = data)
14951496
if (quickPayHandled) return
14961497

14971498
if (isMainScanner) {
@@ -1513,10 +1514,11 @@ class AppViewModel @Inject constructor(
15131514
private suspend fun onScanLnurlWithdraw(data: LnurlWithdrawData) {
15141515
Logger.debug("LNURL: $data", context = TAG)
15151516

1517+
val isFixed = data.isFixedAmount()
15161518
val minWithdrawable = data.minWithdrawableSat()
15171519
val maxWithdrawable = data.maxWithdrawableSat()
15181520

1519-
if (minWithdrawable > maxWithdrawable) {
1521+
if (!isFixed && minWithdrawable > maxWithdrawable) {
15201522
toast(
15211523
type = Toast.ToastType.WARNING,
15221524
title = context.getString(R.string.other__lnurl_withdr_error),
@@ -1525,15 +1527,17 @@ class AppViewModel @Inject constructor(
15251527
return
15261528
}
15271529

1530+
val displayAmount = minWithdrawable
1531+
15281532
_sendUiState.update {
15291533
it.copy(
15301534
payMethod = SendMethod.LIGHTNING,
1531-
amount = minWithdrawable,
1535+
amount = displayAmount,
15321536
lnurl = LnurlParams.LnurlWithdraw(data = data)
15331537
)
15341538
}
15351539

1536-
if (minWithdrawable == maxWithdrawable) {
1540+
if (isFixed || minWithdrawable == maxWithdrawable) {
15371541
delay(TRANSITION_SCREEN_MS)
15381542
if (isMainScanner) {
15391543
showSheet(Sheet.Send(SendRoute.WithdrawConfirm))
@@ -1642,7 +1646,11 @@ class AppViewModel @Inject constructor(
16421646

16431647
val quickPayData: QuickPayData = when {
16441648
lnurlPay != null -> {
1645-
QuickPayData.LnurlPay(sats = amountSats, callback = lnurlPay.callback)
1649+
QuickPayData.LnurlPay(
1650+
sats = amountSats,
1651+
callback = lnurlPay.callback,
1652+
amountMsats = lnurlPay.callbackAmountMsats(amountSats),
1653+
)
16461654
}
16471655

16481656
else -> {
@@ -1766,9 +1774,10 @@ class AppViewModel @Inject constructor(
17661774
val isLnurlPay = lnurl is LnurlParams.LnurlPay
17671775

17681776
if (isLnurlPay) {
1777+
val amountMsats = lnurl.data.callbackAmountMsats(amount)
17691778
lightningRepo.fetchLnurlInvoice(
17701779
callbackUrl = lnurl.data.callback,
1771-
amountSats = amount,
1780+
amountMsats = amountMsats,
17721781
comment = _sendUiState.value.comment.takeIf { it.isNotEmpty() },
17731782
).onSuccess { invoice ->
17741783
_sendUiState.update {
@@ -1816,8 +1825,8 @@ class AppViewModel @Inject constructor(
18161825
val decodedInvoice = requireNotNull(_sendUiState.value.decodedInvoice)
18171826
val bolt11 = decodedInvoice.bolt11
18181827

1819-
// Determine if we should override amount
1820-
val paymentAmount = decodedInvoice.amountSatoshis.takeIf { it > 0uL } ?: amount
1828+
val paymentAmount = if (decodedInvoice.amountSatoshis > 0uL) null else amount
1829+
val displayAmountSats = decodedInvoice.amountSatoshis.takeIf { it > 0uL } ?: amount ?: 0uL
18211830

18221831
val tags = _sendUiState.value.selectedTags
18231832
var createdMetadataPaymentId: String? = null
@@ -1845,14 +1854,14 @@ class AppViewModel @Inject constructor(
18451854
type = NewTransactionSheetType.LIGHTNING,
18461855
direction = NewTransactionSheetDirection.SENT,
18471856
paymentHashOrTxId = actualPaymentHash,
1848-
sats = paymentAmount.toLong(), // TODO Add fee when available
1857+
sats = displayAmountSats.toLong(), // TODO Add fee when available
18491858
),
18501859
)
18511860
}.onFailure {
18521861
if (it is PaymentPendingException) {
18531862
Logger.info("Lightning payment pending", context = TAG)
18541863
pendingPaymentRepo.track(it.paymentHash)
1855-
setSendEffect(SendEffect.NavigateToPending(it.paymentHash, paymentAmount.toLong()))
1864+
setSendEffect(SendEffect.NavigateToPending(it.paymentHash, displayAmountSats.toLong()))
18561865
return@onFailure
18571866
}
18581867
// Delete pre-activity metadata on failure
@@ -1877,19 +1886,23 @@ class AppViewModel @Inject constructor(
18771886
return@launch
18781887
}
18791888

1880-
_sendUiState.update {
1881-
it.copy(
1882-
amount = it.amount.coerceAtLeast(
1883-
(lnurl.data.minWithdrawable ?: 0u) / 1000u
1884-
)
1889+
val invoice = if (lnurl.data.isFixedAmount()) {
1890+
lightningRepo.createInvoiceMsats(
1891+
amountMsats = lnurl.data.maxWithdrawable,
1892+
description = lnurl.data.defaultDescription,
1893+
expirySeconds = 3600u,
18851894
)
1886-
}
1887-
1888-
val invoice = lightningRepo.createInvoice(
1889-
amountSats = _sendUiState.value.amount,
1890-
description = lnurl.data.defaultDescription,
1891-
expirySeconds = 3600u,
1892-
).getOrNull()
1895+
} else {
1896+
val withdrawAmountSats = _sendUiState.value.amount.coerceAtLeast(
1897+
(lnurl.data.minWithdrawable ?: 0u) / 1000u
1898+
)
1899+
_sendUiState.update { it.copy(amount = withdrawAmountSats) }
1900+
lightningRepo.createInvoice(
1901+
amountSats = withdrawAmountSats,
1902+
description = lnurl.data.defaultDescription,
1903+
expirySeconds = 3600u,
1904+
)
1905+
}.getOrNull()
18931906

18941907
if (invoice == null) {
18951908
setSendEffect(SendEffect.NavigateToWithdrawError)
@@ -2630,6 +2643,6 @@ sealed interface QuickPayData {
26302643
data class Bolt11(override val sats: ULong, val bolt11: String) : QuickPayData
26312644

26322645
@Stable
2633-
data class LnurlPay(override val sats: ULong, val callback: String) : QuickPayData
2646+
data class LnurlPay(override val sats: ULong, val callback: String, val amountMsats: ULong) : QuickPayData
26342647
}
26352648
// endregion

app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ class ProbingToolViewModel @Inject constructor(
179179

180180
is Scanner.LnurlPay -> {
181181
val amount = amountSats ?: return@runCatching null
182-
lightningRepo.fetchLnurlInvoice(decoded.data.callback, amount).getOrThrow().bolt11
182+
lightningRepo.fetchLnurlInvoice(decoded.data.callback, amount * 1000u).getOrThrow().bolt11
183183
}
184184

185185
else -> null

0 commit comments

Comments
 (0)