From 45fce1153fc40ebb84effb3b1ae421714b9dae74 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Tue, 14 Jul 2026 08:59:04 -0400 Subject: [PATCH 1/9] feat: add boltz swap support --- .../java/to/bitkit/services/BoltzService.kt | 202 ++++++++++ app/src/main/java/to/bitkit/ui/ContentView.kt | 33 ++ .../java/to/bitkit/ui/components/Slider.kt | 131 +++++++ .../ui/screens/settings/DevSettingsScreen.kt | 1 + .../screens/transfer/SavingsConfirmScreen.kt | 157 +++++++- .../screens/transfer/SavingsProgressScreen.kt | 119 ++++-- .../java/to/bitkit/ui/settings/SwapsScreen.kt | 358 ++++++++++++++++++ .../to/bitkit/viewmodels/SwapsViewModel.kt | 70 ++++ .../to/bitkit/viewmodels/TransferViewModel.kt | 179 +++++++++ .../to/bitkit/viewmodels/WalletViewModel.kt | 35 ++ app/src/main/res/values/strings.xml | 6 + .../java/to/bitkit/ui/WalletViewModelTest.kt | 7 + .../viewmodels/TransferViewModelTest.kt | 4 + changelog.d/next/1081.added.md | 1 + gradle/libs.versions.toml | 2 +- 15 files changed, 1249 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/to/bitkit/services/BoltzService.kt create mode 100644 app/src/main/java/to/bitkit/ui/settings/SwapsScreen.kt create mode 100644 app/src/main/java/to/bitkit/viewmodels/SwapsViewModel.kt create mode 100644 changelog.d/next/1081.added.md 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 0000000000..20199f7c2c --- /dev/null +++ b/app/src/main/java/to/bitkit/services/BoltzService.kt @@ -0,0 +1,202 @@ +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 + * confirmed reverse swaps. [feeRateSatPerVb] is the rate used for those + * auto-claims (Bitkit owns fee estimation). Replaces any running stream. + */ + suspend fun startUpdates( + feeRateSatPerVb: Double?, + network: BoltzNetwork = boltzNetwork(), + ) { + val (mnemonic, passphrase) = credentials() + boltzStartSwapUpdates( + network = network, + listener = listener, + mnemonic = mnemonic, + bip39Passphrase = passphrase, + feeRateSatPerVb = feeRateSatPerVb, + ) + 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 + + /** 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 b41ddca8e3..203c1a8a0d 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -154,6 +154,8 @@ import to.bitkit.ui.settings.LanguageSettingsScreen import to.bitkit.ui.settings.LogDetailScreen import to.bitkit.ui.settings.LogsScreen import to.bitkit.ui.settings.OrderDetailScreen +import to.bitkit.ui.settings.SwapDetailScreen +import to.bitkit.ui.settings.SwapsScreen import to.bitkit.ui.settings.SettingsScreen import to.bitkit.ui.settings.advanced.AddressTypePreferenceScreen import to.bitkit.ui.settings.advanced.AddressViewerScreen @@ -688,6 +690,8 @@ private fun RootNavHost( channelOrdersSettings(navController) orderDetailSettings(navController) cjitDetailSettings(navController) + swapsSettings(navController) + swapDetailSettings(navController) lightningConnections(navController) activityItem(activityListViewModel, navController, settingsViewModel) authCheck(navController) @@ -1564,6 +1568,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, ) { @@ -1824,6 +1850,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)) @@ -1960,6 +1987,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 39229c3545..4b90cb8047 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -19,6 +19,7 @@ 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 @@ -240,6 +241,120 @@ 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() + .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 +370,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 6bb2907a15..f8311d8cbf 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 6359ef1a61..480ce8788c 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,18 @@ 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.BodyM 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 +59,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,16 +89,35 @@ fun SavingsConfirmScreen( val amount = channels.sumOf { it.amountOnClose } + val swapState by transfer.savingsSwapState.collectAsStateWithLifecycle() + + // 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, + quoteError = swapState.error, + 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 = { + onSwapConfirm = { + transfer.setSavingsTransferMode(SavingsTransferMode.SWAP) + transfer.onTransferToSavingsConfirm(channels) + onConfirm() + }, + onCloseConfirm = { + transfer.setSavingsTransferMode(SavingsTransferMode.CLOSE) transfer.onTransferToSavingsConfirm(channels) onConfirm() }, @@ -104,19 +135,27 @@ fun SavingsConfirmScreen( } } -@Suppress("MagicNumber") +@Suppress("MagicNumber", "LongParameterList") @Composable private fun SavingsConfirmContent( - amount: ULong, + fallbackAmount: ULong, + quote: SavingsSwapQuote?, + isQuoteLoading: Boolean, + quoteError: String?, + minSat: ULong, + maxSat: ULong, + onAmountChange: (Long) -> Unit, hasMultiple: Boolean, hasSelected: Boolean, onBackClick: () -> Unit = {}, onAmountClick: () -> Unit = {}, onAdvancedClick: () -> Unit = {}, onSelectAllClick: () -> Unit = {}, - onConfirm: () -> Unit = {}, + onSwapConfirm: () -> Unit = {}, + onCloseConfirm: () -> Unit = {}, ) { val scope = rememberCoroutineScope() + val headlineAmount = quote?.amountSat ?: fallbackAmount ScreenColumn { AppTopBar( titleText = stringResource(R.string.lightning__transfer__nav_title), @@ -135,7 +174,51 @@ 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, + ) + } + } else if (quoteError != null) { + Spacer(modifier = Modifier.height(16.dp)) + BodyM(text = quoteError, color = Colors.White64) + } if (hasMultiple) { Spacer(modifier = Modifier.height(24.dp)) @@ -156,32 +239,53 @@ 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), + ) + } + } + // Swapping funds out is the default; it only fires once the fee quote is ready. 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 = { + if (quote == null) return@SwipeToConfirm scope.launch { isLoading = true delay(300) - onConfirm() + onSwapConfirm() } } ) + 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 +296,18 @@ 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, + quoteError = null, + 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 3b3521c2ed..526e91a137 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 @@ -41,6 +41,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 @@ -55,43 +57,39 @@ fun SavingsProgressScreen( val context = LocalContext.current var progressState by remember { mutableStateOf(SavingsProgressState.PROGRESS) } - // Effect to close channels & update UI + // Effect to execute the transfer & update UI // TODO move this logic to viewmodel so it can outlive the screen lifecycle LaunchedEffect(Unit) { - val channelsFailedToCoopClose = transfer.closeSelectedChannels() + when (transfer.savingsTransferMode.value) { + SavingsTransferMode.SWAP -> runSavingsSwap( + transfer = transfer, + wallet = wallet, + onSuccess = { progressState = SavingsProgressState.SUCCESS }, + onFailure = { reason -> + app.toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = reason, + ) + onTransferUnavailable() + }, + ) - 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 - app.toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__close_error), - description = context.getString(R.string.lightning__close_error_msg), - ) - 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 - } + 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() + }, + ) } } @@ -102,6 +100,59 @@ fun SavingsProgressScreen( ) } +/** Swaps spending funds out to on-chain savings. A pending claim is treated as success. */ +private suspend fun runSavingsSwap( + transfer: TransferViewModel, + wallet: WalletViewModel, + onSuccess: () -> Unit, + onFailure: (String) -> Unit, +) { + when (val result = transfer.executeSavingsSwap()) { + is SavingsSwapResult.Success, SavingsSwapResult.Pending -> { + wallet.refreshState() + onSuccess() + } + + is SavingsSwapResult.Failure -> onFailure(result.reason) + } +} + +/** 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, 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 0000000000..b5bcc14abc --- /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 com.synonym.bitkitcore.BoltzSwapType +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 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 != null && swap.swapType == BoltzSwapType.REVERSE && swap.claimTxId == null + + 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 0000000000..15d45dde17 --- /dev/null +++ b/app/src/main/java/to/bitkit/viewmodels/SwapsViewModel.kt @@ -0,0 +1,70 @@ +package to.bitkit.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.BoltzSwap +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.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 } + runCatching { 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 = runCatching { 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" + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index e66ccaf7e9..b426b8ebdc 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -3,6 +3,8 @@ package to.bitkit.viewmodels import android.content.Context 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 @@ -50,6 +52,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 @@ -76,6 +79,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()) @@ -921,6 +925,145 @@ class TransferViewModel @Inject constructor( private var channelsToClose = emptyList() + /** + * How the LN -> onchain "transfer to savings" is executed. Swapping funds out + * (default) keeps channels open; closing a channel is the fallback the user can + * pick to drain a whole channel on-chain. + */ + private val _savingsTransferMode = MutableStateFlow(SavingsTransferMode.SWAP) + val savingsTransferMode = _savingsTransferMode.asStateFlow() + + private val _savingsSwapState = MutableStateFlow(SavingsSwapUiState()) + val savingsSwapState = _savingsSwapState.asStateFlow() + + /** 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 + + fun setSavingsTransferMode(mode: SavingsTransferMode) = _savingsTransferMode.update { mode } + + /** + * 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]. Errors surface in state. + */ + fun loadSavingsSwapQuote(requestedSat: ULong) { + viewModelScope.launch { + _savingsSwapState.update { it.copy(isLoading = true, error = null) } + awaitNodeRunning() + + val limits = runCatching { boltzService.reverseLimits() }.getOrElse { e -> + Logger.error("Failed to load reverse swap limits", e, context = TAG) + reverseLimits = null + _savingsSwapState.update { it.copy(isLoading = false, quote = null, error = e.message) } + 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) { + pendingSwapAmountSat = 0uL + _savingsSwapState.update { + it.copy( + isLoading = false, + quote = null, + minSat = 0uL, + maxSat = 0uL, + error = context.getString(R.string.lightning__savings_confirm__amount_too_low), + ) + } + return@launch + } + + // Default to transferring as much as possible; the slider can lower it. + pendingSwapAmountSat = maxSat + _savingsSwapState.update { + it.copy( + isLoading = false, + error = null, + 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, + ) + } + + /** + * 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 once the lockup confirms, so a + * timeout here is not a failure; the swap completes in the background. + */ + suspend fun executeSavingsSwap(): SavingsSwapResult { + val amount = pendingSwapAmountSat.takeIf { it > 0uL } + ?: return SavingsSwapResult.Failure( + context.getString(R.string.lightning__savings_confirm__amount_too_low), + ) + + return runCatching { + val claimAddress = lightningRepo.newAddress().getOrThrow() + val swap = boltzService.createReverseSwap(amountSat = amount, claimAddress = claimAddress) + Logger.info("Created savings transfer swap ${swap.id}", context = TAG) + + // 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() + + awaitSwapClaim(swap.id) + }.getOrElse { e -> + Logger.error("Savings transfer swap failed", e, context = TAG) + ToastEventBus.send(e) + 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 } } @@ -1121,6 +1264,13 @@ 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 = 60.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 @@ -1176,4 +1326,33 @@ 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 (default) or closes a channel. */ +enum class SavingsTransferMode { SWAP, CLOSE } + +data class SavingsSwapQuote( + val amountSat: ULong, + val networkFeeSat: ULong, + val swapFeeSat: ULong, + val receiveSat: ULong, +) + +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, + val error: String? = null, +) + +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 diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index aae350dc6d..3055997c0d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -6,6 +6,7 @@ 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 @@ -37,6 +38,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,6 +62,7 @@ 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" @@ -309,6 +312,8 @@ class WalletViewModel @Inject constructor( if (_restoreState.value.isIdle()) { walletRepo.refreshBip21() } + collectSwapEventsOnce() + viewModelScope.launch { startSwapUpdates() } // checkForOrphanedChannelMonitorRecovery() } .onFailure { @@ -319,6 +324,36 @@ class WalletViewModel @Inject constructor( } } + private var swapEventsCollected = false + + /** + * Open the swap updates stream so any pending LN -> onchain swaps resume and auto-claim + * once their lockup confirms. Uses the wallet's current fee rate for the claim tx. + */ + private suspend fun startSwapUpdates() { + runCatching { + val speed = settingsStore.data.first().defaultTransactionSpeed + val feeRate = lightningRepo.getFeeRateForSpeed(speed).getOrNull()?.toDouble() + boltzService.startUpdates(feeRateSatPerVb = feeRate) + }.onFailure { + Logger.error("Failed to start swap updates", it, context = TAG) + } + } + + /** Refresh balances when a swap lands on-chain so savings reflect it without a manual sync. */ + private fun collectSwapEventsOnce() { + if (swapEventsCollected) return + swapEventsCollected = true + viewModelScope.launch { + 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) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 98d83f888e..e1ce5c1980 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -319,6 +319,12 @@ Total selected Transfer to savings Transfer all + To savings + Network fee + Service fee + You\'ll receive + Close channel instead + Amount is too low to transfer to savings. 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. Keep Bitkit\n<accent>open</accent> diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 29347acced..5c7832e055 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -2,6 +2,7 @@ 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.runBlocking import kotlinx.coroutines.test.advanceUntilIdle @@ -28,6 +29,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 +49,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 +67,7 @@ class WalletViewModelTest : BaseUnitTest() { whenever(migrationService.tryFetchMigrationPeersFromBackup()).thenReturn(emptyList()) whenever { migrationService.getRNRemoteBackupTimestamp() }.thenReturn(null) whenever(connectivityRepo.isOnline).thenReturn(isOnline) + whenever(boltzService.events).thenReturn(MutableSharedFlow()) sut = WalletViewModel( context = context, @@ -76,6 +80,7 @@ class WalletViewModelTest : BaseUnitTest() { pubkyRepo = pubkyRepo, migrationService = migrationService, connectivityRepo = connectivityRepo, + boltzService = boltzService, ) } @@ -287,6 +292,7 @@ class WalletViewModelTest : BaseUnitTest() { pubkyRepo = pubkyRepo, migrationService = migrationService, connectivityRepo = connectivityRepo, + boltzService = boltzService, ) assertEquals(RestoreState.Initial, testSut.restoreState.value) @@ -348,6 +354,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/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index ccd100f410..ca55108d12 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -61,6 +61,7 @@ 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 @@ -86,6 +87,7 @@ 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()) @@ -114,6 +116,7 @@ class TransferViewModelTest : BaseUnitTest() { cacheStore = cacheStore, transferRepo = transferRepo, clock = clock, + boltzService = boltzService, ) } @@ -520,6 +523,7 @@ class TransferViewModelTest : BaseUnitTest() { cacheStore = cacheStore, transferRepo = transferRepo, clock = clock, + boltzService = boltzService, ) viewModel.onTransferToSpendingHwConfirm(order, DEVICE_ID) diff --git a/changelog.d/next/1081.added.md b/changelog.d/next/1081.added.md new file mode 100644 index 0000000000..39c6bc8d6e --- /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 08a8bcf6f6..187956437a 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.0" } 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" } From cdce3eba0e3f6014443a3d872e2191ed18a92c96 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 15 Jul 2026 12:51:49 -0400 Subject: [PATCH 2/9] fix: keep savings slider off back gesture --- app/src/main/java/to/bitkit/ui/components/Slider.kt | 6 ++++++ 1 file changed, 6 insertions(+) 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 4b90cb8047..8c6d124055 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -15,6 +15,7 @@ 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 @@ -45,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 @@ -278,6 +282,8 @@ fun AmountSlider( Box( modifier = modifier .fillMaxWidth() + .systemGestureExclusion() + .padding(horizontal = SLIDER_EDGE_INSET_DP.dp) .height(KNOB_SIZE_DP.dp) .onGloballyPositioned { sliderWidth = it.size.width } ) { From e0c952a1072b2117d75703926c052e3dd012324f Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 15 Jul 2026 12:51:49 -0400 Subject: [PATCH 3/9] fix: refresh balances entering savings --- .../bitkit/ui/screens/transfer/SavingsConfirmScreen.kt | 5 +++++ .../main/java/to/bitkit/viewmodels/WalletViewModel.kt | 9 +++++++++ 2 files changed, 14 insertions(+) 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 480ce8788c..6979387981 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 @@ -91,6 +91,11 @@ fun SavingsConfirmScreen( 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) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 3055997c0d..e93e6ffc1b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -387,6 +387,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() From 289b950147bf87d32035c1379de8843c17c9ecb1 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 15 Jul 2026 13:29:37 -0400 Subject: [PATCH 4/9] chore: bump bitkit-core to 0.5.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 187956437a..390cafd9b9 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.5.0" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.1" } 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" } From eba8bc85a7432fa5c5d1ea7a6f95e0c74e9af93e Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Thu, 16 Jul 2026 08:49:05 -0400 Subject: [PATCH 5/9] fix: gate swap claim and harden boltz flow --- app/src/main/java/to/bitkit/ui/ContentView.kt | 2 +- .../java/to/bitkit/ui/settings/SwapsScreen.kt | 4 +- .../to/bitkit/viewmodels/SwapsViewModel.kt | 24 ++- .../to/bitkit/viewmodels/TransferViewModel.kt | 22 ++- .../to/bitkit/viewmodels/WalletViewModel.kt | 9 +- app/src/main/res/values/strings.xml | 10 +- .../bitkit/viewmodels/SwapsViewModelTest.kt | 109 ++++++++++++ .../viewmodels/TransferViewModelTest.kt | 158 ++++++++++++++++++ 8 files changed, 320 insertions(+), 18 deletions(-) create mode 100644 app/src/test/java/to/bitkit/viewmodels/SwapsViewModelTest.kt diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 203c1a8a0d..e3bf62d210 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -154,9 +154,9 @@ import to.bitkit.ui.settings.LanguageSettingsScreen 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.SettingsScreen import to.bitkit.ui.settings.advanced.AddressTypePreferenceScreen import to.bitkit.ui.settings.advanced.AddressViewerScreen import to.bitkit.ui.settings.advanced.CoinSelectPreferenceScreen diff --git a/app/src/main/java/to/bitkit/ui/settings/SwapsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SwapsScreen.kt index b5bcc14abc..9589f2e329 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SwapsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SwapsScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.BoltzSwap -import com.synonym.bitkitcore.BoltzSwapType import kotlinx.collections.immutable.ImmutableList import to.bitkit.models.Toast import to.bitkit.models.formatToModernDisplay @@ -49,6 +48,7 @@ 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 @@ -178,7 +178,7 @@ fun SwapDetailScreen( val app = appViewModel ?: return val swaps by viewModel.swaps.collectAsStateWithLifecycle() val swap = swaps.find { it.id == swapItem.id } - val canClaim = swap != null && swap.swapType == BoltzSwapType.REVERSE && swap.claimTxId == null + val canClaim = swap?.isClaimable == true SwapDetailContent( swap = swap, diff --git a/app/src/main/java/to/bitkit/viewmodels/SwapsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SwapsViewModel.kt index 15d45dde17..47bee4a0fd 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SwapsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SwapsViewModel.kt @@ -3,6 +3,8 @@ 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 @@ -11,6 +13,7 @@ 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 @@ -40,7 +43,7 @@ class SwapsViewModel @Inject constructor( fun refresh() { viewModelScope.launch { _isLoading.update { true } - runCatching { boltzService.listSwaps() } + runSuspendCatching { boltzService.listSwaps() } .onSuccess { list -> _swaps.update { list.sortedByDescending { swap -> swap.createdAt }.toImmutableList() } _error.update { null } @@ -56,7 +59,7 @@ class SwapsViewModel @Inject constructor( /** 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 = runCatching { boltzService.claimReverseSwap(id) } + val result = runSuspendCatching { boltzService.claimReverseSwap(id) } result .onSuccess { refresh() } .onFailure { Logger.error("Manual claim failed for '$id'", it, context = TAG) } @@ -68,3 +71,20 @@ class SwapsViewModel @Inject constructor( private const val TAG = "SwapsViewModel" } } + +/** + * Whether a manual claim can succeed for this swap: reverse direction, lockup funds visible + * on-chain (mempool or confirmed), and no claim broadcast yet. Freshly created, expired, + * failed, and refunded swaps have nothing to claim. + */ +val BoltzSwap.isClaimable: Boolean + get() = swapType == BoltzSwapType.REVERSE && + claimTxId == null && + when (status) { + BoltzSwapStatus.TransactionMempool, + BoltzSwapStatus.TransactionConfirmed, + BoltzSwapStatus.TransactionClaimPending, + -> true + + else -> false + } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index b426b8ebdc..04604f7cff 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -1,6 +1,7 @@ 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 @@ -954,7 +955,7 @@ class TransferViewModel @Inject constructor( _savingsSwapState.update { it.copy(isLoading = true, error = null) } awaitNodeRunning() - val limits = runCatching { boltzService.reverseLimits() }.getOrElse { e -> + val limits = runSuspendCatching { boltzService.reverseLimits() }.getOrElse { e -> Logger.error("Failed to load reverse swap limits", e, context = TAG) reverseLimits = null _savingsSwapState.update { it.copy(isLoading = false, quote = null, error = e.message) } @@ -1033,19 +1034,24 @@ class TransferViewModel @Inject constructor( context.getString(R.string.lightning__savings_confirm__amount_too_low), ) - return runCatching { + 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) - // 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() + coroutineScope { + // Subscribe before paying so a claim settling faster than the payment + // call returns cannot be missed (the events flow has no replay). + val claim = async { 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() - awaitSwapClaim(swap.id) + claim.await() + } }.getOrElse { e -> Logger.error("Savings transfer swap failed", e, context = TAG) - ToastEventBus.send(e) SavingsSwapResult.Failure(e.message ?: context.getString(R.string.common__error_body)) } } @@ -1330,6 +1336,7 @@ sealed interface TransferEffect { /** Whether a transfer to savings swaps funds out (default) or closes a channel. */ enum class SavingsTransferMode { SWAP, CLOSE } +@Immutable data class SavingsSwapQuote( val amountSat: ULong, val networkFeeSat: ULong, @@ -1337,6 +1344,7 @@ data class SavingsSwapQuote( val receiveSat: ULong, ) +@Immutable data class SavingsSwapUiState( val isLoading: Boolean = false, val quote: SavingsSwapQuote? = null, diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index e93e6ffc1b..20ab326ab2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -28,6 +28,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 @@ -331,7 +332,7 @@ class WalletViewModel @Inject constructor( * once their lockup confirms. Uses the wallet's current fee rate for the claim tx. */ private suspend fun startSwapUpdates() { - runCatching { + runSuspendCatching { val speed = settingsStore.data.first().defaultTransactionSpeed val feeRate = lightningRepo.getFeeRateForSpeed(speed).getOrNull()?.toDouble() boltzService.startUpdates(feeRateSatPerVb = feeRate) @@ -370,6 +371,7 @@ class WalletViewModel @Inject constructor( if (!walletExists) return viewModelScope.launch(bgDispatcher) { + stopSwapUpdates() lightningRepo.stop() .onFailure { Logger.error("Node stop error", it) @@ -378,6 +380,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 { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e1ce5c1980..88000c7562 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -317,14 +317,14 @@ 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 - Transfer to savings - Transfer all To savings + Amount is too low to transfer to savings. + Close channel instead + Transfer to savings Network fee - Service fee You\'ll receive - Close channel instead - Amount is too low to transfer to savings. + 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. Keep Bitkit\n<accent>open</accent> 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 0000000000..9d65b648b6 --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/SwapsViewModelTest.kt @@ -0,0 +1,109 @@ +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 only while the reverse swap lockup is on-chain and unclaimed`() { + assertTrue(swap(status = BoltzSwapStatus.TransactionMempool).isClaimable) + assertTrue(swap(status = BoltzSwapStatus.TransactionConfirmed).isClaimable) + assertTrue(swap(status = BoltzSwapStatus.TransactionClaimPending).isClaimable) + + assertFalse(swap(status = BoltzSwapStatus.SwapCreated).isClaimable) + assertFalse(swap(status = BoltzSwapStatus.SwapExpired).isClaimable) + assertFalse(swap(status = BoltzSwapStatus.TransactionFailed).isClaimable) + assertFalse(swap(status = BoltzSwapStatus.TransactionRefunded).isClaimable) + assertFalse(swap(status = BoltzSwapStatus.InvoiceSettled).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 ca55108d12..25dfcf2723 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 @@ -14,7 +17,9 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher @@ -68,6 +73,8 @@ import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import kotlin.math.roundToLong import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds @@ -91,6 +98,7 @@ class TransferViewModelTest : BaseUnitTest() { private val balanceState = MutableStateFlow(BalanceState()) private val blocktankState = MutableStateFlow(BlocktankState()) + private val boltzEvents = MutableSharedFlow(extraBufferCapacity = 8) private val feeResponse = mock() @Before @@ -105,6 +113,7 @@ 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) sut = TransferViewModel( context = context, @@ -967,6 +976,144 @@ 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) + assertNull(state.error) + 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 reports amount too low 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) + assertEquals(TOO_LOW, state.error) + assertEquals(SavingsSwapResult.Failure(TOO_LOW), sut.executeSavingsSwap()) + } + + @Test + fun `loadSavingsSwapQuote surfaces an error 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() + + val state = sut.savingsSwapState.value + assertNull(state.quote) + assertEquals(BOLTZ_ERROR, state.error) + } + + @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 `executeSavingsSwap succeeds when the claim event arrives`() = test { + stubSavingsSwapHappyPath() + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + + val result = async { sut.executeSavingsSwap() } + runCurrent() + boltzEvents.emit(BoltzSwapEvent.Claimed(swapId = SWAP_ID, txid = TXID)) + + assertEquals(SavingsSwapResult.Success(TXID), result.await()) + } + + @Test + fun `executeSavingsSwap fails when the swap reports an error event`() = test { + stubSavingsSwapHappyPath() + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + + val result = async { sut.executeSavingsSwap() } + runCurrent() + boltzEvents.emit(BoltzSwapEvent.Error(swapId = SWAP_ID, message = BOLTZ_ERROR)) + + assertEquals(SavingsSwapResult.Failure(BOLTZ_ERROR), result.await()) + } + + @Test + fun `executeSavingsSwap returns pending when the claim does not arrive in time`() = test { + stubSavingsSwapHappyPath() + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + + val result = async { sut.executeSavingsSwap() } + runCurrent() + advanceTimeBy(61.seconds) + runCurrent() + + assertEquals(SavingsSwapResult.Pending, result.await()) + } + + @Test + fun `executeSavingsSwap fails when the invoice payment fails`() = test { + stubSavingsSwapHappyPath() + whenever(lightningRepo.payInvoice(any(), anyOrNull())).thenReturn(Result.failure(AppError(PAY_ERROR))) + sut.loadSavingsSwapQuote(REQUESTED_SAT) + advanceUntilIdle() + + assertEquals(SavingsSwapResult.Failure(PAY_ERROR), sut.executeSavingsSwap()) + } + + 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, @@ -1029,5 +1176,16 @@ class TransferViewModelTest : BaseUnitTest() { const val FEE_RATE = 2uL const val FALLBACK_FEE_RATE = 1uL 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" } } From 41c6c85aaca77610b93e9dd5c34028d220a93f81 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Thu, 16 Jul 2026 09:41:52 -0400 Subject: [PATCH 6/9] fix: harden savings swap races and lifecycle --- .../screens/transfer/SavingsProgressScreen.kt | 58 +++++++++---------- .../to/bitkit/viewmodels/TransferViewModel.kt | 37 ++++++++++-- .../to/bitkit/viewmodels/WalletViewModel.kt | 30 +++++++--- .../viewmodels/TransferViewModelTest.kt | 54 ++++++++++++----- 4 files changed, 122 insertions(+), 57 deletions(-) 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 526e91a137..adbe09e86d 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 @@ -56,24 +57,14 @@ fun SavingsProgressScreen( ) { val context = LocalContext.current var progressState by remember { mutableStateOf(SavingsProgressState.PROGRESS) } + val swapResult by transfer.savingsSwapResult.collectAsStateWithLifecycle() // Effect to execute the transfer & update UI - // TODO move this logic to viewmodel so it can outlive the screen lifecycle LaunchedEffect(Unit) { when (transfer.savingsTransferMode.value) { - SavingsTransferMode.SWAP -> runSavingsSwap( - transfer = transfer, - wallet = wallet, - onSuccess = { progressState = SavingsProgressState.SUCCESS }, - onFailure = { reason -> - app.toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = reason, - ) - onTransferUnavailable() - }, - ) + // The swap itself is owned by the viewmodel so it survives leaving this screen; + // the outcome arrives via savingsSwapResult below. + SavingsTransferMode.SWAP -> transfer.startSavingsSwap() SavingsTransferMode.CLOSE -> runChannelClose( transfer = transfer, @@ -93,6 +84,28 @@ fun SavingsProgressScreen( } } + // A pending claim counts as success: the hold invoice is paid and the updates stream + // auto-broadcasts the claim once the lockup confirms. + LaunchedEffect(swapResult) { + when (val result = swapResult) { + is SavingsSwapResult.Success, SavingsSwapResult.Pending -> { + wallet.refreshState() + progressState = SavingsProgressState.SUCCESS + } + + is SavingsSwapResult.Failure -> { + app.toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = result.reason, + ) + onTransferUnavailable() + } + + null -> Unit + } + } + Content( progressState = progressState, onContinueClick = { onContinueClick() }, @@ -100,23 +113,6 @@ fun SavingsProgressScreen( ) } -/** Swaps spending funds out to on-chain savings. A pending claim is treated as success. */ -private suspend fun runSavingsSwap( - transfer: TransferViewModel, - wallet: WalletViewModel, - onSuccess: () -> Unit, - onFailure: (String) -> Unit, -) { - when (val result = transfer.executeSavingsSwap()) { - is SavingsSwapResult.Success, SavingsSwapResult.Pending -> { - wallet.refreshState() - onSuccess() - } - - is SavingsSwapResult.Failure -> onFailure(result.reason) - } -} - /** Legacy path: cooperatively close the selected channel(s), retrying on failure. */ @Suppress("MagicNumber", "LongParameterList") private suspend fun runChannelClose( diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 04604f7cff..3370d8cb08 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -11,6 +11,7 @@ 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.TimeoutCancellationException import kotlinx.coroutines.async @@ -937,21 +938,31 @@ class TransferViewModel @Inject constructor( 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 + fun setSavingsTransferMode(mode: SavingsTransferMode) = _savingsTransferMode.update { mode } /** * 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]. Errors surface in state. + * Cancels any in-flight quote so a slower earlier request cannot overwrite a newer one. */ fun loadSavingsSwapQuote(requestedSat: ULong) { - viewModelScope.launch { + savingsSwapQuoteJob?.cancel() + savingsSwapQuoteJob = viewModelScope.launch { _savingsSwapState.update { it.copy(isLoading = true, error = null) } awaitNodeRunning() @@ -1022,13 +1033,28 @@ class TransferViewModel @Inject constructor( ) } + /** + * 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 once the lockup confirms, so a * timeout here is not a failure; the swap completes in the background. */ - suspend fun executeSavingsSwap(): SavingsSwapResult { + private suspend fun executeSavingsSwap(): SavingsSwapResult { val amount = pendingSwapAmountSat.takeIf { it > 0uL } ?: return SavingsSwapResult.Failure( context.getString(R.string.lightning__savings_confirm__amount_too_low), @@ -1040,9 +1066,10 @@ class TransferViewModel @Inject constructor( Logger.info("Created savings transfer swap ${swap.id}", context = TAG) coroutineScope { - // Subscribe before paying so a claim settling faster than the payment - // call returns cannot be missed (the events flow has no replay). - val claim = async { awaitSwapClaim(swap.id) } + // 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. diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 20ab326ab2..6a9251b537 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -10,6 +10,7 @@ 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 @@ -68,6 +69,12 @@ class WalletViewModel @Inject constructor( companion object { private const val TAG = "WalletViewModel" private val TIMEOUT_RESTORE_WAIT = 30.seconds + + /** Attempts to open the Boltz updates stream before giving up until the next wallet start. */ + private const val SWAP_UPDATES_START_ATTEMPTS = 3 + + /** Base backoff between swap updates stream attempts; scales linearly per attempt. */ + private val SWAP_UPDATES_RETRY_DELAY = 5.seconds } val lightningState = lightningRepo.lightningState @@ -330,22 +337,31 @@ class WalletViewModel @Inject constructor( /** * Open the swap updates stream so any pending LN -> onchain swaps resume and auto-claim * once their lockup confirms. Uses the wallet's current fee rate for the claim tx. + * Retries on failure: without the stream a paid swap has nothing to broadcast its claim. */ private suspend fun startSwapUpdates() { - runSuspendCatching { - val speed = settingsStore.data.first().defaultTransactionSpeed - val feeRate = lightningRepo.getFeeRateForSpeed(speed).getOrNull()?.toDouble() - boltzService.startUpdates(feeRateSatPerVb = feeRate) - }.onFailure { - Logger.error("Failed to start swap updates", it, context = TAG) + repeat(SWAP_UPDATES_START_ATTEMPTS) { attempt -> + val started = runSuspendCatching { + val speed = settingsStore.data.first().defaultTransactionSpeed + val feeRate = lightningRepo.getFeeRateForSpeed(speed).getOrNull()?.toDouble() + boltzService.startUpdates(feeRateSatPerVb = feeRate) + }.onFailure { + Logger.warn("Failed to start swap updates, attempt '${attempt + 1}'", context = TAG) + }.isSuccess + + if (started) return + delay(SWAP_UPDATES_RETRY_DELAY * (attempt + 1)) } + Logger.error("Gave up starting swap updates after '$SWAP_UPDATES_START_ATTEMPTS' attempts", context = TAG) } /** Refresh balances when a swap lands on-chain so savings reflect it without a manual sync. */ private fun collectSwapEventsOnce() { if (swapEventsCollected) return swapEventsCollected = true - viewModelScope.launch { + // 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) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 25dfcf2723..fab301f93d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -1010,7 +1009,11 @@ class TransferViewModelTest : BaseUnitTest() { assertNull(state.quote) assertEquals(0uL, state.maxSat) assertEquals(TOO_LOW, state.error) - assertEquals(SavingsSwapResult.Failure(TOO_LOW), sut.executeSavingsSwap()) + + sut.startSavingsSwap() + advanceUntilIdle() + + assertEquals(SavingsSwapResult.Failure(TOO_LOW), sut.savingsSwapResult.value) } @Test @@ -1039,53 +1042,76 @@ class TransferViewModelTest : BaseUnitTest() { } @Test - fun `executeSavingsSwap succeeds when the claim event arrives`() = test { + fun `startSavingsSwap succeeds when the claim event arrives`() = test { stubSavingsSwapHappyPath() sut.loadSavingsSwapQuote(REQUESTED_SAT) advanceUntilIdle() - val result = async { sut.executeSavingsSwap() } + sut.startSavingsSwap() runCurrent() boltzEvents.emit(BoltzSwapEvent.Claimed(swapId = SWAP_ID, txid = TXID)) + advanceUntilIdle() - assertEquals(SavingsSwapResult.Success(TXID), result.await()) + assertEquals(SavingsSwapResult.Success(TXID), sut.savingsSwapResult.value) } @Test - fun `executeSavingsSwap fails when the swap reports an error event`() = test { + fun `startSavingsSwap fails when the swap reports an error event`() = test { stubSavingsSwapHappyPath() sut.loadSavingsSwapQuote(REQUESTED_SAT) advanceUntilIdle() - val result = async { sut.executeSavingsSwap() } + sut.startSavingsSwap() runCurrent() boltzEvents.emit(BoltzSwapEvent.Error(swapId = SWAP_ID, message = BOLTZ_ERROR)) + advanceUntilIdle() - assertEquals(SavingsSwapResult.Failure(BOLTZ_ERROR), result.await()) + assertEquals(SavingsSwapResult.Failure(BOLTZ_ERROR), sut.savingsSwapResult.value) } @Test - fun `executeSavingsSwap returns pending when the claim does not arrive in time`() = test { + fun `startSavingsSwap returns pending when the claim does not arrive in time`() = test { stubSavingsSwapHappyPath() sut.loadSavingsSwapQuote(REQUESTED_SAT) advanceUntilIdle() - val result = async { sut.executeSavingsSwap() } + sut.startSavingsSwap() runCurrent() advanceTimeBy(61.seconds) - runCurrent() + advanceUntilIdle() - assertEquals(SavingsSwapResult.Pending, result.await()) + assertEquals(SavingsSwapResult.Pending, sut.savingsSwapResult.value) } @Test - fun `executeSavingsSwap fails when the invoice payment fails`() = 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() - assertEquals(SavingsSwapResult.Failure(PAY_ERROR), sut.executeSavingsSwap()) + 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() { From ce42da38d2cb428bc3cc523ea4ad2d8f4aba4b75 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 17 Jul 2026 12:04:09 -0400 Subject: [PATCH 7/9] fix: claim savings swaps reliably --- .../screens/transfer/SavingsProgressScreen.kt | 102 +++++++++++------- .../to/bitkit/viewmodels/SwapsViewModel.kt | 27 +++-- .../to/bitkit/viewmodels/WalletViewModel.kt | 10 ++ app/src/main/res/values/strings.xml | 2 + .../bitkit/viewmodels/SwapsViewModelTest.kt | 15 ++- gradle/libs.versions.toml | 2 +- 6 files changed, 108 insertions(+), 50 deletions(-) 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 adbe09e86d..49fa85ef07 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 @@ -63,8 +63,12 @@ fun SavingsProgressScreen( LaunchedEffect(Unit) { when (transfer.savingsTransferMode.value) { // The swap itself is owned by the viewmodel so it survives leaving this screen; - // the outcome arrives via savingsSwapResult below. - SavingsTransferMode.SWAP -> transfer.startSavingsSwap() + // 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 confirms. + SavingsTransferMode.SWAP -> { + wallet.ensureSwapUpdatesRunning() + transfer.startSavingsSwap() + } SavingsTransferMode.CLOSE -> runChannelClose( transfer = transfer, @@ -84,15 +88,21 @@ fun SavingsProgressScreen( } } - // A pending claim counts as success: the hold invoice is paid and the updates stream - // auto-broadcasts the claim once the lockup confirms. LaunchedEffect(swapResult) { when (val result = swapResult) { - is SavingsSwapResult.Success, SavingsSwapResult.Pending -> { + 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 confirms, 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, @@ -156,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", " ") @@ -177,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, @@ -250,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 @@ -262,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/viewmodels/SwapsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SwapsViewModel.kt index 47bee4a0fd..9622253863 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SwapsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SwapsViewModel.kt @@ -73,18 +73,29 @@ class SwapsViewModel @Inject constructor( } /** - * Whether a manual claim can succeed for this swap: reverse direction, lockup funds visible - * on-chain (mempool or confirmed), and no claim broadcast yet. Freshly created, expired, - * failed, and refunded swaps have nothing to claim. + * 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.TransactionMempool, - BoltzSwapStatus.TransactionConfirmed, - BoltzSwapStatus.TransactionClaimPending, - -> true + BoltzSwapStatus.InvoiceExpired, + BoltzSwapStatus.InvoiceFailedToPay, + BoltzSwapStatus.InvoiceSettled, + BoltzSwapStatus.SwapExpired, + BoltzSwapStatus.TransactionClaimed, + BoltzSwapStatus.TransactionFailed, + BoltzSwapStatus.TransactionLockupFailed, + BoltzSwapStatus.TransactionRefunded, + -> false - else -> false + else -> true } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 6a9251b537..205f53686d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -334,6 +334,16 @@ class WalletViewModel @Inject constructor( private var swapEventsCollected = false + /** + * Ensure the swap updates stream is running so a swap created now is tracked and auto-claimed. + * Restarting re-subscribes and reconciles every pending swap in bitkit-core, so calling this as + * a swap begins closes the window where the stream was down when the swap was created. + */ + fun ensureSwapUpdatesRunning() { + collectSwapEventsOnce() + viewModelScope.launch { startSwapUpdates() } + } + /** * Open the swap updates stream so any pending LN -> onchain swaps resume and auto-claim * once their lockup confirms. Uses the wallet's current fee rate for the claim tx. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 88000c7562..89060206b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -333,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/viewmodels/SwapsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SwapsViewModelTest.kt index 9d65b648b6..51f5dc2e91 100644 --- a/app/src/test/java/to/bitkit/viewmodels/SwapsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/SwapsViewModelTest.kt @@ -62,16 +62,27 @@ class SwapsViewModelTest : BaseUnitTest() { } @Test - fun `isClaimable only while the reverse swap lockup is on-chain and unclaimed`() { + 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) - assertFalse(swap(status = BoltzSwapStatus.SwapCreated).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, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 390cafd9b9..f1ac8dca02 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.5.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" } From d372cf5dc5194e4a20627f494c3f73cb71d57554 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 20 Jul 2026 09:11:59 -0400 Subject: [PATCH 8/9] fix: speed up savings swap claim flow --- .../java/to/bitkit/services/BoltzService.kt | 8 +++- .../screens/transfer/SavingsProgressScreen.kt | 4 +- .../to/bitkit/viewmodels/TransferViewModel.kt | 6 +-- .../to/bitkit/viewmodels/WalletViewModel.kt | 47 ++++++++++++------- .../java/to/bitkit/ui/WalletViewModelTest.kt | 4 ++ .../viewmodels/TransferViewModelTest.kt | 2 +- 6 files changed, 46 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/BoltzService.kt b/app/src/main/java/to/bitkit/services/BoltzService.kt index 20199f7c2c..3417321027 100644 --- a/app/src/main/java/to/bitkit/services/BoltzService.kt +++ b/app/src/main/java/to/bitkit/services/BoltzService.kt @@ -147,11 +147,14 @@ class BoltzService @Inject constructor( /** * Open the Boltz updates WebSocket, subscribe all pending swaps and auto-claim - * confirmed reverse swaps. [feeRateSatPerVb] is the rate used for those - * auto-claims (Bitkit owns fee estimation). Replaces any running stream. + * 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() @@ -161,6 +164,7 @@ class BoltzService @Inject constructor( mnemonic = mnemonic, bip39Passphrase = passphrase, feeRateSatPerVb = feeRateSatPerVb, + acceptZeroConf = acceptZeroConf, ) Logger.info("Started Boltz updates stream on $network", context = TAG) } 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 49fa85ef07..b5d7d85544 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 @@ -64,7 +64,7 @@ fun SavingsProgressScreen( 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 confirms. + // running first so the new swap is tracked and auto-claimed once its lockup appears. SavingsTransferMode.SWAP -> { wallet.ensureSwapUpdatesRunning() transfer.startSavingsSwap() @@ -96,7 +96,7 @@ fun SavingsProgressScreen( } // 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 confirms, so the transfer is + // 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() diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 7f1b16d7d8..9c702e257d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -1049,8 +1049,8 @@ class TransferViewModel @Inject constructor( /** * 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 once the lockup confirms, so a - * timeout here is not a failure; the swap completes in the background. + * 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 } @@ -1297,7 +1297,7 @@ class TransferViewModel @Inject constructor( 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 = 60.seconds + private val SWAP_CLAIM_TIMEOUT = 30.seconds /** Minimum sats held back from a swap to cover Lightning routing fees. */ private const val MIN_LN_ROUTING_FEE_RESERVE_SATS = 10L diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 205f53686d..361f21a014 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -70,11 +70,11 @@ class WalletViewModel @Inject constructor( private const val TAG = "WalletViewModel" private val TIMEOUT_RESTORE_WAIT = 30.seconds - /** Attempts to open the Boltz updates stream before giving up until the next wallet start. */ - private const val SWAP_UPDATES_START_ATTEMPTS = 3 - /** 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 @@ -320,8 +320,7 @@ class WalletViewModel @Inject constructor( if (_restoreState.value.isIdle()) { walletRepo.refreshBip21() } - collectSwapEventsOnce() - viewModelScope.launch { startSwapUpdates() } + ensureSwapUpdatesRunning() // checkForOrphanedChannelMonitorRecovery() } .onFailure { @@ -333,36 +332,48 @@ 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 a swap created now is tracked and auto-claimed. - * Restarting re-subscribes and reconciles every pending swap in bitkit-core, so calling this as - * a swap begins closes the window where the stream was down when the swap was created. + * 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() { collectSwapEventsOnce() - viewModelScope.launch { startSwapUpdates() } + 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 - * once their lockup confirms. Uses the wallet's current fee rate for the claim tx. - * Retries on failure: without the stream a paid swap has nothing to broadcast its claim. + * 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() { - repeat(SWAP_UPDATES_START_ATTEMPTS) { attempt -> + 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) + boltzService.startUpdates(feeRateSatPerVb = feeRate, acceptZeroConf = true) }.onFailure { Logger.warn("Failed to start swap updates, attempt '${attempt + 1}'", context = TAG) }.isSuccess - if (started) return - delay(SWAP_UPDATES_RETRY_DELAY * (attempt + 1)) + if (started) { + swapUpdatesRunning = true + return + } + attempt++ + delay((SWAP_UPDATES_RETRY_DELAY * attempt).coerceAtMost(SWAP_UPDATES_RETRY_CAP)) } - Logger.error("Gave up starting swap updates after '$SWAP_UPDATES_START_ATTEMPTS' attempts", context = TAG) } /** Refresh balances when a swap lands on-chain so savings reflect it without a manual sync. */ @@ -396,6 +407,8 @@ class WalletViewModel @Inject constructor( fun stop() { if (!walletExists) return + swapUpdatesJob?.cancel() + swapUpdatesRunning = false viewModelScope.launch(bgDispatcher) { stopSwapUpdates() lightningRepo.stop() diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 5c7832e055..5c457f393f 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -4,6 +4,7 @@ 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 @@ -16,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 @@ -68,6 +70,8 @@ class WalletViewModelTest : BaseUnitTest() { 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, diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index c10877d7a9..f3e49d32ab 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1079,7 +1079,7 @@ class TransferViewModelTest : BaseUnitTest() { sut.startSavingsSwap() runCurrent() - advanceTimeBy(61.seconds) + advanceTimeBy(31.seconds) advanceUntilIdle() assertEquals(SavingsSwapResult.Pending, sut.savingsSwapResult.value) From cd6499ac6b6ce82fee0834327d07ad24d0b9dd33 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 20 Jul 2026 11:26:54 -0400 Subject: [PATCH 9/9] fix: fall back to close when swap amount too low --- .../screens/transfer/SavingsConfirmScreen.kt | 23 ++++++++++++------- .../to/bitkit/viewmodels/TransferViewModel.kt | 10 ++++++-- .../viewmodels/TransferViewModelTest.kt | 5 ++-- 3 files changed, 26 insertions(+), 12 deletions(-) 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 6979387981..8848f26093 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 @@ -107,6 +107,7 @@ fun SavingsConfirmScreen( quote = swapState.quote, isQuoteLoading = swapState.isLoading, quoteError = swapState.error, + amountTooLow = swapState.amountTooLow, minSat = swapState.minSat, maxSat = swapState.maxSat, onAmountChange = { transfer.onSwapAmountChange(it.toULong()) }, @@ -147,6 +148,7 @@ private fun SavingsConfirmContent( quote: SavingsSwapQuote?, isQuoteLoading: Boolean, quoteError: String?, + amountTooLow: Boolean, minSat: ULong, maxSat: ULong, onAmountChange: (Long) -> Unit, @@ -271,26 +273,30 @@ private fun SavingsConfirmContent( } // Swapping funds out is the default; it only fires once the fee quote is ready. + // Below the swap minimum we revert to the pre-swap behaviour: the swipe closes the + // channel and the extra "close instead" action is hidden. var isLoading by remember { mutableStateOf(false) } SwipeToConfirm( text = stringResource(R.string.lightning__transfer__swipe), loading = isLoading || (quote == null && isQuoteLoading), color = Colors.Brand, onConfirm = { - if (quote == null) return@SwipeToConfirm + if (!amountTooLow && quote == null) return@SwipeToConfirm scope.launch { isLoading = true delay(300) - onSwapConfirm() + if (amountTooLow) onCloseConfirm() else onSwapConfirm() } } ) - 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, - ) + if (!amountTooLow) { + 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)) } } @@ -310,6 +316,7 @@ private fun SavingsConfirmScreenPreview() { ), isQuoteLoading = false, quoteError = null, + amountTooLow = false, minSat = 25_000u, maxSat = 72_000u, onAmountChange = {}, diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 9c702e257d..2741820eea 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -961,7 +961,7 @@ class TransferViewModel @Inject constructor( fun loadSavingsSwapQuote(requestedSat: ULong) { savingsSwapQuoteJob?.cancel() savingsSwapQuoteJob = viewModelScope.launch { - _savingsSwapState.update { it.copy(isLoading = true, error = null) } + _savingsSwapState.update { it.copy(isLoading = true, error = null, amountTooLow = false) } awaitNodeRunning() val limits = runSuspendCatching { boltzService.reverseLimits() }.getOrElse { e -> @@ -982,6 +982,8 @@ class TransferViewModel @Inject constructor( 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( @@ -989,7 +991,8 @@ class TransferViewModel @Inject constructor( quote = null, minSat = 0uL, maxSat = 0uL, - error = context.getString(R.string.lightning__savings_confirm__amount_too_low), + error = null, + amountTooLow = true, ) } return@launch @@ -1001,6 +1004,7 @@ class TransferViewModel @Inject constructor( it.copy( isLoading = false, error = null, + amountTooLow = false, minSat = minSat, maxSat = maxSat, quote = buildQuote(maxSat, limits), @@ -1377,6 +1381,8 @@ data class SavingsSwapUiState( val minSat: ULong = 0uL, val maxSat: ULong = 0uL, val error: String? = null, + /** Swap amount is below the swap minimum; the screen falls back to closing the channel. */ + val amountTooLow: Boolean = false, ) sealed interface SavingsSwapResult { diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index f3e49d32ab..41dda28e94 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -999,7 +999,7 @@ class TransferViewModelTest : BaseUnitTest() { } @Test - fun `loadSavingsSwapQuote reports amount too low when below the swap minimum`() = test { + fun `loadSavingsSwapQuote flags amount too low 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()) @@ -1010,7 +1010,8 @@ class TransferViewModelTest : BaseUnitTest() { val state = sut.savingsSwapState.value assertNull(state.quote) assertEquals(0uL, state.maxSat) - assertEquals(TOO_LOW, state.error) + assertNull(state.error) + assertTrue(state.amountTooLow) sut.startSavingsSwap() advanceUntilIdle()