Skip to content
Merged
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
5 changes: 3 additions & 2 deletions app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@ 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,
)

Expand Down Expand Up @@ -215,7 +216,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
}
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.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 @@
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 @@
configureChainSource(customServerUrl)
configureGossipSource(customRgsServerUrl)
configureScorerSource()
setScoringFeeParams(scorerFeeParameters())
setAddressType(selectedType)
setAddressTypesToMonitor(monitoredTypes)

Expand Down Expand Up @@ -860,6 +881,16 @@
}
// 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,

Check warning

Code scanning / detekt

Reports mis-indented code Warning

Unexpected indentation (12) (should be 8)
Comment thread
pwltr marked this conversation as resolved.
Dismissed
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