From e5c0e553eb0df176647efdf3362918ef7dc9ca17 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 8 Oct 2026 13:04:28 -0500 Subject: [PATCH 1/8] fix: prioritize explicit contact linking --- .../bitkit/repositories/PrivatePaykitRepo.kt | 303 +++++++++++------ .../screens/contacts/AddContactViewModel.kt | 1 + .../repositories/PrivatePaykitRepoTest.kt | 307 +++++++++++++++++- .../contacts/AddContactViewModelTest.kt | 2 + changelog.d/next/1419.fixed.md | 1 + 5 files changed, 510 insertions(+), 104 deletions(-) create mode 100644 changelog.d/next/1419.fixed.md diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index f9fd56f0e2..4648c1b1de 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -12,9 +12,11 @@ import com.synonym.paykit.PrivatePaymentResolutionState import com.synonym.paykit.PrivatePaymentResolutionStatus import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive @@ -27,6 +29,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import org.lightningdevkit.ldknode.PaymentDirection @@ -54,6 +57,8 @@ import java.time.Instant import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext import kotlin.time.Clock import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes @@ -74,6 +79,7 @@ class PrivatePaykitRepo @Inject constructor( private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, private val publicPaykitRepo: PublicPaykitRepo, + private val paymentRequestRepo: PaykitPaymentRequestRepo, private val coreService: CoreService, private val clock: Clock, ) { @@ -84,6 +90,9 @@ class PrivatePaykitRepo @Inject constructor( private val invoiceRefreshBuffer = 30.minutes private val unavailableLinkRetryDelay = 5.minutes + /** Admission window for Interactive retries after explicit contact linking. */ + private val explicitLinkRetryWindow = 20.seconds + // Private links can finish after a contact is added on the other device; // keep draining long enough for staggered mutual adds. private val privateMessageDrainRetryDelays = listOf( @@ -109,17 +118,28 @@ class PrivatePaykitRepo @Inject constructor( private val knownSavedContactKeys = mutableSetOf() private val pendingPreparationKeys = mutableSetOf() private var activePreparationKeys = emptySet() - private val activeLinkPreparationKeys = mutableSetOf() + private val activeLinkPreparations = mutableMapOf>() private var preparationJob: Job? = null private var preparationGeneration = 0 private var isDeletingProfile = false private var pendingForceRefreshLightning = false private val unavailableLinkRetryAt = mutableMapOf() private var state: PrivatePaykitState? = null - private val pendingMessageDrainRetryLock = Any() - private val pendingMessageDrainRetryKeys = mutableSetOf() - private var pendingMessageDrainRetryJob: Job? = null - private var pendingMessageDrainRetryGeneration = 0 + private val pendingMessageDrainRetries = mutableMapOf() + + private class PrivateMessageDrainRetry( + val publicKey: String, + val identity: String, + val generation: Int, + ) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key + + val wake = Channel(Channel.CONFLATED) + var job: Job? = null + var interactiveUntil: KotlinInstant? = null + var prepareEndpoints = false + var refreshReadiness = false + } private data class PrivateLinkPreparation( val publicKeys: List, @@ -208,7 +228,25 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun awaitContactPreparationActive(priority: Priority) { - if (priority == Priority.Background) isContactPreparationActive.first { it } + if (priority != Priority.Ordered) isContactPreparationActive.first { it } + currentCoroutineContext()[PrivateMessageDrainRetry]?.let { + val isRemovedExplicitContact = it.interactiveUntil != null && it.publicKey !in knownSavedContactKeys + if (it.generation != preparationGeneration || pendingMessageDrainRetries[it.publicKey] !== it || + isRemovedExplicitContact + ) { + throw CancellationException("Contact preparation invalidated") + } + } + } + + private suspend fun contactPreparationPriority(priority: Priority): Priority { + awaitContactPreparationActive(priority) + val retry = currentCoroutineContext()[PrivateMessageDrainRetry] ?: return priority + return if (retry.interactiveUntil?.let { it > clock.now() } == true) { + Priority.Interactive + } else { + Priority.Background + } } private fun scheduleContactPreparation(publicKeys: Collection, forceRefreshLightning: Boolean = false) { @@ -258,7 +296,7 @@ class PrivatePaykitRepo @Inject constructor( pendingPreparationKeys.clear() activePreparationKeys = emptySet() pendingForceRefreshLightning = false - activeLinkPreparationKeys.clear() + activeLinkPreparations.clear() clearPendingMessageDrainRetries() } @@ -278,13 +316,9 @@ class PrivatePaykitRepo @Inject constructor( withContext(serializedDispatcher) { runSuspendCatching { val normalizedKey = normalizedPublicKey(publicKey) ?: return@runSuspendCatching + if (isDeletingProfile || isContactSharingCleanupPending()) return@runSuspendCatching rememberSavedContacts(savedPublicKeys + normalizedKey, replacing = false) - val keys = listOf(normalizedKey) - if (!canPublishPrivateEndpoints()) { - prepareRelevantPrivateLinksIfAvailable(keys, "refresh") - return@runSuspendCatching - } - publishLocalEndpoints(keys, reason = "refresh").getOrThrow() + schedulePendingPrivateMessageDrainRetries("refresh", listOf(normalizedKey), explicit = true) } } @@ -339,6 +373,7 @@ class PrivatePaykitRepo @Inject constructor( if (keys.isEmpty()) return@runSuspendCatching knownSavedContactKeys.removeAll(keys) pendingPreparationKeys.removeAll(keys) + keys.forEach { pendingMessageDrainRetries.remove(it)?.job?.cancel() } keys.forEach(unavailableLinkRetryAt::remove) if (!isDeletingProfile) { removePublishedEndpoints(keys).onFailure { @@ -763,7 +798,7 @@ class PrivatePaykitRepo @Inject constructor( } } - private fun unresolvedPrivateLinkResult( + private suspend fun unresolvedPrivateLinkResult( publicKey: String, paymentRequest: PaykitPaymentRequest?, resolution: PaykitPrivateContactPaymentResolution, @@ -778,7 +813,7 @@ class PrivatePaykitRepo @Inject constructor( else -> null } - private fun privateLinkPendingResult( + private suspend fun privateLinkPendingResult( publicKey: String, ): PublicPaykitPaymentResult { schedulePendingPrivateMessageDrainRetries( @@ -832,8 +867,9 @@ class PrivatePaykitRepo @Inject constructor( val keys = publicKeys.mapNotNull { normalizedPublicKey(it) }.distinct() if (keys.isEmpty()) return@runSuspendCatching val generation = preparationGeneration - val identity = pubkyService.currentPublicKey() ?: throw PublicPaykitError.SessionNotActive - val preparation = preparePrivateLinks( + val identity = currentCoroutineContext()[PrivateMessageDrainRetry]?.identity + ?: pubkyService.currentPublicKey() ?: throw PublicPaykitError.SessionNotActive + val links = preparePrivateLinks( publicKeys = keys, reason = reason, generation = generation, @@ -854,14 +890,14 @@ class PrivatePaykitRepo @Inject constructor( } val publication = preparePrivatePaymentListReservations( - preparation.publicKeys, + links.publicKeys, reason, forceRefreshLightning, generation, ) if (!isCurrentPublication(generation, identity, requireImmediatePublication)) return@withLock if (publication.updates.isEmpty()) { - schedulePendingPrivateMessageDrainRetries(reason, preparation.linkRetryKeys) + schedulePendingPrivateMessageDrainRetries(reason, links.linkRetryKeys, identity = identity) if (requireImmediatePublication) publication.firstError?.let { throw it } return@withLock } @@ -872,8 +908,8 @@ class PrivatePaykitRepo @Inject constructor( ) val deliveryError = applyPrivatePaymentListDeliveryReport(report, reason) val firstError = publication.firstError ?: deliveryError - val retryKeys = (preparation.linkRetryKeys + privatePaymentListDeliveryRetryKeys(report)).distinct() - schedulePendingPrivateMessageDrainRetries(reason, retryKeys) + val retryKeys = (links.linkRetryKeys + privatePaymentListDeliveryRetryKeys(report)).distinct() + schedulePendingPrivateMessageDrainRetries(reason, retryKeys, identity = identity) if (firstError != null) { if (requireImmediatePublication) throw firstError @@ -885,7 +921,7 @@ class PrivatePaykitRepo @Inject constructor( } } }.onFailure { - schedulePreparedLinkRetries(preparation.linkRetryKeys, reason, generation, identity) + schedulePreparedLinkRetries(links.linkRetryKeys, reason, generation, identity) }.getOrThrow() } } @@ -901,7 +937,11 @@ class PrivatePaykitRepo @Inject constructor( val currentIdentity = pubkyService.currentPublicKey() currentCoroutineContext().ensureActive() if (generation == preparationGeneration && currentIdentity == identity && !cleanupPending) { - schedulePendingPrivateMessageDrainRetries(reason, keys.filter { it in knownSavedContactKeys }) + schedulePendingPrivateMessageDrainRetries( + reason, + keys.filter { it in knownSavedContactKeys }, + identity = identity + ) } } @@ -950,7 +990,8 @@ class PrivatePaykitRepo @Inject constructor( val linkRetryKeys = mutableListOf() awaitContactPreparationActive(priority) if (generation != preparationGeneration) return PrivateLinkPreparation(emptyList(), emptyList()) - val peerStates = paykitSdkService.linkedPeers(priority).associate { it.counterparty to it.state } + val peerStates = paykitSdkService.linkedPeers(contactPreparationPriority(priority)) + .associate { it.counterparty to it.state } for (publicKey in publicKeys.distinct()) { awaitContactPreparationActive(priority) if (generation != preparationGeneration) break @@ -990,7 +1031,8 @@ class PrivatePaykitRepo @Inject constructor( ): Boolean = when (error) { is PaykitException.NotFound -> true is PaykitException.Transport -> runSuspendCatching { - val state = paykitSdkService.linkedPeers(priority).firstOrNull { it.counterparty == publicKey }?.state + val state = paykitSdkService.linkedPeers(contactPreparationPriority(priority)) + .firstOrNull { it.counterparty == publicKey }?.state state == null || state == LinkedPeerState.NOT_LINKED }.getOrDefault(false) else -> false @@ -1001,12 +1043,24 @@ class PrivatePaykitRepo @Inject constructor( priority: Priority = Priority.Ordered, ): LinkedPeerState? { currentCoroutineContext().ensureActive() - if (!activeLinkPreparationKeys.add(publicKey)) return null + val admittedPriority = contactPreparationPriority(priority) + activeLinkPreparations[publicKey]?.let { + val isExplicitRetry = currentCoroutineContext()[PrivateMessageDrainRetry]?.interactiveUntil != null + return if (isExplicitRetry) it.await() else null + } + val completion = CompletableDeferred() + activeLinkPreparations[publicKey] = completion val generation = preparationGeneration return try { - paykitSdkService.ensureLinkWithPeer(publicKey, priority = priority).state + runSuspendCatching { + paykitSdkService.ensureLinkWithPeer(publicKey, priority = admittedPriority).state + } + .onSuccess { completion.complete(it) } + .onFailure { completion.completeExceptionally(it) } + .getOrThrow() } finally { - if (generation == preparationGeneration) activeLinkPreparationKeys.remove(publicKey) + if (!completion.isCompleted) completion.cancel() + if (generation == preparationGeneration) activeLinkPreparations.remove(publicKey) } } @@ -1032,9 +1086,7 @@ class PrivatePaykitRepo @Inject constructor( retryKeys: Collection, priority: Priority = Priority.Ordered, ) { - val newKeys = synchronized(pendingMessageDrainRetryLock) { - retryKeys.toSet() - pendingMessageDrainRetryKeys - } + val newKeys = retryKeys.toSet() - pendingMessageDrainRetries.keys val pendingKeys = pendingPrivateMessageDrainKeys(newKeys, retryMissingPeers = true, priority = priority) if (pendingKeys.isEmpty()) return @@ -1138,46 +1190,53 @@ class PrivatePaykitRepo @Inject constructor( retryKeys: Collection, includeUnsavedPeers: Boolean = false, priority: Priority = Priority.Ordered, - ) { + ): Set { val retryKeys = retryKeys.mapNotNull(::normalizedPublicKey).toSet() currentCoroutineContext().ensureActive() - if (retryKeys.isEmpty()) return - runSuspendCatching { + if (retryKeys.isEmpty()) return emptySet() + return runSuspendCatching { val generation = preparationGeneration advancePendingPrivateLinks(retryKeys, includeUnsavedPeers, priority, generation, reason) awaitContactPreparationActive(priority) currentCoroutineContext().ensureActive() - if (generation != preparationGeneration) return@runSuspendCatching - val pendingKeys = paykitSdkService.pendingOutboundPrivateCounterparties(priority) + if (generation != preparationGeneration) return@runSuspendCatching emptySet() + val pendingKeys = paykitSdkService.pendingOutboundPrivateCounterparties( + contactPreparationPriority(priority) + ) .mapNotNull(::normalizedPublicKey).toSet().intersect(retryKeys) pendingKeys.forEach { publicKey -> awaitContactPreparationActive(priority) currentCoroutineContext().ensureActive() - if (generation != preparationGeneration) return@runSuspendCatching + if (generation != preparationGeneration) return@runSuspendCatching emptySet() runSuspendCatching { - paykitSdkService.processOutboundPrivateMessages(publicKey, priority) + paykitSdkService.processOutboundPrivateMessages(publicKey, contactPreparationPriority(priority)) }.onFailure { Logger.warn("Failed to send private Paykit messages during '$reason'", it, context = TAG) } } awaitContactPreparationActive(priority) currentCoroutineContext().ensureActive() - if (generation != preparationGeneration) return@runSuspendCatching - val linkedKeys = paykitSdkService.linkedPeers(priority).filter { it.state == LinkedPeerState.LINKED } + if (generation != preparationGeneration) return@runSuspendCatching emptySet() + val linkedKeys = paykitSdkService.linkedPeers(contactPreparationPriority(priority)) + .filter { it.state == LinkedPeerState.LINKED } .mapNotNull { normalizedPublicKey(it.counterparty) }.toSet().intersect(retryKeys) + val receivedKeys = mutableSetOf() linkedKeys.forEach { publicKey -> awaitContactPreparationActive(priority) currentCoroutineContext().ensureActive() - if (generation != preparationGeneration) return@runSuspendCatching + if (generation != preparationGeneration) return@runSuspendCatching emptySet() runSuspendCatching { - paykitSdkService.receivePrivateMessages(publicKey, priority) + paykitSdkService.receivePrivateMessages(publicKey, contactPreparationPriority(priority)) + }.onSuccess { + receivedKeys += publicKey }.onFailure { Logger.warn("Failed to receive private Paykit messages during '$reason'", it, context = TAG) } } + receivedKeys }.onFailure { Logger.warn("Failed to process pending private Paykit messages during '$reason'", it, context = TAG) - } + }.getOrDefault(emptySet()) } private suspend fun advancePendingPrivateLinks( @@ -1189,7 +1248,8 @@ class PrivatePaykitRepo @Inject constructor( ) { awaitContactPreparationActive(priority) if (generation != preparationGeneration) return - val alreadyLinkedKeys = paykitSdkService.linkedPeers(priority).filter { it.state == LinkedPeerState.LINKED } + val alreadyLinkedKeys = paykitSdkService.linkedPeers(contactPreparationPriority(priority)) + .filter { it.state == LinkedPeerState.LINKED } .mapNotNull { normalizedPublicKey(it.counterparty) }.toSet() (retryKeys - alreadyLinkedKeys).forEach { retryKey -> awaitContactPreparationActive(priority) @@ -1209,82 +1269,123 @@ class PrivatePaykitRepo @Inject constructor( } } - private fun schedulePendingPrivateMessageDrainRetries( + private suspend fun schedulePendingPrivateMessageDrainRetries( reason: String, retryKeys: Collection, + explicit: Boolean = false, + identity: String? = null, ) { - val retryKeys = retryKeys.toSet() - if (retryKeys.isEmpty()) return - - synchronized(pendingMessageDrainRetryLock) { - val hasActiveRetry = - pendingMessageDrainRetryJob?.isActive == true && pendingMessageDrainRetryKeys.isNotEmpty() - pendingMessageDrainRetryKeys.addAll(retryKeys) - if (hasActiveRetry) return - pendingMessageDrainRetryGeneration += 1 - val retryGeneration = pendingMessageDrainRetryGeneration - pendingMessageDrainRetryJob?.cancel() - - pendingMessageDrainRetryJob = retryScope.launch { - var retryIndex = 0 - while (true) { - val retryDelay = privateMessageDrainRetryDelays[ - retryIndex.coerceAtMost(privateMessageDrainRetryDelays.lastIndex), - ] - delay(retryDelay) - drainPendingPrivateMessageRetryKeys("$reason retry") - if (!hasPendingMessageDrainRetryKeys(retryGeneration)) break - retryIndex += 1 + if (retryKeys.isEmpty() || isDeletingProfile) return + if (!explicit && retryKeys.all { it in pendingMessageDrainRetries }) return + val generation = preparationGeneration + val identity = identity ?: pubkyService.currentPublicKey() ?: return + if (generation != preparationGeneration) return + for (publicKey in retryKeys.distinct()) { + val retry = pendingMessageDrainRetries[publicKey]?.takeIf { + PubkyPublicKeyFormat.matches(it.identity, identity) + } + ?: PrivateMessageDrainRetry(publicKey, identity, generation).also { + pendingMessageDrainRetries.put(publicKey, it)?.job?.cancel() + } + if (explicit) promoteExplicitLinkRetry(retry) + if (retry.job == null) { + retry.job = retryScope.launch(retry) { + try { + runPrivateMessageDrainRetries(retry, reason) + } finally { + if (pendingMessageDrainRetries[publicKey] === retry) { + pendingMessageDrainRetries.remove(publicKey) + } + } } - finishPendingMessageDrainRetries(retryGeneration) } } } - private suspend fun drainPendingPrivateMessageRetryKeys(reason: String) = withContext(serializedDispatcher) { - val retryKeys = synchronized(pendingMessageDrainRetryLock) { - pendingMessageDrainRetryKeys.toList() - } - if (retryKeys.isEmpty()) return@withContext - val pendingKeys = pendingPrivateMessageDrainKeys(retryKeys, priority = Priority.Background) - if (pendingKeys.isNotEmpty()) { - drainPendingPrivateMessages(reason, retryKeys = pendingKeys, priority = Priority.Background) + private fun promoteExplicitLinkRetry(retry: PrivateMessageDrainRetry) { + if (retry.interactiveUntil?.let { it > clock.now() } == true) return + retry.interactiveUntil = clock.now() + explicitLinkRetryWindow + retry.prepareEndpoints = retry.publicKey !in activePreparationKeys + retry.refreshReadiness = true + unavailableLinkRetryAt.remove(retry.publicKey) + retry.wake.trySend(Unit) + } + + private suspend fun runPrivateMessageDrainRetries(retry: PrivateMessageDrainRetry, reason: String) { + var retryIndex = 0 + while (true) { + val retryDelay = privateMessageDrainRetryDelays[retryIndex] + val wasWoken = withTimeoutOrNull(retryDelay) { + retry.wake.receive() + true + } == true + if (wasWoken) retryIndex = 0 + awaitContactPreparationActive(Priority.Background) + val priority = if (retry.interactiveUntil?.let { it > clock.now() } == true) { + Priority.Interactive + } else { + retry.refreshReadiness = false + retry.prepareEndpoints = false + Priority.Background + } + val keepRetrying = runSuspendCatching { drainPrivateMessageRetry(retry, reason, priority) } + .onFailure { Logger.warn("Failed to retry private Paykit messages", it, context = TAG) } + .getOrDefault(true) + if (!keepRetrying && !retry.prepareEndpoints) return + if (!wasWoken) retryIndex = (retryIndex + 1).coerceAtMost(privateMessageDrainRetryDelays.lastIndex) } - updatePendingMessageDrainRetryKeys(retryKeys) } - private fun hasPendingMessageDrainRetryKeys(generation: Int): Boolean = - synchronized(pendingMessageDrainRetryLock) { - generation == pendingMessageDrainRetryGeneration && pendingMessageDrainRetryKeys.isNotEmpty() + private suspend fun drainPrivateMessageRetry( + retry: PrivateMessageDrainRetry, + reason: String, + priority: Priority, + ): Boolean { + val status = paykitSdkService.identityStatus(contactPreparationPriority(priority)) + if (!PubkyPublicKeyFormat.matches(status?.publicKey, retry.identity) || + status?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + ) { + return false } - - private fun finishPendingMessageDrainRetries(generation: Int) { - synchronized(pendingMessageDrainRetryLock) { - if (generation != pendingMessageDrainRetryGeneration) return - pendingMessageDrainRetryJob = null - pendingMessageDrainRetryKeys.clear() + awaitContactPreparationActive(priority) + val keys = listOf(retry.publicKey) + if (retry.prepareEndpoints) { + retry.prepareEndpoints = false + if (canPublishPrivateEndpoints(status)) { + publishLocalEndpoints(keys, reason, priority = priority).getOrThrow() + return true + } } - } - - private suspend fun updatePendingMessageDrainRetryKeys(retryKeys: Collection) { - val remainingKeys = pendingPrivateMessageDrainKeys(retryKeys, priority = Priority.Background) - synchronized(pendingMessageDrainRetryLock) { - pendingMessageDrainRetryKeys.removeAll(retryKeys.toSet()) - pendingMessageDrainRetryKeys.addAll(remainingKeys) + val pendingKeys = pendingPrivateMessageDrainKeys( + keys, + retryMissingPeers = true, + priority = priority, + receiveLinkedPeers = retry.refreshReadiness, + ) + val receivedKeys = drainPendingPrivateMessages(reason, retryKeys = pendingKeys, priority = priority) + awaitContactPreparationActive(priority) + if (retry.refreshReadiness && retry.publicKey in receivedKeys && + contactPreparationPriority(priority) == Priority.Interactive + ) { + paymentRequestRepo.refreshEligibleTarget(retry.publicKey) + .onSuccess { if (it.isComplete) retry.refreshReadiness = false } } + val readinessPending = retry.refreshReadiness && retry.interactiveUntil?.let { it > clock.now() } == true + return readinessPending || pendingPrivateMessageDrainKeys(keys, priority = priority).isNotEmpty() } private suspend fun pendingPrivateMessageDrainKeys( retryKeys: Collection, retryMissingPeers: Boolean = false, priority: Priority = Priority.Ordered, + receiveLinkedPeers: Boolean = false, ): Set { val retryKeys = retryKeys.toSet() val generation = preparationGeneration if (retryKeys.isNotEmpty()) awaitContactPreparationActive(priority) if (retryKeys.isEmpty() || generation != preparationGeneration) return emptySet() - val linkedPeers = runSuspendCatching { paykitSdkService.linkedPeers(priority) } + val linkedPeers = runSuspendCatching { paykitSdkService.linkedPeers(contactPreparationPriority(priority)) } .getOrElse { Logger.warn("Failed to inspect private Paykit link state", it, context = TAG) return retryKeys @@ -1297,7 +1398,9 @@ class PrivatePaykitRepo @Inject constructor( .toMap() awaitContactPreparationActive(priority) if (generation != preparationGeneration) return emptySet() - val pendingOutbound = runSuspendCatching { paykitSdkService.pendingOutboundPrivateCounterparties(priority) } + val pendingOutbound = runSuspendCatching { + paykitSdkService.pendingOutboundPrivateCounterparties(contactPreparationPriority(priority)) + } .getOrElse { Logger.warn("Failed to inspect pending private Paykit messages", it, context = TAG) return retryKeys @@ -1307,7 +1410,7 @@ class PrivatePaykitRepo @Inject constructor( return retryKeys.filterTo(mutableSetOf()) { retryKey -> when (linkedPeers[retryKey]) { - LinkedPeerState.LINKED -> retryKey in pendingOutbound + LinkedPeerState.LINKED -> receiveLinkedPeers || retryKey in pendingOutbound null -> retryMissingPeers || retryKey in pendingOutbound LinkedPeerState.BLOCKED, LinkedPeerState.UNKNOWN -> false else -> true @@ -1316,12 +1419,8 @@ class PrivatePaykitRepo @Inject constructor( } private fun clearPendingMessageDrainRetries() { - synchronized(pendingMessageDrainRetryLock) { - pendingMessageDrainRetryJob?.cancel() - pendingMessageDrainRetryJob = null - pendingMessageDrainRetryKeys.clear() - pendingMessageDrainRetryGeneration += 1 - } + pendingMessageDrainRetries.values.forEach { it.job?.cancel() } + pendingMessageDrainRetries.clear() } private suspend fun recordPublishedPrivatePaymentListCache(publicKey: String) { diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt index 9ecf84fd70..d4818aafbb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt @@ -158,6 +158,7 @@ class AddContactViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true) } pubkyRepo.addContact(profile.publicKey, profile) .onSuccess { + refreshContactPaykitLink(profile.publicKey) _effects.emit(AddContactEffect.ContactSaved(profile.publicKey)) } .onFailure { diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 9420dcf787..875d0198e9 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -23,6 +23,7 @@ import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.CancellationException 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 @@ -30,6 +31,7 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.withContext import org.junit.After import org.junit.Before import org.junit.Test @@ -71,6 +73,7 @@ import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -97,6 +100,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val lightningRepo = mock() private val walletRepo = mock() private val publicPaykitRepo = mock() + private val paymentRequestRepo = mock() private val coreService = mock() private val clock = mock() @@ -143,8 +147,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever { paykitSdkService.ensureLinkWithPeer(any(), any(), eq(Priority.Ordered)) }.thenAnswer { LinkedPeerHandshakeReport(it.getArgument(0), LinkedPeerState.LINKED, 1uL, null) } - whenever(paykitSdkService.ensureLinkWithPeer(any(), any(), eq(Priority.Background))) - .doSuspendableAnswer { paykitSdkService.ensureLinkWithPeer(it.getArgument(0)) } + stubContactPreparationPriorities() whenever { paykitSdkService.pendingOutboundPrivateCounterparties() }.thenReturn(emptyList()) whenever(paykitSdkService.linkedPeers(any())).doSuspendableAnswer { paykitSdkService.linkedPeers() } whenever(paykitSdkService.pendingOutboundPrivateCounterparties(any())).doSuspendableAnswer { @@ -163,6 +166,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:bcrt1qpublic"))) whenever { publicPaykitRepo.payableEndpoints(any()) }.thenAnswer { it.getArgument>(0) } whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) }.thenReturn(Result.success(Unit)) + whenever { paymentRequestRepo.refreshEligibleTarget(any()) } + .thenReturn(Result.success(PaykitPaymentRequestTargetCheck(null, isComplete = true))) whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) PublicPaykitRepo.lightningRouteHintsValidator = { true } @@ -381,6 +386,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(sut.prepareSavedContacts(listOf(CONTACT_KEY)).isFailure) advanceTimeBy(1_000) runCurrent() + linkPrepared = false + advanceTimeBy(3_000) + runCurrent() verify(paykitSdkService, times(2)).ensureLinkWithPeer(CONTACT_KEY) } finally { sut.closeAndClear() @@ -406,6 +414,292 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() } + @Test + fun `new peer retries independently of a peer at maximum backoff`() = test { + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(CONTACT_KEY, OTHER_CONTACT_KEY).map { linkedPeer(it, LinkedPeerState.LINKING) }, + ) + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + advanceTimeBy(167_000) + runCurrent() + clearInvocations(paykitSdkService) + + sut.prepareSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)).getOrThrow() + verify(paykitSdkService).ensureLinkWithPeer(OTHER_CONTACT_KEY) + advanceTimeBy(1_000) + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(OTHER_CONTACT_KEY, priority = Priority.Background) + verify(paykitSdkService, never()).ensureLinkWithPeer(eq(CONTACT_KEY), any(), any()) + + advanceTimeBy(89_000) + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY, priority = Priority.Background) + sut.closeAndClear() + } + + @Test + fun `explicit peer wakes promptly and duplicate intent does not extend interactive retries`() = test { + whenever(clock.now()).thenAnswer { + Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds + } + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(CONTACT_KEY, OTHER_CONTACT_KEY).map { linkedPeer(it, LinkedPeerState.LINKING) }, + ) + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + advanceTimeBy(167_000) + runCurrent() + clearInvocations(paykitSdkService) + + sut.refreshSavedContactEndpoints(OTHER_CONTACT_KEY, listOf(CONTACT_KEY, OTHER_CONTACT_KEY)).getOrThrow() + runCurrent() + advanceTimeBy(12_000) + runCurrent() + sut.refreshSavedContactEndpoints(OTHER_CONTACT_KEY, listOf(CONTACT_KEY, OTHER_CONTACT_KEY)).getOrThrow() + runCurrent() + verify(paykitSdkService, times(4)).ensureLinkWithPeer(OTHER_CONTACT_KEY, 1u, Priority.Interactive) + verify(paykitSdkService, never()).ensureLinkWithPeer(eq(CONTACT_KEY), any(), any()) + + advanceTimeBy(20_000) + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(OTHER_CONTACT_KEY, 1u, Priority.Background) + verify(paykitSdkService, times(4)).ensureLinkWithPeer(OTHER_CONTACT_KEY, 1u, Priority.Interactive) + verify(paykitSdkService, never()).ensureLinkWithPeer(eq(CONTACT_KEY), any(), any()) + sut.closeAndClear() + } + + @Test + fun `waking an explicit peer does not cancel another admitted advance`() = test { + val completion = CompletableDeferred() + var finished = false + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(CONTACT_KEY, OTHER_CONTACT_KEY).map { linkedPeer(it, LinkedPeerState.LINKING) }, + ) + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).doSuspendableAnswer { + completion.await() + finished = true + LinkedPeerHandshakeReport(CONTACT_KEY, LinkedPeerState.LINKING, 1uL, null) + } + advanceTimeBy(1_000) + runCurrent() + + sut.refreshSavedContactEndpoints(OTHER_CONTACT_KEY, listOf(CONTACT_KEY, OTHER_CONTACT_KEY)).getOrThrow() + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(OTHER_CONTACT_KEY, 1u, Priority.Interactive) + assertFalse(finished) + completion.complete(Unit) + runCurrent() + assertTrue(finished) + sut.closeAndClear() + } + + @Test + fun `explicit refresh joins admitted maintenance and refreshes only that linked target`() = test { + val completion = CompletableDeferred() + var peerState = LinkedPeerState.LINKING + whenever(paykitSdkService.linkedPeers()).thenAnswer { listOf(linkedPeer(CONTACT_KEY, peerState)) } + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).doSuspendableAnswer { + completion.await() + peerState = LinkedPeerState.LINKED + LinkedPeerHandshakeReport(CONTACT_KEY, peerState, 1uL, null) + } + sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + repeat(2) { sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() } + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + + completion.complete(Unit) + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + verify(paymentRequestRepo).refreshEligibleTarget(CONTACT_KEY) + verify(paymentRequestRepo, never()).refreshEligibleTargets(any(), any()) + verify(publicPaykitRepo, never()).beginPayment(any()) + advanceTimeBy(120_000) + runCurrent() + verify(paymentRequestRepo).refreshEligibleTarget(CONTACT_KEY) + sut.closeAndClear() + } + + @Test + fun `explicit linked readiness retries transient intake failure without pending outbound`() = test { + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) + whenever(paykitSdkService.receivePrivateMessages(CONTACT_KEY)) + .thenAnswer { throw PaykitException.Transport("offline", "Unavailable homeserver") } + .thenReturn(mock()) + + try { + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + verifyNoInteractions(paymentRequestRepo) + advanceTimeBy(1_000) + runCurrent() + + verify(paykitSdkService, times(2)).receivePrivateMessages(CONTACT_KEY, Priority.Interactive) + verify(paymentRequestRepo).refreshEligibleTarget(CONTACT_KEY) + advanceTimeBy(120_000) + runCurrent() + verify(paymentRequestRepo).refreshEligibleTarget(CONTACT_KEY) + } finally { + sut.closeAndClear() + } + } + + @Test + fun `explicit readiness retries failed or incomplete discovery only within active window`() = test { + whenever(clock.now()).thenAnswer { + Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds + } + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) + whenever(paymentRequestRepo.refreshEligibleTarget(CONTACT_KEY)).thenReturn( + Result.failure(AppError("Discovery unavailable")), + Result.success(PaykitPaymentRequestTargetCheck(null, isComplete = false)), + Result.success(PaykitPaymentRequestTargetCheck(null, isComplete = true)), + ) + + try { + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + advanceTimeBy(1_000) + runCurrent() + verify(paymentRequestRepo, times(2)).refreshEligibleTarget(CONTACT_KEY) + advanceTimeBy(3_000) + runCurrent() + verify(paymentRequestRepo, times(3)).refreshEligibleTarget(CONTACT_KEY) + advanceTimeBy(120_000) + runCurrent() + verify(paymentRequestRepo, times(3)).refreshEligibleTarget(CONTACT_KEY) + + clearInvocations(paymentRequestRepo) + whenever(paymentRequestRepo.refreshEligibleTarget(CONTACT_KEY)) + .thenReturn(Result.failure(AppError("Discovery unavailable"))) + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + advanceTimeBy(120_000) + runCurrent() + verify(paymentRequestRepo, times(4)).refreshEligibleTarget(CONTACT_KEY) + } finally { + sut.closeAndClear() + } + } + + @Test + fun `explicit admitted write finishes in background and resumes at ordinary priority after expiry`() = test { + whenever(clock.now()).thenAnswer { + Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds + } + val completion = CompletableDeferred() + var peerState = LinkedPeerState.LINKING + var finished = false + whenever(paykitSdkService.linkedPeers()).thenAnswer { listOf(linkedPeer(CONTACT_KEY, peerState)) } + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).doSuspendableAnswer { + withContext(NonCancellable) { completion.await() } + finished = true + peerState = LinkedPeerState.LINKED + LinkedPeerHandshakeReport(CONTACT_KEY, peerState, 1uL, null) + } + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY, 1u, Priority.Interactive) + sut.setContactPreparationActive(false) + clearInvocations(paykitSdkService) + completion.complete(Unit) + runCurrent() + assertTrue(finished) + advanceTimeBy(30_000) + runCurrent() + verify(paykitSdkService, never()).pendingOutboundPrivateCounterparties(any()) + verifyNoInteractions(paymentRequestRepo) + + sut.setContactPreparationActive(true) + runCurrent() + verify(paykitSdkService).receivePrivateMessages(CONTACT_KEY, Priority.Background) + verify(paykitSdkService, never()).receivePrivateMessages(CONTACT_KEY, Priority.Interactive) + verifyNoInteractions(paymentRequestRepo) + sut.closeAndClear() + } + + @Test + fun `backgrounding queued retry publication does not block explicit publication`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) + val completion = CompletableDeferred() + var firstLookup = true + whenever(paykitSdkService.identityStatus()).doSuspendableAnswer { + if (firstLookup) { + firstLookup = false + completion.await() + } + IdentityStatus(OWN_KEY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + } + val initialPublication = async { sut.prepareSavedContacts(listOf(CONTACT_KEY)) } + runCurrent() + assertFalse(initialPublication.isCompleted) + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + + try { + sut.setContactPreparationActive(false) + completion.complete(Unit) + runCurrent() + initialPublication.await().getOrThrow() + val explicitPublication = async { + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + } + runCurrent() + + assertTrue(explicitPublication.isCompleted) + explicitPublication.await().getOrThrow() + } finally { + sut.closeAndClear() + } + } + + @Test + fun `explicit retries discard a changed identity before another advance`() = test { + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKING))) + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + clearInvocations(paykitSdkService) + whenever(paykitSdkService.identityStatus()) + .thenReturn(IdentityStatus(OTHER_CONTACT_KEY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + + advanceTimeBy(120_000) + runCurrent() + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) + verify(paykitSdkService, never()).receivePrivateMessages(any(), any()) + verifyNoInteractions(paymentRequestRepo) + sut.closeAndClear() + } + + @Test + fun `profile deletion invalidates an explicit admitted advance before followup work`() = test { + val completion = CompletableDeferred() + var finished = false + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKING))) + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).doSuspendableAnswer { + withContext(NonCancellable) { + completion.await() + finished = true + } + LinkedPeerHandshakeReport(CONTACT_KEY, LinkedPeerState.LINKED, 1uL, null) + } + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + sut.beginProfileDeletion() + clearInvocations(paykitSdkService) + completion.complete(Unit) + advanceTimeBy(120_000) + runCurrent() + + assertTrue(finished) + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) + verify(paykitSdkService, never()).receivePrivateMessages(any(), any()) + verify(paykitSdkService, never()).processOutboundPrivateMessages(any(), any()) + verifyNoInteractions(paymentRequestRepo) + sut.closeAndClear() + } + @Test fun `scheduled preparation waits for foreground and retains selected contacts`() = test { sut.setContactPreparationActive(false) @@ -2323,6 +2617,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService) { retainRecoveryBackup(backup) } } + private suspend fun stubContactPreparationPriorities() { + for (priority in listOf(Priority.Background, Priority.Interactive)) { + whenever(paykitSdkService.ensureLinkWithPeer(any(), any(), eq(priority))) + .doSuspendableAnswer { paykitSdkService.ensureLinkWithPeer(it.getArgument(0)) } + } + whenever(paykitSdkService.identityStatus(any())).doSuspendableAnswer { paykitSdkService.identityStatus() } + } + private fun createSut(publicPaykitRepo: PublicPaykitRepo = this.publicPaykitRepo) = PrivatePaykitRepo( ioDispatcher = testDispatcher, paykitSdkService = paykitSdkService, @@ -2333,6 +2635,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { lightningRepo = lightningRepo, walletRepo = walletRepo, publicPaykitRepo = publicPaykitRepo, + paymentRequestRepo = paymentRequestRepo, coreService = coreService, clock = clock, ).also { it.setContactPreparationActive(true) } diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt index 9fa6a397b2..02de7ae0af 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt @@ -87,6 +87,7 @@ class AddContactViewModelTest : BaseUnitTest() { whenever(pubkyRepo.fetchContactProfile(TEST_PUBLIC_KEY)).thenReturn(Result.success(profile)) whenever(publicPaykitRepo.hasPayablePublicEndpoint(TEST_PUBLIC_KEY)).thenReturn(Result.success(false)) whenever { pubkyRepo.addContact(TEST_PUBLIC_KEY, profile) }.thenReturn(Result.success(Unit)) + whenever { refreshContactPaykitLink(TEST_PUBLIC_KEY) }.thenReturn(Result.success(Unit)) val sut = createSut() advanceUntilIdle() @@ -95,6 +96,7 @@ class AddContactViewModelTest : BaseUnitTest() { advanceUntilIdle() assertEquals(AddContactEffect.ContactSaved(TEST_PUBLIC_KEY), awaitItem()) + verify(refreshContactPaykitLink).invoke(TEST_PUBLIC_KEY) } } diff --git a/changelog.d/next/1419.fixed.md b/changelog.d/next/1419.fixed.md new file mode 100644 index 0000000000..20215caa74 --- /dev/null +++ b/changelog.d/next/1419.fixed.md @@ -0,0 +1 @@ +Keep contact linking retries independent and prioritize contacts added or refreshed while Bitkit is active. From de2bbdba5df87362be64f0d1ceffa350c52ebc5c Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 8 Oct 2026 15:50:54 -0500 Subject: [PATCH 2/8] fix: retain endpoint publication until queued --- .../bitkit/repositories/PrivatePaykitRepo.kt | 40 ++++++++--- .../repositories/PrivatePaykitRepoTest.kt | 68 ++++++++++++++++++- journeys/contacts/README.md | 6 ++ .../contacts/link-contact-after-resume.xml | 18 +++++ 4 files changed, 118 insertions(+), 14 deletions(-) create mode 100644 journeys/contacts/link-contact-after-resume.xml diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 4648c1b1de..ca92b0da5b 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -139,6 +139,21 @@ class PrivatePaykitRepo @Inject constructor( var interactiveUntil: KotlinInstant? = null var prepareEndpoints = false var refreshReadiness = false + + fun recordQueuedPublication(report: PrivatePaymentListDeliveryReport, publicationWindow: KotlinInstant?) { + if (interactiveUntil == publicationWindow && + report.queued.any { PubkyPublicKeyFormat.matches(it.counterparty, publicKey) } + ) { + prepareEndpoints = false + } + } + + fun stopIfBlocked(state: LinkedPeerState?) { + if (state == LinkedPeerState.BLOCKED) { + prepareEndpoints = false + refreshReadiness = false + } + } } private data class PrivateLinkPreparation( @@ -867,8 +882,10 @@ class PrivatePaykitRepo @Inject constructor( val keys = publicKeys.mapNotNull { normalizedPublicKey(it) }.distinct() if (keys.isEmpty()) return@runSuspendCatching val generation = preparationGeneration - val identity = currentCoroutineContext()[PrivateMessageDrainRetry]?.identity - ?: pubkyService.currentPublicKey() ?: throw PublicPaykitError.SessionNotActive + val retry = currentCoroutineContext()[PrivateMessageDrainRetry] + val identity = retry?.identity ?: pubkyService.currentPublicKey() + ?: throw PublicPaykitError.SessionNotActive + val publicationWindow = retry?.interactiveUntil val links = preparePrivateLinks( publicKeys = keys, reason = reason, @@ -906,18 +923,14 @@ class PrivatePaykitRepo @Inject constructor( updates = publication.updates, clearUnlistedLinkedPeers = false, ) - val deliveryError = applyPrivatePaymentListDeliveryReport(report, reason) + val deliveryError = applyPrivatePaymentListDeliveryReport(report, reason, publicationWindow) val firstError = publication.firstError ?: deliveryError val retryKeys = (links.linkRetryKeys + privatePaymentListDeliveryRetryKeys(report)).distinct() schedulePendingPrivateMessageDrainRetries(reason, retryKeys, identity = identity) if (firstError != null) { if (requireImmediatePublication) throw firstError - Logger.warn( - "Deferred private Paykit endpoint publish during '$reason'", - firstError, - context = TAG, - ) + Logger.warn("Deferred private endpoint publication during '$reason'", firstError, context = TAG) } } }.onFailure { @@ -992,6 +1005,9 @@ class PrivatePaykitRepo @Inject constructor( if (generation != preparationGeneration) return PrivateLinkPreparation(emptyList(), emptyList()) val peerStates = paykitSdkService.linkedPeers(contactPreparationPriority(priority)) .associate { it.counterparty to it.state } + currentCoroutineContext()[PrivateMessageDrainRetry]?.let { retry -> + retry.stopIfBlocked(peerStates[retry.publicKey]) + } for (publicKey in publicKeys.distinct()) { awaitContactPreparationActive(priority) if (generation != preparationGeneration) break @@ -1131,7 +1147,9 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun applyPrivatePaymentListDeliveryReport( report: PrivatePaymentListDeliveryReport, reason: String, + publicationWindow: KotlinInstant?, ): Throwable? { + currentCoroutineContext()[PrivateMessageDrainRetry]?.recordQueuedPublication(report, publicationWindow) logPrivatePaymentListDeliveryFailures(report, reason) var didUpdateCache = false @@ -1305,7 +1323,7 @@ class PrivatePaykitRepo @Inject constructor( private fun promoteExplicitLinkRetry(retry: PrivateMessageDrainRetry) { if (retry.interactiveUntil?.let { it > clock.now() } == true) return retry.interactiveUntil = clock.now() + explicitLinkRetryWindow - retry.prepareEndpoints = retry.publicKey !in activePreparationKeys + retry.prepareEndpoints = retry.prepareEndpoints || retry.publicKey !in activePreparationKeys retry.refreshReadiness = true unavailableLinkRetryAt.remove(retry.publicKey) retry.wake.trySend(Unit) @@ -1325,7 +1343,6 @@ class PrivatePaykitRepo @Inject constructor( Priority.Interactive } else { retry.refreshReadiness = false - retry.prepareEndpoints = false Priority.Background } val keepRetrying = runSuspendCatching { drainPrivateMessageRetry(retry, reason, priority) } @@ -1345,16 +1362,17 @@ class PrivatePaykitRepo @Inject constructor( if (!PubkyPublicKeyFormat.matches(status?.publicKey, retry.identity) || status?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE ) { + retry.prepareEndpoints = false return false } awaitContactPreparationActive(priority) val keys = listOf(retry.publicKey) if (retry.prepareEndpoints) { - retry.prepareEndpoints = false if (canPublishPrivateEndpoints(status)) { publishLocalEndpoints(keys, reason, priority = priority).getOrThrow() return true } + retry.prepareEndpoints = false } val pendingKeys = pendingPrivateMessageDrainKeys( keys, diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 875d0198e9..0b6ea2ca64 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -395,6 +395,68 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `explicit publication retries queue failures after its priority window expires`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + whenever(clock.now()).thenAnswer { + Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds + } + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) + val completion = CompletableDeferred() + var publications = 0 + whenever(paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any())).doSuspendableAnswer { + when (++publications) { + 1 -> { + completion.await() + throw PaykitException.Transport("offline", "Unavailable homeserver") + } + 2 -> privateListDeliveryReport(failedToQueue = listOf(privateListSyncChange(CONTACT_KEY))) + else -> privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY)) + } + } + + try { + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + assertEquals(1, publications) + advanceTimeBy(30_000) + completion.complete(Unit) + runCurrent() + advanceTimeBy(1_000) + runCurrent() + assertEquals(2, publications) + advanceTimeBy(3_000) + runCurrent() + assertEquals(3, publications) + advanceTimeBy(120_000) + runCurrent() + assertEquals(3, publications) + } finally { + completion.complete(Unit) + sut.closeAndClear() + } + } + + @Test + fun `blocked peer retires an unpublished explicit retry`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.BLOCKED))) + + try { + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + advanceTimeBy(1_000) + runCurrent() + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) + clearInvocations(paykitSdkService) + advanceTimeBy(120_000) + runCurrent() + verify(paykitSdkService, never()).identityStatus(any()) + } finally { + sut.closeAndClear() + } + } + @Test fun `repeated refreshes preserve pending link retry backoff`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) @@ -2617,12 +2679,12 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService) { retainRecoveryBackup(backup) } } - private suspend fun stubContactPreparationPriorities() { + private fun stubContactPreparationPriorities() { for (priority in listOf(Priority.Background, Priority.Interactive)) { - whenever(paykitSdkService.ensureLinkWithPeer(any(), any(), eq(priority))) + whenever { paykitSdkService.ensureLinkWithPeer(any(), any(), eq(priority)) } .doSuspendableAnswer { paykitSdkService.ensureLinkWithPeer(it.getArgument(0)) } } - whenever(paykitSdkService.identityStatus(any())).doSuspendableAnswer { paykitSdkService.identityStatus() } + whenever { paykitSdkService.identityStatus(any()) }.doSuspendableAnswer { paykitSdkService.identityStatus() } } private fun createSut(publicPaykitRepo: PublicPaykitRepo = this.publicPaykitRepo) = PrivatePaykitRepo( diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index ae662a601e..3ef45aa021 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -1,5 +1,11 @@ # Contacts +`link-contact-after-resume.xml` checks saving a new contact, leaving and reopening Bitkit, and +reaching Request or Pay without re-adding the contact. It is mirrored on iOS and requires two +disposable, request-capable identities. Record readiness timing separately from a pass/fail result. +A natural run may finish linking before backgrounding; the controlled pending-operation checks +below are still needed to verify that pending work resumes. + `delete-newly-saved-contact.xml` checks deletion directly from Contact Saved and adding that contact again. It is mirrored on iOS. Deleted contact screens must not remain in Back history. diff --git a/journeys/contacts/link-contact-after-resume.xml b/journeys/contacts/link-contact-after-resume.xml new file mode 100644 index 0000000000..927063b706 --- /dev/null +++ b/journeys/contacts/link-contact-after-resume.xml @@ -0,0 +1,18 @@ + + + Requires two disposable Bitkit identities with contact payment sharing enabled. On the second + device, save the first identity as a contact and keep Bitkit open. The first device must not + already have the second identity saved or linked. No payment is sent. Record the time from + saving the contact to Request or Pay becoming available; there is no fixed latency assertion. + + + On the first device, open Contacts and add the second identity by its Pubky key + Save the contact and verify Contact Saved shows the second identity's name + Immediately press the device Home button, wait 5 seconds, then reopen Bitkit + Open the saved contact from Contacts without saving or importing it again + Tap Pay and wait for the result; if Request or Pay is not yet offered, close the payment flow without paying and retry after 5 seconds + Verify Request or Pay becomes available with an enabled Request button; record elapsed time or report a failure if it never becomes available during the run + Tap Request and verify the Payment Request amount screen opens + Close the request flow without sending, return to Contacts and verify the contact appears only once + + From 22617672328b9c302d28f771cd63c5c16aa554b3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 8 Oct 2026 16:37:22 -0500 Subject: [PATCH 3/8] perf: batch private retry scheduling reads --- .../bitkit/repositories/PrivatePaykitRepo.kt | 113 +++++++++-- .../repositories/PrivatePaykitRepoTest.kt | 182 ++++++++++++++++++ 2 files changed, 278 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index ca92b0da5b..01ece7ef75 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -14,8 +14,10 @@ import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Deferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay @@ -30,6 +32,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.yield import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import org.lightningdevkit.ldknode.PaymentDirection @@ -126,6 +129,17 @@ class PrivatePaykitRepo @Inject constructor( private val unavailableLinkRetryAt = mutableMapOf() private var state: PrivatePaykitState? = null private val pendingMessageDrainRetries = mutableMapOf() + private var pendingMessageDrainRead: PrivateMessageDrainRead? = null + + private class PrivateMessageDrainRead(val identity: String, val generation: Int) { + val retries = mutableSetOf() + lateinit var result: Deferred + } + + private data class PrivateMessageDrainState( + val linkedPeers: Map, + val pendingOutbound: Set, + ) private class PrivateMessageDrainRetry( val publicKey: String, @@ -1358,6 +1372,17 @@ class PrivatePaykitRepo @Inject constructor( reason: String, priority: Priority, ): Boolean { + val keys = listOf(retry.publicKey) + val pendingKeys = if (retry.prepareEndpoints) { + null + } else { + pendingPrivateMessageDrainKeys( + keys, + retryMissingPeers = true, + priority = priority, + receiveLinkedPeers = retry.refreshReadiness, + ) + } val status = paykitSdkService.identityStatus(contactPreparationPriority(priority)) if (!PubkyPublicKeyFormat.matches(status?.publicKey, retry.identity) || status?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE @@ -1366,7 +1391,6 @@ class PrivatePaykitRepo @Inject constructor( return false } awaitContactPreparationActive(priority) - val keys = listOf(retry.publicKey) if (retry.prepareEndpoints) { if (canPublishPrivateEndpoints(status)) { publishLocalEndpoints(keys, reason, priority = priority).getOrThrow() @@ -1374,13 +1398,13 @@ class PrivatePaykitRepo @Inject constructor( } retry.prepareEndpoints = false } - val pendingKeys = pendingPrivateMessageDrainKeys( + val drainKeys = pendingKeys ?: pendingPrivateMessageDrainKeys( keys, retryMissingPeers = true, priority = priority, receiveLinkedPeers = retry.refreshReadiness, ) - val receivedKeys = drainPendingPrivateMessages(reason, retryKeys = pendingKeys, priority = priority) + val receivedKeys = drainPendingPrivateMessages(reason, retryKeys = drainKeys, priority = priority) awaitContactPreparationActive(priority) if (retry.refreshReadiness && retry.publicKey in receivedKeys && contactPreparationPriority(priority) == Priority.Interactive @@ -1403,10 +1427,70 @@ class PrivatePaykitRepo @Inject constructor( if (retryKeys.isNotEmpty()) awaitContactPreparationActive(priority) if (retryKeys.isEmpty() || generation != preparationGeneration) return emptySet() - val linkedPeers = runSuspendCatching { paykitSdkService.linkedPeers(contactPreparationPriority(priority)) } + val state = privateMessageDrainState(priority) + awaitContactPreparationActive(priority) + if (generation != preparationGeneration) return emptySet() + if (state == null) return retryKeys + return retryKeys.filterTo(mutableSetOf()) { retryKey -> + when (state.linkedPeers[retryKey]) { + LinkedPeerState.LINKED -> receiveLinkedPeers || retryKey in state.pendingOutbound + null -> retryMissingPeers || retryKey in state.pendingOutbound + LinkedPeerState.BLOCKED, LinkedPeerState.UNKNOWN -> false + else -> true + } + } + } + + private suspend fun privateMessageDrainState(priority: Priority): PrivateMessageDrainState? { + val retry = currentCoroutineContext()[PrivateMessageDrainRetry] + ?: return readPrivateMessageDrainState { contactPreparationPriority(priority) } + val batch = pendingMessageDrainRead?.takeIf { + it.generation == retry.generation && PubkyPublicKeyFormat.matches(it.identity, retry.identity) + } ?: PrivateMessageDrainRead(retry.identity, retry.generation).also { read -> + pendingMessageDrainRead = read + read.result = retryScope.async { + try { + yield() + if (pendingMessageDrainRead === read) pendingMessageDrainRead = null + readPrivateMessageDrainState { privateMessageDrainReadPriority(read) } + } finally { + if (pendingMessageDrainRead === read) pendingMessageDrainRead = null + } + } + } + batch.retries += retry + return try { + batch.result.await() + } finally { + batch.retries -= retry + if (batch.retries.isEmpty()) { + if (pendingMessageDrainRead === batch) pendingMessageDrainRead = null + batch.result.cancel() + } + } + } + + private suspend fun privateMessageDrainReadPriority(batch: PrivateMessageDrainRead): Priority { + isContactPreparationActive.first { it } + currentCoroutineContext().ensureActive() + val retries = batch.retries.filter { + it.generation == preparationGeneration && pendingMessageDrainRetries[it.publicKey] === it && + it.job?.isActive == true && (it.interactiveUntil == null || it.publicKey in knownSavedContactKeys) + } + if (retries.isEmpty()) throw CancellationException("Contact preparation invalidated") + return if (retries.any { it.interactiveUntil?.let { deadline -> deadline > clock.now() } == true }) { + Priority.Interactive + } else { + Priority.Background + } + } + + private suspend fun readPrivateMessageDrainState(priority: suspend () -> Priority): PrivateMessageDrainState? { + val generation = preparationGeneration + val linkedPeers = runSuspendCatching { paykitSdkService.linkedPeers(priority()) } .getOrElse { Logger.warn("Failed to inspect private Paykit link state", it, context = TAG) - return retryKeys + return null } .mapNotNull { peer -> normalizedPublicKey(peer.counterparty)?.let { publicKey -> @@ -1414,29 +1498,24 @@ class PrivatePaykitRepo @Inject constructor( } } .toMap() - awaitContactPreparationActive(priority) - if (generation != preparationGeneration) return emptySet() + val pendingPriority = priority() + if (generation != preparationGeneration) return null val pendingOutbound = runSuspendCatching { - paykitSdkService.pendingOutboundPrivateCounterparties(contactPreparationPriority(priority)) + paykitSdkService.pendingOutboundPrivateCounterparties(pendingPriority) } .getOrElse { Logger.warn("Failed to inspect pending private Paykit messages", it, context = TAG) - return retryKeys + return null } .mapNotNull(::normalizedPublicKey) .toSet() - return retryKeys.filterTo(mutableSetOf()) { retryKey -> - when (linkedPeers[retryKey]) { - LinkedPeerState.LINKED -> receiveLinkedPeers || retryKey in pendingOutbound - null -> retryMissingPeers || retryKey in pendingOutbound - LinkedPeerState.BLOCKED, LinkedPeerState.UNKNOWN -> false - else -> true - } - } + return PrivateMessageDrainState(linkedPeers, pendingOutbound) } private fun clearPendingMessageDrainRetries() { + pendingMessageDrainRead?.result?.cancel() + pendingMessageDrainRead = null pendingMessageDrainRetries.values.forEach { it.job?.cancel() } pendingMessageDrainRetries.clear() } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 0b6ea2ca64..fb6cb664c7 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -476,6 +476,188 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() } + @Test + fun `due peers share scheduling reads and recheck state after each advance`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + val keys = listOf(CONTACT_KEY, OTHER_CONTACT_KEY) + val states = keys.associateWith { LinkedPeerState.LINKING }.toMutableMap() + val pending = mutableSetOf() + whenever(paykitSdkService.linkedPeers()).thenAnswer { states.map { linkedPeer(it.key, it.value) } } + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenAnswer { pending.toList() } + sut.prepareSavedContacts(keys).getOrThrow() + clearInvocations(paykitSdkService) + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).thenAnswer { + states[CONTACT_KEY] = LinkedPeerState.LINKED + pending += CONTACT_KEY + LinkedPeerHandshakeReport(CONTACT_KEY, LinkedPeerState.LINKED, 1uL, null) + } + whenever(paykitSdkService.processOutboundPrivateMessages(CONTACT_KEY)).thenAnswer { + pending -= CONTACT_KEY + mock() + } + + try { + advanceTimeBy(1_000) + runCurrent() + verify(paykitSdkService, times(6)).linkedPeers(Priority.Background) + verify(paykitSdkService, times(4)).pendingOutboundPrivateCounterparties(Priority.Background) + verify(paykitSdkService).processOutboundPrivateMessages(CONTACT_KEY, Priority.Background) + verify(paykitSdkService).receivePrivateMessages(CONTACT_KEY, Priority.Background) + verify(paykitSdkService, never()).receivePrivateMessages(OTHER_CONTACT_KEY, Priority.Background) + + clearInvocations(paykitSdkService) + advanceTimeBy(3_000) + runCurrent() + verify(paykitSdkService, never()).ensureLinkWithPeer(eq(CONTACT_KEY), any(), any()) + verify(paykitSdkService).ensureLinkWithPeer(OTHER_CONTACT_KEY, priority = Priority.Background) + states[OTHER_CONTACT_KEY] = LinkedPeerState.BLOCKED + advanceTimeBy(8_000) + runCurrent() + clearInvocations(paykitSdkService) + advanceTimeBy(120_000) + runCurrent() + verifyNoInteractions(paykitSdkService) + } finally { + sut.closeAndClear() + } + } + + @Test + fun `shared scheduling reads retain each due peers foreground priority`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + val keys = listOf(CONTACT_KEY, OTHER_CONTACT_KEY) + whenever(paykitSdkService.linkedPeers()).thenReturn(keys.map { linkedPeer(it, LinkedPeerState.LINKING) }) + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + sut.refreshSavedContactEndpoints(OTHER_CONTACT_KEY, keys).getOrThrow() + runCurrent() + clearInvocations(paykitSdkService) + + try { + advanceTimeBy(1_000) + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY, priority = Priority.Background) + verify(paykitSdkService).ensureLinkWithPeer(OTHER_CONTACT_KEY, priority = Priority.Interactive) + verify(paykitSdkService, times(3)).linkedPeers(Priority.Background) + verify(paykitSdkService, times(3)).linkedPeers(Priority.Interactive) + verify(paykitSdkService, never()).ensureLinkWithPeer(CONTACT_KEY, priority = Priority.Interactive) + } finally { + sut.closeAndClear() + } + } + + @Test + fun `shared scheduling reads discard invalidated work and pause until foreground`() = test { + val keys = listOf(CONTACT_KEY, OTHER_CONTACT_KEY) + for (invalidation in listOf("background", "identity", "deletion")) { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + whenever(paykitSdkService.linkedPeers(any())).doSuspendableAnswer { paykitSdkService.linkedPeers() } + whenever(paykitSdkService.linkedPeers()).thenReturn(keys.map { linkedPeer(it, LinkedPeerState.LINKING) }) + sut.prepareSavedContacts(keys).getOrThrow() + val completion = CompletableDeferred() + whenever(paykitSdkService.linkedPeers(Priority.Background)).doSuspendableAnswer { + withContext(NonCancellable) { completion.await() } + keys.map { linkedPeer(it, LinkedPeerState.LINKING) } + } + clearInvocations(paykitSdkService) + + try { + advanceTimeBy(1_000) + runCurrent() + verify(paykitSdkService).linkedPeers(Priority.Background) + when (invalidation) { + "background" -> sut.setContactPreparationActive(false) + "identity" -> whenever(paykitSdkService.identityStatus()).thenReturn( + IdentityStatus(OTHER_CONTACT_KEY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + ) + "deletion" -> sut.beginProfileDeletion() + } + completion.complete(Unit) + runCurrent() + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) + verify(paykitSdkService, never()).processOutboundPrivateMessages(any(), any()) + verify(paykitSdkService, never()).receivePrivateMessages(any(), any()) + verifyNoInteractions(paymentRequestRepo) + if (invalidation == "background") { + verify(paykitSdkService, never()).pendingOutboundPrivateCounterparties(any()) + sut.setContactPreparationActive(true) + runCurrent() + keys.forEach { verify(paykitSdkService).ensureLinkWithPeer(it, priority = Priority.Background) } + } + } finally { + completion.complete(Unit) + sut.closeAndClear() + sut.endProfileDeletion() + whenever(paykitSdkService.identityStatus()).thenReturn( + IdentityStatus(OWN_KEY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + ) + } + } + } + + @Test + fun `removing one waiter preserves shared scheduling reads for other due peers`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + val keys = listOf(CONTACT_KEY, OTHER_CONTACT_KEY) + whenever(paykitSdkService.linkedPeers()).thenReturn(keys.map { linkedPeer(it, LinkedPeerState.LINKING) }) + sut.prepareSavedContacts(keys).getOrThrow() + whenever(paykitSdkService.clearPrivatePaymentLists(listOf(CONTACT_KEY))).thenReturn(privateListDeliveryReport()) + val completion = CompletableDeferred() + whenever(paykitSdkService.linkedPeers(Priority.Background)).doSuspendableAnswer { + completion.await() + keys.map { linkedPeer(it, LinkedPeerState.LINKING) } + } + try { + advanceTimeBy(1_000) + runCurrent() + val removal = async { sut.removeSavedContact(CONTACT_KEY) } + runCurrent() + clearInvocations(paykitSdkService) + completion.complete(Unit) + removal.await().getOrThrow() + runCurrent() + verify(paykitSdkService, never()).ensureLinkWithPeer(eq(CONTACT_KEY), any(), any()) + verify(paykitSdkService).ensureLinkWithPeer(OTHER_CONTACT_KEY, priority = Priority.Background) + } finally { + completion.complete(Unit) + sut.closeAndClear() + } + } + + @Test + fun `later due peer starts fresh scheduling reads without joining an earlier snapshot`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + val states = mutableMapOf(CONTACT_KEY to LinkedPeerState.LINKING, OTHER_CONTACT_KEY to LinkedPeerState.LINKING) + whenever(paykitSdkService.linkedPeers()).thenAnswer { states.map { linkedPeer(it.key, it.value) } } + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + advanceTimeBy(500) + sut.prepareSavedContacts(states.keys).getOrThrow() + states[OTHER_CONTACT_KEY] = LinkedPeerState.LINKED + val completion = CompletableDeferred() + var reads = 0 + whenever(paykitSdkService.pendingOutboundPrivateCounterparties(Priority.Background)).doSuspendableAnswer { + if (++reads == 1) completion.await() + emptyList() + } + clearInvocations(paykitSdkService) + + try { + advanceTimeBy(500) + runCurrent() + verify(paykitSdkService).linkedPeers(Priority.Background) + states[OTHER_CONTACT_KEY] = LinkedPeerState.LINKING + advanceTimeBy(500) + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(OTHER_CONTACT_KEY, priority = Priority.Background) + verify(paykitSdkService, never()).ensureLinkWithPeer(CONTACT_KEY, priority = Priority.Background) + completion.complete(Unit) + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY, priority = Priority.Background) + } finally { + completion.complete(Unit) + sut.closeAndClear() + } + } + @Test fun `new peer retries independently of a peer at maximum backoff`() = test { whenever(paykitSdkService.linkedPeers()).thenReturn( From a34eaa0ad4530a0748b8d784d723b747de06b83f Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 8 Oct 2026 19:56:44 -0500 Subject: [PATCH 4/8] chore: adopt paykit rc72 --- .../to/bitkit/ext/PaykitExceptionExtTest.kt | 11 +++++++ .../repositories/PrivatePaykitRepoTest.kt | 30 +++++++++++++++++++ .../bitkit/services/PaykitSdkServiceTest.kt | 22 ++++++++++++++ gradle/libs.versions.toml | 2 +- 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/ext/PaykitExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/PaykitExceptionExtTest.kt index a60831a5c5..2db7330e80 100644 --- a/app/src/test/java/to/bitkit/ext/PaykitExceptionExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/PaykitExceptionExtTest.kt @@ -37,4 +37,15 @@ class PaykitExceptionExtTest { fun `generic failures do not require recovery`() { assertFalse(AppError("Native load failed").isPaykitRecoveryRequired()) } + + @Test + fun `link observation and transport failures do not require recovery`() { + val observation = AppError(PaykitException.Protocol("link_observation_failed", "Invalid link metadata")) + val transport = AppError(PaykitException.Transport("transport_error", "Unavailable homeserver")) + + assertFalse(observation.isPaykitRecoveryRequired()) + assertFalse(observation.isPaykitTemporarilyUnavailable()) + assertFalse(transport.isPaykitRecoveryRequired()) + assertTrue(transport.isPaykitTemporarilyUnavailable()) + } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index fb6cb664c7..05d3e36175 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -765,6 +765,35 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() } + @Test + fun `explicit link retry resumes after invalid metadata is corrected`() = test { + var peerState = LinkedPeerState.LINKING + whenever(paykitSdkService.linkedPeers()).thenAnswer { listOf(linkedPeer(CONTACT_KEY, peerState)) } + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)) + .thenAnswer { throw PaykitException.Protocol("link_observation_failed", "Invalid link metadata") } + .thenAnswer { + peerState = LinkedPeerState.LINKED + LinkedPeerHandshakeReport(CONTACT_KEY, peerState, 1uL, null) + } + + try { + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + verifyNoInteractions(paymentRequestRepo) + verify(paykitSdkService, never()).receivePrivateMessages(any(), any()) + + advanceTimeBy(1_000) + runCurrent() + + verify(paykitSdkService, times(2)).ensureLinkWithPeer(CONTACT_KEY, priority = Priority.Interactive) + verify(paykitSdkService).receivePrivateMessages(CONTACT_KEY, Priority.Interactive) + verify(paymentRequestRepo).refreshEligibleTarget(CONTACT_KEY) + verify(publicPaykitRepo, never()).beginPayment(any()) + } finally { + sut.closeAndClear() + } + } + @Test fun `explicit linked readiness retries transient intake failure without pending outbound`() = test { whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) @@ -784,6 +813,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { advanceTimeBy(120_000) runCurrent() verify(paymentRequestRepo).refreshEligibleTarget(CONTACT_KEY) + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) } finally { sut.closeAndClear() } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 6f05c713a5..00b5cd8cb6 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -58,6 +58,7 @@ import org.mockito.kotlin.description import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.doThrow import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -208,6 +209,27 @@ class PaykitSdkServiceTest { verify(sdk).prepareAndResolvePrivatePaymentRequest(RING_PUBKY, "request", null, 1u) } + @Test + fun `private link retries preserve observation and transport failures`() = runTest { + val sdk = mock() + whenever(sdk.stateRevision()).thenReturn("state") + whenever(sdk.backupStateRevision()).thenReturn("backup") + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val failures = listOf( + PaykitException.Protocol("link_observation_failed", "Invalid link metadata"), + PaykitException.Transport("transport_error", "Unavailable homeserver"), + ) + + for (failure in failures) { + doThrow(failure).whenever(sdk).ensureLinkWithPeer(RING_PUBKY, 1u) + assertSame(failure, assertFailsWith { service.ensureLinkWithPeer(RING_PUBKY) }) + } + + doReturn(LinkedPeerHandshakeReport(RING_PUBKY, LinkedPeerState.LINKED, 1uL, null)) + .whenever(sdk).ensureLinkWithPeer(RING_PUBKY, 1u) + assertEquals(LinkedPeerState.LINKED, service.ensureLinkWithPeer(RING_PUBKY).state) + } + @Test @OptIn(ExperimentalCoroutinesApi::class) fun `scoped private receive holds mutation lock and tracks backup changes`() = runTest { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a100b2007..b236d5cb1c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.18" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc71" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc72" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From 98a2f05abec5af4d5507ba64e0c6248ef51f70fc Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 9 Oct 2026 02:25:14 -0500 Subject: [PATCH 5/8] fix: preserve contacts during stale sync --- .../ContactPaymentSettingsRepo.kt | 20 +- .../PrivatePaykitAddressReservationRepo.kt | 12 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 189 +++++++++++++----- .../to/bitkit/services/PaykitSdkService.kt | 10 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 47 +++-- .../ContactPaymentSettingsRepoTest.kt | 71 +++++-- ...PrivatePaykitAddressReservationRepoTest.kt | 81 ++++++++ .../repositories/PrivatePaykitRepoTest.kt | 139 ++++++++++++- .../bitkit/services/PaykitSdkServiceTest.kt | 65 ++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 120 +++++++++-- journeys/contacts/README.md | 14 ++ 11 files changed, 653 insertions(+), 115 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt index 529d71c5d3..909157c013 100644 --- a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -75,6 +75,7 @@ class ContactPaymentSettingsRepo @Inject constructor( } private suspend fun enable(contacts: List): Result { + val signIn = pubkyRepo.currentSignIn() val previous = settingsStore.data.first() val canUsePrivateContactPayments = runSuspendCatching { privatePaykitRepo.hasPrivatePaymentAccess() @@ -92,16 +93,23 @@ class ContactPaymentSettingsRepo @Inject constructor( publicPaykitRepo.syncPublishedEndpoints(publish = true).getOrThrow() if (canUsePrivateContactPayments) { - privatePaykitRepo.enableSharingAndPrepareSavedContacts( - publicKeys = contacts, - ).getOrThrow() + prepareCurrentSavedContacts(signIn).getOrThrow() } - }.onFailure { rollbackEnabled(previous, contacts, it) } + }.onFailure { rollbackEnabled(previous, contacts, signIn, it) } + } + + private suspend fun prepareCurrentSavedContacts(signIn: PubkySignIn?): Result { + if (signIn == null || !pubkyRepo.isCurrent(signIn)) return Result.success(Unit) + val contacts = pubkyRepo.contacts.value.map { it.publicKey }.toSet() + return privatePaykitRepo.enableSharingAndPrepareSavedContacts(contacts) { + pubkyRepo.isCurrent(signIn) && pubkyRepo.contacts.value.map { it.publicKey }.toSet() == contacts + } } private suspend fun rollbackEnabled( previous: SettingsData, contacts: List, + signIn: PubkySignIn?, error: Throwable, ) { runSuspendCatching { @@ -128,9 +136,7 @@ class ContactPaymentSettingsRepo @Inject constructor( markPublicPaykitRetry(error) } if (previous.sharesPrivatePaykitEndpoints) { - privatePaykitRepo.enableSharingAndPrepareSavedContacts( - publicKeys = contacts, - ).onFailure(error::addSuppressed) + prepareCurrentSavedContacts(signIn).onFailure(error::addSuppressed) } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt index db7d9d830e..c2528fc434 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt @@ -199,9 +199,13 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( return lightningRepo.getAddressBalance(address).getOrDefault(0u) > 0u } - suspend fun clearContactAssignments(excludingPublicKeys: Collection) = withContext(ioDispatcher) { + suspend fun clearContactAssignments( + excludingPublicKeys: Collection, + isStillCurrent: (() -> Boolean)? = null, + ) = withContext(ioDispatcher) { val savedKeys = excludingPublicKeys.mapNotNull { normalizedPublicKeyOrNull(it) }.toSet() locked { current -> + if (isStillCurrent?.invoke() == false) return@locked val next = current.copy( contactAssignments = current.contactAssignments.filterKeys { it in savedKeys @@ -217,9 +221,13 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( } } - suspend fun removeContactAssignments(publicKeys: Collection) = withContext(ioDispatcher) { + suspend fun removeContactAssignments( + publicKeys: Collection, + isStillCurrent: (() -> Boolean)? = null, + ) = withContext(ioDispatcher) { val removedKeys = publicKeys.mapNotNull { normalizedPublicKeyOrNull(it) }.toSet() locked { current -> + if (isStillCurrent?.invoke() == false) return@locked val next = current.copy( contactAssignments = current.contactAssignments.filterKeys { it !in removedKeys }, contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { it !in removedKeys }, diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 01ece7ef75..a2686817ea 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -49,11 +49,13 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService +import to.bitkit.services.ObsoletePaykitContactSync import to.bitkit.services.PaykitPreparedPrivateContactPayment import to.bitkit.services.PaykitPrivateContactPaymentResolution import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService +import to.bitkit.services.checkPaykitContactSync import to.bitkit.utils.Logger import java.security.MessageDigest import java.time.Instant @@ -228,11 +230,15 @@ class PrivatePaykitRepo @Inject constructor( suspend fun enableSharingAndPrepareSavedContacts( publicKeys: Collection, + isStillCurrent: (() -> Boolean)? = null, ): Result = withContext(serializedDispatcher) { - runSuspendCatching { + runContactSync { + checkPaykitContactSync(isStillCurrent) val wasCleanupPending = isContactSharingCleanupPending() - updateContactSharingCleanupPending(false) - scheduleSavedContactPreparation(publicKeys).onFailure { + checkPaykitContactSync(isStillCurrent) + updateContactSharingCleanupPending(false, isStillCurrent) + checkPaykitContactSync(isStillCurrent) + scheduleSavedContactPreparation(publicKeys, isStillCurrent).onFailure { if (wasCleanupPending) { runSuspendCatching { updateContactSharingCleanupPending(true) }.onFailure(it::addSuppressed) } @@ -240,9 +246,13 @@ class PrivatePaykitRepo @Inject constructor( } } - suspend fun scheduleSavedContactPreparation(publicKeys: Collection): Result = + suspend fun scheduleSavedContactPreparation( + publicKeys: Collection, + isStillCurrent: (() -> Boolean)? = null, + ): Result = withContext(serializedDispatcher) { - runSuspendCatching { + runContactSync { + checkPaykitContactSync(isStillCurrent) val keys = rememberSavedContacts(publicKeys, replacing = true) scheduleContactPreparation(keys) } @@ -385,37 +395,54 @@ class PrivatePaykitRepo @Inject constructor( suspend fun pruneUnsavedContactState( savedPublicKeys: Collection, + isStillCurrent: (() -> Boolean)? = null, ): Result = withContext(serializedDispatcher) { - runSuspendCatching { + runContactSync { + checkPaykitContactSync(isStillCurrent) + val contacts = ensureState().contacts + checkPaykitContactSync(isStillCurrent) val savedKeys = rememberSavedContacts(savedPublicKeys, replacing = true).toSet() - val staleKeys = ensureState().contacts.keys.filter { it !in savedKeys } - removeSavedContacts(staleKeys).getOrThrow() - addressReservationRepo.clearContactAssignments(excludingPublicKeys = savedKeys) + val staleKeys = contacts.keys.filter { it !in savedKeys } + removeSavedContacts(staleKeys, isStillCurrent).getOrThrow() + checkPaykitContactSync(isStillCurrent) + addressReservationRepo.clearContactAssignments(savedKeys, isStillCurrent) } } suspend fun removeSavedContact(publicKey: String): Result = removeSavedContacts(listOf(publicKey)) - suspend fun removeSavedContacts(publicKeys: Collection): Result = withContext(serializedDispatcher) { - runSuspendCatching { + suspend fun removeSavedContacts( + publicKeys: Collection, + isStillCurrent: (() -> Boolean)? = null, + ): Result = withContext(serializedDispatcher) { + runContactSync { + checkPaykitContactSync(isStillCurrent) val keys = publicKeys.mapNotNull(::normalizedPublicKey).toSet() - if (keys.isEmpty()) return@runSuspendCatching + if (keys.isEmpty()) return@runContactSync knownSavedContactKeys.removeAll(keys) pendingPreparationKeys.removeAll(keys) keys.forEach { pendingMessageDrainRetries.remove(it)?.job?.cancel() } keys.forEach(unavailableLinkRetryAt::remove) if (!isDeletingProfile) { - removePublishedEndpoints(keys).onFailure { - updateDeletedContactCleanupPending(keys, true) + removePublishedEndpoints(keys, isStillCurrent).onFailure { + checkPaykitContactSync(isStillCurrent) + updateDeletedContactCleanupPending(keys, true, isStillCurrent) Logger.warn("Failed to remove private Paykit endpoints for deleted contacts", it, context = TAG) }.getOrThrow() } - clearContactStates(keys) - addressReservationRepo.removeContactAssignments(keys) - if (!isDeletingProfile) updateDeletedContactCleanupPending(keys, false) + clearContactStates(keys, isStillCurrent) + checkPaykitContactSync(isStillCurrent) + addressReservationRepo.removeContactAssignments(keys, isStillCurrent) + if (!isDeletingProfile) updateDeletedContactCleanupPending(keys, false, isStillCurrent) } } + private suspend fun runContactSync(block: suspend () -> Unit): Result = try { + runSuspendCatching { block() } + } catch (_: ObsoletePaykitContactSync) { + Result.success(Unit) + } + suspend fun disableSharingAndPruneUnsavedContactState(savedPublicKeys: Collection): Result = withContext(serializedDispatcher) { runSuspendCatching { @@ -1679,16 +1706,24 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun removePublishedEndpoints(publicKeys: Collection): Result = + private suspend fun removePublishedEndpoints( + publicKeys: Collection, + isStillCurrent: (() -> Boolean)? = null, + ): Result = withContext(serializedDispatcher) { publicationMutex.withLock { - removePublishedEndpointsLocked(publicKeys) + removePublishedEndpointsLocked(publicKeys, isStillCurrent) } } - private suspend fun removePublishedEndpointsLocked(publicKeys: Collection? = null): Result = + private suspend fun removePublishedEndpointsLocked( + publicKeys: Collection? = null, + isStillCurrent: (() -> Boolean)? = null, + ): Result = runSuspendCatching { + checkPaykitContactSync(isStillCurrent) val peers = paykitSdkService.linkedPeers() + checkPaykitContactSync(isStillCurrent) val linkedPublicKeys = peers .filter { it.state == LinkedPeerState.LINKED || it.state == LinkedPeerState.LINKING || @@ -1701,34 +1736,22 @@ class PrivatePaykitRepo @Inject constructor( linkedPublicKeys ) val normalizedBatch = normalizedPublicKeyBatch(keys) - discardInvalidCleanupKeys(normalizedBatch.invalidKeys) + discardInvalidCleanupKeys(normalizedBatch.invalidKeys, isStillCurrent) val normalizedKeys = normalizedBatch.normalizedKeys if (normalizedKeys.isEmpty()) return@runSuspendCatching ensureState() + checkPaykitContactSync(isStillCurrent) val cleanupStateByPublicKey = normalizedKeys.associateWith(::publishedEndpointCleanupState) - val preparation = clearPrivatePaymentLists(normalizedKeys, linkedPublicKeys) + val preparation = clearPrivatePaymentLists(normalizedKeys, linkedPublicKeys, isStillCurrent) + checkPaykitContactSync(isStillCurrent) val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() var firstError = preparation.firstError - if (preparation.clearedRetryKeys.isNotEmpty()) { - var pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) - if (pendingRetryKeys.isNotEmpty()) { - drainPendingPrivateMessages( - reason = "private endpoint cleanup", - retryKeys = pendingRetryKeys, - includeUnsavedPeers = true, - ) - pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) - } - if (pendingRetryKeys.isNotEmpty()) { - Logger.warn( - "Private Paykit endpoint withdrawal remains pending for ${pendingRetryKeys.map(::redacted)}", - context = TAG, - ) - failedPublicKeys += pendingRetryKeys - firstError = firstError ?: PrivatePaykitError.PrivateUnavailable - } + val pendingRetryKeys = drainPendingEndpointWithdrawals(preparation.clearedRetryKeys, isStillCurrent) + if (pendingRetryKeys.isNotEmpty()) { + failedPublicKeys += pendingRetryKeys + firstError = firstError ?: PrivatePaykitError.PrivateUnavailable } normalizedKeys.filterNot { it in failedPublicKeys }.forEach { publicKey -> @@ -1742,14 +1765,47 @@ class PrivatePaykitRepo @Inject constructor( } } - clearPublishedEndpointCache(normalizedKeys.filterNot { it in failedPublicKeys }) + clearPublishedEndpointCache(normalizedKeys.filterNot { it in failedPublicKeys }, isStillCurrent) firstError?.let { throw it } + checkPaykitContactSync(isStillCurrent) if (publicKeys != null) publicPaykitRepo.syncPaykitApp().getOrThrow() }.onFailure { - runSuspendCatching { settingsStore.update { it.copy(publicPaykitCleanupPending = true) } } + checkPaykitContactSync(isStillCurrent) + runSuspendCatching { + settingsStore.update { + if (isStillCurrent?.invoke() == false) it else it.copy(publicPaykitCleanupPending = true) + } + } .onFailure(it::addSuppressed) } + private suspend fun drainPendingEndpointWithdrawals( + publicKeys: Collection, + isStillCurrent: (() -> Boolean)?, + ): Set { + if (publicKeys.isEmpty()) return emptySet() + + var pendingKeys = pendingPrivateMessageDrainKeys(publicKeys) + checkPaykitContactSync(isStillCurrent) + if (pendingKeys.isNotEmpty()) { + drainPendingPrivateMessages( + reason = "private endpoint cleanup", + retryKeys = pendingKeys, + includeUnsavedPeers = true, + ) + checkPaykitContactSync(isStillCurrent) + pendingKeys = pendingPrivateMessageDrainKeys(publicKeys) + } + checkPaykitContactSync(isStillCurrent) + if (pendingKeys.isNotEmpty()) { + Logger.warn( + "Private Paykit endpoint withdrawal remains pending for ${pendingKeys.map(::redacted)}", + context = TAG, + ) + } + return pendingKeys + } + private suspend fun syncPaykitAppAfterCleanup(): Result = publicPaykitRepo.syncPaykitApp().onFailure { runSuspendCatching { settingsStore.update { it.copy(publicPaykitCleanupPending = true) } } @@ -1759,6 +1815,7 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun clearPrivatePaymentLists( publicKeys: Collection, linkedPublicKeys: Set, + isStillCurrent: (() -> Boolean)? = null, ): PrivateEndpointCleanupPreparation { val cleanupKeys = publicKeys.filter { it in linkedPublicKeys || state?.contacts?.get(it)?.hasPublishedPrivatePaymentList == true @@ -1766,7 +1823,7 @@ class PrivatePaykitRepo @Inject constructor( if (cleanupKeys.isEmpty()) return PrivateEndpointCleanupPreparation(emptyList(), emptySet(), null) return runSuspendCatching { - val report = paykitSdkService.clearPrivatePaymentLists(cleanupKeys) + val report = paykitSdkService.clearPrivatePaymentLists(cleanupKeys, isStillCurrent) ?: return@runSuspendCatching PrivateEndpointCleanupPreparation(emptyList(), emptySet(), null) logPrivatePaymentListDeliveryFailures(report, "cleanup") val failedPublicKeys = ( @@ -1786,8 +1843,12 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun clearPublishedEndpointCache(publicKeys: Collection) { + private suspend fun clearPublishedEndpointCache( + publicKeys: Collection, + isStillCurrent: (() -> Boolean)? = null, + ) { if (publicKeys.isEmpty()) return + checkPaykitContactSync(isStillCurrent) publicKeys.forEach { publicKey -> state?.contacts?.get(publicKey)?.let { contactState -> @@ -1800,23 +1861,27 @@ class PrivatePaykitRepo @Inject constructor( } } - persistState(markWalletBackup = true) - updateDeletedContactCleanupPending(publicKeys, isPending = false) + persistState(markWalletBackup = true, isStillCurrent = isStillCurrent) + updateDeletedContactCleanupPending(publicKeys, isPending = false, isStillCurrent = isStillCurrent) } - private suspend fun discardInvalidCleanupKeys(publicKeys: Collection) { + private suspend fun discardInvalidCleanupKeys( + publicKeys: Collection, + isStillCurrent: (() -> Boolean)? = null, + ) { if (publicKeys.isEmpty()) return val contactState = ensureState().contacts + checkPaykitContactSync(isStillCurrent) var didRemoveContactState = false publicKeys.forEach { publicKey -> Logger.warn("Dropped invalid private Paykit cleanup key '${redacted(publicKey)}'", context = TAG) didRemoveContactState = contactState.remove(publicKey) != null || didRemoveContactState } if (didRemoveContactState) { - persistState(markWalletBackup = true) + persistState(markWalletBackup = true, isStillCurrent = isStillCurrent) } - updateDeletedContactCleanupPending(publicKeys, isPending = false) + updateDeletedContactCleanupPending(publicKeys, isPending = false, isStillCurrent = isStillCurrent) } private fun publishedEndpointCleanupState(publicKey: String): PublishedEndpointCleanupState { @@ -1848,12 +1913,16 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun clearContactStates(publicKeys: Collection) { + private suspend fun clearContactStates( + publicKeys: Collection, + isStillCurrent: (() -> Boolean)? = null, + ) { if (publicKeys.isEmpty()) return val contacts = ensureState().contacts + checkPaykitContactSync(isStillCurrent) publicKeys.forEach(contacts::remove) - persistState(markWalletBackup = true) + persistState(markWalletBackup = true, isStillCurrent = isStillCurrent) } private suspend fun privatePayableEndpoints( @@ -1950,9 +2019,14 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun hasPublishedPrivateEndpoints(): Boolean = ensureState().contacts.values.any { it.hasPublishedPrivatePaymentList } - private suspend fun updateContactSharingCleanupPending(isPending: Boolean) { + private suspend fun updateContactSharingCleanupPending( + isPending: Boolean, + isStillCurrent: (() -> Boolean)? = null, + ) { if (isPending) invalidateContactPreparation() - cacheStore.update { it.copy(cleanupPending = isPending) } + cacheStore.update { + if (isStillCurrent?.invoke() == false) it else it.copy(cleanupPending = isPending) + } } private suspend fun pendingDeletedContactCleanupPublicKeys(): Set = @@ -1961,10 +2035,15 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun updateDeletedContactCleanupPending(publicKey: String, isPending: Boolean) = updateDeletedContactCleanupPending(listOf(publicKey), isPending) - private suspend fun updateDeletedContactCleanupPending(publicKeys: Collection, isPending: Boolean) { + private suspend fun updateDeletedContactCleanupPending( + publicKeys: Collection, + isPending: Boolean, + isStillCurrent: (() -> Boolean)? = null, + ) { if (publicKeys.isEmpty()) return cacheStore.update { + if (isStillCurrent?.invoke() == false) return@update it val pendingKeys = if (isPending) { it.deletedContactCleanupPendingPublicKeys + publicKeys } else { @@ -2084,9 +2163,11 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun persistState( markWalletBackup: Boolean = false, preserveCleanupMarkers: Boolean = true, + isStillCurrent: (() -> Boolean)? = null, ) { val currentState = state ?: PrivatePaykitState() cacheStore.update { + if (isStillCurrent?.invoke() == false) return@update it currentState.cacheState( cleanupPending = if (preserveCleanupMarkers) it.cleanupPending else false, deletedContactCleanupPendingPublicKeys = if (preserveCleanupMarkers) { diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index af2f61b682..f04d68ee7d 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -835,10 +835,12 @@ class PaykitSdkService @Inject constructor( suspend fun clearPrivatePaymentLists( counterparties: List, + isStillCurrent: (() -> Boolean)? = null, ): PrivatePaymentListDeliveryReport? { if (counterparties.isEmpty()) return null isSetup.await() return operationLock.withLock { + checkPaykitContactSync(isStillCurrent) withStateRevisionTracking { handle -> val peers = completeSdkCall { handle.linkedPeers() } val blockedPeers = peers.filter { it.state == LinkedPeerState.BLOCKED } @@ -854,6 +856,7 @@ class PaykitSdkService @Inject constructor( if (app?.capabilities?.privatePayments == false) return@withStateRevisionTracking null } for (update in updates) { + checkPaykitContactSync(isStillCurrent) if (peers.any { it.state == LinkedPeerState.RECOVERY_REQUIRED && PubkyPublicKeyFormat.matches(it.counterparty, update.counterparty) @@ -866,6 +869,7 @@ class PaykitSdkService @Inject constructor( } } } + checkPaykitContactSync(isStillCurrent) completeSdkCall { handle.syncPrivatePaymentListsWithReservationsAndProcessOutbound( updates = updates, @@ -1716,6 +1720,12 @@ internal class PaykitSdkSessionProvider( } } +internal class ObsoletePaykitContactSync : CancellationException("Paykit contact sync is obsolete") + +internal fun checkPaykitContactSync(isStillCurrent: (() -> Boolean)?) { + if (isStillCurrent?.invoke() == false) throw ObsoletePaykitContactSync() +} + internal fun clearPubkySessionCredentials(deleteKeychainValue: (String) -> Unit) { val sessionResult = runCatching { deleteKeychainValue(Keychain.Key.PAYKIT_SESSION.name) } val localSecretResult = runCatching { deleteKeychainValue(Keychain.Key.PUBKY_SECRET_KEY.name) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 077a0bb2e7..91ff986ab5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -180,6 +180,7 @@ import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PubkySignIn import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.QuickPayPaymentFailedError @@ -777,6 +778,9 @@ class AppViewModel @Inject constructor( return } + val signIn = pubkyRepo.currentSignIn() + if (signIn == null || !PubkyPublicKeyFormat.matches(signIn.publicKey, state.publicKey)) return + val isStillCurrent = { isCurrentPaykitContactSync(state, signIn) } val identityChanged = !PubkyPublicKeyFormat.matches(paymentRequestIdentity, state.publicKey) if (identityChanged) { paykitPaymentProofRepo.clearOnchainPaymentResolutions() @@ -789,23 +793,10 @@ class AppViewModel @Inject constructor( isPaymentRequestIdentityActivating = true val requestRefresh = try { paykitPaymentRequestRepo.activate(state.publicKey) - if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return + if (!pubkyRepo.isCurrent(signIn)) return paymentRequestIdentity = state.publicKey refreshPrivateOnlyPaykitApp("contact sync", onlyIfNeeded = !identityChanged) - if (!state.contactsLoaded) return - - val removedKeys = lastPrivatePaykitContactKeys - state.contactKeys - if (removedKeys.isNotEmpty()) { - privatePaykitRepo.removeSavedContacts(removedKeys) - .onFailure { Logger.warn("Failed to remove private Paykit contacts", it, context = TAG) } - } - - privatePaykitRepo.scheduleSavedContactPreparation(state.contactKeys) - .onFailure { Logger.warn("Failed to prepare private Paykit contacts", it, context = TAG) } - privatePaykitRepo.pruneUnsavedContactState(state.contactKeys) - .onFailure { Logger.warn("Failed to prune private Paykit contact state", it, context = TAG) } - if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return - lastPrivatePaykitContactKeys = state.contactKeys + if (!state.contactsLoaded || !synchronizeSavedPaykitContacts(state.contactKeys, isStillCurrent)) return if (isPaymentRequestPollingStopped.value) return val result = refreshIncomingPaykitPaymentRequests(forceFresh = true, onlyWhileActive = true) if (!isPaymentRequestPollingStopped.value) refreshPaymentRequestTargets(force = true) @@ -818,6 +809,32 @@ class AppViewModel @Inject constructor( requestRefresh.onSuccess { presentNextIncomingPaykitPaymentRequest() } } + private fun isCurrentPaykitContactSync(state: PaykitContactSyncState, signIn: PubkySignIn): Boolean = + pubkyRepo.isCurrent(signIn) && isPaykitEnabled.value && + (pubkyRepo.contactsLoadVersion.value > 0L) == state.contactsLoaded && + pubkyRepo.contacts.value.map { it.publicKey }.toSet() == state.contactKeys + + private suspend fun synchronizeSavedPaykitContacts( + contactKeys: Set, + isStillCurrent: () -> Boolean, + ): Boolean { + if (!isStillCurrent()) return false + val removedKeys = lastPrivatePaykitContactKeys - contactKeys + if (removedKeys.isNotEmpty()) { + privatePaykitRepo.removeSavedContacts(removedKeys, isStillCurrent) + .onFailure { Logger.warn("Failed to remove private Paykit contacts", it, context = TAG) } + } + if (!isStillCurrent()) return false + privatePaykitRepo.scheduleSavedContactPreparation(contactKeys, isStillCurrent) + .onFailure { Logger.warn("Failed to prepare private Paykit contacts", it, context = TAG) } + if (!isStillCurrent()) return false + privatePaykitRepo.pruneUnsavedContactState(contactKeys, isStillCurrent) + .onFailure { Logger.warn("Failed to prune private Paykit contact state", it, context = TAG) } + if (!isStillCurrent()) return false + lastPrivatePaykitContactKeys = contactKeys + return true + } + private suspend fun refreshPrivatePaykitEndpointsIfEnabled( reason: String, forceRefreshLightning: Boolean = false, diff --git a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt index 2245d3402a..95f71f937a 100644 --- a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import org.junit.Before @@ -15,6 +16,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -43,12 +45,15 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { private val privatePaykitRepo: PrivatePaykitRepo = mock() private val pubkyRepo: PubkyRepo = mock() private val settingsFlow = MutableStateFlow(SettingsData()) + private val contacts = MutableStateFlow(listOf(createContact())) @Before fun setUp() { settingsFlow.value = SettingsData() whenever(settingsStore.data).thenReturn(settingsFlow) - whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(pubkyRepo.contacts).thenReturn(contacts) + whenever(pubkyRepo.currentSignIn()).thenReturn(PubkySignIn("identity", 0L)) + whenever(pubkyRepo.isCurrent(any())).thenReturn(true) whenever { privatePaykitRepo.hasPrivatePaymentAccess() }.thenReturn(true) whenever { settingsStore.update(any()) }.thenAnswer { val transform = it.getArgument<(SettingsData) -> SettingsData>(0) @@ -59,7 +64,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { whenever { publicPaykitRepo.syncPublishedEndpoints(any(), any()) }.thenReturn(Result.success(Unit)) whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) } .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>()) } + whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) @@ -81,7 +86,45 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(settingsFlow.value.publicPaykitLightningEnabled) assertTrue(settingsFlow.value.publicPaykitOnchainEnabled) verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY)) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(eq(setOf(CONTACT_KEY)), any()) + } + + @Test + fun `enabling prepares contacts saved during public publication`() = test { + val publication = CompletableDeferred() + whenever(publicPaykitRepo.syncPublishedEndpoints(publish = true)).doSuspendableAnswer { + publication.await() + Result.success(Unit) + } + contacts.update { emptyList() } + val enable = async { createSut().setEnabled(true) } + runCurrent() + verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) + + contacts.update { listOf(createContact()) } + publication.complete(Unit) + assertTrue(enable.await().isSuccess) + + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(eq(setOf(CONTACT_KEY)), any()) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(eq(emptySet()), any()) + } + + @Test + fun `enabling skips private preparation after its sign in ends`() = test { + val publication = CompletableDeferred() + whenever(publicPaykitRepo.syncPublishedEndpoints(publish = true)).doSuspendableAnswer { + publication.await() + Result.success(Unit) + } + val enable = async { createSut().setEnabled(true) } + runCurrent() + verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) + + whenever(pubkyRepo.isCurrent(any())).thenReturn(false) + publication.complete(Unit) + assertTrue(enable.await().isSuccess) + + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any(), any()) } @Test @@ -93,7 +136,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>()) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) } @Test @@ -105,7 +148,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY)) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(eq(setOf(CONTACT_KEY)), any()) verify(pubkyRepo, never()).hasSecretKey() } @@ -129,7 +172,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertEquals(previous, settingsFlow.value) verify(settingsStore, never()).update(any()) verifyNoInteractions(publicPaykitRepo) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>()) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) verify(privatePaykitRepo, never()).disableSharingAndPruneUnsavedContactState(any>()) accessAvailable = true @@ -137,7 +180,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(sut.setEnabled(true).isSuccess) assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY)) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(eq(setOf(CONTACT_KEY)), any()) } @Test @@ -158,7 +201,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertEquals(previous, settingsFlow.value) verify(settingsStore, never()).update(any()) verifyNoInteractions(publicPaykitRepo) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>()) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) verify(privatePaykitRepo, never()).disableSharingAndPruneUnsavedContactState(any>()) waitForAccess = false @@ -183,7 +226,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { @Test fun `failed private setup restores sharing with withdrawal priority only when disabled`() = test { - whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>()) } + whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>(), any()) } .thenReturn(Result.failure(ContactPaymentSettingsTestError("private setup failed"))) for (wasPublic in listOf(false, true)) { @@ -234,7 +277,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>()) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) } @Test @@ -258,7 +301,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) assertTrue(settingsFlow.value.publicPaykitCleanupPending) verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>()) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) } } @@ -309,7 +352,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) verify(publicPaykitRepo).syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive) verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY)) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(eq(setOf(CONTACT_KEY)), any()) } } } @@ -317,7 +360,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { @Test fun `sharing changes wait for failed enable rollback`() = test { val rollback = CompletableDeferred() - whenever(privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>())) + whenever(privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>(), any())) .thenReturn(Result.failure(ContactPaymentSettingsTestError("private setup failed"))) whenever(privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>())) .doSuspendableAnswer { @@ -396,7 +439,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>()) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt index fd4c70180d..d8c2e9f1b9 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -1,10 +1,17 @@ package to.bitkit.repositories import com.synonym.bitkitcore.AddressType +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -61,6 +68,80 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { ) } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `contact cleanup rechecks snapshot after ledger load and lock handoff`() = test { + for (clearUnlisted in listOf(false, true)) { + val assignment = PrivatePaykitStoredAssignmentData("nativeSegwit", 1, PRIVATE_ADDRESS) + val initial = PrivatePaykitReservationData( + contactAssignments = mapOf(CONTACT_KEY to assignment), + contactAssignmentHistory = mapOf(CONTACT_KEY to listOf(assignment)), + ) + reservationData.update { initial } + val loadStarted = CompletableDeferred() + val resumeLoad = CompletableDeferred() + whenever(reservationStore.data).thenReturn( + flow { + loadStarted.complete(Unit) + resumeLoad.await() + emit(reservationData.value) + }, + ) + sut = PrivatePaykitAddressReservationRepo( + testDispatcher, reservationStore, settingsStore, coreService, lightningRepo, + ) + var isCurrent = true + val loading = async { sut.backupSnapshot().getOrThrow() } + loadStarted.await() + val cleanup = async { + if (clearUnlisted) { + sut.clearContactAssignments(emptyList()) { isCurrent } + } else { + sut.removeContactAssignments(listOf(CONTACT_KEY)) { isCurrent } + } + } + runCurrent() + assertFalse(cleanup.isCompleted) + isCurrent = false + resumeLoad.complete(Unit) + loading.await() + cleanup.await() + + assertEquals(initial, reservationData.value) + if (clearUnlisted) { + sut.clearContactAssignments(emptyList()) { true } + } else { + sut.removeContactAssignments(listOf(CONTACT_KEY)) { true } + } + assertTrue(reservationData.value.contactAssignments.isEmpty()) + assertTrue(reservationData.value.contactAssignmentHistory.isEmpty()) + } + } + + @Test + fun `admitted assignment removal finishes when snapshot changes during persistence`() = test { + val assignment = PrivatePaykitStoredAssignmentData("nativeSegwit", 1, PRIVATE_ADDRESS) + reservationData.update { + PrivatePaykitReservationData(contactAssignments = mapOf(CONTACT_KEY to assignment)) + } + val writeStarted = CompletableDeferred() + val resumeWrite = CompletableDeferred() + var isCurrent = true + whenever(reservationStore.update(any())).doSuspendableAnswer { + val transform = it.getArgument<(PrivatePaykitReservationData) -> PrivatePaykitReservationData>(0) + writeStarted.complete(Unit) + resumeWrite.await() + reservationData.update(transform) + } + val cleanup = async { sut.removeContactAssignments(listOf(CONTACT_KEY)) { isCurrent } } + writeStarted.await() + isCurrent = false + resumeWrite.complete(Unit) + cleanup.await() + + assertTrue(reservationData.value.contactAssignments.isEmpty()) + } + @Test fun `contactsWithUsedReservedAddresses treats positive ldk address balance as used`() = test { reservationData.value = PrivatePaykitReservationData( diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 05d3e36175..0ba4a1855c 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -27,6 +27,8 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle @@ -159,7 +161,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.receivePrivateMessages(any(), any())).doSuspendableAnswer { paykitSdkService.receivePrivateMessages(it.getArgument(0)) } - whenever { paykitSdkService.clearPrivatePaymentLists(any()) }.thenAnswer { + whenever { paykitSdkService.clearPrivatePaymentLists(any(), anyOrNull()) }.thenAnswer { privateListDeliveryReport(clearedCounterparties = it.getArgument(0)) } whenever { publicPaykitRepo.beginPayment(any()) } @@ -181,6 +183,129 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { App.currentActivity = null } + @Test + fun `obsolete contact sync preserves the admitted retry and its original priority deadline`() = test { + whenever(clock.now()).thenAnswer { + Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds + } + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKING))) + val resumeAdvance = CompletableDeferred() + var finished = false + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).doSuspendableAnswer { + resumeAdvance.await() + finished = true + LinkedPeerHandshakeReport(CONTACT_KEY, LinkedPeerState.LINKING, 1uL, null) + } + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + advanceTimeBy(12_000) + + sut.scheduleSavedContactPreparation(emptyList()) { false }.getOrThrow() + sut.pruneUnsavedContactState(emptyList()) { false }.getOrThrow() + sut.removeSavedContacts(listOf(CONTACT_KEY)) { false }.getOrThrow() + sut.enableSharingAndPrepareSavedContacts(emptyList()) { false }.getOrThrow() + + verifyNoInteractions(addressReservationRepo) + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY, 1u, Priority.Interactive) + resumeAdvance.complete(Unit) + runCurrent() + assertTrue(finished) + advanceTimeBy(12_000) + runCurrent() + verify(paykitSdkService, times(3)).ensureLinkWithPeer(CONTACT_KEY, 1u, Priority.Interactive) + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY, 1u, Priority.Background) + sut.closeAndClear() + } + + @Test + fun `pruning rechecks contact snapshot after cold cache load`() = test { + cacheData.update { PrivatePaykitCacheData(contacts = mapOf(CONTACT_KEY to cachedPublishedContact())) } + val loadStarted = CompletableDeferred() + val resumeLoad = CompletableDeferred() + var holdLoad = true + var isCurrent = true + whenever(cacheStore.data).thenReturn( + flow { + if (holdLoad) { + loadStarted.complete(Unit) + resumeLoad.await() + } + emit(cacheData.value) + }, + ) + sut = createSut() + sut.setContactPreparationActive(false) + val prune = async { sut.pruneUnsavedContactState(emptyList()) { isCurrent } } + loadStarted.await() + + isCurrent = false + holdLoad = false + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + resumeLoad.complete(Unit) + prune.await().getOrThrow() + + assertTrue(cacheData.value.contacts.containsKey(CONTACT_KEY)) + verifyNoInteractions(addressReservationRepo) + verify(paykitSdkService, never()).clearPrivatePaymentLists(any(), anyOrNull()) + sut.setContactPreparationActive(true) + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY, 1u, Priority.Interactive) + sut.closeAndClear() + } + + @Test + fun `contact readd during withdrawal preserves cache assignments and explicit retry`() = test { + cacheData.update { PrivatePaykitCacheData(contacts = mapOf(CONTACT_KEY to cachedPublishedContact())) } + sut = createSut() + sut.setContactPreparationActive(false) + val withdrawalStarted = CompletableDeferred() + val resumeWithdrawal = CompletableDeferred() + var isCurrent = true + whenever(paykitSdkService.clearPrivatePaymentLists(any(), any())).doSuspendableAnswer { + withdrawalStarted.complete(Unit) + resumeWithdrawal.await() + privateListDeliveryReport(clearedCounterparties = listOf(CONTACT_KEY)) + } + val removal = async { sut.removeSavedContacts(listOf(CONTACT_KEY)) { isCurrent } } + withdrawalStarted.await() + + isCurrent = false + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + resumeWithdrawal.complete(Unit) + removal.await().getOrThrow() + + assertEquals(cachedPublishedContact(), cacheData.value.contacts[CONTACT_KEY]) + assertTrue(cacheData.value.deletedContactCleanupPendingPublicKeys.isEmpty()) + verifyNoInteractions(addressReservationRepo) + sut.setContactPreparationActive(true) + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY, 1u, Priority.Interactive) + sut.closeAndClear() + } + + @Test + fun `enabling sharing rechecks snapshot inside cleanup flag update`() = test { + cacheData.update { it.copy(cleanupPending = true) } + val updateStarted = CompletableDeferred() + val resumeUpdate = CompletableDeferred() + var isCurrent = true + whenever(cacheStore.update(any())).doSuspendableAnswer { + val transform = it.getArgument<(PrivatePaykitCacheData) -> PrivatePaykitCacheData>(0) + updateStarted.complete(Unit) + resumeUpdate.await() + cacheData.update(transform) + } + val enabling = async { sut.enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY)) { isCurrent } } + updateStarted.await() + isCurrent = false + resumeUpdate.complete(Unit) + + enabling.await().getOrThrow() + + assertTrue(cacheData.value.cleanupPending) + verifyNoInteractions(paykitSdkService, addressReservationRepo) + } + @Test fun `handleOnchainActivity skips SDK access when publication is locally unnecessary`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) @@ -1481,7 +1606,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(cleanup.isSuccess, cleanup.exceptionOrNull().toString()) verifyBlocking(addressReservationRepo, never()) { currentOrRotatedAddress(any()) } verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } - verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentLists(any()) } + verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentLists(any(), anyOrNull()) } } @Test @@ -1523,7 +1648,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(sut.disableSharingAndPruneUnsavedContactState(emptyList()).isFailure) assertTrue(cacheData.value.contacts.isEmpty()) assertTrue(cacheData.value.cleanupPending) - verify(paykitSdkService, never()).clearPrivatePaymentLists(any()) + verify(paykitSdkService, never()).clearPrivatePaymentLists(any(), anyOrNull()) failLookup = false assertTrue(sut.retryPendingEndpointRemoval(emptyList()).isFailure) @@ -1739,7 +1864,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(PrivatePaykitError.PrivateUnavailable, result.exceptionOrNull()) assertTrue(cacheData.value.cleanupPending) assertTrue(cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList) - verifyBlocking(addressReservationRepo, never()) { clearContactAssignments(excludingPublicKeys = any()) } + verifyBlocking(addressReservationRepo, never()) { clearContactAssignments(any(), anyOrNull()) } } @Test @@ -1784,7 +1909,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() cacheData.value = cacheData.value.copy(cleanupPending = false) settingsData.value = settingsData.value.copy(sharesPrivatePaykitEndpoints = false) - whenever { addressReservationRepo.clearContactAssignments(excludingPublicKeys = any()) } + whenever { addressReservationRepo.clearContactAssignments(any(), anyOrNull()) } .thenThrow(IllegalStateException("storage unavailable")) val result = sut.retryPendingEndpointRemoval(listOf(CONTACT_KEY)) @@ -1860,7 +1985,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess, result.exceptionOrNull().toString()) assertTrue(cacheData.value.contacts.isEmpty()) assertTrue(cacheData.value.deletedContactCleanupPendingPublicKeys.isEmpty()) - verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentLists(any()) } + verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentLists(any(), anyOrNull()) } } @Test @@ -2134,7 +2259,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) sut.removeSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)).getOrThrow() - verify(paykitSdkService, never()).clearPrivatePaymentLists(any()) + verify(paykitSdkService, never()).clearPrivatePaymentLists(any(), anyOrNull()) verify(addressReservationRepo).removeContactAssignments(setOf(CONTACT_KEY, OTHER_CONTACT_KEY)) sut.endProfileDeletion() diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 00b5cd8cb6..e25f9f4476 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -2137,6 +2137,71 @@ class PaykitSdkServiceTest { verify(newSdk).saveContactsAndUnblockPeers(any()) } + @Test + fun `obsolete contact withdrawal is rejected after queue and preflight waits`() = runTest { + for (holdQueue in listOf(false, true)) { + val sdk = mock() + val started = CompletableDeferred() + val resume = CompletableDeferred() + whenever(sdk.contactRecords()).doSuspendableAnswer { + started.complete(Unit) + resume.await() + emptyList() + } + whenever(sdk.linkedPeers()).doSuspendableAnswer { + if (!holdQueue) { + started.complete(Unit) + resume.await() + } + listOf(contactPeer(LinkedPeerState.LINKED)) + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + var isCurrent = true + val read = if (holdQueue) async { service.contactRecords() } else null + if (holdQueue) started.await() + val cleanup = async { + assertFailsWith { + service.clearPrivatePaymentLists(listOf(RING_PUBKY)) { isCurrent } + } + } + runCurrent() + started.await() + assertFalse(cleanup.isCompleted) + isCurrent = false + resume.complete(Unit) + read?.await() + cleanup.await() + + verify(sdk, never()).syncPrivatePaymentListsWithReservationsAndProcessOutbound(any(), any()) + verify(sdk, never()).ensureLinkWithPeer(any(), any()) + service.clearPrivatePaymentLists(listOf(RING_PUBKY)) { true } + verify(sdk).syncPrivatePaymentListsWithReservationsAndProcessOutbound(any(), any()) + } + } + + @Test + fun `admitted withdrawal finishes after contact snapshot changes`() = runTest { + val sdk = mock() + val started = CompletableDeferred() + val resume = CompletableDeferred() + val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList()) + whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.LINKED))) + whenever(sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(any(), any())).doSuspendableAnswer { + started.complete(Unit) + resume.await() + report + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + var isCurrent = true + val cleanup = async { service.clearPrivatePaymentLists(listOf(RING_PUBKY)) { isCurrent } } + started.await() + isCurrent = false + resume.complete(Unit) + + assertSame(report, cleanup.await()) + verify(sdk).syncPrivatePaymentListsWithReservationsAndProcessOutbound(any(), any()) + } + @Test fun `blocked peer cleanup does not attempt network delivery`() = runTest { val sdk = mock() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index db58ecb5b4..4f5d14f7c8 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -157,6 +157,7 @@ import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyIdentityReadiness import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PubkySignIn import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.QuickPayCompletionKind @@ -417,6 +418,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(pubkyRepo.adoptedSourceLost).thenReturn(MutableStateFlow(false)) whenever(pubkyRepo.contactImportFailure).thenReturn(pubkyContactImportFailure) whenever(pubkyRepo.publicKey).thenReturn(pubkyPublicKey) + whenever(pubkyRepo.currentSignIn()).thenAnswer { pubkyPublicKey.value?.let { PubkySignIn(it, 0L) } } + whenever(pubkyRepo.isCurrent(any())).thenAnswer { + pubkyPublicKey.value == it.getArgument(0).publicKey + } whenever { pubkyRepo.republishIdentityIfNeeded() }.thenReturn(Result.success(Unit)) whenever { pubkyRepo.hasIdentity() }.thenAnswer { pubkyPublicKey.value != null } whenever { pubkyRepo.awaitIdentityReady() }.thenAnswer { @@ -478,9 +483,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paykitPaymentProofRepo.markOnchainPaymentStarted(any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { activityRepo.setContact(any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.scheduleSavedContactPreparation(any>()) } + whenever { privatePaykitRepo.scheduleSavedContactPreparation(any>(), any()) } .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.pruneUnsavedContactState(any>()) } + whenever { privatePaykitRepo.pruneUnsavedContactState(any>(), any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any()) } .thenReturn(Result.success(Unit)) @@ -491,7 +496,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.removeSavedContact(any()) }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.removeSavedContacts(any()) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.removeSavedContacts(any(), any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.reconcileReceivedPayments() }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.handleOnchainActivity(any>()) } .thenReturn(Result.success(Unit)) @@ -9637,8 +9642,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(publicPaykitRepo).syncPaykitApp() - verify(privatePaykitRepo, never()).scheduleSavedContactPreparation(any>()) - verify(privatePaykitRepo, never()).pruneUnsavedContactState(any>()) + verify(privatePaykitRepo, never()).scheduleSavedContactPreparation(any>(), any()) + verify(privatePaykitRepo, never()).pruneUnsavedContactState(any>(), any()) val prepared = CompletableDeferred() whenever(privatePaykitRepo.awaitContactPreparation()).doSuspendableAnswer { prepared.await() } @@ -9649,8 +9654,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo, never()).awaitContactPreparation() advanceUntilIdle() - verify(privatePaykitRepo).scheduleSavedContactPreparation(any>()) - verify(privatePaykitRepo).pruneUnsavedContactState(any>()) + verify(privatePaykitRepo).scheduleSavedContactPreparation(any>(), any()) + verify(privatePaykitRepo).pruneUnsavedContactState(any>(), any()) verify(paykitPaymentRequestRepo).refreshEligibleTargets(any(), eq(true)) verify(publicPaykitRepo, never()).syncPaykitApp() } @@ -9678,7 +9683,90 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyContacts.value = listOf(contact) advanceUntilIdle() verify(publicPaykitRepo, times(2)).syncPaykitApp() - verify(privatePaykitRepo).scheduleSavedContactPreparation(setOf(contact.publicKey)) + verify(privatePaykitRepo).scheduleSavedContactPreparation(eq(setOf(contact.publicKey)), any()) + } + + @Test + fun `contact sync discards its old membership after app publication waits`() = test { + enablePaykitUi() + settingsData.update { it.copy(sharesPrivatePaykitEndpoints = true) } + advanceUntilIdle() + val publication = CompletableDeferred() + whenever(publicPaykitRepo.syncPaykitApp()).doSuspendableAnswer { + publication.await() + Result.success(Unit) + } + pubkyContactsLoadVersion.update { 1L } + advanceUntilIdle() + pubkyPublicKey.update { testPublicKey } + runCurrent() + verify(publicPaykitRepo).syncPaykitApp() + + val contact = PubkyProfile.placeholder("pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg") + pubkyContacts.update { listOf(contact) } + runCurrent() + publication.complete(Unit) + advanceUntilIdle() + + verify(privatePaykitRepo, never()).scheduleSavedContactPreparation(eq(emptySet()), any()) + verify(privatePaykitRepo, never()).pruneUnsavedContactState(eq(emptySet()), any()) + verify(privatePaykitRepo).scheduleSavedContactPreparation(eq(setOf(contact.publicKey)), any()) + verify(privatePaykitRepo).pruneUnsavedContactState(eq(setOf(contact.publicKey)), any()) + } + + @Test + fun `contact sync invalidates cleanup when a removed contact is readded`() = test { + enablePaykitUi() + val contact = PubkyProfile.placeholder("pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg") + pubkyPublicKey.update { testPublicKey } + pubkyContacts.update { listOf(contact) } + pubkyContactsLoadVersion.update { 1L } + advanceUntilIdle() + clearInvocations(privatePaykitRepo) + val removal = CompletableDeferred() + var isRemovalCurrent: (() -> Boolean)? = null + whenever(privatePaykitRepo.removeSavedContacts(any(), any())).doSuspendableAnswer { + isRemovalCurrent = it.getArgument(1) + removal.await() + Result.success(Unit) + } + + pubkyContacts.update { emptyList() } + runCurrent() + assertTrue(requireNotNull(isRemovalCurrent).invoke()) + pubkyContacts.update { listOf(contact) } + runCurrent() + assertFalse(requireNotNull(isRemovalCurrent).invoke()) + removal.complete(Unit) + advanceUntilIdle() + + verify(privatePaykitRepo, never()).scheduleSavedContactPreparation(eq(emptySet()), any()) + verify(privatePaykitRepo, never()).pruneUnsavedContactState(eq(emptySet()), any()) + verify(privatePaykitRepo).scheduleSavedContactPreparation(eq(setOf(contact.publicKey)), any()) + } + + @Test + fun `contact sync skips cleanup when its sign in ends during publication`() = test { + enablePaykitUi() + settingsData.update { it.copy(sharesPrivatePaykitEndpoints = true) } + pubkyContactsLoadVersion.update { 1L } + advanceUntilIdle() + val publication = CompletableDeferred() + whenever(publicPaykitRepo.syncPaykitApp()).doSuspendableAnswer { + publication.await() + Result.success(Unit) + } + pubkyPublicKey.update { testPublicKey } + runCurrent() + verify(publicPaykitRepo).syncPaykitApp() + + whenever(pubkyRepo.isCurrent(any())).thenReturn(false) + publication.complete(Unit) + advanceUntilIdle() + + verify(privatePaykitRepo, never()).removeSavedContacts(any(), any()) + verify(privatePaykitRepo, never()).scheduleSavedContactPreparation(any(), any()) + verify(privatePaykitRepo, never()).pruneUnsavedContactState(any(), any()) } @Test @@ -9690,14 +9778,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyContacts.value = listOf(contact) pubkyContactsLoadVersion.value = 1L advanceUntilIdle() - verify(privatePaykitRepo).scheduleSavedContactPreparation(setOf(contact.publicKey)) + verify(privatePaykitRepo).scheduleSavedContactPreparation(eq(setOf(contact.publicKey)), any()) clearInvocations(privatePaykitRepo) pubkyContacts.value = listOf(contact.copy(name = "Bob", imageUrl = "pubky://avatar")) advanceUntilIdle() verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) - verify(privatePaykitRepo, never()).scheduleSavedContactPreparation(any()) + verify(privatePaykitRepo, never()).scheduleSavedContactPreparation(any(), any()) } @Test @@ -9723,11 +9811,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyContactsLoadVersion.value = 2L advanceUntilIdle() - verify(privatePaykitRepo).removeSavedContacts(setOf(contact.publicKey)) - verify(privatePaykitRepo).scheduleSavedContactPreparation(emptySet()) - verify(privatePaykitRepo).pruneUnsavedContactState(emptySet()) + verify(privatePaykitRepo).removeSavedContacts(eq(setOf(contact.publicKey)), any()) + verify(privatePaykitRepo).scheduleSavedContactPreparation(eq(emptySet()), any()) + verify(privatePaykitRepo).pruneUnsavedContactState(eq(emptySet()), any()) inOrder(privatePaykitRepo, paykitPaymentRequestRepo).apply { - verify(privatePaykitRepo).removeSavedContacts(setOf(contact.publicKey)) + verify(privatePaykitRepo).removeSavedContacts(eq(setOf(contact.publicKey)), any()) verify(paykitPaymentRequestRepo).refreshAfterStateChange() } verify(paykitPaymentRequestRepo, never()).refresh(any()) @@ -9739,7 +9827,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.update { testPublicKey } runCurrent() val contactWork = CompletableDeferred() - whenever(privatePaykitRepo.pruneUnsavedContactState(any>())).doSuspendableAnswer { + whenever(privatePaykitRepo.pruneUnsavedContactState(any>(), any())).doSuspendableAnswer { contactWork.await() Result.success(Unit) } @@ -9750,7 +9838,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyContactsLoadVersion.update { 1L } sut.refreshPrivatePaykitEndpoints() runCurrent() - verify(privatePaykitRepo).pruneUnsavedContactState(emptySet()) + verify(privatePaykitRepo).pruneUnsavedContactState(eq(emptySet()), any()) verify(privatePaykitRepo).refreshKnownSavedContactEndpoints("foreground", forceRefreshLightning = false) sut.stopPaykitPaymentRequestPolling() diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index 3ef45aa021..de9973f6d8 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -54,6 +54,20 @@ with one withdrawal failing: OFF remains immediate, cleanup remains pending on f publication starts. Record action-to-result time separately from SDK lock and network waits; these fault-injection checks do not establish staging latency or a guaranteed completion deadline. +## Contact synchronization overlap + +Hold contact synchronization before private preparation or cleanup, then save a new contact and +start its explicit link retry. Release synchronization and verify that the contact stays saved, +its retry retains the original priority deadline, and the older contact list does not withdraw +its payment endpoints. On Android, hold the startup app-registry refresh; on iOS, hold public +publication while enabling contact payments and leave Settings to save the contact. + +Repeat by removing and re-adding a contact while synchronization's cleanup is queued, then by changing identity. +Obsolete cleanup must not cancel the new retry or remove its assignments. A deletion that stays +current must still remove the contact. These checks require controlled operation blocking and +retry-state inspection, which the standard journey runner does not provide; record timing +separately from the correctness result. + ## Background preparation Sign in from Pubky Ring with a saved, request-capable contact while holding the selected From 57a370e5e0d25219c95919b38400239dcd1f1485 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 9 Oct 2026 04:16:46 -0500 Subject: [PATCH 6/8] fix: keep sharing enabled across contact changes --- .../ContactPaymentSettingsRepo.kt | 8 +-- .../bitkit/repositories/PrivatePaykitRepo.kt | 11 +--- .../ContactPaymentSettingsRepoTest.kt | 66 ++++++++++++++----- .../repositories/PrivatePaykitRepoTest.kt | 50 ++++++++++++-- journeys/contacts/README.md | 6 ++ 5 files changed, 108 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt index 909157c013..19b9e789c2 100644 --- a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -100,10 +100,10 @@ class ContactPaymentSettingsRepo @Inject constructor( private suspend fun prepareCurrentSavedContacts(signIn: PubkySignIn?): Result { if (signIn == null || !pubkyRepo.isCurrent(signIn)) return Result.success(Unit) - val contacts = pubkyRepo.contacts.value.map { it.publicKey }.toSet() - return privatePaykitRepo.enableSharingAndPrepareSavedContacts(contacts) { - pubkyRepo.isCurrent(signIn) && pubkyRepo.contacts.value.map { it.publicKey }.toSet() == contacts - } + return privatePaykitRepo.enableSharingAndPrepareSavedContacts( + savedPublicKeys = { pubkyRepo.contacts.value.map { it.publicKey }.toSet() }, + isStillCurrent = { pubkyRepo.isCurrent(signIn) }, + ) } private suspend fun rollbackEnabled( diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index a2686817ea..3156e35702 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -229,20 +229,15 @@ class PrivatePaykitRepo @Inject constructor( } suspend fun enableSharingAndPrepareSavedContacts( - publicKeys: Collection, + savedPublicKeys: () -> Collection, isStillCurrent: (() -> Boolean)? = null, ): Result = withContext(serializedDispatcher) { runContactSync { - checkPaykitContactSync(isStillCurrent) - val wasCleanupPending = isContactSharingCleanupPending() checkPaykitContactSync(isStillCurrent) updateContactSharingCleanupPending(false, isStillCurrent) checkPaykitContactSync(isStillCurrent) - scheduleSavedContactPreparation(publicKeys, isStillCurrent).onFailure { - if (wasCleanupPending) { - runSuspendCatching { updateContactSharingCleanupPending(true) }.onFailure(it::addSuppressed) - } - }.getOrThrow() + val keys = rememberSavedContacts(savedPublicKeys(), replacing = true) + scheduleContactPreparation(keys) } } diff --git a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt index 95f71f937a..2adacf44ff 100644 --- a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt @@ -14,9 +14,9 @@ import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doSuspendableAnswer -import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -64,7 +64,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { whenever { publicPaykitRepo.syncPublishedEndpoints(any(), any()) }.thenReturn(Result.success(Unit)) whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) } .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>(), any()) } + whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any<() -> Collection>(), any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) @@ -86,7 +86,10 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(settingsFlow.value.publicPaykitLightningEnabled) assertTrue(settingsFlow.value.publicPaykitOnchainEnabled) verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(eq(setOf(CONTACT_KEY)), any()) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts( + argThat { invoke().toSet() == setOf(CONTACT_KEY) }, + any(), + ) } @Test @@ -105,8 +108,27 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { publication.complete(Unit) assertTrue(enable.await().isSuccess) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(eq(setOf(CONTACT_KEY)), any()) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(eq(emptySet()), any()) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts( + argThat { invoke().toSet() == setOf(CONTACT_KEY) }, + any(), + ) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(argThat { invoke().isEmpty() }, any()) + } + + @Test + fun `contact changes do not invalidate the sharing enable transition`() = test { + whenever(privatePaykitRepo.enableSharingAndPrepareSavedContacts(any(), any())).thenAnswer { + contacts.update { emptyList() } + val isCurrent = it.getArgument<() -> Boolean>(1) + assertTrue(isCurrent()) + val savedPublicKeys = it.getArgument<() -> Collection>(0) + assertTrue(savedPublicKeys().isEmpty()) + whenever(pubkyRepo.isCurrent(any())).thenReturn(false) + assertFalse(isCurrent()) + Result.success(Unit) + } + + assertTrue(createSut().setEnabled(true).isSuccess) } @Test @@ -136,7 +158,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any<() -> Collection>(), any()) } @Test @@ -148,7 +170,10 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(eq(setOf(CONTACT_KEY)), any()) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts( + argThat { invoke().toSet() == setOf(CONTACT_KEY) }, + any(), + ) verify(pubkyRepo, never()).hasSecretKey() } @@ -172,7 +197,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertEquals(previous, settingsFlow.value) verify(settingsStore, never()).update(any()) verifyNoInteractions(publicPaykitRepo) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any<() -> Collection>(), any()) verify(privatePaykitRepo, never()).disableSharingAndPruneUnsavedContactState(any>()) accessAvailable = true @@ -180,7 +205,10 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(sut.setEnabled(true).isSuccess) assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(eq(setOf(CONTACT_KEY)), any()) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts( + argThat { invoke().toSet() == setOf(CONTACT_KEY) }, + any(), + ) } @Test @@ -201,7 +229,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertEquals(previous, settingsFlow.value) verify(settingsStore, never()).update(any()) verifyNoInteractions(publicPaykitRepo) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any<() -> Collection>(), any()) verify(privatePaykitRepo, never()).disableSharingAndPruneUnsavedContactState(any>()) waitForAccess = false @@ -226,7 +254,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { @Test fun `failed private setup restores sharing with withdrawal priority only when disabled`() = test { - whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>(), any()) } + whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any<() -> Collection>(), any()) } .thenReturn(Result.failure(ContactPaymentSettingsTestError("private setup failed"))) for (wasPublic in listOf(false, true)) { @@ -277,7 +305,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any<() -> Collection>(), any()) } @Test @@ -301,7 +329,10 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) assertTrue(settingsFlow.value.publicPaykitCleanupPending) verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts( + any<() -> Collection>(), + any(), + ) } } @@ -352,7 +383,10 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) verify(publicPaykitRepo).syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive) verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(eq(setOf(CONTACT_KEY)), any()) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts( + argThat { invoke().toSet() == setOf(CONTACT_KEY) }, + any(), + ) } } } @@ -360,7 +394,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { @Test fun `sharing changes wait for failed enable rollback`() = test { val rollback = CompletableDeferred() - whenever(privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>(), any())) + whenever(privatePaykitRepo.enableSharingAndPrepareSavedContacts(any<() -> Collection>(), any())) .thenReturn(Result.failure(ContactPaymentSettingsTestError("private setup failed"))) whenever(privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>())) .doSuspendableAnswer { @@ -439,7 +473,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any<() -> Collection>(), any()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 0ba4a1855c..ca895ea27d 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -200,10 +200,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { runCurrent() advanceTimeBy(12_000) + sut.enableSharingAndPrepareSavedContacts({ listOf(CONTACT_KEY) }).getOrThrow() sut.scheduleSavedContactPreparation(emptyList()) { false }.getOrThrow() sut.pruneUnsavedContactState(emptyList()) { false }.getOrThrow() sut.removeSavedContacts(listOf(CONTACT_KEY)) { false }.getOrThrow() - sut.enableSharingAndPrepareSavedContacts(emptyList()) { false }.getOrThrow() + sut.enableSharingAndPrepareSavedContacts({ emptyList() }) { false }.getOrThrow() verifyNoInteractions(addressReservationRepo) verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY, 1u, Priority.Interactive) @@ -284,7 +285,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `enabling sharing rechecks snapshot inside cleanup flag update`() = test { + fun `enabling sharing rechecks sign in inside cleanup flag update`() = test { cacheData.update { it.copy(cleanupPending = true) } val updateStarted = CompletableDeferred() val resumeUpdate = CompletableDeferred() @@ -295,7 +296,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { resumeUpdate.await() cacheData.update(transform) } - val enabling = async { sut.enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY)) { isCurrent } } + val enabling = async { sut.enableSharingAndPrepareSavedContacts({ listOf(CONTACT_KEY) }) { isCurrent } } updateStarted.await() isCurrent = false resumeUpdate.complete(Unit) @@ -306,6 +307,45 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyNoInteractions(paykitSdkService, addressReservationRepo) } + @Test + fun `enabling sharing reschedules current contacts after pending cleanup skipped preparation`() = test { + settingsData.update { SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) } + cacheData.update { + it.copy(cleanupPending = true, contacts = mapOf(CONTACT_KEY to cachedPublishedContact())) + } + sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + verifyNoInteractions(paykitSdkService, addressReservationRepo) + + val updateStarted = CompletableDeferred() + val resumeUpdate = CompletableDeferred() + var holdUpdate = true + var contacts = listOf(CONTACT_KEY) + whenever(cacheStore.update(any())).doSuspendableAnswer { + val transform = it.getArgument<(PrivatePaykitCacheData) -> PrivatePaykitCacheData>(0) + cacheData.update(transform) + if (holdUpdate) { + holdUpdate = false + updateStarted.complete(Unit) + resumeUpdate.await() + } + } + val enabling = async { sut.enableSharingAndPrepareSavedContacts({ contacts }) { true } } + updateStarted.await() + contacts = listOf(CONTACT_KEY, OTHER_CONTACT_KEY) + resumeUpdate.complete(Unit) + enabling.await().getOrThrow() + sut.retryPendingEndpointRemoval(contacts).getOrThrow() + runCurrent() + sut.awaitContactPreparation() + + assertFalse(cacheData.value.cleanupPending) + verify(paykitSdkService, never()).clearPrivatePaymentLists(any(), anyOrNull()) + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + verify(paykitSdkService).ensureLinkWithPeer(OTHER_CONTACT_KEY) + sut.closeAndClear() + } + @Test fun `handleOnchainActivity skips SDK access when publication is locally unnecessary`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) @@ -1876,7 +1916,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitOnchainEnabled = true, ) - val result = sut.enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY)) + val result = sut.enableSharingAndPrepareSavedContacts({ listOf(CONTACT_KEY) }) assertTrue(result.isSuccess, result.exceptionOrNull().toString()) assertFalse(cacheData.value.cleanupPending) @@ -2234,7 +2274,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { LinkedPeerHandshakeReport(CONTACT_KEY, LinkedPeerState.LINKED, 1uL, null) } - assertTrue(sut.enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY)).isSuccess) + assertTrue(sut.enableSharingAndPrepareSavedContacts({ listOf(CONTACT_KEY) }).isSuccess) runCurrent() assertTrue(linkStarted.isCompleted) repeat(3) { sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() } diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index de9973f6d8..c3af3af6c7 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -68,6 +68,12 @@ current must still remove the contact. These checks require controlled operation retry-state inspection, which the standard journey runner does not provide; record timing separately from the correctness result. +Repeat enabling contact payments with a pending private withdrawal. Change saved contacts while +the cleanup flag update is held, release it, and run the removal retry. Sharing must stay enabled, +the old withdrawal must not run, and current contacts must be prepared without resetting an +existing explicit retry deadline. On iOS the cleanup flag is updated synchronously; hold the +following private-preparation call instead. + ## Background preparation Sign in from Pubky Ring with a saved, request-capable contact while holding the selected From 1c8f938cba827d2174892e040cf61c963ac00eff Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 9 Oct 2026 05:16:38 -0500 Subject: [PATCH 7/8] fix: bound unavailable contact retries --- .../bitkit/repositories/PrivatePaykitRepo.kt | 30 +++++- .../RefreshContactPaykitLinkUseCase.kt | 5 +- .../repositories/PrivatePaykitRepoTest.kt | 101 +++++++++++++++++- .../RefreshContactPaykitLinkUseCaseTest.kt | 50 ++++++++- journeys/contacts/README.md | 9 ++ 5 files changed, 183 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 3156e35702..aefbcd62c5 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -156,6 +156,13 @@ class PrivatePaykitRepo @Inject constructor( var prepareEndpoints = false var refreshReadiness = false + fun stopIfUnavailable(counterparty: String, error: Throwable) { + if (publicKey == counterparty && error is PaykitException.NotFound) { + prepareEndpoints = false + refreshReadiness = false + } + } + fun recordQueuedPublication(report: PrivatePaymentListDeliveryReport, publicationWindow: KotlinInstant?) { if (interactiveUntil == publicationWindow && report.queued.any { PubkyPublicKeyFormat.matches(it.counterparty, publicKey) } @@ -346,13 +353,21 @@ class PrivatePaykitRepo @Inject constructor( suspend fun refreshSavedContactEndpoints( publicKey: String, savedPublicKeys: Collection, + identity: String? = null, + isStillCurrent: (() -> Boolean)? = null, ): Result = withContext(serializedDispatcher) { - runSuspendCatching { - val normalizedKey = normalizedPublicKey(publicKey) ?: return@runSuspendCatching - if (isDeletingProfile || isContactSharingCleanupPending()) return@runSuspendCatching + runContactSync { + val normalizedKey = normalizedPublicKey(publicKey) ?: return@runContactSync + if (isDeletingProfile || isContactSharingCleanupPending()) return@runContactSync + checkPaykitContactSync(isStillCurrent) rememberSavedContacts(savedPublicKeys + normalizedKey, replacing = false) - schedulePendingPrivateMessageDrainRetries("refresh", listOf(normalizedKey), explicit = true) + schedulePendingPrivateMessageDrainRetries( + "refresh", + listOf(normalizedKey), + explicit = true, + identity = identity, + ) } } @@ -1066,6 +1081,7 @@ class PrivatePaykitRepo @Inject constructor( if (generation != preparationGeneration) return@onFailure if (isUnavailable) { unavailableLinkRetryAt[publicKey] = clock.now() + unavailableLinkRetryDelay + currentCoroutineContext()[PrivateMessageDrainRetry]?.stopIfUnavailable(publicKey, it) } else if (it is PaykitException.Transport) { unavailableLinkRetryAt.remove(publicKey) } @@ -1416,7 +1432,7 @@ class PrivatePaykitRepo @Inject constructor( if (retry.prepareEndpoints) { if (canPublishPrivateEndpoints(status)) { publishLocalEndpoints(keys, reason, priority = priority).getOrThrow() - return true + return hasPendingPrivateMessageRetry(retry, priority) } retry.prepareEndpoints = false } @@ -1438,6 +1454,10 @@ class PrivatePaykitRepo @Inject constructor( return readinessPending || pendingPrivateMessageDrainKeys(keys, priority = priority).isNotEmpty() } + private suspend fun hasPendingPrivateMessageRetry(retry: PrivateMessageDrainRetry, priority: Priority): Boolean = + retry.prepareEndpoints || retry.refreshReadiness || + pendingPrivateMessageDrainKeys(listOf(retry.publicKey), priority = priority).isNotEmpty() + private suspend fun pendingPrivateMessageDrainKeys( retryKeys: Collection, retryMissingPeers: Boolean = false, diff --git a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt index e85ff5460e..45c230ebee 100644 --- a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt @@ -21,8 +21,11 @@ class RefreshContactPaykitLinkUseCase @Inject constructor( suspend operator fun invoke(publicKey: String): Result = withContext(ioDispatcher) { runSuspendCatching { + val signIn = pubkyRepo.currentSignIn() ?: return@runSuspendCatching val savedPublicKeys = (pubkyRepo.contacts.value.map { it.publicKey } + publicKey).distinct() - privatePaykitRepo.refreshSavedContactEndpoints(publicKey, savedPublicKeys).getOrThrow() + privatePaykitRepo.refreshSavedContactEndpoints(publicKey, savedPublicKeys, signIn.publicKey) { + pubkyRepo.isCurrent(signIn) + }.getOrThrow() }.onFailure { Logger.warn( "Failed to refresh the Paykit link for '${PubkyPublicKeyFormat.redacted(publicKey)}'", diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index ca895ea27d..366634efc6 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -569,6 +569,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) val completion = CompletableDeferred() var publications = 0 + var deliveryPending = false + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenAnswer { + if (deliveryPending) listOf(CONTACT_KEY) else emptyList() + } + whenever(paykitSdkService.processOutboundPrivateMessages(CONTACT_KEY)).thenAnswer { + deliveryPending = false + mock() + } whenever(paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any())).doSuspendableAnswer { when (++publications) { 1 -> { @@ -576,7 +584,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { throw PaykitException.Transport("offline", "Unavailable homeserver") } 2 -> privateListDeliveryReport(failedToQueue = listOf(privateListSyncChange(CONTACT_KEY))) - else -> privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY)) + else -> { + deliveryPending = true + privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY)) + } } } @@ -596,12 +607,100 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { advanceTimeBy(120_000) runCurrent() assertEquals(3, publications) + assertFalse(deliveryPending) + verify(paykitSdkService).processOutboundPrivateMessages(CONTACT_KEY) } finally { completion.complete(Unit) sut.closeAndClear() } } + @Test + fun `cached identity schedules without SDK reads and validates before linking`() = test { + val identity = CompletableDeferred() + whenever(paykitSdkService.identityStatus()).doSuspendableAnswer { identity.await() } + whenever(pubkyService.currentPublicKey()).doSuspendableAnswer { awaitCancellation() } + + try { + val scheduled = async { + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY), OWN_KEY) { true } + } + runCurrent() + assertTrue(scheduled.isCompleted) + scheduled.await().getOrThrow() + verify(pubkyService, never()).currentPublicKey() + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) + + identity.complete(IdentityStatus(OTHER_CONTACT_KEY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + runCurrent() + advanceTimeBy(120_000) + runCurrent() + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) + } finally { + sut.closeAndClear() + } + } + + @Test + fun `contact refresh checks sign in after loading cleanup state`() = test { + val cleanup = CompletableDeferred() + var isCurrent = true + whenever(cacheStore.data).thenReturn( + flow { + cleanup.await() + emit(PrivatePaykitCacheData()) + }, + ) + + try { + val scheduled = async { + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY), OWN_KEY) { isCurrent } + } + runCurrent() + assertFalse(scheduled.isCompleted) + isCurrent = false + cleanup.complete(Unit) + assertTrue(scheduled.await().isSuccess) + advanceTimeBy(120_000) + runCurrent() + verifyNoInteractions(paykitSdkService, pubkyService) + } finally { + cleanup.complete(Unit) + sut.closeAndClear() + } + } + + @Test + fun `unavailable peer retires explicit publication until refreshed`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + whenever(clock.now()).thenAnswer { + Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds + } + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)) + .thenAnswer { throw PaykitException.NotFound("not_found", "No App Registry") } + + try { + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + advanceTimeBy(1_000) + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) + clearInvocations(paykitSdkService) + + advanceTimeBy(600_000) + runCurrent() + verifyNoInteractions(paykitSdkService) + + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + } finally { + sut.closeAndClear() + } + } + @Test fun `blocked peer retires an unpublished explicit retry`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) diff --git a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt index d0e0f94233..d954723c36 100644 --- a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt @@ -3,19 +3,25 @@ package to.bitkit.usecases import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.models.PubkyProfile import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PubkySignIn import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class RefreshContactPaykitLinkUseCaseTest : BaseUnitTest() { private val pubkyRepo = mock() private val privatePaykitRepo = mock() + private val signIn = PubkySignIn("pubky-owner", 1L) private val contactKeys = listOf("pubky-alice", "pubky-bob") private val contacts = MutableStateFlow( contactKeys.map { publicKey -> @@ -39,27 +45,61 @@ class RefreshContactPaykitLinkUseCaseTest : BaseUnitTest() { @Before fun setUp() { whenever(pubkyRepo.contacts).thenReturn(contacts) + whenever(pubkyRepo.currentSignIn()).thenReturn(signIn) + whenever(pubkyRepo.isCurrent(signIn)).thenReturn(true) } @Test fun `refreshes contact endpoints with saved contacts`() = test { - whenever(privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys)) - .thenReturn(Result.success(Unit)) + var isStillCurrent: (() -> Boolean)? = null + whenever( + privatePaykitRepo.refreshSavedContactEndpoints( + eq(contactKeys.last()), + eq(contactKeys), + eq(signIn.publicKey), + any(), + ), + ).thenAnswer { + isStillCurrent = it.getArgument(3) + Result.success(Unit) + } val result = sut(contactKeys.last()) assertTrue(result.isSuccess) - verify(privatePaykitRepo).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) + verify(privatePaykitRepo).refreshSavedContactEndpoints( + eq(contactKeys.last()), + eq(contactKeys), + eq(signIn.publicKey), + any(), + ) + assertTrue(checkNotNull(isStillCurrent).invoke()) + whenever(pubkyRepo.isCurrent(signIn)).thenReturn(false) + assertFalse(checkNotNull(isStillCurrent).invoke()) } @Test fun `returns endpoint refresh failure`() = test { val error = IllegalStateException("Endpoint refresh failed") - whenever(privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys)) - .thenReturn(Result.failure(error)) + whenever( + privatePaykitRepo.refreshSavedContactEndpoints( + eq(contactKeys.last()), + eq(contactKeys), + eq(signIn.publicKey), + any(), + ), + ).thenReturn(Result.failure(error)) val result = sut(contactKeys.last()) assertEquals(error, result.exceptionOrNull()) } + + @Test + fun `signed out refresh does not schedule linking`() = test { + whenever(pubkyRepo.currentSignIn()).thenReturn(null) + + assertTrue(sut(contactKeys.last()).isSuccess) + verifyNoInteractions(privatePaykitRepo) + } } diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index c3af3af6c7..f03902c4cc 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -42,6 +42,15 @@ guidance and leave saved contacts intact. ## Foreground wait isolation +Hold an unrelated SDK operation after saving the contact itself. Contact Saved must appear without +waiting for the queued identity lookup; leaving Add Contact must not discard its link retry. +Switch identity while scheduling is held and verify that no linking starts for the previous sign-in. +These checks require controlled operation blocking, which the journey runner does not provide. + +Save a contact with no Paykit records. After its missing-peer result, the explicit retry must retire +without recurring SDK reads. A later explicit refresh can try again; pending outbound delivery for +other peers must continue. Inspecting retry retirement requires an instrumented fixture. + Hold an unrelated contact's background preparation in progress, then open a saved, linked contact and request or pay it. The selected contact must be eligible for its own lookup before the full contact scan finishes. Hold its public capability lookup separately: this public read must not From f9fa0b56802c78f734f70c1f09d3451b99d650eb Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 9 Oct 2026 10:08:57 -0500 Subject: [PATCH 8/8] fix: stop empty retries for missing peers --- .../bitkit/repositories/PrivatePaykitRepo.kt | 6 ++ .../repositories/PrivatePaykitRepoTest.kt | 63 ++++++++++++++++--- journeys/contacts/README.md | 5 +- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index aefbcd62c5..b2f6aa8681 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -155,11 +155,13 @@ class PrivatePaykitRepo @Inject constructor( var interactiveUntil: KotlinInstant? = null var prepareEndpoints = false var refreshReadiness = false + var isMissing = false fun stopIfUnavailable(counterparty: String, error: Throwable) { if (publicKey == counterparty && error is PaykitException.NotFound) { prepareEndpoints = false refreshReadiness = false + isMissing = true } } @@ -1330,6 +1332,7 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { advanceLinkIfIdle(retryKey, priority) }.onFailure { + currentCoroutineContext()[PrivateMessageDrainRetry]?.stopIfUnavailable(retryKey, it) Logger.warn( "Failed to advance private Paykit link for '${redacted(retryKey)}' during '$reason'", it, @@ -1377,6 +1380,7 @@ class PrivatePaykitRepo @Inject constructor( retry.interactiveUntil = clock.now() + explicitLinkRetryWindow retry.prepareEndpoints = retry.prepareEndpoints || retry.publicKey !in activePreparationKeys retry.refreshReadiness = true + retry.isMissing = false unavailableLinkRetryAt.remove(retry.publicKey) retry.wake.trySend(Unit) } @@ -1473,7 +1477,9 @@ class PrivatePaykitRepo @Inject constructor( awaitContactPreparationActive(priority) if (generation != preparationGeneration) return emptySet() if (state == null) return retryKeys + val missingKey = currentCoroutineContext()[PrivateMessageDrainRetry]?.takeIf { it.isMissing }?.publicKey return retryKeys.filterTo(mutableSetOf()) { retryKey -> + if (retryKey == missingKey && retryKey !in state.pendingOutbound) return@filterTo false when (state.linkedPeers[retryKey]) { LinkedPeerState.LINKED -> receiveLinkedPeers || retryKey in state.pendingOutbound null -> retryMissingPeers || retryKey in state.pendingOutbound diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 366634efc6..c51eab7d2c 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -673,29 +673,78 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `unavailable peer retires explicit publication until refreshed`() = test { + whenever(clock.now()).thenAnswer { + Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds + } + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.NOT_LINKED))) + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenReturn(listOf(OTHER_CONTACT_KEY)) + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)) + .thenAnswer { throw PaykitException.NotFound("not_found", "No App Registry") } + + for (sharingEnabled in listOf(true, false)) { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = sharingEnabled, + publicPaykitLightningEnabled = false, + ) + clearInvocations(paykitSdkService) + try { + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + advanceTimeBy(1_000) + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) + clearInvocations(paykitSdkService) + + advanceTimeBy(600_000) + runCurrent() + verifyNoInteractions(paykitSdkService) + + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + } finally { + sut.closeAndClear() + } + } + } + + @Test + fun `unavailable peer retains queued delivery until drained`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) whenever(clock.now()).thenAnswer { Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds } + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.NOT_LINKED))) whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)) .thenAnswer { throw PaykitException.NotFound("not_found", "No App Registry") } + var deliveryPending = true + var deliveries = 0 + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenAnswer { + if (deliveryPending) listOf(CONTACT_KEY) else emptyList() + } + whenever(paykitSdkService.processOutboundPrivateMessages(CONTACT_KEY)).thenAnswer { + if (++deliveries == 1) throw PaykitException.Transport("offline", "Unavailable homeserver") + deliveryPending = false + mock() + } try { sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() runCurrent() advanceTimeBy(1_000) runCurrent() - verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) - verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) - clearInvocations(paykitSdkService) + assertEquals(1, deliveries) + assertTrue(deliveryPending) + advanceTimeBy(3_000) + runCurrent() + assertEquals(2, deliveries) + assertFalse(deliveryPending) + clearInvocations(paykitSdkService) advanceTimeBy(600_000) runCurrent() verifyNoInteractions(paykitSdkService) - - sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() - runCurrent() - verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) } finally { sut.closeAndClear() } diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index f03902c4cc..11de80e79a 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -49,7 +49,10 @@ These checks require controlled operation blocking, which the journey runner doe Save a contact with no Paykit records. After its missing-peer result, the explicit retry must retire without recurring SDK reads. A later explicit refresh can try again; pending outbound delivery for -other peers must continue. Inspecting retry retirement requires an instrumented fixture. +other peers must continue. Repeat with an existing unlinked peer record whose remote Paykit +authorization is absent. That stored row alone must not keep the retry running. If the same peer +has queued outbound work, preserve delivery retries until it drains. Inspecting retry retirement +requires an instrumented fixture. Hold an unrelated contact's background preparation in progress, then open a saved, linked contact and request or pay it. The selected contact must be eligible for its own lookup before the full