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
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() }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Android can derive the type from exception class NodeException.DuplicatePayment -> DuplicatePayment, could use it instead of depending on the message

?: 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(".")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

substringAfterLast(".") assumes the iOS input shape. This function is a port of compactFailureType in bitkit-ios (Bitkit/Views/Wallets/Send/SendFailure.swift:41), where the input is String(describing: someType) and the dot split strips a Swift module/type qualifier (LDKNode.NodeError -> NodeError), the same way the Optional( and ( steps handle Swift's optional and associated-value rendering.

On Android the input is Throwable.message, which is a human-readable sentence: Errors.kt:120 builds "LDK Node error: $it" over strings like "Duplicate payment.". So the dot split lands on sentence punctuation rather than a qualifier:

  • "LDK Node error: Duplicate payment." -> substringAfterLast(".") == "" -> ifBlank { UNKNOWN_FAILURE_TYPE } -> "Unknown"
  • "LDK Node error: Invalid custom TLVs" (no trailing period) -> no . at all -> the whole "LDK Node error: ..." prefix leaks through as the failure type

Net effect: Failure type: Unknown in the prefilled support ticket for essentially every synchronous LN send failure (insufficient funds, duplicate payment, invalid invoice...). The this::class.simpleName fallback at :61 is already unqualified, so the dot-stripping is vestigial on that path too.

Minimum regression test that pins the trigger without prescribing an implementation:

assertNotEquals("Unknown", Exception("Payment sending failed.").toCompactFailureType())

See the test-file comment for the fuller block. Getting a real type name out of an LdkError means reading the inner NodeException class rather than parsing its message — note LdkError.inner is currently private (Errors.kt:31), so that would need widening.

.trim()
.ifBlank { UNKNOWN_FAILURE_TYPE }
}
Comment on lines +90 to +98

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kotlin errors here comes in a different shape, like "LDK Node error: Duplicate payment."
using .substringAfterLast(".") makes the last part always
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(",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also iOS exclusive error shape

"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
}
}
182 changes: 178 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,127 @@ 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,
)

val resetResult = withContext(NonCancellable) reset@{
stop().onFailure {
start(
walletIndex = walletIndex,
shouldRetry = false,
shouldValidateGraph = false,
).onFailure { startError ->
Logger.error(
"Failed to restart node after payment routing refresh stop failure",
startError,
context = TAG,
)
}
return@reset Result.failure(it)
}

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

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

resetErrors.firstOrNull()?.let {
Result.failure(it)
} ?: Result.success(Unit)
}
resetResult.onFailure {
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 +2016,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 +2085,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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.lightningdevkit.ldknode.PaymentFailureReason
import to.bitkit.R
import to.bitkit.models.NotificationDetails
import to.bitkit.utils.AppError
Expand Down Expand Up @@ -48,7 +49,10 @@ sealed interface PendingPaymentResolution {
val paymentHash: String

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

object PendingPaymentNotification {
Expand Down
Loading
Loading