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
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
104 changes: 90 additions & 14 deletions app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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) }
}
}
}
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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
}
}
}
}

Expand All @@ -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,
)

Expand Down
Loading