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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Fixed
- 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
Expand Down
31 changes: 31 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,36 @@ 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())
}
15 changes: 12 additions & 3 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -897,20 +897,29 @@ class LightningRepo @Inject constructor(
runCatching { lightningService.receive(amountSats, description, expirySeconds) }
}

suspend fun createInvoiceMsats(
amountMsats: ULong,
description: String,
expirySeconds: UInt = 86_400u,
): Result<String> = executeWhenNodeRunning("createInvoiceMsats") {
updateGeoBlockState()
runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) }
}

@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",
"Failed to fetch LNURL invoice, url: '$callbackUrl', amountMsats: '$amountMsats', comment: '$comment'",
it,
context = TAG,
)
Expand Down
8 changes: 6 additions & 2 deletions app/src/main/java/to/bitkit/services/LightningService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
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
75 changes: 44 additions & 31 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,8 @@ 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.isFixedAmount
import to.bitkit.ext.maxSendableSat
import to.bitkit.ext.maxWithdrawableSat
import to.bitkit.ext.minSendableSat
Expand Down Expand Up @@ -1219,7 +1221,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 @@ -1465,10 +1467,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 = 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 @@ -1477,8 +1479,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 @@ -1488,10 +1489,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) {
Expand All @@ -1513,10 +1514,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 @@ -1525,15 +1527,17 @@ class AppViewModel @Inject constructor(
return
}

val displayAmount = 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 @@ -1642,7 +1646,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 @@ -1766,9 +1774,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 @@ -1816,8 +1825,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 @@ -1845,14 +1854,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 @@ -1877,19 +1886,23 @@ class AppViewModel @Inject constructor(
return@launch
}

_sendUiState.update {
it.copy(
amount = it.amount.coerceAtLeast(
(lnurl.data.minWithdrawable ?: 0u) / 1000u
)
val invoice = if (lnurl.data.isFixedAmount()) {
lightningRepo.createInvoiceMsats(
amountMsats = lnurl.data.maxWithdrawable,
description = lnurl.data.defaultDescription,
expirySeconds = 3600u,
)
}

val invoice = lightningRepo.createInvoice(
amountSats = _sendUiState.value.amount,
description = lnurl.data.defaultDescription,
expirySeconds = 3600u,
).getOrNull()
} else {
val withdrawAmountSats = _sendUiState.value.amount.coerceAtLeast(
(lnurl.data.minWithdrawable ?: 0u) / 1000u
)
_sendUiState.update { it.copy(amount = withdrawAmountSats) }
lightningRepo.createInvoice(
amountSats = withdrawAmountSats,
description = lnurl.data.defaultDescription,
expirySeconds = 3600u,
)
}.getOrNull()

if (invoice == null) {
setSendEffect(SendEffect.NavigateToWithdrawError)
Expand Down Expand Up @@ -2630,6 +2643,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
15 changes: 9 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,25 @@ 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
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,
)
.getOrElse { error ->
_uiState.update {
it.copy(result = QuickPayResult.Error(error.message.orEmpty()))
}
return@launch
}
invoice.bolt11 to data.sats
Triple(invoice.bolt11, null, data.sats)
}
}

Expand All @@ -65,7 +68,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
)
)
}
Expand All @@ -77,7 +80,7 @@ class QuickPayViewModel @Inject constructor(
it.copy(
result = QuickPayResult.Pending(
paymentHash = error.paymentHash,
amount = amount.toLong(),
amount = displaySats.toLong(),
)
)
}
Expand Down
Loading
Loading