Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1462,6 +1462,17 @@ class PrivatePaykitRepo @Inject constructor(
retry.prepareEndpoints || retry.refreshReadiness ||
pendingPrivateMessageDrainKeys(listOf(retry.publicKey), priority = priority).isNotEmpty()

private suspend fun restoreLinkedPeerReadiness(
state: PrivateMessageDrainState,
receiveLinkedPeers: Boolean,
): Boolean {
val retry = currentCoroutineContext()[PrivateMessageDrainRetry] ?: return receiveLinkedPeers
if (!retry.isMissing || state.linkedPeers[retry.publicKey] != LinkedPeerState.LINKED) return receiveLinkedPeers
retry.isMissing = false
retry.refreshReadiness = retry.interactiveUntil?.let { it > clock.now() } == true
return receiveLinkedPeers || retry.refreshReadiness
}

private suspend fun pendingPrivateMessageDrainKeys(
retryKeys: Collection<String>,
retryMissingPeers: Boolean = false,
Expand All @@ -1477,6 +1488,7 @@ class PrivatePaykitRepo @Inject constructor(
awaitContactPreparationActive(priority)
if (generation != preparationGeneration) return emptySet()
if (state == null) return retryKeys
val receiveLinkedPeers = restoreLinkedPeerReadiness(state, receiveLinkedPeers)
val missingKey = currentCoroutineContext()[PrivateMessageDrainRetry]?.takeIf { it.isMissing }?.publicKey
return retryKeys.filterTo(mutableSetOf()) { retryKey ->
if (retryKey == missingKey && retryKey !in state.pendingOutbound) return@filterTo false
Expand Down
101 changes: 101 additions & 0 deletions app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,107 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) {
}
}

@Test
fun `unavailable peer that links during queued delivery retries failed intake`() = test {
settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false)
whenever(clock.now()).thenAnswer {
Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds
}
var peerState = LinkedPeerState.NOT_LINKED
whenever(paykitSdkService.linkedPeers()).thenAnswer { listOf(linkedPeer(CONTACT_KEY, peerState)) }
whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY))
.thenAnswer { throw PaykitException.NotFound("not_found", "No App Registry") }
var deliveryPending = true
whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenAnswer {
if (deliveryPending) listOf(CONTACT_KEY) else emptyList()
}
whenever(paykitSdkService.processOutboundPrivateMessages(CONTACT_KEY)).thenAnswer {
if (peerState != LinkedPeerState.LINKED) {
throw PaykitException.Transport("offline", "Unavailable homeserver")
}
deliveryPending = false
mock<OutboundPrivateSendReport>()
}
whenever(paykitSdkService.receivePrivateMessages(CONTACT_KEY))
.thenAnswer { throw PaykitException.Transport("offline", "Unavailable homeserver") }
.thenReturn(mock())

try {
sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow()
runCurrent()
advanceTimeBy(1_000)
runCurrent()
assertTrue(deliveryPending)
verify(paykitSdkService).processOutboundPrivateMessages(CONTACT_KEY, Priority.Interactive)

peerState = LinkedPeerState.LINKED
advanceTimeBy(3_000)
runCurrent()
assertFalse(deliveryPending)
verify(paykitSdkService).receivePrivateMessages(CONTACT_KEY, Priority.Interactive)
verifyNoInteractions(paymentRequestRepo)

advanceTimeBy(8_000)
runCurrent()
verify(paykitSdkService, times(2)).receivePrivateMessages(CONTACT_KEY, Priority.Interactive)
verify(paymentRequestRepo).refreshEligibleTarget(CONTACT_KEY)
clearInvocations(paykitSdkService, paymentRequestRepo)
advanceTimeBy(600_000)
runCurrent()
verifyNoInteractions(paykitSdkService, paymentRequestRepo)
} finally {
sut.closeAndClear()
}
}

@Test
fun `unavailable peer that links after identity check failure retries failed intake`() = test {
settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false)
whenever(clock.now()).thenAnswer {
Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds
}
var peerState = LinkedPeerState.NOT_LINKED
var failIdentityCheck = false
whenever(paykitSdkService.linkedPeers()).thenAnswer { listOf(linkedPeer(CONTACT_KEY, peerState)) }
whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).thenAnswer {
failIdentityCheck = true
throw PaykitException.NotFound("not_found", "No App Registry")
}
whenever(paykitSdkService.identityStatus()).thenAnswer {
if (failIdentityCheck) throw PaykitException.Transport("offline", "Unavailable identity")
IdentityStatus(OWN_KEY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)
}
whenever(paykitSdkService.receivePrivateMessages(CONTACT_KEY))
.thenAnswer { throw PaykitException.Transport("offline", "Unavailable homeserver") }
.thenReturn(mock())

try {
sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow()
runCurrent()
assertTrue(failIdentityCheck)
verifyNoInteractions(paymentRequestRepo)

failIdentityCheck = false
peerState = LinkedPeerState.LINKED
advanceTimeBy(1_000)
runCurrent()
verify(paykitSdkService).receivePrivateMessages(CONTACT_KEY, Priority.Interactive)
verifyNoInteractions(paymentRequestRepo)

advanceTimeBy(3_000)
runCurrent()
verify(paykitSdkService, times(2)).receivePrivateMessages(CONTACT_KEY, Priority.Interactive)
verify(paymentRequestRepo).refreshEligibleTarget(CONTACT_KEY)
verify(paykitSdkService, never()).processOutboundPrivateMessages(any(), any())
clearInvocations(paykitSdkService, paymentRequestRepo)
advanceTimeBy(600_000)
runCurrent()
verifyNoInteractions(paykitSdkService, paymentRequestRepo)
} finally {
sut.closeAndClear()
}
}

@Test
fun `blocked peer retires an unpublished explicit retry`() = test {
settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false)
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1455.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed contact readiness retries stopping after an unavailable contact reconnects.
2 changes: 2 additions & 0 deletions journeys/contacts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ other peers must continue. Repeat with an existing unlinked peer record whose re
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.
If that peer becomes linked while delivery is pending, drain its queue and fail intake once within
the foreground window: intake must retry and signal readiness only after success.

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
Expand Down
Loading