diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index e5085ff311..6373c4c520 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -230,6 +230,7 @@ data class SettingsData( val publicPaykitUsdtEnabled: Boolean = true, val usdtPreference: UsdtPreference = UsdtPreference.UNDECIDED, val hasSeenUsdtIntro: Boolean = false, + val hasSeenMoveToSparkIntro: Boolean = false, val publicPaykitBolt11: String = "", val publicPaykitBolt11PaymentHash: String = "", val publicPaykitBolt11ExpiresAtMillis: Long = 0, diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index ad995b68cc..cb71bc768a 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1472,6 +1472,19 @@ class LightningRepo @Inject constructor( return payInvoiceWithLdk(bolt11, sats, paymentDeadlineAt, maxTotalRoutingFeeMsat, onBeforeSend) } + /** Pays [bolt11] from the channels only, never falling back to Spark, which moving a channel balance needs. */ + suspend fun payInvoiceOverLdk( + bolt11: String, + sats: ULong?, + maxTotalRoutingFeeMsat: ULong?, + ): Result = payInvoiceWithLdk(bolt11, sats, null, maxTotalRoutingFeeMsat, onBeforeSend = { true }) + + /** The routing fee for paying [amountSats] to [bolt11] from the channels, never quoted on Spark. */ + suspend fun estimateLdkRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result = + executeWhenNodeRunning("estimateLdkRoutingFeesForAmount") { + lightningService.estimateRoutingFeesForAmount(bolt11, amountSats) + } + private suspend fun payInvoiceWithLdk( bolt11: String, sats: ULong?, diff --git a/app/src/main/java/to/bitkit/repositories/MoveToSparkRepo.kt b/app/src/main/java/to/bitkit/repositories/MoveToSparkRepo.kt new file mode 100644 index 0000000000..f560b09fa1 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/MoveToSparkRepo.kt @@ -0,0 +1,198 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.lightningdevkit.ldknode.Bolt11Invoice +import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.PaymentFailureReason +import to.bitkit.di.BgDispatcher +import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.totalNextOutboundHtlcLimitSats +import to.bitkit.models.safe +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.minutes + +/** + * Moves the balance of the user's Lightning channels to their own Spark wallet, paying a Spark invoice over the + * channels only. Blocktank is closing its channels, and this is the cheapest way to keep the balance in Spending. + */ +@Singleton +class MoveToSparkRepo @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val lightningRepo: LightningRepo, + private val sparkRepo: SparkRepo, +) { + companion object { + private const val TAG = "MoveToSparkRepo" + private const val INVOICE_DESCRIPTION = "Move to Spark" + private const val INVOICE_EXPIRY_SECS = 3600u + private const val MAX_QUOTE_ROUNDS = 4 + + /** How long the move waits for the payment to settle before it reports it as still in flight. */ + private val PAYMENT_TIMEOUT = 2.minutes + } + + private val ownPaymentHashes = ConcurrentHashMap.newKeySet() + + /** Whether [paymentHash] is a move to Spark, whose send and receive the app does not announce as payments. */ + fun isMovePayment(paymentHash: String): Boolean = paymentHash in ownPaymentHashes + + /** Whether the channels hold a balance worth moving to a usable Spark wallet. */ + val canMove: Flow = combine(lightningRepo.lightningState, sparkRepo.state) { lightning, spark -> + MoveToSparkMath.canMove(lightning.channels, spark) + }.distinctUntilChanged() + + /** The most the channels can send now, before routing fees. */ + fun movableSats(): ULong = MoveToSparkMath.movableSats(lightningRepo.lightningState.value.channels) + + /** + * Quotes moving [targetSats], or everything the channels can send, to a new Spark invoice. + * The amount leaves room for the routing fee, which the quote carries and the move caps the payment at. + */ + suspend fun quote(targetSats: ULong? = null): Result = withContext(bgDispatcher) { + runSuspendCatching { + val limitSats = movableSats() + val wantedSats = targetSats?.coerceAtMost(limitSats) ?: limitSats + if (wantedSats < MoveToSparkMath.MIN_MOVE_SATS) throw MoveToSparkError.NothingToMove() + if (!sparkRepo.state.value.isReady) throw MoveToSparkError.SparkUnavailable() + + val invoice = sparkRepo.createInvoice(null, INVOICE_DESCRIPTION, INVOICE_EXPIRY_SECS).getOrThrow() + var amountSats = MoveToSparkMath.firstCandidate(wantedSats, limitSats) + var fitting: MoveToSparkQuote? = null + // The first amount leaves room for a guessed fee; once the real fee is known, the amount grows to fit it. + repeat(MAX_QUOTE_ROUNDS) { + if (amountSats < MoveToSparkMath.MIN_MOVE_SATS) return@repeat + val feeSats = lightningRepo.estimateLdkRoutingFeesForAmount(invoice, amountSats).getOrElse { + fitting?.let { quote -> return@runSuspendCatching quote } + throw MoveToSparkError.TooLarge(amountSats, it) + } + val nextSats = minOf(wantedSats, MoveToSparkMath.amountAfterFee(limitSats, feeSats)) + if (amountSats.safe() + feeSats.safe() <= limitSats) { + fitting = MoveToSparkQuote(invoice, amountSats, feeSats) + if (nextSats <= amountSats) return@runSuspendCatching checkNotNull(fitting) + } + amountSats = nextSats + } + fitting ?: throw MoveToSparkError.TooLarge(amountSats) + }.onFailure { + Logger.warn("Failed to quote move to Spark of '$targetSats'", it, context = TAG) + } + } + + /** Pays [quote] over the channels and waits for it to settle in Spark. */ + suspend fun move(quote: MoveToSparkQuote): Result = withContext(bgDispatcher) { + runSuspendCatching { + val paymentHash = Bolt11Invoice.fromStr(quote.invoice).paymentHash() + ownPaymentHashes += paymentHash + coroutineScope { + val settled = async(start = CoroutineStart.UNDISPATCHED) { + lightningRepo.nodeEvents.first { it.settles(paymentHash) } + } + lightningRepo.payInvoiceOverLdk( + bolt11 = quote.invoice, + sats = quote.amountSats, + maxTotalRoutingFeeMsat = MoveToSparkMath.feeCapMsat(quote.feeSats), + ).getOrElse { + settled.cancel() + throw MoveToSparkError.TooLarge(quote.amountSats, it) + } + when (val event = withTimeoutOrNull(PAYMENT_TIMEOUT) { settled.await() }) { + is Event.PaymentSuccessful -> MoveToSparkOutcome.Moved( + amountSats = quote.amountSats, + feeSats = (event.feePaidMsat ?: 0uL) / 1000uL, + ) + is Event.PaymentFailed -> throw MoveToSparkError.TooLarge(quote.amountSats, reason = event.reason) + else -> { + settled.cancel() + MoveToSparkOutcome.InFlight(quote.amountSats) + } + } + } + }.onFailure { + Logger.error("Failed to move '${quote.amountSats}' sats to Spark", it, context = TAG) + } + } + + private fun Event.settles(paymentHash: String): Boolean = when (this) { + is Event.PaymentSuccessful -> this.paymentHash == paymentHash + is Event.PaymentFailed -> this.paymentHash == paymentHash + else -> false + } +} + +/** The amounts behind moving the channels' balance, kept apart so they can be tested without a node. */ +object MoveToSparkMath { + /** Below this a move is not offered: what is left is about the routing fee. */ + const val MIN_MOVE_SATS = 1_000uL + + /** The fee guessed for the first quote of a full move, as a share of the amount in parts per million. */ + private const val FIRST_FEE_GUESS_PPM = 5_000uL + + /** The least fee guessed for the first quote of a full move. */ + private const val MIN_FEE_GUESS_SATS = 10uL + + /** Room left above the quoted fee, in sats, for the fee moving between the quote and the payment. */ + private const val FEE_MARGIN_SATS = 2uL + + fun movableSats(channels: List?): ULong = channels.totalNextOutboundHtlcLimitSats() + + /** Whether the channels hold a balance worth moving to a usable Spark wallet. */ + fun canMove(channels: List?, spark: SparkState): Boolean = + spark.isReady && movableSats(channels) >= MIN_MOVE_SATS + + /** The first amount to quote: [wantedSats] when it leaves room for fees, else the limit less a guessed fee. */ + fun firstCandidate(wantedSats: ULong, limitSats: ULong): ULong { + val guessSats = maxOf(limitSats.safe() * FIRST_FEE_GUESS_PPM.safe() / 1_000_000uL, MIN_FEE_GUESS_SATS) + return minOf(wantedSats, amountAfterFee(limitSats, guessSats)) + } + + /** The amount that fits under [limitSats] together with [feeSats] and its margin. */ + fun amountAfterFee(limitSats: ULong, feeSats: ULong): ULong = + limitSats.safe() - (feeSats.safe() + FEE_MARGIN_SATS.safe()).safe() + + /** The routing fee cap for the payment: the quoted fee and its margin, so it never pays more than shown. */ + fun feeCapMsat(feeSats: ULong): ULong = (feeSats.safe() + FEE_MARGIN_SATS.safe()).safe() * 1000uL.safe() + + /** The smaller amount to offer after one did not route: half of it, or nothing below the minimum. */ + fun smallerAmount(sats: ULong): ULong? = (sats / 2uL).takeIf { it >= MIN_MOVE_SATS } +} + +data class MoveToSparkQuote( + val invoice: String, + val amountSats: ULong, + val feeSats: ULong, +) + +sealed interface MoveToSparkOutcome { + val amountSats: ULong + + data class Moved(override val amountSats: ULong, val feeSats: ULong) : MoveToSparkOutcome + + /** The payment left but had not settled when the move stopped waiting; Spark shows it once it does. */ + data class InFlight(override val amountSats: ULong) : MoveToSparkOutcome +} + +sealed class MoveToSparkError(message: String, cause: Throwable? = null) : AppError(message, cause) { + class NothingToMove : MoveToSparkError("The channels have nothing to move") + class SparkUnavailable : MoveToSparkError("Spark is not ready to receive") + + /** [amountSats] did not route from the channels to Spark in one payment. */ + class TooLarge( + val amountSats: ULong, + cause: Throwable? = null, + val reason: PaymentFailureReason? = null, + ) : MoveToSparkError("Could not route '$amountSats' sats to Spark, reason '$reason'", cause) +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index c0789dd495..09e0863e08 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -69,6 +69,7 @@ import to.bitkit.ext.walletId import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast import to.bitkit.repositories.ConnectivityState +import to.bitkit.repositories.MoveToSparkMath import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.ui.Routes.ExternalConnection @@ -204,6 +205,7 @@ import to.bitkit.ui.settings.lightning.ChannelDetailScreen import to.bitkit.ui.settings.lightning.CloseConnectionScreen import to.bitkit.ui.settings.lightning.LightningConnectionsScreen import to.bitkit.ui.settings.lightning.LightningConnectionsViewModel +import to.bitkit.ui.settings.lightning.MoveToSparkScreen import to.bitkit.ui.settings.lightning.SparkDetailScreen import to.bitkit.ui.settings.pin.PinManagementScreen import to.bitkit.ui.settings.quickPay.QuickPayIntroScreen @@ -223,6 +225,7 @@ import to.bitkit.ui.sheets.DisablePinSheet import to.bitkit.ui.sheets.ForceTransferSheet import to.bitkit.ui.sheets.HighBalanceWarningSheet import to.bitkit.ui.sheets.LnurlAuthSheet +import to.bitkit.ui.sheets.MoveToSparkSheet import to.bitkit.ui.sheets.PinSheet import to.bitkit.ui.sheets.QrScanningSheet import to.bitkit.ui.sheets.QuickPayIntroSheet @@ -764,6 +767,14 @@ fun ContentView( ) } + TimedSheetType.MOVE_TO_SPARK -> MoveToSparkSheet( + onLater = { appViewModel.dismissTimedSheet() }, + onMove = { + appViewModel.dismissTimedSheet() + navController.navigateTo(Routes.MoveToSpark) + }, + ) + TimedSheetType.HIGH_BALANCE -> { HighBalanceWarningSheet( understoodClick = { appViewModel.dismissTimedSheet() }, @@ -1366,6 +1377,8 @@ private fun NavGraphBuilder.home( SpendingWalletScreen( channels = lightningState.channels, hasSparkBalance = sparkState.balanceSats > 0uL, + canMoveToSpark = MoveToSparkMath.canMove(lightningState.channels, sparkState), + onMoveToSparkClick = { navController.navigateTo(Routes.MoveToSpark) }, lightningActivities = lightningActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, onActivityItemClick = { navController.navToActivityDetail(it) }, @@ -2003,6 +2016,18 @@ private fun NavGraphBuilder.lightningConnections( }, ) } + composableWithDefaultTransitions { + MoveToSparkScreen( + viewModel = hiltViewModel(), + onBack = { navController.popBackStack() }, + onCloseChannel = { channelId -> + // Closing returns to Lightning Connections, so the close screen opens on top of it. + navController.popBackStack() + navController.navigateTo(Routes.LightningConnections) + channelId?.let { navController.navigateTo(Routes.CloseConnection(it)) } + }, + ) + } deepLinkableComposable { val route = it.toRoute() CloseConnectionScreen( @@ -2474,6 +2499,9 @@ sealed interface Routes { @Serializable data class CloseConnection(val channelId: String) : Routes.DeepLinkable + @Serializable + data object MoveToSpark : Routes.InternalOnly + @Serializable data object DevSettings : Routes.DeepLinkable diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 66640d1037..12d42a7539 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -114,6 +114,7 @@ sealed interface Sheet { /**@param priority Priority levels for timed sheets (higher number = higher priority)*/ enum class TimedSheetType(val priority: Int) { + MOVE_TO_SPARK(priority = 7), APP_UPDATE(priority = 6), USDT(priority = 5), BACKUP(priority = 4), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 169ae8b03a..ff7bd6ce69 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -43,6 +43,7 @@ import to.bitkit.ui.LocalBalances import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.EmptyStateView import to.bitkit.ui.components.IncomingTransfer +import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.StatusBarSpacer import to.bitkit.ui.components.TabBar @@ -70,6 +71,8 @@ fun SpendingWalletScreen( onBackClick: () -> Unit, balances: BalanceState = LocalBalances.current, hasSparkBalance: Boolean = false, + canMoveToSpark: Boolean = false, + onMoveToSparkClick: () -> Unit = {}, ) { val showEmptyState by remember(balances.totalLightningSats, lightningActivities.size) { val hasLnFunds = balances.totalLightningSats == 0uL @@ -176,6 +179,24 @@ fun SpendingWalletScreen( if (!showEmptyState) { item { VerticalSpacer(32.dp) } + if (canMoveToSpark) { + item { + PrimaryButton( + onClick = onMoveToSparkClick, + text = stringResource(R.string.lightning__move_to_spark__nav_title), + icon = { + Icon( + painter = painterResource(R.drawable.ic_lightning), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier.testTag("MoveToSpark") + ) + } + item { VerticalSpacer(16.dp) } + } + if (canTransfer) { item { SecondaryButton( diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt index e1f37f26b3..628c5cda9e 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt @@ -108,6 +108,7 @@ fun LightningConnectionsScreen( navController.navigateTo(Routes.ChannelDetail(channelUi.details.channelId)) }, onClickSpark = { navController.navigateTo(Routes.SparkDetail) }, + onClickMoveToSpark = { navController.navigateTo(Routes.MoveToSpark) }, onOpenTx = { txId -> context.startActivity(Intent(Intent.ACTION_VIEW, getBlockExplorerUrl(txId).toUri())) }, onRefresh = { viewModel.onPullToRefresh() @@ -124,6 +125,7 @@ private fun Content( onClickExportLogs: () -> Unit = {}, onClickChannel: (ChannelUi) -> Unit = {}, onClickSpark: () -> Unit = {}, + onClickMoveToSpark: () -> Unit = {}, onOpenTx: (String) -> Unit = {}, onRefresh: () -> Unit = {}, ) { @@ -237,6 +239,15 @@ private fun Content( ) } + if (uiState.canMoveToSpark) { + VerticalSpacer(16.dp) + PrimaryButton( + text = stringResource(R.string.lightning__move_to_spark__nav_title), + onClick = onClickMoveToSpark, + modifier = Modifier.testTag("MoveToSpark") + ) + } + // Closed Channels Section AnimatedVisibility(visible = showClosed && uiState.closedChannels.isNotEmpty()) { Column { diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 2687b39880..8638c3ef8b 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -37,6 +37,7 @@ import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LogsRepo +import to.bitkit.repositories.MoveToSparkMath import to.bitkit.repositories.SparkDeposit import to.bitkit.repositories.SparkRepo import to.bitkit.repositories.SparkState @@ -117,6 +118,7 @@ class LightningConnectionsViewModel @Inject constructor( .reversed().toImmutableList(), localBalance = calculateLocalBalance(channels).safe() + sparkState.balanceSats.safe(), remoteBalance = channels.calculateRemoteBalance(), + canMoveToSpark = MoveToSparkMath.canMove(channels, sparkState), spark = sparkState.toUi(lightningRepo), isSparkReceiving = sparkState.isReady, ) @@ -231,6 +233,8 @@ data class LightningConnectionsUiState( val remoteBalance: ULong = 0u, val spark: SparkUi? = null, val isSparkReceiving: Boolean = false, + /** The channels hold a balance that can move to Spark, which Blocktank's channel closes make the cheaper path. */ + val canMoveToSpark: Boolean = false, ) /** The Spark wallet shown as a connection, present once it holds a balance or a deposit. */ diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/MoveToSparkScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/MoveToSparkScreen.kt new file mode 100644 index 0000000000..4a8741816a --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/MoveToSparkScreen.kt @@ -0,0 +1,311 @@ +package to.bitkit.ui.settings.lightning + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.repositories.MoveToSparkOutcome +import to.bitkit.repositories.MoveToSparkQuote +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.GradientCircularProgressIndicator +import to.bitkit.ui.components.MoneyCaptionB +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.SwipeToConfirm +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.rememberMoneyText +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +/** Moves the channels' balance to the user's Spark wallet over Lightning, then offers to close the emptied channel. */ +@Composable +fun MoveToSparkScreen( + viewModel: MoveToSparkViewModel, + onBack: () -> Unit, + onCloseChannel: (channelId: String?) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + Content( + uiState = uiState, + onBack = onBack, + onMove = viewModel::move, + onRetry = viewModel::quote, + onCloseChannel = onCloseChannel, + ) +} + +@Composable +private fun Content( + uiState: MoveToSparkUiState, + onBack: () -> Unit = {}, + onMove: () -> Unit = {}, + onRetry: (ULong?) -> Unit = {}, + onCloseChannel: (String?) -> Unit = {}, +) { + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__move_to_spark__nav_title), + onBackClick = onBack, + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + when (uiState) { + MoveToSparkUiState.Quoting -> Quoting() + is MoveToSparkUiState.Confirm -> Confirm(uiState.quote, uiState.isMoving, onMove) + is MoveToSparkUiState.Done -> Done(uiState, onBack, onRetry, onCloseChannel) + is MoveToSparkUiState.TooLarge -> TooLarge(uiState, onBack, onRetry) + is MoveToSparkUiState.Failed -> Failed(uiState.failure, onBack, onRetry) + } + VerticalSpacer(16.dp) + } + } +} + +@Composable +private fun Header(title: String, text: String) { + VerticalSpacer(32.dp) + Display(text = title.withAccent(accentColor = Colors.Purple)) + VerticalSpacer(8.dp) + BodyM(text = text, color = Colors.White64) +} + +/** Takes the height between the content and the bottom buttons, so the buttons stay at the screen's bottom. */ +@Composable +private fun ColumnScope.Illustration(@DrawableRes drawable: Int) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + Image( + painter = painterResource(drawable), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.sizeIn(maxWidth = 256.dp, maxHeight = 256.dp) + ) + } +} + +@Composable +private fun ColumnScope.Quoting() { + Header( + title = stringResource(R.string.lightning__move_to_spark__confirm_title), + text = stringResource(R.string.lightning__move_to_spark__quoting), + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + GradientCircularProgressIndicator(modifier = Modifier.size(64.dp)) + } +} + +@Composable +private fun ColumnScope.Confirm(quote: MoveToSparkQuote, isMoving: Boolean, onMove: () -> Unit) { + Header( + title = stringResource(R.string.lightning__move_to_spark__confirm_title), + text = stringResource(R.string.lightning__move_to_spark__confirm_text), + ) + VerticalSpacer(32.dp) + AmountRow(stringResource(R.string.lightning__move_to_spark__amount), quote.amountSats, "MoveToSparkAmount") + AmountRow(stringResource(R.string.lightning__move_to_spark__fee), quote.feeSats, "MoveToSparkFee") + Illustration(R.drawable.lightning) + SwipeToConfirm( + text = stringResource(R.string.lightning__move_to_spark__swipe), + color = Colors.Purple, + loading = isMoving, + enabled = !isMoving, + onConfirm = onMove, + modifier = Modifier.testTag("MoveToSparkSwipe") + ) +} + +@Composable +private fun AmountRow(label: String, sats: ULong, testTag: String) { + SectionRow( + name = label, + valueContent = { MoneyCaptionB(sats = sats.toLong(), symbol = true, modifier = Modifier.testTag(testTag)) }, + ) +} + +@Composable +private fun ColumnScope.Done( + uiState: MoveToSparkUiState.Done, + onDone: () -> Unit, + onMoveRest: (ULong?) -> Unit, + onCloseChannel: (String?) -> Unit, +) { + val outcome = uiState.outcome + Header( + title = stringResource(R.string.lightning__move_to_spark__done_title), + text = stringResource( + when { + outcome is MoveToSparkOutcome.InFlight -> R.string.lightning__move_to_spark__in_flight_text + uiState.hasRest -> R.string.lightning__move_to_spark__done_rest_text + else -> R.string.lightning__move_to_spark__done_text + } + ), + ) + VerticalSpacer(32.dp) + AmountRow(stringResource(R.string.lightning__move_to_spark__amount), outcome.amountSats, "MoveToSparkMoved") + if (outcome is MoveToSparkOutcome.Moved) { + AmountRow(stringResource(R.string.lightning__move_to_spark__fee), outcome.feeSats, "MoveToSparkFeePaid") + } + Illustration(R.drawable.check) + if (uiState.hasRest) { + Buttons( + secondaryText = stringResource(R.string.common__later), + onSecondary = onDone, + primaryText = stringResource(R.string.lightning__move_to_spark__move_rest), + onPrimary = { onMoveRest(null) }, + primaryTestTag = "MoveToSparkMoveRest", + ) + return + } + Buttons( + secondaryText = stringResource(R.string.common__later), + onSecondary = onDone, + primaryText = stringResource(R.string.common__close), + onPrimary = { onCloseChannel(uiState.closeChannelId) }, + primaryTestTag = "MoveToSparkClose", + ) +} + +@Composable +private fun ColumnScope.TooLarge( + uiState: MoveToSparkUiState.TooLarge, + onBack: () -> Unit, + onRetry: (ULong?) -> Unit, +) { + val amount = amountText(uiState.amountSats) + Header( + title = stringResource(R.string.lightning__move_to_spark__too_large_title), + text = stringResource(R.string.lightning__move_to_spark__too_large_text, amount) + " " + stringResource( + when (uiState.smallerSats) { + null -> R.string.lightning__move_to_spark__too_large_none + else -> R.string.lightning__move_to_spark__too_large_smaller + } + ), + ) + Illustration(R.drawable.exclamation_mark) + val smallerSats = uiState.smallerSats + if (smallerSats == null) { + PrimaryButton(text = stringResource(R.string.common__back), onClick = onBack) + return + } + Buttons( + secondaryText = stringResource(R.string.common__cancel), + onSecondary = onBack, + primaryText = stringResource(R.string.lightning__move_to_spark__move_half), + onPrimary = { onRetry(smallerSats) }, + primaryTestTag = "MoveToSparkTrySmaller", + ) +} + +@Composable +private fun ColumnScope.Failed(failure: MoveToSparkFailure, onBack: () -> Unit, onRetry: (ULong?) -> Unit) { + Header( + title = stringResource(R.string.lightning__move_to_spark__failed_title), + text = stringResource( + when (failure) { + MoveToSparkFailure.NOTHING_TO_MOVE -> R.string.lightning__move_to_spark__failed_nothing + MoveToSparkFailure.SPARK_UNAVAILABLE -> R.string.lightning__move_to_spark__failed_spark + MoveToSparkFailure.UNKNOWN -> R.string.lightning__move_to_spark__failed_unknown + } + ), + ) + Illustration(R.drawable.cross) + if (failure == MoveToSparkFailure.NOTHING_TO_MOVE) { + PrimaryButton(text = stringResource(R.string.common__back), onClick = onBack) + return + } + Buttons( + secondaryText = stringResource(R.string.common__cancel), + onSecondary = onBack, + primaryText = stringResource(R.string.common__try_again), + onPrimary = { onRetry(null) }, + primaryTestTag = "MoveToSparkRetry", + ) +} + +@Composable +private fun amountText(sats: ULong): String = + rememberMoneyText(sats.toLong(), showSymbol = true)?.replace(Regex(""), "") ?: sats.toString() + +@Composable +private fun Buttons( + secondaryText: String, + onSecondary: () -> Unit, + primaryText: String, + onPrimary: () -> Unit, + primaryTestTag: String, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth(), + ) { + SecondaryButton(text = secondaryText, onClick = onSecondary, modifier = Modifier.weight(1f)) + PrimaryButton( + text = primaryText, + onClick = onPrimary, + modifier = Modifier + .weight(1f) + .testTag(primaryTestTag) + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewConfirm() { + AppThemeSurface { + Content(uiState = MoveToSparkUiState.Confirm(MoveToSparkQuote("lnbc", 98_500uL, 12uL))) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewDone() { + AppThemeSurface { + Content(uiState = MoveToSparkUiState.Done(MoveToSparkOutcome.Moved(98_500uL, 9uL), "channel")) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewTooLarge() { + AppThemeSurface { + Content(uiState = MoveToSparkUiState.TooLarge(98_500uL, 49_250uL)) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/MoveToSparkViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/MoveToSparkViewModel.kt new file mode 100644 index 0000000000..d0e7f935ee --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/MoveToSparkViewModel.kt @@ -0,0 +1,95 @@ +package to.bitkit.ui.settings.lightning + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.ext.filterOpen +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.MoveToSparkError +import to.bitkit.repositories.MoveToSparkMath +import to.bitkit.repositories.MoveToSparkOutcome +import to.bitkit.repositories.MoveToSparkQuote +import to.bitkit.repositories.MoveToSparkRepo +import to.bitkit.repositories.WalletRepo +import javax.inject.Inject + +@HiltViewModel +class MoveToSparkViewModel @Inject constructor( + private val moveToSparkRepo: MoveToSparkRepo, + private val lightningRepo: LightningRepo, + private val walletRepo: WalletRepo, +) : ViewModel() { + + private val _uiState = MutableStateFlow(MoveToSparkUiState.Quoting) + val uiState = _uiState.asStateFlow() + + init { + quote() + } + + /** Quotes moving [targetSats], or everything the channels can send. */ + fun quote(targetSats: ULong? = null) { + _uiState.update { MoveToSparkUiState.Quoting } + viewModelScope.launch { + moveToSparkRepo.quote(targetSats).fold( + onSuccess = { quote -> _uiState.update { MoveToSparkUiState.Confirm(quote) } }, + onFailure = { error -> _uiState.update { error.toUiState() } }, + ) + } + } + + fun move() { + val quote = (_uiState.value as? MoveToSparkUiState.Confirm)?.quote ?: return + _uiState.update { MoveToSparkUiState.Confirm(quote, isMoving = true) } + viewModelScope.launch { + moveToSparkRepo.move(quote).fold( + onSuccess = { outcome -> + walletRepo.syncNodeAndWallet() + val hasRest = moveToSparkRepo.movableSats() >= MoveToSparkMath.MIN_MOVE_SATS + _uiState.update { MoveToSparkUiState.Done(outcome, emptiedChannelId(), hasRest) } + }, + onFailure = { error -> _uiState.update { error.toUiState() } }, + ) + } + } + + /** The channel to close after the move, when exactly one is open; with several, the user picks in Connections. */ + private fun emptiedChannelId(): String? = lightningRepo.lightningState.value.channels.filterOpen() + .singleOrNull()?.channelId + + private fun Throwable.toUiState(): MoveToSparkUiState = when (this) { + is MoveToSparkError.TooLarge -> MoveToSparkUiState.TooLarge( + amountSats = amountSats, + smallerSats = MoveToSparkMath.smallerAmount(amountSats), + ) + is MoveToSparkError.NothingToMove -> MoveToSparkUiState.Failed(MoveToSparkFailure.NOTHING_TO_MOVE) + is MoveToSparkError.SparkUnavailable -> MoveToSparkUiState.Failed(MoveToSparkFailure.SPARK_UNAVAILABLE) + else -> MoveToSparkUiState.Failed(MoveToSparkFailure.UNKNOWN) + } +} + +@Immutable +sealed interface MoveToSparkUiState { + data object Quoting : MoveToSparkUiState + + data class Confirm(val quote: MoveToSparkQuote, val isMoving: Boolean = false) : MoveToSparkUiState + + /** [hasRest]: the channels can still send enough to move again, as after a smaller amount. */ + data class Done( + val outcome: MoveToSparkOutcome, + val closeChannelId: String?, + val hasRest: Boolean = false, + ) : MoveToSparkUiState + + /** [amountSats] did not route in one payment; [smallerSats] is the next amount to try, if any is left. */ + data class TooLarge(val amountSats: ULong, val smallerSats: ULong?) : MoveToSparkUiState + + data class Failed(val failure: MoveToSparkFailure) : MoveToSparkUiState +} + +enum class MoveToSparkFailure { NOTHING_TO_MOVE, SPARK_UNAVAILABLE, UNKNOWN } diff --git a/app/src/main/java/to/bitkit/ui/sheets/MoveToSparkSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/MoveToSparkSheet.kt new file mode 100644 index 0000000000..63f1706fb9 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/MoveToSparkSheet.kt @@ -0,0 +1,89 @@ +package to.bitkit.ui.sheets + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +/** Tells channel holders that Blocktank closes its channels and that moving the balance to Spark costs least. */ +@Composable +fun MoveToSparkSheet(onLater: () -> Unit, onMove: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier.sheetHeight().navigationBarsPadding().testTag("MoveToSparkIntro") + ) { + SheetTopBar(stringResource(R.string.lightning__move_to_spark__nav_title)) + Column(modifier = Modifier.weight(1f).padding(horizontal = 16.dp)) { + // The illustration shrinks on small screens so the copy below it stays whole. + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1f).fillMaxWidth() + ) { + Image( + painter = painterResource(R.drawable.lightning), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.sizeIn(maxWidth = 256.dp, maxHeight = 256.dp) + ) + } + VerticalSpacer(32.dp) + Display( + stringResource(R.string.lightning__move_to_spark__intro_title).withAccent(accentColor = Colors.Purple) + ) + VerticalSpacer(8.dp) + BodyM(stringResource(R.string.lightning__move_to_spark__intro_text), color = Colors.White64) + } + VerticalSpacer(32.dp) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + maxItemsInEachRow = if (LocalDensity.current.fontScale > 1.3f) 1 else 2, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + SecondaryButton( + stringResource(R.string.common__later), + onClick = onLater, + testTag = "MoveToSparkIntroLater", + modifier = Modifier.weight(1f) + ) + PrimaryButton( + stringResource(R.string.lightning__move_to_spark__move_now), + onClick = onMove, + modifier = Modifier.weight(1f).testTag("MoveToSparkIntroMove") + ) + } + VerticalSpacer(16.dp) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { BottomSheetPreview { MoveToSparkSheet({}, {}) } } +} diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/MoveToSparkTimedSheet.kt b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/MoveToSparkTimedSheet.kt new file mode 100644 index 0000000000..d886770099 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/MoveToSparkTimedSheet.kt @@ -0,0 +1,28 @@ +package to.bitkit.utils.timedsheets.sheets + +import kotlinx.coroutines.flow.first +import to.bitkit.data.SettingsStore +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.MoveToSparkMath +import to.bitkit.repositories.SparkRepo +import to.bitkit.ui.components.TimedSheetType +import to.bitkit.utils.timedsheets.TimedSheetItem +import javax.inject.Inject + +/** Shown once after the update to users whose channels still hold a balance worth moving to Spark. */ +class MoveToSparkTimedSheet @Inject constructor( + private val settingsStore: SettingsStore, + private val lightningRepo: LightningRepo, + private val sparkRepo: SparkRepo, +) : TimedSheetItem { + override val type = TimedSheetType.MOVE_TO_SPARK + override val priority = type.priority + + override suspend fun shouldShow(): Boolean { + if (settingsStore.data.first().hasSeenMoveToSparkIntro) return false + return MoveToSparkMath.canMove(lightningRepo.lightningState.value.channels, sparkRepo.state.value) + } + + override suspend fun onShown() = settingsStore.update { it.copy(hasSeenMoveToSparkIntro = true) } + override suspend fun onDismissed() = Unit +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6fabcf9943..17ef144808 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -176,6 +176,7 @@ import to.bitkit.repositories.IncomingPaykitPaymentRequestFailureReason import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.MethodId +import to.bitkit.repositories.MoveToSparkRepo import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.OnchainPreparedReceipt import to.bitkit.repositories.OnchainSendAttempt @@ -261,6 +262,7 @@ import to.bitkit.utils.timedsheets.TimedSheetManager import to.bitkit.utils.timedsheets.sheets.AppUpdateTimedSheet import to.bitkit.utils.timedsheets.sheets.BackupTimedSheet import to.bitkit.utils.timedsheets.sheets.HighBalanceTimedSheet +import to.bitkit.utils.timedsheets.sheets.MoveToSparkTimedSheet import to.bitkit.utils.timedsheets.sheets.NotificationsTimedSheet import to.bitkit.utils.timedsheets.sheets.QuickPayTimedSheet import to.bitkit.utils.timedsheets.sheets.UsdtTimedSheet @@ -328,6 +330,8 @@ class AppViewModel @Inject constructor( private val usdtSheet: UsdtTimedSheet, private val usdtPreferences: UsdtPreferencesRepo, private val highBalanceSheet: HighBalanceTimedSheet, + private val moveToSparkSheet: MoveToSparkTimedSheet, + private val moveToSparkRepo: MoveToSparkRepo, private val formatMoneyValue: FormatMoneyValue, private val widgetsRepo: WidgetsRepo, private val timeSource: TimeSource, @@ -565,6 +569,7 @@ class AppViewModel @Inject constructor( private val paymentRequestPresentationRetryJobs = mutableMapOf() private val paymentRequestPresentationCompletionJobs = mutableMapOf() private val timedSheetManager = timedSheetManagerProvider(viewModelScope).apply { + registerSheet(moveToSparkSheet) registerSheet(appUpdateSheet) registerSheet(usdtSheet) registerSheet(backupSheet) @@ -733,8 +738,13 @@ class AppViewModel @Inject constructor( sheet == null && authenticated && !forgotPin && receipt == NewTransactionSheetDetails.EMPTY }.collect { if (it) retryUsdtIntro() + if (it) retryMoveToSparkIntro() } } + viewModelScope.launch { + // Channels and Spark load after Home first checks its sheets, so the intro is checked again once they can. + moveToSparkRepo.canMove.collect { if (it) retryMoveToSparkIntro() } + } viewModelScope.launch { pubkyRepo.sessionRestorationFailed.collect { failed -> if (failed) { @@ -2856,6 +2866,7 @@ class AppViewModel @Inject constructor( refreshPaymentActivity(paymentHash) // A failed automatic Allowance payment falls back to the request's manual flow without a send error. if (isAutomaticAllowancePayment(paymentHash)) return + if (moveToSparkRepo.isMovePayment(paymentHash)) return if (pendingPaymentRepo.isPending(paymentHash)) { clearPendingContactPaymentContext(paymentHash) pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash, event.reason)) @@ -2903,6 +2914,7 @@ class AppViewModel @Inject constructor( event.paymentHash.let { paymentHash -> closeSettledReceiveSheet(receiveSheetToClose) activityRepo.notifyPaymentActivityChanged() + if (moveToSparkRepo.isMovePayment(paymentHash)) return privatePaykitRepo.contactPublicKeyForPrivateInvoicePaymentHash(paymentHash)?.let { publicKey -> activityRepo.setContact( contactPublicKey = publicKey, @@ -2953,6 +2965,8 @@ class AppViewModel @Inject constructor( refreshPaymentActivity(paymentHash) // An automatic Allowance payment settles headlessly; the allowance repo posts its notice and contact tag. if (isAutomaticAllowancePayment(paymentHash)) return + // A move to Spark reports its outcome on its own screen. + if (moveToSparkRepo.isMovePayment(paymentHash)) return if (!pendingPaymentRepo.isPending(paymentHash)) { notifyPaymentSentOnLightning(event) return @@ -8803,6 +8817,10 @@ class AppViewModel @Inject constructor( if (!isUsdtIntroPresented && usdtSheet.shouldShow()) timedSheetManager.reevaluate() } + private suspend fun retryMoveToSparkIntro() { + if (moveToSparkSheet.shouldShow()) timedSheetManager.reevaluate() + } + private fun canShowTimedSheet() = _isAuthenticated.value && !_showForgotPinSheet.value && _currentSheet.value == null && sheetTransitionJob?.isActive != true && diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 39227d7601..b9d8ad59d6 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -167,6 +167,30 @@ قابل للاستخدام أرصدة Lightning ارتفاع أرشفة مراقب القناة + المبلغ + ينتقل رصيدك إلى Spark عبر Lightning، وهي أقل طريقة رسومًا للإبقاء عليه في الإنفاق. رسوم التوجيه أدناه هي أقصى ما تدفعه. + الانتقال إلى\n<accent>Spark</accent> + جزء من رصيدك الآن على Spark. انقل الباقي بالطريقة نفسها، ثم أغلق الاتصال الذي أصبح فارغًا. + رصيدك الآن على Spark. أغلق الاتصال الذي أصبح فارغًا لإعادة احتياطيه إلى المدخرات. + تم النقل إلى\n<accent>Spark</accent> + لم يتبق في اتصالات Lightning لديك رصيد للنقل. + Spark غير جاهز للاستلام بعد. حاول مرة أخرى بعد قليل. + تعذّر إتمام\n<accent>النقل</accent> + حدث خطأ أثناء تحضير النقل. حاول مرة أخرى. + رسوم التوجيه + الدفعة في طريقها وستظهر في Spark بمجرد إتمامها. ثم أغلق الاتصال الفارغ لإعادة احتياطيه إلى المدخرات. + يقوم Blocktank بإغلاق اتصالات Lightning الخاصة به. انقل رصيدك إلى Spark الآن: فهو الخيار الأقل رسومًا، ويبقى ضمن الإنفاق. + اتصالك على وشك\n<accent>الإغلاق</accent> + نقل النصف + نقل الآن + نقل الباقي + النقل إلى Spark + جارٍ البحث عن مسار ورسومه… + اسحب للنقل + أغلق الاتصال بدلاً من ذلك لإعادة الرصيد إلى المدخرات. + يمكنك نقله على دفعات أصغر. + %1$s أكبر من أن يُوجَّه إلى Spark في دفعة واحدة. + المبلغ كبير\n<accent>جدًا</accent> حد HTLC الصادر التالي الحد الأدنى لـ HTLC الصادر التالي قبل أن تتمكن من تحويل الأموال من رصيد مدخراتك، تحتاج إلى إرسال Bitcoin إلى محفظة 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 dac1da7456..bcd63749f8 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -174,6 +174,30 @@ Es utilizable Saldos Lightning Altura de archivo del monitor de canal + Monto + Su saldo pasa a Spark por Lightning, la forma de menor comisión para mantenerlo en Gasto. La comisión de enrutamiento de abajo es lo máximo que paga. + Mover a\n<accent>Spark</accent> + Una parte de su saldo ya está en Spark. Mueva el resto de la misma forma y luego termine la conexión vacía. + Su saldo ya está en Spark. Termine la conexión vacía para devolver su reserva a Ahorro. + Movido a\n<accent>Spark</accent> + Sus conexiones Lightning ya no tienen saldo para mover. + Spark aún no está listo para recibir. Inténtelo de nuevo en un momento. + No se pudo\n<accent>mover</accent> + Algo salió mal al preparar el movimiento. Inténtelo de nuevo. + Comisión de enrutamiento + El pago está en camino y aparecerá en Spark cuando se complete. Luego termine la conexión vacía para devolver su reserva a Ahorro. + Blocktank está cerrando sus conexiones Lightning. Mueva su saldo a Spark ahora: es la opción de menor comisión y se mantiene en Gasto. + Su conexión\n<accent>se cierra</accent> + Mover mitad + Mover ahora + Mover resto + Mover a Spark + Buscando una ruta y su comisión… + Deslizar para mover + Termine la conexión para devolver el saldo a Ahorro. + Puede moverlo en partes más pequeñas. + %1$s es demasiado grande para enrutarse a Spark en un solo pago. + El monto es\n<accent>excesivo</accent> Límite del próximo HTLC saliente Mínimo del próximo HTLC saliente Para transferir fondos a su saldo de ahorros, primero tiene que enviar bitcoin a su billetera Bitkit. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 334505505c..5824650ede 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -171,6 +171,30 @@ És utilitzable Saldos Lightning Alçada d\'arxiu del monitor de canal + Import + El teu saldo passa a Spark per Lightning, la manera amb menys tarifes de mantenir-lo a Despesa. La tarifa d\'enrutament de sota és el màxim que pagues. + Moure a\n<accent>Spark</accent> + Una part del teu saldo ja és a Spark. Mou la resta de la mateixa manera i després tanca la connexió buida. + El teu saldo ja és a Spark. Tanca la connexió buida per tornar-ne la reserva als Estalvis. + Mogut a\n<accent>Spark</accent> + Les teves connexions Lightning ja no tenen saldo per moure. + Spark encara no està a punt per rebre. Torna-ho a provar d\'aquí a un moment. + No s\'ha pogut\n<accent>moure</accent> + Alguna cosa ha anat malament en preparar el moviment. Torna-ho a provar. + Tarifa d\'enrutament + El pagament està en camí i apareixerà a Spark quan s\'hagi liquidat. Després tanca la connexió buida per tornar-ne la reserva als Estalvis. + Blocktank està tancant les seves connexions Lightning. Mou ara el teu saldo a Spark: és l\'opció amb menys tarifes i es manté a Despesa. + La teva connexió\n<accent>es tanca</accent> + Moure meitat + Moure ara + Moure resta + Moure a Spark + Cercant una ruta i la seva tarifa… + Desplaça per moure + Tanca la connexió per tornar el saldo als Estalvis. + El pots moure en parts més petites. + %1$s és massa gran per enrutar-se a Spark en un sol pagament. + Import massa\n<accent>gran</accent> Límit del pròxim HTLC de sortida Mínim del pròxim HTLC de sortida Abans de poder transferir fons del teu saldo d\'estalvis, has d\'enviar bitcoin a la teva cartera Bitkit. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 91f4805c90..5cbe916972 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -174,6 +174,30 @@ Je použitelný? Lightning zůstatky Archivní výška monitoru kanálu + Částka + Váš zůstatek se přesune do Spark přes Lightning, což je způsob s nejnižšími poplatky, jak jej udržet v Útratách. Poplatek za směrování níže je maximum, které zaplatíte. + Přesun do\n<accent>Spark</accent> + Část vašeho zůstatku je nyní ve Spark. Zbytek přesuňte stejným způsobem a poté uzavřete vyprázdněné spojení. + Váš zůstatek je nyní ve Spark. Uzavřete vyprázdněné spojení, aby se jeho rezerva vrátila do Úspor. + Přesunuto do\n<accent>Spark</accent> + Vaše spojení Lightning už nemají žádný zůstatek k přesunu. + Spark zatím není připraven přijímat. Zkuste to za chvíli znovu. + Přesun se\n<accent>nezdařil</accent> + Při přípravě přesunu se něco pokazilo. Zkuste to znovu. + Poplatek za směrování + Platba je na cestě a ve Spark se zobrazí po vypořádání. Poté uzavřete vyprázdněné spojení, aby se jeho rezerva vrátila do Úspor. + Blocktank uzavírá svá spojení Lightning. Přesuňte svůj zůstatek do Spark hned: je to možnost s nejnižšími poplatky a zůstane v Útratách. + Vaše spojení\n<accent>se uzavírá</accent> + Přesun půlku + Přesun hned + Zbytek + Přesun do Spark + Hledání trasy a jejího poplatku… + Přejetím přesuňte + Místo toho uzavřete spojení, aby se zůstatek vrátil do Úspor. + Můžete jej přesunout po menších částech. + %1$s je příliš velká částka na směrování do Spark v jedné platbě. + Částka je příliš\n<accent>velká</accent> Limit dalšího odchozího HTLC Min dalšího odchozího HTLC Než budete moci převést prostředky ze svého zůstatku na spořicím účtu, musíte poslat bitcoiny do peněženky Bitkit. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5cea87a889..1271bfceaa 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -226,6 +226,30 @@ Eingehender HTLC Min Lightning-Guthaben Channel Monitor Archivhöhe + Betrag + Ihr Guthaben wechselt über Lightning zu Spark, dem günstigsten Weg, es im Ausgabenkonto zu behalten. Die Routing-Gebühr unten ist das Maximum, das Sie zahlen. + Verschieben zu\n<accent>Spark</accent> + Ein Teil Ihres Guthabens ist jetzt bei Spark. Verschieben Sie den Rest auf dieselbe Weise und schließen Sie dann die geleerte Verbindung. + Ihr Guthaben ist jetzt bei Spark. Schließen Sie die geleerte Verbindung, um deren Reserve an das Sparkonto zurückzugeben. + Verschoben zu\n<accent>Spark</accent> + Ihre Lightning-Verbindungen haben kein Guthaben mehr zum Verschieben. + Spark ist noch nicht bereit zum Empfangen. Versuchen Sie es gleich noch einmal. + Das Verschieben\n<accent>schlug fehl</accent> + Beim Vorbereiten der Verschiebung ist etwas schiefgelaufen. Versuchen Sie es erneut. + Routing-Gebühr + Die Zahlung ist unterwegs und erscheint in Spark, sobald sie abgeschlossen ist. Schließen Sie dann die geleerte Verbindung, um deren Reserve an das Sparkonto zurückzugeben. + Blocktank schließt seine Lightning-Verbindungen. Verschieben Sie Ihr Guthaben jetzt zu Spark: Das ist die günstigste Option, und es bleibt im Ausgabenkonto. + Ihre Verbindung wird\n<accent>geschlossen</accent> + Halb senden + Jetzt senden + Rest senden + Zu Spark verschieben + Route und Gebühr werden gesucht… + Swipe zum Verschieben + Schließen Sie stattdessen die Verbindung, um das Guthaben an das Sparkonto zurückzugeben. + Sie können es in kleineren Teilen verschieben. + %1$s ist zu groß, um in einer Zahlung zu Spark geroutet zu werden. + Betrag ist\n<accent>zu groß</accent> Nächstes ausgehendes HTLC-Limit Nächster ausgehender HTLC Min Bevor du Geld von deinem Sparkonto übertragen kannst, musst du Bitcoin zu deiner Bitkit Wallet senden. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 13bc6c27e3..a8850314c7 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -167,6 +167,30 @@ Είναι χρησιμοποιήσιμο Υπόλοιπα Lightning Ύψος αρχειοθέτησης παρακολούθησης καναλιού + Ποσό + Το υπόλοιπό σας μεταφέρεται στο Spark μέσω Lightning, τον τρόπο με τα χαμηλότερα τέλη για να παραμείνει στις Δαπάνες. Το τέλος δρομολόγησης παρακάτω είναι το μέγιστο που πληρώνετε. + Μεταφορά στο\n<accent>Spark</accent> + Ένα μέρος του υπολοίπου σας βρίσκεται πλέον στο Spark. Μεταφέρετε τα υπόλοιπα με τον ίδιο τρόπο και έπειτα κλείστε την άδεια σύνδεση. + Το υπόλοιπό σας βρίσκεται πλέον στο Spark. Κλείστε την άδεια σύνδεση για να επιστρέψει το απόθεμά της στις Αποταμιεύσεις. + Μεταφέρθηκε στο\n<accent>Spark</accent> + Οι συνδέσεις Lightning σας δεν έχουν πλέον υπόλοιπο για μεταφορά. + Το Spark δεν είναι ακόμη έτοιμο να λάβει. Δοκιμάστε ξανά σε λίγο. + Η μεταφορά\n<accent>απέτυχε</accent> + Κάτι πήγε στραβά κατά την προετοιμασία της μεταφοράς. Δοκιμάστε ξανά. + Τέλος δρομολόγησης + Η πληρωμή βρίσκεται σε εξέλιξη και θα εμφανιστεί στο Spark μόλις διακανονιστεί. Έπειτα κλείστε την άδεια σύνδεση για να επιστρέψει το απόθεμά της στις Αποταμιεύσεις. + Το Blocktank κλείνει τις συνδέσεις Lightning του. Μεταφέρετε το υπόλοιπό σας στο Spark τώρα: είναι η επιλογή με τα χαμηλότερα τέλη και παραμένει στις Δαπάνες. + Η σύνδεσή σας\n<accent>κλείνει</accent> + Το μισό + Μεταφορά + Τα υπόλοιπα + Μεταφορά στο Spark + Εύρεση διαδρομής και του τέλους της… + Σύρε για μεταφορά + Κλείστε αντί αυτού τη σύνδεση για να επιστρέψει το υπόλοιπο στις Αποταμιεύσεις. + Μπορείτε να το μεταφέρετε σε μικρότερα μέρη. + Το %1$s είναι πολύ μεγάλο για δρομολόγηση στο Spark με μία πληρωμή. + Το ποσό είναι\n<accent>πολύ μεγάλο</accent> Όριο επόμενου εξερχόμενου HTLC Ελάχιστο επόμενο εξερχόμενο HTLC Πριν μπορέσεις να μεταφέρεις κεφάλαια από το υπόλοιπο αποταμιεύσεων, πρέπει να στείλεις bitcoin στο πορτοφόλι Bitkit. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index e50e8de4c2..05819ef552 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -173,6 +173,30 @@ ¿Es utilizable? Saldos Lightning Altura de archivo del monitor de canales + Cantidad + Tu saldo pasa a Spark por Lightning, la forma de menor comisión para mantenerlo en Gasto. La comisión de enrutamiento de abajo es lo máximo que pagas. + Mover a\n<accent>Spark</accent> + Una parte de tu saldo ya está en Spark. Mueve el resto de la misma forma y luego termina la conexión vacía. + Tu saldo ya está en Spark. Termina la conexión vacía para devolver su reserva a Ahorro. + Movido a\n<accent>Spark</accent> + Tus conexiones Lightning ya no tienen saldo para mover. + Spark aún no está listo para recibir. Inténtalo de nuevo en un momento. + No se pudo\n<accent>mover</accent> + Algo salió mal al preparar el movimiento. Inténtalo de nuevo. + Comisión de enrutamiento + El pago está en camino y aparecerá en Spark cuando se complete. Luego termina la conexión vacía para devolver su reserva a Ahorro. + Blocktank está cerrando sus conexiones Lightning. Mueve ahora tu saldo a Spark: es la opción de menor comisión y se mantiene en Gasto. + Tu conexión\n<accent>se cierra</accent> + Mover mitad + Mover ahora + Mover resto + Mover a Spark + Buscando una ruta y su comisión… + Deslizar para mover + Termina la conexión para devolver el saldo a Ahorro. + Puedes moverlo en partes más pequeñas. + %1$s es demasiado grande para enrutarse a Spark en un solo pago. + La cantidad es\n<accent>excesiva</accent> Límite del siguiente HTLC saliente Mínimo del siguiente HTLC saliente Antes de que pueda transferir fondos a su saldo de ahorros, tiene que enviar bitcoin a su monedero Bitkit. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index fa41c6b382..7d92361c93 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -140,6 +140,30 @@ HTLC de entrada mínimo Saldos Lightning Altura de archivo del monitor de canal + Cantidad + Su saldo pasa a Spark por Lightning, la forma de menor comisión para mantenerlo en Gasto. La comisión de enrutamiento de abajo es lo máximo que paga. + Mover a\n<accent>Spark</accent> + Una parte de su saldo ya está en Spark. Mueva el resto de la misma forma y luego termine la conexión vacía. + Su saldo ya está en Spark. Termine la conexión vacía para devolver su reserva a Ahorro. + Movido a\n<accent>Spark</accent> + Sus conexiones Lightning ya no tienen saldo para mover. + Spark aún no está listo para recibir. Inténtelo de nuevo en un momento. + No se pudo\n<accent>mover</accent> + Algo salió mal al preparar el movimiento. Inténtelo de nuevo. + Comisión de enrutamiento + El pago está en camino y aparecerá en Spark cuando se complete. Luego termine la conexión vacía para devolver su reserva a Ahorro. + Blocktank está cerrando sus conexiones Lightning. Mueva su saldo a Spark ahora: es la opción de menor comisión y se mantiene en Gasto. + Su conexión\n<accent>se cierra</accent> + Mover mitad + Mover ahora + Mover resto + Mover a Spark + Buscando una ruta y su comisión… + Deslizar para mover + Termine la conexión para devolver el saldo a Ahorro. + Puede moverlo en partes más pequeñas. + %1$s es demasiado grande para enrutarse a Spark en un solo pago. + La cantidad es\n<accent>excesiva</accent> Límite del siguiente HTLC saliente Mínimo del siguiente HTLC saliente Antes de que pueda transferir fondos a su saldo de ahorros, tiene que enviar bitcoin a su monedero Bitkit. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 205a538624..c6a6ca6ac4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -254,6 +254,30 @@ Point du canal Motif de la fermeture Hauteur d\'archivage du moniteur de canal + Montant + Votre solde passe à Spark via Lightning, le moyen le moins coûteux de le garder dans Dépenses. Les frais de routage ci-dessous sont le maximum que vous payez. + Déplacer vers\n<accent>Spark</accent> + Une partie de votre solde est maintenant sur Spark. Déplacez le reste de la même façon, puis fermez la connexion vidée. + Votre solde est maintenant sur Spark. Fermez la connexion vidée pour rendre sa réserve à Épargnes. + Déplacé vers\n<accent>Spark</accent> + Vos connexions Lightning n\'ont plus de solde à déplacer. + Spark n\'est pas encore prêt à recevoir. Réessayez dans un instant. + Le déplacement\n<accent>a échoué</accent> + Un problème est survenu pendant la préparation du déplacement. Réessayez. + Frais de routage + Le paiement est en route et apparaîtra sur Spark une fois réglé. Fermez ensuite la connexion vidée pour rendre sa réserve à Épargnes. + Blocktank ferme ses connexions Lightning. Déplacez votre solde vers Spark maintenant : c\'est l\'option la moins coûteuse, et il reste dans Dépenses. + Votre connexion\n<accent>se ferme</accent> + Moitié + Déplacer + Le reste + Déplacer vers Spark + Recherche d\'un itinéraire et de ses frais… + Glissez pour déplacer + Fermez plutôt la connexion pour rendre le solde à Épargnes. + Vous pouvez le déplacer en plus petites parties. + %1$s est trop élevé pour être acheminé vers Spark en un seul paiement. + Le montant est\n<accent>trop élevé</accent> Limite HTLC sortant suivant HTLC sortant min suivant Avant de pouvoir transférer des fonds depuis votre solde d\'épargne, vous devez envoyer des bitcoins à votre portefeuille Bitkit. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f46eaf89c8..d5d3f28e33 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -172,6 +172,30 @@ È utilizzabile? Saldi Lightning Altezza Archivio Monitor Canali + Importo + Il tuo saldo passa a Spark tramite Lightning, il modo a commissioni più basse per mantenerlo in Spesa. La commissione di instradamento qui sotto è il massimo che paghi. + Sposta su\n<accent>Spark</accent> + Una parte del tuo saldo è ora su Spark. Sposta il resto allo stesso modo, poi chiudi la connessione svuotata. + Il tuo saldo è ora su Spark. Chiudi la connessione svuotata per restituire la sua riserva ai Risparmi. + Spostato su\n<accent>Spark</accent> + Le tue connessioni Lightning non hanno più saldo da spostare. + Spark non è ancora pronto a ricevere. Riprova tra un momento. + Impossibile\n<accent>spostare</accent> + Qualcosa è andato storto durante la preparazione dello spostamento. Riprova. + Commissione di instradamento + Il pagamento è in arrivo e compare su Spark una volta completato. Poi chiudi la connessione svuotata per restituire la sua riserva ai Risparmi. + Blocktank sta chiudendo le sue connessioni Lightning. Sposta ora il tuo saldo su Spark: è l\'opzione con le commissioni più basse e resta in Spesa. + La tua connessione\n<accent>si sta chiudendo</accent> + Sposta Metà + Sposta Ora + Sposta Resto + Sposta Su Spark + Ricerca di un percorso e della sua commissione… + Swipe Per Spostare + Chiudi invece la connessione per restituire il saldo ai Risparmi. + Puoi spostarlo in parti più piccole. + %1$s è troppo grande per essere instradato su Spark in un unico pagamento. + Importo troppo\n<accent>grande</accent> Limite HTLC in Uscita Successivo HTLC Min in Uscita Successivo Prima di poter trasferire fondi dal tuo conto di risparmio, devi inviare bitcoin al tuo portafoglio Bitkit. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index fc7c27129e..a2451762c5 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -171,6 +171,30 @@ Is bruikbaar Lightning-saldi Kanaalmonitor archiveringshoogte + Bedrag + Uw saldo gaat via Lightning naar Spark, de goedkoopste manier om het op uw bestedingssaldo te houden. De routeringsvergoeding hieronder is het maximum dat u betaalt. + Verplaatsen naar\n<accent>Spark</accent> + Een deel van uw saldo staat nu op Spark. Verplaats de rest op dezelfde manier en sluit daarna de lege verbinding. + Uw saldo staat nu op Spark. Sluit de lege verbinding om de reserve terug te geven aan uw spaargeld. + Verplaatst naar\n<accent>Spark</accent> + Uw Lightning-verbindingen hebben geen saldo meer om te verplaatsen. + Spark is nog niet klaar om te ontvangen. Probeer het zo meteen opnieuw. + Verplaatsen\n<accent>mislukt</accent> + Er is iets misgegaan bij het voorbereiden van de verplaatsing. Probeer het opnieuw. + Routeringsvergoeding + De betaling is onderweg en verschijnt in Spark zodra ze is afgerond. Sluit daarna de lege verbinding om de reserve terug te geven aan uw spaargeld. + Blocktank sluit zijn Lightning-verbindingen. Verplaats uw saldo nu naar Spark: dit is de goedkoopste optie en het blijft op uw bestedingssaldo. + Uw verbinding wordt\n<accent>gesloten</accent> + Stuur helft + Stuur nu + Stuur rest + Verplaatsen naar Spark + Route en vergoeding zoeken… + Veeg om te verplaatsen + Sluit in plaats daarvan de verbinding om het saldo terug te geven aan uw spaargeld. + U kunt het in kleinere delen verplaatsen. + %1$s is te groot om in één betaling naar Spark te routeren. + Bedrag is\n<accent>te groot</accent> Volgende uitgaande HTLC-limiet Volgende uitgaande HTLC min Voordat je geld van je spaargeld kunt overboeken, moet je bitcoin naar je Bitkit-wallet sturen. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 61316f843c..c817b19b7a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -228,6 +228,30 @@ Min. HTLC przychodząca Saldo Lightning Wysokość archiwalnego monitora kanału + Kwota + Twoje saldo trafia do Spark przez Lightning, najtańszym sposobem na pozostawienie go w Wydatkach. Opłata za routing poniżej to maksimum, jakie zapłacisz. + Przenieś do\n<accent>Spark</accent> + Część Twojego salda jest teraz w Spark. Przenieś resztę w ten sam sposób, a następnie zamknij opróżnione połączenie. + Twoje saldo jest teraz w Spark. Zamknij opróżnione połączenie, aby zwrócić jego rezerwę do Oszczędności. + Przeniesiono do\n<accent>Spark</accent> + Twoje połączenia Lightning nie mają już salda do przeniesienia. + Spark nie jest jeszcze gotowy do odbioru. Spróbuj ponownie za chwilę. + Nie udało się\n<accent>przenieść</accent> + Coś poszło nie tak podczas przygotowania przeniesienia. Spróbuj ponownie. + Opłata za routing + Płatność jest w drodze i pojawi się w Spark po rozliczeniu. Następnie zamknij opróżnione połączenie, aby zwrócić jego rezerwę do Oszczędności. + Blocktank zamyka swoje połączenia Lightning. Przenieś teraz saldo do Spark: to opcja z najniższymi opłatami, a środki zostają w Wydatkach. + Twoje połączenie\n<accent>się zamyka</accent> + Połowę + Przenieś + Resztę + Przenieś do Spark + Szukanie trasy i jej opłaty… + Przesuń, aby przenieść + Zamiast tego zamknij połączenie, aby zwrócić saldo do Oszczędności. + Możesz przenieść je w mniejszych częściach. + Kwota %1$s jest zbyt duża, aby przekazać ją do Spark jedną płatnością. + Kwota jest\n<accent>zbyt duża</accent> Limit następnej wychodzącej HTLC Min. następnej wychodzącej HTLC Zanim będzie można przelać środki z salda oszczędności, należy wysłać bitcoiny do portfela Bitkit. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 3e7b7deb6b..7b9cb9a6ab 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -175,6 +175,30 @@ É utilizável Saldos Lightning Altura de Arquivamento do Monitor de Canal + Valor + Seu saldo vai para o Spark pela Lightning, a forma de menor taxa de mantê-lo em Gastos. A taxa de roteamento abaixo é o máximo que você paga. + Mover para o\n<accent>Spark</accent> + Parte do seu saldo agora está no Spark. Mova o resto da mesma forma e depois feche a conexão esvaziada. + Seu saldo agora está no Spark. Feche a conexão esvaziada para devolver a reserva à Poupança. + Movido para o\n<accent>Spark</accent> + Suas conexões Lightning não têm mais saldo para mover. + O Spark ainda não está pronto para receber. Tente novamente em instantes. + Não foi possível\n<accent>mover</accent> + Algo deu errado ao preparar a movimentação. Tente novamente. + Taxa de roteamento + O pagamento está a caminho e aparece no Spark assim que for liquidado. Depois, feche a conexão esvaziada para devolver a reserva à Poupança. + O Blocktank está fechando suas conexões Lightning. Mova seu saldo para o Spark agora: é a opção de menor taxa e ele continua em Gastos. + Sua conexão está\n<accent>fechando</accent> + Mover Metade + Mover Agora + Mover Resto + Mover Para o Spark + Buscando uma rota e sua taxa… + Deslizar Para Mover + Feche a conexão para devolver o saldo à Poupança. + Você pode movê-lo em partes menores. + %1$s é grande demais para ser roteado ao Spark em um único pagamento. + Valor grande\n<accent>demais</accent> Limite do Próximo HTLC de Saída HTLC de Saída Mín Próximo Antes de transferir fundos de seu saldo de poupança, receba bitcoin na sua carteira Bitkit. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 81321c2947..9ec8c382df 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -272,6 +272,30 @@ Continuar Usando o Bitkit Tempo de Atualização do Cache de Taxa Capacidade de Entrada + Montante + O seu saldo passa para o Spark através do Lightning, a forma com as taxas mais baixas de o manter em Gastos. A taxa de encaminhamento abaixo é o máximo que paga. + Mover para o\n<accent>Spark</accent> + Parte do seu saldo está agora no Spark. Mova o resto da mesma forma e depois feche a conexão esvaziada. + O seu saldo está agora no Spark. Feche a conexão esvaziada para devolver a reserva à Poupança. + Movido para o\n<accent>Spark</accent> + As suas conexões Lightning já não têm saldo para mover. + O Spark ainda não está pronto para receber. Tente novamente dentro de instantes. + Não foi possível\n<accent>mover</accent> + Algo correu mal ao preparar a movimentação. Tente novamente. + Taxa de encaminhamento + O pagamento está a caminho e aparece no Spark quando for liquidado. Depois, feche a conexão esvaziada para devolver a reserva à Poupança. + O Blocktank está a fechar as suas conexões Lightning. Mova já o seu saldo para o Spark: é a opção com as taxas mais baixas e fica em Gastos. + A sua conexão\n<accent>vai fechar</accent> + Mover Metade + Mover Agora + Mover Resto + Mover Para o Spark + A procurar uma rota e a respetiva taxa… + Deslizar Para Mover + Feche antes a conexão para devolver o saldo à Poupança. + Pode movê-lo em partes mais pequenas. + %1$s é demasiado grande para ser encaminhado para o Spark num só pagamento. + Montante demasiado\n<accent>grande</accent> Limite do Próximo HTLC de Saída Mínimo do Próximo HTLC de Saída Antes de transferir fundos de seu saldo de poupança, receba bitcoin na sua carteira Bitkit. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3f8d38a426..d4eae42c6d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -183,6 +183,30 @@ Можно Использовать Балансы Lightning Архивная высота мониторинга канала + Сумма + Ваш баланс переходит в Spark через Lightning — самым дешёвым по комиссиям способом сохранить его в Расходах. Комиссия за маршрутизацию ниже — максимум, который вы платите. + Перенос в\n<accent>Spark</accent> + Часть вашего баланса теперь в Spark. Перенесите остаток таким же способом, затем закройте опустевшее соединение. + Ваш баланс теперь в Spark. Закройте опустевшее соединение, чтобы вернуть его резерв в Сбережения. + Перенесено в\n<accent>Spark</accent> + На ваших соединениях Lightning больше нет баланса для переноса. + Spark пока не готов к получению. Попробуйте ещё раз чуть позже. + Не удалось\n<accent>перенести</accent> + Что-то пошло не так при подготовке переноса. Попробуйте ещё раз. + Комиссия за маршрутизацию + Платёж в пути и появится в Spark после завершения. Затем закройте опустевшее соединение, чтобы вернуть его резерв в Сбережения. + Blocktank закрывает свои соединения Lightning. Перенесите баланс в Spark сейчас: это вариант с самой низкой комиссией, и средства остаются в Расходах. + Ваше соединение\n<accent>закрывается</accent> + Половину + Перенести + Остаток + Перенос в Spark + Поиск маршрута и его комиссии… + Проведите для Переноса + Вместо этого закройте соединение, чтобы вернуть баланс в Сбережения. + Вы можете перенести его меньшими частями. + %1$s — слишком большая сумма для маршрутизации в Spark одним платежом. + Сумма слишком\n<accent>велика</accent> Лимит след. исходящего HTLC Мин. след. исходящего HTLC Прежде чем вы сможете перевести средства со своего баланса сбережений, вам нужно отправить биткойны в ваш кошелёк Bitkit. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2fb23120cf..7ed4f07771 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -320,6 +320,30 @@ Is Usable Lightning Balances Channel Monitor Archival Height + Amount + Your balance moves to Spark over Lightning, the lowest-fee way to keep it in Spending. The routing fee below is the most you pay. + Move to\n<accent>Spark</accent> + Part of your balance is on Spark now. Move the rest the same way, then close the emptied connection. + Your balance is on Spark now. Close the emptied connection to return its reserve to Savings. + Moved to\n<accent>Spark</accent> + Your Lightning connections have no balance left to move. + Spark is not ready to receive yet. Try again in a moment. + Could not\n<accent>move</accent> + Something went wrong while preparing the move. Try again. + Routing fee + The payment is on its way and shows in Spark once it settles. Then close the emptied connection to return its reserve to Savings. + Blocktank is closing its Lightning connections. Move your balance to Spark now: it is the lowest-fee option, and it stays in Spending. + Your connection\n<accent>is closing</accent> + Move Half + Move Now + Move Rest + Move To Spark + Finding a route and its fee… + Swipe To Move + Close the connection instead to return the balance to Savings. + You can move it in smaller parts. + %1$s is too large to route to Spark in one payment. + Amount\n<accent>too large</accent> Next Outbound HTLC Limit Next Outbound HTLC Min Before you can transfer funds from your savings balance, you need to send bitcoin to your Bitkit wallet. diff --git a/app/src/test/java/to/bitkit/repositories/MoveToSparkRepoTest.kt b/app/src/test/java/to/bitkit/repositories/MoveToSparkRepoTest.kt new file mode 100644 index 0000000000..ea69bad78e --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/MoveToSparkRepoTest.kt @@ -0,0 +1,125 @@ +package to.bitkit.repositories + +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.ext.createChannelDetails +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MoveToSparkRepoTest : BaseUnitTest() { + companion object { + private const val INVOICE = "lnbcrt1spark" + } + + private val lightningRepo = mock() + private val sparkRepo = mock() + private val sparkState = MutableStateFlow(SparkState(isReady = true)) + private val lightningState = MutableStateFlow(LightningState()) + private lateinit var sut: MoveToSparkRepo + + @Before + fun setUp() { + whenever(lightningRepo.lightningState).thenReturn(lightningState) + whenever(sparkRepo.state).thenReturn(sparkState) + sut = MoveToSparkRepo(testDispatcher, lightningRepo, sparkRepo) + } + + private fun sendable(sats: ULong) { + val channel = createChannelDetails().copy(isUsable = true, nextOutboundHtlcLimitMsat = sats * 1000uL) + lightningState.value = LightningState(channels = persistentListOf(channel)) + } + + @Test + fun `quote leaves room under the outbound limit for the routing fee`() = test { + sendable(100_000uL) + whenever(sparkRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(INVOICE)) + whenever(lightningRepo.estimateLdkRoutingFeesForAmount(eq(INVOICE), any())).thenReturn(Result.success(30uL)) + + val quote = sut.quote().getOrThrow() + + assertEquals(INVOICE, quote.invoice) + assertEquals(30uL, quote.feeSats) + assertEquals(MoveToSparkMath.amountAfterFee(100_000uL, 30uL), quote.amountSats) + verify(sparkRepo).createInvoice(eq(null), any(), any()) + } + + @Test + fun `quote shrinks the amount when the fee does not fit the first guess`() = test { + sendable(1_000_000uL) + whenever(sparkRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(INVOICE)) + whenever(lightningRepo.estimateLdkRoutingFeesForAmount(eq(INVOICE), any())).thenReturn(Result.success(9_000uL)) + + val quote = sut.quote().getOrThrow() + + assertEquals(MoveToSparkMath.amountAfterFee(1_000_000uL, 9_000uL), quote.amountSats) + assertTrue(quote.amountSats + quote.feeSats <= 1_000_000uL) + } + + @Test + fun `quote of a smaller target keeps the target`() = test { + sendable(100_000uL) + whenever(sparkRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(INVOICE)) + whenever(lightningRepo.estimateLdkRoutingFeesForAmount(eq(INVOICE), any())).thenReturn(Result.success(5uL)) + + assertEquals(50_000uL, sut.quote(50_000uL).getOrThrow().amountSats) + } + + @Test + fun `quote fails as too large when no route is found`() = test { + sendable(100_000uL) + whenever(sparkRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(INVOICE)) + whenever(lightningRepo.estimateLdkRoutingFeesForAmount(eq(INVOICE), any())) + .thenReturn(Result.failure(IllegalStateException("route not found"))) + + val error = sut.quote().exceptionOrNull() + + assertIs(error) + } + + @Test + fun `quote fails with nothing to move below the minimum`() = test { + sendable(MoveToSparkMath.MIN_MOVE_SATS - 1uL) + + assertIs(sut.quote().exceptionOrNull()) + verify(sparkRepo, never()).createInvoice(anyOrNull(), any(), any()) + } + + @Test + fun `quote fails while Spark is not usable`() = test { + sendable(100_000uL) + sparkState.value = SparkState(isReady = false) + + assertIs(sut.quote().exceptionOrNull()) + } + + @Test + fun `a move is offered only with a usable Spark and a balance above the minimum`() { + val channels = listOf(createChannelDetails().copy(isUsable = true, nextOutboundHtlcLimitMsat = 5_000_000uL)) + assertTrue(MoveToSparkMath.canMove(channels, SparkState(isReady = true))) + assertTrue(!MoveToSparkMath.canMove(channels, SparkState(isReady = false))) + assertTrue(!MoveToSparkMath.canMove(emptyList(), SparkState(isReady = true))) + } + + @Test + fun `smaller amount halves until the minimum`() { + assertEquals(50_000uL, MoveToSparkMath.smallerAmount(100_000uL)) + assertNull(MoveToSparkMath.smallerAmount(MoveToSparkMath.MIN_MOVE_SATS)) + } + + @Test + fun `fee cap covers the quoted fee and its margin`() { + assertEquals(32_000uL, MoveToSparkMath.feeCapMsat(30uL)) + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ac1d2b580f..1b54560d75 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -48,6 +48,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf @@ -967,6 +968,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { usdtPreferences = usdtPreferences, highBalanceSheet = mock { on { type } doReturn TimedSheetType.HIGH_BALANCE }, + moveToSparkSheet = mock { + on { type } doReturn TimedSheetType.MOVE_TO_SPARK + onBlocking { shouldShow() } doReturn false + }, + moveToSparkRepo = mock { on { canMove } doReturn emptyFlow() }, formatMoneyValue = formatMoneyValue, widgetsRepo = widgetsRepo, pubkyRepo = pubkyRepo, diff --git a/changelog.d/next/1768.added.md b/changelog.d/next/1768.added.md new file mode 100644 index 0000000000..4562661bb3 --- /dev/null +++ b/changelog.d/next/1768.added.md @@ -0,0 +1 @@ +Move to Spark: move a Lightning connection's balance to Spark at the lowest fee, then close the emptied connection to return its reserve to Savings. diff --git a/docs/screens-map.md b/docs/screens-map.md index b46984af02..837f3f1cf8 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -316,6 +316,7 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | ChannelDetailScreen.kt | Settings > Advanced › Connection details | | CloseConnectionScreen.kt | Settings > Advanced › Close connection | | LightningConnectionsScreen.kt | Settings > Advanced › Lightning Connections overview | +| MoveToSparkScreen.kt | `todo` | | SparkDetailScreen.kt | `todo` | ## ui/settings/pin diff --git a/journeys/transfer/move-to-spark.xml b/journeys/transfer/move-to-spark.xml new file mode 100644 index 0000000000..4a47e1307e --- /dev/null +++ b/journeys/transfer/move-to-spark.xml @@ -0,0 +1,29 @@ + + + Verifies the post-update "Move to Spark" sheet and the move itself. With an LDK channel that can send at least + 1,000 sats and Spark connected, Home shows the "Move To Spark" sheet once (testTag "MoveToSparkIntro"); Spending + and Lightning Connections show a "Move To Spark" button (testTag "MoveToSpark") while the channel can still send. + The move quotes the routing fee over the channel only, pays the amount to a new invoice of the user's own Spark + wallet, and then offers to close the emptied connection. A wallet without such a channel never gets the sheet or + the button. + + The "too large to route" state (testTag "MoveToSparkTrySmaller") needs a route that cannot carry the balance; on a + direct channel to the Spark service provider every amount routes, so it is covered by the quote's unit tests and the + PR's manual test rather than here. + + Precondition: dev build pointed at a local Spark regtest (spark-sdk regtest/local), and a wallet with an open + channel to that stack's SSP Lightning node that holds most of its capacity on the wallet's side (open it from + Lightning Connections, Manual Setup, with the node URI the stack prints). Start on the wallet home screen with the + app freshly launched. + + + Wait on Home and verify the "Move To Spark" sheet (testTag "MoveToSparkIntro") shows, saying Blocktank is closing its Lightning connections + Tap "Later" (testTag "MoveToSparkIntroLater") and verify the sheet closes + Relaunch the app, wait on Home and verify the sheet does not show again + Open the Spending wallet and verify the "Move To Spark" button (testTag "MoveToSpark") shows + Tap "Move To Spark" and verify the screen shows an amount (testTag "MoveToSparkAmount") and a routing fee (testTag "MoveToSparkFee") + Swipe to move (testTag "MoveToSparkSwipe") and verify "Moved to Spark" shows the moved amount (testTag "MoveToSparkMoved") and "Close Connection" (testTag "MoveToSparkClose") + Tap "Close Connection", then "Close" on the close confirmation, and verify Lightning Connections shows no open connection and the Spark balance grew by the moved amount + Go back to Home, open Spending and verify the "Move To Spark" button is gone + +