diff --git a/app/src/main/java/to/bitkit/ext/Lnurl.kt b/app/src/main/java/to/bitkit/ext/Lnurl.kt index 436d8b25ad..7ac501bbcc 100644 --- a/app/src/main/java/to/bitkit/ext/Lnurl.kt +++ b/app/src/main/java/to/bitkit/ext/Lnurl.kt @@ -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) diff --git a/app/src/main/java/to/bitkit/ext/PaymentFailureReasonExt.kt b/app/src/main/java/to/bitkit/ext/PaymentFailureReasonExt.kt index 49f64498e8..7db9ca1549 100644 --- a/app/src/main/java/to/bitkit/ext/PaymentFailureReasonExt.kt +++ b/app/src/main/java/to/bitkit/ext/PaymentFailureReasonExt.kt @@ -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" diff --git a/app/src/main/java/to/bitkit/models/SendFailureDetails.kt b/app/src/main/java/to/bitkit/models/SendFailureDetails.kt new file mode 100644 index 0000000000..e3d1f983b6 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/SendFailureDetails.kt @@ -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 + } +} diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 728e878f55..c855e6f399 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -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 @@ -637,8 +638,14 @@ class LightningRepo @Inject constructor( } private suspend fun clearNetworkGraph(walletIndex: Int): Result { - 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) @@ -1859,7 +1866,7 @@ 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) @@ -1867,12 +1874,72 @@ class LightningRepo @Inject constructor( 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 = 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() + + 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 = 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 = withContext(bgDispatcher) { @@ -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") @@ -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, diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index d82fea6521..634ca03452 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -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 @@ -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") diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 384cc94683..6a85e9ba1e 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1770,7 +1770,9 @@ private fun NavGraphBuilder.support( } deepLinkableComposable { + val route = it.toRoute() ReportIssueScreen( + prefillMessage = route.prefillMessage, onBack = { navController.popBackStack() }, navigateResultScreen = { isSuccess -> if (isSuccess) { @@ -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 diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt index c65190b5ca..f574bfbd4b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt @@ -1,9 +1,7 @@ package to.bitkit.ui.screens.wallets.send import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -31,23 +29,29 @@ import to.bitkit.ui.theme.Colors @Composable fun SendErrorScreen( + title: String, message: String?, + isRetrying: Boolean, onRetry: () -> Unit, - onClose: () -> Unit, + onContactSupport: () -> Unit, ) { Content( + title = title, message, + isRetrying = isRetrying, onRetry = onRetry, - onClose = onClose, + onContactSupport = onContactSupport, ) } @Composable private fun Content( + title: String, message: String?, modifier: Modifier = Modifier, + isRetrying: Boolean = false, onRetry: () -> Unit = {}, - onClose: () -> Unit = {}, + onContactSupport: () -> Unit = {}, ) { Column( modifier = modifier @@ -55,7 +59,7 @@ private fun Content( .gradientBackground() .navigationBarsPadding() ) { - SheetTopBar(stringResource(R.string.wallet__send_error_tx_failed)) + SheetTopBar(title) Column( modifier = Modifier @@ -64,9 +68,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( @@ -78,25 +83,25 @@ private fun Content( ) FillHeight() - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - SecondaryButton( - text = stringResource(R.string.common__cancel), - onClick = onClose, - modifier = Modifier - .weight(1f) - .testTag("Close") - ) - PrimaryButton( - text = stringResource(R.string.common__try_again), - onClick = onRetry, - modifier = Modifier - .weight(1f) - .testTag("Retry") - ) - } + SecondaryButton( + text = stringResource(R.string.wallet__send_error_support), + onClick = onContactSupport, + enabled = !isRetrying, + modifier = Modifier + .fillMaxWidth() + .testTag("Support") + ) + + VerticalSpacer(16.dp) + + PrimaryButton( + text = stringResource(R.string.common__try_again), + onClick = onRetry, + isLoading = isRetrying, + modifier = Modifier + .fillMaxWidth() + .testTag("Retry") + ) VerticalSpacer(16.dp) } @@ -109,6 +114,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(), ) @@ -122,6 +128,7 @@ private fun PreviewUnknown() { AppThemeSurface { BottomSheetPreview { Content( + title = stringResource(R.string.wallet__toast_payment_failed_title), message = null, modifier = Modifier.sheetHeight(), ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt index 201896a2c4..285a2afed4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt @@ -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 @@ -19,6 +18,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.SendFailureDetails import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BottomSheetPreview @@ -40,7 +40,7 @@ fun SendQuickPayScreen( quickPayData: QuickPayData, onPaymentComplete: (String, Long) -> Unit, onPaymentPending: (String, Long) -> Unit, - onShowError: (String) -> Unit, + onShowError: (SendFailureDetails) -> Unit, viewModel: QuickPayViewModel = hiltViewModel(), ) { val app = appViewModel ?: return @@ -53,17 +53,17 @@ 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.Error -> onShowError(result.message) + 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.failure) null -> Unit // continue showing loading state } } diff --git a/app/src/main/java/to/bitkit/ui/settings/support/ReportIssueScreen.kt b/app/src/main/java/to/bitkit/ui/settings/support/ReportIssueScreen.kt index 3e57d86da5..c6e50dc8cd 100644 --- a/app/src/main/java/to/bitkit/ui/settings/support/ReportIssueScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/support/ReportIssueScreen.kt @@ -32,9 +32,14 @@ import to.bitkit.ui.theme.Colors @Composable fun ReportIssueScreen( viewModel: ReportIssueViewModel = hiltViewModel(), + prefillMessage: String? = null, onBack: () -> Unit, navigateResultScreen: (Boolean) -> Unit, ) { + LaunchedEffect(prefillMessage) { + prefillMessage?.let { viewModel.updateMessage(it) } + } + LaunchedEffect(Unit) { viewModel.reportIssueEffect.collect { event -> when (event) { diff --git a/app/src/main/java/to/bitkit/ui/settings/support/SupportScreen.kt b/app/src/main/java/to/bitkit/ui/settings/support/SupportScreen.kt index 1fdb18c5ac..d3da3c1cc6 100644 --- a/app/src/main/java/to/bitkit/ui/settings/support/SupportScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/support/SupportScreen.kt @@ -83,7 +83,7 @@ fun SupportScreen( Content( onBack = { navController.popBackStack() }, - onClickReportIssue = { navController.navigateTo(Routes.ReportIssue) }, + onClickReportIssue = { navController.navigateTo(Routes.ReportIssue()) }, onClickHelpCenter = { val intent = Intent(Intent.ACTION_VIEW, Env.BITKIT_HELP_CENTER.toUri()) context.startActivity(intent) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 5b801ee8aa..2e0e18b2ad 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -13,7 +13,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -22,11 +26,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import to.bitkit.R +import to.bitkit.ext.supportPaymentRequest import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.SendFailureDetails import to.bitkit.repositories.ConnectivityState import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.components.SyncNodeView @@ -58,8 +65,11 @@ import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.utils.navigationWithDefaultTransitions import to.bitkit.viewmodels.AppViewModel +import to.bitkit.viewmodels.LnurlParams import to.bitkit.viewmodels.SendEffect import to.bitkit.viewmodels.SendEvent +import to.bitkit.viewmodels.SendMethod +import to.bitkit.viewmodels.SendUiState import to.bitkit.viewmodels.WalletViewModel @Suppress("CyclomaticComplexMethod") @@ -72,6 +82,7 @@ fun SendSheet( val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() val isOffline by remember { derivedStateOf { connectivityState != ConnectivityState.CONNECTED } } val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() + var routingCacheResetAttempted by rememberSaveable(startDestination) { mutableStateOf(false) } val shouldShowSyncOverlay by remember { derivedStateOf { @@ -86,6 +97,7 @@ fun SendSheet( if (startDestination == SendRoute.Recipient) { appViewModel.resetSendState() appViewModel.resetQuickPay() + routingCacheResetAttempted = false } } Box( @@ -128,6 +140,12 @@ fun SendSheet( is SendEffect.NavigateToPending -> navController.navigateTo( SendRoute.Pending(it.paymentHash, it.amount) ) { popUpTo(startDestination) { inclusive = true } } + is SendEffect.NavigateToError -> navController.navigateTo( + SendRoute.errorFromFailure( + failure = it.failure, + routingCacheResetAttempted = routingCacheResetAttempted, + ) + ) } } } @@ -320,18 +338,27 @@ fun SendSheet( }, onPaymentPending = { paymentHash, amount -> appViewModel.preserveContactPaymentContext(paymentHash) - navController.navigateTo(SendRoute.Pending(paymentHash, amount)) { + navController.navigateTo( + SendRoute.Pending(paymentHash, amount, SendRetryRoute.QuickPay) + ) { popUpTo(startDestination) { inclusive = true } } }, - onShowError = { errorMessage -> + onShowError = { failure -> appViewModel.clearActiveContactPaymentContext() - navController.navigateTo(SendRoute.Error(errorMessage)) + navController.navigateTo( + SendRoute.errorFromFailure( + failure = failure, + retryRoute = SendRetryRoute.QuickPay, + routingCacheResetAttempted = routingCacheResetAttempted, + ) + ) } ) } composableWithDefaultTransitions { val route = it.toRoute() + val sendUiState by appViewModel.sendUiState.collectAsStateWithLifecycle() SendPendingScreen( paymentHash = route.paymentHash, amount = route.amount, @@ -346,7 +373,13 @@ fun SendSheet( ) }, onPaymentError = { - navController.navigateTo(SendRoute.Error()) { + navController.navigateTo( + SendRoute.Error( + retryRoute = route.retryRoute, + paymentRequest = sendUiState.failurePaymentRequest(), + routingCacheResetAttempted = routingCacheResetAttempted, + ) + ) { popUpTo { inclusive = true } } }, @@ -363,16 +396,44 @@ fun SendSheet( } composableWithDefaultTransitions { val route = it.toRoute() + val sendUiState by appViewModel.sendUiState.collectAsStateWithLifecycle() + val isRetrying by walletViewModel.isRetryingLightningPayment.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() SendErrorScreen( + title = stringResource(route.failureTitle(sendUiState.payMethod)), message = route.message, + isRetrying = isRetrying, onRetry = { - navController.navigateTo(SendRoute.Recipient) { - popUpTo(navController.graph.id) { inclusive = true } + if (isRetrying) return@SendErrorScreen + scope.launch { + val shouldResetRoutingCaches = route.shouldResetRoutingCaches( + routingCacheResetAttempted = routingCacheResetAttempted + ) + if (shouldResetRoutingCaches) routingCacheResetAttempted = true + val resetResult = if (shouldResetRoutingCaches) { + walletViewModel.resetPaymentRoutingCachesAndWait() + } else { + Result.success(Unit) + } + + resetResult + .onSuccess { + appViewModel.setSendEvent(SendEvent.ClearPayConfirmation) + navController.navigateTo(route.retryRoute.sendRoute) { + popUpTo(navController.graph.id) { inclusive = true } + } + } + .onFailure { appViewModel.toast(it) } } }, - onClose = { - appViewModel.hideSheet() - } + onContactSupport = { + appViewModel.navigateToReportIssue( + route.supportMessage( + paymentMethod = sendUiState.payMethod, + routingCacheResetAttempted = routingCacheResetAttempted, + ) + ) + }, ) } } @@ -461,10 +522,21 @@ sealed interface SendRoute { data object ComingSoon : DeepLinkStart @Serializable - data class Pending(val paymentHash: String, val amount: Long) : InternalOnly + data class Pending( + val paymentHash: String, + val amount: Long, + val retryRoute: SendRetryRoute = SendRetryRoute.Confirm, + ) : InternalOnly @Serializable - data class Error(val message: String? = null) : InternalOnly + data class Error( + val message: String? = null, + val retryRoute: SendRetryRoute = SendRetryRoute.Confirm, + val resetRoutingCachesOnRetry: Boolean = false, + val failureType: String = "Unknown", + val paymentRequest: String? = null, + val routingCacheResetAttempted: Boolean = false, + ) : InternalOnly companion object { private val DEEP_LINK_STARTS: List = listOf( @@ -481,5 +553,74 @@ sealed interface SendRoute { fun fromDeepLink(path: String): DeepLinkStart? = ScreenDeepLinks.matchStart(path, Recipient, DEEP_LINK_STARTS) + + fun errorFromFailure( + failure: SendFailureDetails, + retryRoute: SendRetryRoute = SendRetryRoute.Confirm, + routingCacheResetAttempted: Boolean, + ): Error { + return Error( + message = failure.message, + retryRoute = retryRoute, + resetRoutingCachesOnRetry = failure.resetRoutingCachesOnRetry, + failureType = failure.failureType, + paymentRequest = failure.paymentRequest, + routingCacheResetAttempted = routingCacheResetAttempted, + ) + } + } +} + +@Serializable +enum class SendRetryRoute(val sendRoute: SendRoute) { + Confirm(SendRoute.Confirm), + QuickPay(SendRoute.QuickPay), +} + +private fun SendRoute.Error.failureTitle(payMethod: SendMethod): Int { + return when (retryRoute) { + SendRetryRoute.QuickPay -> R.string.wallet__send_instant_failed + SendRetryRoute.Confirm -> when (payMethod) { + SendMethod.LIGHTNING -> R.string.wallet__send_instant_failed + SendMethod.ONCHAIN -> R.string.wallet__send_error_tx_failed + } + } +} + +private fun SendRoute.Error.shouldResetRoutingCaches(routingCacheResetAttempted: Boolean): Boolean { + return SendFailureDetails( + message = message.orEmpty(), + failureType = failureType, + resetRoutingCachesOnRetry = resetRoutingCachesOnRetry, + paymentRequest = paymentRequest, + ).shouldResetRoutingCaches(routingCacheResetAttempted) +} + +private fun SendRoute.Error.supportMessage( + paymentMethod: SendMethod, + routingCacheResetAttempted: Boolean, +): String { + return buildString { + appendLine("I need help with a failed send payment.") + appendLine() + appendLine("Failure type: $failureType") + appendLine("Payment method: ${paymentMethod.supportLabel()}") + appendLine("Routing cache reset attempted: ${if (routingCacheResetAttempted) "Yes" else "No"}") + appendLine() + appendLine("Payment request: ${paymentRequest?.takeIf { it.isNotBlank() } ?: "Unavailable"}") + appendLine() + append("Please investigate this payment failure.") } } + +private fun SendMethod.supportLabel(): String { + return when (this) { + SendMethod.LIGHTNING -> "lightning" + SendMethod.ONCHAIN -> "onchain" + } +} + +private fun SendUiState.failurePaymentRequest(): String? { + if (payMethod != SendMethod.LIGHTNING) return null + return decodedInvoice?.bolt11 ?: (lnurl as? LnurlParams.LnurlPay)?.data?.supportPaymentRequest() +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6ce64be2ae..ea226f710a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -97,7 +97,9 @@ import to.bitkit.ext.minWithdrawableSat import to.bitkit.ext.rawId import to.bitkit.ext.removeSpaces import to.bitkit.ext.setClipboardText +import to.bitkit.ext.supportPaymentRequest import to.bitkit.ext.toHex +import to.bitkit.ext.toSendFailureDetails import to.bitkit.ext.toUserMessage import to.bitkit.ext.totalValue import to.bitkit.ext.walletId @@ -113,6 +115,7 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.PubkyRingAuthCallback import to.bitkit.models.PubkyRingAuthCallbackHandlingResult import to.bitkit.models.SamRockSetupRequest +import to.bitkit.models.SendFailureDetails import to.bitkit.models.Suggestion import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed @@ -1129,11 +1132,22 @@ class AppViewModel @Inject constructor( val activePaymentHash = _sendUiState.value.decodedInvoice?.paymentHash?.toHex() if (_currentSheet.value !is Sheet.Send || activePaymentHash != paymentHash) return false - notifyPaymentFailed(reason) - hideSheet() + setSendEffect( + SendEffect.NavigateToError( + reason.toSendFailureDetails( + context = context, + paymentRequest = _sendUiState.value.currentLightningPaymentRequest(), + ) + ) + ) return true } + private fun SendUiState.currentLightningPaymentRequest(): String? { + if (payMethod != SendMethod.LIGHTNING) return null + return decodedInvoice?.bolt11 ?: (lnurl as? LnurlParams.LnurlPay)?.data?.supportPaymentRequest() + } + private suspend fun handlePaymentReceived( event: Event.PaymentReceived, receiveSheetToClose: ReceiveSheetContext?, @@ -2705,8 +2719,13 @@ class AppViewModel @Inject constructor( preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) } Logger.error("Error sending lightning payment", it, context = TAG) - toast(it) - hideSheet() + val failure = when (it) { + is LightningPaymentFailedError -> it.reason.toSendFailureDetails(context, it.paymentRequest) + else -> it.toSendFailureDetails(context, _sendUiState.value.currentLightningPaymentRequest()) + } + setSendEffect( + SendEffect.NavigateToError(failure) + ) } } } @@ -2900,7 +2919,9 @@ class AppViewModel @Inject constructor( when (it) { is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete(Result.success(hash)) is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( - Result.failure(AppError(it.reason.toUserMessage(context))) + Result.failure( + LightningPaymentFailedError(reason = it.reason, paymentRequest = bolt11) + ) ) else -> WatchResult.Continue() @@ -2928,6 +2949,13 @@ class AppViewModel @Inject constructor( } } + fun navigateToReportIssue(prefillMessage: String) { + viewModelScope.launch { + hideSheet() + mainScreenEffect(MainScreenEffect.Navigate(Routes.ReportIssue(prefillMessage))) + } + } + /** Reselect utxos for current amount & speed then refresh fees using updated utxos */ private fun refreshOnchainSendIfNeeded() { val currentState = _sendUiState.value @@ -3809,6 +3837,7 @@ sealed class SendEffect { data object NavigateToContacts : SendEffect() data object NavigateToComingSoon : SendEffect() data object PaymentSuccess : SendEffect() + data class NavigateToError(val failure: SendFailureDetails) : SendEffect() data class NavigateToPending(val paymentHash: String, val amount: Long) : SendEffect() } @@ -3852,6 +3881,11 @@ sealed interface SendEvent { data object Contacts : SendEvent } +private class LightningPaymentFailedError( + val reason: PaymentFailureReason?, + val paymentRequest: String?, +) : AppError(reason?.name) + sealed interface LnurlParams { data class LnurlPay(val data: LnurlPayData) : LnurlParams data class LnurlWithdraw(val data: LnurlWithdrawData) : LnurlParams diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 0599d4ae42..f2073557ec 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -10,11 +10,14 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.PaymentId import to.bitkit.ext.WatchResult import to.bitkit.ext.callbackAmountMsats -import to.bitkit.ext.toUserMessage +import to.bitkit.ext.supportPaymentRequest +import to.bitkit.ext.toSendFailureDetails import to.bitkit.ext.watchUntil +import to.bitkit.models.SendFailureDetails import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo @@ -40,36 +43,16 @@ class QuickPayViewModel @Inject constructor( fun pay(data: QuickPayData) { viewModelScope.launch { - val (bolt11, amount, displaySats) = when (data) { - is QuickPayData.Bolt11 -> { - Logger.info("QuickPay: processing bolt11 invoice") - Triple(data.bolt11, null, data.sats) - } + val invoice = resolveQuickPayInvoice(data) ?: return@launch - is QuickPayData.LnurlPay -> { - Logger.info("QuickPay: fetching LNURL Pay invoice from callback") - val invoice = lightningRepo.fetchLnurlInvoice( - data = data.data, - amountMsats = data.data.callbackAmountMsats(data.sats), - ) - .getOrElse { error -> - _uiState.update { - it.copy(result = QuickPayResult.Error(error.message.orEmpty())) - } - return@launch - } - Triple(invoice.bolt11, null, data.sats) - } - } - - sendLightning(bolt11, amount) + sendLightning(invoice.bolt11, invoice.amount) .onSuccess { paymentHash -> Logger.info("QuickPay lightning payment successful") _uiState.update { it.copy( result = QuickPayResult.Success( paymentHash = paymentHash, - amountWithFee = displaySats.toLong() // TODO GET FEE WHEN AVAILABLE + amountWithFee = invoice.displaySats.toLong() // TODO GET FEE WHEN AVAILABLE ) ) } @@ -81,7 +64,7 @@ class QuickPayViewModel @Inject constructor( it.copy( result = QuickPayResult.Pending( paymentHash = error.paymentHash, - amount = displaySats.toLong(), + amount = invoice.displaySats.toLong(), ) ) } @@ -89,13 +72,50 @@ class QuickPayViewModel @Inject constructor( } Logger.error("QuickPay lightning payment failed", error, context = TAG) - _uiState.update { - it.copy(result = QuickPayResult.Error(error.message.orEmpty())) - } + handleQuickPayFailure(error, invoice) } } } + private suspend fun resolveQuickPayInvoice(data: QuickPayData): QuickPayInvoice? { + return when (data) { + is QuickPayData.Bolt11 -> { + Logger.info("QuickPay: processing bolt11 invoice") + QuickPayInvoice(data.bolt11, null, data.sats, data.bolt11) + } + + is QuickPayData.LnurlPay -> { + Logger.info("QuickPay: fetching LNURL Pay invoice from callback") + lightningRepo.fetchLnurlInvoice( + data = data.data, + amountMsats = data.data.callbackAmountMsats(data.sats), + ).fold( + onSuccess = { QuickPayInvoice(it.bolt11, null, data.sats, data.data.supportPaymentRequest()) }, + onFailure = { + _uiState.update { state -> + state.copy( + result = QuickPayResult.Error( + it.toSendFailureDetails(context, data.data.supportPaymentRequest()) + ) + ) + } + null + }, + ) + } + } + } + + private fun handleQuickPayFailure(error: Throwable, invoice: QuickPayInvoice) { + val failure = when (error) { + is QuickPayPaymentFailedError -> error.reason.toSendFailureDetails(context, error.paymentRequest) + else -> error.toSendFailureDetails(context, invoice.bolt11.ifBlank { invoice.fallbackPaymentRequest }) + } + _uiState.update { + it.copy(result = QuickPayResult.Error(failure)) + } + } + private suspend fun sendLightning( bolt11: String, amount: ULong? = null, @@ -111,7 +131,9 @@ class QuickPayViewModel @Inject constructor( when (it) { is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete(Result.success(hash)) is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( - Result.failure(AppError(it.reason.toUserMessage(context))) + Result.failure( + QuickPayPaymentFailedError(reason = it.reason, paymentRequest = bolt11) + ) ) else -> WatchResult.Continue() @@ -132,9 +154,21 @@ sealed class QuickPayResult { val amount: Long, ) : QuickPayResult() - data class Error(val message: String) : QuickPayResult() + data class Error(val failure: SendFailureDetails) : QuickPayResult() } data class QuickPayUiState( val result: QuickPayResult? = null, ) + +private data class QuickPayInvoice( + val bolt11: String, + val amount: ULong?, + val displaySats: ULong, + val fallbackPaymentRequest: String, +) + +private class QuickPayPaymentFailedError( + val reason: PaymentFailureReason?, + val paymentRequest: String?, +) : AppError(reason?.name) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index e08c0d3e9d..5c1495aa1e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -1065,7 +1065,7 @@ class TransferViewModel @Inject constructor( ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error), - description = context.getString(R.string.wallet__toast_payment_failed_timeout), + description = context.getString(R.string.wallet__payment_timeout), ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 9c99ef1d77..56a8c8feac 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.PeerDetails @@ -44,6 +45,7 @@ import to.bitkit.services.BoltzService import to.bitkit.services.MigrationService import to.bitkit.ui.onboarding.LOADING_MS import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.isTxSyncTimeout import javax.inject.Inject @@ -104,6 +106,10 @@ class WalletViewModel @Inject constructor( private val _isRefreshing = MutableStateFlow(false) val isRefreshing = _isRefreshing.asStateFlow() + private val retryLightningPaymentMutex = Mutex() + private val _isRetryingLightningPayment = MutableStateFlow(false) + val isRetryingLightningPayment = _isRetryingLightningPayment.asStateFlow() + private var syncJob: Job? = null private var pendingWalletStart = false @@ -455,6 +461,18 @@ class WalletViewModel @Inject constructor( lightningRepo.syncState() } + suspend fun resetPaymentRoutingCachesAndWait(): Result { + if (!retryLightningPaymentMutex.tryLock()) return Result.failure(LightningPaymentRetryInProgressError()) + + _isRetryingLightningPayment.update { true } + return try { + lightningRepo.resetPaymentRoutingCachesAndWait() + } finally { + _isRetryingLightningPayment.update { false } + retryLightningPaymentMutex.unlock() + } + } + fun onPullToRefresh() { // Cancel any existing sync, manual or event triggered syncJob?.cancel() @@ -587,3 +605,5 @@ sealed interface RestoreState { fun isOngoing() = this is InProgress fun isIdle() = this is Initial || this is Settled } + +class LightningPaymentRetryInProgressError : AppError("Lightning payment retry already in progress") diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 02ad62e9f4..f1bb35f478 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -929,6 +929,7 @@ تبدو رسوم المعاملة أكثر من 50% من المبلغ الذي ترسله. هل تريد المتابعة؟ تبدو رسوم المعاملة أكثر من 10$. هل تريد المتابعة؟ تعذر بث المعاملة. يرجى المحاولة مرة أخرى. + الدعم فشلت المعاملة السرعة والرسوم تعيين رسوم مخصصة @@ -938,6 +939,7 @@ السرعة ₿ {feeSats} لهذه المعاملة ₿ {feeSats} لهذه المعاملة ({fiatSymbol}{fiatFormatted}) + فشل الدفع الفاتورة انتهاء صلاحية الفاتورة الحد الأقصى @@ -960,6 +962,16 @@ علامة جديدة أدخل علامة جديدة العلامات المستخدمة سابقًا + تم إيقاف دفعة Lightning قبل اكتمالها. + انتهت صلاحية دفعة Lightning هذه. اطلب فاتورة جديدة. + فشل دفعك الفوري. يرجى المحاولة مرة أخرى. + انتهت صلاحية طلب فاتورة Lightning هذا. اطلب فاتورة جديدة. + رفض المستلم طلب فاتورة Lightning هذا. + رفض المستلم دفعة Lightning هذه. تحقق من الفاتورة وحاول مرة أخرى. + جرّب Bitkit عدة مسارات Lightning، لكن تعذر إكمال الدفعة. + لم يتمكن Bitkit من العثور على مسار Lightning لهذه الدفعة. + انتهت مهلة الدفع. حاول مرة أخرى. + تستخدم فاتورة Lightning هذه ميزات لا يدعمها Bitkit بعد. فشل دفعك الفوري. يرجى المحاولة مرة أخرى. فشل الدفع تم استبدال معاملتك المستلمة برفع الرسوم diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 7664e9a943..9875e2dec1 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -929,6 +929,7 @@ La comisión de transacción parece ser superior al 50% del importe que está enviando. ¿Desea continuar? La tasa de transacción parece ser superior a 10 dólares. ¿Desea continuar? No se ha podido emitir la transacción. Por favor, inténtelo de nuevo. + Contactar a soporte Transacción ha fallado Velocidad y tarifa Fijar tarifa personalizada @@ -938,6 +939,7 @@ Velocidad ₿ {feeSats} para esta transacción ₿ {feeSats} para esta transacción ({fiatSymbol}{fiatFormatted} ) + Pago fallido Factura Expiración de la factura MAX @@ -960,6 +962,16 @@ Nueva etiqueta Introduzca una nueva etiqueta Etiquetas utilizadas anteriormente + El pago Lightning se detuvo antes de completarse. + Este pago Lightning venció. Solicite una factura nueva. + Su pago instantáneo ha fallado. Por favor, inténtelo de nuevo. + Esta solicitud de factura Lightning venció. Solicite una factura nueva. + El destinatario rechazó esta solicitud de factura Lightning. + El destinatario rechazó este pago Lightning. Revise la factura e inténtelo de nuevo. + Bitkit probó varias rutas Lightning, pero el pago no se pudo completar. + Bitkit no pudo encontrar una ruta Lightning para este pago. + El pago agotó el tiempo de espera. Inténtelo de nuevo. + Esta factura Lightning usa funciones que Bitkit aún no admite. Su pago instantáneo ha fallado. Por favor, inténtelo de nuevo. Pago fallido Tu transacción entrante fue reemplazada al aumentar la comisión diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 61a2571fed..5cbb378717 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -929,6 +929,7 @@ La comissió de la transacció sembla ser més del 50% de l\'import que estàs enviant. Vols continuar? La comissió de la transacció sembla ser superior a 10 $. Vols continuar? No es pot transmetre la transacció. Si us plau, torna-ho a provar. + Contacta amb el suport Transacció fallida Velocitat i comissió Estableix una tarifa personalitzada @@ -938,6 +939,7 @@ Velocitat ₿ {feeSats} per a aquesta transacció ₿ {feeSats} per a aquesta transacció ({fiatSymbol}{fiatFormatted}) + Pagament fallit Factura Caducitat de la factura MAX @@ -960,6 +962,16 @@ Nova etiqueta Introdueix una nova etiqueta Etiquetes prèviament utilitzades + El pagament Lightning s\'ha aturat abans de completar-se. + Aquest pagament Lightning ha caducat. Demana una factura nova. + El teu pagament instantani ha fallat. Si us plau, torna-ho a provar. + Aquesta sol·licitud de factura Lightning ha caducat. Demana una factura nova. + El destinatari ha rebutjat aquesta sol·licitud de factura Lightning. + El destinatari ha rebutjat aquest pagament Lightning. Comprova la factura i torna-ho a provar. + Bitkit ha provat diverses rutes Lightning, però el pagament no s\'ha pogut completar. + Bitkit no ha pogut trobar cap ruta Lightning per a aquest pagament. + El pagament ha esgotat el temps d\'espera. Torna-ho a provar. + Aquesta factura Lightning utilitza funcions que Bitkit encara no admet. El teu pagament instantani ha fallat. Si us plau, torna-ho a provar. Pagament fallit La teva transacció rebuda ha estat substituïda per un augment de tarifa diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index d55526311d..aa791ddbfb 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -929,6 +929,7 @@ Zdá se, že transakční poplatek přesahuje 50 % částky, kterou odesíláte. Přejete si pokračovat? Transakční poplatek se zdá být vyšší než $10. Přejete si pokračovat? Transakci se nepodařilo provést. Zkuste to prosím znovu. + Kontaktovat podporu Transakce selhala Rychlost a poplatek Nastavit vlastní poplatek @@ -938,6 +939,7 @@ Rychlost ₿ {feeSats} za tuto transakci ₿ {feeSats} za tuto transakci ({fiatSymbol}{fiatFormatted} ). + Platba se nezdařila Faktura Expirace faktury MAX @@ -960,6 +962,16 @@ Nový tag Vložte nový tag Dříve použité tagy + Lightning platba byla zastavena před dokončením. + Platnost této Lightning platby vypršela. Vyžádejte si novou fakturu. + Vaše okamžitá platba se nezdařila. Zkuste to prosím znovu. + Platnost této žádosti o Lightning fakturu vypršela. Vyžádejte si novou fakturu. + Příjemce tuto žádost o Lightning fakturu odmítl. + Příjemce tuto Lightning platbu odmítl. Zkontrolujte fakturu a zkuste to znovu. + Bitkit vyzkoušel několik Lightning tras, ale platbu se nepodařilo dokončit. + Bitkit nenašel pro tuto platbu žádnou Lightning trasu. + Časový limit platby vypršel. Zkuste to znovu. + Tato Lightning faktura používá funkce, které Bitkit zatím nepodporuje. Vaše okamžitá platba se nezdařila. Zkuste to prosím znovu. Platba se nezdařila Vaše přijatá transakce byla nahrazena navýšením poplatku diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 778412f0b5..e25e3d018b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -784,6 +784,7 @@ Rechnung einfügen Manuell eingeben QR-Code scannen + Zahlung fehlgeschlagen Rechnung Zwischenablage leer Bitte kopiere eine Adresse oder eine Rechnung. @@ -809,6 +810,7 @@ Bezahlen\n<accent>Rechnung...</accent> Transaktion fehlgeschlagen Die Transaktion konnte nicht gesendet werden. Bitte versuche es erneut. + Support kontaktieren Ungültige Bitcoin-Sendeadresse Fehler beim Aktualisieren der Rechnung Fehler beim Abrufen der LNURL-Rechnung @@ -838,6 +840,16 @@ Aktivität nach Tags filtern Tag auswählen Zahlung fehlgeschlagen + Die Lightning-Zahlung wurde gestoppt, bevor sie abgeschlossen wurde. + Diese Lightning-Zahlung ist abgelaufen. Bitte fordere eine neue Rechnung an. + Deine sofortige Zahlung ist fehlgeschlagen. Bitte versuche es erneut. + Diese Lightning-Rechnungsanfrage ist abgelaufen. Bitte fordere eine neue Rechnung an. + Der Empfänger hat diese Lightning-Rechnungsanfrage abgelehnt. + Der Empfänger hat diese Lightning-Zahlung abgelehnt. Bitte prüfe die Rechnung und versuche es erneut. + Bitkit hat mehrere Lightning-Routen ausprobiert, aber die Zahlung konnte nicht abgeschlossen werden. + Bitkit konnte keine Lightning-Route für diese Zahlung finden. + Zeitüberschreitung bei der Zahlung. Bitte versuche es erneut. + Diese Lightning-Rechnung verwendet Funktionen, die Bitkit noch nicht unterstützt. Deine sofortige Zahlung ist fehlgeschlagen. Bitte versuche es erneut. Empfangene Transaktion ersetzt Deine empfangene Transaktion wurde durch eine Gebührenerhöhung ersetzt diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 70fe46fce4..5d4b6e25ad 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -929,6 +929,7 @@ Το τέλος συναλλαγής φαίνεται να είναι πάνω από 50% του ποσού που στέλνεις. Θέλεις να συνεχίσεις; Το τέλος συναλλαγής φαίνεται να είναι πάνω από $10. Θέλεις να συνεχίσεις; Δεν ήταν δυνατή η μετάδοση της συναλλαγής. Δοκίμασε ξανά. + Επικοινωνία με Υποστήριξη Αποτυχία συναλλαγής Ταχύτητα και τέλος Ορισμός προσαρμοσμένου τέλους @@ -938,6 +939,7 @@ Ταχύτητα ₿ {feeSats} για αυτή τη συναλλαγή ₿ {feeSats} για αυτή τη συναλλαγή ({fiatSymbol}{fiatFormatted}) + Αποτυχία πληρωμής Τιμολόγιο Λήξη τιμολογίου ΜΑΧ @@ -960,6 +962,16 @@ Νέα ετικέτα Εισάγαγε νέα ετικέτα Προηγούμενες ετικέτες + Η πληρωμή Lightning σταμάτησε πριν ολοκληρωθεί. + Αυτή η πληρωμή Lightning έληξε. Ζητήστε νέο τιμολόγιο. + Η άμεση πληρωμή σας απέτυχε. Παρακαλώ δοκιμάστε ξανά. + Αυτό το αίτημα τιμολογίου Lightning έληξε. Ζητήστε νέο τιμολόγιο. + Ο παραλήπτης απέρριψε αυτό το αίτημα τιμολογίου Lightning. + Ο παραλήπτης απέρριψε αυτήν την πληρωμή Lightning. Ελέγξτε το τιμολόγιο και δοκιμάστε ξανά. + Το Bitkit δοκίμασε αρκετές διαδρομές Lightning, αλλά η πληρωμή δεν μπόρεσε να ολοκληρωθεί. + Το Bitkit δεν μπόρεσε να βρει διαδρομή Lightning για αυτήν την πληρωμή. + Το χρονικό όριο πληρωμής έληξε. Δοκιμάστε ξανά. + Αυτό το τιμολόγιο Lightning χρησιμοποιεί λειτουργίες που το Bitkit δεν υποστηρίζει ακόμη. Η άμεση πληρωμή σου απέτυχε. Δοκίμασε ξανά. Αποτυχία πληρωμής Η εισερχόμενη συναλλαγή σου αντικαταστάθηκε από αύξηση τέλους diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 67310bbbf6..889b8cadeb 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -929,6 +929,7 @@ La comisión de transacción parece ser superior al 50% del importe que está enviando. ¿Desea continuar? La comisión de transacción parece ser superior a 10 dólares. ¿Desea continuar? No se pudo emitir la transacción. Por favor, inténtalo de nuevo. + Contactar con el servicio de asistencia Transacción ha fallado Velocidad y tarifa Fijar tarifa personalizada @@ -938,6 +939,7 @@ Velocidad ₿ {feeSats} para esta transacción ₿ {feeSats} para esta transacción ({fiatSymbol}{fiatFormatted}) + Pago fallido Factura Expiración de la factura MAX @@ -960,6 +962,16 @@ Nueva etiqueta Introduzca una nueva etiqueta Etiquetas utilizadas anteriormente + El pago Lightning se detuvo antes de completarse. + Este pago Lightning ha caducado. Solicita una factura nueva. + Tu pago instantáneo falló. Por favor, inténtalo de nuevo. + Esta solicitud de factura Lightning ha caducado. Solicita una factura nueva. + El destinatario ha rechazado esta solicitud de factura Lightning. + El destinatario ha rechazado este pago Lightning. Comprueba la factura e inténtalo de nuevo. + Bitkit ha probado varias rutas Lightning, pero el pago no se ha podido completar. + Bitkit no ha podido encontrar una ruta Lightning para este pago. + El pago agotó el tiempo de espera. Inténtalo de nuevo. + Esta factura Lightning usa funciones que Bitkit aún no admite. Tu pago instantáneo falló. Por favor, inténtalo de nuevo. Pago fallido Tu transacción recibida fue reemplazada por un aumento de comisión diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2622d91547..513aa64745 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -785,6 +785,7 @@ Pegar factura Introducir manualmente Escanear QR + Pago Fallido Factura Portapapeles vacío Por favor, copia una dirección o factura @@ -810,6 +811,7 @@ Introduce una factura, dirección o clave de perfil Disponible No se puede emitir la transacción. Por favor, inténtalo de nuevo. + Contactar con el servicio de asistencia Detalles Velocidad y tarifa Velocidad @@ -832,6 +834,16 @@ Introduzca una nueva etiqueta Etiquetas utilizadas anteriormente Filtrar la actividad mediante etiquetas + El pago Lightning se detuvo antes de completarse. + Este pago Lightning ha caducado. Solicita una factura nueva. + Tu pago instantáneo falló. Por favor, inténtalo de nuevo. + Esta solicitud de factura Lightning ha caducado. Solicita una factura nueva. + El destinatario ha rechazado esta solicitud de factura Lightning. + El destinatario ha rechazado este pago Lightning. Comprueba la factura e inténtalo de nuevo. + Bitkit ha probado varias rutas Lightning, pero el pago no se ha podido completar. + Bitkit no ha podido encontrar una ruta Lightning para este pago. + El pago agotó el tiempo de espera. Inténtalo de nuevo. + Esta factura Lightning usa funciones que Bitkit aún no admite. Tu pago instantáneo ha fallado. Por favor, inténtalo de nuevo. Pago Fallido Tu transacción recibida fue reemplazada por un aumento de comisión diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 0b663cbae7..9a34593d38 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -784,6 +784,7 @@ Coller la facture Entrer manuellement Scanner QR + Échec du paiement Facture Saisissez une facture, une adresse ou une clé de profil Presse-papiers vide @@ -809,6 +810,7 @@ Payer \n<accent>facture...</accent> Échec de la transaction Impossible de diffuser la transaction. Veuillez réessayer. + Contacter le support Détails Vitesse et frais Vitesse @@ -834,6 +836,16 @@ Filtrer l\'activité à l\'aide de tags Sélectionnez un Tag Échec du paiement + Le paiement Lightning a été arrêté avant d\'être terminé. + Ce paiement Lightning a expiré. Demandez une nouvelle facture. + Votre paiement instantané a échoué. Veuillez réessayer. + Cette demande de facture Lightning a expiré. Demandez une nouvelle facture. + Le destinataire a rejeté cette demande de facture Lightning. + Le destinataire a rejeté ce paiement Lightning. Vérifiez la facture et réessayez. + Bitkit a essayé plusieurs routes Lightning, mais le paiement n\'a pas pu être terminé. + Bitkit n\'a pas trouvé de route Lightning pour ce paiement. + Le paiement a expiré. Veuillez réessayer. + Cette facture Lightning utilise des fonctionnalités que Bitkit ne prend pas encore en charge. Votre paiement instantané a échoué. Veuillez réessayer. Votre transaction reçue a été remplacée par une augmentation de frais Transaction reçue remplacée diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f56f3f570c..a5010eb8a0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -929,6 +929,7 @@ La commissione della transazione sembra essere superiore al 50% dell\'importo che stai inviando. Vuoi continuare? La commissione della transazione sembra essere superiore a $10. Vuoi continuare? Impossibile trasmettere la transazione. Per favore riprova. + Contatta l\'Assistenza Transazione Fallita Velocità e commissioni Imposta commissione personalizzata @@ -938,6 +939,7 @@ Velocità ₿ {feeSats} per questa transazione ₿ {feeSats} per questa transazione ({fiatSymbol}{fiatFormatted}) + Pagamento fallito Invoice Scadenza invoice MASSIMO @@ -960,6 +962,16 @@ Nuovo tag Inserisci un nuovo tag Tag usati in precedenza + Il pagamento Lightning è stato interrotto prima del completamento. + Questo pagamento Lightning è scaduto. Richiedi una nuova fattura. + Il tuo pagamento istantaneo non è riuscito. Per favore riprova. + Questa richiesta di fattura Lightning è scaduta. Richiedi una nuova fattura. + Il destinatario ha rifiutato questa richiesta di fattura Lightning. + Il destinatario ha rifiutato questo pagamento Lightning. Controlla la fattura e riprova. + Bitkit ha provato diverse rotte Lightning, ma il pagamento non è stato completato. + Bitkit non ha trovato una rotta Lightning per questo pagamento. + Il pagamento è scaduto. Riprova. + Questa fattura Lightning usa funzionalità che Bitkit non supporta ancora. Il tuo pagamento istantaneo non è riuscito. Per favore riprova. Pagamento fallito La tua transazione ricevuta è stata sostituita da un aumento di commissione diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 5fcd184f99..ad63610848 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -929,6 +929,7 @@ De transactievergoeding lijkt meer dan 50% van het bedrag dat je verstuurt te zijn. Wil je doorgaan? De transactievergoeding lijkt meer dan $10 te zijn. Wil je doorgaan? Kan de transactie niet verzenden. Probeer het opnieuw. + Contact Opnemen Transactie mislukt Snelheid en vergoeding Aangepaste vergoeding instellen @@ -938,6 +939,7 @@ Snelheid ₿ {feeSats} voor deze transactie ₿ {feeSats} voor deze transactie ({fiatSymbol}{fiatFormatted}) + Betaling mislukt Factuur Factuutvervaldatum MAX @@ -960,6 +962,16 @@ Nieuwe tag Voer een nieuwe tag in Eerder gebruikte tags + De Lightning-betaling is gestopt voordat deze was voltooid. + Deze Lightning-betaling is verlopen. Vraag een nieuwe factuur aan. + Uw directe betaling is mislukt. Probeer het opnieuw. + Deze Lightning-factuuraanvraag is verlopen. Vraag een nieuwe factuur aan. + De ontvanger heeft deze Lightning-factuuraanvraag geweigerd. + De ontvanger heeft deze Lightning-betaling geweigerd. Controleer de factuur en probeer het opnieuw. + Bitkit heeft meerdere Lightning-routes geprobeerd, maar de betaling kon niet worden voltooid. + Bitkit kon geen Lightning-route voor deze betaling vinden. + Time-out voor betaling. Probeer het opnieuw. + Deze Lightning-factuur gebruikt functies die Bitkit nog niet ondersteunt. Je directe betaling is mislukt. Probeer het opnieuw. Betaling mislukt Je ontvangen transactie is vervangen door een vergoedingsverhoging diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 6f84a53c0e..e636256b03 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -784,6 +784,7 @@ Wklej fakturę Wprowadź ręcznie Skanuj QR + Płatność nie powiodła się Faktura Wprowadzić fakturę, adres lub klucz profilu Schowek pusty @@ -809,6 +810,7 @@ Płatność\n<accent>faktury w toku...</accent> Transakcja nie powiodła się Nie udało się wysłać transakcji. Proszę spróbować ponownie. + Skontaktuj się z pomocą techniczną Szczegóły Prędkość i opłata transakcyjna Prędkość @@ -834,6 +836,16 @@ Filtruj aktywność za pomocą tagów Wybierz tag Płatność nie powiodła się + Płatność Lightning została zatrzymana przed zakończeniem. + Ta płatność Lightning wygasła. Poproś o nową fakturę. + Natychmiastowa płatność nie powiodła się. Proszę spróbować ponownie. + To żądanie faktury Lightning wygasło. Poproś o nową fakturę. + Odbiorca odrzucił to żądanie faktury Lightning. + Odbiorca odrzucił tę płatność Lightning. Sprawdź fakturę i spróbuj ponownie. + Bitkit wypróbował kilka tras Lightning, ale płatności nie udało się ukończyć. + Bitkit nie znalazł trasy Lightning dla tej płatności. + Przekroczono limit czasu płatności. Spróbuj ponownie. + Ta faktura Lightning używa funkcji, których Bitkit jeszcze nie obsługuje. Natychmiastowa płatność nie powiodła się. Proszę spróbować ponownie. Twoja otrzymana transakcja została zastąpiona przez przyspieszenie opłaty Otrzymana transakcja zastąpiona diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ed9a98bc6b..959c0affac 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -929,6 +929,7 @@ A taxa de transação parece ser superior a 50% do valor que você está enviando. Deseja continuar? A taxa de transação parece ser superior a US$ 10. Deseja continuar? Não foi possível transmitir a transação. Por favor, tente novamente. + Suporte Falha na Transação Taxa e velocidade Definir Taxa Personalizada @@ -938,6 +939,7 @@ Velocidade ₿ {feeSats} para esta transação ₿ {feeSats} para esta transação ({fiatSymbol}{fiatFormatted} ) + Pagamento Falhou Invoice Vencimento do invoice MAX @@ -960,6 +962,16 @@ Nova Tag Inserir uma nova tag Tags usadas anteriormente + O pagamento Lightning foi interrompido antes de ser concluído. + Este pagamento Lightning expirou. Solicite uma nova fatura. + Seu pagamento instantâneo falhou. Por favor, tente novamente. + Esta solicitação de fatura Lightning expirou. Solicite uma nova fatura. + O destinatário rejeitou esta solicitação de fatura Lightning. + O destinatário rejeitou este pagamento Lightning. Verifique a fatura e tente novamente. + O Bitkit tentou várias rotas Lightning, mas o pagamento não pôde ser concluído. + O Bitkit não conseguiu encontrar uma rota Lightning para este pagamento. + O pagamento expirou. Tente novamente. + Esta fatura Lightning usa recursos que o Bitkit ainda não oferece suporte. Seu pagamento instantâneo falhou. Por favor, tente novamente. Pagamento Falhou Sua transação recebida foi substituída por um aumento de taxa diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3e2f0e0a9b..92bedfbef5 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -751,6 +751,7 @@ Colar Invoice Inserir Manualmente Escanear QR + Pagamento Falhou Invoice Inserir um invoice, endereço ou código de perfil Área de transferência vazia @@ -776,6 +777,7 @@ Pagando \n<accent>invoice...</accent> Falha na Transação Não foi possível transmitir a transação. Por favor, tente novamente. + Contactar Suporte Detalhes Taxa e velocidade Velocidade @@ -800,6 +802,16 @@ Filtrar atividades usando tags Selecionar Tag Pagamento Falhou + O pagamento Lightning foi interrompido antes de ser concluído. + Este pagamento Lightning expirou. Peça uma nova fatura. + Seu pagamento instantâneo falhou. Por favor, tente novamente. + Este pedido de fatura Lightning expirou. Peça uma nova fatura. + O destinatário rejeitou este pedido de fatura Lightning. + O destinatário rejeitou este pagamento Lightning. Verifique a fatura e tente novamente. + O Bitkit tentou várias rotas Lightning, mas o pagamento não pôde ser concluído. + O Bitkit não conseguiu encontrar uma rota Lightning para este pagamento. + O pagamento expirou. Tente novamente. + Esta fatura Lightning usa funcionalidades que o Bitkit ainda não suporta. Seu pagamento instantâneo falhou. Por favor, tente novamente. Seleção de Moedas Auto diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ac7ef8c831..63e6813b4a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -956,6 +956,7 @@ Bitkit должен увеличить приёмную ёмкость ваше Комиссия за транзакцию составляет более 50% от суммы, которую вы отправляете. Вы хотите продолжать? Комиссия за транзакцию составляет более 10$. Вы хотите продолжать? Не удалось отправить транзакцию. Пожалуйста, попробуйте снова. + Связаться с Поддержкой Транзакция не удалась Скорость и комиссия Установить Пользовательскую Комиссию @@ -965,6 +966,7 @@ Bitkit должен увеличить приёмную ёмкость ваше Скорость ₿ {feeSats} за эту транзакцию ₿ {feeSats} за эту транзакцию ({fiatSymbol}{fiatFormatted}) + Платеж не выполнен Инвойс Срок действия инвойса МАКС @@ -988,6 +990,16 @@ Bitkit должен увеличить приёмную ёмкость ваше Новый тег Введите тег Ранее использовавшиеся теги + Платеж Lightning был остановлен до завершения. + Срок действия этого платежа Lightning истек. Запросите новый счет. + Ваш мгновенный платеж не удался. Пожалуйста, попробуйте снова. + Срок действия этого запроса счета Lightning истек. Запросите новый счет. + Получатель отклонил этот запрос счета Lightning. + Получатель отклонил этот платеж Lightning. Проверьте счет и попробуйте снова. + Bitkit попробовал несколько маршрутов Lightning, но платеж не удалось завершить. + Bitkit не смог найти маршрут Lightning для этого платежа. + Время ожидания платежа истекло. Попробуйте еще раз. + Этот счет Lightning использует функции, которые Bitkit пока не поддерживает. Ваш мгновенный платеж не удался. Пожалуйста, попробуйте снова. Платеж не выполнен Ваша полученная транзакция была заменена повышением комиссии diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1af6e9845f..d0a79bfd27 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1149,8 +1149,18 @@ MINIMUM Note Received Bitcoin + The Lightning payment was stopped before it completed. + This Lightning payment expired. Please request a new invoice. + Your instant payment failed. Please try again. + This Lightning invoice request expired. Please request a new invoice. + The recipient rejected this Lightning invoice request. + The recipient rejected this Lightning payment. Please check the invoice and try again. Payment Request The payment details did not match the request. Payment cancelled. + Bitkit tried several Lightning routes, but the payment could not be completed. + Bitkit couldn\'t find a Lightning route for this payment. + Payment timed out. Please try again. + This Lightning invoice uses features Bitkit does not support yet. Peer disconnected. Receive Receive Lightning funds @@ -1208,6 +1218,7 @@ The transaction fee appears to be over 50% of the amount you are sending. Do you want to continue? The transaction fee appears to be over $10. Do you want to continue? Unable to broadcast the transaction. Please try again. + Contact Support Transaction Failed Speed and fee Set Custom Fee @@ -1218,6 +1229,7 @@ ₿ {feeSats} for this transaction ₿ {feeSats} for this transaction ({fiatSymbol}{fiatFormatted}) From + Payment Failed Invoice Invoice expiration MAX @@ -1243,10 +1255,6 @@ Enter a new tag Previously used tags Your instant payment failed. Please try again. - The recipient rejected this payment. Try a different amount. - Could not find a route with sufficient liquidity. Try a smaller amount or wait and try again. - Could not find a payment path to the recipient. - Payment timed out. Please try again. Payment Failed Your instant payment was sent successfully. Payment Sent diff --git a/app/src/test/java/to/bitkit/ext/PaymentFailureReasonExtTest.kt b/app/src/test/java/to/bitkit/ext/PaymentFailureReasonExtTest.kt new file mode 100644 index 0000000000..5b8f293a89 --- /dev/null +++ b/app/src/test/java/to/bitkit/ext/PaymentFailureReasonExtTest.kt @@ -0,0 +1,63 @@ +package to.bitkit.ext + +import android.content.Context +import org.junit.Test +import org.lightningdevkit.ldknode.PaymentFailureReason +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.models.SendFailureDetails +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PaymentFailureReasonExtTest { + private val context = mock() + + @Test + fun `routing failures use generic payment copy in send context`() { + val generic = "Generic route message" + whenever(context.getString(R.string.wallet__payment_route_not_found)).thenReturn(generic) + + assertEquals(generic, PaymentFailureReason.ROUTE_NOT_FOUND.toUserMessage(context)) + assertEquals(generic, PaymentFailureReason.ROUTE_NOT_FOUND.toSendFailureDetails(context).message) + } + + @Test + fun `unmapped reasons fall back to generic payment failure copy`() { + val message = "Generic payment failed" + whenever(context.getString(R.string.wallet__payment_failed_description)).thenReturn(message) + + assertEquals(message, PaymentFailureReason.UNEXPECTED_ERROR.toUserMessage(context)) + assertEquals(message, (null as PaymentFailureReason?).toUserMessage(context)) + } + + @Test + fun `send failure messages fall back for blank and internal exception messages`() { + val message = "Generic payment failed" + whenever(context.getString(R.string.wallet__payment_failed_description)).thenReturn(message) + + assertEquals(message, Exception(" ").toSendFailureMessage(context)) + assertEquals(message, Exception("Optional(NodeError(DuplicatePayment))").toSendFailureMessage(context)) + } + + @Test + fun `compact failure types omit optional and node error wrappers`() { + assertEquals("routeNotFound", PaymentFailureReason.ROUTE_NOT_FOUND.toCompactFailureType()) + assertEquals("DuplicatePayment", Exception("Optional(NodeError(DuplicatePayment))").toCompactFailureType()) + } + + @Test + fun `routing cache reset is gated to one routing failure retry attempt`() { + val routingFailure = SendFailureDetails( + message = "Route not found", + failureType = "routeNotFound", + resetRoutingCachesOnRetry = true, + ) + val genericFailure = routingFailure.copy(resetRoutingCachesOnRetry = false) + + assertTrue(routingFailure.shouldResetRoutingCaches(routingCacheResetAttempted = false)) + assertFalse(routingFailure.shouldResetRoutingCaches(routingCacheResetAttempted = true)) + assertFalse(genericFailure.shouldResetRoutingCaches(routingCacheResetAttempted = false)) + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 181f882999..f03ba0018b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -29,6 +29,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -62,6 +63,7 @@ import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.PubkyProfile import to.bitkit.models.SamRockPaymentMethod import to.bitkit.models.SamRockSetupRequest +import to.bitkit.models.SendFailureDetails import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransportType import to.bitkit.repositories.ActivityRepo @@ -1345,9 +1347,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `active lightning send failure hides send sheet`() = test { + fun `active lightning send failure navigates to failure screen`() = test { val bolt11 = "lnbcrt1activefailure" val paymentHash = "010203" + val errorMessage = "Bitkit could not find a route" + whenever(context.getString(R.string.wallet__payment_route_not_found)).thenReturn(errorMessage) whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(false) setSendState( SendUiState( @@ -1360,16 +1364,28 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.showSheet(Sheet.Send()) advanceUntilIdle() - emitNodeEvent( - Event.PaymentFailed( - paymentId = "payment_id", - paymentHash = paymentHash, - reason = null, - ), - ) - advanceUntilIdle() + sut.sendEffect.test { + emitNodeEvent( + Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = paymentHash, + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + advanceUntilIdle() - assertNull(sut.currentSheet.value) + assertEquals( + SendEffect.NavigateToError( + SendFailureDetails( + message = errorMessage, + failureType = "routeNotFound", + resetRoutingCachesOnRetry = true, + paymentRequest = bolt11, + ) + ), + awaitItem(), + ) + } } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 7b6c796b14..67d640f8b9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1427,7 +1427,7 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `startSavingsSwap fails when the paid invoice reports a lightning routing failure`() = test { stubSavingsSwapHappyPath() - whenever(context.getString(R.string.wallet__toast_payment_failed_route_not_found)) + whenever(context.getString(R.string.wallet__payment_route_not_found)) .thenReturn(ROUTE_NOT_FOUND_MSG) sut.loadSavingsSwapQuote(REQUESTED_SAT) advanceUntilIdle() diff --git a/changelog.d/next/1140.feat.md b/changelog.d/next/1140.feat.md new file mode 100644 index 0000000000..08e36e9883 --- /dev/null +++ b/changelog.d/next/1140.feat.md @@ -0,0 +1 @@ +Improved Lightning send failure recovery with clearer messages and a retry action that refreshes payment routing.