diff --git a/.gitignore b/.gitignore index 85acba5a5d..9efb786a2a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ google-services.json !debug.keystore keystore.* !keystore.properties.template +.vscode diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt index ea61fd726e..bee1bebf8c 100644 --- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt +++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.os.Binder import android.os.Bundle import android.os.Process +import android.os.SystemClock import androidx.core.os.bundleOf import dagger.hilt.EntryPoint import dagger.hilt.InstallIn @@ -114,16 +115,19 @@ private sealed interface DevCommand { context = TAG, ) + val startedAt = SystemClock.elapsedRealtime() return deps.lightningRepo().sendProbeForInvoice(args.bolt11, amountSats) .fold( onSuccess = { deps.lightningRepo().waitForProbeOutcome(it.paymentIds, timeout) .fold( - onSuccess = { outcome -> outcome.toDevResult(it.paymentIds) }, - onFailure = { error -> DevResult.ProbeFailure.from(error, it.paymentIds) }, + onSuccess = { outcome -> outcome.toDevResult(it.paymentIds, startedAt.durationMs()) }, + onFailure = { error -> + DevResult.ProbeFailure.from(error, it.paymentIds, startedAt.durationMs()) + }, ) }, - onFailure = { DevResult.ProbeFailure.from(it) }, + onFailure = { DevResult.ProbeFailure.from(it, durationMs = startedAt.durationMs()) }, ) } } @@ -149,20 +153,24 @@ private sealed interface DevCommand { val timeout = args.timeoutSeconds.coerceAtLeast(1).seconds Logger.info( - "Sending keysend probe for target '${args.targetName ?: "unknown"}' nodeId='${args.nodeId}' amountSats='$amountSats'", + "Sending keysend probe for target '${args.targetName ?: "unknown"}' " + + "nodeId='${args.nodeId}' amountSats='$amountSats'", context = TAG, ) + val startedAt = SystemClock.elapsedRealtime() return deps.lightningRepo().sendProbeForNode(args.nodeId, amountSats) .fold( onSuccess = { deps.lightningRepo().waitForProbeOutcome(it.paymentIds, timeout) .fold( - onSuccess = { outcome -> outcome.toDevResult(it.paymentIds) }, - onFailure = { error -> DevResult.ProbeFailure.from(error, it.paymentIds) }, + onSuccess = { outcome -> outcome.toDevResult(it.paymentIds, startedAt.durationMs()) }, + onFailure = { error -> + DevResult.ProbeFailure.from(error, it.paymentIds, startedAt.durationMs()) + }, ) }, - onFailure = { DevResult.ProbeFailure.from(it) }, + onFailure = { DevResult.ProbeFailure.from(it, durationMs = startedAt.durationMs()) }, ) } } @@ -206,6 +214,8 @@ private sealed interface DevResult { val success: Boolean = true, val paymentId: String, val paymentHash: String, + val durationMs: Long, + val routeFeeMsat: ULong?, val paymentIds: List, ) : DevResult @@ -215,12 +225,19 @@ private sealed interface DevResult { val message: String? = null, val paymentId: String? = null, val paymentHash: String? = null, - val shortChannelId: ULong? = null, + val shortChannelId: String? = null, + val durationMs: Long, + val routeFeeMsat: ULong? = null, val paymentIds: List = emptyList(), ) : DevResult { companion object { - fun from(error: Throwable, paymentIds: Set = emptySet()) = ProbeFailure( + fun from( + error: Throwable, + paymentIds: Set = emptySet(), + durationMs: Long, + ) = ProbeFailure( message = error.message, + durationMs = durationMs, paymentIds = paymentIds.toList(), ) } @@ -243,6 +260,7 @@ private sealed interface DevResult { val graphChannelCount: Int? = null, val latestRgsSyncTimestamp: ULong? = null, val latestPathfindingScoresSyncTimestamp: ULong? = null, + val probeRuntimeConfig: ProbeRuntimeConfig, ) : DevResult { companion object { fun from(readiness: NodeProbeReadiness) = ProbeReadiness( @@ -261,29 +279,91 @@ private sealed interface DevResult { graphChannelCount = readiness.graphChannelCount, latestRgsSyncTimestamp = readiness.latestRgsSyncTimestamp, latestPathfindingScoresSyncTimestamp = readiness.latestPathfindingScoresSyncTimestamp, + probeRuntimeConfig = ProbeRuntimeConfig.from(readiness.probeRuntimeConfig), ) } } + @Serializable + data class ProbeRuntimeConfig( + val sampleAmountMsat: ULong, + val route: ProbeRouteConfig, + val scoring: ProbeScoringConfig, + ) { + companion object { + fun from(config: to.bitkit.services.ProbeRuntimeConfig) = ProbeRuntimeConfig( + sampleAmountMsat = config.sampleAmountMsat, + route = ProbeRouteConfig( + maxTotalRoutingFeeMsat = config.route.maxTotalRoutingFeeMsat, + maxTotalCltvExpiryDelta = config.route.maxTotalCltvExpiryDelta, + maxPathCount = config.route.maxPathCount, + maxChannelSaturationPowerOfHalf = config.route.maxChannelSaturationPowerOfHalf, + ), + scoring = ProbeScoringConfig( + basePenaltyMsat = config.scoring.basePenaltyMsat, + basePenaltyAmountMultiplierMsat = config.scoring.basePenaltyAmountMultiplierMsat, + liquidityPenaltyMultiplierMsat = config.scoring.liquidityPenaltyMultiplierMsat, + liquidityPenaltyAmountMultiplierMsat = config.scoring.liquidityPenaltyAmountMultiplierMsat, + historicalLiquidityPenaltyMultiplierMsat = + config.scoring.historicalLiquidityPenaltyMultiplierMsat, + historicalLiquidityPenaltyAmountMultiplierMsat = + config.scoring.historicalLiquidityPenaltyAmountMultiplierMsat, + antiProbingPenaltyMsat = config.scoring.antiProbingPenaltyMsat, + consideredImpossiblePenaltyMsat = config.scoring.consideredImpossiblePenaltyMsat, + linearSuccessProbability = config.scoring.linearSuccessProbability, + probingDiversityPenaltyMsat = config.scoring.probingDiversityPenaltyMsat, + ), + ) + } + } + + @Serializable + data class ProbeRouteConfig( + val maxTotalRoutingFeeMsat: ULong?, + val maxTotalCltvExpiryDelta: UInt, + val maxPathCount: Int, + val maxChannelSaturationPowerOfHalf: Int, + ) + + @Serializable + data class ProbeScoringConfig( + val basePenaltyMsat: ULong, + val basePenaltyAmountMultiplierMsat: ULong, + val liquidityPenaltyMultiplierMsat: ULong, + val liquidityPenaltyAmountMultiplierMsat: ULong, + val historicalLiquidityPenaltyMultiplierMsat: ULong, + val historicalLiquidityPenaltyAmountMultiplierMsat: ULong, + val antiProbingPenaltyMsat: ULong, + val consideredImpossiblePenaltyMsat: ULong, + val linearSuccessProbability: Boolean, + val probingDiversityPenaltyMsat: ULong, + ) + @Serializable data class Error(val message: String? = null) : DevResult fun toBundle() = bundleOf(KEY_RESULT to DEV_JSON.encodeToString(this)) } -private fun ProbeOutcome.toDevResult(paymentIds: Set): DevResult = when (this) { +private fun ProbeOutcome.toDevResult(paymentIds: Set, durationMs: Long): DevResult = when (this) { is ProbeOutcome.Success -> DevResult.ProbeSuccess( paymentId = paymentId, paymentHash = paymentHash, + durationMs = durationMs, + routeFeeMsat = routeFeeMsat, paymentIds = paymentIds.toList(), ) is ProbeOutcome.Failure -> DevResult.ProbeFailure( message = "Probe failed", paymentId = paymentId, paymentHash = paymentHash, - shortChannelId = shortChannelId, + shortChannelId = shortChannelId?.toString(), + durationMs = durationMs, + routeFeeMsat = routeFeeMsat, paymentIds = paymentIds.toList(), ) } private inline fun String?.deserialize(): T = if (isNullOrBlank()) Json.decodeFromString("{}") else Json.decodeFromString(this) + +private fun Long.durationMs() = SystemClock.elapsedRealtime() - this diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index dc4f99a7e4..400b3f2515 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -85,6 +85,7 @@ import to.bitkit.services.LnurlService import to.bitkit.services.LnurlWithdrawResponse import to.bitkit.services.LspNotificationsService import to.bitkit.services.NodeEventHandler +import to.bitkit.services.ProbeRuntimeConfig import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError @@ -571,8 +572,13 @@ 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) + is Event.ProbeSuccessful -> ProbeOutcome.Success(event.paymentId, event.paymentHash, event.routeFeeMsat) + is Event.ProbeFailed -> ProbeOutcome.Failure( + event.paymentId, + event.paymentHash, + event.shortChannelId, + event.routeFeeMsat, + ) else -> return } @@ -1637,6 +1643,7 @@ class LightningRepo @Inject constructor( graphChannelCount = graph?.channelCount, latestRgsSyncTimestamp = graph?.latestRgsSyncTimestamp, latestPathfindingScoresSyncTimestamp = state.nodeStatus?.latestPathfindingScoresSyncTimestamp, + probeRuntimeConfig = lightningService.probeRuntimeConfig(), syncHealthy = state.isSyncHealthy, ) } @@ -1761,6 +1768,7 @@ data class ProbeReadiness( val graphChannelCount: Int?, val latestRgsSyncTimestamp: ULong?, val latestPathfindingScoresSyncTimestamp: ULong?, + val probeRuntimeConfig: ProbeRuntimeConfig, val syncHealthy: Boolean, ) { val ready: Boolean @@ -1779,11 +1787,13 @@ sealed interface ProbeOutcome { data class Success( override val paymentId: PaymentId, override val paymentHash: PaymentHash, + val routeFeeMsat: ULong?, ) : ProbeOutcome data class Failure( override val paymentId: PaymentId, override val paymentHash: PaymentHash, val shortChannelId: ULong?, + val routeFeeMsat: 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 3ca4de9eef..ab654c6d15 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -38,6 +38,8 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.PublicKey +import org.lightningdevkit.ldknode.RouteParametersConfig +import org.lightningdevkit.ldknode.ScoringFeeParameters import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid import org.lightningdevkit.ldknode.defaultConfig @@ -89,6 +91,22 @@ class LightningService @Inject constructor( companion object { private const val TAG = "LightningService" private const val NODE_ID_PREVIEW_LEN = 20 + private const val PROBE_MAX_TOTAL_ROUTING_FEE_DIVISOR = 100uL + private const val PROBE_MAX_TOTAL_ROUTING_FEE_BASE_MSAT = 50_000uL + private const val PROBE_MAX_TOTAL_CLTV_EXPIRY_DELTA = 2_016u + private const val PROBE_MAX_PATH_COUNT = 10u + private const val PROBE_MAX_CHANNEL_SATURATION_POWER_OF_HALF = 2u + private const val PROBE_CONFIG_SAMPLE_AMOUNT_MSAT = 80_000_000uL + + private const val SCORING_BASE_PENALTY_MSAT = 50_000uL + private const val SCORING_BASE_PENALTY_AMOUNT_MULTIPLIER_MSAT = 131_072uL + private const val SCORING_LIQUIDITY_PENALTY_MULTIPLIER_MSAT = 10_000uL + private const val SCORING_LIQUIDITY_PENALTY_AMOUNT_MULTIPLIER_MSAT = 10_000uL + private const val SCORING_HISTORICAL_LIQUIDITY_PENALTY_MULTIPLIER_MSAT = 10_000uL + private const val SCORING_HISTORICAL_LIQUIDITY_PENALTY_AMOUNT_MULTIPLIER_MSAT = 20_000uL + private const val SCORING_ANTI_PROBING_PENALTY_MSAT = 250uL + private const val SCORING_CONSIDERED_IMPOSSIBLE_PENALTY_MSAT = 1_000_000_000_000uL + private const val SCORING_PROBING_DIVERSITY_PENALTY_MSAT = 60_000uL } @Volatile @@ -168,6 +186,7 @@ class LightningService @Inject constructor( configureChainSource(customServerUrl) configureGossipSource(customRgsServerUrl) configureScorerSource() + setScoringFeeParams(scorerFeeParameters()) setAddressType(selectedType) setAddressTypesToMonitor(monitoredTypes) @@ -808,7 +827,10 @@ class LightningService @Inject constructor( return ServiceQueue.LDK.background { runCatching { - val handles = node.bolt11Payment().sendProbes(bolt11Invoice, null) + val handles = node.bolt11Payment().sendProbes( + bolt11Invoice, + invoiceAmountMsat?.let { probeRouteParameters(it) }, + ) Result.success(handles.map { it.paymentId }.toSet()) }.getOrElse { dumpNetworkGraphInfo(bolt11) @@ -832,7 +854,11 @@ class LightningService @Inject constructor( return ServiceQueue.LDK.background { runCatching { - val handles = node.bolt11Payment().sendProbesUsingAmount(bolt11Invoice, amountMsat, null) + val handles = node.bolt11Payment().sendProbesUsingAmount( + bolt11Invoice, + amountMsat, + probeRouteParameters(amountMsat), + ) Result.success(handles.map { it.paymentId }.toSet()) }.getOrElse { dumpNetworkGraphInfo(bolt11) @@ -860,6 +886,55 @@ class LightningService @Inject constructor( } // endregion + private fun probeRouteParameters(amountMsat: ULong) = RouteParametersConfig( + maxTotalRoutingFeeMsat = amountMsat / PROBE_MAX_TOTAL_ROUTING_FEE_DIVISOR + + PROBE_MAX_TOTAL_ROUTING_FEE_BASE_MSAT, + maxTotalCltvExpiryDelta = PROBE_MAX_TOTAL_CLTV_EXPIRY_DELTA, + maxPathCount = PROBE_MAX_PATH_COUNT.toUByte(), + maxChannelSaturationPowerOfHalf = PROBE_MAX_CHANNEL_SATURATION_POWER_OF_HALF.toUByte(), + ) + + private fun scorerFeeParameters() = ScoringFeeParameters( + basePenaltyMsat = SCORING_BASE_PENALTY_MSAT, + basePenaltyAmountMultiplierMsat = SCORING_BASE_PENALTY_AMOUNT_MULTIPLIER_MSAT, + liquidityPenaltyMultiplierMsat = SCORING_LIQUIDITY_PENALTY_MULTIPLIER_MSAT, + liquidityPenaltyAmountMultiplierMsat = SCORING_LIQUIDITY_PENALTY_AMOUNT_MULTIPLIER_MSAT, + historicalLiquidityPenaltyMultiplierMsat = SCORING_HISTORICAL_LIQUIDITY_PENALTY_MULTIPLIER_MSAT, + historicalLiquidityPenaltyAmountMultiplierMsat = SCORING_HISTORICAL_LIQUIDITY_PENALTY_AMOUNT_MULTIPLIER_MSAT, + antiProbingPenaltyMsat = SCORING_ANTI_PROBING_PENALTY_MSAT, + consideredImpossiblePenaltyMsat = SCORING_CONSIDERED_IMPOSSIBLE_PENALTY_MSAT, + linearSuccessProbability = false, + probingDiversityPenaltyMsat = SCORING_PROBING_DIVERSITY_PENALTY_MSAT, + ) + + fun probeRuntimeConfig( + sampleAmountMsat: ULong = PROBE_CONFIG_SAMPLE_AMOUNT_MSAT, + ) = ProbeRuntimeConfig( + sampleAmountMsat = sampleAmountMsat, + route = probeRouteParameters(sampleAmountMsat).let { + ProbeRouteConfig( + maxTotalRoutingFeeMsat = it.maxTotalRoutingFeeMsat, + maxTotalCltvExpiryDelta = it.maxTotalCltvExpiryDelta, + maxPathCount = it.maxPathCount.toInt(), + maxChannelSaturationPowerOfHalf = it.maxChannelSaturationPowerOfHalf.toInt(), + ) + }, + scoring = scorerFeeParameters().let { + ProbeScoringConfig( + basePenaltyMsat = it.basePenaltyMsat, + basePenaltyAmountMultiplierMsat = it.basePenaltyAmountMultiplierMsat, + liquidityPenaltyMultiplierMsat = it.liquidityPenaltyMultiplierMsat, + liquidityPenaltyAmountMultiplierMsat = it.liquidityPenaltyAmountMultiplierMsat, + historicalLiquidityPenaltyMultiplierMsat = it.historicalLiquidityPenaltyMultiplierMsat, + historicalLiquidityPenaltyAmountMultiplierMsat = it.historicalLiquidityPenaltyAmountMultiplierMsat, + antiProbingPenaltyMsat = it.antiProbingPenaltyMsat, + consideredImpossiblePenaltyMsat = it.consideredImpossiblePenaltyMsat, + linearSuccessProbability = it.linearSuccessProbability, + probingDiversityPenaltyMsat = it.probingDiversityPenaltyMsat, + ) + }, + ) + // region utxo selection suspend fun listSpendableOutputs(): Result> { val node = this.node ?: throw ServiceError.NodeNotSetup() @@ -1157,6 +1232,32 @@ data class NetworkGraphInfo( val latestRgsSyncTimestamp: ULong?, ) +data class ProbeRuntimeConfig( + val sampleAmountMsat: ULong, + val route: ProbeRouteConfig, + val scoring: ProbeScoringConfig, +) + +data class ProbeRouteConfig( + val maxTotalRoutingFeeMsat: ULong?, + val maxTotalCltvExpiryDelta: UInt, + val maxPathCount: Int, + val maxChannelSaturationPowerOfHalf: Int, +) + +data class ProbeScoringConfig( + val basePenaltyMsat: ULong, + val basePenaltyAmountMultiplierMsat: ULong, + val liquidityPenaltyMultiplierMsat: ULong, + val liquidityPenaltyAmountMultiplierMsat: ULong, + val historicalLiquidityPenaltyMultiplierMsat: ULong, + val historicalLiquidityPenaltyAmountMultiplierMsat: ULong, + val antiProbingPenaltyMsat: ULong, + val consideredImpossiblePenaltyMsat: ULong, + val linearSuccessProbability: Boolean, + val probingDiversityPenaltyMsat: ULong, +) + class TrustedPeerForceCloseException : AppError( "Cannot force close channel with trusted peer. Force close is disabled for Blocktank LSP channels." ) 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 10b47f31dc..6cecd87013 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 @@ -186,11 +186,11 @@ private fun ProbingToolContent( iconRes = R.drawable.ic_clock, value = "${result.durationMs} ms", ) - result.estimatedFeeSats?.let { fee -> + result.routeFeeMsat?.let { fee -> SettingsTextButtonRow( - title = "Estimated Fee", + title = "Route Fee", iconRes = R.drawable.ic_coins, - value = "$fee sats", + value = "$fee msat", ) } result.errorMessage?.let { error -> @@ -216,7 +216,7 @@ private fun Preview() { probeResult = ProbeResult( success = true, durationMs = 342, - estimatedFeeSats = 5uL, + routeFeeMsat = 5_000uL, ), ) ) diff --git a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt index 372269bc8d..2934bbd4bc 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt @@ -155,7 +155,7 @@ class ProbingToolViewModel @Inject constructor( dispatch .onSuccess { probe -> lightningRepo.waitForProbeOutcome(probe.paymentIds) - .onSuccess { handleProbeOutcome(startTime, it, bolt11, amountSats) } + .onSuccess { handleProbeOutcome(startTime, it) } .onFailure { handleProbeFailure(startTime, it) } } .onFailure { handleProbeFailure(startTime, it) } @@ -241,8 +241,6 @@ class ProbingToolViewModel @Inject constructor( private suspend fun handleProbeOutcome( startTime: Long, outcome: ProbeOutcome, - invoice: String?, - amountSats: ULong?, ) { val durationMs = System.currentTimeMillis() - startTime when (outcome) { @@ -252,13 +250,12 @@ class ProbingToolViewModel @Inject constructor( context = TAG, ) - val estimatedFee = invoice?.let { getEstimatedFee(it, amountSats) } _uiState.update { it.copy( probeResult = ProbeResult( success = true, durationMs = durationMs, - estimatedFeeSats = estimatedFee, + routeFeeMsat = outcome.routeFeeMsat, ) ) } @@ -278,6 +275,7 @@ class ProbingToolViewModel @Inject constructor( probeResult = ProbeResult( success = false, durationMs = durationMs, + routeFeeMsat = outcome.routeFeeMsat, errorMessage = message, ) ) @@ -363,6 +361,6 @@ data class ProbingToolUiState( data class ProbeResult( val success: Boolean, val durationMs: Long, - val estimatedFeeSats: ULong? = null, + val routeFeeMsat: ULong? = null, val errorMessage: String? = null, ) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 8b40195825..59cd22db38 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -54,6 +54,9 @@ import to.bitkit.services.LnurlService import to.bitkit.services.LspNotificationsService import to.bitkit.services.NetworkGraphInfo import to.bitkit.services.NodeEventHandler +import to.bitkit.services.ProbeRouteConfig +import to.bitkit.services.ProbeRuntimeConfig +import to.bitkit.services.ProbeScoringConfig import to.bitkit.test.BaseUnitTest import to.bitkit.utils.UrlValidator import kotlin.test.assertEquals @@ -98,6 +101,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(connectivityRepo.isOnline).thenReturn(MutableStateFlow(ConnectivityState.CONNECTED)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) whenever(lightningService.aresRequiredPeersInNetworkGraph()).thenReturn(true) + whenever(lightningService.probeRuntimeConfig()).thenReturn(testProbeRuntimeConfig()) sut = LightningRepo( bgDispatcher = testDispatcher, lightningService = lightningService, @@ -128,6 +132,28 @@ class LightningRepoTest : BaseUnitTest() { sut.sync() } + private fun testProbeRuntimeConfig() = ProbeRuntimeConfig( + sampleAmountMsat = 80_000_000uL, + route = ProbeRouteConfig( + maxTotalRoutingFeeMsat = 850_000uL, + maxTotalCltvExpiryDelta = 2_016u, + maxPathCount = 10, + maxChannelSaturationPowerOfHalf = 2, + ), + scoring = ProbeScoringConfig( + basePenaltyMsat = 50_000uL, + basePenaltyAmountMultiplierMsat = 131_072uL, + liquidityPenaltyMultiplierMsat = 10_000uL, + liquidityPenaltyAmountMultiplierMsat = 10_000uL, + historicalLiquidityPenaltyMultiplierMsat = 10_000uL, + historicalLiquidityPenaltyAmountMultiplierMsat = 20_000uL, + antiProbingPenaltyMsat = 250uL, + consideredImpossiblePenaltyMsat = 1_000_000_000_000uL, + linearSuccessProbability = false, + probingDiversityPenaltyMsat = 50_000uL, + ), + ) + private suspend fun startNodeAndCaptureEvents(): NodeEventHandler { var capturedHandler: NodeEventHandler? = null whenever { lightningService.start(anyOrNull(), any()) }.thenAnswer { @@ -1275,24 +1301,26 @@ class LightningRepoTest : BaseUnitTest() { val onEvent = startNodeAndCaptureEvents() val result = async { sut.waitForProbeOutcome(setOf(probePaymentA)) } - onEvent(Event.ProbeSuccessful(paymentId = probePaymentA, paymentHash = probeHashA)) + onEvent(Event.ProbeSuccessful(paymentId = probePaymentA, paymentHash = probeHashA, routeFeeMsat = 123uL)) val outcome = result.await().getOrThrow() assertIs(outcome) assertEquals(probePaymentA, outcome.paymentId) assertEquals(probeHashA, outcome.paymentHash) + assertEquals(123uL, outcome.routeFeeMsat) } @Test fun `waitForProbeOutcome returns cached success when event arrives before wait`() = test { val onEvent = startNodeAndCaptureEvents() - onEvent(Event.ProbeSuccessful(paymentId = probePaymentA, paymentHash = probeHashA)) + onEvent(Event.ProbeSuccessful(paymentId = probePaymentA, paymentHash = probeHashA, routeFeeMsat = 123uL)) val outcome = sut.waitForProbeOutcome(setOf(probePaymentA)).getOrThrow() assertIs(outcome) assertEquals(probePaymentA, outcome.paymentId) assertEquals(probeHashA, outcome.paymentHash) + assertEquals(123uL, outcome.routeFeeMsat) } @Test @@ -1300,14 +1328,29 @@ class LightningRepoTest : BaseUnitTest() { 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)) + onEvent( + Event.ProbeFailed( + paymentId = probePaymentA, + paymentHash = probeHashA, + shortChannelId = 1uL, + routeFeeMsat = 123uL, + ) + ) + onEvent( + Event.ProbeFailed( + paymentId = probePaymentB, + paymentHash = probeHashB, + shortChannelId = 2uL, + routeFeeMsat = 456uL, + ) + ) val outcome = result.await().getOrThrow() assertIs(outcome) assertEquals(probePaymentB, outcome.paymentId) assertEquals(probeHashB, outcome.paymentHash) assertEquals(2uL, outcome.shortChannelId) + assertEquals(456uL, outcome.routeFeeMsat) } @Test @@ -1315,27 +1358,56 @@ class LightningRepoTest : BaseUnitTest() { 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)) + onEvent( + Event.ProbeFailed( + paymentId = probePaymentA, + paymentHash = probeHashA, + shortChannelId = 1uL, + routeFeeMsat = 123uL, + ) + ) + onEvent( + Event.ProbeSuccessful( + paymentId = probePaymentB, + paymentHash = probeHashB, + routeFeeMsat = 456uL, + ) + ) val outcome = result.await().getOrThrow() assertIs(outcome) assertEquals(probePaymentB, outcome.paymentId) assertEquals(probeHashB, outcome.paymentHash) + assertEquals(456uL, outcome.routeFeeMsat) } @Test fun `waitForProbeOutcome does not hang on partial cached failures`() = test { val onEvent = startNodeAndCaptureEvents() - onEvent(Event.ProbeFailed(paymentId = probePaymentA, paymentHash = probeHashA, shortChannelId = 1uL)) + onEvent( + Event.ProbeFailed( + paymentId = probePaymentA, + paymentHash = probeHashA, + shortChannelId = 1uL, + routeFeeMsat = 123uL, + ) + ) val result = async { sut.waitForProbeOutcome(setOf(probePaymentA, probePaymentB)) } - onEvent(Event.ProbeFailed(paymentId = probePaymentB, paymentHash = probeHashB, shortChannelId = 2uL)) + onEvent( + Event.ProbeFailed( + paymentId = probePaymentB, + paymentHash = probeHashB, + shortChannelId = 2uL, + routeFeeMsat = 456uL, + ) + ) val outcome = result.await().getOrThrow() assertIs(outcome) assertEquals(probePaymentB, outcome.paymentId) assertEquals(2uL, outcome.shortChannelId) + assertEquals(456uL, outcome.routeFeeMsat) } @Test @@ -1352,7 +1424,7 @@ class LightningRepoTest : BaseUnitTest() { fun `stop clears probe cache`() = test { val onEvent = startNodeAndCaptureEvents() whenever(lightningService.stop()).thenReturn(Unit) - onEvent(Event.ProbeSuccessful(paymentId = probePaymentA, paymentHash = probeHashA)) + onEvent(Event.ProbeSuccessful(paymentId = probePaymentA, paymentHash = probeHashA, routeFeeMsat = null)) sut.stop() val result = sut.waitForProbeOutcome(setOf(probePaymentA), timeout = 1.seconds) diff --git a/app/src/test/java/to/bitkit/viewmodels/ProbingToolViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/ProbingToolViewModelTest.kt index 958c9252bd..d651ddb0b5 100644 --- a/app/src/test/java/to/bitkit/viewmodels/ProbingToolViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/ProbingToolViewModelTest.kt @@ -47,7 +47,15 @@ class ProbingToolViewModelTest : BaseUnitTest() { 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))) + .thenReturn( + Result.success( + ProbeOutcome.Success( + paymentId = paymentId, + paymentHash = paymentHash, + routeFeeMsat = null, + ) + ) + ) sut.updateInvoice(nodeUri) sut.updateAmountSats("42") diff --git a/changelog.d/next/1052.fixed.md b/changelog.d/next/1052.fixed.md new file mode 100644 index 0000000000..f397e0c62b --- /dev/null +++ b/changelog.d/next/1052.fixed.md @@ -0,0 +1 @@ +Improved Lightning payment route selection reliability. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 40ca00380e..9379bc6cef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,7 +64,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.51" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.52" } 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" }