diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 3caaf57c1..a492771ac 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -2,6 +2,8 @@ package to.bitkit.repositories import androidx.compose.runtime.Immutable import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.LegacyRnCloseRecoveryScanResult +import com.synonym.bitkitcore.LegacyRnCloseRecoverySweepPreview import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.Scanner import kotlinx.collections.immutable.ImmutableList @@ -114,6 +116,77 @@ class WalletRepo @Inject constructor( } } + suspend fun scanLegacyRnNativeSegwitRecoveryFunds( + indexLimit: UInt, + ): Result = withContext(bgDispatcher) { + runCatching { + val (mnemonic, passphrase) = recoveryWalletCredentials() + val electrumUrl = settingsStore.data.first().electrumServer + + coreService.onchain.scanLegacyRnNativeSegwitRecoveryFunds( + mnemonicPhrase = mnemonic, + network = Env.network, + electrumUrl = electrumUrl, + indexLimit = indexLimit, + bip39Passphrase = passphrase, + ) + }.onFailure { + Logger.error("Legacy RN recovery scan failed", it, context = TAG) + } + } + + suspend fun prepareLegacyRnNativeSegwitRecoverySweep( + indexLimit: UInt, + feeRateSatsPerVbyte: UInt?, + ): Result = withContext(bgDispatcher) { + runCatching { + val (mnemonic, passphrase) = recoveryWalletCredentials() + val electrumUrl = settingsStore.data.first().electrumServer + val destinationAddress = recoverySweepDestinationAddress() + + coreService.onchain.prepareLegacyRnNativeSegwitRecoverySweep( + mnemonicPhrase = mnemonic, + network = Env.network, + electrumUrl = electrumUrl, + destinationAddress = destinationAddress, + feeRateSatsPerVbyte = feeRateSatsPerVbyte, + indexLimit = indexLimit, + bip39Passphrase = passphrase, + ) + }.onFailure { + Logger.error("Legacy RN recovery sweep prepare failed", it, context = TAG) + } + } + + suspend fun broadcastLegacyRnNativeSegwitRecoverySweep(txHex: String): Result = withContext(bgDispatcher) { + runCatching { + val electrumUrl = settingsStore.data.first().electrumServer + val txid = coreService.onchain.broadcastRawTx(serializedTx = txHex, electrumUrl = electrumUrl) + syncNodeAndWallet(SyncSource.MANUAL).onFailure { + Logger.warn("Legacy RN recovery post-broadcast sync failed", it, context = TAG) + } + txid + }.onFailure { + Logger.error("Legacy RN recovery sweep broadcast failed", it, context = TAG) + } + } + + private fun recoveryWalletCredentials(): Pair { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw ServiceError.MnemonicNotFound() + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + return mnemonic to passphrase + } + + private suspend fun recoverySweepDestinationAddress(): String { + val currentAddress = getOnchainAddress() + if (currentAddress.isNotBlank()) return currentAddress + + return newAddress().getOrThrow().also { + require(it.isNotBlank()) { "Destination address unavailable" } + } + } + suspend fun refreshBip21(): Result = withContext(bgDispatcher) { Logger.debug("Refreshing bip21", context = TAG) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index a9a8cd23d..7003bc5e2 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -16,6 +16,8 @@ import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IcJitEntry +import com.synonym.bitkitcore.LegacyRnCloseRecoveryScanResult +import com.synonym.bitkitcore.LegacyRnCloseRecoverySweepPreview import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState @@ -40,10 +42,13 @@ import com.synonym.bitkitcore.getOrders import com.synonym.bitkitcore.getTags import com.synonym.bitkitcore.initDb import com.synonym.bitkitcore.insertActivity +import com.synonym.bitkitcore.onchainBroadcastRawTx import com.synonym.bitkitcore.openChannel +import com.synonym.bitkitcore.prepareLegacyRnNativeSegwitRecoverySweep import com.synonym.bitkitcore.refreshActiveCjitEntries import com.synonym.bitkitcore.refreshActiveOrders import com.synonym.bitkitcore.removeTags +import com.synonym.bitkitcore.scanLegacyRnNativeSegwitRecoveryFunds import com.synonym.bitkitcore.updateActivity import com.synonym.bitkitcore.updateBlocktankUrl import com.synonym.bitkitcore.upsertActivities @@ -1810,6 +1815,56 @@ class OnchainService { } } + suspend fun scanLegacyRnNativeSegwitRecoveryFunds( + mnemonicPhrase: String, + network: Network?, + electrumUrl: String, + indexLimit: UInt, + bip39Passphrase: String?, + ): LegacyRnCloseRecoveryScanResult { + return ServiceQueue.CORE.background { + scanLegacyRnNativeSegwitRecoveryFunds( + mnemonicPhrase = mnemonicPhrase, + network = network?.toCoreNetwork(), + electrumUrl = electrumUrl, + indexLimit = indexLimit, + bip39Passphrase = bip39Passphrase, + ) + } + } + + @Suppress("LongParameterList") + suspend fun prepareLegacyRnNativeSegwitRecoverySweep( + mnemonicPhrase: String, + network: Network?, + electrumUrl: String, + destinationAddress: String, + feeRateSatsPerVbyte: UInt?, + indexLimit: UInt, + bip39Passphrase: String?, + ): LegacyRnCloseRecoverySweepPreview { + return ServiceQueue.CORE.background { + prepareLegacyRnNativeSegwitRecoverySweep( + mnemonicPhrase = mnemonicPhrase, + network = network?.toCoreNetwork(), + electrumUrl = electrumUrl, + destinationAddress = destinationAddress, + feeRateSatsPerVbyte = feeRateSatsPerVbyte, + indexLimit = indexLimit, + bip39Passphrase = bip39Passphrase, + ) + } + } + + suspend fun broadcastRawTx( + serializedTx: String, + electrumUrl: String, + ): String { + return ServiceQueue.CORE.background { + onchainBroadcastRawTx(serializedTx = serializedTx, electrumUrl = electrumUrl) + } + } + suspend fun derivePrivateKey( mnemonicPhrase: String, derivationPathStr: String?, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 167ea4f32..aa7d1424d 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -94,6 +94,7 @@ import to.bitkit.ui.screens.recovery.RecoveryModeScreen import to.bitkit.ui.screens.settings.DevSettingsScreen import to.bitkit.ui.screens.settings.FeeSettingsScreen import to.bitkit.ui.screens.settings.LdkDebugScreen +import to.bitkit.ui.screens.settings.LegacyRnRecoveryScreen import to.bitkit.ui.screens.settings.ProbingToolScreen import to.bitkit.ui.screens.settings.VssDebugScreen import to.bitkit.ui.screens.shop.ShopIntroScreen @@ -955,6 +956,9 @@ private fun NavGraphBuilder.settings( composableWithDefaultTransitions { DevSettingsScreen(navController) } + composableWithDefaultTransitions { + LegacyRnRecoveryScreen(navController) + } composableWithDefaultTransitions { TrezorScreen(navController) } @@ -1946,6 +1950,9 @@ sealed interface Routes { @Serializable data object DevSettings : Routes + @Serializable + data object LegacyRnRecovery : Routes + @Serializable data object LdkDebug : Routes 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 d370d4ae1..323f6e610 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 @@ -68,6 +68,9 @@ fun DevSettingsScreen( SettingsButtonRow("VSS") { navController.navigateTo(Routes.VssDebug) } SettingsButtonRow("Probing Tool") { navController.navigateTo(Routes.ProbingTool) } + SectionHeader("RECOVERY") + SettingsButtonRow("Legacy Close Recovery") { navController.navigateTo(Routes.LegacyRnRecovery) } + if (PaykitFeatureFlags.isUiAvailable) { SectionHeader("PAYKIT") SettingsSwitchRow( diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/LegacyRnRecoveryScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/LegacyRnRecoveryScreen.kt new file mode 100644 index 000000000..414e59364 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/settings/LegacyRnRecoveryScreen.kt @@ -0,0 +1,334 @@ +package to.bitkit.ui.screens.settings + +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import to.bitkit.models.formatToModernDisplay +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.settings.SectionHeader +import to.bitkit.ui.components.settings.SettingsTextButtonRow +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.viewmodels.DevSettingsViewModel +import to.bitkit.viewmodels.LegacyRnRecoveryUiState + +@Composable +fun LegacyRnRecoveryScreen( + navController: NavController, + viewModel: DevSettingsViewModel = hiltViewModel(), +) { + val state by viewModel.legacyRnRecoveryState.collectAsStateWithLifecycle() + + LegacyRnRecoveryContent( + state = state, + onBackClick = { navController.popBackStack() }, + onDone = { navController.popBackStack() }, + onIndexLimitChange = viewModel::setLegacyRnRecoveryIndexLimit, + onScan = viewModel::scanLegacyRnRecovery, + onPrepare = viewModel::prepareLegacyRnRecoverySweep, + onBroadcast = viewModel::broadcastLegacyRnRecoverySweep, + onScanAgain = viewModel::scanLegacyRnRecovery, + ) +} + +@Composable +private fun LegacyRnRecoveryContent( + state: LegacyRnRecoveryUiState, + onBackClick: () -> Unit, + onDone: () -> Unit, + onIndexLimitChange: (String) -> Unit, + onScan: () -> Unit, + onPrepare: () -> Unit, + onBroadcast: () -> Unit, + onScanAgain: () -> Unit, +) { + val broadcastTxid = state.broadcastTxid + val scanResult = state.scanResult + val isBusy = state.isScanning || state.isPreparing || state.isBroadcasting + val canScan = state.indexLimit.toUIntOrNull()?.let { it > 0u } == true && !isBusy + val hasResult = broadcastTxid != null || state.sweepPreview != null || scanResult != null + + ScreenColumn { + AppTopBar( + titleText = "Legacy Recovery", + onBackClick = onBackClick, + actions = { DrawerNavIcon() }, + ) + + if (broadcastTxid != null) { + SuccessPageContent( + state = state, + canScan = canScan, + txid = broadcastTxid, + onIndexLimitChange = onIndexLimitChange, + onScan = onScan, + onDone = onDone, + ) + } else { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + ScanContent( + state = state, + canScan = canScan, + showScanButton = !hasResult, + onIndexLimitChange = onIndexLimitChange, + onScan = onScan, + ) + + state.error?.let { error -> + StatusMessage(title = "ERROR", message = error, color = Colors.Red) + } + + when { + state.sweepPreview != null -> PreviewContent( + state = state, + isBusy = isBusy, + onBroadcast = onBroadcast, + onScanAgain = onScanAgain, + ) + + scanResult != null && scanResult.outputsCount == 0u -> NoFundsContent( + indexLimit = state.indexLimit, + isBusy = isBusy, + onScanAgain = onScanAgain, + ) + + scanResult != null -> FoundContent( + state = state, + isBusy = isBusy, + onPrepare = onPrepare, + onScanAgain = onScanAgain, + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } + } +} + +@Composable +private fun ScanContent( + state: LegacyRnRecoveryUiState, + canScan: Boolean, + showScanButton: Boolean, + onIndexLimitChange: (String) -> Unit, + onScan: () -> Unit, +) { + SectionHeader("SCAN") + BodyS( + text = "Scan for native SegWit outputs generated by the legacy channel-close path.", + color = Colors.White64, + ) + Spacer(modifier = Modifier.height(12.dp)) + TextInput( + value = state.indexLimit, + onValueChange = onIndexLimitChange, + placeholder = "10000", + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + + if (showScanButton) { + Spacer(modifier = Modifier.height(12.dp)) + PrimaryButton( + text = "Scan", + isLoading = state.isScanning, + enabled = canScan, + onClick = onScan, + ) + } +} + +@Composable +private fun SuccessPageContent( + state: LegacyRnRecoveryUiState, + canScan: Boolean, + txid: String, + onIndexLimitChange: (String) -> Unit, + onScan: () -> Unit, + onDone: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + ScanContent( + state = state, + canScan = canScan, + showScanButton = false, + onIndexLimitChange = onIndexLimitChange, + onScan = onScan, + ) + Spacer(modifier = Modifier.height(24.dp)) + SuccessContent(txid = txid) + Spacer(modifier = Modifier.height(24.dp)) + } + + PrimaryButton( + text = "Done", + onClick = onDone, + ) + Spacer(modifier = Modifier.height(32.dp)) + } +} + +@Composable +private fun FoundContent( + state: LegacyRnRecoveryUiState, + isBusy: Boolean, + onPrepare: () -> Unit, + onScanAgain: () -> Unit, +) { + val result = state.scanResult ?: return + + SectionHeader("FUNDS FOUND") + SettingsTextButtonRow(title = "Total", value = sats(result.totalAmount)) + SettingsTextButtonRow(title = "Outputs", value = result.outputsCount.toString()) + Spacer(modifier = Modifier.height(12.dp)) + PrimaryButton( + text = "Prepare Sweep", + isLoading = state.isPreparing, + enabled = !isBusy, + onClick = onPrepare, + ) + Spacer(modifier = Modifier.height(8.dp)) + SecondaryButton( + text = "Scan Again", + enabled = !isBusy, + onClick = onScanAgain, + ) +} + +@Composable +private fun NoFundsContent( + indexLimit: String, + isBusy: Boolean, + onScanAgain: () -> Unit, +) { + StatusMessage( + title = "NO FUNDS FOUND", + message = "No legacy native SegWit close outputs were found up to index $indexLimit.", + color = Colors.White64, + ) + Spacer(modifier = Modifier.height(12.dp)) + SecondaryButton( + text = "Scan Again", + enabled = !isBusy, + onClick = onScanAgain, + ) +} + +@Composable +private fun PreviewContent( + state: LegacyRnRecoveryUiState, + isBusy: Boolean, + onBroadcast: () -> Unit, + onScanAgain: () -> Unit, +) { + val preview = state.sweepPreview ?: return + + SectionHeader("CONFIRM SWEEP") + SettingsTextButtonRow(title = "Receive", value = sats(preview.amountAfterFees)) + SettingsTextButtonRow(title = "Network Fee", value = sats(preview.estimatedFee)) + SettingsTextButtonRow(title = "Inputs", value = preview.outputsCount.toString()) + SettingsTextButtonRow(title = "To", value = preview.destinationAddress.shortened()) + SettingsTextButtonRow(title = "Tx", value = preview.txid.shortened()) + Spacer(modifier = Modifier.height(12.dp)) + PrimaryButton( + text = "Broadcast Sweep", + isLoading = state.isBroadcasting, + enabled = !isBusy, + onClick = onBroadcast, + ) + Spacer(modifier = Modifier.height(8.dp)) + SecondaryButton( + text = "Scan Again", + enabled = !isBusy, + onClick = onScanAgain, + ) +} + +@Composable +private fun SuccessContent( + txid: String, +) { + SectionHeader("SWEEP COMPLETE") + SettingsTextButtonRow(title = "Tx", value = txid.shortened()) + BodyS( + text = "The sweep transaction was broadcast. The funds will appear after the wallet syncs " + + "and the transaction confirms.", + color = Colors.White64, + ) +} + +@Composable +private fun StatusMessage( + title: String, + message: String, + color: androidx.compose.ui.graphics.Color, +) { + SectionHeader(title) + BodyS( + text = message, + color = color, + maxLines = 6, + overflow = TextOverflow.Ellipsis, + ) +} + +private fun sats(value: ULong): String = "${value.formatToModernDisplay()} sats" + +private fun String.shortened(): String { + if (length <= 24) return this + return "${take(10)}...${takeLast(10)}" +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + LegacyRnRecoveryContent( + state = LegacyRnRecoveryUiState(), + onBackClick = {}, + onDone = {}, + onIndexLimitChange = {}, + onScan = {}, + onPrepare = {}, + onBroadcast = {}, + onScanAgain = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt index 30a5bd849..4d788524e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt @@ -5,9 +5,14 @@ import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.firebase.messaging.FirebaseMessaging +import com.synonym.bitkitcore.LegacyRnCloseRecoveryScanResult +import com.synonym.bitkitcore.LegacyRnCloseRecoverySweepPreview import com.synonym.bitkitcore.testNotification import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import to.bitkit.R @@ -19,6 +24,7 @@ import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.Toast +import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo @@ -28,6 +34,17 @@ import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject +data class LegacyRnRecoveryUiState( + val indexLimit: String = "10000", + val isScanning: Boolean = false, + val isPreparing: Boolean = false, + val isBroadcasting: Boolean = false, + val scanResult: LegacyRnCloseRecoveryScanResult? = null, + val sweepPreview: LegacyRnCloseRecoverySweepPreview? = null, + val broadcastTxid: String? = null, + val error: String? = null, +) + @Suppress("TooManyFunctions", "LongParameterList") @HiltViewModel class DevSettingsViewModel @Inject constructor( @@ -42,6 +59,98 @@ class DevSettingsViewModel @Inject constructor( private val blocktankRepo: BlocktankRepo, private val appDb: AppDb, ) : ViewModel() { + private val _legacyRnRecoveryState = MutableStateFlow(LegacyRnRecoveryUiState()) + val legacyRnRecoveryState = _legacyRnRecoveryState.asStateFlow() + + fun setLegacyRnRecoveryIndexLimit(value: String) { + val filtered = value.filter { it.isDigit() } + _legacyRnRecoveryState.update { + it.copy( + indexLimit = filtered, + scanResult = null, + sweepPreview = null, + broadcastTxid = null, + error = null, + ) + } + } + + fun scanLegacyRnRecovery() = viewModelScope.launch { + val indexLimit = legacyRnRecoveryIndexLimitOrNull() ?: return@launch + + _legacyRnRecoveryState.update { + it.copy( + isScanning = true, + scanResult = null, + sweepPreview = null, + broadcastTxid = null, + error = null, + ) + } + + walletRepo.scanLegacyRnNativeSegwitRecoveryFunds(indexLimit) + .onSuccess { result -> + _legacyRnRecoveryState.update { it.copy(scanResult = result) } + } + .onFailure { error -> + _legacyRnRecoveryState.update { it.copy(error = error.recoveryMessage()) } + } + + _legacyRnRecoveryState.update { it.copy(isScanning = false) } + } + + fun prepareLegacyRnRecoverySweep() = viewModelScope.launch { + val indexLimit = legacyRnRecoveryIndexLimitOrNull() ?: return@launch + + _legacyRnRecoveryState.update { + it.copy( + isPreparing = true, + sweepPreview = null, + broadcastTxid = null, + error = null, + ) + } + + val feeRate = lightningRepo.getFeeRateForSpeed(TransactionSpeed.default()).getOrNull()?.toUInt() + walletRepo.prepareLegacyRnNativeSegwitRecoverySweep( + indexLimit = indexLimit, + feeRateSatsPerVbyte = feeRate, + ).onSuccess { preview -> + _legacyRnRecoveryState.update { it.copy(sweepPreview = preview) } + }.onFailure { error -> + _legacyRnRecoveryState.update { it.copy(error = error.recoveryMessage()) } + } + + _legacyRnRecoveryState.update { it.copy(isPreparing = false) } + } + + fun broadcastLegacyRnRecoverySweep() = viewModelScope.launch { + val preview = _legacyRnRecoveryState.value.sweepPreview ?: return@launch + + _legacyRnRecoveryState.update { it.copy(isBroadcasting = true, error = null) } + + walletRepo.broadcastLegacyRnNativeSegwitRecoverySweep(preview.txHex) + .onSuccess { txid -> + _legacyRnRecoveryState.update { it.copy(broadcastTxid = txid) } + ToastEventBus.send(type = Toast.ToastType.SUCCESS, title = "Sweep broadcast", description = txid) + } + .onFailure { error -> + _legacyRnRecoveryState.update { it.copy(error = error.recoveryMessage()) } + } + + _legacyRnRecoveryState.update { it.copy(isBroadcasting = false) } + } + + private fun legacyRnRecoveryIndexLimitOrNull(): UInt? { + val indexLimit = _legacyRnRecoveryState.value.indexLimit.toUIntOrNull() + if (indexLimit == null || indexLimit == 0u) { + _legacyRnRecoveryState.update { it.copy(error = "Enter a valid index limit.") } + return null + } + return indexLimit + } + + private fun Throwable.recoveryMessage(): String = localizedMessage ?: message ?: "Unknown error" fun openChannel() = viewModelScope.launch { val peer = lightningRepo.getPeers()?.firstOrNull() diff --git a/changelog.d/next/974.added.md b/changelog.d/next/974.added.md new file mode 100644 index 000000000..d87358cbd --- /dev/null +++ b/changelog.d/next/974.added.md @@ -0,0 +1 @@ +Added a Legacy Recovery option in developer settings to help recover funds from affected legacy channel closes. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f01b2d018..18c654b70 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.1.62" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.64" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc8" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }