diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index eb14cf962..ba86bb38a 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -40,6 +40,13 @@ internal object Env { else -> listOf() } + /** + * Whether LN -> onchain swaps can reach a Boltz backend. Boltz only serves a public API on + * mainnet: its testnet deployment is deprecated and regtest resolves to a local backend that + * no build of ours can reach. Elsewhere the transfer to savings closes a channel instead. + */ + val isSwapSupported get() = network == Network.BITCOIN + const val fxRateRefreshInterval = 2 * 60 * 1000L // 2 minutes in millis const val fxRateStaleThreshold = 10 * 60 * 1000L // 10 minutes in millis const val lspOrdersRefreshInterval = 2 * 60 * 1000L // 2 minutes in millis diff --git a/app/src/main/java/to/bitkit/services/BoltzService.kt b/app/src/main/java/to/bitkit/services/BoltzService.kt new file mode 100644 index 000000000..b2987940e --- /dev/null +++ b/app/src/main/java/to/bitkit/services/BoltzService.kt @@ -0,0 +1,209 @@ +package to.bitkit.services + +import com.synonym.bitkitcore.BoltzEventListener +import com.synonym.bitkitcore.BoltzNetwork +import com.synonym.bitkitcore.BoltzPairInfo +import com.synonym.bitkitcore.BoltzSwap +import com.synonym.bitkitcore.BoltzSwapEvent +import com.synonym.bitkitcore.ReverseSwapResponse +import com.synonym.bitkitcore.SubmarineSwapResponse +import com.synonym.bitkitcore.boltzClaimReverseSwap +import com.synonym.bitkitcore.boltzCreateReverseSwap +import com.synonym.bitkitcore.boltzCreateSubmarineSwap +import com.synonym.bitkitcore.boltzGetReverseLimits +import com.synonym.bitkitcore.boltzGetSubmarineLimits +import com.synonym.bitkitcore.boltzGetSwap +import com.synonym.bitkitcore.boltzListPendingSwaps +import com.synonym.bitkitcore.boltzListSwaps +import com.synonym.bitkitcore.boltzRefundSubmarineSwap +import com.synonym.bitkitcore.boltzStartSwapUpdates +import com.synonym.bitkitcore.boltzStopSwapUpdates +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import org.lightningdevkit.ldknode.Network +import to.bitkit.data.SettingsStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Thin wrapper around the bitkit-core Boltz swaps FFI (submarine + reverse swaps + * between onchain Bitcoin and Lightning). + * + * Mirrors the existing service pattern (e.g. [TrezorService]): a Hilt singleton + * that wraps the FFI and bridges the [BoltzEventListener] foreign callback to a + * [SharedFlow]. bitkit-core persists only a derivation index, never key material; + * swap keys are re-derived on demand from the wallet mnemonic. + * + * The Lightning side (issuing/paying invoices, fresh onchain addresses) is owned + * by [LightningService]; this service only talks to Boltz + the chain. + */ +@Suppress("TooManyFunctions") +@Singleton +class BoltzService @Inject constructor( + private val keychain: Keychain, + private val settingsStore: SettingsStore, +) { + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + + /** Swap lifecycle events emitted while the updates stream is running. */ + val events: SharedFlow = _events.asSharedFlow() + + private val listener = object : BoltzEventListener { + override fun onEvent(event: BoltzSwapEvent) { + Logger.info("Boltz event: $event", context = TAG) + _events.tryEmit(event) + } + } + + // region Limits + + suspend fun submarineLimits(network: BoltzNetwork = boltzNetwork()): BoltzPairInfo = + boltzGetSubmarineLimits(network = network) + + suspend fun reverseLimits(network: BoltzNetwork = boltzNetwork()): BoltzPairInfo = + boltzGetReverseLimits(network = network) + + // endregion + + // region Create + + /** Submarine swap: onchain BTC -> Lightning. Fund the returned lockup address. */ + suspend fun createSubmarineSwap( + invoice: String, + network: BoltzNetwork = boltzNetwork(), + electrumUrl: String? = null, + ): SubmarineSwapResponse { + val (mnemonic, passphrase) = credentials() + return boltzCreateSubmarineSwap( + network = network, + electrumUrl = electrumUrl ?: electrumUrl(), + invoice = invoice, + mnemonic = mnemonic, + bip39Passphrase = passphrase, + ).also { Logger.info("Created Boltz submarine swap ${it.id}", context = TAG) } + } + + /** Reverse swap: Lightning -> onchain BTC. Pay the returned hold invoice. */ + suspend fun createReverseSwap( + amountSat: ULong, + claimAddress: String, + network: BoltzNetwork = boltzNetwork(), + electrumUrl: String? = null, + ): ReverseSwapResponse { + val (mnemonic, passphrase) = credentials() + return boltzCreateReverseSwap( + network = network, + electrumUrl = electrumUrl ?: electrumUrl(), + amountSat = amountSat, + claimAddress = claimAddress, + mnemonic = mnemonic, + bip39Passphrase = passphrase, + ).also { Logger.info("Created Boltz reverse swap ${it.id}", context = TAG) } + } + + // endregion + + // region Query + + suspend fun listSwaps(): List = boltzListSwaps() + + suspend fun listPendingSwaps(): List = boltzListPendingSwaps() + + suspend fun getSwap(id: String): BoltzSwap? = boltzGetSwap(swapId = id) + + // endregion + + // region Manual claim / refund + + suspend fun claimReverseSwap(id: String, feeRateSatPerVb: Double? = null): String { + val (mnemonic, passphrase) = credentials() + return boltzClaimReverseSwap( + swapId = id, + mnemonic = mnemonic, + bip39Passphrase = passphrase, + feeRateSatPerVb = feeRateSatPerVb, + ) + } + + suspend fun refundSubmarineSwap(id: String, refundAddress: String, feeRateSatPerVb: Double? = null): String { + val (mnemonic, passphrase) = credentials() + return boltzRefundSubmarineSwap( + swapId = id, + refundAddress = refundAddress, + mnemonic = mnemonic, + bip39Passphrase = passphrase, + feeRateSatPerVb = feeRateSatPerVb, + ) + } + + // endregion + + // region Updates stream + + /** + * Open the Boltz updates WebSocket, subscribe all pending swaps and auto-claim + * reverse swaps. [feeRateSatPerVb] is the rate used for those auto-claims + * (Bitkit owns fee estimation). [acceptZeroConf] claims a reverse swap as soon + * as its lockup hits the mempool instead of waiting for its confirmation. + * Replaces any running stream. + */ + suspend fun startUpdates( + feeRateSatPerVb: Double?, + acceptZeroConf: Boolean, + network: BoltzNetwork = boltzNetwork(), + ) { + val (mnemonic, passphrase) = credentials() + boltzStartSwapUpdates( + network = network, + listener = listener, + mnemonic = mnemonic, + bip39Passphrase = passphrase, + feeRateSatPerVb = feeRateSatPerVb, + acceptZeroConf = acceptZeroConf, + ) + Logger.info("Started Boltz updates stream on $network", context = TAG) + } + + suspend fun stopUpdates() { + boltzStopSwapUpdates() + Logger.info("Stopped Boltz updates stream", context = TAG) + } + + // endregion + + // region Helpers + + /** Whether the configured network has a reachable Boltz backend. See [Env.isSwapSupported]. */ + val isSwapSupported: Boolean get() = Env.isSwapSupported + + /** The Boltz network matching the app's configured network. */ + fun boltzNetwork(network: Network = Env.network): BoltzNetwork = when (network) { + Network.BITCOIN -> BoltzNetwork.MAINNET + Network.TESTNET -> BoltzNetwork.TESTNET + Network.REGTEST -> BoltzNetwork.REGTEST + // Boltz does not operate on signet; fall back to testnet for development. + else -> BoltzNetwork.TESTNET + } + + /** Current Electrum URL, used by Boltz for claim/refund broadcasting. */ + suspend fun electrumUrl(): String = settingsStore.data.first().electrumServer + + private suspend fun credentials(): Pair { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: error("Mnemonic not found") + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + ?.takeIf { it.isNotEmpty() } + return mnemonic to passphrase + } + + // endregion + + companion object { + private const val TAG = "BoltzService" + } +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 6d6615fe6..fe8e40f59 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -155,6 +155,8 @@ import to.bitkit.ui.settings.LogDetailScreen import to.bitkit.ui.settings.LogsScreen import to.bitkit.ui.settings.OrderDetailScreen import to.bitkit.ui.settings.SettingsScreen +import to.bitkit.ui.settings.SwapDetailScreen +import to.bitkit.ui.settings.SwapsScreen import to.bitkit.ui.settings.advanced.AddressTypePreferenceScreen import to.bitkit.ui.settings.advanced.AddressViewerScreen import to.bitkit.ui.settings.advanced.CoinSelectPreferenceScreen @@ -686,6 +688,8 @@ private fun RootNavHost( channelOrdersSettings(navController) orderDetailSettings(navController) cjitDetailSettings(navController) + swapsSettings(navController) + swapDetailSettings(navController) lightningConnections(navController) activityItem(activityListViewModel, navController, settingsViewModel) authCheck(navController) @@ -1561,6 +1565,28 @@ private fun NavGraphBuilder.cjitDetailSettings( } } +private fun NavGraphBuilder.swapsSettings( + navController: NavHostController, +) { + composableWithDefaultTransitions { + SwapsScreen( + onBackClick = { navController.popBackStack() }, + onSwapItemClick = { navController.navigateToSwapDetail(it) }, + ) + } +} + +private fun NavGraphBuilder.swapDetailSettings( + navController: NavHostController, +) { + composableWithDefaultTransitions { + SwapDetailScreen( + swapItem = it.toRoute(), + onBackClick = { navController.popBackStack() }, + ) + } +} + private fun NavGraphBuilder.lightningConnections( navController: NavHostController, ) { @@ -1821,6 +1847,7 @@ fun NavController.navigateToLocalCurrencySettings() = navigateTo(Routes.LocalCur fun NavController.navigateToBackupSettings() = navigateTo(Routes.BackupSettings) fun NavController.navigateToOrderDetail(id: String) = navigateTo(Routes.OrderDetail(id)) +fun NavController.navigateToSwapDetail(id: String) = navigateTo(Routes.SwapDetail(id)) fun NavController.navigateToCjitDetail(id: String) = navigateTo(Routes.CjitDetail(id)) @@ -1963,6 +1990,12 @@ sealed interface Routes { @Serializable data object ChannelOrdersSettings : Routes + @Serializable + data object SwapsSettings : Routes + + @Serializable + data class SwapDetail(val id: String) : Routes + @Serializable data object Logs : Routes diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index 39229c354..8c6d12405 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -15,10 +15,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.systemGestureExclusion import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -44,6 +46,9 @@ import kotlin.math.abs import kotlin.math.roundToInt private const val KNOB_SIZE_DP = 32 + +/** Horizontal inset so the knob stays clear of the screen edge and its system back-gesture zone. */ +private const val SLIDER_EDGE_INSET_DP = 16 private const val TRACK_HEIGHT_DP = 8 private const val STEP_MARKER_WIDTH_DP = 4 private const val STEP_MARKER_HEIGHT_DP = 16 @@ -240,6 +245,122 @@ fun StepSlider( } } +/** + * Continuous slider over a [min]..[max] range, styled to match [StepSlider] (same track and + * knob) but without discrete steps. Used to pick a transfer amount within its allowed limits. + */ +@Composable +fun AmountSlider( + value: Long, + min: Long, + max: Long, + onValueChange: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val coroutineScope = rememberCoroutineScope() + + var sliderWidth by remember { mutableIntStateOf(0) } + val knobPosition = remember { Animatable(0f) } + val span = (max - min).coerceAtLeast(1) + + fun fractionFor(v: Long): Float = ((v - min).toFloat() / span).coerceIn(0f, 1f) + + fun valueFor(positionPx: Float): Long { + if (sliderWidth == 0) return min + val fraction = (positionPx / sliderWidth).coerceIn(0f, 1f) + return (min + (fraction * span).roundToInt()).coerceIn(min, max) + } + + // Keep the knob in sync with external value changes (and initial layout). + LaunchedEffect(value, sliderWidth) { + if (sliderWidth > 0) { + knobPosition.snapTo(fractionFor(value) * sliderWidth) + } + } + + Box( + modifier = modifier + .fillMaxWidth() + .systemGestureExclusion() + .padding(horizontal = SLIDER_EDGE_INSET_DP.dp) + .height(KNOB_SIZE_DP.dp) + .onGloballyPositioned { sliderWidth = it.size.width } + ) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(KNOB_SIZE_DP.dp) + .pointerInput(sliderWidth, min, max) { + detectTapGestures { offset -> + val v = valueFor(offset.x) + coroutineScope.launch { knobPosition.snapTo(fractionFor(v) * sliderWidth) } + onValueChange(v) + } + } + ) { + val trackY = center.y + val trackHeight = density.run { TRACK_HEIGHT_DP.dp.toPx() } + val cornerRadius = density.run { 3.dp.toPx() } + + // Inactive track + drawRoundRect( + color = Colors.Green32, + topLeft = Offset(0f, trackY - trackHeight / 2), + size = Size(size.width, trackHeight), + cornerRadius = CornerRadius(cornerRadius), + ) + // Active track + val activeWidth = knobPosition.value + if (activeWidth > 0) { + drawRoundRect( + color = Colors.Green, + topLeft = Offset(0f, trackY - trackHeight / 2), + size = Size(activeWidth, trackHeight), + cornerRadius = CornerRadius(cornerRadius), + ) + } + } + + // Knob + Box( + modifier = Modifier + .offset { + IntOffset( + x = (knobPosition.value - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), + y = 0, + ) + } + .size(KNOB_SIZE_DP.dp) + .pointerInput(sliderWidth, min, max) { + detectDragGestures { _, dragAmount -> + coroutineScope.launch { + val newPosition = (knobPosition.value + dragAmount.x) + .coerceIn(0f, sliderWidth.toFloat()) + knobPosition.snapTo(newPosition) + onValueChange(valueFor(newPosition)) + } + } + } + ) { + Box( + modifier = Modifier + .size(KNOB_SIZE_DP.dp) + .clip(CircleShape) + .background(Colors.Green) + ) { + Box( + modifier = Modifier + .size(16.dp) + .clip(CircleShape) + .background(Colors.White) + .align(Alignment.Center) + ) + } + } + } +} + @Preview @Composable private fun Preview() { @@ -255,6 +376,22 @@ private fun Preview() { } } +@Preview +@Composable +private fun AmountSliderPreview() { + AppThemeSurface { + var value by remember { mutableLongStateOf(72_000L) } + Column(modifier = Modifier.padding(32.dp)) { + AmountSlider( + value = value, + min = 50_000L, + max = 100_000L, + onValueChange = { value = it }, + ) + } + } +} + @Preview @Composable private fun Preview2() { diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt index 6bb2907a1..f8311d8cb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt @@ -64,6 +64,7 @@ fun DevSettingsScreen( ) { SettingsButtonRow("Fee Settings") { navController.navigateTo(Routes.FeeSettings) } SettingsButtonRow("Channel Orders") { navController.navigateTo(Routes.ChannelOrdersSettings) } + SettingsButtonRow("Swaps") { navController.navigateTo(Routes.SwapsSettings) } SettingsButtonRow("LDK") { navController.navigateTo(Routes.LdkDebug) } SettingsButtonRow("VSS") { navController.navigateTo(Routes.VssDebug) } SettingsButtonRow("Probing Tool") { navController.navigateTo(Routes.ProbingTool) } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt index 6359ef1a6..41ba6dd92 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt @@ -4,21 +4,26 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut 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.IntrinsicSize +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale @@ -33,13 +38,17 @@ import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R import to.bitkit.ext.amountOnClose import to.bitkit.ext.filterOpen +import to.bitkit.ui.components.AmountSlider import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FeeInfo +import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.MoneyDisplay import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SwipeToConfirm +import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.currencyViewModel import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -49,6 +58,8 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.transferViewModel import to.bitkit.ui.utils.withAccent import to.bitkit.ui.walletViewModel +import to.bitkit.viewmodels.SavingsSwapQuote +import to.bitkit.viewmodels.SavingsTransferMode @Composable fun SavingsConfirmScreen( @@ -77,19 +88,40 @@ fun SavingsConfirmScreen( val amount = channels.sumOf { it.amountOnClose } + val swapState by transfer.savingsSwapState.collectAsStateWithLifecycle() + + // Pull the latest node balances so a just-received payment is reflected in the amounts below. + LaunchedEffect(Unit) { + wallet.refreshBalances() + } + + // Present the swap fee before the user commits. Recomputed when the amount changes. + LaunchedEffect(amount) { + if (amount > 0uL) transfer.loadSavingsSwapQuote(amount) + } + Box { SavingsConfirmContent( - amount = amount, + fallbackAmount = amount, + quote = swapState.quote, + isQuoteLoading = swapState.isLoading, + minSat = swapState.minSat, + maxSat = swapState.maxSat, + onAmountChange = { transfer.onSwapAmountChange(it.toULong()) }, hasMultiple = hasMultiple, hasSelected = hasSelected, onBackClick = onBackClick, onAmountClick = { currency.switchUnit() }, onAdvancedClick = onAdvancedClick, onSelectAllClick = { transfer.setSelectedChannelIds(emptySet()) }, - onConfirm = { + onSwipeConfirm = { transfer.onTransferToSavingsConfirm(channels) onConfirm() }, + onCloseConfirm = { + transfer.onTransferToSavingsConfirm(channels, SavingsTransferMode.CLOSE) + onConfirm() + }, ) AnimatedVisibility( visible = isOffline, @@ -104,19 +136,26 @@ fun SavingsConfirmScreen( } } -@Suppress("MagicNumber") +@Suppress("MagicNumber", "LongParameterList") @Composable private fun SavingsConfirmContent( - amount: ULong, + fallbackAmount: ULong, + quote: SavingsSwapQuote?, + isQuoteLoading: Boolean, + minSat: ULong, + maxSat: ULong, + onAmountChange: (Long) -> Unit, hasMultiple: Boolean, hasSelected: Boolean, onBackClick: () -> Unit = {}, onAmountClick: () -> Unit = {}, onAdvancedClick: () -> Unit = {}, onSelectAllClick: () -> Unit = {}, - onConfirm: () -> Unit = {}, + onSwipeConfirm: () -> Unit = {}, + onCloseConfirm: () -> Unit = {}, ) { val scope = rememberCoroutineScope() + val headlineAmount = quote?.amountSat ?: fallbackAmount ScreenColumn { AppTopBar( titleText = stringResource(R.string.lightning__transfer__nav_title), @@ -135,7 +174,48 @@ private fun SavingsConfirmContent( Caption13Up(text = stringResource(R.string.lightning__savings_confirm__label), color = Colors.White64) Spacer(modifier = Modifier.height(8.dp)) - MoneyDisplay(sats = amount.toLong(), onClick = onAmountClick) + MoneyDisplay(sats = headlineAmount.toLong(), onClick = onAmountClick) + + if (quote != null) { + Spacer(modifier = Modifier.height(24.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min), + ) { + FeeInfo( + label = stringResource(R.string.lightning__savings_confirm__network_fee), + amount = quote.networkFeeSat.toLong(), + ) + FeeInfo( + label = stringResource(R.string.lightning__savings_confirm__service_fee), + amount = quote.swapFeeSat.toLong(), + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min), + ) { + FeeInfo( + label = stringResource(R.string.lightning__savings_confirm__amount), + amount = quote.amountSat.toLong(), + ) + FeeInfo( + label = stringResource(R.string.lightning__savings_confirm__receive), + amount = quote.receiveSat.toLong(), + ) + } + + // Adjust how much to move to savings, bounded to a payable range. + if (maxSat > minSat) { + Spacer(modifier = Modifier.height(28.dp)) + AmountSlider( + value = quote.amountSat.toLong(), + min = minSat.toLong(), + max = maxSat.toLong(), + onValueChange = onAmountChange, + ) + } + } if (hasMultiple) { Spacer(modifier = Modifier.height(24.dp)) @@ -156,32 +236,55 @@ private fun SavingsConfirmContent( } } - Spacer(modifier = Modifier.weight(1f)) - Image( - painter = painterResource(R.drawable.piggybank), - contentDescription = null, - contentScale = ContentScale.Fit, + // Flexible middle: the piggybank fills the remaining space, so it shrinks when the + // fees/slider are shown (no squished buttons) and fills the gap while the quote loads. + Box( modifier = Modifier - .size(256.dp) - .graphicsLayer(scaleX = -1f) - .align(alignment = CenterHorizontally) - ) - - Spacer(modifier = Modifier.height(32.dp)) + .weight(1f) + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + if (quote == null && isQuoteLoading) { + GradientCircularProgressIndicator( + modifier = Modifier.size(48.dp), + strokeWidth = 3.dp, + ) + } else { + Image( + painter = painterResource(R.drawable.piggybank), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .graphicsLayer(scaleX = -1f), + ) + } + } + // The swipe always commits the transfer: it swaps when a quote is ready and otherwise + // falls back to the pre-swap behaviour of closing the channel, so it is never inert. var isLoading by remember { mutableStateOf(false) } SwipeToConfirm( text = stringResource(R.string.lightning__transfer__swipe), - loading = isLoading, + loading = isLoading || (quote == null && isQuoteLoading), color = Colors.Brand, onConfirm = { scope.launch { isLoading = true delay(300) - onConfirm() + onSwipeConfirm() } } ) + if (quote != null) { + Spacer(modifier = Modifier.height(12.dp)) + // Fallback: drain a whole channel on-chain by closing it instead of swapping. + TertiaryButton( + text = stringResource(R.string.lightning__savings_confirm__close_instead), + onClick = onCloseConfirm, + ) + } Spacer(modifier = Modifier.height(16.dp)) } } @@ -192,7 +295,17 @@ private fun SavingsConfirmContent( private fun SavingsConfirmScreenPreview() { AppThemeSurface { SavingsConfirmContent( - amount = 50_123u, + fallbackAmount = 50_123u, + quote = SavingsSwapQuote( + amountSat = 50_123u, + networkFeeSat = 320u, + swapFeeSat = 125u, + receiveSat = 49_678u, + ), + isQuoteLoading = false, + minSat = 25_000u, + maxSat = 72_000u, + onAmountChange = {}, hasMultiple = true, hasSelected = false, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt index 3b3521c2e..b5d7d8554 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt @@ -24,6 +24,7 @@ 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 kotlinx.coroutines.delay import to.bitkit.R import to.bitkit.models.Toast @@ -41,6 +42,8 @@ import to.bitkit.ui.utils.removeAccentTags import to.bitkit.ui.utils.withAccent import to.bitkit.ui.utils.withAccentBoldBright import to.bitkit.viewmodels.AppViewModel +import to.bitkit.viewmodels.SavingsSwapResult +import to.bitkit.viewmodels.SavingsTransferMode import to.bitkit.viewmodels.TransferViewModel import to.bitkit.viewmodels.WalletViewModel @@ -54,44 +57,62 @@ fun SavingsProgressScreen( ) { val context = LocalContext.current var progressState by remember { mutableStateOf(SavingsProgressState.PROGRESS) } + val swapResult by transfer.savingsSwapResult.collectAsStateWithLifecycle() - // Effect to close channels & update UI - // TODO move this logic to viewmodel so it can outlive the screen lifecycle + // Effect to execute the transfer & update UI LaunchedEffect(Unit) { - val channelsFailedToCoopClose = transfer.closeSelectedChannels() - - if (channelsFailedToCoopClose.isEmpty()) { - wallet.refreshState() - delay(5000) - progressState = SavingsProgressState.SUCCESS - } else { - // Check if any channels can be retried (filter out trusted peers) - val (_, nonTrustedChannels) = transfer.separateTrustedChannels(channelsFailedToCoopClose) - - if (nonTrustedChannels.isEmpty()) { - // All channels are trusted peers - show error and navigate back immediately + when (transfer.savingsTransferMode.value) { + // The swap itself is owned by the viewmodel so it survives leaving this screen; + // the outcome arrives via savingsSwapResult below. Ensure the updates stream is + // running first so the new swap is tracked and auto-claimed once its lockup appears. + SavingsTransferMode.SWAP -> { + wallet.ensureSwapUpdatesRunning() + transfer.startSavingsSwap() + } + + SavingsTransferMode.CLOSE -> runChannelClose( + transfer = transfer, + wallet = wallet, + onSuccess = { progressState = SavingsProgressState.SUCCESS }, + onInterrupted = { progressState = SavingsProgressState.INTERRUPTED }, + onGiveUp = { app.showSheet(Sheet.ForceTransfer) }, + onUnavailable = { + app.toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.lightning__close_error), + description = context.getString(R.string.lightning__close_error_msg), + ) + onTransferUnavailable() + }, + ) + } + } + + LaunchedEffect(swapResult) { + when (val result = swapResult) { + is SavingsSwapResult.Success -> { + wallet.refreshState() + progressState = SavingsProgressState.SUCCESS + } + + // The hold invoice is paid but the on-chain claim has not landed within the wait + // window. The claim is auto-broadcast once the lockup appears, so the transfer is + // committed and settling; show that honestly instead of a completed success. + SavingsSwapResult.Pending -> { + wallet.refreshState() + progressState = SavingsProgressState.SETTLING + } + + is SavingsSwapResult.Failure -> { app.toast( type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__close_error), - description = context.getString(R.string.lightning__close_error_msg), + title = context.getString(R.string.common__error), + description = result.reason, ) onTransferUnavailable() - } else { - transfer.startCoopCloseRetries( - channels = nonTrustedChannels, - onGiveUp = { app.showSheet(Sheet.ForceTransfer) }, - onTransferUnavailable = { - app.toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__close_error), - description = context.getString(R.string.lightning__close_error_msg), - ) - onTransferUnavailable() - }, - ) - delay(2500) - progressState = SavingsProgressState.INTERRUPTED } + + null -> Unit } } @@ -102,6 +123,42 @@ fun SavingsProgressScreen( ) } +/** Legacy path: cooperatively close the selected channel(s), retrying on failure. */ +@Suppress("MagicNumber", "LongParameterList") +private suspend fun runChannelClose( + transfer: TransferViewModel, + wallet: WalletViewModel, + onSuccess: () -> Unit, + onInterrupted: () -> Unit, + onGiveUp: () -> Unit, + onUnavailable: () -> Unit, +) { + val channelsFailedToCoopClose = transfer.closeSelectedChannels() + + if (channelsFailedToCoopClose.isEmpty()) { + wallet.refreshState() + delay(5000) + onSuccess() + return + } + + // Check if any channels can be retried (filter out trusted peers) + val (_, nonTrustedChannels) = transfer.separateTrustedChannels(channelsFailedToCoopClose) + + if (nonTrustedChannels.isEmpty()) { + // All channels are trusted peers - show error and navigate back immediately + onUnavailable() + } else { + transfer.startCoopCloseRetries( + channels = nonTrustedChannels, + onGiveUp = onGiveUp, + onTransferUnavailable = onUnavailable, + ) + delay(2500) + onInterrupted() + } +} + @Composable private fun Content( progressState: SavingsProgressState, @@ -109,12 +166,22 @@ private fun Content( modifier: Modifier = Modifier, ) { val inProgress = progressState == SavingsProgressState.PROGRESS + val showAnimation = inProgress || progressState == SavingsProgressState.SETTLING ScreenColumn( - modifier = modifier.testTag(if (inProgress) "TransferSettingUp" else "TransferSuccess") + modifier = modifier.testTag( + when (progressState) { + SavingsProgressState.PROGRESS -> "TransferSettingUp" + SavingsProgressState.SETTLING -> "TransferSettling" + else -> "TransferSuccess" + } + ) ) { AppTopBar( titleText = when (progressState) { - SavingsProgressState.PROGRESS -> stringResource(R.string.lightning__transfer__nav_title) + SavingsProgressState.PROGRESS, + SavingsProgressState.SETTLING, + -> stringResource(R.string.lightning__transfer__nav_title) + SavingsProgressState.SUCCESS -> stringResource(R.string.lightning__transfer_success__nav_title) SavingsProgressState.INTERRUPTED -> stringResource(R.string.lightning__savings_interrupted__nav_title) .removeAccentTags().replace("\n", " ") @@ -130,38 +197,9 @@ private fun Content( .padding(horizontal = 16.dp) ) { Spacer(modifier = Modifier.height(12.dp)) - when (progressState) { - SavingsProgressState.PROGRESS -> { - Display( - text = stringResource(R.string.lightning__savings_progress__title).withAccent(), - ) - Spacer(modifier = Modifier.height(8.dp)) - BodyM( - text = stringResource(R.string.lightning__savings_progress__text).withAccentBoldBright(), - color = Colors.White64, - ) - } - - SavingsProgressState.SUCCESS -> { - Display(text = stringResource(R.string.lightning__transfer_success__title_savings).withAccent()) - Spacer(modifier = Modifier.height(8.dp)) - BodyM( - text = stringResource(R.string.lightning__transfer_success__text_savings), - color = Colors.White64, - ) - } - - SavingsProgressState.INTERRUPTED -> { - Display(text = stringResource(R.string.lightning__savings_interrupted__title).withAccent()) - Spacer(modifier = Modifier.height(8.dp)) - BodyM( - text = stringResource(R.string.lightning__savings_interrupted__text).withAccentBoldBright(), - color = Colors.White64, - ) - } - } + ProgressMessage(progressState = progressState) Spacer(modifier = Modifier.weight(1f)) - if (progressState == SavingsProgressState.PROGRESS) { + if (showAnimation) { TransferAnimationView( largeCircleRes = R.drawable.onchain_sync_large, smallCircleRes = R.drawable.onchain_sync_small, @@ -203,7 +241,30 @@ private fun Content( } } -enum class SavingsProgressState { PROGRESS, SUCCESS, INTERRUPTED } +@Composable +private fun ProgressMessage(progressState: SavingsProgressState) { + val (titleRes, textRes) = when (progressState) { + SavingsProgressState.PROGRESS -> + R.string.lightning__savings_progress__title to R.string.lightning__savings_progress__text + + SavingsProgressState.SETTLING -> + R.string.lightning__savings_settling__title to R.string.lightning__savings_settling__text + + SavingsProgressState.SUCCESS -> + R.string.lightning__transfer_success__title_savings to R.string.lightning__transfer_success__text_savings + + SavingsProgressState.INTERRUPTED -> + R.string.lightning__savings_interrupted__title to R.string.lightning__savings_interrupted__text + } + Display(text = stringResource(titleRes).withAccent()) + Spacer(modifier = Modifier.height(8.dp)) + BodyM( + text = stringResource(textRes).withAccentBoldBright(), + color = Colors.White64, + ) +} + +enum class SavingsProgressState { PROGRESS, SETTLING, SUCCESS, INTERRUPTED } @Preview(showSystemUi = true) @Composable @@ -215,6 +276,16 @@ private fun PreviewProgress() { } } +@Preview(showSystemUi = true) +@Composable +private fun PreviewSettling() { + AppThemeSurface { + Content( + progressState = SavingsProgressState.SETTLING, + ) + } +} + @Preview(showSystemUi = true) @Composable private fun PreviewSuccess() { diff --git a/app/src/main/java/to/bitkit/ui/settings/SwapsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SwapsScreen.kt new file mode 100644 index 000000000..9589f2e32 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/SwapsScreen.kt @@ -0,0 +1,358 @@ +package to.bitkit.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.synonym.bitkitcore.BoltzSwap +import kotlinx.collections.immutable.ImmutableList +import to.bitkit.models.Toast +import to.bitkit.models.formatToModernDisplay +import to.bitkit.ui.Routes +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.Footnote +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.settings.SectionHeader +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppShapes +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.copyToClipboard +import to.bitkit.viewmodels.SwapsViewModel +import to.bitkit.viewmodels.isClaimable +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun SwapsScreen( + onBackClick: () -> Unit, + onSwapItemClick: (String) -> Unit, + viewModel: SwapsViewModel = hiltViewModel(), +) { + val swaps by viewModel.swaps.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { viewModel.refresh() } + + SwapsContent( + swaps = swaps, + error = error, + onBack = onBackClick, + onClickSwap = onSwapItemClick, + ) +} + +@Composable +private fun SwapsContent( + swaps: ImmutableList, + error: String?, + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, + onClickSwap: (String) -> Unit = {}, +) { + Scaffold( + topBar = { AppTopBar(titleText = "Swaps", onBackClick = onBack) }, + modifier = modifier, + ) { padding -> + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(padding), + ) { + error?.let { + item { BodyS(text = "Error: $it") } + } + if (swaps.isEmpty()) { + item { BodyS(text = "No swaps found…") } + } else { + items(swaps) { swap -> SwapCard(swap, onClickSwap) } + } + } + } +} + +@Composable +private fun SwapCard(model: BoltzSwap, onClick: (String) -> Unit) { + Card( + colors = cardColors, + modifier = Modifier + .fillMaxWidth() + .clickableAlpha { onClick(model.id) } + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CaptionB( + text = model.id, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier + .weight(1f) + .clickableAlpha(onClick = copyToClipboard(model.id)) + ) + HorizontalSpacer(8.dp) + Surface(color = Colors.White16, shape = AppShapes.small) { + Footnote( + text = model.status.toString(), + color = Colors.White64, + maxLines = 1, + modifier = Modifier.padding(4.dp) + ) + } + } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + InfoCell(label = "Type", value = model.swapType.toString()) + InfoCell( + label = "Amount", + value = "${model.amountSat.formatToModernDisplay()} sats", + alignment = Alignment.End, + ) + } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + InfoCell( + label = "Receives", + value = model.onchainAmountSat?.let { "${it.formatToModernDisplay()} sats" } ?: "-", + ) + InfoCell( + label = "Created", + value = formatEpochSeconds(model.createdAt), + alignment = Alignment.End, + ) + } + } + } +} + +@Composable +fun SwapDetailScreen( + swapItem: Routes.SwapDetail, + onBackClick: () -> Unit = {}, + viewModel: SwapsViewModel = hiltViewModel(), +) { + val app = appViewModel ?: return + val swaps by viewModel.swaps.collectAsStateWithLifecycle() + val swap = swaps.find { it.id == swapItem.id } + val canClaim = swap?.isClaimable == true + + SwapDetailContent( + swap = swap, + onBack = onBackClick, + canClaim = canClaim, + onClaim = { + val id = swap?.id ?: return@SwapDetailContent + viewModel.claimReverseSwap(id) { result -> + result + .onSuccess { txid -> + app.toast( + type = Toast.ToastType.SUCCESS, + title = "Claim broadcast", + description = txid, + ) + } + .onFailure { e -> + app.toast( + type = Toast.ToastType.ERROR, + title = "Claim failed", + description = e.message ?: "Unknown error", + ) + } + } + }, + ) +} + +@Composable +private fun SwapDetailContent( + swap: BoltzSwap?, + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, + canClaim: Boolean = false, + onClaim: () -> Unit = {}, +) { + Scaffold( + topBar = { AppTopBar(titleText = "Swap Details", onBackClick = onBack) }, + modifier = modifier, + ) { padding -> + if (swap == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + BodyS(text = "Loading…") + } + return@Scaffold + } + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(padding), + ) { + item { + InfoCard(header = "Overview") { + DetailRow("ID", swap.id) + DetailRow("Type", swap.swapType.toString()) + DetailRow("Status", swap.status.toString()) + DetailRow("Network", swap.network.toString()) + } + } + item { + InfoCard(header = "Amounts") { + DetailRow("Amount", "${swap.amountSat.formatToModernDisplay()} sats") + DetailRow( + "Onchain amount", + swap.onchainAmountSat?.let { "${it.formatToModernDisplay()} sats" } ?: "-", + ) + } + } + item { + InfoCard(header = "Addresses") { + DetailRow("Lockup", swap.lockupAddress ?: "-") + DetailRow("Claim / onchain", swap.onchainAddress ?: "-") + } + } + swap.invoice?.let { invoice -> + item { + InfoCard(header = "Lightning") { + DetailRow("Invoice", invoice) + } + } + } + item { + InfoCard(header = "Transactions") { + DetailRow("Claim txid", swap.claimTxId ?: "-") + DetailRow("Refund txid", swap.refundTxId ?: "-") + } + } + item { + InfoCard(header = "Recovery") { + DetailRow("Swap index", swap.swapIndex.toString()) + DetailRow("Timeout block", swap.timeoutBlockHeight.toString()) + } + } + item { + InfoCard(header = "Timestamps") { + DetailRow("Created", formatEpochSeconds(swap.createdAt)) + } + } + if (canClaim) { + item { + PrimaryButton(text = "Claim now", onClick = onClaim) + } + } + } + } +} + +private val cardColors: CardColors @Composable get() = CardDefaults.cardColors(containerColor = Colors.White10) + +@Composable +private fun InfoCard( + header: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Column(modifier = modifier) { + SectionHeader(header, padding = PaddingValues.Zero) + Card( + colors = cardColors, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + content() + } + } + } +} + +@Composable +private fun InfoCell(label: String, value: String, alignment: Alignment.Horizontal = Alignment.Start) { + Column(horizontalAlignment = alignment) { + Caption13Up(text = label, color = Colors.White64) + VerticalSpacer(4.dp) + BodySSB(text = value) + } +} + +@Composable +private fun DetailRow(label: String, value: String) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + Caption( + text = label, + color = Colors.White64, + overflow = TextOverflow.MiddleEllipsis, + maxLines = 1, + ) + HorizontalSpacer(16.dp) + Caption( + text = value, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.End, + overflow = TextOverflow.MiddleEllipsis, + maxLines = 1, + modifier = Modifier + .weight(1f, fill = false) + .clickableAlpha(onClick = copyToClipboard(value)) + ) + } +} + +private val epochFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()) + +private fun formatEpochSeconds(seconds: ULong): String = + runCatching { epochFormatter.format(Instant.ofEpochSecond(seconds.toLong())) } + .getOrDefault(seconds.toString()) diff --git a/app/src/main/java/to/bitkit/viewmodels/SwapsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SwapsViewModel.kt new file mode 100644 index 000000000..962225386 --- /dev/null +++ b/app/src/main/java/to/bitkit/viewmodels/SwapsViewModel.kt @@ -0,0 +1,101 @@ +package to.bitkit.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.BoltzSwap +import com.synonym.bitkitcore.BoltzSwapStatus +import com.synonym.bitkitcore.BoltzSwapType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.ext.runSuspendCatching +import to.bitkit.services.BoltzService +import to.bitkit.utils.Logger +import javax.inject.Inject + +/** + * Dev-tools view model backing the Swaps history screen. Reads persisted swaps from + * bitkit-core (submarine + reverse) so they can be inspected/tracked, and allows a manual + * reverse-swap claim for recovery when the automatic claim did not fire. + */ +@HiltViewModel +class SwapsViewModel @Inject constructor( + private val boltzService: BoltzService, +) : ViewModel() { + private val _swaps = MutableStateFlow>(persistentListOf()) + val swaps = _swaps.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + _isLoading.update { true } + runSuspendCatching { boltzService.listSwaps() } + .onSuccess { list -> + _swaps.update { list.sortedByDescending { swap -> swap.createdAt }.toImmutableList() } + _error.update { null } + } + .onFailure { e -> + Logger.error("Failed to list swaps", e, context = TAG) + _error.update { e.message } + } + _isLoading.update { false } + } + } + + /** Manually broadcast the claim for a reverse swap (recovery when auto-claim didn't fire). */ + fun claimReverseSwap(id: String, onResult: (Result) -> Unit) { + viewModelScope.launch { + val result = runSuspendCatching { boltzService.claimReverseSwap(id) } + result + .onSuccess { refresh() } + .onFailure { Logger.error("Manual claim failed for '$id'", it, context = TAG) } + onResult(result) + } + } + + companion object { + private const val TAG = "SwapsViewModel" + } +} + +/** + * Whether a manual claim is worth attempting: a reverse swap with no claim broadcast yet that has + * not reached a terminal state. + * + * Deliberately permissive about [BoltzSwapStatus]. The persisted status only advances while the + * updates stream is delivering events, so gating on it hides the recovery tool in exactly the case + * it exists for: a stalled stream leaves the swap at [BoltzSwapStatus.SwapCreated] locally even + * after Boltz has locked up on-chain and the funds are claimable. The chain, not the cached status, + * is the source of truth here, so offer the claim and let [boltzClaimReverseSwap] decide; when + * there is nothing to claim it fails harmlessly and the error surfaces to the caller. + */ +val BoltzSwap.isClaimable: Boolean + get() = swapType == BoltzSwapType.REVERSE && + claimTxId == null && + when (status) { + BoltzSwapStatus.InvoiceExpired, + BoltzSwapStatus.InvoiceFailedToPay, + BoltzSwapStatus.InvoiceSettled, + BoltzSwapStatus.SwapExpired, + BoltzSwapStatus.TransactionClaimed, + BoltzSwapStatus.TransactionFailed, + BoltzSwapStatus.TransactionLockupFailed, + BoltzSwapStatus.TransactionRefunded, + -> false + + else -> true + } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 74e779431..d3d3a85f0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -1,13 +1,17 @@ package to.bitkit.viewmodels import android.content.Context +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.BoltzPairInfo +import com.synonym.bitkitcore.BoltzSwapEvent import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.IBtOrder import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.TimeoutCancellationException @@ -54,6 +58,7 @@ import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo +import to.bitkit.services.BoltzService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -80,6 +85,7 @@ class TransferViewModel @Inject constructor( private val settingsStore: SettingsStore, private val cacheStore: CacheStore, private val transferRepo: TransferRepo, + private val boltzService: BoltzService, private val clock: Clock, ) : ViewModel() { private val _spendingUiState = MutableStateFlow(TransferToSpendingUiState()) @@ -1100,15 +1106,205 @@ class TransferViewModel @Inject constructor( private var channelsToClose = emptyList() + /** + * How the LN -> onchain "transfer to savings" is executed. Closing a channel is the + * default because it always works; swapping funds out keeps channels open and is used + * whenever a priced quote is available. + */ + private val _savingsTransferMode = MutableStateFlow(SavingsTransferMode.CLOSE) + val savingsTransferMode = _savingsTransferMode.asStateFlow() + + private val _savingsSwapState = MutableStateFlow(SavingsSwapUiState()) + val savingsSwapState = _savingsSwapState.asStateFlow() + + /** Outcome of the swap started by [startSavingsSwap]; null while none has finished. */ + private val _savingsSwapResult = MutableStateFlow(null) + val savingsSwapResult = _savingsSwapResult.asStateFlow() + + private var savingsSwapJob: Job? = null + + /** The amount (sat) that will actually be swapped out; adjustable via the confirm slider. */ + private var pendingSwapAmountSat: ULong = 0uL + + /** Cached swap limits so the slider can re-price locally without hitting the network. */ + private var reverseLimits: BoltzPairInfo? = null + + private var savingsSwapQuoteJob: Job? = null + + /** + * Fetch swap limits, derive the adjustable amount range, and publish an initial fee quote + * (defaulting to the maximum transferable) so the user sees the cost before confirming. + * The confirm slider then re-prices locally via [onSwapAmountChange]. A quote is the only + * thing that unlocks the swap, so every failure simply leaves it null and the transfer + * falls back to closing a channel. + * Cancels any in-flight quote so a slower earlier request cannot overwrite a newer one. + */ + fun loadSavingsSwapQuote(requestedSat: ULong) { + if (!boltzService.isSwapSupported) return + savingsSwapQuoteJob?.cancel() + savingsSwapQuoteJob = viewModelScope.launch { + _savingsSwapState.update { it.copy(isLoading = true) } + awaitNodeRunning() + + // Bounded so a hanging Boltz request cannot leave the confirm swipe stuck loading. + val limits = withTimeoutOrNull(SWAP_QUOTE_TIMEOUT) { + runSuspendCatching { boltzService.reverseLimits() } + .onFailure { Logger.error("Failed to load reverse swap limits", it, context = TAG) } + .getOrNull() + } + if (limits == null) { + reverseLimits = null + _savingsSwapState.update { it.copy(isLoading = false, quote = null) } + return@launch + } + reverseLimits = limits + + // Reserve headroom for Lightning routing fees. Paying an invoice for 100% of + // outbound capacity leaves nothing for fees and fails with RouteNotFound, so cap + // the swap at outbound minus ~1% (with a small floor). + val spendable = walletRepo.balanceState.value.maxSendLightningSats.toLong() + val routingReserve = (spendable / 100).coerceAtLeast(MIN_LN_ROUTING_FEE_RESERVE_SATS) + val sendable = (spendable - routingReserve).coerceAtLeast(0).toULong() + val maxSat = minOf(requestedSat, limits.maximalSat, sendable) + val minSat = limits.minimalSat + + if (maxSat < minSat) { + // Below the swap minimum: revert to the pre-swap view where the swipe closes + // the channel instead. No error text or extra close action is shown. + pendingSwapAmountSat = 0uL + _savingsSwapState.update { + it.copy( + isLoading = false, + quote = null, + minSat = 0uL, + maxSat = 0uL, + ) + } + return@launch + } + + // Default to transferring as much as possible; the slider can lower it. + pendingSwapAmountSat = maxSat + _savingsSwapState.update { + it.copy( + isLoading = false, + minSat = minSat, + maxSat = maxSat, + quote = buildQuote(maxSat, limits), + ) + } + } + } + + /** Re-price the swap for a slider-selected amount, clamped to the allowed range. */ + fun onSwapAmountChange(sat: ULong) { + val limits = reverseLimits ?: return + val state = _savingsSwapState.value + if (state.maxSat < state.minSat) return + val amount = sat.coerceIn(state.minSat, state.maxSat) + pendingSwapAmountSat = amount + _savingsSwapState.update { it.copy(quote = buildQuote(amount, limits)) } + } + + private fun buildQuote(amount: ULong, limits: BoltzPairInfo): SavingsSwapQuote { + val swapFee = (amount.toDouble() * limits.feePercentage / 100.0).roundToLong().coerceAtLeast(0).toULong() + val networkFee = limits.minerFeesSat + val receive = (amount.toLong() - swapFee.toLong() - networkFee.toLong()).coerceAtLeast(0).toULong() + return SavingsSwapQuote( + amountSat = amount, + networkFeeSat = networkFee, + swapFeeSat = swapFee, + receiveSat = receive, + ) + } + + /** + * Run the swap in [viewModelScope] so it outlives the progress screen: navigating away + * mid-flight must not cancel a swap whose hold invoice is already paid. Idempotent while + * a swap is in flight, so re-entering the journey cannot create and pay a second swap. + * The outcome lands in [savingsSwapResult]. + */ + fun startSavingsSwap() { + if (savingsSwapJob?.isActive == true) return + _savingsSwapResult.update { null } + savingsSwapJob = viewModelScope.launch { + val result = executeSavingsSwap() + _savingsSwapResult.update { result } + } + } + + /** + * Execute the LN -> onchain swap: derive a fresh claim address, create the swap, + * pay the returned hold invoice over Lightning, then wait for the on-chain claim. + * The claim is auto-broadcast by the updates stream as soon as the lockup hits the + * mempool, so a timeout here is not a failure; the swap completes in the background. + */ + private suspend fun executeSavingsSwap(): SavingsSwapResult { + val amount = pendingSwapAmountSat.takeIf { it > 0uL } + ?: return SavingsSwapResult.Failure( + context.getString(R.string.lightning__savings_confirm__amount_too_low), + ) + + return runSuspendCatching { + val claimAddress = lightningRepo.newAddress().getOrThrow() + val swap = boltzService.createReverseSwap(amountSat = amount, claimAddress = claimAddress) + Logger.info("Created savings transfer swap ${swap.id}", context = TAG) + + coroutineScope { + // UNDISPATCHED so the collector actually subscribes before we pay: the events + // flow has no replay, so a claim settling faster than the payment call returns + // would otherwise be missed and the swap would idle until the claim timeout. + val claim = async(start = CoroutineStart.UNDISPATCHED) { awaitSwapClaim(swap.id) } + + // Pay the hold invoice (amount is encoded). It stays pending until Boltz + // locks funds on-chain and we claim them, which is the expected happy path. + lightningRepo.payInvoice(bolt11 = swap.invoice).getOrThrow() + + claim.await() + } + }.getOrElse { e -> + Logger.error("Savings transfer swap failed", e, context = TAG) + SavingsSwapResult.Failure(e.message ?: context.getString(R.string.common__error_body)) + } + } + + private suspend fun awaitSwapClaim(swapId: String): SavingsSwapResult { + val event = withTimeoutOrNull(SWAP_CLAIM_TIMEOUT) { + boltzService.events.first { + (it is BoltzSwapEvent.Claimed && it.swapId == swapId) || + (it is BoltzSwapEvent.Error && it.swapId == swapId) + } + } + return when (event) { + is BoltzSwapEvent.Claimed -> SavingsSwapResult.Success(event.txid) + is BoltzSwapEvent.Error -> SavingsSwapResult.Failure(event.message) + else -> SavingsSwapResult.Pending + } + } + fun setSelectedChannelIds(channelIds: Set) { _selectedChannelIdsState.update { channelIds } } - fun onTransferToSavingsConfirm(channels: List) { + /** + * Commit the transfer and pick how it runs. A swap needs a priced quote, so without one + * (swaps unsupported on this network, Boltz unreachable, or an amount below the swap + * minimum) the transfer closes a channel exactly as it did before swaps existed. + */ + fun onTransferToSavingsConfirm( + channels: List, + mode: SavingsTransferMode = resolveSavingsTransferMode(), + ) { + _savingsTransferMode.update { mode } + // Drop any outcome from an earlier attempt so the progress screen cannot act on it. + _savingsSwapResult.update { null } _selectedChannelIdsState.update { emptySet() } channelsToClose = channels } + private fun resolveSavingsTransferMode(): SavingsTransferMode = + if (_savingsSwapState.value.quote != null) SavingsTransferMode.SWAP else SavingsTransferMode.CLOSE + /** Closes the channels selected earlier, pending closure */ suspend fun closeSelectedChannels() = closeChannels(channelsToClose) @@ -1299,6 +1495,16 @@ class TransferViewModel @Inject constructor( /** Upper bound for broadcasting a signed hardware funding transaction. */ private val HW_BROADCAST_TIMEOUT = 120.seconds + + /** How long the confirm/progress flow waits for the on-chain claim before backgrounding it. */ + private val SWAP_CLAIM_TIMEOUT = 30.seconds + + /** Upper bound for fetching swap limits before the confirm screen gives up on a quote. */ + private val SWAP_QUOTE_TIMEOUT = 15.seconds + + /** Minimum sats held back from a swap to cover Lightning routing fees. */ + private const val MIN_LN_ROUTING_FEE_RESERVE_SATS = 10L + const val LN_SETUP_STEP_0 = 0 const val LN_SETUP_STEP_1 = 1 const val LN_SETUP_STEP_2 = 2 @@ -1370,6 +1576,36 @@ sealed interface TransferEffect { data class ToastException(val e: Throwable) : TransferEffect data class ToastError(val title: String, val description: String) : TransferEffect } + +/** Whether a transfer to savings swaps funds out or closes a channel (default). */ +enum class SavingsTransferMode { SWAP, CLOSE } + +@Immutable +data class SavingsSwapQuote( + val amountSat: ULong, + val networkFeeSat: ULong, + val swapFeeSat: ULong, + val receiveSat: ULong, +) + +@Immutable +data class SavingsSwapUiState( + val isLoading: Boolean = false, + val quote: SavingsSwapQuote? = null, + /** Inclusive adjustable range for the confirm slider (sat). Equal/zero when unavailable. */ + val minSat: ULong = 0uL, + val maxSat: ULong = 0uL, +) + +sealed interface SavingsSwapResult { + /** Funds landed on-chain during the flow. */ + data class Success(val txid: String) : SavingsSwapResult + + /** Swap created and invoice paid; the claim completes in the background. */ + data object Pending : SavingsSwapResult + + data class Failure(val reason: String) : SavingsSwapResult +} // endregion private fun Throwable.rethrowIfCancellation() { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index aae350dc6..b9f83d7cb 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -6,9 +6,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.BoltzSwapEvent import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -27,6 +29,7 @@ import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.ext.of +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.Toast import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo @@ -37,6 +40,7 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.RecoveryModeError import to.bitkit.repositories.SyncSource import to.bitkit.repositories.WalletRepo +import to.bitkit.services.BoltzService import to.bitkit.services.MigrationService import to.bitkit.ui.onboarding.LOADING_MS import to.bitkit.ui.shared.toast.ToastEventBus @@ -60,10 +64,17 @@ class WalletViewModel @Inject constructor( private val pubkyRepo: PubkyRepo, private val migrationService: MigrationService, private val connectivityRepo: ConnectivityRepo, + private val boltzService: BoltzService, ) : ViewModel() { companion object { private const val TAG = "WalletViewModel" private val TIMEOUT_RESTORE_WAIT = 30.seconds + + /** Base backoff between swap updates stream attempts; scales linearly per attempt. */ + private val SWAP_UPDATES_RETRY_DELAY = 5.seconds + + /** Upper bound for the backoff between swap updates stream attempts. */ + private val SWAP_UPDATES_RETRY_CAP = 60.seconds } val lightningState = lightningRepo.lightningState @@ -309,6 +320,7 @@ class WalletViewModel @Inject constructor( if (_restoreState.value.isIdle()) { walletRepo.refreshBip21() } + ensureSwapUpdatesRunning() // checkForOrphanedChannelMonitorRecovery() } .onFailure { @@ -319,6 +331,68 @@ class WalletViewModel @Inject constructor( } } + private var swapEventsCollected = false + private var swapUpdatesJob: Job? = null + + @Volatile + private var swapUpdatesRunning = false + + /** + * Ensure the swap updates stream is running so pending LN -> onchain swaps are tracked and + * auto-claimed. A live stream is left untouched: restarting it would abort bitkit-core's + * background tasks and could race an in-flight claim. New swaps are reconciled at creation + * inside bitkit-core, and the stream reconciles every pending swap periodically, so a + * running stream is always enough. + */ + fun ensureSwapUpdatesRunning() { + if (!boltzService.isSwapSupported) return + collectSwapEventsOnce() + if (swapUpdatesRunning || swapUpdatesJob?.isActive == true) return + swapUpdatesJob = viewModelScope.launch { startSwapUpdates() } + } + + /** + * Open the swap updates stream so any pending LN -> onchain swaps resume and auto-claim. + * Uses the wallet's current fee rate for the claim tx. Retries until started: without + * the stream a paid swap has nothing to broadcast its claim. Once started, bitkit-core + * keeps the WebSocket alive with its own reconnect loop. + */ + private suspend fun startSwapUpdates() { + var attempt = 0 + while (true) { + val started = runSuspendCatching { + val speed = settingsStore.data.first().defaultTransactionSpeed + val feeRate = lightningRepo.getFeeRateForSpeed(speed).getOrNull()?.toDouble() + boltzService.startUpdates(feeRateSatPerVb = feeRate, acceptZeroConf = true) + }.onFailure { + Logger.warn("Failed to start swap updates, attempt '${attempt + 1}'", context = TAG) + }.isSuccess + + if (started) { + swapUpdatesRunning = true + return + } + attempt++ + delay((SWAP_UPDATES_RETRY_DELAY * attempt).coerceAtMost(SWAP_UPDATES_RETRY_CAP)) + } + } + + /** Refresh balances when a swap lands on-chain so savings reflect it without a manual sync. */ + private fun collectSwapEventsOnce() { + if (swapEventsCollected) return + swapEventsCollected = true + // UNDISPATCHED so we subscribe before the updates stream starts: the events flow has + // no replay, so a swap claimed early would otherwise leave balances stale. + viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) { + boltzService.events.collect { event -> + if (event is BoltzSwapEvent.Claimed) { + Logger.info("Savings swap claimed: ${event.swapId}", context = TAG) + walletRepo.syncBalances() + } + } + } + } + private suspend fun connectMigrationPeers() { val peerUris = migrationService.tryFetchMigrationPeersFromBackup() for (uri in peerUris) { @@ -334,7 +408,10 @@ class WalletViewModel @Inject constructor( fun stop() { if (!walletExists) return + swapUpdatesJob?.cancel() + swapUpdatesRunning = false viewModelScope.launch(bgDispatcher) { + stopSwapUpdates() lightningRepo.stop() .onFailure { Logger.error("Node stop error", it) @@ -343,6 +420,11 @@ class WalletViewModel @Inject constructor( } } + private suspend fun stopSwapUpdates() { + runSuspendCatching { boltzService.stopUpdates() } + .onFailure { Logger.error("Failed to stop swap updates", it, context = TAG) } + } + fun refreshState() = viewModelScope.launch { walletRepo.syncNodeAndWallet() .onFailure { @@ -352,6 +434,15 @@ class WalletViewModel @Inject constructor( } } + /** + * Refresh wallet balances and channel state from the running node without a chain sync, so a + * just-received payment is reflected immediately (e.g. when entering the transfer-to-savings flow). + */ + fun refreshBalances() = viewModelScope.launch { + walletRepo.syncBalances() + lightningRepo.syncState() + } + fun onPullToRefresh() { // Cancel any existing sync, manual or event triggered syncJob?.cancel() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 98d83f888..89060206b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -317,7 +317,13 @@ You can transfer part of your spending balance to savings, because you have multiple active Lightning Connections. Select funds\n<accent>to transfer</accent> Total selected + To savings + Amount is too low to transfer to savings. + Close channel instead Transfer to savings + Network fee + You\'ll receive + Service fee Transfer all Transfer\n<accent>interrupted</accent> Funds were not transferred yet. Bitkit will try to initiate the transfer during the next <accent>30 minutes</accent>. Please keep your app open. @@ -327,6 +333,8 @@ Transfer\n<accent>to savings</accent> Please wait, your funds transfer is in progress. This should take <accent>±10 seconds.</accent> Funds\n<accent>in transfer</accent> + Your transfer has been initiated and is <accent>settling on-chain</accent>. Your savings balance will update automatically once it completes. + Transfer\n<accent>on the way</accent> Pathfinding Scores Sync Time Continue Using Bitkit Processing Payment diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 29347acce..64c1ddf1e 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -2,7 +2,9 @@ package to.bitkit.ui import android.content.Context import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before @@ -15,6 +17,7 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.ext.of import to.bitkit.models.BalanceState @@ -28,6 +31,7 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.SyncSource import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WalletState +import to.bitkit.services.BoltzService import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest import to.bitkit.viewmodels.RestoreState @@ -47,6 +51,7 @@ class WalletViewModelTest : BaseUnitTest() { private val pubkyRepo = mock() private val migrationService = mock() private val connectivityRepo = mock() + private val boltzService = mock() private val lightningState = MutableStateFlow(LightningState()) private val walletState = MutableStateFlow(WalletState()) @@ -64,6 +69,9 @@ class WalletViewModelTest : BaseUnitTest() { whenever(migrationService.tryFetchMigrationPeersFromBackup()).thenReturn(emptyList()) whenever { migrationService.getRNRemoteBackupTimestamp() }.thenReturn(null) whenever(connectivityRepo.isOnline).thenReturn(isOnline) + whenever(boltzService.events).thenReturn(MutableSharedFlow()) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) + whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) }.thenReturn(Result.success(1uL)) sut = WalletViewModel( context = context, @@ -76,9 +84,30 @@ class WalletViewModelTest : BaseUnitTest() { pubkyRepo = pubkyRepo, migrationService = migrationService, connectivityRepo = connectivityRepo, + boltzService = boltzService, ) } + @Test + fun `ensureSwapUpdatesRunning should start updates when swaps are supported`() = test { + whenever(boltzService.isSwapSupported).thenReturn(true) + + sut.ensureSwapUpdatesRunning() + advanceUntilIdle() + + verify(boltzService).startUpdates(anyOrNull(), any(), anyOrNull()) + } + + @Test + fun `ensureSwapUpdatesRunning should do nothing when swaps are unsupported`() = test { + whenever(boltzService.isSwapSupported).thenReturn(false) + + sut.ensureSwapUpdatesRunning() + advanceUntilIdle() + + verify(boltzService, never()).startUpdates(anyOrNull(), any(), anyOrNull()) + } + @Test fun `setInitNodeLifecycleState should call lightningRepo`() = test { sut.setInitNodeLifecycleState() @@ -287,6 +316,7 @@ class WalletViewModelTest : BaseUnitTest() { pubkyRepo = pubkyRepo, migrationService = migrationService, connectivityRepo = connectivityRepo, + boltzService = boltzService, ) assertEquals(RestoreState.Initial, testSut.restoreState.value) @@ -348,6 +378,7 @@ class WalletViewModelTest : BaseUnitTest() { pubkyRepo = pubkyRepo, migrationService = migrationService, connectivityRepo = connectivityRepo, + boltzService = boltzService, ) // Trigger restore to put state in non-idle diff --git a/app/src/test/java/to/bitkit/viewmodels/SwapsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SwapsViewModelTest.kt new file mode 100644 index 000000000..51f5dc2e9 --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/SwapsViewModelTest.kt @@ -0,0 +1,120 @@ +package to.bitkit.viewmodels + +import com.synonym.bitkitcore.BoltzNetwork +import com.synonym.bitkitcore.BoltzSwap +import com.synonym.bitkitcore.BoltzSwapStatus +import com.synonym.bitkitcore.BoltzSwapType +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.services.BoltzService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SwapsViewModelTest : BaseUnitTest() { + private val boltzService = mock() + + @Test + fun `refresh sorts swaps by creation time descending`() = test { + whenever(boltzService.listSwaps()).thenReturn( + listOf( + swap(id = "older", createdAt = 100uL), + swap(id = "newer", createdAt = 200uL), + ), + ) + + val sut = SwapsViewModel(boltzService) + advanceUntilIdle() + + assertEquals(listOf("newer", "older"), sut.swaps.value.map { it.id }) + } + + @Test + fun `refresh surfaces the error when listing swaps fails`() = test { + whenever(boltzService.listSwaps()).thenAnswer { throw AppError(LIST_ERROR) } + + val sut = SwapsViewModel(boltzService) + advanceUntilIdle() + + assertEquals(LIST_ERROR, sut.error.value) + assertTrue(sut.swaps.value.isEmpty()) + } + + @Test + fun `claimReverseSwap reports the broadcast txid`() = test { + whenever(boltzService.listSwaps()).thenReturn(emptyList()) + whenever(boltzService.claimReverseSwap(any(), anyOrNull())).thenReturn(TXID) + val sut = SwapsViewModel(boltzService) + + var result: Result? = null + sut.claimReverseSwap(SWAP_ID) { result = it } + advanceUntilIdle() + + assertEquals(TXID, result?.getOrNull()) + } + + @Test + fun `isClaimable while a reverse swap is unclaimed and not in a terminal state`() { + assertTrue(swap(status = BoltzSwapStatus.TransactionMempool).isClaimable) + assertTrue(swap(status = BoltzSwapStatus.TransactionConfirmed).isClaimable) + assertTrue(swap(status = BoltzSwapStatus.TransactionClaimPending).isClaimable) + assertTrue(swap(status = BoltzSwapStatus.InvoicePending).isClaimable) + + // A stalled updates stream leaves the swap at SwapCreated locally even once Boltz has + // locked up on-chain, so the claim must stay reachable: this is the recovery case. + assertTrue(swap(status = BoltzSwapStatus.SwapCreated).isClaimable) + } + + @Test + fun `isClaimable is false for terminal, already claimed, and submarine swaps`() { + assertFalse(swap(status = BoltzSwapStatus.SwapExpired).isClaimable) + assertFalse(swap(status = BoltzSwapStatus.TransactionFailed).isClaimable) + assertFalse(swap(status = BoltzSwapStatus.TransactionLockupFailed).isClaimable) + assertFalse(swap(status = BoltzSwapStatus.TransactionRefunded).isClaimable) + assertFalse(swap(status = BoltzSwapStatus.TransactionClaimed).isClaimable) + assertFalse(swap(status = BoltzSwapStatus.InvoiceSettled).isClaimable) + assertFalse(swap(status = BoltzSwapStatus.InvoiceExpired).isClaimable) + assertFalse(swap(status = BoltzSwapStatus.InvoiceFailedToPay).isClaimable) + assertFalse(swap(status = BoltzSwapStatus.TransactionConfirmed, claimTxId = TXID).isClaimable) + assertFalse( + swap(swapType = BoltzSwapType.SUBMARINE, status = BoltzSwapStatus.TransactionConfirmed).isClaimable, + ) + } + + private fun swap( + id: String = SWAP_ID, + swapType: BoltzSwapType = BoltzSwapType.REVERSE, + status: BoltzSwapStatus = BoltzSwapStatus.TransactionConfirmed, + claimTxId: String? = null, + createdAt: ULong = 0uL, + ) = BoltzSwap( + id = id, + swapType = swapType, + status = status, + network = BoltzNetwork.REGTEST, + swapIndex = 0uL, + amountSat = 100_000uL, + onchainAmountSat = 99_000uL, + invoice = null, + lockupAddress = null, + onchainAddress = null, + timeoutBlockHeight = 800uL, + createdAt = createdAt, + claimTxId = claimTxId, + refundTxId = null, + ) + + private companion object { + const val SWAP_ID = "swap1" + const val TXID = "txid1" + const val LIST_ERROR = "database unavailable" + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 6ed2be063..8554568da 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1,11 +1,14 @@ package to.bitkit.viewmodels import android.content.Context +import com.synonym.bitkitcore.BoltzPairInfo +import com.synonym.bitkitcore.BoltzSwapEvent import com.synonym.bitkitcore.BroadcastException import com.synonym.bitkitcore.ChannelLiquidityOptions import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtInfoOptions +import com.synonym.bitkitcore.ReverseSwapResponse import com.synonym.bitkitcore.TrezorException import com.synonym.bitkitcore.TrezorFeatures import kotlinx.collections.immutable.persistentListOf @@ -15,6 +18,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher @@ -66,12 +70,16 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo +import to.bitkit.services.BoltzService import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.transfer.previewBtOrder import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import kotlin.math.roundToLong import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds @@ -91,9 +99,11 @@ class TransferViewModelTest : BaseUnitTest() { private val cacheStore = mock() private val transferRepo = mock() private val clock = mock() + private val boltzService = mock() private val balanceState = MutableStateFlow(BalanceState()) private val blocktankState = MutableStateFlow(BlocktankState()) + private val boltzEvents = MutableSharedFlow(extraBufferCapacity = 8) private val feeResponse = mock() @Before @@ -108,6 +118,8 @@ class TransferViewModelTest : BaseUnitTest() { whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus))) whenever(walletRepo.balanceState).thenReturn(balanceState) whenever(blocktankRepo.blocktankState).thenReturn(blocktankState) + whenever(boltzService.events).thenReturn(boltzEvents) + whenever(boltzService.isSwapSupported).thenReturn(true) // Default: no mining-fee reserve so existing limit tests keep their balances. whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } .thenReturn(Result.success(0uL)) @@ -127,6 +139,7 @@ class TransferViewModelTest : BaseUnitTest() { cacheStore = cacheStore, transferRepo = transferRepo, clock = clock, + boltzService = boltzService, ) } @@ -753,6 +766,7 @@ class TransferViewModelTest : BaseUnitTest() { cacheStore = cacheStore, transferRepo = transferRepo, clock = clock, + boltzService = boltzService, ) viewModel.onTransferToSpendingHwConfirm(order, DEVICE_ID) @@ -1196,6 +1210,218 @@ class TransferViewModelTest : BaseUnitTest() { verify(cacheStore, never()).addPaidOrder(any(), any()) } + @Test + fun `loadSavingsSwapQuote defaults to max transferable within limits and spendable balance`() = test { + balanceState.value = BalanceState(maxSendLightningSats = SPENDABLE_LN) + whenever(boltzService.reverseLimits(anyOrNull())).thenReturn(reverseLimits()) + + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + + val state = sut.savingsSwapState.value + val expectedMax = SPENDABLE_LN - SPENDABLE_LN / 100uL // 1% routing fee reserve + assertEquals(SWAP_MIN, state.minSat) + assertEquals(expectedMax, state.maxSat) + val quote = assertNotNull(state.quote) + assertEquals(expectedMax, quote.amountSat) + assertEquals(SWAP_MINER_FEE, quote.networkFeeSat) + val expectedSwapFee = (expectedMax.toLong() * SWAP_FEE_PERCENT / 100.0).roundToLong().toULong() + assertEquals(expectedSwapFee, quote.swapFeeSat) + assertEquals(expectedMax - expectedSwapFee - SWAP_MINER_FEE, quote.receiveSat) + } + + @Test + fun `loadSavingsSwapQuote falls back to close when below the swap minimum`() = test { + whenever(context.getString(R.string.lightning__savings_confirm__amount_too_low)).thenReturn(TOO_LOW) + balanceState.value = BalanceState(maxSendLightningSats = SWAP_MIN - 1uL) + whenever(boltzService.reverseLimits(anyOrNull())).thenReturn(reverseLimits()) + + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + + val state = sut.savingsSwapState.value + assertNull(state.quote) + assertEquals(0uL, state.maxSat) + + sut.onTransferToSavingsConfirm(emptyList()) + assertEquals(SavingsTransferMode.CLOSE, sut.savingsTransferMode.value) + + sut.startSavingsSwap() + advanceUntilIdle() + + assertEquals(SavingsSwapResult.Failure(TOO_LOW), sut.savingsSwapResult.value) + } + + @Test + fun `loadSavingsSwapQuote falls back to close when the limits fetch fails`() = test { + balanceState.value = BalanceState(maxSendLightningSats = SPENDABLE_LN) + whenever(boltzService.reverseLimits(anyOrNull())).thenAnswer { throw AppError(BOLTZ_ERROR) } + + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + + assertNull(sut.savingsSwapState.value.quote) + assertFalse(sut.savingsSwapState.value.isLoading) + + sut.onTransferToSavingsConfirm(emptyList()) + assertEquals(SavingsTransferMode.CLOSE, sut.savingsTransferMode.value) + } + + @Test + fun `loadSavingsSwapQuote skips the network when swaps are unsupported`() = test { + balanceState.value = BalanceState(maxSendLightningSats = SPENDABLE_LN) + whenever(boltzService.isSwapSupported).thenReturn(false) + + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + + assertEquals(SavingsSwapUiState(), sut.savingsSwapState.value) + verify(boltzService, never()).reverseLimits(anyOrNull()) + + sut.onTransferToSavingsConfirm(emptyList()) + assertEquals(SavingsTransferMode.CLOSE, sut.savingsTransferMode.value) + } + + @Test + fun `onTransferToSavingsConfirm swaps when a quote is ready and closes when the user opts out`() = test { + balanceState.value = BalanceState(maxSendLightningSats = SPENDABLE_LN) + whenever(boltzService.reverseLimits(anyOrNull())).thenReturn(reverseLimits()) + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + + sut.onTransferToSavingsConfirm(emptyList()) + assertEquals(SavingsTransferMode.SWAP, sut.savingsTransferMode.value) + + sut.onTransferToSavingsConfirm(emptyList(), SavingsTransferMode.CLOSE) + assertEquals(SavingsTransferMode.CLOSE, sut.savingsTransferMode.value) + } + + @Test + fun `onTransferToSavingsConfirm clears the outcome of an earlier swap`() = test { + stubSavingsSwapHappyPath() + whenever(lightningRepo.payInvoice(any(), anyOrNull())).thenReturn(Result.failure(AppError(PAY_ERROR))) + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + sut.startSavingsSwap() + advanceUntilIdle() + assertEquals(SavingsSwapResult.Failure(PAY_ERROR), sut.savingsSwapResult.value) + + sut.onTransferToSavingsConfirm(emptyList()) + + assertNull(sut.savingsSwapResult.value) + } + + @Test + fun `onSwapAmountChange clamps to the allowed range and reprices`() = test { + balanceState.value = BalanceState(maxSendLightningSats = SPENDABLE_LN) + whenever(boltzService.reverseLimits(anyOrNull())).thenReturn(reverseLimits()) + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + + sut.onSwapAmountChange(SWAP_MIN - 10_000uL) + + assertEquals(SWAP_MIN, sut.savingsSwapState.value.quote?.amountSat) + } + + @Test + fun `startSavingsSwap succeeds when the claim event arrives`() = test { + stubSavingsSwapHappyPath() + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + + sut.startSavingsSwap() + runCurrent() + boltzEvents.emit(BoltzSwapEvent.Claimed(swapId = SWAP_ID, txid = TXID)) + advanceUntilIdle() + + assertEquals(SavingsSwapResult.Success(TXID), sut.savingsSwapResult.value) + } + + @Test + fun `startSavingsSwap fails when the swap reports an error event`() = test { + stubSavingsSwapHappyPath() + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + + sut.startSavingsSwap() + runCurrent() + boltzEvents.emit(BoltzSwapEvent.Error(swapId = SWAP_ID, message = BOLTZ_ERROR)) + advanceUntilIdle() + + assertEquals(SavingsSwapResult.Failure(BOLTZ_ERROR), sut.savingsSwapResult.value) + } + + @Test + fun `startSavingsSwap returns pending when the claim does not arrive in time`() = test { + stubSavingsSwapHappyPath() + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + + sut.startSavingsSwap() + runCurrent() + advanceTimeBy(31.seconds) + advanceUntilIdle() + + assertEquals(SavingsSwapResult.Pending, sut.savingsSwapResult.value) + } + + @Test + fun `startSavingsSwap fails when the invoice payment fails`() = test { + stubSavingsSwapHappyPath() + whenever(lightningRepo.payInvoice(any(), anyOrNull())).thenReturn(Result.failure(AppError(PAY_ERROR))) + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + + sut.startSavingsSwap() + advanceUntilIdle() + + assertEquals(SavingsSwapResult.Failure(PAY_ERROR), sut.savingsSwapResult.value) + } + + @Test + fun `startSavingsSwap ignores a second start while a swap is in flight`() = test { + stubSavingsSwapHappyPath() + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + + sut.startSavingsSwap() + runCurrent() + sut.startSavingsSwap() + runCurrent() + boltzEvents.emit(BoltzSwapEvent.Claimed(swapId = SWAP_ID, txid = TXID)) + advanceUntilIdle() + + assertEquals(SavingsSwapResult.Success(TXID), sut.savingsSwapResult.value) + verify(boltzService).createReverseSwap(any(), any(), anyOrNull(), anyOrNull()) + verify(lightningRepo).payInvoice(any(), anyOrNull()) + } + + private suspend fun stubSavingsSwapHappyPath() { + balanceState.value = BalanceState(maxSendLightningSats = SPENDABLE_LN) + whenever(boltzService.reverseLimits(anyOrNull())).thenReturn(reverseLimits()) + whenever(lightningRepo.newAddress()).thenReturn(Result.success(CLAIM_ADDRESS)) + whenever(boltzService.createReverseSwap(any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(reverseSwapResponse()) + whenever(lightningRepo.payInvoice(any(), anyOrNull())).thenReturn(Result.success("paymentId")) + } + + private fun reverseLimits() = BoltzPairInfo( + hash = "hash", + rate = 1.0, + minimalSat = SWAP_MIN, + maximalSat = SWAP_MAX, + feePercentage = SWAP_FEE_PERCENT, + minerFeesSat = SWAP_MINER_FEE, + ) + + private fun reverseSwapResponse() = ReverseSwapResponse( + id = SWAP_ID, + invoice = "lnbc1invoice", + lockupAddress = "bcrt1qlockup", + onchainAmountSat = SWAP_MIN, + timeoutBlockHeight = 800uL, + ) + private fun signedFunding( funding: HwFundingTransaction, feeRate: ULong = FEE_RATE, @@ -1291,5 +1517,16 @@ class TransferViewModelTest : BaseUnitTest() { const val FEE_RATE = 2uL const val FALLBACK_FEE_RATE = 3uL const val MINING_FEE = 1_250uL + const val SPENDABLE_LN = 150_000uL + const val REQUESTED_SAT = 200_000uL + const val SWAP_MIN = 25_000uL + const val SWAP_MAX = 1_000_000uL + const val SWAP_FEE_PERCENT = 0.5 + const val SWAP_MINER_FEE = 300uL + const val SWAP_ID = "swap1" + const val CLAIM_ADDRESS = "bcrt1qclaim" + const val TOO_LOW = "Amount is too low" + const val PAY_ERROR = "no route found" + const val BOLTZ_ERROR = "boltz unavailable" } } diff --git a/changelog.d/next/1081.added.md b/changelog.d/next/1081.added.md new file mode 100644 index 000000000..39c6bc8d6 --- /dev/null +++ b/changelog.d/next/1081.added.md @@ -0,0 +1 @@ +Transferring to savings can now move Lightning funds on-chain through a swap, showing the fee upfront with the option to close the channel instead. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 08a8bcf6f..f1ac8dca0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.4.1" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.2" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc31" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }