From abffb06254710f5bfd3733adb56c4fdbb7350364 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 18 Feb 2026 08:51:24 -0300 Subject: [PATCH 1/6] fix: invoice amount validation and improve error message --- .../ui/screens/settings/ProbingToolScreen.kt | 3 + .../bitkit/viewmodels/ProbingToolViewModel.kt | 70 +++++++++++++++---- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/ProbingToolScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/ProbingToolScreen.kt index 2041345ce..d3e2da661 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/ProbingToolScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/ProbingToolScreen.kt @@ -137,6 +137,9 @@ private fun ProbingToolContent( if (uiState.isLnurlPay) { SectionHeader("AMOUNT (REQUIRED)") SectionFooter("Enter the amount in sats to probe via LNURL") + } else if (uiState.isZeroAmountInvoice) { + SectionHeader("AMOUNT (OPTIONAL)") + SectionFooter("Enter amount in sats, or leave empty to probe with 1 sat") } else { SectionHeader("AMOUNT OVERRIDE (OPTIONAL)") SectionFooter("Override the invoice amount for variable-amount invoices") diff --git a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt index c71d70b1d..47546cc75 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher import to.bitkit.ext.maxSendableSat import to.bitkit.ext.minSendableSat +import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.Toast import to.bitkit.repositories.LightningRepo import to.bitkit.services.CoreService @@ -74,7 +75,9 @@ class ProbingToolViewModel @Inject constructor( viewModelScope.launch(bgDispatcher) { _uiState.update { it.copy(isLoading = true, probeResult = null) } - val amountSats = _uiState.value.amountSats.toULongOrNull() + val userAmount = _uiState.value.amountSats.toULongOrNull() + val amountSats = userAmount ?: if (_uiState.value.isZeroAmountInvoice) 1uL else null + val bolt11 = extractBolt11Invoice(input, amountSats) if (bolt11 == null) { ToastEventBus.send( @@ -86,6 +89,18 @@ class ProbingToolViewModel @Inject constructor( return@launch } + val effectiveAmount = amountSats ?: getInvoiceAmount(input) + if (effectiveAmount != null && effectiveAmount > 0uL && !lightningRepo.canSend(effectiveAmount)) { + val outbound = lightningRepo.lightningState.value.channels.totalNextOutboundHtlcLimitSats() + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = "Amount exceeds outbound capacity", + description = "Available: $outbound sats", + ) + _uiState.update { it.copy(isLoading = false) } + return@launch + } + val startTime = System.currentTimeMillis() lightningRepo.sendProbeForInvoice(bolt11, amountSats) @@ -99,18 +114,25 @@ class ProbingToolViewModel @Inject constructor( private fun detectInputType(input: String) { viewModelScope.launch(bgDispatcher) { val data = runCatching { coreService.decode(input.trim()) }.getOrNull() - if (data is Scanner.LnurlPay) { - val min = data.data.minSendableSat() - val max = data.data.maxSendableSat() - val isFixed = min == max && min > 0uL - _uiState.update { - it.copy( - isLnurlPay = true, - amountSats = if (isFixed) min.toString() else it.amountSats, - ) + when { + data is Scanner.LnurlPay -> { + val min = data.data.minSendableSat() + val max = data.data.maxSendableSat() + val isFixed = min == max && min > 0uL + _uiState.update { + it.copy( + isLnurlPay = true, + isZeroAmountInvoice = false, + amountSats = if (isFixed) min.toString() else it.amountSats, + ) + } + } + data is Scanner.Lightning && data.invoice.amountSatoshis == 0uL -> { + _uiState.update { it.copy(isLnurlPay = false, isZeroAmountInvoice = true) } + } + else -> { + _uiState.update { it.copy(isLnurlPay = false, isZeroAmountInvoice = false) } } - } else { - _uiState.update { it.copy(isLnurlPay = false) } } } } @@ -145,12 +167,20 @@ class ProbingToolViewModel @Inject constructor( val durationMs = System.currentTimeMillis() - startTime Logger.error("Probe failed in ${durationMs}ms", error, context = TAG) + val friendlyMessage = getFriendlyErrorMessage(error) _uiState.update { - it.copy(probeResult = ProbeResult(success = false, durationMs = durationMs, errorMessage = error.message)) + it.copy(probeResult = ProbeResult(success = false, durationMs = durationMs, errorMessage = friendlyMessage)) } - ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Probe failed", description = error.message) + ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Probe failed", description = friendlyMessage) } + private suspend fun getInvoiceAmount(input: String): ULong? = runCatching { + when (val decoded = coreService.decode(input.trim())) { + is Scanner.Lightning -> decoded.invoice.amountSatoshis.takeIf { it > 0uL } + else -> null + } + }.getOrNull() + private suspend fun getEstimatedFee(invoice: String, amountSats: ULong?): ULong? = run { if (amountSats != null) { lightningRepo.estimateRoutingFeesForAmount(invoice, amountSats) @@ -161,6 +191,17 @@ class ProbingToolViewModel @Inject constructor( companion object { private const val TAG = "ProbingToolViewModel" + + private fun getFriendlyErrorMessage(error: Throwable): String { + val msg = error.message ?: return "Unknown error" + return when { + msg.contains("RouteNotFound", ignoreCase = true) -> "No route found to destination" + msg.contains("InsufficientFunds", ignoreCase = true) -> "Insufficient funds for this probe" + msg.contains("PaymentPathFailed", ignoreCase = true) -> "Payment path failed" + msg.contains("SendingFailed", ignoreCase = true) -> "Probe sending failed" + else -> msg + } + } } } @@ -170,6 +211,7 @@ data class ProbingToolUiState( val amountSats: String = "", val isLoading: Boolean = false, val isLnurlPay: Boolean = false, + val isZeroAmountInvoice: Boolean = false, val probeResult: ProbeResult? = null, ) From a80db2c2b724f6bdb1183be91002ea16a2cf024d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 19 Feb 2026 11:23:54 -0300 Subject: [PATCH 2/6] refactor: replace if with takeIf --- .../main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt index 47546cc75..7824b5deb 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt @@ -76,7 +76,7 @@ class ProbingToolViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true, probeResult = null) } val userAmount = _uiState.value.amountSats.toULongOrNull() - val amountSats = userAmount ?: if (_uiState.value.isZeroAmountInvoice) 1uL else null + val amountSats = userAmount ?: 1uL.takeIf { _uiState.value.isZeroAmountInvoice } val bolt11 = extractBolt11Invoice(input, amountSats) if (bolt11 == null) { @@ -127,9 +127,11 @@ class ProbingToolViewModel @Inject constructor( ) } } + data is Scanner.Lightning && data.invoice.amountSatoshis == 0uL -> { _uiState.update { it.copy(isLnurlPay = false, isZeroAmountInvoice = true) } } + else -> { _uiState.update { it.copy(isLnurlPay = false, isZeroAmountInvoice = false) } } @@ -144,10 +146,12 @@ class ProbingToolViewModel @Inject constructor( val lightningParam = decoded.invoice.params?.get("lightning") ?: return@runCatching null (coreService.decode(lightningParam) as? Scanner.Lightning)?.invoice?.bolt11 } + is Scanner.LnurlPay -> { val amount = amountSats ?: return@runCatching null lightningRepo.fetchLnurlInvoice(decoded.data.callback, amount).getOrThrow().bolt11 } + else -> null } }.getOrNull() From 3e4aa0b95acd2a296d4cc648fc5c134609b49f65 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 19 Feb 2026 11:26:04 -0300 Subject: [PATCH 3/6] refactor: lift data --- .../main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt index 7824b5deb..47fa7f819 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt @@ -114,8 +114,8 @@ class ProbingToolViewModel @Inject constructor( private fun detectInputType(input: String) { viewModelScope.launch(bgDispatcher) { val data = runCatching { coreService.decode(input.trim()) }.getOrNull() - when { - data is Scanner.LnurlPay -> { + when (data) { + is Scanner.LnurlPay -> { val min = data.data.minSendableSat() val max = data.data.maxSendableSat() val isFixed = min == max && min > 0uL @@ -128,7 +128,7 @@ class ProbingToolViewModel @Inject constructor( } } - data is Scanner.Lightning && data.invoice.amountSatoshis == 0uL -> { + is Scanner.Lightning if data.invoice.amountSatoshis == 0uL -> { _uiState.update { it.copy(isLnurlPay = false, isZeroAmountInvoice = true) } } From 5717ab734f765ba92f462f8eb4a6c59b31f75dec Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 19 Feb 2026 11:42:23 -0300 Subject: [PATCH 4/6] feat: handle BIP21 for detectInputType --- .../java/to/bitkit/viewmodels/ProbingToolViewModel.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt index 47fa7f819..7487eda24 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt @@ -132,6 +132,15 @@ class ProbingToolViewModel @Inject constructor( _uiState.update { it.copy(isLnurlPay = false, isZeroAmountInvoice = true) } } + is Scanner.OnChain -> { + val lightningParam = data.invoice.params?.get("lightning") + val lightning = lightningParam?.let { + runCatching { coreService.decode(it) }.getOrNull() as? Scanner.Lightning + } + val isZeroAmount = lightning?.invoice?.amountSatoshis == 0uL + _uiState.update { it.copy(isLnurlPay = false, isZeroAmountInvoice = isZeroAmount) } + } + else -> { _uiState.update { it.copy(isLnurlPay = false, isZeroAmountInvoice = false) } } From 2b9e279c444cc2534dfe27a5d5197b01e3019180 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 19 Feb 2026 12:00:00 -0300 Subject: [PATCH 5/6] fix: account for routing fees in probe capacity check --- .../bitkit/viewmodels/ProbingToolViewModel.kt | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt index 7487eda24..08586b982 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt @@ -17,6 +17,7 @@ import to.bitkit.di.BgDispatcher import to.bitkit.ext.maxSendableSat import to.bitkit.ext.minSendableSat import to.bitkit.ext.totalNextOutboundHtlcLimitSats +import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.Toast import to.bitkit.repositories.LightningRepo import to.bitkit.services.CoreService @@ -90,15 +91,20 @@ class ProbingToolViewModel @Inject constructor( } val effectiveAmount = amountSats ?: getInvoiceAmount(input) - if (effectiveAmount != null && effectiveAmount > 0uL && !lightningRepo.canSend(effectiveAmount)) { - val outbound = lightningRepo.lightningState.value.channels.totalNextOutboundHtlcLimitSats() - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = "Amount exceeds outbound capacity", - description = "Available: $outbound sats", - ) - _uiState.update { it.copy(isLoading = false) } - return@launch + if (effectiveAmount != null && effectiveAmount > 0uL) { + val estimatedFee = getEstimatedFee(bolt11, amountSats) ?: 0uL + val totalRequired = effectiveAmount + estimatedFee + if (!lightningRepo.canSend(totalRequired)) { + val outbound = lightningRepo.lightningState.value.channels.totalNextOutboundHtlcLimitSats() + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = "Amount + fees exceed capacity", + description = "Needed: $BITCOIN_SYMBOL $totalRequired" + + "(includes ~$estimatedFee fee), available: $BITCOIN_SYMBOL $outbound", + ) + _uiState.update { it.copy(isLoading = false) } + return@launch + } } val startTime = System.currentTimeMillis() From 07700b65f88065504e9dcc56b7506176eac91c59 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 19 Feb 2026 12:25:18 -0300 Subject: [PATCH 6/6] fix: add treshold --- .../bitkit/viewmodels/ProbingToolViewModel.kt | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt index 08586b982..f8eb3c204 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt @@ -92,15 +92,30 @@ class ProbingToolViewModel @Inject constructor( val effectiveAmount = amountSats ?: getInvoiceAmount(input) if (effectiveAmount != null && effectiveAmount > 0uL) { - val estimatedFee = getEstimatedFee(bolt11, amountSats) ?: 0uL - val totalRequired = effectiveAmount + estimatedFee + val outbound = lightningRepo.lightningState.value.channels + .totalNextOutboundHtlcLimitSats() + val estimatedFee = getEstimatedFee(bolt11, amountSats) + + val nearCapacityThreshold = outbound * 95uL / 100uL + if (estimatedFee == null && effectiveAmount >= nearCapacityThreshold) { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = "Amount too close to capacity", + description = "Available: $BITCOIN_SYMBOL $outbound. " + + "Reduce amount to leave room for routing fees.", + ) + _uiState.update { it.copy(isLoading = false) } + return@launch + } + + val totalRequired = effectiveAmount + (estimatedFee ?: 0uL) if (!lightningRepo.canSend(totalRequired)) { - val outbound = lightningRepo.lightningState.value.channels.totalNextOutboundHtlcLimitSats() ToastEventBus.send( type = Toast.ToastType.WARNING, title = "Amount + fees exceed capacity", description = "Needed: $BITCOIN_SYMBOL $totalRequired" + - "(includes ~$estimatedFee fee), available: $BITCOIN_SYMBOL $outbound", + "(includes ~${estimatedFee ?: 0uL} fee), " + + "available: $BITCOIN_SYMBOL $outbound", ) _uiState.update { it.copy(isLoading = false) } return@launch