diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt index d404555329..eef9af0b15 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt @@ -31,15 +31,15 @@ class VssBackupClient @Inject constructor( private val vssStoreIdProvider: VssStoreIdProvider, private val keychain: Keychain, ) { + @Volatile private var isSetup = CompletableDeferred() private val setupMutex = Mutex() suspend fun setup(walletIndex: Int = 0): Result = withContext(ioDispatcher) { setupMutex.withLock { + val gate = isSetup runCatching { - if (isSetup.isCompleted && !isSetup.isCancelled) { - runCatching { isSetup.await() }.onSuccess { return@runCatching } - } + if (gate.isCompleted && !gate.isCancelled) return@runCatching val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw MnemonicNotAvailableException() @@ -63,11 +63,12 @@ class VssBackupClient @Inject constructor( passphrase = passphrase, lnurlAuthServerUrl = lnurlAuthServerUrl, ) - isSetup.complete(Unit) + gate.complete(Unit) Logger.info("VSS client setup with server: '$vssUrl'", context = TAG) } }.onFailure { - isSetup.completeExceptionally(it) + gate.completeExceptionally(it) + if (isSetup === gate) isSetup = CompletableDeferred() Logger.error("VSS client setup error", it, context = TAG) } } diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt index 061723bea4..1fa5296ac2 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt @@ -39,15 +39,15 @@ class VssBackupClientLdk @Inject constructor( ) } + @Volatile private var isSetup = CompletableDeferred() private val setupMutex = Mutex() suspend fun setup(walletIndex: Int = 0): Result = withContext(ioDispatcher) { setupMutex.withLock { + val gate = isSetup runCatching { - if (isSetup.isCompleted && !isSetup.isCancelled) { - runCatching { isSetup.await() }.onSuccess { return@runCatching } - } + if (gate.isCompleted && !gate.isCancelled) return@runCatching val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw MnemonicNotAvailableException() @@ -64,11 +64,12 @@ class VssBackupClientLdk @Inject constructor( passphrase = passphrase, lnurlAuthServerUrl = Env.lnurlAuthServerUrl, ) - isSetup.complete(Unit) + gate.complete(Unit) Logger.info("VSS LDK client setup", context = TAG) } }.onFailure { - isSetup.completeExceptionally(it) + gate.completeExceptionally(it) + if (isSetup === gate) isSetup = CompletableDeferred() Logger.error("VSS LDK client setup error", it, context = TAG) } } diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 7c2c5b699d..f55770be20 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -122,6 +122,7 @@ class BackupRepo @Inject constructor( val isRestoring: StateFlow = _isRestoring.asStateFlow() private val _isWiping = MutableStateFlow(false) + val isWiping: StateFlow = _isWiping.asStateFlow() fun reset() { stopObservingBackups() @@ -140,6 +141,10 @@ class BackupRepo @Inject constructor( fun startObservingBackups() { if (isObserving) return + if (_isWiping.value) { + Logger.debug("Skipped observing backups while wiping", context = TAG) + return + } isObserving = true Logger.debug("Start observing backup statuses and data store changes", context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 7b4888f970..41fc0b3621 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -144,6 +144,9 @@ class LightningRepo @Inject constructor( private val _isRecoveryMode = MutableStateFlow(false) val isRecoveryMode = _isRecoveryMode.asStateFlow() + @Volatile + private var isWiping = false + private val channelCache = ConcurrentHashMap() private val probeOutcomeCache = ConcurrentHashMap() private val probeOutcomeSignal = MutableSharedFlow(extraBufferCapacity = 64) @@ -341,6 +344,7 @@ class LightningRepo @Inject constructor( var initialLifecycleState: NodeLifecycleState val result = lifecycleMutex.withLock { + if (isWiping) return@withLock Result.failure(WipeInProgressError()) initialLifecycleState = _lightningState.value.nodeLifecycleState if (initialLifecycleState.isRunningOrStarting()) { return@withLock skipStartForRunningNode( @@ -574,6 +578,8 @@ class LightningRepo @Inject constructor( fun setRecoveryMode(enabled: Boolean) = _isRecoveryMode.update { enabled } + fun setWiping(enabled: Boolean) = run { isWiping = enabled } + suspend fun updateGeoBlockState() = withContext(bgDispatcher) { _lightningState.update { it.copy(isGeoBlocked = coreService.isGeoBlocked()) @@ -614,30 +620,32 @@ class LightningRepo @Inject constructor( fun cancelPendingStop() = synchronized(pendingStopLock) { pendingStopJob.getAndSet(null)?.cancel() } suspend fun stop(): Result = withContext(bgDispatcher) { - lifecycleMutex.withLock { - if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping()) { + lifecycleMutex.withLock { stopLocked() } + } + + private suspend fun stopLocked(): Result { + if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping() && lightningService.node == null) { + clearProbeOutcomes() + return Result.success(Unit) + } + + return runCatching { + withContext(NonCancellable) { + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } + lightningService.stop() clearProbeOutcomes() - return@withLock Result.success(Unit) + _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } } - - runCatching { - withContext(NonCancellable) { - _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } - lightningService.stop() - clearProbeOutcomes() - _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } - } - }.onFailure { - Logger.error("Node stop error", it, context = TAG) - // On failure, check actual node state and update accordingly - // If node is still running, revert to Running state to allow retry - if (lightningService.node != null && lightningService.status?.isRunning == true) { - Logger.warn("Stop failed but node is still running, reverting to Running state", context = TAG) - _lightningState.update { s -> s.copy(nodeLifecycleState = NodeLifecycleState.Running) } - } else { - // Node appears stopped, update state - _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } - } + }.onFailure { + Logger.error("Node stop error", it, context = TAG) + // On failure, check actual node state and update accordingly + // If node is still running, revert to Running state to allow retry + if (lightningService.node != null && lightningService.status?.isRunning == true) { + Logger.warn("Stop failed but node is still running, reverting to Running state", context = TAG) + _lightningState.update { s -> s.copy(nodeLifecycleState = NodeLifecycleState.Running) } + } else { + // Node appears stopped, update state + _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } } } } @@ -810,17 +818,19 @@ class LightningRepo @Inject constructor( suspend fun wipeStorage(walletIndex: Int): Result = withContext(bgDispatcher) { Logger.debug("wipeStorage called, stopping node first", context = TAG) - stop().mapCatching { - Logger.debug("node stopped, calling wipeStorage", context = TAG) - lightningService.wipeStorage(walletIndex) - clearProbeOutcomes() - _lightningState.update { - LightningState( - nodeStatus = it.nodeStatus, - nodeLifecycleState = it.nodeLifecycleState, - ) + lifecycleMutex.withLock { + stopLocked().mapCatching { + Logger.debug("node stopped, calling wipeStorage", context = TAG) + lightningService.wipeStorage(walletIndex) + clearProbeOutcomes() + _lightningState.update { + LightningState( + nodeStatus = it.nodeStatus, + nodeLifecycleState = it.nodeLifecycleState, + ) + } + setRecoveryMode(false) } - setRecoveryMode(false) }.onFailure { Logger.error("wipeStorage error", it, context = TAG) } @@ -2117,6 +2127,8 @@ private data class PaymentRoutingRefreshStatus( } class RecoveryModeError : AppError("App in recovery mode, skipping node start") + +class WipeInProgressError : AppError("Wallet wipe in progress, refusing node start") class NodeSetupError : AppError("Unknown node setup error") class NodeStopTimeoutError : AppError("Timeout waiting for node to stop") class NodeConfigNotAppliedError : AppError("Node already running, requested config was not applied") diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 587f91aa07..2558834169 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -45,6 +45,7 @@ import to.bitkit.models.toDerivationPath import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase +import to.bitkit.usecases.WipeIncomplete import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger @@ -477,7 +478,9 @@ class WalletRepo @Inject constructor( walletIndex = walletIndex, resetWalletState = ::resetState, onSuccess = ::setWalletExistsState, - ) + ).onFailure { + if (it is WipeIncomplete) setWalletExistsState() + } } fun resetState() { diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt index fe901c1076..e23ffe5817 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ResetAndRestoreScreen.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.settings.backups +import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -23,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 androidx.navigation.NavController import to.bitkit.R import to.bitkit.ui.appViewModel @@ -45,12 +47,17 @@ fun ResetAndRestoreScreen( val app = appViewModel ?: return val wallet = walletViewModel ?: return var showDialog by remember { mutableStateOf(false) } + val isWiping by wallet.isWiping.collectAsStateWithLifecycle() Content( showConfirmDialog = showDialog, + isWiping = isWiping, onClickBackup = { app.showSheet(Sheet.Backup()) }, onClickReset = { showDialog = true }, - onResetConfirm = { wallet.wipeWallet() }, + onResetConfirm = { + showDialog = false + wallet.wipeWallet() + }, onResetDismiss = { showDialog = false }, onBack = { navController.popBackStack() }, ) @@ -59,16 +66,19 @@ fun ResetAndRestoreScreen( @Composable private fun Content( showConfirmDialog: Boolean, + isWiping: Boolean, onClickBackup: () -> Unit, onClickReset: () -> Unit, onResetConfirm: () -> Unit, onResetDismiss: () -> Unit, onBack: () -> Unit, ) { + BackHandler(enabled = isWiping) {} + ScreenColumn { AppTopBar( titleText = stringResource(R.string.security__reset_title), - onBackClick = onBack, + onBackClick = if (isWiping) null else onBack, actions = { DrawerNavIcon() }, ) Spacer(Modifier.height(32.dp)) @@ -101,6 +111,7 @@ private fun Content( SecondaryButton( text = stringResource(R.string.security__reset_button_backup), onClick = onClickBackup, + enabled = !isWiping, modifier = Modifier .weight(1f) .testTag(ResetAndRestoreTestTags.BACKUP_BUTTON) @@ -108,6 +119,7 @@ private fun Content( PrimaryButton( text = stringResource(R.string.security__reset_button_reset), onClick = onClickReset, + isLoading = isWiping, modifier = Modifier .weight(1f) .testTag(ResetAndRestoreTestTags.RESET_BUTTON) @@ -143,6 +155,7 @@ private fun Preview() { AppThemeSurface { Content( showConfirmDialog = false, + isWiping = false, onClickBackup = {}, onClickReset = {}, onResetConfirm = {}, @@ -158,6 +171,7 @@ private fun PreviewDialog() { AppThemeSurface { Content( showConfirmDialog = true, + isWiping = false, onClickBackup = {}, onClickReset = {}, onResetConfirm = {}, diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index ce9e32170e..215fc565d7 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -1,6 +1,7 @@ package to.bitkit.usecases import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.sync.Mutex import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore @@ -18,6 +19,7 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.services.CoreService import to.bitkit.services.MigrationService +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Provider @@ -44,52 +46,78 @@ class WipeWalletUseCase @Inject constructor( private val firebaseMessaging: FirebaseMessaging, private val migrationService: MigrationService, ) { + private val wipeMutex = Mutex() + suspend operator fun invoke( walletIndex: Int = 0, resetWalletState: () -> Unit, onSuccess: () -> Unit, ): Result { + if (!wipeMutex.tryLock()) return Result.failure(WipeAlreadyInProgress()) backupRepo.setWiping(true) - return try { + lightningRepo.setWiping(true) + val result = try { runSuspendCatching { - backupRepo.reset() - - privatePaykitRepo.get().removePublishedEndpointsForCleanup(TAG) - pubkyRepo.removeBitkitPaymentEndpoints() - .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } - privatePaykitRepo.get().closeAndClear() - privatePaykitAddressReservationRepo.clear() - pubkyRepo.wipeLocalState() - keychain.wipe() - firebaseMessaging.deleteToken() - - coreService.wipeData() - db.clearAllTables() - - settingsStore.reset() - cacheStore.reset() - watchOnlyAccountRepo.clear() - widgetsStore.reset() - - blocktankRepo.resetState() - activityRepo.resetState() - hwWalletRepo.resetState() - resetWalletState() - - migrationService.markMigrationChecked() - - lightningRepo.wipeStorage(walletIndex) - .onSuccess { onSuccess() } - .getOrThrow() - }.onFailure { - Logger.error("Failed to wipe wallet", it, context = TAG) + stopNode().getOrThrow() + cleanupRemote() + wipeLocal(walletIndex, resetWalletState).getOrThrow() + onSuccess() } } finally { + lightningRepo.setWiping(false) backupRepo.setWiping(false) + wipeMutex.unlock() + } + return result.onFailure { + Logger.error("Failed to wipe wallet", it, context = TAG) + if (lightningRepo.lightningState.value.nodeLifecycleState.isRunning()) { + backupRepo.startObservingBackups() + } } } + private suspend fun stopNode(): Result { + backupRepo.reset() + return lightningRepo.stop() + } + + private suspend fun cleanupRemote() { + step("remove Paykit published endpoints") { privatePaykitRepo.get().removePublishedEndpointsForCleanup(TAG) } + step("remove Bitkit payment endpoints") { pubkyRepo.removeBitkitPaymentEndpoints() } + step("close Paykit SDK") { privatePaykitRepo.get().closeAndClear() } + } + + private suspend fun wipeLocal(walletIndex: Int, resetWalletState: () -> Unit): Result { + lightningRepo.wipeStorage(walletIndex).onFailure { return Result.failure(it) } + step("clear Paykit address reservations") { privatePaykitAddressReservationRepo.clear() } + step("wipe Pubky local state") { pubkyRepo.wipeLocalState() } + val keychainWiped = step("wipe keychain") { keychain.wipe() } + step("delete FCM token") { firebaseMessaging.deleteToken() } + step("wipe core data") { coreService.wipeData() } + step("clear database") { db.clearAllTables() } + step("reset settings") { settingsStore.reset() } + step("reset cache") { cacheStore.reset() } + step("clear watch-only accounts") { watchOnlyAccountRepo.clear() } + step("reset widgets") { widgetsStore.reset() } + blocktankRepo.resetState() + activityRepo.resetState() + hwWalletRepo.resetState() + resetWalletState() + step("mark migration checked") { migrationService.markMigrationChecked() } + return if (keychainWiped) Result.success(Unit) else Result.failure(WipeIncomplete()) + } + + private suspend fun step(name: String, block: suspend () -> Any?): Boolean = + runSuspendCatching { block() } + .mapCatching { if (it is Result<*>) it.getOrThrow() } + .onFailure { Logger.warn("Failed wipe step '$name'", it, context = TAG) } + .isSuccess + companion object { private const val TAG = "WipeWalletUseCase" } } + +class WipeAlreadyInProgress : AppError("Wallet wipe already in progress") + +class WipeIncomplete : AppError("Wallet wipe did not complete, please reset again") diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index aa4a52d69b..1b3620319b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -41,6 +41,7 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.RecoveryModeError import to.bitkit.repositories.SyncSource import to.bitkit.repositories.WalletRepo +import to.bitkit.repositories.WipeInProgressError import to.bitkit.services.BoltzService import to.bitkit.services.MigrationService import to.bitkit.ui.onboarding.LOADING_MS @@ -99,6 +100,7 @@ class WalletViewModel @Inject constructor( val isShowingMigrationLoading: StateFlow = migrationService.isShowingMigrationLoading val isRestoringFromRNRemoteBackup: StateFlow = migrationService.isRestoringFromRNRemoteBackup + val isWiping: StateFlow = backupRepo.isWiping private val _restoreState = MutableStateFlow(RestoreState.Initial) val restoreState: StateFlow = _restoreState.asStateFlow() @@ -340,10 +342,12 @@ class WalletViewModel @Inject constructor( // checkForOrphanedChannelMonitorRecovery() } .onFailure { - Logger.error("Node startup error", it, context = TAG) - if (it !is RecoveryModeError) { - ToastEventBus.send(it) + if (it is RecoveryModeError || it is WipeInProgressError) { + Logger.debug("Skipped node start: '${it.message}'", context = TAG) + return@onFailure } + Logger.error("Node startup error", it, context = TAG) + ToastEventBus.send(it) } } diff --git a/app/src/test/java/to/bitkit/data/backup/VssBackupClientLdkTest.kt b/app/src/test/java/to/bitkit/data/backup/VssBackupClientLdkTest.kt new file mode 100644 index 0000000000..f80e27d0b0 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/backup/VssBackupClientLdkTest.kt @@ -0,0 +1,55 @@ +package to.bitkit.data.backup + +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class VssBackupClientLdkTest : BaseUnitTest() { + + private lateinit var sut: VssBackupClientLdk + + private val vssStoreIdProvider = mock() + private val keychain = mock() + + @Before + fun setUp() = runBlocking { + sut = VssBackupClientLdk( + ioDispatcher = testDispatcher, + vssStoreIdProvider = vssStoreIdProvider, + keychain = keychain, + ) + } + + @Test + fun `setup succeeding after a failure leaves the client usable`() = test { + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null) + assertIs(sut.setup().exceptionOrNull()) + + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(TEST_MNEMONIC) + whenever(vssStoreIdProvider.getVssStoreId(any())).thenReturn("test-store-id") + + mockStatic(Class.forName(VSS_FFI_CLASS)).use { + assertTrue(sut.setup().isSuccess) + + val result = sut.getObject("key") + + assertTrue(result.isSuccess) + assertNull(result.getOrNull()) + } + } + + companion object { + private const val VSS_FFI_CLASS = "com.synonym.vssclient.Vss_rust_client_ffiKt" + private const val TEST_MNEMONIC = "abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon abandon abandon about" + } +} diff --git a/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt b/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt index 2c4d00216f..1ab0912034 100644 --- a/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt +++ b/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt @@ -1,8 +1,11 @@ package to.bitkit.data.backup +import com.synonym.vssclient.VssItem +import com.synonym.vssclient.vssStore import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -10,7 +13,9 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue class VssBackupClientTest : BaseUnitTest() { @@ -50,9 +55,7 @@ class VssBackupClientTest : BaseUnitTest() { @Test fun `setup checks mnemonic before proceeding with vss initialization`() = test { - val testMnemonic = "abandon abandon abandon abandon abandon abandon " + - "abandon abandon abandon abandon abandon about" - whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(testMnemonic) + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(TEST_MNEMONIC) whenever(vssStoreIdProvider.getVssStoreId(any())).thenReturn("test-store-id") // Setup will fail on native VSS calls, but we verify we passed the mnemonic check @@ -70,4 +73,45 @@ class VssBackupClientTest : BaseUnitTest() { assertIs(sut.setup().exceptionOrNull()) assertIs(sut.setup().exceptionOrNull()) } + + @Test + fun `setup succeeding after a failure leaves the client usable`() = test { + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null) + assertIs(sut.setup().exceptionOrNull()) + + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(TEST_MNEMONIC) + whenever(vssStoreIdProvider.getVssStoreId(any())).thenReturn("test-store-id") + + mockStatic(Class.forName(VSS_FFI_CLASS)).use { + assertTrue(sut.setup().isSuccess) + + val result = sut.getObject("METADATA") + + assertTrue(result.isSuccess) + assertNull(result.getOrNull()) + } + } + + @Test + fun `setupWithRetry succeeding after a failure leaves the client usable`() = test { + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)) + .thenReturn(null) + .thenReturn(TEST_MNEMONIC) + whenever(vssStoreIdProvider.getVssStoreId(any())).thenReturn("test-store-id") + + mockStatic(Class.forName(VSS_FFI_CLASS)).use { + assertTrue(sut.setupWithRetry(baseDelayMs = 0L) {}.isSuccess) + + val item = VssItem("METADATA", byteArrayOf(), 1L) + whenever(vssStore(any(), any())).thenReturn(item) + + assertEquals(item, sut.putObject("METADATA", byteArrayOf()).getOrNull()) + } + } + + companion object { + private const val VSS_FFI_CLASS = "com.synonym.vssclient.Vss_rust_client_ffiKt" + private const val TEST_MNEMONIC = "abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon abandon abandon about" + } } diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 51ffa91440..7aa8fcf911 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -134,6 +134,16 @@ class BackupRepoTest : BaseUnitTest() { sut = createSut() } + @Test + fun `start observing is skipped while wiping`() = test { + sut.setWiping(true) + + sut.startObservingBackups() + runCurrent() + + verify(vssBackupClient, never()).setupWithRetry(any(), any(), any()) + } + @Test fun `full restore should fail when private Paykit reservations fail to restore`() = test { stubWalletBackup() diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index b694f62fd0..53d5756cfa 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.AddressTypeBalance @@ -416,6 +417,29 @@ class LightningRepoTest : BaseUnitTest() { } } + @Test + fun `stop tears down a node object left alive by a failed start`() = test { + whenever(lightningService.node).thenReturn(mock()) + whenever(lightningService.stop()).thenReturn(Unit) + assertEquals(NodeLifecycleState.Stopped, sut.lightningState.value.nodeLifecycleState) + + val result = sut.stop() + + assertTrue(result.isSuccess) + verify(lightningService).stop() + assertEquals(NodeLifecycleState.Stopped, sut.lightningState.value.nodeLifecycleState) + } + + @Test + fun `stop does not touch the service when nothing is running`() = test { + whenever(lightningService.node).thenReturn(null) + + val result = sut.stop() + + assertTrue(result.isSuccess) + verify(lightningService, never()).stop() + } + @Test fun `stopDebounced does not stop the node before the delay elapses`() = test { startNodeForTesting() @@ -848,6 +872,38 @@ class LightningRepoTest : BaseUnitTest() { verify(lightningService).wipeStorage(0) } + @Test + fun `wipeStorage holds the lifecycle lock so a start cannot rebuild the node mid-wipe`() = test { + startNodeForTesting() + whenever(lightningService.stop()).thenReturn(Unit) + val release = CompletableDeferred() + whenever(lightningService.wipeStorage(0)).doSuspendableAnswer { release.await() } + + val wipe = launch { sut.wipeStorage(0) } + runCurrent() + whenever(lightningService.node).thenReturn(null) + val start = launch { sut.start() } + runCurrent() + + verifyBlocking(lightningService, times(1)) { start(anyOrNull(), any()) } + release.complete(Unit) + wipe.join() + start.join() + verify(lightningService).wipeStorage(0) + verifyBlocking(lightningService, times(2)) { start(anyOrNull(), any()) } + } + + @Test + fun `start is refused while a wipe is in progress`() = test { + sut.setWiping(true) + + val result = sut.start() + + assertIs(result.exceptionOrNull()) + verifyBlocking(lightningService, never()) { start(anyOrNull(), any()) } + assertEquals(NodeLifecycleState.Stopped, sut.lightningState.value.nodeLifecycleState) + } + @Test fun `connectToTrustedPeers should fail when node is not running`() = test { val result = sut.connectToTrustedPeers() diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 4cbf4f91b3..f8a10668e1 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -34,6 +34,7 @@ import to.bitkit.services.CoreService import to.bitkit.services.OnchainService import to.bitkit.test.BaseUnitTest import to.bitkit.usecases.DeriveBalanceStateUseCase +import to.bitkit.usecases.WipeIncomplete import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.ServiceError import kotlin.test.assertEquals @@ -846,6 +847,17 @@ class WalletRepoTest : BaseUnitTest() { verify(wipeWalletUseCase).invoke(any(), any(), any()) } + @Test + fun `wipeWallet re-reads wallet existence when the wipe is incomplete`() = test { + whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(true) + whenever(wipeWalletUseCase.invoke(any(), any(), any())).thenReturn(Result.failure(WipeIncomplete())) + + val result = sut.wipeWallet() + + assertTrue(result.isFailure) + assertTrue(sut.walletState.value.walletExists) + } + @Test fun `wipeWallet should return failure when use case fails`() = test { whenever(wipeWalletUseCase.invoke(any(), any(), any())).thenReturn(Result.failure(error)) diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 54ab73af9b..10385b9708 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -2,12 +2,18 @@ package to.bitkit.usecases import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.AppDb @@ -15,11 +21,13 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.LightningState import to.bitkit.repositories.PrivatePaykitAddressReservationRepo import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo @@ -29,6 +37,8 @@ import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest import javax.inject.Provider import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue class WipeWalletUseCaseTest : BaseUnitTest() { @@ -59,7 +69,9 @@ class WipeWalletUseCaseTest : BaseUnitTest() { @Before fun setUp() { + whenever { lightningRepo.stop() }.thenReturn(Result.success(Unit)) whenever { lightningRepo.wipeStorage(0) }.thenReturn(Result.success(Unit)) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever { pubkyRepo.removeBitkitPaymentEndpoints() }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) @@ -114,10 +126,13 @@ class WipeWalletUseCaseTest : BaseUnitTest() { privatePaykitAddressReservationRepo, ) inOrder.verify(backupRepo).setWiping(true) + inOrder.verify(lightningRepo).setWiping(true) inOrder.verify(backupRepo).reset() + inOrder.verify(lightningRepo).stop() inOrder.verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any()) inOrder.verify(pubkyRepo).removeBitkitPaymentEndpoints() inOrder.verify(privatePaykitRepo).closeAndClear() + inOrder.verify(lightningRepo).wipeStorage(0) inOrder.verify(privatePaykitAddressReservationRepo).clear() inOrder.verify(pubkyRepo).wipeLocalState() inOrder.verify(keychain).wipe() @@ -131,11 +146,84 @@ class WipeWalletUseCaseTest : BaseUnitTest() { inOrder.verify(activityRepo).resetState() inOrder.verify(hwWalletRepo).resetState() assertTrue(onWipeCalled) - inOrder.verify(lightningRepo).wipeStorage(0) assertTrue(onSetWalletExistsStateCalled) + inOrder.verify(lightningRepo).setWiping(false) inOrder.verify(backupRepo).setWiping(false) } + @Test + fun `invoke should reject a second wipe while one is in flight`() = runTest { + val release = CompletableDeferred() + whenever { lightningRepo.stop() }.doSuspendableAnswer { + release.await() + Result.success(Unit) + } + var first: Result? = null + val job = launch { first = sut.invoke(resetWalletState = {}, onSuccess = {}) } + runCurrent() + + val second = sut.invoke(resetWalletState = {}, onSuccess = {}) + + assertIs(second.exceptionOrNull()) + release.complete(Unit) + job.join() + assertTrue(requireNotNull(first).isSuccess) + verify(lightningRepo).stop() + } + + @Test + fun `invoke should fail without wiping and restart observers when node stop fails while running`() = runTest { + whenever(lightningRepo.lightningState) + .thenReturn(MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running))) + whenever { lightningRepo.stop() }.thenReturn(Result.failure(RuntimeException("stop failed"))) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isFailure) + val inOrder = inOrder(backupRepo) + inOrder.verify(backupRepo).setWiping(false) + inOrder.verify(backupRepo).startObservingBackups() + verify(lightningRepo, never()).wipeStorage(any()) + verify(keychain, never()).wipe() + verify(db, never()).clearAllTables() + assertFalse(onWipeCalled) + assertFalse(onSetWalletExistsStateCalled) + } + + @Test + fun `invoke should continue when address reservation clear fails`() = runTest { + whenever { privatePaykitAddressReservationRepo.clear() }.thenThrow(RuntimeException("clear failed")) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isSuccess) + verify(keychain).wipe() + assertTrue(onSetWalletExistsStateCalled) + } + + @Test + fun `invoke should fail after wiping the rest when keychain wipe fails`() = runTest { + whenever(keychain.wipe()).thenThrow(RuntimeException("keystore write failed")) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertIs(result.exceptionOrNull()) + verify(lightningRepo).wipeStorage(0) + verify(db).clearAllTables() + verify(settingsStore).reset() + assertTrue(onWipeCalled) + assertFalse(onSetWalletExistsStateCalled) + } + @Test fun `invoke should pass walletIndex to lightningRepo wipeStorage`() = runTest { val walletIndex = 5 @@ -153,7 +241,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { @Test fun `invoke should set wiping to false even on failure`() = runTest { - whenever(keychain.wipe()).thenThrow(RuntimeException("Test error")) + whenever { lightningRepo.stop() }.thenReturn(Result.failure(RuntimeException("Test error"))) val result = sut.invoke( resetWalletState = { onWipeCalled = true }, @@ -216,7 +304,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { } @Test - fun `invoke should return failure when lightningRepo wipeStorage fails`() = runTest { + fun `invoke should fail before wiping local state when LDK storage wipe fails`() = runTest { val error = RuntimeException("Lightning wipe failed") whenever { lightningRepo.wipeStorage(0) }.thenReturn(Result.failure(error)) @@ -226,11 +314,15 @@ class WipeWalletUseCaseTest : BaseUnitTest() { ) assertTrue(result.isFailure) - verify(backupRepo).setWiping(false) + verify(privatePaykitRepo).closeAndClear() + verify(keychain, never()).wipe() + verify(db, never()).clearAllTables() + assertFalse(onWipeCalled) + assertFalse(onSetWalletExistsStateCalled) } @Test - fun `invoke should return failure when database clear fails`() = runTest { + fun `invoke should complete the wipe when database clear fails`() = runTest { whenever(db.clearAllTables()).thenThrow(RuntimeException("DB clear failed")) val result = sut.invoke( @@ -238,7 +330,10 @@ class WipeWalletUseCaseTest : BaseUnitTest() { onSuccess = { onSetWalletExistsStateCalled = true }, ) - assertTrue(result.isFailure) - verify(backupRepo).setWiping(false) + assertTrue(result.isSuccess) + verify(settingsStore).reset() + verify(keychain).wipe() + assertTrue(onWipeCalled) + assertTrue(onSetWalletExistsStateCalled) } }