Skip to content
Open
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
91 changes: 86 additions & 5 deletions app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -85,6 +88,7 @@ class PrivatePaykitRepo @Inject constructor(
45.seconds,
90.seconds,
)
private val initialLinkBurstRetryDelays = List(14) { 2.seconds }
private val privatePaymentResolutionRetryDelays = privateMessageDrainRetryDelays.take(3)

fun isDuplicatePaymentError(error: Throwable): Boolean =
Expand All @@ -100,6 +104,12 @@ class PrivatePaykitRepo @Inject constructor(
private val pendingMessageDrainRetryKeys = mutableSetOf<PrivateMessageDrainRetryKey>()
private var pendingMessageDrainRetryJob: Job? = null
private var pendingMessageDrainRetryGeneration = 0
private val _initialLinkBurstStarted = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val initialLinkBurstStarted: SharedFlow<Unit> = _initialLinkBurstStarted.asSharedFlow()
private val initialLinkBurstLock = Any()
private val initialLinkBurstPublicKeys = mutableSetOf<String>()
private var initialLinkBurstJob: Job? = null
private var initialLinkBurstGeneration = 0

private data class PrivatePublicationPreparation(
val updates: List<PrivatePaymentListReservationUpdateInput>,
Expand Down Expand Up @@ -219,6 +229,35 @@ class PrivatePaykitRepo @Inject constructor(
}
}

fun startInitialLinkBurst(publicKeys: Collection<String>, 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<String>,
): Result<Unit> = withContext(serializedDispatcher) {
Expand Down Expand Up @@ -303,6 +342,7 @@ class PrivatePaykitRepo @Inject constructor(
suspend fun closeAndClear(): Result<Unit> = withContext(serializedDispatcher) {
runSuspendCatching {
publicationMutex.withLock {
clearInitialLinkBurst()
clearPendingMessageDrainRetries()
knownSavedContactKeys.clear()
state = PrivatePaykitState()
Expand Down Expand Up @@ -1349,11 +1389,52 @@ class PrivatePaykitRepo @Inject constructor(
}

private suspend fun receiverPathsForSavedContact(publicKey: String): List<String> {
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<String>): List<String> =
PaykitReceiverPaths.supported.filter { it in receiverPaths }
.ifEmpty { listOf(PaykitReceiverPaths.WALLET) }

private suspend fun refreshSavedContactEndpointsDuringInitialLinkBurst(
publicKeys: Collection<String>,
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}'",
Expand Down
37 changes: 34 additions & 3 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,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<PaykitPaymentRequestId, Int>()
private val paymentRequestPresentationRetryJobs = mutableMapOf<PaykitPaymentRequestId, Job>()
private val timedSheetManager = timedSheetManagerProvider(viewModelScope).apply {
Expand Down Expand Up @@ -420,6 +421,7 @@ class AppViewModel @Inject constructor(
observePublicPaykitInvoiceExpiry()
observePrivatePaykitContacts()
observePaykitPaymentRequestConnectivity()
observeInitialPaykitLinkBursts()
observeIncomingPaykitPaymentRequests()
observeSendEvents()
viewModelScope.launch {
Expand Down Expand Up @@ -583,6 +585,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
}
Expand All @@ -604,6 +607,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()
}

Expand All @@ -612,7 +616,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() }
}
}

Expand All @@ -635,23 +645,39 @@ 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 {
(refreshIntervalIndex + 1).coerceAtMost(PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVALS.lastIndex)
}
}
}
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 {
Expand Down Expand Up @@ -695,8 +721,12 @@ class AppViewModel @Inject constructor(
val result = privatePaykitRepo.beginPaymentRequest(request).getOrNull()
if (currentSheet.value != null || hasActiveContactPaymentContext()) 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
}

Expand Down Expand Up @@ -3717,6 +3747,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,
Expand Down
29 changes: 29 additions & 0 deletions app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand All @@ -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")
}
}
Loading
Loading