Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
*.iml
.gradle
.idea
.vscode
!.idea/codeStyles/
.kotlin
.DS_Store
Expand Down
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 41 additions & 1 deletion app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Args>())
}

@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"

Expand Down Expand Up @@ -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<String> = emptyList(),
) : DevResult {
companion object {
Expand Down
13 changes: 10 additions & 3 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
31 changes: 31 additions & 0 deletions app/src/main/java/to/bitkit/services/LightningService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -168,6 +188,7 @@ class LightningService @Inject constructor(
configureChainSource(customServerUrl)
configureGossipSource(customRgsServerUrl)
configureScorerSource()
setScoringFeeParams(scorerFeeParameters())
setAddressType(selectedType)
setAddressTypesToMonitor(monitoredTypes)

Expand Down Expand Up @@ -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<List<SpendableUtxo>> {
val node = this.node ?: throw ServiceError.NodeNotSetup()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand All @@ -216,7 +216,7 @@ private fun Preview() {
probeResult = ProbeResult(
success = true,
durationMs = 342,
estimatedFeeSats = 5uL,
routeFeeMsat = 5_000uL,
),
)
)
Expand Down
10 changes: 4 additions & 6 deletions app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
)
)
}
Expand All @@ -294,6 +291,7 @@ class ProbingToolViewModel @Inject constructor(
probeResult = ProbeResult(
success = false,
durationMs = durationMs,
routeFeeMsat = outcome.routeFeeMsat,
errorMessage = message,
)
)
Expand Down Expand Up @@ -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,
)
68 changes: 57 additions & 11 deletions app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1323,67 +1323,113 @@ 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<ProbeOutcome.Success>(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<ProbeOutcome.Success>(outcome)
assertEquals(probePaymentA, outcome.paymentId)
assertEquals(probeHashA, outcome.paymentHash)
assertEquals(123uL, outcome.routeFeeMsat)
}

@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))
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<ProbeOutcome.Failure>(outcome)
assertEquals(probePaymentB, outcome.paymentId)
assertEquals(probeHashB, outcome.paymentHash)
assertEquals(2uL, outcome.shortChannelId)
assertEquals("990718250873192449", outcome.shortChannelId)
assertEquals(456uL, outcome.routeFeeMsat)
}

@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))
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<ProbeOutcome.Success>(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<ProbeOutcome.Failure>(outcome)
assertEquals(probePaymentB, outcome.paymentId)
assertEquals(2uL, outcome.shortChannelId)
assertEquals("2", outcome.shortChannelId)
assertEquals(456uL, outcome.routeFeeMsat)
}

@Test
Expand All @@ -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)
Expand Down
Loading
Loading