Skip to content
Draft
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() }
?: 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
}
}
97 changes: 93 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 @@ -637,8 +638,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 +1866,80 @@ 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 refreshStartedAtSecs = (nowMillis() / 1000).toULong()
val requiresRgsRefresh = Env.network != Network.REGTEST &&
!settingsStore.data.first().rgsServerUrl.isNullOrEmpty()
val requiresScorerRefresh = Env.ldkScorerUrl != null
val resetErrors = mutableListOf<Throwable>()

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

waitForPaymentRoutingDataRefresh(
walletIndex = walletIndex,
refreshStartedAtSecs = refreshStartedAtSecs,
requiresRgsRefresh = requiresRgsRefresh,
requiresScorerRefresh = requiresScorerRefresh,
)
}

private suspend fun waitForPaymentRoutingDataRefresh(
walletIndex: Int,
refreshStartedAtSecs: ULong,
requiresRgsRefresh: Boolean,
requiresScorerRefresh: Boolean,
): Result<Unit> = withContext(bgDispatcher) {
if (!requiresRgsRefresh && !requiresScorerRefresh) return@withContext Result.success(Unit)

val refreshed = withTimeoutOrNull(PAYMENT_ROUTING_REFRESH_TIMEOUT) {
while (isActive) {
syncState()
if (
_lightningState.value.hasFreshPaymentRoutingData(
graphCacheModificationDate = lightningService.networkGraphCacheModificationDate(walletIndex),
refreshStartedAtSecs = refreshStartedAtSecs,
requiresRgsRefresh = requiresRgsRefresh,
requiresScorerRefresh = requiresScorerRefresh,
)
) {
return@withTimeoutOrNull true
}
delay(PAYMENT_ROUTING_REFRESH_POLL_DELAY)
}
false
} == true

if (refreshed) Result.success(Unit) else Result.failure(PaymentRoutingRefreshTimeoutError())
}
// endregion

suspend fun restartNode(): Result<Unit> = withContext(bgDispatcher) {
Expand Down Expand Up @@ -1901,9 +1968,30 @@ 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.hasFreshPaymentRoutingData(
graphCacheModificationDate: Long?,
refreshStartedAtSecs: ULong,
requiresRgsRefresh: Boolean,
requiresScorerRefresh: Boolean,
): Boolean {
val status = nodeStatus
if (!nodeLifecycleState.isRunning()) return false

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

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

return hasFreshRgs && hasFreshScores
}

class RecoveryModeError : AppError("App in recovery mode, skipping node start")
class NodeSetupError : AppError("Unknown node setup error")
class NodeStopTimeoutError : AppError("Timeout waiting for node to stop")
Expand All @@ -1912,6 +2000,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
16 changes: 13 additions & 3 deletions app/src/main/java/to/bitkit/services/LightningService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -526,16 +526,24 @@ class LightningService @Inject constructor(
if (node != null) throw ServiceError.NodeStillRunning()
awaitNodeRelease()
Logger.warn("Resetting network graph cache…", context = TAG)
val ldkPath = Path(Env.ldkStoragePath(walletIndex)).toFile()
val graphFile = ldkPath.resolve("network_graph_cache")
val graphFile = networkGraphCacheFile(walletIndex)
if (graphFile.exists()) {
graphFile.delete()
if (!graphFile.delete()) throw NetworkGraphCacheDeleteError()
Logger.info("Network graph cache deleted", context = TAG)
} else {
Logger.info("No network graph cache found", context = TAG)
}
}

fun networkGraphCacheModificationDate(walletIndex: Int): Long? {
val graphFile = networkGraphCacheFile(walletIndex)
return graphFile.takeIf { it.exists() }?.lastModified()
}

private fun networkGraphCacheFile(walletIndex: Int): File {
return Path(Env.ldkStoragePath(walletIndex)).toFile().resolve("network_graph_cache")
}

@Suppress("ReturnCount")
fun aresRequiredPeersInNetworkGraph(): Boolean {
val node = this.node ?: return true
Expand Down Expand Up @@ -1385,3 +1393,5 @@ data class NetworkGraphInfo(
class TrustedPeerForceCloseException : AppError(
"Cannot force close channel with trusted peer. Force close is disabled for Blocktank LSP channels."
)

class NetworkGraphCacheDeleteError : AppError("Failed to delete network graph cache")
4 changes: 3 additions & 1 deletion app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1770,7 +1770,9 @@ private fun NavGraphBuilder.support(
}

deepLinkableComposable<Routes.ReportIssue> {
val route = it.toRoute<Routes.ReportIssue>()
ReportIssueScreen(
prefillMessage = route.prefillMessage,
onBack = { navController.popBackStack() },
navigateResultScreen = { isSuccess ->
if (isSuccess) {
Expand Down Expand Up @@ -2187,7 +2189,7 @@ sealed interface Routes {
data object Support : Routes.DeepLinkable

@Serializable
data object ReportIssue : Routes.DeepLinkable
data class ReportIssue(val prefillMessage: String? = null) : Routes.DeepLinkable

@Serializable
data object ReportIssueSuccess : Routes.DeepLinkable
Expand Down
Loading
Loading