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..f8eb3c204 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt @@ -16,6 +16,8 @@ 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.BITCOIN_SYMBOL import to.bitkit.models.Toast import to.bitkit.repositories.LightningRepo import to.bitkit.services.CoreService @@ -74,7 +76,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 ?: 1uL.takeIf { _uiState.value.isZeroAmountInvoice } + val bolt11 = extractBolt11Invoice(input, amountSats) if (bolt11 == null) { ToastEventBus.send( @@ -86,6 +90,38 @@ class ProbingToolViewModel @Inject constructor( return@launch } + val effectiveAmount = amountSats ?: getInvoiceAmount(input) + if (effectiveAmount != null && effectiveAmount > 0uL) { + 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)) { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = "Amount + fees exceed capacity", + description = "Needed: $BITCOIN_SYMBOL $totalRequired" + + "(includes ~${estimatedFee ?: 0uL} fee), " + + "available: $BITCOIN_SYMBOL $outbound", + ) + _uiState.update { it.copy(isLoading = false) } + return@launch + } + } + val startTime = System.currentTimeMillis() lightningRepo.sendProbeForInvoice(bolt11, amountSats) @@ -99,18 +135,36 @@ 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, + ) + } + } + + is Scanner.Lightning if data.invoice.amountSatoshis == 0uL -> { + _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) } } - } else { - _uiState.update { it.copy(isLnurlPay = false) } } } } @@ -122,10 +176,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() @@ -145,12 +201,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 +225,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 +245,7 @@ data class ProbingToolUiState( val amountSats: String = "", val isLoading: Boolean = false, val isLnurlPay: Boolean = false, + val isZeroAmountInvoice: Boolean = false, val probeResult: ProbeResult? = null, )