From f47af2456c5061d6c77b8afb46a2a863b280e7d5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 15 Sep 2026 14:20:11 +0200 Subject: [PATCH 1/7] fix: vss client error recovery --- .../to/bitkit/data/backup/VssBackupClient.kt | 6 +-- .../bitkit/data/backup/VssBackupClientTest.kt | 50 +++++++++++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) 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..46c2dc592a 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,14 @@ 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 { runCatching { - if (isSetup.isCompleted && !isSetup.isCancelled) { - runCatching { isSetup.await() }.onSuccess { return@runCatching } - } + if (isSetup.isCompleted && !isSetup.isCancelled) return@runCatching val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw MnemonicNotAvailableException() @@ -68,6 +67,7 @@ class VssBackupClient @Inject constructor( } }.onFailure { isSetup.completeExceptionally(it) + isSetup = CompletableDeferred() Logger.error("VSS client setup error", it, context = TAG) } } 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" + } } From a9e08f82fa59552ffbbfc371f63b44d431811977 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 15 Sep 2026 13:57:47 +0200 Subject: [PATCH 2/7] fix: stop node before wiping keychain on reset --- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 4 ++++ .../main/java/to/bitkit/usecases/WipeWalletUseCase.kt | 6 ++---- .../test/java/to/bitkit/repositories/BackupRepoTest.kt | 10 ++++++++++ .../java/to/bitkit/usecases/WipeWalletUseCaseTest.kt | 2 +- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 7c2c5b699d..87f3f6456a 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -140,6 +140,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/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index ce9e32170e..77b52d9d8f 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -53,6 +53,7 @@ class WipeWalletUseCase @Inject constructor( return try { runSuspendCatching { backupRepo.reset() + lightningRepo.wipeStorage(walletIndex).getOrThrow() privatePaykitRepo.get().removePublishedEndpointsForCleanup(TAG) pubkyRepo.removeBitkitPaymentEndpoints() @@ -77,10 +78,7 @@ class WipeWalletUseCase @Inject constructor( resetWalletState() migrationService.markMigrationChecked() - - lightningRepo.wipeStorage(walletIndex) - .onSuccess { onSuccess() } - .getOrThrow() + onSuccess() }.onFailure { Logger.error("Failed to wipe wallet", it, context = TAG) } 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/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 54ab73af9b..3c24625730 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -115,6 +115,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { ) inOrder.verify(backupRepo).setWiping(true) inOrder.verify(backupRepo).reset() + inOrder.verify(lightningRepo).wipeStorage(0) inOrder.verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any()) inOrder.verify(pubkyRepo).removeBitkitPaymentEndpoints() inOrder.verify(privatePaykitRepo).closeAndClear() @@ -131,7 +132,6 @@ class WipeWalletUseCaseTest : BaseUnitTest() { inOrder.verify(activityRepo).resetState() inOrder.verify(hwWalletRepo).resetState() assertTrue(onWipeCalled) - inOrder.verify(lightningRepo).wipeStorage(0) assertTrue(onSetWalletExistsStateCalled) inOrder.verify(backupRepo).setWiping(false) } From 2b5431eaeca5073a884e9a779c02558e5a6630d2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 15 Sep 2026 16:10:11 +0200 Subject: [PATCH 3/7] fix: harden wallet wipe and ldk vss client recovery --- .../bitkit/data/backup/VssBackupClientLdk.kt | 6 +- .../java/to/bitkit/repositories/BackupRepo.kt | 1 + .../settings/backups/ResetAndRestoreScreen.kt | 18 +++++- .../to/bitkit/usecases/WipeWalletUseCase.kt | 13 ++++- .../to/bitkit/viewmodels/WalletViewModel.kt | 1 + .../data/backup/VssBackupClientLdkTest.kt | 55 +++++++++++++++++++ .../bitkit/usecases/WipeWalletUseCaseTest.kt | 45 ++++++++++++++- 7 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/to/bitkit/data/backup/VssBackupClientLdkTest.kt 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..4f9c5c1fc1 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,14 @@ class VssBackupClientLdk @Inject constructor( ) } + @Volatile private var isSetup = CompletableDeferred() private val setupMutex = Mutex() suspend fun setup(walletIndex: Int = 0): Result = withContext(ioDispatcher) { setupMutex.withLock { runCatching { - if (isSetup.isCompleted && !isSetup.isCancelled) { - runCatching { isSetup.await() }.onSuccess { return@runCatching } - } + if (isSetup.isCompleted && !isSetup.isCancelled) return@runCatching val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw MnemonicNotAvailableException() @@ -69,6 +68,7 @@ class VssBackupClientLdk @Inject constructor( } }.onFailure { isSetup.completeExceptionally(it) + 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 87f3f6456a..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() 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 77b52d9d8f..352a52e4a5 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,16 +46,20 @@ 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 { runSuspendCatching { backupRepo.reset() lightningRepo.wipeStorage(walletIndex).getOrThrow() + keychain.wipe() privatePaykitRepo.get().removePublishedEndpointsForCleanup(TAG) pubkyRepo.removeBitkitPaymentEndpoints() @@ -61,7 +67,6 @@ class WipeWalletUseCase @Inject constructor( privatePaykitRepo.get().closeAndClear() privatePaykitAddressReservationRepo.clear() pubkyRepo.wipeLocalState() - keychain.wipe() firebaseMessaging.deleteToken() coreService.wipeData() @@ -81,9 +86,13 @@ class WipeWalletUseCase @Inject constructor( onSuccess() }.onFailure { Logger.error("Failed to wipe wallet", it, context = TAG) + if (lightningRepo.lightningState.value.nodeLifecycleState.isRunning()) { + backupRepo.startObservingBackups() + } } } finally { backupRepo.setWiping(false) + wipeMutex.unlock() } } @@ -91,3 +100,5 @@ class WipeWalletUseCase @Inject constructor( private const val TAG = "WipeWalletUseCase" } } + +class WipeAlreadyInProgress : AppError("Wallet wipe already in progress") diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index aa4a52d69b..ce5a116631 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -99,6 +99,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() 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/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 3c24625730..a957fc0ad4 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,7 @@ import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest import javax.inject.Provider import kotlin.test.assertFailsWith +import kotlin.test.assertIs import kotlin.test.assertTrue class WipeWalletUseCaseTest : BaseUnitTest() { @@ -60,6 +69,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { @Before fun setUp() { 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)) @@ -116,12 +126,12 @@ class WipeWalletUseCaseTest : BaseUnitTest() { inOrder.verify(backupRepo).setWiping(true) inOrder.verify(backupRepo).reset() inOrder.verify(lightningRepo).wipeStorage(0) + inOrder.verify(keychain).wipe() inOrder.verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any()) inOrder.verify(pubkyRepo).removeBitkitPaymentEndpoints() inOrder.verify(privatePaykitRepo).closeAndClear() inOrder.verify(privatePaykitAddressReservationRepo).clear() inOrder.verify(pubkyRepo).wipeLocalState() - inOrder.verify(keychain).wipe() inOrder.verify(coreService).wipeData() inOrder.verify(db).clearAllTables() inOrder.verify(settingsStore).reset() @@ -136,6 +146,39 @@ class WipeWalletUseCaseTest : BaseUnitTest() { inOrder.verify(backupRepo).setWiping(false) } + @Test + fun `invoke should reject a second wipe while one is in flight`() = runTest { + val release = CompletableDeferred() + whenever { lightningRepo.wipeStorage(0) }.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).wipeStorage(0) + } + + @Test + fun `invoke should restart backup observers when wipe fails while node is running`() = runTest { + whenever(lightningRepo.lightningState) + .thenReturn(MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running))) + whenever { lightningRepo.wipeStorage(0) }.thenReturn(Result.failure(RuntimeException("stop failed"))) + + val result = sut.invoke(resetWalletState = {}, onSuccess = {}) + + assertTrue(result.isFailure) + verify(backupRepo).startObservingBackups() + verify(keychain, never()).wipe() + } + @Test fun `invoke should pass walletIndex to lightningRepo wipeStorage`() = runTest { val walletIndex = 5 From 43449afd677b85ae0edae0e3be14750d2e013ad1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 15 Sep 2026 16:27:02 +0200 Subject: [PATCH 4/7] fix: wipe wallet in phases so reset never half-completes --- .../to/bitkit/usecases/WipeWalletUseCase.kt | 79 +++++++++++-------- .../bitkit/usecases/WipeWalletUseCaseTest.kt | 62 +++++++++++---- 2 files changed, 93 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index 352a52e4a5..792912aa22 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -55,45 +55,58 @@ class WipeWalletUseCase @Inject constructor( ): Result { if (!wipeMutex.tryLock()) return Result.failure(WipeAlreadyInProgress()) backupRepo.setWiping(true) - return try { - runSuspendCatching { - backupRepo.reset() - lightningRepo.wipeStorage(walletIndex).getOrThrow() - keychain.wipe() - - 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() - firebaseMessaging.deleteToken() - - coreService.wipeData() - db.clearAllTables() - - settingsStore.reset() - cacheStore.reset() - watchOnlyAccountRepo.clear() - widgetsStore.reset() - - blocktankRepo.resetState() - activityRepo.resetState() - hwWalletRepo.resetState() - resetWalletState() - - migrationService.markMigrationChecked() + val result = try { + stopNode().map { + cleanupRemote() + wipeLocal(walletIndex, resetWalletState) onSuccess() - }.onFailure { - Logger.error("Failed to wipe wallet", it, context = TAG) - if (lightningRepo.lightningState.value.nodeLifecycleState.isRunning()) { - backupRepo.startObservingBackups() - } } } finally { 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) { + step("wipe LDK storage") { lightningRepo.wipeStorage(walletIndex) } + step("clear Paykit address reservations") { privatePaykitAddressReservationRepo.clear() } + step("wipe Pubky local state") { pubkyRepo.wipeLocalState() } + 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() } + } + + private suspend fun step(name: String, block: suspend () -> Any?) { + runSuspendCatching { block() } + .mapCatching { if (it is Result<*>) it.getOrThrow() } + .onFailure { Logger.warn("Failed wipe step '$name'", it, context = TAG) } } companion object { diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index a957fc0ad4..1879cc10c3 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -37,6 +37,7 @@ 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 @@ -68,6 +69,7 @@ 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)) @@ -125,13 +127,14 @@ class WipeWalletUseCaseTest : BaseUnitTest() { ) inOrder.verify(backupRepo).setWiping(true) inOrder.verify(backupRepo).reset() - inOrder.verify(lightningRepo).wipeStorage(0) - inOrder.verify(keychain).wipe() + 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() inOrder.verify(coreService).wipeData() inOrder.verify(db).clearAllTables() inOrder.verify(settingsStore).reset() @@ -149,7 +152,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { @Test fun `invoke should reject a second wipe while one is in flight`() = runTest { val release = CompletableDeferred() - whenever { lightningRepo.wipeStorage(0) }.doSuspendableAnswer { + whenever { lightningRepo.stop() }.doSuspendableAnswer { release.await() Result.success(Unit) } @@ -163,20 +166,43 @@ class WipeWalletUseCaseTest : BaseUnitTest() { release.complete(Unit) job.join() assertTrue(requireNotNull(first).isSuccess) - verify(lightningRepo).wipeStorage(0) + verify(lightningRepo).stop() } @Test - fun `invoke should restart backup observers when wipe fails while node is running`() = runTest { + 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.wipeStorage(0) }.thenReturn(Result.failure(RuntimeException("stop failed"))) + whenever { lightningRepo.stop() }.thenReturn(Result.failure(RuntimeException("stop failed"))) - val result = sut.invoke(resetWalletState = {}, onSuccess = {}) + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) assertTrue(result.isFailure) - verify(backupRepo).startObservingBackups() + 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 @@ -196,7 +222,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 }, @@ -259,7 +285,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { } @Test - fun `invoke should return failure when lightningRepo wipeStorage fails`() = runTest { + fun `invoke should complete the wipe when lightningRepo wipeStorage fails`() = runTest { val error = RuntimeException("Lightning wipe failed") whenever { lightningRepo.wipeStorage(0) }.thenReturn(Result.failure(error)) @@ -268,12 +294,15 @@ class WipeWalletUseCaseTest : BaseUnitTest() { onSuccess = { onSetWalletExistsStateCalled = true }, ) - assertTrue(result.isFailure) - verify(backupRepo).setWiping(false) + assertTrue(result.isSuccess) + verify(keychain).wipe() + verify(db).clearAllTables() + assertTrue(onWipeCalled) + assertTrue(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( @@ -281,7 +310,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) } } From ee283d43f2d454e342c8b44a87ff2a8e690b6521 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 15 Sep 2026 17:15:38 +0200 Subject: [PATCH 5/7] fix: gate wipe on ldk storage and capture vss setup deferred --- .../java/to/bitkit/data/backup/VssBackupClient.kt | 9 +++++---- .../to/bitkit/data/backup/VssBackupClientLdk.kt | 9 +++++---- .../java/to/bitkit/usecases/WipeWalletUseCase.kt | 10 ++++++---- .../to/bitkit/usecases/WipeWalletUseCaseTest.kt | 13 +++++++------ 4 files changed, 23 insertions(+), 18 deletions(-) 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 46c2dc592a..eef9af0b15 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt @@ -37,8 +37,9 @@ class VssBackupClient @Inject constructor( suspend fun setup(walletIndex: Int = 0): Result = withContext(ioDispatcher) { setupMutex.withLock { + val gate = isSetup runCatching { - if (isSetup.isCompleted && !isSetup.isCancelled) return@runCatching + if (gate.isCompleted && !gate.isCancelled) return@runCatching val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw MnemonicNotAvailableException() @@ -62,12 +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) - isSetup = CompletableDeferred() + 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 4f9c5c1fc1..1fa5296ac2 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt @@ -45,8 +45,9 @@ class VssBackupClientLdk @Inject constructor( suspend fun setup(walletIndex: Int = 0): Result = withContext(ioDispatcher) { setupMutex.withLock { + val gate = isSetup runCatching { - if (isSetup.isCompleted && !isSetup.isCancelled) return@runCatching + if (gate.isCompleted && !gate.isCancelled) return@runCatching val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw MnemonicNotAvailableException() @@ -63,12 +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) - isSetup = CompletableDeferred() + 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/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index 792912aa22..fae6f835f3 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -56,9 +56,10 @@ class WipeWalletUseCase @Inject constructor( if (!wipeMutex.tryLock()) return Result.failure(WipeAlreadyInProgress()) backupRepo.setWiping(true) val result = try { - stopNode().map { + runSuspendCatching { + stopNode().getOrThrow() cleanupRemote() - wipeLocal(walletIndex, resetWalletState) + wipeLocal(walletIndex, resetWalletState).getOrThrow() onSuccess() } } finally { @@ -84,8 +85,8 @@ class WipeWalletUseCase @Inject constructor( step("close Paykit SDK") { privatePaykitRepo.get().closeAndClear() } } - private suspend fun wipeLocal(walletIndex: Int, resetWalletState: () -> Unit) { - step("wipe LDK storage") { lightningRepo.wipeStorage(walletIndex) } + 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() } step("wipe keychain") { keychain.wipe() } @@ -101,6 +102,7 @@ class WipeWalletUseCase @Inject constructor( hwWalletRepo.resetState() resetWalletState() step("mark migration checked") { migrationService.markMigrationChecked() } + return Result.success(Unit) } private suspend fun step(name: String, block: suspend () -> Any?) { diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 1879cc10c3..c7cfdc9b11 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -285,7 +285,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { } @Test - fun `invoke should complete the wipe 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)) @@ -294,11 +294,12 @@ class WipeWalletUseCaseTest : BaseUnitTest() { onSuccess = { onSetWalletExistsStateCalled = true }, ) - assertTrue(result.isSuccess) - verify(keychain).wipe() - verify(db).clearAllTables() - assertTrue(onWipeCalled) - assertTrue(onSetWalletExistsStateCalled) + assertTrue(result.isFailure) + verify(privatePaykitRepo).closeAndClear() + verify(keychain, never()).wipe() + verify(db, never()).clearAllTables() + assertFalse(onWipeCalled) + assertFalse(onSetWalletExistsStateCalled) } @Test From ed82850afd3f156c7d49e7850c65ffaa3e692f1b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 15 Sep 2026 18:45:10 +0200 Subject: [PATCH 6/7] fix: stop a node object left alive by a failed start --- .../to/bitkit/repositories/LightningRepo.kt | 2 +- .../bitkit/repositories/LightningRepoTest.kt | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 7b4888f970..1b5c8186aa 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -615,7 +615,7 @@ class LightningRepo @Inject constructor( suspend fun stop(): Result = withContext(bgDispatcher) { lifecycleMutex.withLock { - if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping()) { + if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping() && lightningService.node == null) { clearProbeOutcomes() return@withLock Result.success(Unit) } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index b694f62fd0..8bba86bf36 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -416,6 +416,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() From effa097b706edf81e5437c5d66e917a4037c22e7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 15 Sep 2026 19:45:56 +0200 Subject: [PATCH 7/7] fix: hold lifecycle lock across node stop and storage wipe --- .../to/bitkit/repositories/LightningRepo.kt | 68 ++++++++++--------- .../bitkit/repositories/LightningRepoTest.kt | 22 ++++++ 2 files changed, 58 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 1b5c8186aa..9c66433a19 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -614,30 +614,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() && lightningService.node == null) { + 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 +812,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) } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 8bba86bf36..109c0a2d78 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 @@ -871,6 +872,27 @@ 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 `connectToTrustedPeers should fail when node is not running`() = test { val result = sut.connectToTrustedPeers()