From 4c598b60777d87f7c127258176cd4e41771b57e4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 6 Aug 2026 10:30:51 -0500 Subject: [PATCH 1/5] fix: establish private Paykit links promptly --- .../bitkit/repositories/PrivatePaykitRepo.kt | 91 ++++++++++++++++++- .../RefreshContactPaykitReceiversUseCase.kt | 1 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 29 +++++- .../repositories/PrivatePaykitRepoTest.kt | 39 ++++++++ ...efreshContactPaykitReceiversUseCaseTest.kt | 2 + .../viewmodels/AppViewModelSendFlowTest.kt | 7 +- changelog.d/next/1141.fixed.md | 1 + 7 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 changelog.d/next/1141.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 08ee7f113..8044152b1 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -13,8 +13,11 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update @@ -85,7 +88,10 @@ class PrivatePaykitRepo @Inject constructor( 45.seconds, 90.seconds, ) + private val initialLinkBurstRetryDelays = List(14) { 2.seconds } private val privatePaymentResolutionRetryDelays = privateMessageDrainRetryDelays.take(3) + private val _initialLinkBurstStarted = MutableSharedFlow(extraBufferCapacity = 1) + val initialLinkBurstStarted: SharedFlow = _initialLinkBurstStarted.asSharedFlow() fun isDuplicatePaymentError(error: Throwable): Boolean = PrivatePaykitErrorClassifier.isDuplicatePaymentError(error) @@ -100,6 +106,10 @@ class PrivatePaykitRepo @Inject constructor( private val pendingMessageDrainRetryKeys = mutableSetOf() private var pendingMessageDrainRetryJob: Job? = null private var pendingMessageDrainRetryGeneration = 0 + private val initialLinkBurstLock = Any() + private val initialLinkBurstPublicKeys = mutableSetOf() + private var initialLinkBurstJob: Job? = null + private var initialLinkBurstGeneration = 0 private data class PrivatePublicationPreparation( val updates: List, @@ -219,6 +229,35 @@ class PrivatePaykitRepo @Inject constructor( } } + fun startInitialLinkBurst(publicKeys: Collection, reason: String) { + val publicKeys = normalizedPublicKeyBatch(publicKeys).normalizedKeys + if (publicKeys.isEmpty()) return + + synchronized(initialLinkBurstLock) { + initialLinkBurstPublicKeys += publicKeys + initialLinkBurstGeneration += 1 + val generation = initialLinkBurstGeneration + initialLinkBurstJob?.cancel() + _initialLinkBurstStarted.tryEmit(Unit) + + initialLinkBurstJob = retryScope.launch { + (listOf(kotlin.time.Duration.ZERO) + initialLinkBurstRetryDelays).forEach { retryDelay -> + delay(retryDelay) + val keys = synchronized(initialLinkBurstLock) { + if (generation != initialLinkBurstGeneration) return@launch + initialLinkBurstPublicKeys.toList() + } + refreshSavedContactEndpointsDuringInitialLinkBurst(keys, reason) + } + synchronized(initialLinkBurstLock) { + if (generation != initialLinkBurstGeneration) return@launch + initialLinkBurstJob = null + initialLinkBurstPublicKeys.clear() + } + } + } + } + suspend fun retryPendingEndpointRemoval( savedPublicKeys: Collection, ): Result = withContext(serializedDispatcher) { @@ -303,6 +342,7 @@ class PrivatePaykitRepo @Inject constructor( suspend fun closeAndClear(): Result = withContext(serializedDispatcher) { runSuspendCatching { publicationMutex.withLock { + clearInitialLinkBurst() clearPendingMessageDrainRetries() knownSavedContactKeys.clear() state = PrivatePaykitState() @@ -1349,11 +1389,52 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun receiverPathsForSavedContact(publicKey: String): List { - val paths = paykitSdkService.contactRecord(publicKey) - ?.receiverPaths - ?.filter { it in PaykitReceiverPaths.supported } - .orEmpty() - return paths.ifEmpty { listOf(PaykitReceiverPaths.WALLET) } + val record = paykitSdkService.contactRecord(publicKey) + val savedPaths = supportedReceiverPaths(record?.receiverPaths.orEmpty()) + + return runSuspendCatching { + val discoveredPaths = pubkyService.discoverRelevantReceiverPaths(publicKey) + val mergedPaths = supportedReceiverPaths(savedPaths + discoveredPaths) + if (mergedPaths == savedPaths) return@runSuspendCatching savedPaths + + val updatedRecord = pubkyService.saveContact(publicKey, record?.label, mergedPaths) + _initialLinkBurstStarted.tryEmit(Unit) + Logger.info("Discovered new Paykit receiver paths for '${redacted(publicKey)}'", context = TAG) + supportedReceiverPaths(updatedRecord.receiverPaths) + }.getOrElse { + if (it is CancellationException) throw it + Logger.warn( + "Failed to refresh Paykit receiver paths for '${redacted(publicKey)}'; using saved paths", + it, + context = TAG, + ) + savedPaths + } + } + + private fun supportedReceiverPaths(receiverPaths: Collection): List = + PaykitReceiverPaths.supported.filter { it in receiverPaths } + .ifEmpty { listOf(PaykitReceiverPaths.WALLET) } + + private suspend fun refreshSavedContactEndpointsDuringInitialLinkBurst( + publicKeys: Collection, + reason: String, + ) = withContext(serializedDispatcher) { + if (!canPublishPrivateEndpoints()) { + prepareRelevantPrivateLinksIfAvailable(publicKeys, "$reason initial link burst") + return@withContext + } + publishLocalEndpoints(publicKeys.toList(), reason = "$reason initial link burst") + .onFailure { Logger.warn("Failed initial private Paykit sync for '$reason'", it, context = TAG) } + } + + private fun clearInitialLinkBurst() { + synchronized(initialLinkBurstLock) { + initialLinkBurstJob?.cancel() + initialLinkBurstJob = null + initialLinkBurstPublicKeys.clear() + initialLinkBurstGeneration += 1 + } } private fun receiverPathsForPrivateEndpointCleanup( diff --git a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt index 010c16ca7..be69e7623 100644 --- a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt @@ -24,6 +24,7 @@ class RefreshContactPaykitReceiversUseCase @Inject constructor( pubkyRepo.refreshContactReceiverPaths(publicKey).getOrThrow() val savedPublicKeys = (pubkyRepo.contacts.value.map { it.publicKey } + publicKey).distinct() privatePaykitRepo.refreshSavedContactEndpoints(publicKey, savedPublicKeys).getOrThrow() + privatePaykitRepo.startInitialLinkBurst(savedPublicKeys, "contact receiver refresh") }.onFailure { Logger.warn( "Failed to refresh Paykit receivers for '${PubkyPublicKeyFormat.redacted(publicKey)}'", diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 849726cc2..fe85cd89a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -304,6 +304,7 @@ class AppViewModel @Inject constructor( private var isPresentingPaymentRequest = false private var isSubmittingPaymentRequest = false private var paykitPaymentRequestPollingJob: Job? = null + private var initialPaykitPaymentRequestPollingJob: Job? = null private val paymentRequestPresentationRetryAttempts = mutableMapOf() private val paymentRequestPresentationRetryJobs = mutableMapOf() private val timedSheetManager = timedSheetManagerProvider(viewModelScope).apply { @@ -433,6 +434,7 @@ class AppViewModel @Inject constructor( observePublicPaykitInvoiceExpiry() observePrivatePaykitContacts() observePaykitPaymentRequestConnectivity() + observeInitialPaykitLinkBursts() observeIncomingPaykitPaymentRequests() observeSendEvents() viewModelScope.launch { @@ -596,6 +598,7 @@ class AppViewModel @Inject constructor( .onFailure { Logger.warn("Failed to prune private Paykit contact state", it, context = TAG) } + privatePaykitRepo.startInitialLinkBurst(state.contactKeys, "contact sync") refreshIncomingPaykitPaymentRequests() lastPrivatePaykitContactKeys = state.contactKeys } @@ -617,6 +620,7 @@ class AppViewModel @Inject constructor( Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) } privatePaykitRepo.refreshKnownSavedContactEndpoints(reason, forceRefreshLightning = forceRefreshLightning) + privatePaykitRepo.startInitialLinkBurst(contactKeys, reason) refreshIncomingPaykitPaymentRequests() } @@ -625,7 +629,13 @@ class AppViewModel @Inject constructor( isOnline .drop(1) .filter { it == ConnectivityState.CONNECTED } - .collect { refreshIncomingPaykitPaymentRequests() } + .collect { refreshPrivatePaykitEndpointsIfEnabled("network restored") } + } + } + + private fun observeInitialPaykitLinkBursts() { + viewModelScope.launch { + PrivatePaykitRepo.initialLinkBurstStarted.collect { startInitialPaykitPaymentRequestPolling() } } } @@ -648,6 +658,7 @@ class AppViewModel @Inject constructor( var refreshIntervalIndex = 0 while (true) { delay(PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVALS[refreshIntervalIndex]) + privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") refreshIntervalIndex = if (refreshIncomingPaykitPaymentRequests()) { 0 } else { @@ -655,16 +666,31 @@ class AppViewModel @Inject constructor( } } } + startInitialPaykitPaymentRequestPolling() } fun stopPaykitPaymentRequestPolling() { paykitPaymentRequestPollingJob?.cancel() paykitPaymentRequestPollingJob = null + initialPaykitPaymentRequestPollingJob?.cancel() + initialPaykitPaymentRequestPollingJob = null paymentRequestPresentationRetryJobs.values.forEach { it.cancel() } paymentRequestPresentationRetryJobs.clear() paymentRequestPresentationRetryAttempts.clear() } + private fun startInitialPaykitPaymentRequestPolling() { + if (paykitPaymentRequestPollingJob?.isActive != true) return + initialPaykitPaymentRequestPollingJob?.cancel() + initialPaykitPaymentRequestPollingJob = viewModelScope.launch { + refreshIncomingPaykitPaymentRequests() + INITIAL_PAYKIT_SYNC_RETRY_DELAYS.forEach { + delay(it) + refreshIncomingPaykitPaymentRequests() + } + } + } + private fun observeIncomingPaykitPaymentRequests() { viewModelScope.launch { currentSheet.collect { @@ -3862,6 +3888,7 @@ class AppViewModel @Inject constructor( private const val ADDRESS_VALIDATION_DEBOUNCE_MS = 1000L private const val PAYKIT_CHANNEL_USABILITY_REFRESH_DELAY_MS = 5_000L private val PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVALS = listOf(30.seconds, 60.seconds, 120.seconds) + private val INITIAL_PAYKIT_SYNC_RETRY_DELAYS = List(14) { 2.seconds } private val PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS = listOf( 30.seconds, 60.seconds, diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 0cbc0df75..74c213d10 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -122,6 +122,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever { pubkyService.discoverRelevantReceiverPaths(any()) } + .thenReturn(listOf(WALLET_RECEIVER_PATH)) whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(true) whenever(walletRepo.walletExists()).thenReturn(true) whenever { walletRepo.refreshReusableReceiveAddressIfReserved() }.thenReturn(Result.success(Unit)) @@ -299,6 +301,43 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(addressReservationRepo, never()).currentOrRotatedAddress(any(), any()) } + @Test + fun `initial link burst discovers a server receiver published after the contact was saved`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + whenever { paykitSdkService.contactRecord(CONTACT_KEY) } + .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH))) + whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY) } + .thenReturn(listOf(WALLET_RECEIVER_PATH)) + .thenReturn(listOf(WALLET_RECEIVER_PATH)) + .thenReturn(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH)) + whenever { + pubkyService.saveContact( + CONTACT_KEY, + null, + listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), + ) + }.thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) + + assertTrue(sut.prepareSavedContacts(listOf(CONTACT_KEY)).isSuccess) + clearInvocations(paykitSdkService, pubkyService) + + sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") + runCurrent() + advanceTimeBy(2_000) + runCurrent() + + verifyBlocking(pubkyService) { + saveContact( + CONTACT_KEY, + null, + listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), + ) + } + verifyBlocking(paykitSdkService) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + sut.closeAndClear() + } + @Test fun `prepareSavedContacts clears receiver paths that are no longer eligible`() = test { settingsData.value = SettingsData( diff --git a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt index ee5801e7b..052a91a00 100644 --- a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt @@ -56,6 +56,7 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { inOrder(pubkyRepo, privatePaykitRepo).apply { verify(pubkyRepo).refreshContactReceiverPaths(contactKeys.last()) verify(privatePaykitRepo).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) + verify(privatePaykitRepo).startInitialLinkBurst(contactKeys, "contact receiver refresh") } } @@ -68,5 +69,6 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { assertEquals(error, result.exceptionOrNull()) verify(privatePaykitRepo, never()).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) + verify(privatePaykitRepo, never()).startInitialLinkBurst(contactKeys, "contact receiver refresh") } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6822141c6..60320b577 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -365,13 +365,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `payment requests refresh periodically only while polling is active`() = test { + fun `payment requests refresh immediately and periodically only while polling is active`() = test { isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) runCurrent() sut.startPaykitPaymentRequestPolling() + runCurrent() + + verify(paykitPaymentRequestRepo).refresh() + clearInvocations(paykitPaymentRequestRepo) + advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() diff --git a/changelog.d/next/1141.fixed.md b/changelog.d/next/1141.fixed.md new file mode 100644 index 000000000..c76c161b9 --- /dev/null +++ b/changelog.d/next/1141.fixed.md @@ -0,0 +1 @@ +Fixed delayed private payment requests after adding a Paykit contact or returning to the app. From a2f36b8d6d55577ce8a0ab04e6c4bdc5b654c814 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 6 Aug 2026 14:09:36 -0500 Subject: [PATCH 2/5] fix: stop retrying removed paykit requests --- .../bitkit/repositories/PrivatePaykitRepo.kt | 4 +-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 10 ++++-- .../viewmodels/AppViewModelSendFlowTest.kt | 35 +++++++++++-------- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 8044152b1..416de57d0 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -90,8 +90,6 @@ class PrivatePaykitRepo @Inject constructor( ) private val initialLinkBurstRetryDelays = List(14) { 2.seconds } private val privatePaymentResolutionRetryDelays = privateMessageDrainRetryDelays.take(3) - private val _initialLinkBurstStarted = MutableSharedFlow(extraBufferCapacity = 1) - val initialLinkBurstStarted: SharedFlow = _initialLinkBurstStarted.asSharedFlow() fun isDuplicatePaymentError(error: Throwable): Boolean = PrivatePaykitErrorClassifier.isDuplicatePaymentError(error) @@ -106,6 +104,8 @@ class PrivatePaykitRepo @Inject constructor( private val pendingMessageDrainRetryKeys = mutableSetOf() private var pendingMessageDrainRetryJob: Job? = null private var pendingMessageDrainRetryGeneration = 0 + private val _initialLinkBurstStarted = MutableSharedFlow(extraBufferCapacity = 1) + val initialLinkBurstStarted: SharedFlow = _initialLinkBurstStarted.asSharedFlow() private val initialLinkBurstLock = Any() private val initialLinkBurstPublicKeys = mutableSetOf() private var initialLinkBurstJob: Job? = null diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index fe85cd89a..bdbdeb9c2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -635,7 +635,7 @@ class AppViewModel @Inject constructor( private fun observeInitialPaykitLinkBursts() { viewModelScope.launch { - PrivatePaykitRepo.initialLinkBurstStarted.collect { startInitialPaykitPaymentRequestPolling() } + privatePaykitRepo.initialLinkBurstStarted.collect { startInitialPaykitPaymentRequestPolling() } } } @@ -734,8 +734,12 @@ class AppViewModel @Inject constructor( val result = privatePaykitRepo.beginPaymentRequest(request).getOrNull() if (isPaymentRequestPresentationBlocked()) return true val isPending = paykitPaymentRequestRepo.isPending(request) - if (result !is PublicPaykitPaymentResult.Opened || !isPending) { - if (isPending) deferPaymentRequestPresentation(request) + if (!isPending) { + presentedPaymentRequestIds += request.id + return false + } + if (result !is PublicPaykitPaymentResult.Opened) { + deferPaymentRequestPresentation(request) return false } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 60320b577..3fd80d5a9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -37,6 +37,7 @@ import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.atLeast import org.mockito.kotlin.check import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doSuspendableAnswer @@ -196,6 +197,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @After fun tearDown() { + sut.stopPaykitPaymentRequestPolling() App.currentActivity = null } @@ -238,6 +240,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) whenever(paykitPaymentRequestRepo.pendingRequests).thenReturn(pendingPaykitPaymentRequests) whenever(paykitPaymentRequestRepo.isPending(any())).thenReturn(true) + whenever(privatePaykitRepo.initialLinkBurstStarted).thenReturn(MutableSharedFlow()) whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.pruneUnsavedContactState(any>()) } @@ -370,28 +373,32 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.value = testPublicKey whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) runCurrent() + clearInvocations(paykitPaymentRequestRepo) sut.startPaykitPaymentRequestPolling() - runCurrent() + try { + runCurrent() - verify(paykitPaymentRequestRepo).refresh() - clearInvocations(paykitPaymentRequestRepo) + verify(paykitPaymentRequestRepo).refresh() + clearInvocations(paykitPaymentRequestRepo) - advanceTimeBy(30.seconds.inWholeMilliseconds) - runCurrent() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() - verify(paykitPaymentRequestRepo).refresh() - clearInvocations(paykitPaymentRequestRepo) + verify(paykitPaymentRequestRepo, atLeast(2)).refresh() + clearInvocations(paykitPaymentRequestRepo) - advanceTimeBy(59.seconds.inWholeMilliseconds) - runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + advanceTimeBy(59.seconds.inWholeMilliseconds) + runCurrent() + verify(paykitPaymentRequestRepo, never()).refresh() - advanceTimeBy(1.seconds.inWholeMilliseconds) - runCurrent() - verify(paykitPaymentRequestRepo).refresh() + advanceTimeBy(1.seconds.inWholeMilliseconds) + runCurrent() + verify(paykitPaymentRequestRepo).refresh() + } finally { + sut.stopPaykitPaymentRequestPolling() + } - sut.stopPaykitPaymentRequestPolling() clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(120.seconds.inWholeMilliseconds) runCurrent() From c527994eb209f87951cdb2399fec9c33b624b67f Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 6 Aug 2026 18:07:25 -0500 Subject: [PATCH 3/5] test: cover pubky auth name deduplication --- .../to/bitkit/models/PubkyAuthRequestTest.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index cbc7d5482..d94cce541 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -50,6 +50,35 @@ class PubkyAuthRequestTest { assertNull(request.bitkitClaim) } + @Test + fun `parse deduplicates service name across public and private capabilities`() { + val capabilities = "/pub/locks.app/:rw,/priv/locks.app/:rw" + + val request = PubkyAuthRequest.parse( + rawUrl = authUrl(capabilities), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ).getOrThrow() + + assertEquals(listOf("/pub/locks.app/", "/priv/locks.app/"), request.permissions.map { it.path }) + assertEquals(listOf("locks.app"), request.serviceNames) + } + + @Test + fun `parse deduplicates service names across multiple paths in first-seen order`() { + val capabilities = + "/pub/locks.app/posts/:r,/pub/example.app/:r,/priv/locks.app/settings/:w,/priv/example.app/cache/:r" + + val request = PubkyAuthRequest.parse( + rawUrl = authUrl(capabilities), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ).getOrThrow() + + assertEquals(4, request.permissions.size) + assertEquals(listOf("locks.app", "example.app"), request.serviceNames) + } + @Test fun `parse rejects watch-only capability without Bitkit claim`() { val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES From 1aa9b9da7a269d8ad54e04bac13dc22a866d4243 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 17 Aug 2026 14:13:46 -0500 Subject: [PATCH 4/5] fix: stop removed contact link bursts --- .../bitkit/repositories/PrivatePaykitRepo.kt | 9 +++-- .../repositories/PrivatePaykitRepoTest.kt | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 416de57d0..86cd8de67 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -231,13 +231,16 @@ class PrivatePaykitRepo @Inject constructor( fun startInitialLinkBurst(publicKeys: Collection, reason: String) { val publicKeys = normalizedPublicKeyBatch(publicKeys).normalizedKeys - if (publicKeys.isEmpty()) return synchronized(initialLinkBurstLock) { - initialLinkBurstPublicKeys += publicKeys initialLinkBurstGeneration += 1 - val generation = initialLinkBurstGeneration initialLinkBurstJob?.cancel() + initialLinkBurstJob = null + initialLinkBurstPublicKeys.clear() + initialLinkBurstPublicKeys += publicKeys + if (publicKeys.isEmpty()) return + + val generation = initialLinkBurstGeneration _initialLinkBurstStarted.tryEmit(Unit) initialLinkBurstJob = retryScope.launch { diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 74c213d10..b7d9a06bc 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -338,6 +338,41 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() } + @Test + fun `restarting initial link burst replaces saved contact keys`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + + sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") + runCurrent() + clearInvocations(pubkyService) + + sut.startInitialLinkBurst(listOf(OTHER_CONTACT_KEY), "test") + runCurrent() + clearInvocations(pubkyService) + advanceTimeBy(2_000) + runCurrent() + + verifyBlocking(pubkyService) { discoverRelevantReceiverPaths(OTHER_CONTACT_KEY) } + verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(CONTACT_KEY) } + sut.closeAndClear() + } + + @Test + fun `restarting initial link burst with no contacts cancels retries`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + + sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") + runCurrent() + clearInvocations(pubkyService) + + sut.startInitialLinkBurst(emptyList(), "test") + advanceTimeBy(30_000) + runCurrent() + + verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(any()) } + sut.closeAndClear() + } + @Test fun `prepareSavedContacts clears receiver paths that are no longer eligible`() = test { settingsData.value = SettingsData( From cfc3088685eaf3088d42862e8c758bc490ef3919 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 18 Aug 2026 11:53:18 -0500 Subject: [PATCH 5/5] fix: avoid recreating deleted contacts --- .../bitkit/repositories/PrivatePaykitRepo.kt | 9 +++++--- .../repositories/PrivatePaykitRepoTest.kt | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 86cd8de67..709aa9a22 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1397,10 +1397,13 @@ class PrivatePaykitRepo @Inject constructor( return runSuspendCatching { val discoveredPaths = pubkyService.discoverRelevantReceiverPaths(publicKey) - val mergedPaths = supportedReceiverPaths(savedPaths + discoveredPaths) - if (mergedPaths == savedPaths) return@runSuspendCatching savedPaths + val currentRecord = paykitSdkService.contactRecord(publicKey) + ?: return@runSuspendCatching savedPaths + val currentSavedPaths = supportedReceiverPaths(currentRecord.receiverPaths) + val mergedPaths = supportedReceiverPaths(currentSavedPaths + discoveredPaths) + if (mergedPaths == currentSavedPaths) return@runSuspendCatching currentSavedPaths - val updatedRecord = pubkyService.saveContact(publicKey, record?.label, mergedPaths) + val updatedRecord = pubkyService.saveContact(publicKey, currentRecord.label, mergedPaths) _initialLinkBurstStarted.tryEmit(Unit) Logger.info("Discovered new Paykit receiver paths for '${redacted(publicKey)}'", context = TAG) supportedReceiverPaths(updatedRecord.receiverPaths) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index b7d9a06bc..0520bd162 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -338,6 +338,28 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() } + @Test + fun `initial link burst does not recreate a contact deleted during discovery`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + whenever(paykitSdkService.contactRecord(CONTACT_KEY)) + .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH)), null) + whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY) } + .thenReturn(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH)) + + sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") + runCurrent() + + verify(paykitSdkService, times(2)).contactRecord(CONTACT_KEY) + verifyBlocking(pubkyService, never()) { + saveContact( + CONTACT_KEY, + null, + listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), + ) + } + sut.closeAndClear() + } + @Test fun `restarting initial link burst replaces saved contact keys`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false)