From 505669fa7ea31301bc8469ad11f92e7ac431425f Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 14 Jul 2026 15:44:16 +0200 Subject: [PATCH 1/3] fix: tune lightning probe routing (#1052) * feat: show probe route fee * fix: tune scorer fee params * fix: address probe compile errors * fix: use scorer defaults --- .gitignore | 1 + .../java/to/bitkit/dev/DevToolsProvider.kt | 42 +++++++++++- .../to/bitkit/repositories/LightningRepo.kt | 13 +++- .../to/bitkit/services/LightningService.kt | 31 +++++++++ .../ui/screens/settings/ProbingToolScreen.kt | 8 +-- .../bitkit/viewmodels/ProbingToolViewModel.kt | 10 ++- .../bitkit/repositories/LightningRepoTest.kt | 68 ++++++++++++++++--- .../viewmodels/ProbingToolViewModelTest.kt | 10 ++- changelog.d/next/1052.fixed.md | 1 + gradle/libs.versions.toml | 2 +- 10 files changed, 159 insertions(+), 27 deletions(-) create mode 100644 changelog.d/next/1052.fixed.md diff --git a/.gitignore b/.gitignore index 5eb8376414..0d7033683e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.iml .gradle .idea +.vscode !.idea/codeStyles/ .kotlin .DS_Store diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt index e2f5108ad4..7aa449e6a6 100644 --- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt +++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt @@ -126,6 +126,46 @@ private sealed interface DevCommand { } } + data class ProbeNode(val args: Args) : DevCommand { + companion object { + const val METHOD = "probeNode" + fun parse(arg: String?) = ProbeNode(arg.deserialize()) + } + + @Serializable + data class Args( + val targetName: String? = null, + val nodeId: String, + val amountMsat: ULong? = null, + val amountSats: ULong? = null, + val timeoutSeconds: Long = 90, + ) + + override suspend fun execute(deps: DevToolsProvider.Dependencies): DevResult { + val amountSats = args.amountSats ?: args.amountMsat?.let { msatCeilOf(it) } + ?: return DevResult.Error("Probe node requires amountSats or amountMsat") + val timeout = args.timeoutSeconds.coerceAtLeast(1).seconds + + Logger.info( + "Sending keysend probe for target '${args.targetName ?: "unknown"}' " + + "nodeId='${args.nodeId}' amountSats='$amountSats'", + context = TAG, + ) + + 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) }, + ) + }, + onFailure = { DevResult.ProbeFailure.from(it) }, + ) + } + } + data object ProbeReadiness : DevCommand { const val METHOD = "probeReadiness" @@ -157,7 +197,7 @@ 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 paymentIds: List = emptyList(), ) : DevResult { companion object { diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 45faadea22..b5231f553a 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -557,8 +557,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?.toString(), + event.routeFeeMsat, + ) else -> return } @@ -1738,11 +1743,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 shortChannelId: String?, + 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..c58b1151cf 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -38,6 +38,7 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.PublicKey +import org.lightningdevkit.ldknode.ScoringFeeParameters import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid import org.lightningdevkit.ldknode.defaultConfig @@ -89,6 +90,25 @@ class LightningService @Inject constructor( companion object { private const val TAG = "LightningService" private const val NODE_ID_PREVIEW_LEN = 20 + private const val SCORING_BASE_PENALTY_MSAT = 50_000uL + 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_AMOUNT_MULTIPLIER_MSAT = 20_000uL + private const val SCORING_CONSIDERED_IMPOSSIBLE_PENALTY_MSAT = 1_000_000_000_000uL + private const val SCORING_PROBING_DIVERSITY_PENALTY_MSAT = 60_000uL + + private val DEFAULT_SCORING_FEE_PARAMETERS = ScoringFeeParameters( + basePenaltyMsat = 1_024uL, + basePenaltyAmountMultiplierMsat = 131_072uL, + liquidityPenaltyMultiplierMsat = 0uL, + liquidityPenaltyAmountMultiplierMsat = 0uL, + historicalLiquidityPenaltyMultiplierMsat = 10_000uL, + historicalLiquidityPenaltyAmountMultiplierMsat = 1_250uL, + antiProbingPenaltyMsat = 250uL, + consideredImpossiblePenaltyMsat = 100_000_000_000uL, + linearSuccessProbability = false, + probingDiversityPenaltyMsat = 0uL, + ) } @Volatile @@ -168,6 +188,7 @@ class LightningService @Inject constructor( configureChainSource(customServerUrl) configureGossipSource(customRgsServerUrl) configureScorerSource() + setScoringFeeParams(scorerFeeParameters()) setAddressType(selectedType) setAddressTypesToMonitor(monitoredTypes) @@ -860,6 +881,16 @@ class LightningService @Inject constructor( } // endregion + private fun scorerFeeParameters(): ScoringFeeParameters = DEFAULT_SCORING_FEE_PARAMETERS.copy( + basePenaltyMsat = SCORING_BASE_PENALTY_MSAT, + liquidityPenaltyMultiplierMsat = SCORING_LIQUIDITY_PENALTY_MULTIPLIER_MSAT, + liquidityPenaltyAmountMultiplierMsat = SCORING_LIQUIDITY_PENALTY_AMOUNT_MULTIPLIER_MSAT, + historicalLiquidityPenaltyAmountMultiplierMsat = + SCORING_HISTORICAL_LIQUIDITY_PENALTY_AMOUNT_MULTIPLIER_MSAT, + consideredImpossiblePenaltyMsat = SCORING_CONSIDERED_IMPOSSIBLE_PENALTY_MSAT, + probingDiversityPenaltyMsat = SCORING_PROBING_DIVERSITY_PENALTY_MSAT, + ) + // region utxo selection suspend fun listSpendableOutputs(): Result> { val node = this.node ?: throw ServiceError.NodeNotSetup() 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 4fb1bfc13b..3b7ab65bfb 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt @@ -161,7 +161,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) } @@ -257,8 +257,6 @@ class ProbingToolViewModel @Inject constructor( private suspend fun handleProbeOutcome( startTime: Long, outcome: ProbeOutcome, - invoice: String?, - amountSats: ULong?, ) { val durationMs = Clock.System.nowMs() - startTime when (outcome) { @@ -268,13 +266,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, ) ) } @@ -294,6 +291,7 @@ class ProbingToolViewModel @Inject constructor( probeResult = ProbeResult( success = false, durationMs = durationMs, + routeFeeMsat = outcome.routeFeeMsat, errorMessage = message, ) ) @@ -379,6 +377,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 6f55b26951..e785f70926 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1323,24 +1323,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 @@ -1348,14 +1350,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 = 990_718_250_873_192_449uL, + routeFeeMsat = 456uL, + ) + ) val outcome = result.await().getOrThrow() assertIs(outcome) assertEquals(probePaymentB, outcome.paymentId) assertEquals(probeHashB, outcome.paymentHash) - assertEquals(2uL, outcome.shortChannelId) + assertEquals("990718250873192449", outcome.shortChannelId) + assertEquals(456uL, outcome.routeFeeMsat) } @Test @@ -1363,27 +1380,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("2", outcome.shortChannelId) + assertEquals(456uL, outcome.routeFeeMsat) } @Test @@ -1400,7 +1446,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 241d959bd4..7521d5120c 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.46" } +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" } From 2f0eb8ea02166f8bcf9436b4ed89d5ea1e3ef089 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 14 Jul 2026 15:49:03 +0200 Subject: [PATCH 2/3] chore: update changelog --- changelog.d/{next => hotfix}/1052.fixed.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{next => hotfix}/1052.fixed.md (100%) diff --git a/changelog.d/next/1052.fixed.md b/changelog.d/hotfix/1052.fixed.md similarity index 100% rename from changelog.d/next/1052.fixed.md rename to changelog.d/hotfix/1052.fixed.md From a2bf1107ca65e6cdd87e8f82cb077148a7c70820 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 14 Jul 2026 16:04:05 +0200 Subject: [PATCH 3/3] chore: version 2.3.2 --- CHANGELOG.md | 8 +++++++- app/build.gradle.kts | 4 ++-- changelog.d/hotfix/1052.fixed.md | 1 - 3 files changed, 9 insertions(+), 4 deletions(-) delete mode 100644 changelog.d/hotfix/1052.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e370d0eba9..9c043b7124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.3.2] - 2026-07-14 + +### Fixed +- Improved Lightning payment route selection reliability. #1052 + ## [2.3.1] - 2026-06-26 ### Fixed @@ -100,7 +105,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - About screen (content merged into Support) #857 - Standalone General, Security, and Advanced settings screens (merged into tabs) #857 -[Unreleased]: https://github.com/synonymdev/bitkit-android/compare/v2.3.1...HEAD +[Unreleased]: https://github.com/synonymdev/bitkit-android/compare/v2.3.2...HEAD +[2.3.2]: https://github.com/synonymdev/bitkit-android/compare/v2.3.1...v2.3.2 [2.3.1]: https://github.com/synonymdev/bitkit-android/compare/v2.3.0...v2.3.1 [2.3.0]: https://github.com/synonymdev/bitkit-android/compare/v2.2.0...v2.3.0 [2.2.0]: https://github.com/synonymdev/bitkit-android/compare/v2.1.2...v2.2.0 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9a8b63bd7a..74b7f75f2b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -149,8 +149,8 @@ android { applicationId = "to.bitkit" minSdk = 28 targetSdk = 36 - versionCode = 184 - versionName = "2.3.1" + versionCode = 185 + versionName = "2.3.2" testInstrumentationRunner = "to.bitkit.test.HiltTestRunner" bitkitAndroidTestAnnotation?.let { testInstrumentationRunnerArguments["annotation"] = it diff --git a/changelog.d/hotfix/1052.fixed.md b/changelog.d/hotfix/1052.fixed.md deleted file mode 100644 index f397e0c62b..0000000000 --- a/changelog.d/hotfix/1052.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Improved Lightning payment route selection reliability.