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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package to.bitkit.ext

enum class PaymentFailureMessageContext {
GENERIC,
SEND,
}
40 changes: 34 additions & 6 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,43 @@ package to.bitkit.ext
import android.content.Context
import org.lightningdevkit.ldknode.PaymentFailureReason
import to.bitkit.R
import to.bitkit.utils.LdkError

fun PaymentFailureReason?.toUserMessage(context: Context): String = when (this) {
fun PaymentFailureReason?.toUserMessage(
context: Context,
messageContext: PaymentFailureMessageContext = PaymentFailureMessageContext.GENERIC,
): 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)
when (messageContext) {
PaymentFailureMessageContext.GENERIC -> context.getString(R.string.wallet__payment_retries_exhausted)
PaymentFailureMessageContext.SEND -> context.getString(R.string.wallet__send_payment_retries_exhausted)
}
PaymentFailureReason.ROUTE_NOT_FOUND ->
context.getString(R.string.wallet__toast_payment_failed_route_not_found)
when (messageContext) {
PaymentFailureMessageContext.GENERIC -> context.getString(R.string.wallet__payment_route_not_found)
PaymentFailureMessageContext.SEND -> context.getString(R.string.wallet__send_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 Throwable.toSendFailureMessage(context: Context): String {
val fallbackMessage = context.getString(R.string.wallet__payment_failed_description)
if (this is LdkError) return fallbackMessage

return message
?.trim()
?.takeIf { it.isNotEmpty() }
?: fallbackMessage
}
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")
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,27 @@ import to.bitkit.ui.theme.Colors

@Composable
fun SendErrorScreen(
title: String,
message: String?,
isRetrying: Boolean,
onRetry: () -> Unit,
onClose: () -> Unit,
) {
Content(
title = title,
message,
isRetrying = isRetrying,
onRetry = onRetry,
onClose = onClose,
)
}

@Composable
private fun Content(
title: String,
message: String?,
modifier: Modifier = Modifier,
isRetrying: Boolean = false,
onRetry: () -> Unit = {},
onClose: () -> Unit = {},
) {
Expand All @@ -55,7 +61,7 @@ private fun Content(
.gradientBackground()
.navigationBarsPadding()
) {
SheetTopBar(stringResource(R.string.wallet__send_error_tx_failed))
SheetTopBar(title)

Column(
modifier = Modifier
Expand All @@ -64,9 +70,10 @@ private fun Content(
) {
VerticalSpacer(16.dp)

message?.let {
BodyM(it, color = Colors.White64)
}
BodyM(
text = message ?: stringResource(R.string.wallet__payment_failed_description),
color = Colors.White64,
)

FillHeight()
Image(
Expand All @@ -85,13 +92,15 @@ private fun Content(
SecondaryButton(
text = stringResource(R.string.common__cancel),
onClick = onClose,
enabled = !isRetrying,
modifier = Modifier
.weight(1f)
.testTag("Close")
)
PrimaryButton(
text = stringResource(R.string.common__try_again),
text = stringResource(R.string.common__retry),
onClick = onRetry,
isLoading = isRetrying,
modifier = Modifier
.weight(1f)
.testTag("Retry")
Expand All @@ -109,6 +118,7 @@ private fun Preview() {
AppThemeSurface {
BottomSheetPreview {
Content(
title = stringResource(R.string.wallet__send_error_tx_failed),
message = stringResource(R.string.wallet__send_error_create_tx),
modifier = Modifier.sheetHeight(),
)
Expand All @@ -122,6 +132,7 @@ private fun PreviewUnknown() {
AppThemeSurface {
BottomSheetPreview {
Content(
title = stringResource(R.string.wallet__toast_payment_failed_title),
message = null,
modifier = Modifier.sheetHeight(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
Expand Down Expand Up @@ -53,16 +52,16 @@ fun SendQuickPayScreen(
}
}

DisposableEffect(Unit) {
onDispose {
app.resetQuickPay()
}
}

LaunchedEffect(uiState.result) {
when (val result = uiState.result) {
is QuickPayResult.Success -> onPaymentComplete(result.paymentHash, result.amountWithFee)
is QuickPayResult.Pending -> onPaymentPending(result.paymentHash, result.amount)
is QuickPayResult.Success -> {
app.resetQuickPay()
onPaymentComplete(result.paymentHash, result.amountWithFee)
}
is QuickPayResult.Pending -> {
app.resetQuickPay()
onPaymentPending(result.paymentHash, result.amount)
}
is QuickPayResult.Error -> onShowError(result.message)
null -> Unit // continue showing loading state
}
Expand Down
Loading
Loading