From cae32959b22d256a8bd71e0e9d541c9b537542fe Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 10 Jul 2026 10:59:39 +0200 Subject: [PATCH 1/3] feat: support paykit receiver paths --- .../to/bitkit/data/PrivatePaykitStores.kt | 4 +- .../PrivatePaykitAddressReservationRepo.kt | 95 ++-- .../PrivatePaykitContactResolver.kt | 4 +- .../repositories/PrivatePaykitModels.kt | 20 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 457 +++++++++++---- .../java/to/bitkit/repositories/PubkyRepo.kt | 30 +- .../bitkit/repositories/PublicPaykitRepo.kt | 30 +- .../to/bitkit/services/PaykitSdkService.kt | 165 +++++- .../java/to/bitkit/services/PubkyService.kt | 25 +- .../ui/screens/contacts/AddContactScreen.kt | 12 +- .../screens/contacts/AddContactViewModel.kt | 5 + .../ui/screens/contacts/ContactImportFlow.kt | 4 +- .../ui/screens/contacts/ContactsScreen.kt | 6 +- .../ui/screens/contacts/ContactsViewModel.kt | 9 + .../screens/profile/EditProfileViewModel.kt | 30 +- .../screens/profile/PayContactsViewModel.kt | 67 ++- .../ui/screens/profile/ProfileViewModel.kt | 15 +- .../PaymentPreferenceViewModel.kt | 55 +- .../RefreshContactPaykitReceiversUseCase.kt | 34 ++ .../to/bitkit/usecases/WipeWalletUseCase.kt | 57 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 27 +- .../to/bitkit/viewmodels/SettingsViewModel.kt | 14 +- .../PrivatePaykitContactResolverTest.kt | 10 +- .../repositories/PrivatePaykitRepoTest.kt | 531 +++++++++++++++++- .../to/bitkit/repositories/PubkyRepoTest.kt | 50 ++ .../repositories/PublicPaykitRepoTest.kt | 47 +- .../contacts/AddContactViewModelTest.kt | 6 + .../screens/contacts/ContactImportFlowTest.kt | 2 +- .../profile/EditProfileViewModelTest.kt | 29 +- .../profile/PayContactsViewModelTest.kt | 28 + .../screens/profile/ProfileViewModelTest.kt | 16 +- .../PaymentPreferenceViewModelTest.kt | 53 ++ ...efreshContactPaykitReceiversUseCaseTest.kt | 55 ++ .../bitkit/usecases/WipeWalletUseCaseTest.kt | 35 ++ .../viewmodels/AppViewModelSendFlowTest.kt | 54 +- .../viewmodels/SettingsViewModelTest.kt | 27 + changelog.d/next/1066.added.md | 1 + gradle/libs.versions.toml | 2 +- 38 files changed, 1795 insertions(+), 316 deletions(-) create mode 100644 app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt create mode 100644 app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt create mode 100644 changelog.d/next/1066.added.md diff --git a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt index dd810b0972..3ec708739b 100644 --- a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt +++ b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt @@ -68,9 +68,9 @@ data class PrivatePaykitCacheData( @Serializable data class PrivatePaykitContactCacheData( val remoteEndpoints: List = emptyList(), - val localInvoice: PrivatePaykitStoredInvoiceData? = null, + val localInvoicesByReceiverPath: Map = emptyMap(), val receivedInvoicePaymentHashes: List = emptyList(), - val hasPublishedPrivatePaymentList: Boolean = false, + val publishedPrivatePaymentReceiverPaths: Set = emptySet(), ) @Serializable diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt index 9a013e8e71..6071d148a3 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt @@ -15,12 +15,14 @@ import to.bitkit.data.PrivatePaykitReservationStore import to.bitkit.data.PrivatePaykitStoredAssignmentData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.DEFAULT_ADDRESS_TYPE import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.addressTypeFromAddress import to.bitkit.models.toAddressType import to.bitkit.models.toSettingsString import to.bitkit.services.CoreService +import to.bitkit.services.PaykitReceiverPaths import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject @@ -80,18 +82,21 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( } } - suspend fun currentOrRotatedAddress(publicKey: String): Result = withContext(ioDispatcher) { - runCatching { - val normalizedKey = normalizedPublicKey(publicKey) - val current = locked { it.contactAssignments[normalizedKey] } + suspend fun currentOrRotatedAddress( + publicKey: String, + receiverPath: String, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val assignmentKey = contactAssignmentKey(publicKey, receiverPath) + val current = locked { it.contactAssignments[assignmentKey] } if (current != null && isAddressTypeMonitored(current.addressType)) { val address = resolvedAddress(current).getOrThrow() - if (!isReservedAddressUsed(address)) return@runCatching address + if (!isReservedAddressUsed(address)) return@runSuspendCatching address } else if (current != null) { - clearCurrentAssignment(normalizedKey) + clearCurrentAssignment(assignmentKey) } - allocateAddress(normalizedKey).getOrThrow() + allocateAddress(assignmentKey).getOrThrow() }.onFailure { Logger.warn( "Failed to get private Paykit address for '${redacted(publicKey)}'", @@ -101,18 +106,6 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( } } - suspend fun rotateAddress(publicKey: String): Result = withContext(ioDispatcher) { - runCatching { - allocateAddress(normalizedPublicKey(publicKey)).getOrThrow() - }.onFailure { - Logger.warn( - "Failed to rotate private Paykit address for '${redacted(publicKey)}'", - it, - context = TAG, - ) - } - } - suspend fun nextReusableReceiveAddress(): Result = nextReusableReceiveAddress(selectedAddressType()) @@ -163,7 +156,7 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( assignments.firstOrNull { (_, assignment) -> assignment.addressType == addressType && (assignment.address == address || resolvedAddress(assignment).getOrNull() == address) - }?.first + }?.first?.publicKeyFromAssignmentKey() } suspend fun currentContactPublicKeyForReservedAddress(address: String): String? = withContext(ioDispatcher) { @@ -173,24 +166,25 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( assignments.firstOrNull { (_, assignment) -> assignment.addressType == addressType && (assignment.address == address || resolvedAddress(assignment).getOrNull() == address) - }?.first + }?.first?.publicKeyFromAssignmentKey() } suspend fun contactsWithUsedReservedAddresses(): List = withContext(ioDispatcher) { val assignments = locked { it.contactAssignments.map { entry -> entry.key to entry.value } } assignments.mapNotNull { (publicKey, assignment) -> val address = resolvedAddress(assignment).getOrNull() ?: return@mapNotNull null - val isUsed = runCatching { isReservedAddressUsed(address) } + val isUsed = runSuspendCatching { isReservedAddressUsed(address) } .onFailure { Logger.warn( - "Failed to check private Paykit address usage for '${redacted(publicKey)}'", + "Failed to check private Paykit address usage for " + + "'${redacted(publicKey.publicKeyFromAssignmentKey())}'", it, context = TAG, ) } .getOrDefault(false) - publicKey.takeIf { isUsed } - } + publicKey.publicKeyFromAssignmentKey().takeIf { isUsed } + }.distinct() } private suspend fun isReservedAddressUsed(address: String): Boolean { @@ -201,18 +195,30 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( suspend fun hasContactAssignment(publicKey: String): Boolean = withContext(ioDispatcher) { val normalizedKey = normalizedPublicKey(publicKey) - locked { it.contactAssignments.containsKey(normalizedKey) } + locked { + it.contactAssignments.keys.any { assignmentKey -> + assignmentKey.publicKeyFromAssignmentKey() == normalizedKey + } + } } suspend fun clearContactAssignment(publicKey: String) = withContext(ioDispatcher) { val normalizedKey = normalizedPublicKey(publicKey) locked { current -> - val hadAssignment = normalizedKey in current.contactAssignments - val hadHistory = normalizedKey in current.contactAssignmentHistory + val hadAssignment = current.contactAssignments.keys.any { + it.publicKeyFromAssignmentKey() == normalizedKey + } + val hadHistory = current.contactAssignmentHistory.keys.any { + it.publicKeyFromAssignmentKey() == normalizedKey + } if (!hadAssignment && !hadHistory) return@locked val next = current.copy( - contactAssignments = current.contactAssignments - normalizedKey, - contactAssignmentHistory = current.contactAssignmentHistory - normalizedKey, + contactAssignments = current.contactAssignments.filterKeys { + it.publicKeyFromAssignmentKey() != normalizedKey + }, + contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { + it.publicKeyFromAssignmentKey() != normalizedKey + }, ) ledger = next persist(next) @@ -224,8 +230,12 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( val savedKeys = excludingPublicKeys.mapNotNull { normalizedPublicKeyOrNull(it) }.toSet() locked { current -> val next = current.copy( - contactAssignments = current.contactAssignments.filterKeys { it in savedKeys }, - contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { it in savedKeys }, + contactAssignments = current.contactAssignments.filterKeys { + it.publicKeyFromAssignmentKey() in savedKeys + }, + contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { + it.publicKeyFromAssignmentKey() in savedKeys + }, ) if (next == current) return@locked ledger = next @@ -242,8 +252,8 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( } } - private suspend fun allocateAddress(publicKey: String): Result = withContext(ioDispatcher) { - runCatching { + private suspend fun allocateAddress(assignmentKey: String): Result = withContext(ioDispatcher) { + runSuspendCatching { val addressType = selectedAddressType() val addressTypeKey = addressType.toSettingsString() @@ -259,13 +269,13 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( ) locked { current -> val reserved = current.reservedReceiveIndexesByAddressType[addressTypeKey].orEmpty() + addressInfo.index - val history = current.contactAssignmentHistory[publicKey].orEmpty() + val history = current.contactAssignmentHistory[assignmentKey].orEmpty() .let { if (assignment in it) it else it + assignment } val next = current.copy( reservedReceiveIndexesByAddressType = current.reservedReceiveIndexesByAddressType + (addressTypeKey to reserved), - contactAssignments = current.contactAssignments + (publicKey to assignment), - contactAssignmentHistory = current.contactAssignmentHistory + (publicKey to history), + contactAssignments = current.contactAssignments + (assignmentKey to assignment), + contactAssignmentHistory = current.contactAssignmentHistory + (assignmentKey to history), ) ledger = next persist(next) @@ -317,9 +327,9 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( } } - private suspend fun clearCurrentAssignment(publicKey: String) { + private suspend fun clearCurrentAssignment(assignmentKey: String) { locked { current -> - val next = current.copy(contactAssignments = current.contactAssignments - publicKey) + val next = current.copy(contactAssignments = current.contactAssignments - assignmentKey) ledger = next persist(next) notifyBackupStateChanged() @@ -377,6 +387,13 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( private fun normalizedPublicKeyOrNull(publicKey: String): String? = PubkyPublicKeyFormat.normalized(publicKey) + private fun contactAssignmentKey(publicKey: String, receiverPath: String): String { + val normalizedKey = normalizedPublicKey(publicKey) + return if (receiverPath == PaykitReceiverPaths.WALLET) normalizedKey else "$normalizedKey#$receiverPath" + } + + private fun String.publicKeyFromAssignmentKey(): String = substringBefore("#") + private fun redacted(publicKey: String): String = PubkyPublicKeyFormat.redacted(publicKey) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt index d72cab8dc4..ebb38c4c7f 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt @@ -20,7 +20,9 @@ class PrivatePaykitContactResolver @Inject constructor( if (paymentHash.isBlank()) return@withContext null cacheStore.data.first().contacts.firstNotNullOfOrNull { (publicKey, contactState) -> publicKey.takeIf { - contactState.localInvoice?.paymentHash == paymentHash || + contactState.localInvoicesByReceiverPath.values.any { invoice -> + invoice.paymentHash == paymentHash + } || paymentHash in contactState.receivedInvoicePaymentHashes } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt index ff3b42719c..7741558c9a 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt @@ -32,28 +32,32 @@ internal data class PrivatePaykitState( internal data class ContactState( var remoteEndpoints: List = emptyList(), - var localInvoice: StoredInvoice? = null, + var localInvoicesByReceiverPath: Map = emptyMap(), var receivedInvoicePaymentHashes: List = emptyList(), - var hasPublishedPrivatePaymentList: Boolean = false, + var publishedPrivatePaymentReceiverPaths: Set = emptySet(), ) { constructor(cache: PrivatePaykitContactCacheData) : this( remoteEndpoints = cache.remoteEndpoints.map { StoredPaymentEntry(it.methodId, it.endpointData) }, - localInvoice = cache.localInvoice?.let { StoredInvoice(it.bolt11, it.paymentHash, it.expiresAt) }, + localInvoicesByReceiverPath = cache.localInvoicesByReceiverPath.mapValues { (_, invoice) -> + StoredInvoice(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) + }, receivedInvoicePaymentHashes = cache.receivedInvoicePaymentHashes, - hasPublishedPrivatePaymentList = cache.hasPublishedPrivatePaymentList, + publishedPrivatePaymentReceiverPaths = cache.publishedPrivatePaymentReceiverPaths, ) val hasCacheState: Boolean - get() = hasPublishedPrivatePaymentList || + get() = publishedPrivatePaymentReceiverPaths.isNotEmpty() || remoteEndpoints.isNotEmpty() || - localInvoice != null || + localInvoicesByReceiverPath.isNotEmpty() || receivedInvoicePaymentHashes.isNotEmpty() fun cacheState() = PrivatePaykitContactCacheData( remoteEndpoints = remoteEndpoints.map { PrivatePaykitStoredPaymentEntryData(it.methodId, it.endpointData) }, - localInvoice = localInvoice?.let { PrivatePaykitStoredInvoiceData(it.bolt11, it.paymentHash, it.expiresAt) }, + localInvoicesByReceiverPath = localInvoicesByReceiverPath.mapValues { (_, invoice) -> + PrivatePaykitStoredInvoiceData(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) + }, receivedInvoicePaymentHashes = receivedInvoicePaymentHashes, - hasPublishedPrivatePaymentList = hasPublishedPrivatePaymentList, + publishedPrivatePaymentReceiverPaths = publishedPrivatePaymentReceiverPaths, ) } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 135d67bdcc..9ce2eb7e4e 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -34,6 +34,7 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService +import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService import to.bitkit.utils.Logger @@ -90,15 +91,21 @@ class PrivatePaykitRepo @Inject constructor( private val knownSavedContactKeys = mutableSetOf() private var state: PrivatePaykitState? = null private val pendingMessageDrainRetryLock = Any() - private val pendingMessageDrainRetryKeys = mutableSetOf() + private val pendingMessageDrainRetryKeys = mutableSetOf() private var pendingMessageDrainRetryJob: Job? = null private var pendingMessageDrainRetryGeneration = 0 private data class PrivatePublicationPreparation( val updates: List, + val linkRetryKeys: List, val firstError: Throwable?, ) + private data class PrivateMessageDrainRetryKey( + val publicKey: String, + val receiverPath: String, + ) + private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() @@ -112,6 +119,7 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { val keys = rememberSavedContacts(publicKeys, replacing = true) if (!canPublishPrivateEndpoints()) { + prepareRelevantPrivateLinksIfAvailable(keys, "prepare") if (requireImmediatePublication && keys.isNotEmpty()) throw PrivatePaykitError.PrivateUnavailable return@runSuspendCatching } @@ -149,7 +157,10 @@ class PrivatePaykitRepo @Inject constructor( withContext(serializedDispatcher) { runSuspendCatching { val keys = rememberSavedContacts(publicKeys, replacing = true) - if (!canPublishPrivateEndpoints()) return@runSuspendCatching + if (!canPublishPrivateEndpoints()) { + prepareRelevantPrivateLinksIfAvailable(keys, "refresh") + return@runSuspendCatching + } publishLocalEndpoints(keys, reason = "refresh").getOrThrow() } } @@ -159,7 +170,10 @@ class PrivatePaykitRepo @Inject constructor( forceRefreshLightning: Boolean = false, ): Result = withContext(serializedDispatcher) { runSuspendCatching { - if (!canPublishPrivateEndpoints()) return@runSuspendCatching + if (!canPublishPrivateEndpoints()) { + prepareRelevantPrivateLinksIfAvailable(knownSavedContactKeys, reason) + return@runSuspendCatching + } publishLocalEndpoints( publicKeys = knownSavedContactKeys.toList(), reason = reason, @@ -321,33 +335,39 @@ class PrivatePaykitRepo @Inject constructor( } } - suspend fun handleReceivedPayment(paymentHash: String): Result = withContext(serializedDispatcher) { - runSuspendCatching { - val matchingContacts = ensureState().contacts - .filter { (publicKey, contactState) -> - publicKey in knownSavedContactKeys && contactState.localInvoice?.paymentHash == paymentHash - } - .keys - if (matchingContacts.isEmpty()) return@runSuspendCatching - - matchingContacts.forEach { rememberReceivedInvoicePaymentHash(paymentHash, it) } - if (!canPublishPrivateEndpoints()) return@runSuspendCatching + suspend fun handleReceivedPayment(paymentHash: String): Result = + refreshReceivedPrivateInvoices(setOf(paymentHash), reason = "invoice rotation") - publishLocalEndpoints( - publicKeys = matchingContacts, - reason = "invoice rotation", - forceRefreshLightning = true, - ).onFailure { - Logger.warn("Failed to rotate private Paykit invoice", it, context = TAG) - }.getOrThrow() + suspend fun reconcileReceivedPayments(): Result = withContext(serializedDispatcher) { + runSuspendCatching { + refreshReceivedPrivateInvoices( + paymentHashes = settledPrivateInvoicePaymentHashes().toSet(), + reason = "invoice reconciliation", + ).getOrThrow() } } - suspend fun reconcileReceivedPayments(): Result = withContext(serializedDispatcher) { + private suspend fun refreshReceivedPrivateInvoices( + paymentHashes: Set, + reason: String, + ): Result = withContext(serializedDispatcher) { runSuspendCatching { - settledPrivateInvoicePaymentHashes().forEach { - handleReceivedPayment(it).getOrThrow() + val matches = buildList { + ensureState().contacts.forEach { (publicKey, contactState) -> + if (publicKey !in knownSavedContactKeys) return@forEach + contactState.localInvoicesByReceiverPath.values.forEach { invoice -> + if (invoice.paymentHash in paymentHashes) add(publicKey to invoice.paymentHash) + } + } } + if (matches.isEmpty()) return@runSuspendCatching + + matches.forEach { (publicKey, paymentHash) -> rememberReceivedInvoicePaymentHash(paymentHash, publicKey) } + if (!canPublishPrivateEndpoints()) return@runSuspendCatching + + publishLocalEndpoints(matches.map { it.first }.distinct(), reason = reason) + .onFailure { Logger.warn("Failed to rotate private Paykit invoice", it, context = TAG) } + .getOrThrow() } } @@ -364,9 +384,6 @@ class PrivatePaykitRepo @Inject constructor( }.filter { it in knownSavedContactKeys }.distinct() if (publicKeys.isEmpty()) return@runSuspendCatching - publicKeys.forEach { - addressReservationRepo.rotateAddress(it).getOrThrow() - } publishLocalEndpoints(publicKeys, reason = "on-chain rotation").getOrThrow() } } @@ -376,7 +393,7 @@ class PrivatePaykitRepo @Inject constructor( if (paymentHash.isBlank()) return@withContext null ensureState().contacts.firstNotNullOfOrNull { (publicKey, contactState) -> publicKey.takeIf { - contactState.localInvoice?.paymentHash == paymentHash || + contactState.localInvoices().any { invoice -> invoice.paymentHash == paymentHash } || paymentHash in contactState.receivedInvoicePaymentHashes } } @@ -432,6 +449,7 @@ class PrivatePaykitRepo @Inject constructor( val resolution = paykitSdkService.prepareAndResolveContactPayment( counterparty = publicKey, + receiverPath = PaykitReceiverPaths.WALLET, includePublicEndpoints = true, ) val privateEndpoints = resolution.payableEndpoints @@ -448,7 +466,10 @@ class PrivatePaykitRepo @Inject constructor( } if (resolution.privateState == ContactPaymentResolutionPrivateState.RECOVERY_PENDING) { - schedulePendingPrivateMessageDrainRetries("payment recovery", publicKeys = listOf(publicKey)) + schedulePendingPrivateMessageDrainRetries( + reason = "payment recovery", + retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, PaykitReceiverPaths.WALLET)), + ) } val publicEndpoints = resolution.payableEndpoints @@ -493,9 +514,8 @@ class PrivatePaykitRepo @Inject constructor( ) if (preparation.updates.isEmpty()) { - if (requireImmediatePublication) { - throw preparation.firstError ?: PrivatePaykitError.PrivateUnavailable - } + drainAndSchedulePrivateLinkRetries(reason, preparation.linkRetryKeys) + if (requireImmediatePublication) preparation.firstError?.let { throw it } return@withLock } @@ -505,11 +525,8 @@ class PrivatePaykitRepo @Inject constructor( ) val deliveryError = applyPrivatePaymentListDeliveryReport(report, reason) val firstError = preparation.firstError ?: deliveryError - val retryKeys = privatePaymentListDeliveryRetryKeys(report) - drainPendingPrivateMessages(reason, advancingLinksFor = retryKeys) - if (retryKeys.isNotEmpty()) { - schedulePendingPrivateMessageDrainRetries(reason, publicKeys = retryKeys) - } + val retryKeys = (preparation.linkRetryKeys + privatePaymentListDeliveryRetryKeys(report)).distinct() + drainAndSchedulePrivateLinkRetries(reason, retryKeys) if (firstError != null) { if (requireImmediatePublication) throw firstError @@ -530,43 +547,123 @@ class PrivatePaykitRepo @Inject constructor( ): PrivatePublicationPreparation { var firstError: Throwable? = null val updates = mutableListOf() + val linkRetryKeys = mutableListOf() for (publicKey in publicKeys) { - runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey) }.onFailure { + val receiverPaths = receiverPathsForSavedContact(publicKey) + val receiverPathSelection = paykitSdkService.privateReceiverPathSelection(publicKey, receiverPaths) + val linkableReceiverPaths = receiverPathSelection.linkableReceiverPaths + val publicationReceiverPaths = receiverPathSelection.publishableReceiverPaths + receiverPathSelection.error?.let { + firstError = firstError ?: it Logger.warn( - "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", + "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", it, context = TAG, ) } + val cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( + publicKey = publicKey, + excludedReceiverPaths = publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, + ) - val endpointResult = buildLocalEndpoints(publicKey, forceRefreshLightning) - val endpointError = endpointResult.exceptionOrNull() - if (endpointError != null) { - firstError = firstError ?: endpointError - Logger.warn( - "Failed to prepare private Paykit endpoints for '${redacted(publicKey)}' during '$reason'", - endpointError, - context = TAG, + (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, + ) + } + } + + cleanupReceiverPaths.forEach { receiverPath -> + updates += PrivatePaymentListReservationUpdateInput( + counterparty = publicKey, + counterpartyReceiverPath = receiverPath, + reservations = emptyList(), ) - } else if (endpointResult.getOrThrow().isEmpty()) { - firstError = firstError ?: PrivatePaykitError.PrivateUnavailable + } + + publicationReceiverPaths.forEach { receiverPath -> + runSuspendCatching { + privatePaymentListUpdate(publicKey, receiverPath, forceRefreshLightning) + }.onSuccess { + updates += it + }.onFailure { + firstError = firstError ?: it + logPrivatePublicationPreparationFailure(publicKey, reason, it) + } + } + } + + return PrivatePublicationPreparation(updates, linkRetryKeys.distinct(), firstError) + } + + private suspend fun prepareRelevantPrivateLinksIfAvailable(publicKeys: Collection, reason: String) { + if (!hasLocalSecretKeyForCurrentProfile()) return + + val retryKeys = publicKeys.flatMap { publicKey -> + val receiverPaths = receiverPathsForSavedContact(publicKey) + val selection = paykitSdkService.privateReceiverPathSelection(publicKey, receiverPaths) + selection.error?.let { Logger.warn( - "Skipped private Paykit endpoint publish for '${redacted(publicKey)}' during '$reason'", + "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", + it, context = TAG, ) - } else { - val endpoints = endpointResult.getOrThrow() - updates += PrivatePaymentListReservationUpdateInput( - counterparty = publicKey, - reservations = endpoints.map { endpoint -> - privateReservation(publicKey, endpoint) - }, - ) } + selection.linkableReceiverPaths.map { PrivateMessageDrainRetryKey(publicKey, it) } + }.distinct() + + drainAndSchedulePrivateLinkRetries(reason, retryKeys) + } + + private suspend fun drainAndSchedulePrivateLinkRetries( + reason: String, + retryKeys: Collection, + ) { + if (retryKeys.isEmpty()) return + + drainPendingPrivateMessages(reason, advancingLinksFor = retryKeys.toList()) + val pendingRetryKeys = pendingPrivateMessageDrainKeys(retryKeys) + if (pendingRetryKeys.isNotEmpty()) { + schedulePendingPrivateMessageDrainRetries(reason, retryKeys = pendingRetryKeys) } + } + + private suspend fun privatePaymentListUpdate( + publicKey: String, + receiverPath: String, + forceRefreshLightning: Boolean, + ): PrivatePaymentListReservationUpdateInput { + val endpoints = buildLocalEndpoints(publicKey, receiverPath, forceRefreshLightning).getOrThrow() + if (endpoints.isEmpty()) throw PrivatePaykitError.PrivateUnavailable + return PrivatePaymentListReservationUpdateInput( + counterparty = publicKey, + counterpartyReceiverPath = receiverPath, + reservations = endpoints.map { endpoint -> privateReservation(publicKey, receiverPath, endpoint) }, + ) + } - return PrivatePublicationPreparation(updates, firstError) + private fun logPrivatePublicationPreparationFailure( + publicKey: String, + reason: String, + error: Throwable, + ) { + if (error is PrivatePaykitError.PrivateUnavailable) { + Logger.warn( + "Skipped private Paykit endpoint publish for '${redacted(publicKey)}' during '$reason'", + context = TAG, + ) + } else { + Logger.warn( + "Failed to prepare private Paykit endpoints for '${redacted(publicKey)}' during '$reason'", + error, + context = TAG, + ) + } } private suspend fun applyPrivatePaymentListDeliveryReport( @@ -591,13 +688,15 @@ class PrivatePaykitRepo @Inject constructor( var didUpdateCache = false for (change in report.queued) { val publicKey = normalizedPublicKey(change.counterparty) ?: continue - ensureState().contacts.getOrPut(publicKey) { ContactState() }.hasPublishedPrivatePaymentList = true - updateDeletedContactCleanupPending(publicKey, isPending = false) + recordPublishedPrivatePaymentListCache(publicKey, change.counterpartyReceiverPath) didUpdateCache = true } for (change in report.cleared) { - didUpdateCache = clearPublishedPrivatePaymentListCache(change.counterparty) || didUpdateCache + didUpdateCache = clearPublishedPrivatePaymentListCache( + counterparty = change.counterparty, + receiverPath = change.counterpartyReceiverPath, + ) || didUpdateCache } if (didUpdateCache) { @@ -609,17 +708,34 @@ class PrivatePaykitRepo @Inject constructor( } } - private fun privatePaymentListDeliveryRetryKeys(report: PrivatePaymentListDeliveryReport): List = - (report.queued.map { it.counterparty } + report.failedToDeliver.map { it.counterparty }) - .mapNotNull { normalizedPublicKey(it) } + private fun privatePaymentListDeliveryRetryKeys( + report: PrivatePaymentListDeliveryReport, + ): List { + val changes = report.queued.map { it.counterparty to it.counterpartyReceiverPath } + + report.cleared.map { it.counterparty to it.counterpartyReceiverPath } + + report.failedToDeliver.map { it.counterparty to it.counterpartyReceiverPath } + + return changes + .mapNotNull { (counterparty, receiverPath) -> + normalizedPublicKey(counterparty)?.let { PrivateMessageDrainRetryKey(it, receiverPath) } + } .distinct() + } - private suspend fun drainPendingPrivateMessages(reason: String, advancingLinksFor: List = emptyList()) { + private suspend fun drainPendingPrivateMessages( + reason: String, + advancingLinksFor: List = emptyList(), + ) { runSuspendCatching { - advancingLinksFor.forEach { publicKey -> - runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey) }.onFailure { + advancingLinksFor.distinct().forEach { retryKey -> + runSuspendCatching { + paykitSdkService.ensureLinkWithPeer( + counterparty = retryKey.publicKey, + receiverPath = retryKey.receiverPath, + ) + }.onFailure { Logger.warn( - "Failed to advance private Paykit link for '${redacted(publicKey)}' during '$reason'", + "Failed to advance private Paykit link for '${redacted(retryKey.publicKey)}' during '$reason'", it, context = TAG, ) @@ -634,8 +750,11 @@ class PrivatePaykitRepo @Inject constructor( } } - private fun schedulePendingPrivateMessageDrainRetries(reason: String, publicKeys: List) { - val retryKeys = publicKeys.mapNotNull(::normalizedPublicKey).toSet() + private fun schedulePendingPrivateMessageDrainRetries( + reason: String, + retryKeys: Collection, + ) { + val retryKeys = retryKeys.toSet() if (retryKeys.isEmpty()) return synchronized(pendingMessageDrainRetryLock) { @@ -682,16 +801,18 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun updatePendingMessageDrainRetryKeys(publicKeys: Collection) { - val remainingKeys = pendingPrivateMessageDrainKeys(publicKeys) + private suspend fun updatePendingMessageDrainRetryKeys(retryKeys: Collection) { + val remainingKeys = pendingPrivateMessageDrainKeys(retryKeys) synchronized(pendingMessageDrainRetryLock) { - pendingMessageDrainRetryKeys.removeAll(publicKeys.toSet()) + pendingMessageDrainRetryKeys.removeAll(retryKeys.toSet()) pendingMessageDrainRetryKeys.addAll(remainingKeys) } } - private suspend fun pendingPrivateMessageDrainKeys(publicKeys: Collection): Set { - val retryKeys = publicKeys.mapNotNull(::normalizedPublicKey).toSet() + private suspend fun pendingPrivateMessageDrainKeys( + retryKeys: Collection, + ): Set { + val retryKeys = retryKeys.toSet() if (retryKeys.isEmpty()) return emptySet() val linkedPeers = runSuspendCatching { paykitSdkService.linkedPeers() } @@ -700,22 +821,29 @@ class PrivatePaykitRepo @Inject constructor( return retryKeys } .mapNotNull { peer -> - normalizedPublicKey(peer.counterparty)?.let { publicKey -> publicKey to peer.state } + normalizedPublicKey(peer.counterparty)?.let { publicKey -> + PrivateMessageDrainRetryKey(publicKey, peer.counterpartyReceiverPath) to peer.state + } } - .toMap() + .groupBy(keySelector = { it.first }, valueTransform = { it.second }) val pendingOutbound = runSuspendCatching { paykitSdkService.pendingOutboundPrivateCounterparties() } .getOrElse { Logger.warn("Failed to inspect pending private Paykit messages", it, context = TAG) return retryKeys } - .mapNotNull(::normalizedPublicKey) + .mapNotNull { receiver -> + normalizedPublicKey(receiver.counterparty)?.let { + PrivateMessageDrainRetryKey(it, receiver.counterpartyReceiverPath) + } + } .toSet() - return retryKeys.filterTo(mutableSetOf()) { publicKey -> - when (linkedPeers[publicKey]) { - LinkedPeerState.LINKED -> publicKey in pendingOutbound - LinkedPeerState.BLOCKED, LinkedPeerState.UNKNOWN -> false - null -> publicKey in pendingOutbound + return retryKeys.filterTo(mutableSetOf()) { retryKey -> + val states = linkedPeers[retryKey].orEmpty() + when { + LinkedPeerState.LINKED in states -> retryKey in pendingOutbound + states.isEmpty() -> retryKey in pendingOutbound + states.all { it == LinkedPeerState.BLOCKED || it == LinkedPeerState.UNKNOWN } -> false else -> true } } @@ -730,29 +858,41 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun clearPublishedPrivatePaymentListCache(counterparty: String): Boolean { + private suspend fun recordPublishedPrivatePaymentListCache(publicKey: String, receiverPath: String) { + val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } + contactState.publishedPrivatePaymentReceiverPaths = + (contactState.publishedPrivatePaymentReceiverPaths + receiverPath).toSortedSet() + } + + private suspend fun clearPublishedPrivatePaymentListCache( + counterparty: String, + receiverPath: String, + ): Boolean { val publicKey = normalizedPublicKey(counterparty) ?: return false ensureState().contacts[publicKey]?.let { contactState -> - contactState.remoteEndpoints = emptyList() - contactState.localInvoice = null - contactState.hasPublishedPrivatePaymentList = false + contactState.publishedPrivatePaymentReceiverPaths = + contactState.publishedPrivatePaymentReceiverPaths.filterTo(mutableSetOf()) { it != receiverPath } + contactState.localInvoicesByReceiverPath = contactState.localInvoicesByReceiverPath - receiverPath if (!contactState.hasCacheState) { state?.contacts?.remove(publicKey) } } - updateDeletedContactCleanupPending(publicKey, isPending = false) return true } private suspend fun buildLocalEndpoints( publicKey: String, + receiverPath: String, forceRefreshLightning: Boolean = false, ): Result> = withContext(serializedDispatcher) { runSuspendCatching { val settings = settingsStore.data.first() val endpoints = mutableListOf() if (PublicPaykitRepo.isOnchainPaymentOptionEnabled(settings)) { - val reservedAddress = addressReservationRepo.currentOrRotatedAddress(publicKey).getOrThrow() + val reservedAddress = addressReservationRepo.currentOrRotatedAddress( + publicKey, + receiverPath, + ).getOrThrow() walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow() endpoints += Endpoint( methodId = PublicPaykitRepo.onchainMethodId(reservedAddress), @@ -762,7 +902,11 @@ class PrivatePaykitRepo @Inject constructor( } if (PublicPaykitRepo.isLightningPaymentOptionEnabled(settings) && lightningRepo.canReceive()) { - currentOrRotatedInvoice(publicKey, forceRefresh = forceRefreshLightning).onSuccess { invoice -> + currentOrRotatedInvoice( + publicKey, + receiverPath, + forceRefresh = forceRefreshLightning, + ).onSuccess { invoice -> endpoints += Endpoint( methodId = MethodId.Bolt11, value = invoice.bolt11, @@ -783,17 +927,18 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun currentOrRotatedInvoice( publicKey: String, + receiverPath: String, forceRefresh: Boolean = false, ): Result = withContext(serializedDispatcher) { runSuspendCatching { - if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runSuspendCatching it } + if (!forceRefresh) reusablePrivateInvoice(publicKey, receiverPath)?.let { return@runSuspendCatching it } val bolt11 = lightningRepo.createInvoice( amountSats = null, description = "", expirySeconds = privateInvoiceExpiry.inWholeSeconds.toUInt(), ).getOrThrow() - if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runSuspendCatching it } + if (!forceRefresh) reusablePrivateInvoice(publicKey, receiverPath)?.let { return@runSuspendCatching it } val decoded = (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice ?: throw PublicPaykitError.InvalidPayload @@ -806,14 +951,17 @@ class PrivatePaykitRepo @Inject constructor( paymentHash = decoded.paymentHash.toHex(), expiresAt = expiresAt, ) - ensureState().contacts.getOrPut(publicKey) { ContactState() }.localInvoice = invoice + setLocalInvoice(publicKey, receiverPath, invoice) persistState() invoice } } - private suspend fun reusablePrivateInvoice(publicKey: String): StoredInvoice? { - val invoice = ensureState().contacts[publicKey]?.localInvoice ?: return null + private suspend fun reusablePrivateInvoice( + publicKey: String, + receiverPath: String, + ): StoredInvoice? { + val invoice = localInvoice(publicKey, receiverPath) ?: return null val refreshAt = clock.now().epochSeconds + invoiceRefreshBuffer.inWholeSeconds val decoded = (coreService.decode(invoice.bolt11) as? Scanner.Lightning)?.invoice ?: return null val isReusable = invoice.expiresAt > refreshAt && @@ -824,27 +972,33 @@ class PrivatePaykitRepo @Inject constructor( return invoice.takeIf { isReusable } } - private fun privateReservation(publicKey: String, endpoint: Endpoint): PaymentEndpointReservationInput { + private fun privateReservation( + publicKey: String, + receiverPath: String, + endpoint: Endpoint, + ): PaymentEndpointReservationInput { val contactState = state?.contacts?.get(publicKey) val attribution = if (endpoint.methodId == MethodId.Bolt11) { - val paymentHash = contactState?.localInvoice?.takeIf { it.bolt11 == endpoint.value }?.paymentHash + val paymentHash = localInvoice(publicKey, receiverPath)?.takeIf { it.bolt11 == endpoint.value }?.paymentHash mapOf( "type" to "private_paykit", "counterparty" to publicKey, + "receiver_path" to receiverPath, ) + listOfNotNull(paymentHash?.let { "payment_hash" to it }).toMap() } else { mapOf( "type" to "private_paykit", "counterparty" to publicKey, + "receiver_path" to receiverPath, ) } val expiresAt = contactState - ?.localInvoice + ?.let { localInvoice(publicKey, receiverPath) } ?.takeIf { endpoint.methodId == MethodId.Bolt11 && it.bolt11 == endpoint.value } ?.let { Instant.ofEpochSecond(it.expiresAt).toString() } return PaymentEndpointReservationInput( - reservationId = privateReservationId(publicKey, endpoint), + reservationId = privateReservationId(publicKey, receiverPath, endpoint), identifier = endpoint.methodId.rawValue, payload = endpoint.rawPayload, expiresAt = expiresAt, @@ -852,12 +1006,12 @@ class PrivatePaykitRepo @Inject constructor( ) } - private fun privateReservationId(publicKey: String, endpoint: Endpoint): String { + private fun privateReservationId(publicKey: String, receiverPath: String, endpoint: Endpoint): String { val payloadHashPrefix = MessageDigest.getInstance("SHA-256") .digest(endpoint.rawPayload.toByteArray(Charsets.UTF_8)) .copyOfRange(0, 8) .toHex() - return "$publicKey:${endpoint.methodId.rawValue}:$payloadHashPrefix" + return "$publicKey:$receiverPath:${endpoint.methodId.rawValue}:$payloadHashPrefix" } private suspend fun cacheResolvedPrivateEndpoints(publicKey: String, endpoints: List) { @@ -879,14 +1033,33 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun removePublishedEndpoints(publicKey: String): Result = withContext(serializedDispatcher) { runSuspendCatching { - val report = paykitSdkService.clearPrivatePaymentList(counterparty = publicKey) - if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { - throw PrivatePaykitError.PrivateUnavailable + var firstError: Throwable? = null + receiverPathsForCleanup(publicKey).forEach { receiverPath -> + val result = runSuspendCatching { + val report = paykitSdkService.clearPrivatePaymentList( + counterparty = publicKey, + receiverPath = 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() + } } + + firstError?.let { throw it } + state?.contacts?.get(publicKey)?.let { contactState -> contactState.remoteEndpoints = emptyList() - contactState.localInvoice = null - contactState.hasPublishedPrivatePaymentList = false + contactState.localInvoicesByReceiverPath = emptyMap() + contactState.publishedPrivatePaymentReceiverPaths = emptySet() if (!contactState.hasCacheState) { state?.contacts?.remove(publicKey) } @@ -896,6 +1069,57 @@ 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) } + } + + private suspend fun receiverPathsForPrivateEndpointCleanup( + publicKey: String, + excludedReceiverPaths: List, + ): List { + val publishedPaths = publishedPrivatePaymentReceiverPaths(publicKey) + val linkedPaths = linkedReceiverPaths(publicKey) + return (publishedPaths + linkedPaths) + .filter { it in PaykitReceiverPaths.supported } + .filterNot { it in excludedReceiverPaths } + .distinct() + .sorted() + } + + private suspend fun receiverPathsForCleanup(publicKey: String): List { + val paths = ( + linkedReceiverPaths(publicKey) + + 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 + } + } + .distinct() + .sorted() + return paths + } + + private fun publishedPrivatePaymentReceiverPaths(publicKey: String): List { + val contactState = state?.contacts?.get(publicKey) ?: return emptyList() + return contactState.publishedPrivatePaymentReceiverPaths.toList() + } + private suspend fun clearUnsavedContactState(savedPublicKeys: Collection): Result = withContext(serializedDispatcher) { runSuspendCatching { @@ -1044,7 +1268,10 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun settledPrivateInvoicePaymentHashes(): List { val settled = receivedSettledPaymentHashes() - return ensureState().contacts.values.mapNotNull { it.localInvoice?.paymentHash?.takeIf(settled::contains) } + return ensureState().contacts.values + .flatMap { it.localInvoices() } + .map { it.paymentHash } + .filter(settled::contains) } private suspend fun paymentHashForBolt11(bolt11: String): String? = @@ -1091,6 +1318,20 @@ class PrivatePaykitRepo @Inject constructor( persistState() } + private fun localInvoice(publicKey: String, receiverPath: String): StoredInvoice? { + val contactState = state?.contacts?.get(publicKey) ?: return null + return contactState.localInvoicesByReceiverPath[receiverPath] + } + + private suspend fun setLocalInvoice(publicKey: String, receiverPath: String, invoice: StoredInvoice) { + val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } + contactState.localInvoicesByReceiverPath = contactState.localInvoicesByReceiverPath + (receiverPath to invoice) + } + + private fun ContactState.localInvoices(): List { + return localInvoicesByReceiverPath.values.toList() + } + private fun rememberSavedContacts(publicKeys: Collection, replacing: Boolean): List { val normalizedKeys = publicKeys.mapNotNull { normalizedPublicKey(it) }.distinct() if (replacing) { diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 9557ed240c..cd416318a8 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -35,7 +35,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import to.bitkit.data.PubkyStore import to.bitkit.data.SettingsStore -import to.bitkit.data.hasPublicPaykitPublicationState +import to.bitkit.data.hasPaykitState import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.env.Env @@ -49,6 +49,7 @@ import to.bitkit.models.PubkyRingAuthCallback import to.bitkit.models.PubkyRingAuthCallbackHandlingResult import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 +import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PubkyService import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -769,7 +770,7 @@ class PubkyRepo @Inject constructor( val profile = existingProfile?.copy(publicKey = prefixedKey) ?: resolveContactProfile(prefixedKey).getOrThrow() ?: PubkyProfile.placeholder(prefixedKey) - pubkyService.saveContact(prefixedKey, profile.name) + pubkyService.saveContact(prefixedKey, profile.name, relevantReceiverPaths(prefixedKey)) _contacts.update { current -> (current.filter { it.publicKey != prefixedKey } + profile) .sortedBy { it.name.lowercase() } @@ -779,6 +780,16 @@ class PubkyRepo @Inject constructor( } } + suspend fun refreshContactReceiverPaths(publicKey: String): Result = runSuspendCatching { + withContext(ioDispatcher) { + val prefixedKey = requireAddableContactPublicKey(publicKey = publicKey, allowExisting = true) + val contact = _contacts.value.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, prefixedKey) } + ?: return@withContext + pubkyService.saveContact(prefixedKey, contact.name, relevantReceiverPaths(prefixedKey)) + Logger.info("Refreshed contact receiver paths for '${redacted(prefixedKey)}'", context = TAG) + } + } + @Suppress("LongParameterList") suspend fun updateContact( publicKey: String, @@ -830,7 +841,7 @@ class PubkyRepo @Inject constructor( runSuspendCatching { val profile = resolveContactProfile(prefixedKey).getOrThrow() ?: PubkyProfile.placeholder(prefixedKey) - pubkyService.saveContact(prefixedKey, profile.name) + pubkyService.saveContact(prefixedKey, profile.name, relevantReceiverPaths(prefixedKey)) profile }.onFailure { Logger.warn("Failed to import contact '${redacted(prefixedKey)}'", it, context = TAG) @@ -1000,7 +1011,7 @@ class PubkyRepo @Inject constructor( // region Sign out suspend fun signOut(): Result { - val hadPublicPaykitState = settingsStore.data.first().hasPublicPaykitPublicationState() + val hadPaykitState = settingsStore.data.first().hasPaykitState() val endpointCleanupResult = removeBitkitPaymentEndpoints() .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } @@ -1014,7 +1025,7 @@ class PubkyRepo @Inject constructor( }, ) - clearLocalState(publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPublicPaykitState) + clearLocalState(publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState) return result } @@ -1111,6 +1122,14 @@ class PubkyRepo @Inject constructor( ) } + private suspend fun relevantReceiverPaths(publicKey: String): List = + runSuspendCatching { + pubkyService.discoverRelevantReceiverPaths(publicKey) + }.onFailure { + Logger.warn("Failed to discover Paykit receivers for '${redacted(publicKey)}'", it, context = TAG) + }.getOrNull() + ?: listOf(PaykitReceiverPaths.WALLET) + private suspend fun upsertContactProfileOverride(profile: PubkyProfile) { val prefixedKey = profile.publicKey.ensurePubkyPrefix() pubkyStore.update { data -> @@ -1196,6 +1215,7 @@ class PubkyRepo @Inject constructor( it.copy( hasConfirmedPublicPaykitEndpoints = false, sharesPublicPaykitEndpoints = false, + sharesPrivatePaykitEndpoints = false, publicPaykitBolt11 = "", publicPaykitBolt11PaymentHash = "", publicPaykitBolt11ExpiresAtMillis = 0, diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 73401dc3f0..170e1a9481 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -23,6 +23,7 @@ import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.toLdkNetwork import to.bitkit.services.CoreService +import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.utils.AppError import to.bitkit.utils.NetworkValidationHelper @@ -181,12 +182,19 @@ class PublicPaykitRepo @Inject constructor( suspend fun syncPublishedEndpoints(publish: Boolean): Result = withContext(ioDispatcher) { runSuspendCatching { if (!publish) { - removePublishedEndpoints() + val endpointError = runSuspendCatching { removePublishedEndpoints() }.exceptionOrNull() + val markerError = syncLocalReceiverMarker(publicSharingEnabled = false).exceptionOrNull() + if (endpointError != null) { + markerError?.let(endpointError::addSuppressed) + throw endpointError + } + markerError?.let { throw it } settingsStore.update { it.copy(publicPaykitCleanupPending = false) } return@runSuspendCatching } val desired = buildWalletEndpoints(refresh = true) + syncLocalReceiverMarker(publicSharingEnabled = true).getOrThrow() applyPublishedEndpoints(desired) settingsStore.update { it.copy(publicPaykitCleanupPending = false) } } @@ -202,6 +210,7 @@ class PublicPaykitRepo @Inject constructor( forceRefreshLightning = forceRefreshLightning, requireEndpoint = requireEndpoint, ) + syncLocalReceiverMarker(publicSharingEnabled = true).getOrThrow() applyPublishedEndpoints(desired) settingsStore.update { it.copy(publicPaykitCleanupPending = false) } } @@ -222,7 +231,10 @@ class PublicPaykitRepo @Inject constructor( private suspend fun fetchPublicEndpoints(publicKey: String): Result> = withContext(ioDispatcher) { runSuspendCatching { val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey - paykitSdkService.resolvePublicContactPayment(counterparty = normalizedKey).payableEndpoints + paykitSdkService.resolvePublicContactPayment( + counterparty = normalizedKey, + receiverPath = PaykitReceiverPaths.WALLET, + ).payableEndpoints .mapNotNull { parseEndpoint(it.identifier, it.payload) } .associateBy { it.methodId } .values @@ -230,6 +242,20 @@ class PublicPaykitRepo @Inject constructor( } } + suspend fun syncLocalReceiverMarker( + publicSharingEnabled: Boolean? = null, + privateSharingEnabled: Boolean? = null, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val settings = settingsStore.data.first() + val publicSharing = publicSharingEnabled ?: settings.sharesPublicPaykitEndpoints + val privateSharing = privateSharingEnabled ?: settings.sharesPrivatePaykitEndpoints + paykitSdkService.syncLocalReceiverMarker( + isDiscoverable = publicSharing || privateSharing, + ) + } + } + private suspend fun removePublishedEndpoints() { applyPublishedEndpoints(emptyList()) clearPublicBolt11Metadata() diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index e22a09dba2..8c9146151a 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -6,6 +6,7 @@ import com.synonym.paykit.ContactPaymentResolutionPrivateState import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate +import com.synonym.paykit.CounterpartyReceiver import com.synonym.paykit.EndpointSyncReport import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord @@ -13,6 +14,8 @@ import com.synonym.paykit.PaykitAndroid import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitProfile import com.synonym.paykit.PaykitProfileRecord +import com.synonym.paykit.PaykitReceiverCapabilities +import com.synonym.paykit.PaykitReceiverMarker import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitSdkDefaults import com.synonym.paykit.PaymentEndpointCandidate @@ -79,6 +82,21 @@ data class PaykitResolvedPaymentEndpoint( val payload: String, ) +data class PaykitPrivateReceiverPathSelection( + val linkableReceiverPaths: List, + val publishableReceiverPaths: List, + val cleanupProtectedReceiverPaths: List, + val error: Throwable?, +) + +internal object PaykitReceiverPaths { + const val WALLET = "bitkit/wallet" + const val SERVER = "bitkit/server" + + /** Current Bitkit flows only route its own receivers; cross-wallet routing can broaden this allowlist. */ + val supported = listOf(WALLET, SERVER) +} + @Singleton @Suppress("TooManyFunctions") class PaykitSdkService @Inject constructor( @@ -170,6 +188,7 @@ class PaykitSdkService @Inject constructor( localSecretKey = localSecretKey(secretKeyHex), homeserverPublicKey = homeserverPublicKey, signupCode = signupCode, + requiredCapabilities = requiredCapabilities(), ) operationMutex.withLock { activateBootstrapResult( @@ -185,7 +204,10 @@ class PaykitSdkService @Inject constructor( suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } - val result = PubkySessionBootstrap().signIn(localSecretKey(secretKeyHex)) + val result = PubkySessionBootstrap().signIn( + localSecretKey = localSecretKey(secretKeyHex), + requiredCapabilities = requiredCapabilities(), + ) operationMutex.withLock { activateBootstrapResult( result = result, @@ -302,11 +324,24 @@ class PaykitSdkService @Inject constructor( } } - suspend fun saveContact(publicKey: String, label: String?): ContactRecord { + suspend fun contactRecord(publicKey: String): ContactRecord? { isSetup.await() return operationMutex.withLock { - handle().saveContact(ContactUpdate(publicKey, label)).also { - notifyBackupStateChanged() + handle().contactRecord(publicKey) + } + } + + suspend fun saveContact( + publicKey: String, + label: String?, + receiverPaths: List? = null, + ): ContactRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + val existingPaths = handle.contactRecord(publicKey)?.receiverPaths.orEmpty() + val contactPaths = mergedReceiverPaths(existingPaths + receiverPaths.orEmpty()) + handle.saveContact(ContactUpdate(publicKey, contactPaths, label)) } } } @@ -326,7 +361,82 @@ class PaykitSdkService @Inject constructor( ): ContactProfileResolution? { isSetup.await() return operationMutex.withLock { - handle().resolveContactProfile(publicKey, allowPubkyProfileFallback) + handle().resolveContactProfile(publicKey, PaykitReceiverPaths.WALLET, allowPubkyProfileFallback) + } + } + + suspend fun discoverRelevantReceiverPaths(publicKey: String): List { + isSetup.await() + return operationMutex.withLock { + val handle = handle() + val discovered = handle.paykitReceiverPaths(publicKey) + .filter { it in PaykitReceiverPaths.supported } + .filter { + it == PaykitReceiverPaths.WALLET || + handle.paykitReceiverMarker(publicKey, it)?.requiresPrivateLink() == true + } + mergedReceiverPaths(discovered) + } + } + + suspend fun privateReceiverPathSelection( + publicKey: String, + savedReceiverPaths: List, + ): PaykitPrivateReceiverPathSelection { + isSetup.await() + return operationMutex.withLock { + val handle = handle() + val linkable = mutableListOf() + val publishable = mutableListOf() + val cleanupProtected = mutableListOf() + var firstError: Throwable? = null + + mergedReceiverPaths(savedReceiverPaths).forEach { receiverPath -> + runSuspendCatching { handle.paykitReceiverMarker(publicKey, receiverPath) } + .onSuccess { marker -> + if (marker?.requiresPrivateLink() == true) { + linkable += receiverPath + } + if (marker.canReceivePrivatePaymentDetails()) { + publishable += receiverPath + } + } + .onFailure { + cleanupProtected += receiverPath + firstError = firstError ?: it + } + } + + PaykitPrivateReceiverPathSelection( + linkableReceiverPaths = linkable, + publishableReceiverPaths = publishable, + cleanupProtectedReceiverPaths = cleanupProtected, + error = firstError, + ) + } + } + + suspend fun syncLocalReceiverMarker( + isDiscoverable: Boolean, + ) { + isSetup.await() + operationMutex.withLock { + withStateRevisionTracking { handle -> + if (!isDiscoverable) { + handle.removePaykitReceiverMarker() + return@withStateRevisionTracking + } + + val status = handle.identityStatus() + handle.publishPaykitReceiverMarker( + PaykitReceiverCapabilities( + privatePayments = status?.privateLinkCapable == true, + paymentRequests = false, + receipts = false, + outgoingPayments = true, + ), + ) + } } } @@ -356,20 +466,27 @@ class PaykitSdkService @Inject constructor( } } - suspend fun ensureLinkWithPeer(counterparty: String, maxAdvanceSteps: UInt = 8u) = run { + suspend fun ensureLinkWithPeer( + counterparty: String, + receiverPath: String, + maxAdvanceSteps: UInt = 8u, + ) = run { isSetup.await() operationMutex.withLock { withStateRevisionTracking { handle -> - handle.ensureLinkWithPeer(counterparty, maxAdvanceSteps) + handle.ensureLinkWithPeer(counterparty, receiverPath, maxAdvanceSteps) } } } - suspend fun clearPrivatePaymentList(counterparty: String): PrivatePaymentListDeliveryReport { + suspend fun clearPrivatePaymentList( + counterparty: String, + receiverPath: String, + ): PrivatePaymentListDeliveryReport { isSetup.await() return operationMutex.withLock { withStateRevisionTracking { handle -> - handle.clearPrivatePaymentListAndProcessOutbound(counterparty) + handle.clearPrivatePaymentListAndProcessOutbound(counterparty, receiverPath) } } } @@ -399,7 +516,7 @@ class PaykitSdkService @Inject constructor( } } - suspend fun pendingOutboundPrivateCounterparties(): List { + suspend fun pendingOutboundPrivateCounterparties(): List { isSetup.await() return operationMutex.withLock { handle().pendingOutboundPrivateCounterparties() @@ -408,6 +525,7 @@ class PaykitSdkService @Inject constructor( suspend fun prepareAndResolveContactPayment( counterparty: String, + receiverPath: String, includePublicEndpoints: Boolean, ): PaykitContactPaymentResolution { isSetup.await() @@ -415,6 +533,7 @@ class PaykitSdkService @Inject constructor( withStateRevisionTracking { handle -> handle.prepareAndResolveContactPayment( counterparty = counterparty, + counterpartyReceiverPath = receiverPath, amount = null, includePublicEndpoints = includePublicEndpoints, maxAdvanceSteps = 8u, @@ -424,10 +543,13 @@ class PaykitSdkService @Inject constructor( return prepared.resolution.toPaykitContactPaymentResolution() } - suspend fun resolvePublicContactPayment(counterparty: String): PaykitContactPaymentResolution { + suspend fun resolvePublicContactPayment( + counterparty: String, + receiverPath: String, + ): PaykitContactPaymentResolution { isSetup.await() val resolution = operationMutex.withLock { - handle().resolvePublicContactPayment(counterparty, amount = null) + handle().resolvePublicContactPayment(counterparty, receiverPath, amount = null) } return resolution.toPaykitContactPaymentResolution() } @@ -583,6 +705,18 @@ class PaykitSdkService @Inject constructor( sdk = null } + private fun mergedReceiverPaths(paths: List): List { + return (listOf(PaykitReceiverPaths.WALLET) + paths) + .filter { it in PaykitReceiverPaths.supported } + .distinct() + } + + private fun PaykitReceiverMarker.requiresPrivateLink(): Boolean = + capabilities.privatePayments || capabilities.paymentRequests || capabilities.receipts + + private fun PaykitReceiverMarker?.canReceivePrivatePaymentDetails(): Boolean = + this?.capabilities?.let { it.privatePayments && it.outgoingPayments } == true + companion object { fun localSecretKey(secretKeyHex: String): PubkyLocalSecretKey = PubkyLocalSecretKey(secretKeyHex.fromHex()) @@ -609,7 +743,7 @@ internal object BitkitPaykitSdkConfig { val publicContactSharing = PaykitSdkDefaults.DEFAULT_PUBLIC_CONTACT_SHARING_POLICY } -internal fun paykitSdkConfig() = defaultConfig().copy( +internal fun paykitSdkConfig() = defaultConfig(PaykitReceiverPaths.WALLET).copy( profileNamespace = BitkitPaykitSdkConfig.profileNamespace, endpointManagementScope = BitkitPaykitSdkConfig.endpointManagementScope, encryptedLinkRecoveryMarkers = BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers, @@ -704,7 +838,10 @@ private class PaykitSdkSessionProvider( class PaykitSdkPaymentAdapter : SdkPaymentAdapter { override fun currentReceivingDetails(scope: ReceivingDetailScope): List = emptyList() - override fun reserveReceivingDetails(counterparty: String): ReceivingDetailReservationResponse = + override fun reserveReceivingDetails( + counterparty: String, + counterpartyReceiverPath: String, + ): ReceivingDetailReservationResponse = ReceivingDetailReservationResponse( kind = ReceivingDetailReservationResponseKind.USE_CURRENT_RECEIVING_DETAILS, reservations = emptyList(), diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 7acd69c0a2..73b215ff36 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -4,6 +4,7 @@ import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord import com.synonym.paykit.PaykitProfile import to.bitkit.async.ServiceQueue +import to.bitkit.ext.runSuspendCatching import to.bitkit.utils.AppError import javax.inject.Inject import javax.inject.Singleton @@ -44,8 +45,16 @@ class PubkyService @Inject constructor( } suspend fun removeBitkitPaymentEndpoints() = ServiceQueue.CORE.background { - val report = paykitSdkService.syncPublicEndpoints(emptyList()) - if (report.failed.isNotEmpty()) throw AppError("Failed to remove Paykit payment endpoints") + val endpointError = runSuspendCatching { + val report = paykitSdkService.syncPublicEndpoints(emptyList()) + if (report.failed.isNotEmpty()) throw AppError("Failed to remove Paykit payment endpoints") + }.exceptionOrNull() + val markerError = runSuspendCatching { + paykitSdkService.syncLocalReceiverMarker(isDiscoverable = false) + }.exceptionOrNull() + val cleanupError = endpointError ?: markerError + if (endpointError != null && markerError != null) endpointError.addSuppressed(markerError) + cleanupError?.let { throw it } } // endregion @@ -140,8 +149,12 @@ class PubkyService @Inject constructor( paykitSdkService.contactRecords() } - suspend fun saveContact(publicKey: String, label: String?): ContactRecord = ServiceQueue.CORE.background { - paykitSdkService.saveContact(publicKey, label) + suspend fun saveContact( + publicKey: String, + label: String?, + receiverPaths: List? = null, + ): ContactRecord = ServiceQueue.CORE.background { + paykitSdkService.saveContact(publicKey, label, receiverPaths) } suspend fun removeContact(publicKey: String): ContactRecord? = ServiceQueue.CORE.background { @@ -155,5 +168,9 @@ class PubkyService @Inject constructor( paykitSdkService.resolveContactProfile(publicKey, allowPubkyProfileFallback) } + suspend fun discoverRelevantReceiverPaths(publicKey: String): List = ServiceQueue.CORE.background { + paykitSdkService.discoverRelevantReceiverPaths(publicKey) + } + // endregion } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt index 2059649a5e..28f5b9340d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt @@ -85,6 +85,7 @@ fun AddContactSheet( contacts: ImmutableList, onDismiss: () -> Unit, onSubmit: (publicKey: String) -> Unit, + onExistingContact: (publicKey: String) -> Unit, onScanQr: () -> Unit, ) { val context = LocalContext.current @@ -96,12 +97,11 @@ fun AddContactSheet( ) val validationMessage = when (validationResult) { AddContactValidationResult.Empty -> null - AddContactValidationResult.ExistingContact -> context.getString(R.string.contacts__add_error_existing) + is AddContactValidationResult.ExistingContact -> context.getString(R.string.contacts__add_error_existing) AddContactValidationResult.InvalidKey -> context.getString(R.string.contacts__add_error_invalid_key) AddContactValidationResult.OwnKey -> context.getString(R.string.contacts__add_error_self) is AddContactValidationResult.Valid -> null } - BottomSheet( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), @@ -114,6 +114,14 @@ fun AddContactSheet( onPaste = { context.getClipboardText()?.trim()?.let { publicKeyInput = PubkyPublicKeyFormat.bounded(it) + val pastedValidation = resolveAddContactValidation( + input = publicKeyInput, + ownPublicKey = currentPublicKey, + contacts = contacts, + ) + if (pastedValidation is AddContactValidationResult.ExistingContact) { + onExistingContact(pastedValidation.normalizedKey) + } } }, onScanQr = onScanQr, 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 9cc2ec6d24..5f8182f02f 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 @@ -23,6 +23,7 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.Logger import javax.inject.Inject @@ -31,6 +32,7 @@ class AddContactViewModel @Inject constructor( @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, private val publicPaykitRepo: PublicPaykitRepo, + private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -78,6 +80,9 @@ class AddContactViewModel @Inject constructor( } } .onFailure { error -> + if (error == PubkyContactError.AlreadyExists) { + refreshContactPaykitReceivers(publicKey) + } _uiState.update { state -> state.copy( isLoading = false, diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportFlow.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportFlow.kt index 06fdce7f20..4cf007a0db 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportFlow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportFlow.kt @@ -11,7 +11,7 @@ internal fun hasPendingImport(profile: PubkyProfile?, contacts: List Unit, onSearchTextChange: (String) -> Unit, onAddContact: (String) -> Unit, + onExistingContact: (String) -> Unit, onScanQr: () -> Unit, openAddContactSheet: Boolean, ) { @@ -134,12 +136,13 @@ private fun Content( if (showAddContactSheet) { AddContactSheet( currentPublicKey = uiState.myProfile?.publicKey, - contacts = uiState.contacts, + contacts = uiState.allContacts, onDismiss = { showAddContactSheet = false }, onSubmit = { publicKey -> showAddContactSheet = false onAddContact(publicKey) }, + onExistingContact = onExistingContact, onScanQr = { showAddContactSheet = false onScanQr() @@ -309,6 +312,7 @@ private fun Preview() { onClickContact = {}, onSearchTextChange = {}, onAddContact = {}, + onExistingContact = {}, onScanQr = {}, openAddContactSheet = false, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt index 4bc530ec8e..cfd1cb7439 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt @@ -16,11 +16,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.models.PubkyProfile import to.bitkit.repositories.PubkyRepo +import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import javax.inject.Inject @HiltViewModel class ContactsViewModel @Inject constructor( private val pubkyRepo: PubkyRepo, + private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, ) : ViewModel() { private val _searchText = MutableStateFlow("") @@ -40,6 +42,7 @@ class ContactsViewModel @Inject constructor( myProfile, _searchText, ) { contacts, isLoading, myProfileValue, search -> + val sortedContacts = contacts.sortedBy { it.name.lowercase() }.toImmutableList() val filtered = if (search.isBlank()) { contacts } else { @@ -51,6 +54,7 @@ class ContactsViewModel @Inject constructor( val sorted = filtered.sortedBy { it.name.lowercase() }.toImmutableList() ContactsUiState( contacts = sorted, + allContacts = sortedContacts, myProfile = myProfileValue, isLoading = isLoading, searchText = search, @@ -64,11 +68,16 @@ class ContactsViewModel @Inject constructor( fun refresh() { viewModelScope.launch { pubkyRepo.loadContacts() } } + + fun refreshExistingContact(publicKey: String) { + viewModelScope.launch { refreshContactPaykitReceivers(publicKey) } + } } @Stable data class ContactsUiState( val contacts: ImmutableList = persistentListOf(), + val allContacts: ImmutableList = persistentListOf(), val myProfile: PubkyProfile? = null, val isLoading: Boolean = false, val searchText: String = "", diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt index 7aa95cebf7..9cf2adb228 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt @@ -227,7 +227,20 @@ class EditProfileViewModel @Inject constructor( fun disconnectProfile() { viewModelScope.launch { _uiState.update { it.copy(showDeleteFailureDialog = false, isSaving = true) } - privatePaykitRepo.removePublishedEndpointsForCleanup(TAG) + val cleanupResult = privatePaykitRepo.removePublishedEndpointsForCleanup(TAG) + if (cleanupResult.isFailure) { + val error = requireNotNull(cleanupResult.exceptionOrNull()) { + "Private Paykit cleanup failed without an error" + } + _uiState.update { it.copy(isSaving = false) } + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__disconnect_error), + description = error.message, + ) + return@launch + } + val result = pubkyRepo.signOut() privatePaykitRepo.closeAndClear() if (result.isSuccess) { @@ -254,7 +267,20 @@ class EditProfileViewModel @Inject constructor( isSaving = true, ) } - privatePaykitRepo.removePublishedEndpointsForCleanup(TAG) + val cleanupResult = privatePaykitRepo.removePublishedEndpointsForCleanup(TAG) + if (cleanupResult.isFailure) { + val error = requireNotNull(cleanupResult.exceptionOrNull()) { + "Private Paykit cleanup failed without an error" + } + _uiState.update { + it.copy( + isSaving = false, + showDeleteFailureDialog = true, + ) + } + return + } + val result = pubkyRepo.deleteProfileWithSessionRetry() if (result.isSuccess) { privatePaykitRepo.closeAndClear() diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt index fb5135a9cf..5ed92e43cf 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.Toast import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo @@ -91,19 +92,23 @@ class PayContactsViewModel @Inject constructor( } private suspend fun enableContactPayments(contacts: List): Result { + val previous = settingsStore.data.first() publicPaykitRepo.syncPublishedEndpoints(publish = true) - .onFailure { return Result.failure(it) } + .onFailure { + rollbackEnabledContactPayments(previous, contacts, it) + return Result.failure(it) + } val canUsePrivateContactPayments = pubkyRepo.hasSecretKey() if (canUsePrivateContactPayments) { privatePaykitRepo.setContactSharingCleanupPending(false) .onFailure { - publicPaykitRepo.syncPublishedEndpoints(publish = false) + rollbackEnabledContactPayments(previous, contacts, it) return Result.failure(it) } } - runCatching { + runSuspendCatching { settingsStore.update { it.copy( hasConfirmedPublicPaykitEndpoints = true, @@ -112,6 +117,7 @@ class PayContactsViewModel @Inject constructor( ) } }.onFailure { + rollbackEnabledContactPayments(previous, contacts, it) return Result.failure(it) } @@ -122,9 +128,34 @@ class PayContactsViewModel @Inject constructor( return Result.success(Unit) } + private suspend fun rollbackEnabledContactPayments( + previous: SettingsData, + contacts: List, + error: Throwable, + ) { + runSuspendCatching { + settingsStore.update { + it.copy( + hasConfirmedPublicPaykitEndpoints = previous.hasConfirmedPublicPaykitEndpoints, + sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints, + sharesPrivatePaykitEndpoints = previous.sharesPrivatePaykitEndpoints, + ) + } + }.onFailure(error::addSuppressed) + publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) + .onFailure { rollbackError -> + error.addSuppressed(rollbackError) + markPublicPaykitRetry(error) + } + if (!previous.sharesPrivatePaykitEndpoints) { + privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) + .onFailure(error::addSuppressed) + } + } + private suspend fun disableContactPayments(contacts: List): Result { val previous = settingsStore.data.first() - runCatching { + runSuspendCatching { settingsStore.update { it.copy( hasConfirmedPublicPaykitEndpoints = true, @@ -145,13 +176,18 @@ class PayContactsViewModel @Inject constructor( .onFailure { privateCleanupError = it } publicCleanupError?.let { error -> - runCatching { + runSuspendCatching { settingsStore.update { settings -> settings.copy(sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints) } }.onFailure { rollbackError -> error.addSuppressed(rollbackError) } + publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) + .onFailure { rollbackError -> + error.addSuppressed(rollbackError) + markPublicPaykitRetry(error) + } } privateCleanupError?.let { error -> if (previous.sharesPrivatePaykitEndpoints) { @@ -185,23 +221,30 @@ class PayContactsViewModel @Inject constructor( privatePaykitRepo.prepareSavedContacts( publicKeys = contacts, requireImmediatePublication = true, - ).onFailure { + ).exceptionOrNull()?.let { error.addSuppressed(it) updatePrivateContactsPreference(isEnabled = false, error = error) + publicPaykitRepo.syncLocalReceiverMarker().onFailure(error::addSuppressed) return } - privatePaykitRepo.setContactSharingCleanupPending(false) - .onFailure { - error.addSuppressed(it) - updatePrivateContactsPreference(isEnabled = false, error = error) - } + privatePaykitRepo.setContactSharingCleanupPending(false).exceptionOrNull()?.let { + error.addSuppressed(it) + updatePrivateContactsPreference(isEnabled = false, error = error) + } + publicPaykitRepo.syncLocalReceiverMarker().onFailure(error::addSuppressed) + } + + private suspend fun markPublicPaykitRetry(error: Throwable) { + runSuspendCatching { + settingsStore.update { it.copy(publicPaykitCleanupPending = true) } + }.onFailure(error::addSuppressed) } private suspend fun updatePrivateContactsPreference( isEnabled: Boolean, error: Throwable, - ): Boolean = runCatching { + ): Boolean = runSuspendCatching { settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = isEnabled) } }.onFailure(error::addSuppressed).isSuccess diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt index 77e670acdb..f967e9ece4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt @@ -77,7 +77,20 @@ class ProfileViewModel @Inject constructor( viewModelScope.launch { _isSigningOut.update { true } _showSignOutDialog.update { false } - privatePaykitRepo.removePublishedEndpointsForCleanup(TAG) + val cleanupResult = privatePaykitRepo.removePublishedEndpointsForCleanup(TAG) + if (cleanupResult.isFailure) { + val error = requireNotNull(cleanupResult.exceptionOrNull()) { + "Private Paykit cleanup failed without an error" + } + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__sign_out_title), + description = error.message, + ) + _isSigningOut.update { false } + return@launch + } + val result = pubkyRepo.signOut() privatePaykitRepo.closeAndClear() if (result.isSuccess) { diff --git a/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt index 96e5165d21..06d703c1bc 100644 --- a/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.Toast import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo @@ -81,14 +82,13 @@ class PaymentPreferenceViewModel @Inject constructor( val previous = settingsStore.data.first() privateContactsPendingValue.update { _uiState.value.privateContactsEnabled } _uiState.update { it.copy(isUpdatingPrivateContacts = true) } - val result = runCatching { + val result = runSuspendCatching { settingsStore.update { it.copy( hasConfirmedPublicPaykitEndpoints = true, sharesPrivatePaykitEndpoints = isEnabled, ) } - }.mapCatching { if (isEnabled) { privatePaykitRepo.enableSharingAndPrepareSavedContacts( publicKeys = contactPublicKeys(), @@ -97,6 +97,9 @@ class PaymentPreferenceViewModel @Inject constructor( } else { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contactPublicKeys()).getOrThrow() } + if (!previous.sharesPublicPaykitEndpoints) { + publicPaykitRepo.syncLocalReceiverMarker(privateSharingEnabled = isEnabled).getOrThrow() + } } result.exceptionOrNull()?.let { @@ -128,11 +131,9 @@ class PaymentPreferenceViewModel @Inject constructor( ) } - publicPaykitRepo.syncPublishedEndpoints(publish = isEnabled).exceptionOrNull()?.let { - settingsStore.update { settings -> - settings.copy(sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints) - } - showSyncError(it) + publicPaykitRepo.syncPublishedEndpoints(publish = isEnabled).exceptionOrNull()?.let { error -> + rollbackPublicContactsPreference(previous, error) + showSyncError(error) } _uiState.update { it.copy(isUpdatingPublicContacts = false) } } @@ -179,7 +180,7 @@ class PaymentPreferenceViewModel @Inject constructor( } } - private suspend fun refreshPublishedPreferences(): Result = runCatching { + private suspend fun refreshPublishedPreferences(): Result = runSuspendCatching { val settings = settingsStore.data.first() if (settings.sharesPublicPaykitEndpoints) { publicPaykitRepo.syncCurrentPublishedEndpoints( @@ -195,6 +196,7 @@ class PaymentPreferenceViewModel @Inject constructor( ).getOrThrow() } else { settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } + publicPaykitRepo.syncLocalReceiverMarker(privateSharingEnabled = false).getOrThrow() } } } @@ -202,6 +204,22 @@ class PaymentPreferenceViewModel @Inject constructor( private fun contactPublicKeys(): List = pubkyRepo.contacts.value.map { it.publicKey } + private suspend fun rollbackPublicContactsPreference(previous: SettingsData, error: Throwable) { + runSuspendCatching { + settingsStore.update { settings -> + settings.copy(sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints) + } + }.onFailure(error::addSuppressed) + + publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) + .onFailure { rollbackError -> + error.addSuppressed(rollbackError) + runSuspendCatching { + settingsStore.update { it.copy(publicPaykitCleanupPending = true) } + }.onFailure(error::addSuppressed) + } + } + private suspend fun rollbackPrivateContactsPreference( requestedEnabled: Boolean, previous: SettingsData, @@ -213,9 +231,13 @@ class PaymentPreferenceViewModel @Inject constructor( return } - runCatching { + runSuspendCatching { settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = previous.sharesPrivatePaykitEndpoints) } }.onFailure(error::addSuppressed) + if (!previous.sharesPublicPaykitEndpoints) { + publicPaykitRepo.syncLocalReceiverMarker(privateSharingEnabled = previous.sharesPrivatePaykitEndpoints) + .onFailure(error::addSuppressed) + } if (requestedEnabled && !previous.sharesPrivatePaykitEndpoints) { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) @@ -233,23 +255,24 @@ class PaymentPreferenceViewModel @Inject constructor( privatePaykitRepo.prepareSavedContacts( publicKeys = contacts, requireImmediatePublication = true, - ).onFailure { + ).exceptionOrNull()?.let { error.addSuppressed(it) updatePrivateContactsPreference(isEnabled = false, error = error) + publicPaykitRepo.syncLocalReceiverMarker().onFailure(error::addSuppressed) return } - privatePaykitRepo.setContactSharingCleanupPending(false) - .onFailure { - error.addSuppressed(it) - updatePrivateContactsPreference(isEnabled = false, error = error) - } + privatePaykitRepo.setContactSharingCleanupPending(false).exceptionOrNull()?.let { + error.addSuppressed(it) + updatePrivateContactsPreference(isEnabled = false, error = error) + } + publicPaykitRepo.syncLocalReceiverMarker().onFailure(error::addSuppressed) } private suspend fun updatePrivateContactsPreference( isEnabled: Boolean, error: Throwable, - ): Boolean = runCatching { + ): Boolean = runSuspendCatching { settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = isEnabled) } }.onFailure(error::addSuppressed).isSuccess diff --git a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt new file mode 100644 index 0000000000..0f8409f685 --- /dev/null +++ b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt @@ -0,0 +1,34 @@ +package to.bitkit.usecases + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.utils.Logger +import javax.inject.Inject + +class RefreshContactPaykitReceiversUseCase @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val pubkyRepo: PubkyRepo, + private val privatePaykitRepo: PrivatePaykitRepo, +) { + companion object { + private const val TAG = "RefreshContactPaykitReceiversUseCase" + } + + suspend operator fun invoke(publicKey: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + pubkyRepo.refreshContactReceiverPaths(publicKey).getOrThrow() + privatePaykitRepo.refreshSavedContactEndpoints(pubkyRepo.contacts.value.map { it.publicKey }).getOrThrow() + }.onFailure { + Logger.warn( + "Failed to refresh Paykit receivers for '${PubkyPublicKeyFormat.redacted(publicKey)}'", + it, + context = TAG, + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index ef324d0db8..3b23c2092f 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -6,6 +6,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.ext.runSuspendCatching import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo @@ -46,39 +47,41 @@ class WipeWalletUseCase @Inject constructor( resetWalletState: () -> Unit, onSuccess: () -> Unit, ): Result { - return runCatching { - backupRepo.setWiping(true) - backupRepo.reset() + backupRepo.setWiping(true) + return try { + runSuspendCatching { + backupRepo.reset() - privatePaykitRepo.get().removePublishedEndpointsForCleanup(TAG) - pubkyRepo.removeBitkitPaymentEndpoints() - .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } - privatePaykitRepo.get().closeAndClear() - privatePaykitAddressReservationRepo.clear() - pubkyRepo.wipeLocalState() - keychain.wipe() - firebaseMessaging.deleteToken() + privatePaykitRepo.get().removePublishedEndpointsForCleanup(TAG) + pubkyRepo.removeBitkitPaymentEndpoints() + .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } + privatePaykitRepo.get().closeAndClear() + privatePaykitAddressReservationRepo.clear() + pubkyRepo.wipeLocalState() + keychain.wipe() + firebaseMessaging.deleteToken() - coreService.wipeData() - db.clearAllTables() + coreService.wipeData() + db.clearAllTables() - settingsStore.reset() - cacheStore.reset() - widgetsStore.reset() + settingsStore.reset() + cacheStore.reset() + widgetsStore.reset() - blocktankRepo.resetState() - activityRepo.resetState() - hwWalletRepo.resetState() - resetWalletState() + blocktankRepo.resetState() + activityRepo.resetState() + hwWalletRepo.resetState() + resetWalletState() - migrationService.markMigrationChecked() + migrationService.markMigrationChecked() - lightningRepo.wipeStorage(walletIndex) - .onSuccess { onSuccess() } - .getOrThrow() - }.onFailure { - Logger.error("Wipe wallet error", it, context = TAG) - }.also { + lightningRepo.wipeStorage(walletIndex) + .onSuccess { onSuccess() } + .getOrThrow() + }.onFailure { + Logger.error("Failed to wipe wallet", it, context = TAG) + } + } finally { backupRepo.setWiping(false) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index dd7217c838..927bc8008c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -153,6 +153,7 @@ import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.hardware.HardwareRoute import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.usecases.FormatMoneyValue +import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger @@ -206,6 +207,7 @@ class AppViewModel @Inject constructor( private val pubkyRepo: PubkyRepo, private val publicPaykitRepo: PublicPaykitRepo, private val privatePaykitRepo: PrivatePaykitRepo, + private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, private val samRockRepo: SamRockRepo, private val appUpdateSheet: AppUpdateTimedSheet, private val backupSheet: BackupTimedSheet, @@ -506,6 +508,8 @@ class AppViewModel @Inject constructor( lastPrivatePaykitContactKeys = emptySet() return@collect } + + refreshPrivateOnlyPaykitReceiverMarker("contact sync") if (!state.contactsLoaded) return@collect val removedKeys = lastPrivatePaykitContactKeys - state.contactKeys @@ -542,6 +546,7 @@ class AppViewModel @Inject constructor( if (!isPaykitEnabled.value) return + refreshPrivateOnlyPaykitReceiverMarker(reason) privatePaykitRepo.reconcileReservedReceiveIndexes() .onFailure { Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) @@ -549,11 +554,28 @@ class AppViewModel @Inject constructor( privatePaykitRepo.refreshKnownSavedContactEndpoints(reason, forceRefreshLightning = forceRefreshLightning) } + private suspend fun refreshPrivateOnlyPaykitReceiverMarker(reason: String) { + val settings = settingsStore.data.first() + if (!settings.sharesPrivatePaykitEndpoints || settings.sharesPublicPaykitEndpoints) return + if (pubkyRepo.publicKey.value == null) return + + publicPaykitRepo.syncLocalReceiverMarker() + .onFailure { + Logger.warn("Failed to refresh private Paykit receiver marker for '$reason'", it, context = TAG) + } + } + private suspend fun retryPendingPaykitEndpointRemoval(contactKeys: Collection, reason: String) { val settings = settingsStore.data.first() if (settings.publicPaykitCleanupPending) { if (settings.sharesPublicPaykitEndpoints) { - settingsStore.update { it.copy(publicPaykitCleanupPending = false) } + publicPaykitRepo.syncCurrentPublishedEndpoints() + .onSuccess { + settingsStore.update { it.copy(publicPaykitCleanupPending = false) } + } + .onFailure { + Logger.warn("Failed to retry public Paykit endpoint sync for '$reason'", it, context = TAG) + } } else { publicPaykitRepo.syncPublishedEndpoints(publish = false) .onSuccess { @@ -1681,6 +1703,9 @@ class AppViewModel @Inject constructor( clearActiveContactPaymentContext() if (currentSheet.value is Sheet.Send) hideSheet() mainScreenEffect(MainScreenEffect.Navigate(route)) + if (route is Routes.ContactDetail) { + refreshContactPaykitReceivers(route.publicKey) + } return@withContext } } diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 6af5924f53..91c2e38e19 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -196,18 +196,20 @@ class SettingsViewModel @Inject constructor( private suspend fun updatePaykitEnabled(value: Boolean) { val shouldEnable = value && PaykitFeatureFlags.isUiAvailable - val hadPublicPaykitState = settingsStore.data.first().hasPublicPaykitPublicationState() + val previousSettings = settingsStore.data.first() + val hadPublicPaykitState = previousSettings.hasPublicPaykitPublicationState() + val hadPrivatePaykitState = previousSettings.sharesPrivatePaykitEndpoints settingsStore.setIsPaykitEnabled(shouldEnable) if (!shouldEnable) { settingsStore.update { it.paykitDisabled(markPublicCleanupPending = it.hasPublicPaykitPublicationState()) } - removePaykitEndpoints(hadPublicPaykitState) + removePaykitEndpoints(hadPublicPaykitState, hadPrivatePaykitState) } } - private suspend fun removePaykitEndpoints(hadPublicPaykitState: Boolean) { + private suspend fun removePaykitEndpoints(hadPublicPaykitState: Boolean, hadPrivatePaykitState: Boolean) { val contacts = pubkyRepo.contacts.value.map { it.publicKey } if (hadPublicPaykitState) { @@ -219,6 +221,12 @@ class SettingsViewModel @Inject constructor( settingsStore.update { it.copy(publicPaykitCleanupPending = true) } Logger.warn("Failed to remove public Paykit endpoints after disabling Paykit UI", it, context = TAG) } + } else if (hadPrivatePaykitState) { + publicPaykitRepo.syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false) + .onFailure { + settingsStore.update { settings -> settings.copy(publicPaykitCleanupPending = true) } + Logger.warn("Failed to remove Paykit receiver marker after disabling Paykit UI", it, context = TAG) + } } privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt index 616d9e3a8b..dcb74d2530 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt @@ -42,10 +42,12 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( CONTACT_KEY to PrivatePaykitContactCacheData( - localInvoice = PrivatePaykitStoredInvoiceData( - bolt11 = "lnbcrt1private", - paymentHash = PAYMENT_HASH, - expiresAt = 1_700_000_000L, + localInvoicesByReceiverPath = mapOf( + "bitkit/wallet" to PrivatePaykitStoredInvoiceData( + bolt11 = "lnbcrt1private", + paymentHash = PAYMENT_HASH, + expiresAt = 1_700_000_000L, + ), ), ), ), diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 0cedce1623..df01cfac87 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -5,6 +5,8 @@ import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import com.synonym.paykit.ContactPaymentResolutionPrivateState +import com.synonym.paykit.ContactRecord +import com.synonym.paykit.CounterpartyReceiver import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState @@ -13,6 +15,7 @@ import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import com.synonym.paykit.PrivatePaymentListSyncChange import com.synonym.paykit.PubkyIdentityCapability +import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -22,13 +25,19 @@ import kotlinx.coroutines.test.runCurrent import org.junit.After import org.junit.Before import org.junit.Test +import org.lightningdevkit.ldknode.PaymentDetails +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentKind +import org.lightningdevkit.ldknode.PaymentStatus import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeast import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.App @@ -40,6 +49,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.models.NodeLifecycleState import to.bitkit.services.CoreService import to.bitkit.services.PaykitContactPaymentResolution +import to.bitkit.services.PaykitPrivateReceiverPathSelection import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService @@ -61,8 +71,12 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private const val PRIVATE_ADDRESS = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" private const val OTHER_PRIVATE_ADDRESS = "bcrt1q9x0pz2tqf8clz0lq6m9wj8t47zffnrdz2tkt6v" private const val PRIVATE_BOLT11 = "lnbcrt1private" + private const val SERVER_PRIVATE_BOLT11 = "lnbcrt1serverprivate" + private const val ROTATED_PRIVATE_BOLT11 = "lnbcrt1rotatedprivate" private const val PRIVATE_BOLT11_EXPIRY_SECONDS = 86_400u private const val NOW_SECONDS = 1_700_000_000L + private const val WALLET_RECEIVER_PATH = "bitkit/wallet" + private const val SERVER_RECEIVER_PATH = "bitkit/server" } private val paykitSdkService = mock() @@ -112,13 +126,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(walletRepo.walletExists()).thenReturn(true) whenever { walletRepo.refreshReusableReceiveAddressIfReserved() }.thenReturn(Result.success(Unit)) whenever { addressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY) } + whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, WALLET_RECEIVER_PATH) } .thenReturn(Result.success(PRIVATE_ADDRESS)) + whenever { paykitSdkService.privateReceiverPathSelection(any(), any()) }.thenAnswer { + privateReceiverPathSelection(it.getArgument(1)) + } whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) } .thenReturn(privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY))) whenever { paykitSdkService.linkedPeers() }.thenReturn(emptyList()) whenever { paykitSdkService.pendingOutboundPrivateCounterparties() }.thenReturn(emptyList()) - whenever { paykitSdkService.clearPrivatePaymentList(any()) }.thenReturn(privateListDeliveryReport()) + whenever { paykitSdkService.clearPrivatePaymentList(any(), any()) }.thenReturn(privateListDeliveryReport()) whenever { publicPaykitRepo.beginPayment(any()) } .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:bcrt1qpublic"))) whenever { publicPaykitRepo.payableEndpoints(any()) }.thenAnswer { it.getArgument>(0) } @@ -154,11 +171,197 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(CONTACT_KEY, update.counterparty) assertEquals(MethodId.P2wpkh.rawValue, reservation.identifier) assertEquals(PublicPaykitRepo.serializePayload(PRIVATE_ADDRESS), reservation.payload) - assertTrue(reservation.reservationId.startsWith("$CONTACT_KEY:${MethodId.P2wpkh.rawValue}:")) + assertTrue( + reservation.reservationId.startsWith( + "$CONTACT_KEY:$WALLET_RECEIVER_PATH:${MethodId.P2wpkh.rawValue}:", + ), + ) assertTrue(reservation.reservationId.length <= 128) assertEquals("private_paykit", reservation.attribution["type"]) assertEquals(CONTACT_KEY, reservation.attribution["counterparty"]) - assertEquals(true, cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList) + assertEquals(WALLET_RECEIVER_PATH, reservation.attribution["receiver_path"]) + assertEquals( + setOf(WALLET_RECEIVER_PATH), + cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + ) + } + + @Test + fun `prepareSavedContacts publishes distinct private reservations for eligible receiver paths`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + whenever { paykitSdkService.contactRecord(CONTACT_KEY) } + .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) + whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } + .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) + whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { + privateListDeliveryReportForUpdates(it.getArgument(0)) + } + + val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + val captor = argumentCaptor>() + verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } + + assertEquals( + listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), + captor.firstValue.map { it.counterpartyReceiverPath }, + ) + assertEquals( + listOf(PRIVATE_ADDRESS, OTHER_PRIVATE_ADDRESS).map(PublicPaykitRepo::serializePayload), + captor.firstValue.map { it.reservations.single().payload }, + ) + assertEquals(2, captor.firstValue.map { it.reservations.single().reservationId }.distinct().size) + assertEquals( + setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), + cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + ) + verifyBlocking(paykitSdkService, atLeast(1)) { ensureLinkWithPeer(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService, atLeast(1)) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } + } + + @Test + fun `prepareSavedContacts skips public-only receiver paths`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + whenever { paykitSdkService.contactRecord(CONTACT_KEY) } + .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) + whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } + .thenReturn(privateReceiverPathSelection(emptyList())) + + val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verifyBlocking(paykitSdkService, never()) { ensureLinkWithPeer(any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } + + assertTrue(sut.removePublishedEndpointsForCleanup("test").isSuccess) + verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any(), any()) } + } + + @Test + fun `prepareSavedContacts links server receiver without publishing payment details`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + whenever { paykitSdkService.contactRecord(CONTACT_KEY) } + .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) + whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } + .thenReturn( + privateReceiverPathSelection( + linkableReceiverPaths = listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), + publishableReceiverPaths = listOf(WALLET_RECEIVER_PATH), + ), + ) + whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { + privateListDeliveryReportForUpdates(it.getArgument(0)) + } + + val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verifyBlocking(paykitSdkService, atLeast(1)) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } + val captor = argumentCaptor>() + verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } + assertEquals(listOf(WALLET_RECEIVER_PATH), captor.firstValue.map { it.counterpartyReceiverPath }) + } + + @Test + fun `prepareSavedContacts links relevant receivers when endpoint sharing is disabled`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + whenever { paykitSdkService.contactRecord(CONTACT_KEY) } + .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) + whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } + .thenReturn( + privateReceiverPathSelection( + linkableReceiverPaths = listOf(SERVER_RECEIVER_PATH), + publishableReceiverPaths = emptyList(), + ), + ) + + val result = sut.prepareSavedContacts(listOf(CONTACT_KEY)) + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verifyBlocking(paykitSdkService) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } + verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } + verify(addressReservationRepo, never()).currentOrRotatedAddress(any(), any()) + } + + @Test + fun `prepareSavedContacts clears receiver paths that are no longer eligible`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + whenever { paykitSdkService.contactRecord(CONTACT_KEY) } + .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) + whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } + .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) + whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } + .thenReturn(privateReceiverPathSelection(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) + .thenReturn(privateReceiverPathSelection(listOf(WALLET_RECEIVER_PATH))) + whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { + privateListDeliveryReportForUpdates(it.getArgument(0)) + } + + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + clearInvocations(paykitSdkService) + val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + val captor = argumentCaptor>() + verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } + val updatesByPath = captor.firstValue.associateBy { it.counterpartyReceiverPath } + assertEquals(1, updatesByPath.getValue(WALLET_RECEIVER_PATH).reservations.size) + assertTrue(updatesByPath.getValue(SERVER_RECEIVER_PATH).reservations.isEmpty()) + assertEquals( + setOf(WALLET_RECEIVER_PATH), + cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + ) + } + + @Test + fun `prepareSavedContacts preserves receiver paths when marker lookup fails`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + whenever { paykitSdkService.contactRecord(CONTACT_KEY) } + .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) + whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } + .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) + whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } + .thenReturn(privateReceiverPathSelection(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) + .thenReturn( + privateReceiverPathSelection( + publishableReceiverPaths = listOf(WALLET_RECEIVER_PATH), + cleanupProtectedReceiverPaths = listOf(SERVER_RECEIVER_PATH), + error = PrivatePaykitTestAppError("marker unavailable"), + ), + ) + whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { + privateListDeliveryReportForUpdates(it.getArgument(0)) + } + + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + clearInvocations(paykitSdkService) + val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = false) + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + val captor = argumentCaptor>() + verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } + assertEquals(listOf(WALLET_RECEIVER_PATH), captor.firstValue.map { it.counterpartyReceiverPath }) + assertEquals( + setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), + cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + ) } @Test @@ -168,7 +371,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) - whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY) }.thenAnswer { + whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenAnswer { throw PrivatePaykitTestAppError("still linking") } @@ -176,7 +379,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess, result.exceptionOrNull().toString()) verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(any(), eq(false)) } - assertEquals(true, cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList) + assertEquals( + setOf(WALLET_RECEIVER_PATH), + cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + ) } @Test @@ -195,7 +401,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { advanceTimeBy(257_000) runCurrent() - verifyBlocking(paykitSdkService, atLeast(8)) { ensureLinkWithPeer(CONTACT_KEY) } + verifyBlocking(paykitSdkService, atLeast(8)) { ensureLinkWithPeer(CONTACT_KEY, WALLET_RECEIVER_PATH) } sut.closeAndClear() } @@ -229,6 +435,52 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals("090909", reservation.attribution["payment_hash"]) } + @Test + fun `received wallet invoice rotation preserves server receiver invoice`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = true, + publicPaykitOnchainEnabled = false, + ) + whenever(lightningRepo.canReceive()).thenReturn(true) + whenever { paykitSdkService.contactRecord(CONTACT_KEY) } + .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) + whenever { + lightningRepo.createInvoice( + amountSats = null, + description = "", + expirySeconds = PRIVATE_BOLT11_EXPIRY_SECONDS, + ) + }.thenReturn( + Result.success(PRIVATE_BOLT11), + Result.success(SERVER_PRIVATE_BOLT11), + Result.success(ROTATED_PRIVATE_BOLT11), + ) + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(1, 1, 1)))) + whenever(coreService.decode(SERVER_PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(SERVER_PRIVATE_BOLT11, byteArrayOf(2, 2, 2)))) + whenever(coreService.decode(ROTATED_PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(ROTATED_PRIVATE_BOLT11, byteArrayOf(3, 3, 3)))) + + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() + val settledPayment = mock { + on { id } doReturn "010101" + on { kind } doReturn mock() + on { direction } doReturn PaymentDirection.INBOUND + on { status } doReturn PaymentStatus.SUCCEEDED + } + whenever(lightningRepo.getPayments()).thenReturn(Result.success(listOf(settledPayment))) + + val result = sut.handleReceivedPayment("010101") + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + val invoices = cacheData.value.contacts.getValue(CONTACT_KEY).localInvoicesByReceiverPath + assertEquals(ROTATED_PRIVATE_BOLT11, invoices.getValue(WALLET_RECEIVER_PATH).bolt11) + assertEquals(SERVER_PRIVATE_BOLT11, invoices.getValue(SERVER_RECEIVER_PATH).bolt11) + sut.closeAndClear() + } + @Test fun `disable sharing removes onchain-only private publications from cache`() = test { settingsData.value = SettingsData( @@ -241,7 +493,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) assertTrue(result.isSuccess) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } assertTrue(cacheData.value.contacts.isEmpty()) } @@ -253,11 +505,79 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( + 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) + assertEquals(true, cacheData.value.cleanupPending) + } + + @Test + fun `cleanup remains pending until queued clear is delivered`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + .thenReturn(privateListDeliveryReport(clearedCounterparties = listOf(CONTACT_KEY))) + val pendingReceiver = mock() + whenever(pendingReceiver.counterparty).thenReturn(CONTACT_KEY) + whenever(pendingReceiver.counterpartyReceiverPath).thenReturn(WALLET_RECEIVER_PATH) + whenever { paykitSdkService.pendingOutboundPrivateCounterparties() } + .thenReturn(listOf(pendingReceiver)) + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isFailure) + assertTrue(cacheData.value.cleanupPending) + assertEquals( + setOf(WALLET_RECEIVER_PATH), + cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + ) + sut.closeAndClear() + } + + @Test + fun `cleanup keeps publication state when any saved receiver cleanup fails`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + whenever { paykitSdkService.contactRecord(CONTACT_KEY) } + .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) + whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } + .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) + whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { + privateListDeliveryReportForUpdates(it.getArgument(0)) + } + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + assertEquals( + setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), + cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + ) + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + .thenReturn(privateListDeliveryReport(clearedCounterparties = listOf(CONTACT_KEY))) + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, SERVER_RECEIVER_PATH) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( PrivatePaymentListSyncChange( counterparty = CONTACT_KEY, + counterpartyReceiverPath = SERVER_RECEIVER_PATH, outboundMessageId = null, error = "failed", ), @@ -269,6 +589,47 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isFailure) assertEquals(true, cacheData.value.cleanupPending) + assertEquals( + setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), + cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + ) + } + + @Test + fun `cleanup keeps pending state when linked receiver inspection fails`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() + whenever { paykitSdkService.linkedPeers() } + .thenThrow(IllegalStateException("link inspection failed")) + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isFailure) + assertTrue(cacheData.value.cleanupPending) + verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any(), any()) } + } + + @Test + fun `cleanup uses linked receiver paths when contact record is gone`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + whenever { paykitSdkService.contactRecord(CONTACT_KEY) }.thenReturn(null) + whenever { paykitSdkService.linkedPeers() } + .thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(CONTACT_KEY, SERVER_RECEIVER_PATH) } } @Test @@ -278,9 +639,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY) } + whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, WALLET_RECEIVER_PATH) } .thenReturn(Result.failure(PrivatePaykitTestAppError("address unavailable"))) - whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY) } + whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY, WALLET_RECEIVER_PATH) } .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenReturn( privateListDeliveryReport(queuedCounterparties = listOf(OTHER_CONTACT_KEY)), @@ -289,7 +650,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.prepareSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)) assertTrue(result.isSuccess) - assertEquals(true, cacheData.value.contacts.getValue(OTHER_CONTACT_KEY).hasPublishedPrivatePaymentList) + assertEquals( + setOf(WALLET_RECEIVER_PATH), + cacheData.value.contacts.getValue(OTHER_CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + ) } @Test @@ -333,7 +697,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + ) }.thenReturn( resolution( resolvedEndpoint( @@ -347,7 +715,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() assertEquals(PublicPaykitPaymentResult.Opened("bcrt1qpublic"), result) - verifyBlocking(paykitSdkService) { prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) } + verifyBlocking(paykitSdkService) { + prepareAndResolveContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, includePublicEndpoints = true) + } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -356,7 +726,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + ) }.thenReturn( resolution( resolvedEndpoint( @@ -371,7 +745,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() assertEquals(PublicPaykitPaymentResult.Opened(PRIVATE_BOLT11), result) - verifyBlocking(paykitSdkService) { prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) } + verifyBlocking(paykitSdkService) { + prepareAndResolveContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, includePublicEndpoints = true) + } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -381,7 +757,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.prepareSavedContacts(listOf(CONTACT_KEY)) clearInvocations(paykitSdkService) whenever { - paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + ) }.thenReturn( resolution( resolvedEndpoint( @@ -398,7 +778,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val captor = argumentCaptor>() verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } assertEquals(CONTACT_KEY, captor.firstValue.single().counterparty) - verifyBlocking(paykitSdkService) { prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) } + verifyBlocking(paykitSdkService) { + prepareAndResolveContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, includePublicEndpoints = true) + } } @Test @@ -406,7 +788,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + ) }.thenThrow(CancellationException("cancelled")) assertFailsWith { @@ -428,7 +814,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.beginSavedContactPayment(CONTACT_KEY) } - verifyBlocking(paykitSdkService, never()) { prepareAndResolveContactPayment(any(), any()) } + verifyBlocking(paykitSdkService, never()) { prepareAndResolveContactPayment(any(), any(), any()) } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -444,7 +830,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.beginSavedContactPayment(CONTACT_KEY) } - verifyBlocking(paykitSdkService, never()) { prepareAndResolveContactPayment(any(), any()) } + verifyBlocking(paykitSdkService, never()) { prepareAndResolveContactPayment(any(), any(), any()) } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -459,7 +845,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.beginSavedContactPayment(CONTACT_KEY) } - verifyBlocking(paykitSdkService, never()) { prepareAndResolveContactPayment(any(), any()) } + verifyBlocking(paykitSdkService, never()) { prepareAndResolveContactPayment(any(), any(), any()) } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -468,7 +854,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + ) }.thenThrow(IllegalStateException("private unavailable")) whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)).thenReturn( Result.success(PublicPaykitPaymentResult.Opened("public-fallback")), @@ -485,7 +875,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + ) }.thenReturn( resolution( resolvedEndpoint( @@ -507,7 +901,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + ) }.thenReturn( resolution( resolvedEndpoint( @@ -530,7 +928,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + ) }.thenReturn( resolution(privateState = ContactPaymentResolutionPrivateState.RECOVERY_PENDING), ) @@ -546,7 +948,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + ) }.thenReturn( resolution( resolvedEndpoint( @@ -577,7 +983,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + ) }.thenReturn( resolution( resolvedEndpoint( @@ -606,7 +1016,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + ) }.thenReturn( resolution( resolvedEndpoint( @@ -685,6 +1099,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { queued = queuedCounterparties.map { PrivatePaymentListSyncChange( counterparty = it, + counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, error = null, ) @@ -692,6 +1107,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { cleared = clearedCounterparties.map { PrivatePaymentListSyncChange( counterparty = it, + counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, error = null, ) @@ -700,8 +1116,63 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { failedToDeliver = emptyList(), ) - private fun linkedPeer(publicKey: String, state: LinkedPeerState) = LinkedPeerRecord( + private fun privateReceiverPathSelection( + publishableReceiverPaths: List, + linkableReceiverPaths: List = publishableReceiverPaths, + cleanupProtectedReceiverPaths: List = emptyList(), + error: Throwable? = null, + ) = PaykitPrivateReceiverPathSelection( + linkableReceiverPaths = linkableReceiverPaths, + publishableReceiverPaths = publishableReceiverPaths, + cleanupProtectedReceiverPaths = cleanupProtectedReceiverPaths, + error = error, + ) + + private fun privateListDeliveryReportForUpdates( + updates: List, + ) = PrivatePaymentListDeliveryReport( + queued = updates + .filter { it.reservations.isNotEmpty() } + .map { privateListSyncChange(it.counterparty, it.counterpartyReceiverPath) }, + cleared = updates + .filter { it.reservations.isEmpty() } + .map { privateListSyncChange(it.counterparty, it.counterpartyReceiverPath) }, + failedToQueue = emptyList(), + failedToDeliver = emptyList(), + ) + + private fun privateListSyncChange( + counterparty: String, + receiverPath: String, + ) = PrivatePaymentListSyncChange( + counterparty = counterparty, + counterpartyReceiverPath = receiverPath, + outboundMessageId = null, + error = null, + ) + + private fun contactRecord(publicKey: String, receiverPaths: List) = ContactRecord( + publicKey = publicKey, + receiverPaths = receiverPaths, + label = null, + profile = null, + profileFetchedAt = null, + createdAt = "2026-01-01T00:00:00Z", + updatedAt = "2026-01-01T00:00:00Z", + publicContactMarkerStatus = PublicationStatus.NOT_PUBLISHED, + publicContactMarkerReceiverPath = null, + publicContactPublishedAt = null, + publicContactRemovedAt = null, + publicContactLastError = null, + ) + + private fun linkedPeer( + publicKey: String, + state: LinkedPeerState, + receiverPath: String = WALLET_RECEIVER_PATH, + ) = LinkedPeerRecord( counterparty = publicKey, + counterpartyReceiverPath = receiverPath, state = state, lastSyncAt = null, lastPrivateReceiveAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index ec2597f546..fd9cfc4ce4 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -511,6 +511,7 @@ class PubkyRepoTest : BaseUnitTest() { settingsFlow.value = SettingsData( hasConfirmedPublicPaykitEndpoints = true, sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, publicPaykitBolt11 = "lnbc1old", publicPaykitBolt11PaymentHash = "010203", publicPaykitBolt11ExpiresAtMillis = 123L, @@ -521,6 +522,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertFalse(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) assertEquals("", settingsFlow.value.publicPaykitBolt11) assertEquals("", settingsFlow.value.publicPaykitBolt11PaymentHash) assertEquals(0, settingsFlow.value.publicPaykitBolt11ExpiresAtMillis) @@ -563,6 +565,23 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(keychain, atLeastOnce()) { delete(Keychain.Key.PAYKIT_SESSION.name) } } + @Test + fun `signOut should keep cleanup pending when private-only endpoint cleanup fails`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + settingsFlow.value = SettingsData(sharesPrivatePaykitEndpoints = true) + whenever(pubkyService.removeBitkitPaymentEndpoints()).thenAnswer { throw TestAppError("Cleanup failed") } + + val result = sut.signOut() + + assertTrue(result.isSuccess) + assertNull(sut.publicKey.value) + assertFalse(sut.isAuthenticated.value) + assertTrue(settingsFlow.value.publicPaykitCleanupPending) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + verifyBlocking(pubkyService) { signOut() } + verifyBlocking(keychain, atLeastOnce()) { delete(Keychain.Key.PAYKIT_SESSION.name) } + } + @Test fun `signOut should evict pubky images from caches`() = test { authenticateForTesting() @@ -918,6 +937,35 @@ class PubkyRepoTest : BaseUnitTest() { assertEquals(listOf("new"), contacts.first().tags) } + @Test + fun `refreshContactReceiverPaths should update saved contact receiver paths`() = test { + authenticateForTesting() + val contact = PubkyProfile( + publicKey = VALID_CONTACT_KEY_B, + name = "Alice", + bio = "", + imageUrl = null, + links = emptyList(), + tags = emptyList(), + status = null, + ) + sut.addContact(VALID_CONTACT_KEY_B, existingProfile = contact) + clearInvocations(pubkyService) + whenever(pubkyService.discoverRelevantReceiverPaths(VALID_CONTACT_KEY_B)) + .thenReturn(listOf("bitkit/wallet", "bitkit/server")) + + val result = sut.refreshContactReceiverPaths(VALID_CONTACT_KEY_B) + + assertTrue(result.isSuccess) + verifyBlocking(pubkyService) { + saveContact( + VALID_CONTACT_KEY_B, + "Alice", + listOf("bitkit/wallet", "bitkit/server"), + ) + } + } + @Test fun `loadProfile should ignore stale result when authenticated key changes`() = test { val oldSecret = "old_secret" @@ -1275,12 +1323,14 @@ class PubkyRepoTest : BaseUnitTest() { profile: PaykitProfile? = null, ) = ContactRecord( publicKey = publicKey, + receiverPaths = listOf("bitkit/wallet"), label = label, profile = profile, profileFetchedAt = null, createdAt = "2026-01-01T00:00:00Z", updatedAt = "2026-01-01T00:00:00Z", publicContactMarkerStatus = PublicationStatus.NOT_PUBLISHED, + publicContactMarkerReceiverPath = null, publicContactPublishedAt = null, publicContactRemovedAt = null, publicContactLastError = null, diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 32084e63ed..db262bba9b 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -22,6 +22,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.services.CoreService import to.bitkit.services.PaykitContactPaymentResolution +import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest @@ -120,6 +121,23 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertEquals("bc1qfresh", captor.firstValue.single().value) } + @Test + fun `syncPublishedEndpoints does not publish endpoints when receiver marker publish fails`() = test { + settingsFlow.value = SettingsData( + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + walletState.value = WalletState(onchainAddress = "bc1ptest") + val markerError = RuntimeException("marker failed") + whenever { paykitSdkService.syncLocalReceiverMarker(isDiscoverable = true) } + .thenThrow(markerError) + + val error = sut.syncPublishedEndpoints(publish = true).exceptionOrNull() + + assertEquals(markerError, error) + verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any()) } + } + @Test fun `syncPublishedEndpoints remove clears SDK public endpoints and metadata`() = test { settingsFlow.value = SettingsData( @@ -137,6 +155,33 @@ class PublicPaykitRepoTest : BaseUnitTest() { verifyBlocking(paykitSdkService) { syncPublicEndpoints(emptyList()) } } + @Test + fun `syncPublishedEndpoints remove keeps cleanup pending when receiver marker removal fails`() = test { + settingsFlow.value = SettingsData(publicPaykitCleanupPending = true) + val markerError = RuntimeException("marker failed") + whenever { paykitSdkService.syncLocalReceiverMarker(isDiscoverable = false) } + .thenThrow(markerError) + + val error = sut.syncPublishedEndpoints(publish = false).exceptionOrNull() + + assertEquals(markerError, error) + assertTrue(settingsFlow.value.publicPaykitCleanupPending) + verifyBlocking(paykitSdkService) { syncPublicEndpoints(emptyList()) } + } + + @Test + fun `syncPublishedEndpoints remove preserves both cleanup failures`() = test { + val endpointError = RuntimeException("endpoint failed") + val markerError = RuntimeException("marker failed") + whenever { paykitSdkService.syncPublicEndpoints(emptyList()) }.thenThrow(endpointError) + whenever { paykitSdkService.syncLocalReceiverMarker(isDiscoverable = false) }.thenThrow(markerError) + + val error = sut.syncPublishedEndpoints(publish = false).exceptionOrNull() + + assertEquals(endpointError, error) + assertEquals(listOf(markerError), error?.suppressedExceptions) + } + @Test fun `syncCurrentPublishedEndpoints returns publication failed when SDK sync fails`() = test { walletState.value = WalletState(onchainAddress = "bc1ptest") @@ -171,7 +216,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `beginPayment opens SDK resolved public endpoint`() = test { whenever { - paykitSdkService.resolvePublicContactPayment("pubkycontact") + paykitSdkService.resolvePublicContactPayment("pubkycontact", PaykitReceiverPaths.WALLET) }.thenReturn( resolution( resolvedEndpoint( 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 b1f632d656..15041d7082 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 @@ -7,6 +7,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.models.PubkyProfile @@ -14,6 +15,7 @@ import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import kotlin.test.assertEquals import kotlin.test.assertNull @@ -22,6 +24,7 @@ class AddContactViewModelTest : BaseUnitTest() { private val context: Context = mock() private val pubkyRepo: PubkyRepo = mock() private val publicPaykitRepo: PublicPaykitRepo = mock() + private val refreshContactPaykitReceivers = mock() @Test fun `self add failure should show dedicated error`() = test { @@ -54,12 +57,14 @@ class AddContactViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.contacts__add_error_existing)).thenReturn("existing contact") whenever(pubkyRepo.fetchContactProfile(any())) .thenReturn(Result.failure(PubkyContactError.AlreadyExists)) + whenever { refreshContactPaykitReceivers(TEST_PUBLIC_KEY) }.thenReturn(Result.success(Unit)) val sut = createSut() advanceUntilIdle() assertEquals("existing contact", sut.uiState.value.error) assertNull(sut.uiState.value.fetchedProfile) + verify(refreshContactPaykitReceivers).invoke(TEST_PUBLIC_KEY) } @Test @@ -80,6 +85,7 @@ class AddContactViewModelTest : BaseUnitTest() { context = context, pubkyRepo = pubkyRepo, publicPaykitRepo = publicPaykitRepo, + refreshContactPaykitReceivers = refreshContactPaykitReceivers, savedStateHandle = SavedStateHandle(mapOf("publicKey" to publicKey)), ) } diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt index 6ea7bf0903..ac7a78848f 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt @@ -36,7 +36,7 @@ class ContactImportFlowTest { @Test fun `resolveAddContactValidation returns existing contact for saved contact`() { assertEquals( - AddContactValidationResult.ExistingContact, + AddContactValidationResult.ExistingContact(normalizedKey = VALID_PUBLIC_KEY), resolveAddContactValidation( input = VALID_PUBLIC_KEY, ownPublicKey = null, diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt index c8dc3f71b3..47f3fb95a5 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt @@ -11,6 +11,7 @@ import org.mockito.Mockito.clearInvocations import org.mockito.kotlin.any import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.PubkyProfile @@ -129,22 +130,19 @@ class EditProfileViewModelTest : BaseUnitTest() { } @Test - fun `disconnectProfile continues when private cleanup fails`() = test { + fun `disconnectProfile stops when private cleanup fails`() = test { val sut = createSut() whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } .thenReturn(Result.failure(TestAppError("cleanup failed"))) whenever(pubkyRepo.signOut()).thenReturn(Result.success(Unit)) advanceUntilIdle() - sut.effects.test { - sut.disconnectProfile() - advanceUntilIdle() + sut.disconnectProfile() + advanceUntilIdle() - assertEquals(EditProfileEffect.DisconnectSuccess, awaitItem()) - } assertFalse(sut.uiState.value.isSaving) - verify(pubkyRepo).signOut() - verify(privatePaykitRepo).closeAndClear() + verify(pubkyRepo, never()).signOut() + verify(privatePaykitRepo, never()).closeAndClear() } @Test @@ -161,23 +159,20 @@ class EditProfileViewModelTest : BaseUnitTest() { } @Test - fun `deleteProfile should continue when private cleanup fails`() = test { + fun `deleteProfile should stop when private cleanup fails`() = test { val sut = createSut() whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } .thenReturn(Result.failure(TestAppError("cleanup failed"))) whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(Result.success(Unit)) advanceUntilIdle() - sut.effects.test { - sut.deleteProfile() - advanceUntilIdle() + sut.deleteProfile() + advanceUntilIdle() - assertEquals(EditProfileEffect.DeleteSuccess, awaitItem()) - } - assertFalse(sut.uiState.value.showDeleteFailureDialog) + assertTrue(sut.uiState.value.showDeleteFailureDialog) assertFalse(sut.uiState.value.isSaving) - verify(pubkyRepo).deleteProfileWithSessionRetry() - verify(privatePaykitRepo).closeAndClear() + verify(pubkyRepo, never()).deleteProfileWithSessionRetry() + verify(privatePaykitRepo, never()).closeAndClear() } @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt index 1dead11590..0cdd04d145 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -56,6 +57,7 @@ class PayContactsViewModelTest : BaseUnitTest() { Unit } whenever { publicPaykitRepo.syncPublishedEndpoints(any()) }.thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.setContactSharingCleanupPending(any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) @@ -80,6 +82,7 @@ class PayContactsViewModelTest : BaseUnitTest() { assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) + verify(publicPaykitRepo, never()).syncLocalReceiverMarker(anyOrNull(), anyOrNull()) verify(privatePaykitRepo).setContactSharingCleanupPending(false) verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), false) verify(privatePaykitRepo, never()).disableSharingAndPruneUnsavedContactState(any>()) @@ -103,10 +106,33 @@ class PayContactsViewModelTest : BaseUnitTest() { assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) + verify(publicPaykitRepo, never()).syncLocalReceiverMarker(anyOrNull(), anyOrNull()) verify(privatePaykitRepo, never()).setContactSharingCleanupPending(false) verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) } + @Test + fun `continueToProfile cleans up when initial public publish fails`() = test { + whenever { publicPaykitRepo.syncPublishedEndpoints(publish = true) } + .thenReturn(Result.failure(PayContactsTestAppError("publish failed"))) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.setPaymentSharingEnabled(true) + sut.continueToProfile() + advanceUntilIdle() + + expectNoEvents() + } + + assertFalse(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + } + @Test fun `continueToProfile keeps sharing disabled when cleanup marker clear fails`() = test { whenever { privatePaykitRepo.setContactSharingCleanupPending(false) } @@ -227,6 +253,7 @@ class PayContactsViewModelTest : BaseUnitTest() { assertTrue(sut.uiState.value.isPaymentSharingEnabled) verify(privatePaykitRepo).setContactSharingCleanupPending(false) verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), true) + verify(publicPaykitRepo).syncLocalReceiverMarker() } @Test @@ -284,6 +311,7 @@ class PayContactsViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.isLoading) assertTrue(sut.uiState.value.isPaymentSharingEnabled) verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) verify(privatePaykitRepo, never()).setContactSharingCleanupPending(true) } diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt index 141bf1db1a..1059215cba 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt @@ -9,6 +9,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.repositories.PrivatePaykitRepo @@ -16,6 +17,7 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals +import kotlin.test.assertFalse @OptIn(ExperimentalCoroutinesApi::class) class ProfileViewModelTest : BaseUnitTest() { @@ -43,20 +45,18 @@ class ProfileViewModelTest : BaseUnitTest() { } @Test - fun `signOut continues when private cleanup fails`() = test { + fun `signOut stops when private cleanup fails`() = test { val sut = createSut() whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } .thenReturn(Result.failure(ProfileTestAppError("cleanup failed"))) advanceUntilIdle() - sut.effects.test { - sut.signOut() - advanceUntilIdle() + sut.signOut() + advanceUntilIdle() - assertEquals(ProfileEffect.SignedOut, awaitItem()) - } - verify(pubkyRepo).signOut() - verify(privatePaykitRepo).closeAndClear() + assertFalse(sut.uiState.value.isSigningOut) + verify(pubkyRepo, never()).signOut() + verify(privatePaykitRepo, never()).closeAndClear() } @Test diff --git a/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt index 51d772943a..f12d7cc09f 100644 --- a/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -59,6 +60,10 @@ class PaymentPreferenceViewModelTest : BaseUnitTest() { } whenever { publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any()) } .thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncPublishedEndpoints(any()) } + .thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.setContactSharingCleanupPending(any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>(), any()) } @@ -79,6 +84,54 @@ class PaymentPreferenceViewModelTest : BaseUnitTest() { assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY), true) + verify(publicPaykitRepo).syncLocalReceiverMarker(privateSharingEnabled = true) + } + + @Test + fun `setPrivateContactsEnabled does not republish marker while public sharing is enabled`() = test { + settingsFlow.value = SettingsData(sharesPublicPaykitEndpoints = true) + val sut = createSut() + advanceUntilIdle() + + sut.setPrivateContactsEnabled(true) + advanceUntilIdle() + + assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY), true) + verify(publicPaykitRepo, never()).syncLocalReceiverMarker(anyOrNull(), anyOrNull()) + } + + @Test + fun `setPublicContactsEnabled republishes previous state when disabling fails`() = test { + settingsFlow.value = SettingsData(sharesPublicPaykitEndpoints = true) + whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false) } + .thenReturn(Result.failure(PublicPaykitError.PublicationFailed)) + val sut = createSut() + advanceUntilIdle() + + sut.setPublicContactsEnabled(false) + advanceUntilIdle() + + assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.publicPaykitCleanupPending) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) + } + + @Test + fun `setPrivateContactsEnabled rolls back when receiver marker sync fails`() = test { + whenever { publicPaykitRepo.syncLocalReceiverMarker(privateSharingEnabled = true) } + .thenReturn(Result.failure(PublicPaykitError.WalletNotReady)) + val sut = createSut() + advanceUntilIdle() + + sut.setPrivateContactsEnabled(true) + advanceUntilIdle() + + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertFalse(sut.uiState.value.privateContactsEnabled) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY), true) + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) } @Test diff --git a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt new file mode 100644 index 0000000000..5414d75de5 --- /dev/null +++ b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt @@ -0,0 +1,55 @@ +package to.bitkit.usecases + +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Test +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { + private val pubkyRepo = mock() + private val privatePaykitRepo = mock() + private val contactKeys = listOf("pubky-alice", "pubky-bob") + + private val sut = RefreshContactPaykitReceiversUseCase( + ioDispatcher = testDispatcher, + pubkyRepo = pubkyRepo, + privatePaykitRepo = privatePaykitRepo, + ) + + @Test + fun `refreshes receiver paths before publishing the complete contact set`() = test { + whenever(pubkyRepo.contacts).thenReturn( + MutableStateFlow(contactKeys.map(PubkyProfile::placeholder)), + ) + whenever { pubkyRepo.refreshContactReceiverPaths(contactKeys.last()) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.refreshSavedContactEndpoints(contactKeys) }.thenReturn(Result.success(Unit)) + + val result = sut(contactKeys.last()) + + assertTrue(result.isSuccess) + inOrder(pubkyRepo, privatePaykitRepo).apply { + verify(pubkyRepo).refreshContactReceiverPaths(contactKeys.last()) + verify(privatePaykitRepo).refreshSavedContactEndpoints(contactKeys) + } + } + + @Test + fun `stops when receiver discovery fails`() = test { + val error = IllegalStateException("Discovery failed") + whenever { pubkyRepo.refreshContactReceiverPaths(contactKeys.last()) }.thenReturn(Result.failure(error)) + + val result = sut(contactKeys.last()) + + assertEquals(error, result.exceptionOrNull()) + verify(privatePaykitRepo, never()).refreshSavedContactEndpoints(contactKeys) + } +} diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 7b2cff4ad2..47adac7781 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -1,6 +1,7 @@ package to.bitkit.usecases import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -26,6 +27,7 @@ import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest import javax.inject.Provider +import kotlin.test.assertFailsWith import kotlin.test.assertTrue class WipeWalletUseCaseTest : BaseUnitTest() { @@ -158,6 +160,21 @@ class WipeWalletUseCaseTest : BaseUnitTest() { verify(backupRepo).setWiping(false) } + @Test + fun `invoke should set wiping to false when cancelled`() = runTest { + whenever(keychain.wipe()).thenThrow(CancellationException("Cancelled")) + + assertFailsWith { + sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + } + + verify(backupRepo).setWiping(true) + verify(backupRepo).setWiping(false) + } + @Test fun `invoke should continue when endpoint cleanup fails`() = runTest { whenever { pubkyRepo.removeBitkitPaymentEndpoints() }.thenReturn( @@ -175,6 +192,24 @@ class WipeWalletUseCaseTest : BaseUnitTest() { verify(backupRepo).setWiping(false) } + @Test + fun `invoke should continue when private endpoint cleanup fails`() = runTest { + whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) }.thenReturn( + Result.failure(RuntimeException("Private cleanup failed")), + ) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isSuccess) + verify(privatePaykitRepo).closeAndClear() + verify(pubkyRepo).wipeLocalState() + verify(keychain).wipe() + verify(backupRepo).setWiping(false) + } + @Test fun `invoke should return failure when lightningRepo wipeStorage fails`() = runTest { val error = RuntimeException("Lightning wipe failed") diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f0ebda6e1c..badca4fb36 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -80,6 +80,7 @@ import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.hardware.HardwareRoute import to.bitkit.usecases.FormatMoneyValue +import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError import to.bitkit.utils.timedsheets.TimedSheetManager import java.net.URLEncoder @@ -126,6 +127,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val samRockRepo = mock() private val widgetsRepo = mock() private val formatMoneyValue = mock() + private val refreshContactPaykitReceivers = mock() private val clipboardManager = mock() private val toastManager = mock() @@ -178,6 +180,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(pubkyRepo.sessionRestorationFailed).thenReturn(MutableStateFlow(false)) whenever(pubkyRepo.publicKey).thenReturn(pubkyPublicKey) whenever(pubkyRepo.contacts).thenReturn(pubkyContacts) + whenever { refreshContactPaykitReceivers(any()) }.thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(Unit)) whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) @@ -251,6 +256,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { nodeServiceFgState = nodeServiceFgState, publicPaykitRepo = publicPaykitRepo, privatePaykitRepo = privatePaykitRepo, + refreshContactPaykitReceivers = refreshContactPaykitReceivers, samRockRepo = samRockRepo, appUpdateSheet = mock(), backupSheet = mock(), @@ -652,6 +658,31 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `existing contact scan refreshes Paykit receivers`() = test { + enablePaykitUi() + pubkyContacts.value = listOf( + PubkyProfile( + publicKey = testPublicKey, + name = "Bob", + bio = "", + imageUrl = null, + links = emptyList(), + status = null, + ), + ) + advanceUntilIdle() + + sut.mainScreenEffect.test { + sut.onScanResult(testPublicKey, routePubkyKeys = true) + + assertEquals(MainScreenEffect.Navigate(Routes.ContactDetail(testPublicKey)), awaitItem()) + advanceUntilIdle() + } + + verify(refreshContactPaykitReceivers).invoke(testPublicKey) + } + @Test fun `manual address input accepts pubky without decode error`() = test { enablePaykitUi() @@ -1235,14 +1266,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `private Paykit waits for contacts load before pruning`() = test { + fun `private Paykit refreshes marker but waits for contacts load before pruning`() = test { enablePaykitUi() + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) advanceUntilIdle() - clearInvocations(privatePaykitRepo) + clearInvocations(privatePaykitRepo, publicPaykitRepo) pubkyPublicKey.value = testPublicKey advanceUntilIdle() + verify(publicPaykitRepo).syncLocalReceiverMarker() verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) verify(privatePaykitRepo, never()).pruneUnsavedContactState(any>()) @@ -1296,21 +1329,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `private Paykit refresh clears stale public cleanup when public sharing is enabled`() = test { + fun `private Paykit refresh reconciles pending public state when sharing is enabled`() = test { isPaykitEnabled.value = true settingsData.value = SettingsData( sharesPublicPaykitEndpoints = true, publicPaykitCleanupPending = true, ) + whenever { publicPaykitRepo.syncCurrentPublishedEndpoints() }.thenReturn(Result.success(Unit)) sut.refreshPrivatePaykitEndpoints() advanceUntilIdle() - verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) + verify(publicPaykitRepo).syncCurrentPublishedEndpoints() assertFalse(settingsData.value.publicPaykitCleanupPending) verify(privatePaykitRepo).retryPendingEndpointRemoval(emptyList()) } + @Test + fun `private Paykit refresh republishes marker for private-only sharing`() = test { + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + + sut.refreshPrivatePaykitEndpoints() + advanceUntilIdle() + + verify(publicPaykitRepo).syncLocalReceiverMarker() + } + private suspend fun TestScope.confirmCurrentPayment() { sut.setSendEvent(SendEvent.SwipeToPay) advanceUntilIdle() diff --git a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt index bc857e71ba..fc6fc4d2c0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -67,6 +68,7 @@ class SettingsViewModelTest : BaseUnitTest() { whenever(pubkyRepo.isAuthenticated).thenReturn(MutableStateFlow(false)) whenever(pubkyRepo.contacts).thenReturn(contacts) whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false) }.thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) @@ -131,6 +133,31 @@ class SettingsViewModelTest : BaseUnitTest() { assertFalse(settingsData.value.publicPaykitCleanupPending) verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) + verify(publicPaykitRepo).syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false) + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) + } + + @Test + fun `disabling Paykit with private-only state keeps cleanup pending when marker removal fails`() = test { + clearInvocations(publicPaykitRepo) + whenever { + publicPaykitRepo.syncLocalReceiverMarker( + publicSharingEnabled = false, + privateSharingEnabled = false, + ) + } + .thenReturn(Result.failure(SettingsViewModelTestError("marker cleanup failed"))) + isPaykitEnabled.value = true + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + ) + + sut.setIsPaykitEnabled(false) + advanceUntilIdle() + + assertTrue(settingsData.value.publicPaykitCleanupPending) + verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) + verify(publicPaykitRepo).syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) } diff --git a/changelog.d/next/1066.added.md b/changelog.d/next/1066.added.md new file mode 100644 index 0000000000..ccbf4efab4 --- /dev/null +++ b/changelog.d/next/1066.added.md @@ -0,0 +1 @@ +Added receiver-specific Paykit contact support for Bitkit wallet and server integrations. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5aad7bc9b..201e6ed275 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.4.0" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc31" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc33" } 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 af9751a937cc31f675b2497047bd20826803b035 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 10 Jul 2026 11:59:05 +0200 Subject: [PATCH 2/3] fix: harden paykit receiver refresh --- .../PrivatePaykitAddressReservationRepo.kt | 9 ---- .../bitkit/repositories/PrivatePaykitRepo.kt | 42 ++++++++++++------- .../screens/contacts/AddContactViewModel.kt | 6 +-- .../RefreshContactPaykitReceiversUseCase.kt | 2 +- .../repositories/PrivatePaykitRepoTest.kt | 23 ++++++++++ ...efreshContactPaykitReceiversUseCaseTest.kt | 13 ++---- 6 files changed, 59 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt index 6071d148a3..7a70657ce2 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt @@ -193,15 +193,6 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( return lightningRepo.getAddressBalance(address).getOrDefault(0u) > 0u } - suspend fun hasContactAssignment(publicKey: String): Boolean = withContext(ioDispatcher) { - val normalizedKey = normalizedPublicKey(publicKey) - locked { - it.contactAssignments.keys.any { assignmentKey -> - assignmentKey.publicKeyFromAssignmentKey() == normalizedKey - } - } - } - suspend fun clearContactAssignment(publicKey: String) = withContext(ioDispatcher) { val normalizedKey = normalizedPublicKey(publicKey) locked { current -> diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 9ce2eb7e4e..304c13b699 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -153,10 +153,10 @@ class PrivatePaykitRepo @Inject constructor( } } - suspend fun refreshSavedContactEndpoints(publicKeys: Collection): Result = + suspend fun refreshSavedContactEndpoints(publicKey: String): Result = withContext(serializedDispatcher) { runSuspendCatching { - val keys = rememberSavedContacts(publicKeys, replacing = true) + val keys = rememberSavedContacts(listOf(publicKey), replacing = false) if (!canPublishPrivateEndpoints()) { prepareRelevantPrivateLinksIfAvailable(keys, "refresh") return@runSuspendCatching @@ -550,7 +550,15 @@ class PrivatePaykitRepo @Inject constructor( val linkRetryKeys = mutableListOf() for (publicKey in publicKeys) { - val receiverPaths = receiverPathsForSavedContact(publicKey) + val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } + .onFailure { + firstError = firstError ?: it + Logger.warn( + "Failed to read saved Paykit receivers for '${redacted(publicKey)}' during '$reason'", + it, + context = TAG, + ) + }.getOrNull() ?: continue val receiverPathSelection = paykitSdkService.privateReceiverPathSelection(publicKey, receiverPaths) val linkableReceiverPaths = receiverPathSelection.linkableReceiverPaths val publicationReceiverPaths = receiverPathSelection.publishableReceiverPaths @@ -604,8 +612,16 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun prepareRelevantPrivateLinksIfAvailable(publicKeys: Collection, reason: String) { if (!hasLocalSecretKeyForCurrentProfile()) return - val retryKeys = publicKeys.flatMap { publicKey -> - val receiverPaths = receiverPathsForSavedContact(publicKey) + val retryKeys = mutableListOf() + for (publicKey in publicKeys) { + val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } + .onFailure { + Logger.warn( + "Failed to read saved Paykit receivers for '${redacted(publicKey)}' during '$reason'", + it, + context = TAG, + ) + }.getOrNull() ?: continue val selection = paykitSdkService.privateReceiverPathSelection(publicKey, receiverPaths) selection.error?.let { Logger.warn( @@ -614,10 +630,10 @@ class PrivatePaykitRepo @Inject constructor( context = TAG, ) } - selection.linkableReceiverPaths.map { PrivateMessageDrainRetryKey(publicKey, it) } - }.distinct() + retryKeys += selection.linkableReceiverPaths.map { PrivateMessageDrainRetryKey(publicKey, it) } + } - drainAndSchedulePrivateLinkRetries(reason, retryKeys) + drainAndSchedulePrivateLinkRetries(reason, retryKeys.distinct()) } private suspend fun drainAndSchedulePrivateLinkRetries( @@ -825,7 +841,7 @@ class PrivatePaykitRepo @Inject constructor( PrivateMessageDrainRetryKey(publicKey, peer.counterpartyReceiverPath) to peer.state } } - .groupBy(keySelector = { it.first }, valueTransform = { it.second }) + .toMap() val pendingOutbound = runSuspendCatching { paykitSdkService.pendingOutboundPrivateCounterparties() } .getOrElse { Logger.warn("Failed to inspect pending private Paykit messages", it, context = TAG) @@ -839,11 +855,9 @@ class PrivatePaykitRepo @Inject constructor( .toSet() return retryKeys.filterTo(mutableSetOf()) { retryKey -> - val states = linkedPeers[retryKey].orEmpty() - when { - LinkedPeerState.LINKED in states -> retryKey in pendingOutbound - states.isEmpty() -> retryKey in pendingOutbound - states.all { it == LinkedPeerState.BLOCKED || it == LinkedPeerState.UNKNOWN } -> false + when (linkedPeers[retryKey]) { + LinkedPeerState.LINKED, null -> retryKey in pendingOutbound + LinkedPeerState.BLOCKED, LinkedPeerState.UNKNOWN -> false else -> true } } 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 5f8182f02f..1d35947bd6 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 @@ -80,9 +80,6 @@ class AddContactViewModel @Inject constructor( } } .onFailure { error -> - if (error == PubkyContactError.AlreadyExists) { - refreshContactPaykitReceivers(publicKey) - } _uiState.update { state -> state.copy( isLoading = false, @@ -98,6 +95,9 @@ class AddContactViewModel @Inject constructor( }, ) } + if (error == PubkyContactError.AlreadyExists) { + refreshContactPaykitReceivers(publicKey) + } } } } diff --git a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt index 0f8409f685..a17dc958cf 100644 --- a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt @@ -22,7 +22,7 @@ class RefreshContactPaykitReceiversUseCase @Inject constructor( suspend operator fun invoke(publicKey: String): Result = withContext(ioDispatcher) { runSuspendCatching { pubkyRepo.refreshContactReceiverPaths(publicKey).getOrThrow() - privatePaykitRepo.refreshSavedContactEndpoints(pubkyRepo.contacts.value.map { it.publicKey }).getOrThrow() + privatePaykitRepo.refreshSavedContactEndpoints(publicKey).getOrThrow() }.onFailure { Logger.warn( "Failed to refresh Paykit receivers 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 df01cfac87..20d62407fc 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -656,6 +656,29 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } + @Test + fun `prepareSavedContacts continues when another contact record cannot be read`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + whenever { paykitSdkService.contactRecord(CONTACT_KEY) } + .thenThrow(IllegalStateException("contact unavailable")) + whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY, WALLET_RECEIVER_PATH) } + .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) + whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { + privateListDeliveryReportForUpdates(it.getArgument(0)) + } + + val result = sut.prepareSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } + assertEquals(listOf(OTHER_CONTACT_KEY), captor.firstValue.map { it.counterparty }) + } + @Test fun `prepareSavedContacts preserves cleanup markers while saving publication state`() = 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 5414d75de5..2e731c5a60 100644 --- a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt @@ -1,13 +1,11 @@ package to.bitkit.usecases -import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Test import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import to.bitkit.models.PubkyProfile import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest @@ -26,19 +24,16 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { ) @Test - fun `refreshes receiver paths before publishing the complete contact set`() = test { - whenever(pubkyRepo.contacts).thenReturn( - MutableStateFlow(contactKeys.map(PubkyProfile::placeholder)), - ) + fun `refreshes receiver paths before publishing the contact`() = test { whenever { pubkyRepo.refreshContactReceiverPaths(contactKeys.last()) }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.refreshSavedContactEndpoints(contactKeys) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last()) }.thenReturn(Result.success(Unit)) val result = sut(contactKeys.last()) assertTrue(result.isSuccess) inOrder(pubkyRepo, privatePaykitRepo).apply { verify(pubkyRepo).refreshContactReceiverPaths(contactKeys.last()) - verify(privatePaykitRepo).refreshSavedContactEndpoints(contactKeys) + verify(privatePaykitRepo).refreshSavedContactEndpoints(contactKeys.last()) } } @@ -50,6 +45,6 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { val result = sut(contactKeys.last()) assertEquals(error, result.exceptionOrNull()) - verify(privatePaykitRepo, never()).refreshSavedContactEndpoints(contactKeys) + verify(privatePaykitRepo, never()).refreshSavedContactEndpoints(contactKeys.last()) } } From 48df7a47749b048e1a2ce0bcf147c7599d08cb05 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 10 Jul 2026 18:34:13 +0200 Subject: [PATCH 3/3] fix: harden paykit receiver state --- .../bitkit/repositories/PrivatePaykitRepo.kt | 9 ++++-- .../screens/profile/EditProfileViewModel.kt | 3 -- .../RefreshContactPaykitReceiversUseCase.kt | 3 +- ...efreshContactPaykitReceiversUseCaseTest.kt | 28 +++++++++++++++++-- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 304c13b699..45c7ca6dda 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -153,10 +153,15 @@ class PrivatePaykitRepo @Inject constructor( } } - suspend fun refreshSavedContactEndpoints(publicKey: String): Result = + suspend fun refreshSavedContactEndpoints( + publicKey: String, + savedPublicKeys: Collection, + ): Result = withContext(serializedDispatcher) { runSuspendCatching { - val keys = rememberSavedContacts(listOf(publicKey), replacing = false) + val normalizedKey = normalizedPublicKey(publicKey) ?: return@runSuspendCatching + rememberSavedContacts(savedPublicKeys + normalizedKey, replacing = false) + val keys = listOf(normalizedKey) if (!canPublishPrivateEndpoints()) { prepareRelevantPrivateLinksIfAvailable(keys, "refresh") return@runSuspendCatching diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt index 9cf2adb228..5b4b6e1852 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt @@ -269,9 +269,6 @@ class EditProfileViewModel @Inject constructor( } val cleanupResult = privatePaykitRepo.removePublishedEndpointsForCleanup(TAG) if (cleanupResult.isFailure) { - val error = requireNotNull(cleanupResult.exceptionOrNull()) { - "Private Paykit cleanup failed without an error" - } _uiState.update { it.copy( isSaving = false, diff --git a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt index a17dc958cf..010c16ca75 100644 --- a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt @@ -22,7 +22,8 @@ class RefreshContactPaykitReceiversUseCase @Inject constructor( suspend operator fun invoke(publicKey: String): Result = withContext(ioDispatcher) { runSuspendCatching { pubkyRepo.refreshContactReceiverPaths(publicKey).getOrThrow() - privatePaykitRepo.refreshSavedContactEndpoints(publicKey).getOrThrow() + val savedPublicKeys = (pubkyRepo.contacts.value.map { it.publicKey } + publicKey).distinct() + privatePaykitRepo.refreshSavedContactEndpoints(publicKey, savedPublicKeys).getOrThrow() }.onFailure { Logger.warn( "Failed to refresh Paykit receivers for '${PubkyPublicKeyFormat.redacted(publicKey)}'", diff --git a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt index 2e731c5a60..ee5801e7bf 100644 --- a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt @@ -1,11 +1,14 @@ package to.bitkit.usecases +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before import org.junit.Test import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.models.PubkyProfile import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest @@ -16,6 +19,18 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { private val pubkyRepo = mock() private val privatePaykitRepo = mock() private val contactKeys = listOf("pubky-alice", "pubky-bob") + private val contacts = MutableStateFlow( + contactKeys.map { publicKey -> + PubkyProfile( + publicKey = publicKey, + name = publicKey, + bio = "", + imageUrl = null, + links = emptyList(), + status = null, + ) + }, + ) private val sut = RefreshContactPaykitReceiversUseCase( ioDispatcher = testDispatcher, @@ -23,17 +38,24 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { privatePaykitRepo = privatePaykitRepo, ) + @Before + fun setUp() { + whenever(pubkyRepo.contacts).thenReturn(contacts) + } + @Test fun `refreshes receiver paths before publishing the contact`() = test { whenever { pubkyRepo.refreshContactReceiverPaths(contactKeys.last()) }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last()) }.thenReturn(Result.success(Unit)) + whenever { + privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys) + }.thenReturn(Result.success(Unit)) val result = sut(contactKeys.last()) assertTrue(result.isSuccess) inOrder(pubkyRepo, privatePaykitRepo).apply { verify(pubkyRepo).refreshContactReceiverPaths(contactKeys.last()) - verify(privatePaykitRepo).refreshSavedContactEndpoints(contactKeys.last()) + verify(privatePaykitRepo).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) } } @@ -45,6 +67,6 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { val result = sut(contactKeys.last()) assertEquals(error, result.exceptionOrNull()) - verify(privatePaykitRepo, never()).refreshSavedContactEndpoints(contactKeys.last()) + verify(privatePaykitRepo, never()).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) } }