diff --git a/app/src/main/java/to/bitkit/data/WalletConnectionRecovery.kt b/app/src/main/java/to/bitkit/data/WalletConnectionRecovery.kt index e1c70bbffb..fdecbf8854 100644 --- a/app/src/main/java/to/bitkit/data/WalletConnectionRecovery.kt +++ b/app/src/main/java/to/bitkit/data/WalletConnectionRecovery.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import to.bitkit.data.backup.VssClientCoordinator import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.keychain.Keychain import to.bitkit.data.keychain.hasPendingWalletRestore @@ -30,6 +31,7 @@ class WalletConnectionRecovery @Inject constructor( private val db: AppDb, private val storeIdProvider: VssStoreIdProvider, private val settingsStore: SettingsStore, + private val vssClients: VssClientCoordinator, ) { companion object { internal const val KEY = "WALLET_CONNECTION_RECOVERY" @@ -130,7 +132,7 @@ class WalletConnectionRecovery @Inject constructor( currentCoroutineContext().ensureActive() beginWipe() currentCoroutineContext().ensureActive() - block() + vssClients.withSuspendedClients { block() } } finally { storeIdProvider.clearCache() endWipe() diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt index eef9af0b15..728b112e41 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt @@ -3,24 +3,27 @@ package to.bitkit.data.backup import com.synonym.vssclient.KeyVersion import com.synonym.vssclient.VssItem import com.synonym.vssclient.vssDelete +import com.synonym.vssclient.vssDeriveStoreId import com.synonym.vssclient.vssGet import com.synonym.vssclient.vssListKeys import com.synonym.vssclient.vssNewClientWithLnurlAuth import com.synonym.vssclient.vssStore -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds class MnemonicNotAvailableException : Exception("Mnemonic not available") @@ -28,19 +31,12 @@ class MnemonicNotAvailableException : Exception("Mnemonic not available") @Singleton class VssBackupClient @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, - private val vssStoreIdProvider: VssStoreIdProvider, + private val coordinator: VssClientCoordinator, private val keychain: Keychain, ) { - @Volatile - private var isSetup = CompletableDeferred() - private val setupMutex = Mutex() - suspend fun setup(walletIndex: Int = 0): Result = withContext(ioDispatcher) { - setupMutex.withLock { - val gate = isSetup - runCatching { - if (gate.isCompleted && !gate.isCancelled) return@runCatching - + runCatching { + coordinator.setup(VssClientCoordinator.Domain.APP) { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw MnemonicNotAvailableException() @@ -48,13 +44,13 @@ class VssBackupClient @Inject constructor( Logger.debug("VSS client setting up…", context = TAG) val vssUrl = Env.vssServerUrl val lnurlAuthServerUrl = Env.lnurlAuthServerUrl - val vssStoreId = vssStoreIdProvider.getVssStoreId(walletIndex) Logger.verbose("Building VSS client with vssUrl: '$vssUrl'", context = TAG) Logger.verbose("Building VSS client with lnurlAuthServerUrl: '$lnurlAuthServerUrl'", context = TAG) if (lnurlAuthServerUrl.isBlank()) { throw ServiceError.VssAuthRequired() } val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val vssStoreId = vssDeriveStoreId(Env.vssStoreIdPrefix, mnemonic, passphrase) vssNewClientWithLnurlAuth( baseUrl = vssUrl, @@ -63,14 +59,13 @@ class VssBackupClient @Inject constructor( passphrase = passphrase, lnurlAuthServerUrl = lnurlAuthServerUrl, ) - gate.complete(Unit) - Logger.info("VSS client setup with server: '$vssUrl'", context = TAG) + Logger.info("VSS client setup for wallet[$walletIndex] with server: '$vssUrl'", context = TAG) } - }.onFailure { - gate.completeExceptionally(it) - if (isSetup === gate) isSetup = CompletableDeferred() - Logger.error("VSS client setup error", it, context = TAG) } + }.onFailure { + currentCoroutineContext().ensureActive() + if (it is CancellationException && it !is TimeoutCancellationException) throw it + Logger.error("VSS client setup error", it, context = TAG) } } @@ -85,49 +80,43 @@ class VssBackupClient @Inject constructor( baseDelayMs: Long = 1000L, logger: SetupRetryLogger.() -> Unit, ): Result = withContext(ioDispatcher) { - val log = SetupRetryLogger().apply(logger) - var attempt = 0 - while (attempt < maxAttempts) { - val result = setup() - if (result.isSuccess) { - log.onSuccess(attempt + 1) - return@withContext Result.success(Unit) - } - val exception = result.exceptionOrNull() - if (exception != null && exception !is MnemonicNotAvailableException) { - return@withContext result - } - attempt++ - if (attempt < maxAttempts) { - val delayMs = baseDelayMs * attempt - log.onRetry(attempt, maxAttempts, delayMs) - delay(delayMs) + coordinator.withWallet { + val log = SetupRetryLogger().apply(logger) + var attempt = 0 + while (attempt < maxAttempts) { + val result = setup() + if (result.isSuccess) { + log.onSuccess(attempt + 1) + return@withWallet Result.success(Unit) + } + val exception = result.exceptionOrNull() + if (exception != null && exception !is MnemonicNotAvailableException) { + return@withWallet result + } + attempt++ + if (attempt < maxAttempts) { + val delayMs = baseDelayMs * attempt + log.onRetry(attempt, maxAttempts, delayMs) + delay(delayMs) + } } + log.onExhausted(maxAttempts) + Result.failure(MnemonicNotAvailableException()) } - log.onExhausted(maxAttempts) - Result.failure(MnemonicNotAvailableException()) - } - - fun reset() { - synchronized(this) { - isSetup.cancel() - isSetup = CompletableDeferred() - } - vssStoreIdProvider.clearCache() - Logger.debug("VSS client reset", context = TAG) } suspend fun putObject( key: String, data: ByteArray, ): Result = withContext(ioDispatcher) { - isSetup.await() Logger.verbose("VSS 'putObject' call for '$key'", context = TAG) - runCatching { - vssStore( - key = key, - value = data, - ) + runSuspendCatching { + coordinator.withClient(VssClientCoordinator.Domain.APP) { + vssStore( + key = key, + value = data, + ) + } }.onSuccess { Logger.verbose("VSS 'putObject' success for '$key' at version: '${it.version}'", context = TAG) }.onFailure { @@ -136,12 +125,13 @@ class VssBackupClient @Inject constructor( } suspend fun getObject(key: String): Result = withContext(ioDispatcher) { - isSetup.await() Logger.verbose("VSS 'getObject' call for '$key'", context = TAG) - runCatching { - vssGet( - key = key, - ) + runSuspendCatching { + coordinator.withClient(VssClientCoordinator.Domain.APP) { + vssGet( + key = key, + ) + } }.onSuccess { if (it == null) { Logger.verbose("VSS 'getObject' success null for '$key'", context = TAG) @@ -154,10 +144,11 @@ class VssBackupClient @Inject constructor( } suspend fun listKeys(prefix: String? = null): Result> = withContext(ioDispatcher) { - isSetup.await() Logger.verbose("VSS 'listKeys' call with prefix: '$prefix'", context = TAG) - runCatching { - vssListKeys(prefix = prefix) + runSuspendCatching { + coordinator.withClient(VssClientCoordinator.Domain.APP) { + vssListKeys(prefix = prefix) + } }.onSuccess { Logger.verbose("VSS 'listKeys' success - found ${it.size} key(s)", context = TAG) }.onFailure { @@ -166,10 +157,11 @@ class VssBackupClient @Inject constructor( } suspend fun deleteObject(key: String): Result = withContext(ioDispatcher) { - isSetup.await() Logger.verbose("VSS 'deleteObject' call for '$key'", context = TAG) - runCatching { - vssDelete(key = key) + runSuspendCatching { + coordinator.withClient(VssClientCoordinator.Domain.APP) { + vssDelete(key = key) + } }.onSuccess { wasDeleted -> if (wasDeleted) { Logger.verbose("VSS 'deleteObject' success for '$key' - key was deleted", context = TAG) @@ -182,16 +174,17 @@ class VssBackupClient @Inject constructor( } suspend fun deleteAllKeys(): Result = withContext(ioDispatcher) { - isSetup.await() Logger.verbose("VSS 'deleteAllKeys' call", context = TAG) - runCatching { - val keys = vssListKeys(prefix = null) - var deletedCount = 0 - for (keyVersion in keys) { - val wasDeleted = vssDelete(key = keyVersion.key) - if (wasDeleted) deletedCount++ + runSuspendCatching { + coordinator.withClient(VssClientCoordinator.Domain.APP) { + val keys = vssListKeys(prefix = null) + var deletedCount = 0 + for (keyVersion in keys) { + val wasDeleted = vssDelete(key = keyVersion.key) + if (wasDeleted) deletedCount++ + } + deletedCount } - deletedCount }.onSuccess { Logger.verbose("VSS 'deleteAllKeys' success - deleted $it key(s)", context = TAG) }.onFailure { diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt index 7842dbf194..bd5fe8766e 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt @@ -3,29 +3,32 @@ package to.bitkit.data.backup import com.synonym.vssclient.KeyVersion import com.synonym.vssclient.LdkNamespace import com.synonym.vssclient.VssItem +import com.synonym.vssclient.vssDeriveStoreId import com.synonym.vssclient.vssLdkDelete import com.synonym.vssclient.vssLdkGet import com.synonym.vssclient.vssLdkListKeys import com.synonym.vssclient.vssNewLdkClientWithLnurlAuth -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds @Singleton class VssBackupClientLdk @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, - private val vssStoreIdProvider: VssStoreIdProvider, + private val coordinator: VssClientCoordinator, private val keychain: Keychain, ) { companion object { @@ -38,16 +41,9 @@ class VssBackupClientLdk @Inject constructor( ) } - @Volatile - private var isSetup = CompletableDeferred() - private val setupMutex = Mutex() - suspend fun setup(walletIndex: Int = 0): Result = withContext(ioDispatcher) { - setupMutex.withLock { - val gate = isSetup - runCatching { - if (gate.isCompleted && !gate.isCancelled) return@runCatching - + runCatching { + coordinator.setup(VssClientCoordinator.Domain.LDK) { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw MnemonicNotAvailableException() @@ -58,38 +54,30 @@ class VssBackupClientLdk @Inject constructor( val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) vssNewLdkClientWithLnurlAuth( baseUrl = Env.vssServerUrl, - storeId = vssStoreIdProvider.getVssStoreId(walletIndex), + storeId = vssDeriveStoreId(Env.vssStoreIdPrefix, mnemonic, passphrase), mnemonic = mnemonic, passphrase = passphrase, lnurlAuthServerUrl = Env.lnurlAuthServerUrl, ) - gate.complete(Unit) - Logger.info("VSS LDK client setup", context = TAG) + Logger.info("VSS LDK client setup for wallet[$walletIndex]", context = TAG) } - }.onFailure { - gate.completeExceptionally(it) - if (isSetup === gate) isSetup = CompletableDeferred() - Logger.error("VSS LDK client setup error", it, context = TAG) } + }.onFailure { + currentCoroutineContext().ensureActive() + if (it is CancellationException && it !is TimeoutCancellationException) throw it + Logger.error("VSS LDK client setup error", it, context = TAG) } } - fun reset() { - synchronized(this) { - isSetup.cancel() - isSetup = CompletableDeferred() - } - Logger.debug("VSS LDK client reset", context = TAG) - } - suspend fun getObject( key: String, namespace: LdkNamespace = LdkNamespace.Default, ): Result = withContext(ioDispatcher) { - isSetup.await() Logger.verbose("VSS LDK 'getObject' call for '$key'", context = TAG) - runCatching { - vssLdkGet(key = key, namespace = namespace) + runSuspendCatching { + coordinator.withClient(VssClientCoordinator.Domain.LDK) { + vssLdkGet(key = key, namespace = namespace) + } }.onSuccess { if (it == null) { Logger.verbose("VSS LDK 'getObject' success null for '$key'", context = TAG) @@ -105,10 +93,11 @@ class VssBackupClientLdk @Inject constructor( key: String, namespace: LdkNamespace = LdkNamespace.Default, ): Result = withContext(ioDispatcher) { - isSetup.await() Logger.verbose("VSS LDK 'deleteObject' call for '$key'", context = TAG) - runCatching { - vssLdkDelete(key = key, namespace = namespace) + runSuspendCatching { + coordinator.withClient(VssClientCoordinator.Domain.LDK) { + vssLdkDelete(key = key, namespace = namespace) + } }.onSuccess { wasDeleted -> if (wasDeleted) { Logger.verbose("VSS LDK 'deleteObject' success for '$key' - key was deleted", context = TAG) @@ -120,11 +109,23 @@ class VssBackupClientLdk @Inject constructor( } } + suspend fun deleteObjects(keys: List, walletIndex: Int = 0): Result = withContext(ioDispatcher) { + runSuspendCatching { + coordinator.withWallet { + setup(walletIndex).getOrThrow() + coordinator.withClient(VssClientCoordinator.Domain.LDK) { + keys.forEach { vssLdkDelete(key = it, namespace = LdkNamespace.Default) } + } + } + } + } + suspend fun listAllKeysTagged(): Result>> = withContext(ioDispatcher) { - isSetup.await() Logger.verbose("VSS LDK 'listAllKeysTagged' call", context = TAG) - runCatching { - NAMESPACES.flatMap { ns -> vssLdkListKeys(namespace = ns).map { ns to it } } + runSuspendCatching { + coordinator.withClient(VssClientCoordinator.Domain.LDK) { + NAMESPACES.flatMap { ns -> vssLdkListKeys(namespace = ns).map { ns to it } } + } }.onSuccess { Logger.verbose("VSS LDK 'listAllKeysTagged' success - found ${it.size} key(s)", context = TAG) }.onFailure { diff --git a/app/src/main/java/to/bitkit/data/backup/VssClientCoordinator.kt b/app/src/main/java/to/bitkit/data/backup/VssClientCoordinator.kt new file mode 100644 index 0000000000..e7768e136c --- /dev/null +++ b/app/src/main/java/to/bitkit/data/backup/VssClientCoordinator.kt @@ -0,0 +1,158 @@ +package to.bitkit.data.backup + +import com.synonym.vssclient.vssShutdownClient +import com.synonym.vssclient.vssShutdownLdkClient +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.cancellation.CancellationException + +/** Owns the two standalone native slots; the running Lightning node has a separate lifetime. */ +@Singleton +class VssClientCoordinator @Inject constructor() { + enum class Domain { APP, LDK } + + class Owner internal constructor(internal val generation: Long) + + private class Admission(val coordinator: VssClientCoordinator, val owner: Owner) : + AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key + } + + private class Client { + val setupMutex = Mutex() + var ready = CompletableDeferred() + var attempted = false + } + + private val lock = Any() + private val transitionMutex = Mutex() + private var generation = 0L + private var suspended = false + private val jobs = mutableSetOf() + private val clients = Domain.entries.associateWith { Client() } + private val _owners = MutableStateFlow(Owner(generation)) + val owners = _owners.asStateFlow() + + fun captureOwner(): Owner = synchronized(lock) { + if (suspended) throw CancellationException("VSS clients are suspended") + Owner(generation) + } + + private fun checkOwner(owner: Owner) = synchronized(lock) { + if (suspended || owner.generation != generation) { + throw CancellationException("VSS wallet owner changed") + } + } + + /** Retain admission across payload capture, retries, compound requests and their local tails. */ + suspend fun withWallet(owner: Owner? = null, block: suspend () -> T): T = coroutineScope { + val inherited = currentCoroutineContext()[Admission]?.takeIf { it.coordinator === this@VssClientCoordinator } + val original = owner ?: inherited?.owner ?: captureOwner() + val job = checkNotNull(currentCoroutineContext()[Job]) + synchronized(lock) { + checkOwner(original) + jobs.add(job) + } + try { + withContext(Admission(this@VssClientCoordinator, original)) { + val result = runCatching { block() } + currentCoroutineContext().ensureActive() + checkOwner(original) + result.getOrThrow() + } + } finally { + synchronized(lock) { jobs.remove(job) } + } + } + + suspend fun setup(domain: Domain, block: suspend () -> Unit) = withWallet { + val client = clients.getValue(domain) + client.setupMutex.withLock { + currentCoroutineContext().ensureActive() + val gate = synchronized(lock) { client.ready } + if (gate.isCompleted && !gate.isCancelled) return@withLock + try { + synchronized(lock) { client.attempted = true } + block() + currentCoroutineContext().ensureActive() + gate.complete(Unit) + } catch (error: Throwable) { + gate.completeExceptionally(error) + synchronized(lock) { + if (client.ready === gate) client.ready = CompletableDeferred() + } + throw error + } + } + } + + suspend fun withClient(domain: Domain, block: suspend () -> T): T = withWallet { + val gate = synchronized(lock) { clients.getValue(domain).ready } + gate.await() + currentCoroutineContext().ensureActive() + block() + } + + /** Close admission before credentials change, including cleanup when the caller is cancelled. */ + suspend fun withSuspendedClients( + shutdown: (Domain) -> Unit = { + when (it) { + Domain.APP -> vssShutdownClient() + Domain.LDK -> vssShutdownLdkClient() + } + }, + block: suspend () -> T, + ): T { + check(currentCoroutineContext()[Admission]?.coordinator !== this) { + "Cannot replace a wallet inside an admitted VSS operation" + } + val original = captureOwner() + return transitionMutex.withLock { + val pending = synchronized(lock) { + checkOwner(original) + suspended = true + _owners.update { null } + generation += 1 + clients.values.forEach { it.ready.cancel() } + jobs.toList() + } + var shutdownComplete = false + try { + withContext(NonCancellable) { + pending.forEach { it.cancel(CancellationException("VSS wallet retired")) } + pending.joinAll() + clients.forEach { (domain, client) -> if (client.attempted) shutdown(domain) } + shutdownComplete = true + } + currentCoroutineContext().ensureActive() + block() + } finally { + synchronized(lock) { + if (shutdownComplete) { + clients.values.forEach { + it.ready = CompletableDeferred() + it.attempted = false + } + suspended = false + _owners.update { Owner(generation) } + } + } + } + } + } +} diff --git a/app/src/main/java/to/bitkit/models/BalanceState.kt b/app/src/main/java/to/bitkit/models/BalanceState.kt index 7618389b57..8161394b3f 100644 --- a/app/src/main/java/to/bitkit/models/BalanceState.kt +++ b/app/src/main/java/to/bitkit/models/BalanceState.kt @@ -14,6 +14,9 @@ data class BalanceState( val balanceInTransferToSavings: ULong = 0uL, val balanceInTransferToSpending: ULong = 0uL, val hardwareWallets: List = emptyList(), + /** Spark amounts included in the totals; null in caches whose rail split is unknown. */ + val sparkBalanceSats: ULong? = null, + val sparkPendingDepositSats: ULong? = null, ) { val totalSats get() = totalOnchainSats diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 2faa96a4e2..008a002884 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -5,6 +5,8 @@ import com.synonym.bitkitcore.UsdtException import com.synonym.vssclient.VssException import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -17,14 +19,13 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive @@ -52,6 +53,7 @@ import to.bitkit.data.WidgetsStore import to.bitkit.data.backup.UsdtRecoveryBackupStore import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.backup.VssBackupClientLdk +import to.bitkit.data.backup.VssClientCoordinator import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.entities.TransferEntity import to.bitkit.data.hwWalletNames @@ -92,8 +94,6 @@ import to.bitkit.utils.NetworkValidationHelper import to.bitkit.utils.jsonLogOf import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicLong -import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @@ -124,6 +124,7 @@ class BackupRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val cacheStore: CacheStore, private val vssBackupClient: VssBackupClient, + private val vssClients: VssClientCoordinator, private val vssBackupClientLdk: VssBackupClientLdk, private val settingsStore: SettingsStore, private val widgetsStore: WidgetsStore, @@ -161,19 +162,41 @@ class BackupRepo @Inject constructor( private val usdtRecoveryBackup = UsdtRecoveryBackupStore(vssBackupClient) private val scope = appScope(ioDispatcher, TAG) - private val backupJobs = mutableMapOf() + private val backupJobs = ConcurrentHashMap.newKeySet() + private val backupJobLock = Any() + private val pendingBackups = ConcurrentHashMap() + private class PendingBackup( + val owner: VssClientCoordinator.Owner, + @Volatile var skipUnchangedActivity: Boolean, + ) { + @Volatile + var admitted = false + } + private val statusObserverJobs = mutableListOf() private val dataListenerJobs = mutableListOf() private var periodicCheckJob: Job? = null + private var setupJob: Job? = null + private var setupGeneration: Long? = null private val runningBackups = ConcurrentHashMap.newKeySet() // Tracks active jobs since app start private val failedBackupRequired = ConcurrentHashMap() private val temporaryFailureRetries = ConcurrentHashMap() private val temporaryFailureSince = ConcurrentHashMap() + @Volatile private var isObserving = false private var lastNotificationTime = 0L + private class ActivityUploadCache { + var ownerGeneration: Long? = null + var digest: ByteArray? = null + } + + // Uploads are serialized by activityBackupMutex. Reset swaps the cache so an old ACK cannot populate it. + @Volatile + private var activityUploadCache = ActivityUploadCache() + /** Spark's current connection was backed up once ready, so a restart of the observers doesn't upload it again. */ @Volatile private var hasBackedUpSparkConnection = false @@ -182,9 +205,6 @@ class BackupRepo @Inject constructor( @Volatile private var markedSparkExitGeneration = 0L - private val uploadedActivityDigest = AtomicReference(null) - private val activityDigestEpoch = AtomicLong(0) - private val _isRestoring = MutableStateFlow(false) val isRestoring: StateFlow = _isRestoring.asStateFlow() @@ -212,8 +232,6 @@ class BackupRepo @Inject constructor( fun reset() { stopObservingBackups() clearUploadedActivityDigest() - vssBackupClient.reset() - vssBackupClientLdk.reset() } fun setWiping(isWiping: Boolean) { @@ -270,35 +288,48 @@ class BackupRepo @Inject constructor( isObserving = true Logger.debug("Start observing backup statuses and data store changes", context = TAG) - scope.launch { - vssBackupClient.setupWithRetry { - onSuccess = { attempt -> - Logger.debug("VSS client setup succeeded on attempt $attempt", context = TAG) - } - onRetry = { attempt, maxAttempts, delayMs -> - Logger.debug( - "VSS client setup deferred, retrying in ${delayMs}ms (attempt $attempt/$maxAttempts)", - context = TAG, - ) - } - onExhausted = { maxAttempts -> - Logger.warn("VSS client setup failed after $maxAttempts attempts", context = TAG) + setupJob = scope.launch { + vssClients.owners.collectLatest { owner -> + if (owner == null) return@collectLatest + vssClients.withWallet(owner) { + if (setupGeneration != null && setupGeneration != owner.generation) { + failedBackupRequired.clear() + temporaryFailureRetries.clear() + temporaryFailureSince.clear() + } + setupGeneration = owner.generation + vssBackupClient.setupWithRetry { + onSuccess = { attempt -> + Logger.debug("VSS client setup succeeded on attempt $attempt", context = TAG) + } + onRetry = { attempt, maxAttempts, delayMs -> + Logger.debug( + "VSS client setup deferred, retrying in ${delayMs}ms (attempt $attempt/$maxAttempts)", + context = TAG, + ) + } + onExhausted = { maxAttempts -> + Logger.warn("VSS client setup failed after $maxAttempts attempts", context = TAG) + } + }.onSuccess { + vssBackupClientLdk.setup() + _isWiping.first { !it } + cacheStore.backupStatuses.first().forEach { (category, status) -> + if (status.shouldBackup(category)) scheduleBackup(category) + } + } } - }.onSuccess { - scope.launch { vssBackupClientLdk.setup() } } } - scope.launch { + launchForWallet { BackupCategory.entries.forEach { category -> - if (category !in runningBackups) { - cacheStore.updateBackupStatus(category) { status -> - if (status.running) { - Logger.debug("Clearing stale running flag for: '$category'", context = TAG) - status.copy(running = false) - } else { - status - } + cacheStore.updateBackupStatus(category) { status -> + if (status.running && category !in runningBackups && pendingBackups[category]?.admitted != true) { + Logger.debug("Clearing stale running flag for: '$category'", context = TAG) + status.copy(running = false) + } else { + status } } } @@ -312,11 +343,18 @@ class BackupRepo @Inject constructor( fun stopObservingBackups() { if (!isObserving) return - isObserving = false + val pendingJobs = synchronized(backupJobLock) { + isObserving = false + backupJobs.toList().also { + backupJobs.clear() + pendingBackups.clear() + } + } + setupJob?.cancel() + setupJob = null // Cancel all backup jobs - backupJobs.values.forEach { it.cancel() } - backupJobs.clear() + pendingJobs.forEach { it.cancel() } // Cancel backup status observer jobs statusObserverJobs.forEach { it.cancel() } @@ -337,18 +375,23 @@ class BackupRepo @Inject constructor( // Observe backup status changes for each category BackupCategory.entries.forEach { category -> val job = scope.launch { - cacheStore.backupStatuses + val statuses = cacheStore.backupStatuses .map { statuses -> statuses[category] ?: BackupItemStatus() } .distinctUntilChanged { old, new -> old.synced == new.synced && old.required == new.required && old.running == new.running } - .collect { status -> - if (status.shouldBackup(category)) { - scheduleBackup(category) - } + val resumableStatuses = if (category == BackupCategory.SPARK_EXIT) { + statuses.combine(restoreInProgress) { status, _ -> status } + } else { + statuses + } + resumableStatuses.collect { status -> + if (status.shouldBackup(category)) { + scheduleBackup(category) } + } } statusObserverJobs.add(job) } @@ -481,27 +524,41 @@ class BackupRepo @Inject constructor( // payment and sync traffic does not re-upload the whole metadata envelope. dataListenerJobs.add(observeBackupChanges(activityRepo.activityTagsChanged, BackupCategory.METADATA)) - // SPARK_EXIT - back up once per Spark connection when it is ready and synced, even if observing starts after - // that, and again whenever Spark announces its unilateral exit data changed - dataListenerJobs.add( - scope.launch { - val isReady = sparkRepo.state.map { it.isReady && it.hasSynced }.distinctUntilChanged() - .onEach { if (!it) hasBackedUpSparkConnection = false } - .filter { it } - isReady.collect { - if (hasBackedUpSparkConnection || shouldSkipBackup()) return@collect - hasBackedUpSparkConnection = true - markBackupRequired(BackupCategory.SPARK_EXIT) - } - } - ) - // The generation replays on each start, so exit data that changed while nothing observed is backed up then. + // Replay unrecorded Spark changes when wallet admission or the restore gate reopens. dataListenerJobs.add( scope.launch { - sparkRepo.exitStateGeneration.collect { generation -> - if (generation <= markedSparkExitGeneration || shouldSkipBackup()) return@collect - markedSparkExitGeneration = generation - markBackupRequired(BackupCategory.SPARK_EXIT) + vssClients.owners.collectLatest { owner -> + if (owner == null) return@collectLatest + vssClients.withWallet(owner) { + combine( + sparkRepo.state, + sparkRepo.exitStateGeneration, + restoreInProgress + ) { state, generation, _ -> + state to generation + }.collect { (state, generation) -> + val ready = state.isReady && state.hasSynced + if (!ready) hasBackedUpSparkConnection = false + val connectionNeedsBackup = ready && !hasBackedUpSparkConnection + if ((!connectionNeedsBackup && generation <= markedSparkExitGeneration) || + shouldSkipBackup() + ) { + return@collect + } + runSuspendCatching { updateBackupRequired(BackupCategory.SPARK_EXIT) } + .onSuccess { + hasBackedUpSparkConnection = ready + markedSparkExitGeneration = maxOf(markedSparkExitGeneration, generation) + } + .onFailure { + Logger.error( + "Failed to mark Spark exit backup required", + it, + context = TAG + ) + } + } + } } } ) @@ -543,51 +600,100 @@ class BackupRepo @Inject constructor( } } - private fun markBackupRequired(category: BackupCategory) { - scope.launch { - failedBackupRequired -= category - cacheStore.updateBackupStatus(category) { - it.copy(required = maxOf(currentTimeMillis(), it.required + 1, it.synced + 1)) - } - Logger.verbose("Marked backup required for: '$category'", context = TAG) - } + private fun launchForWallet(block: suspend CoroutineScope.() -> Unit): Job { + val owner = runCatching { vssClients.captureOwner() }.getOrNull() ?: return Job().apply { cancel() } + return scope.launch { vssClients.withWallet(owner) { block() } } } - private fun scheduleBackup(category: BackupCategory, skipUnchangedActivity: Boolean = true) { - backupJobs[category]?.cancel() - - Logger.verbose("Scheduling backup for: '$category'", context = TAG) - - backupJobs[category] = scope.launch { - runningBackups += category - cacheStore.updateBackupStatus(category) { - it.copy(running = true) - } + private fun markBackupRequired(category: BackupCategory) { + launchForWallet { updateBackupRequired(category) } + } - delay(if (category == BackupCategory.SPARK_EXIT) SPARK_EXIT_BACKUP_DEBOUNCE else BACKUP_DEBOUNCE) - if (category == BackupCategory.WALLET) paykitSdkService.isPaymentSubmissionActive.first { !it } + private suspend fun updateBackupRequired(category: BackupCategory) { + failedBackupRequired -= category + cacheStore.updateBackupStatus(category) { + it.copy(required = maxOf(currentTimeMillis(), it.required + 1, it.synced + 1)) + } + Logger.verbose("Marked backup required for: '$category'", context = TAG) + } - val status = cacheStore.backupStatuses.first()[category] ?: BackupItemStatus() - if (status.isRequired && !shouldSkipBackup(category)) { - triggerBackup(category, skipUnchangedActivity) - } else { - Logger.debug("Backup no longer needed for: '$category'", context = TAG) - runningBackups -= category - cacheStore.updateBackupStatus(category) { - it.copy(running = false) + private fun scheduleBackup( + category: BackupCategory, + expectedOwner: VssClientCoordinator.Owner? = null, + requireObservation: Boolean = true, + skipUnchangedActivity: Boolean = true, + ) { + val owner = expectedOwner ?: runCatching { vssClients.captureOwner() }.getOrNull() ?: return + val job = synchronized(backupJobLock) { + if (requireObservation && !isObserving) return + val pending = PendingBackup(owner, skipUnchangedActivity) + var reserved = false + pendingBackups.compute(category) { _, previous -> + if (previous == null || previous.owner.generation < owner.generation) { + reserved = true + pending + } else { + if (previous.owner.generation == owner.generation && !skipUnchangedActivity) { + previous.skipUnchangedActivity = false + } + previous } } - }.also { job -> - job.invokeOnCompletion { exception -> - if (exception != null) { - Logger.debug("Backup job cancelled for: '$category'", context = TAG) - scope.launch { - runningBackups -= category - cacheStore.updateBackupStatus(category) { - it.copy(running = false) + if (!reserved) return + Logger.debug("Scheduling backup for: '$category'", context = TAG) + scope.launch(start = CoroutineStart.LAZY) { + try { + vssClients.withWallet(owner) { + pending.admitted = true + try { + cacheStore.updateBackupStatus(category) { it.copy(running = true) } + val debounce = if (category == BackupCategory.SPARK_EXIT) { + SPARK_EXIT_BACKUP_DEBOUNCE + } else { + BACKUP_DEBOUNCE + } + delay(debounce) + if (category == BackupCategory.WALLET) { + paykitSdkService.isPaymentSubmissionActive.first { !it } + } + runPendingBackup(category, pending) + } finally { + withContext(NonCancellable) { + pendingBackups.remove(category, pending) + cacheStore.updateBackupStatus(category) { + it.copy( + running = category in runningBackups || + pendingBackups[category]?.admitted == true + ) + } + } } } + } finally { + pendingBackups.remove(category, pending) } + }.also { scheduled -> + backupJobs.add(scheduled) + scheduled.invokeOnCompletion { backupJobs.remove(scheduled) } + } + } + job.start() + } + + private suspend fun runPendingBackup(category: BackupCategory, pending: PendingBackup) { + // A full backup can still upgrade an observed ACTIVITY request while its payload is being read. + if (category != BackupCategory.ACTIVITY) pendingBackups.remove(category, pending) + val status = cacheStore.backupStatuses.first()[category] ?: BackupItemStatus() + if (!status.copy(running = category in runningBackups).shouldBackup(category)) return + + val uploaded = triggerBackup(category, pending.skipUnchangedActivity) + if (category == BackupCategory.ACTIVITY && pendingBackups.remove(category, pending)) { + if (uploaded.getOrNull() == false && !pending.skipUnchangedActivity && !shouldSkipBackup(category)) { + triggerBackup(category, skipUnchangedActivity = false) + } + // An observer may have joined this reservation after the upload captured its revision. + if (cacheStore.backupStatuses.first()[category]?.shouldBackup(category) == true) { + scheduleBackup(category, expectedOwner = pending.owner) } } } @@ -596,7 +702,7 @@ class BackupRepo @Inject constructor( val currentTime = currentTimeMillis() // find if there are any backup categories that have been failing for more than 30 minutes - scope.launch { + launchForWallet { val backupStatuses = cacheStore.backupStatuses.first() val hasFailedBackups = BackupCategory.entries.any { category -> val status = backupStatuses[category] ?: BackupItemStatus() @@ -619,7 +725,7 @@ class BackupRepo @Inject constructor( lastNotificationTime = currentTime - scope.launch { + launchForWallet { ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.settings__backup__failed_title), @@ -630,20 +736,23 @@ class BackupRepo @Inject constructor( } } - suspend fun triggerBackup(category: BackupCategory): Result = triggerBackup(category, false) + suspend fun triggerBackup(category: BackupCategory): Result = + triggerBackup(category, skipUnchangedActivity = false).map {} private suspend fun triggerBackup( category: BackupCategory, skipUnchangedActivity: Boolean, - ): Result = withContext(ioDispatcher) { - if (category == BackupCategory.WALLET || category == BackupCategory.METADATA) { - walletBackupMutex.withLock { performBackup(category, skipUnchangedActivity) } - } else { - performBackup(category, skipUnchangedActivity) + ): Result = withContext(ioDispatcher) { + vssClients.withWallet { + if (category == BackupCategory.WALLET || category == BackupCategory.METADATA) { + walletBackupMutex.withLock { performBackup(category, skipUnchangedActivity) } + } else { + performBackup(category, skipUnchangedActivity) + } } } - private suspend fun performBackup(category: BackupCategory, skipUnchangedActivity: Boolean): Result { + private suspend fun performBackup(category: BackupCategory, skipUnchangedActivity: Boolean): Result { Logger.debug("Backup starting for: '$category'", context = TAG) var backupRequired = currentTimeMillis() runningBackups += category @@ -692,7 +801,6 @@ class BackupRepo @Inject constructor( } } .onFailure { markBackupFailed(category, backupRequired, it) } - .map {} } finally { if (runningBackups.remove(category)) { withContext(NonCancellable) { @@ -723,15 +831,20 @@ class BackupRepo @Inject constructor( suspend fun persistWalletBackup(): Result = withContext(ioDispatcher) { - walletBackupMutex.withLock { uploadWalletBackup() } - }.onFailure { markBackupRequired(BackupCategory.WALLET) } - - suspend fun persistUsdtRecoveryBackup(snapshot: String): Result = runSuspendCatching { - cacheStore.updateBackupStatus(BackupCategory.USDT_RECOVERY) { - it.copy(required = maxOf(currentTimeMillis(), it.required + 1, it.synced + 1)) + vssClients.withWallet { + walletBackupMutex.withLock { uploadWalletBackup() } + .onFailure { markBackupRequired(BackupCategory.WALLET) } + } } - uploadUsdtRecoveryBackup(snapshot).getOrThrow() - }.onFailure { markBackupRequired(BackupCategory.USDT_RECOVERY) } + + suspend fun persistUsdtRecoveryBackup(snapshot: String): Result = vssClients.withWallet { + runSuspendCatching { + cacheStore.updateBackupStatus(BackupCategory.USDT_RECOVERY) { + it.copy(required = maxOf(currentTimeMillis(), it.required + 1, it.synced + 1)) + } + uploadUsdtRecoveryBackup(snapshot).getOrThrow() + }.onFailure { markBackupRequired(BackupCategory.USDT_RECOVERY) } + } private suspend fun uploadUsdtRecoveryBackup(snapshot: String? = null): Result = withContext(ioDispatcher) { runSuspendCatching { @@ -795,10 +908,10 @@ class BackupRepo @Inject constructor( val retryDelay = (TEMPORARY_FAILURE_RETRY_DELAY * (1 shl (attempt - 1).coerceAtMost(MAX_RETRY_SHIFT))) .coerceAtMost(TEMPORARY_FAILURE_RETRY_MAX_DELAY) Logger.warn("Retrying backup for: '$category' in '$retryDelay'", e, context = TAG) - scope.launch { + launchForWallet { delay(retryDelay) - if (!failedBackupRequired.remove(category, backupRequired) || !isObserving) return@launch - val status = cacheStore.backupStatuses.first()[category] ?: return@launch + if (!failedBackupRequired.remove(category, backupRequired) || !isObserving) return@launchForWallet + val status = cacheStore.backupStatuses.first()[category] ?: return@launchForWallet if (status.shouldBackup(category)) scheduleBackup(category) } } @@ -861,24 +974,29 @@ class BackupRepo @Inject constructor( } private suspend fun uploadActivityBackupLocked(skipIfUnchanged: Boolean): Boolean { + val cache = activityUploadCache + val ownerGeneration = vssClients.captureOwner().generation val payload = getActivityBackupPayload() val digest = MessageDigest.getInstance("SHA-256") .digest(json.encodeToString(payload.copy(createdAt = 0)).toByteArray()) - if (skipIfUnchanged && digest.contentEquals(uploadedActivityDigest.get())) return false + if (skipIfUnchanged && cache === activityUploadCache && + cache.ownerGeneration == ownerGeneration && digest.contentEquals(cache.digest) + ) { + return false + } - val epoch = activityDigestEpoch.get() - uploadedActivityDigest.set(null) + cache.digest = null vssBackupClient.putObject( key = BackupCategory.ACTIVITY.name, data = json.encodeToString(payload).toByteArray(), ).getOrThrow() - if (activityDigestEpoch.get() == epoch) uploadedActivityDigest.set(digest) + cache.ownerGeneration = ownerGeneration + cache.digest = digest return true } private fun clearUploadedActivityDigest() { - activityDigestEpoch.incrementAndGet() - uploadedActivityDigest.set(null) + activityUploadCache = ActivityUploadCache() } private suspend fun getMetadataBackupDataBytes(): ByteArray = withContext(ioDispatcher) { @@ -902,8 +1020,8 @@ class BackupRepo @Inject constructor( // propagate and fail the backup rather than upload an empty set over the stored ones. val hwWalletNames = hwWalletStore.backupSnapshot().takeIf { it.isNotEmpty() } val cacheData = cacheStore.data.first() - val pubkySession = pubkyRepo.snapshotSessionBackupState().getOrDefault(null) - val pubkyContactProfileOverrides = pubkyRepo.snapshotContactProfileOverrides().getOrDefault(null) + val pubkySession = pubkyRepo.snapshotSessionBackupState().getOrThrow() + val pubkyContactProfileOverrides = pubkyRepo.snapshotContactProfileOverrides().getOrThrow() val payload = MetadataBackupV1( createdAt = currentTimeMillis(), @@ -941,6 +1059,9 @@ class BackupRepo @Inject constructor( val privateRepo = privatePaykitRepo.get() val privateVersion = privateRepo.backupStateVersion.value val paykitSdkBackupState = privateRepo.backupSnapshot().getOrThrow() + check(paykitSdkBackupState.state != null || !pubkyRepo.hasIdentityForBackup()) { + "Paykit identity is unavailable for backup" + } val reservationRepo = privatePaykitAddressReservationRepo.get() val reservationVersion = reservationRepo.backupStateVersion.value val privateReservations = reservationRepo.backupSnapshot().getOrThrow() @@ -1365,20 +1486,22 @@ class BackupRepo @Inject constructor( } suspend fun getLatestBackupTime(): ULong? = withContext(ioDispatcher) { - runCatching { - withTimeout(VSS_TIMESTAMP_TIMEOUT) { - vssBackupClient.setup().getOrThrow() - coroutineScope { - LATEST_BACKUP_TIME_CATEGORIES - .map { category -> async { getRemoteBackupTimestamp(category) } } - .mapNotNull { it.await() } - .filter { it > 0uL } - .maxOrNull() + vssClients.withWallet { + runCatching { + withTimeout(VSS_TIMESTAMP_TIMEOUT) { + vssBackupClient.setup().getOrThrow() + coroutineScope { + LATEST_BACKUP_TIME_CATEGORIES + .map { category -> async { getRemoteBackupTimestamp(category) } } + .mapNotNull { it.await() } + .filter { it > 0uL } + .maxOrNull() + } } - } - }.onFailure { e -> - Logger.warn("Failed to get VSS backup timestamp: $e", context = TAG) - }.getOrNull() + }.onFailure { e -> + Logger.warn("Failed to get VSS backup timestamp: $e", context = TAG) + }.getOrNull() + } } private suspend fun getRemoteBackupTimestamp(category: BackupCategory): ULong? { @@ -1395,11 +1518,12 @@ class BackupRepo @Inject constructor( } fun scheduleFullBackup() { + val owner = runCatching { vssClients.captureOwner() }.getOrNull() ?: return Logger.debug("Scheduling backups for all categories", context = TAG) BackupCategory.entries .filter { it != BackupCategory.LIGHTNING_CONNECTIONS } .forEach { - scheduleBackup(it, skipUnchangedActivity = false) + scheduleBackup(it, expectedOwner = owner, requireObservation = false, skipUnchangedActivity = false) } } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index a3231b62d6..38e755fe61 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -69,6 +69,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssBackupClientLdk +import to.bitkit.data.backup.VssClientCoordinator import to.bitkit.data.keychain.Keychain import to.bitkit.data.keychain.requireWalletRestoreComplete import to.bitkit.di.BgDispatcher @@ -136,6 +137,7 @@ class LightningRepo @Inject constructor( private val onchainSendAttemptStore: OnchainSendAttemptStore, private val connectivityRepo: ConnectivityRepo, private val vssBackupClientLdk: VssBackupClientLdk, + private val vssClients: VssClientCoordinator, private val urlValidator: UrlValidator, private val electrumProbeService: ElectrumProbeService, private val sparkRepo: SparkRepo, @@ -382,157 +384,163 @@ class LightningRepo @Inject constructor( shouldValidateGraph: Boolean = true, shouldRetryYieldToStop: Boolean = false, shouldCancelPendingStop: Boolean = true, - ): Result = withContext(bgDispatcher) { - if (_isRecoveryMode.value) { - return@withContext Result.failure(RecoveryModeError()) - } + ): Result = vssClients.withWallet { + withContext(bgDispatcher) { + if (_isRecoveryMode.value) { + return@withContext Result.failure(RecoveryModeError()) + } - if (shouldCancelPendingStop) cancelPendingStop() + if (shouldCancelPendingStop) cancelPendingStop() - // Spark needs no node, so it starts alongside it and stays up while the node stops in background. - if (!isWiping) scope.launch { sparkRepo.start(walletIndex) } + // Spark needs no node, so it starts alongside it and stays up while the node stops in background. + if (!isWiping) scope.launch { sparkRepo.start(walletIndex) } - eventHandler?.let { _eventHandlers.add(it) } + eventHandler?.let { _eventHandlers.add(it) } - // Track retry state outside mutex to avoid deadlock (Mutex is non-reentrant) - var shouldRetryStart = false - var shouldRestartForGraphReset = false - var initialLifecycleState: NodeLifecycleState + // Track retry state outside mutex to avoid deadlock (Mutex is non-reentrant) + var shouldRetryStart = false + var shouldRestartForGraphReset = false + var initialLifecycleState: NodeLifecycleState - val result = lifecycleMutex.withLock { - if (isWiping) return@withLock Result.failure(WipeInProgressError()) - initialLifecycleState = _lightningState.value.nodeLifecycleState - if (initialLifecycleState.isRunningOrStarting()) { - return@withLock skipStartForRunningNode( - lifecycleState = initialLifecycleState, - customServerUrl = customServerUrl, - customRgsServerUrl = customRgsServerUrl, - ) - } + val result = lifecycleMutex.withLock { + if (isWiping) return@withLock Result.failure(WipeInProgressError()) + initialLifecycleState = _lightningState.value.nodeLifecycleState + if (initialLifecycleState.isRunningOrStarting()) { + return@withLock skipStartForRunningNode( + lifecycleState = initialLifecycleState, + customServerUrl = customServerUrl, + customRgsServerUrl = customRgsServerUrl, + ) + } - runCatching { - _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Starting) } - - // Setup if needed - if (lightningService.node == null) { - val setupResult = - setup(walletIndex, customServerUrl, customRgsServerUrl, channelMigration) - if (setupResult.isFailure) { - _lightningState.update { - it.copy( - nodeLifecycleState = NodeLifecycleState.ErrorStarting( - setupResult.exceptionOrNull() ?: NodeSetupError() + runCatching { + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Starting) } + + // Setup if needed + if (lightningService.node == null) { + val setupResult = + setup(walletIndex, customServerUrl, customRgsServerUrl, channelMigration) + if (setupResult.isFailure) { + _lightningState.update { + it.copy( + nodeLifecycleState = NodeLifecycleState.ErrorStarting( + setupResult.exceptionOrNull() ?: NodeSetupError() + ) ) - ) + } + return@withLock setupResult } - return@withLock setupResult } - } - if (lightningService.status?.isRunning == true) { - Logger.info("LDK node already running", context = TAG) - runSuspendCatching { lightningService.reconcileWatchOnlyAccounts() } - .onFailure { - Logger.warn("Failed to reconcile Paykit Server accounts during startup", it, context = TAG) - } - return@withLock adoptRunningNode() - } + if (lightningService.status?.isRunning == true) { + Logger.info("LDK node already running", context = TAG) + runSuspendCatching { lightningService.reconcileWatchOnlyAccounts() } + .onFailure { + Logger.warn( + "Failed to reconcile Paykit Server accounts during startup", + it, + context = TAG, + ) + } + return@withLock adoptRunningNode() + } - lightningService.start(timeout, ::onEvent) + lightningService.start(timeout, ::onEvent) - _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } - listPaymentsKeepingSendHashes() + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } + listPaymentsKeepingSendHashes() - // Initial state sync - syncState() - logNodeSupportSummary("node started") - refreshChannelCache() + // Initial state sync + syncState() + logNodeSupportSummary("node started") + refreshChannelCache() - if (shouldValidateGraph && !lightningService.aresRequiredPeersInNetworkGraph()) { - Logger.warn("Network graph is stale, resetting and restarting...", context = TAG) + if (shouldValidateGraph && !lightningService.aresRequiredPeersInNetworkGraph()) { + Logger.warn("Network graph is stale, resetting and restarting...", context = TAG) - lightningService.stop() - clearNetworkGraph(walletIndex) + lightningService.stop() + clearNetworkGraph(walletIndex) - _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopped) } - shouldRestartForGraphReset = true - return@withLock Result.success(Unit) - } + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopped) } + shouldRestartForGraphReset = true + return@withLock Result.success(Unit) + } - // Post-startup tasks (non-blocking) - disconnectIdlePeers().onFailure { - Logger.error("Failed to disconnect idle peers", it, context = TAG) - } - logNodeSupportSummary("idle peers disconnected") + // Post-startup tasks (non-blocking) + disconnectIdlePeers().onFailure { + Logger.error("Failed to disconnect idle peers", it, context = TAG) + } + logNodeSupportSummary("idle peers disconnected") - sync().onFailure { e -> - Logger.warn("Initial sync failed, event-driven sync will retry", e, context = TAG) - } - Result.success(Unit) - }.getOrElse { e -> - if (e is CancellationException) { - withContext(NonCancellable) { reconcileCancelledStart(initialLifecycleState) } - throw e - } + sync().onFailure { e -> + Logger.warn("Initial sync failed, event-driven sync will retry", e, context = TAG) + } + Result.success(Unit) + }.getOrElse { e -> + if (e is CancellationException) { + withContext(NonCancellable) { reconcileCancelledStart(initialLifecycleState) } + throw e + } - val currentState = _lightningState.value.nodeLifecycleState - if (currentState.isRunning()) { - Logger.warn("Start error but node is $currentState, skipping retry", e, context = TAG) - return@withLock Result.success(Unit) - } + val currentState = _lightningState.value.nodeLifecycleState + if (currentState.isRunning()) { + Logger.warn("Start error but node is $currentState, skipping retry", e, context = TAG) + return@withLock Result.success(Unit) + } - if (lightningService.status?.isRunning == true) { - Logger.warn("Start error but LDK node is running, adopting it", e, context = TAG) - return@withLock adoptRunningNode() + if (lightningService.status?.isRunning == true) { + Logger.warn("Start error but LDK node is running, adopting it", e, context = TAG) + return@withLock adoptRunningNode() + } + + if (shouldRetry) { + Logger.warn("Start error, will retry...", e, context = TAG) + _lightningState.update { it.copy(nodeLifecycleState = initialLifecycleState) } + shouldRetryStart = true + Result.failure(e) + } else { + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.ErrorStarting(e)) } + Result.failure(e) + } } + } - if (shouldRetry) { - Logger.warn("Start error, will retry...", e, context = TAG) - _lightningState.update { it.copy(nodeLifecycleState = initialLifecycleState) } - shouldRetryStart = true - Result.failure(e) - } else { - _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.ErrorStarting(e)) } - Result.failure(e) + // Retry OUTSIDE the mutex to avoid deadlock (Kotlin Mutex is non-reentrant) + if (shouldRetryStart) { + delay(2.seconds) + // A stop requested after this start began wins over the retry; a foreground start cancels it + if (shouldRetryYieldToStop && pendingStopJob.get() != null) { + Logger.info("Skipped start retry because a stop was requested", context = TAG) + return@withContext Result.failure(NodeStartYieldedToStopError(result.exceptionOrNull())) } + return@withContext startNode( + walletIndex = walletIndex, + timeout = timeout, + shouldRetry = false, + customServerUrl = customServerUrl, + customRgsServerUrl = customRgsServerUrl, + channelMigration = channelMigration, + shouldValidateGraph = shouldValidateGraph, + shouldCancelPendingStop = !shouldRetryYieldToStop, + ) } - } - // Retry OUTSIDE the mutex to avoid deadlock (Kotlin Mutex is non-reentrant) - if (shouldRetryStart) { - delay(2.seconds) - // A stop requested after this start began wins over the retry; a foreground start cancels it - if (shouldRetryYieldToStop && pendingStopJob.get() != null) { - Logger.info("Skipped start retry because a stop was requested", context = TAG) - return@withContext Result.failure(NodeStartYieldedToStopError(result.exceptionOrNull())) + // Restart after graph reset OUTSIDE the mutex to avoid deadlock + if (shouldRestartForGraphReset) { + return@withContext start( + walletIndex = walletIndex, + timeout = timeout, + shouldRetry = shouldRetry, + customServerUrl = customServerUrl, + customRgsServerUrl = customRgsServerUrl, + eventHandler = eventHandler, + channelMigration = channelMigration, + shouldValidateGraph = false, // Prevent infinite loop + ) } - return@withContext startNode( - walletIndex = walletIndex, - timeout = timeout, - shouldRetry = false, - customServerUrl = customServerUrl, - customRgsServerUrl = customRgsServerUrl, - channelMigration = channelMigration, - shouldValidateGraph = shouldValidateGraph, - shouldCancelPendingStop = !shouldRetryYieldToStop, - ) - } - // Restart after graph reset OUTSIDE the mutex to avoid deadlock - if (shouldRestartForGraphReset) { - return@withContext start( - walletIndex = walletIndex, - timeout = timeout, - shouldRetry = shouldRetry, - customServerUrl = customServerUrl, - customRgsServerUrl = customRgsServerUrl, - eventHandler = eventHandler, - channelMigration = channelMigration, - shouldValidateGraph = false, // Prevent infinite loop - ) + result } - - result } private suspend fun adoptRunningNode(): Result { @@ -783,14 +791,16 @@ class LightningRepo @Inject constructor( } } - suspend fun resetNetworkGraph(walletIndex: Int = 0): Result = withContext(bgDispatcher) { - Logger.warn("Resetting network graph (manual)", context = TAG) - runCatching { - if (lightningService.node != null) { - lightningService.stop() + suspend fun resetNetworkGraph(walletIndex: Int = 0): Result = vssClients.withWallet { + withContext(bgDispatcher) { + Logger.warn("Resetting network graph (manual)", context = TAG) + runSuspendCatching { + if (lightningService.node != null) { + lightningService.stop() + } + // Propagate VSS failures: a manual reset that leaves the graph in VSS is ineffective. + clearNetworkGraph(walletIndex).getOrThrow() } - // Propagate VSS failures: a manual reset that leaves the graph in VSS is ineffective. - clearNetworkGraph(walletIndex).getOrThrow() } } @@ -803,8 +813,7 @@ class LightningRepo @Inject constructor( } return runSuspendCatching { - vssBackupClientLdk.setup(walletIndex).getOrThrow() - vssBackupClientLdk.deleteObject("network_graph").getOrThrow() + vssBackupClientLdk.deleteObjects(listOf("network_graph"), walletIndex).getOrThrow() Logger.info("Cleared network graph from VSS", context = TAG) }.onFailure { Logger.warn("Failed to clear network graph from VSS", it, context = TAG) @@ -2917,100 +2926,110 @@ class LightningRepo @Inject constructor( * so callers can require any scores sync timestamp to be strictly newer to prove a post-reset download. */ @OptIn(ExperimentalTime::class) - suspend fun resetPathfindingScores(walletIndex: Int = 0): Result = withContext(bgDispatcher) { - Logger.info("Resetting pathfinding scores", context = TAG) + suspend fun resetPathfindingScores(walletIndex: Int = 0): Result = vssClients.withWallet { + withContext(bgDispatcher) { + Logger.info("Resetting pathfinding scores", context = TAG) - waitForNodeToStop().onFailure { return@withContext Result.failure(it) } - stop().onFailure { - Logger.error("Failed to stop node during pathfinding scores reset", it, context = TAG) - return@withContext Result.failure(it) - } + waitForNodeToStop().onFailure { return@withContext Result.failure(it) } + stop().onFailure { + Logger.error("Failed to stop node during pathfinding scores reset", it, context = TAG) + return@withContext Result.failure(it) + } - runCatching { - val lifecycleState = _lightningState.value.nodeLifecycleState - check(lifecycleState == NodeLifecycleState.Stopped) { - "Node lifecycle changed to '$lifecycleState' during pathfinding scores reset" - } - // Gate the destructive VSS deletes on the previous node's release, so the old node cannot - // re-persist scores over the delete while it is still draining. - lightningService.awaitNodeRelease() - vssBackupClientLdk.setup(walletIndex).getOrThrow() - vssBackupClientLdk.deleteObject(VSS_KEY_SCORER).getOrThrow() - vssBackupClientLdk.deleteObject(VSS_KEY_EXTERNAL_SCORES_CACHE).getOrThrow() - }.onFailure { - Logger.error("Failed to delete pathfinding scores from VSS", it, context = TAG) - start(walletIndex = walletIndex, shouldRetry = false, shouldValidateGraph = false).onFailure { startError -> - Logger.error("Failed to restart node after pathfinding scores reset failure", startError, context = TAG) + runSuspendCatching { + val lifecycleState = _lightningState.value.nodeLifecycleState + check(lifecycleState == NodeLifecycleState.Stopped) { + "Node lifecycle changed to '$lifecycleState' during pathfinding scores reset" + } + // Gate the destructive VSS deletes on the previous node's release, so the old node cannot + // re-persist scores over the delete while it is still draining. + lightningService.awaitNodeRelease() + vssBackupClientLdk.deleteObjects( + listOf(VSS_KEY_SCORER, VSS_KEY_EXTERNAL_SCORES_CACHE), + walletIndex, + ).getOrThrow() + }.onFailure { + Logger.error("Failed to delete pathfinding scores from VSS", it, context = TAG) + start(walletIndex = walletIndex, shouldRetry = false, shouldValidateGraph = false) + .onFailure { startError -> + Logger.error( + "Failed to restart node after pathfinding scores reset failure", + startError, + context = TAG, + ) + } + return@withContext Result.failure(it) } - return@withContext Result.failure(it) - } - val resetAtSecs = nowMillis() / 1000 + val resetAtSecs = nowMillis() / 1000 - start(walletIndex = walletIndex, shouldRetry = false, shouldValidateGraph = false) - .map { resetAtSecs } - .onSuccess { - Logger.info("Pathfinding scores reset at '$resetAtSecs'", context = TAG) - } + start(walletIndex = walletIndex, shouldRetry = false, shouldValidateGraph = false) + .map { resetAtSecs } + .onSuccess { + Logger.info("Pathfinding scores reset at '$resetAtSecs'", context = TAG) + } + } } - suspend fun resetPaymentRoutingCachesAndWait(walletIndex: Int = 0): Result = withContext(bgDispatcher) { - val refreshStartedAtMs = nowMillis() - val refreshStartedAtSecs = (refreshStartedAtMs / 1000).toULong() - val requiresRgsRefresh = Env.network != Network.REGTEST && - !settingsStore.data.first().rgsServerUrl.isNullOrEmpty() - val requiresScorerRefresh = Env.ldkScorerUrl != null - val resetErrors = mutableListOf() + suspend fun resetPaymentRoutingCachesAndWait(walletIndex: Int = 0): Result = vssClients.withWallet { + withContext(bgDispatcher) { + val refreshStartedAtMs = nowMillis() + val refreshStartedAtSecs = (refreshStartedAtMs / 1000).toULong() + val requiresRgsRefresh = Env.network != Network.REGTEST && + !settingsStore.data.first().rgsServerUrl.isNullOrEmpty() + val requiresScorerRefresh = Env.ldkScorerUrl != null + val resetErrors = mutableListOf() - Logger.info( - "Started payment routing refresh rgs='$requiresRgsRefresh' scorer='$requiresScorerRefresh'", - context = TAG, - ) + Logger.info( + "Started payment routing refresh rgs='$requiresRgsRefresh' scorer='$requiresScorerRefresh'", + context = TAG, + ) - val resetResult = withContext(NonCancellable) reset@{ - stop().onFailure { - start( - walletIndex = walletIndex, - shouldRetry = false, - shouldValidateGraph = false, - ).onFailure { startError -> - Logger.error( - "Failed to restart node after payment routing refresh stop failure", - startError, - context = TAG, - ) + val resetResult = withContext(NonCancellable) reset@{ + stop().onFailure { + start( + walletIndex = walletIndex, + shouldRetry = false, + shouldValidateGraph = false, + ).onFailure { startError -> + Logger.error( + "Failed to restart node after payment routing refresh stop failure", + startError, + context = TAG, + ) + } + return@reset Result.failure(it) } - return@reset Result.failure(it) - } - clearNetworkGraph(walletIndex).onFailure { - resetErrors.add(it) - } + clearNetworkGraph(walletIndex).onFailure { + resetErrors.add(it) + } - resetPathfindingScores(walletIndex).onFailure { - resetErrors.add(it) - } + resetPathfindingScores(walletIndex).onFailure { + resetErrors.add(it) + } - resetErrors.firstOrNull()?.let { - Result.failure(it) - } ?: Result.success(Unit) - } - resetResult.onFailure { - return@withContext Result.failure(it) - } + resetErrors.firstOrNull()?.let { + Result.failure(it) + } ?: Result.success(Unit) + } + resetResult.onFailure { + return@withContext Result.failure(it) + } - val result = waitForPaymentRoutingDataRefresh( - walletIndex = walletIndex, - refreshStartedAtMs = refreshStartedAtMs, - refreshStartedAtSecs = refreshStartedAtSecs, - requiresRgsRefresh = requiresRgsRefresh, - requiresScorerRefresh = requiresScorerRefresh, - ) - result.onSuccess { - Logger.info( - "Finished payment routing refresh elapsedMs='${nowMillis() - refreshStartedAtMs}'", - context = TAG, + val result = waitForPaymentRoutingDataRefresh( + walletIndex = walletIndex, + refreshStartedAtMs = refreshStartedAtMs, + refreshStartedAtSecs = refreshStartedAtSecs, + requiresRgsRefresh = requiresRgsRefresh, + requiresScorerRefresh = requiresScorerRefresh, ) + result.onSuccess { + Logger.info( + "Finished payment routing refresh elapsedMs='${nowMillis() - refreshStartedAtMs}'", + context = TAG, + ) + } } } @@ -3071,30 +3090,35 @@ class LightningRepo @Inject constructor( * The caller still awaits the [Result], so a live caller keeps reporting the outcome; a cancelled * one only loses the reporting, never the restart. */ - suspend fun restartNodeDetached(): Result = scope.async { restartNode() }.await() + suspend fun restartNodeDetached(): Result { + val owner = vssClients.captureOwner() + return scope.async { vssClients.withWallet(owner) { restartNode() } }.await() + } 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( - walletIndex = walletIndex, - channelMigration = channelMigration, - shouldRetryYieldToStop = true - ).onFailure { - if (it is NodeStartYieldedToStopError) { - Logger.info("Deferred node restart to a requested stop", context = TAG) - } else { - Logger.error("Failed to start node during restart", it, context = TAG) + ): Result = vssClients.withWallet { + 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( + walletIndex = walletIndex, + channelMigration = channelMigration, + shouldRetryYieldToStop = true + ).onFailure { + if (it is NodeStartYieldedToStopError) { + Logger.info("Deferred node restart to a requested stop", context = TAG) + } else { + Logger.error("Failed to start node during restart", it, context = TAG) + } + return@withContext Result.failure(it) + }.onSuccess { + Logger.info("Node restarted successfully", context = TAG) } - return@withContext Result.failure(it) - }.onSuccess { - Logger.info("Node restarted successfully", context = TAG) } } diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index fc4c021fb0..e9f94c2cb8 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -407,6 +407,8 @@ class PubkyRepo @Inject constructor( } private val initializationReady = CompletableDeferred() + private val restoreInitializationLock = Any() + private var restoreInitialization: Pair? = null init { scope.launch { initialize() }.invokeOnCompletion { @@ -424,16 +426,60 @@ class PubkyRepo @Inject constructor( runSuspendCatching { pubkyService.republishIdentityIfNeeded(publicKey.value) } } - suspend fun initialize() = withContext(ioDispatcher) { - val restored = initializeMutex.withLock { initializeSession() } + suspend fun initialize() = initializeForRevision() + + fun scheduleInitializationAfterRestore() { + val job = synchronized(restoreInitializationLock) { + val revision = storedIdentityRevision.get() + restoreInitialization?.takeIf { it.first == revision && !it.second.isCompleted }?.second + ?: scope.launch(start = CoroutineStart.LAZY) { + initializeForRevision(revision) + }.also { pending -> + restoreInitialization = revision to pending + pending.invokeOnCompletion { + synchronized(restoreInitializationLock) { + if (restoreInitialization?.second === pending) restoreInitialization = null + } + } + } + } + job.start() + } + + private suspend fun initializeForRevision( + expectedRevision: Long? = null, + ): Result = withContext(ioDispatcher) { + val (restored, revision) = initializeMutex.withLock { + if (expectedRevision != null && expectedRevision != storedIdentityRevision.get()) { + return@withContext Result.success(Unit) + } + val restored = if (expectedRevision != null && _publicKey.value != null) { + true + } else { + initializeSession() + } + restored to storedIdentityRevision.get() + } + if (revision != storedIdentityRevision.get()) return@withContext Result.success(Unit) if (restored) loadIdentityData() + if (revision != storedIdentityRevision.get()) return@withContext Result.success(Unit) checkAdoptedSource() } suspend fun restoreSessionIfNeeded() = withContext(ioDispatcher) { awaitInitialization() val restored = restoreSession() - if (restored) loadIdentityData() + if (restored) { + loadIdentityData() + } else { + currentSignIn()?.takeIf { _profile.value == null }?.let { signIn -> + scope.launch { + if (!settingsStore.isPubkyProfileSetupPending.first()) { + loadProfile(signIn, onlyIfMissing = true) + } + } + } + } restored } @@ -861,9 +907,14 @@ class PubkyRepo @Inject constructor( suspend fun loadProfile() { val signIn = currentSignIn() ?: return loadReadOnlyProfile() + loadProfile(signIn) + } + + private suspend fun loadProfile(signIn: PubkySignIn, onlyIfMissing: Boolean = false) { val version = completedProfileLoadVersion.get() loadProfileMutex.withLock { if (!isCurrent(signIn)) return + if (onlyIfMissing && _profile.value != null) return val writeGeneration = profileWriteGeneration.get() if (completedProfileLoadVersion.get() != version && completedProfileSignInGeneration == signIn.generation && completedProfileWriteGeneration == writeGeneration @@ -876,28 +927,36 @@ class PubkyRepo @Inject constructor( try { runSuspendCatching { withContext(ioDispatcher) { - loadOwnProfile(signIn.publicKey) ?: throw PubkyProfileNotFoundError() + loadOwnProfile(signIn) ?: throw PubkyProfileNotFoundError() } }.onSuccess { (loadedProfile, revision) -> var isCurrent = false - _profile.update { - isCurrent = isCurrentLoad() - if (isCurrent) loadedProfile else it + initializeMutex.withLock { + _profile.update { + isCurrent = isCurrentLoad() + if (isCurrent) loadedProfile else it + } + if (isCurrent) { + ownProfileRevision = revision + invalidateReadOnlyProfile() + } } if (!isCurrent) { Logger.debug("Skipped stale profile load for '${redacted(signIn.publicKey)}'", context = TAG) return@onSuccess } - ownProfileRevision = revision - invalidateReadOnlyProfile() cacheMetadata(loadedProfile, isCurrentLoad) }.onFailure { Logger.error("Failed to load profile", it, context = TAG) // A profile deleted on pubky.app is set up again rather than left unreachable. - if (it is PubkyProfileNotFoundError && isCurrentLoad()) { - _profile.update { null } - ownProfileRevision = null - settingsStore.setPubkyProfileSetupPending(true) + if (it is PubkyProfileNotFoundError) { + initializeMutex.withLock { + if (isCurrentLoad()) { + _profile.update { null } + ownProfileRevision = null + settingsStore.setPubkyProfileSetupPending(true) + } + } } } completedProfileSignInGeneration = signIn.generation @@ -1405,20 +1464,25 @@ class PubkyRepo @Inject constructor( .take(PUBKY_APP_LINKS_MAX_COUNT), ) - private suspend fun loadOwnProfile(publicKey: String): Pair? { + private suspend fun loadOwnProfile(signIn: PubkySignIn): Pair? { + val publicKey = signIn.publicKey val generation = profileDeletionGeneration.get() val saveEpoch = profileSaveEpoch.get() val paykitRead = runSuspendCatching { pubkyService.fetchPaykitProfile(publicKey) } .onFailure { Logger.warn("Failed to read Bitkit profile copy", it, context = TAG) } - tagsReadEpoch.incrementAndGet() - tagsUnavailable = paykitRead.isFailure + initializeMutex.withLock { + requireCurrent(signIn) + tagsReadEpoch.incrementAndGet() + tagsUnavailable = paykitRead.isFailure + } val paykitData = paykitRead.getOrNull()?.let { PubkyProfileData.fromPaykitProfile(it.profile) } val fetched = readRetryingOnce(publicKey) { pubkyService.fetchPubkyProfileWithRevision(publicKey) } if (fetched == null) paykitRead.getOrThrow() val stored = pubkyStore.data.first() + requireCurrent(signIn) val hadPubkyAppProfile = publicKey in stored.pubkyAppProfileOwners val migrating = fetched == null && !hadPubkyAppProfile - var snapshot = fetched ?: paykitData?.takeIf { migrating }?.let { migrateToPubkyApp(publicKey, it, generation) } + var snapshot = fetched ?: paykitData?.takeIf { migrating }?.let { migrateToPubkyApp(signIn, it, generation) } // While the move cannot finish, the Bitkit profile is shown and the next save creates the pubky.app profile. snapshot ?: return paykitData?.takeIf { migrating }?.let { it.toPubkyProfile(publicKey) to null } val pendingAvatar = paykitData?.image?.takeIf { it.isPaykitStorage() } @@ -1427,32 +1491,36 @@ class PubkyRepo @Inject constructor( if (retryingAvatar && avatarMoveSettled) { // An avatar set on pubky.app meanwhile wins, and only an avatar on Paykit storage is moved. An unread copy // settles nothing, so the move is retried on a later load. - pubkyStore.update { it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending - publicKey) } + pubkyStore.update { + if (isCurrent(signIn)) it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending - publicKey) else it + } } if (pendingAvatar == null || avatarMoveSettled) retryingAvatar = false if (retryingAvatar) { - snapshot = moveAvatar(publicKey, checkNotNull(pendingAvatar), snapshot, generation) ?: snapshot + snapshot = moveAvatar(signIn, checkNotNull(pendingAvatar), snapshot, generation) ?: snapshot } if (!hadPubkyAppProfile) { - pubkyStore.update { it.copy(pubkyAppProfileOwners = it.pubkyAppProfileOwners + publicKey) } + pubkyStore.update { + if (isCurrent(signIn)) it.copy(pubkyAppProfileOwners = it.pubkyAppProfileOwners + publicKey) else it + } } - if (snapshot !== fetched) ownProfileRevision = snapshot.revision val avatarPending = pendingAvatar != null && snapshot.profile.image == null && (migrating || retryingAvatar) val profile = PubkyProfile.fromPubkyProfile(publicKey, snapshot.profile).let { it.copy(tags = paykitData?.tags.orEmpty(), imageUrl = if (avatarPending) pendingAvatar else it.imageUrl) } if (!avatarPending && paykitData != null && paykitData != profile.toProfileData()) { - publishPaykitProfileCopy(publicKey, profile, generation, saveEpoch) + publishPaykitProfileCopy(signIn, profile, generation, saveEpoch) } return profile to snapshot.revision } private suspend fun migrateToPubkyApp( - publicKey: String, + signIn: PubkySignIn, data: PubkyProfileData, generation: Long, ): PubkyProfileSnapshot? = runSuspendCatching { + val publicKey = signIn.publicKey val fitted = fitted(data.name, data.bio, data.links.map { PubkyProfileLink(it.label, it.url) }) // An avatar already on pubky.app is kept as is; one on Paykit storage is moved below. val keptImage = data.image?.takeIf { it.isPubkyAppFile() } @@ -1466,22 +1534,25 @@ class PubkyRepo @Inject constructor( // Recorded before the profile is created without it, so an app stopped before the move still retries it. // A failed create keeps it too: the create may have landed, and the next load or save handles either case. if (image != null) { - pubkyStore.update { it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending + publicKey) } + pubkyStore.update { + if (isCurrent(signIn)) it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending + publicKey) else it + } } - val snapshot = profileWrite(generation) { + val snapshot = profileWrite(signIn, generation) { withPubkyAppScope { pubkyService.publishPubkyProfile(update, null, publicKey) } } Logger.info("Moved Bitkit profile to pubky.app", context = TAG) image ?: return@runSuspendCatching snapshot - moveAvatar(publicKey, image, snapshot, generation) ?: snapshot + moveAvatar(signIn, image, snapshot, generation) ?: snapshot }.onFailure { Logger.warn("Failed to move Bitkit profile to pubky.app", it, context = TAG) }.getOrNull() private suspend fun moveAvatar( - publicKey: String, + signIn: PubkySignIn, uri: String, snapshot: PubkyProfileSnapshot, generation: Long, ): PubkyProfileSnapshot? = runSuspendCatching { + val publicKey = signIn.publicKey val bytes = pubkyService.fetchFile(uri, PUBKY_APP_AVATAR_MAX_BYTES) val contentType = when (uri.substringAfterLast('.').lowercase()) { "png" -> "image/png" @@ -1489,7 +1560,7 @@ class PubkyRepo @Inject constructor( "gif" -> "image/gif" else -> "image/jpeg" } - profileWrite(generation) { + profileWrite(signIn, generation) { val image = withPubkyAppScope { pubkyService.uploadPubkyAppAvatar(bytes, contentType, publicKey) } withPubkyAppScope { pubkyService.publishPubkyProfile( @@ -1500,10 +1571,14 @@ class PubkyRepo @Inject constructor( } } }.onSuccess { - pubkyStore.update { it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending - publicKey) } + pubkyStore.update { + if (isCurrent(signIn)) it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending - signIn.publicKey) else it + } }.onFailure { Logger.warn("Failed to move avatar to pubky.app", it, context = TAG) - pubkyStore.update { it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending + publicKey) } + pubkyStore.update { + if (isCurrent(signIn)) it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending + signIn.publicKey) else it + } }.getOrNull() private fun Throwable.isConcurrentUpdate() = @@ -1560,20 +1635,26 @@ class PubkyRepo @Inject constructor( "/pub/pubky.app/files/" in this && codePointCount(0, length) <= PUBKY_APP_LINK_URL_MAX_LENGTH private suspend fun publishPaykitProfileCopy( - publicKey: String, + signIn: PubkySignIn, profile: PubkyProfile, generation: Long, saveEpoch: Long, ) { runSuspendCatching { - profileWrite(generation) { + profileWrite(signIn, generation) { // Checked under the write lock, so a save that starts after it writes its copy after this one. if (profileSavesRunning.get() != 0 || profileSaveEpoch.get() != saveEpoch) return@profileWrite - pubkyService.publishPaykitProfile(profile.toProfileData().toPaykitProfile(), publicKey) + pubkyService.publishPaykitProfile(profile.toProfileData().toPaykitProfile(), signIn.publicKey) } }.onFailure { Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) } } + private suspend fun profileWrite(signIn: PubkySignIn, generation: Long, block: suspend () -> T): T = + initializeMutex.withLock { + requireCurrent(signIn) + profileWrite(generation, block) + } + /** Runs a write of a load or save that started at [generation], refused once a profile deletion began since. */ private suspend fun profileWrite(generation: Long, block: suspend () -> T): T = profileWriteMutex.withLock { check(profileDeletionGeneration.get() == generation) { "The profile was deleted since this write began" } @@ -2033,6 +2114,14 @@ class PubkyRepo @Inject constructor( !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrEmpty() } + internal suspend fun hasIdentityForBackup(): Boolean = withContext(ioDispatcher) { + _publicKey.value != null || listOf( + Keychain.Key.PAYKIT_SESSION, + Keychain.Key.PUBKY_SECRET_KEY, + Keychain.Key.SHARED_PUBKY_SOURCE, + ).any { !keychain.loadString(it.name).isNullOrEmpty() } + } + suspend fun parseAuthUrl(authUrl: String): Result = runSuspendCatching { withContext(ioDispatcher) { if (PubkyAuthRequest.isSignupUrl(authUrl) && !PubkyAuthRequest.isGrantSignupUrl(authUrl)) { diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 71d527ef98..ce8475f5a9 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -12,7 +12,10 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException +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.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -30,9 +33,11 @@ import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.WordCount import to.bitkit.async.appScope +import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.WalletConnectionRecovery +import to.bitkit.data.backup.VssClientCoordinator import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env @@ -47,6 +52,7 @@ import to.bitkit.models.BalanceState import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING import to.bitkit.models.ReceiveLiquidityDecision import to.bitkit.models.WalletScope +import to.bitkit.models.safe import to.bitkit.models.toAccountDerivationPath import to.bitkit.models.toBalance import to.bitkit.models.toDerivationPath @@ -82,6 +88,7 @@ class WalletRepo @Inject constructor( private val connectionRecovery: WalletConnectionRecovery, private val sparkRepo: SparkRepo, private val backupRepo: BackupRepo, + private val vssClients: VssClientCoordinator, ) { private val repoScope = appScope(bgDispatcher, TAG) @@ -90,6 +97,9 @@ class WalletRepo @Inject constructor( private val _balanceState = MutableStateFlow(BalanceState()) val balanceState = _balanceState.asStateFlow() + private val balanceSyncMutex = Mutex() + private val balanceMutex = Mutex() + private var hasLiveBalance = false private var eventSyncJob: Job? = null private val receiveStateMutex = Mutex() @@ -136,9 +146,9 @@ class WalletRepo @Inject constructor( } repoScope.launch { // The read balance too, as sends use it: it can come back to the shown one while that stays the same. - sparkRepo.state.map { Triple(it.balanceSats, it.shownBalanceSats, it.pendingDepositSats) } + sparkRepo.state.map { it.isReady to Triple(it.balanceSats, it.shownBalanceSats, it.pendingDepositSats) } .distinctUntilChanged().collect { - if (walletExists()) syncBalances() + if (walletExists()) coroutineScope { launch { syncBalances() } } } } } @@ -203,32 +213,34 @@ class WalletRepo @Inject constructor( } fun loadFromCache() { + val owner = vssClients.captureOwner() changePendingReceiveCacheLoads(1) repoScope.launch { - receiveStateMutex.withLock { - var cacheData = cacheStore.data.first() - cacheData.balance?.let { balance -> _balanceState.update { balance } } - val unavailable = runSuspendCatching { - privatePaykitAddressReservationRepo.isUnavailableForReusableReceive(cacheData.onchainAddress) - }.getOrElse { - receiveCacheValidationFailed = true - Logger.warn("Failed to validate cached receive address", it, context = TAG) - return@withLock - } - if (unavailable) { - cacheData = cacheData.invalidateReceiveOnchainAddress() - cacheStore.update { it.invalidateReceiveOnchainAddress() } - } - committedReceiveBip21 = cacheData.bip21 - _walletState.update { - it.copy( - onchainAddress = cacheData.onchainAddress, - bolt11 = cacheData.bolt11, - bip21 = cacheData.bip21, - ) + vssClients.withWallet(owner) { + receiveStateMutex.withLock { + var cacheData = loadBalanceCache() + val unavailable = runSuspendCatching { + privatePaykitAddressReservationRepo.isUnavailableForReusableReceive(cacheData.onchainAddress) + }.getOrElse { + receiveCacheValidationFailed = true + Logger.warn("Failed to validate cached receive address", it, context = TAG) + return@withLock + } + if (unavailable) { + cacheData = cacheData.invalidateReceiveOnchainAddress() + cacheStore.update { it.invalidateReceiveOnchainAddress() } + } + committedReceiveBip21 = cacheData.bip21 + _walletState.update { + it.copy( + onchainAddress = cacheData.onchainAddress, + bolt11 = cacheData.bolt11, + bip21 = cacheData.bip21, + ) + } + synchronized(receiveUpdateStatusLock) { updateReceivePendingState() } + receiveCacheValidationFailed = false } - synchronized(receiveUpdateStatusLock) { updateReceivePendingState() } - receiveCacheValidationFailed = false } }.invokeOnCompletion { changePendingReceiveCacheLoads(-1) } } @@ -438,23 +450,112 @@ class WalletRepo @Inject constructor( /** Re-derives the Spending limit from the published channels, without the balances call of [syncBalances]. */ fun refreshMaxSendLightning() { - val state = lightningRepo.lightningState.value - if (!state.nodeLifecycleState.isRunning()) return - val maxSendSats = maxOf(state.channels.totalNextOutboundHtlcLimitSats(), sparkRepo.state.value.sendableSats) - _balanceState.update { it.copy(maxSendLightningSats = maxSendSats) } + _balanceState.update { + val state = lightningRepo.lightningState.value + if (!state.nodeLifecycleState.isRunning()) return@update it + it.copy( + maxSendLightningSats = maxOf( + state.channels.totalNextOutboundHtlcLimitSats(), + sparkRepo.state.value.sendableSats, + ), + ) + } } suspend fun syncBalances() { - deriveBalanceStateUseCase().onSuccess { balanceState -> - runCatching { cacheStore.cacheBalance(balanceState) } - _balanceState.update { balanceState } - }.onFailure { - if (it !is CancellationException) { - Logger.warn("Could not sync balances", it, context = TAG) + vssClients.withWallet { + balanceSyncMutex.withLock { + val result = deriveBalanceStateUseCase() + val channels = lightningRepo.lightningState.value.channels + balanceMutex.withLock publish@{ + val balance = result.getOrElse { + if (it is CancellationException) throw it + if (it is NodeNotRunningError) { + updateSparkBalance(_balanceState.value)?.let { updated -> return@getOrElse updated } + } + Logger.warn("Could not sync balances", it, context = TAG) + return@publish + } + val hasAggregate = result.isSuccess || + (balance.sparkBalanceSats != null && balance.sparkPendingDepositSats != null) + if (hasAggregate) cacheBalance(balance) + publishBalance(balance, channels) + hasLiveBalance = hasLiveBalance || hasAggregate + } } } } + private fun updateSparkBalance(balance: BalanceState): BalanceState? { + val spark = sparkRepo.state.value + if (!spark.isReady) return null + val included = balance.sparkBalanceSats + val pending = balance.sparkPendingDepositSats + if (included == null || pending == null) { + return balance.copy(maxSendLightningSats = spark.sendableSats) + } + return balance.copy( + totalLightningSats = (balance.totalLightningSats.safe() - included.safe()).safe() + + spark.shownBalanceSats.safe(), + balanceInTransferToSpending = (balance.balanceInTransferToSpending.safe() - pending.safe()).safe() + + spark.pendingDepositSats.safe(), + maxSendLightningSats = spark.sendableSats, + sparkBalanceSats = spark.shownBalanceSats, + sparkPendingDepositSats = spark.pendingDepositSats, + ) + } + + private suspend fun loadBalanceCache(): AppCacheData = balanceMutex.withLock { + val hardware = hwWalletRepo.wallets.value + val channels = lightningRepo.lightningState.value.channels + cacheStore.data.first().also { data -> + val balance = if (hasLiveBalance) _balanceState.value else data.balance + balance?.let { + val updated = if (lightningRepo.lightningState.value.nodeLifecycleState.isRunning()) { + it + } else { + updateSparkBalance(it) ?: it + } + if (updated != it) cacheBalance(updated) + publishBalance( + updated, + if (hasLiveBalance) channels else null, + refreshHardware = hwWalletRepo.wallets.value !== hardware, + ) + } + } + } + + private suspend fun cacheBalance(balance: BalanceState) { + runSuspendCatching { cacheStore.cacheBalance(balance) } + } + + private suspend fun publishBalance( + balance: BalanceState, + previousChannels: List?, + refreshHardware: Boolean = true, + ) { + currentCoroutineContext().ensureActive() + _balanceState.update { + val node = lightningRepo.lightningState.value + val spark = sparkRepo.state.value + val maxSend = when { + !node.nodeLifecycleState.isRunning() && spark.isReady -> spark.sendableSats + node.nodeLifecycleState.isRunning() && node.channels != previousChannels -> + maxOf(node.channels.totalNextOutboundHtlcLimitSats(), spark.sendableSats) + else -> balance.maxSendLightningSats + } + balance.copy( + maxSendLightningSats = maxSend, + hardwareWallets = if (refreshHardware) { + hwWalletRepo.wallets.value.map { it.toBalance() } + } else { + balance.hardwareWallets + }, + ) + } + } + /** Coalesces event syncs, then runs [onActivitiesSynced] only after a successful fresh activity sync. */ fun debounceSyncByEvent(onActivitiesSynced: suspend () -> Unit = {}) { eventSyncJob?.cancel() @@ -712,6 +813,7 @@ class WalletRepo @Inject constructor( fun resetState() { _walletState.update { WalletState() } _balanceState.update { BalanceState() } + hasLiveBalance = false } fun getOnchainAddress(): String = _walletState.value.onchainAddress diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt b/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt index ea3483bc12..d887f298df 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt @@ -32,7 +32,7 @@ internal class PaykitSdkOperationLock { suspend fun withLock(priority: Priority = Priority.Ordered, operation: suspend () -> T): T { if (ownsWipe()) return operation() val admittedGeneration = admit() - acquire(priority) + acquire(priority, admittedGeneration) return try { currentCoroutineContext().ensureActive() checkAdmitted(admittedGeneration) @@ -75,8 +75,9 @@ internal class PaykitSdkOperationLock { } } - private suspend fun acquire(priority: Priority) { + private suspend fun acquire(priority: Priority, admittedGeneration: Long? = null) { val waiter = synchronized(stateLock) { + admittedGeneration?.let(::checkAdmitted) if (!isLocked) { isLocked = true return diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index fd2c3a9df8..67b76cbad8 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -134,7 +134,14 @@ internal fun waitsForNode( hasAnyChannels: Boolean, hasUsableChannels: Boolean, sparkCanPay: Boolean, + destination: NavDestination? = null, ): Boolean { + if (destination?.hasRoute() == true || + destination?.hasRoute() == true || + destination?.hasRoute() == true + ) { + return false + } if (sparkCanPay) return false if (!isNodeRunning) return true return hasAnyChannels && !hasUsableChannels @@ -174,6 +181,8 @@ fun SendSheet( preparingRequest: PaykitPaymentRequest? = null, ) { val context = LocalContext.current + val navController = rememberNavController() + val entry by navController.currentBackStackEntryAsState() val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() val isOffline by remember { derivedStateOf { connectivityState != ConnectivityState.CONNECTED } } val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() @@ -193,6 +202,7 @@ fun SendSheet( hasAnyChannels = hasAnyChannels, hasUsableChannels = lightningState.channels.any { it.isUsable }, sparkCanPay = sparkCanPay, + destination = entry?.destination, ) } @@ -230,8 +240,6 @@ fun SendSheet( .imePadding() .testTag("SendSheet"), ) { - val navController = rememberNavController() - val entry by navController.currentBackStackEntryAsState() val reviewing = entry?.destination?.isPaymentReview() == true LaunchedEffect(reviewing, lifecycle, appViewModel) { if (!reviewing) return@LaunchedEffect diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index eab2df0941..a6913210e7 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -13,6 +13,7 @@ import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults import to.bitkit.ext.amountSats import to.bitkit.ext.channelId +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.BalanceState import to.bitkit.models.TransferType @@ -37,7 +38,7 @@ class DeriveBalanceStateUseCase @Inject constructor( private val sparkRepo: SparkRepo, ) { suspend operator fun invoke(): Result = withContext(bgDispatcher) { - runCatching { + runSuspendCatching { val balanceDetails = lightningRepo.getBalancesAsync().getOrThrow() val channels = lightningRepo.getChannels().orEmpty() val activeTransfers = transferRepo.activeTransfers.first() @@ -52,9 +53,10 @@ class DeriveBalanceStateUseCase @Inject constructor( val channelFundableBalance = getMaxChannelFundableAmount(lightningRepo.getChannelFundableBalance()) val afterPendingChannels = balanceDetails.totalLightningBalanceSats.safe() - pendingChannelsSats.safe() val afterClosingChannels = afterPendingChannels.safe() - toSavingsAmount.safe() - val sparkSats = sparkRepo.state.value.shownBalanceSats val afterLingeringCoopClose = afterClosingChannels.safe() - lingeringCoopCloseSats.safe() - val totalLightningSats = afterLingeringCoopClose.safe() + sparkSats.safe() + val maxSendOnchainSats = getMaxSendAmount(balanceDetails) + val spark = sparkRepo.state.value + val totalLightningSats = afterLingeringCoopClose.safe() + spark.shownBalanceSats.safe() val balanceState = BalanceState( totalOnchainSats = totalOnchainSats, @@ -63,13 +65,15 @@ class DeriveBalanceStateUseCase @Inject constructor( // A payment goes over one rail, so the larger of the two balances caps it; Spark keeps its fee. maxSendLightningSats = maxOf( lightningRepo.getChannels().totalNextOutboundHtlcLimitSats(), - sparkRepo.state.value.sendableSats, + spark.sendableSats, ), - maxSendOnchainSats = getMaxSendAmount(balanceDetails), + maxSendOnchainSats = maxSendOnchainSats, balanceInTransferToSavings = toSavingsAmount.safe() - coopCloseSavingsSats.safe(), balanceInTransferToSpending = pendingChannelsSats.safe() + - sparkRepo.state.value.pendingDepositSats.safe(), + spark.pendingDepositSats.safe(), hardwareWallets = hwWalletRepo.wallets.value.map { it.toBalance() }, + sparkBalanceSats = spark.shownBalanceSats, + sparkPendingDepositSats = spark.pendingDepositSats, ) val height = lightningRepo.lightningState.value.block()?.height @@ -77,7 +81,7 @@ class DeriveBalanceStateUseCase @Inject constructor( Logger.verbose("Balances in ldk-node at block height=$height: ${jsonLogOf(balanceDetails)}", context = TAG) Logger.verbose("Balances in state at block height=$height: ${jsonLogOf(balanceState)}", context = TAG) - return@runCatching balanceState + return@runSuspendCatching balanceState } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 69a30da646..54f0f9ffe8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -584,6 +584,7 @@ class AppViewModel @Inject constructor( private var isCompletingRestoreHold = false private var addressValidationJob: Job? = null private var lastPrivatePaykitContactKeys: Set = emptySet() + private val allowancePauseRevision = MutableStateFlow(0L) private val isPaykitEnabled = settingsStore.isPaykitEnabled .map { PaykitFeatureFlags.isUiEnabled(it) } .stateIn(viewModelScope, SharingStarted.Eagerly, false) @@ -957,15 +958,20 @@ class AppViewModel @Inject constructor( combine( settingsStore.isPaykitEnabled.map { PaykitFeatureFlags.isUiEnabled(it) }, settingsStore.areAllowancesEnabled, - ) { isPaykitEnabled, areAllowancesEnabled -> isPaykitEnabled to areAllowancesEnabled }, + allowancePauseRevision, + ) { isPaykitEnabled, areAllowancesEnabled, pauseRevision -> + Triple(isPaykitEnabled, areAllowancesEnabled, pauseRevision) + }, privatePaykitRepo.isProfileDeletionInProgress, - ) { publicKey, contacts, contactsLoadVersion, (isPaykitEnabled, areAllowancesEnabled), deleting -> + ) { publicKey, contacts, contactsLoadVersion, allowanceSettings, deleting -> + val (isPaykitEnabled, areAllowancesEnabled, pauseRevision) = allowanceSettings PaykitContactSyncState( publicKey = publicKey, contactKeys = contacts.map { it.publicKey }.toSet(), contactsLoaded = contactsLoadVersion > 0L, isPaykitEnabled = isPaykitEnabled, areAllowancesEnabled = areAllowancesEnabled, + allowancePauseRevision = pauseRevision, ).takeUnless { deleting } } .distinctUntilChanged() @@ -977,7 +983,11 @@ class AppViewModel @Inject constructor( settingsStore.areAllowancesEnabled .distinctUntilChanged() .filter { !it } - .collect { paykitAllowanceRepo.pause() } + .collect { + paykitAllowanceRepo.pause() + // Off/on can coalesce while contact sync waits; revoked activation still needs a fresh sync. + allowancePauseRevision.update { it + 1 } + } } } @@ -1044,6 +1054,7 @@ class AppViewModel @Inject constructor( private fun isCurrentPaykitContactSync(state: PaykitContactSyncState, signIn: PubkySignIn): Boolean = pubkyRepo.isCurrent(signIn) && isPaykitEnabled.value && !privatePaykitRepo.isProfileDeletionInProgress.value && + state.allowancePauseRevision == allowancePauseRevision.value && (pubkyRepo.contactsLoadVersion.value > 0L) == state.contactsLoaded && pubkyRepo.contacts.value.map { it.publicKey }.toSet() == state.contactKeys @@ -9255,6 +9266,7 @@ private data class PaykitContactSyncState( val contactsLoaded: Boolean, val isPaykitEnabled: Boolean, val areAllowancesEnabled: Boolean, + val allowancePauseRevision: Long, ) sealed class SendEffect { diff --git a/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt index 672811b189..21b4362656 100644 --- a/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.data.CacheStore +import to.bitkit.data.backup.VssClientCoordinator import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus import to.bitkit.models.NodeLifecycleState @@ -26,6 +27,7 @@ class BackupsViewModel @Inject constructor( private val cacheStore: CacheStore, private val backupRepo: BackupRepo, private val lightningRepo: LightningRepo, + private val vssClients: VssClientCoordinator, private val sparkRepo: SparkRepo, ) : ViewModel() { @@ -67,9 +69,12 @@ class BackupsViewModel @Inject constructor( } fun retryBackup(category: BackupCategory) { + val owner = runCatching { vssClients.captureOwner() }.getOrNull() ?: return viewModelScope.launch { - delay(500) // small delay for UX feedback - backupRepo.triggerBackup(category) + vssClients.withWallet(owner) { + delay(500) // small delay for UX feedback + backupRepo.triggerBackup(category) + } } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 7de4230089..5a08f7f254 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -490,7 +490,7 @@ class WalletViewModel @Inject constructor( connectionSession = connectionSession, ) .getOrThrow() - .also { pubkyRepo.initialize() } + .also { pubkyRepo.scheduleInitializationAfterRestore() } } else { restoreFromMostRecentBackup(connectionSession) } @@ -533,7 +533,7 @@ class WalletViewModel @Inject constructor( .getOrThrow() } - pubkyRepo.initialize() + pubkyRepo.scheduleInitializationAfterRestore() return failedCategories } @@ -855,7 +855,7 @@ class WalletViewModel @Inject constructor( boltzService.events.collect { event -> if (event is BoltzSwapEvent.Claimed) { Logger.info("Savings swap claimed: ${event.swapId}", context = TAG) - walletRepo.syncBalances() + coroutineScope { launch { walletRepo.syncBalances() } } } } } diff --git a/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt b/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt index 8354250793..9029439678 100644 --- a/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt +++ b/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt @@ -74,18 +74,25 @@ class PubkyAuthManifestTest { ) val allUrls = signupUrls + authUrls + unrelatedUrls - assertRoutes(packageManager, application.packageName, allUrls, null) + assertRoutes(packageManager, application.packageName, allUrls, null, "initial defaults") packageManager.setComponentEnabledSetting( signupAlias, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP, ) - assertRoutes(packageManager, application.packageName, signupUrls + authUrls.last(), signupAlias) + assertRoutes( + packageManager, + application.packageName, + signupUrls + authUrls.last(), + signupAlias, + "signup enabled", + ) assertRoutes( packageManager, application.packageName, authUrls.dropLast(1) + unrelatedUrls, null, + "signup enabled", ) packageManager.setComponentEnabledSetting( @@ -98,15 +105,15 @@ class PubkyAuthManifestTest { PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP, ) - assertRoutes(packageManager, application.packageName, authUrls, authAlias) - assertRoutes(packageManager, application.packageName, signupUrls + unrelatedUrls, null) + assertRoutes(packageManager, application.packageName, authUrls, authAlias, "authorization enabled") + assertRoutes(packageManager, application.packageName, signupUrls + unrelatedUrls, null, "authorization enabled") packageManager.setComponentEnabledSetting( authAlias, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP, ) - assertRoutes(packageManager, application.packageName, allUrls, null) + assertRoutes(packageManager, application.packageName, allUrls, null, "both explicitly disabled") } private fun assertRoutes( @@ -114,19 +121,50 @@ class PubkyAuthManifestTest { packageName: String, urls: List, alias: ComponentName?, + phase: String, ) { urls.forEach { val intent = Intent(Intent.ACTION_VIEW, it.toUri()) .addCategory(Intent.CATEGORY_BROWSABLE) .setPackage(packageName) val resolved = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) - if (alias == null) { - assertTrue(resolved.isEmpty(), it) - } else { - val activity = resolved.single().activityInfo - assertEquals(alias.className, activity.name, it) - assertEquals("to.bitkit.ui.MainActivity", activity.targetActivity) - assertTrue(activity.exported) + fun diagnostics() = buildString { + append("phase=$phase url=$it package=$packageName expected=${alias?.className}") + listOf("to.bitkit.ui.MainActivityPubkySignup", "to.bitkit.ui.MainActivityPubkyAuth").forEach { name -> + val component = ComponentName(packageName, name) + append("; alias=$name override=${packageManager.getComponentEnabledSetting(component)}") + runCatching { + packageManager.getActivityInfo( + component, + PackageManager.ComponentInfoFlags.of(PackageManager.MATCH_DISABLED_COMPONENTS.toLong()), + ) + }.onSuccess { info -> + append(" enabled=${info.enabled} exported=${info.exported} target=${info.targetActivity}") + }.onFailure { error -> + append(" activityInfoError=${error.javaClass.simpleName}") + } + } + resolved.forEach { result -> + val info = result.activityInfo + append("; resolved=${info.packageName}/${info.name} target=${info.targetActivity}") + append(" enabled=${info.enabled} exported=${info.exported} match=${result.match}") + } + } + try { + if (alias == null) { + assertTrue(resolved.isEmpty(), it) + } else { + assertEquals(1, resolved.size, it) + val activity = resolved.single().activityInfo + assertEquals(alias.className, activity.name, it) + assertEquals("to.bitkit.ui.MainActivity", activity.targetActivity) + assertTrue(activity.exported) + } + } catch (error: AssertionError) { + val message = runCatching { diagnostics() }.getOrElse { diagnosticError -> + "phase=$phase url=$it diagnosticsUnavailable=${diagnosticError.javaClass.simpleName}" + } + throw AssertionError(message, error) } } } diff --git a/app/src/test/java/to/bitkit/data/WalletConnectionRecoveryTest.kt b/app/src/test/java/to/bitkit/data/WalletConnectionRecoveryTest.kt index 5324176595..ef5943b176 100644 --- a/app/src/test/java/to/bitkit/data/WalletConnectionRecoveryTest.kt +++ b/app/src/test/java/to/bitkit/data/WalletConnectionRecoveryTest.kt @@ -5,12 +5,15 @@ import android.content.Context import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.withContext import org.junit.After import org.junit.Before import org.junit.Test @@ -22,6 +25,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import to.bitkit.data.backup.VssClientCoordinator import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.entities.ConfigEntity import to.bitkit.data.keychain.Keychain @@ -45,6 +49,7 @@ class WalletConnectionRecoveryTest : BaseUnitTest() { private val keychain = mock() private val db = mock() private val binding = mock() + private val vssClients = VssClientCoordinator() private val config = MutableStateFlow(listOf(ConfigEntity())) private var record: String? = null private var pending = false @@ -82,7 +87,47 @@ class WalletConnectionRecoveryTest : BaseUnitTest() { @After fun tearDown() = runBlocking { settings.reset() } - private fun instance(store: SettingsStore = settings) = WalletConnectionRecovery(keychain, db, binding, store) + private fun instance(store: SettingsStore = settings) = WalletConnectionRecovery( + keychain, + db, + binding, + store, + vssClients, + ) + + @Test + fun `identity change waits for VSS cleanup and keeps admission closed through credentials`() = test { + val cleanup = CompletableDeferred() + var changed = false + var drained = false + val old = launch { + vssClients.withWallet { + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { + cleanup.await() + drained = true + } + } + } + } + runCurrent() + val transition = launch { + sut.withIdentityChange { + assertTrue(drained) + assertFailsWith { vssClients.withWallet {} } + changed = true + } + } + runCurrent() + assertFalse(changed) + cleanup.complete(Unit) + transition.join() + assertTrue(changed) + old.join() + vssClients.withWallet {} + } @Test fun `queued wallet generation is revoked during same seed replacement`() = test { diff --git a/app/src/test/java/to/bitkit/data/backup/VssBackupClientLdkTest.kt b/app/src/test/java/to/bitkit/data/backup/VssBackupClientLdkTest.kt index f80e27d0b0..6b8c919349 100644 --- a/app/src/test/java/to/bitkit/data/backup/VssBackupClientLdkTest.kt +++ b/app/src/test/java/to/bitkit/data/backup/VssBackupClientLdkTest.kt @@ -1,14 +1,16 @@ package to.bitkit.data.backup +import com.synonym.vssclient.vssDeriveStoreId import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test import org.mockito.Mockito.mockStatic -import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -17,27 +19,55 @@ class VssBackupClientLdkTest : BaseUnitTest() { private lateinit var sut: VssBackupClientLdk - private val vssStoreIdProvider = mock() private val keychain = mock() @Before fun setUp() = runBlocking { sut = VssBackupClientLdk( ioDispatcher = testDispatcher, - vssStoreIdProvider = vssStoreIdProvider, + coordinator = VssClientCoordinator(), keychain = keychain, ) } + @Test + fun `setup derives store id from the same captured credentials as the signer`() = test { + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(TEST_MNEMONIC) + whenever(keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn("test-passphrase") + var setupArguments: List? = null + var deriveArguments: List? = null + mockStatic(Class.forName(VSS_FFI_CLASS)) { invocation -> + when (invocation.method.name) { + "vssDeriveStoreId" -> { + deriveArguments = invocation.arguments.toList() + "captured-store" + } + "vssNewLdkClientWithLnurlAuth" -> { + setupArguments = invocation.arguments.take(5) + null + } + else -> null + } + }.use { + assertTrue(sut.setup().isSuccess) + assertEquals(listOf(Env.vssStoreIdPrefix, TEST_MNEMONIC, "test-passphrase"), deriveArguments) + assertEquals( + listOf(Env.vssServerUrl, "captured-store", TEST_MNEMONIC, "test-passphrase", Env.lnurlAuthServerUrl), + setupArguments, + ) + } + } + @Test fun `setup succeeding after a failure leaves the client usable`() = test { whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null) assertIs(sut.setup().exceptionOrNull()) whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(TEST_MNEMONIC) - whenever(vssStoreIdProvider.getVssStoreId(any())).thenReturn("test-store-id") mockStatic(Class.forName(VSS_FFI_CLASS)).use { + whenever(vssDeriveStoreId(Env.vssStoreIdPrefix, TEST_MNEMONIC, null)) + .thenReturn("captured-store") assertTrue(sut.setup().isSuccess) val result = sut.getObject("key") diff --git a/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt b/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt index 1ab0912034..abb2309777 100644 --- a/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt +++ b/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt @@ -1,6 +1,7 @@ package to.bitkit.data.backup import com.synonym.vssclient.VssItem +import com.synonym.vssclient.vssDeriveStoreId import com.synonym.vssclient.vssStore import kotlinx.coroutines.runBlocking import org.junit.Before @@ -8,10 +9,9 @@ import org.junit.Test import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertIs @@ -22,14 +22,13 @@ class VssBackupClientTest : BaseUnitTest() { private lateinit var sut: VssBackupClient - private val vssStoreIdProvider = mock() private val keychain = mock() @Before fun setUp() = runBlocking { sut = VssBackupClient( ioDispatcher = testDispatcher, - vssStoreIdProvider = vssStoreIdProvider, + coordinator = VssClientCoordinator(), keychain = keychain, ) } @@ -45,23 +44,31 @@ class VssBackupClientTest : BaseUnitTest() { } @Test - fun `setup does not call vssStoreIdProvider when mnemonic is not available`() = test { - whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null) - - sut.setup() - - verify(vssStoreIdProvider, never()).getVssStoreId(any()) - } - - @Test - fun `setup checks mnemonic before proceeding with vss initialization`() = test { + fun `setup derives store id from the same captured credentials as the signer`() = test { whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(TEST_MNEMONIC) - whenever(vssStoreIdProvider.getVssStoreId(any())).thenReturn("test-store-id") - - // Setup will fail on native VSS calls, but we verify we passed the mnemonic check - runCatching { sut.setup() } - - verify(vssStoreIdProvider).getVssStoreId(any()) + whenever(keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn("test-passphrase") + var setupArguments: List? = null + var deriveArguments: List? = null + mockStatic(Class.forName(VSS_FFI_CLASS)) { invocation -> + when (invocation.method.name) { + "vssDeriveStoreId" -> { + deriveArguments = invocation.arguments.toList() + "captured-store" + } + "vssNewClientWithLnurlAuth" -> { + setupArguments = invocation.arguments.take(5) + null + } + else -> null + } + }.use { + assertTrue(sut.setup().isSuccess) + assertEquals(listOf(Env.vssStoreIdPrefix, TEST_MNEMONIC, "test-passphrase"), deriveArguments) + assertEquals( + listOf(Env.vssServerUrl, "captured-store", TEST_MNEMONIC, "test-passphrase", Env.lnurlAuthServerUrl), + setupArguments, + ) + } } @Test @@ -80,9 +87,10 @@ class VssBackupClientTest : BaseUnitTest() { assertIs(sut.setup().exceptionOrNull()) whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(TEST_MNEMONIC) - whenever(vssStoreIdProvider.getVssStoreId(any())).thenReturn("test-store-id") mockStatic(Class.forName(VSS_FFI_CLASS)).use { + whenever(vssDeriveStoreId(Env.vssStoreIdPrefix, TEST_MNEMONIC, null)) + .thenReturn("captured-store") assertTrue(sut.setup().isSuccess) val result = sut.getObject("METADATA") @@ -97,9 +105,10 @@ class VssBackupClientTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)) .thenReturn(null) .thenReturn(TEST_MNEMONIC) - whenever(vssStoreIdProvider.getVssStoreId(any())).thenReturn("test-store-id") mockStatic(Class.forName(VSS_FFI_CLASS)).use { + whenever(vssDeriveStoreId(Env.vssStoreIdPrefix, TEST_MNEMONIC, null)) + .thenReturn("captured-store") assertTrue(sut.setupWithRetry(baseDelayMs = 0L) {}.isSuccess) val item = VssItem("METADATA", byteArrayOf(), 1L) diff --git a/app/src/test/java/to/bitkit/data/backup/VssClientCoordinatorTest.kt b/app/src/test/java/to/bitkit/data/backup/VssClientCoordinatorTest.kt new file mode 100644 index 0000000000..437d300ea7 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/backup/VssClientCoordinatorTest.kt @@ -0,0 +1,188 @@ +package to.bitkit.data.backup + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.withContext +import org.junit.Test +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.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class VssClientCoordinatorTest : BaseUnitTest() { + private val sut = VssClientCoordinator() + + @Test + fun `retirement waits for cancelled setup cleanup before shutdown and replacement`() = test { + val cleanup = CompletableDeferred() + var shutdown = false + val old = launch { + sut.setup(VssClientCoordinator.Domain.APP) { + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { cleanup.await() } + } + } + } + runCurrent() + val transition = launch { sut.withSuspendedClients({ shutdown = true }) {} } + runCurrent() + assertFalse(shutdown) + assertFalse(transition.isCompleted) + assertFailsWith { sut.withWallet {} } + cleanup.complete(Unit) + transition.join() + assertTrue(old.isCancelled) + assertTrue(shutdown) + sut.setup(VssClientCoordinator.Domain.APP) {} + assertEquals("replacement", sut.withClient(VssClientCoordinator.Domain.APP) { "replacement" }) + } + + @Test + fun `both domains and local result tails finish before credential change`() = test { + VssClientCoordinator.Domain.entries.forEach { sut.setup(it) {} } + val release = CompletableDeferred() + val finished = mutableSetOf() + val shutdown = mutableSetOf() + VssClientCoordinator.Domain.entries.forEach { domain -> + launch { + sut.withClient(domain) { + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { + release.await() + finished.add(domain) + } + } + } + } + } + runCurrent() + val transition = launch { + sut.withSuspendedClients({ shutdown.add(it) }) { + assertEquals(VssClientCoordinator.Domain.entries.toSet(), finished) + assertEquals(finished, shutdown) + assertFailsWith { sut.captureOwner() } + } + } + runCurrent() + assertTrue(shutdown.isEmpty()) + release.complete(Unit) + transition.join() + } + + @Test + fun `old payload and compound followup cannot enter replacement client`() = test { + sut.setup(VssClientCoordinator.Domain.APP) {} + val owner = sut.captureOwner() + var calls = 0 + val followup = CompletableDeferred() + val original = launch { + sut.withWallet(owner) { + sut.withClient(VssClientCoordinator.Domain.APP) { calls++ } + followup.await() + sut.withClient(VssClientCoordinator.Domain.APP) { calls++ } + } + } + runCurrent() + sut.withSuspendedClients({}) {} + assertTrue(original.isCancelled) + sut.setup(VssClientCoordinator.Domain.APP) {} + assertFailsWith { sut.withWallet(owner) { calls++ } } + assertEquals(1, calls) + } + + @Test + fun `cancelled retirement still drains and reopens without changing credentials`() = test { + val cleanup = CompletableDeferred() + var changed = false + launch { + sut.withWallet { + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { cleanup.await() } + } + } + } + runCurrent() + val transition = launch { sut.withSuspendedClients({}) { changed = true } } + runCurrent() + transition.cancel() + assertFailsWith { sut.captureOwner() } + cleanup.complete(Unit) + transition.join() + assertFalse(changed) + sut.withWallet {} + } + + @Test + fun `credential failure reopens uninitialized slots and invalidates previous owner`() = test { + val original = sut.captureOwner() + assertFailsWith { sut.withSuspendedClients({}) { throw FixtureFailure() } } + assertFailsWith { sut.withWallet(original) {} } + var initialized = false + sut.setup(VssClientCoordinator.Domain.APP) { initialized = true } + assertTrue(initialized) + } + + @Test + fun `setup waiters share successful setup and calls can overlap`() = test { + val setupRelease = CompletableDeferred() + var setups = 0 + val first = launch { + sut.setup(VssClientCoordinator.Domain.APP) { + setups++ + setupRelease.await() + } + } + val second = launch { sut.setup(VssClientCoordinator.Domain.APP) { setups++ } } + runCurrent() + setupRelease.complete(Unit) + first.join() + second.join() + assertEquals(1, setups) + val release = CompletableDeferred() + var entered = 0 + val calls = List(2) { + async { + sut.withClient(VssClientCoordinator.Domain.APP) { + entered++ + release.await() + } + } + } + runCurrent() + assertEquals(2, entered) + release.complete(Unit) + calls.forEach { it.await() } + } + + @Test + fun `retirement wakes calls waiting for explicit setup`() = test { + val waiting = launch { sut.withClient(VssClientCoordinator.Domain.APP) { error("Not initialized") } } + runCurrent() + sut.withSuspendedClients({}) {} + assertTrue(waiting.isCancelled) + } + + @Test + fun `shutdown failure leaves admission closed`() = test { + sut.setup(VssClientCoordinator.Domain.APP) {} + assertFailsWith { sut.withSuspendedClients({ throw FixtureFailure() }) {} } + assertFailsWith { sut.withWallet {} } + } + + private class FixtureFailure : AppError("fixture failure") +} diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 70901da134..1263e4ff36 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -7,8 +7,11 @@ import com.synonym.paykit.PaykitException import com.synonym.vssclient.VssItem import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Delay import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation @@ -20,6 +23,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runCurrent @@ -66,6 +71,7 @@ import to.bitkit.data.backup.UsdtRecoveryBackupStore import to.bitkit.data.backup.UsdtRecoveryBackupV1 import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.backup.VssBackupClientLdk +import to.bitkit.data.backup.VssClientCoordinator import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.data.keychain.Keychain @@ -95,10 +101,12 @@ import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import to.bitkit.test.connectionRecoveryFixture import to.bitkit.utils.AppError +import java.io.IOException import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import javax.inject.Provider +import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -119,6 +127,7 @@ class BackupRepoTest : BaseUnitTest() { private val context = mock() private val cacheStore = mock() private val vssBackupClient = mock() + private val vssClients = VssClientCoordinator() private val vssBackupClientLdk = mock() private val settingsStore = mock() private val widgetsStore = mock() @@ -183,6 +192,7 @@ class BackupRepoTest : BaseUnitTest() { whenever { cacheStore.updateBackupStatus(any(), any()) }.thenReturn(Unit) whenever { cacheStore.update(any()) }.thenReturn(Unit) whenever(cacheStore.data).thenReturn(cacheData) + whenever(cacheStore.backupStatuses).thenReturn(cacheData.map { it.backupStatuses }) whenever(settingsStore.data).thenReturn(settingsData) whenever { settingsStore.update(any()) }.thenReturn(Unit) whenever(widgetsStore.data).thenReturn(widgetsData) @@ -224,6 +234,7 @@ class BackupRepoTest : BaseUnitTest() { }.thenReturn(Result.success(Unit)) whenever { vssBackupClient.listKeys() }.thenReturn(Result.success(emptyList())) + whenever(pubkyRepo.hasIdentityForBackup()).thenReturn(false) whenever { sparkRepo.exitBackup() }.thenReturn(Result.success(null)) whenever { sparkRepo.restoreExitBackup(any(), any()) }.thenReturn(Result.success(Unit)) whenever { sparkRepo.mergeRemoteExitBackup(anyOrNull()) }.thenReturn(Result.success(Unit)) @@ -234,6 +245,196 @@ class BackupRepoTest : BaseUnitTest() { sut = createSut() } + @Test + fun `explicit first wallet backup does not wait for node observers`() = test { + val statuses = MutableStateFlow(mapOf(BackupCategory.WALLET to BackupItemStatus(required = 1_000))) + stubBackupStatuses(statuses, CompletableDeferred(Unit)) {} + sut.scheduleFullBackup() + advanceTimeBy(5_000) + runCurrent() + verify(vssBackupClient).setup() + verify(vssBackupClient, times(1)).putObject(eq(BackupCategory.WALLET.name), any()) + verify(vssBackupClient, never()).setupWithRetry(any(), any(), any()) + } + + @Test + fun `observer bootstrap requeues a required backup after a failed identity transition`() = test { + val statuses = MutableStateFlow(mapOf(BackupCategory.SETTINGS to BackupItemStatus(required = 1_000))) + stubBackupStatuses(statuses, CompletableDeferred(Unit)) {} + stubBackupObservers() + try { + sut.startObservingBackups() + runCurrent() + verify(vssBackupClient, times(1)).setupWithRetry(any(), any(), any()) + assertTrue(statuses.value.getValue(BackupCategory.SETTINGS).running) + val recovery = connectionRecoveryFixture(settingsStore, keychain, vssClients) + assertFailsWith { + recovery.withIdentityChange { + assertFalse(statuses.value.getValue(BackupCategory.SETTINGS).running) + throw BackupRepoTestError("credentials unchanged") + } + } + runCurrent() + verify(vssBackupClient, times(2)).setupWithRetry(any(), any(), any()) + verify(vssBackupClientLdk, times(2)).setup() + advanceTimeBy(5_000) + runCurrent() + verify(vssBackupClient, times(1)).putObject(eq(BackupCategory.SETTINGS.name), any()) + } finally { + sut.stopObservingBackups() + } + } + + @Test + fun `retired replacement reservation cannot leave a cancelled backup running`() = test { + for (retireBeforeCleanup in listOf(true, false)) { + val category = BackupCategory.SETTINGS + val statuses = MutableStateFlow(mapOf(category to BackupItemStatus(required = 1_000))) + stubBackupStatuses(statuses, CompletableDeferred(Unit)) {} + stubBackupObservers() + val finishCleanup = CompletableDeferred() + var holdCleanup = false + var cleanupEntered = false + var holdDispatch = false + val queued = mutableListOf>() + val delegate = StandardTestDispatcher(testScheduler) + + @OptIn(InternalCoroutinesApi::class) + val dispatcher = object : CoroutineDispatcher(), Delay by (delegate as Delay) { + override fun dispatch(context: CoroutineContext, block: Runnable) { + if (holdDispatch) queued += context to block else delegate.dispatch(context, block) + } + } + sut = createSut(ioDispatcher = dispatcher) + whenever(cacheStore.updateBackupStatus(any(), any())).doSuspendableAnswer { + val target = it.getArgument(0) + val transform = it.getArgument<(BackupItemStatus) -> BackupItemStatus>(1) + if (target == category && holdCleanup && !cleanupEntered) { + cleanupEntered = true + finishCleanup.await() + } + statuses.update { it + (target to transform(it[target] ?: BackupItemStatus())) } + } + try { + sut.startObservingBackups() + runCurrent() + assertTrue(statuses.value.getValue(category).running) + holdCleanup = true + sut.stopObservingBackups() + runCurrent() + assertTrue(cleanupEntered) + + holdDispatch = true + sut.scheduleFullBackup() + holdDispatch = false + assertTrue(queued.isNotEmpty()) + if (retireBeforeCleanup) { + val retiring = launch(start = CoroutineStart.UNDISPATCHED) { + vssClients.withSuspendedClients(shutdown = {}) { + assertFalse(statuses.value.getValue(category).running) + } + } + assertNull(vssClients.owners.value) + finishCleanup.complete(Unit) + runCurrent() + retiring.join() + } else { + finishCleanup.complete(Unit) + runCurrent() + vssClients.withSuspendedClients(shutdown = {}) { + assertFalse(statuses.value.getValue(category).running) + } + } + queued.forEach { (context, block) -> delegate.dispatch(context, block) } + queued.clear() + runCurrent() + + assertFalse(statuses.value.getValue(category).running) + assertTrue(statuses.value.getValue(category).isRequired) + verify(vssBackupClient, never()).putObject(eq(category.name), any()) + } finally { + holdDispatch = false + finishCleanup.complete(Unit) + queued.forEach { (context, block) -> delegate.dispatch(context, block) } + sut.reset() + } + } + } + + @Test + fun `backup failure status drains before replacement credentials`() = test { + val entered = CompletableDeferred() + val release = CompletableDeferred() + var replaced = false + var crossed = false + whenever(cacheStore.updateBackupStatus(eq(BackupCategory.WALLET), any())).doSuspendableAnswer { + entered.complete(Unit) + release.await() + crossed = replaced + Unit + } + whenever(pubkyRepo.hasIdentityForBackup()).thenReturn(true) + assertTrue(sut.persistWalletBackup().isFailure) + entered.await() + val recovery = connectionRecoveryFixture(settingsStore, keychain, vssClients) + recovery.withIdentityChange { replaced = true } + release.complete(Unit) + runCurrent() + assertTrue(replaced) + assertFalse(crossed) + } + + @Test + fun `metadata backup preserves complete data when identity records cannot be read`() = test { + stubMetadataBackupReads() + val error = AppError("keychain unavailable") + whenever(pubkyRepo.snapshotSessionBackupState()).thenReturn(Result.failure(error)) + assertEquals(error, sut.triggerBackup(BackupCategory.METADATA).exceptionOrNull()) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.METADATA.name), any()) + + stubMetadataBackupReads() + sut.triggerBackup(BackupCategory.METADATA).getOrThrow() + verify(vssBackupClient).putObject(eq(BackupCategory.METADATA.name), any()) + } + + @Test + fun `metadata backup preserves complete data when contact overrides cannot be read`() = test { + stubMetadataBackupReads() + val error = AppError("store unavailable") + whenever(pubkyRepo.snapshotContactProfileOverrides()).thenReturn(Result.failure(error)) + assertEquals(error, sut.triggerBackup(BackupCategory.METADATA).exceptionOrNull()) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.METADATA.name), any()) + + stubMetadataBackupReads() + sut.triggerBackup(BackupCategory.METADATA).getOrThrow() + verify(vssBackupClient).putObject(eq(BackupCategory.METADATA.name), any()) + } + + @Test + fun `wallet backup preserves complete data while stored identity is unresolved`() = test { + whenever(pubkyRepo.hasIdentityForBackup()).thenReturn(true) + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + + whenever(privatePaykitRepo.backupSnapshot()).thenReturn( + Result.success(PrivatePaykitBackupSnapshot("fresh-sdk-state", 0)), + ) + sut.triggerBackup(BackupCategory.WALLET).getOrThrow() + val payload = argumentCaptor() + verify(vssBackupClient).putObject(eq(BackupCategory.WALLET.name), payload.capture()) + val backup = json.decodeFromString(payload.firstValue.decodeToString()) + assertEquals("fresh-sdk-state", backup.paykitSdkBackupState) + } + + @Test + fun `wallet backup cannot treat unreadable identity as absent`() = test { + whenever(pubkyRepo.hasIdentityForBackup()).doSuspendableAnswer { + throw IllegalStateException("keychain unavailable") + } + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + @Test fun `the latest backup time skips the spark exit data`() { assertFalse(BackupCategory.SPARK_EXIT in BackupRepo.LATEST_BACKUP_TIME_CATEGORIES) @@ -371,6 +572,192 @@ class BackupRepoTest : BaseUnitTest() { } } + @Test + fun `spark connection backup survives a cancelled required-status write`() = test { + assertSparkBackupSurvivesOwnerRetirement(initiallyReady = false) + } + + @Test + fun `spark exit backup resumes when the pending restore clears`() = test { + assertSparkBackupResumesAfterRestore(expires = false) + } + + @Test + fun `spark exit backup resumes when the pending restore expires`() = test { + assertSparkBackupResumesAfterRestore(expires = true) + } + + @Test + fun `spark exit backup resumes when another restore interrupts its debounce`() = test { + assertSparkBackupResumesAfterRestore(expires = false, interruptDebounce = true) + } + + private suspend fun TestScope.assertSparkBackupResumesAfterRestore( + expires: Boolean, + interruptDebounce: Boolean = false, + ) { + val category = BackupCategory.SPARK_EXIT + val statuses = MutableStateFlow(mapOf(category to BackupItemStatus(synced = 1, required = 1))) + stubBackupStatuses(statuses, CompletableDeferred(Unit)) {} + stubBackupObservers() + val sparkState = MutableStateFlow(SparkState()) + whenever(sparkRepo.state).thenReturn(sparkState) + whenever(sparkRepo.exitBackup()).thenReturn( + Result.success(SparkExitBackupV1(createdAt = 1_000, network = "regtest", exitState = "state")) + ) + try { + sut.setRestorePending(true) + sut.startObservingBackups() + runCurrent() + sparkState.update { SparkState(isSupported = true, isReady = true, hasSynced = true) } + exitStateGeneration.update { 1L } + runCurrent() + advanceTimeBy(61_000) + runCurrent() + assertFalse(statuses.value.getValue(category).isRequired) + verify(vssBackupClient, never()).putObject(eq(category.name), any()) + + if (expires) { + whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(RESTORE_GATE_EXPIRED_AT)) + advanceTimeBy(RESTORE_GATE_EXPIRED_AT) + } else { + sut.setRestorePending(false) + } + runCurrent() + if (interruptDebounce) { + assertTrue(statuses.value.getValue(category).isRequired) + sut.setRestorePending(true) + runCurrent() + advanceTimeBy(61_000) + runCurrent() + assertTrue(statuses.value.getValue(category).isRequired) + assertFalse(statuses.value.getValue(category).running) + verify(vssBackupClient, never()).putObject(eq(category.name), any()) + sut.setRestorePending(false) + runCurrent() + } + advanceTimeBy(61_000) + runCurrent() + verify(vssBackupClient).putObject(eq(category.name), any()) + assertFalse(statuses.value.getValue(category).isRequired) + + sut.setRestorePending(true) + runCurrent() + sut.setRestorePending(false) + runCurrent() + advanceTimeBy(61_000) + runCurrent() + verify(vssBackupClient, times(1)).putObject(eq(category.name), any()) + } finally { + sut.stopObservingBackups() + } + } + + @Test + fun `spark exit generation survives a cancelled required-status write`() = test { + assertSparkBackupSurvivesOwnerRetirement(initiallyReady = true) + } + + @Test + fun `spark exit observation continues after a required-status storage failure`() = test { + val category = BackupCategory.SPARK_EXIT + val statuses = MutableStateFlow(mapOf(category to BackupItemStatus(synced = 1, required = 1))) + stubBackupStatuses(statuses, CompletableDeferred(Unit)) {} + stubBackupObservers() + whenever(sparkRepo.state).thenReturn( + MutableStateFlow(SparkState(isSupported = true, isReady = true, hasSynced = true)) + ) + whenever(sparkRepo.exitBackup()).thenReturn( + Result.success(SparkExitBackupV1(createdAt = 1_000, network = "regtest", exitState = "state")) + ) + var failRequiredWrite = true + whenever(cacheStore.updateBackupStatus(any(), any())).doSuspendableAnswer { + val target = it.getArgument(0) + val transform = it.getArgument<(BackupItemStatus) -> BackupItemStatus>(1) + val prior = statuses.value[target] ?: BackupItemStatus() + val next = transform(prior) + if (target == category && failRequiredWrite && next.required > prior.required) { + failRequiredWrite = false + throw IOException("status storage unavailable") + } + statuses.update { it + (target to next) } + } + try { + sut.startObservingBackups() + runCurrent() + assertFalse(failRequiredWrite) + exitStateGeneration.value = 1L + runCurrent() + advanceTimeBy(61_000) + runCurrent() + verify(vssBackupClient).putObject(eq(category.name), any()) + } finally { + sut.stopObservingBackups() + } + } + + private suspend fun TestScope.assertSparkBackupSurvivesOwnerRetirement( + initiallyReady: Boolean, + ) { + val category = BackupCategory.SPARK_EXIT + val statuses = MutableStateFlow(mapOf(category to BackupItemStatus(synced = 1, required = 1))) + stubBackupStatuses(statuses, CompletableDeferred(Unit)) {} + stubBackupObservers() + val sparkState = MutableStateFlow( + SparkState(isSupported = true, isReady = initiallyReady, hasSynced = initiallyReady) + ) + whenever(sparkRepo.state).thenReturn(sparkState) + whenever(sparkRepo.exitBackup()).thenReturn( + Result.success(SparkExitBackupV1(createdAt = 1_000, network = "regtest", exitState = "state")) + ) + val requiredWrite = CompletableDeferred() + var holdRequiredWrite = false + whenever(cacheStore.updateBackupStatus(any(), any())).doSuspendableAnswer { + val target = it.getArgument(0) + val transform = it.getArgument<(BackupItemStatus) -> BackupItemStatus>(1) + val prior = statuses.value[target] ?: BackupItemStatus() + val next = transform(prior) + if (target == category && holdRequiredWrite && next.required > prior.required) { + requiredWrite.complete(Unit) + awaitCancellation() + } + statuses.update { it + (target to next) } + } + try { + sut.startObservingBackups() + runCurrent() + advanceTimeBy(61_000) + runCurrent() + holdRequiredWrite = true + if (initiallyReady) { + exitStateGeneration.value = 1L + } else { + sparkState.value = SparkState(isSupported = true, isReady = true, hasSynced = true) + } + runCurrent() + assertTrue(requiredWrite.isCompleted) + holdRequiredWrite = false + val recovery = connectionRecoveryFixture(settingsStore, keychain, vssClients) + assertFailsWith { + recovery.withIdentityChange { throw BackupRepoTestError("credentials unchanged") } + } + runCurrent() + advanceTimeBy(61_000) + runCurrent() + val expectedUploads = if (initiallyReady) 2 else 1 + verify(vssBackupClient, times(expectedUploads)).putObject(eq(category.name), any()) + + sut.stopObservingBackups() + sut.startObservingBackups() + runCurrent() + advanceTimeBy(61_000) + runCurrent() + verify(vssBackupClient, times(expectedUploads)).putObject(eq(category.name), any()) + } finally { + sut.stopObservingBackups() + } + } + @Test fun `spark exit backup defers, without uploading, until Spark has connected and synced`() = test { whenever(sparkRepo.hasMergedRemoteExitState).thenReturn(false) @@ -453,6 +840,7 @@ class BackupRepoTest : BaseUnitTest() { runCurrent() assertTrue(statuses.value.getValue(BackupCategory.WALLET).isRequired) + assertTrue(statuses.value.getValue(BackupCategory.WALLET).running) verify(privatePaykitRepo, never()).backupSnapshot() verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) @@ -2201,6 +2589,83 @@ class BackupRepoTest : BaseUnitTest() { } } + @Test + fun `scheduled backup stays running until completion or observation stops`() = test { + val category = BackupCategory.SETTINGS + val statuses = MutableStateFlow(mapOf(category to BackupItemStatus(required = 1_000))) + stubBackupStatuses(statuses, CompletableDeferred(Unit)) {} + stubBackupObservers() + try { + sut.startObservingBackups() + runCurrent() + assertTrue(statuses.value.getValue(category).running) + advanceTimeBy(4_999) + runCurrent() + assertTrue(statuses.value.getValue(category).running) + verify(vssBackupClient, never()).putObject(eq(category.name), any()) + + sut.stopObservingBackups() + runCurrent() + assertFalse(statuses.value.getValue(category).running) + assertTrue(statuses.value.getValue(category).isRequired) + + sut.startObservingBackups() + runCurrent() + assertTrue(statuses.value.getValue(category).running) + advanceTimeBy(5_000) + runCurrent() + verify(vssBackupClient).putObject(eq(category.name), any()) + assertFalse(statuses.value.getValue(category).running) + assertFalse(statuses.value.getValue(category).isRequired) + } finally { + sut.stopObservingBackups() + } + } + + @Test + fun `delayed startup cleanup preserves a newly scheduled backup status`() = test { + val category = BackupCategory.SETTINGS + val statuses = MutableStateFlow(mapOf(category to BackupItemStatus(required = 1_000))) + val finishSetup = CompletableDeferred() + val finishCleanup = CompletableDeferred() + var cleanupEntered = false + whenever(vssBackupClient.setupWithRetry(any(), any(), any())).doSuspendableAnswer { + finishSetup.await() + Result.success(Unit) + } + whenever(cacheStore.backupStatuses).thenReturn(statuses) + whenever(cacheStore.updateBackupStatus(any(), any())).doSuspendableAnswer { + val updatedCategory = it.getArgument(0) + val transform = it.getArgument<(BackupItemStatus) -> BackupItemStatus>(1) + if (updatedCategory == category && !cleanupEntered) { + cleanupEntered = true + finishCleanup.await() + } + statuses.update { current -> + current + (updatedCategory to transform(current[updatedCategory] ?: BackupItemStatus())) + } + } + stubBackupObservers() + try { + sut.startObservingBackups() + runCurrent() + assertTrue(cleanupEntered) + assertTrue(statuses.value.getValue(category).running) + + finishCleanup.complete(Unit) + runCurrent() + assertTrue(statuses.value.getValue(category).running) + advanceTimeBy(5_000) + runCurrent() + verify(vssBackupClient).putObject(eq(category.name), any()) + assertFalse(statuses.value.getValue(category).isRequired) + } finally { + finishCleanup.complete(Unit) + finishSetup.complete(Unit) + sut.stopObservingBackups() + } + } + @Test fun `start observing backs up stale required status after clearing running flag`() = test { val backupStatuses = MutableStateFlow( @@ -2841,6 +3306,100 @@ class BackupRepoTest : BaseUnitTest() { } } + @Test + fun `a full backup upgrades a pending activity upload for the same wallet`() = test { + val category = BackupCategory.ACTIVITY + val changes = MutableStateFlow(0L) + val statuses = MutableStateFlow>(emptyMap()) + stubBackupObservers() + stubActivityBackupReads() + whenever(activityRepo.activitiesChanged).thenReturn(changes) + stubBackupStatuses(statuses, CompletableDeferred(Unit)) {} + + try { + sut.startObservingBackups() + runCurrent() + sut.triggerBackup(category).getOrThrow() + changes.update { it + 1 } + runCurrent() + assertTrue(statuses.value.getValue(category).running) + sut.scheduleFullBackup() + advanceTimeBy(10_000) + runCurrent() + + verify(vssBackupClient, times(2)).putObject(eq(category.name), any()) + assertFalse(statuses.value.getValue(category).isRequired) + } finally { + sut.stopObservingBackups() + } + } + + @Test + fun `a full backup requested during unchanged activity capture still uploads`() = test { + val category = BackupCategory.ACTIVITY + val changes = MutableStateFlow(0L) + val entered = CompletableDeferred() + val resume = CompletableDeferred() + stubBackupObservers() + stubActivityBackupReads() + whenever(activityRepo.activitiesChanged).thenReturn(changes) + stubBackupStatuses(MutableStateFlow(emptyMap()), CompletableDeferred(Unit)) {} + + try { + sut.startObservingBackups() + runCurrent() + sut.triggerBackup(category).getOrThrow() + whenever(activityRepo.getActivities()).doSuspendableAnswer { + entered.complete(Unit) + resume.await() + Result.success(emptyList()) + } + changes.update { it + 1 } + runCurrent() + advanceTimeBy(10_000) + runCurrent() + assertTrue(entered.isCompleted) + + sut.scheduleFullBackup() + advanceTimeBy(10_000) + runCurrent() + resume.complete(Unit) + runCurrent() + advanceTimeBy(10_000) + runCurrent() + + verify(vssBackupClient, times(2)).putObject(eq(category.name), any()) + } finally { + resume.complete(Unit) + sut.stopObservingBackups() + } + } + + @Test + fun `a new wallet owner uploads activity even if its payload matches the previous owner`() = test { + val category = BackupCategory.ACTIVITY + val changes = MutableStateFlow(0L) + stubBackupObservers() + stubActivityBackupReads() + whenever(activityRepo.activitiesChanged).thenReturn(changes) + stubBackupStatuses(MutableStateFlow(emptyMap()), CompletableDeferred(Unit)) {} + + sut.triggerBackup(category).getOrThrow() + vssClients.withSuspendedClients(shutdown = {}) {} + try { + sut.startObservingBackups() + runCurrent() + changes.update { it + 1 } + runCurrent() + advanceTimeBy(10_000) + runCurrent() + + verify(vssBackupClient, times(2)).putObject(eq(category.name), any()) + } finally { + sut.stopObservingBackups() + } + } + @Test fun `scheduled activity backup uploads a reverted payload after a failed upload`() = test { val activitiesChanged = MutableStateFlow(0L) @@ -3407,6 +3966,7 @@ class BackupRepoTest : BaseUnitTest() { ioDispatcher = ioDispatcher, cacheStore = cacheStore, vssBackupClient = vssBackupClient, + vssClients = vssClients, vssBackupClientLdk = vssBackupClientLdk, settingsStore = settingsStore, widgetsStore = widgetsStore, @@ -3436,7 +3996,7 @@ class BackupRepoTest : BaseUnitTest() { lightningService = lightningService, clock = clock, db = db, - connectionRecovery = connectionRecoveryFixture(settingsStore, keychain), + connectionRecovery = connectionRecoveryFixture(settingsStore, keychain, vssClients), ) 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 4bdee90058..84ecc43036 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -79,6 +79,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssBackupClientLdk +import to.bitkit.data.backup.VssClientCoordinator import to.bitkit.data.keychain.Keychain import to.bitkit.data.keychain.WalletBackupRestorePendingError import to.bitkit.ext.createChannelDetails @@ -151,6 +152,7 @@ class LightningRepoTest : BaseUnitTest() { private val lnurlService = mock() private val connectivityRepo = mock() private val vssBackupClientLdk = mock() + private val vssClients = VssClientCoordinator() private val electrumProbeService = mock() private val urlValidator = UrlValidator { Result.success(Unit) } private val probePaymentA = "probe-payment-a" @@ -181,7 +183,7 @@ class LightningRepoTest : BaseUnitTest() { preActivityMetadataRepo = preActivityMetadataRepo, onchainSendAttemptStore = onchainSendAttemptStore, connectivityRepo = connectivityRepo, - vssBackupClientLdk = vssBackupClientLdk, + vssBackupClientLdk = vssBackupClientLdk, vssClients = vssClients, urlValidator = urlValidator, electrumProbeService = electrumProbeService, sparkRepo = sparkRepo, @@ -315,9 +317,10 @@ class LightningRepoTest : BaseUnitTest() { transferRepo = mock(), activityRepo = mock(), hwWalletRepo = hardware, - connectionRecovery = connectionRecoveryFixture(settingsStore), + connectionRecovery = connectionRecoveryFixture(settingsStore, vssClients = vssClients), sparkRepo = sparkRepo, backupRepo = mock { on { restoreInProgress } doReturn MutableStateFlow(false) }, + vssClients = vssClients, ) } @@ -1123,23 +1126,61 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(NodeLifecycleState.Stopped, sut.lightningState.value.nodeLifecycleState) } + @Test + fun `detached restart drains its old stop without starting replacement wallet`() = test { + startNodeForTesting() + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(lightningService.stop()).doSuspendableAnswer { + entered.complete(Unit) + release.await() + } + val restart = launch { sut.restartNodeDetached() } + entered.await() + var replaced = false + val recovery = connectionRecoveryFixture(settingsStore, vssClients = vssClients) + val transition = launch { recovery.withIdentityChange { replaced = true } } + runCurrent() + assertFalse(replaced) + release.complete(Unit) + transition.join() + restart.join() + assertTrue(replaced) + assertTrue(restart.isCancelled) + verify(lightningService, times(1)).start(anyOrNull(), any()) + verify(vssBackupClientLdk, never()).deleteObjects(any(), any()) + } + + @Test + fun `manual graph reset cannot delete after wallet identity replacement`() = test { + val entered = CompletableDeferred() + whenever(lightningService.resetNetworkGraph(any())).doSuspendableAnswer { + entered.complete(Unit) + awaitCancellation() + } + val reset = launch { sut.resetNetworkGraph() } + entered.await() + val recovery = connectionRecoveryFixture(settingsStore, vssClients = vssClients) + recovery.withIdentityChange {} + reset.join() + assertTrue(reset.isCancelled) + verify(vssBackupClientLdk, never()).deleteObjects(any(), any()) + } + @Test fun `resetNetworkGraph clears local cache and VSS copy`() = test { - whenever(vssBackupClientLdk.setup(any())).thenReturn(Result.success(Unit)) - whenever(vssBackupClientLdk.deleteObject(any(), any())).thenReturn(Result.success(true)) + whenever(vssBackupClientLdk.deleteObjects(any(), any())).thenReturn(Result.success(Unit)) val result = sut.resetNetworkGraph() assertTrue(result.isSuccess) verify(lightningService).resetNetworkGraph(0) - verify(vssBackupClientLdk).setup(0) - verify(vssBackupClientLdk).deleteObject(eq("network_graph"), any()) + verify(vssBackupClientLdk).deleteObjects(listOf("network_graph"), 0) } @Test fun `resetNetworkGraph fails when VSS delete fails`() = test { - whenever(vssBackupClientLdk.setup(any())).thenReturn(Result.success(Unit)) - whenever(vssBackupClientLdk.deleteObject(any(), any())) + whenever(vssBackupClientLdk.deleteObjects(any(), any())) .thenReturn(Result.failure(RuntimeException("vss unavailable"))) val result = sut.resetNetworkGraph() @@ -1676,6 +1717,7 @@ class LightningRepoTest : BaseUnitTest() { onchainSendAttemptStore = onchainSendAttemptStore, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, + vssClients = vssClients, urlValidator = urlValidator, electrumProbeService = electrumProbeService, sparkRepo = sparkRepo, @@ -1802,6 +1844,7 @@ class LightningRepoTest : BaseUnitTest() { onchainSendAttemptStore = onchainSendAttemptStore, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, + vssClients = vssClients, urlValidator = urlValidator, electrumProbeService = electrumProbeService, sparkRepo = spark, @@ -3027,7 +3070,7 @@ class LightningRepoTest : BaseUnitTest() { preActivityMetadataRepo = preActivityMetadataRepo, onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, - vssBackupClientLdk = vssBackupClientLdk, + vssBackupClientLdk = vssBackupClientLdk, vssClients = vssClients, urlValidator = urlValidator, electrumProbeService = electrumProbeService, sparkRepo = sparkRepo, @@ -3508,7 +3551,7 @@ class LightningRepoTest : BaseUnitTest() { keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, - vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, + vssBackupClientLdk = vssBackupClientLdk, vssClients = vssClients, urlValidator = urlValidator, electrumProbeService = electrumProbeService, sparkRepo = sparkRepo, ) @@ -3579,7 +3622,7 @@ class LightningRepoTest : BaseUnitTest() { keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, - vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, + vssBackupClientLdk = vssBackupClientLdk, vssClients = vssClients, urlValidator = urlValidator, electrumProbeService = electrumProbeService, sparkRepo = sparkRepo, ) @@ -3661,7 +3704,7 @@ class LightningRepoTest : BaseUnitTest() { keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, - vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, + vssBackupClientLdk = vssBackupClientLdk, vssClients = vssClients, urlValidator = urlValidator, electrumProbeService = electrumProbeService, sparkRepo = sparkRepo, ) @@ -4140,7 +4183,7 @@ class LightningRepoTest : BaseUnitTest() { preActivityMetadataRepo = preActivityMetadataRepo, onchainSendAttemptStore = onchainSendAttemptStore, connectivityRepo = connectivityRepo, - vssBackupClientLdk = vssBackupClientLdk, + vssBackupClientLdk = vssBackupClientLdk, vssClients = vssClients, urlValidator = failingValidator, electrumProbeService = electrumProbeService, sparkRepo = sparkRepo, diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 00940b9097..6625ed2540 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -51,6 +51,8 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield import org.junit.Before import org.junit.Test import org.mockito.Mockito.clearInvocations @@ -1167,6 +1169,42 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService, never()) { publishPaykitProfile(any(), any()) } } + @Test + fun `avatar migration from a replaced sign-in cannot write even for the same key`() = test { + val store = stubGatedPubkyStore() + authenticateForTesting(publicKey = VALID_SELF_KEY) + val originalSignIn = assertNotNull(sut.currentSignIn()) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenReturn(null) + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenReturn( + paykitRecord(createPaykitProfile("Old", image = "pubky://self/pub/paykit/blobs/avatar.jpg")) + ) + val readStarted = CompletableDeferred() + val finishRead = CompletableDeferred() + whenever(pubkyService.fetchFile(any(), any())).doSuspendableAnswer { + readStarted.complete(Unit) + finishRead.await() + byteArrayOf(1) + } + val load = async { sut.loadProfile() } + readStarted.await() + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("test mnemonic") + whenever(pubkyService.deriveSecretKey("test mnemonic")).thenReturn("restored_secret") + whenever(pubkyService.publicKeyFromSecret("restored_secret")).thenReturn(VALID_SELF_KEY) + sut.restoreSessionBackupState(PubkySessionBackupV1(kind = PubkySessionBackupKind.LocalSeed)).getOrThrow() + assertFalse(sut.isCurrent(originalSignIn)) + clearInvocations(pubkyService) + + finishRead.complete(Unit) + load.await() + + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertNull(sut.profile.value) + verify(pubkyService, never()).uploadPubkyAppAvatar(any(), any(), any()) + verify(pubkyService, never()).publishPubkyProfile(any(), anyOrNull(), any()) + verify(pubkyService, never()).publishPaykitProfile(any(), any()) + assertEquals(PubkyStoreData(), store.data) + } + @Test fun `loadProfile keeps an avatar set on pubky app over a pending avatar move`() = test { whenever(pubkyStore.data).thenReturn( @@ -3120,6 +3158,268 @@ class PubkyRepoTest : BaseUnitTest() { assertNull(result.getOrNull()) } + @Test + fun `scheduled restore activation returns while authentication is held and coalesces duplicates`() = test { + sut.awaitInitialization() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("restored_session") + val release = CompletableDeferred() + whenever(pubkyService.importSession("restored_session")).doSuspendableAnswer { release.await() } + clearInvocations(pubkyService) + try { + repeat(3) { sut.scheduleInitializationAfterRestore() } + runCurrent() + assertNull(sut.publicKey.value) + assertFalse(sut.isAuthenticated.value) + verify(pubkyService).importSession("restored_session") + } finally { + release.complete(VALID_SELF_KEY) + } + advanceUntilIdle() + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + verify(pubkyService).importSession("restored_session") + } + + @Test + fun `scheduled metadata refresh preserves an identity already activated by restore`() = test { + sut.awaitInitialization() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("restored_session") + whenever(pubkyService.importSession("restored_session")).thenReturn(VALID_SELF_KEY) + assertTrue(sut.restoreSessionIfNeeded()) + val originalSignIn = assertNotNull(sut.currentSignIn()) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + clearInvocations(pubkyService) + whenever(pubkyService.importSession("restored_session")).doSuspendableAnswer { + throw PaykitException.Transport("transport_error", "controlled unavailable re-import") + } + val refreshed = CompletableDeferred() + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { + refreshed.complete(Unit) + createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Refreshed")) + } + sut.scheduleInitializationAfterRestore() + yield() + advanceUntilIdle() + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertTrue(sut.isCurrent(originalSignIn)) + assertFalse(sut.sessionRestorationFailed.value) + verify(pubkyService, never()).importSession(any()) + withTimeout(1.seconds) { refreshed.await() } + verify(pubkyService).fetchPubkyProfileWithRevision(VALID_SELF_KEY) + } + + @Test + fun `wipe drains a scheduled profile refresh without restoring its identity`() = test { + val store = stubGatedPubkyStore() + authenticateForTesting(publicKey = VALID_SELF_KEY) + val credentials = mutableMapOf( + Keychain.Key.PAYKIT_SESSION.name to "saved_session", + Keychain.Key.PUBKY_SECRET_KEY.name to "local_secret", + ) + whenever(keychain.loadString(any())).thenAnswer { credentials[it.getArgument(0)] } + whenever(keychain.delete(any())).thenAnswer { + credentials.remove(it.getArgument(0)) + Unit + } + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenReturn(null) + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenReturn(paykitRecord(createPaykitProfile("Old"))) + val retryPublished = CompletableDeferred() + whenever(pubkyService.publishPubkyProfile(any(), anyOrNull(), any())) + .thenAnswer { throw PaykitException.Identity("identity_error", "Missing capabilities") } + .thenAnswer { + retryPublished.complete(Unit) + createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Old")) + } + var refreshed = false + whenever(pubkyService.signIn("local_secret")).thenAnswer { + refreshed = true + Unit + } + val refreshStarted = CompletableDeferred() + val finishRefresh = CompletableDeferred() + whenever(pubkyService.publicKeyFromSecret("local_secret")).doSuspendableAnswer { + if (refreshed) { + refreshStarted.complete(Unit) + finishRefresh.await() + } + VALID_SELF_KEY + } + + sut.scheduleInitializationAfterRestore() + refreshStarted.await() + val wipe = async { sut.wipeLocalState() } + runCurrent() + finishRefresh.complete(Unit) + retryPublished.await() + wipe.await() + advanceUntilIdle() + + assertNull(sut.publicKey.value) + assertFalse(sut.hasIdentity()) + assertTrue(credentials.isEmpty()) + assertEquals(PubkyStoreData(), store.data) + } + + @Test + fun `reconnect skips expected missing profile until local setup completes`() = test { + sut.awaitInitialization() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("restored_session") + whenever(pubkyService.importSession("restored_session")).thenReturn(VALID_SELF_KEY) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenAnswer { + throw PaykitException.Transport("transport_error", "offline") + } + assertTrue(sut.restoreSessionIfNeeded()) + val originalSignIn = assertNotNull(sut.currentSignIn()) + profileSetupPending.value = true + clearInvocations(pubkyService) + + repeat(3) { + assertFalse(sut.restoreSessionIfNeeded()) + yield() + advanceUntilIdle() + } + + assertTrue(sut.isCurrent(originalSignIn)) + verify(pubkyService, never()).fetchPubkyProfileWithRevision(VALID_SELF_KEY) + verify(pubkyService, never()).importSession(any()) + profileSetupPending.value = false + doSuspendableAnswer { + createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Completed")) + }.whenever(pubkyService).fetchPubkyProfileWithRevision(VALID_SELF_KEY) + assertFalse(sut.restoreSessionIfNeeded()) + yield() + advanceUntilIdle() + assertEquals("Completed", sut.profile.value?.name) + verify(pubkyService).fetchPubkyProfileWithRevision(VALID_SELF_KEY) + } + + @Test + fun `active identity retries its missing profile on reconnect without delaying wallet maintenance`() = test { + sut.awaitInitialization() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("restored_session") + whenever(pubkyService.importSession("restored_session")).thenReturn(VALID_SELF_KEY) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenAnswer { + throw PaykitException.Transport("transport_error", "offline") + } + assertTrue(sut.restoreSessionIfNeeded()) + val originalSignIn = assertNotNull(sut.currentSignIn()) + assertNull(sut.profile.value) + + val response = CompletableDeferred() + doSuspendableAnswer { + response.await() + createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Restored")) + }.whenever(pubkyService).fetchPubkyProfileWithRevision(VALID_SELF_KEY) + clearInvocations(pubkyService) + try { + repeat(3) { assertFalse(sut.restoreSessionIfNeeded()) } + yield() + runCurrent() + assertTrue(sut.isCurrent(originalSignIn)) + verify(pubkyService).fetchPubkyProfileWithRevision(VALID_SELF_KEY) + assertTrue(sut.isLoadingProfile.value) + verify(pubkyService, never()).importSession(any()) + } finally { + response.complete(Unit) + } + yield() + advanceUntilIdle() + assertEquals("Restored", sut.profile.value?.name) + assertFalse(sut.restoreSessionIfNeeded()) + advanceUntilIdle() + verify(pubkyService).fetchPubkyProfileWithRevision(VALID_SELF_KEY) + } + + @Test + fun `reconnect profile result cannot restore an ended identity`() = test { + sut.awaitInitialization() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("restored_session") + whenever(pubkyService.importSession("restored_session")).thenReturn(VALID_SELF_KEY) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenAnswer { + throw PaykitException.Transport("transport_error", "offline") + } + assertTrue(sut.restoreSessionIfNeeded()) + val response = CompletableDeferred() + doSuspendableAnswer { + response.await() + createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Old")) + }.whenever(pubkyService).fetchPubkyProfileWithRevision(VALID_SELF_KEY) + + try { + assertFalse(sut.restoreSessionIfNeeded()) + yield() + runCurrent() + verify(pubkyService, times(3)).fetchPubkyProfileWithRevision(VALID_SELF_KEY) + assertTrue(sut.isLoadingProfile.value) + sut.restoreSessionBackupState(null).getOrThrow() + assertNull(sut.currentSignIn()) + } finally { + response.complete(Unit) + } + yield() + advanceUntilIdle() + assertNull(sut.profile.value) + } + + @Test + fun `new restored identity proceeds while old scheduled profile loading is held`() = test { + sut.awaitInitialization() + var session: String? = "first_session" + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { session } + whenever(keychain.delete(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { session = null } + whenever(pubkyService.importSession("first_session")).thenReturn(VALID_SELF_KEY) + whenever(pubkyService.importSession("second_session")).thenReturn(VALID_CONTACT_KEY_B) + val releaseFirst = CompletableDeferred() + val releaseSecond = CompletableDeferred() + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { + releaseFirst.await() + createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Obsolete")) + } + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_CONTACT_KEY_B)).doSuspendableAnswer { + releaseSecond.await() + createSnapshot(VALID_CONTACT_KEY_B, createPubkyProfile(name = "Current")) + } + try { + sut.scheduleInitializationAfterRestore() + runCurrent() + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + sut.restoreSessionBackupState(null).getOrThrow() + assertNull(sut.publicKey.value) + session = "second_session" + sut.scheduleInitializationAfterRestore() + runCurrent() + assertEquals(VALID_CONTACT_KEY_B, sut.publicKey.value) + releaseFirst.complete(Unit) + runCurrent() + sut.scheduleInitializationAfterRestore() + runCurrent() + verify(pubkyService).importSession("second_session") + } finally { + releaseFirst.complete(Unit) + releaseSecond.complete(Unit) + } + advanceUntilIdle() + assertEquals(VALID_CONTACT_KEY_B, sut.publicKey.value) + assertEquals("Current", sut.profile.value?.name) + } + + @Test + fun `backup identity includes persisted and adopted credentials and fails on unreadable storage`() = test { + sut.awaitInitialization() + assertFalse(sut.hasIdentityForBackup()) + val identityKeys = listOf( + Keychain.Key.PAYKIT_SESSION, + Keychain.Key.PUBKY_SECRET_KEY, + Keychain.Key.SHARED_PUBKY_SOURCE, + ) + for (key in identityKeys) { + whenever(keychain.loadString(key.name)).thenReturn("stored") + assertTrue(sut.hasIdentityForBackup()) + whenever(keychain.loadString(key.name)).thenReturn(null) + } + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { throw TestAppError("unreadable") } + assertTrue(runSuspendCatching { sut.hasIdentityForBackup() }.isFailure) + } + @Test fun `awaitInitialization shares startup and preserves it when a waiter is cancelled`() = test { val imported = CompletableDeferred() @@ -4058,6 +4358,7 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `wipe completes while restoration profile loading remains in flight`() = test { + val store = stubGatedPubkyStore() sut.awaitInitialization() val credentials = mutableMapOf(Keychain.Key.PAYKIT_SESSION.name to "saved_session") whenever(keychain.loadString(any())).thenAnswer { credentials[it.getArgument(0)] } @@ -4091,6 +4392,32 @@ class PubkyRepoTest : BaseUnitTest() { assertNull(sut.profile.value) assertTrue(sut.contacts.value.isEmpty()) verify(pubkyStore).reset() + assertEquals(PubkyStoreData(), store.data) + } + + @Test + fun `wipe drains a missing profile setup update before clearing it`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenReturn(null) + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenReturn(null) + val updateStarted = CompletableDeferred() + val finishUpdate = CompletableDeferred() + whenever(settingsStore.setPubkyProfileSetupPending(true)).doSuspendableAnswer { + updateStarted.complete(Unit) + finishUpdate.await() + profileSetupPending.value = true + } + val load = async { sut.loadProfile() } + updateStarted.await() + val wipe = async { sut.wipeLocalState() } + runCurrent() + + finishUpdate.complete(Unit) + load.await() + wipe.await() + + assertNull(sut.publicKey.value) + assertFalse(profileSetupPending.value) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 8af155152b..01f3a0ec79 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Before import org.junit.Test +import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Network @@ -51,7 +52,9 @@ import to.bitkit.data.PrivatePaykitReservationStore import to.bitkit.data.PrivatePaykitStoredAssignmentData import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.data.backup.VssClientCoordinator import to.bitkit.data.keychain.Keychain +import to.bitkit.di.json import to.bitkit.models.BalanceState import to.bitkit.models.NodeLifecycleState import to.bitkit.services.AddressDerivationInfo @@ -94,7 +97,8 @@ class WalletRepoTest : BaseUnitTest() { private val activityRepo = mock() private val hwWalletRepo = mock() private val backupRepo = mock() - private val connectionRecovery = connectionRecoveryFixture(settingsStore) + private val vssClients = VssClientCoordinator() + private val connectionRecovery = connectionRecoveryFixture(settingsStore, vssClients = vssClients) companion object Fixtures { const val ACTIVITY_TAG = "testTag" @@ -180,6 +184,7 @@ class WalletRepoTest : BaseUnitTest() { dispatcher: CoroutineDispatcher = testDispatcher, reservationRepo: PrivatePaykitAddressReservationRepo = privatePaykitAddressReservationRepo, spark: SparkRepo = sparkRepo, + balances: DeriveBalanceStateUseCase = deriveBalanceStateUseCase, ) = WalletRepo( bgDispatcher = dispatcher, keychain = keychain, @@ -188,7 +193,7 @@ class WalletRepoTest : BaseUnitTest() { lightningRepo = lightningRepo, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, - deriveBalanceStateUseCase = deriveBalanceStateUseCase, + deriveBalanceStateUseCase = balances, wipeWalletUseCase = wipeWalletUseCase, privatePaykitAddressReservationRepo = reservationRepo, transferRepo = transferRepo, @@ -197,6 +202,7 @@ class WalletRepoTest : BaseUnitTest() { connectionRecovery = connectionRecovery, sparkRepo = spark, backupRepo = backupRepo, + vssClients = vssClients, ) private fun lightningInvoice(bolt11: String, isExpired: Boolean = false) = LightningInvoice( @@ -276,6 +282,325 @@ class WalletRepoTest : BaseUnitTest() { verify(deriveBalanceStateUseCase).invoke() } + @Test + fun `Spark balance changes update spending while the node is stopped`() = test { + whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(true) + val nodeState = MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running)) + whenever(lightningRepo.lightningState).thenReturn(nodeState) + whenever(lightningRepo.getBalancesAsync()).thenReturn( + Result.success( + BalanceDetails( + totalOnchainBalanceSats = 50_000uL, + spendableOnchainBalanceSats = 0uL, + totalAnchorChannelsReserveSats = 0uL, + totalLightningBalanceSats = 20_000uL, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = emptyList(), + ), + ), + ) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(lightningRepo.getChannelFundableBalance()).thenReturn(0uL) + whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) + val sparkState = + MutableStateFlow(SparkState(isReady = true, balanceSats = 99_865uL, pendingDepositSats = 600uL)) + val spark = sparkRepoWith(sparkState) + val balances = DeriveBalanceStateUseCase( + bgDispatcher = testDispatcher, + lightningRepo = lightningRepo, + transferRepo = transferRepo, + settingsStore = settingsStore, + hwWalletRepo = hwWalletRepo, + sparkRepo = spark, + ) + sut = createSut(spark = spark, balances = balances) + testScheduler.runCurrent() + assertEquals(119_865uL, sut.balanceState.value.totalLightningSats) + + nodeState.value = LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) + whenever(lightningRepo.getBalancesAsync()).thenReturn( + Result.failure(NodeNotRunningError("getBalancesAsync", NodeLifecycleState.Stopped)), + ) + for (remaining in listOf(98_659uL, 97_653uL, 0uL)) { + sparkState.update { it.copy(balanceSats = remaining, pendingDepositSats = 100uL) } + testScheduler.runCurrent() + + assertEquals(20_000uL + remaining, sut.balanceState.value.totalLightningSats) + assertEquals(50_000uL, sut.balanceState.value.totalOnchainSats) + assertEquals(100uL, sut.balanceState.value.balanceInTransferToSpending) + assertEquals(SparkFees.sendableSats(remaining), sut.balanceState.value.maxSendLightningSats) + verify(cacheStore).cacheBalance(sut.balanceState.value) + } + sparkState.update { it.copy(keptBalanceSats = 97_653uL) } + testScheduler.runCurrent() + assertEquals(117_653uL, sut.balanceState.value.totalLightningSats) + assertEquals(0uL, sut.balanceState.value.maxSendLightningSats) + } + + @Test + fun `cached Spark contribution updates only after Spark is ready and never guesses a legacy split`() = test { + val known = BalanceState( + totalOnchainSats = 50_000uL, + totalLightningSats = 120_000uL, + balanceInTransferToSpending = 3_000uL, + sparkBalanceSats = 100_000uL, + sparkPendingDepositSats = 1_000uL, + ) + val legacy = json.decodeFromString("""{"totalLightningSats":120000}""") + assertEquals(null, legacy.sparkBalanceSats) + val restored = json.decodeFromString(json.encodeToString(known)) + assertEquals(known, restored) + val state = MutableStateFlow(SparkState()) + whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(true) + whenever(deriveBalanceStateUseCase.invoke()).thenReturn( + Result.failure(NodeNotRunningError("getBalancesAsync", NodeLifecycleState.Stopped)), + ) + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(balance = restored))) + sut = createSut(spark = sparkRepoWith(state)) + sut.loadFromCache() + testScheduler.runCurrent() + assertEquals(known, sut.balanceState.value) + + state.update { it.copy(isReady = true) } + testScheduler.runCurrent() + assertEquals(20_000uL, sut.balanceState.value.totalLightningSats) + assertEquals(2_000uL, sut.balanceState.value.balanceInTransferToSpending) + assertEquals(50_000uL, sut.balanceState.value.totalOnchainSats) + + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(balance = legacy))) + val legacyState = MutableStateFlow(SparkState(isReady = true)) + sut = createSut(spark = sparkRepoWith(legacyState)) + sut.loadFromCache() + testScheduler.runCurrent() + assertEquals(legacy, sut.balanceState.value) + legacyState.update { it.copy(balanceSats = 5_000uL) } + testScheduler.runCurrent() + assertEquals(legacy.copy(maxSendLightningSats = SparkFees.sendableSats(5_000uL)), sut.balanceState.value) + } + + @Test + fun `cached wallet data loads during serialized balance reads`() = test { + val node = MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Starting)) + whenever(lightningRepo.lightningState).thenReturn(node) + val cached = AppCacheData( + balance = BalanceState(totalOnchainSats = 50_000uL), + onchainAddress = ADDRESS, + bolt11 = INVOICE, + bip21 = "bitcoin:$ADDRESS?lightning=$INVOICE", + ) + whenever(cacheStore.data).thenReturn(flowOf(cached)) + val reading = CompletableDeferred() + val release = CompletableDeferred() + val fresh = BalanceState(totalOnchainSats = 60_000uL) + val latest = BalanceState(totalOnchainSats = 70_000uL) + var reads = 0 + whenever(deriveBalanceStateUseCase.invoke()).doSuspendableAnswer { + if (reads++ == 0) { + reading.complete(Unit) + release.await() + Result.success(fresh) + } else { + Result.success(latest) + } + } + + val sync = launch { sut.syncBalances() } + reading.await() + sut.loadFromCache() + testScheduler.runCurrent() + try { + assertEquals(cached.balance, sut.balanceState.value) + assertEquals(cached.onchainAddress, sut.walletState.value.onchainAddress) + assertEquals(cached.bolt11, sut.walletState.value.bolt11) + assertEquals(cached.bip21, sut.walletState.value.bip21) + assertFalse(sut.walletState.value.isReceiveInvoiceUpdating) + launch { sut.syncBalances() } + testScheduler.runCurrent() + } finally { + release.complete(Unit) + sync.join() + } + testScheduler.runCurrent() + assertEquals(latest, sut.balanceState.value) + verify(cacheStore).cacheBalance(latest) + } + + @Test + fun `balance writes and cache hydration cannot overtake a pending update`() = test { + var cached = AppCacheData() + whenever(cacheStore.data).thenReturn(flow { emit(cached) }) + val first = BalanceState(totalLightningSats = 120_000uL) + val latest = BalanceState(totalLightningSats = 20_000uL) + whenever(deriveBalanceStateUseCase.invoke()).thenReturn(Result.success(first), Result.success(latest)) + val held = CompletableDeferred() + val release = CompletableDeferred() + whenever(cacheStore.cacheBalance(any())).doSuspendableAnswer { + val balance = it.getArgument(0) + if (balance == first) { + held.complete(Unit) + release.await() + } + cached = cached.copy(balance = balance) + } + val older = launch { sut.syncBalances() } + held.await() + val newer = launch { sut.syncBalances() } + sut.loadFromCache() + testScheduler.runCurrent() + assertEquals(null, cached.balance) + release.complete(Unit) + older.join() + newer.join() + testScheduler.runCurrent() + + assertEquals(latest, cached.balance) + assertEquals(latest, sut.balanceState.value) + } + + @Test + fun `failed cache write keeps the latest visible balance during hydration`() = test { + val latest = BalanceState(totalLightningSats = 20_000uL) + whenever(deriveBalanceStateUseCase.invoke()).thenReturn(Result.success(latest)) + whenever(cacheStore.cacheBalance(any())).doSuspendableAnswer { throw IOException("cache unavailable") } + whenever(cacheStore.data).thenReturn( + flowOf(AppCacheData(balance = BalanceState(totalLightningSats = 120_000uL))), + ) + + sut.syncBalances() + sut.loadFromCache() + testScheduler.runCurrent() + + assertEquals(latest, sut.balanceState.value) + } + + @Test + fun `wallet retirement drains balance writes and leaves Spark observation available for the next wallet`() = test { + val state = MutableStateFlow(SparkState(isReady = true, balanceSats = 50_000uL)) + val held = CompletableDeferred() + val release = CompletableDeferred() + var cached: BalanceState? = null + whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(true) + whenever(deriveBalanceStateUseCase.invoke()).thenReturn( + Result.success(BalanceState(totalLightningSats = 50_000uL)), + ) + whenever(cacheStore.cacheBalance(any())).doSuspendableAnswer { + held.complete(Unit) + release.await() + cached = it.getArgument(0) + } + sut = createSut(spark = sparkRepoWith(state)) + held.await() + vssClients.withSuspendedClients(shutdown = {}) { + sut.resetState() + cached = null + } + release.complete(Unit) + testScheduler.runCurrent() + assertEquals(BalanceState(), sut.balanceState.value) + assertEquals(null, cached) + + val next = BalanceState( + totalLightningSats = 10_000uL, + maxSendLightningSats = SparkFees.sendableSats(10_000uL), + ) + whenever(deriveBalanceStateUseCase.invoke()).thenReturn(Result.success(next)) + state.update { it.copy(balanceSats = 10_000uL) } + testScheduler.runCurrent() + assertEquals(next, sut.balanceState.value) + assertEquals(next, cached) + } + + @Test + fun `balance publication preserves native limits and later channel refreshes`() = test { + val node = MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running)) + whenever(lightningRepo.lightningState).thenReturn(node) + var cached = AppCacheData() + whenever(cacheStore.data).thenReturn(flow { emit(cached) }) + whenever(cacheStore.cacheBalance(any())).doSuspendableAnswer { + cached = cached.copy(balance = it.getArgument(0)) + } + val fresh = BalanceState(totalLightningSats = 20_000uL, maxSendLightningSats = 5_000uL) + val published = mock { + on { isChannelReady } doReturn true + on { isUsable } doReturn true + on { nextOutboundHtlcLimitMsat } doReturn 2_000_000uL + } + whenever(deriveBalanceStateUseCase.invoke()).doSuspendableAnswer { + node.update { it.copy(channels = listOf(published).toImmutableList()) } + Result.success(fresh) + } + sut.syncBalances() + assertEquals(5_000uL, sut.balanceState.value.maxSendLightningSats) + sut.loadFromCache() + testScheduler.runCurrent() + assertEquals(5_000uL, sut.balanceState.value.maxSendLightningSats) + + val held = CompletableDeferred() + val release = CompletableDeferred() + whenever(cacheStore.cacheBalance(any())).doSuspendableAnswer { + held.complete(Unit) + release.await() + } + val sync = launch { sut.syncBalances() } + held.await() + val recovered = mock { + on { isChannelReady } doReturn true + on { isUsable } doReturn true + on { nextOutboundHtlcLimitMsat } doReturn 7_000_000uL + } + node.update { it.copy(channels = listOf(recovered).toImmutableList()) } + sut.refreshMaxSendLightning() + assertEquals(7_000uL, sut.balanceState.value.maxSendLightningSats) + release.complete(Unit) + sync.join() + assertEquals(7_000uL, sut.balanceState.value.maxSendLightningSats) + assertEquals(20_000uL, sut.balanceState.value.totalLightningSats) + sut.loadFromCache() + testScheduler.runCurrent() + assertEquals(7_000uL, sut.balanceState.value.maxSendLightningSats) + } + + @Test + fun `Spark readiness before cache hydration preserves stored wallet totals`() = test { + var cached = AppCacheData( + balance = BalanceState( + totalOnchainSats = 50_000uL, + totalLightningSats = 120_000uL, + sparkBalanceSats = 100_000uL, + sparkPendingDepositSats = 0uL, + ), + ) + val original = cached + whenever(cacheStore.data).thenReturn(flow { emit(cached) }) + whenever(cacheStore.cacheBalance(any())).doSuspendableAnswer { + cached = cached.copy(balance = it.getArgument(0)) + } + whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(true) + whenever(deriveBalanceStateUseCase.invoke()).thenReturn( + Result.failure(NodeNotRunningError("getBalancesAsync", NodeLifecycleState.Stopped)), + ) + val state = MutableStateFlow(SparkState(isReady = true, balanceSats = 5_000uL)) + sut = createSut(spark = sparkRepoWith(state)) + testScheduler.runCurrent() + assertEquals(original, cached) + assertEquals(SparkFees.sendableSats(5_000uL), sut.balanceState.value.maxSendLightningSats) + + sut.loadFromCache() + testScheduler.runCurrent() + assertEquals(25_000uL, sut.balanceState.value.totalLightningSats) + assertEquals(50_000uL, sut.balanceState.value.totalOnchainSats) + assertEquals(sut.balanceState.value, cached.balance) + } + + @Test + fun `balance refresh can run inside a recovery startup session`() = test { + val owner = connectionRecovery.captureOwner() + connectionRecovery.withOperation(owner) { + connectionRecovery.withStartup(owner) { sut.syncBalances() } + } + verify(cacheStore).cacheBalance(BalanceState()) + } + private fun sparkRepoWith(state: MutableStateFlow): SparkRepo = mock { on { this.state } doReturn state on { nodeEvents } doReturn MutableSharedFlow() diff --git a/app/src/test/java/to/bitkit/test/ConnectionRecoveryFixture.kt b/app/src/test/java/to/bitkit/test/ConnectionRecoveryFixture.kt index 5f8ccf5859..373b2ef185 100644 --- a/app/src/test/java/to/bitkit/test/ConnectionRecoveryFixture.kt +++ b/app/src/test/java/to/bitkit/test/ConnectionRecoveryFixture.kt @@ -7,6 +7,7 @@ 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.VssClientCoordinator import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.entities.ConfigEntity import to.bitkit.data.keychain.Keychain @@ -14,6 +15,7 @@ import to.bitkit.data.keychain.Keychain fun connectionRecoveryFixture( settings: SettingsStore, keychain: Keychain = mock(), + vssClients: VssClientCoordinator = VssClientCoordinator(), currentBinding: () -> String = { "test-wallet-binding" }, ): WalletConnectionRecovery { val db = mock() @@ -21,5 +23,5 @@ fun connectionRecoveryFixture( whenever(db.configDao().getAll()).thenReturn(MutableStateFlow(listOf(ConfigEntity()))) val binding = mock() whenever(binding.getBackupWalletBinding(any())).thenAnswer { currentBinding() } - return WalletConnectionRecovery(keychain, db, binding, settings) + return WalletConnectionRecovery(keychain, db, binding, settings, vssClients) } diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 158bb3669c..976068872a 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -2,6 +2,7 @@ package to.bitkit.ui import android.content.Context import app.cash.turbine.test +import com.synonym.bitkitcore.BoltzSwapEvent import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable @@ -31,7 +32,6 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -39,6 +39,7 @@ 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.BackupCategory import to.bitkit.models.BalanceState import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.BackupRepo @@ -143,6 +144,27 @@ class WalletViewModelTest : BaseUnitTest() { verify(boltzService).startUpdates(anyOrNull(), any(), anyOrNull()) } + @Test + fun `swap balance observation survives a retired wallet refresh`() = test { + val events = MutableSharedFlow() + whenever(boltzService.events).thenReturn(events) + whenever(boltzService.isSwapSupported).thenReturn(true) + whenever(boltzService.isSwapEnabled()).thenReturn(true) + var refreshes = 0 + whenever(walletRepo.syncBalances()).doSuspendableAnswer { + if (++refreshes == 1) throw CancellationException("Wallet retired") + } + sut.ensureSwapUpdatesRunning() + runCurrent() + + events.emit(BoltzSwapEvent.Claimed(swapId = "first", txid = "first-transaction")) + runCurrent() + events.emit(BoltzSwapEvent.Claimed(swapId = "second", txid = "second-transaction")) + runCurrent() + + assertEquals(2, refreshes) + } + @Test fun `ensureSwapUpdatesRunning should do nothing when swaps are unsupported`() = test { whenever(boltzService.isSwapSupported).thenReturn(false) @@ -941,11 +963,48 @@ class WalletViewModelTest : BaseUnitTest() { } @Test - fun `post restore profile failure allows proceeding only while safety markers stay clear`() = test { - whenever( - backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull()) - ).thenReturn(Result.success(emptySet())) - whenever(pubkyRepo.initialize()).doSuspendableAnswer { throw AppError("profile cache unavailable") } + fun `completed wallet restore retains failed categories without waiting for optional identity activation`() = test { + val release = CompletableDeferred() + val failedCategories = setOf(BackupCategory.SETTINGS, BackupCategory.WIDGETS) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())) + .thenReturn(Result.success(failedCategories)) + whenever(pubkyRepo.initialize()).doSuspendableAnswer { + release.await() + Result.success(Unit) + } + try { + sut.onBackupRestoreRetry() + runCurrent() + assertEquals(RestoreState.Completed(failedCategories), sut.restoreState.value) + verify(pubkyRepo).scheduleInitializationAfterRestore() + verify(pubkyRepo, never()).initialize() + verify(backupRepo).setRestorePending(false) + } finally { + release.complete(Unit) + } + } + + @Test + fun `most recent backup restore preserves failed categories while identity activates separately`() = test { + val failedCategories = setOf(BackupCategory.METADATA) + whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())) + .thenReturn(Result.success(failedCategories)) + whenever(pubkyRepo.initialize()).doSuspendableAnswer { throw AppError("identity unavailable") } + + sut.restoreWallet("mnemonic", "passphrase") + walletState.value = walletState.value.copy(walletExists = true) + advanceUntilIdle() + + assertEquals(RestoreState.Completed(failedCategories), sut.restoreState.value) + verify(pubkyRepo).scheduleInitializationAfterRestore() + verify(pubkyRepo, never()).initialize() + } + + @Test + fun `proceed rechecks safety markers after an optional restore failure`() = test { + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())) + .thenReturn(Result.failure(AppError("metadata cache unavailable"))) sut.onBackupRestoreRetry() advanceUntilIdle() assertEquals(RestoreState.BackupFailed(1, hasPendingWalletRestore = false), sut.restoreState.value) @@ -953,6 +1012,7 @@ class WalletViewModelTest : BaseUnitTest() { advanceUntilIdle() assertEquals(RestoreState.BackupFailed(2, hasPendingWalletRestore = false), sut.restoreState.value) assertEquals(null, sut.connectionStartupError.value) + verify(pubkyRepo, never()).scheduleInitializationAfterRestore() whenever(backupRepo.hasPendingWalletRestore()).thenReturn(true) var dismissed = false @@ -1135,7 +1195,7 @@ class WalletViewModelTest : BaseUnitTest() { walletState.value = walletState.value.copy(walletExists = true) advanceUntilIdle() - verifyBlocking(pubkyRepo) { initialize() } + verify(pubkyRepo).scheduleInitializationAfterRestore() } @Test @@ -1727,10 +1787,8 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `backup only retry with saved correction counts one retry`() = test { stubRecoveryRecord() - whenever( - backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull()) - ).thenReturn(Result.success(emptySet())) - whenever(pubkyRepo.initialize()).doSuspendableAnswer { throw AppError("profile cache unavailable") } + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())) + .thenReturn(Result.failure(AppError("restore completion unavailable"))) whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn(Result.success(Unit)) lightningState.value = lightningState.value.copy(nodeLifecycleState = NodeLifecycleState.Running) sut.onBackupRestoreRetry() @@ -1893,12 +1951,10 @@ class WalletViewModelTest : BaseUnitTest() { } @Test - fun `optional profile failure cannot replay a completed correction over later settings`() = test { + fun `restore completion failure cannot replay a completed correction over later settings`() = test { val stored = stubRecoveryRecord() - whenever( - backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull()) - ).thenReturn(Result.success(emptySet())) - whenever(pubkyRepo.initialize()).doSuspendableAnswer { throw AppError("profile unavailable") } + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())) + .thenReturn(Result.failure(AppError("restore completion unavailable"))) whenever(lightningRepo.restartNode(anyOrNull(), any())).thenReturn(Result.success(Unit)) whenever( lightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), any()), @@ -1925,10 +1981,8 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `proceed retains an unapplied correction until explicit retry`() = test { stubRecoveryRecord() - whenever( - backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull()) - ).thenReturn(Result.success(emptySet())) - whenever(pubkyRepo.initialize()).doSuspendableAnswer { throw AppError("profile unavailable") } + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())) + .thenReturn(Result.failure(AppError("restore completion unavailable"))) lightningState.value = lightningState.value.copy(nodeLifecycleState = NodeLifecycleState.Running) repeat(2) { sut.onBackupRestoreRetry() diff --git a/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt b/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt index eb65547d98..89c4b9630d 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt @@ -11,7 +11,9 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import to.bitkit.repositories.SparkState import to.bitkit.ui.navigateTo +import to.bitkit.viewmodels.SendMethod import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -19,6 +21,42 @@ import kotlin.test.assertTrue @Config(sdk = [34]) @RunWith(RobolectricTestRunner::class) class SendSheetNavigationTest { + @Test + fun `payment results stay visible after a stopped-node Spark payment reduces the balance`() { + val navController = NavHostController(ApplicationProvider.getApplicationContext()).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + graph = createGraph(startDestination = SendRoute.Confirm) { + composable {} + composable {} + composable {} + composable {} + } + } + var spark = SparkState(isSupported = true, isReady = true, balanceSats = 100_000uL) + fun waiting() = waitsForNode( + isNodeRunning = false, + hasAnyChannels = true, + hasUsableChannels = false, + sparkCanPay = sparkCanPayInvoice(SendMethod.LIGHTNING, 60_000uL, spark), + destination = navController.currentDestination, + ) + + assertFalse(waiting()) + spark = spark.copy(balanceSats = 39_758uL) + assertTrue(waiting()) + val results = listOf( + SendRoute.Pending(paymentHash = "payment", amount = 60_000, walletId = "wallet0"), + SendRoute.Success, + SendRoute.Error(), + ) + for (result in results) { + navController.navigateTo(result) + assertFalse(waiting()) + assertTrue(navController.popBackStack()) + assertTrue(waiting()) + } + } + @Test fun `recovery pin holds review only until returning to pending`() { val pending = SendRoute.Pending(paymentHash = "payment", amount = 1000, walletId = "wallet0") diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6b1f91892e..434240d31a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1,6 +1,7 @@ package to.bitkit.viewmodels import android.app.Activity +import android.app.Application import android.app.NotificationManager import android.content.ClipData import android.content.ClipboardManager @@ -142,6 +143,7 @@ import to.bitkit.models.PaykitAmount import to.bitkit.models.PaykitAsset import to.bitkit.models.PaykitRequestPricing import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.SamRockPaymentMethod import to.bitkit.models.SamRockSetupRequest import to.bitkit.models.SendFailureDetails @@ -174,6 +176,8 @@ import to.bitkit.repositories.OnchainSendEvidence import to.bitkit.repositories.OnchainSendNotDispatchedError import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PaykitAllowanceEvent +import to.bitkit.repositories.PaykitAllowanceExecutor +import to.bitkit.repositories.PaykitAllowanceLocalState import to.bitkit.repositories.PaykitAllowanceRepo import to.bitkit.repositories.PaykitBillingPeriod import to.bitkit.repositories.PaykitOnchainPaymentProofResolution @@ -266,7 +270,7 @@ import com.synonym.bitkitcore.Activity as BitkitActivity @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) @RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) +@Config(application = Application::class, sdk = [34]) class AppViewModelSendFlowTest : BaseUnitTest() { private lateinit var sut: AppViewModel @@ -3338,6 +3342,59 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitAllowanceRepo, never()).activate(anyOrNull(), any()) } + @Test + fun `off on during allowance admission activates the latest enabled state`() = test { + val sdk = mock() + val executor = mock() + whenever(executor.events).thenReturn(MutableSharedFlow()) + whenever(executor.localState(any())).thenReturn(PaykitAllowanceLocalState()) + whenever(executor.isLedgerBehind(any())).thenReturn(false) + whenever(sdk.listAllowances(any())).thenReturn(emptyList()) + val realAllowances = PaykitAllowanceRepo( + ioDispatcher = testDispatcher, + paykitSdkService = sdk, + executor = executor, + lightningRepo = lightningRepo, + activityRepo = activityRepo, + clock = Clock.System, + ) + val activationStarted = CompletableDeferred() + val finishActivation = CompletableDeferred() + var holdNextAdmission = true + whenever { paykitAllowanceRepo.activate(anyOrNull(), any()) }.doSuspendableAnswer { + val identity = it.getArgument(0) + val admission = it.getArgument Boolean>(1) + realAllowances.activate(identity) { + if (holdNextAdmission) { + holdNextAdmission = false + activationStarted.complete(Unit) + finishActivation.await() + } + admission() + } + } + whenever { paykitAllowanceRepo.pause(anyOrNull()) }.doSuspendableAnswer { + realAllowances.pause(it.getArgument(0)) + } + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + activationStarted.await() + + areAllowancesEnabled.value = false + runCurrent() + verify(executor).stopAdmitting() + areAllowancesEnabled.value = true + runCurrent() + verify(executor, never()).activate(any()) + + finishActivation.complete(Unit) + runCurrent() + + val identity = requireNotNull(PubkyPublicKeyFormat.normalized(testPublicKey)) + verify(executor).activate(identity) + verify(executor).recover(identity) + } + @Test fun `allowance admission rechecks deletion after the settings read`() = test { val settingsReadStarted = CompletableDeferred() diff --git a/changelog.d/next/1686.fixed.md b/changelog.d/next/1686.fixed.md new file mode 100644 index 0000000000..0bf584b967 --- /dev/null +++ b/changelog.d/next/1686.fixed.md @@ -0,0 +1 @@ +Wallet restore finishes without optional profile refresh waits, missing profiles retry after reconnecting without interrupting active setup, incomplete identity backups remain blocked, and allowances resume safely after re-enabling without reviving cancelled work, while backup uploads, retries and routing resets stay with their original wallet during identity changes, scheduled backups stay marked as running until they finish or stop, and pending Spark changes back up when restore finishes, cached balances remain visible during node startup, and Spending reflects Spark balance changes and payment results remain visible while the Lightning node is stopped. diff --git a/journeys/README.md b/journeys/README.md index 95effc36fd..84659a6c85 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -172,6 +172,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `send/prepared-fee-recovery.md` | Same four manual fault cases and file on both platforms; the recipe records original-send versus replacement fee retention and iOS Max/identity-flow adaptations. These faults are not journey capabilities. | | `settings/electrum-server-error-toasts.xml` | not ported — iOS still shows one generic message for every manual Electrum connect failure | | `onchain-receive/*` | port pending in synonymdev/bitkit-ios#588, which wires `onchainTransactionConfirmed` into the same received-sheet flow but carries no `journeys/` files. Two adaptations when it lands: iOS suppresses replayed historical receives with a `pendingRestoreActivitySeen` flag cleared by the first post-restore on-chain sync, not the one-hour block-timestamp guard used here, so a stale-confirmation step has to drive a restore instead of a clock; and iOS has no foreground-service path, so `confirmed-only-background-notification.xml` has no counterpart | +| `backup/scheduled-backup-status.xml` | Android-only status regression in the VSS ownership scheduler; iOS #1121 changed VSS admission without changing backup scheduling or its running flag. | | `backup/show-mnemonic-long-words.xml` | not ported — the long-word fit is an Android-only change (synonymdev/bitkit-android#633); whether iOS wraps long words at larger text sizes is unchecked | | `security/forgot-pin-nav-bar-inset.xml` | not ported — Android-only fix (synonymdev/bitkit-android#1572): the Android sheet host adds no navigation-bar inset, so each sheet screen pads itself. iOS sheets keep their content inside the bottom safe area | | `deeplinks/pubky-contact.xml` | ported — same contact routing and unlock behavior | diff --git a/journeys/allowances/allowances-turned-off.xml b/journeys/allowances/allowances-turned-off.xml index 59af61c2e7..12b8acabc7 100644 --- a/journeys/allowances/allowances-turned-off.xml +++ b/journeys/allowances/allowances-turned-off.xml @@ -2,7 +2,9 @@ Allowances can be turned off from Dev Settings. With them off the Subscriptions screen shows only the Overview and Payments tabs, and a request covered by an Active allowance is not paid - automatically: it opens as an ordinary Payment Request. Turning them back on shows the tab again. + automatically: it opens as an ordinary Payment Request. Turning them back on restores both the tab + and its Active allowance row. On Android, repeat the off/on sequence while contact loading is + delayed, then let it finish: the latest enabled state must restore the row without restarting the app. Launch the E2E Bitkit app on both instances with an Active allowance, per set-and-accept.xml diff --git a/journeys/backup-restore/connection-recovery.xml b/journeys/backup-restore/connection-recovery.xml index 4bf9e8817a..e8472f1155 100644 --- a/journeys/backup-restore/connection-recovery.xml +++ b/journeys/backup-restore/connection-recovery.xml @@ -20,6 +20,6 @@ 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 + Repeat with only optional profile enrichment unavailable after required recovery; verify ordinary wallet actions remain available and no failed-node-startup or restore-retry screen appears diff --git a/journeys/backup/scheduled-backup-status.xml b/journeys/backup/scheduled-backup-status.xml new file mode 100644 index 0000000000..a9eb251f13 --- /dev/null +++ b/journeys/backup/scheduled-backup-status.xml @@ -0,0 +1,16 @@ + + + On an unfunded disposable dev wallet with working staging VSS and Electrum, verify + that a queued backup shows Running during its five-second debounce and becomes + Latest Backup after acknowledgement. Start at Home in English. No profile, + payment, recovery-phrase access or wallet reset is required. Android-only regression: + the iOS VSS ownership change did not alter backup scheduling or its running status. + + + Open Settings, select the General tab, and open Local Currency (testTag "CurrenciesSettings") + Choose a different local currency, then immediately open bitkit://screen/backup-settings + Verify that the Settings row shows Running and does not show Retry while its backup is queued + Wait for the Settings row to show Latest Backup after the VSS acknowledgement + Verify that Settings has no Failed Backup message or Retry button + + diff --git a/journeys/wallet/restore-during-identity-outage.xml b/journeys/wallet/restore-during-identity-outage.xml new file mode 100644 index 0000000000..e549f8d36a --- /dev/null +++ b/journeys/wallet/restore-during-identity-outage.xml @@ -0,0 +1,19 @@ + + + Use a disposable test-network wallet with a complete VSS backup and a saved Pubky identity. + Required wallet, private reservation and USDT recovery imports must succeed before the injected + delay starts. Delay only final identity activation or subsequent profile/contact reads. + Keep metadata credential replacement and required restore imports available. These complete-app + fault controls are not currently provisioned; component test results are separate evidence. + Never use live funds or reset an existing wallet. + + + Restore the complete backup and hold final identity activation; verify ordinary wallet access resumes after required recovery completes, without reporting Paykit authenticated or payment-ready + Trigger a full backup while the saved identity cannot be exported; verify failure retains the previous complete WALLET record and required pending backup work + Close the restore screen, release the identity fault and verify retained work completes and a fresh complete backup succeeds once + Fail the restored identity's own public profile read, then reconnect or resume while staying on Home; verify wallet maintenance continues while the read is held, and the saved profile replaces the placeholder after release without opening Profile or re-importing the active session + Repeat with delayed profile/contact enrichment, then replace or remove the identity; verify late results cannot republish the old identity or overwrite current profile data + Restart during the outage; verify saved credentials resume recovery without duplicating a payment or contact mutation + Fail a required wallet or USDT restore instead; verify spending and address allocation remain gated and retry resumes the same required recovery + +