diff --git a/app/src/androidTest/java/to/bitkit/ui/onboarding/WalletConnectionRecoveryViewTest.kt b/app/src/androidTest/java/to/bitkit/ui/onboarding/WalletConnectionRecoveryViewTest.kt new file mode 100644 index 0000000000..6ef3fc3e52 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/onboarding/WalletConnectionRecoveryViewTest.kt @@ -0,0 +1,99 @@ +package to.bitkit.ui.onboarding + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import org.junit.Rule +import org.junit.Test +import to.bitkit.data.WalletConnectionRecovery +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.viewmodels.ConnectionRecoveryUiState +import kotlin.test.assertEquals + +@ComposeUi +class WalletConnectionRecoveryViewTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun requiredRestoreCanSaveConnectionsWithoutProceedingOrRetrying() { + var editing by mutableStateOf(false) + var state by mutableStateOf( + ConnectionRecoveryUiState( + owner = WalletConnectionRecovery.Owner(0, "test-wallet", 0), + electrum = "ssl://electrum.example:50002", + ) + ) + var retries = 0 + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + if (editing) { + WalletConnectionRecoveryView( + state = state, + onBack = { editing = false }, + onElectrum = { state = state.copy(electrum = it) }, + onRgs = { state = state.copy(rgs = it) }, + onResetElectrum = {}, + onResetRgs = {}, + onSave = { state = state.copy(saved = true) }, + onRetry = { retries++ }, + ) + } else { + WalletRestoreErrorView( + retryCount = 3, + allowProceedWithoutRestore = false, + onRetry = { retries++ }, + onConnectionSettings = { editing = true }, + onProceedWithoutRestore = { error("Required restoration must remain gated") }, + ) + } + } + } + } + composeTestRule.onNodeWithTag("ProceedWithoutBackupButton").assertDoesNotExist() + composeTestRule.onNodeWithTag("ConnectionRecoveryButton").performClick() + composeTestRule.onNodeWithTag("WalletConnectionRecovery").assertIsDisplayed() + composeTestRule.onNodeWithTag("RecoveryElectrumSave").performScrollTo().performClick() + composeTestRule.onNodeWithTag("RecoverySaved").performScrollTo().assertIsDisplayed() + composeTestRule.runOnIdle { assertEquals(0, retries) } + composeTestRule.onNodeWithTag("RecoveryRetry").performScrollTo().performClick() + composeTestRule.runOnIdle { assertEquals(1, retries) } + composeTestRule.onNodeWithTag("ProceedWithoutBackupButton").assertDoesNotExist() + } + + @Test + fun unavailableOwnerDisablesEditsButKeepsExplicitRetryReachable() { + var retries = 0 + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + WalletConnectionRecoveryView( + state = ConnectionRecoveryUiState(error = "Stored connection unavailable"), + onBack = {}, + onElectrum = { error("Unavailable owner must not edit") }, + onRgs = { error("Unavailable owner must not edit") }, + onResetElectrum = { error("Unavailable owner must not edit") }, + onResetRgs = { error("Unavailable owner must not edit") }, + onSave = { error("Unavailable owner must not save") }, + onRetry = { retries++ }, + ) + } + } + } + composeTestRule.onNodeWithTag("RecoveryElectrumSave").assertIsNotEnabled() + composeTestRule.onNodeWithTag("RecoveryRgsSave").assertIsNotEnabled() + composeTestRule.onNodeWithTag("RecoveryError").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithTag("RecoveryRetry").performScrollTo().performClick() + composeTestRule.runOnIdle { assertEquals(1, retries) } + } +} diff --git a/app/src/androidTest/java/to/bitkit/ui/onboarding/WalletRestoreErrorViewTest.kt b/app/src/androidTest/java/to/bitkit/ui/onboarding/WalletRestoreErrorViewTest.kt new file mode 100644 index 0000000000..73d0dd8915 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/onboarding/WalletRestoreErrorViewTest.kt @@ -0,0 +1,111 @@ +package to.bitkit.ui.onboarding + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import to.bitkit.R +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.viewmodels.ConnectionStartupFailure +import to.bitkit.viewmodels.RestoreState +import to.bitkit.viewmodels.canProceedWithoutRestore +import kotlin.test.assertEquals + +@ComposeUi +class WalletRestoreErrorViewTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun ordinaryStartupFailureUsesProductionGateAndDismissesOnlyAfterConfirmation() { + var failure by mutableStateOf(ConnectionStartupFailure("storage unavailable", true)) + var restore by mutableStateOf(RestoreState.Retry(0)) + var dismissed = 0 + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + if (failure != null || restore is RestoreState.BackupFailed) { + WalletRestoreErrorView( + retryCount = restore.retryCount(), + allowProceedWithoutRestore = canProceedWithoutRestore(restore, failure?.canProceedWithoutRestore), + onRetry = {}, + onProceedWithoutRestore = { + failure = null + restore = RestoreState.Settled + dismissed++ + }, + ) + } + } + } + } + composeTestRule.onNodeWithTag("ProceedWithoutBackupButton").assertDoesNotExist() + composeTestRule.runOnIdle { restore = RestoreState.Retry(1) } + composeTestRule.onNodeWithTag("ProceedWithoutBackupButton").assertDoesNotExist() + composeTestRule.runOnIdle { restore = RestoreState.Retry(2) } + composeTestRule.onNodeWithTag("ProceedWithoutBackupButton").assertIsDisplayed().performClick() + composeTestRule.onNodeWithTag("ProceedWithoutBackupDialog").assertIsDisplayed() + composeTestRule.runOnIdle { failure = failure?.copy(canProceedWithoutRestore = false) } + composeTestRule.onNodeWithTag("ProceedWithoutBackupDialog").assertDoesNotExist() + composeTestRule.onNodeWithTag("ProceedWithoutBackupButton").assertDoesNotExist() + composeTestRule.runOnIdle { failure = failure?.copy(canProceedWithoutRestore = true) } + composeTestRule.onNodeWithTag("ProceedWithoutBackupButton").performClick() + val confirm = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.common__yes_proceed) + composeTestRule.onNodeWithText(confirm).performClick() + composeTestRule.onNodeWithTag("ProceedWithoutBackupButton").assertDoesNotExist() + composeTestRule.onNodeWithTag("TryAgainButton").assertDoesNotExist() + composeTestRule.runOnIdle { assertEquals(1, dismissed) } + } + + @Test + fun pendingRecoveryOnlyOffersRetryAfterRepeatedFailures() { + var retries = 0 + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + WalletRestoreErrorView( + retryCount = 3, + allowProceedWithoutRestore = false, + onRetry = { retries++ }, + onProceedWithoutRestore = { error("Pending recovery must not be dismissed") }, + ) + } + } + } + composeTestRule.onNodeWithTag("ProceedWithoutBackupButton").assertDoesNotExist() + composeTestRule.onNodeWithTag("TryAgainButton").assertIsDisplayed().performClick() + composeTestRule.runOnIdle { assertEquals(1, retries) } + } + + @Test + fun pendingRecoveryRemovesAnAlreadyOpenProceedDialog() { + var allowed by mutableStateOf(true) + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + WalletRestoreErrorView( + retryCount = 3, + allowProceedWithoutRestore = allowed, + onRetry = {}, + onProceedWithoutRestore = { error("Pending recovery must not be dismissed") }, + ) + } + } + } + composeTestRule.onNodeWithTag("ProceedWithoutBackupButton").performClick() + composeTestRule.onNodeWithTag("ProceedWithoutBackupDialog").assertIsDisplayed() + composeTestRule.runOnIdle { allowed = false } + composeTestRule.onNodeWithTag("ProceedWithoutBackupDialog").assertDoesNotExist() + composeTestRule.onNodeWithTag("ProceedWithoutBackupButton").assertDoesNotExist() + } +} diff --git a/app/src/main/java/to/bitkit/data/WalletConnectionRecovery.kt b/app/src/main/java/to/bitkit/data/WalletConnectionRecovery.kt new file mode 100644 index 0000000000..b4b64f937b --- /dev/null +++ b/app/src/main/java/to/bitkit/data/WalletConnectionRecovery.kt @@ -0,0 +1,289 @@ +package to.bitkit.data + +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import to.bitkit.data.backup.VssStoreIdProvider +import to.bitkit.data.keychain.Keychain +import to.bitkit.data.keychain.hasPendingWalletRestore +import to.bitkit.env.Env +import to.bitkit.models.SettingsBackupV1 +import to.bitkit.utils.AppError +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException + +/** Local connection choices made while recovering a wallet, never included in a remote backup. */ +@Singleton +class WalletConnectionRecovery @Inject constructor( + private val keychain: Keychain, + private val db: AppDb, + private val storeIdProvider: VssStoreIdProvider, + private val settingsStore: SettingsStore, +) { + companion object { + internal const val KEY = "WALLET_CONNECTION_RECOVERY" + } + + private val stateLock = Any() + private val operationMutex = Mutex() + private val startupMutex = Mutex() + private val settingsMutex = Mutex() + private val lifecycleMutex = Mutex() + private var generation = 0L + private var wiping = false + private val jobs = mutableSetOf() + private val json = Json { encodeDefaults = true } + + class Owner internal constructor(val walletIndex: Int, internal val binding: String?, internal val generation: Long) + + suspend fun captureOwner(): Owner = captureOwner(bindToCredentials = true) + + internal suspend fun captureRuntimeOwner(): Owner = captureOwner(bindToCredentials = false) + + private suspend fun captureOwner(bindToCredentials: Boolean): Owner { + val capturedGeneration = admittedGeneration() + return lifecycleMutex.withLock { + checkGeneration(capturedGeneration) + val index = db.configDao().getAll().first().firstOrNull()?.walletIndex ?: 0L + require(index in 0..Int.MAX_VALUE.toLong()) + val binding = if (bindToCredentials) storeIdProvider.getBackupWalletBinding(index.toInt()) else null + Owner(index.toInt(), binding, capturedGeneration).also { checkOwner(it) } + } + } + + private fun admittedGeneration(): Long = synchronized(stateLock) { + checkAdmission() + generation + } + + private fun checkGeneration(expected: Long) = synchronized(stateLock) { + checkAdmission() + if (generation != expected) throw CancellationException("Wallet recovery owner changed") + } + + /** Callers pass this session to nested restore steps instead of reacquiring the operation mutex. */ + suspend fun withOperation(owner: Owner, block: suspend (Session) -> T): T = + withAdmission(owner, operationMutex, block) + + /** Startup must remain able to satisfy LDK-dependent restoration while a restore is admitted. */ + suspend fun withStartup(owner: Owner, block: suspend (Session) -> T): T = + withAdmission(owner, startupMutex, block) + + private suspend fun withAdmission( + owner: Owner, + mutex: Mutex, + block: suspend (Session) -> T, + ): T = coroutineScope { + val job = checkNotNull(currentCoroutineContext()[Job]) + synchronized(stateLock) { + checkAdmission() + if (owner.generation != generation) throw CancellationException("Wallet recovery owner changed") + jobs.add(job) + } + try { + mutex.withLock { + checkOwner(owner) + val session = Session(owner, job) + try { + block(session) + } finally { + session.active = false + } + } + } finally { + synchronized(stateLock) { jobs.remove(job) } + } + } + + /** Source selection must not add credential reads before the selected restore path arms its gate. */ + suspend fun withCurrentWallet(block: suspend (Session) -> T): T = + withOperation(captureRuntimeOwner(), block) + + /** Drains original owners before credentials, settings or the SDK wipe barrier can change. */ + suspend fun withIdentityChange( + isStillNeeded: suspend () -> Boolean = { true }, + block: suspend () -> T, + ): T { + val capturedGeneration = admittedGeneration() + return lifecycleMutex.withLock { + checkGeneration(capturedGeneration) + if (!isStillNeeded()) throw CancellationException("Wallet identity transition no longer needed") + try { + currentCoroutineContext().ensureActive() + beginWipe() + currentCoroutineContext().ensureActive() + block() + } finally { + storeIdProvider.clearCache() + endWipe() + } + } + } + + private suspend fun beginWipe() { + val pending = synchronized(stateLock) { + check(!wiping) { "Connection recovery wipe already active" } + wiping = true + generation += 1 + jobs.toList() + } + withContext(NonCancellable) { + pending.forEach { it.cancel(CancellationException("Wallet wipe started")) } + pending.joinAll() + } + } + + private fun endWipe() { + synchronized(stateLock) { wiping = false } + } + + inner class Session internal constructor(private val owner: Owner, private val job: Job) { + @Volatile + internal var active = true + + private suspend fun checkCurrent(checkCancellation: Boolean = true) { + if (checkCancellation) job.ensureActive() + check(active) { "Connection recovery operation ended" } + checkOwner(owner) + } + + /** Publish local UI state only while this original owner still holds admission. */ + internal suspend fun commitCurrentState(block: () -> T): T { + checkCurrent() + return synchronized(stateLock) { + job.ensureActive() + check(active) { "Connection recovery operation ended" } + checkAdmission() + if (owner.generation != generation) throw CancellationException("Wallet recovery owner changed") + block() + } + } + + private suspend fun read(): ConnectionChoices? { + checkCurrent() + val encoded = keychain.loadString(KEY, owner.walletIndex) ?: return null + val record = json.decodeFromString(encoded) + require(record.version == 1 && UUID.fromString(record.revision).toString() == record.revision) + val binding = owner.binding ?: storeIdProvider.getBackupWalletBinding(owner.walletIndex) + if (record.walletIndex != owner.walletIndex || record.binding != binding || + record.network != Env.network.name + ) { + return null + } + return record + } + + /** A present RGS choice with null value means the platform's absent default, not no override. */ + suspend fun save(electrum: String? = null, rgs: RgsChoice? = null): SettingsData = settingsMutex.withLock { + require(electrum != null || rgs != null) + checkCurrent() + val previous = read() + val record = ConnectionChoices( + walletIndex = owner.walletIndex, + binding = checkNotNull(owner.binding) { "Connection edits require a credential-bound owner" }, + network = Env.network.name, + revision = UUID.randomUUID().toString(), + electrum = electrum ?: previous?.electrum, + rgs = rgs ?: previous?.rgs, + ) + // Once the durable intent starts, view cancellation cannot interrupt its settings write. + // Wipe invalidates this owner, then drains the owning job before erasing either store. + withContext(NonCancellable) { + checkCurrent() + keychain.upsertString(KEY, json.encodeToString(record), owner.walletIndex) + checkCurrent(checkCancellation = false) + settingsStore.update { record.applyTo(it) } + checkCurrent(checkCancellation = false) + settingsStore.data.first().also { + require(record.matches(it)) + checkCurrent(checkCancellation = false) + } + } + } + + suspend fun restoreSettings(restored: SettingsBackupV1) = settingsMutex.withLock { + val choices = read() + settingsStore.restoreFromBackup( + restored.copy(settings = choices?.applyTo(restored.settings) ?: restored.settings), + ).getOrThrow() + checkCurrent() + } + + /** Required even when the optional remote SETTINGS record is missing or failed to restore. */ + suspend fun replay(): String? = settingsMutex.withLock { + val record = read() ?: return@withLock null + settingsStore.update { record.applyTo(it) } + checkCurrent() + require(record.matches(settingsStore.data.first())) + checkCurrent() + record.revision + } + + suspend fun complete(startedRevision: String?) = settingsMutex.withLock { + checkCurrent() + if (keychain.hasPendingWalletRestore()) return@withLock + val record = read() ?: return@withLock + if (record.revision != startedRevision) return@withLock + require(record.matches(settingsStore.data.first())) + checkCurrent() + keychain.delete(KEY, owner.walletIndex) + } + } + + private fun checkAdmission() { + if (wiping) throw CancellationException("Wallet wipe in progress") + } + + private suspend fun checkOwner(owner: Owner) { + synchronized(stateLock) { + checkAdmission() + if (owner.generation != generation) throw CancellationException("Wallet recovery owner changed") + } + val activeIndex = db.configDao().getAll().first().firstOrNull()?.walletIndex ?: 0L + if (activeIndex != owner.walletIndex.toLong() || + (owner.binding != null && storeIdProvider.getBackupWalletBinding(owner.walletIndex) != owner.binding) + ) { + throw ConnectionRecoveryOwnerChanged() + } + synchronized(stateLock) { + checkAdmission() + if (owner.generation != generation) throw CancellationException("Wallet recovery owner changed") + } + } + + @Serializable + data class RgsChoice(val value: String?) + + @Serializable + private data class ConnectionChoices( + val version: Int = 1, + val walletIndex: Int, + val binding: String, + val network: String, + val revision: String, + val electrum: String? = null, + val rgs: RgsChoice? = null, + ) { + fun applyTo(settings: SettingsData) = settings.copy( + electrumServer = electrum ?: settings.electrumServer, + rgsServerUrl = if (rgs != null) rgs.value else settings.rgsServerUrl, + ) + + fun matches(settings: SettingsData) = + (electrum == null || settings.electrumServer == electrum) && + (rgs == null || settings.rgsServerUrl == rgs.value) + } +} + +class ConnectionRecoveryOwnerChanged : AppError("The wallet changed. Open connection recovery again.") diff --git a/app/src/main/java/to/bitkit/data/keychain/WalletRestoreState.kt b/app/src/main/java/to/bitkit/data/keychain/WalletRestoreState.kt new file mode 100644 index 0000000000..27e3d08a1e --- /dev/null +++ b/app/src/main/java/to/bitkit/data/keychain/WalletRestoreState.kt @@ -0,0 +1,14 @@ +package to.bitkit.data.keychain + +import to.bitkit.utils.AppError + +internal fun Keychain.hasPendingWalletRestore(): Boolean = + exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) || exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name) + +internal fun Keychain.requireWalletRestoreComplete() { + if (hasPendingWalletRestore()) throw WalletBackupRestorePendingError() +} + +class WalletBackupRestorePendingError : AppError( + "Wallet backup restoration is incomplete. Retry the restore before continuing." +) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 55e88e56c1..07ca2ff8a6 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -39,6 +39,7 @@ import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsStore +import to.bitkit.data.WalletConnectionRecovery import to.bitkit.data.WatchOnlyAccountStore import to.bitkit.data.WidgetsStore import to.bitkit.data.backup.UsdtRecoveryBackupStore @@ -48,6 +49,7 @@ import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.entities.TransferEntity import to.bitkit.data.hwWalletNames import to.bitkit.data.keychain.Keychain +import to.bitkit.data.keychain.hasPendingWalletRestore import to.bitkit.data.resetPin import to.bitkit.di.IoDispatcher import to.bitkit.di.json @@ -135,6 +137,7 @@ class BackupRepo @Inject constructor( private val lightningService: LightningService, private val clock: Clock, private val db: AppDb, + private val connectionRecovery: WalletConnectionRecovery, ) { private val walletBackupMutex = Mutex() private val usdtBackupMutex = Mutex() @@ -186,7 +189,7 @@ class BackupRepo @Inject constructor( } suspend fun hasPendingWalletRestore(): Boolean = withContext(ioDispatcher) { - keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) || isUsdtRestorePending() + keychain.hasPendingWalletRestore() } private fun currentTimeMillis(): Long = nowMillis(clock) @@ -552,7 +555,6 @@ class BackupRepo @Inject constructor( suspend fun triggerBackup(category: BackupCategory): Result = withContext(ioDispatcher) { Logger.debug("Backup starting for: '$category'", context = TAG) - var backupRequired = currentTimeMillis() runningBackups += category failedBackupRequired -= category @@ -774,7 +776,7 @@ class BackupRepo @Inject constructor( } private suspend fun captureWalletBackupDataBytes(sdkGeneration: Long): ByteArray { - check(!keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) && !isUsdtRestorePending()) { + check(!keychain.hasPendingWalletRestore()) { "Wallet backup restore is incomplete" } lateinit var transfers: List @@ -841,9 +843,17 @@ class BackupRepo @Inject constructor( suspend fun performFullRestoreFromLatestBackup( onCacheRestored: suspend () -> Unit = {}, + connectionSession: WalletConnectionRecovery.Session? = null, ): Result = withContext(ioDispatcher) { + if (connectionSession == null) { + return@withContext runSuspendCatching { + connectionRecovery.withCurrentWallet { + performFullRestoreFromLatestBackup(onCacheRestored, it).getOrThrow() + } + } + } if (isUsdtRestorePending() && !keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)) { - return@withContext restoreUsdtRecoveryAfterLegacyMigration() + return@withContext restoreUsdtRecoveryAfterLegacyMigration(connectionSession) } Logger.debug("Full restore starting", context = TAG) @@ -858,6 +868,7 @@ class BackupRepo @Inject constructor( if (!keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)) { keychain.upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, "") } + connectionSession.replay() restoreUsdtRecovery() performRestore(BackupCategory.METADATA) { dataBytes -> val restored = restoreMetadataBackup(dataBytes, onCacheRestored) @@ -866,7 +877,7 @@ class BackupRepo @Inject constructor( } performRestore(BackupCategory.SETTINGS) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) - settingsStore.restoreFromBackup(parsed) + connectionSession.restoreSettings(parsed) parsed.createdAt } performRestore(BackupCategory.WIDGETS) { dataBytes -> @@ -874,6 +885,8 @@ class BackupRepo @Inject constructor( widgetsStore.restoreFromBackup(parsed) parsed.createdAt } + // SETTINGS is optional; preserving explicit local corrections is required. + connectionSession.replay() val retainedWalletBackup = keychain.loadString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) if (retainedWalletBackup.isNullOrEmpty()) { var didRestoreWalletBackup = false @@ -919,8 +932,22 @@ class BackupRepo @Inject constructor( return@withContext result } - suspend fun restoreUsdtRecoveryAfterLegacyMigration(): Result = withContext(ioDispatcher) { - runSuspendCatching { restoreUsdtRecovery(retainWallet = false) } + suspend fun restoreUsdtRecoveryAfterLegacyMigration( + connectionSession: WalletConnectionRecovery.Session? = null, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (connectionSession == null) { + connectionRecovery.withCurrentWallet { + restoreUsdtRecoveryAfterLegacyMigration(it).getOrThrow() + } + } else { + if (!isUsdtRestorePending()) { + keychain.upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, "") + } + connectionSession.replay() + restoreUsdtRecovery(retainWallet = false) + } + } } private suspend fun restoreUsdtRecovery(retainWallet: Boolean = true) { diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index d1752edce9..bce9e7dbff 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -66,6 +66,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.keychain.Keychain +import to.bitkit.data.keychain.requireWalletRestoreComplete import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults import to.bitkit.env.Env @@ -284,6 +285,10 @@ class LightningRepo @Inject constructor( return@withContext executeOperation(operationName, operation) } + private fun requireWalletRestoreComplete() { + keychain.requireWalletRestoreComplete() + } + private suspend fun executeOperation( operationName: String, operation: suspend () -> Result, @@ -1023,13 +1028,13 @@ class LightningRepo @Inject constructor( } } - private suspend fun validateRgsUrl(url: String): Result = withContext(bgDispatcher) { + suspend fun validateRgsUrl(url: String): Result = withContext(bgDispatcher) { val initialTimestamp = 0 val testUrl = "${url.trimEnd('/')}/$initialTimestamp" urlValidator.validate(testUrl) } - private suspend fun validateElectrumServer(url: String): Result = withContext(bgDispatcher) { + suspend fun validateElectrumServer(url: String): Result = withContext(bgDispatcher) { runSuspendCatching { ElectrumServer.parse(url) } .fold( onSuccess = { electrumProbeService.probe(it) }, @@ -1274,16 +1279,19 @@ class LightningRepo @Inject constructor( } suspend fun newAddress(): Result = executeWhenNodeRunning("newAddress") { + requireWalletRestoreComplete() runCatching { lightningService.newAddress() } } suspend fun newAddressForType(addressType: AddressType): Result = executeWhenNodeRunning("newAddressForType") { + requireWalletRestoreComplete() runCatching { lightningService.newAddressForType(addressType) } } suspend fun newAddressInfoForType(addressType: AddressType): Result = executeWhenNodeRunning("newAddressInfoForType") { + requireWalletRestoreComplete() runSuspendCatching { lightningService.newAddressInfoForType(addressType) } } @@ -1312,7 +1320,9 @@ class LightningRepo @Inject constructor( description: String, expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoice") { + requireWalletRestoreComplete() updateGeoBlockState() + requireWalletRestoreComplete() runCatching { lightningService.receive(amountSats, description, expirySeconds) } } @@ -1321,7 +1331,9 @@ class LightningRepo @Inject constructor( description: String, expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoiceMsats") { + requireWalletRestoreComplete() updateGeoBlockState() + requireWalletRestoreComplete() runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) } } @@ -1350,6 +1362,7 @@ class LightningRepo @Inject constructor( callback: String, paymentRequest: String, ): Result = executeWhenNodeRunning("requestLnurlWithdraw") { + requireWalletRestoreComplete() val callbackUrl = createWithdrawCallbackUrl(k1, callback, paymentRequest) Logger.debug("handleLnurlWithdraw callbackUrl generated: '$callbackUrl'", context = TAG) lnurlService.requestLnurlWithdraw(callbackUrl) @@ -1360,6 +1373,7 @@ class LightningRepo @Inject constructor( callback: String, nodeId: String, ): Result = executeWhenNodeRunning("requestLnurlChannel") { + requireWalletRestoreComplete() val url = createChannelRequestUrl( k1 = k1, callback = callback, @@ -1417,8 +1431,11 @@ class LightningRepo @Inject constructor( maxTotalRoutingFeeMsat: ULong?, onBeforeSend: suspend () -> Boolean, ): Result = executeWhenNodeRunning("payInvoice") { + requireWalletRestoreComplete() waitForUsableChannels() + requireWalletRestoreComplete() if (!onBeforeSend()) return@executeWhenNodeRunning Result.failure(PaymentAbortedBeforeSend()) + requireWalletRestoreComplete() runCatching { lightningService.send(bolt11, sats, paymentDeadlineAt, maxTotalRoutingFeeMsat) }.also { syncState() } @@ -1519,6 +1536,7 @@ class LightningRepo @Inject constructor( paymentDeadlineAt: Instant? = null, contactPublicKey: String? = null, ): Result = executeWhenNodeRunning("sendOnChain") { + requireWalletRestoreComplete() require(address.isNotEmpty()) { "Send address cannot be empty" } // Recovery must remain reachable when observing the original transaction makes sync fail. @@ -1551,6 +1569,7 @@ class LightningRepo @Inject constructor( Logger.debug("UTXOs selected to spend: $utxosForSend", context = TAG) val attempt = runSuspendCatching { + requireWalletRestoreComplete() onchainSendAttemptStore.admit( walletId = WalletScope.default, requestId = requestId, @@ -1577,6 +1596,7 @@ class LightningRepo @Inject constructor( } val preparedResult = try { runSuspendCatching { + requireWalletRestoreComplete() lightningService.prepareOnchainSend( address, sats, @@ -1625,7 +1645,9 @@ class LightningRepo @Inject constructor( prepared.receipt.txid, broadcast = { beforeDispatch -> prepared.broadcast { + requireWalletRestoreComplete() beforeDispatch() + requireWalletRestoreComplete() dispatchStarted = true } }, @@ -1717,14 +1739,17 @@ class LightningRepo @Inject constructor( val selected = inputs.map { input -> SpendableUtxo(OutPoint(input.txid, input.vout), 0uL) } - return lightningService.prepareOnchainSend( - attempt.address, - attempt.amountSats, - feeRateSatsPerVByte, - selected, - false, - attempt.walletIndex, - paymentDeadlineAt, + requireWalletRestoreComplete() + return guardPreparedBroadcast( + lightningService.prepareOnchainSend( + attempt.address, + attempt.amountSats, + feeRateSatsPerVByte, + selected, + false, + attempt.walletIndex, + paymentDeadlineAt, + ) ) } }, @@ -1740,6 +1765,7 @@ class LightningRepo @Inject constructor( approvePrepared: suspend (OnchainPreparedReceipt) -> Unit = {}, authorizeOriginal: suspend (OnchainSendAttempt) -> Unit, ): Result = executeWhenNodeRunning("retryOriginalOnchainSend") { + requireWalletRestoreComplete() val result = recoveryCoordinator.retryOriginal( attemptId, walletId, @@ -1913,8 +1939,20 @@ class LightningRepo @Inject constructor( override suspend fun originalCoinSpentElsewhere(attempt: OnchainSendAttempt): Boolean = lightningService.originalCoinSpentElsewhere(attempt) - override suspend fun prepareRelease(attempt: OnchainSendAttempt): PreparedOnchainSend = - lightningService.prepareRelease(attempt) + override suspend fun prepareRelease(attempt: OnchainSendAttempt): PreparedOnchainSend { + requireWalletRestoreComplete() + return guardPreparedBroadcast(lightningService.prepareRelease(attempt)) + } + + private fun guardPreparedBroadcast(prepared: PreparedOnchainSend): PreparedOnchainSend = + PreparedOnchainSend(prepared.receipt) { beforeDispatch -> + requireWalletRestoreComplete() + prepared.broadcast { + requireWalletRestoreComplete() + beforeDispatch() + requireWalletRestoreComplete() + } + } suspend fun recordOnchainRelease(expected: OnchainSendAttempt, txid: String): OnchainSendAttempt? = onchainSendAttemptStore.recordRelease(expected, txid) @@ -2086,6 +2124,7 @@ class LightningRepo @Inject constructor( pushToCounterpartySats: ULong? = null, channelConfig: ChannelConfig? = null, ): Result = executeWhenNodeRunning("openChannel") { + requireWalletRestoreComplete() lightningService.openChannel(peer, channelAmountSats, pushToCounterpartySats, channelConfig).also { syncState() } @@ -2096,6 +2135,7 @@ class LightningRepo @Inject constructor( force: Boolean = false, forceCloseReason: String? = null, ): Result = executeWhenNodeRunning("closeChannel") { + requireWalletRestoreComplete() runCatching { lightningService.closeChannel(channel, force, forceCloseReason) }.also { syncState() } @@ -2279,6 +2319,7 @@ class LightningRepo @Inject constructor( originalTxId: Txid, satsPerVByte: ULong, ): Result = executeWhenNodeRunning("bumpFeeByRbf") { + requireWalletRestoreComplete() runCatching { require(!originalTxId.isBlank()) { "originalTxId is null or empty: $originalTxId" } require(satsPerVByte > 0u) { "satsPerVByte invalid: $satsPerVByte" } @@ -2309,6 +2350,7 @@ class LightningRepo @Inject constructor( satsPerVByte: ULong, destinationAddress: Address, ): Result = executeWhenNodeRunning("accelerateByCpfp") { + requireWalletRestoreComplete() runCatching { require(!originalTxId.isBlank()) { "originalTxId is null or empty: $originalTxId" } require(!destinationAddress.isBlank()) { "destinationAddress is null or empty: $destinationAddress" } @@ -2619,13 +2661,20 @@ class LightningRepo @Inject constructor( */ suspend fun restartNodeDetached(): Result = scope.async { restartNode() }.await() - suspend fun restartNode(channelMigration: ChannelDataMigration? = null): Result = withContext(bgDispatcher) { + suspend fun restartNode( + channelMigration: ChannelDataMigration? = null, + walletIndex: Int = 0, + ): Result = withContext(bgDispatcher) { Logger.info("Restarting node", context = TAG) stop().onFailure { Logger.error("Failed to stop node during restart", it, context = TAG) return@withContext Result.failure(it) } - startNode(channelMigration = channelMigration, shouldRetryYieldToStop = true).onFailure { + startNode( + walletIndex = walletIndex, + channelMigration = channelMigration, + shouldRetryYieldToStop = true + ).onFailure { if (it is NodeStartYieldedToStopError) { Logger.info("Deferred node restart to a requested stop", context = TAG) } else { diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index c7c9173a01..dad6696d84 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -28,6 +28,7 @@ import org.lightningdevkit.ldknode.WordCount import to.bitkit.async.appScope import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore +import to.bitkit.data.WalletConnectionRecovery import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env @@ -74,6 +75,7 @@ class WalletRepo @Inject constructor( private val transferRepo: TransferRepo, private val activityRepo: ActivityRepo, private val hwWalletRepo: HwWalletRepo, + private val connectionRecovery: WalletConnectionRecovery, ) { private val repoScope = appScope(bgDispatcher, TAG) @@ -604,17 +606,19 @@ class WalletRepo @Inject constructor( suspend fun createWallet(bip39Passphrase: String?): Result = withContext(bgDispatcher) { lightningRepo.setRecoveryMode(enabled = false) - runCatching { - val mnemonic = generateEntropyMnemonic() - keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) - if (bip39Passphrase != null) { - keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) - } - settingsStore.update { - it.copy( - selectedAddressType = DEFAULT_ADDRESS_TYPE_STRING, - addressTypesToMonitor = listOf(DEFAULT_ADDRESS_TYPE_STRING), - ) + runSuspendCatching { + connectionRecovery.withIdentityChange { + val mnemonic = generateEntropyMnemonic() + keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) + if (bip39Passphrase != null) { + keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) + } + settingsStore.update { + it.copy( + selectedAddressType = DEFAULT_ADDRESS_TYPE_STRING, + addressTypesToMonitor = listOf(DEFAULT_ADDRESS_TYPE_STRING), + ) + } } setWalletExistsState() }.onFailure { @@ -624,16 +628,18 @@ class WalletRepo @Inject constructor( suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?): Result = withContext(bgDispatcher) { lightningRepo.setRecoveryMode(enabled = false) - runCatching { - keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) - if (bip39Passphrase != null) { - keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) - } - settingsStore.update { - it.copy( - selectedAddressType = DEFAULT_ADDRESS_TYPE_STRING, - addressTypesToMonitor = ALL_ADDRESS_TYPE_STRINGS, - ) + runSuspendCatching { + connectionRecovery.withIdentityChange { + keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) + if (bip39Passphrase != null) { + keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) + } + settingsStore.update { + it.copy( + selectedAddressType = DEFAULT_ADDRESS_TYPE_STRING, + addressTypesToMonitor = ALL_ADDRESS_TYPE_STRINGS, + ) + } } setWalletExistsState() }.onFailure { diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 78a272c4c9..a30dadb996 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -58,6 +58,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.WatchOnlyAccountStore import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.keychain.Keychain +import to.bitkit.data.keychain.requireWalletRestoreComplete import to.bitkit.data.withRequiredNativeSegwitMonitoring import to.bitkit.di.BgDispatcher import to.bitkit.di.IoDispatcher @@ -676,6 +677,7 @@ class LightningService internal constructor( val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background(ldkQueue) { + keychain.requireWalletRestoreComplete() node.onchainPayment().newAddress() } } @@ -689,6 +691,7 @@ class LightningService internal constructor( val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background(ldkQueue) { + keychain.requireWalletRestoreComplete() val addressInfo = node.onchainPayment().newAddressInfoForType(addressType.toLdkAddressType()) AddressDerivationInfo(address = addressInfo.address, index = addressInfo.index.toInt()) } @@ -838,6 +841,7 @@ class LightningService internal constructor( val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background(ldkQueue) { + keychain.requireWalletRestoreComplete() try { val pushToCounterpartyMsat = pushToCounterpartySats?.let { it * 1000u } Logger.debug("Initiating channel open (sats: '$channelAmountSats') with: '${peer.uri}'", context = TAG) @@ -886,6 +890,7 @@ class LightningService internal constructor( try { ServiceQueue.LDK.background(ldkQueue) { + keychain.requireWalletRestoreComplete() Logger.debug("Initiating channel close (force=$force): '$channelId'", context = TAG) if (force) { node.forceCloseChannel(userChannelId, counterpartyNodeId, forceCloseReason.orEmpty()) @@ -954,6 +959,7 @@ class LightningService internal constructor( val message = description return ServiceQueue.LDK.background(ldkQueue) { + keychain.requireWalletRestoreComplete() val bolt11Invoice: Bolt11Invoice = if (amountMsat != null) { node.bolt11Payment() .receive( @@ -983,6 +989,7 @@ class LightningService internal constructor( walletIndex: Int = currentWalletIndex, paymentDeadlineAt: Instant? = null, ): PreparedOnchainSend = callOnchainSend { + keychain.requireWalletRestoreComplete() ensurePaymentDeadline(paymentDeadlineAt) require(satsPerVByte > 0uL && satsPerVByte <= UInt.MAX_VALUE.toULong()) if (currentWalletIndex != walletIndex) throw ServiceError.NodeNotSetup() @@ -1012,8 +1019,10 @@ class LightningService internal constructor( callOnchainSend { // Dispatch only through the handle that signed the original receipt. if (currentWalletIndex != walletIndex || node !== originalNode) throw ServiceError.NodeNotSetup() + keychain.requireWalletRestoreComplete() ensurePaymentDeadline(paymentDeadlineAt) beforeDispatch() + keychain.requireWalletRestoreComplete() when (val result = prepared.broadcast()) { is OnchainSendResult.Accepted -> OnchainSendOutcome.Accepted(result.txid) is OnchainSendResult.Rejected -> OnchainSendOutcome.Rejected(result.txid, result.reason) @@ -1131,6 +1140,7 @@ class LightningService internal constructor( .getOrElse { throw LdkError(it as NodeException) } return ServiceQueue.LDK.background(ldkQueue) { + keychain.requireWalletRestoreComplete() ensurePaymentDeadline(paymentDeadlineAt) runCatching { val routeParameters = maxTotalRoutingFeeMsat?.let(::routeParametersWithFeeCap) @@ -1311,6 +1321,7 @@ class LightningService internal constructor( Logger.info("RBF for txid='$txid' using satsPerVByte='$satsPerVByte'", context = TAG) return ServiceQueue.LDK.background(ldkQueue) { + keychain.requireWalletRestoreComplete() return@background try { node.onchainPayment().bumpFeeByRbf( txid = txid, @@ -1332,6 +1343,7 @@ class LightningService internal constructor( Logger.info("CPFP for txid='$txid' using satsPerVByte='$satsPerVByte', to address='$toAddress'", context = TAG) return ServiceQueue.LDK.background(ldkQueue) { + keychain.requireWalletRestoreComplete() return@background try { node.onchainPayment().accelerateByCpfp( txid = txid, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index fff4a35de1..206b02fa0e 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -82,6 +82,7 @@ import to.bitkit.ui.components.SheetHost import to.bitkit.ui.components.TabBar import to.bitkit.ui.components.TimedSheetType import to.bitkit.ui.onboarding.InitializingWalletView +import to.bitkit.ui.onboarding.WalletConnectionRecoveryView import to.bitkit.ui.onboarding.WalletRestoreErrorView import to.bitkit.ui.onboarding.WalletRestoreSuccessView import to.bitkit.ui.scaffold.AppAlertDialog @@ -252,6 +253,7 @@ import to.bitkit.viewmodels.SettingsViewModel import to.bitkit.viewmodels.TransferEffect import to.bitkit.viewmodels.TransferViewModel import to.bitkit.viewmodels.WalletViewModel +import to.bitkit.viewmodels.canProceedWithoutRestore internal fun shouldFinishWalletInitialization( nodeLifecycleState: NodeLifecycleState, @@ -416,16 +418,42 @@ fun ContentView( } } - val backupRestoreFailed = - restoreState is RestoreState.BackupFailed && nodeLifecycleState == NodeLifecycleState.Running + val backupRestoreFailed = restoreState is RestoreState.BackupFailed - if (walletIsInitializing || backupRestoreFailed) { - if (nodeLifecycleState is NodeLifecycleState.ErrorStarting || backupRestoreFailed) { + val connectionFailure by walletViewModel.connectionStartupError.collectAsStateWithLifecycle() + val connectionRetryBusy by walletViewModel.connectionRetryBusy.collectAsStateWithLifecycle() + val canProceedWithoutRestore = canProceedWithoutRestore( + restoreState, + connectionFailure?.canProceedWithoutRestore, + ) + + val connectionEditor by walletViewModel.connectionEditor.collectAsStateWithLifecycle() + if (connectionEditor != null) { + WalletConnectionRecoveryView( + state = checkNotNull(connectionEditor).let { it.copy(busy = it.busy || connectionRetryBusy) }, + onBack = walletViewModel::closeConnectionRecovery, + onElectrum = walletViewModel::editRecoveryElectrum, + onRgs = walletViewModel::editRecoveryRgs, + onResetElectrum = { walletViewModel.editRecoveryElectrum(Env.electrumServerUrl) }, + onResetRgs = { walletViewModel.editRecoveryRgs(Env.ldkRgsServerUrl.orEmpty()) }, + onSave = walletViewModel::saveRecoveryConnection, + onRetry = walletViewModel::retryRecoveryConnection, + ) + return + } + + if (walletIsInitializing || backupRestoreFailed || connectionFailure != null) { + if (nodeLifecycleState is NodeLifecycleState.ErrorStarting || + backupRestoreFailed || connectionFailure != null + ) { WalletRestoreErrorView( retryCount = restoreState.retryCount(), + allowProceedWithoutRestore = canProceedWithoutRestore, + isBusy = connectionRetryBusy, + onConnectionSettings = walletViewModel::openConnectionRecovery, hazeState = hazeState, onRetry = { - if (backupRestoreFailed) { + if (backupRestoreFailed && nodeLifecycleState == NodeLifecycleState.Running) { walletIsInitializing = true walletViewModel.onBackupRestoreRetry() } else { diff --git a/app/src/main/java/to/bitkit/ui/onboarding/WalletConnectionRecoveryView.kt b/app/src/main/java/to/bitkit/ui/onboarding/WalletConnectionRecoveryView.kt new file mode 100644 index 0000000000..5fbfb860b5 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/onboarding/WalletConnectionRecoveryView.kt @@ -0,0 +1,103 @@ +package to.bitkit.ui.onboarding + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +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.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.Colors +import to.bitkit.viewmodels.ConnectionRecoveryUiState + +@Composable +fun WalletConnectionRecoveryView( + state: ConnectionRecoveryUiState, + onBack: () -> Unit, + onElectrum: (String) -> Unit, + onRgs: (String) -> Unit, + onResetElectrum: () -> Unit, + onResetRgs: () -> Unit, + onSave: (Boolean) -> Unit, + onRetry: () -> Unit, +) { + BackHandler(onBack = onBack) + ScreenColumn(modifier = Modifier.testTag("WalletConnectionRecovery")) { + AppTopBar(titleText = stringResource(R.string.onboarding__connection_settings), onBackClick = onBack) + Column( + modifier = Modifier.padding(horizontal = 16.dp).verticalScroll(rememberScrollState()).imePadding() + ) { + BodyM(stringResource(R.string.settings__adv__electrum_server)) + TextInput( + value = state.electrum, + onValueChange = onElectrum, + enabled = !state.busy && state.owner != null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth().testTag("RecoveryElectrumInput") + ) + SecondaryButton( + text = stringResource(R.string.settings__es__button_reset), + onClick = onResetElectrum, + enabled = !state.busy && state.owner != null, + modifier = Modifier.testTag("RecoveryElectrumReset") + ) + PrimaryButton( + text = stringResource(R.string.common__save), + onClick = { onSave(true) }, + enabled = !state.busy && state.owner != null && state.electrum.isNotBlank(), + modifier = Modifier.testTag("RecoveryElectrumSave") + ) + VerticalSpacer(24.dp) + BodyM(stringResource(R.string.settings__rgs__server_url)) + TextInput( + value = state.rgs, + onValueChange = onRgs, + enabled = !state.busy && state.owner != null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth().testTag("RecoveryRgsInput") + ) + SecondaryButton( + text = stringResource(R.string.settings__es__button_reset), + onClick = onResetRgs, + enabled = !state.busy && state.owner != null, + modifier = Modifier.testTag("RecoveryRgsReset") + ) + PrimaryButton( + text = stringResource(R.string.common__save), + onClick = { onSave(false) }, + enabled = !state.busy && state.owner != null, + modifier = Modifier.testTag("RecoveryRgsSave") + ) + VerticalSpacer(24.dp) + if (state.saved) { + BodyM( + stringResource(R.string.onboarding__connection_saved), + modifier = Modifier.testTag("RecoverySaved") + ) + } + state.error?.let { BodyM(it, color = Colors.Red, modifier = Modifier.testTag("RecoveryError")) } + PrimaryButton( + text = stringResource(R.string.common__try_again), + onClick = onRetry, + enabled = !state.busy, + modifier = Modifier.testTag("RecoveryRetry") + ) + VerticalSpacer(16.dp) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/onboarding/WalletRestoreErrorView.kt b/app/src/main/java/to/bitkit/ui/onboarding/WalletRestoreErrorView.kt index f12db70684..08c4d1191a 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/WalletRestoreErrorView.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/WalletRestoreErrorView.kt @@ -38,6 +38,9 @@ fun WalletRestoreErrorView( onRetry: () -> Unit, onProceedWithoutRestore: () -> Unit, modifier: Modifier = Modifier, + allowProceedWithoutRestore: Boolean = true, + isBusy: Boolean = false, + onConnectionSettings: (() -> Unit)? = null, hazeState: HazeState = rememberHazeState(), ) { var showDialog by remember { mutableStateOf(false) } @@ -72,21 +75,33 @@ fun WalletRestoreErrorView( PrimaryButton( text = stringResource(R.string.common__try_again), onClick = onRetry, + enabled = !isBusy, modifier = Modifier.testTag("TryAgainButton") ) - if (retryCount > 1) { + onConnectionSettings?.let { + VerticalSpacer(12.dp) + SecondaryButton( + text = stringResource(R.string.onboarding__connection_settings), + onClick = it, + enabled = !isBusy, + modifier = Modifier.testTag("ConnectionRecoveryButton") + ) + } + + if (allowProceedWithoutRestore && retryCount > 1) { VerticalSpacer(12.dp) SecondaryButton( text = stringResource(R.string.onboarding__restore_no_backup_button), onClick = { showDialog = true }, + enabled = !isBusy, modifier = Modifier.testTag("ProceedWithoutBackupButton") ) } VerticalSpacer(16.dp) - if (showDialog) { + if (showDialog && allowProceedWithoutRestore) { AppAlertDialog( title = stringResource(R.string.common__are_you_sure), text = stringResource(R.string.onboarding__restore_no_backup_warn), diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index 96307eaaf4..684585d997 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.sync.Mutex import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore +import to.bitkit.data.WalletConnectionRecovery import to.bitkit.data.WidgetsStore import to.bitkit.data.keychain.Keychain import to.bitkit.ext.runSuspendCatching @@ -50,6 +51,7 @@ class WipeWalletUseCase @Inject constructor( private val paykitSdkService: PaykitSdkService, private val usdtRepo: UsdtRepo, private val paykitUsdtPaymentRepo: PaykitUsdtPaymentRepo, + private val connectionRecovery: WalletConnectionRecovery, ) { private val wipeMutex = Mutex() @@ -63,11 +65,13 @@ class WipeWalletUseCase @Inject constructor( lightningRepo.setWiping(true) val result = try { runSuspendCatching { - paykitSdkService.withWalletWipe { - stopNode().getOrThrow() - cleanupRemote() - wipeLocal(walletIndex, resetWalletState).getOrThrow() - onSuccess() + connectionRecovery.withIdentityChange { + paykitSdkService.withWalletWipe { + stopNode().getOrThrow() + cleanupRemote() + wipeLocal(walletIndex, resetWalletState).getOrThrow() + onSuccess() + } } } } finally { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index c37b95d466..ffbcc7a9c3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -1,6 +1,7 @@ package to.bitkit.viewmodels import android.content.Context +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -14,7 +15,9 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -22,13 +25,17 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R +import to.bitkit.data.ConnectionRecoveryOwnerChanged import to.bitkit.data.SettingsStore +import to.bitkit.data.WalletConnectionRecovery import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env import to.bitkit.ext.nowTimestamp import to.bitkit.ext.of import to.bitkit.ext.runSuspendCatching @@ -52,6 +59,7 @@ import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.isTxSyncTimeout +import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.milliseconds @@ -70,6 +78,7 @@ class WalletViewModel @Inject constructor( private val migrationService: MigrationService, private val connectivityRepo: ConnectivityRepo, private val boltzService: BoltzService, + private val connectionRecovery: WalletConnectionRecovery, ) : ViewModel() { companion object { private const val TAG = "WalletViewModel" @@ -111,9 +120,208 @@ class WalletViewModel @Inject constructor( val isRefreshing = _isRefreshing.asStateFlow() private val retryLightningPaymentMutex = Mutex() + private val backupRestoreMutex = Mutex() private val _isRetryingLightningPayment = MutableStateFlow(false) val isRetryingLightningPayment = _isRetryingLightningPayment.asStateFlow() + private val _connectionEditor = MutableStateFlow(null) + val connectionEditor = _connectionEditor.asStateFlow() + private val connectionEditorGeneration = AtomicLong() + private var connectionRetryJob: Job? = null + private val startupAttempt = AtomicLong() + private val _connectionRetryBusy = MutableStateFlow(false) + val connectionRetryBusy = _connectionRetryBusy.asStateFlow() + private val _connectionStartupError = MutableStateFlow(null) + val connectionStartupError = _connectionStartupError.asStateFlow() + private var connectionEditorJob: Job? = null + + fun openConnectionRecovery() { + if (_connectionEditor.value != null) return + val id = connectionEditorGeneration.incrementAndGet() + _connectionEditor.update { ConnectionRecoveryUiState(id = id, busy = true) } + launchConnectionEditorOperation(id) { + val owner = connectionRecovery.captureOwner() + connectionRecovery.withOperation(owner) { session -> + session.replay() + val settings = settingsStore.data.first() + session.commitCurrentState { + updateConnectionEditor(id) { + ConnectionRecoveryUiState( + id = id, + owner = owner, + electrum = settings.electrumServer, + rgs = settings.rgsServerUrl.orEmpty() + ) + } + } + } + } + } + + private fun launchConnectionEditorOperation(id: Long, operation: suspend () -> Unit) { + connectionEditorJob = viewModelScope.launch(bgDispatcher) { + try { + runSuspendCatching { operation() }.onFailure { error -> + if (error is ConnectionRecoveryOwnerChanged) { + invalidateConnectionEditor(id, error) + } else { + updateConnectionEditor(id) { it.copy(error = error.message) } + } + } + } catch (error: CancellationException) { + invalidateConnectionEditor(id, error) + throw error + } finally { + updateConnectionEditor(id) { it.copy(busy = false) } + } + } + } + + fun closeConnectionRecovery() { + connectionEditorJob?.cancel() + _connectionEditor.update { null } + } + + private fun updateConnectionEditor(id: Long, transform: (ConnectionRecoveryUiState) -> ConnectionRecoveryUiState?) { + _connectionEditor.update { if (it?.id == id) transform(it) else it } + } + + fun editRecoveryElectrum(value: String) = _connectionEditor.update { + it?.takeUnless { state -> state.busy }?.copy(electrum = value, saved = false, error = null) ?: it + } + + fun editRecoveryRgs(value: String) = _connectionEditor.update { + it?.takeUnless { state -> state.busy }?.copy(rgs = value, saved = false, error = null) ?: it + } + + fun saveRecoveryConnection(electrum: Boolean) { + val state = _connectionEditor.value ?: return + val owner = state.owner ?: return + if (state.busy) return + _connectionEditor.update { it?.copy(busy = true, saved = false, error = null) } + launchConnectionEditorOperation(state.id) { + connectionRecovery.withOperation(owner) { session -> + val settings = if (electrum) { + val url = state.electrum.trim() + lightningRepo.validateElectrumServer(url).getOrThrow() + session.save(electrum = url) + } else { + val input = state.rgs.trim() + val url = when { + input.isBlank() -> Env.ldkRgsServerUrl + input.startsWith("https://") || input.startsWith("http://") -> input + else -> "https://$input" + } + url?.let { lightningRepo.validateRgsUrl(it).getOrThrow() } + session.save(rgs = WalletConnectionRecovery.RgsChoice(url)) + } + session.commitCurrentState { + updateConnectionEditor(state.id) { + it.copy( + saved = true, + electrum = if (electrum) settings.electrumServer else it.electrum, + rgs = if (!electrum) settings.rgsServerUrl.orEmpty() else it.rgs, + ) + } + } + } + } + } + + fun retryRecoveryConnection() { + val state = _connectionEditor.value ?: return + if (state.busy || _connectionRetryBusy.value) return + val owner = state.owner + if (owner == null) { + closeConnectionRecovery() + openConnectionRecovery() + return + } + updateConnectionEditor(state.id) { it.copy(busy = true, error = null) } + requestRecoveryRetry(owner, state.id) + } + + private fun requestRecoveryRetry( + owner: WalletConnectionRecovery.Owner?, + editorId: Long?, + backupOnly: Boolean = false, + ): Job { + connectionRetryJob?.takeIf { it.isActive }?.let { return it } + _connectionRetryBusy.update { true } + val attempt = startupAttempt.incrementAndGet() + return viewModelScope.launch(bgDispatcher) { + var attemptOwner = owner + try { + runSuspendCatching { + val admittedOwner = attemptOwner ?: connectionRecovery.captureRuntimeOwner().also { + attemptOwner = it + } + retryRecovery(admittedOwner, backupOnly, attempt) + }.onSuccess { editorId?.let { updateConnectionEditor(it) { null } } } + .onFailure { error -> reportStartupFailure(attemptOwner, error, attempt, editorId) } + } catch (error: CancellationException) { + editorId?.let { invalidateConnectionEditor(it, error) } + throw error + } finally { + editorId?.let { updateConnectionEditor(it) { state -> state.copy(busy = false) } } + _connectionRetryBusy.update { false } + } + }.also { connectionRetryJob = it } + } + + private suspend fun reportStartupFailure( + owner: WalletConnectionRecovery.Owner?, + error: Throwable, + attempt: Long, + editorId: Long? = null, + ) { + if (error is ConnectionRecoveryOwnerChanged) { + if (startupAttempt.get() == attempt) _connectionStartupError.update { null } + editorId?.let { invalidateConnectionEditor(it, error) } + return + } + if (error is RecoveryModeError || error is WipeInProgressError || error is NodeStartYieldedToStopError) return + Logger.error("Wallet connection recovery startup failed", error, context = TAG) + if (startupAttempt.get() != attempt) return + if (owner == null) { + _connectionStartupError.update { + ConnectionStartupFailure(error.message ?: context.getString(R.string.common__error_body)) + } + editorId?.let { updateConnectionEditor(it) { state -> state.copy(busy = false, error = error.message) } } + ToastEventBus.send(error) + return + } + runSuspendCatching { + connectionRecovery.withOperation(owner) { session -> + val markersClear = runSuspendCatching { !backupRepo.hasPendingWalletRestore() }.getOrDefault(false) + val published = session.commitCurrentState { + if (startupAttempt.get() != attempt) return@commitCurrentState false + _connectionStartupError.update { + ConnectionStartupFailure( + message = error.message ?: context.getString(R.string.common__error_body), + canProceedWithoutRestore = markersClear, + owner = owner, + ) + } + editorId?.let { + updateConnectionEditor( + it + ) { state -> state.copy(busy = false, error = error.message) } + } + true + } + if (published) ToastEventBus.send(error) + } + }.onFailure { + if (it is ConnectionRecoveryOwnerChanged) editorId?.let { id -> invalidateConnectionEditor(id, it) } + Logger.warn("Startup failure owner is no longer available", it, context = TAG) + } + } + + private fun invalidateConnectionEditor(id: Long, error: Throwable) = updateConnectionEditor(id) { + it.copy(owner = null, busy = false, error = error.message) + } + private var syncJob: Job? = null private var pendingWalletStart = false @@ -165,11 +373,17 @@ class WalletViewModel @Inject constructor( return@launch } - migrationService.setShowingMigrationLoading(true) - Logger.info("RN wallet data found, starting migration...", context = TAG) - - runCatching { - migrationService.migrateFromReactNative() + runSuspendCatching { + connectionRecovery.withIdentityChange( + isStillNeeded = { + !migrationService.isMigrationChecked() && + !migrationService.hasNativeWalletData() && migrationService.hasRNWalletData() + }, + ) { + migrationService.setShowingMigrationLoading(true) + Logger.info("RN wallet data found, starting migration...", context = TAG) + migrationService.migrateFromReactNative() + } walletRepo.setWalletExistsState() walletExists = walletRepo.walletExists() loadCacheIfWalletExists() @@ -179,15 +393,9 @@ class WalletViewModel @Inject constructor( Logger.warn("Network offline, dismissing loader and skipping wallet start", context = TAG) migrationService.setShowingMigrationLoading(false) pendingWalletStart = true - return@launch - } - migrationService.lockChannelMigration() - try { - val channelMigration = buildChannelMigrationIfAvailable() - startNode(0, channelMigration) - } finally { - migrationService.unlockChannelMigration() + return@runSuspendCatching } + startNode(0) } else { migrationService.setShowingMigrationLoading(false) } @@ -215,7 +423,15 @@ class WalletViewModel @Inject constructor( if (!it.walletExists) return@collect when (_restoreState.value) { - RestoreState.InProgress.Wallet -> restoreFromBackup() + RestoreState.InProgress.Wallet -> supervisorScope { + val restore = async { restoreFromBackup() } + try { + restore.await() + } catch (_: CancellationException) { + // Identity replacement cancels its admitted restore, not the wallet-state observer. + currentCoroutineContext().ensureActive() + } + } RestoreState.Initial if backupRepo.hasPendingWalletRestore() -> { _restoreState.update { RestoreState.BackupFailed(1) } } @@ -225,31 +441,53 @@ class WalletViewModel @Inject constructor( } } - private suspend fun restoreFromBackup(resumePendingWalletBackup: Boolean = false) { + private suspend fun restoreFromBackup( + resumePendingWalletBackup: Boolean = false, + connectionSession: WalletConnectionRecovery.Session? = null, + ) { + if (connectionSession == null) { + runSuspendCatching { + connectionRecovery.withCurrentWallet { + restoreFromBackup(resumePendingWalletBackup, it) + } + }.onFailure { + Logger.error("Connection recovery restore admission failed", it, context = TAG) + _restoreState.update { RestoreState.BackupFailed(it.backupRetryCount() + 1, true) } + backupRepo.setRestorePending(false) + ToastEventBus.send(it) + } + return + } + if (!backupRestoreMutex.tryLock()) return val retryCount = _restoreState.value.backupRetryCount() _restoreState.update { RestoreState.InProgress.Metadata } try { runSuspendCatching { if (resumePendingWalletBackup) { - backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) + backupRepo.performFullRestoreFromLatestBackup( + onCacheRestored = walletRepo::loadFromCache, + connectionSession = connectionSession, + ) .getOrThrow() pubkyRepo.initialize() } else { - restoreFromMostRecentBackup() + restoreFromMostRecentBackup(connectionSession) } }.onSuccess { _restoreState.update { RestoreState.Completed } }.onFailure { Logger.error("Restore from backup failed", it, context = TAG) ToastEventBus.send(it) - _restoreState.update { RestoreState.BackupFailed(retryCount + 1) } + val pendingRestore = runSuspendCatching { backupRepo.hasPendingWalletRestore() }.getOrDefault(true) + _restoreState.update { RestoreState.BackupFailed(retryCount + 1, pendingRestore) } } } finally { backupRepo.setRestorePending(false) + backupRestoreMutex.unlock() } } - private suspend fun restoreFromMostRecentBackup() { + private suspend fun restoreFromMostRecentBackup(connectionSession: WalletConnectionRecovery.Session) { val (rnTimestamp, vssTimestamp) = coroutineScope { val rn = async { migrationService.getRNRemoteBackupTimestamp() } val vss = async { backupRepo.getLatestBackupTime() } @@ -263,29 +501,35 @@ class WalletViewModel @Inject constructor( } if (shouldRestoreRN) { - restoreFromRNRemoteBackup() + restoreFromRNRemoteBackup(connectionSession) } else { - backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) + backupRepo.performFullRestoreFromLatestBackup( + onCacheRestored = walletRepo::loadFromCache, + connectionSession = connectionSession, + ) .getOrThrow() } pubkyRepo.initialize() } - private suspend fun restoreFromRNRemoteBackup() { + private suspend fun restoreFromRNRemoteBackup(connectionSession: WalletConnectionRecovery.Session) { val legacyRestore = runSuspendCatching { migrationService.restoreFromRNRemoteBackup() walletRepo.loadFromCache() } if (legacyRestore.isSuccess) { - backupRepo.restoreUsdtRecoveryAfterLegacyMigration().getOrThrow() + backupRepo.restoreUsdtRecoveryAfterLegacyMigration(connectionSession).getOrThrow() } else { Logger.warn( "RN remote backup restore failed, falling back to VSS", legacyRestore.exceptionOrNull(), context = TAG, ) - backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) + backupRepo.performFullRestoreFromLatestBackup( + onCacheRestored = walletRepo::loadFromCache, + connectionSession = connectionSession, + ) .getOrThrow() } } @@ -300,36 +544,89 @@ class WalletViewModel @Inject constructor( _restoreState.update { RestoreState.Settled } } - fun onRestoreRetry() = viewModelScope.launch(bgDispatcher) { - _restoreState.update { it.countRetry() } - setInitNodeLifecycleState() - migrationService.lockChannelMigration() - try { - val channelMigration = buildChannelMigrationIfAvailable() - if (channelMigration == null) { - lightningRepo.restartNode() - } else { - startNode(0, channelMigration, isRestoreRetry = true) + fun onRestoreRetry() = requestRecoveryRetry(owner = null, editorId = null) + + private suspend fun retryRecovery(owner: WalletConnectionRecovery.Owner, backupOnly: Boolean, attempt: Long) { + connectionRecovery.withOperation(owner) { session -> + session.commitCurrentState { + if (startupAttempt.get() == attempt) _connectionStartupError.update { null } + } + val revision = session.replay() + if (!backupOnly || revision != null) { + if (!backupRepo.hasPendingWalletRestore()) _restoreState.update { it.countRetry() } + setInitNodeLifecycleState() + connectionRecovery.withStartup(owner) { + migrationService.lockChannelMigration() + try { + val channelMigration = buildChannelMigrationIfAvailable() + startNodeOwned(owner.walletIndex, channelMigration, isRestoreRetry = true).getOrThrow() + } finally { + migrationService.unlockChannelMigration() + } + } + } + if (backupOnly || backupRepo.hasPendingWalletRestore()) { + backupRepo.setRestorePending(true) + restoreFromBackup(resumePendingWalletBackup = true, connectionSession = session) + if (_restoreState.value !is RestoreState.Completed) { + // Restore failures carry their own required-state gate; optional profile work is not a node error. + session.commitCurrentState { + if (startupAttempt.get() == attempt) _connectionStartupError.update { null } + } + return@withOperation + } + } + session.complete(revision) + session.commitCurrentState { + if (startupAttempt.get() == attempt) _connectionStartupError.update { null } } - } finally { - migrationService.unlockChannelMigration() } } - fun onBackupRestoreRetry() = viewModelScope.launch(bgDispatcher) { - backupRepo.setRestorePending(true) - restoreFromBackup(resumePendingWalletBackup = true) - } + fun onBackupRestoreRetry() = requestRecoveryRetry(owner = null, editorId = null, backupOnly = true) @Suppress("ForbiddenComment") fun onProceedWithoutRestore(onDone: () -> Unit) = viewModelScope.launch { - // TODO start LDK without trying to restore backup state from VSS if possible - if (lightningState.value.nodeLifecycleState is NodeLifecycleState.ErrorStarting) { - lightningRepo.stop() - delay(LOADING_MS.milliseconds) + val failure = _connectionStartupError.value + val attempt = startupAttempt.get() + if (_connectionRetryBusy.value || isStarting) return@launch + if (failure?.canProceedWithoutRestore == false) return@launch + if (failure == null && _restoreState.value !is RestoreState.BackupFailed) return@launch + runSuspendCatching { + val owner = failure?.owner ?: connectionRecovery.captureRuntimeOwner() + connectionRecovery.withOperation(owner) { session -> + if (!canStillProceed(session, failure, attempt)) return@withOperation + // TODO start LDK without trying to restore backup state from VSS if possible + if (lightningState.value.nodeLifecycleState is NodeLifecycleState.ErrorStarting) { + lightningRepo.stop().getOrThrow() + delay(LOADING_MS.milliseconds) + } + if (!canStillProceed(session, failure, attempt)) return@withOperation + session.commitCurrentState { + if (startupAttempt.get() != attempt || _connectionStartupError.value !== failure) { + return@commitCurrentState + } + _connectionStartupError.update { null } + _restoreState.update { RestoreState.Settled } + onDone() + } + } + }.onFailure { Logger.error("Proceed without restore admission failed", it, context = TAG) } + } + + private suspend fun canStillProceed( + session: WalletConnectionRecovery.Session, + failure: ConnectionStartupFailure?, + attempt: Long, + ): Boolean { + if (runSuspendCatching { !backupRepo.hasPendingWalletRestore() }.getOrDefault(false)) return true + session.commitCurrentState { + if (startupAttempt.get() == attempt && _connectionStartupError.value === failure) { + _connectionStartupError.update { it?.copy(canProceedWithoutRestore = false) } + _restoreState.update { (it as? RestoreState.BackupFailed)?.copy(hasPendingWalletRestore = true) ?: it } + } } - _restoreState.update { RestoreState.Settled } - onDone() + return false } fun setInitNodeLifecycleState() = lightningRepo.setInitNodeLifecycleState() @@ -356,13 +653,7 @@ class WalletViewModel @Inject constructor( waitForRestoreIfNeeded() - migrationService.lockChannelMigration() - try { - val channelMigration = buildChannelMigrationIfAvailable() - startNode(walletIndex, channelMigration) - } finally { - migrationService.unlockChannelMigration() - } + startNode(walletIndex) } finally { isStarting = false } @@ -391,17 +682,47 @@ class WalletViewModel @Inject constructor( ) } - private suspend fun startNode( - walletIndex: Int = 0, + private suspend fun startNode(walletIndex: Int = 0): Result { + val attempt = startupAttempt.incrementAndGet() + var owner: WalletConnectionRecovery.Owner? = null + return runSuspendCatching { + val admittedOwner = connectionRecovery.captureRuntimeOwner().also { owner = it } + require(admittedOwner.walletIndex == walletIndex) { "Wallet startup owner changed" } + val revision = connectionRecovery.withStartup(admittedOwner) { session -> + session.commitCurrentState { + if (startupAttempt.get() == attempt) _connectionStartupError.update { null } + } + val revision = session.replay() + migrationService.lockChannelMigration() + try { + // A skipped start of a running node cannot acknowledge a newly saved configuration. + startNodeOwned(walletIndex, buildChannelMigrationIfAvailable(), isRestoreRetry = revision != null) + .getOrThrow() + } finally { + migrationService.unlockChannelMigration() + } + revision + } + connectionRecovery.withOperation(admittedOwner) { session -> + session.complete(revision) + session.commitCurrentState { + if (startupAttempt.get() == attempt) _connectionStartupError.update { null } + } + } + }.onFailure { reportStartupFailure(owner, it, attempt) } + } + + private suspend fun startNodeOwned( + walletIndex: Int, channelMigration: ChannelDataMigration?, - isRestoreRetry: Boolean = false, - ) { + isRestoreRetry: Boolean, + ): Result { val result = if (isRestoreRetry) { - lightningRepo.restartNode(channelMigration) + lightningRepo.restartNode(channelMigration, walletIndex) } else { lightningRepo.start(walletIndex, channelMigration = channelMigration) } - result + return result .onSuccess { if (channelMigration != null) { migrationService.consumePendingChannelMigration() @@ -416,14 +737,6 @@ class WalletViewModel @Inject constructor( ensureSwapUpdatesRunning() // checkForOrphanedChannelMonitorRecovery() } - .onFailure { - if (it is RecoveryModeError || it is WipeInProgressError || it is NodeStartYieldedToStopError) { - Logger.debug("Skipped node start: '${it.message}'", context = TAG) - return@onFailure - } - Logger.error("Node startup error", it, context = TAG) - ToastEventBus.send(it) - } } private var swapEventsCollected = false @@ -643,6 +956,10 @@ class WalletViewModel @Inject constructor( setInitNodeLifecycleState() walletRepo.createWallet(bip39Passphrase) .onSuccess { + // The acknowledged new wallet must not inherit a cancelled restore's screen state. + _restoreState.update { RestoreState.Initial } + _connectionStartupError.update { null } + closeConnectionRecovery() backupRepo.scheduleFullBackup() } .onFailure { @@ -720,7 +1037,7 @@ sealed interface RestoreState { } data class Retry(val count: Int) : RestoreState - data class BackupFailed(val count: Int) : RestoreState + data class BackupFailed(val count: Int, val hasPendingWalletRestore: Boolean = true) : RestoreState data object Completed : RestoreState data object Settled : RestoreState @@ -733,7 +1050,7 @@ sealed interface RestoreState { fun backupRetryCount() = (this as? BackupFailed)?.count ?: 0 fun countRetry(): RestoreState = when (this) { is Retry -> Retry(count + 1) - is BackupFailed -> BackupFailed(count + 1) + is BackupFailed -> copy(count = count + 1) else -> Retry(1) } fun isOngoing() = this is InProgress @@ -741,3 +1058,24 @@ sealed interface RestoreState { } class LightningPaymentRetryInProgressError : AppError("Lightning payment retry already in progress") + +@Immutable +data class ConnectionRecoveryUiState( + internal val id: Long = 0, + internal val owner: WalletConnectionRecovery.Owner? = null, + val electrum: String = "", + val rgs: String = "", + val busy: Boolean = false, + val saved: Boolean = false, + val error: String? = null, +) + +@Immutable +data class ConnectionStartupFailure( + val message: String, + val canProceedWithoutRestore: Boolean = false, + internal val owner: WalletConnectionRecovery.Owner? = null, +) + +fun canProceedWithoutRestore(restoreState: RestoreState, startupClearance: Boolean?): Boolean = + startupClearance ?: ((restoreState as? RestoreState.BackupFailed)?.hasPendingWalletRestore == false) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 23f52e9523..cf99b64b01 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -292,6 +292,8 @@ إيقاف التطبيق متقدم إعداد متقدم + تم الحفظ. أعد المحاولة لمتابعة الاستعادة. + إعدادات الاتصال إنشاء محفظة جديدة للبدء\n<accent>أرسل\nBitcoin</accent>\nإلى\nمحفظتك ابدأ diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 854b902b00..f5051434a9 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -300,6 +300,8 @@ Detener app Avanzado Configuración avanzada + Guardado. Reintenta para continuar la restauración. + Configuración de conexión Crear nueva cartera Para\nempezar\n<accent>envíe\nBitcoin</accent>\na su\nbilletera Empezar diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index bfcc033901..32b44f40f3 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -296,6 +296,8 @@ Aturar aplicació Avançat Configuració avançada + Desat. Torna-ho a provar per continuar restaurant. + Configuració de connexió Crear una nova cartera Per començar\n<accent>envia\nBitcoin</accent>\na la teva\ncartera Començar diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index ec26262d2b..cf6f214c4a 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -299,6 +299,8 @@ Zastavit aplikaci Pokročilé Pokročilé nastavení + Uloženo. Zkuste to znovu a pokračujte v obnově. + Nastavení připojení Vytvořit novou peněženku Chcete-li\nzačít\n<accent>pošlete\nbitcoin</accent>\ndo vaší\npeněženky Začít diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 862f4f1b0c..414d31d92c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -297,6 +297,8 @@ Zahlung empfangen Bitkit läuft im Hintergrund, damit du Lightning-Zahlungen empfangen kannst App stoppen + Gespeichert. Versuche es erneut, um die Wiederherstellung fortzusetzen. + Verbindungseinstellungen Bitkit\n<accent>Nutzungs-bedingungen</accent> Nutzungsbedingungen Durch Fortfahren erklärst du, dass du die Nutzungsbedingungen gelesen und akzeptiert hast. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 273e174438..1e16295210 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -293,6 +293,8 @@ Διακοπή εφαρμογής Για προχωρημένους Ρύθμιση για προχωρημένους + Αποθηκεύτηκε. Δοκιμάστε ξανά για να συνεχίσετε την επαναφορά. + Ρυθμίσεις σύνδεσης Δημιουργία νέου πορτοφολιού Για να\nξεκινήσεις\n<accent>στείλε\nBitcoin</accent>\nστο\nπορτοφόλι σου Ξεκίνα diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 558a020922..290ae4727c 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -299,6 +299,8 @@ Detener aplicación Avanzado Configuración avanzada + Guardado. Reintenta para continuar la restauración. + Ajustes de conexión Crear nueva cartera Para\nempezar\n<accent>envíe\nBitcoin</accent>\na su\nmonedero Empezar diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5224935de6..d2414b8030 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -295,6 +295,8 @@ Pago Recibido Bitkit se está ejecutando en segundo plano para que puedas recibir pagos Lightning Detener App + Guardado. Reintenta para continuar la restauración. + Ajustes de conexión Condiciones de uso Al continuar, declaras que has leído y aceptas los términos de uso. Política de privacidad diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 06a89e56bd..fb4c933711 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -297,6 +297,8 @@ Paiement reçu Bitkit fonctionne en arrière-plan pour que vous puissiez recevoir des paiements Lightning Arrêter l\'application + Enregistré. Réessayez pour poursuivre la restauration. + Paramètres de connexion Bitkit \n<accent>Conditions d\'utilisation</accent> Conditions d\'utilisation En continuant, vous déclarez avoir lu et accepté les conditions d\'utilisation. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e0ebc9f571..02ed08faeb 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -298,6 +298,8 @@ Ferma App Avanzate Impostazioni Avanzate + Salvato. Riprova per continuare il ripristino. + Impostazioni di connessione Crea Nuovo Wallet Per\niniziare\n<accent>invia\nBitcoin</accent>\nal tuo\nportafoglio Inizia diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index de56a9473b..5614111209 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -297,6 +297,8 @@ App stoppen Geavanceerd Geavanceerde configuratie + Opgeslagen. Probeer opnieuw om het herstel voort te zetten. + Verbindingsinstellingen Nieuwe wallet aanmaken Om te beginnen\n<accent>stuur\nBitcoin</accent>\nnaar je\nwallet Aan de slag diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 64f2590831..dcadf51905 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -299,6 +299,8 @@ Płatność otrzymana Bitkit działa w tle, abyś mógł otrzymywać płatności Lightning Zatrzymaj aplikację + Zapisano. Spróbuj ponownie, aby kontynuować przywracanie. + Ustawienia połączenia Bitkit\n<accent>warunki użytkowania</accent> Warunki użytkowania Kontynuując, oświadczasz, że zapoznałeś się i akceptujesz warunki użytkowania. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index af9986b902..9a7f62a075 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -301,6 +301,8 @@ Parar Aplicativo Avançado Configuração Avançada + Salvo. Tente novamente para continuar a restauração. + Configurações de conexão Criar Nova Carteira Para\ncomeçar\n<accent>receba\nBitcoin</accent>\nna sua\ncarteira Começar diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index e49a4a0d16..4ff92d3f36 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -292,6 +292,8 @@ Pagamento Recebido A Bitkit está rodando em segundo plano para você receber pagamentos Lightning Parar App + Guardado. Tente novamente para continuar o restauro. + Definições de ligação Bitkit\n<accent>termos de uso</accent> Termos de uso Ao continuar, você declara que leu e aceita os termos de uso. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7f841901c8..1fe4c8ca45 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -314,6 +314,8 @@ Остановить приложение Дополнительно Дополнительно + Сохранено. Повторите попытку, чтобы продолжить восстановление. + Настройки подключения Создать Новый Кошелёк Чтобы начать\nработу\n<accent>отправьте\nбиткойны</accent>\nв ваш\nкошелёк Начать diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ab3c0af5e3..33a0c7981c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -468,6 +468,8 @@ Stop App Advanced Advanced Setup + Saved. Retry to continue restoring. + Connection settings Create New Wallet To get\nstarted\n<accent>send\nBitcoin</accent>\nto your\nwallet Get Started diff --git a/app/src/test/java/to/bitkit/data/WalletConnectionRecoveryTest.kt b/app/src/test/java/to/bitkit/data/WalletConnectionRecoveryTest.kt new file mode 100644 index 0000000000..97c536a3cb --- /dev/null +++ b/app/src/test/java/to/bitkit/data/WalletConnectionRecoveryTest.kt @@ -0,0 +1,404 @@ +package to.bitkit.data + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runCurrent +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.data.backup.VssStoreIdProvider +import to.bitkit.data.entities.ConfigEntity +import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env +import to.bitkit.models.SettingsBackupV1 +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +@Config(application = Application::class, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class WalletConnectionRecoveryTest : BaseUnitTest() { + private val settings = SettingsStore(ApplicationProvider.getApplicationContext()) + private val keychain = mock() + private val db = mock() + private val binding = mock() + private val config = MutableStateFlow(listOf(ConfigEntity())) + private var record: String? = null + private var pending = false + private val markers = mutableMapOf() + private lateinit var sut: WalletConnectionRecovery + + @Before + fun setUp() = runBlocking { + settings.reset() + whenever(db.configDao()).thenReturn(mock()) + whenever(db.configDao().getAll()).thenReturn(config) + whenever(binding.getBackupWalletBinding(any())).thenReturn("original-wallet") + whenever(keychain.loadString(eq(WalletConnectionRecovery.KEY), any())).thenAnswer { record } + whenever(keychain.exists(any())).thenAnswer { + val key = it.getArgument(0) + (pending && key == Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) || markers.containsKey(key) + } + whenever(keychain.upsertString(any(), any(), eq(0))).thenAnswer { + val key = it.getArgument(0) + markers[key] = it.getArgument(1) + Unit + } + whenever(keychain.delete(any(), eq(0))).thenAnswer { + markers.remove(it.getArgument(0)) + Unit + } + whenever(keychain.upsertString(eq(WalletConnectionRecovery.KEY), any(), eq(0))).thenAnswer { + record = it.getArgument(1) + Unit + } + whenever(keychain.delete(WalletConnectionRecovery.KEY, 0)).thenAnswer { record = null } + sut = instance() + } + + @After + fun tearDown() = runBlocking { settings.reset() } + + private fun instance(store: SettingsStore = settings) = WalletConnectionRecovery(keychain, db, binding, store) + + @Test + fun `new instance preserves selected fields through remote restore without changing unrelated settings`() = test { + sut.withOperation(sut.captureOwner()) { it.save(electrum = "ssl://chosen.example:50002") } + val restarted = instance() + restarted.withOperation(restarted.captureOwner()) { session -> + val remote = SettingsData(electrumServer = "ssl://old.example:50002", selectedCurrency = "EUR") + session.restoreSettings(SettingsBackupV1(createdAt = 0, settings = remote)) + val revision = session.replay() + assertEquals("ssl://chosen.example:50002", settings.data.first().electrumServer) + assertEquals("EUR", settings.data.first().selectedCurrency) + session.complete(revision) + } + assertNull(record) + } + + @Test + fun `separate saves preserve both choices and an absent default RGS remains explicit`() = test { + sut.withOperation(sut.captureOwner()) { session -> + session.save(electrum = "tcp://chosen.example:50001") + session.save(rgs = WalletConnectionRecovery.RgsChoice(null)) + session.restoreSettings( + SettingsBackupV1( + createdAt = 0, + settings = SettingsData(rgsServerUrl = "https://broken.example") + ) + ) + val restored = settings.data.first() + assertEquals("tcp://chosen.example:50001", restored.electrumServer) + assertNull(restored.rgsServerUrl) + } + } + + @Test + fun `pending wallet restore retains correction after successful node start`() = test { + pending = true + sut.withOperation(sut.captureOwner()) { session -> + session.save(electrum = Env.electrumServerUrl) + session.complete(session.replay()) + } + assertNotNull(record) + } + + @Test + fun `older completion cannot erase a more recent correction`() = test { + sut.withOperation(sut.captureOwner()) { session -> + session.save(electrum = "ssl://first.example:50002") + val firstRevision = session.replay() + session.save(electrum = "ssl://second.example:50002") + session.complete(firstRevision) + } + assertNotNull(record) + assertEquals("ssl://second.example:50002", settings.data.first().electrumServer) + } + + @Test + fun `settings failure retains durable choice for restart replay`() = test { + val unavailable = mock() + whenever(unavailable.update(any())).thenAnswer { throw AppError("settings unavailable") } + val failed = instance(unavailable) + assertFailsWith { + failed.withOperation(failed.captureOwner()) { it.save(electrum = "ssl://chosen.example:50002") } + } + assertNotNull(record) + val restarted = instance() + restarted.withOperation(restarted.captureOwner()) { it.replay() } + assertEquals("ssl://chosen.example:50002", settings.data.first().electrumServer) + } + + @Test + fun `unacknowledged intent write never changes settings`() = test { + whenever(keychain.upsertString(eq(WalletConnectionRecovery.KEY), any(), eq(0))) + .thenAnswer { throw AppError("keychain unavailable") } + val before = settings.data.first() + assertFailsWith { + sut.withOperation(sut.captureOwner()) { it.save(electrum = "ssl://chosen.example:50002") } + } + assertNull(record) + assertEquals(before, settings.data.first()) + } + + @Test + fun `corrupt or future record fails closed without replacing its bytes`() = test { + for (value in listOf("not-json", """{"version":99}""")) { + record = value + assertFailsWith { + sut.withOperation(sut.captureOwner()) { it.save(electrum = Env.electrumServerUrl) } + } + assertEquals(value, record) + } + } + + @Test + fun `unavailable record is not treated as an absent override`() = test { + whenever(keychain.loadString(WalletConnectionRecovery.KEY, 0)).thenAnswer { throw AppError("read failed") } + assertFailsWith { sut.withOperation(sut.captureOwner()) { it.replay() } } + } + + @Test + fun `wallet replacement after confirmation cannot save to new identity`() = test { + val owner = sut.captureOwner() + whenever(binding.getBackupWalletBinding(0)).thenReturn("replacement-wallet") + assertFailsWith { + sut.withOperation(owner) { it.save(electrum = Env.electrumServerUrl) } + } + assertNull(record) + } + + @Test + fun `active index replacement cannot reuse old confirmation`() = test { + val owner = sut.captureOwner() + config.value = listOf(ConfigEntity(1)) + assertFailsWith { sut.withOperation(owner) { it.replay() } } + } + + @Test + fun `same seed replacement revokes earlier owner and escaped session`() = test { + val owner = sut.captureOwner() + lateinit var ended: WalletConnectionRecovery.Session + sut.withOperation(owner) { ended = it } + assertFailsWith { ended.replay() } + sut.withIdentityChange { } + assertFailsWith { sut.withOperation(owner) { it.replay() } } + sut.withOperation(sut.captureOwner()) { it.save(electrum = Env.electrumServerUrl) } + assertNotNull(record) + } + + @Test + fun `cancellation during validation does not persist a choice`() = test { + val entered = CompletableDeferred() + val validation = CompletableDeferred() + val operation = launch { + sut.withOperation(sut.captureOwner()) { session -> + entered.complete(Unit) + validation.await() + session.save(electrum = Env.electrumServerUrl) + } + } + entered.await() + operation.cancel() + operation.join() + assertNull(record) + } + + @Test + fun `view cancellation after durable write starts still commits normal settings`() = test { + val writing = CompletableDeferred() + val release = CompletableDeferred() + whenever(keychain.upsertString(eq(WalletConnectionRecovery.KEY), any(), eq(0))).doSuspendableAnswer { + record = it.getArgument(1) + writing.complete(Unit) + release.await() + } + val operation = launch { + sut.withOperation(sut.captureOwner()) { it.save(electrum = "ssl://chosen.example:50002") } + } + writing.await() + operation.cancel() + release.complete(Unit) + operation.join() + assertEquals("ssl://chosen.example:50002", settings.data.first().electrumServer) + assertNotNull(record) + } + + @Test + fun `wipe drains noncancellable intent before erasing and rejects queued old owners`() = test { + val writing = CompletableDeferred() + val release = CompletableDeferred() + val owner = sut.captureOwner() + whenever(keychain.upsertString(eq(WalletConnectionRecovery.KEY), any(), eq(0))).doSuspendableAnswer { + record = it.getArgument(1) + writing.complete(Unit) + release.await() + } + val first = launch { sut.withOperation(owner) { it.save(electrum = Env.electrumServerUrl) } } + writing.await() + var secondRan = false + val second = launch { sut.withOperation(owner) { secondRan = true } } + runCurrent() + var wiped = false + val wipe = async { + sut.withIdentityChange { + record = null + wiped = true + } + } + runCurrent() + assertFalse(wiped) + release.complete(Unit) + first.join() + second.join() + wipe.await() + assertTrue(wiped) + assertFalse(secondRan) + assertNull(record) + } + + @Test + fun `canceled identity change releases admission after draining the old writer`() = test { + val writing = CompletableDeferred() + val release = CompletableDeferred() + whenever(keychain.upsertString(eq(WalletConnectionRecovery.KEY), any(), eq(0))).doSuspendableAnswer { + record = it.getArgument(1) + writing.complete(Unit) + release.await() + } + val writer = launch { + sut.withOperation(sut.captureOwner()) { it.save(electrum = Env.electrumServerUrl) } + } + writing.await() + var replacementRan = false + val replacement = launch { sut.withIdentityChange { replacementRan = true } } + runCurrent() + replacement.cancel() + release.complete(Unit) + writer.join() + replacement.join() + assertFalse(replacementRan) + assertTrue(runCatching { sut.captureOwner() }.isSuccess) + } + + @Test + fun `startup can satisfy node dependent restoration without taking its operation lock`() = test { + val waitingForNode = CompletableDeferred() + val nodeRunning = CompletableDeferred() + val owner = sut.captureOwner() + val restore = launch { + sut.withOperation(owner) { + waitingForNode.complete(Unit) + nodeRunning.await() + } + } + waitingForNode.await() + sut.withStartup(owner) { nodeRunning.complete(Unit) } + restore.join() + } + + @Test + fun `older startup replay cannot overwrite a newly acknowledged save`() = test { + val state = MutableStateFlow(SettingsData()) + val store = mock() + whenever(store.data).thenReturn(state) + var pauseReplay = false + val replayWriting = CompletableDeferred() + val releaseReplay = CompletableDeferred() + whenever(store.update(any())).doSuspendableAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + if (pauseReplay) { + pauseReplay = false + replayWriting.complete(Unit) + releaseReplay.await() + } + state.value = transform(state.value) + } + val coordinator = instance(store) + val owner = coordinator.captureOwner() + coordinator.withOperation(owner) { it.save(electrum = "ssl://first.example:50002") } + pauseReplay = true + val replay = launch { coordinator.withStartup(owner) { it.replay() } } + replayWriting.await() + var saved = false + val save = launch { + coordinator.withOperation(owner) { it.save(electrum = "ssl://second.example:50002") } + saved = true + } + runCurrent() + assertFalse(saved) + releaseReplay.complete(Unit) + replay.join() + save.join() + assertTrue(saved) + assertEquals("ssl://second.example:50002", state.value.electrumServer) + } + + @Test + fun `ordinary admission without corrections does not derive backup credentials`() = test { + whenever(binding.getBackupWalletBinding(0)).thenAnswer { throw AppError("binding unavailable") } + sut.withCurrentWallet { assertEquals(null, it.replay()) } + sut.withStartup(sut.captureRuntimeOwner()) { assertEquals(null, it.replay()) } + } + + @Test + fun `runtime admission still validates persisted correction credentials`() = test { + sut.withOperation(sut.captureOwner()) { it.save(electrum = "ssl://corrected.example:50002") } + whenever(binding.getBackupWalletBinding(0)).thenAnswer { throw AppError("binding unavailable") } + assertFailsWith { sut.withCurrentWallet { it.replay() } } + assertTrue(record != null) + } + + @Test + fun `capture requested during replacement never adopts its new lifecycle`() = test { + val replacing = CompletableDeferred() + val release = CompletableDeferred() + val replacement = launch { + sut.withIdentityChange { + replacing.complete(Unit) + release.await() + } + } + replacing.await() + val capture = async { runCatching { sut.captureOwner() } } + runCurrent() + val rejectedWithoutWaiting = capture.isCompleted + release.complete(Unit) + replacement.join() + val result = capture.await() + assertTrue(rejectedWithoutWaiting) + assertTrue(result.exceptionOrNull() is CancellationException) + } + + @Test + fun `rejected conditional migration preserves the current wallet admission`() = test { + val owner = sut.captureOwner() + assertFailsWith { + sut.withIdentityChange(isStillNeeded = { false }) { error("stale migration must not run") } + } + sut.withOperation(owner) { assertEquals(null, it.replay()) } + } +} diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index cd7289587f..4848b75228 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -43,6 +43,7 @@ import to.bitkit.data.HwWalletData import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.data.WalletConnectionRecovery import to.bitkit.data.WatchOnlyAccountAllocationState import to.bitkit.data.WatchOnlyAccountBackupSnapshot import to.bitkit.data.WatchOnlyAccountData @@ -72,6 +73,7 @@ import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.services.LightningService import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.connectionRecoveryFixture import to.bitkit.utils.AppError import javax.inject.Provider import kotlin.coroutines.cancellation.CancellationException @@ -2157,6 +2159,22 @@ class BackupRepoTest : BaseUnitTest() { whenever(lightningService.syncStatusChanged).thenReturn(MutableSharedFlow()) } + @Test + fun `native restore gate survives malformed connection correction`() = test { + whenever(keychain.loadString(WalletConnectionRecovery.KEY, 0)).thenReturn("malformed") + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(keychain).upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, "") + verify(vssBackupClient, never()).getObject(any()) + } + + @Test + fun `legacy USDT restore gate precedes malformed connection correction`() = test { + whenever(keychain.loadString(WalletConnectionRecovery.KEY, 0)).thenReturn("malformed") + assertTrue(sut.restoreUsdtRecoveryAfterLegacyMigration().isFailure) + verify(keychain).upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, "") + verify(vssBackupClient, never()).getObject(any()) + } + private fun createSut() = BackupRepo( context = context, ioDispatcher = testDispatcher, @@ -2190,6 +2208,7 @@ class BackupRepoTest : BaseUnitTest() { lightningService = lightningService, clock = clock, db = db, + connectionRecovery = connectionRecoveryFixture(settingsStore, keychain), ) private class BackupRepoTestError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index a06dcd724b..1bf37156d7 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -67,6 +67,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.keychain.Keychain +import to.bitkit.data.keychain.WalletBackupRestorePendingError import to.bitkit.ext.createChannelDetails import to.bitkit.ext.of import to.bitkit.models.ActiveOnchainAttemptBackup @@ -86,6 +87,7 @@ import to.bitkit.services.LspNotificationsService import to.bitkit.services.NetworkGraphInfo import to.bitkit.services.NodeEventHandler import to.bitkit.test.BaseUnitTest +import to.bitkit.test.connectionRecoveryFixture import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.UrlValidator @@ -230,6 +232,7 @@ class LightningRepoTest : BaseUnitTest() { transferRepo = mock(), activityRepo = mock(), hwWalletRepo = hardware, + connectionRecovery = connectionRecoveryFixture(settingsStore), ) } @@ -1082,6 +1085,109 @@ class LightningRepoTest : BaseUnitTest() { verify(lightningService).resetNetworkGraph(0) } + @Test + fun `pending wallet recovery blocks new receiving data while node remains available`() = test { + startNodeForTesting() + whenever(lightningService.newAddress()).thenReturn("address") + for (marker in listOf(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE, Keychain.Key.USDT_PENDING_BACKUP_RESTORE)) { + whenever(keychain.exists(marker.name)).thenReturn(true) + assertIs(sut.newAddress().exceptionOrNull()) + assertIs(sut.newAddressForType(AddressType.P2WPKH).exceptionOrNull()) + assertIs(sut.newAddressInfoForType(AddressType.P2WPKH).exceptionOrNull()) + assertIs(sut.createInvoice(description = "test").exceptionOrNull()) + assertIs(sut.createInvoiceMsats(1000uL, "test").exceptionOrNull()) + assertTrue(sut.revealReceiveAddresses(505, AddressType.P2WPKH).isSuccess) + assertTrue(sut.sync().isSuccess) + whenever(keychain.exists(marker.name)).thenReturn(false) + } + verify(lightningService, never()).newAddress() + verify(lightningService, never()).newAddressForType(any()) + verify(lightningService, never()).newAddressInfoForType(any()) + verify(lightningService, never()).receive(anyOrNull(), any(), any()) + verify(lightningService, never()).receiveMsats(any(), any(), any()) + assertEquals("address", sut.newAddress().getOrThrow()) + } + + @Test + fun `pending wallet recovery prevents ordinary payment admission`() = test { + startNodeForTesting() + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + whenever(lightningService.channels).thenReturn(listOf(createChannelDetails().copy(isUsable = true))) + var beforeSend = false + sut.payInvoice("invoice", null, onBeforeSend = { + beforeSend = true + true + }) + assertFalse(beforeSend) + assertIs(sut.sendOnChain("address", 1000uL).exceptionOrNull()) + verify(onchainSendAttemptStore, never()).current() + verify(lightningService, never()).send(any(), anyOrNull(), anyOrNull(), anyOrNull()) + } + + @Test + fun `restore beginning during payment authorization prevents dispatch`() = test { + startNodeForTesting() + val result = sut.payInvoice("invoice", null, onBeforeSend = { + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + true + }) + assertIs(result.exceptionOrNull()) + verify(lightningService, never()).send(any(), anyOrNull(), anyOrNull(), anyOrNull()) + } + + @Test + fun `unreadable restore admission fails closed`() = test { + startNodeForTesting() + val failure = IllegalStateException("Keychain unavailable") + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenThrow(failure) + assertEquals(failure, sut.newAddress().exceptionOrNull()) + verify(lightningService, never()).newAddress() + } + + @Test + fun `recreated repository retains durable recovery admission`() = test { + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + repeat(2) { + setUp() + startNodeForTesting() + assertIs(sut.newAddress().exceptionOrNull()) + } + verify(lightningService, never()).newAddress() + } + + @Test + fun `pending restore blocks explicit recovery channel close and fee mutations`() = test { + startNodeForTesting() + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + val results = listOf( + sut.retryOriginalOnchainSend("attempt", "wallet", 1uL, authorizeOriginal = { error("Must not authorize") }), + sut.closeChannel(createChannelDetails()), + sut.openChannel(mock(), 1000uL), + sut.bumpFeeByRbf("txid", 1uL), + sut.accelerateByCpfp("txid", 1uL, "address"), + ) + results.forEach { assertIs(it.exceptionOrNull()) } + assertFailsWith { sut.prepareRelease(pendingSendAttempt()) } + verify(lightningService, never()).prepareRelease(any()) + } + + @Test + fun `restore beginning after release preparation prevents broadcast`() = test { + val attempt = pendingSendAttempt() + var broadcasts = 0 + val native = PreparedOnchainSend(preparedOutcome(OnchainSendOutcome.Accepted("release")).receipt) { + broadcasts++ + OnchainSendOutcome.Accepted("release") + } + whenever(lightningService.prepareRelease(attempt)).thenReturn(native) + val prepared = sut.prepareRelease(attempt) + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + repeat(2) { + assertFailsWith { prepared.broadcast() } + } + assertEquals(0, broadcasts) + } + @Test fun `newAddress should fail when node is not running`() = test { val result = sut.newAddress() diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index e4506b8a6b..a2b650aab8 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -12,14 +12,17 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.ChannelDetails @@ -49,6 +52,7 @@ import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService import to.bitkit.services.OnchainService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.connectionRecoveryFixture import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeIncomplete import to.bitkit.usecases.WipeWalletUseCase @@ -78,6 +82,7 @@ class WalletRepoTest : BaseUnitTest() { private val onchainService = mock() private val activityRepo = mock() private val hwWalletRepo = mock() + private val connectionRecovery = connectionRecoveryFixture(settingsStore) companion object Fixtures { const val ACTIVITY_TAG = "testTag" @@ -173,6 +178,7 @@ class WalletRepoTest : BaseUnitTest() { transferRepo = transferRepo, activityRepo = activityRepo, hwWalletRepo = hwWalletRepo, + connectionRecovery = connectionRecovery, ) private fun lightningInvoice(bolt11: String, isExpired: Boolean = false) = LightningInvoice( @@ -1783,4 +1789,16 @@ class WalletRepoTest : BaseUnitTest() { count = 2u, ) } + + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + @Test + fun `connection recovery admits the newly published restored wallet`() = test { + whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(true) + val observed = async(UnconfinedTestDispatcher(testScheduler)) { + sut.walletState.first { it.walletExists } + runCatching { connectionRecovery.captureRuntimeOwner() } + } + sut.restoreWallet("restored-mnemonic", null).getOrThrow() + assertTrue(observed.await().isSuccess) + } } diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index 2e9736f816..517ba4c656 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -1,14 +1,19 @@ package to.bitkit.services import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json @@ -51,6 +56,7 @@ import to.bitkit.data.WatchOnlyAccountReconciliationState import to.bitkit.data.WatchOnlyAccountStore import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.keychain.Keychain +import to.bitkit.data.keychain.WalletBackupRestorePendingError import to.bitkit.env.Env import to.bitkit.ext.createChannelDetails import to.bitkit.models.ActiveOnchainAttemptBackup @@ -103,7 +109,11 @@ class LightningServiceTest : BaseUnitTest() { @Before fun setUp() { - sut = LightningService( + sut = createService() + } + + private fun createService(ldkDispatcher: CoroutineDispatcher = testDispatcher): LightningService = + LightningService( bgDispatcher = testDispatcher, ioDispatcher = testDispatcher, keychain = keychain, @@ -112,10 +122,39 @@ class LightningServiceTest : BaseUnitTest() { watchOnlyAccountStore = watchOnlyAccountStore, loggerLdk = loggerLdk, watchOnlyAccountLifecycleCoordinator = watchOnlyAccountLifecycleCoordinator, - ldkQueue = testDispatcher, + ldkQueue = ldkDispatcher, onchainFeeRateFactory = { sendFeeRate }, - ) - sut.node = node + ).also { it.node = node } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `queued address allocation rechecks recovery before native mutation`() = test { + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(onchainPayment.newAddress()).thenReturn("bcrt1qnew") + val queued = createService(StandardTestDispatcher(testScheduler)) + val result = async { runCatching { queued.newAddress() } } + assertFalse(result.isCompleted) + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + runCurrent() + assertTrue(result.await().exceptionOrNull()?.cause is WalletBackupRestorePendingError) + verify(onchainPayment, never()).newAddress() + } + + @Test + fun `prepared native broadcast rechecks recovery after caller suspension`() = test { + val txid = "ab".repeat(32) + val native = preparedNative(txid) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(onchainPayment.prepareSendToAddress("address", 1_000uL, sendFeeRate, null)).thenReturn(native) + whenever(native.broadcast()).thenReturn(OnchainSendResult.Accepted(txid)) + val prepared = sut.prepareOnchainSend("address", 1_000uL, 1uL) + val result = runCatching { + prepared.broadcast { + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + } + } + assertTrue(result.exceptionOrNull() is WalletBackupRestorePendingError) + verify(native, never()).broadcast() } @Test diff --git a/app/src/test/java/to/bitkit/test/ConnectionRecoveryFixture.kt b/app/src/test/java/to/bitkit/test/ConnectionRecoveryFixture.kt new file mode 100644 index 0000000000..5f8ccf5859 --- /dev/null +++ b/app/src/test/java/to/bitkit/test/ConnectionRecoveryFixture.kt @@ -0,0 +1,25 @@ +package to.bitkit.test + +import kotlinx.coroutines.flow.MutableStateFlow +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.AppDb +import to.bitkit.data.SettingsStore +import to.bitkit.data.WalletConnectionRecovery +import to.bitkit.data.backup.VssStoreIdProvider +import to.bitkit.data.entities.ConfigEntity +import to.bitkit.data.keychain.Keychain + +fun connectionRecoveryFixture( + settings: SettingsStore, + keychain: Keychain = mock(), + currentBinding: () -> String = { "test-wallet-binding" }, +): WalletConnectionRecovery { + val db = mock() + whenever(db.configDao()).thenReturn(mock()) + whenever(db.configDao().getAll()).thenReturn(MutableStateFlow(listOf(ConfigEntity()))) + val binding = mock() + whenever(binding.getBackupWalletBinding(any())).thenAnswer { currentBinding() } + return WalletConnectionRecovery(keychain, db, binding, settings) +} diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 898a9e08a6..b47ca98c7b 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -1,14 +1,21 @@ package to.bitkit.ui import android.content.Context +import app.cash.turbine.test import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.withContext import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.ChannelDataMigration @@ -17,6 +24,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -26,6 +34,9 @@ import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.data.WalletConnectionRecovery +import to.bitkit.data.keychain.Keychain +import to.bitkit.data.keychain.hasPendingWalletRestore import to.bitkit.ext.of import to.bitkit.models.BalanceState import to.bitkit.models.NodeLifecycleState @@ -44,9 +55,12 @@ import to.bitkit.services.BoltzService import to.bitkit.services.MigrationService import to.bitkit.services.PendingChannelMigration import to.bitkit.test.BaseUnitTest +import to.bitkit.test.connectionRecoveryFixture +import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.WalletViewModel +import to.bitkit.viewmodels.canProceedWithoutRestore import kotlin.coroutines.cancellation.CancellationException import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -61,6 +75,9 @@ class WalletViewModelTest : BaseUnitTest() { private val walletRepo = mock() private val lightningRepo = mock() private val settingsStore = mock() + private val recoveryKeychain = mock() + private lateinit var connectionRecovery: WalletConnectionRecovery + private var currentBinding = "test-wallet-binding" private val backupRepo = mock() private val blocktankRepo = mock() private val pubkyRepo = mock() @@ -80,6 +97,7 @@ class WalletViewModelTest : BaseUnitTest() { whenever(walletRepo.walletState).thenReturn(walletState) whenever(walletRepo.refreshReceiveState()).thenReturn(Result.success(Unit)) whenever(lightningRepo.lightningState).thenReturn(lightningState) + whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) whenever(migrationService.isMigrationChecked()).thenReturn(true) whenever(migrationService.isChannelRecoveryChecked()).thenReturn(true) whenever(migrationService.isRestoringFromRNRemoteBackup).thenReturn(MutableStateFlow(false)) @@ -92,6 +110,7 @@ class WalletViewModelTest : BaseUnitTest() { whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) }.thenReturn(Result.success(1uL)) + connectionRecovery = connectionRecoveryFixture(settingsStore, recoveryKeychain) { currentBinding } sut = WalletViewModel( context = context, bgDispatcher = testDispatcher, @@ -104,6 +123,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + connectionRecovery = connectionRecovery, ) } @@ -274,13 +294,13 @@ class WalletViewModelTest : BaseUnitTest() { walletState.value = walletState.value.copy(walletExists = true) - verify(backupRepo, never()).performFullRestoreFromLatestBackup() + verify(backupRepo, never()).performFullRestoreFromLatestBackup(any(), anyOrNull()) } @Test fun `onBackupRestoreSuccess should reset restoreState`() = test { whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) - whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).thenReturn(Result.success(Unit)) walletState.value = walletState.value.copy(walletExists = true) sut.restoreWallet("mnemonic", "passphrase") assertEquals(RestoreState.InProgress.Wallet, sut.restoreState.value) @@ -326,7 +346,7 @@ class WalletViewModelTest : BaseUnitTest() { fun `failed backup restore can retry without restarting node`() = test { val testError = AppError("restore failed") whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) - whenever(backupRepo.performFullRestoreFromLatestBackup(any())).thenReturn( + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).thenReturn( Result.failure(testError), Result.success(Unit), ) @@ -334,16 +354,458 @@ class WalletViewModelTest : BaseUnitTest() { sut.restoreWallet("mnemonic", "passphrase") walletState.value = walletState.value.copy(walletExists = true) advanceUntilIdle() - assertEquals(RestoreState.BackupFailed(1), sut.restoreState.value) + assertEquals(RestoreState.BackupFailed(1, hasPendingWalletRestore = false), sut.restoreState.value) sut.onBackupRestoreRetry() advanceUntilIdle() assertEquals(RestoreState.Completed, sut.restoreState.value) - verify(backupRepo, times(2)).performFullRestoreFromLatestBackup(any()) + verify(backupRepo, times(2)).performFullRestoreFromLatestBackup(any(), anyOrNull()) verify(lightningRepo, never()).restartNode() } + @Test + fun `replacement cancels editor opening without leaving it busy`() = test { + val reading = CompletableDeferred() + whenever(settingsStore.data).thenReturn( + flow { + reading.complete(Unit) + awaitCancellation() + } + ) + sut.openConnectionRecovery() + reading.await() + connectionRecovery.withIdentityChange { } + advanceUntilIdle() + val cancelled = checkNotNull(sut.connectionEditor.value) + assertFalse(cancelled.busy) + assertEquals(null, cancelled.owner) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) + sut.retryRecoveryConnection() + advanceUntilIdle() + assertFalse(checkNotNull(sut.connectionEditor.value).busy) + assertTrue(sut.connectionEditor.value?.owner != null) + } + + @Test + fun `replacement cancels editor save without retaining its old owner`() = test { + sut.openConnectionRecovery() + advanceUntilIdle() + sut.editRecoveryElectrum("ssl://electrum.example.com:50002") + val validating = CompletableDeferred() + whenever(lightningRepo.validateElectrumServer(any())).doSuspendableAnswer { + validating.complete(Unit) + awaitCancellation() + } + sut.saveRecoveryConnection(electrum = true) + validating.await() + connectionRecovery.withIdentityChange { } + advanceUntilIdle() + val cancelled = checkNotNull(sut.connectionEditor.value) + assertFalse(cancelled.busy) + assertEquals(null, cancelled.owner) + assertFalse(cancelled.saved) + sut.retryRecoveryConnection() + advanceUntilIdle() + assertTrue(sut.connectionEditor.value?.owner != null) + verify(lightningRepo, never()).restartNode(anyOrNull(), any()) + } + + @Test + fun `cancelled save cleanup cannot overwrite a newly opened editor`() = test { + sut.openConnectionRecovery() + advanceUntilIdle() + val original = checkNotNull(sut.connectionEditor.value) + sut.editRecoveryElectrum("ssl://electrum.example.com:50002") + val validating = CompletableDeferred() + val release = CompletableDeferred() + whenever(lightningRepo.validateElectrumServer(any())).doSuspendableAnswer { + validating.complete(Unit) + withContext(NonCancellable) { release.await() } + Result.success(Unit) + } + sut.saveRecoveryConnection(electrum = true) + validating.await() + sut.closeConnectionRecovery() + sut.openConnectionRecovery() + runCurrent() + val replacement = checkNotNull(sut.connectionEditor.value) + assertTrue(replacement.id != original.id) + assertTrue(replacement.busy) + release.complete(Unit) + advanceUntilIdle() + val completed = checkNotNull(sut.connectionEditor.value) + assertEquals(replacement.id, completed.id) + assertFalse(completed.busy) + assertFalse(completed.saved) + assertTrue(completed.owner != null) + assertEquals(null, completed.error) + } + + @Test + fun `ordinary startup failure can proceed only after affirmative marker clearance`() = test { + whenever( + lightningRepo.restartNode(anyOrNull(), any()) + ).thenReturn(Result.failure(AppError("storage unavailable"))) + repeat(2) { + sut.onRestoreRetry() + advanceUntilIdle() + } + val failure = checkNotNull(sut.connectionStartupError.value) + assertEquals(2, sut.restoreState.value.retryCount()) + assertTrue(canProceedWithoutRestore(sut.restoreState.value, failure.canProceedWithoutRestore)) + var dismissed = 0 + sut.onProceedWithoutRestore { dismissed++ } + advanceUntilIdle() + assertEquals(1, dismissed) + assertEquals(null, sut.connectionStartupError.value) + assertEquals(RestoreState.Settled, sut.restoreState.value) + } + + @Test + fun `pending or unreadable markers never grant ordinary startup clearance`() = test { + whenever( + lightningRepo.restartNode(anyOrNull(), any()) + ).thenReturn(Result.failure(AppError("storage unavailable"))) + for (unreadable in listOf(false, true)) { + whenever(backupRepo.hasPendingWalletRestore()).doSuspendableAnswer { + if (unreadable) throw AppError("keychain unavailable") else true + } + sut.onRestoreRetry() + advanceUntilIdle() + assertFalse(checkNotNull(sut.connectionStartupError.value).canProceedWithoutRestore) + var dismissed = false + sut.onProceedWithoutRestore { dismissed = true } + advanceUntilIdle() + assertFalse(dismissed) + } + verify(lightningRepo, never()).stop() + } + + @Test + fun `failed node stop cannot acknowledge proceeding without restore`() = test { + whenever( + lightningRepo.restartNode(anyOrNull(), any()) + ).thenReturn(Result.failure(AppError("storage unavailable"))) + sut.onRestoreRetry() + advanceUntilIdle() + val failure = sut.connectionStartupError.value + lightningState.value = lightningState.value.copy( + nodeLifecycleState = NodeLifecycleState.ErrorStarting(AppError("storage")) + ) + whenever(lightningRepo.stop()).thenReturn(Result.failure(AppError("stop persistence failed"))) + var dismissed = false + sut.onProceedWithoutRestore { dismissed = true } + advanceUntilIdle() + assertFalse(dismissed) + assertEquals(failure, sut.connectionStartupError.value) + } + + @Test + fun `marker appearing during proceed delay retains failure and prevents dismissal`() = test { + whenever( + lightningRepo.restartNode(anyOrNull(), any()) + ).thenReturn(Result.failure(AppError("storage unavailable"))) + sut.onRestoreRetry() + advanceUntilIdle() + val failure = sut.connectionStartupError.value + lightningState.value = lightningState.value.copy( + nodeLifecycleState = NodeLifecycleState.ErrorStarting(AppError("storage")) + ) + var pending = false + whenever(backupRepo.hasPendingWalletRestore()).doSuspendableAnswer { pending } + var dismissed = false + sut.onProceedWithoutRestore { dismissed = true } + runCurrent() + pending = true + advanceUntilIdle() + assertFalse(dismissed) + assertEquals(failure?.message, sut.connectionStartupError.value?.message) + assertFalse(checkNotNull(sut.connectionStartupError.value).canProceedWithoutRestore) + verify(lightningRepo).stop() + } + + @Test + fun `replacement during failure marker read cannot publish old clearance`() = test { + whenever( + lightningRepo.restartNode(anyOrNull(), any()) + ).thenReturn(Result.failure(AppError("storage unavailable"))) + var reads = 0 + val reading = CompletableDeferred() + whenever(backupRepo.hasPendingWalletRestore()).doSuspendableAnswer { + reads++ + if (reads > 1) { + reading.complete(Unit) + awaitCancellation() + } + false + } + ToastEventBus.events.test { + sut.onRestoreRetry() + reading.await() + connectionRecovery.withIdentityChange { } + advanceUntilIdle() + assertEquals(null, sut.connectionStartupError.value) + assertFalse(sut.connectionRetryBusy.value) + expectNoEvents() + } + } + + @Test + fun `replacement drains proceed stop without old dismissal`() = test { + whenever( + lightningRepo.restartNode(anyOrNull(), any()) + ).thenReturn(Result.failure(AppError("storage unavailable"))) + sut.onRestoreRetry() + advanceUntilIdle() + val failure = sut.connectionStartupError.value + lightningState.value = lightningState.value.copy( + nodeLifecycleState = NodeLifecycleState.ErrorStarting(AppError("storage")) + ) + val stopping = CompletableDeferred() + val release = CompletableDeferred() + whenever(lightningRepo.stop()).doSuspendableAnswer { + stopping.complete(Unit) + withContext(NonCancellable) { release.await() } + Result.success(Unit) + } + var dismissed = false + var replaced = false + sut.onProceedWithoutRestore { dismissed = true } + stopping.await() + val replacement = launch { connectionRecovery.withIdentityChange { replaced = true } } + runCurrent() + assertFalse(replaced) + release.complete(Unit) + replacement.join() + advanceUntilIdle() + assertTrue(replaced) + assertFalse(dismissed) + assertEquals(failure, sut.connectionStartupError.value) + sut.onProceedWithoutRestore { dismissed = true } + advanceUntilIdle() + assertFalse(dismissed) + } + + @Test + fun `editor retry reports one toast and inline error without stale underlying clearance`() = test { + whenever( + lightningRepo.restartNode(anyOrNull(), any()) + ).thenReturn(Result.failure(AppError("storage unavailable"))) + sut.openConnectionRecovery() + advanceUntilIdle() + ToastEventBus.events.test { + sut.retryRecoveryConnection() + advanceUntilIdle() + assertEquals("storage unavailable", awaitItem().description) + expectNoEvents() + assertEquals("storage unavailable", sut.connectionEditor.value?.error) + assertFalse(checkNotNull(sut.connectionEditor.value).busy) + assertTrue(checkNotNull(sut.connectionStartupError.value).canProceedWithoutRestore) + sut.closeConnectionRecovery() + assertTrue(checkNotNull(sut.connectionStartupError.value).canProceedWithoutRestore) + } + } + + @Test + fun `editor control and cancellation release busy state without toast or clearance`() = test { + ToastEventBus.events.test { + for (error in listOf(NodeStartYieldedToStopError(null), CancellationException("cancelled"))) { + sut.openConnectionRecovery() + advanceUntilIdle() + whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn(Result.failure(error)) + sut.retryRecoveryConnection() + advanceUntilIdle() + assertFalse(checkNotNull(sut.connectionEditor.value).busy) + assertFalse(sut.connectionRetryBusy.value) + assertEquals(null, sut.connectionStartupError.value) + expectNoEvents() + sut.closeConnectionRecovery() + } + } + } + + @Test + fun `changed credential owner invalidates editor and permits explicit reopening`() = test { + sut.openConnectionRecovery() + advanceUntilIdle() + val originalOwner = checkNotNull(sut.connectionEditor.value?.owner) + currentBinding = "replacement-wallet-binding" + ToastEventBus.events.test { + sut.retryRecoveryConnection() + advanceUntilIdle() + val invalidated = checkNotNull(sut.connectionEditor.value) + assertFalse(invalidated.busy) + assertEquals(null, invalidated.owner) + assertTrue(invalidated.error.orEmpty().contains("wallet changed")) + assertEquals(null, sut.connectionStartupError.value) + expectNoEvents() + } + sut.retryRecoveryConnection() + advanceUntilIdle() + val reopened = checkNotNull(sut.connectionEditor.value) + assertFalse(reopened.busy) + assertTrue(reopened.owner != null && reopened.owner !== originalOwner) + verify(lightningRepo, never()).restartNode(anyOrNull(), any()) + } + + @Test + fun `each real keychain restore marker separately denies startup clearance`() = test { + whenever( + lightningRepo.restartNode(anyOrNull(), any()) + ).thenReturn(Result.failure(AppError("storage unavailable"))) + val markers = mutableSetOf() + whenever(recoveryKeychain.exists(any())).thenAnswer { markers.contains(it.getArgument(0)) } + whenever( + backupRepo.hasPendingWalletRestore() + ).doSuspendableAnswer { recoveryKeychain.hasPendingWalletRestore() } + for (pending in listOf( + setOf(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name), + setOf(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name), + setOf(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name), + emptySet(), + )) { + markers.clear() + markers.addAll(pending) + sut.onRestoreRetry() + advanceUntilIdle() + assertEquals(pending.isEmpty(), checkNotNull(sut.connectionStartupError.value).canProceedWithoutRestore) + } + } + + @Test + fun `unavailable final marker read revokes proceed without dismissing`() = test { + whenever( + lightningRepo.restartNode(anyOrNull(), any()) + ).thenReturn(Result.failure(AppError("storage unavailable"))) + sut.onRestoreRetry() + advanceUntilIdle() + lightningState.value = lightningState.value.copy( + nodeLifecycleState = NodeLifecycleState.ErrorStarting(AppError("storage")) + ) + var reads = 0 + whenever(backupRepo.hasPendingWalletRestore()).doSuspendableAnswer { + if (++reads > 1) throw AppError("keychain unavailable") + false + } + var dismissed = false + sut.onProceedWithoutRestore { dismissed = true } + advanceUntilIdle() + assertFalse(dismissed) + assertFalse(checkNotNull(sut.connectionStartupError.value).canProceedWithoutRestore) + } + + @Test + fun `admitted editor cancellation permits explicit fresh owner capture`() = test { + val starting = CompletableDeferred() + whenever(lightningRepo.restartNode(anyOrNull(), any())).doSuspendableAnswer { + starting.complete(Unit) + awaitCancellation() + } + sut.openConnectionRecovery() + advanceUntilIdle() + val originalOwner = sut.connectionEditor.value?.owner + sut.retryRecoveryConnection() + starting.await() + connectionRecovery.withIdentityChange { } + advanceUntilIdle() + val cancelled = checkNotNull(sut.connectionEditor.value) + assertFalse(cancelled.busy) + assertEquals(null, cancelled.owner) + sut.retryRecoveryConnection() + advanceUntilIdle() + assertTrue(sut.connectionEditor.value?.owner != null && sut.connectionEditor.value?.owner !== originalOwner) + verify(lightningRepo, times(1)).restartNode(anyOrNull(), any()) + } + + @Test + fun `newer retry prevents held older failure from publishing`() = test { + walletState.value = walletState.value.copy(walletExists = true) + advanceUntilIdle() + whenever( + lightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), any()) + ) + .thenReturn(Result.failure(AppError("older failure"))) + whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn(Result.success(Unit)) + val reading = CompletableDeferred() + val release = CompletableDeferred() + var first = true + whenever(backupRepo.hasPendingWalletRestore()).doSuspendableAnswer { + if (first) { + first = false + reading.complete(Unit) + release.await() + } + false + } + ToastEventBus.events.test { + sut.start() + reading.await() + sut.onRestoreRetry() + runCurrent() + release.complete(Unit) + advanceUntilIdle() + assertEquals(null, sut.connectionStartupError.value) + expectNoEvents() + } + } + + @Test + fun `control failure retry has no ordinary toast or clearance`() = test { + ToastEventBus.events.test { + for (error in listOf(NodeStartYieldedToStopError(null), CancellationException("cancelled"))) { + whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn(Result.failure(error)) + sut.onRestoreRetry() + advanceUntilIdle() + assertEquals(null, sut.connectionStartupError.value) + assertFalse(sut.connectionRetryBusy.value) + expectNoEvents() + } + } + } + + @Test + fun `unknown restore state is not affirmative proceed permission`() { + assertFalse(canProceedWithoutRestore(RestoreState.Initial, null)) + assertFalse(canProceedWithoutRestore(RestoreState.Retry(2), null)) + assertFalse(canProceedWithoutRestore(RestoreState.BackupFailed(2), null)) + assertTrue(canProceedWithoutRestore(RestoreState.BackupFailed(2, false), null)) + assertFalse(canProceedWithoutRestore(RestoreState.BackupFailed(2, false), false)) + assertTrue(canProceedWithoutRestore(RestoreState.Retry(2), true)) + } + + @Test + fun `ordinary node startup failure emits one error toast`() = test { + whenever(walletRepo.walletExists()).thenReturn(true) + walletState.value = walletState.value.copy(walletExists = true) + whenever( + lightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), any()), + ).thenReturn(Result.failure(AppError("node storage unavailable"))) + + ToastEventBus.events.test { + sut.start() + advanceUntilIdle() + assertEquals("node storage unavailable", awaitItem().description) + expectNoEvents() + } + } + + @Test + fun `ordinary node retry failure emits one error toast per attempt`() = test { + whenever( + lightningRepo.restartNode(anyOrNull(), any()) + ).thenReturn(Result.failure(AppError("node storage unavailable"))) + + ToastEventBus.events.test { + repeat(2) { + sut.onRestoreRetry() + advanceUntilIdle() + assertEquals("node storage unavailable", awaitItem().description) + expectNoEvents() + } + } + } + @Test fun `node restore retry remains separate from backup recovery`() = test { sut.onRestoreRetry() @@ -351,12 +813,14 @@ class WalletViewModelTest : BaseUnitTest() { assertEquals(RestoreState.Retry(1), sut.restoreState.value) verify(lightningRepo).restartNode() - verify(backupRepo, never()).performFullRestoreFromLatestBackup(any()) + verify(backupRepo, never()).performFullRestoreFromLatestBackup(any(), anyOrNull()) } @Test fun `node retry preserves pending backup recovery and increments retry count`() = test { whenever(backupRepo.hasPendingWalletRestore()).thenReturn(true) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())) + .thenReturn(Result.failure(AppError("backup still unavailable"))) walletState.value = walletState.value.copy(walletExists = true) lightningState.value = lightningState.value.copy( nodeLifecycleState = NodeLifecycleState.ErrorStarting(AppError("start failed")), @@ -369,19 +833,21 @@ class WalletViewModelTest : BaseUnitTest() { assertEquals(RestoreState.BackupFailed(2), sut.restoreState.value) verify(lightningRepo).restartNode() - verify(backupRepo, never()).performFullRestoreFromLatestBackup(any()) + verify(backupRepo).performFullRestoreFromLatestBackup(any(), anyOrNull()) } @Test fun `onProceedWithoutRestore should exit failed backup restore without stopping running node`() = test { val testError = AppError("restore failed") whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) - whenever(backupRepo.performFullRestoreFromLatestBackup(any())).thenReturn(Result.failure(testError)) + whenever( + backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull()) + ).thenReturn(Result.failure(testError)) lightningState.value = lightningState.value.copy(nodeLifecycleState = NodeLifecycleState.Running) sut.restoreWallet("mnemonic", "passphrase") walletState.value = walletState.value.copy(walletExists = true) advanceUntilIdle() - assertEquals(RestoreState.BackupFailed(1), sut.restoreState.value) + assertEquals(RestoreState.BackupFailed(1, hasPendingWalletRestore = false), sut.restoreState.value) sut.onProceedWithoutRestore(onDone = {}) advanceUntilIdle() @@ -390,6 +856,90 @@ class WalletViewModelTest : BaseUnitTest() { verify(lightningRepo, never()).stop() } + @Test + fun `proceed cannot dismiss pending wallet recovery`() = test { + whenever(backupRepo.hasPendingWalletRestore()).thenReturn(true) + walletState.value = walletState.value.copy(walletExists = true) + lightningState.value = lightningState.value.copy(nodeLifecycleState = NodeLifecycleState.Running) + advanceUntilIdle() + val pendingState = sut.restoreState.value + var dismissed = false + + repeat(3) { + sut.onProceedWithoutRestore { dismissed = true } + advanceUntilIdle() + } + + assertEquals(pendingState, sut.restoreState.value) + assertFalse(dismissed) + verify(lightningRepo, never()).stop() + } + + @Test + fun `post restore profile failure allows proceeding only while safety markers stay clear`() = test { + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever(pubkyRepo.initialize()).doSuspendableAnswer { throw AppError("profile cache unavailable") } + sut.onBackupRestoreRetry() + advanceUntilIdle() + assertEquals(RestoreState.BackupFailed(1, hasPendingWalletRestore = false), sut.restoreState.value) + sut.onBackupRestoreRetry() + advanceUntilIdle() + assertEquals(RestoreState.BackupFailed(2, hasPendingWalletRestore = false), sut.restoreState.value) + assertEquals(null, sut.connectionStartupError.value) + + whenever(backupRepo.hasPendingWalletRestore()).thenReturn(true) + var dismissed = false + sut.onProceedWithoutRestore { dismissed = true } + advanceUntilIdle() + assertFalse(dismissed) + assertTrue(sut.restoreState.value is RestoreState.BackupFailed) + + whenever(backupRepo.hasPendingWalletRestore()).thenReturn(false) + sut.onProceedWithoutRestore { dismissed = true } + advanceUntilIdle() + assertTrue(dismissed) + assertEquals(RestoreState.Settled, sut.restoreState.value) + verify(backupRepo, times(2)).performFullRestoreFromLatestBackup(any(), anyOrNull()) + } + + @Test + fun `failed safety marker inspection keeps restore failure closed`() = test { + whenever( + backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull()) + ).thenReturn(Result.failure(AppError("offline"))) + whenever(backupRepo.hasPendingWalletRestore()).doSuspendableAnswer { throw AppError("keychain unavailable") } + sut.onBackupRestoreRetry() + advanceUntilIdle() + assertEquals(RestoreState.BackupFailed(1), sut.restoreState.value) + var dismissed = false + sut.onProceedWithoutRestore { dismissed = true } + advanceUntilIdle() + assertFalse(dismissed) + assertEquals(RestoreState.BackupFailed(1), sut.restoreState.value) + } + + @Test + fun `overlapping backup retries perform one restore`() = test { + val finishRestore = CompletableDeferred() + var calls = 0 + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).doSuspendableAnswer { + calls++ + finishRestore.await() + Result.success(Unit) + } + sut.onBackupRestoreRetry() + sut.onBackupRestoreRetry() + runCurrent() + try { + assertEquals(1, calls) + assertEquals(RestoreState.InProgress.Metadata, sut.restoreState.value) + } finally { + finishRestore.complete(Unit) + } + advanceUntilIdle() + assertEquals(RestoreState.Completed, sut.restoreState.value) + } + @Test fun `pending wallet restore is offered after wallet state reloads`() = test { whenever(backupRepo.hasPendingWalletRestore()).thenReturn(true) @@ -398,7 +948,7 @@ class WalletViewModelTest : BaseUnitTest() { advanceUntilIdle() assertEquals(RestoreState.BackupFailed(1), sut.restoreState.value) - verify(backupRepo, never()).performFullRestoreFromLatestBackup(any()) + verify(backupRepo, never()).performFullRestoreFromLatestBackup(any(), anyOrNull()) } @Test @@ -406,16 +956,16 @@ class WalletViewModelTest : BaseUnitTest() { whenever(migrationService.getRNRemoteBackupTimestamp()).thenReturn(2uL) whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) whenever( - backupRepo.restoreUsdtRecoveryAfterLegacyMigration(), + backupRepo.restoreUsdtRecoveryAfterLegacyMigration(anyOrNull()), ).thenReturn(Result.failure(AppError("VSS offline"))) whenever(walletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.success(Unit)) sut.restoreWallet("mnemonic", "passphrase") walletState.value = walletState.value.copy(walletExists = true) advanceUntilIdle() - assertEquals(RestoreState.BackupFailed(1), sut.restoreState.value) + assertEquals(RestoreState.BackupFailed(1, hasPendingWalletRestore = false), sut.restoreState.value) verify(migrationService).restoreFromRNRemoteBackup() - verify(backupRepo).restoreUsdtRecoveryAfterLegacyMigration() - verify(backupRepo, never()).performFullRestoreFromLatestBackup(any()) + verify(backupRepo).restoreUsdtRecoveryAfterLegacyMigration(anyOrNull()) + verify(backupRepo, never()).performFullRestoreFromLatestBackup(any(), anyOrNull()) } @Test @@ -423,7 +973,7 @@ class WalletViewModelTest : BaseUnitTest() { whenever(backupRepo.hasPendingWalletRestore()).thenReturn(true) whenever { migrationService.getRNRemoteBackupTimestamp() }.thenReturn(2uL) whenever(backupRepo.getLatestBackupTime()).thenReturn(null) - whenever(backupRepo.performFullRestoreFromLatestBackup(any())).thenReturn(Result.success(Unit)) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).thenReturn(Result.success(Unit)) walletState.value = walletState.value.copy(walletExists = true) advanceUntilIdle() @@ -431,14 +981,14 @@ class WalletViewModelTest : BaseUnitTest() { advanceUntilIdle() assertEquals(RestoreState.Completed, sut.restoreState.value) - verify(backupRepo).performFullRestoreFromLatestBackup(any()) + verify(backupRepo).performFullRestoreFromLatestBackup(any(), anyOrNull()) verify(migrationService, never()).restoreFromRNRemoteBackup() } @Test fun `cancelled wallet restore retry clears transient backup gate`() = test { whenever(backupRepo.hasPendingWalletRestore()).thenReturn(true) - whenever(backupRepo.performFullRestoreFromLatestBackup(any())) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())) .thenReturn(Result.failure(CancellationException("cancelled"))) walletState.value = walletState.value.copy(walletExists = true) advanceUntilIdle() @@ -454,7 +1004,7 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `restore state should transition as expected`() = test { whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) - whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).thenReturn(Result.success(Unit)) assertEquals(RestoreState.Initial, sut.restoreState.value) sut.restoreWallet("mnemonic", "passphrase") @@ -471,7 +1021,7 @@ class WalletViewModelTest : BaseUnitTest() { fun `restore should hold ordinary backups from its start until it completes`() = test { whenever(walletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.success(Unit)) whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) - whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).thenReturn(Result.success(Unit)) sut.restoreWallet("mnemonic", "passphrase") @@ -504,7 +1054,7 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `backup restore should reinitialize pubky state after metadata restore`() = test { whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) - whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).thenReturn(Result.success(Unit)) sut.restoreWallet("mnemonic", "passphrase") walletState.value = walletState.value.copy(walletExists = true) @@ -553,6 +1103,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + connectionRecovery = connectionRecovery, ) assertEquals(RestoreState.Initial, testSut.restoreState.value) @@ -619,6 +1170,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + connectionRecovery = connectionRecovery, ) testSut.start() @@ -674,6 +1226,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + connectionRecovery = connectionRecovery, ) // Trigger restore to put state in non-idle @@ -713,6 +1266,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + connectionRecovery = connectionRecovery, ) testSut.restoreWallet("mnemonic", null) @@ -731,14 +1285,14 @@ class WalletViewModelTest : BaseUnitTest() { fun `restore retry rebuilds the node with the pending channel file`() = test { val pending = PendingChannelMigration(channelManager = byteArrayOf(1), channelMonitors = listOf(byteArrayOf(2))) whenever(migrationService.peekPendingChannelMigration()).thenReturn(pending) - whenever(lightningRepo.restartNode(anyOrNull())).thenReturn(Result.success(Unit)) + whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn(Result.success(Unit)) sut.onRestoreRetry() advanceUntilIdle() verifyNodeNotStarted(lightningRepo) val migration = argumentCaptor() - verify(lightningRepo).restartNode(migration.capture()) + verify(lightningRepo).restartNode(migration.capture(), eq(0)) assertEquals(listOf(1.toUByte()), migration.firstValue.channelManager) assertEquals(listOf(listOf(2.toUByte())), migration.firstValue.channelMonitors) inOrder(migrationService) { @@ -753,13 +1307,13 @@ class WalletViewModelTest : BaseUnitTest() { fun `restore retry keeps pending channels when restart fails`() = test { val pending = PendingChannelMigration(channelManager = byteArrayOf(1), channelMonitors = listOf(byteArrayOf(2))) whenever(migrationService.peekPendingChannelMigration()).thenReturn(pending) - whenever(lightningRepo.restartNode(anyOrNull())).thenReturn(Result.failure(AppError("restart failed"))) + whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn(Result.failure(AppError("restart failed"))) sut.onRestoreRetry() advanceUntilIdle() verifyNodeNotStarted(lightningRepo) - verify(lightningRepo).restartNode(anyOrNull()) + verify(lightningRepo).restartNode(anyOrNull(), any()) verify(migrationService, never()).consumePendingChannelMigration() verify(migrationService).unlockChannelMigration() } @@ -768,7 +1322,7 @@ class WalletViewModelTest : BaseUnitTest() { fun `restore retry keeps pending channels when restart yields to background stop`() = test { val pending = PendingChannelMigration(channelManager = byteArrayOf(1), channelMonitors = listOf(byteArrayOf(2))) whenever(migrationService.peekPendingChannelMigration()).thenReturn(pending) - whenever(lightningRepo.restartNode(anyOrNull())).thenReturn( + whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn( Result.failure(NodeStartYieldedToStopError(AppError("start failed"))), ) @@ -776,7 +1330,7 @@ class WalletViewModelTest : BaseUnitTest() { advanceUntilIdle() verifyNodeNotStarted(lightningRepo) - verify(lightningRepo).restartNode(anyOrNull()) + verify(lightningRepo).restartNode(anyOrNull(), any()) verify(migrationService, never()).consumePendingChannelMigration() verify(migrationService).unlockChannelMigration() } @@ -803,6 +1357,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + connectionRecovery = connectionRecovery, ) advanceUntilIdle() @@ -868,4 +1423,143 @@ class WalletViewModelTest : BaseUnitTest() { } return settingsData } + + @Test + fun `leaving recovery editor during required restore does not cancel the coordinator`() = test { + val restoring = CompletableDeferred() + val releaseRestore = CompletableDeferred() + whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn(Result.success(Unit)) + whenever(backupRepo.hasPendingWalletRestore()).thenReturn(true) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).doSuspendableAnswer { + restoring.complete(Unit) + releaseRestore.await() + Result.success(Unit) + } + sut.openConnectionRecovery() + advanceUntilIdle() + sut.retryRecoveryConnection() + restoring.await() + sut.closeConnectionRecovery() + assertEquals(null, sut.connectionEditor.value) + releaseRestore.complete(Unit) + advanceUntilIdle() + assertEquals(RestoreState.Completed, sut.restoreState.value) + verify(backupRepo).performFullRestoreFromLatestBackup(any(), anyOrNull()) + } + + @Test + fun `unavailable correction on restart is a reachable fail closed restore error`() = test { + whenever( + recoveryKeychain.loadString(WalletConnectionRecovery.KEY, 0) + ).thenAnswer { throw AppError("unavailable") } + sut.openConnectionRecovery() + advanceUntilIdle() + assertFalse(checkNotNull(sut.connectionEditor.value).busy) + assertEquals("unavailable", sut.connectionEditor.value?.error) + assertEquals(null, sut.connectionEditor.value?.owner) + verify( + lightningRepo, + never() + ).start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `saving Electrum does not require broken RGS or discard its unsaved draft`() = test { + val stored = MutableStateFlow( + SettingsData(electrumServer = "ssl://old.example:50002", rgsServerUrl = "https://old-rgs.example") + ) + var record: String? = null + whenever(settingsStore.data).thenReturn(stored) + whenever(settingsStore.update(any())).doSuspendableAnswer { + stored.value = it.getArgument<(SettingsData) -> SettingsData>(0)(stored.value) + } + whenever(recoveryKeychain.loadString(WalletConnectionRecovery.KEY, 0)).thenAnswer { record } + whenever(recoveryKeychain.upsertString(any(), any(), any())).thenAnswer { + record = it.getArgument(1) + Unit + } + whenever(lightningRepo.validateElectrumServer(any())).thenReturn(Result.success(Unit)) + sut.openConnectionRecovery() + advanceUntilIdle() + sut.editRecoveryElectrum("ssl://chosen.example:50002") + sut.editRecoveryRgs("https://draft-rgs.example") + sut.saveRecoveryConnection(electrum = true) + advanceUntilIdle() + assertTrue(checkNotNull(sut.connectionEditor.value).saved) + assertEquals("ssl://chosen.example:50002", stored.value.electrumServer) + assertEquals("https://old-rgs.example", stored.value.rgsServerUrl) + assertEquals("https://draft-rgs.example", sut.connectionEditor.value?.rgs) + verify(lightningRepo, never()).validateRgsUrl(any()) + verify(lightningRepo, never()).restartNode(anyOrNull(), any()) + } + + @Test + fun `connection recovery outer retry applies saved settings after editor closes`() = test { + val stored = MutableStateFlow(SettingsData()) + var record: String? = null + whenever(settingsStore.data).thenReturn(stored) + whenever(settingsStore.update(any())).doSuspendableAnswer { + stored.value = it.getArgument<(SettingsData) -> SettingsData>(0)(stored.value) + } + whenever(recoveryKeychain.loadString(WalletConnectionRecovery.KEY, 0)).thenAnswer { record } + whenever(recoveryKeychain.upsertString(any(), any(), any())).thenAnswer { + record = it.getArgument(1) + Unit + } + whenever(lightningRepo.validateElectrumServer(any())).thenReturn(Result.success(Unit)) + whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn(Result.success(Unit)) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())) + .thenReturn(Result.success(Unit)) + lightningState.value = lightningState.value.copy(nodeLifecycleState = NodeLifecycleState.Running) + sut.openConnectionRecovery() + advanceUntilIdle() + sut.editRecoveryElectrum("ssl://corrected.example:50002") + sut.saveRecoveryConnection(electrum = true) + advanceUntilIdle() + sut.closeConnectionRecovery() + sut.onBackupRestoreRetry() + advanceUntilIdle() + verify(lightningRepo).restartNode(anyOrNull(), eq(0)) + verify(backupRepo).performFullRestoreFromLatestBackup(any(), anyOrNull()) + assertEquals(RestoreState.Completed, sut.restoreState.value) + } + + @Test + fun `connection recovery cancellation preserves wallet state observation`() = test { + val restoring = CompletableDeferred() + whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).doSuspendableAnswer { + restoring.complete(Unit) + awaitCancellation() + } + sut.restoreWallet("mnemonic", "passphrase") + walletState.value = walletState.value.copy(walletExists = true) + restoring.await() + connectionRecovery.withIdentityChange { } + walletState.value = walletState.value.copy(walletExists = false) + runCurrent() + assertFalse(sut.walletExists) + } + + @Test + fun `connection recovery cancelled restore does not strand a newly created wallet`() = test { + val restoring = CompletableDeferred() + whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).doSuspendableAnswer { + restoring.complete(Unit) + awaitCancellation() + } + sut.restoreWallet("mnemonic", "passphrase") + walletState.value = walletState.value.copy(walletExists = true) + restoring.await() + connectionRecovery.withIdentityChange { } + walletState.value = walletState.value.copy(walletExists = false) + runCurrent() + whenever(walletRepo.createWallet(anyOrNull())).thenReturn(Result.success(Unit)) + sut.createWallet(null) + walletState.value = walletState.value.copy(walletExists = true) + runCurrent() + assertEquals(RestoreState.Initial, sut.restoreState.value) + assertTrue(shouldFinishWalletInitialization(NodeLifecycleState.Running, sut.restoreState.value, false)) + } } diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 0c14b2144b..6d5147b117 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -39,6 +39,7 @@ import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.connectionRecoveryFixture import javax.inject.Provider import kotlin.test.assertFailsWith import kotlin.test.assertFalse @@ -112,6 +113,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { firebaseMessaging = firebaseMessaging, migrationService = migrationService, paykitSdkService = paykitSdkService, + connectionRecovery = connectionRecoveryFixture(settingsStore), ) } diff --git a/changelog.d/next/1685.fixed.md b/changelog.d/next/1685.fixed.md new file mode 100644 index 0000000000..63303e553f --- /dev/null +++ b/changelog.d/next/1685.fixed.md @@ -0,0 +1 @@ +Keep payments and new receive addresses unavailable until required wallet recovery finishes, allow connection settings to be corrected safely across retries and restart, and preserve access after unrelated profile loading fails, with a single error notice and safe recovery after ordinary startup failures. diff --git a/journeys/backup-restore/README.md b/journeys/backup-restore/README.md index 9f4460b390..ce76f1c099 100644 --- a/journeys/backup-restore/README.md +++ b/journeys/backup-restore/README.md @@ -56,3 +56,11 @@ recovery phrase, and what the app is allowed to upload while that restore is sti `SpendingIntro-button`, `SpendingAmount`, `SpendingAmountContinue`, `GRAB`, `TransferSuccess-button`. - Lightning connections: `Channel`, `CloseConnection`, `CloseConnectionButton`, `ChannelsClosed`. + +## Required-restore connection recovery + +`connection-recovery.xml` covers the restricted Electrum/RGS editor while required restoration is +pending. Its setup is independent of the older reset-based journey above and does not authorize a +wallet reset. Use only disposable fixture identities. The matching iOS gated AppScene integration +is unfinished; the Android journey must be ported and run there when that behavior is available. +Component UI and repository tests are recorded separately from the complete authenticated journey. diff --git a/journeys/backup-restore/connection-recovery.xml b/journeys/backup-restore/connection-recovery.xml new file mode 100644 index 0000000000..0df48ec1e4 --- /dev/null +++ b/journeys/backup-restore/connection-recovery.xml @@ -0,0 +1,22 @@ + + + Use a disposable dev wallet and a controlled recovery fixture with an acknowledged complete wallet + backup. Prepare a failing Electrum endpoint and a reachable replacement, plus a backup SETTINGS + record containing the old endpoint. Required WALLET and USDT recovery must remain pending until + their imports succeed. Never reset an existing wallet or use live funds. The installed Compose + cases cover the editor and admission UI; repository tests cover persistence, cancellation and + concurrent restore. They do not execute this complete authenticated journey. iOS connection + recovery through AppScene is still being implemented; this journey is not yet an iOS pass. + + + Restore the fixture wallet and verify failed node startup offers Connection Settings without access to ordinary wallet actions + Open Connection Settings and verify the screen tagged WalletConnectionRecovery is visible without Proceed Without Backup + Enter the reachable Electrum endpoint in RecoveryElectrumInput and tap RecoveryElectrumSave; verify RecoverySaved appears without automatically retrying restoration + Change and save RGS independently; verify a failed probe shows RecoveryError and retains the last acknowledged correction + Return to the restore error and tap Try Again; verify the node is rebuilt using the saved correction before required wallet restoration resumes + Keep required recovery unavailable and restart the app; verify the correction is retained and spending remains gated + Allow restoration of the old SETTINGS backup; verify only the explicitly corrected endpoints override that backup and unrelated preferences restore normally + Complete required WALLET and USDT recovery; verify ordinary wallet actions become available only after both acknowledgements + Repeat with only optional profile caching unavailable after required recovery; verify that error retains its optional recovery controls and is not classified as failed node startup + + diff --git a/journeys/backup-restore/pending-wallet-recovery.xml b/journeys/backup-restore/pending-wallet-recovery.xml new file mode 100644 index 0000000000..633cfd8acb --- /dev/null +++ b/journeys/backup-restore/pending-wallet-recovery.xml @@ -0,0 +1,21 @@ + + + Use a disposable test-network recovery fixture with synthetic funds and a complete wallet backup + containing an unresolved payment attempt and private address reservation ceilings. A VSS control + must fail or delay wallet download/import without preventing node sync, and a separate control + must fail Pubky profile caching after wallet restoration succeeds. These full-app fault controls + are not currently provisioned; repository tests and Compose component tests are separate evidence. + iOS already keeps its normal startup behind the durable restore gate and presents retry-only + recovery failure. Android keeps node sync and reservation reveal available because restore needs them. + Never reset a real wallet or use live funds. + + + Start restoring the fixture wallet and fail required wallet backup download + Retry more than twice and verify Try Again remains available without Proceed Without Backup + Attempt ordinary BTC and Lightning sends, a new receive address and a prepared broadcast through the fixture; verify no new operation is signed or broadcast while required recovery is pending + Restart the app with the same pending restore and verify the same admission restrictions remain + Restore backup availability, trigger overlapping retries and verify one restoration completes before wallet actions become available + Repeat with successful wallet recovery followed by profile cache failure; after repeated failures verify Proceed is available only while safety markers are clear + Set a pending restore marker before confirming Proceed and verify the current state is rechecked and recovery cannot be dismissed + +