Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
38 changes: 38 additions & 0 deletions app/src/main/java/to/bitkit/ext/Lnurl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
ovitrif marked this conversation as resolved.
Outdated
6 changes: 3 additions & 3 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -900,17 +900,17 @@ class LightningRepo @Inject constructor(
@Suppress("ForbiddenComment")
suspend fun fetchLnurlInvoice(
callbackUrl: String,
amountSats: ULong,
amountMsats: ULong,
comment: String? = null,
): Result<LightningInvoice> {
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",
Comment thread
ovitrif marked this conversation as resolved.
Outdated
it,
context = TAG,
)
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/to/bitkit/services/LnurlService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@ class LnurlService @Inject constructor(

suspend fun fetchLnurlInvoice(
callbackUrl: String,
amountSats: ULong,
amountMsats: ULong,
comment: String? = null,
): Result<LnurlPayResponse> = 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
}
Expand Down
64 changes: 39 additions & 25 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
import to.bitkit.ext.isFixedAmount
import to.bitkit.ext.maxSendableSat
import to.bitkit.ext.maxWithdrawableSat
import to.bitkit.ext.minSendableSat
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand All @@ -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(
Expand All @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLAUDE.md violation: log parameter values must be wrapped in single quotes

Per CLAUDE.md:

ALWAYS wrap parameter values in log messages with single quotes, e.g. Logger.info("Received event '$eventName'", context = TAG)

Suggested change
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 = minSendable, lnurlPay = data)
val quickPayHandled = handleQuickPayIfApplicable(amountSats = displaySats, lnurlPay = data)
if (quickPayHandled) return

if (isMainScanner) {
Expand All @@ -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),
Expand All @@ -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))
Expand Down Expand Up @@ -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 -> {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1756,8 +1766,8 @@ 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
val paymentAmount = if (decodedInvoice.amountSatoshis > 0uL) null else amount
val displayAmountSats = decodedInvoice.amountSatoshis.takeIf { it > 0uL } ?: amount ?: 0uL

val tags = _sendUiState.value.selectedTags
var createdMetadataPaymentId: String? = null
Expand Down Expand Up @@ -1785,14 +1795,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
Expand All @@ -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()
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,22 +39,22 @@

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
Triple(data.bolt11, null, data.sats)
}

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)

Check warning

Code scanning / detekt

Reports lines with exceeded length Warning

Exceeded max line length (120)

Check warning

Code scanning / detekt

Line detected, which is longer than the defined maximum line length in the code style. Warning

Line detected, which is longer than the defined maximum line length in the code style.

Check warning

Code scanning / detekt

Reports incorrect argument list wrapping Warning

Argument should be on a separate line (unless all arguments can fit a single line)

Check warning

Code scanning / detekt

Reports incorrect argument list wrapping Warning

Argument should be on a separate line (unless all arguments can fit a single line)

Check warning

Code scanning / detekt

Reports incorrect argument list wrapping Warning

Missing newline before ")"
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
.getOrElse { error ->
_uiState.update {
it.copy(result = QuickPayResult.Error(error.message.orEmpty()))
}
return@launch
}
invoice.bolt11 to data.sats
Triple(invoice.bolt11, data.sats, data.sats)
Comment thread
ovitrif marked this conversation as resolved.
Outdated
}
}

Expand All @@ -65,7 +65,7 @@
it.copy(
result = QuickPayResult.Success(
paymentHash = paymentHash,
amountWithFee = amount.toLong() // TODO GET FEE WHEN AVAILABLE
amountWithFee = displaySats.toLong() // TODO GET FEE WHEN AVAILABLE
)
)
}
Expand All @@ -77,7 +77,7 @@
it.copy(
result = QuickPayResult.Pending(
paymentHash = error.paymentHash,
amount = amount.toLong(),
amount = displaySats.toLong(),
)
)
}
Expand Down
77 changes: 77 additions & 0 deletions app/src/test/java/to/bitkit/ext/LnurlExtTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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",
)
}
Loading
Loading