Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package to.bitkit.domain.commands

import org.lightningdevkit.ldknode.Event
import org.lightningdevkit.ldknode.PaymentFailureReason
import to.bitkit.models.NotificationDetails

sealed interface NotifyPendingPaymentResolved {
Expand All @@ -9,12 +10,15 @@ sealed interface NotifyPendingPaymentResolved {
val paymentHash: String

data class Success(override val paymentHash: String) : Command
data class Failure(override val paymentHash: String) : Command
data class Failure(
override val paymentHash: String,
val reason: PaymentFailureReason? = null,
) : Command

companion object {
fun from(event: Event): Command? = when (event) {
is Event.PaymentSuccessful -> Success(event.paymentHash)
is Event.PaymentFailed -> event.paymentHash?.let { Failure(it) }
is Event.PaymentFailed -> event.paymentHash?.let { Failure(it, event.reason) }
else -> null
}
}
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/ext/Lnurl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ fun LnurlPayData.isFixedAmount(): Boolean =
fun LnurlPayData.callbackAmountMsats(userSats: ULong? = null): ULong =
if (isFixedAmount()) minSendable else (userSats ?: minSendableSat()) * MSat.PER_SAT

fun LnurlPayData.supportPaymentRequest(): String = "LNURL: $uri"

fun LnurlWithdrawData.minWithdrawableSat(): ULong = msatCeilOf(minWithdrawable ?: 0u)
fun LnurlWithdrawData.maxWithdrawableSat(): ULong = msatFloorOf(maxWithdrawable)

Expand Down
106 changes: 101 additions & 5 deletions app/src/main/java/to/bitkit/ext/PaymentFailureReasonExt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,111 @@ package to.bitkit.ext
import android.content.Context
import org.lightningdevkit.ldknode.PaymentFailureReason
import to.bitkit.R
import to.bitkit.models.SendFailureDetails
import to.bitkit.utils.LdkError

fun PaymentFailureReason?.toUserMessage(context: Context): String = when (this) {
PaymentFailureReason.RECIPIENT_REJECTED ->
context.getString(R.string.wallet__toast_payment_failed_recipient_rejected)
context.getString(R.string.wallet__payment_recipient_rejected)
PaymentFailureReason.USER_ABANDONED ->
context.getString(R.string.wallet__payment_abandoned)
PaymentFailureReason.RETRIES_EXHAUSTED ->
context.getString(R.string.wallet__toast_payment_failed_retries_exhausted)
context.getString(R.string.wallet__payment_retries_exhausted)
PaymentFailureReason.ROUTE_NOT_FOUND ->
context.getString(R.string.wallet__toast_payment_failed_route_not_found)
context.getString(R.string.wallet__payment_route_not_found)
PaymentFailureReason.PAYMENT_EXPIRED ->
context.getString(R.string.wallet__toast_payment_failed_timeout)
else -> context.getString(R.string.wallet__toast_payment_failed_description)
context.getString(R.string.wallet__payment_expired)
PaymentFailureReason.UNKNOWN_REQUIRED_FEATURES ->
context.getString(R.string.wallet__payment_unknown_required_features)
PaymentFailureReason.INVOICE_REQUEST_EXPIRED ->
context.getString(R.string.wallet__payment_invoice_request_expired)
PaymentFailureReason.INVOICE_REQUEST_REJECTED ->
context.getString(R.string.wallet__payment_invoice_request_rejected)
else -> context.getString(R.string.wallet__payment_failed_description)
}

fun PaymentFailureReason?.shouldResetRoutingCachesOnRetry(): Boolean =
this == PaymentFailureReason.ROUTE_NOT_FOUND || this == PaymentFailureReason.RETRIES_EXHAUSTED

fun PaymentFailureReason?.toCompactFailureType(): String {
return this?.name?.snakeToLowerCamel() ?: UNKNOWN_FAILURE_TYPE
}

fun PaymentFailureReason?.toSendFailureDetails(
context: Context,
paymentRequest: String? = null,
): SendFailureDetails {
return SendFailureDetails(
message = toUserMessage(context),
failureType = toCompactFailureType(),
resetRoutingCachesOnRetry = shouldResetRoutingCachesOnRetry(),
paymentRequest = paymentRequest,
)
}

fun Throwable.toSendFailureMessage(context: Context): String {
val fallbackMessage = context.getString(R.string.wallet__payment_failed_description)
val rawMessage = message?.trim().orEmpty()

if (this is LdkError || rawMessage.isBlank() || rawMessage.looksInternalPaymentError()) {
return fallbackMessage
}

return rawMessage
}

fun Throwable.toCompactFailureType(): String {
val rawValue = message?.trim()?.takeIf { it.isNotEmpty() }
?: this::class.simpleName
?: UNKNOWN_FAILURE_TYPE

return rawValue.compactFailureType()
}

fun Throwable.toSendFailureDetails(
context: Context,
paymentRequest: String? = null,
): SendFailureDetails {
return SendFailureDetails(
message = toSendFailureMessage(context),
failureType = toCompactFailureType(),
resetRoutingCachesOnRetry = false,
paymentRequest = paymentRequest,
)
}

private fun String.snakeToLowerCamel(): String {
return lowercase()
.split("_")
.filter { it.isNotBlank() }
.mapIndexed { index, segment ->
if (index == 0) segment else segment.replaceFirstChar { it.titlecase() }
}
.joinToString("")
.ifBlank { UNKNOWN_FAILURE_TYPE }
}

private fun String.compactFailureType(): String {
val unwrappedOptional = removeSurrounding("Optional(", ")")
val unwrappedNodeError = unwrappedOptional.removeSurrounding("NodeError(", ")")
return unwrappedNodeError
.substringBefore("(")
.substringAfterLast(".")
.trim()
.ifBlank { UNKNOWN_FAILURE_TYPE }
}

private fun String.looksInternalPaymentError(): Boolean {
return INTERNAL_PAYMENT_ERROR_MARKERS.any { contains(it, ignoreCase = true) }
}

private val INTERNAL_PAYMENT_ERROR_MARKERS = listOf(
"Optional(",
"NodeError",
"DuplicatePayment",
"PaymentFailureReason",
"ldknode",
"LDK",
)

private const val UNKNOWN_FAILURE_TYPE = "Unknown"
12 changes: 12 additions & 0 deletions app/src/main/java/to/bitkit/models/SendFailureDetails.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package to.bitkit.models

data class SendFailureDetails(
val message: String,
val failureType: String,
val resetRoutingCachesOnRetry: Boolean,
val paymentRequest: String? = null,
) {
fun shouldResetRoutingCaches(routingCacheResetAttempted: Boolean): Boolean {
return resetRoutingCachesOnRetry && !routingCacheResetAttempted
}
}
166 changes: 162 additions & 4 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import org.lightningdevkit.ldknode.ChannelDetails
import org.lightningdevkit.ldknode.ClosureReason
import org.lightningdevkit.ldknode.CoinSelectionAlgorithm
import org.lightningdevkit.ldknode.Event
import org.lightningdevkit.ldknode.Network
import org.lightningdevkit.ldknode.NodeStatus
import org.lightningdevkit.ldknode.PaymentDetails
import org.lightningdevkit.ldknode.PaymentHash
Expand Down Expand Up @@ -92,6 +93,7 @@ import to.bitkit.services.LnurlChannelResponse
import to.bitkit.services.LnurlService
import to.bitkit.services.LnurlWithdrawResponse
import to.bitkit.services.LspNotificationsService
import to.bitkit.services.NetworkGraphInfo
import to.bitkit.services.NodeEventHandler
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
Expand Down Expand Up @@ -637,8 +639,14 @@ class LightningRepo @Inject constructor(
}

private suspend fun clearNetworkGraph(walletIndex: Int): Result<Unit> {
lightningService.resetNetworkGraph(walletIndex)
return runCatching {
runSuspendCatching {
lightningService.resetNetworkGraph(walletIndex)
}.onFailure {
Logger.warn("Failed to clear local network graph", it, context = TAG)
return Result.failure(it)
}

return runSuspendCatching {
vssBackupClientLdk.setup(walletIndex).getOrThrow()
vssBackupClientLdk.deleteObject("network_graph").getOrThrow()
Logger.info("Cleared network graph from VSS", context = TAG)
Expand Down Expand Up @@ -1859,20 +1867,111 @@ class LightningRepo @Inject constructor(
vssBackupClientLdk.deleteObject(VSS_KEY_EXTERNAL_SCORES_CACHE).getOrThrow()
}.onFailure {
Logger.error("Failed to delete pathfinding scores from VSS", it, context = TAG)
start(walletIndex = walletIndex, shouldRetry = false).onFailure { startError ->
start(walletIndex = walletIndex, shouldRetry = false, shouldValidateGraph = false).onFailure { startError ->
Logger.error("Failed to restart node after pathfinding scores reset failure", startError, context = TAG)
}
return@withContext Result.failure(it)
}

val resetAtSecs = nowMillis() / 1000

start(walletIndex = walletIndex, shouldRetry = false)
start(walletIndex = walletIndex, shouldRetry = false, shouldValidateGraph = false)
.map { resetAtSecs }
.onSuccess {
Logger.info("Pathfinding scores reset at '$resetAtSecs'", context = TAG)
}
}

suspend fun resetPaymentRoutingCachesAndWait(walletIndex: Int = 0): Result<Unit> = withContext(bgDispatcher) {
val refreshStartedAtMs = nowMillis()
val refreshStartedAtSecs = (refreshStartedAtMs / 1000).toULong()
val requiresRgsRefresh = Env.network != Network.REGTEST &&
!settingsStore.data.first().rgsServerUrl.isNullOrEmpty()
val requiresScorerRefresh = Env.ldkScorerUrl != null
val resetErrors = mutableListOf<Throwable>()

Logger.info(
"Started payment routing refresh rgs='$requiresRgsRefresh' scorer='$requiresScorerRefresh'",
context = TAG,
)

stop().onFailure {
return@withContext Result.failure(it)
}

clearNetworkGraph(walletIndex).onFailure {
resetErrors.add(it)
}

resetPathfindingScores(walletIndex).onFailure {
resetErrors.add(it)
}

resetErrors.firstOrNull()?.let {
return@withContext Result.failure(it)
}

val result = waitForPaymentRoutingDataRefresh(
walletIndex = walletIndex,
refreshStartedAtMs = refreshStartedAtMs,
refreshStartedAtSecs = refreshStartedAtSecs,
requiresRgsRefresh = requiresRgsRefresh,
requiresScorerRefresh = requiresScorerRefresh,
)
result.onSuccess {
Logger.info(
"Finished payment routing refresh elapsedMs='${nowMillis() - refreshStartedAtMs}'",
context = TAG,
)
}
}

private suspend fun waitForPaymentRoutingDataRefresh(
walletIndex: Int,
refreshStartedAtMs: Long,
refreshStartedAtSecs: ULong,
requiresRgsRefresh: Boolean,
requiresScorerRefresh: Boolean,
): Result<Unit> = withContext(bgDispatcher) {
if (!requiresRgsRefresh && !requiresScorerRefresh) {
Logger.info(
"Skipped payment routing refresh wait because no routing sources are required",
context = TAG,
)
return@withContext Result.success(Unit)
}

var lastStatus: PaymentRoutingRefreshStatus? = null
val refreshed = withTimeoutOrNull(PAYMENT_ROUTING_REFRESH_TIMEOUT) {
while (isActive) {
syncState()
val status = _lightningState.value.paymentRoutingRefreshStatus(
graphCacheModificationDate = lightningService.networkGraphCacheModificationDate(walletIndex),
networkGraphInfo = getNetworkGraphInfo(),
refreshStartedAtSecs = refreshStartedAtSecs,
requiresRgsRefresh = requiresRgsRefresh,
requiresScorerRefresh = requiresScorerRefresh,
)
lastStatus = status
if (status.isFresh) {
return@withTimeoutOrNull true
}
delay(PAYMENT_ROUTING_REFRESH_POLL_DELAY)
}
false
} == true

if (refreshed) {
Result.success(Unit)
} else {
Logger.warn(
"Timed out payment routing refresh elapsedMs='${nowMillis() - refreshStartedAtMs}' " +
lastStatus?.toLogFields().orEmpty(),
context = TAG,
)
Result.failure(PaymentRoutingRefreshTimeoutError())
}
}
// endregion

suspend fun restartNode(): Result<Unit> = withContext(bgDispatcher) {
Expand Down Expand Up @@ -1901,6 +2000,64 @@ class LightningRepo @Inject constructor(
private val NO_USABLE_CHANNELS_FEEDBACK_DELAY = 2_500.milliseconds
val SEND_LN_TIMEOUT = 10.seconds
private val PROBE_TIMEOUT = 60.seconds
private val PAYMENT_ROUTING_REFRESH_TIMEOUT = 20.seconds
private val PAYMENT_ROUTING_REFRESH_POLL_DELAY = 500.milliseconds
}
}

private fun LightningState.paymentRoutingRefreshStatus(
graphCacheModificationDate: Long?,
networkGraphInfo: NetworkGraphInfo?,
refreshStartedAtSecs: ULong,
requiresRgsRefresh: Boolean,
requiresScorerRefresh: Boolean,
): PaymentRoutingRefreshStatus {
val status = nodeStatus
val nodeRunning = nodeLifecycleState.isRunning()
val graphNodeCount = networkGraphInfo?.nodeCount
val graphChannelCount = networkGraphInfo?.channelCount
val hasInMemoryGraph = (graphNodeCount ?: 0) > 0 && (graphChannelCount ?: 0) > 0

val hasFreshRgs = !requiresRgsRefresh ||
graphCacheModificationDate != null && (graphCacheModificationDate / 1000).toULong() >= refreshStartedAtSecs ||
hasInMemoryGraph

val latestScoresTimestamp = status?.latestPathfindingScoresSyncTimestamp
val hasFreshScores = !requiresScorerRefresh ||
latestScoresTimestamp != null && latestScoresTimestamp >= refreshStartedAtSecs

return PaymentRoutingRefreshStatus(
nodeRunning = nodeRunning,
graphFresh = hasFreshRgs,
scorerFresh = hasFreshScores,
graphCacheModificationDate = graphCacheModificationDate,
graphNodeCount = graphNodeCount,
graphChannelCount = graphChannelCount,
latestPathfindingScoresSyncTimestamp = latestScoresTimestamp,
refreshStartedAtSecs = refreshStartedAtSecs,
)
}

private data class PaymentRoutingRefreshStatus(
val nodeRunning: Boolean,
val graphFresh: Boolean,
val scorerFresh: Boolean,
val graphCacheModificationDate: Long?,
val graphNodeCount: Int?,
val graphChannelCount: Int?,
val latestPathfindingScoresSyncTimestamp: ULong?,
val refreshStartedAtSecs: ULong,
) {
val isFresh: Boolean = nodeRunning && graphFresh && scorerFresh

fun toLogFields(): String {
return "nodeRunning='$nodeRunning' graphFresh='$graphFresh' scorerFresh='$scorerFresh' " +
"graphMtime='${graphCacheModificationDate ?: "-"}' " +
"graphMtimeSecs='${graphCacheModificationDate?.let { it / 1000 } ?: "-"}' " +
"graphNodes='${graphNodeCount ?: "-"}' " +
"graphChannels='${graphChannelCount ?: "-"}' " +
"scorerTs='${latestPathfindingScoresSyncTimestamp ?: "-"}' " +
"refreshStartedAtSecs='$refreshStartedAtSecs'"
}
}

Expand All @@ -1912,6 +2069,7 @@ class NodeRunTimeoutError(opName: String) : AppError("Timeout waiting for node t
class GetPaymentsError : AppError("It wasn't possible get the payments")
class SyncUnhealthyError : AppError("Wallet sync failed before send")
class LnurlPayInvoiceMismatchError : AppError("The invoice did not match the requested payment. Payment cancelled.")
class PaymentRoutingRefreshTimeoutError : AppError("Timeout waiting for payment routing data refresh")

data class NodeEventUpdate(
val event: Event,
Expand Down
Loading
Loading