Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,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 @@ -578,8 +578,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 @@ -1802,11 +1807,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?,
Comment thread
pwltr marked this conversation as resolved.
val routeFeeMsat: ULong?,
Comment thread
pwltr marked this conversation as resolved.
) : ProbeOutcome
}
20 changes: 20 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.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,12 @@
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
}

@Volatile
Expand Down Expand Up @@ -168,6 +175,7 @@
configureChainSource(customServerUrl)
configureGossipSource(customRgsServerUrl)
configureScorerSource()
setScoringFeeParams(scorerFeeParameters(config))
setAddressType(selectedType)
setAddressTypesToMonitor(monitoredTypes)

Expand Down Expand Up @@ -860,6 +868,18 @@
}
// endregion

private fun scorerFeeParameters(config: Config): ScoringFeeParameters {
val defaultParams = requireNotNull(config.scoringFeeParams) { "scoringFeeParams" }
return defaultParams.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,

Check warning

Code scanning / detekt

Reports lines with exceeded length Warning

Exceeded max line length (120)

Check warning

Code scanning / detekt

Line detected, which is longer than the defined maximum line length in the code style. Warning

Line detected, which is longer than the defined maximum line length in the code style.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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 @@ -1348,67 +1348,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 @@ -1425,7 +1471,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
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1052.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improved Lightning payment route selection reliability.
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Comment thread
piotr-iohk marked this conversation as resolved.
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" }
Expand Down
Loading