From 05bbfc134b2a33eb3005490355932312df96ccc4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 27 May 2026 12:15:01 -0500 Subject: [PATCH 1/6] chore: bump bitkit core to v0.1.63 --- 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 3e2c02fda..11d3d58f0 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.58" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.63" } 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" } From a15c04575042cc662f5a5c41bcd5a26ca5bdc177 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 27 May 2026 12:36:13 -0500 Subject: [PATCH 2/6] feat: add legacy RN close recovery flow --- .../java/to/bitkit/repositories/WalletRepo.kt | 73 +++++++ .../java/to/bitkit/services/CoreService.kt | 55 +++++ app/src/main/java/to/bitkit/ui/ContentView.kt | 7 + .../ui/screens/settings/DevSettingsScreen.kt | 3 + .../settings/LegacyRnRecoveryScreen.kt | 200 ++++++++++++++++++ .../bitkit/viewmodels/DevSettingsViewModel.kt | 109 ++++++++++ 6 files changed, 447 insertions(+) create mode 100644 app/src/main/java/to/bitkit/ui/screens/settings/LegacyRnRecoveryScreen.kt 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..7bed836d9 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 RN 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..59b027b6d --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/settings/LegacyRnRecoveryScreen.kt @@ -0,0 +1,200 @@ +package to.bitkit.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.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.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() + 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 + + ScreenColumn { + AppTopBar( + titleText = "Legacy RN Recovery", + onBackClick = { navController.popBackStack() }, + actions = { DrawerNavIcon() }, + ) + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + SectionHeader("SCAN") + BodyS( + text = "Scan for native SegWit outputs generated by the legacy React Native channel-close path.", + color = Colors.White64, + ) + Spacer(modifier = Modifier.height(12.dp)) + TextInput( + value = state.indexLimit, + onValueChange = viewModel::setLegacyRnRecoveryIndexLimit, + placeholder = "10000", + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(12.dp)) + PrimaryButton( + text = "Scan", + isLoading = state.isScanning, + enabled = canScan, + onClick = viewModel::scanLegacyRnRecovery, + ) + + state.error?.let { error -> + StatusMessage(title = "ERROR", message = error, color = Colors.Red) + } + + when { + broadcastTxid != null -> SuccessContent( + txid = broadcastTxid, + onDone = { navController.popBackStack() }, + ) + + state.sweepPreview != null -> PreviewContent( + state = state, + isBusy = isBusy, + onBroadcast = viewModel::broadcastLegacyRnRecoverySweep, + onScanAgain = viewModel::scanLegacyRnRecovery, + ) + + scanResult != null && scanResult.outputsCount == 0u -> StatusMessage( + title = "NO FUNDS FOUND", + message = "No legacy RN native SegWit close outputs were found up to index ${state.indexLimit}.", + color = Colors.White64, + ) + + scanResult != null -> FoundContent( + state = state, + isBusy = isBusy, + onPrepare = viewModel::prepareLegacyRnRecoverySweep, + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@Composable +private fun FoundContent( + state: LegacyRnRecoveryUiState, + isBusy: Boolean, + onPrepare: () -> 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, + ) +} + +@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, + onDone: () -> Unit, +) { + 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, + ) + Spacer(modifier = Modifier.height(12.dp)) + PrimaryButton(text = "Done", onClick = onDone) +} + +@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)}" +} 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() From 07889ec094c00528820dbfd60cc841fe82dc2ce2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 27 May 2026 15:45:43 -0500 Subject: [PATCH 3/6] fix: polish legacy rn recovery actions --- .../settings/LegacyRnRecoveryScreen.kt | 197 +++++++++++++----- 1 file changed, 144 insertions(+), 53 deletions(-) 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 index 59b027b6d..8e43b95ec 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/LegacyRnRecoveryScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/LegacyRnRecoveryScreen.kt @@ -2,6 +2,7 @@ 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 @@ -41,6 +42,7 @@ fun LegacyRnRecoveryScreen( 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( @@ -49,65 +51,131 @@ fun LegacyRnRecoveryScreen( actions = { DrawerNavIcon() }, ) - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .verticalScroll(rememberScrollState()) - ) { - SectionHeader("SCAN") - BodyS( - text = "Scan for native SegWit outputs generated by the legacy React Native channel-close path.", - color = Colors.White64, - ) - Spacer(modifier = Modifier.height(12.dp)) - TextInput( - value = state.indexLimit, - onValueChange = viewModel::setLegacyRnRecoveryIndexLimit, - placeholder = "10000", - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(12.dp)) - PrimaryButton( - text = "Scan", - isLoading = state.isScanning, - enabled = canScan, - onClick = viewModel::scanLegacyRnRecovery, + if (broadcastTxid != null) { + SuccessPageContent( + state = state, + canScan = canScan, + txid = broadcastTxid, + onIndexLimitChange = viewModel::setLegacyRnRecoveryIndexLimit, + onScan = viewModel::scanLegacyRnRecovery, + onDone = { navController.popBackStack() }, ) + } else { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + ScanContent( + state = state, + canScan = canScan, + showScanButton = !hasResult, + onIndexLimitChange = viewModel::setLegacyRnRecoveryIndexLimit, + onScan = viewModel::scanLegacyRnRecovery, + ) - state.error?.let { error -> - StatusMessage(title = "ERROR", message = error, color = Colors.Red) - } + state.error?.let { error -> + StatusMessage(title = "ERROR", message = error, color = Colors.Red) + } - when { - broadcastTxid != null -> SuccessContent( - txid = broadcastTxid, - onDone = { navController.popBackStack() }, - ) + when { + state.sweepPreview != null -> PreviewContent( + state = state, + isBusy = isBusy, + onBroadcast = viewModel::broadcastLegacyRnRecoverySweep, + onScanAgain = viewModel::scanLegacyRnRecovery, + ) - state.sweepPreview != null -> PreviewContent( - state = state, - isBusy = isBusy, - onBroadcast = viewModel::broadcastLegacyRnRecoverySweep, - onScanAgain = viewModel::scanLegacyRnRecovery, - ) + scanResult != null && scanResult.outputsCount == 0u -> NoFundsContent( + indexLimit = state.indexLimit, + isBusy = isBusy, + onScanAgain = viewModel::scanLegacyRnRecovery, + ) - scanResult != null && scanResult.outputsCount == 0u -> StatusMessage( - title = "NO FUNDS FOUND", - message = "No legacy RN native SegWit close outputs were found up to index ${state.indexLimit}.", - color = Colors.White64, - ) + scanResult != null -> FoundContent( + state = state, + isBusy = isBusy, + onPrepare = viewModel::prepareLegacyRnRecoverySweep, + onScanAgain = viewModel::scanLegacyRnRecovery, + ) + } - scanResult != null -> FoundContent( - state = state, - isBusy = isBusy, - onPrepare = viewModel::prepareLegacyRnRecoverySweep, - ) + 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 React Native 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, + ) + } +} - Spacer(modifier = Modifier.height(32.dp)) +@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)) } } @@ -116,6 +184,7 @@ private fun FoundContent( state: LegacyRnRecoveryUiState, isBusy: Boolean, onPrepare: () -> Unit, + onScanAgain: () -> Unit, ) { val result = state.scanResult ?: return @@ -129,6 +198,31 @@ private fun FoundContent( 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 RN 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 @@ -164,7 +258,6 @@ private fun PreviewContent( @Composable private fun SuccessContent( txid: String, - onDone: () -> Unit, ) { SectionHeader("SWEEP COMPLETE") SettingsTextButtonRow(title = "Tx", value = txid.shortened()) @@ -173,8 +266,6 @@ private fun SuccessContent( "and the transaction confirms.", color = Colors.White64, ) - Spacer(modifier = Modifier.height(12.dp)) - PrimaryButton(text = "Done", onClick = onDone) } @Composable From 8851852a61564e237e5f997a9a586a35c0bdf7bd Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 28 May 2026 14:30:31 -0500 Subject: [PATCH 4/6] Remove RN wording from legacy recovery UI --- .../java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt | 2 +- .../to/bitkit/ui/screens/settings/LegacyRnRecoveryScreen.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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 7bed836d9..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 @@ -69,7 +69,7 @@ fun DevSettingsScreen( SettingsButtonRow("Probing Tool") { navController.navigateTo(Routes.ProbingTool) } SectionHeader("RECOVERY") - SettingsButtonRow("Legacy RN Close Recovery") { navController.navigateTo(Routes.LegacyRnRecovery) } + SettingsButtonRow("Legacy Close Recovery") { navController.navigateTo(Routes.LegacyRnRecovery) } if (PaykitFeatureFlags.isUiAvailable) { SectionHeader("PAYKIT") 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 index 8e43b95ec..55fb893f0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/LegacyRnRecoveryScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/LegacyRnRecoveryScreen.kt @@ -46,7 +46,7 @@ fun LegacyRnRecoveryScreen( ScreenColumn { AppTopBar( - titleText = "Legacy RN Recovery", + titleText = "Legacy Recovery", onBackClick = { navController.popBackStack() }, actions = { DrawerNavIcon() }, ) @@ -116,7 +116,7 @@ private fun ScanContent( ) { SectionHeader("SCAN") BodyS( - text = "Scan for native SegWit outputs generated by the legacy React Native channel-close path.", + text = "Scan for native SegWit outputs generated by the legacy channel-close path.", color = Colors.White64, ) Spacer(modifier = Modifier.height(12.dp)) @@ -214,7 +214,7 @@ private fun NoFundsContent( ) { StatusMessage( title = "NO FUNDS FOUND", - message = "No legacy RN native SegWit close outputs were found up to index $indexLimit.", + message = "No legacy native SegWit close outputs were found up to index $indexLimit.", color = Colors.White64, ) Spacer(modifier = Modifier.height(12.dp)) From 848a48b89cf4bf9329983eeee4358ef872d2cd78 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 28 May 2026 14:41:44 -0500 Subject: [PATCH 5/6] Add legacy recovery changelog --- changelog.d/next/974.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/974.added.md 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. From 4741e65ed9f7c3e71e1980eb2c4035599af11fa8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 29 May 2026 07:44:41 -0500 Subject: [PATCH 6/6] Address Android PR review comments --- .../java/to/bitkit/services/TrezorService.kt | 3 +- .../settings/LegacyRnRecoveryScreen.kt | 67 +++++++++++++++---- .../ui/screens/trezor/TrezorPreviewData.kt | 2 + gradle/libs.versions.toml | 2 +- 4 files changed, 60 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 6702dd408..defc6da79 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -18,6 +18,7 @@ import com.synonym.bitkitcore.TrezorSignMessageParams import com.synonym.bitkitcore.TrezorSignedMessageResponse import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TrezorVerifyMessageParams +import com.synonym.bitkitcore.WalletSelection import com.synonym.bitkitcore.onchainBroadcastRawTx import com.synonym.bitkitcore.onchainComposeTransaction import com.synonym.bitkitcore.onchainGetAccountInfo @@ -90,7 +91,7 @@ class TrezorService @Inject constructor( suspend fun connect(deviceId: String): TrezorFeatures { return ServiceQueue.CORE.background { - trezorConnect(deviceId = deviceId) + trezorConnect(deviceId = deviceId, selection = WalletSelection.Standard) } } 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 index 55fb893f0..414e59364 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/LegacyRnRecoveryScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/LegacyRnRecoveryScreen.kt @@ -14,6 +14,7 @@ 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 @@ -28,6 +29,7 @@ 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 @@ -38,6 +40,30 @@ fun LegacyRnRecoveryScreen( 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 @@ -47,7 +73,7 @@ fun LegacyRnRecoveryScreen( ScreenColumn { AppTopBar( titleText = "Legacy Recovery", - onBackClick = { navController.popBackStack() }, + onBackClick = onBackClick, actions = { DrawerNavIcon() }, ) @@ -56,9 +82,9 @@ fun LegacyRnRecoveryScreen( state = state, canScan = canScan, txid = broadcastTxid, - onIndexLimitChange = viewModel::setLegacyRnRecoveryIndexLimit, - onScan = viewModel::scanLegacyRnRecovery, - onDone = { navController.popBackStack() }, + onIndexLimitChange = onIndexLimitChange, + onScan = onScan, + onDone = onDone, ) } else { Column( @@ -70,8 +96,8 @@ fun LegacyRnRecoveryScreen( state = state, canScan = canScan, showScanButton = !hasResult, - onIndexLimitChange = viewModel::setLegacyRnRecoveryIndexLimit, - onScan = viewModel::scanLegacyRnRecovery, + onIndexLimitChange = onIndexLimitChange, + onScan = onScan, ) state.error?.let { error -> @@ -82,21 +108,21 @@ fun LegacyRnRecoveryScreen( state.sweepPreview != null -> PreviewContent( state = state, isBusy = isBusy, - onBroadcast = viewModel::broadcastLegacyRnRecoverySweep, - onScanAgain = viewModel::scanLegacyRnRecovery, + onBroadcast = onBroadcast, + onScanAgain = onScanAgain, ) scanResult != null && scanResult.outputsCount == 0u -> NoFundsContent( indexLimit = state.indexLimit, isBusy = isBusy, - onScanAgain = viewModel::scanLegacyRnRecovery, + onScanAgain = onScanAgain, ) scanResult != null -> FoundContent( state = state, isBusy = isBusy, - onPrepare = viewModel::prepareLegacyRnRecoverySweep, - onScanAgain = viewModel::scanLegacyRnRecovery, + onPrepare = onPrepare, + onScanAgain = onScanAgain, ) } @@ -126,7 +152,7 @@ private fun ScanContent( placeholder = "10000", singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() ) if (showScanButton) { @@ -289,3 +315,20 @@ 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/ui/screens/trezor/TrezorPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt index 23b2d0fdc..e78ad9942 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt @@ -37,6 +37,7 @@ internal object TrezorPreviewData { passphraseProtection = false, initialized = true, needsBackup = false, + passphraseEntryCapable = true, ) val sampleFeaturesMinimal = TrezorFeatures( @@ -51,6 +52,7 @@ internal object TrezorPreviewData { passphraseProtection = null, initialized = null, needsBackup = null, + passphraseEntryCapable = null, ) val sampleKnownDevice = KnownDevice( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 11d3d58f0..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.63" } +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" }