diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index a5cc91685..08ee7f113 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -107,6 +107,28 @@ class PrivatePaykitRepo @Inject constructor( val firstError: Throwable?, ) + private data class PrivateEndpointCleanupPreparation( + val clearedRetryKeys: List, + val failedPublicKeys: Set, + val firstError: Throwable?, + ) + + private data class NormalizedPublicKeyBatch( + val normalizedKeys: List, + val invalidKeys: Set, + ) + + private data class LinkedReceiverPathsSnapshot( + val pathsByPublicKey: Map>, + val error: Throwable?, + ) + + private data class PublishedEndpointCleanupState( + val remoteEndpoints: List, + val localInvoicesByReceiverPath: Map, + val publishedPrivatePaymentReceiverPaths: Set, + ) + private data class PrivateMessageDrainRetryKey( val publicKey: String, val receiverPath: String, @@ -683,6 +705,8 @@ class PrivatePaykitRepo @Inject constructor( var firstError: Throwable? = null val updates = mutableListOf() val linkRetryKeys = mutableListOf() + val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshot(reason) + firstError = linkedReceiverPathsSnapshot.error for (publicKey in publicKeys) { val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } @@ -699,27 +723,15 @@ class PrivatePaykitRepo @Inject constructor( val publicationReceiverPaths = receiverPathSelection.publishableReceiverPaths receiverPathSelection.error?.let { firstError = firstError ?: it - Logger.warn( - "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) + logPrivateReceiverPathSelectionFailure(publicKey, reason, it) } val cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( publicKey = publicKey, excludedReceiverPaths = publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, + linkedReceiverPaths = linkedReceiverPathsSnapshot.pathsByPublicKey[publicKey].orEmpty(), ) - (linkableReceiverPaths + cleanupReceiverPaths).distinct().forEach { receiverPath -> - linkRetryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) - runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey, receiverPath) }.onFailure { - Logger.warn( - "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - } - } + linkRetryKeys += preparePrivateLinks(publicKey, linkableReceiverPaths + cleanupReceiverPaths, reason) cleanupReceiverPaths.forEach { receiverPath -> updates += PrivatePaymentListReservationUpdateInput( @@ -771,6 +783,26 @@ class PrivatePaykitRepo @Inject constructor( drainAndSchedulePrivateLinkRetries(reason, retryKeys.distinct()) } + private suspend fun preparePrivateLinks( + publicKey: String, + receiverPaths: Collection, + reason: String, + ): List { + val retryKeys = mutableListOf() + for (receiverPath in receiverPaths.distinct()) { + runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey, receiverPath) }.onFailure { + Logger.warn( + "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", + it, + context = TAG, + ) + } + retryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) + } + + return retryKeys + } + private suspend fun drainAndSchedulePrivateLinkRetries( reason: String, retryKeys: Collection, @@ -817,6 +849,18 @@ class PrivatePaykitRepo @Inject constructor( } } + private fun logPrivateReceiverPathSelectionFailure( + publicKey: String, + reason: String, + error: Throwable, + ) { + Logger.warn( + "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", + error, + context = TAG, + ) + } + private suspend fun applyPrivatePaymentListDeliveryReport( report: PrivatePaymentListDeliveryReport, reason: String, @@ -1170,41 +1214,102 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun removePublishedEndpoints(): Result = withContext(serializedDispatcher) { - runSuspendCatching { + publicationMutex.withLock { val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) .distinct() - val firstError = keys.mapNotNull { publicKey -> - removePublishedEndpoints(publicKey).exceptionOrNull() - }.firstOrNull() - if (firstError != null) throw firstError + removePublishedEndpointsLocked(keys) } } - private suspend fun removePublishedEndpoints(publicKey: String): Result = withContext(serializedDispatcher) { + private suspend fun removePublishedEndpoints(publicKey: String): Result = + removePublishedEndpoints(listOf(publicKey)) + + private suspend fun removePublishedEndpoints(publicKeys: Collection): Result = + withContext(serializedDispatcher) { + publicationMutex.withLock { + removePublishedEndpointsLocked(publicKeys) + } + } + + private suspend fun removePublishedEndpointsLocked(publicKeys: Collection): Result = runSuspendCatching { - var firstError: Throwable? = null - receiverPathsForCleanup(publicKey).forEach { receiverPath -> - val result = runSuspendCatching { - val report = paykitSdkService.clearPrivatePaymentList( - counterparty = publicKey, - receiverPath = receiverPath, + val normalizedBatch = normalizedPublicKeyBatch(publicKeys) + discardInvalidCleanupKeys(normalizedBatch.invalidKeys) + val normalizedKeys = normalizedBatch.normalizedKeys + if (normalizedKeys.isEmpty()) return@runSuspendCatching + + ensureState() + val cleanupStateByPublicKey = normalizedKeys.associateWith(::publishedEndpointCleanupState) + val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshot("private endpoint cleanup") + val preparation = clearPrivatePaymentLists(normalizedKeys, linkedReceiverPathsSnapshot) + val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() + var firstError = preparation.firstError + + if (preparation.clearedRetryKeys.isNotEmpty()) { + drainPendingPrivateMessages( + reason = "private endpoint cleanup", + advancingLinksFor = preparation.clearedRetryKeys, + ) + val pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) + if (pendingRetryKeys.isNotEmpty()) { + failedPublicKeys += pendingRetryKeys.map { it.publicKey } + firstError = firstError ?: PrivatePaykitError.PrivateUnavailable + } + } + + normalizedKeys.filterNot { it in failedPublicKeys }.forEach { publicKey -> + if (publishedEndpointCleanupState(publicKey) != cleanupStateByPublicKey[publicKey]) { + failedPublicKeys += publicKey + firstError = firstError ?: PrivatePaykitError.PrivateUnavailable + Logger.warn( + "Deferred private Paykit cache cleanup for '${redacted(publicKey)}' because its state changed", + context = TAG, ) + } + } + + clearPublishedEndpointCache(normalizedKeys.filterNot { it in failedPublicKeys }) + firstError?.let { throw it } + } + + private suspend fun clearPrivatePaymentLists( + publicKeys: Collection, + linkedReceiverPathsSnapshot: LinkedReceiverPathsSnapshot, + ): PrivateEndpointCleanupPreparation { + val failedPublicKeys = if (linkedReceiverPathsSnapshot.error == null) { + mutableSetOf() + } else { + publicKeys.toMutableSet() + } + val clearedRetryKeys = mutableListOf() + var firstError = linkedReceiverPathsSnapshot.error + + publicKeys.forEach { publicKey -> + receiverPathsForCleanup( + publicKey = publicKey, + linkedReceiverPaths = linkedReceiverPathsSnapshot.pathsByPublicKey[publicKey].orEmpty(), + ).forEach { receiverPath -> + runSuspendCatching { + val report = paykitSdkService.clearPrivatePaymentList(publicKey, receiverPath) if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { throw PrivatePaykitError.PrivateUnavailable } - val retryKey = PrivateMessageDrainRetryKey(publicKey, receiverPath) - drainPendingPrivateMessages("private endpoint cleanup", advancingLinksFor = listOf(retryKey)) - if (retryKey in pendingPrivateMessageDrainKeys(listOf(retryKey))) { - throw PrivatePaykitError.PrivateUnavailable - } - } - if (result.isFailure) { - firstError = firstError ?: result.exceptionOrNull() + }.onSuccess { + clearedRetryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) + }.onFailure { + failedPublicKeys += publicKey + firstError = firstError ?: it } } + } - firstError?.let { throw it } + return PrivateEndpointCleanupPreparation(clearedRetryKeys, failedPublicKeys, firstError) + } + private suspend fun clearPublishedEndpointCache(publicKeys: Collection) { + if (publicKeys.isEmpty()) return + + publicKeys.forEach { publicKey -> state?.contacts?.get(publicKey)?.let { contactState -> contactState.remoteEndpoints = emptyList() contactState.localInvoicesByReceiverPath = emptyMap() @@ -1213,9 +1318,34 @@ class PrivatePaykitRepo @Inject constructor( state?.contacts?.remove(publicKey) } } - updateDeletedContactCleanupPending(publicKey, isPending = false) + } + + persistState(markWalletBackup = true) + updateDeletedContactCleanupPending(publicKeys, isPending = false) + } + + private suspend fun discardInvalidCleanupKeys(publicKeys: Collection) { + if (publicKeys.isEmpty()) return + + val contactState = ensureState().contacts + 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) } + updateDeletedContactCleanupPending(publicKeys, isPending = false) + } + + private fun publishedEndpointCleanupState(publicKey: String): PublishedEndpointCleanupState { + val contactState = state?.contacts?.get(publicKey) + return PublishedEndpointCleanupState( + remoteEndpoints = contactState?.remoteEndpoints.orEmpty(), + localInvoicesByReceiverPath = contactState?.localInvoicesByReceiverPath.orEmpty(), + publishedPrivatePaymentReceiverPaths = contactState?.publishedPrivatePaymentReceiverPaths.orEmpty(), + ) } private suspend fun receiverPathsForSavedContact(publicKey: String): List { @@ -1226,42 +1356,66 @@ class PrivatePaykitRepo @Inject constructor( return paths.ifEmpty { listOf(PaykitReceiverPaths.WALLET) } } - private suspend fun receiverPathsForPrivateEndpointCleanup( + private fun receiverPathsForPrivateEndpointCleanup( publicKey: String, excludedReceiverPaths: List, + linkedReceiverPaths: Collection, ): List { val publishedPaths = publishedPrivatePaymentReceiverPaths(publicKey) - val linkedPaths = linkedReceiverPaths(publicKey) - return (publishedPaths + linkedPaths) + return (publishedPaths + linkedReceiverPaths) .filter { it in PaykitReceiverPaths.supported } .filterNot { it in excludedReceiverPaths } .distinct() .sorted() } - private suspend fun receiverPathsForCleanup(publicKey: String): List { - val paths = ( - linkedReceiverPaths(publicKey) + - publishedPrivatePaymentReceiverPaths(publicKey) - ) + private fun receiverPathsForCleanup( + publicKey: String, + linkedReceiverPaths: Collection, + ): List { + return (linkedReceiverPaths + publishedPrivatePaymentReceiverPaths(publicKey)) .filter { it in PaykitReceiverPaths.supported } .distinct() .sorted() - return paths } - private suspend fun linkedReceiverPaths(publicKey: String): List { - val normalizedKey = normalizedPublicKey(publicKey) ?: return emptyList() - val paths = paykitSdkService.linkedPeers() - .mapNotNull { peer -> - val peerKey = normalizedPublicKey(peer.counterparty) - peer.counterpartyReceiverPath.takeIf { - peerKey == normalizedKey && it in PaykitReceiverPaths.supported - } + private suspend fun linkedReceiverPathsByPublicKey(): Map> { + val linkedPaths = mutableMapOf>() + paykitSdkService.linkedPeers().forEach { peer -> + val publicKey = normalizedPublicKey(peer.counterparty) ?: return@forEach + if (peer.counterpartyReceiverPath in PaykitReceiverPaths.supported) { + linkedPaths.getOrPut(publicKey, ::mutableSetOf) += peer.counterpartyReceiverPath } - .distinct() - .sorted() - return paths + } + return linkedPaths + } + + private suspend fun linkedReceiverPathsSnapshot(reason: String): LinkedReceiverPathsSnapshot { + repeat(2) { attempt -> + val result = runSuspendCatching { linkedReceiverPathsByPublicKey() } + result.getOrNull()?.let { return LinkedReceiverPathsSnapshot(it, null) } + val error = result.exceptionOrNull() ?: PrivatePaykitError.PrivateUnavailable + val suffix = if (attempt == 0) "; retrying once" else " after retry" + Logger.warn( + "Failed to inspect private Paykit links during '$reason'$suffix", + error, + context = TAG, + ) + if (attempt == 1) return LinkedReceiverPathsSnapshot(emptyMap(), error) + } + + return LinkedReceiverPathsSnapshot(emptyMap(), PrivatePaykitError.PrivateUnavailable) + } + + private fun normalizedPublicKeyBatch(publicKeys: Collection): NormalizedPublicKeyBatch { + val invalidKeys = mutableSetOf() + val normalizedKeys = publicKeys.mapNotNull { publicKey -> + normalizedPublicKey(publicKey) ?: run { + invalidKeys += publicKey + null + } + }.distinct() + return NormalizedPublicKeyBatch(normalizedKeys, invalidKeys) } private fun publishedPrivatePaymentReceiverPaths(publicKey: String): List { @@ -1282,7 +1436,14 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun clearContactState(publicKey: String) { - ensureState().contacts.remove(publicKey) + clearContactStates(listOf(publicKey)) + } + + private suspend fun clearContactStates(publicKeys: Collection) { + if (publicKeys.isEmpty()) return + + val contacts = ensureState().contacts + publicKeys.forEach(contacts::remove) persistState(markWalletBackup = true) } @@ -1375,12 +1536,17 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun pendingDeletedContactCleanupPublicKeys(): Set = cacheStore.data.first().deletedContactCleanupPendingPublicKeys - private suspend fun updateDeletedContactCleanupPending(publicKey: String, isPending: Boolean) { + private suspend fun updateDeletedContactCleanupPending(publicKey: String, isPending: Boolean) = + updateDeletedContactCleanupPending(listOf(publicKey), isPending) + + private suspend fun updateDeletedContactCleanupPending(publicKeys: Collection, isPending: Boolean) { + if (publicKeys.isEmpty()) return + cacheStore.update { val pendingKeys = if (isPending) { - it.deletedContactCleanupPendingPublicKeys + publicKey + it.deletedContactCleanupPendingPublicKeys + publicKeys } else { - it.deletedContactCleanupPendingPublicKeys - publicKey + it.deletedContactCleanupPendingPublicKeys - publicKeys.toSet() } it.copy(deletedContactCleanupPendingPublicKeys = pendingKeys) } @@ -1391,17 +1557,21 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runSuspendCatching { val savedKeys = savedPublicKeys.mapNotNull { normalizedPublicKey(it) }.toSet() - pendingDeletedContactCleanupPublicKeys().forEach { publicKey -> - if (publicKey in savedKeys) { - updateDeletedContactCleanupPending(publicKey, false) - return@forEach - } - - removePublishedEndpoints(publicKey).getOrThrow() - clearContactState(publicKey) + val pendingKeys = pendingDeletedContactCleanupPublicKeys() + updateDeletedContactCleanupPending(pendingKeys.intersect(savedKeys), isPending = false) + val cleanupKeys = pendingKeys - savedKeys + if (cleanupKeys.isEmpty()) return@runSuspendCatching + + val removalResult = removePublishedEndpoints(cleanupKeys) + val remainingPendingKeys = pendingDeletedContactCleanupPublicKeys() + val successfulKeys = cleanupKeys + .mapNotNull(::normalizedPublicKey) + .filterNot { it in remainingPendingKeys } + clearContactStates(successfulKeys) + successfulKeys.forEach { publicKey -> addressReservationRepo.clearContactAssignment(publicKey) - updateDeletedContactCleanupPending(publicKey, false) } + removalResult.getOrThrow() } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 9237350f3..0cbc0df75 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -16,7 +16,9 @@ import com.synonym.paykit.PrivatePaymentResolutionState import com.synonym.paykit.PrivatePaymentResolutionStatus import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -33,6 +35,7 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeast import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -44,6 +47,7 @@ import to.bitkit.App import to.bitkit.CurrentActivity import to.bitkit.data.PrivatePaykitCacheData import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.PrivatePaykitContactCacheData import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.NodeLifecycleState @@ -388,6 +392,26 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } + @Test + fun `prepareSavedContacts reads linked peers once for multiple contacts`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + whenever { paykitSdkService.privateReceiverPathSelection(any(), any()) }.thenReturn( + privateReceiverPathSelection( + publishableReceiverPaths = emptyList(), + linkableReceiverPaths = emptyList(), + ), + ) + + val result = sut.prepareSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)) + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verifyBlocking(paykitSdkService, times(1)) { linkedPeers() } + } + @Test fun `private message drain keeps retrying while link is still pending`() = test { settingsData.value = SettingsData( @@ -613,6 +637,54 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isFailure) assertTrue(cacheData.value.cleanupPending) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + assertEquals( + setOf(WALLET_RECEIVER_PATH), + cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + ) + } + + @Test + fun `cleanup retries linked receiver inspection once for the batch`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + ) + sut = createSut() + var linkedPeerReads = 0 + whenever { paykitSdkService.linkedPeers() }.thenAnswer { + linkedPeerReads += 1 + if (linkedPeerReads <= 2) error("link inspection failed") + emptyList() + } + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isFailure) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } + assertTrue(CONTACT_KEY in cacheData.value.contacts) + assertTrue(OTHER_CONTACT_KEY in cacheData.value.contacts) + assertTrue(cacheData.value.cleanupPending) + assertEquals(3, linkedPeerReads) + } + + @Test + fun `invalid deleted contact key is dropped from cleanup state`() = test { + val invalidPublicKey = "not-a-pubky" + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf(invalidPublicKey to cachedPublishedContact(WALLET_RECEIVER_PATH)), + deletedContactCleanupPendingPublicKeys = setOf(invalidPublicKey), + ) + sut = createSut() + + val result = sut.retryPendingEndpointRemoval(emptyList()) + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + assertTrue(cacheData.value.contacts.isEmpty()) + assertTrue(cacheData.value.deletedContactCleanupPendingPublicKeys.isEmpty()) verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any(), any()) } } @@ -635,6 +707,142 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(CONTACT_KEY, SERVER_RECEIVER_PATH) } } + @Test + fun `cleanup drains all contacts in one batch`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + ) + sut = createSut() + whenever { paykitSdkService.linkedPeers() }.thenReturn( + listOf( + linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED), + linkedPeer(OTHER_CONTACT_KEY, LinkedPeerState.LINKED, SERVER_RECEIVER_PATH), + ), + ) + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } + verifyBlocking(paykitSdkService, times(2)) { linkedPeers() } + verifyBlocking(paykitSdkService, times(1)) { pendingOutboundPrivateCounterparties() } + verifyBlocking(paykitSdkService, times(2)) { processPendingPrivateMessages() } + verifyBlocking(paykitSdkService, times(2)) { receivePrivateMessagesFromLinkedPeers() } + assertTrue(cacheData.value.contacts.isEmpty()) + } + + @Test + fun `deleted contact retry cleans all pending contacts in one batch`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + deletedContactCleanupPendingPublicKeys = setOf(CONTACT_KEY, OTHER_CONTACT_KEY), + ) + sut = createSut() + + val result = sut.retryPendingEndpointRemoval(emptyList()) + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } + verifyBlocking(paykitSdkService, times(2)) { linkedPeers() } + assertTrue(cacheData.value.contacts.isEmpty()) + assertTrue(cacheData.value.deletedContactCleanupPendingPublicKeys.isEmpty()) + } + + @Test + fun `cleanup retains the batch when drain inspection fails`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + ) + sut = createSut() + whenever { paykitSdkService.linkedPeers() } + .thenReturn(emptyList()) + .thenThrow(IllegalStateException("drain inspection failed")) + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isFailure) + assertTrue(CONTACT_KEY in cacheData.value.contacts) + assertTrue(OTHER_CONTACT_KEY in cacheData.value.contacts) + assertTrue(cacheData.value.cleanupPending) + } + + @Test + fun `cleanup retains endpoint cache updated during remote removal`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf(CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH)), + ) + sut = createSut() + val cleanupStarted = CompletableDeferred() + val resumeCleanup = CompletableDeferred() + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + .doSuspendableAnswer { + cleanupStarted.complete(Unit) + resumeCleanup.await() + privateListDeliveryReport(clearedCounterparties = listOf(CONTACT_KEY)) + } + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment( + eq(CONTACT_KEY), + eq(SERVER_RECEIVER_PATH), + eq(null), + any(), + ) + }.thenReturn(resolution(resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), version = 7uL)) + whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(false) + + val cleanup = async { sut.removePublishedEndpointsForCleanup("test") } + cleanupStarted.await() + sut.beginPaymentRequest( + paymentRequest(acceptedEndpointIdentifiers = listOf(MethodId.P2wpkh.rawValue)), + ).getOrThrow() + resumeCleanup.complete(Unit) + + assertTrue(cleanup.await().isFailure) + assertTrue(cacheData.value.contacts.getValue(CONTACT_KEY).remoteEndpoints.isNotEmpty()) + assertTrue(cacheData.value.cleanupPending) + } + + @Test + fun `cleanup isolates a failed contact while clearing successful contacts`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + ) + sut = createSut() + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenReturn( + privateListDeliveryReport( + failedToQueue = listOf( + PrivatePaymentListSyncChange( + counterparty = CONTACT_KEY, + counterpartyReceiverPath = WALLET_RECEIVER_PATH, + outboundMessageId = null, + error = "failed", + ), + ), + ), + ) + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isFailure) + assertTrue(CONTACT_KEY in cacheData.value.contacts) + assertTrue(OTHER_CONTACT_KEY !in cacheData.value.contacts) + assertTrue(cacheData.value.cleanupPending) + } + @Test fun `prepareSavedContacts records queued contacts when another contact cannot publish`() = test { settingsData.value = SettingsData( @@ -1117,6 +1325,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { failedToDeliver = emptyList(), ) + private fun cachedPublishedContact(receiverPath: String) = PrivatePaykitContactCacheData( + publishedPrivatePaymentReceiverPaths = setOf(receiverPath), + ) + private fun privateReceiverPathSelection( publishableReceiverPaths: List, linkableReceiverPaths: List = publishableReceiverPaths,