From 337aecddd11f300b5c746996a3af8a47c9fce3c0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 29 Apr 2026 16:53:30 +0200 Subject: [PATCH] fix: use actual probe result --- CHANGELOG.md | 1 + app/src/main/java/to/bitkit/models/USat.kt | 4 + .../to/bitkit/repositories/LightningRepo.kt | 126 ++++++++- .../to/bitkit/services/LightningService.kt | 34 ++- .../ui/screens/settings/ProbingToolScreen.kt | 41 ++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 + .../bitkit/viewmodels/ProbingToolViewModel.kt | 241 +++++++++++++----- .../test/java/to/bitkit/models/USatTest.kt | 20 ++ .../bitkit/repositories/LightningRepoTest.kt | 136 ++++++++++ .../viewmodels/ProbingToolViewModelTest.kt | 63 +++++ gradle/libs.versions.toml | 2 +- 11 files changed, 579 insertions(+), 91 deletions(-) create mode 100644 app/src/test/java/to/bitkit/viewmodels/ProbingToolViewModelTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 11cc8f9806..db4df41e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improve Pubky profile restore, contact editing, and contact routing flows #905 ### Fixed +- Fix probe results and add keysend probes #920 - Align top bar back arrow and passphrase input cursor/placeholder with iOS #906 - Polish Terms of Use screen padding to match iOS #903 diff --git a/app/src/main/java/to/bitkit/models/USat.kt b/app/src/main/java/to/bitkit/models/USat.kt index 7737ebd1db..bbd539e93c 100644 --- a/app/src/main/java/to/bitkit/models/USat.kt +++ b/app/src/main/java/to/bitkit/models/USat.kt @@ -17,6 +17,10 @@ value class USat(val value: ULong) : Comparable { /** Saturating addition: caps at ULong.MAX_VALUE if result would overflow. */ operator fun plus(other: USat): ULong = if (value <= ULong.MAX_VALUE - other.value) value + other.value else ULong.MAX_VALUE + + /** Saturating multiplication: caps at ULong.MAX_VALUE if result would overflow. */ + operator fun times(other: USat): ULong = + if (other.value == 0uL || value <= ULong.MAX_VALUE / other.value) value * other.value else ULong.MAX_VALUE } /** diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 9575864cd8..9402c01d94 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -24,8 +24,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -44,6 +47,7 @@ import org.lightningdevkit.ldknode.ClosureReason import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails +import org.lightningdevkit.ldknode.PaymentHash import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.SpendableUtxo @@ -60,6 +64,7 @@ import to.bitkit.ext.nowTimestamp import to.bitkit.ext.toPeerDetailsList import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.CoinSelectionPreference +import to.bitkit.models.MSat import to.bitkit.models.NATIVE_WITNESS_TYPES import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult @@ -121,6 +126,8 @@ class LightningRepo @Inject constructor( val isRecoveryMode = _isRecoveryMode.asStateFlow() private val channelCache = ConcurrentHashMap() + private val probeOutcomeCache = ConcurrentHashMap() + private val probeOutcomeSignal = MutableSharedFlow(extraBufferCapacity = 64) private val syncMutex = Mutex() private val syncPending = AtomicBoolean(false) @@ -420,6 +427,7 @@ class LightningRepo @Inject constructor( private suspend fun onEvent(event: Event) { handleLdkEvent(event) + recordProbeOutcome(event) _eventHandlers.toList().forEach { runCatching { it.invoke(event) } } @@ -441,12 +449,14 @@ class LightningRepo @Inject constructor( suspend fun stop(): Result = withContext(bgDispatcher) { lifecycleMutex.withLock { if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping()) { + clearProbeOutcomes() return@withLock Result.success(Unit) } runCatching { _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } lightningService.stop() + clearProbeOutcomes() _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } }.onFailure { Logger.error("Node stop error", it, context = TAG) @@ -529,6 +539,21 @@ class LightningRepo @Inject constructor( } } + private suspend fun recordProbeOutcome(event: Event) { + val outcome = when (event) { + is Event.ProbeSuccessful -> ProbeOutcome.Success(event.paymentId, event.paymentHash) + is Event.ProbeFailed -> ProbeOutcome.Failure(event.paymentId, event.paymentHash, event.shortChannelId) + else -> return + } + + probeOutcomeCache[outcome.paymentId] = outcome + probeOutcomeSignal.emit(outcome) + } + + private fun clearProbeOutcomes() { + probeOutcomeCache.clear() + } + private suspend fun registerClosedChannel(channelId: String, reason: ClosureReason?) = withContext(bgDispatcher) { runCatching { val channel = channelCache[channelId] ?: run { @@ -582,6 +607,7 @@ class LightningRepo @Inject constructor( stop().mapCatching { Logger.debug("node stopped, calling wipeStorage", context = TAG) lightningService.wipeStorage(walletIndex) + clearProbeOutcomes() _lightningState.update { LightningState( nodeStatus = it.nodeStatus, @@ -1363,23 +1389,74 @@ class LightningRepo @Inject constructor( // endregion // region probing - suspend fun sendProbeForInvoice(bolt11: String, amountSats: ULong? = null): Result = + suspend fun sendProbeForInvoice(bolt11: String, amountSats: ULong? = null): Result = executeWhenNodeRunning("sendProbeForInvoice") { Logger.debug( - "sendProbeForInvoice: amountSats=${amountSats ?: "null (using invoice amount)"}", - context = TAG + "sendProbeForInvoice: amountSats='${amountSats ?: "null (using invoice amount)"}'", + context = TAG, ) - runCatching { - if (amountSats != null) { - val amountMsat = amountSats * 1000u - lightningService.sendProbesUsingAmount(bolt11, amountMsat) - } else { - lightningService.sendProbes(bolt11) - } - }.getOrElse { - Result.failure(it) + val result = if (amountSats != null) { + val amountMsat = amountSats.safe() * MSat.PER_SAT.safe() + lightningService.sendProbesUsingAmount(bolt11, amountMsat) + } else { + lightningService.sendProbes(bolt11) } + + result.map { ProbeDispatch(paymentIds = it) } } + + suspend fun sendProbeForNode(nodeId: String, amountSats: ULong): Result = + executeWhenNodeRunning("sendProbeForNode") { + Logger.debug( + "Sending keysend probe to nodeId='$nodeId' amountSats='$amountSats'", + context = TAG, + ) + val amountMsat = amountSats.safe() * MSat.PER_SAT.safe() + lightningService.sendKeysendProbe(nodeId, amountMsat).map { + ProbeDispatch(paymentIds = it) + } + } + + suspend fun waitForProbeOutcome( + paymentIds: Set, + timeout: Duration = PROBE_TIMEOUT, + ): Result = withContext(bgDispatcher) { + if (paymentIds.isEmpty()) { + return@withContext Result.failure(ProbeError.NoProbeHandles()) + } + + val trackedIds = paymentIds.toSet() + val outcome = withTimeoutOrNull(timeout) { + val pending = trackedIds.toMutableSet() + var lastFailure: ProbeOutcome.Failure? = null + + probeOutcomeSignal + .onSubscription { + trackedIds.forEach { id -> + probeOutcomeCache[id]?.let { emit(it) } + } + } + .filter { it.paymentId in trackedIds } + .mapNotNull { probeOutcome -> + if (!pending.remove(probeOutcome.paymentId)) return@mapNotNull null + + probeOutcomeCache.remove(probeOutcome.paymentId) + when (probeOutcome) { + is ProbeOutcome.Success -> probeOutcome + is ProbeOutcome.Failure -> { + lastFailure = probeOutcome + if (pending.isEmpty()) lastFailure else null + } + } + } + .first() + } + + trackedIds.forEach { probeOutcomeCache.remove(it) } + + outcome?.let { Result.success(it) } + ?: Result.failure(ProbeError.TimedOut()) + } // endregion suspend fun restartNode(): Result = withContext(bgDispatcher) { @@ -1404,6 +1481,7 @@ class LightningRepo @Inject constructor( private const val CHANNELS_READY_TIMEOUT_MS = 15_000L private const val CHANNELS_USABLE_TIMEOUT_MS = 15_000L val SEND_LN_TIMEOUT = 10.seconds + private val PROBE_TIMEOUT = 60.seconds } } @@ -1413,6 +1491,10 @@ class NodeStopTimeoutError : AppError("Timeout waiting for node to stop") class NodeRunTimeoutError(opName: String) : AppError("Timeout waiting for node to run and execute: '$opName'") class GetPaymentsError : AppError("It wasn't possible get the payments") class SyncUnhealthyError : AppError("Wallet sync failed before send") +sealed class ProbeError(message: String) : AppError(message) { + class NoProbeHandles : ProbeError("No probe handles returned") + class TimedOut : ProbeError("Probe timed out") +} @Stable data class LightningState( @@ -1436,3 +1518,23 @@ data class LightningState( val isSyncHealthy: Boolean get() = lastSyncError == null && lastSuccessfulSyncAt != null } + +data class ProbeDispatch( + val paymentIds: Set, +) + +sealed interface ProbeOutcome { + val paymentId: PaymentId + val paymentHash: PaymentHash + + data class Success( + override val paymentId: PaymentId, + override val paymentHash: PaymentHash, + ) : ProbeOutcome + + data class Failure( + override val paymentId: PaymentId, + override val paymentHash: PaymentHash, + val shortChannelId: ULong?, + ) : ProbeOutcome +} diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 74e1a2f3a5..6fbe4925d8 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -36,6 +36,7 @@ import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PeerDetails +import org.lightningdevkit.ldknode.PublicKey import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid import org.lightningdevkit.ldknode.defaultConfig @@ -49,8 +50,8 @@ import to.bitkit.env.Env import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.ext.uByteList import to.bitkit.ext.uri -import to.bitkit.models.msatFloorOf import to.bitkit.models.OpenChannelResult +import to.bitkit.models.msatFloorOf import to.bitkit.models.toAddressType import to.bitkit.utils.AppError import to.bitkit.utils.LdkError @@ -135,6 +136,7 @@ class LightningService @Inject constructor( trustedPeersNoReserve = trustedPeerNodeIds, perChannelReserveSats = 1u, ), + probingLiquidityLimitMultiplier = 1uL, includeUntrustedPendingInSpendable = true, ) } @@ -721,7 +723,7 @@ class LightningService @Inject constructor( // endregion // region probing - suspend fun sendProbes(bolt11: String): Result { + suspend fun sendProbes(bolt11: String): Result> { val node = this.node ?: throw ServiceError.NodeNotSetup() val bolt11Invoice = runCatching { Bolt11Invoice.fromStr(bolt11) } @@ -735,8 +737,8 @@ class LightningService @Inject constructor( return ServiceQueue.LDK.background { runCatching { - node.bolt11Payment().sendProbes(bolt11Invoice, null) - Result.success(Unit) + val handles = node.bolt11Payment().sendProbes(bolt11Invoice, null) + Result.success(handles.map { it.paymentId }.toSet()) }.getOrElse { dumpNetworkGraphInfo(bolt11) Result.failure(if (it is NodeException) LdkError(it) else it) @@ -744,7 +746,7 @@ class LightningService @Inject constructor( } } - suspend fun sendProbesUsingAmount(bolt11: String, amountMsat: ULong): Result { + suspend fun sendProbesUsingAmount(bolt11: String, amountMsat: ULong): Result> { val node = this.node ?: throw ServiceError.NodeNotSetup() val bolt11Invoice = runCatching { Bolt11Invoice.fromStr(bolt11) } @@ -759,14 +761,32 @@ class LightningService @Inject constructor( return ServiceQueue.LDK.background { runCatching { - node.bolt11Payment().sendProbesUsingAmount(bolt11Invoice, amountMsat, null) - Result.success(Unit) + val handles = node.bolt11Payment().sendProbesUsingAmount(bolt11Invoice, amountMsat, null) + Result.success(handles.map { it.paymentId }.toSet()) }.getOrElse { dumpNetworkGraphInfo(bolt11) Result.failure(if (it is NodeException) LdkError(it) else it) } } } + + suspend fun sendKeysendProbe(nodeId: PublicKey, amountMsat: ULong): Result> { + val node = this.node ?: throw ServiceError.NodeNotSetup() + + Logger.debug( + "Sending keysend probe to nodeId='$nodeId' amountMsat='$amountMsat' (${msatFloorOf(amountMsat)} sats)", + context = TAG, + ) + + return ServiceQueue.LDK.background { + runCatching { + val handles = node.spontaneousPayment().sendProbes(amountMsat, nodeId) + Result.success(handles.map { it.paymentId }.toSet()) + }.getOrElse { + Result.failure(if (it is NodeException) LdkError(it) else it) + } + } + } // endregion // region utxo selection 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 0be52d4f02..10b47f31dc 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 @@ -80,6 +80,8 @@ private fun ProbingToolContent( onPasteInvoice: () -> Unit, onSendProbe: () -> Unit, ) { + val requiresAmount = uiState.isLnurlPay || uiState.isNodeId + ScreenColumn { AppTopBar( titleText = "Probing Tool", @@ -92,13 +94,13 @@ private fun ProbingToolContent( .imePadding() .verticalScroll(rememberScrollState()) ) { - SectionHeader("PROBE INVOICE", padding = PaddingValues(0.dp)) - SectionFooter("Enter a Lightning invoice or LNURL to probe the payment route") + SectionHeader("PROBE TARGET", padding = PaddingValues(0.dp)) + SectionFooter("Enter a Lightning invoice, LNURL, node ID, or node URI to probe the payment route") TextInput( value = uiState.invoice, onValueChange = onInvoiceChange, - placeholder = "lnbc...", + placeholder = "Invoice, node ID, or node URI", singleLine = false, modifier = Modifier .fillMaxWidth() @@ -125,7 +127,10 @@ private fun ProbingToolContent( ) } - if (uiState.isLnurlPay) { + if (uiState.isNodeId) { + SectionHeader("AMOUNT (REQUIRED)") + SectionFooter("Enter the amount in sats to keysend-probe to the node") + } else if (uiState.isLnurlPay) { SectionHeader("AMOUNT (REQUIRED)") SectionFooter("Enter the amount in sats to probe via LNURL") } else if (uiState.isZeroAmountInvoice) { @@ -156,7 +161,7 @@ private fun ProbingToolContent( text = "Send Probe", onClick = onSendProbe, enabled = !uiState.isLoading && uiState.invoice.isNotBlank() && - (!uiState.isLnurlPay || uiState.amountSats.isNotBlank()), + (!requiresAmount || uiState.amountSats.isNotBlank()), isLoading = uiState.isLoading, modifier = Modifier.fillMaxWidth(), ) @@ -258,3 +263,29 @@ private fun PreviewFailed() { ) } } + +@Preview(showSystemUi = true) +@Composable +private fun PreviewNodeId() { + var uiState by remember { + mutableStateOf( + ProbingToolUiState( + invoice = "02abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef", + amountSats = "1000", + isNodeId = true, + ) + ) + } + + AppThemeSurface { + ProbingToolContent( + uiState = uiState, + onBackClick = {}, + onScanClick = {}, + onInvoiceChange = { uiState = uiState.copy(invoice = it) }, + onAmountChange = { uiState = uiState.copy(amountSats = it) }, + onPasteInvoice = {}, + onSendProbe = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 46d9731054..7c1ad3b4ec 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -382,6 +382,8 @@ class AppViewModel @Inject constructor( is Event.PaymentForwarded -> Unit is Event.PaymentReceived -> handlePaymentReceived(event) is Event.PaymentSuccessful -> handlePaymentSuccessful(event) + is Event.ProbeFailed -> Unit + is Event.ProbeSuccessful -> Unit is Event.SpliceFailed -> Unit is Event.SplicePending -> Unit is Event.SyncCompleted -> handleSyncCompleted() diff --git a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt index ffe6cfb85d..3f55336591 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt @@ -1,6 +1,5 @@ package to.bitkit.viewmodels -import android.content.ClipboardManager import android.content.Context import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel @@ -14,12 +13,15 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher +import to.bitkit.ext.getClipboardText 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.repositories.ProbeError +import to.bitkit.repositories.ProbeOutcome import to.bitkit.services.CoreService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger @@ -47,9 +49,7 @@ class ProbingToolViewModel @Inject constructor( } fun pasteInvoice() { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clipData = clipboard.primaryClip - val pastedInvoice = clipData?.getItemAt(0)?.text?.toString()?.trim() + val pastedInvoice = context.getClipboardText()?.trim() if (pastedInvoice.isNullOrEmpty()) { viewModelScope.launch { @@ -64,11 +64,12 @@ class ProbingToolViewModel @Inject constructor( updateInvoice(pastedInvoice) } + @Suppress("CyclomaticComplexMethod", "LongMethod", "ReturnCount") fun sendProbe() { val input = _uiState.value.invoice.trim() if (input.isEmpty()) { viewModelScope.launch { - ToastEventBus.send(type = Toast.ToastType.WARNING, title = "Please enter an invoice") + ToastEventBus.send(type = Toast.ToastType.WARNING, title = "Please enter an invoice or node ID") } return } @@ -76,99 +77,149 @@ class ProbingToolViewModel @Inject constructor( viewModelScope.launch(bgDispatcher) { _uiState.update { it.copy(isLoading = true, probeResult = null) } - val userAmount = _uiState.value.amountSats.toULongOrNull() - val amountSats = userAmount ?: 1uL.takeIf { _uiState.value.isZeroAmountInvoice } + try { + val state = _uiState.value + val userAmount = state.amountSats.toULongOrNull() + val nodeId = extractNodeId(input) + val isNodeIdTarget = nodeId != null - val bolt11 = extractBolt11Invoice(input, amountSats) - if (bolt11 == null) { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = "Invalid invoice format", - description = "Could not extract Lightning invoice", - ) - _uiState.update { it.copy(isLoading = false) } - return@launch - } - - val effectiveAmount = amountSats ?: getInvoiceAmount(input) - if (effectiveAmount != null && effectiveAmount > 0uL) { - val outbound = lightningRepo.lightningState.value.channels - .totalNextOutboundHtlcLimitSats() - val estimatedFee = getEstimatedFee(bolt11, amountSats) + if (isNodeIdTarget && (userAmount == null || userAmount == 0uL)) { + ToastEventBus.send(type = Toast.ToastType.WARNING, title = "Please enter an amount") + return@launch + } - val nearCapacityThreshold = outbound * 95uL / 100uL - if (estimatedFee == null && effectiveAmount >= nearCapacityThreshold) { + if (state.isLnurlPay && (userAmount == null || userAmount == 0uL)) { 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.", + title = "Please enter an amount", ) - _uiState.update { it.copy(isLoading = false) } return@launch } - val totalRequired = effectiveAmount + (estimatedFee ?: 0uL) - if (!lightningRepo.canSend(totalRequired)) { + val amountSats = when { + isNodeIdTarget -> userAmount + state.isLnurlPay -> userAmount + else -> userAmount ?: 1uL.takeIf { state.isZeroAmountInvoice } + } + + val bolt11 = if (isNodeIdTarget) null else extractBolt11Invoice(input, amountSats) + if (!isNodeIdTarget && bolt11 == null) { ToastEventBus.send( type = Toast.ToastType.WARNING, - title = "Amount + fees exceed capacity", - description = "Needed: $BITCOIN_SYMBOL $totalRequired" + - "(includes ~${estimatedFee ?: 0uL} fee), " + - "available: $BITCOIN_SYMBOL $outbound", + title = "Invalid target", + description = "Could not extract Lightning invoice", ) - _uiState.update { it.copy(isLoading = false) } return@launch } - } - val startTime = System.currentTimeMillis() + if (!isNodeIdTarget && bolt11 != null) { + 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.", + ) + 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", + ) + return@launch + } + } + } - lightningRepo.sendProbeForInvoice(bolt11, amountSats) - .onSuccess { handleProbeSuccess(startTime, bolt11, amountSats) } - .onFailure { handleProbeFailure(startTime, it) } + val startTime = System.currentTimeMillis() + val dispatch = if (isNodeIdTarget) { + lightningRepo.sendProbeForNode(requireNotNull(nodeId), requireNotNull(amountSats)) + } else { + lightningRepo.sendProbeForInvoice(requireNotNull(bolt11), amountSats) + } - _uiState.update { it.copy(isLoading = false) } + dispatch + .onSuccess { probe -> + lightningRepo.waitForProbeOutcome(probe.paymentIds) + .onSuccess { handleProbeOutcome(startTime, it, bolt11, amountSats) } + .onFailure { handleProbeFailure(startTime, it) } + } + .onFailure { handleProbeFailure(startTime, it) } + } finally { + _uiState.update { it.copy(isLoading = false) } + } } } private fun detectInputType(input: String) { viewModelScope.launch(bgDispatcher) { - val data = runCatching { coreService.decode(input.trim()) }.getOrNull() + val trimmed = input.trim() + if (extractNodeId(trimmed) != null) { + updateInputType(isNodeId = true) + return@launch + } + + val data = runCatching { coreService.decode(trimmed) }.getOrNull() 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, - ) - } + updateInputType(isLnurlPay = true, amountSats = min.toString().takeIf { isFixed }) } is Scanner.Lightning if data.invoice.amountSatoshis == 0uL -> { - _uiState.update { it.copy(isLnurlPay = false, isZeroAmountInvoice = true) } + updateInputType(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) } + updateInputType(isZeroAmountInvoice = data.hasZeroAmountLightningParam()) } else -> { - _uiState.update { it.copy(isLnurlPay = false, isZeroAmountInvoice = false) } + updateInputType() } } } } + private fun updateInputType( + isNodeId: Boolean = false, + isLnurlPay: Boolean = false, + isZeroAmountInvoice: Boolean = false, + amountSats: String? = null, + ) { + _uiState.update { + it.copy( + isNodeId = isNodeId, + isLnurlPay = isLnurlPay, + isZeroAmountInvoice = isZeroAmountInvoice, + amountSats = amountSats ?: it.amountSats, + ) + } + } + + private suspend fun Scanner.OnChain.hasZeroAmountLightningParam(): Boolean { + val lightningParam = invoice.params?.get("lightning") + val lightning = lightningParam?.let { + runCatching { coreService.decode(it) }.getOrNull() as? Scanner.Lightning + } + return lightning?.invoice?.amountSatoshis == 0uL + } + private suspend fun extractBolt11Invoice(input: String, amountSats: ULong?): String? = runCatching { when (val decoded = coreService.decode(input)) { is Scanner.Lightning -> decoded.invoice.bolt11 @@ -186,24 +237,68 @@ class ProbingToolViewModel @Inject constructor( } }.getOrNull() - private suspend fun handleProbeSuccess(startTime: Long, invoice: String, amountSats: ULong?) { + private suspend fun handleProbeOutcome( + startTime: Long, + outcome: ProbeOutcome, + invoice: String?, + amountSats: ULong?, + ) { val durationMs = System.currentTimeMillis() - startTime - Logger.info("Probe successful for invoice in ${durationMs}ms", context = TAG) + when (outcome) { + is ProbeOutcome.Success -> { + Logger.info( + "Received successful probe outcome for paymentId='${outcome.paymentId}' in '${durationMs}ms'", + context = TAG, + ) - val estimatedFee = getEstimatedFee(invoice, amountSats) - _uiState.update { - it.copy(probeResult = ProbeResult(success = true, durationMs = durationMs, estimatedFeeSats = estimatedFee)) + val estimatedFee = invoice?.let { getEstimatedFee(it, amountSats) } + _uiState.update { + it.copy( + probeResult = ProbeResult( + success = true, + durationMs = durationMs, + estimatedFeeSats = estimatedFee, + ) + ) + } + ToastEventBus.send(type = Toast.ToastType.SUCCESS, title = "Probe successful") + } + + is ProbeOutcome.Failure -> { + Logger.info( + "Received failed probe outcome for paymentId='${outcome.paymentId}' " + + "paymentHash='${outcome.paymentHash}' shortChannelId='${outcome.shortChannelId}'", + context = TAG, + ) + + val message = outcome.shortChannelId?.let { "No route found (SCID: $it)" } ?: "No route found" + _uiState.update { + it.copy( + probeResult = ProbeResult( + success = false, + durationMs = durationMs, + errorMessage = message, + ) + ) + } + ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Probe failed", description = message) + } } - ToastEventBus.send(type = Toast.ToastType.SUCCESS, title = "Probe successful") } private suspend fun handleProbeFailure(startTime: Long, error: Throwable) { val durationMs = System.currentTimeMillis() - startTime - Logger.error("Probe failed in ${durationMs}ms", error, context = TAG) + Logger.error("Failed probe in '${durationMs}ms'", error, context = TAG) val friendlyMessage = getFriendlyErrorMessage(error) _uiState.update { - it.copy(probeResult = ProbeResult(success = false, durationMs = durationMs, errorMessage = friendlyMessage)) + it.copy( + probeResult = ProbeResult( + success = false, + durationMs = durationMs, + errorMessage = friendlyMessage, + ) + ) } ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Probe failed", description = friendlyMessage) } @@ -225,8 +320,12 @@ class ProbingToolViewModel @Inject constructor( companion object { private const val TAG = "ProbingToolViewModel" + private const val NODE_ID_HEX_LENGTH = 66 private fun getFriendlyErrorMessage(error: Throwable): String { + if (error is ProbeError.NoProbeHandles) return "Probe was likely skipped" + if (error is ProbeError.TimedOut) return "Probe timed out" + val msg = error.message ?: return "Unknown error" return when { msg.contains("RouteNotFound", ignoreCase = true) -> "No route found to destination" @@ -236,6 +335,15 @@ class ProbingToolViewModel @Inject constructor( else -> msg } } + + private fun extractNodeId(input: String): String? { + val trimmed = input.trim() + val nodeId = trimmed.substringBefore("@") + return nodeId.takeIf { it.length == NODE_ID_HEX_LENGTH && it.all(::isHexChar) } + } + + private fun isHexChar(value: Char): Boolean = + value.isDigit() || value.lowercaseChar() in 'a'..'f' } } @@ -244,6 +352,7 @@ data class ProbingToolUiState( val invoice: String = "", val amountSats: String = "", val isLoading: Boolean = false, + val isNodeId: Boolean = false, val isLnurlPay: Boolean = false, val isZeroAmountInvoice: Boolean = false, val probeResult: ProbeResult? = null, diff --git a/app/src/test/java/to/bitkit/models/USatTest.kt b/app/src/test/java/to/bitkit/models/USatTest.kt index bb30c9ccba..ff721fab6f 100644 --- a/app/src/test/java/to/bitkit/models/USatTest.kt +++ b/app/src/test/java/to/bitkit/models/USatTest.kt @@ -74,6 +74,26 @@ class USatTest { } // endregion + // region Multiplication + @Test + fun `times returns product`() { + val result = USat(42uL) * USat(1_000uL) + assertEquals(42_000uL, result) + } + + @Test + fun `times returns zero when either value is zero`() { + assertEquals(0uL, USat(0uL) * USat(1_000uL)) + assertEquals(0uL, USat(1_000uL) * USat(0uL)) + } + + @Test + fun `times saturates at max when would overflow`() { + val result = USat(ULong.MAX_VALUE) * USat(1_000uL) + assertEquals(ULong.MAX_VALUE, result) + } + // endregion + // region Comparisons @Test fun `compareTo returns negative when less than`() { diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 9ee56808ec..4c0fb52f05 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -17,6 +17,7 @@ import org.junit.Test import org.lightningdevkit.ldknode.AddressTypeBalance import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PeerDetails @@ -50,13 +51,16 @@ import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.services.LnurlService import to.bitkit.services.LspNotificationsService +import to.bitkit.services.NodeEventHandler import to.bitkit.test.BaseUnitTest import to.bitkit.utils.UrlValidator import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds @Suppress("LargeClass") class LightningRepoTest : BaseUnitTest() { @@ -74,6 +78,11 @@ class LightningRepoTest : BaseUnitTest() { private val connectivityRepo = mock() private val vssBackupClientLdk = mock() private val urlValidator = UrlValidator { Result.success(Unit) } + private val probePaymentA = "probe-payment-a" + private val probePaymentB = "probe-payment-b" + private val probeHashA = "probe-hash-a" + private val probeHashB = "probe-hash-b" + private val probeNodeId = "02abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef12" @Before fun setUp() = runBlocking { @@ -113,6 +122,18 @@ class LightningRepoTest : BaseUnitTest() { sut.sync() } + private suspend fun startNodeAndCaptureEvents(): NodeEventHandler { + var capturedHandler: NodeEventHandler? = null + whenever { lightningService.start(anyOrNull(), any()) }.thenAnswer { + @Suppress("UNCHECKED_CAST") + capturedHandler = it.arguments[1] as NodeEventHandler + Unit + } + + startNodeForTesting() + return requireNotNull(capturedHandler) + } + @Test fun `start should transition through correct states`() = test { sut.setInitNodeLifecycleState() @@ -1141,6 +1162,121 @@ class LightningRepoTest : BaseUnitTest() { verifyBlocking(settingsStore, times(2)) { update(any()) } } + @Test + fun `waitForProbeOutcome returns success when ProbeSuccessful arrives after subscription`() = test { + val onEvent = startNodeAndCaptureEvents() + + val result = async { sut.waitForProbeOutcome(setOf(probePaymentA)) } + onEvent(Event.ProbeSuccessful(paymentId = probePaymentA, paymentHash = probeHashA)) + + val outcome = result.await().getOrThrow() + assertIs(outcome) + assertEquals(probePaymentA, outcome.paymentId) + assertEquals(probeHashA, outcome.paymentHash) + } + + @Test + fun `waitForProbeOutcome returns cached success when event arrives before wait`() = test { + val onEvent = startNodeAndCaptureEvents() + onEvent(Event.ProbeSuccessful(paymentId = probePaymentA, paymentHash = probeHashA)) + + val outcome = sut.waitForProbeOutcome(setOf(probePaymentA)).getOrThrow() + + assertIs(outcome) + assertEquals(probePaymentA, outcome.paymentId) + assertEquals(probeHashA, outcome.paymentHash) + } + + @Test + fun `waitForProbeOutcome returns last failure only after all tracked probes fail`() = test { + val onEvent = startNodeAndCaptureEvents() + val result = async { sut.waitForProbeOutcome(setOf(probePaymentA, probePaymentB)) } + + onEvent(Event.ProbeFailed(paymentId = probePaymentA, paymentHash = probeHashA, shortChannelId = 1uL)) + onEvent(Event.ProbeFailed(paymentId = probePaymentB, paymentHash = probeHashB, shortChannelId = 2uL)) + + val outcome = result.await().getOrThrow() + assertIs(outcome) + assertEquals(probePaymentB, outcome.paymentId) + assertEquals(probeHashB, outcome.paymentHash) + assertEquals(2uL, outcome.shortChannelId) + } + + @Test + fun `waitForProbeOutcome returns first success even when another path already failed`() = test { + val onEvent = startNodeAndCaptureEvents() + val result = async { sut.waitForProbeOutcome(setOf(probePaymentA, probePaymentB)) } + + onEvent(Event.ProbeFailed(paymentId = probePaymentA, paymentHash = probeHashA, shortChannelId = 1uL)) + onEvent(Event.ProbeSuccessful(paymentId = probePaymentB, paymentHash = probeHashB)) + + val outcome = result.await().getOrThrow() + assertIs(outcome) + assertEquals(probePaymentB, outcome.paymentId) + assertEquals(probeHashB, outcome.paymentHash) + } + + @Test + fun `waitForProbeOutcome does not hang on partial cached failures`() = test { + val onEvent = startNodeAndCaptureEvents() + onEvent(Event.ProbeFailed(paymentId = probePaymentA, paymentHash = probeHashA, shortChannelId = 1uL)) + + val result = async { sut.waitForProbeOutcome(setOf(probePaymentA, probePaymentB)) } + onEvent(Event.ProbeFailed(paymentId = probePaymentB, paymentHash = probeHashB, shortChannelId = 2uL)) + + val outcome = result.await().getOrThrow() + assertIs(outcome) + assertEquals(probePaymentB, outcome.paymentId) + assertEquals(2uL, outcome.shortChannelId) + } + + @Test + fun `waitForProbeOutcome returns timeout error when no matching event arrives`() = test { + startNodeAndCaptureEvents() + + val result = sut.waitForProbeOutcome(setOf(probePaymentA), timeout = 1.seconds) + + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `stop clears probe cache`() = test { + val onEvent = startNodeAndCaptureEvents() + whenever(lightningService.stop()).thenReturn(Unit) + onEvent(Event.ProbeSuccessful(paymentId = probePaymentA, paymentHash = probeHashA)) + + sut.stop() + val result = sut.waitForProbeOutcome(setOf(probePaymentA), timeout = 1.seconds) + + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `sendProbeForInvoice returns ProbeDispatch with payment IDs`() = test { + startNodeForTesting() + whenever(lightningService.sendProbes("lnbc1")).thenReturn(Result.success(setOf(probePaymentA, probePaymentB))) + + val result = sut.sendProbeForInvoice("lnbc1") + + assertTrue(result.isSuccess) + assertEquals(setOf(probePaymentA, probePaymentB), result.getOrThrow().paymentIds) + } + + @Test + fun `sendProbeForNode delegates to keysend probe and returns payment IDs`() = test { + startNodeForTesting() + whenever(lightningService.sendKeysendProbe(probeNodeId, 42_000uL)) + .thenReturn(Result.success(setOf(probePaymentA))) + + val result = sut.sendProbeForNode(probeNodeId, amountSats = 42uL) + + assertTrue(result.isSuccess) + assertEquals(setOf(probePaymentA), result.getOrThrow().paymentIds) + verifyBlocking(lightningService) { sendKeysendProbe(probeNodeId, 42_000uL) } + } + @Test fun `start should not retry when node lifecycle state is Running`() = test { sut.setInitNodeLifecycleState() diff --git a/app/src/test/java/to/bitkit/viewmodels/ProbingToolViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/ProbingToolViewModelTest.kt new file mode 100644 index 0000000000..958c9252bd --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/ProbingToolViewModelTest.kt @@ -0,0 +1,63 @@ +package to.bitkit.viewmodels + +import android.content.Context +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.LightningState +import to.bitkit.repositories.ProbeDispatch +import to.bitkit.repositories.ProbeOutcome +import to.bitkit.services.CoreService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ProbingToolViewModelTest : BaseUnitTest() { + private lateinit var sut: ProbingToolViewModel + + private val context = mock() + private val coreService = mock() + private val lightningRepo = mock() + + @Before + fun setUp() { + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + + sut = ProbingToolViewModel( + context = context, + bgDispatcher = testDispatcher, + coreService = coreService, + lightningRepo = lightningRepo, + ) + } + + @Test + fun `sendProbe uses node id from node URI`() = test { + val nodeId = "021a7a31f03a9b49807eb18ef03046e264871a1d03cd4cb80d37265499d1b726b9" + val nodeUri = "$nodeId@54.244.234.100:20319" + val paymentId = "probe-payment-id" + val paymentHash = "probe-payment-hash" + + whenever(lightningRepo.sendProbeForNode(nodeId, 42uL)) + .thenReturn(Result.success(ProbeDispatch(paymentIds = setOf(paymentId)))) + whenever(lightningRepo.waitForProbeOutcome(setOf(paymentId))) + .thenReturn(Result.success(ProbeOutcome.Success(paymentId = paymentId, paymentHash = paymentHash))) + + sut.updateInvoice(nodeUri) + sut.updateAmountSats("42") + advanceUntilIdle() + + assertTrue(sut.uiState.value.isNodeId) + + sut.sendProbe() + advanceUntilIdle() + + verifyBlocking(lightningRepo) { sendProbeForNode(nodeId, 42uL) } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b221650f6..27084a1ecf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -61,7 +61,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.36" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.37" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }