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..8baac5ad61 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/WalletConnectionRecovery.kt @@ -0,0 +1,291 @@ +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 pendingRevision(): String? = settingsMutex.withLock { read()?.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..d65fccfb67 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 } }, @@ -1634,7 +1656,7 @@ class LightningRepo @Inject constructor( if (error is OnchainSendNotDispatchedError) { runSuspendCatching { onchainSendAttemptStore.releaseBeforeDispatch(attempt.attemptId, attempt.walletIndex) - }.onFailure { Logger.warn("Failed to clear expired preparation", it, context = TAG) } + }.onFailure { Logger.warn("Failed to clear unsubmitted preparation", it, context = TAG) } return@executeWhenNodeRunning Result.failure(error) } val winnerResult = runSuspendCatching { onchainSendAttemptStore.current() } @@ -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/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 1290a4731c..d32cdc7d22 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -17,6 +17,7 @@ import org.lightningdevkit.ldknode.SpendableUtxo import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.ext.nowMillis +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.ActiveOnchainAttemptBackup import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.WalletScope @@ -467,40 +468,49 @@ class OnchainSendAttemptStore @Inject constructor( if (current.hasPositiveEvidence) return@withLock OnchainSendOutcome.Accepted(requireNotNull(current.txid)) require(txid.lowercase() in current.candidateTxids) val firstSubmission = !current.initialDispatchAttempted + val localPreparation = attemptId in inFlightPreparations if (!firstSubmission) { persist( current.copy(txid = txid.lowercase(), evidence = OnchainSendEvidence.Pending, refusalReason = null) ) } try { - broadcast { - // Called on the native queue after wallet/deadline validation, while this mutex is owned. + runSuspendCatching { + broadcast { + // Called on the native queue after wallet/deadline validation, while this mutex is owned. + val live = loadWithRetainedAccepted(walletIndex) + check(live?.attemptId == attemptId && !live.hasPositiveEvidence) + persist(live.copy(initialDispatchAttempted = true)) + inFlightPreparations -= attemptId + } + }.getOrElse { error -> + val nativeRefused = error is PreparedOnchainSendRefused + if (!nativeRefused && error !is ServiceError.PaymentDeadlineExpired) throw error val live = loadWithRetainedAccepted(walletIndex) - check(live?.attemptId == attemptId && !live.hasPositiveEvidence) - persist(live.copy(initialDispatchAttempted = true)) - inFlightPreparations -= attemptId - } - } catch (error: ServiceError.PaymentDeadlineExpired) { - // The native queue checks the deadline before the durable dispatch boundary. - // Any earlier submission or imported backup remains uncertain and guarded. - if (!firstSubmission || loadWithRetainedAccepted(walletIndex)?.initialDispatchAttempted != false || - current.restoredFromBackup || current.evidence != OnchainSendEvidence.Pending || - current.candidateTxids.size != 1 - ) { - throw error - } - persist( - current.copy( - txid = null, - originalInputs = null, - candidateTxids = emptyList(), - candidateFeeRates = emptyMap(), - candidateMiningFees = emptyMap(), - preparationPending = current.requestId != null, + val samePreparation = live == current || nativeRefused && + live == current.copy(initialDispatchAttempted = true) + // Only the native handle can acknowledge refusal after the dispatch write-ahead. + // Earlier submissions, imported backups and changed journals remain guarded. + if (!firstSubmission || !localPreparation || !samePreparation || + walletIndex != lightningService.currentWalletIndex || current.restoredFromBackup || + current.evidence != OnchainSendEvidence.Pending || current.candidateTxids.size != 1 + ) { + throw error + } + persist( + current.copy( + txid = null, + originalInputs = null, + candidateTxids = emptyList(), + candidateFeeRates = emptyMap(), + candidateMiningFees = emptyMap(), + preparationPending = current.requestId != null, + initialDispatchAttempted = false, + ) ) - ) - // Shop proof cleanup still precedes guard deletion through the existing preparation path. - throw OnchainSendNotDispatchedError(error) + // Shop proof cleanup still precedes guard deletion through the existing preparation path. + throw OnchainSendNotDispatchedError(error) + } } finally { inFlightPreparations -= attemptId } diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt index fb35f190c4..f8a34de714 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import to.bitkit.di.IoDispatcher import to.bitkit.ext.runSuspendCatching +import to.bitkit.utils.AppError import kotlin.time.Instant /** Rust converts sat/vB to sat/kwu by multiplying by 250; validate before the unchecked FFI factory. */ @@ -19,6 +20,9 @@ object OnchainRecoveryFeeRate { fun parse(input: String): ULong? = input.toULongOrNull()?.takeIf(::isValid) } +/** The prepared native handle refused this invocation before calling native broadcast. */ +internal class PreparedOnchainSendRefused(cause: Throwable) : AppError(cause) + /** One native prepared transaction; its direct submission is cached even if it fails. */ class PreparedOnchainSend( val receipt: OnchainPreparedReceipt, 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 576f39e576..aa27587957 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 @@ -78,6 +79,7 @@ import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendInput import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PreparedOnchainSend +import to.bitkit.repositories.PreparedOnchainSendRefused import to.bitkit.repositories.releaseInputSets import to.bitkit.repositories.releaseOutbids import to.bitkit.utils.AppError @@ -676,6 +678,7 @@ class LightningService internal constructor( val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background(ldkQueue) { + keychain.requireWalletRestoreComplete() node.onchainPayment().newAddress() } } @@ -689,6 +692,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 +842,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 +891,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 +960,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 +990,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 +1020,13 @@ 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() + runSuspendCatching { + beforeDispatch() + keychain.requireWalletRestoreComplete() + if (currentWalletIndex != walletIndex || node !== originalNode) throw ServiceError.NodeNotSetup() + }.getOrElse { throw PreparedOnchainSendRefused(it) } when (val result = prepared.broadcast()) { is OnchainSendResult.Accepted -> OnchainSendOutcome.Accepted(result.txid) is OnchainSendResult.Rejected -> OnchainSendOutcome.Rejected(result.txid, result.reason) @@ -1142,6 +1155,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) @@ -1322,6 +1336,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, @@ -1343,6 +1358,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..cd0abee0b5 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 @@ -245,6 +246,7 @@ import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel import to.bitkit.viewmodels.BackupsViewModel import to.bitkit.viewmodels.BlocktankViewModel +import to.bitkit.viewmodels.ConnectionStartupFailure import to.bitkit.viewmodels.CurrencyViewModel import to.bitkit.viewmodels.MainScreenEffect import to.bitkit.viewmodels.RestoreState @@ -252,6 +254,14 @@ 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 shouldPresentWalletRecovery( + walletIsInitializing: Boolean, + restoreState: RestoreState, + connectionFailure: ConnectionStartupFailure?, +): Boolean = walletIsInitializing || restoreState is RestoreState.BackupFailed || + connectionFailure?.requiresRecovery == true internal fun shouldFinishWalletInitialization( nodeLifecycleState: NodeLifecycleState, @@ -416,16 +426,42 @@ fun ContentView( } } - val backupRestoreFailed = - restoreState is RestoreState.BackupFailed && nodeLifecycleState == NodeLifecycleState.Running + val backupRestoreFailed = restoreState is RestoreState.BackupFailed + + 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) { - if (nodeLifecycleState is NodeLifecycleState.ErrorStarting || backupRestoreFailed) { + if (shouldPresentWalletRecovery(walletIsInitializing, restoreState, connectionFailure)) { + 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..5039a5957c 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,220 @@ 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 + + private data class AppliedConnection(val walletIndex: Int, val generation: Long, val revision: String?) + + @Volatile + private var appliedConnection: AppliedConnection? = null + + private fun appliedConnection(owner: WalletConnectionRecovery.Owner, revision: String?) = + AppliedConnection(owner.walletIndex, owner.generation, revision) + + 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 requiresRecovery = !markersClear || runSuspendCatching { + session.pendingRevision() != null + }.getOrDefault(true) + 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, + requiresRecovery = requiresRecovery, + 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 +385,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 +405,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) } @@ -212,10 +432,23 @@ class WalletViewModel @Inject constructor( private fun collectStates() = viewModelScope.launch { walletState.collect { walletExists = it.walletExists - if (!it.walletExists) return@collect + if (!it.walletExists) { + appliedConnection = null + _connectionStartupError.update { null } + closeConnectionRecovery() + 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 +458,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 +518,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 +561,112 @@ 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() + connectionRecovery.withStartup(owner) { startupSession -> + val connection = appliedConnection(owner, revision) + if (!backupOnly || (revision != null && appliedConnection != connection)) { + if (!backupOnly && !backupRepo.hasPendingWalletRestore()) _restoreState.update { it.countRetry() } + setInitNodeLifecycleState() + migrationService.lockChannelMigration() + try { + val channelMigration = buildChannelMigrationIfAvailable() + startNodeOwned(owner.walletIndex, channelMigration, isRestoreRetry = true) { + startupSession.commitCurrentState { appliedConnection = connection } + }.getOrThrow() + } finally { + migrationService.unlockChannelMigration() + } + } + } + if (backupOnly || backupRepo.hasPendingWalletRestore()) { + backupRepo.setRestorePending(true) + restoreFromBackup(resumePendingWalletBackup = true, connectionSession = session) + if (_restoreState.value !is RestoreState.Completed) { + session.complete(revision) + // 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 + val revision = session.pendingRevision() + if (revision != null) { + if (appliedConnection != appliedConnection(owner, revision) || + lightningState.value.nodeLifecycleState !is NodeLifecycleState.Running + ) { + session.commitCurrentState { + if (startupAttempt.get() == attempt && _connectionStartupError.value === failure) { + _connectionStartupError.update { + ConnectionStartupFailure( + message = context.getString(R.string.onboarding__connection_saved), + owner = owner, + ) + } + } + } + return@withOperation + } + session.complete(revision) + } + // 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 +693,7 @@ class WalletViewModel @Inject constructor( waitForRestoreIfNeeded() - migrationService.lockChannelMigration() - try { - val channelMigration = buildChannelMigrationIfAvailable() - startNode(walletIndex, channelMigration) - } finally { - migrationService.unlockChannelMigration() - } + startNode(walletIndex) } finally { isStarting = false } @@ -391,18 +722,56 @@ 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() + val connection = appliedConnection(admittedOwner, revision) + migrationService.lockChannelMigration() + try { + // A skipped start of a running node cannot acknowledge a newly saved configuration. + startNodeOwned( + walletIndex, + buildChannelMigrationIfAvailable(), + isRestoreRetry = revision != null && appliedConnection != connection, + ) { + session.commitCurrentState { appliedConnection = connection } + }.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, + onStarted: suspend () -> Unit, + ): Result { val result = if (isRestoreRetry) { - lightningRepo.restartNode(channelMigration) + lightningRepo.restartNode(channelMigration, walletIndex) } else { lightningRepo.start(walletIndex, channelMigration = channelMigration) } - result + return result .onSuccess { + onStarted() if (channelMigration != null) { migrationService.consumePendingChannelMigration() } @@ -416,14 +785,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 +1004,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 +1085,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 +1098,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 +1106,25 @@ 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, + val requiresRecovery: Boolean = true, + 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 8757d29315..0457d417b3 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 661f7644fa..11bc78e0d4 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 fe7ca4408e..cf26cb9a1a 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 b9c21432b5..5fbd4539f0 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 81176dc29e..33711a7450 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 1a7e71044d..06fd2c04bf 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 e7b83ffcb3..da8667d334 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 6ae294257e..a4f79532f5 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 264f09ce85..07f85bd24d 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 2af679d0f1..a3af05fbd0 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 c991e22d0c..98e8c81291 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 c4cbb73f48..2e3175b5cb 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 25a010fc95..d0816dbdbf 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 bd19d35e1f..d4afa7af6c 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 d41601b50b..ac095704a8 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 196d19d395..bd0fffd113 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 1e183ef287..c02ab8c9f3 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -1,9 +1,11 @@ package to.bitkit.services import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation @@ -12,6 +14,7 @@ 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 @@ -55,8 +58,10 @@ 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.ext.runSuspendCatching import to.bitkit.models.ActiveOnchainAttemptBackup import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX import to.bitkit.models.WalletScope @@ -67,12 +72,17 @@ import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendAttemptStore import to.bitkit.repositories.OnchainSendCoordinator import to.bitkit.repositories.OnchainSendInput +import to.bitkit.repositories.OnchainSendNotDispatchedError import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.OnchainTransferContext +import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PreparedOnchainSend +import to.bitkit.repositories.PreparedOnchainSendRefused import to.bitkit.test.BaseUnitTest import to.bitkit.utils.LoggerLdk import to.bitkit.utils.ServiceError +import java.io.File +import java.io.FileOutputStream import java.util.concurrent.CountDownLatch import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.assertEquals @@ -80,9 +90,11 @@ import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant import com.synonym.bitkitcore.AddressType as BitkitAddressType +import org.lightningdevkit.ldknode.PreparedOnchainSend as NativePreparedOnchainSend private const val VERIFY_TIMEOUT_MS = 2_000L private const val RELEASE_GATE_PROBE_MS = 200L @@ -107,7 +119,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, @@ -116,10 +132,258 @@ class LightningServiceTest : BaseUnitTest() { watchOnlyAccountStore = watchOnlyAccountStore, loggerLdk = loggerLdk, watchOnlyAccountLifecycleCoordinator = watchOnlyAccountLifecycleCoordinator, - ldkQueue = testDispatcher, + ldkQueue = ldkDispatcher, onchainFeeRateFactory = { sendFeeRate }, + ).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 PreparedOnchainSendRefused) + verify(native, never()).broadcast() + } + + @Test + fun `restore refusal after durable dispatch marker releases only local unsubmitted send`() = test { + for (marker in listOf(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE, Keychain.Key.USDT_PENDING_BACKUP_RESTORE)) { + val fixture = preparedJournal() + fixture.onSave = { saved -> + if (saved.initialDispatchAttempted) whenever(keychain.exists(marker.name)).thenReturn(true) + } + assertFailsWith { fixture.broadcast() } + verify(fixture.native, never()).broadcast() + fixture.store.releaseBeforeDispatch(fixture.attempt.attemptId, 0) + assertNull(fixture.store.current()) + whenever(keychain.exists(marker.name)).thenReturn(false) + fixture.onSave = {} + val next = preparedJournal(existing = fixture) + assertEquals(OnchainSendOutcome.Accepted(next.prepared.receipt.txid), next.broadcast()) + } + } + + @Test + fun `unreadable recovery after dispatch marker refuses without uncertain send`() = test { + val fixture = preparedJournal() + fixture.onSave = { saved -> + if (saved.initialDispatchAttempted) { + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)) + .thenThrow(IllegalStateException("unreadable")) + } + } + assertFailsWith { fixture.broadcast() } + verify(fixture.native, never()).broadcast() + fixture.store.releaseBeforeDispatch(fixture.attempt.attemptId, 0) + assertNull(fixture.store.current()) + } + + @Test + fun `refused Shop send retains guard until original proof cleanup succeeds`() = test { + val fixture = preparedJournal(shop = true) + fixture.onSave = { saved -> + if (saved.initialDispatchAttempted) { + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + } + } + assertFailsWith { fixture.broadcast() } + fixture.store.releaseBeforeDispatch(fixture.attempt.attemptId, 0) + val pending = requireNotNull(fixture.store.current()) + assertTrue(pending.preparationPending) + assertFalse(pending.initialDispatchAttempted) + assertTrue(pending.candidateTxids.isEmpty()) + assertFalse(fixture.store.releaseInterruptedShopPreparation { false }) + assertEquals(pending, fixture.store.current()) + assertTrue( + fixture.store.releaseInterruptedShopPreparation { + assertEquals(pending, fixture.read()) + true + } + ) + assertNull(fixture.store.current()) + verify(fixture.native, never()).broadcast() + } + + @Test + fun `failed refusal rollback keeps durable uncertain candidate`() = test { + val fixture = preparedJournal() + fixture.onSave = { saved -> + if (saved.initialDispatchAttempted) { + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + } else if (saved.candidateTxids.isEmpty()) { + error("rollback unavailable") + } + } + assertTrue(runSuspendCatching { fixture.broadcast() }.isFailure) + val retained = requireNotNull(fixture.read()) + assertTrue(retained.initialDispatchAttempted) + assertEquals(listOf(fixture.prepared.receipt.txid), retained.candidateTxids) + assertEquals(retained, fixture.store.current()) + verify(fixture.native, never()).broadcast() + } + + @Test + fun `wallet or journal replacement during dispatch marker never releases another guard`() = test { + for (replaceWallet in listOf(false, true)) { + setWalletIndex(0) + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(false) + val fixture = preparedJournal() + var replacement: OnchainSendAttempt? = null + fixture.onSave = { saved -> + if (saved.initialDispatchAttempted) { + if (replaceWallet) { + setWalletIndex(1) + } else { + replacement = saved.copy(attemptId = "another-attempt") + fixture.file.writeText(Json.encodeToString(replacement)) + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + } + } + } + assertTrue(runSuspendCatching { fixture.broadcast() }.isFailure) + verify(fixture.native, never()).broadcast() + val retained = requireNotNull(fixture.read()) + assertTrue(retained.initialDispatchAttempted) + if (replaceWallet) { + assertEquals(fixture.attempt.attemptId, retained.attemptId) + } else { + assertEquals(replacement, retained) + } + } + } + + @Test + fun `prior or imported candidate survives a locally refused native invocation`() = test { + for (imported in listOf(false, true)) { + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(false) + val fixture = preparedJournal() + val retained = requireNotNull(fixture.read()).copy( + initialDispatchAttempted = !imported, + restoredFromBackup = imported, + ) + fixture.file.writeText(Json.encodeToString(retained)) + fixture.onSave = { saved -> + if (saved.initialDispatchAttempted) { + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + } + } + assertTrue(runSuspendCatching { fixture.broadcast() }.isFailure) + verify(fixture.native, never()).broadcast() + assertEquals(retained.copy(initialDispatchAttempted = true), fixture.read()) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, sut, Clock.System) + assertEquals(fixture.read(), reopened.current()) + } + } + + @Test + fun `positive journal winner is retained without invoking native broadcast`() = test { + val fixture = preparedJournal() + val outcome = OnchainSendOutcome.Accepted(fixture.prepared.receipt.txid) + val winner = fixture.store.recordOutcome(fixture.attempt.attemptId, outcome, 0) + assertEquals(outcome, fixture.broadcast()) + assertEquals(winner, fixture.read()) + verify(fixture.native, never()).broadcast() + } + + @Test + fun `native error or unknown outcome retains dispatched journal`() = test { + for (throws in listOf(false, true)) { + val fixture = preparedJournal() + whenever(fixture.native.broadcast()).thenAnswer { + if (throws) throw WalletBackupRestorePendingError() + OnchainSendResult.Unknown(fixture.prepared.receipt.txid) + } + val result = runSuspendCatching { fixture.broadcast() } + if (throws) { + assertTrue(result.exceptionOrNull() is WalletBackupRestorePendingError) + } else { + assertEquals(OnchainSendOutcome.Unknown(fixture.prepared.receipt.txid), result.getOrThrow()) + } + verify(fixture.native).broadcast() + val retained = requireNotNull(fixture.read()) + assertTrue(retained.initialDispatchAttempted) + assertEquals(listOf(fixture.prepared.receipt.txid), retained.candidateTxids) + assertEquals(retained, fixture.store.current()) + } + } + + private fun setWalletIndex(index: Int) { + LightningService::class.java.getDeclaredField("currentWalletIndex").apply { + isAccessible = true + set(sut, index) + } + } + + private class PreparedJournal( + val store: OnchainSendAttemptStore, + val attempt: OnchainSendAttempt, + val prepared: PreparedOnchainSend, + val native: NativePreparedOnchainSend, + val file: File, + ) { + var onSave: (OnchainSendAttempt) -> Unit = {} + fun read(): OnchainSendAttempt? = file.readText().takeIf(String::isNotEmpty)?.let { + Json.decodeFromString(it) + } + suspend fun broadcast() = store.broadcastPreparedCandidate(attempt.attemptId, 0, prepared.receipt.txid) { + prepared.broadcast(it) + } + } + + private suspend fun preparedJournal(shop: Boolean = false, existing: PreparedJournal? = null): PreparedJournal { + val file = existing?.file ?: tempFolder.newFile() + val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + var fixture: PreparedJournal? = null + whenever(keychain.loadString(key, 0)).thenAnswer { file.readText().takeIf(String::isNotEmpty) } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + val value = it.getArgument(1) + val saved = Json.decodeFromString(value) + if (!saved.initialDispatchAttempted) fixture?.onSave?.invoke(saved) + FileOutputStream(file).use { output -> + output.write(value.toByteArray()) + output.fd.sync() + } + if (saved.initialDispatchAttempted) fixture?.onSave?.invoke(saved) + } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { file.writeText("") } + val store = existing?.store ?: OnchainSendAttemptStore(testDispatcher, keychain, sut, Clock.System) + val attempt = store.admit( + walletId = WalletScope.default, + requestId = if (shop) PaykitPaymentRequestId("request", "payer") else null, + orderId = null, address = "address", amountSats = 1_000uL, isMaxAmount = false, + feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), + beforeSendAttempt = {}, payerIdentity = if (shop) "payer" else null, ) - sut.node = node + 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) + store.retainPreparedReceipt(attempt.attemptId, 0, prepared.receipt, false) + return PreparedJournal(store, attempt, prepared, native, file).also { fixture = it } } @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/ContentViewTest.kt b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt index cc75279ca8..e532a35af8 100644 --- a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt +++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt @@ -22,6 +22,7 @@ import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.navigateIfNotCurrent import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.utils.AppError +import to.bitkit.viewmodels.ConnectionStartupFailure import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.TransferEffect import kotlin.test.assertEquals @@ -138,6 +139,17 @@ class ContentViewTest { assertEquals(listOf(false, false, false, false, true, false, false, true), completion) } + @Test + fun `ordinary foreground errors preserve navigation while initialization and required recovery stay restricted`() { + val ordinary = ConnectionStartupFailure("offline", requiresRecovery = false) + val required = ConnectionStartupFailure("restore incomplete") + assertFalse(shouldPresentWalletRecovery(false, RestoreState.Initial, ordinary)) + assertFalse(shouldPresentWalletRecovery(false, RestoreState.Settled, ordinary)) + assertTrue(shouldPresentWalletRecovery(true, RestoreState.Initial, ordinary)) + assertTrue(shouldPresentWalletRecovery(false, RestoreState.BackupFailed(1), ordinary)) + assertTrue(shouldPresentWalletRecovery(false, RestoreState.Initial, required)) + } + @Test fun `running wallet waits for backup and metadata restoration`() { val pendingRestores = listOf( diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 898a9e08a6..3791702cf6 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -1,14 +1,22 @@ 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.TestScope 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 +25,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 +35,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 +56,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 +76,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 +98,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 +111,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 +124,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + connectionRecovery = connectionRecovery, ) } @@ -274,13 +295,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 +347,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 +355,516 @@ 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() + } + assertFalse(shouldPresentWalletRecovery(false, sut.restoreState.value, sut.connectionStartupError.value)) + } + + @Test + fun `foreground failure remains restricted when required recovery is pending or unreadable`() = 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 unavailable"))) + for (unreadable in listOf(false, true)) { + whenever(backupRepo.hasPendingWalletRestore()).doSuspendableAnswer { + if (unreadable) throw AppError("keychain unavailable") else true + } + sut.start() + advanceUntilIdle() + val failure = checkNotNull(sut.connectionStartupError.value) + assertFalse(failure.canProceedWithoutRestore) + assertTrue(shouldPresentWalletRecovery(false, sut.restoreState.value, failure)) + } + } + + @Test + fun `foreground failure with a saved correction retains the recovery editor route`() = test { + stubRecoveryRecord() + savePendingCorrection() + sut.closeConnectionRecovery() + 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 unavailable"))) + + whenever(lightningRepo.restartNode(anyOrNull(), any())) + .thenReturn(Result.failure(AppError("node unavailable"))) + + sut.start() + advanceUntilIdle() + + val failure = checkNotNull(sut.connectionStartupError.value) + assertTrue(failure.canProceedWithoutRestore) + assertTrue(shouldPresentWalletRecovery(false, sut.restoreState.value, failure)) + assertTrue(recoveryKeychain.loadString(WalletConnectionRecovery.KEY, 0) != null) + } + + @Test + fun `unreadable correction during foreground startup keeps recovery reachable`() = test { + whenever(walletRepo.walletExists()).thenReturn(true) + walletState.value = walletState.value.copy(walletExists = true) + whenever(recoveryKeychain.loadString(WalletConnectionRecovery.KEY, 0)) + .thenAnswer { throw AppError("correction unavailable") } + + sut.start() + advanceUntilIdle() + + assertTrue(shouldPresentWalletRecovery(false, sut.restoreState.value, sut.connectionStartupError.value)) + verify(lightningRepo, never()) + .start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), any()) + } + + @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 +872,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 +892,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 +915,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 +1007,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 +1015,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 +1032,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 +1040,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 +1063,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 +1080,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 +1113,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 +1162,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + connectionRecovery = connectionRecovery, ) assertEquals(RestoreState.Initial, testSut.restoreState.value) @@ -619,6 +1229,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + connectionRecovery = connectionRecovery, ) testSut.start() @@ -674,6 +1285,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + connectionRecovery = connectionRecovery, ) // Trigger restore to put state in non-idle @@ -713,6 +1325,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + connectionRecovery = connectionRecovery, ) testSut.restoreWallet("mnemonic", null) @@ -731,14 +1344,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 +1366,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 +1381,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 +1389,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 +1416,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + connectionRecovery = connectionRecovery, ) advanceUntilIdle() @@ -868,4 +1482,375 @@ 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)) + } + private suspend fun stubRecoveryRecord(): MutableStateFlow { + 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(recoveryKeychain.delete(WalletConnectionRecovery.KEY, 0)).thenAnswer { + record = null + Unit + } + whenever(lightningRepo.validateElectrumServer(any())).thenReturn(Result.success(Unit)) + return stored + } + + @Test + fun `saved correction restarts a running node once while required restore stays pending`() = test { + stubRecoveryRecord() + whenever(recoveryKeychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + whenever(backupRepo.hasPendingWalletRestore()).thenReturn(true) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())) + .thenReturn(Result.failure(AppError("vss down"))) + whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn(Result.success(Unit)) + whenever( + lightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), any()), + ).thenReturn(Result.success(Unit)) + whenever(walletRepo.walletExists()).thenReturn(true) + walletState.value = walletState.value.copy(walletExists = true) + lightningState.value = lightningState.value.copy(nodeLifecycleState = NodeLifecycleState.Running) + advanceUntilIdle() + sut.openConnectionRecovery() + advanceUntilIdle() + sut.editRecoveryElectrum("ssl://corrected.example:50002") + sut.saveRecoveryConnection(electrum = true) + advanceUntilIdle() + sut.closeConnectionRecovery() + sut.onBackupRestoreRetry() + advanceUntilIdle() + verify(lightningRepo, times(1)).restartNode(anyOrNull(), any()) + + repeat(3) { + sut.start() + advanceUntilIdle() + sut.onBackupRestoreRetry() + advanceUntilIdle() + } + + verify(lightningRepo, times(1)).restartNode(anyOrNull(), any()) + } + + @Test + fun `wallet removal clears startup failure before the next wallet restore`() = test { + whenever(lightningRepo.restartNode(anyOrNull(), any())) + .thenReturn(Result.failure(AppError("storage unavailable"))) + walletState.value = walletState.value.copy(walletExists = true) + sut.onRestoreRetry() + advanceUntilIdle() + assertTrue(sut.connectionStartupError.value != null) + walletState.value = walletState.value.copy(walletExists = false) + runCurrent() + + val releaseRestore = CompletableDeferred() + whenever(walletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).doSuspendableAnswer { + releaseRestore.await() + Result.success(Unit) + } + sut.restoreWallet("mnemonic", null) + walletState.value = walletState.value.copy(walletExists = true) + runCurrent() + + assertEquals(null, sut.connectionStartupError.value) + releaseRestore.complete(Unit) + advanceUntilIdle() + } + + @Test + fun `backup only retry with saved correction counts one retry`() = test { + stubRecoveryRecord() + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever(pubkyRepo.initialize()).doSuspendableAnswer { throw AppError("profile cache unavailable") } + whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn(Result.success(Unit)) + lightningState.value = lightningState.value.copy(nodeLifecycleState = NodeLifecycleState.Running) + sut.onBackupRestoreRetry() + advanceUntilIdle() + assertEquals(RestoreState.BackupFailed(1, hasPendingWalletRestore = false), sut.restoreState.value) + sut.openConnectionRecovery() + advanceUntilIdle() + sut.editRecoveryElectrum("ssl://corrected.example:50002") + sut.saveRecoveryConnection(electrum = true) + advanceUntilIdle() + sut.closeConnectionRecovery() + + sut.onBackupRestoreRetry() + advanceUntilIdle() + + assertEquals(RestoreState.BackupFailed(2, hasPendingWalletRestore = false), sut.restoreState.value) + } + + private suspend fun TestScope.savePendingCorrection(url: String = "ssl://corrected.example:50002") { + sut.openConnectionRecovery() + advanceUntilIdle() + sut.editRecoveryElectrum(url) + sut.saveRecoveryConnection(electrum = true) + advanceUntilIdle() + sut.closeConnectionRecovery() + } + + private suspend fun TestScope.preparePendingCorrection() { + stubRecoveryRecord() + whenever(recoveryKeychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + whenever(backupRepo.hasPendingWalletRestore()).thenReturn(true) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())) + .thenReturn(Result.failure(AppError("vss down"))) + whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn(Result.success(Unit)) + walletState.value = walletState.value.copy(walletExists = true) + lightningState.value = lightningState.value.copy(nodeLifecycleState = NodeLifecycleState.Running) + advanceUntilIdle() + savePendingCorrection() + } + + @Test + fun `new correction rebuilds the node again while required restore remains pending`() = test { + preparePendingCorrection() + sut.onBackupRestoreRetry() + advanceUntilIdle() + savePendingCorrection("ssl://new-correction.example:50002") + sut.onBackupRestoreRetry() + advanceUntilIdle() + sut.onBackupRestoreRetry() + advanceUntilIdle() + verify(lightningRepo, times(2)).restartNode(anyOrNull(), any()) + } + + @Test + fun `same seed replacement cannot reuse an earlier lifecycle connection acknowledgement`() = test { + preparePendingCorrection() + sut.onBackupRestoreRetry() + advanceUntilIdle() + connectionRecovery.withIdentityChange { } + sut.onBackupRestoreRetry() + advanceUntilIdle() + verify(lightningRepo, times(2)).restartNode(anyOrNull(), any()) + } + + @Test + fun `failed node restart does not acknowledge the saved correction`() = test { + preparePendingCorrection() + whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn( + Result.failure(AppError("node failed")), + Result.success(Unit), + ) + sut.onBackupRestoreRetry() + advanceUntilIdle() + sut.onBackupRestoreRetry() + advanceUntilIdle() + sut.onBackupRestoreRetry() + advanceUntilIdle() + verify(lightningRepo, times(2)).restartNode(anyOrNull(), any()) + } + + @Test + fun `local followup failure does not forget the successful node configuration`() = test { + preparePendingCorrection() + whenever(walletRepo.syncBalances()).doSuspendableAnswer { throw AppError("balance unavailable") } + sut.onBackupRestoreRetry() + advanceUntilIdle() + sut.onBackupRestoreRetry() + advanceUntilIdle() + verify(lightningRepo, times(1)).restartNode(anyOrNull(), any()) + } + + @Test + fun `optional profile failure cannot replay a completed correction over later settings`() = test { + val stored = stubRecoveryRecord() + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever(pubkyRepo.initialize()).doSuspendableAnswer { throw AppError("profile unavailable") } + whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn(Result.success(Unit)) + whenever( + lightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), any()), + ).thenReturn(Result.success(Unit)) + whenever(walletRepo.walletExists()).thenReturn(true) + walletState.value = walletState.value.copy(walletExists = true) + lightningState.value = lightningState.value.copy(nodeLifecycleState = NodeLifecycleState.Running) + advanceUntilIdle() + savePendingCorrection() + repeat(2) { + sut.onBackupRestoreRetry() + advanceUntilIdle() + } + var proceeded = false + sut.onProceedWithoutRestore { proceeded = true } + advanceUntilIdle() + assertTrue(proceeded) + settingsStore.update { it.copy(electrumServer = "ssl://later-settings.example:50002") } + sut.start() + advanceUntilIdle() + assertEquals("ssl://later-settings.example:50002", stored.value.electrumServer) + } + + @Test + fun `proceed retains an unapplied correction until explicit retry`() = test { + stubRecoveryRecord() + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever(pubkyRepo.initialize()).doSuspendableAnswer { throw AppError("profile unavailable") } + lightningState.value = lightningState.value.copy(nodeLifecycleState = NodeLifecycleState.Running) + repeat(2) { + sut.onBackupRestoreRetry() + advanceUntilIdle() + } + savePendingCorrection() + var proceeded = false + sut.onProceedWithoutRestore { proceeded = true } + advanceUntilIdle() + assertFalse(proceeded) + assertTrue(recoveryKeychain.loadString(WalletConnectionRecovery.KEY, 0) != null) + verify(recoveryKeychain, never()).delete(WalletConnectionRecovery.KEY, 0) + assertFalse(checkNotNull(sut.connectionStartupError.value).canProceedWithoutRestore) + whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn(Result.success(Unit)) + sut.onBackupRestoreRetry() + advanceUntilIdle() + sut.onProceedWithoutRestore { proceeded = true } + advanceUntilIdle() + assertTrue(proceeded) + assertEquals(null, recoveryKeychain.loadString(WalletConnectionRecovery.KEY, 0)) + } } 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..83380c9900 --- /dev/null +++ b/changelog.d/next/1685.fixed.md @@ -0,0 +1 @@ +Keep wallet actions gated during required recovery, allow connection corrections, and avoid repeated node restarts while backup recovery retries. diff --git a/journeys/backup-restore/README.md b/journeys/backup-restore/README.md index 9f4460b390..4c9d66a1db 100644 --- a/journeys/backup-restore/README.md +++ b/journeys/backup-restore/README.md @@ -56,3 +56,12 @@ 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 AppScene recovery path and +journey are implemented in bitkit-ios #1139. Its recorded disposable-app cases retain their exact +revisions and fixture limits; they are not Android acceptance results. Component UI and repository +tests remain separate from complete authenticated journeys. diff --git a/journeys/backup-restore/connection-recovery.xml b/journeys/backup-restore/connection-recovery.xml new file mode 100644 index 0000000000..4bf9e8817a --- /dev/null +++ b/journeys/backup-restore/connection-recovery.xml @@ -0,0 +1,25 @@ + + + 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 restores required backup state + before starting a rebuilt node; Android can need a running node during restoration. Record each + platform’s app evidence with its tested revision and fixture limits; a pass on one platform does + not establish this journey on the other. + + + 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 + While required recovery remains unavailable, background and foreground three times and tap Try Again; verify the correction and gates remain and no already-running node is rebuilt solely because the unchanged correction is retained + 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 + +