Skip to content

fix: stop deletion after incomplete cleanup - #1690

Open
ben-kaufman wants to merge 3 commits into
fix/profile-delete-session-recoveryfrom
fix/profile-cleanup-outcomes
Open

ben-kaufman wants to merge 3 commits into
fix/profile-delete-session-recoveryfrom
fix/profile-cleanup-outcomes

Conversation

@ben-kaufman

@ben-kaufman ben-kaufman commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Twin: synonymdev/bitkit-ios#1124
Dependency: #1688

This PR stops profile deletion after incomplete payment-link or contact cleanup instead of clearing local evidence and continuing.

Description

  • Stops profile deletion when private payment-link withdrawal or contact cleanup is incomplete.
  • Propagates contact read errors and preserves unknown or retained contact data for reconciliation.
  • Propagates profile-deletion errors instead of interpreting 404/missing text as successful deletion and signing out.

Out of Scope

  • Original-session/attempt ownership, failure-dialog Retry/Disconnect binding and restart reconciliation remain tracked in Bind profile deletion cleanup to the original sign-in #1687 and the paired iOS investigation. This PR does not make a manual destructive retry safe.
  • Native contact removal still has no expected-record precondition for cross-device edits/re-adds/unblocks. No such atomicity is claimed.
  • Full-WALLET separation, SDK artifact adoption and the historical contact/acquisition incident cause remain separate.

Design

Existing Profile › Profile Edit in the Bitkit handoff. This changes failure handling; it adds no visual design.

Preview

N/A — existing error UI is reused.

QA Notes

Setup

Use this branch's normal released dependencies: Core 0.8.0-rc11 and Paykit 0.1.1-rc1. Component tests use synthetic identities and injected service failures; no real profile, wallet or funds are needed. For J1, provision a disposable authenticated identity and a homeserver fault proxy that can hold/fail private endpoint withdrawal, lose a contact-removal acknowledgement, and return a partial removal with a retained active peer, or fail profile deletion with a 404-like error without confirming the outcome. Those controls are unavailable in the standard journey fixture. The committed journeys/profile/profile-cleanup-failure.xml describes the sequence; do not use a personal profile or reset another worker's wallet.

Journeys

  • J1: new profile-cleanup-failure.xml — incomplete cleanup preserves retained evidence and prevents later profile deletion.

Manual Tests

  • 1. Enable the J1 cleanup fault → Edit Profile → confirm deletion → failure UI appears without profile deletion or success navigation — authenticated cleanup fault proxy not in Capabilities.

  • 2. Inject a profile-deletion error mentioning 404 → confirm deletion → failure appears with the session and sharing settings retained — authenticated fault proxy not in Capabilities.

Automated Checks

  • updated PubkyRepoTest.kt — partial/unknown contact removal and unavailable reads stop before profile deletion/sign-out and preserve remaining data.
  • updated EditProfileViewModelTest.kt — private cleanup failure stops the flow; local finalization failure emits no success and does not open a deletion Retry dialog.
  • removed the obsolete success-after-private-cleanup-failure expectation, replaced by the stop-before-delete regression.

Evidence

Controlled baseline: 221 repository tests included two expected failures for partial/unknown contact cleanup; 19 view-model tests included two expected failures for ignored cleanup outcomes. Final source passed just compile, just test, just lint and just format: 4,627 tests across 254 suites, zero failures/errors/skips. Complete reports contain zero Detekt/Android Lint findings. JVM sharing notices and the structured Gradle deprecation match recorded baseline diagnostics. Two consecutive independent final reviews found no outstanding issues in this scoped change.

Acknowledged contact removals clear only their requested returned keys. Failed private local finalization reports an error without a deletion Retry dialog; Disconnect also consumes that result. The broader ownership/manual-retry limitation above remains open.

The follow-up at 2daec90e2 removes broad profile-deletion error suppression. A controlled pre-fix repository suite executed 223 tests with one expected failure: a 404-like deletion error returned success and signed out. The repaired focused suite passed all 223 cases. Final validation passed compile, 4,628 tests across 254 suites with zero failures/errors/skips, full formatting, Detekt and Android Lint with zero findings. Complete diagnostics were inspected. Two consecutive independent final reviews are clean. The SDK already represents confirmed profile absence as normal completion; arbitrary thrown errors no longer authorize sign-out. Earlier acknowledged contact/endpoint removals are not rolled back.

The previous 479c53065 hosted head passed 4,627 units and all lint reports (artifact SHA-256/ZIP CRC verified, three SARIF/XML reports with zero findings). Its E2E workflow eventually passed 39 cases, but LNURL and Transfer initially timed out waiting for expected UI and passed the workflow's automatic retry. Full logs were inspected; the initial causes remain unproven. No extra rerun was requested.

Gaps

J1 and manual cases 1–2 have not run as complete app journeys: the authenticated fault proxy is not provisioned. Component tests do not establish cross-device CAS or historical incident causation. All three commits in the complete current-base PR range are verified locally and on GitHub (verified=true, reason=valid). The earlier second commit only renamed unchanged changelog bytes to this PR number. Current-head hosted CI run 40165960501 passed 4,628 units. Lint run 40165960499 passed; complete logs and artifact 12587788708 were inspected with matching SHA-256/ZIP CRC and zero findings across three SARIF/XML reports. The manifest warning matches the actual base and previous head. Current-head E2E run 40165960710 passed 39 cases across seven shards on their first attempts. The complete 6,672-line log was inspected. This is the existing E2E suite, not the new authenticated cleanup fault journey.

Models used

  • Planning/scoping: gpt-6-astra (reasoning: xhigh)
  • Implementation: gpt-6-astra (reasoning: xhigh)
  • Review: gpt-6-astra (reasoning: xhigh)

@greptile-apps

greptile-apps Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] This PR appears safe to merge within its stated cleanup scope.

Summary

This PR stops profile deletion when private payment-link withdrawal or contact removal is incomplete.

  • Profile deletion stops when payment-link or contact cleanup is incomplete.
  • Profile removal waits for local Paykit cleanup before reporting success.

No actionable issues were found. Tests were inspected but not run.

ben-kaufman explicitly deferred session ownership, manual-retry binding, restart reconciliation, and cross-device conditional removal. This assessment does not claim those limitations are resolved.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Confirm profile deletion] --> B[Withdraw private payment endpoints]
    B -->|Failure| F[Show deletion failure options]
    B -->|Success| C[Read and remove contacts]
    C -->|Read error or uncertain result| F
    C -->|Partial removal| D[Keep unacknowledged contact data]
    D --> F
    C -->|Complete| E[Delete profile and sign out]
    E -->|Failure| F
    E -->|Success| G[Clear local private payment state]
    G -->|Failure| H[Show error without success or deletion retry]
    G -->|Success| I[Report success and leave profile]
Loading

Reviews (1) · Last reviewed commit: "fix: stop profile deletion after incompl..." · Reviewed by Greptile

@ovitrif ovitrif added this to the 3.1.0 milestone Oct 25, 2026
@ovitrif

ovitrif commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

Triage: 3.1.0. It fixes shipped profile deletion, and 3.1.0 is due 28 Oct.

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Regtest APK

Built from 2daec90 (run).

Download bitkit-dev-debug universal APK (expires in 30 days).

@talosmachina talosmachina left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No findings. Profile deletion now stops when private endpoint withdrawal, the contact read or the contact removal is incomplete, keeps the contacts and overrides that were not acknowledged as removed, and no longer reports success when local finalization fails. Reviewed 479c530, full tier, stacked on #1688 (fix/profile-delete-session-recovery), so the delta against that base is what I read.

What I checked, and 6 candidates I ruled out

Read in full: PubkyRepo.deleteProfile / deleteAllContacts / signOut / clearAuthenticatedState, EditProfileViewModel.attemptDeleteProfile / disconnectProfile, PrivatePaykitRepo.removePublishedEndpointsForCleanup / removePublishedEndpointsLocked / closeAndClear, PaykitSdkService.contactRecords / removeContacts, and remove_contacts_and_block_peers / contact_records in paykit-rs at v0.1.1-rc1 (the pinned paykit version).
Call sites traced: deleteAllContacts (only deleteProfile), removePublishedEndpointsForCleanup(..., deferAppSync = true) (only the delete flow), closeAndClear (delete and disconnect).
CI: build-local green; build, lint and the e2e matrix still running when this was posted.

Ruled out

  • A missing-storage read now blocks deletion: dropping the isMissingPubkyData() fallback is safe because contact_records is a local SDK storage transaction; it fails only on an uninitialized identity or a storage error, neither of which is evidence of an empty contact list.
  • Overrides of local-only contacts survive a full success: the old code cleared them to emptyMap(), the new code subtracts only removedKeys; on success signOut() reaches clearAuthenticatedState, whose pubkyStore.reset() drops them anyway.
  • Key format mismatch between records, overrides and _contacts: all three are compared after ensurePubkyPrefix(), and override keys are written prefixed (PubkyRepo.kt:2054); the new partial-removal test exercises the subtraction.
  • Contacts the SDK retains by design (active recurring request, peer lease, published marker) now make deletion fail on every retry: this is the stated intent of the PR and its journey (partial removal preserves contacts and prevents deletion), not an accident.
  • User left on Edit Profile after closeAndClear fails post-deletion: deliberate per the new failed local finalization does not report deletion success test; the failure dialog is not shown because the remote deletion did happen.
  • Endpoint withdrawal failure leaves the deletion flag set: endProfileDeletion() stays in the finally under NonCancellable, and removePublishedEndpointsForCleanup marks contactSharingCleanupPending on failure.

Not built here: on the Bitkit track an auto review does not run a PR branch's Gradle build, so this is reasoned from code and CI.

Merge confidence: 4/5, no findings, but the change is reasoned from code rather than built here, and build, lint and e2e had not finished.

Reviewer model: claude-opus-5-5
Reasoning effort: Unknown (not exposed to the reviewing session)

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code scan: one LOW inline, reproduced with a failing unit test.

Checked and clean:

  • When cleanup is incomplete, the user gets the existing Retry / Disconnect / Cancel dialog.
  • Retry is idempotent after a late signOut failure, because fetch_paykit_profile returns None for an absent profile, so dropping the 404 tolerance is safe.
  • No path locks the user out of deletion or sign-in for good.
  • Private endpoints are now withdrawn before the profile delete, which closes a gap on master.
  • With #1688 removing the automatic replay, each stop is final until a manual Retry; the two don't conflict.
  • profile-cleanup-failure.xml's fault point is right: contact removal goes through the shared-state storage adapter (PubkyShared, put_locked), so a homeserver proxy can fail it.
  • Twin synonymdev/bitkit-ios#1124 clears the cleanup flag on a successful withdrawal, so it doesn't have the LOW.

Device gate (partial): 2daec90 — not run yet. profile-cleanup-failure.xml needs a homeserver fault proxy. The partial-removal stop can be driven without one, using a contact with an active recurring payment request on two devices: deletion stops, the dialog shows, and after Cancel the contact can or can't pay privately. Device results will follow in a separate review.

privatePaykitRepo.removePublishedEndpointsForCleanup(TAG, deferAppSync = true)
val result = pubkyRepo.deleteProfile()
val result = runSuspendCatching {
privatePaykitRepo.removePublishedEndpointsForCleanup(TAG, deferAppSync = true).getOrThrow()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When deletion stops after this deferred withdrawal succeeded, private endpoints stay withdrawn until the next foreground or network event. With deferAppSync = true, a successful withdrawal leaves cleanupPending = true (PrivatePaykitRepo.kt:508-513). scheduleContactPreparation returns early while it's set. endProfileDeletion() only flips the in-progress flag, and AppViewModel's follow-up synchronizePaykitContacts → scheduleSavedContactPreparation also does nothing. Only retryPendingEndpointRemoval clears the flag, and it runs on foreground (ContentView.kt:319 → AppViewModel.kt:871), network restore, channel events or a USDT availability change.

So after Cancel on the failure dialog the user is signed in, but their contacts can't pay them privately until the app is backgrounded and reopened. Master had the same deferred flag only on the delete-fails branch. This PR adds three stop cases that now reach it: a partial contact removal, a contact-read error, and a 404 from the profile delete. iOS clears the flag on any successful withdrawal (PubkyProfileManager.swift:1498).

Fix: keep the flag (the crash-recovery test interrupted profile deletion retries registry sync from persisted cleanup state depends on it) and reconcile on the stop path instead, in this function's finally:

withContext(NonCancellable) {
    privatePaykitRepo.endProfileDeletion()
    if (_uiState.value.showDeleteFailureDialog) {
        privatePaykitRepo.retryPendingEndpointRemoval(pubkyRepo.contacts.value.map { it.publicKey })
        privatePaykitRepo.refreshKnownSavedContactEndpoints(TAG)
    }
}

In production, route this through contactPaymentSettingsRepo.reconcilePendingEndpoints or an equivalent entry point, as the foreground path does.

LOW.

Reproduction

EditProfileViewModelTest > stopped deletion reconciles withdrawn private endpoints fails at 2daec90 and passes with the fix (20/20):

Wanted but not invoked:
privatePaykitRepo.retryPendingEndpointRemoval(...)

PrivatePaykitRepoTest > stopped profile deletion keeps private endpoints withdrawn until pending cleanup is retried passes at head by design. It shows the stuck state against the real repo: no ensureLinkWithPeer after preparation is rescheduled, with the flag still set. A retry plus a refresh republishes.

./gradlew testDevDebugUnitTest --tests 'to.bitkit.ui.screens.profile.EditProfileViewModelTest' --tests 'to.bitkit.repositories.PrivatePaykitRepoTest'

diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt
index 5e0aa48fe..cb5ef2438 100644
--- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt
+++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt
@@ -370,6 +370,38 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) {
         sut.closeAndClear()
     }
 
+    @Test
+    fun `stopped profile deletion keeps private endpoints withdrawn until pending cleanup is retried`() = test {
+        settingsData.update {
+            SettingsData(
+                sharesPrivatePaykitEndpoints = true,
+                publicPaykitLightningEnabled = false,
+                publicPaykitUsdtEnabled = false
+            )
+        }
+        cacheData.update { it.copy(contacts = mapOf(CONTACT_KEY to cachedPublishedContact())) }
+        sut = createSut()
+
+        sut.beginProfileDeletion()
+        sut.removePublishedEndpointsForCleanup("profile deletion", deferAppSync = true).getOrThrow()
+        sut.endProfileDeletion()
+        sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow()
+        runCurrent()
+        sut.awaitContactPreparation()
+
+        assertTrue(cacheData.value.cleanupPending)
+        verify(paykitSdkService, never()).ensureLinkWithPeer(CONTACT_KEY)
+
+        sut.retryPendingEndpointRemoval(listOf(CONTACT_KEY)).getOrThrow()
+        sut.refreshKnownSavedContactEndpoints("profile deletion stopped").getOrThrow()
+        runCurrent()
+        sut.awaitContactPreparation()
+
+        assertFalse(cacheData.value.cleanupPending)
+        verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY)
+        sut.closeAndClear()
+    }
+
     @Test
     fun `handleOnchainActivity skips SDK access when publication is locally unnecessary`() = test {
         settingsData.value = SettingsData(
diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt
index 30f48762a..eda68d8ea 100644
--- a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt
+++ b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt
@@ -12,6 +12,7 @@ import kotlinx.coroutines.test.advanceUntilIdle
 import org.junit.Test
 import org.mockito.Mockito.clearInvocations
 import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
 import org.mockito.kotlin.doSuspendableAnswer
 import org.mockito.kotlin.eq
 import org.mockito.kotlin.inOrder
@@ -40,6 +41,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
     private val context: Context = mock()
     private val pubkyRepo: PubkyRepo = mock()
     private val privatePaykitRepo: PrivatePaykitRepo = mock()
+    private val contacts = MutableStateFlow<List<PubkyProfile>>(emptyList())
 
     @Test
     fun `disconnect finishes private cleanup after the screen is closed`() = test {
@@ -318,6 +320,28 @@ class EditProfileViewModelTest : BaseUnitTest() {
         }
     }
 
+    @Test
+    fun `stopped deletion reconciles withdrawn private endpoints`() = test {
+        whenever(pubkyRepo.deleteProfile()).thenReturn(Result.failure(TestAppError("contact retained")))
+        contacts.value = listOf(createProfile())
+        whenever { privatePaykitRepo.retryPendingEndpointRemoval(any()) }.thenReturn(Result.success(Unit))
+        whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any(), any(), anyOrNull()) }
+            .thenReturn(Result.success(Unit))
+        val sut = createSut()
+        advanceUntilIdle()
+
+        sut.deleteProfile()
+        advanceUntilIdle()
+
+        assertTrue(sut.uiState.value.showDeleteFailureDialog)
+        inOrder(privatePaykitRepo).apply {
+            verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))
+            verify(privatePaykitRepo).endProfileDeletion()
+            verify(privatePaykitRepo).retryPendingEndpointRemoval(listOf(TEST_PUBLIC_KEY))
+            verify(privatePaykitRepo).refreshKnownSavedContactEndpoints(any(), any(), any(), anyOrNull())
+        }
+    }
+
     @Test
     fun `deleteProfile should show retry dialog when delete still fails`() = test {
         whenever(pubkyRepo.deleteProfile()).thenReturn(
@@ -426,6 +450,10 @@ class EditProfileViewModelTest : BaseUnitTest() {
         whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true)) }
             .thenReturn(Result.success(Unit))
         whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit))
+        whenever(pubkyRepo.contacts).thenReturn(contacts)
+        whenever { privatePaykitRepo.retryPendingEndpointRemoval(any()) }.thenReturn(Result.success(Unit))
+        whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any(), any(), anyOrNull()) }
+            .thenReturn(Result.success(Unit))
 
         return EditProfileViewModel(
             context = context,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The retained cleanup flag and the missing immediate follow-up are real at this head. I am addressing this with the original-owner/restart repair tracked in #1687 rather than automatically republishing after an uncertain destructive operation. That candidate persists a removal obligation before cleanup, shows pending recovery, and offers read-only Review followed by a new confirmation or an explicitly local Disconnect. It deliberately keeps authenticated publication paused until the obligation is resolved. Its UI integration and controlled retry tests are under final validation; it is not published yet. I will link the follow-up and its evidence here. Leaving this thread open.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The integrated recovery follow-up is now published in #1693 (stacked on this PR). It persists pending removal before destructive work, keeps authenticated publication paused after an uncertain outcome, and exposes read-only Review with a fresh original-owner confirmation or explicit local-only Disconnect. It deliberately does not republish automatically after a potentially completed deletion. Final 4,725 unit/component tests, full compile/format/lint and three installed Compose recovery cases passed; two consecutive independent final reviews are clean and both commits are signed/verified. Complete authenticated fault journeys and native contact CAS remain documented gaps in #1687. Leaving this thread open for review of that recovery behavior rather than claiming the parent alone restores publication safely.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: ✅ Approve

Findings: I found no additional defects. The existing publication-pause finding remains acknowledged at this head; the author's follow-up tracks its recovery separately.

QA for 2daec90: Android 16 / API 36, Core 0.8.0-rc11 and Paykit 0.1.1-rc1. Device cases used this head plus the test fixture below, with disposable identities and an unfunded regtest wallet. The real Edit Profile screen, ViewModel, repositories and native SDK handled deletion; a homeserver proxy supplied the faults. The installed APK hash matched the built fixture APK.

Test 1: Manual 1 / J1 — private cleanup fails ✅ Failure stops before contact or profile deletion.

The proxy returned 503 for the native shared-state read during private cleanup. After Cancel, the refreshed inspector confirmed the session, both contacts and overrides, and both sharing settings remained. Call counters and the proxy trace confirmed no contact or profile deletion.

1a.2x.mp4

Private cleanup failure and retained state, at 2× speed.

Test 2: Manual 1 / J1 — unconfirmed contact removal ✅ Local evidence survives without entering profile deletion.

The proxy accepted the native write-ahead record, then rejected shared-state publication with 503. The failure dialog appeared. The post-failure inspector and call trace confirmed both contacts and overrides remained, with no profile deletion or success feedback.

Failure flow Refreshed post-failure inspector
1b.2x.mp4

Unconfirmed cleanup at 2× speed; the still shows the refreshed counters after Cancel.

Test 3: Manual 1 / J1 — partial native removal ✅ Only the acknowledged contact and override disappear.

The SDK retained a contact with a published public marker and acknowledged the other removal. The inspector showed one remaining contact and override; the persisted override key matched the retained marker. Failure appeared without entering profile deletion. The marker was provisioned through the native SDK.

1c.2x.mp4

Partial removal and retained evidence, at 2× speed.

Test 4: Manual 2 — profile deletion returns 404 ✅ Failure preserves the session and sharing settings.

The proxy trace confirmed profile GET 200 followed by DELETE 404. The app showed failure and retained its session and both sharing settings, with no success feedback. Earlier acknowledged contact removals remained applied, as the refreshed inspector shows.

2.2x.mp4

Rejected profile deletion and retained session, at 2× speed.

Test 5: J1 — Cancel and cold restart ✅ Normal startup restores the same identity without replaying profile deletion.

After unconfirmed contact cleanup and Cancel, a cold launch through the normal app entry restored the same authenticated identity on Home and Profile. The profile QR identifier and restoration log confirmed the owner. Proxy sampling covered over 85 seconds, with successful profile reads and no profile DELETE.

restart.2x.mp4

Normal cold startup and restored profile, at 2× speed.

Check 1: unmodified-head build and regression checks ✅ The app build and affected suites pass.

assembleDevDebug and testDevDebugUnitTest with PubkyRepoTest and EditProfileViewModelTest passed on the unmodified head. git diff --check passed. These focused checks include unavailable contact reads, uncertain/partial removals, 404-like profile errors, private cleanup failure and failed local finalization.

Full review of 99b446a..2daec90, all six changed files and reached cleanup paths. Contact-read errors and local-finalization storage failures had source/unit coverage rather than separate device cases. Active-recurring and peer-lease retention variants were not separately driven. Manual Retry/Disconnect ownership and native cross-device conditional removal remain outside this PR's scope.

Twin: bitkit-ios#1124 fix: stop deletion after incomplete cleanup matches the scoped cleanup/error propagation behavior at a062618; its authenticated device report supplies the iOS evidence.

Test fixture used by Tests 1–5

Not pushed. Provisions disposable native identities and contacts, displays call/state evidence, and renders the actual Edit Profile screen. Public-contact publication is enabled to stage the retained-marker variant. SDK calls return native results. Test 5 launches the normal app entry.

diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
index b88fe73a7..304e984d2 100644
--- a/app/src/debug/AndroidManifest.xml
+++ b/app/src/debug/AndroidManifest.xml
@@ -6,6 +6,8 @@
         android:usesCleartextTraffic="true"
         tools:ignore="MissingApplicationIcon">
 
+        <activity android:name="to.bitkit.dev.CleanupProbeActivity" android:exported="true" /> <!-- test hook -->
+
         <provider
             android:name="to.bitkit.dev.DevToolsProvider"
             android:authorities="${applicationId}.devtools"
diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
index 87c452f64..011d57041 100644
--- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
+++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
@@ -666,6 +666,11 @@ class PaykitSdkService @Inject constructor(
     suspend fun fetchPubkyFollows(publicKey: String): List<String> =
         publicRead { it.fetchPubkyFollows(publicKey, maxEntries = 10_000u) }
 
+    internal suspend fun reviewPublishContact(key: String) { // test hook: provision real native retained marker
+        isSetup.await() // test hook
+        operationLock.withLock { withStateRevisionTracking { handle -> completeSdkCall { handle.publishPublicContact(key) } } } // test hook
+    } // test hook
+
     suspend fun contactRecords(): List<ContactRecord> {
         isSetup.await()
         return operationLock.withLock {
@@ -1952,7 +1957,7 @@ internal object BitkitPaykitSdkConfig {
 }
 
 internal fun paykitSdkConfig() = defaultConfig("bitkit").copy(
-    publicContactSharing = BitkitPaykitSdkConfig.publicContactSharing,
+    publicContactSharing = com.synonym.paykit.PublicContactSharingPolicy.ENABLED, // test hook: native public-marker fixture
 )
 
 internal fun paykitPubkyClientConfig(
diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt
index e9105a40c..560146420 100644
--- a/app/src/main/java/to/bitkit/services/PubkyService.kt
+++ b/app/src/main/java/to/bitkit/services/PubkyService.kt
@@ -208,6 +208,7 @@ class PubkyService @Inject constructor(
     }
 
     suspend fun deletePaykitProfile() = ServiceQueue.CORE.background {
+        to.bitkit.dev.CleanupProbe.deletes++; to.bitkit.dev.CleanupProbe.note("profile delete entered") // test hook
         paykitSdkService.deletePaykitProfile()
     }
 
@@ -216,6 +217,7 @@ class PubkyService @Inject constructor(
     }
 
     suspend fun contactRecords(): List<ContactRecord> = ServiceQueue.CORE.background {
+        to.bitkit.dev.CleanupProbe.reads++; to.bitkit.dev.CleanupProbe.note("contact read entered") // test hook
         paykitSdkService.contactRecords()
     }
 
@@ -252,6 +254,7 @@ class PubkyService @Inject constructor(
     }
 
     suspend fun removeContacts(publicKeys: List<String>): List<ContactRecord> = ServiceQueue.CORE.background {
+        to.bitkit.dev.CleanupProbe.removals++; to.bitkit.dev.CleanupProbe.note("contact remove entered count=${publicKeys.size}") // test hook
         paykitSdkService.removeContacts(publicKeys)
     }
 
diff --git a/app/src/debug/java/to/bitkit/dev/CleanupProbeActivity.kt b/app/src/debug/java/to/bitkit/dev/CleanupProbeActivity.kt
new file mode 100644
index 000000000..f0c70a077
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/CleanupProbeActivity.kt
@@ -0,0 +1,113 @@
+package to.bitkit.dev
+
+// test hook: disposable native fixture and inspector; deletion decisions use the unchanged screen/viewmodel/repositories.
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.lifecycleScope
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.first
+import to.bitkit.data.PubkyStore
+import to.bitkit.data.SettingsStore
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.repositories.*
+import to.bitkit.services.*
+import to.bitkit.ui.screens.profile.*
+import to.bitkit.ui.theme.AppThemeSurface
+import java.security.SecureRandom
+import javax.inject.Inject
+
+object CleanupProbe {
+    var reads = 0
+    var removals = 0
+    var deletes = 0
+    fun note(s: String) { Log.i("CleanupProbe", s) }
+}
+
+@AndroidEntryPoint
+class CleanupProbeActivity : ComponentActivity() {
+    @Inject lateinit var service: PubkyService
+    @Inject lateinit var sdk: PaykitSdkService
+    @Inject lateinit var repo: PubkyRepo
+    @Inject lateinit var privateRepo: PrivatePaykitRepo
+    @Inject lateinit var pubkyStore: PubkyStore
+    @Inject lateinit var settings: SettingsStore
+    @Inject lateinit var keychain: Keychain
+    @Inject lateinit var wallet: WalletRepo
+    private var status by mutableStateOf("Preparing disposable native identity")
+    private var evidence by mutableStateOf("")
+    private var vm by mutableStateOf<EditProfileViewModel?>(null)
+    private var exited by mutableStateOf(false)
+    private var successToasts = 0 // test hook
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContent {
+            AppThemeSurface {
+                Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
+                    Text(status, Modifier.padding(horizontal = 12.dp))
+                    Text(evidence, Modifier.padding(horizontal = 12.dp))
+                    Row {
+                        TextButton(onClick = { lifecycleScope.launch { inspect() } }) { Text("Inspect evidence") }
+                        TextButton(onClick = { vm?.dismissDeleteFailureDialog() }) { Text("Cancel failure") }
+                    }
+                    if (exited) Text("Deletion success navigation")
+                    vm?.let { current ->
+                        Box(Modifier.weight(1f)) {
+                            EditProfileScreen(current, onBackClick = {}, onExitProfile = { exited = true })
+                        }
+                    }
+                }
+            }
+        }
+        lifecycleScope.launch { to.bitkit.ui.shared.toast.ToastEventBus.events.collect { toast -> if (toast.type == to.bitkit.models.Toast.ToastType.SUCCESS) successToasts++; CleanupProbe.note("toast ${toast.type}: ${toast.title}") } } // test hook
+        lifecycleScope.launch {
+            try {
+                if (!keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)) wallet.createWallet(null).getOrThrow() // test hook: unfunded wallet for normal cold startup
+                service.initialize()
+                if (intent.getBooleanExtra("resume", false)) {
+                    repo.initialize().getOrThrow()
+                    status = "Restored through normal profile initialization"
+                } else {
+                    val secret = ByteArray(32).also { SecureRandom().nextBytes(it) }.joinToString("") { "%02x".format(it) }
+                    val home = service.publicKeyFromSecret("00".repeat(32))
+                    service.signUp(secret, home, null)
+                    service.publishPaykitProfile(com.synonym.paykit.PaykitProfile("Disposable cleanup identity", null, null))
+                    repo.initialize().getOrThrow()
+                    val peers = listOf("11", "22").map { service.publicKeyFromSecret(it.repeat(32)) }
+                    for ((index, peer) in peers.withIndex()) {
+                        service.saveContact(peer, "Peer ${index + 1}", restorePrivateConnection = true)
+                    }
+                    if (intent.getBooleanExtra("retained", false)) sdk.reviewPublishContact(peers.last())
+                    repo.loadContacts()
+                    pubkyStore.update { it.copy(contactProfileOverrides = repo.contacts.value.associate { c -> c.publicKey to c.toProfileData() }) }
+                    settings.update { it.copy(sharesPrivatePaykitEndpoints = true, sharesPublicPaykitEndpoints = true) }
+                    status = "Authenticated disposable identity"
+                }
+                CleanupProbe.reads = 0; CleanupProbe.removals = 0; CleanupProbe.deletes = 0
+                vm = EditProfileViewModel(applicationContext, repo, privateRepo)
+                CleanupProbe.note("READY owner=${repo.publicKey.value}")
+                inspect()
+            } catch (e: Exception) {
+                status = "Provisioning failed: ${e.message}"
+                CleanupProbe.note(status)
+                Log.e("CleanupProbe", "setup", e)
+            }
+        }
+    }
+
+    private suspend fun inspect() {
+        val state = settings.data.first()
+        val session = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) != null
+        val overrides = pubkyStore.data.first().contactProfileOverrides.size
+        evidence = "Session $session; contacts ${repo.contacts.value.size}; overrides $overrides; sharing ${state.sharesPublicPaykitEndpoints}/${state.sharesPrivatePaykitEndpoints}; reads ${CleanupProbe.reads}, removals ${CleanupProbe.removals}, deletes ${CleanupProbe.deletes}; success $exited; success toasts $successToasts"
+        CleanupProbe.note(evidence)
+    }
+}
Case 1a: Session true; contacts 2; overrides 2; sharing true/true; reads 0, removals 0, deletes 0; success false; success toasts 0
Case 1b: Session true; contacts 2; overrides 2; sharing true/true; reads 1, removals 1, deletes 0; success false; success toasts 0
Case 1c: Session true; contacts 1; overrides 1; sharing true/true; reads 1, removals 1, deletes 0; success false; success toasts 0
Case 2: Session true; contacts 0; overrides 0; sharing true/true; reads 1, removals 1, deletes 1; success false; success toasts 0

Reviewer model: gpt-6.1-sol
Reasoning effort: high


Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utAck

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA review

Scope: full review of the complete PR diff against its merge base 99b446a (the head of dependency #1688), at 2daec90. No earlier QA baseline existed. Coverage: PubkyRepo.deleteProfile / deleteAllContacts; the error paths in EditProfileViewModel for profile deletion and Disconnect; the callers PrivatePaykitRepo.removePublishedEndpointsForCleanup / closeAndClear, signOut / clearAuthenticatedState, and the PaykitSdkService contact and profile wrappers; the updated unit tests and the new journey. Also checked: the Paykit SDK contracts at paykit-rs v0.1.1-rc1 (f384694) and parity with iOS twin bitkit-ios#1124 at a062618.

1 actionable finding — resolve or provide an evidence-backed rebuttal.

The stricter stop-on-incomplete-cleanup behaviour looks sound for transient failures. Dropping the 404/"missing" tolerance is safe: in the SDK, fetch_paykit_profile returns None when the profile is already gone, so deletePaykitProfile completes normally, and contact_records is a local storage read. Contact keys are compared after normalising the pubky prefix in every place. Returning early when there are no contacts leaves no stale state, because the successful signOut() path calls pubkyStore.reset() and clears the contact list. One problem remains: contacts that the SDK keeps on purpose, because of an active recurring subscription, now block profile deletion permanently without saying why (see the inline finding). The iOS twin has the same behaviour: removeContacts skips subscribed contacts and the best-effort wrapper is gone, so the concern applies to both platforms.

Existing unresolved thread: deferred withdrawal leaves private endpoints withdrawn after a stopped deletion. The author says it is tracked in #1687 / #1693. I did not re-report it here.

Validation: no build or tests were run in this review. The author's CI at this head passed: CI, Lint, E2E. None of these exercises the new cleanup-fault journey.

Device testing: not performed in this review.

Suggested additional test cases

  • Android, two wallets with different Pubky identities (A and B), plus a third saved contact C on A. A saves B and C as contacts and holds an active recurring subscription with B, as payer or payee. On A, go to Edit Profile and delete the profile. Expected: deletion is refused with a message naming the active subscription, and C plus the private payment links are still intact. Then tap Retry. Expected: it does not loop on the same generic failure.

Findings

  • [MEDIUM] An active subscription with a saved contact now blocks profile deletion permanently, with no explanation — inline at app/src/main/java/to/bitkit/repositories/PubkyRepo.kt:1116.

Reviewer model: claude-opus-5-5
Reasoning effort: high

updateContacts { current -> current.filterNot { it.publicKey in removedKeys } }
}
markContactsLoaded()
if (removedKeys != keys) throw PrivatePaykitError.PrivateUnavailable

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] An active subscription with a saved contact now blocks profile deletion permanently, with no explanation

Trigger: the user has a saved contact with an ACTIVE_RECURRING payment request that has not ended (either payer or payee role), and confirms Delete Profile.

Mechanism: PaykitSdkService.removeContacts (PaykitSdkService.kt:807-815) silently leaves out subscribed counterparties before calling removeContactsAndBlockPeers. This is a deliberate retention policy: PaykitSdkServiceTest active subscription prevents deletion until it ends asserts it returns an empty list. In this PR, deleteAllContacts compares the returned keys to every record and throws PrivatePaykitError.PrivateUnavailable when they differ. By the time it throws, attemptDeleteProfile has already withdrawn the private payment links (removePublishedEndpointsForCleanup), and the SDK has removed and blocked every other saved contact. Nothing checks for active subscriptions beforehand, and the error carries no subscription reason. The single-contact delete path does report one (PubkyContactError.ActiveSubscription).

Consequence: this path shows the generic dialog "We couldn't delete your profile data. Retry, or disconnect…". Every Retry withdraws the links again and fails the same way until the subscription is cancelled or ends. The user is never told why, and is left with a profile that still exists, its other contacts already removed and blocked, and its links withdrawn. Disconnect is the only way out, and it leaves the profile published.

Expected behavior: contacts kept on purpose because of a subscription should be treated differently from unknown or failed removals. Check for active subscriptions before any destructive step and show an actionable reason (as the single-contact delete does), or exclude these contacts from the incomplete-cleanup stop.

Evidence basis: source analysis at 2daec90 and paykit-rs f384694 (remove_contacts_and_block_peers doc: a skipped record does not stop the others from being removed). Not reproduced on a device. An earlier bot review called this stop intended. This finding is about the consequences above (non-actionable message, a Retry that always fails, destructive steps before the check), not about the stop itself.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants