From 67d7c00561266d7c4e2642fc8b92b162081fa25c Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 15 Oct 2026 18:39:09 -0500 Subject: [PATCH 01/10] fix: pause automatic paykit work during profile deletion --- .../bitkit/repositories/PrivatePaykitRepo.kt | 9 +- .../bitkit/repositories/PublicPaykitRepo.kt | 6 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 86 ++++++++---- .../repositories/PublicPaykitRepoTest.kt | 26 ++++ .../viewmodels/AppViewModelSendFlowTest.kt | 132 +++++++++++++++++- changelog.d/next/1508.fixed.md | 1 + journeys/profile/README.md | 2 + journeys/profile/delete-profile.xml | 2 + 8 files changed, 233 insertions(+), 31 deletions(-) create mode 100644 changelog.d/next/1508.fixed.md diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 960c29d236..ca924ef823 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -127,7 +127,10 @@ class PrivatePaykitRepo @Inject constructor( private val activeLinkPreparations = mutableMapOf>() private var preparationJob: Job? = null private var preparationGeneration = 0 - private var isDeletingProfile = false + private val _isProfileDeletionInProgress = MutableStateFlow(false) + val isProfileDeletionInProgress = _isProfileDeletionInProgress.asStateFlow() + private val isDeletingProfile: Boolean + get() = _isProfileDeletionInProgress.value private var pendingForceRefreshLightning = false private val unavailableLinkRetryAt = mutableMapOf() private var state: PrivatePaykitState? = null @@ -351,12 +354,12 @@ class PrivatePaykitRepo @Inject constructor( } suspend fun beginProfileDeletion() = withContext(serializedDispatcher) { - isDeletingProfile = true + _isProfileDeletionInProgress.update { true } invalidateContactPreparation() } suspend fun endProfileDeletion() = withContext(serializedDispatcher) { - isDeletingProfile = false + _isProfileDeletionInProgress.update { false } } suspend fun refreshSavedContactEndpoints( diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index a6f18a585c..fe37971fb6 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -268,13 +268,17 @@ class PublicPaykitRepo @Inject constructor( } } - suspend fun refreshPublishedBolt11ForPayment(paymentHash: String): Result = withContext(ioDispatcher) { + suspend fun refreshPublishedBolt11ForPayment( + paymentHash: String, + publish: Boolean = true, + ): Result = withContext(ioDispatcher) { runSuspendCatching { val settings = settingsStore.data.first() if (!settings.sharesPublicPaykitEndpoints) return@runSuspendCatching if (settings.publicPaykitBolt11PaymentHash != paymentHash) return@runSuspendCatching clearPublicBolt11Metadata() + if (!publish) return@runSuspendCatching val desired = buildWalletEndpoints(refresh = true) applyPublishedEndpoints(desired) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 61bb8a4436..d896e8a931 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -813,6 +813,9 @@ class AppViewModel @Inject constructor( .map { it.onchainAddress } .distinctUntilChanged() .combine(isPaymentRequestPollingStopped) { address, stopped -> address.takeUnless { stopped } } + .combine(privatePaykitRepo.isProfileDeletionInProgress) { address, deleting -> + address.takeUnless { deleting } + } .debounce(PUBLIC_PAYKIT_SYNC_DEBOUNCE) .collect { address -> if (address != null && !isPaymentRequestPollingStopped.value) { @@ -848,7 +851,11 @@ class AppViewModel @Inject constructor( private suspend fun refreshPublicPaykitEndpointsIfEnabled(forceRefreshLightning: Boolean = false) { val settings = settingsStore.data.first() - if (!isPaykitEnabled.value || !settings.sharesPublicPaykitEndpoints) return + if (!isPaykitEnabled.value || !settings.sharesPublicPaykitEndpoints || + privatePaykitRepo.isProfileDeletionInProgress.value + ) { + return + } val onchainAddress = walletRepo.walletState.value.onchainAddress if (onchainAddress.isBlank() && !lightningRepo.canReceive()) return @@ -864,15 +871,17 @@ class AppViewModel @Inject constructor( pubkyRepo.contacts, pubkyRepo.contactsLoadVersion, settingsStore.isPaykitEnabled.map { PaykitFeatureFlags.isUiEnabled(it) }, - ) { publicKey, contacts, contactsLoadVersion, isPaykitEnabled -> + privatePaykitRepo.isProfileDeletionInProgress, + ) { publicKey, contacts, contactsLoadVersion, isPaykitEnabled, deleting -> PaykitContactSyncState( publicKey = publicKey, contactKeys = contacts.map { it.publicKey }.toSet(), contactsLoaded = contactsLoadVersion > 0L, isPaykitEnabled = isPaykitEnabled, - ) + ).takeUnless { deleting } } .distinctUntilChanged() + .filterNotNull() .collect(::synchronizePaykitContacts) } } @@ -926,7 +935,7 @@ class AppViewModel @Inject constructor( } private fun isCurrentPaykitContactSync(state: PaykitContactSyncState, signIn: PubkySignIn): Boolean = - pubkyRepo.isCurrent(signIn) && isPaykitEnabled.value && + pubkyRepo.isCurrent(signIn) && isPaykitEnabled.value && !privatePaykitRepo.isProfileDeletionInProgress.value && (pubkyRepo.contactsLoadVersion.value > 0L) == state.contactsLoaded && pubkyRepo.contacts.value.map { it.publicKey }.toSet() == state.contactKeys @@ -956,10 +965,11 @@ class AppViewModel @Inject constructor( forceRefreshLightning: Boolean = false, requeueActive: Boolean = false, ) { + if (privatePaykitRepo.isProfileDeletionInProgress.value) return val contactKeys = pubkyRepo.contacts.value.map { it.publicKey } retryPendingPaykitEndpointRemoval(contactKeys, reason) - if (!isPaykitEnabled.value) return + if (!isPaykitEnabled.value || privatePaykitRepo.isProfileDeletionInProgress.value) return refreshPrivateOnlyPaykitApp(reason) privatePaykitRepo.reconcileReservedReceiveIndexes() @@ -1087,11 +1097,15 @@ class AppViewModel @Inject constructor( if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) { return Result.failure(PaykitPaymentRequestError.RequestUnavailable) } - if (onlyWhileActive && isPaymentRequestPollingStopped.value) { + if (privatePaykitRepo.isProfileDeletionInProgress.value || + onlyWhileActive && isPaymentRequestPollingStopped.value + ) { return Result.failure(PaykitPaymentRequestError.RequestUnavailable) } if (reconcileProofs) paykitPaymentProofRepo.reconcile() - if (onlyWhileActive && isPaymentRequestPollingStopped.value) { + if (privatePaykitRepo.isProfileDeletionInProgress.value || + onlyWhileActive && isPaymentRequestPollingStopped.value + ) { return Result.failure(PaykitPaymentRequestError.RequestUnavailable) } val result = if (forceFresh) { @@ -1123,6 +1137,7 @@ class AppViewModel @Inject constructor( } private suspend fun refreshPaymentRequestTargets(force: Boolean = false) { + if (privatePaykitRepo.isProfileDeletionInProgress.value) return if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return paykitPaymentRequestRepo.refreshEligibleTargets( savedPublicKeys = pubkyRepo.contacts.value.map { it.publicKey }, @@ -1130,6 +1145,9 @@ class AppViewModel @Inject constructor( ) } + private fun isAutomaticPaykitWorkPaused(): Boolean = + isPaymentRequestPollingStopped.value || privatePaykitRepo.isProfileDeletionInProgress.value + fun startPaykitPaymentRequestPolling() { privatePaykitRepo.setContactPreparationActive(true) isPaymentRequestPollingStopped.update { false } @@ -1138,25 +1156,22 @@ class AppViewModel @Inject constructor( startPaykitSessionRestoreRetries() startPaykitContactsRefresh() paykitPaymentRequestPollingJob = viewModelScope.launch { - if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.republishIdentityIfNeeded() + if (!isAutomaticPaykitWorkPaused() && isOnline.value == ConnectivityState.CONNECTED) { + pubkyRepo.republishIdentityIfNeeded() + } refreshIncomingPaykitPaymentRequests(messagePriority = Priority.Background, onlyWhileActive = true) refreshPaymentRequestTargets() var maintenanceIntervalIndex = 0 var nextMaintenance = timeSource.markNow() + PAYKIT_MAINTENANCE_INTERVALS.first() while (true) { delay(PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVAL) - if (isOnline.value != ConnectivityState.CONNECTED) continue + if (isOnline.value != ConnectivityState.CONNECTED || isAutomaticPaykitWorkPaused()) continue val refreshMaintenance = nextMaintenance.hasPassedNow() if (refreshMaintenance) { maintenanceIntervalIndex = (maintenanceIntervalIndex + 1).coerceAtMost(PAYKIT_MAINTENANCE_INTERVALS.lastIndex) nextMaintenance = timeSource.markNow() + PAYKIT_MAINTENANCE_INTERVALS[maintenanceIntervalIndex] - if (isPaykitEnabled.value && walletRepo.walletExists()) pubkyRepo.restoreSessionIfNeeded() - pubkyRepo.republishIdentityIfNeeded() - retryPendingPaykitEndpointRemoval( - contactKeys = pubkyRepo.contacts.value.map { it.publicKey }, - reason = "payment request polling", - ) + refreshPaykitMaintenance() } val refreshIdentity = pubkyRepo.publicKey.value startShopContactImport() @@ -1177,12 +1192,23 @@ class AppViewModel @Inject constructor( } } + private suspend fun refreshPaykitMaintenance() { + if (isPaykitEnabled.value && walletRepo.walletExists()) pubkyRepo.restoreSessionIfNeeded() + if (isAutomaticPaykitWorkPaused()) return + pubkyRepo.republishIdentityIfNeeded() + retryPendingPaykitEndpointRemoval( + contactKeys = pubkyRepo.contacts.value.map { it.publicKey }, + reason = "payment request polling", + ) + } + private fun startPaykitContactsRefresh() { if (paykitContactsRefreshJob?.isActive == true) return paykitContactsRefreshJob = viewModelScope.launch { pubkyRepo.awaitInitialization() - combine(isOnline, isPaykitEnabled, pubkyRepo.publicKey) { connectivity, enabled, publicKey -> - publicKey.takeIf { connectivity == ConnectivityState.CONNECTED && enabled } + combine(isOnline, isPaykitEnabled, pubkyRepo.publicKey, privatePaykitRepo.isProfileDeletionInProgress) { + connectivity, enabled, publicKey, deleting -> + publicKey.takeIf { connectivity == ConnectivityState.CONNECTED && enabled && !deleting } }.distinctUntilChanged().collectLatest { publicKey -> shopContactImportJob?.cancel() shopContactImportJob = null @@ -1206,7 +1232,7 @@ class AppViewModel @Inject constructor( } private fun startShopContactImport() { - if (shopContactImportJob?.isActive == true || isPaymentRequestPollingStopped.value) return + if (shopContactImportJob?.isActive == true || isAutomaticPaykitWorkPaused()) return if (!isPaykitEnabled.value || isOnline.value != ConnectivityState.CONNECTED) return if (pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return shopContactImportJob = viewModelScope.launch { @@ -1329,6 +1355,9 @@ class AppViewModel @Inject constructor( .combine(isPaymentRequestPollingStopped) { version, stopped -> version.takeUnless { stopped } } + .combine(privatePaykitRepo.isProfileDeletionInProgress) { version, deleting -> + version.takeUnless { deleting } + } .filterNotNull() .distinctUntilChanged() .collect { @@ -1926,6 +1955,7 @@ class AppViewModel @Inject constructor( private suspend fun refreshPrivateOnlyPaykitApp(reason: String, onlyIfNeeded: Boolean = false) { val settings = settingsStore.data.first() + if (privatePaykitRepo.isProfileDeletionInProgress.value) return if (!settings.sharesPrivatePaykitEndpoints || settings.sharesPublicPaykitEndpoints) return val identity = pubkyRepo.publicKey.value ?: return if (onlyIfNeeded && PubkyPublicKeyFormat.matches(privatePaykitAppRegistrationIdentity, identity)) return @@ -1943,6 +1973,7 @@ class AppViewModel @Inject constructor( } private suspend fun retryPendingPaykitEndpointRemoval(contactKeys: Collection, reason: String) { + if (privatePaykitRepo.isProfileDeletionInProgress.value) return contactPaymentSettingsRepo.reconcilePendingEndpoints { privatePaykitRepo.retryPendingEndpointRemoval(contactKeys) .onFailure { @@ -1950,6 +1981,7 @@ class AppViewModel @Inject constructor( } val settings = settingsStore.data.first() + if (privatePaykitRepo.isProfileDeletionInProgress.value) return@reconcilePendingEndpoints if (settings.publicPaykitCleanupPending) { val reconciliationResult = if (settings.sharesPublicPaykitEndpoints) { publicPaykitRepo.syncCurrentPublishedEndpoints() @@ -2427,14 +2459,16 @@ class AppViewModel @Inject constructor( syncLdkPayments = false, ) } - publicPaykitRepo.refreshPublishedBolt11ForPayment(paymentHash) - .onFailure { - Logger.warn( - "Failed to refresh public Paykit invoice for '$paymentHash'", - it, - context = TAG, - ) - } + publicPaykitRepo.refreshPublishedBolt11ForPayment( + paymentHash, + publish = !privatePaykitRepo.isProfileDeletionInProgress.value, + ).onFailure { + Logger.warn( + "Failed to refresh public Paykit invoice for '$paymentHash'", + it, + context = TAG, + ) + } privatePaykitRepo.handleReceivedPayment(paymentHash) .onFailure { Logger.warn( diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index a9d40cd290..ed2e4d0871 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -145,6 +145,32 @@ class PublicPaykitRepoTest : BaseUnitTest() { ) } + @Test + fun `deferred paid invoice replacement clears the cache before publication resumes`() = test { + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitOnchainEnabled = false, + publicPaykitUsdtEnabled = false, + publicPaykitBolt11 = "lnbc1paid", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + ) + stubPublicInvoice("lnbc1replacement", byteArrayOf(4, 5, 6)) + + sut.refreshPublishedBolt11ForPayment("unrelated", publish = false).getOrThrow() + assertEquals("lnbc1paid", settingsFlow.value.publicPaykitBolt11) + sut.refreshPublishedBolt11ForPayment("010203", publish = false).getOrThrow() + assertEquals("", settingsFlow.value.publicPaykitBolt11) + assertEquals("", settingsFlow.value.publicPaykitBolt11PaymentHash) + verifyNoInteractions(paykitSdkService) + + sut.syncCurrentPublishedEndpoints().getOrThrow() + val endpoints = argumentCaptor>() + verifyBlocking(paykitSdkService) { syncPublicEndpoints(endpoints.capture()) } + assertEquals("lnbc1replacement", endpoints.firstValue.single().value) + assertEquals("040506", settingsFlow.value.publicPaykitBolt11PaymentHash) + } + @Test fun `syncCurrentPublishedEndpoints waits only for session restoration and remains cancellable`() = test { isRestoringSession.value = true diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index a722178112..ad2eaad8ad 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -304,6 +304,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val nodeEventUpdates = MutableSharedFlow() private val pubkyPublicKey = MutableStateFlow(null) private val pubkyContacts = MutableStateFlow>(emptyList()) + private val isProfileDeletionInProgress = MutableStateFlow(false) private val pubkyContactsLoadVersion = MutableStateFlow(0L) private val pubkyContactsLoadCompletionVersion = MutableStateFlow(0L) private val pubkyContactImportFailure = MutableStateFlow(null) @@ -332,6 +333,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Before fun setUp() { + whenever(privatePaykitRepo.isProfileDeletionInProgress).thenReturn(isProfileDeletionInProgress) whenever(paykitPaymentRequestRepo.isPaymentSubmissionActive).thenReturn(paymentSubmissionActive) whenever(paykitPaymentRequestRepo.setPaymentSubmissionActive(any())).thenAnswer { paymentSubmissionActive.value = it.getArgument(0) @@ -733,7 +735,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.contactPublicKeyForPrivateInvoicePaymentHash(any()) } .thenReturn(null) - whenever { publicPaykitRepo.refreshPublishedBolt11ForPayment(any()) } + whenever { publicPaykitRepo.refreshPublishedBolt11ForPayment(any(), any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.handleReceivedPayment(any()) } .thenReturn(Result.success(Unit)) @@ -11868,6 +11870,27 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.stopPaykitPaymentRequestPolling() } + @Test + fun `profile deletion defers public publication and resumes the latest address on failure`() = test { + enablePublicPaykitSharing() + runCurrent() + advanceTimeBy(1_000) + runCurrent() + clearInvocations(publicPaykitRepo) + + isProfileDeletionInProgress.update { true } + walletState.update { it.copy(onchainAddress = "bc1qreplacement") } + settingsData.update { it.copy(publicPaykitBolt11ExpiresAtMillis = 1L) } + advanceTimeBy(1_000) + runCurrent() + verify(publicPaykitRepo, never()).syncCurrentPublishedEndpoints(any(), any()) + + isProfileDeletionInProgress.update { false } + advanceTimeBy(1_000) + runCurrent() + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(forceRefreshLightning = false) + } + @Test fun `channel ready refreshes public Paykit endpoints when sharing enabled`() = test { enablePublicPaykitSharing() @@ -12321,6 +12344,113 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.stopPaykitPaymentRequestPolling() } + @Test + fun `profile deletion lets admitted reconciliation finish without starting automatic intake`() = test { + enablePaykitUi() + pubkyPublicKey.update { testPublicKey } + runCurrent() + val reconciliation = CompletableDeferred() + var reconciled = false + whenever(paykitPaymentProofRepo.reconcile()).doSuspendableAnswer { + reconciliation.await() + reconciled = true + } + sut.onHomeResumed() + runCurrent() + assertFalse(reconciled) + + isProfileDeletionInProgress.update { true } + clearInvocations(paykitPaymentRequestRepo) + reconciliation.complete(Unit) + runCurrent() + assertTrue(reconciled) + verify(paykitPaymentRequestRepo, never()).refresh(any(), any()) + verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) + + isProfileDeletionInProgress.update { false } + sut.onHomeResumed() + runCurrent() + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL, Priority.Ordered) + } + + @Test + fun `profile deletion defers contact synchronization and polling until deletion ends`() = test { + enablePaykitUi() + pubkyPublicKey.update { testPublicKey } + runCurrent() + isProfileDeletionInProgress.update { true } + pubkyContactsLoadVersion.update { 1L } + clearInvocations(privatePaykitRepo, paykitPaymentRequestRepo, pubkyRepo) + sut.startPaykitPaymentRequestPolling() + runCurrent() + advanceTimeBy(10_000) + runCurrent() + verify(privatePaykitRepo, never()).scheduleSavedContactPreparation(any>(), any()) + verify(paykitPaymentRequestRepo, never()).refresh(any(), any()) + verify(paykitPaymentRequestRepo, never()).refreshAfterStateChange(any()) + verify(pubkyRepo, never()).refreshContacts() + verify(pubkyRepo, never()).importShopContacts() + + isProfileDeletionInProgress.update { false } + runCurrent() + verify(privatePaykitRepo).scheduleSavedContactPreparation(eq(emptySet()), any()) + verify(paykitPaymentRequestRepo).refreshAfterStateChange() + verify(pubkyRepo).refreshContacts() + sut.stopPaykitPaymentRequestPolling() + } + + @Test + fun `profile deletion preserves payment completion without starting intake or publication`() = test { + enablePaykitUi() + pubkyPublicKey.update { testPublicKey } + runCurrent() + isProfileDeletionInProgress.update { true } + runCurrent() + clearInvocations(paykitPaymentRequestRepo, publicPaykitRepo, paykitPaymentProofRepo, activityRepo) + + emitNodeEvent(Event.PaymentSuccessful("success-id", "success-hash", "preimage", 10uL)) + runCurrent() + emitNodeEvent(Event.PaymentFailed("failure-id", "failure-hash", PaymentFailureReason.RETRIES_EXHAUSTED)) + runCurrent() + emitNodeEvent(Event.PaymentReceived("received-id", "received-hash", 1_000uL, emptyList())) + runCurrent() + + verify(paykitPaymentProofRepo).completeLightningPayment("success-hash", "preimage") + verify(paykitPaymentProofRepo).failLightningPayment("failure-hash") + verify(activityRepo).notifyPaymentActivityChanged() + verify(privatePaykitRepo).handleReceivedPayment("received-hash") + verify(paykitPaymentRequestRepo, never()).refresh(any(), any()) + verify(publicPaykitRepo).refreshPublishedBolt11ForPayment("received-hash", publish = false) + verify(publicPaykitRepo, never()).refreshPublishedBolt11ForPayment(any(), eq(true)) + } + + @Test + fun `profile deletion prevents maintenance publication after admitted restoration`() = test { + enablePaykitUi() + pubkyPublicKey.update { testPublicKey } + sut.startPaykitPaymentRequestPolling() + runCurrent() + val restoration = CompletableDeferred() + var restored = false + whenever(pubkyRepo.restoreSessionIfNeeded()).doSuspendableAnswer { + restoration.await() + restored = true + true + } + clearInvocations(pubkyRepo) + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + verify(pubkyRepo).restoreSessionIfNeeded() + assertFalse(restored) + + isProfileDeletionInProgress.update { true } + restoration.complete(Unit) + runCurrent() + sut.stopPaykitPaymentRequestPolling() + assertTrue(restored) + verify(pubkyRepo, never()).republishIdentityIfNeeded() + } + @Test fun `private Paykit endpoint cleanup refreshes requests after state changes`() = test { enablePaykitUi() diff --git a/changelog.d/next/1508.fixed.md b/changelog.d/next/1508.fixed.md new file mode 100644 index 0000000000..ff059d9dfa --- /dev/null +++ b/changelog.d/next/1508.fixed.md @@ -0,0 +1 @@ +Pause automatic Paykit refresh and publication during profile deletion while preserving safe cleanup and recovery. diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 6b9b25ff20..2caa0ee89e 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -2,6 +2,8 @@ `delete-profile.xml` verifies visible progress and completion when deleting a disposable profile. Use a test identity that may be deleted. +During profile deletion, automatic contact refresh, request intake and endpoint publication must not start new work. Already admitted operations and explicit deletion cleanup still finish. After a failed deletion, normal automatic work must resume for the retained identity. Verifying SDK admission and a controlled cleanup failure requires operation gates beyond the journey runner capabilities; this remains a manual check rather than a timed deletion guarantee. + Delayed or failed session restoration requires network fault injection, which is not a journey-runner capability; see the PR manual checks. For a saved identity, inject a temporary import failure (`concurrent_update`, `shared_state_busy`, or a transport failure) during cold start. Verify that no "Session expired" toast appears, the saved credentials remain, and recovery succeeds after the temporary failure ends. A genuinely invalid session must still produce the existing expiry feedback. diff --git a/journeys/profile/delete-profile.xml b/journeys/profile/delete-profile.xml index 14d27866f1..30283f5fe8 100644 --- a/journeys/profile/delete-profile.xml +++ b/journeys/profile/delete-profile.xml @@ -6,6 +6,8 @@ Repeat with 62 imported contacts, once after preparation settles and once immediately after import. Measure confirmation to onboarding and check logs for any uncertain-write safety wait. During deletion, check the indicator on Delete Profile. Save stays disabled with its label visible. + Cleanup may still wait for admitted operations and remote safety checks; no fixed duration is expected. + See the suite README for controlled automatic-admission and deletion-failure checks. Open the existing profile from the Home header From bc00345cb6c9fa2c8d342d064a29139926d1ec69 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 15 Oct 2026 18:42:05 -0500 Subject: [PATCH 02/10] chore: rename changelog fragment --- changelog.d/next/{1508.fixed.md => 1510.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1508.fixed.md => 1510.fixed.md} (100%) diff --git a/changelog.d/next/1508.fixed.md b/changelog.d/next/1510.fixed.md similarity index 100% rename from changelog.d/next/1508.fixed.md rename to changelog.d/next/1510.fixed.md From eacdc265712da0e80ad8d69561e7131f8782edec Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 02:31:25 -0500 Subject: [PATCH 03/10] fix: fence endpoint publication during profile deletion --- .../bitkit/repositories/PrivatePaykitRepo.kt | 31 ++-- .../bitkit/repositories/PublicPaykitRepo.kt | 46 ++++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 20 ++- .../repositories/PrivatePaykitRepoTest.kt | 123 ++++++++++++++++ .../repositories/PublicPaykitRepoTest.kt | 134 +++++++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 35 +++-- journeys/profile/README.md | 6 + journeys/profile/delete-profile.xml | 3 +- 8 files changed, 357 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index ca924ef823..fe4ab94e20 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -225,9 +225,10 @@ class PrivatePaykitRepo @Inject constructor( requireImmediatePublication: Boolean = false, ): Result = withContext(serializedDispatcher) { runSuspendCatching { + val generation = preparationGeneration val keys = rememberSavedContacts(publicKeys, replacing = true) if (!canPublishPrivateEndpoints()) { - prepareRelevantPrivateLinksIfAvailable(keys, "prepare") + prepareRelevantPrivateLinksIfAvailable(keys, "prepare", generation) if (requireImmediatePublication && keys.isNotEmpty()) throw PrivatePaykitError.PrivateUnavailable return@runSuspendCatching } @@ -330,7 +331,12 @@ class PrivatePaykitRepo @Inject constructor( priority = Priority.Background, ).getOrThrow() } else { - prepareRelevantPrivateLinksIfAvailable(keys, "contact preparation", Priority.Background) + prepareRelevantPrivateLinksIfAvailable( + keys, + "contact preparation", + generation, + Priority.Background, + ) } }.onFailure { Logger.warn("Failed to prepare private Paykit contacts", it, context = TAG) @@ -1057,8 +1063,8 @@ class PrivatePaykitRepo @Inject constructor( requireImmediatePublication: Boolean, status: IdentityStatus? = null, ): Boolean { - val isCurrent = generation == preparationGeneration && - (status?.publicKey ?: pubkyService.currentPublicKey()) == identity + val currentIdentity = status?.publicKey ?: pubkyService.currentPublicKey() + val isCurrent = !isDeletingProfile && generation == preparationGeneration && currentIdentity == identity if (!isCurrent && requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable return isCurrent } @@ -1185,24 +1191,29 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun prepareRelevantPrivateLinksIfAvailable( publicKeys: Collection, reason: String, + generation: Int, priority: Priority = Priority.Ordered, ) { + if (isDeletingProfile || generation != preparationGeneration) return if (isContactSharingCleanupPending() || !hasPrivatePaymentAccessForCurrentProfile()) return - drainAndSchedulePrivateLinkRetries(reason, publicKeys.distinct(), priority) + if (isDeletingProfile || generation != preparationGeneration) return + drainAndSchedulePrivateLinkRetries(reason, publicKeys.distinct(), generation, priority) } private suspend fun drainAndSchedulePrivateLinkRetries( reason: String, retryKeys: Collection, + generation: Int, priority: Priority = Priority.Ordered, ) { val newKeys = retryKeys.toSet() - pendingMessageDrainRetries.keys val pendingKeys = pendingPrivateMessageDrainKeys(newKeys, retryMissingPeers = true, priority = priority) - if (pendingKeys.isEmpty()) return + if (pendingKeys.isEmpty() || isDeletingProfile || generation != preparationGeneration) return drainPendingPrivateMessages(reason, retryKeys = pendingKeys, priority = priority) + if (isDeletingProfile || generation != preparationGeneration) return val pendingRetryKeys = pendingPrivateMessageDrainKeys(pendingKeys, priority = priority) - if (pendingRetryKeys.isNotEmpty()) { + if (pendingRetryKeys.isNotEmpty() && !isDeletingProfile && generation == preparationGeneration) { schedulePendingPrivateMessageDrainRetries(reason, retryKeys = pendingRetryKeys) } } @@ -2076,18 +2087,20 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun canPublishPrivateEndpoints(status: IdentityStatus? = null): Boolean { + if (isDeletingProfile) return false val settings = settingsStore.data.first() val locallyEligible = settings.sharesPrivatePaykitEndpoints && !isContactSharingCleanupPending() && App.currentActivity?.value != null && walletRepo.walletExists() && lightningRepo.lightningState.value.nodeLifecycleState.isRunning() - if (!locallyEligible) return false - return if (status?.publicKey != null) { + if (!locallyEligible || isDeletingProfile) return false + val hasAccess = if (status?.publicKey != null) { status.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE } else { hasPrivatePaymentAccessForCurrentProfile() } + return hasAccess && !isDeletingProfile } private suspend fun hasPrivatePaymentAccessForCurrentProfile(): Boolean = runSuspendCatching { diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index fe37971fb6..98a851924d 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -247,40 +247,46 @@ class PublicPaykitRepo @Inject constructor( val desired = buildWalletEndpoints(refresh = true) syncPaykitApp().getOrThrow() applyPublishedEndpoints(desired) - settingsStore.update { it.copy(publicPaykitCleanupPending = false) } + acknowledgePublishedEndpoints(desired) } } suspend fun syncCurrentPublishedEndpoints( forceRefreshLightning: Boolean = false, requireEndpoint: Boolean = false, + canPublish: () -> Boolean = { true }, ): Result = withContext(ioDispatcher) { runSuspendCatching { pubkyRepo.isRestoringSession.first { !it } + if (!canPublish()) return@runSuspendCatching val desired = buildWalletEndpoints( refresh = false, forceRefreshLightning = forceRefreshLightning, requireEndpoint = requireEndpoint, ) + if (!canPublish()) return@runSuspendCatching syncPaykitApp().getOrThrow() - applyPublishedEndpoints(desired) - settingsStore.update { it.copy(publicPaykitCleanupPending = false) } + if (applyPublishedEndpoints(desired, canPublish)) { + acknowledgePublishedEndpoints(desired, canPublish) + } } } suspend fun refreshPublishedBolt11ForPayment( paymentHash: String, - publish: Boolean = true, + canPublish: () -> Boolean = { true }, ): Result = withContext(ioDispatcher) { runSuspendCatching { val settings = settingsStore.data.first() if (!settings.sharesPublicPaykitEndpoints) return@runSuspendCatching if (settings.publicPaykitBolt11PaymentHash != paymentHash) return@runSuspendCatching - clearPublicBolt11Metadata() - if (!publish) return@runSuspendCatching + clearPublicBolt11Metadata(publicationPending = true) + if (!canPublish()) return@runSuspendCatching val desired = buildWalletEndpoints(refresh = true) - applyPublishedEndpoints(desired) + if (applyPublishedEndpoints(desired, canPublish)) { + acknowledgePublishedEndpoints(desired, canPublish) + } } } @@ -315,9 +321,14 @@ class PublicPaykitRepo @Inject constructor( clearPublicBolt11Metadata() } - private suspend fun applyPublishedEndpoints(desiredEndpoints: List) { + private suspend fun applyPublishedEndpoints( + desiredEndpoints: List, + canPublish: () -> Boolean = { true }, + ): Boolean = publishMutex.withLock { + if (!canPublish()) return@withLock false requireCurrentPublicKey() + if (!canPublish()) return@withLock false val report = paykitSdkService.syncPublicEndpoints(desiredEndpoints) report.failed.forEach { failure -> Logger.warn( @@ -326,8 +337,8 @@ class PublicPaykitRepo @Inject constructor( ) } if (report.failed.isNotEmpty()) throw PublicPaykitError.PublicationFailed + true } - } private suspend fun requireCurrentPublicKey(): String { val currentPublicKey = pubkyRepo.publicKey.value @@ -337,6 +348,20 @@ class PublicPaykitRepo @Inject constructor( return currentPublicKey } + private suspend fun acknowledgePublishedEndpoints( + endpoints: List, + canPublish: () -> Boolean = { true }, + ) { + val publishedInvoice = endpoints.firstOrNull { it.methodId == MethodId.Bolt11 }?.value.orEmpty() + settingsStore.update { + if (canPublish() && it.publicPaykitBolt11 == publishedInvoice) { + it.copy(publicPaykitCleanupPending = false) + } else { + it + } + } + } + private suspend fun buildWalletEndpoints( refresh: Boolean, forceRefreshLightning: Boolean = false, @@ -440,12 +465,13 @@ class PublicPaykitRepo @Inject constructor( ) } - private suspend fun clearPublicBolt11Metadata() { + private suspend fun clearPublicBolt11Metadata(publicationPending: Boolean = false) { settingsStore.update { it.copy( publicPaykitBolt11 = "", publicPaykitBolt11PaymentHash = "", publicPaykitBolt11ExpiresAtMillis = 0, + publicPaykitCleanupPending = it.publicPaykitCleanupPending || publicationPending, ) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index d896e8a931..1108b22de3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -860,10 +860,20 @@ class AppViewModel @Inject constructor( val onchainAddress = walletRepo.walletState.value.onchainAddress if (onchainAddress.isBlank() && !lightningRepo.canReceive()) return - publicPaykitRepo.syncCurrentPublishedEndpoints(forceRefreshLightning = forceRefreshLightning) + publicPaykitRepo.syncCurrentPublishedEndpoints( + forceRefreshLightning = forceRefreshLightning, + canPublish = publicPaykitPublicationAdmission(), + ) .onFailure { Logger.warn("Failed to refresh public Paykit endpoints", it, context = TAG) } } + private fun publicPaykitPublicationAdmission(): () -> Boolean { + val signIn = pubkyRepo.currentSignIn() + return { + signIn != null && pubkyRepo.isCurrent(signIn) && !privatePaykitRepo.isProfileDeletionInProgress.value + } + } + private fun observePrivatePaykitContacts() { viewModelScope.launch { combine( @@ -1984,13 +1994,15 @@ class AppViewModel @Inject constructor( if (privatePaykitRepo.isProfileDeletionInProgress.value) return@reconcilePendingEndpoints if (settings.publicPaykitCleanupPending) { val reconciliationResult = if (settings.sharesPublicPaykitEndpoints) { - publicPaykitRepo.syncCurrentPublishedEndpoints() + publicPaykitRepo.syncCurrentPublishedEndpoints(canPublish = publicPaykitPublicationAdmission()) } else { publicPaykitRepo.syncPublishedEndpoints(publish = false) } reconciliationResult .onSuccess { - settingsStore.update { it.copy(publicPaykitCleanupPending = false) } + if (!settings.sharesPublicPaykitEndpoints) { + settingsStore.update { it.copy(publicPaykitCleanupPending = false) } + } } .onFailure { Logger.warn("Failed to reconcile public Paykit state for '$reason'", it, context = TAG) @@ -2461,7 +2473,7 @@ class AppViewModel @Inject constructor( } publicPaykitRepo.refreshPublishedBolt11ForPayment( paymentHash, - publish = !privatePaykitRepo.isProfileDeletionInProgress.value, + canPublish = publicPaykitPublicationAdmission(), ).onFailure { Logger.warn( "Failed to refresh public Paykit invoice for '$paymentHash'", diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 36ec8f8abd..583601a719 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -2695,6 +2695,129 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() } + @Test + fun `profile deletion records received invoices without rotating private endpoints`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = true, + publicPaykitOnchainEnabled = false, + publicPaykitUsdtEnabled = false, + ) + whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(lightningRepo.createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS)) + .thenReturn(Result.success(PRIVATE_BOLT11)) + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() + sut.beginProfileDeletion() + clearInvocations(paykitSdkService) + + sut.handleReceivedPayment("090909").getOrThrow() + + assertEquals(CONTACT_KEY, sut.contactPublicKeyForPrivateInvoicePaymentHash("090909")) + assertEquals(listOf("090909"), cacheData.value.contacts.getValue(CONTACT_KEY).receivedInvoicePaymentHashes) + verifyNoInteractions(paykitSdkService) + sut.endProfileDeletion() + sut.refreshKnownSavedContactEndpoints("deletion ended").getOrThrow() + runCurrent() + verify(paykitSdkService).syncPrivatePaymentListsWithReservations(any(), eq(false)) + sut.closeAndClear() + } + + @Test + fun `profile deletion defers onchain rotation until the retained contacts resume`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() + whenever(addressReservationRepo.contactsWithUsedReservedAddresses()).thenReturn(listOf(CONTACT_KEY)) + whenever(addressReservationRepo.currentContactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + .thenReturn(CONTACT_KEY) + sut.beginProfileDeletion() + clearInvocations(paykitSdkService) + + sut.handleOnchainActivity().getOrThrow() + sut.handleOnchainActivity(listOf(PRIVATE_ADDRESS)).getOrThrow() + + verifyNoInteractions(paykitSdkService) + sut.endProfileDeletion() + sut.refreshKnownSavedContactEndpoints("deletion ended").getOrThrow() + runCurrent() + verify(paykitSdkService).syncPrivatePaymentListsWithReservations(any(), eq(false)) + sut.closeAndClear() + } + + @Test + fun `private publication rechecks deletion generation after final identity lookup`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() + whenever(addressReservationRepo.contactsWithUsedReservedAddresses()).thenReturn(listOf(CONTACT_KEY)) + var prepared = false + whenever(addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY)).thenAnswer { + prepared = true + Result.success(PRIVATE_ADDRESS) + } + val identityStarted = CompletableDeferred() + val resumeIdentity = CompletableDeferred() + whenever(pubkyService.currentPublicKey()).doSuspendableAnswer { + if (prepared) { + identityStarted.complete(Unit) + resumeIdentity.await() + } + OWN_KEY + } + clearInvocations(paykitSdkService) + val rotation = async { sut.handleOnchainActivity() } + runCurrent() + assertTrue(identityStarted.isCompleted) + sut.beginProfileDeletion() + sut.endProfileDeletion() + resumeIdentity.complete(Unit) + rotation.await().getOrThrow() + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) + sut.closeAndClear() + } + + @Test + fun `invalidated publication eligibility cannot fall back to new link preparation`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) + val settingsStarted = CompletableDeferred() + val resumeSettings = CompletableDeferred() + whenever(settingsStore.data).thenReturn( + flow { + if (!settingsStarted.isCompleted) { + settingsStarted.complete(Unit) + resumeSettings.await() + } + emit(settingsData.value) + } + ) + sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + assertTrue(settingsStarted.isCompleted) + sut.beginProfileDeletion() + sut.removePublishedEndpointsForCleanup("profile deletion").getOrThrow() + assertFalse(cacheData.value.cleanupPending) + clearInvocations(paykitSdkService) + resumeSettings.complete(Unit) + runCurrent() + sut.awaitContactPreparation() + + verifyNoInteractions(paykitSdkService) + sut.endProfileDeletion() + sut.closeAndClear() + } + @Test fun `cleanup stops a stalled preparation before later contacts are visited`() = test { settingsData.value = SettingsData( diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index ed2e4d0871..a02131afe0 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -7,6 +7,7 @@ import com.synonym.paykit.EndpointSyncChange import com.synonym.paykit.EndpointSyncReport import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow @@ -16,6 +17,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock @@ -157,9 +159,9 @@ class PublicPaykitRepoTest : BaseUnitTest() { ) stubPublicInvoice("lnbc1replacement", byteArrayOf(4, 5, 6)) - sut.refreshPublishedBolt11ForPayment("unrelated", publish = false).getOrThrow() + sut.refreshPublishedBolt11ForPayment("unrelated", canPublish = { false }).getOrThrow() assertEquals("lnbc1paid", settingsFlow.value.publicPaykitBolt11) - sut.refreshPublishedBolt11ForPayment("010203", publish = false).getOrThrow() + sut.refreshPublishedBolt11ForPayment("010203", canPublish = { false }).getOrThrow() assertEquals("", settingsFlow.value.publicPaykitBolt11) assertEquals("", settingsFlow.value.publicPaykitBolt11PaymentHash) verifyNoInteractions(paykitSdkService) @@ -171,6 +173,134 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertEquals("040506", settingsFlow.value.publicPaykitBolt11PaymentHash) } + @Test + fun `paid public invoice replacement checks deletion after invoice preparation`() = test { + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitOnchainEnabled = false, + publicPaykitUsdtEnabled = false, + publicPaykitBolt11 = "lnbc1paid", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + ) + stubPublicInvoice("lnbc1replacement", byteArrayOf(4, 5, 6)) + val invoiceStarted = CompletableDeferred() + val resumeInvoice = CompletableDeferred() + whenever(lightningRepo.createInvoice(null, "", PUBLIC_BOLT11_EXPIRY_SECONDS)).doSuspendableAnswer { + invoiceStarted.complete(Unit) + resumeInvoice.await() + Result.success("lnbc1replacement") + } + var deleting = false + val replacement = async { + sut.refreshPublishedBolt11ForPayment("010203", canPublish = { !deleting }).getOrThrow() + } + invoiceStarted.await() + assertEquals("", settingsFlow.value.publicPaykitBolt11PaymentHash) + deleting = true + resumeInvoice.complete(Unit) + replacement.await() + + verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any()) } + assertTrue(settingsFlow.value.publicPaykitCleanupPending) + deleting = false + sut.syncCurrentPublishedEndpoints().getOrThrow() + verifyBlocking(paykitSdkService) { syncPublicEndpoints(any()) } + assertFalse(settingsFlow.value.publicPaykitCleanupPending) + } + + @Test + fun `public publication rechecks admission after identity lookup without cancelling admitted writes`() = test { + settingsFlow.value = SettingsData(publicPaykitLightningEnabled = false, publicPaykitUsdtEnabled = false) + walletState.value = WalletState(onchainAddress = "bc1ptest") + publicKey.value = null + val identityStarted = CompletableDeferred() + val resumeIdentity = CompletableDeferred() + whenever(pubkyRepo.currentPublicKey()).doSuspendableAnswer { + identityStarted.complete(Unit) + resumeIdentity.await() + Result.success("pubkyself") + } + var deleting = false + val preparation = async { sut.syncCurrentPublishedEndpoints(canPublish = { !deleting }) } + identityStarted.await() + deleting = true + resumeIdentity.complete(Unit) + preparation.await().getOrThrow() + verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any()) } + + publicKey.value = "pubkyself" + deleting = false + val writeStarted = CompletableDeferred() + val resumeWrite = CompletableDeferred() + whenever(paykitSdkService.syncPublicEndpoints(any())).doSuspendableAnswer { + writeStarted.complete(Unit) + resumeWrite.await() + syncReport() + } + val admittedWrite = async { sut.syncCurrentPublishedEndpoints(canPublish = { !deleting }) } + writeStarted.await() + deleting = true + resumeWrite.complete(Unit) + admittedWrite.await().getOrThrow() + verifyBlocking(paykitSdkService) { syncPublicEndpoints(any()) } + } + + @Test + fun `paid public invoice replacement stays pending after failure until an admitted retry succeeds`() = test { + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + publicPaykitBolt11PaymentHash = "paid", + ) + walletState.value = WalletState(onchainAddress = "bc1ptest") + var offline = true + whenever(paykitSdkService.syncPublicEndpoints(any())).thenAnswer { + if (offline) throw AppError("offline") + syncReport() + } + assertTrue(sut.refreshPublishedBolt11ForPayment("paid").isFailure) + assertEquals("", settingsFlow.value.publicPaykitBolt11PaymentHash) + assertTrue(settingsFlow.value.publicPaykitCleanupPending) + + sut.syncCurrentPublishedEndpoints(canPublish = { false }).getOrThrow() + assertTrue(settingsFlow.value.publicPaykitCleanupPending) + verifyBlocking(paykitSdkService) { syncPublicEndpoints(any()) } + offline = false + sut.syncCurrentPublishedEndpoints().getOrThrow() + assertFalse(settingsFlow.value.publicPaykitCleanupPending) + verifyBlocking(paykitSdkService, times(2)) { syncPublicEndpoints(any()) } + } + + @Test + fun `admitted public publication cannot acknowledge a later paid invoice invalidation`() = test { + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitOnchainEnabled = false, + publicPaykitUsdtEnabled = false, + publicPaykitBolt11 = "lnbc1paid", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + ) + stubPublicInvoice("lnbc1paid", byteArrayOf(1, 2, 3)) + val writeStarted = CompletableDeferred() + val resumeWrite = CompletableDeferred() + whenever(paykitSdkService.syncPublicEndpoints(any())).doSuspendableAnswer { + writeStarted.complete(Unit) + resumeWrite.await() + syncReport() + } + val publication = async { sut.syncCurrentPublishedEndpoints() } + writeStarted.await() + sut.refreshPublishedBolt11ForPayment("010203", canPublish = { false }).getOrThrow() + resumeWrite.complete(Unit) + publication.await().getOrThrow() + + assertEquals("", settingsFlow.value.publicPaykitBolt11PaymentHash) + assertTrue(settingsFlow.value.publicPaykitCleanupPending) + } + @Test fun `syncCurrentPublishedEndpoints waits only for session restoration and remains cancellable`() = test { isRestoringSession.value = true diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ad2eaad8ad..85df612752 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -11852,7 +11852,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() advanceTimeBy(1_000) runCurrent() - verify(publicPaykitRepo, times(1)).syncCurrentPublishedEndpoints(forceRefreshLightning = false) + verify(publicPaykitRepo, times(1)).syncCurrentPublishedEndpoints(eq(false), eq(false), any()) sut.stopPaykitPaymentRequestPolling() runCurrent() @@ -11860,13 +11860,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { settingsData.update { it.copy(publicPaykitBolt11ExpiresAtMillis = 1L) } advanceTimeBy(1_000) runCurrent() - verify(publicPaykitRepo, times(1)).syncCurrentPublishedEndpoints(forceRefreshLightning = false) + verify(publicPaykitRepo, times(1)).syncCurrentPublishedEndpoints(eq(false), eq(false), any()) sut.startPaykitPaymentRequestPolling() runCurrent() advanceTimeBy(1_000) runCurrent() - verify(publicPaykitRepo, times(2)).syncCurrentPublishedEndpoints(forceRefreshLightning = false) + verify(publicPaykitRepo, times(2)).syncCurrentPublishedEndpoints(eq(false), eq(false), any()) sut.stopPaykitPaymentRequestPolling() } @@ -11883,12 +11883,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { settingsData.update { it.copy(publicPaykitBolt11ExpiresAtMillis = 1L) } advanceTimeBy(1_000) runCurrent() - verify(publicPaykitRepo, never()).syncCurrentPublishedEndpoints(any(), any()) + verify(publicPaykitRepo, never()).syncCurrentPublishedEndpoints(any(), any(), any()) isProfileDeletionInProgress.update { false } advanceTimeBy(1_000) runCurrent() - verify(publicPaykitRepo).syncCurrentPublishedEndpoints(forceRefreshLightning = false) + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(eq(false), eq(false), any()) } @Test @@ -11907,8 +11907,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(publicPaykitRepo).syncCurrentPublishedEndpoints(forceRefreshLightning = false) - verify(publicPaykitRepo).syncCurrentPublishedEndpoints(forceRefreshLightning = true) + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(eq(false), eq(false), any()) + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(eq(true), eq(false), any()) } @Test @@ -11927,7 +11927,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(publicPaykitRepo).syncCurrentPublishedEndpoints(forceRefreshLightning = false) + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(eq(false), eq(false), any()) } @Test @@ -12420,8 +12420,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(activityRepo).notifyPaymentActivityChanged() verify(privatePaykitRepo).handleReceivedPayment("received-hash") verify(paykitPaymentRequestRepo, never()).refresh(any(), any()) - verify(publicPaykitRepo).refreshPublishedBolt11ForPayment("received-hash", publish = false) - verify(publicPaykitRepo, never()).refreshPublishedBolt11ForPayment(any(), eq(true)) + val publicationAdmission = argumentCaptor<() -> Boolean>() + verify(publicPaykitRepo).refreshPublishedBolt11ForPayment(eq("received-hash"), publicationAdmission.capture()) + assertFalse(publicationAdmission.firstValue()) + isProfileDeletionInProgress.value = false + assertTrue(publicationAdmission.firstValue()) + pubkyPublicKey.value = null + assertFalse(publicationAdmission.firstValue()) } @Test @@ -12563,19 +12568,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `private Paykit refresh reconciles pending public state when sharing is enabled`() = test { + fun `private Paykit refresh leaves pending public state for the publisher to acknowledge`() = test { isPaykitEnabled.value = true settingsData.value = SettingsData( sharesPublicPaykitEndpoints = true, publicPaykitCleanupPending = true, ) - whenever(publicPaykitRepo.syncCurrentPublishedEndpoints()).thenReturn(Result.success(Unit)) + whenever(publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any(), any())).thenReturn(Result.success(Unit)) sut.refreshPrivatePaykitEndpoints() advanceUntilIdle() - verify(publicPaykitRepo).syncCurrentPublishedEndpoints() - assertFalse(settingsData.value.publicPaykitCleanupPending) + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(eq(false), eq(false), any()) + assertTrue(settingsData.value.publicPaykitCleanupPending) verify(privatePaykitRepo).retryPendingEndpointRemoval(emptyList()) } @@ -12783,7 +12788,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) private suspend fun enablePublicPaykitSharing() { - whenever(publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any())).thenReturn(Result.success(Unit)) + whenever(publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any(), any())).thenReturn(Result.success(Unit)) walletState.value = WalletState(onchainAddress = "bc1qtest") isPaykitEnabled.value = true settingsData.value = SettingsData(sharesPublicPaykitEndpoints = true) diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 2caa0ee89e..06a8e0d4de 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -4,6 +4,12 @@ During profile deletion, automatic contact refresh, request intake and endpoint publication must not start new work. Already admitted operations and explicit deletion cleanup still finish. After a failed deletion, normal automatic work must resume for the retained identity. Verifying SDK admission and a controlled cleanup failure requires operation gates beyond the journey runner capabilities; this remains a manual check rather than a timed deletion guarantee. +Contact-link retention after deletion is platform-specific. Android cancels a scan awaiting a signed-in contacts refresh when deletion signs out; it does not retry that scan as the iOS retained-link flow does. This change does not alter that cancellation behavior. + +If public invoice replacement is deferred during deletion, make deletion fail and the first resumed publication fail transiently. Verify that public publication resumes without waiting for contact refresh to complete, remains pending after the publication error, and is retried by normal foreground maintenance without another foreground event or payment. After sign-out or identity replacement, an older refresh must not publish for the new identity. Controlled payment events and publication failures require gates beyond the journey runner capabilities. + +Hold invoice preparation or the final identity lookup before publication, begin deletion, then release it. New public/private endpoint rotation must stay deferred while matching paid-invoice invalidation and receipt attribution still complete. Already-admitted writes may finish, but an older publication must not acknowledge a newer paid-invoice invalidation. These checks require controlled payment events and SDK-admission gates beyond the journey runner capabilities. + Delayed or failed session restoration requires network fault injection, which is not a journey-runner capability; see the PR manual checks. For a saved identity, inject a temporary import failure (`concurrent_update`, `shared_state_busy`, or a transport failure) during cold start. Verify that no "Session expired" toast appears, the saved credentials remain, and recovery succeeds after the temporary failure ends. A genuinely invalid session must still produce the existing expiry feedback. diff --git a/journeys/profile/delete-profile.xml b/journeys/profile/delete-profile.xml index 30283f5fe8..75f967ac6c 100644 --- a/journeys/profile/delete-profile.xml +++ b/journeys/profile/delete-profile.xml @@ -7,7 +7,8 @@ Measure confirmation to onboarding and check logs for any uncertain-write safety wait. During deletion, check the indicator on Delete Profile. Save stays disabled with its label visible. Cleanup may still wait for admitted operations and remote safety checks; no fixed duration is expected. - See the suite README for controlled automatic-admission and deletion-failure checks. + See the suite README for controlled automatic-admission, contact-link cancellation, + deletion-failure ordering and public-publication retry checks. Open the existing profile from the Home header From af867d033a10eec041afa146582fe7e201f86cc0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 03:26:05 -0500 Subject: [PATCH 04/10] fix: recheck publication admission inside the SDK lock --- .../bitkit/repositories/PublicPaykitRepo.kt | 2 +- .../to/bitkit/services/PaykitSdkService.kt | 9 ++- .../repositories/PublicPaykitRepoTest.kt | 65 ++++++++++------ .../bitkit/services/PaykitSdkServiceTest.kt | 77 +++++++++++++++++++ journeys/profile/README.md | 2 + 5 files changed, 131 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 98a851924d..d65e71dd99 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -329,7 +329,7 @@ class PublicPaykitRepo @Inject constructor( if (!canPublish()) return@withLock false requireCurrentPublicKey() if (!canPublish()) return@withLock false - val report = paykitSdkService.syncPublicEndpoints(desiredEndpoints) + val report = paykitSdkService.syncPublicEndpoints(desiredEndpoints, canPublish) ?: return@withLock false report.failed.forEach { failure -> Logger.warn( "Failed to sync public Paykit endpoint '${failure.identifier}': ${failure.error}", diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 9d1e6ac01c..cd71c7b487 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -817,7 +817,13 @@ class PaykitSdkService @Inject constructor( } } - suspend fun syncPublicEndpoints(endpoints: List): EndpointSyncReport { + suspend fun syncPublicEndpoints(endpoints: List): EndpointSyncReport = + checkNotNull(syncPublicEndpoints(endpoints) { true }) + + internal suspend fun syncPublicEndpoints( + endpoints: List, + canPublish: () -> Boolean, + ): EndpointSyncReport? { isSetup.await() val priority = if (endpoints.isEmpty()) { PaykitSdkOperationLock.Priority.Ordered @@ -826,6 +832,7 @@ class PaykitSdkService @Inject constructor( } return operationLock.withLock(priority) { withStateRevisionTracking { handle -> + if (!canPublish()) return@withStateRevisionTracking null completeSdkCall { handle.syncPublicEndpointsWithReceivingDetails(endpoints.map { it.toPublicReceivingDetail() }) } diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index a02131afe0..4504c49db2 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -84,7 +84,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { whenever(walletRepo.walletState).thenReturn(walletState) whenever(settingsStore.data).thenReturn(settingsFlow) whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(NOW_MILLIS)) - whenever(paykitSdkService.syncPublicEndpoints(any())).thenReturn(syncReport()) + whenever(paykitSdkService.syncPublicEndpoints(any(), any())).thenReturn(syncReport()) whenever { walletRepo.refreshReusableReceiveAddress() }.thenReturn(Result.success(Unit)) whenever { settingsStore.update(any()) }.thenAnswer { val transform = it.getArgument<(SettingsData) -> SettingsData>(0) @@ -110,7 +110,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { whenever(usdtRepo.paymentEndpoint()).thenReturn(Result.failure(AppError("USDT unavailable"))) sut.syncCurrentPublishedEndpoints().getOrThrow() val endpoints = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPublicEndpoints(endpoints.capture()) } + verifyBlocking(paykitSdkService) { syncPublicEndpoints(endpoints.capture(), any()) } assertEquals(listOf(MethodId.P2tr), endpoints.firstValue.map { it.methodId }) whenever(usdtRepo.paymentEndpoint()).thenReturn(Result.failure(CancellationException())) @@ -140,7 +140,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertEquals("010203", settingsFlow.value.publicPaykitBolt11PaymentHash) val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPublicEndpoints(captor.capture()) } + verifyBlocking(paykitSdkService) { syncPublicEndpoints(captor.capture(), any()) } assertEquals( listOf(MethodId.Bolt11, MethodId.P2tr), captor.firstValue.map { it.methodId }, @@ -168,7 +168,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { sut.syncCurrentPublishedEndpoints().getOrThrow() val endpoints = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPublicEndpoints(endpoints.capture()) } + verifyBlocking(paykitSdkService) { syncPublicEndpoints(endpoints.capture(), any()) } assertEquals("lnbc1replacement", endpoints.firstValue.single().value) assertEquals("040506", settingsFlow.value.publicPaykitBolt11PaymentHash) } @@ -201,11 +201,11 @@ class PublicPaykitRepoTest : BaseUnitTest() { resumeInvoice.complete(Unit) replacement.await() - verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any()) } + verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any(), any()) } assertTrue(settingsFlow.value.publicPaykitCleanupPending) deleting = false sut.syncCurrentPublishedEndpoints().getOrThrow() - verifyBlocking(paykitSdkService) { syncPublicEndpoints(any()) } + verifyBlocking(paykitSdkService) { syncPublicEndpoints(any(), any()) } assertFalse(settingsFlow.value.publicPaykitCleanupPending) } @@ -227,13 +227,13 @@ class PublicPaykitRepoTest : BaseUnitTest() { deleting = true resumeIdentity.complete(Unit) preparation.await().getOrThrow() - verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any()) } + verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any(), any()) } publicKey.value = "pubkyself" deleting = false val writeStarted = CompletableDeferred() val resumeWrite = CompletableDeferred() - whenever(paykitSdkService.syncPublicEndpoints(any())).doSuspendableAnswer { + whenever(paykitSdkService.syncPublicEndpoints(any(), any())).doSuspendableAnswer { writeStarted.complete(Unit) resumeWrite.await() syncReport() @@ -243,7 +243,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { deleting = true resumeWrite.complete(Unit) admittedWrite.await().getOrThrow() - verifyBlocking(paykitSdkService) { syncPublicEndpoints(any()) } + verifyBlocking(paykitSdkService) { syncPublicEndpoints(any(), any()) } } @Test @@ -256,7 +256,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { ) walletState.value = WalletState(onchainAddress = "bc1ptest") var offline = true - whenever(paykitSdkService.syncPublicEndpoints(any())).thenAnswer { + whenever(paykitSdkService.syncPublicEndpoints(any(), any())).thenAnswer { if (offline) throw AppError("offline") syncReport() } @@ -266,11 +266,32 @@ class PublicPaykitRepoTest : BaseUnitTest() { sut.syncCurrentPublishedEndpoints(canPublish = { false }).getOrThrow() assertTrue(settingsFlow.value.publicPaykitCleanupPending) - verifyBlocking(paykitSdkService) { syncPublicEndpoints(any()) } + verifyBlocking(paykitSdkService) { syncPublicEndpoints(any(), any()) } offline = false sut.syncCurrentPublishedEndpoints().getOrThrow() assertFalse(settingsFlow.value.publicPaykitCleanupPending) - verifyBlocking(paykitSdkService, times(2)) { syncPublicEndpoints(any()) } + verifyBlocking(paykitSdkService, times(2)) { syncPublicEndpoints(any(), any()) } + } + + @Test + fun `SDK admission skip keeps public invoice replacement pending`() = test { + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + publicPaykitCleanupPending = true, + ) + walletState.value = WalletState(onchainAddress = "bc1ptest") + whenever(paykitSdkService.syncPublicEndpoints(any(), any())).thenReturn(null) + var canPublish = true + sut.syncCurrentPublishedEndpoints(canPublish = { canPublish }).getOrThrow() + + assertTrue(settingsFlow.value.publicPaykitCleanupPending) + val admission = argumentCaptor<() -> Boolean>() + verifyBlocking(paykitSdkService) { syncPublicEndpoints(any(), admission.capture()) } + assertTrue(admission.firstValue()) + canPublish = false + assertFalse(admission.firstValue()) } @Test @@ -286,7 +307,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { stubPublicInvoice("lnbc1paid", byteArrayOf(1, 2, 3)) val writeStarted = CompletableDeferred() val resumeWrite = CompletableDeferred() - whenever(paykitSdkService.syncPublicEndpoints(any())).doSuspendableAnswer { + whenever(paykitSdkService.syncPublicEndpoints(any(), any())).doSuspendableAnswer { writeStarted.complete(Unit) resumeWrite.await() syncReport() @@ -322,7 +343,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { publication.await().getOrThrow() verifyBlocking(paykitSdkService) { syncPaykitApp(privatePaymentsEnabled = false, priority = Priority.Ordered) } - verifyBlocking(paykitSdkService) { syncPublicEndpoints(any()) } + verifyBlocking(paykitSdkService) { syncPublicEndpoints(any(), any()) } verifyBlocking(pubkyRepo, never()) { currentPublicKey() } verifyBlocking(pubkyRepo, never()) { awaitInitialization() } verifyBlocking(pubkyRepo, never()) { initialize() } @@ -346,7 +367,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) val captor = argumentCaptor>() verifyBlocking(walletRepo) { refreshReusableReceiveAddress() } - verifyBlocking(paykitSdkService) { syncPublicEndpoints(captor.capture()) } + verifyBlocking(paykitSdkService) { syncPublicEndpoints(captor.capture(), any()) } assertEquals(listOf(MethodId.P2wpkh), captor.firstValue.map { it.methodId }) assertEquals("bc1qfresh", captor.firstValue.single().value) } @@ -366,7 +387,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { val error = sut.syncPublishedEndpoints(publish = true).exceptionOrNull() assertEquals(appError, error) - verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any()) } + verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any(), any()) } } @Test @@ -391,7 +412,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertEquals("", settingsFlow.value.publicPaykitBolt11) assertEquals(false, settingsFlow.value.publicPaykitCleanupPending) inOrder(paykitSdkService) { - verify(paykitSdkService).syncPublicEndpoints(emptyList()) + verify(paykitSdkService).syncPublicEndpoints(eq(emptyList()), any()) verify(paykitSdkService).syncPaykitApp(privatePaymentsEnabled = false, priority = priority) } } @@ -408,14 +429,14 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertEquals(appError, error) assertTrue(settingsFlow.value.publicPaykitCleanupPending) - verifyBlocking(paykitSdkService) { syncPublicEndpoints(emptyList()) } + verifyBlocking(paykitSdkService) { syncPublicEndpoints(eq(emptyList()), any()) } } @Test fun `syncPublishedEndpoints remove preserves both cleanup failures`() = test { val endpointError = RuntimeException("endpoint failed") val appError = RuntimeException("app registration failed") - whenever { paykitSdkService.syncPublicEndpoints(emptyList()) }.thenThrow(endpointError) + whenever { paykitSdkService.syncPublicEndpoints(eq(emptyList()), any()) }.thenThrow(endpointError) whenever { paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false, priority = Priority.Interactive) } .thenThrow(appError) @@ -427,7 +448,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertEquals(endpointError, error) assertEquals(listOf(appError), error?.suppressedExceptions) inOrder(paykitSdkService) { - verify(paykitSdkService).syncPublicEndpoints(emptyList()) + verify(paykitSdkService).syncPublicEndpoints(eq(emptyList()), any()) verify(paykitSdkService).syncPaykitApp(privatePaymentsEnabled = false, priority = Priority.Interactive) } } @@ -435,7 +456,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `syncCurrentPublishedEndpoints returns publication failed when SDK sync fails`() = test { walletState.value = WalletState(onchainAddress = "bc1ptest") - whenever(paykitSdkService.syncPublicEndpoints(any())).thenReturn( + whenever(paykitSdkService.syncPublicEndpoints(any(), any())).thenReturn( syncReport( failed = listOf( EndpointSyncChange( @@ -460,7 +481,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { val error = sut.syncCurrentPublishedEndpoints(requireEndpoint = true).exceptionOrNull() assertEquals(PublicPaykitError.NoSupportedEndpoint, error) - verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any()) } + verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any(), any()) } } @Test diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 355418a4ce..93fd490f79 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate +import com.synonym.paykit.EndpointSyncReport import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerHandshakeReport import com.synonym.paykit.LinkedPeerRecord @@ -16,6 +17,7 @@ import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitIdentitySecretKey import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitStateObservation +import com.synonym.paykit.PaymentPayload import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord @@ -54,6 +56,7 @@ import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test +import org.mockito.Mockito.mockConstruction import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.description @@ -82,6 +85,8 @@ import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyAuthClaim.Item import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyProfileData +import to.bitkit.repositories.Endpoint +import to.bitkit.repositories.MethodId import to.bitkit.repositories.PubkyContactError import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.test.forEachCase @@ -217,6 +222,78 @@ class PaykitSdkServiceTest { verify(sdk).importContacts(any(), any()) } + @Test + fun `queued public publication cannot restore endpoints after cleanup ends admission`() = runTest { + mockConstruction(PaymentPayload::class.java).use { + val sdk = mock() + val release = CompletableDeferred() + whenever(sdk.contactRecords()).doSuspendableAnswer { + release.await() + emptyList() + } + val report = EndpointSyncReport(emptyList(), emptyList(), emptyList()) + whenever(sdk.syncPublicEndpointsWithReceivingDetails(any())).thenReturn(report) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val active = async { service.contactRecords() } + runCurrent() + val removal = async { service.syncPublicEndpoints(emptyList()) } + var canPublish = true + val publication = async { + service.syncPublicEndpoints( + listOf(Endpoint(MethodId.P2tr, "bc1ptest", rawPayload = "bc1ptest")), + ) { canPublish } + } + runCurrent() + canPublish = false + release.complete(Unit) + active.await() + + assertEquals(report, removal.await()) + assertNull(publication.await()) + verify(sdk).syncPublicEndpointsWithReceivingDetails(emptyList()) + verify(sdk, times(1)).syncPublicEndpointsWithReceivingDetails(any()) + } + } + + @Test + fun `admitted public publication finishes before cleanup despite cancellation`() = runTest { + mockConstruction(PaymentPayload::class.java).use { + val sdk = mock() + val release = CompletableDeferred() + val events = mutableListOf() + val report = EndpointSyncReport(emptyList(), emptyList(), emptyList()) + whenever(sdk.syncPublicEndpointsWithReceivingDetails(any())).doSuspendableAnswer { + if (it.getArgument>(0).isNotEmpty()) { + events.add("publication started") + release.await() + events.add("publication finished") + } else { + events.add("removed") + } + report + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + var canPublish = true + val publication = async { + service.syncPublicEndpoints( + listOf(Endpoint(MethodId.P2tr, "bc1ptest", rawPayload = "bc1ptest")), + ) { canPublish } + } + runCurrent() + assertEquals(listOf("publication started"), events) + canPublish = false + publication.cancel() + val removal = async { service.syncPublicEndpoints(emptyList()) } + runCurrent() + assertFalse(removal.isCompleted) + release.complete(Unit) + + assertFailsWith { publication.await() } + assertEquals(report, removal.await()) + assertEquals(listOf("publication started", "publication finished", "removed"), events) + } + } + @Test fun `queued state observation is rejected after runtime replacement`() = runTest { val sdk = mock() diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 06a8e0d4de..164145bfa3 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -12,6 +12,8 @@ Hold invoice preparation or the final identity lookup before publication, begin Delayed or failed session restoration requires network fault injection, which is not a journey-runner capability; see the PR manual checks. +Hold endpoint preparation before publication admission, or queue a public publication behind SDK work. Begin deletion, complete endpoint removal while session revocation remains held, then release the queued preparation/publication. The removed endpoints must stay absent. Separately, let a publication enter its SDK write before deletion: cleanup must wait for it and remove its endpoints afterward, even if its caller is cancelled. This requires controlled operation gates beyond the journey runner capabilities. + For a saved identity, inject a temporary import failure (`concurrent_update`, `shared_state_busy`, or a transport failure) during cold start. Verify that no "Session expired" toast appears, the saved credentials remain, and recovery succeeds after the temporary failure ends. A genuinely invalid session must still produce the existing expiry feedback. For the reported background-resume case, verify returning directly to Profile with the process still alive, then repeat after the OS recreates the process. A delay in session recovery must keep the saved identity on the profile loading/retry screen rather than show profile onboarding. From e8ade7db8b7e96396051e388a21e0371d3392b2b Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 03:34:12 -0500 Subject: [PATCH 05/10] fix: admit public refresh after initial session restoration --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 8 ++-- .../viewmodels/AppViewModelSendFlowTest.kt | 48 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1108b22de3..80fa84a7cd 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -850,6 +850,7 @@ class AppViewModel @Inject constructor( } private suspend fun refreshPublicPaykitEndpointsIfEnabled(forceRefreshLightning: Boolean = false) { + val signIn = pubkyRepo.currentSignIn() val settings = settingsStore.data.first() if (!isPaykitEnabled.value || !settings.sharesPublicPaykitEndpoints || privatePaykitRepo.isProfileDeletionInProgress.value @@ -857,18 +858,19 @@ class AppViewModel @Inject constructor( return } + pubkyRepo.isRestoringSession.first { !it } + val admission = publicPaykitPublicationAdmission(signIn ?: pubkyRepo.currentSignIn()) val onchainAddress = walletRepo.walletState.value.onchainAddress if (onchainAddress.isBlank() && !lightningRepo.canReceive()) return publicPaykitRepo.syncCurrentPublishedEndpoints( forceRefreshLightning = forceRefreshLightning, - canPublish = publicPaykitPublicationAdmission(), + canPublish = admission, ) .onFailure { Logger.warn("Failed to refresh public Paykit endpoints", it, context = TAG) } } - private fun publicPaykitPublicationAdmission(): () -> Boolean { - val signIn = pubkyRepo.currentSignIn() + private fun publicPaykitPublicationAdmission(signIn: PubkySignIn? = pubkyRepo.currentSignIn()): () -> Boolean { return { signIn != null && pubkyRepo.isCurrent(signIn) && !privatePaykitRepo.isProfileDeletionInProgress.value } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 85df612752..6cd2d50227 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -303,6 +303,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val walletState = MutableStateFlow(WalletState()) private val nodeEventUpdates = MutableSharedFlow() private val pubkyPublicKey = MutableStateFlow(null) + private val isRestoringPubkySession = MutableStateFlow(false) private val pubkyContacts = MutableStateFlow>(emptyList()) private val isProfileDeletionInProgress = MutableStateFlow(false) private val pubkyContactsLoadVersion = MutableStateFlow(0L) @@ -627,6 +628,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(pubkyRepo.adoptedSourceLost).thenReturn(MutableStateFlow(false)) whenever(pubkyRepo.contactImportFailure).thenReturn(pubkyContactImportFailure) whenever(pubkyRepo.publicKey).thenReturn(pubkyPublicKey) + whenever(pubkyRepo.isRestoringSession).thenReturn(isRestoringPubkySession) whenever(pubkyRepo.currentSignIn()).thenAnswer { pubkyPublicKey.value?.let { PubkySignIn(it, 0L) } } whenever(pubkyRepo.isCurrent(any())).thenAnswer { pubkyPublicKey.value == it.getArgument(0).publicKey @@ -11870,6 +11872,52 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.stopPaykitPaymentRequestPolling() } + @Test + fun `public refresh publishes the initially restored identity without another wallet event`() = test { + enablePublicPaykitSharing() + isRestoringPubkySession.value = true + val admitted = mutableListOf() + whenever(publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any(), any())).doSuspendableAnswer { + isRestoringPubkySession.first { restoring -> !restoring } + admitted.add(it.getArgument<() -> Boolean>(2)()) + Result.success(Unit) + } + runCurrent() + advanceTimeBy(1_000) + runCurrent() + assertTrue(admitted.isEmpty()) + + pubkyPublicKey.value = testPublicKey + isRestoringPubkySession.value = false + runCurrent() + assertEquals(listOf(true), admitted) + } + + @Test + fun `public refresh rejects an existing sign in replaced during restoration`() = test { + enablePublicPaykitSharing() + pubkyPublicKey.value = testPublicKey + isRestoringPubkySession.value = true + var signIn = PubkySignIn(testPublicKey, 0L) + whenever(pubkyRepo.currentSignIn()).thenAnswer { signIn } + whenever(pubkyRepo.isCurrent(any())).thenAnswer { it.getArgument(0) == signIn } + val admitted = mutableListOf() + whenever(publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any(), any())).doSuspendableAnswer { + isRestoringPubkySession.first { restoring -> !restoring } + admitted.add(it.getArgument<() -> Boolean>(2)()) + Result.success(Unit) + } + runCurrent() + advanceTimeBy(1_000) + runCurrent() + assertTrue(admitted.isEmpty()) + + signIn = PubkySignIn(testPublicKey, 1L) + isRestoringPubkySession.value = false + runCurrent() + assertEquals(listOf(false), admitted) + } + @Test fun `profile deletion defers public publication and resumes the latest address on failure`() = test { enablePublicPaykitSharing() From f22714b309623ad09f4e0d9a7c7d45e9654ac549 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 04:30:18 -0500 Subject: [PATCH 06/10] fix: retain public sharing admission across waits --- .../bitkit/repositories/PublicPaykitRepo.kt | 17 ++- .../to/bitkit/services/PaykitSdkService.kt | 2 +- .../repositories/PublicPaykitRepoTest.kt | 34 +++-- .../PublicPaykitSharingAdmissionTest.kt | 130 ++++++++++++++++++ journeys/profile/README.md | 2 + 5 files changed, 168 insertions(+), 17 deletions(-) create mode 100644 app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index d65e71dd99..864e78dafb 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -258,15 +258,15 @@ class PublicPaykitRepo @Inject constructor( ): Result = withContext(ioDispatcher) { runSuspendCatching { pubkyRepo.isRestoringSession.first { !it } - if (!canPublish()) return@runSuspendCatching + if (!isAutomaticPublicationAllowed(canPublish)) return@runSuspendCatching val desired = buildWalletEndpoints( refresh = false, forceRefreshLightning = forceRefreshLightning, requireEndpoint = requireEndpoint, ) - if (!canPublish()) return@runSuspendCatching + if (!isAutomaticPublicationAllowed(canPublish)) return@runSuspendCatching syncPaykitApp().getOrThrow() - if (applyPublishedEndpoints(desired, canPublish)) { + if (applyPublishedEndpoints(desired) { isAutomaticPublicationAllowed(canPublish) }) { acknowledgePublishedEndpoints(desired, canPublish) } } @@ -282,9 +282,9 @@ class PublicPaykitRepo @Inject constructor( if (settings.publicPaykitBolt11PaymentHash != paymentHash) return@runSuspendCatching clearPublicBolt11Metadata(publicationPending = true) - if (!canPublish()) return@runSuspendCatching + if (!isAutomaticPublicationAllowed(canPublish)) return@runSuspendCatching val desired = buildWalletEndpoints(refresh = true) - if (applyPublishedEndpoints(desired, canPublish)) { + if (applyPublishedEndpoints(desired) { isAutomaticPublicationAllowed(canPublish) }) { acknowledgePublishedEndpoints(desired, canPublish) } } @@ -321,9 +321,12 @@ class PublicPaykitRepo @Inject constructor( clearPublicBolt11Metadata() } + private suspend fun isAutomaticPublicationAllowed(canPublish: () -> Boolean): Boolean = + settingsStore.data.first().sharesPublicPaykitEndpoints && canPublish() + private suspend fun applyPublishedEndpoints( desiredEndpoints: List, - canPublish: () -> Boolean = { true }, + canPublish: suspend () -> Boolean = { true }, ): Boolean = publishMutex.withLock { if (!canPublish()) return@withLock false @@ -354,7 +357,7 @@ class PublicPaykitRepo @Inject constructor( ) { val publishedInvoice = endpoints.firstOrNull { it.methodId == MethodId.Bolt11 }?.value.orEmpty() settingsStore.update { - if (canPublish() && it.publicPaykitBolt11 == publishedInvoice) { + if (it.sharesPublicPaykitEndpoints && canPublish() && it.publicPaykitBolt11 == publishedInvoice) { it.copy(publicPaykitCleanupPending = false) } else { it diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index cd71c7b487..b6beb1b02f 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -822,7 +822,7 @@ class PaykitSdkService @Inject constructor( internal suspend fun syncPublicEndpoints( endpoints: List, - canPublish: () -> Boolean, + canPublish: suspend () -> Boolean, ): EndpointSyncReport? { isSetup.await() val priority = if (endpoints.isEmpty()) { diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 4504c49db2..b64c58dd66 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -74,7 +74,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Before fun setUp() = test { sut = createRepo() - settingsFlow.value = SettingsData(publicPaykitUsdtEnabled = false) + settingsFlow.value = SettingsData(sharesPublicPaykitEndpoints = true, publicPaykitUsdtEnabled = false) publicKey.value = "pubkyself" isRestoringSession.value = false walletState.value = WalletState() @@ -102,6 +102,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { fun `optional USDT endpoint failure preserves bitcoin publication and propagates cancellation`() = test { walletState.value = WalletState(onchainAddress = "bc1ptest") settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, publicPaykitUsdtEnabled = true, @@ -211,7 +212,11 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `public publication rechecks admission after identity lookup without cancelling admitted writes`() = test { - settingsFlow.value = SettingsData(publicPaykitLightningEnabled = false, publicPaykitUsdtEnabled = false) + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) walletState.value = WalletState(onchainAddress = "bc1ptest") publicKey.value = null val identityStarted = CompletableDeferred() @@ -282,16 +287,19 @@ class PublicPaykitRepoTest : BaseUnitTest() { publicPaykitCleanupPending = true, ) walletState.value = WalletState(onchainAddress = "bc1ptest") - whenever(paykitSdkService.syncPublicEndpoints(any(), any())).thenReturn(null) + lateinit var admission: suspend () -> Boolean + whenever(paykitSdkService.syncPublicEndpoints(any(), any())).thenAnswer { + admission = it.getArgument(1) + null + } var canPublish = true sut.syncCurrentPublishedEndpoints(canPublish = { canPublish }).getOrThrow() assertTrue(settingsFlow.value.publicPaykitCleanupPending) - val admission = argumentCaptor<() -> Boolean>() - verifyBlocking(paykitSdkService) { syncPublicEndpoints(any(), admission.capture()) } - assertTrue(admission.firstValue()) + verifyBlocking(paykitSdkService) { syncPublicEndpoints(any(), any()) } + assertTrue(admission()) canPublish = false - assertFalse(admission.firstValue()) + assertFalse(admission()) } @Test @@ -327,7 +335,11 @@ class PublicPaykitRepoTest : BaseUnitTest() { isRestoringSession.value = true publicKey.value = null walletState.value = WalletState(onchainAddress = "bc1ptest") - settingsFlow.value = SettingsData(publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true) + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) val cancelledPublication = async { sut.syncCurrentPublishedEndpoints() } assertFalse(cancelledPublication.isCompleted) @@ -475,7 +487,11 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `syncCurrentPublishedEndpoints returns NoSupportedEndpoint when endpoint is required`() = test { - settingsFlow.value = SettingsData(publicPaykitUsdtEnabled = false, publicPaykitOnchainEnabled = false) + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitUsdtEnabled = false, + publicPaykitOnchainEnabled = false, + ) whenever(lightningRepo.canReceive()).thenReturn(false) val error = sut.syncCurrentPublishedEndpoints(requireEndpoint = true).exceptionOrNull() diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt new file mode 100644 index 0000000000..6445c207de --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt @@ -0,0 +1,130 @@ +package to.bitkit.repositories + +import com.synonym.paykit.EndpointSyncReport +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.services.PaykitSdkService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class) +class PublicPaykitSharingAdmissionTest : BaseUnitTest() { + private val pubkyRepo = mock() + private val walletRepo = mock() + private val paykitSdkService = mock() + private val settingsStore = mock() + private val isRestoringSession = MutableStateFlow(false) + private val settingsFlow = MutableStateFlow(SettingsData()) + private lateinit var sut: PublicPaykitRepo + + @Before + fun setUp() = test { + sut = PublicPaykitRepo( + ioDispatcher = testDispatcher, + pubkyRepo = pubkyRepo, + walletRepo = walletRepo, + lightningRepo = mock(), + coreService = mock(), + paykitSdkService = paykitSdkService, + settingsStore = settingsStore, + clock = Clock.System, + usdtRepo = mock { + on { state }.thenReturn(MutableStateFlow(UsdtWalletState())) + }, + ) + whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyself")) + whenever(pubkyRepo.isRestoringSession).thenReturn(isRestoringSession) + whenever(walletRepo.walletState).thenReturn(MutableStateFlow(WalletState(onchainAddress = "bc1ptest"))) + whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(settingsStore.update(any())).thenAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsFlow.value = transform(settingsFlow.value) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `public sharing disabled during restoration preserves pending cleanup without preparing endpoints`() = test { + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitCleanupPending = true, + publicPaykitBolt11 = "lnbc1previous", + ) + isRestoringSession.value = true + val publication = async { sut.syncCurrentPublishedEndpoints() } + runCurrent() + settingsFlow.value = settingsFlow.value.copy(sharesPublicPaykitEndpoints = false) + isRestoringSession.value = false + publication.await().getOrThrow() + + verifyNoInteractions(paykitSdkService) + verifyBlocking(walletRepo, never()) { refreshReusableReceiveAddress() } + assertEquals("lnbc1previous", settingsFlow.value.publicPaykitBolt11) + assertTrue(settingsFlow.value.publicPaykitCleanupPending) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + } + + @Test + fun `public sharing disable fences preparation and queued writes without discarding admitted cleanup`() = test { + for (stage in listOf("preparation", "queued", "admitted")) { + clearInvocations(paykitSdkService) + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + publicPaykitCleanupPending = true, + ) + val reached = CompletableDeferred() + val resume = CompletableDeferred() + var published = false + whenever(walletRepo.refreshReusableReceiveAddress()).doSuspendableAnswer { + if (stage == "preparation") { + reached.complete(Unit) + resume.await() + } + Result.success(Unit) + } + whenever(paykitSdkService.syncPublicEndpoints(any(), any())).doSuspendableAnswer { + if (stage == "queued") { + reached.complete(Unit) + resume.await() + } + if (!it.getArgument Boolean>(1)()) return@doSuspendableAnswer null + published = true + if (stage == "admitted") { + reached.complete(Unit) + resume.await() + } + EndpointSyncReport(published = emptyList(), removed = emptyList(), failed = emptyList()) + } + val publication = async { sut.syncCurrentPublishedEndpoints() } + reached.await() + settingsFlow.value = settingsFlow.value.copy(sharesPublicPaykitEndpoints = false) + resume.complete(Unit) + publication.await().getOrThrow() + + assertEquals(stage == "admitted", published, stage) + assertTrue(settingsFlow.value.publicPaykitCleanupPending, stage) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints, stage) + } + } +} diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 164145bfa3..f11ecaf897 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -12,6 +12,8 @@ Hold invoice preparation or the final identity lookup before publication, begin Delayed or failed session restoration requires network fault injection, which is not a journey-runner capability; see the PR manual checks. +Hold initial session restoration with public contact payments enabled, turn contact payments off, then release restoration. No automatic endpoint preparation or publication may resume, and pending cleanup must remain available. Repeat with publication waiting for SDK admission. If a write was already admitted, let it finish and verify it cannot clear the cleanup requested by disabling sharing. These checks require restoration and SDK-operation gates beyond the journey runner capabilities. + Hold endpoint preparation before publication admission, or queue a public publication behind SDK work. Begin deletion, complete endpoint removal while session revocation remains held, then release the queued preparation/publication. The removed endpoints must stay absent. Separately, let a publication enter its SDK write before deletion: cleanup must wait for it and remove its endpoints afterward, even if its caller is cancelled. This requires controlled operation gates beyond the journey runner capabilities. For a saved identity, inject a temporary import failure (`concurrent_update`, `shared_state_busy`, or a transport failure) during cold start. Verify that no "Session expired" toast appears, the saved credentials remain, and recovery succeeds after the temporary failure ends. A genuinely invalid session must still produce the existing expiry feedback. From 71d9ef7af828744e6b2230b401cc1b2547221940 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 07:52:04 -0500 Subject: [PATCH 07/10] fix: retain deferred lightning endpoint refreshes --- .../bitkit/repositories/PrivatePaykitRepo.kt | 67 ++++++-- .../bitkit/repositories/PublicPaykitRepo.kt | 25 ++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 38 ++++- .../repositories/PrivatePaykitRepoTest.kt | 146 ++++++++++++++++++ .../repositories/PublicPaykitRepoTest.kt | 35 +++++ .../PublicPaykitSharingAdmissionTest.kt | 101 +++++++++++- .../AddressTypePreferenceViewModelTest.kt | 3 +- .../viewmodels/AppViewModelSendFlowTest.kt | 118 +++++++++++--- journeys/profile/README.md | 2 + 9 files changed, 488 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index fe4ab94e20..d74d032acb 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -131,12 +131,18 @@ class PrivatePaykitRepo @Inject constructor( val isProfileDeletionInProgress = _isProfileDeletionInProgress.asStateFlow() private val isDeletingProfile: Boolean get() = _isProfileDeletionInProgress.value - private var pendingForceRefreshLightning = false + private var pendingLightningRefresh: LightningRefresh? = null private val unavailableLinkRetryAt = mutableMapOf() private var state: PrivatePaykitState? = null private val pendingMessageDrainRetries = mutableMapOf() private var pendingMessageDrainRead: PrivateMessageDrainRead? = null + private class LightningRefresh( + val owner: PrivatePaykitState, + val remainingKeys: MutableSet, + val isStillCurrent: (() -> Boolean)?, + ) + private class PrivateMessageDrainRead(val identity: String, val generation: Int) { val retries = mutableSetOf() lateinit var result: Deferred @@ -306,7 +312,6 @@ class PrivatePaykitRepo @Inject constructor( pendingPreparationKeys.addAll( publicKeys.filter { requeueActive || forceRefreshLightning || it !in activePreparationKeys } ) - pendingForceRefreshLightning = pendingForceRefreshLightning || forceRefreshLightning if (preparationJob?.isActive == true || pendingPreparationKeys.isEmpty()) return preparationJob = retryScope.launch { try { @@ -314,9 +319,7 @@ class PrivatePaykitRepo @Inject constructor( awaitContactPreparationActive(Priority.Background) if (pendingPreparationKeys.isEmpty()) break val keys = pendingPreparationKeys.intersect(knownSavedContactKeys) - val forceRefresh = pendingForceRefreshLightning pendingPreparationKeys.clear() - pendingForceRefreshLightning = false activePreparationKeys = keys val generation = preparationGeneration runSuspendCatching { @@ -327,7 +330,6 @@ class PrivatePaykitRepo @Inject constructor( publishLocalEndpoints( keys, "contact preparation", - forceRefresh, priority = Priority.Background, ).getOrThrow() } else { @@ -354,7 +356,6 @@ class PrivatePaykitRepo @Inject constructor( preparationGeneration += 1 pendingPreparationKeys.clear() activePreparationKeys = emptySet() - pendingForceRefreshLightning = false activeLinkPreparations.clear() clearPendingMessageDrainRetries() } @@ -393,8 +394,15 @@ class PrivatePaykitRepo @Inject constructor( reason: String, forceRefreshLightning: Boolean = false, requeueActive: Boolean = false, + isStillCurrent: (() -> Boolean)? = null, ): Result = withContext(serializedDispatcher) { runSuspendCatching { + if (isStillCurrent?.invoke() == false) return@runSuspendCatching + if (forceRefreshLightning) { + val owner = ensureState() + if (isStillCurrent?.invoke() == false) return@runSuspendCatching + pendingLightningRefresh = LightningRefresh(owner, knownSavedContactKeys.toMutableSet(), isStillCurrent) + } scheduleContactPreparation(knownSavedContactKeys.toList(), forceRefreshLightning, requeueActive) }.onFailure { Logger.warn("Failed to refresh private Paykit endpoints for '$reason'", it, context = TAG) @@ -504,6 +512,7 @@ class PrivatePaykitRepo @Inject constructor( suspend fun closeAndClear(): Result = withContext(serializedDispatcher) { runSuspendCatching { + pendingLightningRefresh = null invalidateContactPreparation() publicationMutex.withLock { clearPendingMessageDrainRetries() @@ -728,6 +737,7 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { val decoded = backup?.let { json.decodeFromString(it) } decoded?.let { paykitSdkService.retainRecoveryBackup(it.sdkState) } + pendingLightningRefresh = null invalidateContactPreparation() state = PrivatePaykitState() knownSavedContactKeys.clear() @@ -973,7 +983,6 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun publishLocalEndpoints( publicKeys: Collection, reason: String, - forceRefreshLightning: Boolean = false, requireImmediatePublication: Boolean = false, priority: Priority = Priority.Ordered, ): Result = withContext(serializedDispatcher) { @@ -1005,13 +1014,15 @@ class PrivatePaykitRepo @Inject constructor( return@withLock } + val lightningRefresh = currentLightningRefresh() val publication = preparePrivatePaymentListReservations( links.publicKeys, reason, - forceRefreshLightning, - generation, + forceRefreshLightningKeys = lightningRefresh?.remainingKeys.orEmpty(), + generation = generation, ) if (!isCurrentPublication(generation, identity, requireImmediatePublication)) return@withLock + if (lightningRefresh?.isStillCurrent?.invoke() == false) return@withLock if (publication.updates.isEmpty()) { schedulePendingPrivateMessageDrainRetries(reason, links.linkRetryKeys, identity = identity) if (requireImmediatePublication) publication.firstError?.let { throw it } @@ -1027,10 +1038,8 @@ class PrivatePaykitRepo @Inject constructor( val retryKeys = (links.linkRetryKeys + privatePaymentListDeliveryRetryKeys(report)).distinct() schedulePendingPrivateMessageDrainRetries(reason, retryKeys, identity = identity) - if (firstError != null) { - if (requireImmediatePublication) throw firstError - Logger.warn("Deferred private endpoint publication during '$reason'", firstError, context = TAG) - } + acknowledgeLightningRefresh(lightningRefresh, publication.updates, report, generation) + handlePrivatePublicationError(firstError, reason, requireImmediatePublication) } }.onFailure { schedulePreparedLinkRetries(links.linkRetryKeys, reason, generation, identity) @@ -1038,6 +1047,34 @@ class PrivatePaykitRepo @Inject constructor( } } + private fun handlePrivatePublicationError(error: Throwable?, reason: String, requireImmediatePublication: Boolean) { + if (error == null) return + if (requireImmediatePublication) throw error + Logger.warn("Deferred private endpoint publication during '$reason'", error, context = TAG) + } + + private fun currentLightningRefresh(): LightningRefresh? = pendingLightningRefresh?.takeIf { + it.owner === state && it.isStillCurrent?.invoke() != false + } + + private fun acknowledgeLightningRefresh( + refresh: LightningRefresh?, + updates: List, + report: PrivatePaymentListDeliveryReport, + generation: Int, + ) { + if (generation != preparationGeneration || refresh == null || pendingLightningRefresh !== refresh) return + if (refresh.owner !== state || refresh.isStillCurrent?.invoke() == false) return + val refreshedKeys = updates.filter { update -> + update.reservations.any { it.identifier == MethodId.Bolt11.rawValue } + }.map { it.counterparty }.toSet() + val queuedKeys = report.queued.mapNotNull { normalizedPublicKey(it.counterparty) }.toSet() + refresh.remainingKeys.removeAll(refreshedKeys.intersect(queuedKeys)) + if (refresh.remainingKeys.isEmpty()) { + pendingLightningRefresh = null + } + } + private suspend fun schedulePreparedLinkRetries( keys: Collection, reason: String, @@ -1072,7 +1109,7 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun preparePrivatePaymentListReservations( publicKeys: Collection, reason: String, - forceRefreshLightning: Boolean, + forceRefreshLightningKeys: Set, generation: Int, ): PrivatePublicationPreparation { var firstError: Throwable? = null @@ -1080,7 +1117,7 @@ class PrivatePaykitRepo @Inject constructor( for (publicKey in publicKeys) { if (generation != preparationGeneration) break if (publicKey in knownSavedContactKeys) { - runSuspendCatching { privatePaymentListUpdate(publicKey, forceRefreshLightning) } + runSuspendCatching { privatePaymentListUpdate(publicKey, publicKey in forceRefreshLightningKeys) } .onSuccess { updates += it } .onFailure { firstError = firstError ?: it diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 864e78dafb..a012d6ba57 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -28,6 +28,7 @@ import to.bitkit.utils.Logger import to.bitkit.utils.NetworkValidationHelper import to.bitkit.utils.encodeToUrl import java.util.Locale +import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock @@ -201,6 +202,16 @@ class PublicPaykitRepo @Inject constructor( } private val publishMutex = Mutex() + private class LightningRefresh(val signIn: PubkySignIn) + + private val pendingLightningRefresh = AtomicReference(null) + + internal fun requestLightningRefresh(signIn: PubkySignIn) { + if (pubkyRepo.isCurrent(signIn)) pendingLightningRefresh.set(LightningRefresh(signIn)) + } + + internal val hasPendingLightningRefresh: Boolean + get() = pendingLightningRefresh.get()?.let { pubkyRepo.isCurrent(it.signIn) } == true suspend fun beginPayment(publicKey: String): Result = withContext(ioDispatcher) { runSuspendCatching { @@ -259,15 +270,23 @@ class PublicPaykitRepo @Inject constructor( runSuspendCatching { pubkyRepo.isRestoringSession.first { !it } if (!isAutomaticPublicationAllowed(canPublish)) return@runSuspendCatching + val requestedRefresh = pendingLightningRefresh.get()?.takeIf { request -> + pubkyRepo.isCurrent(request.signIn).also { current -> + if (!current) pendingLightningRefresh.compareAndSet(request, null) + } + } val desired = buildWalletEndpoints( refresh = false, - forceRefreshLightning = forceRefreshLightning, + forceRefreshLightning = forceRefreshLightning || requestedRefresh != null, requireEndpoint = requireEndpoint, ) if (!isAutomaticPublicationAllowed(canPublish)) return@runSuspendCatching syncPaykitApp().getOrThrow() if (applyPublishedEndpoints(desired) { isAutomaticPublicationAllowed(canPublish) }) { acknowledgePublishedEndpoints(desired, canPublish) + if (requestedRefresh != null && pubkyRepo.isCurrent(requestedRefresh.signIn)) { + pendingLightningRefresh.compareAndSet(requestedRefresh, null) + } } } } @@ -357,8 +376,8 @@ class PublicPaykitRepo @Inject constructor( ) { val publishedInvoice = endpoints.firstOrNull { it.methodId == MethodId.Bolt11 }?.value.orEmpty() settingsStore.update { - if (it.sharesPublicPaykitEndpoints && canPublish() && it.publicPaykitBolt11 == publishedInvoice) { - it.copy(publicPaykitCleanupPending = false) + if (it.sharesPublicPaykitEndpoints && canPublish()) { + it.copy(publicPaykitCleanupPending = it.publicPaykitBolt11 != publishedInvoice) } else { it } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 80fa84a7cd..8fa1838822 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -852,11 +852,9 @@ class AppViewModel @Inject constructor( private suspend fun refreshPublicPaykitEndpointsIfEnabled(forceRefreshLightning: Boolean = false) { val signIn = pubkyRepo.currentSignIn() val settings = settingsStore.data.first() - if (!isPaykitEnabled.value || !settings.sharesPublicPaykitEndpoints || - privatePaykitRepo.isProfileDeletionInProgress.value - ) { - return - } + if (!isPaykitEnabled.value || !settings.sharesPublicPaykitEndpoints) return + if (forceRefreshLightning && signIn != null) publicPaykitRepo.requestLightningRefresh(signIn) + if (privatePaykitRepo.isProfileDeletionInProgress.value) return pubkyRepo.isRestoringSession.first { !it } val admission = publicPaykitPublicationAdmission(signIn ?: pubkyRepo.currentSignIn()) @@ -977,11 +975,32 @@ class AppViewModel @Inject constructor( forceRefreshLightning: Boolean = false, requeueActive: Boolean = false, ) { - if (privatePaykitRepo.isProfileDeletionInProgress.value) return + val signIn = if (forceRefreshLightning) pubkyRepo.currentSignIn() else null + val isStillCurrent = signIn?.let { { pubkyRepo.isCurrent(it) } } + if (privatePaykitRepo.isProfileDeletionInProgress.value) { + if (forceRefreshLightning && isPaykitEnabled.value && signIn != null) { + privatePaykitRepo.refreshKnownSavedContactEndpoints( + reason, + forceRefreshLightning = true, + isStillCurrent = isStillCurrent, + ) + } + return + } val contactKeys = pubkyRepo.contacts.value.map { it.publicKey } retryPendingPaykitEndpointRemoval(contactKeys, reason) - if (!isPaykitEnabled.value || privatePaykitRepo.isProfileDeletionInProgress.value) return + if (!isPaykitEnabled.value) return + if (privatePaykitRepo.isProfileDeletionInProgress.value) { + if (forceRefreshLightning && signIn != null) { + privatePaykitRepo.refreshKnownSavedContactEndpoints( + reason, + forceRefreshLightning = true, + isStillCurrent = isStillCurrent, + ) + } + return + } refreshPrivateOnlyPaykitApp(reason) privatePaykitRepo.reconcileReservedReceiveIndexes() @@ -992,6 +1011,7 @@ class AppViewModel @Inject constructor( reason, forceRefreshLightning = forceRefreshLightning, requeueActive = requeueActive, + isStillCurrent = isStillCurrent, ) if (isPaymentRequestPollingStopped.value) return refreshIncomingPaykitPaymentRequests(forceFresh = true, onlyWhileActive = true) @@ -1994,7 +2014,9 @@ class AppViewModel @Inject constructor( val settings = settingsStore.data.first() if (privatePaykitRepo.isProfileDeletionInProgress.value) return@reconcilePendingEndpoints - if (settings.publicPaykitCleanupPending) { + if (settings.publicPaykitCleanupPending || + settings.sharesPublicPaykitEndpoints && publicPaykitRepo.hasPendingLightningRefresh + ) { val reconciliationResult = if (settings.sharesPublicPaykitEndpoints) { publicPaykitRepo.syncCurrentPublishedEndpoints(canPublish = publicPaykitPublicationAdmission()) } else { diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 583601a719..42a202d9d7 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -2695,6 +2695,134 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() } + @Test + fun `deferred private Lightning refresh survives cleanup and failed publication`() = test { + preparePrivateLightningContact() + sut.beginProfileDeletion() + clearInvocations(paykitSdkService, pubkyService) + sut.refreshKnownSavedContactEndpoints("channel ready", forceRefreshLightning = true).getOrThrow() + verifyNoInteractions(paykitSdkService, pubkyService) + + whenever(paykitSdkService.clearPrivatePaymentLists(any(), anyOrNull())) + .thenAnswer { throw AppError("cleanup unavailable") } + assertTrue(sut.removePublishedEndpointsForCleanup("profile deletion").isFailure) + sut.endProfileDeletion() + settingsData.update { it.copy(sharesPrivatePaykitEndpoints = false) } + sut.refreshKnownSavedContactEndpoints("foreground").getOrThrow() + runCurrent() + verify(lightningRepo).createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS) + + settingsData.update { it.copy(sharesPrivatePaykitEndpoints = true) } + whenever(paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any())).thenReturn( + privateListDeliveryReport(failedToQueue = listOf(privateListSyncChange(CONTACT_KEY))), + ) + sut.enableSharingAndPrepareSavedContacts({ listOf(CONTACT_KEY) }).getOrThrow() + runCurrent() + verify(lightningRepo, times(2)).createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS) + + whenever(paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any())).thenReturn( + privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY)), + ) + sut.refreshKnownSavedContactEndpoints("foreground").getOrThrow() + runCurrent() + verify(lightningRepo, times(3)).createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS) + sut.refreshKnownSavedContactEndpoints("foreground").getOrThrow() + runCurrent() + verify(lightningRepo, times(3)).createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS) + val publications = argumentCaptor>() + verify(paykitSdkService, times(3)).syncPrivatePaymentListsWithReservations(publications.capture(), eq(false)) + assertEquals(CONTACT_KEY, publications.secondValue.single().counterparty) + assertEquals(MethodId.Bolt11.rawValue, publications.secondValue.single().reservations.single().identifier) + sut.closeAndClear() + } + + @Test + fun `deferred private Lightning refresh cannot consume a newer request during publication`() = test { + preparePrivateLightningContact() + val publicationStarted = CompletableDeferred() + val resumePublication = CompletableDeferred() + var holdPublication = true + whenever(paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any())).doSuspendableAnswer { + if (holdPublication) { + holdPublication = false + publicationStarted.complete(Unit) + resumePublication.await() + } + privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY)) + } + sut.refreshKnownSavedContactEndpoints("channel ready", forceRefreshLightning = true).getOrThrow() + runCurrent() + assertTrue(publicationStarted.isCompleted) + sut.refreshKnownSavedContactEndpoints("channel usable", forceRefreshLightning = true).getOrThrow() + resumePublication.complete(Unit) + runCurrent() + sut.awaitContactPreparation() + + verify(lightningRepo, times(3)).createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS) + sut.refreshKnownSavedContactEndpoints("foreground").getOrThrow() + runCurrent() + verify(lightningRepo, times(3)).createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS) + sut.closeAndClear() + } + + @Test + fun `deferred private Lightning refresh does not follow a replaced sign in`() = test { + preparePrivateLightningContact() + var isCurrent = true + sut.beginProfileDeletion() + sut.refreshKnownSavedContactEndpoints( + "channel ready", + forceRefreshLightning = true, + isStillCurrent = { isCurrent }, + ).getOrThrow() + isCurrent = false + sut.endProfileDeletion() + sut.refreshKnownSavedContactEndpoints("foreground").getOrThrow() + runCurrent() + + verify(lightningRepo).createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS) + sut.closeAndClear() + } + + @Test + fun `deferred private Lightning refresh only retries peers that failed to queue`() = test { + val keys = listOf(CONTACT_KEY, OTHER_CONTACT_KEY) + preparePrivateLightningContact(keys) + whenever(paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any())).thenReturn( + privateListDeliveryReport( + queuedCounterparties = listOf(CONTACT_KEY), + failedToQueue = listOf(privateListSyncChange(OTHER_CONTACT_KEY)), + ), + ) + sut.refreshKnownSavedContactEndpoints("channel ready", forceRefreshLightning = true).getOrThrow() + runCurrent() + verify(lightningRepo, times(4)).createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS) + + val refreshedInvoice = "lnbcrt1refreshed" + whenever(lightningRepo.createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS)) + .thenReturn(Result.success(refreshedInvoice)) + whenever(coreService.decode(refreshedInvoice)) + .thenReturn(Scanner.Lightning(lightningInvoice(refreshedInvoice, byteArrayOf(8, 8, 8)))) + whenever(paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any())).thenReturn( + privateListDeliveryReport(queuedCounterparties = keys), + ) + clearInvocations(paykitSdkService) + sut.refreshKnownSavedContactEndpoints("foreground").getOrThrow() + runCurrent() + verify(lightningRepo, times(5)).createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS) + val publication = argumentCaptor>() + verify(paykitSdkService).syncPrivatePaymentListsWithReservations(publication.capture(), eq(false)) + assertEquals( + mapOf(CONTACT_KEY to PRIVATE_BOLT11, OTHER_CONTACT_KEY to refreshedInvoice) + .mapValues { PublicPaykitRepo.serializePayload(it.value) }, + publication.firstValue.associate { it.counterparty to it.reservations.single().payload }, + ) + sut.refreshKnownSavedContactEndpoints("foreground").getOrThrow() + runCurrent() + verify(lightningRepo, times(5)).createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS) + sut.closeAndClear() + } + @Test fun `profile deletion records received invoices without rotating private endpoints`() = test { settingsData.value = SettingsData( @@ -3713,6 +3841,24 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever { paykitSdkService.identityStatus(any()) }.doSuspendableAnswer { paykitSdkService.identityStatus() } } + private suspend fun preparePrivateLightningContact(keys: List = listOf(CONTACT_KEY)) { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitOnchainEnabled = false, + publicPaykitUsdtEnabled = false, + ) + whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(lightningRepo.createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS)) + .thenReturn(Result.success(PRIVATE_BOLT11)) + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) + whenever(paykitSdkService.linkedPeers()).thenReturn(keys.map { linkedPeer(it, LinkedPeerState.LINKED) }) + whenever(paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any())).thenReturn( + privateListDeliveryReport(queuedCounterparties = keys), + ) + sut.prepareSavedContacts(keys, requireImmediatePublication = true).getOrThrow() + } + private fun createSut(publicPaykitRepo: PublicPaykitRepo = this.publicPaykitRepo) = PrivatePaykitRepo( ioDispatcher = testDispatcher, paykitSdkService = paykitSdkService, diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index b64c58dd66..237cb437d0 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -330,6 +330,41 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertTrue(settingsFlow.value.publicPaykitCleanupPending) } + @Test + fun `older publication after an acknowledged replacement keeps the replacement pending`() = test { + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitOnchainEnabled = false, + publicPaykitUsdtEnabled = false, + publicPaykitBolt11 = "lnbc1paid", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + ) + stubPublicInvoice("lnbc1replacement", byteArrayOf(4, 5, 6)) + val appSyncStarted = CompletableDeferred() + val resumeAppSync = CompletableDeferred() + whenever(paykitSdkService.syncPaykitApp(any(), any())).doSuspendableAnswer { + appSyncStarted.complete(Unit) + resumeAppSync.await() + } + val older = async { sut.syncCurrentPublishedEndpoints() } + appSyncStarted.await() + sut.refreshPublishedBolt11ForPayment("010203").getOrThrow() + assertEquals("lnbc1replacement", settingsFlow.value.publicPaykitBolt11) + assertFalse(settingsFlow.value.publicPaykitCleanupPending) + resumeAppSync.complete(Unit) + older.await().getOrThrow() + + val endpoints = argumentCaptor>() + verifyBlocking(paykitSdkService, times(2)) { syncPublicEndpoints(endpoints.capture(), any()) } + assertEquals(listOf("lnbc1replacement", "lnbc1paid"), endpoints.allValues.map { it.single().value }) + assertTrue(settingsFlow.value.publicPaykitCleanupPending) + sut.syncCurrentPublishedEndpoints().getOrThrow() + verifyBlocking(paykitSdkService, times(3)) { syncPublicEndpoints(endpoints.capture(), any()) } + assertEquals("lnbc1replacement", endpoints.lastValue.single().value) + assertFalse(settingsFlow.value.publicPaykitCleanupPending) + } + @Test fun `syncCurrentPublishedEndpoints waits only for session restoration and remains cancellable`() = test { isRestoringSession.value = true diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt index 6445c207de..8f5b0e8c90 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt @@ -1,11 +1,15 @@ package to.bitkit.repositories +import com.synonym.bitkitcore.LightningInvoice +import com.synonym.bitkitcore.NetworkType +import com.synonym.bitkitcore.Scanner import com.synonym.paykit.EndpointSyncReport import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runCurrent +import org.junit.After import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -13,11 +17,13 @@ import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.services.CoreService import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -32,6 +38,8 @@ class PublicPaykitSharingAdmissionTest : BaseUnitTest() { private val walletRepo = mock() private val paykitSdkService = mock() private val settingsStore = mock() + private val lightningRepo = mock() + private val coreService = mock() private val isRestoringSession = MutableStateFlow(false) private val settingsFlow = MutableStateFlow(SettingsData()) private lateinit var sut: PublicPaykitRepo @@ -42,8 +50,8 @@ class PublicPaykitSharingAdmissionTest : BaseUnitTest() { ioDispatcher = testDispatcher, pubkyRepo = pubkyRepo, walletRepo = walletRepo, - lightningRepo = mock(), - coreService = mock(), + lightningRepo = lightningRepo, + coreService = coreService, paykitSdkService = paykitSdkService, settingsStore = settingsStore, clock = Clock.System, @@ -61,6 +69,95 @@ class PublicPaykitSharingAdmissionTest : BaseUnitTest() { } } + @After + fun tearDown() { + PublicPaykitRepo.lightningRouteHintsValidator = null + } + + @Test + fun `deferred public lightning refresh survives skipped failed and superseded publication`() = test { + val signIn = PubkySignIn("pubkyself", 0L) + whenever(pubkyRepo.isCurrent(any())).thenReturn(true) + whenever(lightningRepo.canReceive()).thenReturn(true) + PublicPaykitRepo.lightningRouteHintsValidator = { true } + for (outcome in listOf("skipped", "failed", "superseded")) { + clearInvocations(lightningRepo) + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitOnchainEnabled = false, + publicPaykitUsdtEnabled = false, + publicPaykitBolt11 = "ln-cached", + publicPaykitBolt11PaymentHash = "01", + publicPaykitBolt11ExpiresAtMillis = Long.MAX_VALUE, + ) + whenever(lightningRepo.createInvoice(null, "", 86_400u)) + .thenReturn(Result.success("ln-first"), Result.success("ln-second")) + for (bolt11 in listOf("ln-first", "ln-second")) { + whenever(coreService.decode(bolt11)).thenReturn(Scanner.Lightning(invoice(bolt11))) + } + var calls = 0 + val published = mutableListOf>() + whenever(paykitSdkService.syncPublicEndpoints(any(), any())).doSuspendableAnswer { + calls += 1 + if (calls == 1) { + if (outcome == "skipped") return@doSuspendableAnswer null + if (outcome == "failed") throw PublicPaykitError.PublicationFailed + sut.requestLightningRefresh(signIn) + } + published.add(it.getArgument>(0).map { endpoint -> endpoint.value }) + EndpointSyncReport(published = emptyList(), removed = emptyList(), failed = emptyList()) + } + sut.requestLightningRefresh(signIn) + assertTrue(sut.hasPendingLightningRefresh) + val first = sut.syncCurrentPublishedEndpoints() + assertEquals(outcome == "failed", first.isFailure, outcome) + assertTrue(sut.hasPendingLightningRefresh, outcome) + sut.syncCurrentPublishedEndpoints().getOrThrow() + assertFalse(sut.hasPendingLightningRefresh, outcome) + assertEquals(listOf("ln-second"), published.last(), outcome) + sut.syncCurrentPublishedEndpoints().getOrThrow() + assertEquals(listOf("ln-second"), published.last(), outcome) + verifyBlocking(lightningRepo, times(2)) { createInvoice(null, "", 86_400u) } + } + } + + @Test + fun `deferred public lightning refresh does not transfer to a replacement session`() = test { + val original = PubkySignIn("pubkyself", 0L) + var generation = 0L + whenever(pubkyRepo.isCurrent(any())).thenAnswer { it.getArgument(0).generation == generation } + whenever(lightningRepo.canReceive()).thenReturn(true) + PublicPaykitRepo.lightningRouteHintsValidator = { true } + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitOnchainEnabled = false, + publicPaykitUsdtEnabled = false, + publicPaykitBolt11 = "ln-replacement-session", + publicPaykitBolt11PaymentHash = "01", + publicPaykitBolt11ExpiresAtMillis = Long.MAX_VALUE, + ) + whenever(paykitSdkService.syncPublicEndpoints(any(), any())) + .thenReturn(EndpointSyncReport(published = emptyList(), removed = emptyList(), failed = emptyList())) + sut.requestLightningRefresh(original) + generation += 1 + assertFalse(sut.hasPendingLightningRefresh) + sut.syncCurrentPublishedEndpoints().getOrThrow() + verifyBlocking(lightningRepo, never()) { createInvoice(any(), any(), any()) } + assertEquals("ln-replacement-session", settingsFlow.value.publicPaykitBolt11) + } + + private fun invoice(bolt11: String) = LightningInvoice( + bolt11 = bolt11, + paymentHash = byteArrayOf(1), + amountSatoshis = 0uL, + timestampSeconds = 0uL, + expirySeconds = 86_400uL, + isExpired = false, + description = "", + networkType = NetworkType.REGTEST, + payeeNodeId = null, + ) + @Test @OptIn(ExperimentalCoroutinesApi::class) fun `public sharing disabled during restoration preserves pending cleanup without preparing endpoints`() = test { diff --git a/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt index 8175acd216..fc12dd1b26 100644 --- a/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.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.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -76,7 +77,7 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { ) ) ) - whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any(), any()) } + whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any(), any(), anyOrNull()) } .thenReturn(Result.success(Unit)) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6cd2d50227..487be3c393 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -722,7 +722,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.pruneUnsavedContactState(any>(), any()) } .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any(), any()) } + whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any(), any(), anyOrNull()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.reconcileReservedReceiveIndexes() } .thenReturn(Result.success(Unit)) @@ -1411,18 +1411,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceTimeBy(1.seconds.inWholeMilliseconds) runCurrent() verify(paykitPaymentRequestRepo).refresh(any()) - verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints(any(), any(), any()) + verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints(any(), any(), any(), anyOrNull()) verify(paykitPaymentProofRepo, never()).reconcile() verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), eq(true)) repeat(5) { verifyPaymentRequestPollingInterval() } - verify(privatePaykitRepo).refreshKnownSavedContactEndpoints(any(), any(), any()) + verify(privatePaykitRepo).refreshKnownSavedContactEndpoints(any(), any(), any(), anyOrNull()) verify(paykitPaymentProofRepo).reconcile() verify(paykitPaymentRequestRepo).refreshEligibleTargets(any(), eq(true)) advanceTimeBy(120.seconds.inWholeMilliseconds) runCurrent() - verify(privatePaykitRepo, times(3)).refreshKnownSavedContactEndpoints(any(), any(), any()) + verify(privatePaykitRepo, times(3)).refreshKnownSavedContactEndpoints(any(), any(), any(), anyOrNull()) verify(paykitPaymentProofRepo, times(3)).reconcile() } finally { sut.stopPaykitPaymentRequestPolling() @@ -11939,6 +11939,84 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(publicPaykitRepo).syncCurrentPublishedEndpoints(eq(false), eq(false), any()) } + @Test + fun `profile deletion retains forced public channel refresh for the retained identity`() = test { + enablePublicPaykitSharing() + pubkyPublicKey.value = testPublicKey + advanceUntilIdle() + clearInvocations(publicPaykitRepo) + + isProfileDeletionInProgress.value = true + emitNodeEvent( + Event.ChannelReady( + channelId = "testChannelId", + userChannelId = "testUserChannelId", + counterpartyNodeId = null, + fundingTxo = null, + ), + ) + advanceUntilIdle() + verify(publicPaykitRepo, never()).syncCurrentPublishedEndpoints(any(), any(), any()) + verify(publicPaykitRepo).requestLightningRefresh(any()) + val privateAdmission = argumentCaptor<() -> Boolean>() + verify(privatePaykitRepo).refreshKnownSavedContactEndpoints( + any(), + eq(true), + eq(false), + privateAdmission.capture(), + ) + assertTrue(privateAdmission.firstValue()) + + isProfileDeletionInProgress.value = false + advanceUntilIdle() + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(eq(false), eq(false), any()) + pubkyPublicKey.value = "replacement" + assertFalse(privateAdmission.firstValue()) + } + + @Test + fun `forced private channel refresh survives deletion beginning during cleanup reconciliation`() = test { + enablePublicPaykitSharing() + pubkyPublicKey.value = testPublicKey + advanceUntilIdle() + val cleanupStarted = CompletableDeferred() + val finishCleanup = CompletableDeferred() + var holdCleanup = false + whenever(publicPaykitRepo.syncCurrentPublishedEndpoints(eq(true), eq(false), any())).thenAnswer { + holdCleanup = true + Result.success(Unit) + } + whenever(privatePaykitRepo.retryPendingEndpointRemoval(any>())).doSuspendableAnswer { + if (holdCleanup) { + cleanupStarted.complete(Unit) + finishCleanup.await() + } + Result.success(Unit) + } + emitNodeEvent( + Event.ChannelReady( + channelId = "testChannelId", + userChannelId = "testUserChannelId", + counterpartyNodeId = null, + fundingTxo = null, + ), + ) + advanceUntilIdle() + assertTrue(cleanupStarted.isCompleted) + isProfileDeletionInProgress.value = true + clearInvocations(privatePaykitRepo) + finishCleanup.complete(Unit) + advanceUntilIdle() + + verify(privatePaykitRepo).refreshKnownSavedContactEndpoints( + eq("channel ready delayed"), + eq(true), + eq(false), + any(), + ) + verify(privatePaykitRepo, never()).reconcileReservedReceiveIndexes() + } + @Test fun `channel ready refreshes public Paykit endpoints when sharing enabled`() = test { enablePublicPaykitSharing() @@ -12339,10 +12417,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { contactWork.await() Result.success(Unit) } - whenever(privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any(), any())).doSuspendableAnswer { - contactWork.await() - Result.success(Unit) - } + whenever(privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any(), any(), anyOrNull())) + .doSuspendableAnswer { + contactWork.await() + Result.success(Unit) + } pubkyContactsLoadVersion.update { 1L } sut.refreshPrivatePaykitEndpoints() runCurrent() @@ -12618,18 +12697,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `private Paykit refresh leaves pending public state for the publisher to acknowledge`() = test { isPaykitEnabled.value = true - settingsData.value = SettingsData( - sharesPublicPaykitEndpoints = true, - publicPaykitCleanupPending = true, - ) whenever(publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any(), any())).thenReturn(Result.success(Unit)) + for (paidInvoicePending in listOf(true, false)) { + clearInvocations(publicPaykitRepo, privatePaykitRepo) + settingsData.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitCleanupPending = paidInvoicePending, + ) + whenever(publicPaykitRepo.hasPendingLightningRefresh).thenReturn(!paidInvoicePending) + sut.refreshPrivatePaykitEndpoints() + advanceUntilIdle() - sut.refreshPrivatePaykitEndpoints() - advanceUntilIdle() - - verify(publicPaykitRepo).syncCurrentPublishedEndpoints(eq(false), eq(false), any()) - assertTrue(settingsData.value.publicPaykitCleanupPending) - verify(privatePaykitRepo).retryPendingEndpointRemoval(emptyList()) + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(eq(false), eq(false), any()) + assertEquals(paidInvoicePending, settingsData.value.publicPaykitCleanupPending) + verify(privatePaykitRepo).retryPendingEndpointRemoval(emptyList()) + } } @Test diff --git a/journeys/profile/README.md b/journeys/profile/README.md index f11ecaf897..8c7e2c1f3d 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -16,6 +16,8 @@ Hold initial session restoration with public contact payments enabled, turn cont Hold endpoint preparation before publication admission, or queue a public publication behind SDK work. Begin deletion, complete endpoint removal while session revocation remains held, then release the queued preparation/publication. The removed endpoints must stay absent. Separately, let a publication enter its SDK write before deletion: cleanup must wait for it and remove its endpoints afterward, even if its caller is cancelled. This requires controlled operation gates beyond the journey runner capabilities. +While deletion is held, make a Lightning channel usable or ready, then fail deletion while retaining the same identity. Public and private endpoint preparation must refresh the invoice rather than reuse its old route hints. A skipped or failed publication must retain that refresh for normal maintenance; a new session must not inherit it. Also hold an older public refresh before publication, pay its invoice and publish the replacement, then release the older refresh. The replacement must become pending again and be republished by normal reconciliation. These checks require channel, failure and publication gates beyond the journey runner capabilities. + For a saved identity, inject a temporary import failure (`concurrent_update`, `shared_state_busy`, or a transport failure) during cold start. Verify that no "Session expired" toast appears, the saved credentials remain, and recovery succeeds after the temporary failure ends. A genuinely invalid session must still produce the existing expiry feedback. For the reported background-resume case, verify returning directly to Profile with the process still alive, then repeat after the OS recreates the process. A delay in session recovery must keep the saved identity on the profile loading/retry screen rather than show profile onboarding. From 9fddab92b3aebb8e0ba5743eb85468e00cd92d30 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 08:38:09 -0500 Subject: [PATCH 08/10] fix: retain forced refresh after initial sign-in --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 +++- .../viewmodels/AppViewModelSendFlowTest.kt | 36 +++++++++++++++++++ journeys/profile/README.md | 2 ++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8fa1838822..578153fd71 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -857,7 +857,11 @@ class AppViewModel @Inject constructor( if (privatePaykitRepo.isProfileDeletionInProgress.value) return pubkyRepo.isRestoringSession.first { !it } - val admission = publicPaykitPublicationAdmission(signIn ?: pubkyRepo.currentSignIn()) + val restoredSignIn = signIn ?: pubkyRepo.currentSignIn() + if (forceRefreshLightning && signIn == null && restoredSignIn != null) { + publicPaykitRepo.requestLightningRefresh(restoredSignIn) + } + val admission = publicPaykitPublicationAdmission(restoredSignIn) val onchainAddress = walletRepo.walletState.value.onchainAddress if (onchainAddress.isBlank() && !lightningRepo.canReceive()) return diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 487be3c393..25c590e68d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -11918,6 +11918,42 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(listOf(false), admitted) } + @Test + fun `forced public refresh records the restored identity before skipped or failed publication`() = test { + val nodeState = lightningRepo.lightningState as MutableStateFlow + for (hasAddress in listOf(false, true)) { + nodeState.update { it.copy(channels = persistentListOf()) } + runCurrent() + enablePublicPaykitSharing() + pubkyPublicKey.value = null + isRestoringPubkySession.value = true + walletState.value = WalletState(onchainAddress = if (hasAddress) "bc1qtest" else "") + whenever(lightningRepo.canReceive()).thenReturn(false) + whenever(publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any(), any())) + .thenReturn(Result.failure(AppError("publication unavailable"))) + clearInvocations(publicPaykitRepo) + runCurrent() + nodeState.update { it.copy(channels = persistentListOf(mock { on { isUsable } doReturn true })) } + advanceUntilIdle() + verify(publicPaykitRepo, never()).requestLightningRefresh(any()) + verify(publicPaykitRepo, never()).syncCurrentPublishedEndpoints(any(), any(), any()) + + pubkyPublicKey.value = testPublicKey + isRestoringPubkySession.value = false + advanceUntilIdle() + + val request = argumentCaptor() + verify(publicPaykitRepo).requestLightningRefresh(request.capture()) + assertEquals(testPublicKey, request.firstValue.publicKey) + assertEquals(0L, request.firstValue.generation) + if (hasAddress) { + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(eq(true), eq(false), any()) + } else { + verify(publicPaykitRepo, never()).syncCurrentPublishedEndpoints(any(), any(), any()) + } + } + } + @Test fun `profile deletion defers public publication and resumes the latest address on failure`() = test { enablePublicPaykitSharing() diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 8c7e2c1f3d..cece352244 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -18,6 +18,8 @@ Hold endpoint preparation before publication admission, or queue a public public While deletion is held, make a Lightning channel usable or ready, then fail deletion while retaining the same identity. Public and private endpoint preparation must refresh the invoice rather than reuse its old route hints. A skipped or failed publication must retain that refresh for normal maintenance; a new session must not inherit it. Also hold an older public refresh before publication, pay its invoice and publish the replacement, then release the older refresh. The replacement must become pending again and be republished by normal reconciliation. These checks require channel, failure and publication gates beyond the journey runner capabilities. +Hold initial session restoration before sign-in is available, then make a Lightning channel usable. Release restoration while public publication cannot proceed, then restore readiness or connectivity without another channel event. Normal maintenance must retain the forced invoice refresh for the restored identity. A refresh that began with an existing sign-in must not transfer to a replacement session. + For a saved identity, inject a temporary import failure (`concurrent_update`, `shared_state_busy`, or a transport failure) during cold start. Verify that no "Session expired" toast appears, the saved credentials remain, and recovery succeeds after the temporary failure ends. A genuinely invalid session must still produce the existing expiry feedback. For the reported background-resume case, verify returning directly to Profile with the process still alive, then repeat after the OS recreates the process. A delay in session recovery must keep the saved identity on the profile loading/retry screen rather than show profile onboarding. From d46bd6cacd5b67015fd6484e64443df39b5b1608 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 10:25:57 -0500 Subject: [PATCH 09/10] fix: retain private refresh for late-loaded contacts --- .../bitkit/repositories/PrivatePaykitRepo.kt | 2 + .../repositories/PrivatePaykitRepoTest.kt | 38 ++++++++++++++++++- journeys/profile/README.md | 2 + 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index d74d032acb..453716caee 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -2269,6 +2269,8 @@ class PrivatePaykitRepo @Inject constructor( private fun rememberSavedContacts(publicKeys: Collection, replacing: Boolean): List { val normalizedKeys = publicKeys.mapNotNull { normalizedPublicKey(it) }.distinct() + val newKeys = normalizedKeys.filter { it !in knownSavedContactKeys } + currentLightningRefresh()?.remainingKeys?.addAll(newKeys) if (replacing) { knownSavedContactKeys.clear() unavailableLinkRetryAt.keys.retainAll(normalizedKeys.toSet()) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 42a202d9d7..bd09b63c33 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -2736,6 +2736,38 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() } + @Test + fun `forced private Lightning refresh includes contacts loaded later`() = test { + preparePrivateLightningContact() + sut.scheduleSavedContactPreparation(emptyList()).getOrThrow() + runCurrent() + sut.refreshKnownSavedContactEndpoints("channel usable", forceRefreshLightning = true).getOrThrow() + runCurrent() + + val refreshedInvoice = "lnbcrt1refreshed" + whenever(lightningRepo.createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS)) + .thenReturn(Result.success(refreshedInvoice)) + whenever(coreService.decode(refreshedInvoice)) + .thenReturn(Scanner.Lightning(lightningInvoice(refreshedInvoice, byteArrayOf(8, 8, 8)))) + clearInvocations(paykitSdkService) + sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + sut.awaitContactPreparation() + + val publication = argumentCaptor>() + verify(paykitSdkService).syncPrivatePaymentListsWithReservations(publication.capture(), eq(false)) + assertEquals( + PublicPaykitRepo.serializePayload(refreshedInvoice), + publication.firstValue.single().reservations.single().payload, + ) + verify(lightningRepo, times(2)).createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS) + sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + sut.awaitContactPreparation() + verify(lightningRepo, times(2)).createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS) + sut.closeAndClear() + } + @Test fun `deferred private Lightning refresh cannot consume a newer request during publication`() = test { preparePrivateLightningContact() @@ -2768,6 +2800,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `deferred private Lightning refresh does not follow a replaced sign in`() = test { preparePrivateLightningContact() + sut.scheduleSavedContactPreparation(emptyList()).getOrThrow() + runCurrent() var isCurrent = true sut.beginProfileDeletion() sut.refreshKnownSavedContactEndpoints( @@ -2777,7 +2811,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).getOrThrow() isCurrent = false sut.endProfileDeletion() - sut.refreshKnownSavedContactEndpoints("foreground").getOrThrow() + sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() runCurrent() verify(lightningRepo).createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS) @@ -2807,7 +2841,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { privateListDeliveryReport(queuedCounterparties = keys), ) clearInvocations(paykitSdkService) - sut.refreshKnownSavedContactEndpoints("foreground").getOrThrow() + sut.scheduleSavedContactPreparation(keys).getOrThrow() runCurrent() verify(lightningRepo, times(5)).createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS) val publication = argumentCaptor>() diff --git a/journeys/profile/README.md b/journeys/profile/README.md index cece352244..38b4ffcb60 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -20,6 +20,8 @@ While deletion is held, make a Lightning channel usable or ready, then fail dele Hold initial session restoration before sign-in is available, then make a Lightning channel usable. Release restoration while public publication cannot proceed, then restore readiness or connectivity without another channel event. Normal maintenance must retain the forced invoice refresh for the restored identity. A refresh that began with an existing sign-in must not transfer to a replacement session. +On Android, hold the saved-contact load at cold start while a Lightning channel becomes usable. When contacts load, their pending private invoice refresh must use current route hints without another channel event. Repeating the contact list must not refresh already-completed peers again, and replacing the session must discard the old request. This Android-specific check requires a contact-load gate beyond the journey runner capabilities. + For a saved identity, inject a temporary import failure (`concurrent_update`, `shared_state_busy`, or a transport failure) during cold start. Verify that no "Session expired" toast appears, the saved credentials remain, and recovery succeeds after the temporary failure ends. A genuinely invalid session must still produce the existing expiry feedback. For the reported background-resume case, verify returning directly to Profile with the process still alive, then repeat after the OS recreates the process. A delay in session recovery must keep the saved identity on the profile loading/retry screen rather than show profile onboarding. From 98cab12b689976a0c4cc1509747486338fce8184 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 12:00:31 -0500 Subject: [PATCH 10/10] fix: retain public refresh across retries --- .../java/to/bitkit/repositories/PubkyRepo.kt | 20 ++- .../bitkit/repositories/PublicPaykitRepo.kt | 47 +++++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 9 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 35 ++++++ .../PublicPaykitSharingAdmissionTest.kt | 115 +++++++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 43 +++++-- journeys/profile/README.md | 4 +- 7 files changed, 238 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 93ed5efc3c..ec00f106b4 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -182,6 +182,7 @@ class PubkyRepo @Inject constructor( private val adoptionMutex = Mutex() private val profileWriteGeneration = AtomicLong(0L) private val signInGeneration = AtomicLong(0L) + private val storedIdentityRevision = AtomicLong(0L) private val readOnlyProfileGeneration = AtomicLong(0L) private val readOnlyProfileLock = Any() private var isServiceInitialized = false @@ -482,6 +483,7 @@ class PubkyRepo @Inject constructor( clearAuthenticatedState( clearCachedProfile = false, clearRestorationFailure = notifyFailure, + preserveStoredIdentity = true, ) if (notifyFailure) _sessionRestorationFailed.update { result == InitResult.RestorationFailed } } @@ -640,6 +642,7 @@ class PubkyRepo @Inject constructor( // Before the SDK installs the session, which can be of the same identity: a contact save of the // ending sign-in that the SDK runs after it then fails the sign-in check under the SDK lock. signInGeneration.incrementAndGet() + storedIdentityRevision.incrementAndGet() signInOrSignUpAdoptedIdentity(secretKeyHex, rawPublicKey) sessionInstalled = true @@ -765,6 +768,16 @@ class PubkyRepo @Inject constructor( fun isCurrent(signIn: PubkySignIn): Boolean = signInGeneration.get() == signIn.generation && _publicKey.value == signIn.publicKey + internal suspend fun savedIdentityGuard(): (() -> Boolean)? { + val revision = storedIdentityRevision.get() + return withContext(ioDispatcher) { + if (!runSuspendCatching { hasIdentity() }.getOrDefault(false) || storedIdentityRevision.get() != revision) { + return@withContext null + } + return@withContext { storedIdentityRevision.get() == revision } + } + } + // endregion // region Profile loading @@ -2094,16 +2107,17 @@ class PubkyRepo @Inject constructor( _profile.update { profile } } - private fun startSignIn(publicKey: String) { + private fun startSignIn(publicKey: String, restoringStoredIdentity: Boolean = false) { // First, so the sign-in it replaces has ended before the key is published, even when it is the same identity. signInGeneration.incrementAndGet() + if (!restoringStoredIdentity) storedIdentityRevision.incrementAndGet() invalidateReadOnlyProfile(keepingPublicKey = publicKey) _publicKey.update { publicKey } } private fun continueSignIn(publicKey: String) { // Restoring or refreshing the session already signed in keeps its sign-in, so an edit under way still saves. - if (_publicKey.value != publicKey) startSignIn(publicKey) + if (_publicKey.value != publicKey) startSignIn(publicKey, restoringStoredIdentity = true) } private fun invalidateReadOnlyProfile(keepingPublicKey: String? = null) = synchronized(readOnlyProfileLock) { @@ -2114,9 +2128,11 @@ class PubkyRepo @Inject constructor( private suspend fun clearAuthenticatedState( clearCachedProfile: Boolean = true, clearRestorationFailure: Boolean = true, + preserveStoredIdentity: Boolean = false, ) = withContext(ioDispatcher) { // First, so work of the ending sign-in stops before the store reset below, and cannot write after it. signInGeneration.incrementAndGet() + if (!preserveStoredIdentity) storedIdentityRevision.incrementAndGet() if (clearCachedProfile) { invalidateReadOnlyProfile() evictPubkyImages() diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index a012d6ba57..fbc898d662 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -202,16 +202,28 @@ class PublicPaykitRepo @Inject constructor( } private val publishMutex = Mutex() - private class LightningRefresh(val signIn: PubkySignIn) + private val invoiceMutex = Mutex() + private class LightningRefresh(signIn: PubkySignIn?, val ownsIdentity: () -> Boolean) { + val signIn = AtomicReference(signIn) + var invalidateInvoice = true + } private val pendingLightningRefresh = AtomicReference(null) - internal fun requestLightningRefresh(signIn: PubkySignIn) { - if (pubkyRepo.isCurrent(signIn)) pendingLightningRefresh.set(LightningRefresh(signIn)) + internal fun requestLightningRefresh(signIn: PubkySignIn?, restorationGuard: (() -> Boolean)? = null) { + val ownsIdentity = signIn?.let { { pubkyRepo.isCurrent(it) } } ?: restorationGuard ?: return + if (ownsIdentity()) pendingLightningRefresh.set(LightningRefresh(signIn, ownsIdentity)) } internal val hasPendingLightningRefresh: Boolean - get() = pendingLightningRefresh.get()?.let { pubkyRepo.isCurrent(it.signIn) } == true + get() = currentLightningRefresh() != null + + private fun currentLightningRefresh(): LightningRefresh? = pendingLightningRefresh.get()?.takeIf { request -> + pubkyRepo.currentSignIn()?.let { request.signIn.compareAndSet(null, it) } + val current = request.ownsIdentity() && request.signIn.get()?.let { pubkyRepo.isCurrent(it) } != false + if (!current) pendingLightningRefresh.compareAndSet(request, null) + current + } suspend fun beginPayment(publicKey: String): Result = withContext(ioDispatcher) { runSuspendCatching { @@ -270,21 +282,18 @@ class PublicPaykitRepo @Inject constructor( runSuspendCatching { pubkyRepo.isRestoringSession.first { !it } if (!isAutomaticPublicationAllowed(canPublish)) return@runSuspendCatching - val requestedRefresh = pendingLightningRefresh.get()?.takeIf { request -> - pubkyRepo.isCurrent(request.signIn).also { current -> - if (!current) pendingLightningRefresh.compareAndSet(request, null) - } - } + val requestedRefresh = currentLightningRefresh() val desired = buildWalletEndpoints( refresh = false, - forceRefreshLightning = forceRefreshLightning || requestedRefresh != null, + forceRefreshLightning = forceRefreshLightning && requestedRefresh == null, requireEndpoint = requireEndpoint, + lightningRefresh = requestedRefresh, ) if (!isAutomaticPublicationAllowed(canPublish)) return@runSuspendCatching syncPaykitApp().getOrThrow() if (applyPublishedEndpoints(desired) { isAutomaticPublicationAllowed(canPublish) }) { acknowledgePublishedEndpoints(desired, canPublish) - if (requestedRefresh != null && pubkyRepo.isCurrent(requestedRefresh.signIn)) { + if (requestedRefresh != null && currentLightningRefresh() === requestedRefresh) { pendingLightningRefresh.compareAndSet(requestedRefresh, null) } } @@ -388,6 +397,7 @@ class PublicPaykitRepo @Inject constructor( refresh: Boolean, forceRefreshLightning: Boolean = false, requireEndpoint: Boolean = true, + lightningRefresh: LightningRefresh? = null, ): List { val settings = settingsStore.data.first() val includeLightning = isLightningPaymentOptionEnabled(settings) @@ -407,7 +417,7 @@ class PublicPaykitRepo @Inject constructor( val state = walletRepo.walletState.value val endpoints = mutableListOf() if (includeLightning) { - buildPublicBolt11Endpoint(forceRefreshLightning)?.let { endpoints += it } + buildPublicBolt11Endpoint(forceRefreshLightning, lightningRefresh)?.let { endpoints += it } } else { clearPublicBolt11Metadata() } @@ -436,7 +446,18 @@ class PublicPaykitRepo @Inject constructor( return endpoints } - private suspend fun buildPublicBolt11Endpoint(forceRefreshLightning: Boolean = false): Endpoint? { + private suspend fun buildPublicBolt11Endpoint( + forceRefreshLightning: Boolean = false, + lightningRefresh: LightningRefresh? = null, + ): Endpoint? = invoiceMutex.withLock { + if (lightningRefresh?.invalidateInvoice == true && currentLightningRefresh() === lightningRefresh) { + clearPublicBolt11Metadata(publicationPending = true) + lightningRefresh.invalidateInvoice = false + } + preparePublicBolt11Endpoint(forceRefreshLightning) + } + + private suspend fun preparePublicBolt11Endpoint(forceRefreshLightning: Boolean): Endpoint? { if (!lightningRepo.canReceive()) { clearPublicBolt11Metadata() return null diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 578153fd71..be86ea2a77 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -851,22 +851,21 @@ class AppViewModel @Inject constructor( private suspend fun refreshPublicPaykitEndpointsIfEnabled(forceRefreshLightning: Boolean = false) { val signIn = pubkyRepo.currentSignIn() + val restorationGuard = if (forceRefreshLightning && signIn == null) pubkyRepo.savedIdentityGuard() else null val settings = settingsStore.data.first() if (!isPaykitEnabled.value || !settings.sharesPublicPaykitEndpoints) return - if (forceRefreshLightning && signIn != null) publicPaykitRepo.requestLightningRefresh(signIn) + if (forceRefreshLightning) { + publicPaykitRepo.requestLightningRefresh(signIn, restorationGuard) + } if (privatePaykitRepo.isProfileDeletionInProgress.value) return pubkyRepo.isRestoringSession.first { !it } val restoredSignIn = signIn ?: pubkyRepo.currentSignIn() - if (forceRefreshLightning && signIn == null && restoredSignIn != null) { - publicPaykitRepo.requestLightningRefresh(restoredSignIn) - } val admission = publicPaykitPublicationAdmission(restoredSignIn) val onchainAddress = walletRepo.walletState.value.onchainAddress if (onchainAddress.isBlank() && !lightningRepo.canReceive()) return publicPaykitRepo.syncCurrentPublishedEndpoints( - forceRefreshLightning = forceRefreshLightning, canPublish = admission, ) .onFailure { Logger.warn("Failed to refresh public Paykit endpoints", it, context = TAG) } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 4f379d4058..5ef2d1acfc 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -2656,6 +2656,41 @@ class PubkyRepoTest : BaseUnitTest() { verify(keychain, never()).delete(Keychain.Key.PAYKIT_SESSION.name) } + @Test + fun `saved identity guard survives deferred restoration but rejects explicit replacement or removal`() = test { + for (action in listOf("restore", "wipe", "sign out", "backup", "adopt")) { + resetForCase() + val restore = stubSavedSessionRestore() + restore.answer = { throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") } + val ownsIdentity = assertNotNull(sut.savedIdentityGuard()) + sut.initialize() + assertTrue(ownsIdentity(), action) + assertFalse(sut.restoreSessionIfNeeded()) + assertTrue(ownsIdentity(), action) + restore.answer = { VALID_SELF_KEY } + when (action) { + "restore" -> assertTrue(sut.restoreSessionIfNeeded()) + "wipe" -> sut.wipeLocalState() + "sign out" -> sut.signOut().getOrThrow() + "backup" -> sut.restoreSessionBackupState(null).getOrThrow() + "adopt" -> { + whenever(sharedPubkyClient.ringCredential(VALID_SELF_KEY)).thenReturn(Result.success("same-secret")) + whenever(pubkyService.publicKeyFromSecret("same-secret")).thenReturn(VALID_SELF_KEY) + whenever(pubkyService.signIn("same-secret")).thenAnswer { throw TestAppError("Offline") } + assertTrue(sut.adoptRingIdentity(VALID_SELF_KEY).isFailure) + } + } + assertEquals(action == "restore", ownsIdentity(), action) + } + } + + @Test + fun `saved identity guard rejects missing and unreadable credentials`() = test { + assertNull(sut.savedIdentityGuard()) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { throw TestAppError("Unavailable") } + assertNull(sut.savedIdentityGuard()) + } + @Test fun `deferred restoration recovers without another foreground caller`() = test { val restore = stubSavedSessionRestore() diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt index 8f5b0e8c90..b6793dd993 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt @@ -114,13 +114,122 @@ class PublicPaykitSharingAdmissionTest : BaseUnitTest() { assertTrue(sut.hasPendingLightningRefresh, outcome) sut.syncCurrentPublishedEndpoints().getOrThrow() assertFalse(sut.hasPendingLightningRefresh, outcome) - assertEquals(listOf("ln-second"), published.last(), outcome) + val expectedInvoice = if (outcome == "superseded") "ln-second" else "ln-first" + assertEquals(listOf(expectedInvoice), published.last(), outcome) sut.syncCurrentPublishedEndpoints().getOrThrow() - assertEquals(listOf("ln-second"), published.last(), outcome) - verifyBlocking(lightningRepo, times(2)) { createInvoice(null, "", 86_400u) } + assertEquals(listOf(expectedInvoice), published.last(), outcome) + verifyBlocking(lightningRepo, times(if (outcome == "superseded") 2 else 1)) { + createInvoice(null, "", 86_400u) + } + } + } + + @Test + fun `initial public refresh waits for its saved identity and binds its first restored sign in`() = test { + for (outcome in listOf("restore", "replace before", "replace after")) { + var ownsIdentity = true + var signIn: PubkySignIn? = null + whenever(pubkyRepo.currentSignIn()).thenAnswer { signIn } + whenever(pubkyRepo.isCurrent(any())).thenAnswer { + val token = it.getArgument(0) + token.publicKey == signIn?.publicKey && token.generation == signIn?.generation + } + whenever(lightningRepo.canReceive()).thenReturn(true) + PublicPaykitRepo.lightningRouteHintsValidator = { true } + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitOnchainEnabled = false, + publicPaykitUsdtEnabled = false, + publicPaykitBolt11 = "ln-cached", + publicPaykitBolt11PaymentHash = "01", + publicPaykitBolt11ExpiresAtMillis = Long.MAX_VALUE, + ) + whenever(lightningRepo.createInvoice(null, "", 86_400u)).thenReturn(Result.success("ln-refreshed")) + whenever(coreService.decode("ln-refreshed")).thenReturn(Scanner.Lightning(invoice("ln-refreshed"))) + sut.requestLightningRefresh(null) { ownsIdentity } + repeat(2) { + sut.syncCurrentPublishedEndpoints(canPublish = { signIn != null }).getOrThrow() + assertTrue(sut.hasPendingLightningRefresh, outcome) + assertEquals("ln-cached", settingsFlow.value.publicPaykitBolt11, outcome) + } + if (outcome == "replace before") ownsIdentity = false + signIn = PubkySignIn("pubkyself", 1L) + var publicationFails = true + whenever(paykitSdkService.syncPublicEndpoints(any(), any())).doSuspendableAnswer { + if (publicationFails) throw PublicPaykitError.PublicationFailed + EndpointSyncReport(published = emptyList(), removed = emptyList(), failed = emptyList()) + } + assertTrue(sut.syncCurrentPublishedEndpoints().isFailure) + assertEquals(outcome != "replace before", sut.hasPendingLightningRefresh, outcome) + if (outcome == "replace after") { + signIn = PubkySignIn("pubkyself", 2L) + settingsFlow.value = settingsFlow.value.copy(publicPaykitBolt11 = "ln-replacement") + assertFalse(sut.hasPendingLightningRefresh) + } + publicationFails = false + sut.syncCurrentPublishedEndpoints().getOrThrow() + assertEquals( + when (outcome) { + "restore" -> "ln-refreshed" + "replace before" -> "ln-cached" + else -> "ln-replacement" + }, + settingsFlow.value.publicPaykitBolt11, + outcome, + ) + assertFalse(sut.hasPendingLightningRefresh, outcome) } } + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `overlapping refreshes prepare one invoice each and reuse it after publication failure`() = test { + val signIn = PubkySignIn("pubkyself", 0L) + whenever(pubkyRepo.currentSignIn()).thenReturn(signIn) + whenever(pubkyRepo.isCurrent(any())).thenReturn(true) + whenever(lightningRepo.canReceive()).thenReturn(true) + PublicPaykitRepo.lightningRouteHintsValidator = { true } + settingsFlow.value = SettingsData(sharesPublicPaykitEndpoints = true, publicPaykitOnchainEnabled = false) + val preparing = CompletableDeferred() + val resume = CompletableDeferred() + var invoiceCount = 0 + whenever(lightningRepo.createInvoice(null, "", 86_400u)).doSuspendableAnswer { + invoiceCount += 1 + if (invoiceCount == 1) { + preparing.complete(Unit) + resume.await() + } + Result.success("ln-$invoiceCount") + } + for (bolt11 in listOf("ln-1", "ln-2")) { + whenever(coreService.decode(bolt11)).thenReturn(Scanner.Lightning(invoice(bolt11))) + } + var publicationFails = true + whenever(paykitSdkService.syncPublicEndpoints(any(), any())).doSuspendableAnswer { + if (publicationFails) throw PublicPaykitError.PublicationFailed + EndpointSyncReport(published = emptyList(), removed = emptyList(), failed = emptyList()) + } + sut.requestLightningRefresh(signIn) + val first = async { sut.syncCurrentPublishedEndpoints() } + preparing.await() + val duplicate = async { sut.syncCurrentPublishedEndpoints() } + runCurrent() + sut.requestLightningRefresh(signIn) + val newer = async { sut.syncCurrentPublishedEndpoints() } + resume.complete(Unit) + assertTrue(first.await().isFailure) + assertTrue(duplicate.await().isFailure) + assertTrue(newer.await().isFailure) + assertEquals(2, invoiceCount) + assertTrue(sut.hasPendingLightningRefresh) + + publicationFails = false + sut.syncCurrentPublishedEndpoints().getOrThrow() + assertEquals(2, invoiceCount) + assertEquals("ln-2", settingsFlow.value.publicPaykitBolt11) + assertFalse(sut.hasPendingLightningRefresh) + } + @Test fun `deferred public lightning refresh does not transfer to a replacement session`() = test { val original = PubkySignIn("pubkyself", 0L) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 25c590e68d..c8356a902b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -79,6 +79,7 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder +import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -11935,25 +11936,44 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() nodeState.update { it.copy(channels = persistentListOf(mock { on { isUsable } doReturn true })) } advanceUntilIdle() - verify(publicPaykitRepo, never()).requestLightningRefresh(any()) + verify(publicPaykitRepo, never()).requestLightningRefresh(any(), anyOrNull()) + verify(publicPaykitRepo).requestLightningRefresh(isNull(), any()) verify(publicPaykitRepo, never()).syncCurrentPublishedEndpoints(any(), any(), any()) pubkyPublicKey.value = testPublicKey isRestoringPubkySession.value = false advanceUntilIdle() - val request = argumentCaptor() - verify(publicPaykitRepo).requestLightningRefresh(request.capture()) - assertEquals(testPublicKey, request.firstValue.publicKey) - assertEquals(0L, request.firstValue.generation) + verify(publicPaykitRepo, never()).requestLightningRefresh(any(), anyOrNull()) + verify(publicPaykitRepo).requestLightningRefresh(isNull(), any()) if (hasAddress) { - verify(publicPaykitRepo).syncCurrentPublishedEndpoints(eq(true), eq(false), any()) + verify(publicPaykitRepo, times(2)).syncCurrentPublishedEndpoints(eq(false), eq(false), any()) } else { verify(publicPaykitRepo, never()).syncCurrentPublishedEndpoints(any(), any(), any()) } } } + @Test + fun `forced public refresh survives deferred initial restoration without another channel event`() = test { + enablePublicPaykitSharing() + pubkyPublicKey.value = null + isRestoringPubkySession.value = true + val nodeState = lightningRepo.lightningState as MutableStateFlow + runCurrent() + nodeState.update { it.copy(channels = persistentListOf(mock { on { isUsable } doReturn true })) } + advanceUntilIdle() + isRestoringPubkySession.value = false + advanceUntilIdle() + verify(publicPaykitRepo).requestLightningRefresh(isNull(), any()) + + pubkyPublicKey.value = testPublicKey + advanceUntilIdle() + + verify(publicPaykitRepo).requestLightningRefresh(isNull(), any()) + verify(publicPaykitRepo, never()).requestLightningRefresh(any(), anyOrNull()) + } + @Test fun `profile deletion defers public publication and resumes the latest address on failure`() = test { enablePublicPaykitSharing() @@ -11993,7 +12013,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() verify(publicPaykitRepo, never()).syncCurrentPublishedEndpoints(any(), any(), any()) - verify(publicPaykitRepo).requestLightningRefresh(any()) + verify(publicPaykitRepo).requestLightningRefresh(any(), isNull()) val privateAdmission = argumentCaptor<() -> Boolean>() verify(privatePaykitRepo).refreshKnownSavedContactEndpoints( any(), @@ -12018,9 +12038,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val cleanupStarted = CompletableDeferred() val finishCleanup = CompletableDeferred() var holdCleanup = false - whenever(publicPaykitRepo.syncCurrentPublishedEndpoints(eq(true), eq(false), any())).thenAnswer { + whenever(publicPaykitRepo.requestLightningRefresh(any(), isNull())).thenAnswer { holdCleanup = true - Result.success(Unit) + Unit } whenever(privatePaykitRepo.retryPendingEndpointRemoval(any>())).doSuspendableAnswer { if (holdCleanup) { @@ -12069,8 +12089,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(publicPaykitRepo).syncCurrentPublishedEndpoints(eq(false), eq(false), any()) - verify(publicPaykitRepo).syncCurrentPublishedEndpoints(eq(true), eq(false), any()) + verify(publicPaykitRepo, times(2)).syncCurrentPublishedEndpoints(eq(false), eq(false), any()) + verify(publicPaykitRepo).requestLightningRefresh(isNull(), any()) } @Test @@ -12954,6 +12974,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) private suspend fun enablePublicPaykitSharing() { + whenever(pubkyRepo.savedIdentityGuard()).thenReturn { true } whenever(publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any(), any())).thenReturn(Result.success(Unit)) walletState.value = WalletState(onchainAddress = "bc1qtest") isPaykitEnabled.value = true diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 38b4ffcb60..723b3feb5f 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -18,7 +18,9 @@ Hold endpoint preparation before publication admission, or queue a public public While deletion is held, make a Lightning channel usable or ready, then fail deletion while retaining the same identity. Public and private endpoint preparation must refresh the invoice rather than reuse its old route hints. A skipped or failed publication must retain that refresh for normal maintenance; a new session must not inherit it. Also hold an older public refresh before publication, pay its invoice and publish the replacement, then release the older refresh. The replacement must become pending again and be republished by normal reconciliation. These checks require channel, failure and publication gates beyond the journey runner capabilities. -Hold initial session restoration before sign-in is available, then make a Lightning channel usable. Release restoration while public publication cannot proceed, then restore readiness or connectivity without another channel event. Normal maintenance must retain the forced invoice refresh for the restored identity. A refresh that began with an existing sign-in must not transfer to a replacement session. +Hold initial session restoration before sign-in completes, make a Lightning channel usable or ready, then release restoration while public publication remains blocked. Restore readiness or connectivity without another channel event. Normal public maintenance must retain the forced invoice refresh for the restored identity, including after a deferred or failed restoration and a skipped or failed publication. Work retained for the stored identity or an existing signed-in session must not transfer to a replacement identity or a new session for the same key. These checks require restoration, channel and publication gates beyond the journey runner capabilities. + +On Android, keep public publication unavailable across several maintenance attempts after a channel change. The replacement invoice must be prepared once and reused while it remains valid; publication must stay pending. A subsequent channel change, payment or invoice expiry must still prepare a replacement. Release the publication failure and verify that the current invoice is published and acknowledged. This requires failure and invoice-observation gates beyond the journey runner capabilities. On Android, hold the saved-contact load at cold start while a Lightning channel becomes usable. When contacts load, their pending private invoice refresh must use current route hints without another channel event. Repeating the contact list must not refresh already-completed peers again, and replacing the session must discard the old request. This Android-specific check requires a contact-load gate beyond the journey runner capabilities.