Skip to content

Commit 03403b3

Browse files
ben-kaufmanclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 01ec250 commit 03403b3

7 files changed

Lines changed: 157 additions & 28 deletions

File tree

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,43 @@ 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+
}
60+
61+
/**
62+
* The amount in whole sats to use when creating a withdraw invoice for a fixed-amount request.
63+
*
64+
* Uses floor division so the invoice amount never exceeds `maxWithdrawable` in msats.
65+
*/
66+
fun LnurlWithdrawData.fixedWithdrawAmountSat(): ULong = maxWithdrawable / MSATS_PER_SAT

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -900,17 +900,17 @@ class LightningRepo @Inject constructor(
900900
@Suppress("ForbiddenComment")
901901
suspend fun fetchLnurlInvoice(
902902
callbackUrl: String,
903-
amountSats: ULong,
903+
amountMsats: ULong,
904904
comment: String? = null,
905905
): Result<LightningInvoice> {
906906
return runCatching {
907907
// TODO use bitkit-core getLnurlInvoice if it works with callbackUrl
908-
val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountSats, comment).getOrThrow().pr
908+
val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountMsats, comment).getOrThrow().pr
909909
val decoded = (coreService.decode(bolt11) as Scanner.Lightning).invoice
910910
return@runCatching decoded
911911
}.onFailure {
912912
Logger.error(
913-
"fetchLnurlInvoice error, url: $callbackUrl, amount: $amountSats, comment: $comment",
913+
"fetchLnurlInvoice error, url: $callbackUrl, amountMsats: $amountMsats, comment: $comment",
914914
it,
915915
context = TAG,
916916
)

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: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ 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.fixedWithdrawAmountSat
82+
import to.bitkit.ext.isFixedAmount
8083
import to.bitkit.ext.maxSendableSat
8184
import to.bitkit.ext.maxWithdrawableSat
8285
import to.bitkit.ext.minSendableSat
@@ -1161,7 +1164,7 @@ class AppViewModel @Inject constructor(
11611164
val maxSendable = maxSendableLightningSats()
11621165
when (val lnurl = _sendUiState.value.lnurl) {
11631166
null -> amount <= maxSendable && lightningRepo.canSend(amount)
1164-
is LnurlParams.LnurlWithdraw -> amount < lnurl.data.maxWithdrawableSat()
1167+
is LnurlParams.LnurlWithdraw -> amount <= lnurl.data.maxWithdrawableSat()
11651168
is LnurlParams.LnurlPay -> {
11661169
val maxSat = lnurl.data.maxSendableSat()
11671170
amount <= maxSat && amount <= maxSendable && lightningRepo.canSend(amount)
@@ -1405,10 +1408,10 @@ class AppViewModel @Inject constructor(
14051408
private suspend fun onScanLnurlPay(data: LnurlPayData) {
14061409
Logger.debug("LNURL: $data", context = TAG)
14071410

1408-
val minSendable = data.minSendableSat()
1409-
val maxSendable = data.maxSendableSat()
1411+
val isFixed = data.isFixedAmount()
1412+
val displaySats = if (isFixed) data.maxSendableSat() else data.minSendableSat()
14101413

1411-
if (!lightningRepo.canSend(minSendable)) {
1414+
if (!lightningRepo.canSend(displaySats.coerceAtLeast(1u))) {
14121415
toast(
14131416
type = Toast.ToastType.WARNING,
14141417
title = context.getString(R.string.other__lnurl_pay_error),
@@ -1417,8 +1420,7 @@ class AppViewModel @Inject constructor(
14171420
return
14181421
}
14191422

1420-
val hasAmount = minSendable == maxSendable && minSendable > 0u
1421-
val initialAmount = if (hasAmount) minSendable else 0u
1423+
val initialAmount = if (isFixed) displaySats else 0u
14221424

14231425
_sendUiState.update {
14241426
it.copy(
@@ -1428,10 +1430,10 @@ class AppViewModel @Inject constructor(
14281430
)
14291431
}
14301432

1431-
if (hasAmount) {
1432-
Logger.info("Found amount $$minSendable in lnurlPay, proceeding with payment", context = TAG)
1433+
if (isFixed) {
1434+
Logger.info("Found fixed amount $displaySats sats in lnurlPay, proceeding with payment", context = TAG)
14331435

1434-
val quickPayHandled = handleQuickPayIfApplicable(amountSats = minSendable, lnurlPay = data)
1436+
val quickPayHandled = handleQuickPayIfApplicable(amountSats = displaySats, lnurlPay = data)
14351437
if (quickPayHandled) return
14361438

14371439
if (isMainScanner) {
@@ -1453,10 +1455,11 @@ class AppViewModel @Inject constructor(
14531455
private suspend fun onScanLnurlWithdraw(data: LnurlWithdrawData) {
14541456
Logger.debug("LNURL: $data", context = TAG)
14551457

1458+
val isFixed = data.isFixedAmount()
14561459
val minWithdrawable = data.minWithdrawableSat()
14571460
val maxWithdrawable = data.maxWithdrawableSat()
14581461

1459-
if (minWithdrawable > maxWithdrawable) {
1462+
if (!isFixed && minWithdrawable > maxWithdrawable) {
14601463
toast(
14611464
type = Toast.ToastType.WARNING,
14621465
title = context.getString(R.string.other__lnurl_withdr_error),
@@ -1465,15 +1468,17 @@ class AppViewModel @Inject constructor(
14651468
return
14661469
}
14671470

1471+
val displayAmount = if (isFixed) data.fixedWithdrawAmountSat() else minWithdrawable
1472+
14681473
_sendUiState.update {
14691474
it.copy(
14701475
payMethod = SendMethod.LIGHTNING,
1471-
amount = minWithdrawable,
1476+
amount = displayAmount,
14721477
lnurl = LnurlParams.LnurlWithdraw(data = data)
14731478
)
14741479
}
14751480

1476-
if (minWithdrawable == maxWithdrawable) {
1481+
if (isFixed || minWithdrawable == maxWithdrawable) {
14771482
delay(TRANSITION_SCREEN_MS)
14781483
if (isMainScanner) {
14791484
showSheet(Sheet.Send(SendRoute.WithdrawConfirm))
@@ -1582,7 +1587,11 @@ class AppViewModel @Inject constructor(
15821587

15831588
val quickPayData: QuickPayData = when {
15841589
lnurlPay != null -> {
1585-
QuickPayData.LnurlPay(sats = amountSats, callback = lnurlPay.callback)
1590+
QuickPayData.LnurlPay(
1591+
sats = amountSats,
1592+
callback = lnurlPay.callback,
1593+
amountMsats = lnurlPay.callbackAmountMsats(amountSats),
1594+
)
15861595
}
15871596

15881597
else -> {
@@ -1706,9 +1715,10 @@ class AppViewModel @Inject constructor(
17061715
val isLnurlPay = lnurl is LnurlParams.LnurlPay
17071716

17081717
if (isLnurlPay) {
1718+
val amountMsats = lnurl.data.callbackAmountMsats(amount)
17091719
lightningRepo.fetchLnurlInvoice(
17101720
callbackUrl = lnurl.data.callback,
1711-
amountSats = amount,
1721+
amountMsats = amountMsats,
17121722
comment = _sendUiState.value.comment.takeIf { it.isNotEmpty() },
17131723
).onSuccess { invoice ->
17141724
_sendUiState.update {
@@ -1817,16 +1827,20 @@ class AppViewModel @Inject constructor(
18171827
return@launch
18181828
}
18191829

1820-
_sendUiState.update {
1821-
it.copy(
1822-
amount = it.amount.coerceAtLeast(
1823-
(lnurl.data.minWithdrawable ?: 0u) / 1000u
1824-
)
1830+
val withdrawAmountSats = if (lnurl.data.isFixedAmount()) {
1831+
lnurl.data.fixedWithdrawAmountSat()
1832+
} else {
1833+
_sendUiState.value.amount.coerceAtLeast(
1834+
(lnurl.data.minWithdrawable ?: 0u) / 1000u
18251835
)
18261836
}
18271837

1838+
_sendUiState.update {
1839+
it.copy(amount = withdrawAmountSats)
1840+
}
1841+
18281842
val invoice = lightningRepo.createInvoice(
1829-
amountSats = _sendUiState.value.amount,
1843+
amountSats = withdrawAmountSats,
18301844
description = lnurl.data.defaultDescription,
18311845
expirySeconds = 3600u,
18321846
).getOrNull()
@@ -2567,6 +2581,6 @@ sealed interface QuickPayData {
25672581
data class Bolt11(override val sats: ULong, val bolt11: String) : QuickPayData
25682582

25692583
@Stable
2570-
data class LnurlPay(override val sats: ULong, val callback: String) : QuickPayData
2584+
data class LnurlPay(override val sats: ULong, val callback: String, val amountMsats: ULong) : QuickPayData
25712585
}
25722586
// 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

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class QuickPayViewModel @Inject constructor(
4747

4848
is QuickPayData.LnurlPay -> {
4949
Logger.info("QuickPay: fetching LNURL Pay invoice from callback")
50-
val invoice = lightningRepo.fetchLnurlInvoice(callbackUrl = data.callback, amountSats = data.sats)
50+
val invoice = lightningRepo.fetchLnurlInvoice(callbackUrl = data.callback, amountMsats = data.amountMsats)
5151
.getOrElse { error ->
5252
_uiState.update {
5353
it.copy(result = QuickPayResult.Error(error.message.orEmpty()))

app/src/test/java/to/bitkit/ext/LnurlExtTest.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,81 @@ class LnurlExtTest : BaseUnitTest() {
7474
val nonRoundMin = nullMin.copy(minWithdrawable = 1_500u)
7575
assertEquals(2u, nonRoundMin.minWithdrawableSat())
7676
}
77+
78+
@Test
79+
fun `isFixedAmount returns true when min equals max`() {
80+
val data = lnurlPayData(minSendable = 5_000u, maxSendable = 5_000u)
81+
assertEquals(true, data.isFixedAmount())
82+
}
83+
84+
@Test
85+
fun `isFixedAmount returns true for sub-sat fixed amount`() {
86+
val data = lnurlPayData(minSendable = 500_500u, maxSendable = 500_500u)
87+
assertEquals(501u, data.minSendableSat())
88+
assertEquals(500u, data.maxSendableSat())
89+
assertEquals(true, data.isFixedAmount())
90+
}
91+
92+
@Test
93+
fun `isFixedAmount returns false for variable range`() {
94+
val data = lnurlPayData(minSendable = 1_000u, maxSendable = 100_000u)
95+
assertEquals(false, data.isFixedAmount())
96+
}
97+
98+
@Test
99+
fun `callbackAmountMsats returns original msats for fixed amount`() {
100+
val data = lnurlPayData(minSendable = 500_500u, maxSendable = 500_500u)
101+
assertEquals(500_500u, data.callbackAmountMsats(500u))
102+
}
103+
104+
@Test
105+
fun `callbackAmountMsats converts user sats for variable amount`() {
106+
val data = lnurlPayData(minSendable = 1_000u, maxSendable = 100_000u)
107+
assertEquals(50_000u, data.callbackAmountMsats(50u))
108+
}
109+
110+
@Test
111+
fun `withdraw isFixedAmount returns true for sub-sat fixed amount`() {
112+
val data = withdrawData(minWithdrawable = 500_500u, maxWithdrawable = 500_500u)
113+
assertEquals(true, data.isFixedAmount())
114+
}
115+
116+
@Test
117+
fun `withdraw isFixedAmount returns false for variable range`() {
118+
val data = withdrawData(minWithdrawable = 1_000u, maxWithdrawable = 100_000u)
119+
assertEquals(false, data.isFixedAmount())
120+
}
121+
122+
@Test
123+
fun `fixedWithdrawAmountSat floors to avoid exceeding max`() {
124+
val data = withdrawData(minWithdrawable = 500_500u, maxWithdrawable = 500_500u)
125+
assertEquals(500u, data.fixedWithdrawAmountSat())
126+
}
127+
128+
private fun lnurlPayData(
129+
minSendable: ULong = 1_000u,
130+
maxSendable: ULong = 100_000u,
131+
) = LnurlPayData(
132+
uri = "lnurl",
133+
callback = "callback",
134+
minSendable = minSendable,
135+
maxSendable = maxSendable,
136+
metadataStr = "[]",
137+
commentAllowed = null,
138+
allowsNostr = false,
139+
nostrPubkey = null,
140+
)
141+
142+
private fun withdrawData(
143+
minWithdrawable: ULong? = null,
144+
maxWithdrawable: ULong = 1_000u,
145+
) = LnurlWithdrawData(
146+
uri = "lnurl",
147+
callback = "callback",
148+
k1 = "k1",
149+
defaultDescription = "desc",
150+
minWithdrawable = minWithdrawable,
151+
maxWithdrawable = maxWithdrawable,
152+
tag = "withdraw",
153+
)
77154
}

0 commit comments

Comments
 (0)