Repository navigation
fix: bind profile removal to its original owner - #1693
ben-kaufman wants to merge 5 commits into
Conversation
|
Triage: 3.1.0, with #1687 that it fixes (shipped profile deletion). |
[High impact] The change reaches profile removal, authenticated Paykit work, and wallet teardown. Fix the stuck recovery warning and stale wallet state after credential erasure before merging. Findings
SummaryThis PR binds profile removal to the confirmed owner and adds durable local recovery.
Acknowledged scope: ben-kaufman explicitly deferred native cross-device conditional contact deletion and complete iOS integration under #1687. Complete authenticated journeys and controlled teardown tests are also listed as unfinished; these were not treated as new findings. Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Capture owner and records] --> B[User confirms once]
B --> C[Check owner and records]
C --> D[Persist pending cleanup]
D --> E[Remove remote data]
E --> F[Erase local credentials]
F --> G[Clear private state]
G --> H[Clear pending cleanup]
E -->|Failure| I[Review or local Disconnect]
F -->|Failure| J[Local Disconnect]
G -->|Failure| J
I -->|Review| A
Reviews (1) · Last reviewed commit: "fix: bind profile removal to its origina..." · Reviewed by Greptile |
talosmachina
left a comment
There was a problem hiding this comment.
1 finding (1 P1). Binds profile deletion and disconnect to the confirmed identity, with a durable local cleanup obligation, a read-only Review, and wallet-wipe draining. Reviewed ff4dc35e, oversized tier: 59 files. I reasoned from the code; I did not build here, per the Bitkit track's rule for auto reviews.
What I checked, and 3 candidates I ruled out
Read in full: ProfileRemovalStore.kt, ProfileRemovalCoordinator.kt, PubkyIdentityOperations.kt.
Read as diff: PubkyRepo.kt, PaykitSdkService.kt, PaykitSdkOperationLock.kt, ContactPaymentSettingsRepo.kt, WipeWalletUseCase.kt, AppViewModel.kt, ProfileViewModel.kt, PubkyService.kt.
Not read: the remaining UI, backup and test files.
CI: build is red. One unit test failed, PubkyAuthManifestTest > signup and authorization links resolve only through their enabled aliases (pubkyring://signup?hs=homeserver resolved where none was expected): 4725 tests, 1 failed. The diff doesn't touch the manifest or the alias registrar, and build passed on #1690, so I couldn't tie the failure to this change. It still needs a look or a re-run. detekt and build-local are green; e2e was still running.
Ruled out
- Wipe deadlock against an in-flight removal:
withWalletWiperevokes the removal and awaitsdrainedbeforeacquire, andacquire(admittedGeneration)refuses a stale caller instead of queueing it behind the wipe. - Review widening the target set:
reviewCeilingis shared across confirmations and only replaced by a subset (containsAll).remove()rejects a confirmation whose deletion is no longer the ceiling. - Local disconnect replaying remote work after restart:
prepareSignOutsetslocalOnlywhenever a pending attempt exists, so onlyfinishProfileRemovalLocallyand the private and marker completion run. - Cold reader: it timed out on the finding, so the P1 stands on my own trace of the call chain.
Merge confidence: 3/5, one P1, the oversized tier, and build red for a reason I could not tie to the change.
Reviewer model: claude-opus-5-5
Reasoning effort: Unknown (not exposed to the reviewing session)
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
There was a problem hiding this comment.
Verdict: ✅ Approve
Findings: One LOW: pending cleanup raises repeated “session expired” errors and asks for reconnection while cleanup blocks it (app/src/main/java/to/bitkit/repositories/PubkyRepo.kt:376). The recovery actions themselves completed safely.
QA for 7e2e466: Android 16 / API 36, dev/regtest, disposable profile, controlled homeserver. I built the unmodified head, then drove 7e2e466 plus the unpublished test hooks below. Native SDK results were not mocked. Videos are 2×.
Test 1: J1 — acknowledged removal ✅ Zero and 62 contacts completed; preparation did not mutate remote state.
I repeated 62 contacts after settling and immediately after native import. The immediate run took 7.7 s from confirmation to success; the zero/settled runs each completed within a 10 s interval in their raw recordings. Progress stayed visible and Save stayed disabled. The native acknowledgement and cleared credentials/marker were checked in the logs; Home/reopen showed onboarding.
J1-immediate62-final-2x.2x.mp4
Immediate native import of 62 contacts through removal and reopening.
Test 2: J2 — uncertain acknowledgement ✅ One accepted DELETE remained one through Review, cancellation and cold launch.
The proxy logged upstream DELETE 204, then returned 503 to the app. Review performed reads before a new confirmation. Canceling it did not mutate. Cold launch retained recovery without authenticating. Canceling local Disconnect left it pending; confirming it cleared local state. The request trace before and after local Disconnect was identical.
J2-repeat-2x.2x.mp4
Lost acknowledgement, read-only Review, restart and explicit local recovery.
Test 3: J3 — incomplete cleanup ✅ All four fault variants stopped at the failed boundary without false success.
Request and state logs confirmed: failed private withdrawal caused no contact/profile deletion; an unknown contact-state write retained contacts; a native partial batch removed one of two records while retaining its published peer; profile DELETE 404 remained a failure. The partial repeat ran with the default sharing policy after the marker was seeded. Remaining-contact projection was checked on-device; override retention was checked in the implementation and tests.
| Native partial removal | Private withdrawal failure |
|---|---|
J3-partial-production-config-2x.2x.mp4 |
J3-private-repeat-2x.2x.mp4 |
| The private-failure capture also shows Review rejecting a later native profile edit. |
Test 4: J4 — recovery ✅ Canceled or stale confirmations could not widen removal; local recovery and Receive remained available.
Device logs and request counters covered initial cancellation, a pre-mutation edit (plain error, no marker), a native edit after failure, a record recreated outside the narrowed retry ceiling, and a newer same-key sign-in. Cold launch without credentials showed pending Disconnect. I also opened ordinary Receive and checked its generated regtest address. This capture shows late local failure followed by successful recovery from the existing Profile screen.
J4-live-recovery-2x.2x.mp4
Credentials erased, sharing write failed, existing Profile recovered without reopening.
Test 5: manual 1 — original owner ✅ Revoked work could neither act on nor report success for a replacement sign-in.
The after-marker hold was canceled and drained before replacement admission. Old Review and Disconnect both returned identity-changed errors. In the second run, the non-cancellable local tail rejected replacement and held the real repository/SDK teardown barriers until release. The hook trace checked ordering, replacement credentials and the absence of stale success/navigation.
| Revoked original removal | Held local completion |
|---|---|
M1-held-owner-2x.2x.mp4 |
M1-held-tail-2x.2x.mp4 |
Test 6: manual 2 — strict local completion ✅ Every injected local checkpoint retained recovery until acknowledged cleanup completed.
I faulted credential erasure, sharing, metadata, private completion, cache, attribution and marker persistence, cold-starting each interrupted state. The seeded consumed-list watermark stayed present through failed credential/cache writes; it cleared only after credentials were erased and cache cleanup succeeded. The marker stayed set at the final write failure and cleared last. Local retries generated no remote request. The repeated expired-session toasts are the LOW finding above.
| Credential failure and restart | Final marker-write failure |
|---|---|
M2-credentials-repeat-2x.2x.mp4 |
M2-marker-completion-2x.2x.mp4 |
Test 7: manual 3 — SDK admission ✅ The controlled admission/outer-owner race drained without deadlock and admitted fresh work.
This runs the actual SDK lock class on-device with controlled coroutines and an outer mutex. Manual 1 separately drives the real repository/SDK teardown barriers. The wipe body is deliberately empty; no wallet reset or funds movement ran. The deciding hook trace was:
lock race: stale outer owner rejected before SDK queue
lock race: wipe drained outer owner without wallet teardown
lock race: fresh SDK work admitted after teardown
M3-lock-race-2x.2x.mp4
On-device completion signal; ordering was checked in the trace above.
Check 1: build, review and scope ✅ Full pinned diff and prior feedback checked.
My unmodified-head app and instrumentation APK build passed, as did git diff --check. I reviewed all 64 changed files and reached callers with an independent reviewer, and verified all nine prior review fixes. I read the PR discussion, linked issue/dependency and twin descriptions in full. The existing hosted validation report supplies the broader unit/lint/component results; I did not rerun those suites.
Device journeys do not independently drive unreadable-marker I/O, backup scheduling, broken native initialization or a real wallet wipe. Those paths were checked in source and the regression tests. Native cross-device conditional contact deletion remains under #1687.
Twin: synonymdev/bitkit-ios#1124 covers the earlier acknowledged-cleanup repair; synonymdev/bitkit-ios#1125 covers SDK admission. Neither establishes complete ownership/restart parity.
Unpublished test hooks and deciding state trace
The hooks provision disposable native contacts/profile state, seed one consumed-list watermark, log state/toasts, suspend named boundaries, or fail the next local write. The public-marker fixture was prepared with sharing enabled, then that flag was removed and the app restarted with the default policy before the partial-removal repeat. The network fault proxy forwards real requests; its lost-acknowledgement rule changes only the response after the server accepts the DELETE. No native SDK result is replaced.
Representative deciding states from the device trace:
# failed credential erasure
snapshot pending=true owner=null contacts=0 session=true secret=true watermarks=1
# failed sharing/metadata/private/cache completion, after credential acknowledgement
snapshot pending=true owner=null contacts=0 session=false secret=false watermarks=1
# failed attribution/final-marker completion, after cache acknowledgement
snapshot pending=true owner=null contacts=0 session=false secret=false watermarks=0
# acknowledged local completion
snapshot pending=false owner=null contacts=0 session=false secret=false watermarks=0
# later native profile edit
Failed to disconnect profile; The profile changed after confirmation. Disconnect locally to finish cleanup.
# stale same-key sign-in
Failed to disconnect profile; The profile identity changed
App hook patch (not pushed):
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b845c2d30..2e25e2b76 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -65,6 +65,7 @@
android:roundIcon="${app_icon_round}"
android:supportsRtl="true"
android:theme="@style/Theme.App">
+ <receiver android:name="to.bitkit.qa.ReviewProbeReceiver" android:exported="true" />
<service
android:name=".androidServices.LightningNodeService"
android:foregroundServiceType="dataSync"
diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt
index 0df1da560..0391fced0 100644
--- a/app/src/main/java/to/bitkit/App.kt
+++ b/app/src/main/java/to/bitkit/App.kt
@@ -56,6 +56,7 @@ internal open class App : Application(), Configuration.Provider {
Crypto.installSecurityProvider()
super.onCreate()
Env.initAppStoragePath(filesDir.absolutePath)
+ to.bitkit.qa.ReviewProbe.init(this)
installUncaughtExceptionLogger()
SingletonImageLoader.setSafe { imageLoader }
currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) }
diff --git a/app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt b/app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt
index 8e4b77572..40b46674b 100644
--- a/app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt
+++ b/app/src/main/java/to/bitkit/data/ProfileRemovalStore.kt
@@ -128,6 +128,7 @@ class ProfileRemovalStore internal constructor(private val store: DataStore<Prof
/** Caller must retain removal ownership until all acknowledged teardown's local persistence has completed. */
internal suspend fun complete(attemptId: String) {
+ to.bitkit.qa.ReviewProbe.gate("marker-completion")
val access = currentCoroutineContext()[Access]
check(access?.store === this && access.attemptId == attemptId && access.active.get()) {
"Profile removal admission is required"
diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
index 04078a464..e4dd7be96 100644
--- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
@@ -600,7 +600,9 @@ class PrivatePaykitRepo @Inject constructor(
knownSavedContactKeys.clear()
unavailableLinkRetryAt.clear()
state = PrivatePaykitState()
+ to.bitkit.qa.ReviewProbe.gate("cache-completion")
cacheStore.reset()
+ to.bitkit.qa.ReviewProbe.gate("attribution-completion")
addressReservationRepo.clearContactAssignments(excludingPublicKeys = emptySet())
}
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index 18bc8ec91..9bad16ea9 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -1249,6 +1249,7 @@ class PubkyRepo @Inject constructor(
private suspend fun deleteContacts(records: List<ContactRecord>) {
if (records.isEmpty()) return
+ to.bitkit.qa.ReviewProbe.gate("before-contacts")
val keys = records.map { it.publicKey.ensurePubkyPrefix() }.toSet()
val removedKeys = pubkyService.removeContacts(records.map { it.publicKey })
.map { it.publicKey.ensurePubkyPrefix() }.toSet().intersect(keys)
@@ -1993,7 +1994,9 @@ class PubkyRepo @Inject constructor(
invalidateReadOnlyProfile()
evictPubkyImages()
profileWriteGeneration.incrementAndGet()
+ to.bitkit.qa.ReviewProbe.gate("sharing-completion")
clearPublicPaykitSharingState(publicPaykitCleanupPending = false)
+ to.bitkit.qa.ReviewProbe.gate("metadata-completion")
pubkyStore.reset()
} finally {
notifyBackupStateChanged()
diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt b/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt
index 7db69e37b..b931a7f06 100644
--- a/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt
+++ b/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt
@@ -55,6 +55,7 @@ internal class PaykitSdkOperationLock(
checkRecoveryAccess()
checkProfileRemoval(removal)
}
+ to.bitkit.qa.ReviewProbe.gate("sdk-admission")
acquire(priority, admittedGeneration)
return try {
currentCoroutineContext().ensureActive()
diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
index e1a18f4e0..3e4a38db1 100644
--- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
+++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
@@ -832,6 +832,16 @@ class PaykitSdkService @Inject constructor(
* and sign-in checks run under the operation lock.
* An empty selection is skipped without accessing the SDK.
*/
+ suspend fun reviewUnpublishContact(publicKey: String) {
+ isSetup.await()
+ operationLock.withLock { withStateRevisionTracking { handle -> completeSdkCall { handle.removePublicContact(publicKey) } } }
+ }
+
+ suspend fun reviewPublishContact(publicKey: String) {
+ isSetup.await()
+ operationLock.withLock { withStateRevisionTracking { handle -> completeSdkCall { handle.publishPublicContact(publicKey) } } }
+ }
+
suspend fun saveContacts(
updates: List<ContactUpdate>,
expectedIdentity: String? = null,
@@ -2107,7 +2117,7 @@ internal object BitkitPaykitSdkConfig {
}
internal fun paykitSdkConfig() = defaultConfig("bitkit").copy(
- publicContactSharing = BitkitPaykitSdkConfig.publicContactSharing,
+ publicContactSharing = if (to.bitkit.qa.ReviewProbe.enabled("public-contacts")) com.synonym.paykit.PublicContactSharingPolicy.ENABLED else BitkitPaykitSdkConfig.publicContactSharing,
)
internal fun paykitPubkyClientConfig(
@@ -2187,6 +2197,7 @@ internal class PaykitSdkSessionProvider(
}
override fun clearSessionAccess() = paykitStorageCallback("session_clear_failed") {
+ to.bitkit.qa.ReviewProbe.fail("credentials")
clearLiveSessionAccess()
keychain.accessBlocking {
delete(Keychain.Key.SHARED_PUBKY_SOURCE.name)
diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt
index a9a57d840..8fe7fbd87 100644
--- a/app/src/main/java/to/bitkit/services/PubkyService.kt
+++ b/app/src/main/java/to/bitkit/services/PubkyService.kt
@@ -234,7 +234,9 @@ class PubkyService @Inject constructor(
}
internal suspend fun deletePaykitProfile(snapshot: PaykitProfileDeletionSnapshot) = ServiceQueue.CORE.background {
+ to.bitkit.qa.ReviewProbe.gate("before-profile")
paykitSdkService.deletePaykitProfile(snapshot)
+ to.bitkit.qa.ReviewProbe.loseAcknowledgement()
}
suspend fun deletePaykitProfile() = ServiceQueue.CORE.background {
diff --git a/app/src/main/java/to/bitkit/usecases/ProfileRemovalCoordinator.kt b/app/src/main/java/to/bitkit/usecases/ProfileRemovalCoordinator.kt
index bf6e1c7a0..512bd2de3 100644
--- a/app/src/main/java/to/bitkit/usecases/ProfileRemovalCoordinator.kt
+++ b/app/src/main/java/to/bitkit/usecases/ProfileRemovalCoordinator.kt
@@ -127,6 +127,7 @@ class ProfileRemovalCoordinator @Inject constructor(
confirmation.deletion?.let { pubkyRepo.validateProfileDeletion(it) }
removalMayHaveStarted = true
removalStore.begin(confirmation.attemptId)
+ to.bitkit.qa.ReviewProbe.gate("after-marker")
if (!confirmation.localOnly) {
privatePaykitRepo.removePublishedEndpointsForCleanup(TAG, deferAppSync = true).getOrThrow()
pubkyRepo.removeBitkitPaymentEndpoints().getOrThrow()
@@ -136,6 +137,7 @@ class ProfileRemovalCoordinator @Inject constructor(
localCompletionStarted = true
pubkyRepo.finishProfileRemovalLocally()
withContext(NonCancellable) {
+ to.bitkit.qa.ReviewProbe.gate("private-completion")
privatePaykitRepo.finishProfileRemoval(pause).getOrThrow()
removalStore.complete(confirmation.attemptId)
privatePaykitRepo.notifyProfileRemovalCompleted(pause)
diff --git a/app/src/main/java/to/bitkit/ReviewProbe.kt b/app/src/main/java/to/bitkit/ReviewProbe.kt
new file mode 100644
index 000000000..6400aa007
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ReviewProbe.kt
@@ -0,0 +1,162 @@
+package to.bitkit.qa
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import com.synonym.paykit.ContactUpdate
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.first
+import to.bitkit.BuildConfig
+import to.bitkit.data.*
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.repositories.PubkyRepo
+import to.bitkit.repositories.WalletRepo
+import to.bitkit.services.PubkyService
+import to.bitkit.utils.AppError
+import java.io.File
+import java.security.SecureRandom
+import javax.inject.Inject
+
+// TEST HOOK: device-local controls; actual repositories and native calls are unchanged except named injected faults.
+object ReviewProbe {
+ private var root: File? = null
+ private var toastJob: Job? = null
+ fun init(context: Context) {
+ if (!BuildConfig.DEBUG) return
+ root = File(context.filesDir, "review-probe").apply { mkdirs() }
+ if (toastJob == null) toastJob = CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
+ to.bitkit.ui.shared.toast.ToastEventBus.events.collect { note("toast ${it.type}: ${it.title}; ${it.description}") }
+ }
+ }
+ fun note(message: String) { Log.i("ReviewProbe", message); root?.resolve("trace.log")?.appendText(message + "\n") }
+ fun enabled(point: String): Boolean = root?.resolve(point)?.exists() == true
+ fun fail(point: String) {
+ note("checkpoint $point")
+ if (root?.resolve("fail-$point")?.exists() == true) throw AppError("Injected local failure: $point")
+ }
+ suspend fun gate(point: String) {
+ if (point == "sdk-admission" && !enabled("hold-sdk-admission") && !enabled("fail-sdk-admission")) return
+ fail(point)
+ if (root?.resolve("hold-$point")?.exists() == true) {
+ note("held $point")
+ while (root?.resolve("hold-$point")?.exists() == true) delay(100)
+ note("released $point")
+ }
+ }
+ fun loseAcknowledgement() {
+ note("profile native deletion acknowledged")
+ // Network acknowledgement faults are injected by the real TLS proxy, never by replacing SDK results.
+ }
+}
+
+@AndroidEntryPoint
+class ReviewProbeReceiver : BroadcastReceiver() {
+ @Inject lateinit var repo: PubkyRepo
+ @Inject lateinit var service: PubkyService
+ @Inject lateinit var wallet: WalletRepo
+ @Inject lateinit var settings: SettingsStore
+ @Inject lateinit var keychain: Keychain
+ @Inject lateinit var removal: ProfileRemovalStore
+ @Inject lateinit var cache: PrivatePaykitCacheStore
+ @Inject lateinit var privateRepo: to.bitkit.repositories.PrivatePaykitRepo
+ @Inject lateinit var coordinator: to.bitkit.usecases.ProfileRemovalCoordinator
+ @Inject lateinit var sdk: to.bitkit.services.PaykitSdkService
+ override fun onReceive(context: Context, intent: Intent) {
+ if (!BuildConfig.DEBUG) return
+ ReviewProbe.init(context)
+ val pending = goAsync()
+ CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
+ try {
+ when (val command = intent.getStringExtra("command")) {
+ "seed" -> {
+ settings.setIsPaykitEnabled(true)
+ if (keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) == null) wallet.createWallet(null).getOrThrow()
+ settings.update { it.copy(isDevModeEnabled = true, hasSeenProfileIntro = true, hasSeenContactsIntro = true, hasSeenUsdtIntro = true) }
+ repo.createIdentity("Disposable removal profile", "Local testnet", emptyList(), emptyList(), null).getOrThrow()
+ val count = intent.getIntExtra("count", 0)
+ if (count > 0) {
+ val updates = (1..count).map { index ->
+ val key = ByteArray(32).also { SecureRandom().nextBytes(it) }.joinToString("") { "%02x".format(it) }
+ ContactUpdate(publicKey = service.publicKeyFromSecret(key), label = "Disposable peer $index")
+ }
+ service.saveContacts(updates, repo.publicKey.value)
+ repo.loadContacts()
+ }
+ File(context.filesDir, "review-probe/peers.txt").writeText(service.contactRecords().joinToString("\n") { it.publicKey })
+ ReviewProbe.note("seed ready owner=${repo.publicKey.value} contacts=${service.contactRecords().size}")
+ }
+ "remote-edit", "remote-add" -> {
+ val attempt = removal.pendingAttempt() ?: java.util.UUID.randomUUID().toString()
+ repo.performProfileRemoval(repo.profileOwner(), attempt) {
+ if (command == "remote-edit") {
+ service.publishPaykitProfile(to.bitkit.models.PubkyProfileData(name = "Remote revision", bio = "Externally edited").toPaykitProfile())
+ } else {
+ val peer = File(context.filesDir, "review-probe/peers.txt").readLines().first()
+ service.saveContacts(listOf(ContactUpdate(peer, "Recreated peer")))
+ }
+ }.getOrThrow()
+ ReviewProbe.note("native remote state changed command=$command")
+ }
+ "replace" -> {
+ coordinator.remove(coordinator.prepareSignOut(repo.profileOwner()).getOrThrow()).getOrThrow()
+ repo.createIdentity("Replacement sign-in", "Same key, newer sign-in", emptyList(), emptyList(), null).getOrThrow()
+ ReviewProbe.note("replacement admitted owner=${repo.profileOwner()}")
+ }
+ "lock-race" -> { reviewLockRace(); withContext(Dispatchers.Main) { android.widget.Toast.makeText(context, "Controlled SDK admission race passed", android.widget.Toast.LENGTH_LONG).show() } }
+ "drain" -> repo.withWalletWipeAdmission { sdk.withWalletWipe { ReviewProbe.note("controlled wipe barrier entered; no wallet teardown") } }
+ "edit" -> repo.saveProfile("Edited after confirmation", "Local testnet", emptyList(), emptyList(), null).getOrThrow()
+ "restore" -> ReviewProbe.note("restore result=${repo.restoreSessionIfNeeded()}")
+ "unpublish-contact" -> sdk.reviewUnpublishContact(requireNotNull(intent.getStringExtra("peer")))
+ "publish-contact" -> {
+ val key = service.contactRecords().last().publicKey
+ sdk.reviewPublishContact(key)
+ ReviewProbe.note("published retained peer=$key")
+ }
+ "watermark" -> {
+ val peer = service.contactRecords().first().publicKey
+ privateRepo.consumePrivatePaymentList(peer, to.bitkit.repositories.PrivatePaykitPaymentContext(emptyMap(), 42uL)).getOrThrow()
+ }
+ "snapshot" -> Unit
+ else -> error("Unknown command $command")
+ }
+ val data = cache.data.first()
+ ReviewProbe.note("snapshot pending=${removal.pendingAttempt()!=null} owner=${repo.publicKey.value} contacts=${repo.contacts.value.size} session=${keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)!=null} secret=${keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)!=null} watermarks=${data.contacts.values.count{it.consumedPrivatePaymentListVersion!=null}}")
+ } catch (error: Throwable) {
+ ReviewProbe.note("command failed ${error::class.simpleName}: ${error.message}")
+ } finally { pending.finish() }
+ }
+ }
+}
+
+private suspend fun reviewLockRace() = coroutineScope {
+ withTimeout(10_000) {
+ val admitted = CompletableDeferred<Unit>()
+ val release = CompletableDeferred<Unit>()
+ val outer = kotlinx.coroutines.sync.Mutex()
+ var first = true
+ val lock = to.bitkit.services.PaykitSdkOperationLock {
+ if (first) { first = false; admitted.complete(Unit); release.await() }
+ }
+ val old = async {
+ outer.lock()
+ try {
+ val error = runCatching { lock.withLock { error("stale operation executed") } }.exceptionOrNull()
+ check(error is com.synonym.paykit.PaykitException.Storage)
+ ReviewProbe.note("lock race: stale outer owner rejected before SDK queue")
+ } finally { outer.unlock() }
+ }
+ admitted.await()
+ val wipe = async {
+ lock.withWalletWipe {
+ release.complete(Unit)
+ outer.lock()
+ outer.unlock()
+ ReviewProbe.note("lock race: wipe drained outer owner without wallet teardown")
+ }
+ }
+ old.await(); wipe.await()
+ lock.withLock { ReviewProbe.note("lock race: fresh SDK work admitted after teardown") }
+ }
+}Lost-acknowledgement proxy patch:
--- a/payment-requests/src/bin/homeserver-proxy.rs
+++ b/payment-requests/src/bin/homeserver-proxy.rs
@@ -50,6 +50,8 @@
// answer this status after the delay instead of forwarding
#[serde(default)]
status: Option<u16>,
+ #[serde(default)]
+ after_status: Option<u16>,
}
#[derive(Clone)]
@@ -237,6 +239,10 @@
None => relay(&proxy, request).await,
};
let status = response.status().as_u16();
+ let response = if let Some(after) = rule.and_then(|r| r.after_status) {
+ println!("upstream acknowledged {method} {} {path} -> {status}; lose acknowledgement as {after}", owner.as_deref().unwrap_or("-"));
+ (StatusCode::from_u16(after).unwrap(), "Injected lost upstream acknowledgement").into_response()
+ } else { response };
println!(
"{method} {} {path} -> {status}{}",
owner.as_deref().unwrap_or("-"),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
There was a problem hiding this comment.
Verdict: ✅ Approve
Findings: None. The earlier LOW about repeated session-expired feedback is fixed.
QA for 7e2fd14: Android 16 / API 36, dev/regtest, unmodified app build. I seeded recovery records in a disposable wallet that had never linked a Pubky profile. Videos are 2×.
Test 1: J4 — pending-cleanup feedback ✅ Cleanup guidance remained available through foreground and reconnect without a session-expired prompt.
Cold starts and three foreground cycles also retained the recovery screen. UI assertions checked each transition; direct storage checks confirmed Cancel preserved the marker and explicit local Disconnect cleared it before returning Home. This recording shows the foreground/reconnect repeat.
J4-feedback-2x.2x.mp4
Pending cleanup through foreground and network reconnection.
Test 2: unreadable recovery record ✅ An unsupported record retained cleanup guidance through restart, foreground and reconnect without an expiry prompt or crash.
I used a version-2 envelope, which the real serializer rejects. UI assertions checked cold starts and three foreground cycles; direct storage checks confirmed the unsupported record stayed unchanged. This recording shows the foreground/reconnect repeat.
U1-2x.2x.mp4
Unreadable recovery state through foreground and network reconnection.
Check 1: build and restoration regressions ✅ The app build and focused repository, Profile and application tests passed.
I built 7e2fd14. The regressions confirm pending/unreadable cleanup blocks authentication without setting the expired-session signal. Genuine restoration failure still produces its notification. git diff --check passed.
I retained the passing report at 7e2e466 for unchanged J1–J3, the remaining J4 actions and manual cases 1–3. I did not repeat those authenticated fault journeys or the full lint/E2E suites in this delta.
Twin: synonymdev/bitkit-ios#1143 fix: bind profile removal to its original owner carries the matching recovery journey and feedback check; it intentionally offers local Disconnect without remote Review. Its authenticated fault journeys remain unverified.
Reviewed all four files in 7e2e466..7e2fd14 and reached callers, plus the prior LOW. Device cases cover the feedback change; the changelog wording has no separate device case. Native contact CAS remains under synonymdev/bitkit-android#1687 Bind profile deletion cleanup to the original sign-in.
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
There was a problem hiding this comment.
Requesting changes: one MEDIUM inline. A dropped connection during an ordinary Disconnect leaves only local Disconnect, which orphans a live homeserver session and published endpoints. Reviewed against the base branch (#1690, 2daec90).
Checked and clean:
- No cross-identity contamination through the single
profile_removal.json: create, adopt, approveAuth, backup restore and restoreSession all go throughrequireNoPendingRemoval, so identity B can't be created or restored while A's marker is pending. - Replay after a restart:
prepareSignOutreuses the pending UUID,beginis idempotent andcompleterequires the same UUID. - Cancellation during the local tail is covered by
NonCancellablefromfinishProfileRemovaltocomplete; the cancellableforgetLocalProfileAccessgap only leaves the marker pending, which resumes. - A Reset that fails after the remote cleanup keeps its marker, and resetting again completes it (
pending restart wipe skips remote cleanup…). - No migration path: the store file is new in this PR, so v3.0.0 holds no state for it.
- iOS twin synonymdev/bitkit-ios#1143 is still a draft. It has no Review action (local Disconnect only), and its marker carries a
walletWipescope.
Performance-relevant only lightly (two DataStore requireAccess() reads per SDK lock); nothing scales with data.
profile/ J1, the Disconnect with the network cut mid removal, and unlink-ring-pubky.xml.
| owner, | ||
| pending ?: UUID.randomUUID().toString(), | ||
| deletion = null, | ||
| localOnly = pending != null || owner.signIn == null, |
There was a problem hiding this comment.
MEDIUM: a dropped connection during Disconnect makes the remote sign-out impossible to retry.
Signed-in profile, Disconnect, confirm. removePublishedEndpointsForCleanup or removeBitkitPaymentEndpoints fails on a dropped connection (:131-132), after begin() (:129) has written the marker. The failure is a ProfileRemovalFailure with canReview == false (:38), because a sign-out has no deletion, so ProfileViewModel.kt:161-162 always takes prepareLocalDisconnect. After a restart, this line sets localOnly from pending != null, and reviewAgain (:74) rejects a sign-out. Nothing reruns the endpoint removal or the revocation, although the session is live and the owner is current.
The only action left is local Disconnect. It erases the credentials and clears publicPaykitCleanupPending, leaving a valid homeserver session and published payment endpoints that nothing reconciles. The base branch kept the identity retryable here (PubkyRepo.signOut returns the failure and keeps the identity), and journeys/profile/README.md still requires it: "Repeat with a live session and failed endpoint cleanup or revocation: it must report failure and retain the identity". @Jasonvdb's thread reached the same end state by pressing Back mid sign-out, and that fix only blocked navigation; a network failure is a separate and more common trigger.
Fix: localOnly = owner.signIn == null here. The remote steps are idempotent and begin reuses the same attemptId; after a restart signIn is null while the marker is pending, so that path stays local-only. In showSignOutConfirmation, take prepareLocalDisconnect(failure) only when failure.localCompletionStarted.
Reproduction
diff --git a/app/src/test/java/to/bitkit/usecases/ProfileRemovalCoordinatorTest.kt b/app/src/test/java/to/bitkit/usecases/ProfileRemovalCoordinatorTest.kt
index e8765fed7..77644a0f7 100644
--- a/app/src/test/java/to/bitkit/usecases/ProfileRemovalCoordinatorTest.kt
+++ b/app/src/test/java/to/bitkit/usecases/ProfileRemovalCoordinatorTest.kt
@@ -390,6 +390,23 @@ class ProfileRemovalCoordinatorTest : BaseUnitTest() {
verify(pubky, times(1)).signOutForProfileRemoval()
}
+ @Test
+ fun `transient sign-out failure keeps remote revocation retryable for the live session`() = test {
+ whenever(pubky.removeBitkitPaymentEndpoints()).thenReturn(Result.failure(IllegalStateException("offline")))
+ val first = sut.prepareSignOut(original).getOrThrow()
+ assertFalse(first.localOnly)
+ assertIs<ProfileRemovalFailure>(sut.remove(first).exceptionOrNull())
+ assertSame(original, current)
+ verify(pubky, never()).signOutForProfileRemoval()
+
+ whenever(pubky.removeBitkitPaymentEndpoints()).thenReturn(Result.success(Unit))
+ val retry = sut.prepareSignOut(original).getOrThrow()
+ assertFalse(retry.localOnly, "A live session must be able to retry its remote sign-out")
+ assertTrue(sut.remove(retry).isSuccess)
+ verify(pubky, times(1)).signOutForProfileRemoval()
+ assertNull(store.pendingAttempt())
+ }
+
@Test
fun `restart pending disconnect is local only and rejects stale UUID confirmation`() = test {
current = PubkyProfileOwner(null, 0)./gradlew :app:testDevDebugUnitTest --tests 'to.bitkit.usecases.ProfileRemovalCoordinatorTest' at 7e2fd14:
ProfileRemovalCoordinatorTest > transient sign-out failure keeps remote revocation retryable for the live session FAILED
java.lang.AssertionError: A live session must be able to retry its remote sign-out
With localOnly = owner.signIn == null, all 21 pass, including restart pending disconnect is local only and rejects stale UUID confirmation. The view-model half of the fix isn't covered by this test.
There was a problem hiding this comment.
Confirmed: a network failure after the pending marker can leave a live remote session/endpoints while the remaining action completes only local cleanup. This finding is still open. The newly published 188f456 fixes receipt ownership/accounting during profile cleanup and integrates the current parent; it does not fix remote sign-out retry.
I have not applied the two-line retry change because the retained owner/attempt UUID alone does not establish whether the earlier mutation committed, or whether the remote targets changed. The focused follow-up is original-attempt snapshot/outcome assessment followed by a new explicit confirmation, shared with the iOS twin's corresponding finding. Current SDK read-shaped contact operations can themselves reconcile writes, so they cannot be treated as a read-only outcome probe without verification. Local Disconnect remains available and makes no remote-success claim. I am leaving this thread unresolved; no automatic replay or agreed deferral is claimed.
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full review of the PR diff against merge base 2daec90e, at 7e2fd14. No saved assessment baseline was available, so coverage was not inherited.
No new actionable code findings.
No new actionable code findings. The open thread a dropped connection during Disconnect makes the remote sign-out impossible to retry still matches this revision: after begin() stores the marker, a failure from removePublishedEndpointsForCleanup or removeBitkitPaymentEndpoints on ordinary Disconnect has canReview == false, and the next confirmation is local-only while the original sign-in is still current. A later wallet wipe also skips cleanupRemote when that marker is already pending. This review does not open a second copy of that mechanism. Deletion still has in-session Review for an unchanged snapshot. Native key-only contact deletion remains #1687. Pending cleanup now clears the expired-session flag instead of raising it.
GitHub unit tests, lint, and E2E succeeded on this head. This review did not re-run them. That E2E run does not drive J1–J4. Device testing: not performed.
Compared bitkit-ios#1143 at 2ca19ed for this flow only. A persisted cleanup marker stays local-only on both. Android Review of an unchanged deletion snapshot is absent from that draft twin, which still needs a remote observation API. That comparison is not a full iOS review. Dependency #1690 is this PR's base; only this PR's delta was reviewed.
Reviewer model: grok-4.7
Reasoning effort: high
| } | ||
|
|
||
| override suspend fun writeTo(t: PrivatePaykitReservationData, output: OutputStream) { | ||
| check(t.receiptSourceReadable) { "Cannot persist an incomplete reservation ledger" } |
There was a problem hiding this comment.
An unreadable reservation ledger can make a wallet wipe fail after the keychain is already erased. When private_paykit_reservations.json fails to decode, readFrom returns a default with receiptSourceReadable = false, and this check now refuses to write it. allocateAddress sets ledger = next before persist(next) throws (PrivatePaykitAddressReservationRepo.kt:306-307), so the in-memory ledger keeps an assignment the store never got. A later wipe reaches finishProfileRemoval -> clearContactAssignments (PrivatePaykitRepo.kt:608), which writes a value that differs from the stored default and hits this check. wipeLocal then returns failure (WipeWalletUseCase.kt:133) after wipeStorage and keychain.wipe(), but before privatePaykitAddressReservationRepo.clear().
Trigger: corrupt ledger file, any Paykit receive allocation, then Reset wallet -> WipeIncomplete until the process restarts.
Fix: set ledger = next only after persist(next) succeeds, and let the wipe path replace an unreadable ledger (call clear() before finishProfileRemoval, or have clearContactAssignments write a fresh ledger when receiptSourceReadable is false).
|
|
||
| /** Malformed local admission data is replaced only with a new, still-blocking recovery obligation. */ | ||
| internal fun profileRemovalCorruptionHandler() = ReplaceFileCorruptionHandler { | ||
| ProfileRemovalState(attemptId = UUID.randomUUID().toString()) |
There was a problem hiding this comment.
A replaced corrupt profile_removal.json leaves a marker that Disconnect can never clear, and it now stops payment accounting too. This handler writes ProfileRemovalState(attemptId = ...) with scope = null. readReceiptState (:173) throws ProfileRemovalPending for any non-PROFILE scope, and PrivatePaykitContactResolver rethrows it (PrivatePaykitContactResolver.kt:59-62), so every receipt path (handlePaymentEvent, LDK payment sync, on-chain received/confirmed) fails and received payments stop being written to activity. The error tells the user to Disconnect, but withAccess(pending, PROFILE) fails the scope check at :121-122 for a null scope, so only a wallet wipe clears it. At 7e2fd14 this state only disabled contact attribution.
Fix: write scope = ProfileRemovalScope.PROFILE here (or treat a null scope as PROFILE-recoverable in withAccess), and add a test that a corrupted marker clears through Disconnect.
| val pending = profileRemovalStore.pendingAttempt() | ||
| val attemptId = pending ?: UUID.randomUUID().toString() | ||
| profileRemovalStore.withAccess(attemptId, ProfileRemovalScope.WALLET_WIPE) { | ||
| profileRemovalStore.begin(attemptId) |
There was a problem hiding this comment.
begin(attemptId) now runs before withProfileDeletionPaused and stopNode(), so a wipe that fails or is cancelled before anything is destroyed leaves a durable WALLET_WIPE marker on an untouched wallet. Only complete(attemptId) clears it, and that runs only after wipeLocal succeeds. While the marker is set, receipt accounting throws (readReceiptState, ProfileRemovalStore.kt:173), Disconnect fails withAccess(..., PROFILE) with "Wallet cleanup must complete before profile recovery", and the next wipe sees pending != null and skips cleanupRemote(). At 7e2fd14 begin ran after stopNode().
Trigger: the wipe is cancelled (or the process dies) while the pause is being set up or the node is stopping.
Fix: move begin(attemptId) back inside the pause, right after stopNode().getOrThrow(), and assert pendingAttempt() is null in the node-stop-failure test.
| val payments = lightningService.listPayments() ?: run { | ||
| Logger.warn("No payments available for hash $paymentHash", context = TAG) | ||
| return@background | ||
| suspend fun handlePaymentEvent(paymentHash: String) = withReceiptOperation { receipt -> |
There was a problem hiding this comment.
handlePaymentEvent can now throw CancellationException during a wipe, and that silently ends the LDK event listener. withReceiptOperation -> checkAdmission throws CancellationException("Wallet wipe in progress") while wiping is set (WalletConnectionRecovery.kt:281), and WipeWalletUseCase now sets it through withIdentityChange before the node stops. runSuspendCatching in LightningRepo.kt:578 rethrows cancellation, listenForEvents calls onEvent?.invoke(event) with no per-event catch (LightningService.kt:1448), and startEventListener drops CancellationException without logging.
Trigger: a payment arrives while a wipe is starting, then the wipe fails or is cancelled with the node still running -> no activity updates, receive notifications or channel events until the next node start.
Fix: in LightningRepo.onEvent (or around the invoke), catch CancellationException while the listener's own coroutine is still active, log it and continue; rethrow only when the listener itself is cancelled.
|
|
||
| suspend fun isReservedReceiveAddress(address: String): Boolean { | ||
| requireCurrent() | ||
| val source = snapshot ?: return false |
There was a problem hiding this comment.
Reserved receive address lookup now depends on the attribution snapshot, so it returns false whenever the snapshot is null. CoreService.findPrivateReservedAddress (CoreService.kt:999) now calls this Receipt.isReservedReceiveAddress instead of the ledger-only helper at :139-142, whose doc says address accounting is independent of attribution and which now has no callers. The snapshot is null while the invoice cache is unreadable, during local cleanup, or when the capture race check fails.
Trigger: an inbound on-chain payment to a Paykit-reserved address outside the LDK search window while the invoice cache can't be read -> the activity is stored with no receiving address.
Fix: call the ledger-only isReservedReceiveAddress from findPrivateReservedAddress, or delete that helper and its comment if the snapshot dependency is intended.
Related issue: #1687 (remains open for native conditional contact deletion and complete cross-platform validation).
Twin: synonymdev/bitkit-ios#1143
Dependency: #1690 — this branch is stacked on the acknowledged-cleanup repair.
This PR binds profile deletion and disconnect to the identity and operation the user confirmed, and keeps interrupted local cleanup recoverable after restart.
Description
Out of Scope
Design
N/A — no design is available for the new pending-recovery and Review dialogs. Existing Profile / Profile Edit surfaces are mapped in
docs/screens-map.md.Preview
The recovery dialogs are exercised by the three
ProfileRecoveryContentTestcases. A complete authenticated journey recording is not yet available; see Gaps.QA Notes
Setup
Use the normal released dependencies pinned by this branch, a dev/regtest build and a disposable profile. Build with
./gradlew :app:assembleDevDebug :app:assembleDevDebugAndroidTest. Component UI checks run withadb shell am instrument -w -r -e class to.bitkit.ui.screens.profile.ProfileRecoveryContentTest to.bitkit.dev.test/to.bitkit.test.HiltTestRunneron Android API 36.J2–J4 require an authenticated controlled homeserver/proxy with mutation acknowledgement and contact-removal fault gates, plus injectable local-storage failures. The standard journey runner does not provide those controls. Use no personal identity or live funds; do not reset a wallet to prepare the fixture. Standard staging alone does not supply the needed fault setup.
Journeys
delete-profile.xml— prepare before confirmation and complete acknowledged removal.delete-profile-uncertain-response.xml— one uncertain mutation, read-only Review and explicit local Disconnect.profile-cleanup-failure.xml— incomplete cleanup retains recovery without false success.profile-removal-recovery.xml— canceled confirmations, later edits, identity replacement, restart without credentials and local failure recovery.Manual Tests
Automated Checks
ProfileRemovalStoreTest.kt,PubkyIdentityOperationsTest.kt,ProfileRemovalCoordinatorTest.kt— durable pending state, original-owner admission, strict completion order, shrinking retry scope and one-use confirmations.ProfileRecoveryContentTest.kt— three real Compose recovery/dialog cases, with callbacks controlled by the test.Evidence
Earlier head
7e2e466capassed hosted validation: 4,734 unit tests, full lint with zero findings in checksum/CRC-verified Detekt and Android Lint reports, and all seven existing E2E shards on their first attempts (39 passes, one settings-address-type skip). Complete logs and annotations were inspected; the synthetic merge tree equals this exact head. Four CloseGuard notices trace to unchanged base cursor handling inSharedPubkyProviderTest.kt. These suites do not execute the new authenticated profile-removal fault journeys.just compile,just testandjust lintpassed on the frozen candidate atedd647611a1bd695f12a6f7eff69acdd19f03019(base2daec90e): 4,725 fresh tests across 257 suites, zero failures/errors/skips, and zero Detekt/Android Lint SARIF/XML findings. Complete logs and reports were inspected, includingignoreFailuresoutputs.just formatpassed with zero findings. All 59 reviewed input hashes remained unchanged after native and emulator validation.ProfileRecoveryContentTestcases passed on the owned API 36 emulator with actual app/test APK installation; installed APK hashes matched the built artifacts. They exercise the actual screen content with controlled state and callbacks.Review follow-up evidence
The additive review repair addresses local recovery observation, partial-wipe error/existence reporting, post-completion WALLET and METADATA backup resumption, resume errors, cancellation through Profile navigation, unavailable native initialization, pre-mutation confirmation errors, ended-parent recovery ownership and success feedback. It preserves original-owner/UUID admission and never automatically replays an uncertain remote deletion.
Final
just format,just compile,just testandjust lintpass: 4,734 fresh tests / 257 suites, zero failures/errors/skips, and zero findings in all Detekt/Android Lint SARIF/XML reports. Complete diagnostics were inspected; JVM/Gradle notices match the checked base. Three installed Compose tests pass, including toolbar/hardware Back and drawer blocking during active sign-out; built and installed APK hashes match. All 24 follow-up input hashes remained unchanged. Two consecutive independent final reviews are clean after repairing a metadata-only notification and a test fixture's SDK/private version mix-up. Controlled failures cover each reported boundary; the real BackupRepo scheduler proves marker completion's WALLET notification releases a suppressed complete backup without retrying remote mutations.Two widget component suites now use plain Application rather than starting the production registrar. The original hosted manifest assertion failed at ff4dc35; its precise cause remains unproven. That prior head's hosted E2E run passed 39 cases / 1 skip over seven shards, with helper retries retained in the report. New-head hosted results must be assessed separately. Neither hosted existing E2E nor the three Compose cases establishes the authenticated fault journeys below.
Gaps
J1–J4 and manual 1–3 have not been driven as complete authenticated app journeys against the controlled network fixture. Unit/component tests exercise real DataStore, application admission and lock orchestration with mocked native/keychain/network operations; the three emulator tests render the actual screen content but use controlled state/callbacks. They do not prove deployed homeserver behavior, native cross-device CAS, a real wallet wipe or the original incident cause. The iOS twin is integrated and ready; its remaining runtime cases and safe remote-retry finding remain explicit below.
Pending-cleanup feedback follow-up
Signed
7e2fd14f0keeps deliberate restoration refusal for pending or unreadable profile cleanup separate from the expired-session notification. The existing cleanup-recovery state and local Disconnect remain available; authentication is still blocked. Repeated foreground/connectivity attempts no longer regenerate an expired-session prompt. Genuine restoration-failure feedback is unchanged.The strengthened tests failed twice on the prior source (240 cases: 238 passed, two expected failures), then all 240 passed. Final full validation passed 4,734 tests across 257 suites with zero failures/errors/skips; required format/compile/full Detekt/Android Lint passed with zero findings. Complete diagnostics and four frozen input hashes were inspected, with two consecutive clean independent final reviews. The entire four-commit live-base range is signed locally and GitHub verified/valid. Current-head hosted checks are detailed below.
The updated Android
profile-removal-recovery.xmladds repeated foreground/reconnect feedback checks. Its complete authenticated/storage-fault journey remains unrun. The iOS twin now integrates the corresponding ownership/restart/local-completion flow; its complete runtime limits are recorded in its own QA evidence. No new cross-platform journey pass is claimed here.Models used
gpt-6-astra(reasoning:xhigh)gpt-6-astra(reasoning:xhigh)gpt-6-astra(reasoning:xhigh),gpt-6.1-sol(reasoning:high)Historical hosted validation at 7e2fd14 (2026-10-26)
At
7e2fd14f, CI passed 4,734 tests across 257 classes with zero failures/skips; lint passed with zero Detekt and Android Lint SARIF/XML findings. E2E passed 39 cases on the first attempt across all seven shards, with one address-type settings skip. Complete logs and SHA-256/CRC-verified APK/report archives were inspected. The synthetic merge tree equals the published head against parent2daec90e. These existing suites do not complete the new authenticated/storage-fault journey or iOS parity.iOS parity publication (2026-10-27)
Twin synonymdev/bitkit-ios#1143 was published as draft
2ca19ed3, stacked on its queue prerequisite #1125. It integrates original-owner confirmation, durable local recovery and strict credential-before-watermark completion. Its 3,004 selected native tests and six controlled UI cases passed, with two consecutive clean independent source reviews and both actual-base signatures locally/GitHub valid. iOS offers explicit local Disconnect only because the pinned contact-read path can reconcile remote writes; a safe remote observation API is still needed for equivalent Review. Neither platform claims complete authenticated fault journeys or contact CAS/ABA safety. Earlier evidence above remains scoped to its recorded revisions.Receipt attribution and parent integration (2026-10-28)
Current signed head
188f456c41a0ff79a150ae93b19977cd522d7185includes live parent #1690f72e0c03b76352a7c6db8e332b4dec7ca52b91aeby an additive merge; the validated tree iscb6bf26d7b47b34de7357ead356c24d421078cfb. All five commits in the live-base range verify locally and report verified/valid on GitHub. The PR remains ready for review; this publication does not resolve the remote-Disconnect retry finding.A receipt captures the original wallet and complete local association snapshot before native lookup. Profile cleanup cannot substitute a replacement identity or erase that already-captured attribution. Wallet replacement and wipe revoke and drain admitted receipt work before teardown. A known profile-local cleanup or unavailable optional association lookup still permits owner-valid native payment accounting, supplies no new inferred contact, and does not mark backfill complete. Unknown/unreadable removal obligations and wallet-wipe state retain their mandatory gates. Unreadable private-data fallback cannot be written back as apparently complete empty data.
Final
just format,just compile,just testandjust lintpassed: 4,736 fresh unit tests / 257 suites, zero failures/errors/skips, and zero Detekt/Android Lint findings. All 1,768 frozen inputs match. The sole diagnostic audit inspected all 9,848 log lines and complete structured reports, including embedded Gradle and ignore-failure behavior. Three JVM CDS, four unchanged-provider CloseGuard notices and a Detekt-plugin Gradle deprecation remain documented; this is not a warning-free claim. Two independent affected source reviews cover concurrency/ownership and persistence/accounting; prior controls and intermediate failures remain preserved.The executed regressions include real DataStore cold capture and malformed-data persistence, original-job cancellation and wallet/wipe drains, actual Core payment accounting during profile cleanup, high-index address lookup after live assignments clear, and failed optional derivation followed by successful backfill. These are JVM/Robolectric tests with native resources controlled. They do not complete J1–J4/manual 1–3, constitute a real wallet wipe, or establish authenticated network/cross-device CAS behavior. Current-head CI and lint completed successfully: 4,736 unit tests / 257 classes, zero failures/skips and zero Detekt/Android Lint findings. All 11,217 log lines, annotations and uploaded reports/APK integrity were inspected. Hosted merge
47ac453fhas the exact validated treecb6bf26d; runtime/build notices remain documented. Current E2E is still running; six completed shards report first-attempt success, while the onchain/boost/receive/widgets shard remains unfinished. Notification-helper timeout and invoice-input retry are retained; no full E2E or authenticated profile-fault pass is claimed. Migration was skipped. Older hosted results above remain scoped to their original revisions.Current iOS twin and open requested change
The iOS twin #1143 is ready at
86fb0c5c, based on #11253f887088. Its current hosted run passed 2,945 units and 40 UI executions, with five explicit fixture skips retained; formatting passed. Its two remaining literal runtime cases are disclosed under the human-authorized readiness exception, not reported as passes.João's remote-Disconnect retry finding remains unresolved. After an uncertain endpoint withdrawal or revocation, local Disconnect can complete without proving the remote cleanup finished. The retained owner and UUID do not themselves establish the remote outcome or unchanged targets. A safe separately confirmed retry needs original-attempt outcome/snapshot assessment; no automatic destructive replay or assertion of remote success was added. This is a focused requested change shared with the iOS twin, not a blanket full-wallet or contact-CAS prerequisite.