Repository navigation
fix: decouple wallet restore from profile refresh - #1686
ben-kaufman wants to merge 27 commits into
Conversation
|
Triage: 3.1.0. It keeps the shipped wallet restore from waiting on profile refresh, and 3.1.0 is due 28 Oct. |
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryWallet restore now schedules optional identity activation and profile reads without waiting for them.
No actionable issues found. Review covered source and tests; tests were not run. Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Required wallet and USDT recovery] --> B{Succeeded?}
B -->|No| C[Keep restore blocked]
B -->|Yes| D[Schedule identity work]
D --> E[Complete wallet restore]
D --> F{Identity revision still current?}
F -->|No| G[Skip obsolete work]
F -->|Yes| H[Activate identity and load profile]
I[Build backup] --> J{Identity data complete?}
J -->|No| K[Keep previous remote backup]
J -->|Yes| L[Upload backup]
Reviews (1) · Last reviewed commit: "fix: decouple wallet restore from profil..." · Reviewed by Greptile |
talosmachina
left a comment
There was a problem hiding this comment.
No findings. Moves Pubky identity activation after a wallet restore off the restore screen's critical path (scheduleInitializationAfterRestore, coalesced per stored-identity revision), and makes WALLET and METADATA backups fail rather than upload a payload with the identity part silently missing. Reviewed ac2a7c6, full tier, stacked on #1685 (fix/pending-wallet-restore-admission), reviewed as the delta against that base.
What I checked, and 6 candidates I ruled out
Read in full: PubkyRepo.kt (initialization, restore, adoption, sign-out and backup-state regions), BackupRepo.kt (scheduling, triggerBackup, markBackupFailed, wallet and metadata snapshots, full restore), WalletViewModel.kt restore paths, PrivatePaykitRepo.backupSnapshot, PaykitSdkService (currentPublicKey, session provider, clearSessionAccess), and paykit-rs v0.1.1-rc1 identity_status / initialize.
Call sites traced: scheduleInitializationAfterRestore (both WalletViewModel restore paths), hasIdentityForBackup (only captureWalletBackupDataBytes), initialize() (still only the init block, unchanged semantics with expectedRevision = null).
CI: lint, detekt and build-local green. build is red on one test, PubkyAuthManifestTest > signup and authorization links resolve only through their enabled aliases, at its first assertion (line 77, every alias still at its default). The diff touches neither that test nor the manifest; the base head 837aa1c passed build, and the same test failed the same way in two of the last eight failed ci runs on unrelated branches (fix/update-terms-of-use, feat/usdt-design-followup-20261015). All seven new tests in PubkyRepoTest, BackupRepoTest passed in that run. The e2e shards were still running when I read the checks.
Not built here: this review is reasoned from the code and CI; CI is the gate.
Ruled out
- Scheduled activation lost when the revision moves: the revision only moves through
startSignIn(non-restoring) or a non-preservingclearAuthenticatedState, both of which are a newer identity's own path, so dropping the stale job is the intent.restoreSessionBackupStatebumps it inside the restore, before the schedule reads it. - Something after
Completedneeding the identity: the public key is already set inside the required boundary (restoreSessionBackupStateLocalSeedstartSignIn), and the payment-requestactivateatBackupRepo.kt:1058runs during the restore, unchanged. - Coalescing reusing a finished job:
takeIf { !it.second.isCompleted }, and the completion handler clears the slot only when it still holds the same job. - Uncaught failure in the background job:
initializeSessioncatches its own failures,checkAdoptedSourcereturns aResult, andappScopecarries aSupervisorJobplus a logging handler. - WALLET backup blocked for good while a saved session exists: with a stored session
currentPublicKey()reads the bound identity state throughidentity_status, sostateis null only when the SDK has no identity, which is the case the check means to hold; an unreadable keychain fails the backup (covered bywallet backup cannot treat unreadable identity as absent). - METADATA
getOrThrowturning a benign miss into a failure: both snapshots returnnullfor absence insiderunSuspendCatching, so only a read error now fails, which is what the two new tests pin.
Merge confidence: 3/5, the build job is red on PubkyAuthManifestTest, which I traced to a flake outside this diff; a green re-run would move it to 4/5 (J1 is still unrun).
Reviewer model: claude-opus-5-5
Reasoning effort: Not exposed
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
jvsena42
left a comment
There was a problem hiding this comment.
Code scan: one LOW inline, reproduced with a failing unit test.
Checked and clean:
- No cross-identity leak on wipe and restore: the mutex, the revision and the
isCurrent(signIn)guards hold. - Identity isn't lost for good: the retry paths cover a failed background refresh. The WALLET backup that can stay blocked is the open P1 thread.
- No payment path starts before the identity exists: the key is set inside the required restore.
- Twin synonymdev/bitkit-ios#1121 keeps the header signed-out until the background activation lands, so it has no flip.
Performance-relevant (the purpose is a faster restore). wallet/restore-during-identity-outage.xml needs fault controls that aren't provisioned, so the gate uses a proxy. It restores a regtest wallet whose backup carries a pubky session, then:
- times
Full restore success→ Completed, head vs master, with the homeserver reachable and with it blocked; - checks that the header shows the identity and that profile and contacts arrive afterwards;
- counts
Restored paykit session forper restore (expected 1); - runs Settings → Backup for WALLET once the identity is active.
Device gate (partial): ac2a7c6 — not run yet; pending that proxy run. Device results will follow in a separate review.
There was a problem hiding this comment.
Verdict:
Findings: No new findings. I confirmed that the redundant session import is fixed at 0fd5e42. The acknowledged terminal-restoration backup limitation still applies to Android: retained credentials with no SDK identity can block later complete WALLET backups. Temporary recovery below does not resolve that case.
QA for 0fd5e42: Android 16, API 36 emulator, staging regtest, an unfunded disposable wallet. I built the unmodified head and ran its focused native suites, then drove the app with local Debug controls over the real SDK and VSS paths. Installed APK bytes matched the built artifacts.
Test 1: J1 — complete optional identity outage journey ⏭️ Partial: activation first occurring after required recovery was not driven.
This LocalSeed backup activates its identity during required metadata restoration. Its later scheduled work enriches an already-active identity, so the first action's unauthenticated-completion expectation was not established. The subcases below cover held enrichment, unavailable SDK export, restart, stale profile rejection and required-recovery failure. Initialization with authentication unavailable was held separately across process restart.
Genuine post-required activation failure and funded payment replay remain unverified.
Test 2: J1 — restore with optional enrichment held ✅ Restore completed and ordinary wallet access remained available before release.
The success screen appeared while final enrichment was held. I left restore and opened Receive. App logs confirmed a running node, clear required-recovery markers and successful new-address allocation. Android kept the identity activated by required metadata restoration; the profile was still empty until enrichment resumed.
J1-restore2x.2x.mp4
Required restore, wallet access and receiving while enrichment is held; 2×. Node, marker and allocation state were checked in app logs.
Test 3: J1 — complete-backup protection and recovery ✅ An unavailable SDK identity preserved the remote WALLET record, then recovery permitted a complete backup.
The control removed live SDK session access, retaining the original identity secret for recovery. The real WALLET backup failed with Paykit identity is unavailable for backup; remote bytes and version stayed unchanged and backup work remained pending. Real sign-in with the original secret then allowed a fresh SDK export and complete upload, with pending status cleared. These assertions were checked in the control and app logs.
J1-backup-final2x.2x.mp4
Rejected backup, identity recovery and resumed profile display; 2×. Backup contents and pending status were checked in app logs.
Test 4: J1 — process restart during held work ✅ Saved credentials recovered the same identity and a single saved contact.
I killed the app during held optional work and held initialization again after relaunch. Authentication stayed unavailable while the ordinary wallet ran. Releasing initialization restored the identity and the same one contact. The contacts screens and app logs confirmed the result; no payment was submitted.
J1-restart2x.2x.mp4
One contact before and after process restart and initialization release; 2×. Authentication and node state were checked in app logs.
Test 5: J1 — delayed profile response after identity removal ✅ The late response did not restore the removed identity or profile.
I held a real profile response, removed the identity through the normal repository sign-out, then released the response. Its normal guard reported current=false; authentication and profile remained empty. The UI and app logs confirmed that the old profile was not applied.
J1-stale2x.2x.mp4
Identity removal while a profile response is held, then release; 2×. Stale-response rejection was checked in app logs.
Test 6: J1 — required recovery failure and retry ✅ Required markers blocked address and payment admission until the original payload was repaired and restored.
A malformed retained WALLET payload kept the restore-error screen with no Proceed action. Address allocation returned WalletBackupRestorePendingError. An invoice-admission probe returned the same error before invoice parsing or dispatch. After repairing the original retained WALLET and USDT-recovery payload copies, Retry completed required recovery while optional enrichment was still held. Markers cleared and address allocation succeeded. These states were checked in app logs.
J1-required-final2x.2x.mp4
Required-recovery error, repair, retry and success with enrichment held; 2×. Operation admission was checked in app logs.
Test 7: base comparison — held optional initialization ✅ The base waited for release; the reviewed head completed with the hold active.
I built and drove 55b31ee1 on the same emulator with the same disposable backup and a hold at the corresponding optional-initialization boundary. Required restore logged success, but the base remained at 100% throughout the 34.6-second hold. It completed after release. Test 2 shows the reviewed head completing before release. This is a controlled comparison, not a live homeserver-blackout benchmark.
C2-base2x.2x.mp4
Pinned base waiting after required recovery, then completing after release; 2×. The hold interval was measured from app logs.
Check 1: focused native regressions ✅ The unmodified-head build and selected suites passed without failures or skips.
./gradlew assembleDevDebug testDevDebugUnitTest \
--tests to.bitkit.repositories.PubkyRepoTest \
--tests to.bitkit.repositories.BackupRepoTest \
--tests to.bitkit.ui.WalletViewModelTestI inspected the test XML and complete build log. The suites cover coalescing, active-session preservation, replacement identities, unreadable credentials, complete-backup protection and required-recovery admission.
Local Debug controls and deciding log lines
Not pushed. Holds wait for explicit file removal. The backup control clears this disposable wallet's SDK session access and recovers using its original secret. The required-recovery control corrupts retained local payload input and repairs it from the captured original before Retry. SDK results and recovery decisions are not mocked.
Full restore success
QA1686 final enrichment held
identity=true, profile=null, contacts=1, pendingRestore=false, node=Running
QA1686 unavailable identity: failed=true, unchanged=true, pending=true
QA1686 qa1686BackupFault: backup protection and recovered export passed
QA1686 profile response held
QA1686 qa1686SignOut: identity removed
QA1686 profile released; current=false
QA1686 qa1686SpendingGate: payment admission blocked by required restore before invoice parsing
QA1686 qa1686RepairRequired: repaired original required backup
Full restore success
QA1686 final enrichment held
Head controls:
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 00c57a69a..7aaf9c1ac 100644
--- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
+++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
@@ -42,6 +42,11 @@ class DevToolsProvider : ContentProvider() {
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
check(Binder.getCallingUid() == SHELL_UID) { "Only ADB shell callers are allowed" }
+ if (method.startsWith("qa1686")) { // test hook
+ return kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) { // test hook
+ Qa1686.run(requireNotNull(context), method) // test hook
+ } // test hook
+ } // test hook
return runCatching {
val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
ServiceQueue.LDK.blocking { command.execute(deps) }
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index 41854bbf9..9b0179f2f 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -375,6 +375,12 @@ class PubkyRepo @Inject constructor(
private suspend fun initializeForRevision(
expectedRevision: Long? = null,
): Result<Unit> = withContext(ioDispatcher) {
+ if (to.bitkit.BuildConfig.DEBUG) { // test hook
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/" + if (expectedRevision == null) "qa1686-hold-initial" else "qa1686-hold-final") // test hook
+ if (gate.exists()) Logger.info("QA1686 final enrichment held", context = "TestHook") // test hook
+ while (gate.exists()) delay(200) // test hook
+ Logger.info("QA1686 final enrichment released", context = "TestHook") // test hook
+ } // test hook
val (restored, revision) = initializeMutex.withLock {
if (expectedRevision != null && expectedRevision != storedIdentityRevision.get()) {
return@withContext Result.success(Unit)
@@ -842,6 +848,12 @@ class PubkyRepo @Inject constructor(
?: throw AppError("Profile not found")
}
}.onSuccess { loadedProfile ->
+ if (to.bitkit.BuildConfig.DEBUG) { // test hook
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-profile") // test hook
+ if (gate.exists()) Logger.info("QA1686 profile response held", context = "TestHook") // test hook
+ while (gate.exists()) delay(200) // test hook
+ Logger.info("QA1686 profile released; current=${isCurrentLoad()}", context = "TestHook") // test hook
+ } // test hook
var isCurrent = false
_profile.update {
isCurrent = isCurrentLoad()
diff --git a/app/src/debug/java/to/bitkit/dev/Qa1686.kt b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
new file mode 100644
index 000000000..f52d01a3f
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,125 @@
+package to.bitkit.dev // test hook
+
+import android.content.Context // test hook
+import android.content.ClipData // test hook
+import android.content.ClipboardManager // test hook
+import android.os.Bundle // test hook
+import dagger.hilt.EntryPoint // test hook
+import dagger.hilt.InstallIn // test hook
+import dagger.hilt.android.EntryPointAccessors // test hook
+import dagger.hilt.components.SingletonComponent // test hook
+import kotlinx.coroutines.Dispatchers // test hook
+import kotlinx.coroutines.flow.first // test hook
+import kotlinx.coroutines.withContext // test hook
+import to.bitkit.data.CacheStore // test hook
+import to.bitkit.data.backup.VssBackupClient // test hook
+import to.bitkit.data.keychain.Keychain // test hook
+import to.bitkit.models.BackupCategory // test hook
+import to.bitkit.repositories.BackupRepo // test hook
+import to.bitkit.repositories.PubkyRepo // test hook
+import to.bitkit.repositories.WalletRepo // test hook
+import to.bitkit.repositories.LightningRepo // test hook
+import to.bitkit.services.PubkyService // test hook
+import to.bitkit.di.json // test hook
+import to.bitkit.models.WalletBackupV1 // test hook
+import to.bitkit.utils.Logger // test hook
+import java.io.File // test hook
+
+object Qa1686 { // test hook
+ @EntryPoint @InstallIn(SingletonComponent::class) // test hook
+ interface Dependencies { // test hook
+ fun keychain(): Keychain // test hook
+ fun backupRepo(): BackupRepo // test hook
+ fun pubkyRepo(): PubkyRepo // test hook
+ fun pubkyService(): PubkyService // test hook
+ fun vss(): VssBackupClient // test hook
+ fun cache(): CacheStore // test hook
+ fun wallet(): WalletRepo // test hook
+ fun lightning(): LightningRepo // test hook
+ } // test hook
+ suspend fun run(context: Context, method: String): Bundle { // test hook
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java) // test hook
+ val result = kotlin.runCatching { // test hook
+ when (method) { // test hook
+ "qa1686Capture" -> { // test hook
+ File(context.filesDir, "qa1686-mnemonic").writeText(requireNotNull(d.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name))) // test hook
+ for (c in listOf(BackupCategory.METADATA, BackupCategory.SETTINGS, BackupCategory.WALLET)) d.backupRepo().triggerBackup(c).getOrThrow() // test hook
+ check(d.vss().getObject("WALLET").getOrThrow() != null) // test hook
+ "captured complete backup and test credentials" // test hook
+ } // test hook
+ "qa1686Clipboard" -> { // test hook
+ withContext(Dispatchers.Main) { // test hook
+ context.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("test seed", File(context.filesDir, "qa1686-mnemonic").readText())) // test hook
+ } // test hook
+ "copied test credentials" // test hook
+ } // test hook
+ "qa1686BackupFault" -> { // test hook
+ d.backupRepo().stopObservingBackups() // test hook
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val secret = requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) // test hook
+ d.pubkyService().forgetSessionAccess() // test hook
+ check(d.pubkyService().currentPublicKey() == null) // test hook
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET) // test hook
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true // test hook
+ val same = before.value.contentEquals(after.value) && before.version == after.version // test hook
+ Logger.info("QA1686 unavailable identity: failed=${failed.isFailure}, unchanged=$same, pending=$pending", context="TestHook") // test hook
+ check(failed.isFailure && same && pending) // test hook
+ d.pubkyService().signIn(secret) // test hook
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ val fresh = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val complete = json.decodeFromString<WalletBackupV1>(fresh.value.decodeToString()).paykitSdkBackupState != null // test hook
+ val synced = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == false // test hook
+ check(complete && synced) // test hook
+ d.backupRepo().startObservingBackups() // test hook
+ "backup protection and recovered export passed" // test hook
+ } // test hook
+ "qa1686Recover" -> { // test hook
+ val secret = d.pubkyService().deriveSecretKey(requireNotNull(d.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name))) // test hook
+ d.keychain().upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secret) // test hook
+ d.pubkyService().initialize() // test hook
+ d.pubkyRepo().restoreSessionBackupState(to.bitkit.models.PubkySessionBackupV1(kind = to.bitkit.models.PubkySessionBackupKind.LocalSeed)).getOrThrow() // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ d.backupRepo().startObservingBackups() // test hook
+ "recovered identity and fresh complete backup" // test hook
+ } // test hook
+ "qa1686Refresh" -> { // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ "scheduled optional enrichment" // test hook
+ } // test hook
+ "qa1686RequiredFault" -> { // test hook
+ d.backupRepo().stopObservingBackups() // test hook
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value) // test hook
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, "invalid-json") // test hook
+ "staged malformed retained required backup" // test hook
+ } // test hook
+ "qa1686RepairRequired" -> { // test hook
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ if (d.keychain().exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)) { // test hook
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ } // test hook
+ "repaired original required backup" // test hook
+ } // test hook
+ "qa1686SpendingGate" -> { // test hook
+ check(d.backupRepo().hasPendingWalletRestore()) // test hook
+ val result = d.lightning().payInvoice("qa1686-invalid-invoice") // test hook
+ check(result.exceptionOrNull()?.javaClass?.simpleName == "WalletBackupRestorePendingError") // test hook
+ "payment admission blocked by required restore before invoice parsing" // test hook
+ } // test hook
+ "qa1686AddressGate" -> { // test hook
+ val result = d.wallet().newAddress() // test hook
+ "new address success=${result.isSuccess}; error=${result.exceptionOrNull()?.javaClass?.simpleName}" // test hook
+ } // test hook
+ "qa1686SignOut" -> { // test hook
+ d.pubkyRepo().signOut().getOrThrow() // test hook
+ "identity removed" // test hook
+ } // test hook
+ else -> "identity=${d.pubkyRepo().publicKey.value != null}, profile=${d.pubkyRepo().profile.value?.name}, contacts=${d.pubkyRepo().contacts.value.size}, pendingRestore=${d.backupRepo().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}" // test hook
+ } // test hook
+ }.fold({ it }, { "ERROR: ${it.javaClass.simpleName}: ${it.message}" }) // test hook
+ Logger.info("QA1686 $method: $result", context="TestHook") // test hook
+ return Bundle().apply { putString("result", result) } // test hook
+ } // test hook
+} // test hookBase-comparison controls:
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 00c57a69a..30160f67f 100644
--- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
+++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
@@ -42,6 +42,9 @@ class DevToolsProvider : ContentProvider() {
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
check(Binder.getCallingUid() == SHELL_UID) { "Only ADB shell callers are allowed" }
+ if (method.startsWith("qa1686")) { // test hook
+ return kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) { Qa1686.run(requireNotNull(context), method) } // test hook
+ } // test hook
return runCatching {
val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
ServiceQueue.LDK.blocking { command.execute(deps) }
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index 39a03aefb..17e79f4e1 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -351,6 +351,12 @@ class PubkyRepo @Inject constructor(
}
suspend fun initialize() = withContext(ioDispatcher) {
+ if (to.bitkit.BuildConfig.DEBUG && keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)) { // test hook
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-final") // test hook
+ if (gate.exists()) Logger.info("QA1686 base optional initialization held", context = "TestHook") // test hook
+ while (gate.exists()) delay(200) // test hook
+ Logger.info("QA1686 base optional initialization released", context = "TestHook") // test hook
+ } // test hook
val restored = initializeMutex.withLock { initializeSession() }
if (restored) loadIdentityData()
checkAdoptedSource()
diff --git a/app/src/debug/java/to/bitkit/dev/Qa1686.kt b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
new file mode 100644
index 000000000..e40475dbe
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,27 @@
+package to.bitkit.dev // test hook
+import android.content.Context // test hook
+import android.content.ClipData // test hook
+import android.content.ClipboardManager // test hook
+import android.os.Bundle // test hook
+import dagger.hilt.EntryPoint // test hook
+import dagger.hilt.InstallIn // test hook
+import dagger.hilt.android.EntryPointAccessors // test hook
+import dagger.hilt.components.SingletonComponent // test hook
+import kotlinx.coroutines.Dispatchers // test hook
+import kotlinx.coroutines.withContext // test hook
+import to.bitkit.repositories.PubkyRepo // test hook
+import to.bitkit.repositories.BackupRepo // test hook
+import to.bitkit.repositories.LightningRepo // test hook
+import java.io.File // test hook
+object Qa1686 { // test hook
+ @EntryPoint @InstallIn(SingletonComponent::class) // test hook
+ interface Dependencies { fun pubky(): PubkyRepo; fun backup(): BackupRepo; fun lightning(): LightningRepo } // test hook
+ suspend fun run(context: Context, method: String): Bundle { // test hook
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java) // test hook
+ val text = if (method == "qa1686Clipboard") { // test hook
+ withContext(Dispatchers.Main) { context.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("test seed", File(context.filesDir, "qa1686-mnemonic").readText())) } // test hook
+ "copied test credentials" // test hook
+ } else "identity=${d.pubky().publicKey.value != null}, profile=${d.pubky().profile.value?.name}, contacts=${d.pubky().contacts.value.size}, pendingRestore=${d.backup().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}" // test hook
+ return Bundle().apply { putString("result", text) } // test hook
+ } // test hook
+} // test hookCoverage: full 55b31ee1..0fd5e42, all nine changed files and reached restore, identity replacement, backup and SDK paths. Earlier connection-editor steps, a USDT-specific restore failure, terminal identity failure on device, a live homeserver blackout and funded payment replay were not driven.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh matches the optional scheduling and complete-backup protections. Android preserves an identity already activated during required metadata restoration.
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
jvsena42
left a comment
There was a problem hiding this comment.
Code scan of d4ba907: no findings. PubkyRepo.kt:382-386 now skips the import when _publicKey is already set for the stored revision, so a scheduled restore keeps the active identity. The WalletViewModelTest.kt conflict resolution and the journey edit are sound.
Expected for the proxy run: no Restored paykit session for after Full restore success for a LocalSeed backup (was 1), one for an ExternalSession backup.
Device gate (partial): 0fd5e42 — not run yet; still pending the identity-outage proxy run. Device results will follow in a separate review.
|
Device gate (partial):
Still to settle before the final line: after the outage, head didn't reload the own profile. The header showed "Your Name" and the own Contacts row only the key from 10:41:33 to 10:44:44 (
|
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: full review of the PR diff against its merge base 55b31ee, at 0fd5e42. No saved baseline existed, so nothing was carried over. I also compared the restore scheduler and the WALLET backup guard against the iOS twin #1121 at 39fcaec, and checked composition with #1685 at 7b823d2. Neither of those is a full review of the companion PR.
No new actionable code findings.
- Restore decoupling: the restore now reaches
Completedwithout waiting for optional identity setup. That setup is only scheduled afterperformFullRestoreFromLatestBackupsucceeds, on both the VSS path and the RN path, so required WALLET/USDT recovery still comes first. Duplicate requests for the same identity revision are merged into one. Revision checks before and after profile/contact loading drop work for an identity that has since been replaced or removed. The job runs in the supervisor-backed repository scope, so a failure in background setup is logged and cannot crash the app or turn a completed restore into a failed one. The iOS twin uses the same model. - Earlier redundant-import thread: this thread is fixed at this head. Scheduled setup now skips
initializeSession()when the restore has already activated the identity (PubkyRepo.kt:382-386), and the newPubkyRepoTestregression covers that case. I checked this by reading the source and the test, not by running it. - Existing open concern (not counted above): after a terminal Paykit restoration failure, saved credentials stay in place and the new guard keeps failing every WALLET backup. That WALLET backup also holds transfers, reservation indices, watch-only accounts and payment-proof state. This is the mechanism already tracked in the iOS #1121 thread, and the author confirmed there that it applies to Android. On Android the guard throws a non-temporary
IllegalStateException, so there is no timed retry. It is only retried when a later change re-marks the WALLET backup as required, and the periodic backup-failure notification applies. - Dependency composition: #1685 has moved to
7b823d2, past this PR's base. A local trial merge of this head with that revision merged without conflicts and adds no new restore-timepubkyRepo.initialize()callers. I did not compile or test the merged tree.
Validation: code-only review. I did not run any Gradle tasks, unit tests or E2E. The author reports green CI, lint and E2E at 0fd5e42. Those runs do not cover J1, whose fault controls are not provisioned.
Device testing: not performed in this review.
Recommended before device testing: settle the terminal-restoration WALLET backup behaviour in the linked iOS thread, since it applies to both platforms.
Suggested additional test cases
- Android, wallet with no Pubky profile, so the backup's
pubkySessionis empty: restore from VSS, then make a wallet change such as receiving on-chain. Expected: Settings → Backup shows the WALLET backup succeeding, and the new identity guard does not block a wallet that has no identity. - Android, wallet with an active Pubky profile: sign out of the profile, then make a wallet change. Expected: the WALLET and METADATA backups both succeed afterwards, with no lingering backup-failure notification, which confirms sign-out clears every credential the guard checks.
Reviewer model: claude-opus-5-5
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Approving with one LOW open, inline at PubkyRepo.kt:382: the own profile isn't reloaded after an outage during restore. Fix it here or move it to a follow-up issue, your choice; reply on the thread saying which.
Device gate: 0fd5e42, Gate_Throwaway emulator through a local homeserver fault proxy, base = merge-base 55b31ee. LocalSeed backup: a profile created in Bitkit on staging, with two contacts.
- perf:
Full restore success→ Completed took 2.0–2.2 s on head with the homeserver reachable, held or blackholed. Base took 12.75 s reachable, 103.65 s held, and 192–259 s blackholed (it waits on re-sign-in:Failed to restore paykit session, attempting re-sign-in). - Head keeps the identity through the outage and lists the contacts. Base drops the identity and shows the Contacts intro until the outage ends.
- The LOW above reproduced in 2 of 2 runs.
- After release, the WALLET backup on head took about 95 s, through repeated
Retrying backup for: 'WALLET' … acquire Pubky write lock. Base took about 23 s with one retry. This is the same pubky-sdk write-lock window seen on synonymdev/bitkit-ios#1124, which is not Bitkit code, so I haven't raised it here.
restore-during-identity-outage.xml steps 2 and 6. With a LocalSeed backup the identity activates inside the metadata restore, before a fault can start, and VSS shares a staging host the proxy can't isolate. Step 4 was timed only through the restore measurement above. An ExternalSession backup wasn't tested.
CI is green at 0fd5e42.
|
Device check: a native crash on backgrounding with VSS unreachable. Not from this PR: master crashes the same way. The crash is a SIGABRT in Setup: Pixel_9 emulator, a fresh wallet, the VSS host blocked by DNS with Electrum up, the cached-apps freezer disabled, and a SETTINGS backup pending (denomination change) before Home.
Master's crash has the same abort message and the same 39-frame backtrace; only one frame offset differs: So it is an existing ldk-node shutdown race, not something the bitkit-core or vss-client bumps in this PR introduce. My approval stands. The crash needs its own issue against ldk-node. |
|
Thanks; this matches the already-open native shutdown tracker #1536. I linked your exact head/master comparison there, preserving the VSS/SETTINGS prerequisites, identical-library claim and small-sample limits. The new data remains attributed external evidence until the raw symbols/tombstones are inspected. No duplicate ldk-node issue or app-scope shutdown rewrite is being introduced, and no existing CI failure is waived. |
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of the entire pinned PR at 89e2702e against merge base c747cc0b, after the comparison base changed. No coverage was inherited.
No new actionable code findings.
The previously reported HIGH terminal-identity backup concern and LOW profile-refresh retry concern remain unresolved at this head. Temporary recovery does not resolve terminal backup availability. Existing execution reports retain their original revisions.
Targeted comparisons with iOS c0b1e86f and dependency Android #1685 at c747cc0b checked optional initialization, authentication presentation, required-recovery admission and backup protection; neither received a full companion review.
Validation: matching-head CI and lint succeeded. Their synthetic merge tree equals the pinned head; the CI log records all 4,607 tests passing, including the changed regressions. Local diff-whitespace and journey XML checks passed. Focused Gradle tests stopped before execution because the Android SDK is unavailable. Current-head E2E is still in progress.
Device testing: not performed in this review. J1 remains unexecuted here and its complete-app fault controls are not committed. Resolve the existing concerns and provision those controls before treating the change as ready for a complete device pass.
Suggested additional test cases:
- Android: restore a wallet with no Pubky identity, make an ordinary wallet change, and verify WALLET and METADATA backups synchronize.
- Android: disconnect an adopted Ring identity, make an ordinary wallet change, and verify retained source credentials do not prevent subsequent backup synchronization.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Re-approving at 4056d2c0 ("keep VSS operations with their original wallet"). No findings.
- Identity changes:
withSuspendedClientscloses admission and bumps the generation in one locked block. It then cancels and joins the admitted jobs beforevssShutdownClient/vssShutdownLdkClient, and the gates reopen only infinally. An in-flight put therefore finishes on the old client, and nothing reaches the next wallet's client before a fresh setup. - Node's VSS store: it belongs to ldk-node and is untouched.
- Hangs:
startNodecan't hang on a drain, becausecaptureOwner()throws while suspended. - Cancellation: the VSS calls moved to
runSuspendCatching, so a retired owner cancelsperformBackupwithout marking it synced. - iOS #1121 LOW: not reachable here, because
lifecycleMutexserialises identity changes.
CI: compose-ui / ui-tests (and the build gate that requires it) failed because the CI emulator never finished booting (ServiceNotFoundException: No service published for: input). No tests ran, so a re-run should clear it.
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Follow-up of the VSS wallet-ownership delta since 43e0f3fe, at 4056d2c. Inherited baseline: review 5755240987. Merge base is unchanged at 44bf50ec.
No new actionable code findings.
Identity replacement closes both standalone VSS admissions, cancels and joins admitted backup and Lightning work, and shuts those clients down before the credential block. A failed shutdown leaves admission closed. Scheduled backups, retries, and an explicit full backup keep the captured generation. METADATA and WALLET uploads still pass through the unchanged identity snapshot checks and fail instead of storing a null identity payload. The running LDK node keeps the VSS client it built for itself.
Only the standalone-client drain was compared on bitkit-ios#1121 at 25f25ea. Both sides retire those clients before credential replacement and drop a result once the owner changes. Android cancels admitted calls and stays closed when shutdown throws. iOS waits for admitted calls to finish, and its shutdown callback does not fail the reset. VssClientCoordinatorTest covers the Android contract.
The terminal WALLET backup thread still matches the unchanged snapshot guard: a null SDK snapshot is rejected while stored identity remains, so later WALLET backups stay failed until that identity can be exported. This follow-up did not re-run that case. The allowances journey thread is marked resolved and tracked in #1766; this delta does not change that journey.
Validation: source and unit tests were inspected, not executed. At this SHA, GitHub reports unit and Compose, lint, and E2E succeeded. Compose had an earlier emulator boot failure on this SHA; the completed rerun succeeded. Device testing: not performed in this review.
Suggested additional test cases:
- Android. Disposable wallet, a backup upload held in flight, and a second recovery phrase. Replace the wallet identity before that upload returns. The in-flight upload stays on the original VSS store or is cancelled, and the replacement wallet's later backup setup uses the new phrase.
Reviewer model: grok-4.7
Reasoning effort: high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes. The existing terminal WALLET backup finding is still open. This commit also adds one MEDIUM: Data Backups now shows a backup as failed while it is only waiting to run. The VSS coordinator held up in every wipe, restore and reset case I drove.
Findings:
- MEDIUM: Data Backups shows a scheduled backup as failed during its 5 s debounce.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:534. The rewrittenscheduleBackupno longer setsrunning = truewhen it reserves a job. At 43e0f3f it did, beforedelay(BACKUP_DEBOUNCE)(old line 493). Nowrunningis set only inperformBackup. For the 5 s debounce, and while a WALLET backup waits for payment submission, the category hasrequired > syncedwithrunning = false.BackupSettingsScreen.kt:125-128shows that as a red "Failed Backup: now" with a Retry button. On a fresh wallet ACTIVITY is rescheduled every 5–10 s, so Transaction Log keeps flipping to "Failed Backup" while every upload succeeds. 43e0f3f shows "Running" in the same window. Settingrunning = truewhen the job is reserved, and clearing it when the job ends without a backup, restores the old status. - Existing HIGH: Terminal identity failure blocks newer ordinary WALLET backups.
BackupRepo.kt:854. This commit does not change thehasIdentityForBackup()guard.
QA for 4056d2c: Android 15 emulators, staging regtest and VSS, dev debug build of this head. Videos play at 2×.
Data Backups status while backups are scheduled ❌ This head (left) shows red "Failed Backup" rows with Retry while uploads succeed; 43e0f3f (right) shows "Running".
Both emulators had a fresh wallet with Settings > Data Backups open. On this head, Transaction Log (and Widgets or Tags at times) kept switching to "Failed Backup: now" with a Retry button. The log at the same time shows Backup succeeded for: 'ACTIVITY' every 5–10 s. The 43e0f3f build showed the yellow "Running" state in that window and never "Failed". The reset case's clip shows the same flicker on a new wallet.
status-compare2x.2x.mp4
Reset the wallet, create a new one, and keep each wallet's backup separate ✅ After a reset and a new wallet in the same app process, the new wallet's backup restored with only its own data.
Wallet A had the profile WalletA1686 and GBP. I reset it from Settings > Security > Reset wallet and created wallet C with EUR and no profile, without restarting the app. The log shows the wipe, then VSS client setup for wallet[0], the LDK client set up again, and C's SETTINGS backup succeeding.
Restoring C's phrase on the second emulator showed EUR and the "Your Name" placeholder. So C's uploads went to C's own store, and none of A's data was there. Restoring A's phrase right after the reset brought back GBP and WalletA1686; I checked that one by reading the screen.
| Reset and new wallet | Restore of the new wallet |
|---|---|
wipe-new-wallet2x.2x.mp4 |
restore-c2x.2x.mp4 |
Restore a backed-up wallet that has a Pubky profile ✅ Plain "Wallet restored" text, then Home with WalletA1686 and a receive address.
The log shows Full restore success. METADATA's first backup was refused before identity activation and its retry succeeded 6 s later. ACTIVITY, SETTINGS, WALLET and BLOCKTANK backups succeeded.
restore-a2x.2x.mp4
Restore while the Pubky service is unreachable ✅ The wallet restored and was usable, and the profile came back by itself 15 s after Pubky was reachable.
On the emulator I allowed the app only DNS, VSS and Electrum, Blocktank and the price feed, and rejected everything else. The restore finished with "Your wallet and balance are back, but Bitkit could not restore: Profile, Contacts, Tags." Home showed the "Your Name" placeholder and a receive address.
During the outage METADATA backups were refused and WALLET retried at 10, 20 and 40 s (log). After I lifted the block, Home showed WalletA1686 with no action from me. WALLET and METADATA backups then succeeded. An earlier unrecorded run gave the same result.
| Restore during the outage | After unblocking |
|---|---|
restore-outage2x.2x.mp4 |
restore-outage-after2x.2x.mp4 |
Retry a failed backup from Settings > Backups ✅ A Settings backup that failed while VSS was blocked went through when I tapped Retry.
With VSS rejected on the emulator, a currency change failed to back up (Backup failed for: 'SETTINGS'). The row showed "Failed Backup" with Retry. After I lifted the block and tapped Retry, the log shows Backup starting for: 'SETTINGS' with no Scheduling backup line before it, which is the Retry path. It then shows Backup succeeded, and the row read "Latest Backup: now".
retry-backup2x.2x.mp4
Restart the Lightning node from the LDK debug screen ✅ The node restarted and backups kept succeeding.
Dev Settings > LDK > Restart. The log shows Node restarted successfully, then a completed sync, then WIDGETS, METADATA and ACTIVITY backups succeeding within 15 s.
restart-node2x.2x.mp4
Reset the network graph from recovery mode ✅ The graph was cleared from VSS, the app relaunched and the node started.
bitkit://recovery-mode > Reset Network Graph > Yes. The toast read "Network Graph Reset". The log shows Cleared network graph from VSS, the relaunch, Node started and the LDK VSS client set up again. Home opened.
reset-graph2x.2x.mp4
Check: terminal WALLET backup finding ⏭️ Not re-driven; the guard is unchanged in this commit.
Reproducing it needs an unusable saved session with no recovery secret, which the shipped app cannot produce.
Not driven:
- Allowances off and on, and Request Payment without contacts: carried from our 43e0f3f report. This commit does not change allowance code or that screen.
- Held allowance timing and required-recovery failures: these need injected holds and decoder failures the app does not have. They are covered by the earlier reports cited there.
- A shutdown or native call that fails inside
withSuspendedClients: checked in code only.
Review covered 43e0f3f..4056d2c, one commit: VssClientCoordinator and its use in VssBackupClient, VssBackupClientLdk, BackupRepo, LightningRepo, WalletConnectionRecovery and BackupsViewModel. Store ids derive the same way as VssStoreIdProvider. No device case drove resetPaymentRoutingCachesAndWait from a failed Lightning payment, because staging has no channel for one.
Twin: synonymdev/bitkit-ios#1121 got the matching coordinator at 25f25ea ("drain VSS calls before replacing wallet clients"). It changes only VssBackupClient.swift and AppReset.swift, not backup scheduling, so iOS has no counterpart of the status regression.
Reviewed by claude-opus-5-5-medium
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Follow-up since 4056d2c, at a2c58f2. Inherited baseline: review 5767887265. This pass rechecked scheduled backup status from VSS admission through the debounce, payment wait, completion, observation stop, startup cleanup, and retirement. VSS admission and drain, Lightning restart and routing refresh, METADATA/WALLET identity-snapshot refusal, restore scheduling, allowance admission, and the previously reviewed journeys stay inherited.
No new actionable code findings.
The admitted job sets Running before the five-second debounce and the wallet payment wait, then clears it when the upload finishes, observation stops, or that wallet is retired. Startup cleanup keeps the flag while the reservation is admitted or the upload is still in progress. bitkit-ios#1121 at 25f25ea leaves backup scheduling as it was: iOS already marks a scheduled backup running before its wait, and that pin's BackupService change is Blocktank refresh only. The resolved thread 4467512070 described Failed/Retry for the whole debounce; that wait is now marked Running.
The terminal WALLET backup failure remains and is unchanged: a stored identity with no snapshot still refuses the ordinary WALLET upload. No matching GitHub issue was found. Validation: build-and-test passed on this head. Unit tests were not rerun locally. Device testing: not performed in this review. The author reports J3 on an installed dev build, with Settings showing Running through the currency-change debounce and Latest Backup after acknowledgement.
Reviewer model: grok-4.7
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Re-approving at a2c58f2. I reviewed b93444c (keep queued backups marked as running) and a2c58f2 (clear status for unadmitted backups).
Checked and clean:
pending.admitted = trueis the first statement insidewithWallet, so every path that writesrunning = truereaches the NonCancellable finally, which recomputesrunningfromrunningBackupsand the admitted pending entry. That holds for cancellation during the debounce, during the Paykit wait and duringtriggerBackup, and for owner changes (withSuspendedClientsjoins admitted jobs before the wipe block) andstopObservingBackups.- If the process dies during the debounce, the persisted
running = trueis cleared by the nextstartObservingBackups. A newly admitted job keeps its flag (a2c58f2). - The job's own
running = truedoes not block it: it checksstatus.copy(running = category in runningBackups).shouldBackup(category). The status observer only reschedules whenshouldBackupholds, so the running true→false transition after a skipped or failed job does not loop.
Device gate: a2c58f2 (Gate_Throwaway emulator), journeys: 1 passed (backup/scheduled-backup-status.xml). Changing the currency to EUR and opening bitkit://screen/backup-settings showed Settings as Running with no Retry. It moved to "Latest Backup: now" 16 s later, with no Failed Backup message:
17:29:14.731 DEBUG [BackupRepo.kt:537] Scheduling backup for: 'SETTINGS'
17:29:19.745 DEBUG [BackupRepo.kt:625] Backup starting for: 'SETTINGS'
17:29:20.116 INFO [BackupRepo.kt:663] Backup succeeded for: 'SETTINGS'
perf: not relevant (two extra DataStore writes per scheduled backup); l10n: no UI text changed; iOS: journey recorded as Android-only in journeys/README.md.
There was a problem hiding this comment.
Verdict: ⛔️ Request changes. The scheduled-backup status regression is fixed; the existing terminal-identity WALLET backup HIGH still reproduces at a2c58f2.
Findings:
- Existing HIGH — Terminal identity failure blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:875. An unusable saved session without a recovery secret left a new ordinary watch-only account reservation pending. The real SDK refused complete WALLET capture withidentity_error; the remote bytes and version stayed unchanged. Ordinary receiving still worked. Restoring the original credentials permitted upload again. This is additional evidence for the existing thread, not a new inline finding.
No new actionable delta findings. The scheduled Failed/Retry finding is fixed: admitted work marks Running before its wait, and cancellation cleanup preserves only active uploads or admitted replacement work.
QA for a2c58f2: Android 15 / API 35 emulator, disposable unfunded staging/regtest wallet, real VSS and native SDK. I built and verified the installed pristine APK for J3 and brief backgrounding. The remaining cases used local Debug controls to stage faults, hold activation and inspect state; all native libraries matched the pristine build. Remote-state assertions below came from Debug output and app logs. Videos play at 2×.
Planned cases: 10 passed, 1 failed, 0 unrun.
Back up ordinary wallet changes after terminal identity failure ❌ A new reservation remained pending while the remote WALLET stayed unchanged.
An invalid saved session with no recovery secret reached terminal failure. I reserved watch-only account index 1, then attempted WALLET backup. Debug checks confirmed SDK identity_error, unchanged remote bytes/version and pending work. Address allocation succeeded. Restoring the original credentials allowed a complete upload; it does not resolve the terminal-failure case.
terminal-wallet-2x.2x.mp4
Show Running while a currency backup is queued ✅ Settings showed Running without Retry, then Latest Backup after acknowledgement.
J3, pristine build: changed USD to GBP and immediately opened Data Backups. The log shows scheduling, the five-second wait, then successful SETTINGS upload. The assertion concerns the Settings row.
j3-2x.2x.mp4
Complete a queued backup after briefly backgrounding ✅ The currency change backed up and its row returned to Latest Backup.
Pristine build: changed to EUR, backgrounded briefly and returned. The brief transition cancelled the debounced node stop; exact observation-stop cancellation is covered separately below.
background-2x.2x.mp4
Clear Running when queued backup observation stops ✅ Cancellation cleared Running, retained required work and completed after observation resumed.
Debug controls scheduled a real currency change and stopped observation after one second. The status read showed running=false with required > synced. Resuming showed Running, then running=false with synced=required after acknowledgement.
observation-stop-2x.2x.mp4
Reset during a queued backup and back up a new wallet ✅ Reset completed, and the replacement wallet acknowledged its own Settings backup.
A Debug-scheduled currency change immediately preceded the normal Yes, Reset action. Wipe reached onboarding. I created a new wallet in the same process and scheduled its backups; Settings finished with no pending work or stale Running. Cross-wallet restore isolation remains covered by the earlier report cited below.
retirement-2x.2x.mp4
Keep payments and addresses gated after required wallet recovery fails ✅ Decoder failure retained the gates across restart; repair and Try Again restored receiving.
J1: malformed input reached the real WALLET decoder after the valid payload was retained. The error screen offered Try Again and Connection settings, with no Proceed action. Direct invoice and address calls returned the restore-pending error, including after another process restart. Repairing the original input and retrying produced Full restore success, cleared pending markers and restored the Savings receive QR.
required-wallet-2x.2x.mp4
Keep payments and addresses gated after required USDT recovery fails ✅ The retained-input decoder failed safely across restart, then repair restored receiving.
J1: malformed retained legacy USDT input reached restoreUsdtRecovery and raised JsonDecodingException. Payment and address admission stayed gated across restart. Repairing the original complete payload and Try Again cleared required work and restored receiving. This checks the decoder-failure gate; it is not a nonempty encrypted USDT import or funded transfer test.
required-usdt-2x.2x.mp4
Preserve a corrected Electrum endpoint while required recovery is pending ✅ The saved correction survived the held recovery, foreground cycles, restart and the older Settings backup.
J1 connection recovery: saved a distinct reachable route to staging Electrum through the normal editor. A failed RGS probe preserved that correction and the original RGS setting. With required metadata held, three foreground cycles produced one node start in that process. After restart, releasing the hold and retrying completed the original restore; stored-settings reads confirmed the corrected endpoint, original RGS and EUR.
A supplementary fresh restore checked payment/address gates after startup settled, then completed through Try Again. That separate restore began after the original correction had completed and its journal had cleared.
| Save and foreground cycles | Settled restart gates and Retry |
|---|---|
connection-save-2x.2x.mp4 |
connection-gates-2x.2x.mp4 |
Preserve the complete wallet backup while identity export is unavailable ✅ Refusal preserved remote bytes/version and pending work; recovering the identity permitted complete upload.
The Debug control removed live SDK session access. Actual WALLET export failed without replacing the complete remote record. Restoring the same secret produced a complete SDK snapshot and synchronized upload. These assertions were checked through real SDK/VSS calls and Debug output.
identity-protection-2x.2x.mp4
Restore wallet access before optional identity activation ✅ Receiving worked before authentication; release recovered the profile, one contact and a complete backup.
J1: held final activation after required imports. Completion warned about Profile, Contacts and Tags, but required markers were clear while identity/authentication were false. Home and receiving worked. Attempting WALLET backup preserved the previous bytes/version and pending work. Release recovered the saved profile and exactly one contact; a fresh SDK-bearing WALLET backup synchronized. A repeated scheduling request coalesced with the held job.
A separate cold restart with initialization held retained credentials and ordinary receiving. Releasing that hold recovered the same profile and one contact. State and contact counts were checked through Debug output.
| Final activation held and released | Restart with initialization held |
|---|---|
optional-activation-2x.2x.mp4 |
optional-restart-2x.2x.mp4 |
Back up the ordinary wallet after profile sign-out ✅ Cleared credentials allowed a synchronized ordinary WALLET backup without an identity snapshot.
The normal repository sign-out path cleared the source, secret, session, public identity and contacts. WALLET, METADATA and SETTINGS uploads then succeeded. Decoding the stored WALLET confirmed no SDK identity snapshot; Home showed Your Name and required work was clear.
identity-signout-2x.2x.mp4
Check: backup scheduler and VSS ownership regressions ✅ Both focused suites passed on the pristine head.
./gradlew --no-daemon --max-workers=4 testDevDebugUnitTest --tests to.bitkit.repositories.BackupRepoTest --tests to.bitkit.data.backup.VssClientCoordinatorTest
JUnit XML confirms 116 tests, zero failures/errors/skips. This includes the retired replacement reservation, delayed startup cleanup, observation cancellation, payment-submission wait and client-drain failure controls. The pristine APK build and diff-whitespace check passed. The full unit/lint/Compose gate was not rerun in this review.
Inherited coverage: the unchanged profile restore, Pubky outage recovery, manual backup Retry, LDK restart, graph reset and wallet-store isolation rely on the 4056d2c report. The ordinary J2 allowance journey and Request Payment without contacts rely on the 43e0f3f report. Held allowance admission/off-on cases rely on the b6751e2 report; reconnect, setup-pending, stale-profile and rejected-admission snapshot cases rely on the 1b1426f report. Their relevant mechanisms are unchanged here.
Limits: the WALLET payment wait, delayed-startup cleanup race and never-admitted replacement race were exercised by focused unit tests. No device case forced native shutdown failure, failed-payment routing-cache repair, funded payment replay, a nonempty USDT import or actual Ring-app unlink. The deferred allowance-journey wording remains tracked in bitkit-android#1766 docs: clarify delayed-contact coverage in the allowance toggle journey.
Review covered the contiguous 4056d2c..a2c58f2 delta, the scheduler/coordinator/status paths it reaches, and the existing terminal-WALLET finding. One independent reviewer inspected the delta and prior reports; I checked the conclusions at this head.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh, at 25f25ea. Its scheduler already marks Running before the wait. The restore/VSS ownership intent matches; this status correction is Android-only. This was a source comparison, not an iOS device run.
Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes. The existing terminal-identity WALLET backup HIGH still reproduces at b5d5858.
Findings:
- Existing HIGH — Terminal identity failure blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:875. With an invalid saved session and no recovery secret, the real SDK refused WALLET capture. A new ordinary watch-only reservation stayed pending and the remote WALLET bytes/version stayed unchanged. Ordinary receiving worked; restoring the original credentials permitted a complete upload. This confirms the existing finding, with no duplicate inline comment.
No new actionable delta findings. The resolved backup-status fix, profile-retry fix and allowance-admission fix remain intact.
QA for b5d5858: Android 15 / API 35, disposable regtest wallets, staging VSS/Pubky and manually funded staging Lightning channels. I built the pinned head. J3 ran on the pristine APK; fault cases used unpushed Debug controls with all native libraries identical. State and remote-record assertions below were checked through those controls and logs. Videos play at 2×.
Back up ordinary wallet changes after terminal identity failure ❌ A new reservation stayed pending while the remote WALLET remained unchanged.
The saved session was unusable and its recovery secret absent. Actual SDK capture returned identity_error; Debug readback confirmed unchanged remote bytes/version and pending ordinary work. New address allocation and the Savings QR worked. Original-credential recovery allowed a complete upload.
terminal-wallet-2x.2x.mp4
Show Running while a currency backup is queued ✅ Settings showed Running without Retry, then Latest Backup after acknowledgement.
J3, pristine build: changed USD to GBP and opened Data Backups. The app log confirms the five-second scheduled wait and successful SETTINGS upload. The assertion concerns the Settings row.
j3-2x.2x.mp4
Restore wallet access before optional identity activation ✅ Required recovery finished while identity activation was held; release restored the profile, one contact and complete backup.
J1: with final activation held, Debug checks showed retained credentials, no identity/authentication, clear required-recovery markers and successful ordinary address allocation. Incomplete capture was refused with unchanged remote bytes/version and pending work. Release recovered the same saved profile and exactly one contact. A complete SDK-bearing WALLET synchronized; the persistent allowance pause stayed set. A QuickPay setup sheet covered the first attempted Receive tap, so the while-held receiving assertion is the direct address call.
optional-activation-2x.2x.mp4
Keep payments and addresses gated after required wallet recovery fails ⏭️ Decoder failure and refusal passed; the complete restart/repair continuation remains unverified.
J1: malformed input reached the real WALLET decoder after retaining the original valid envelope. It raised JsonDecodingException; direct payment and address calls returned WalletBackupRestorePendingError, and the persistent restore pause was set. An earlier repair recovered ordinary receiving and identity, but its optional-category result required correcting the native database setup and is not a complete acceptance pass. The clean repeat reached the decoder and safe refusals; the devices then stopped responding before its post-restart repair finished.
required-decoder-2x.2x.mp4
Disable allowances and restore the active row after enabling ⏭️ Linked wallets and usable channels were ready; the request/off/on/payment sequence did not run.
J2: both real identities were linked, a grant was accepted and both manually funded staging channels became usable. The peer prepared a 2,500-sat request. Device transports stopped answering before it was sent, so neither manual presentation while disabled nor a subsequent automatic payment is claimed at this head. The older ordinary-J2 reports do not prove the changed candidate/resolution paths here.
Keep automatic payments paused after restore until Resume ⏭️ Restore set the persistent pause, but request handling and user Resume remain unverified.
The real restore retained the accepted allowance. Debug state exposed persistedPause=true and visiblePause=RESTORED. Relaunch presentation, a covered manual request, the user-facing Resume action and a subsequent automatic payment were not completed.
Device gap: after the transport stall, reconnecting the owned devices, a separate bridge, cold boots preserving the wallets, a fresh Android 16 / API 36 image and a one-vCPU hardware-GPU route did not restore usable device access. These are setup gaps, not app failures.
Check: restore, backup ownership and allowance integration regressions ✅ The focused suites passed on the unmodified head.
./gradlew --no-daemon --max-workers=4 assembleDevDebug testDevDebugUnitTest --tests to.bitkit.repositories.BackupRepoTest --tests to.bitkit.repositories.PubkyRepoTest --tests to.bitkit.data.backup.VssClientCoordinatorTest --tests to.bitkit.repositories.PaykitAllowanceRepoTest --tests to.bitkit.viewmodels.AppViewModelSendFlowTest
JUnit XML reports no failures, errors or skips. The full unit/lint/Compose gate was not rerun locally.
Inherited coverage: original executions remain at their reported heads. The a2c58f2 report covers unchanged observation cancellation, queued-wallet retirement, retained-USDT decoder gates, Electrum correction and sign-out. The b6751e2 report covers unchanged held allowance admission, same-identity contact synchronization and disable/re-enable activation tokens. The 1b1426f report covers unchanged missing-profile retry, setup-pending suppression, stale-response refusal and rejected-admission snapshot preservation. The 4056d2c report covers unchanged VSS ownership/isolation, manual backup Retry, node restart and graph reset. Their specific mechanisms and native library pins are unchanged; these are attributed prior results, not new executions.
Limits: no device case forced native shutdown failure, failed-payment routing-cache repair, funded payment replay after process death, a nonempty encrypted USDT import, actual Ring-app unlink, or the added multiple-candidate/unchecked-limit payment branches. The delayed-contact journey prose is already deferred in bitkit-android#1766 docs: clarify delayed-contact coverage in the allowance toggle journey.
Review covered the contiguous a2c58f2..b5d5858 delta, its master integration and the existing terminal-WALLET finding. Source review included the broader allowance, private-contact routing, hardware-fee, scanned-amount and dependency changes; device checks concentrate on restore/backup/allowance integration. An independent reviewer inspected the delta and prior discussion; I checked its conclusions at the pinned head.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh, at eff0dadf, retains matching deferred identity work and complete-backup refusal. Its latest scheduler repairs are iOS-specific. This was a source comparison, with no iOS device run.
Unpushed Debug controls and deciding output
The controls stage faults and holds and inspect real SDK/VSS results. They do not mock a native result or change the decisions under test. The native database initializer supplies the same fixture prerequisite used in the prior reports. Unused timing helpers are included because this is the full built patch.
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 6532aa144..4a1853cb0 100644
--- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
+++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
@@ -41,6 +41,9 @@ class DevToolsProvider : ContentProvider() {
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
check(Binder.getCallingUid() == SHELL_UID) { "Only ADB shell callers are allowed" }
+ if (method.startsWith("qa1686")) {
+ return kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) { Qa1686.run(requireNotNull(context), method, arg) }
+ }
return runCatching {
val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
ServiceQueue.LDK.blocking { command.execute(deps) }
diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
index 029624f7f..9faa3439e 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -982,6 +982,11 @@ class BackupRepo @Inject constructor(
}
connectionSession.replay()
prepareUsdtRecoveryRestore()
+ if (to.bitkit.BuildConfig.DEBUG) {
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-required")
+ if (gate.exists()) Logger.info("QA1686 required metadata download held", context = TAG)
+ while (gate.exists()) kotlinx.coroutines.delay(200)
+ }
var metadataReceived = false
val metadataResult = performRestore(BackupCategory.METADATA, failedCategories) { dataBytes ->
metadataReceived = true
@@ -1181,7 +1186,11 @@ class BackupRepo @Inject constructor(
// Automatic allowance payments wait for the user to resume them before any restored state is written.
paykitAllowanceStore.pauseAfterRestore()
keychain.upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, String(dataBytes))
- val parsed = json.decodeFromString<WalletBackupV1>(String(dataBytes))
+ val walletInput = if (to.bitkit.BuildConfig.DEBUG && java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-corrupt-wallet").exists()) {
+ Logger.info("QA1686 staging malformed required WALLET input", context = TAG)
+ "invalid-json"
+ } else String(dataBytes)
+ val parsed = json.decodeFromString<WalletBackupV1>(walletInput)
var restoredAttempt: OnchainSendAttempt? = null
parsed.paykitPaymentState?.let {
// Validate every proof before writing a guard or replacing existing payment state.
diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt
index 7535bd701..c50bbd2d5 100644
--- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt
@@ -214,12 +214,18 @@ class PaykitAllowanceRepo @Inject constructor(
}
val revision = synchronized(activationLock) { activationRevision }
withContext(ioDispatcher) {
+ if (to.bitkit.BuildConfig.DEBUG) {
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-activation")
+ if (gate.exists()) Logger.info("QA1686 allowance activation held after revision capture", context = TAG)
+ kotlinx.coroutines.withTimeout(90_000) { while (gate.exists()) kotlinx.coroutines.delay(100) }
+ }
if (!canActivate()) return@withContext
val identityChanged = synchronized(activationLock) {
if (activationRevision !== revision) return@withContext
val changed = activeIdentity != normalizedIdentity
if (changed) activation = Activation(normalizedIdentity)
executor.activate(normalizedIdentity)
+ if (to.bitkit.BuildConfig.DEBUG) Logger.info("QA1686 allowance activation; changed=$changed; token=${activation?.let { System.identityHashCode(it) }}", context = TAG)
changed
}
if (identityChanged) {
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index 72303897d..d0728b1b5 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -380,6 +380,38 @@ class PubkyRepo @Inject constructor(
private suspend fun initializeForRevision(
expectedRevision: Long? = null,
): Result<Unit> = withContext(ioDispatcher) {
+ if (to.bitkit.BuildConfig.DEBUG) {
+ val root = java.io.File("/data/user/0/to.bitkit.dev/files")
+ val drop = java.io.File(root, "qa1686-drop-final-activation")
+ if (expectedRevision != null && drop.exists()) {
+ initializeMutex.withLock {
+ val savedSecret = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
+ val savedSession = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)
+ check(!savedSecret.isNullOrBlank() || !savedSession.isNullOrBlank())
+ pubkyService.forgetSessionAccess()
+ savedSecret?.let { keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, it) }
+ savedSession?.let { keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, it) }
+ clearAuthenticatedState(clearCachedProfile = false, preserveStoredIdentity = true)
+ drop.delete()
+ val sdkField = pubkyService.javaClass.getDeclaredField("paykitSdkService").apply { isAccessible = true }
+ val sdkService = sdkField.get(pubkyService) as to.bitkit.services.PaykitSdkService
+ val providerField = sdkService.javaClass.getDeclaredField("sessionProvider").apply { isAccessible = true }
+ val provider = providerField.get(sdkService) as to.bitkit.services.PaykitSdkSessionProvider
+ provider.suspendStoredSessionAccess()
+ Logger.info("QA1686 held final activation; required imports complete; saved credentials retained", context = TAG)
+ val held = java.io.File(root, "qa1686-hold-final")
+ try {
+ while (held.exists()) delay(200)
+ } finally {
+ provider.resumeStoredSessionAccess()
+ }
+ }
+ }
+ val gate = java.io.File(root, if (expectedRevision == null) "qa1686-hold-initial" else "qa1686-hold-final")
+ if (gate.exists()) Logger.info("QA1686 optional initialization held", context = TAG)
+ while (gate.exists()) delay(200)
+ Logger.info("QA1686 optional initialization released", context = TAG)
+ }
val (restored, revision) = initializeMutex.withLock {
if (expectedRevision != null && expectedRevision != storedIdentityRevision.get()) {
return@withContext Result.success(Unit)
@@ -490,6 +522,7 @@ class PubkyRepo @Inject constructor(
}
private suspend fun initializeSession(notifyFailure: Boolean = true): Boolean {
+ if (to.bitkit.BuildConfig.DEBUG) Logger.info("QA1686 session initialization entered", context = TAG) // test hook
_isRestoringSession.update { true }
try {
val savedSession = runSuspendCatching { keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) }
@@ -853,6 +886,7 @@ class PubkyRepo @Inject constructor(
return
}
val isCurrentLoad = { isCurrent(signIn) && profileWriteGeneration.get() == writeGeneration }
+ if (to.bitkit.BuildConfig.DEBUG) Logger.info("QA1686 own profile read started", context = TAG) // test hook
_isLoadingProfile.update { true }
try {
@@ -862,6 +896,13 @@ class PubkyRepo @Inject constructor(
?: throw AppError("Profile not found")
}
}.onSuccess { loadedProfile ->
+ if (to.bitkit.BuildConfig.DEBUG) {
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-profile")
+ if (gate.exists()) Logger.info("QA1686 profile response held", context = TAG)
+ while (gate.exists()) delay(200)
+ Logger.info("QA1686 profile released; current=${isCurrentLoad()}", context = TAG)
+ }
+
var isCurrent = false
_profile.update {
isCurrent = isCurrentLoad()
diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
index f90d7e2f7..aa3380e48 100644
--- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
+++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
@@ -1324,8 +1324,15 @@ class PaykitSdkService @Inject constructor(
admit: suspend () -> Unit,
): AllowanceRecord {
isSetup.await()
+ if (BuildConfig.DEBUG) {
+ val gate = java.io.File(context.filesDir, "qa1686-hold-accept")
+ if (gate.exists()) Logger.info("QA1686 allowance accept held before SDK lock", context = TAG)
+ kotlinx.coroutines.withTimeout(90_000) { while (gate.exists()) delay(100) }
+ Logger.info("QA1686 allowance accept waiting for SDK lock", context = TAG)
+ }
return operationLock.withLock(Priority.Interactive) {
withStateRevisionTracking(beforeOperation = admit) { handle ->
+ if (BuildConfig.DEBUG) Logger.info("QA1686 allowance accept admitted", context = TAG)
completeSdkCall { handle.acceptAllowance(counterparty, allowanceId, expectedProposalStreamItemId) }
}
}
diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index 9946a8d46..fd996d0d3 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -1001,6 +1001,11 @@ class AppViewModel @Inject constructor(
isPaymentRequestIdentityActivating = true
val requestRefresh = try {
+ if (to.bitkit.BuildConfig.DEBUG) {
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-contact-sync")
+ if (gate.exists()) Logger.info("QA1686 contact synchronization held before activation", context = TAG)
+ kotlinx.coroutines.withTimeout(90_000) { while (gate.exists()) kotlinx.coroutines.delay(100) }
+ }
paykitPaymentRequestRepo.activate(state.publicKey)
if (!isStillCurrent()) return
if (state.areAllowancesEnabled && areAllowancesEnabled()) {
diff --git a/app/src/debug/java/to/bitkit/dev/Qa1686.kt b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
new file mode 100644
index 000000000..a01e9cc12
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,417 @@
+package to.bitkit.dev // test hook
+
+import android.content.Context // test hook
+import android.content.ClipData // test hook
+import android.content.ClipboardManager // test hook
+import android.os.Bundle // test hook
+import dagger.hilt.EntryPoint // test hook
+import dagger.hilt.InstallIn // test hook
+import dagger.hilt.android.EntryPointAccessors // test hook
+import dagger.hilt.components.SingletonComponent // test hook
+import kotlinx.coroutines.Dispatchers // test hook
+import kotlinx.coroutines.flow.first // test hook
+import kotlinx.coroutines.withContext // test hook
+import to.bitkit.data.CacheStore // test hook
+import to.bitkit.data.backup.VssBackupClient // test hook
+import to.bitkit.data.keychain.Keychain // test hook
+import to.bitkit.models.BackupCategory // test hook
+import to.bitkit.repositories.BackupRepo // test hook
+import to.bitkit.repositories.PubkyRepo // test hook
+import to.bitkit.repositories.WalletRepo // test hook
+import to.bitkit.repositories.LightningRepo // test hook
+import to.bitkit.services.PubkyService // test hook
+import to.bitkit.di.json // test hook
+import to.bitkit.models.WalletBackupV1 // test hook
+import to.bitkit.utils.Logger // test hook
+import java.io.File // test hook
+import kotlinx.coroutines.*
+import to.bitkit.models.*
+import to.bitkit.repositories.*
+import to.bitkit.services.*
+import kotlin.time.Clock
+
+object Qa1686 { // test hook
+ private val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + Dispatchers.IO)
+ @EntryPoint @InstallIn(SingletonComponent::class) // test hook
+ interface Dependencies { // test hook
+ fun sdk(): to.bitkit.services.PaykitSdkService
+ fun allowanceExecutor(): to.bitkit.repositories.PaykitAllowanceExecutor
+ fun allowances(): to.bitkit.repositories.PaykitAllowanceRepo
+ fun allowanceStore(): to.bitkit.repositories.PaykitAllowanceStore
+ fun keychain(): Keychain // test hook
+ fun backupRepo(): BackupRepo // test hook
+ fun pubkyRepo(): PubkyRepo // test hook
+ fun pubkyService(): PubkyService // test hook
+ fun vss(): VssBackupClient // test hook
+ fun cache(): CacheStore // test hook
+ fun wallet(): WalletRepo // test hook
+ fun accounts(): to.bitkit.data.WatchOnlyAccountStore
+ fun settings(): to.bitkit.data.SettingsStore
+ fun lightning(): LightningRepo // test hook
+ } // test hook
+ suspend fun run(context: Context, method: String, arg: String?): Bundle { // test hook
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java) // test hook
+ fun file(name: String) = File(context.filesDir, "qa1686-$name")
+ val result = kotlin.runCatching { // test hook
+ when (method) { // test hook
+ "qa1686RestorePause" -> "persistedPause=${d.allowanceStore().isPausedAfterRestore()}; visiblePause=${d.allowances().automaticPaymentPause.value}"
+ "qa1686ResumeAutomatic" -> { d.allowances().resumeAutomaticPayments().getOrThrow(); "resumed automatic payments; pause=${d.allowanceStore().isPausedAfterRestore()}" }
+ "qa1686StageProfile" -> {
+ d.settings().setPubkyProfileSetupPending(arg == "pending")
+ val field = d.pubkyRepo().javaClass.getDeclaredField("_profile").apply { isAccessible = true }
+ @Suppress("UNCHECKED_CAST")
+ (field.get(d.pubkyRepo()) as kotlinx.coroutines.flow.MutableStateFlow<PubkyProfile?>).value = null
+ file("sign-in").writeText(requireNotNull(d.pubkyRepo().currentSignIn()).generation.toString())
+ "cleared local profile; pending=${d.settings().isPubkyProfileSetupPending.first()}"
+ }
+ "qa1686RetryProfile" -> {
+ val started = kotlin.time.TimeSource.Monotonic.markNow()
+ repeat(3) { d.pubkyRepo().restoreSessionIfNeeded() }
+ "three reconnect calls returned in ${started.elapsedNow()}; current=${d.pubkyRepo().currentSignIn()?.generation.toString() == file("sign-in").readText()}"
+ }
+ "qa1686CompleteSetup" -> {
+ d.settings().setPubkyProfileSetupPending(false)
+ "cleared local setup pending flag"
+ }
+ "qa1686RestoreSaved" -> { d.pubkyRepo().initialize().getOrThrow(); "restored saved identity" }
+ "qa1686RejectedBackup" -> {
+ d.sdk().processPendingPrivateMessages()
+ val field = d.sdk().javaClass.getDeclaredField("cachedBackupState").apply { isAccessible = true }
+ val beforeCache = field.get(d.sdk())
+ check(beforeCache != null)
+ val before = d.sdk().backupStateVersion.value
+ var refused = false
+ try {
+ d.sdk().acceptAllowance(requireNotNull(d.pubkyRepo().publicKey.value).removePrefix("pubky"), "qa-no-native-call", 1uL) {
+ throw PaykitAllowanceError.Unavailable
+ }
+ } catch (error: PaykitAllowanceError.Unavailable) { refused = true }
+ check(refused)
+ check(d.sdk().backupStateVersion.value == before)
+ check(field.get(d.sdk()) === beforeCache)
+ "real SDK service refused admission; version unchanged=true; cached snapshot same=true"
+ }
+ "qa1686OpenChannel" -> {
+ d.lightning().sync().getOrThrow()
+ val peer = org.lightningdevkit.ldknode.PeerDetails(
+ nodeId = "028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc",
+ address = "34.65.86.104:9400", isConnected = false, isPersisted = false,
+ )
+ d.lightning().connectPeer(peer).getOrThrow()
+ val result = d.lightning().openChannel(peer, 200_000uL, 50_000uL).getOrThrow()
+ "opened real disposable regtest channel id=${result.userChannelId}; local funding=200000 sats; push=50000 sats"
+ }
+ "qa1686ChannelState" -> {
+ d.lightning().sync().getOrThrow()
+ "channels=${d.lightning().getChannels()?.map { "ready=${it.isChannelReady} usable=${it.isUsable} outMsat=${it.outboundCapacityMsat} inMsat=${it.inboundCapacityMsat}" }}"
+ }
+ "qa1686AllowanceJournal" -> {
+ val identity = requireNotNull(d.pubkyRepo().publicKey.value).removePrefix("pubky")
+ val state = d.allowanceExecutor().localState(identity)
+ "journal=${state.journal.size}; automatic=${state.journal.count { it.isAutomatic }}"
+ }
+ "qa1686Identity" -> requireNotNull(d.pubkyRepo().publicKey.value)
+ "qa1686AddPeer" -> {
+ val peer = requireNotNull(arg)
+ file("peer").writeText(peer)
+ d.pubkyRepo().addContact(peer, PubkyProfile.placeholder(peer).copy(name = "QA peer")).getOrThrow()
+ "saved real peer contact"
+ }
+ "qa1686Link" -> {
+ val peer = file("peer").readText().removePrefix("pubky")
+ d.sdk().syncPaykitApp(true)
+ val link = d.sdk().ensureLinkWithPeer(peer, maxAdvanceSteps = 4u)
+ d.sdk().processOutboundPrivateMessages(peer)
+ "link=$link"
+ }
+ "qa1686Propose" -> {
+ val peer = file("peer").readText().removePrefix("pubky")
+ val role = if (arg == "ask") com.synonym.paykit.AllowanceLocalRole.ALLOWEE else com.synonym.paykit.AllowanceLocalRole.ALLOWER
+ val terms = PaykitAllowanceLimits(5, 50, 10_000uL, 100_000uL).terms(
+ PaykitAllowanceTime.monthStart(Clock.System.now()), PaykitAllowanceRepo.allowedPaymentEndpointIdentifiers())
+ val record = d.sdk().proposeAllowance(peer, role, terms)
+ d.sdk().processOutboundPrivateMessages(peer)
+ "proposed real allowance id=${record.allowanceId}; role=$role"
+ }
+ "qa1686Intake" -> {
+ val peer = file("peer").readText().removePrefix("pubky")
+ d.sdk().receivePrivateMessages(peer)
+ d.allowances().refresh().getOrThrow()
+ "received real messages; entries=${d.allowances().entries.value.size}"
+ }
+ "qa1686Activate" -> {
+ scope.launch {
+ try { d.allowances().activate(requireNotNull(d.pubkyRepo().publicKey.value)) }
+ catch (e: Exception) { Logger.info("QA1686 activate failed=${e.javaClass.simpleName}", context = "TestHook") }
+ }
+ "scheduled same-identity activation"
+ }
+ "qa1686Enable" -> { d.settings().setAreAllowancesEnabled(true); "enabled allowances" }
+ "qa1686Disable" -> { d.settings().setAreAllowancesEnabled(false); "disabled allowances" }
+ "qa1686AllowanceQuick" -> {
+ val f = d.allowances().javaClass.getDeclaredField("activation").apply { isAccessible = true }
+ val activation = f.get(d.allowances())
+ "activation=${activation?.let { System.identityHashCode(it) }}; enabled=${d.settings().areAllowancesEnabled.first()}; entries=${d.allowances().entries.value.size}; acceptance=${file("accept-result").takeIf { it.exists() }?.readText()}"
+ }
+ "qa1686Pause" -> {
+ d.allowances().pause()
+ "paused allowance repository"
+ }
+ "qa1686HoldLock" -> {
+ file("lock").writeText("hold")
+ scope.launch {
+ val f = d.sdk().javaClass.getDeclaredField("operationLock").apply { isAccessible = true }
+ (f.get(d.sdk()) as PaykitSdkOperationLock).withLock {
+ Logger.info("QA1686 SDK operation lock held", context = "TestHook")
+ withTimeout(90_000) { while (file("lock").exists()) delay(100) }
+ Logger.info("QA1686 SDK operation lock released", context = "TestHook")
+ }
+ }
+ "scheduled SDK lock hold (90 second bound)"
+ }
+ "qa1686ReleaseLock" -> { file("lock").delete(); "released lock gate" }
+ "qa1686Accept" -> {
+ val entry = requireNotNull(d.allowances().entries.value.firstOrNull { it.allowances.any { a -> a.isAnswerable } })
+ file("accept-result").delete()
+ scope.launch {
+ val result = if (arg == "auto") {
+ runCatching { check(d.allowances().acceptOffersFromAllowers().isNotEmpty()) }
+ } else d.allowances().accept(entry.id)
+ val value = "success=${result.isSuccess}; error=${result.exceptionOrNull()?.javaClass?.simpleName}"
+ file("accept-result").writeText(value)
+ Logger.info("QA1686 queued accept $value", context = "TestHook")
+ }
+ "queued real allowance acceptance"
+ }
+ "qa1686AllowanceState" -> {
+ val records = d.sdk().listAllowances(com.synonym.paykit.AllowanceFilter(null, null, emptyList()))
+ "native=${records.map { it.allowanceId + ":" + it.state }}; acceptance=${file("accept-result").takeIf { it.exists() }?.readText()}"
+ }
+
+ "qa1686Schedule" -> {
+ d.settings().update { it.copy(selectedCurrency = if (it.selectedCurrency == "EUR") "GBP" else "EUR") }
+ d.backupRepo().scheduleFullBackup()
+ "scheduled all categories"
+ }
+ "qa1686Stop" -> {
+ d.backupRepo().stopObservingBackups()
+ kotlinx.coroutines.delay(300)
+ val statuses = d.cache().backupStatuses.first()
+ "stopped; settings=${statuses[BackupCategory.SETTINGS]}"
+ }
+ "qa1686Start" -> {
+ d.backupRepo().startObservingBackups()
+ "started observers"
+ }
+ "qa1686Status" -> {
+ "settings=${d.cache().backupStatuses.first()[BackupCategory.SETTINGS]}; pending=${d.backupRepo().hasPendingWalletRestore()}; electrum=${d.settings().data.first().electrumServer}"
+ }
+ "qa1686ArmRestore" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value)
+ val old = d.cache().data.first().onchainAddress
+ File(context.filesDir, "qa1686-old-address").writeText(old)
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString())
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString())
+ "armed real required restore with original complete remote WALLET; saved old address=${old.isNotBlank()}"
+ }
+ "qa1686CheckRestore" -> {
+ check(!d.backupRepo().hasPendingWalletRestore())
+ val old = File(context.filesDir, "qa1686-old-address").readText()
+ val next = d.wallet().newAddress().getOrThrow()
+ check(old.isBlank() || next != old)
+ "required markers clear; new ordinary address succeeds; old metadata address excluded=${old.isNotBlank()}"
+ }
+ "qa1686PrepareProvider" -> {
+ File(context.filesDir, "qa1686-provider-secret").writeText(requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name)))
+ File(context.filesDir, "qa1686-provider-pubky").writeText(requireNotNull(d.pubkyRepo().publicKey.value).removePrefix("pubky"))
+ "staged disposable provider credential files"
+ }
+ "qa1686InitCore" -> { // test hook
+ to.bitkit.async.ServiceQueue.CORE.background { // test hook
+ com.synonym.bitkitcore.initDb(to.bitkit.env.Env.bitkitCoreStoragePath(0)) // test hook
+ com.synonym.bitkitcore.updateBlocktankUrl(to.bitkit.env.Env.blocktankApiUrl) // test hook
+ } // test hook
+ "initialized real native database after app storage path became available" // test hook
+ } // test hook
+ "qa1686RestageProfile" -> {
+ check(d.pubkyRepo().publicKey.value != null) // test hook
+ val setter = d.pubkyRepo().javaClass.getDeclaredMethod("setProfile", to.bitkit.models.PubkyProfile::class.java).apply { isAccessible = true } // test hook
+ setter.invoke(d.pubkyRepo(), null) // test hook
+ val store = d.pubkyRepo().javaClass.getDeclaredField("pubkyStore").apply { isAccessible = true }.get(d.pubkyRepo()) as to.bitkit.data.PubkyStore // test hook
+ store.reset() // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ "staged missing own profile and empty display cache for active saved identity; optional enrichment scheduled" // test hook
+ }
+ "qa1686Retry" -> {
+ val original = requireNotNull(d.pubkyRepo().currentSignIn())
+ val started = android.os.SystemClock.elapsedRealtime()
+ repeat(3) { check(!d.pubkyRepo().restoreSessionIfNeeded()) }
+ val elapsed = android.os.SystemClock.elapsedRealtime() - started
+ check(d.pubkyRepo().isCurrent(original))
+ "three retry calls returned in ${elapsed}ms; original sign-in retained=true; profile=${d.pubkyRepo().profile.value?.name}"
+ }
+ "qa1686ConnectionFault" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value)
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString())
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString())
+ d.settings().update { it.copy(electrumServer = "tcp://127.0.0.1:1") }
+ "set isolated unreachable Electrum and retained required WALLET/USDT payloads"
+ }
+ "qa1686AddContact" -> {
+ val secret = d.pubkyService().deriveSecretKey("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
+ val key = "pubky" + d.pubkyService().publicKeyFromSecret(secret).removePrefix("pubky")
+ d.pubkyRepo().addContact(key, to.bitkit.models.PubkyProfile.placeholder(key).copy(name = "QA contact")).getOrThrow()
+ "saved one disposable contact"
+ }
+ "qa1686QuickState" -> {
+ "source=${d.keychain().exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)}, savedSecret=${!d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()}, savedSession=${!d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrBlank()}, identity=${d.pubkyRepo().publicKey.value != null}, authenticated=${d.pubkyRepo().isAuthenticated.value}, profile=${d.pubkyRepo().profile.value?.name}, contacts=${d.pubkyRepo().contacts.value.size}, pendingRestore=${d.backupRepo().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}"
+ }
+ "qa1686SourceMarker" -> {
+ val key = requireNotNull(d.pubkyRepo().publicKey.value).removePrefix("pubky")
+ d.keychain().upsertString(Keychain.Key.SHARED_PUBKY_SOURCE.name, to.bitkit.data.sharedpubky.SharedPubkyContract.RING_SOURCE_PREFIX + key)
+ "staged adopted-source marker for the same active disposable identity"
+ }
+ "qa1686BackupPending" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET)
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true
+ check(failed.isFailure && before.value.contentEquals(after.value) && before.version == after.version && pending)
+ "failed=true; remote bytes/version unchanged=true; pending=true"
+ }
+ "qa1686CreateProfile" -> {
+ d.pubkyRepo().createIdentity("QA restore", "", emptyList(), emptyList(), null).getOrThrow()
+ "created disposable profile"
+ }
+ "qa1686Backup" -> {
+ for (c in listOf(BackupCategory.WALLET, BackupCategory.METADATA, BackupCategory.SETTINGS)) d.backupRepo().triggerBackup(c).getOrThrow()
+ val wallet = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val metadata = requireNotNull(d.vss().getObject("METADATA").getOrThrow())
+ val pending = d.cache().backupStatuses.first().filterKeys { it in listOf(BackupCategory.WALLET, BackupCategory.METADATA) }.any { it.value.isRequired }
+ check(!pending)
+ "backup synchronized; sdk=${json.decodeFromString<WalletBackupV1>(wallet.value.decodeToString()).paykitSdkBackupState != null}; metadataBytes=${metadata.value.size}"
+ }
+ "qa1686UsdtFault" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value)
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, "invalid-json")
+ "staged malformed retained USDT recovery payload"
+ }
+ "qa1686Terminal" -> {
+ d.backupRepo().stopObservingBackups()
+ val secret = requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name))
+ File(context.filesDir, "qa1686-original-secret").writeText(secret)
+ val session = requireNotNull(d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name))
+ File(context.filesDir, "qa1686-original-session").writeText(session)
+ d.pubkyService().forgetSessionAccess()
+ d.keychain().delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ d.keychain().upsertString(Keychain.Key.PAYKIT_SESSION.name, "qa1686-invalid-session")
+ d.pubkyRepo().initialize().getOrThrow()
+ check(d.pubkyRepo().publicKey.value == null)
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val reserved = d.accounts().reserveAccountIndex(0, "qa1686-terminal-ordinary")
+ check(d.accounts().backupSnapshot().allocationState.highestAccountIndexByWallet["0"] == reserved)
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET)
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val same = before.value.contentEquals(after.value) && before.version == after.version
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true
+ check(failed.isFailure && same && pending)
+ "terminal=true; ordinary watch-only index=$reserved reserved; failed=true; remote unchanged=true; pending=true; error=${failed.exceptionOrNull()?.javaClass?.simpleName}"
+ }
+ "qa1686RepairIdentity" -> {
+ d.keychain().upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, File(context.filesDir, "qa1686-original-secret").readText())
+ d.keychain().upsertString(Keychain.Key.PAYKIT_SESSION.name, File(context.filesDir, "qa1686-original-session").readText())
+ d.pubkyRepo().initialize().getOrThrow()
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow()
+ "recovered original identity and complete WALLET upload"
+ }
+ "qa1686Capture" -> { // test hook
+ File(context.filesDir, "qa1686-mnemonic").writeText(requireNotNull(d.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name))) // test hook
+ for (c in listOf(BackupCategory.WALLET, BackupCategory.METADATA, BackupCategory.SETTINGS)) d.backupRepo().triggerBackup(c).getOrThrow() // test hook
+ check(d.vss().getObject("WALLET").getOrThrow() != null) // test hook
+ "captured complete backup and test credentials" // test hook
+ } // test hook
+ "qa1686Clipboard" -> { // test hook
+ withContext(Dispatchers.Main) { // test hook
+ context.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("test seed", File(context.filesDir, "qa1686-mnemonic").readText())) // test hook
+ } // test hook
+ "copied test credentials" // test hook
+ } // test hook
+ "qa1686BackupFault" -> { // test hook
+ d.backupRepo().stopObservingBackups() // test hook
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val secret = requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) // test hook
+ d.pubkyService().forgetSessionAccess() // test hook
+ check(d.pubkyService().currentPublicKey() == null) // test hook
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET) // test hook
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true // test hook
+ val same = before.value.contentEquals(after.value) && before.version == after.version // test hook
+ Logger.info("QA1686 unavailable identity: failed=${failed.isFailure}, unchanged=$same, pending=$pending", context="TestHook") // test hook
+ check(failed.isFailure && same && pending) // test hook
+ d.pubkyService().signIn(secret) // test hook
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ val fresh = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val complete = json.decodeFromString<WalletBackupV1>(fresh.value.decodeToString()).paykitSdkBackupState != null // test hook
+ val synced = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == false // test hook
+ check(complete && synced) // test hook
+ d.backupRepo().startObservingBackups() // test hook
+ "backup protection and recovered export passed" // test hook
+ } // test hook
+ "qa1686Recover" -> { // test hook
+ val secret = d.pubkyService().deriveSecretKey(requireNotNull(d.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name))) // test hook
+ d.keychain().upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secret) // test hook
+ d.pubkyService().initialize() // test hook
+ d.pubkyRepo().restoreSessionBackupState(to.bitkit.models.PubkySessionBackupV1(kind = to.bitkit.models.PubkySessionBackupKind.LocalSeed)).getOrThrow() // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ d.backupRepo().startObservingBackups() // test hook
+ "recovered identity and fresh complete backup" // test hook
+ } // test hook
+ "qa1686Refresh" -> { // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ "scheduled optional enrichment" // test hook
+ } // test hook
+ "qa1686RequiredFault" -> { // test hook
+ d.backupRepo().stopObservingBackups() // test hook
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value) // test hook
+ File(context.filesDir, "qa1686-corrupt-wallet").writeText("hold")
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString()) // test hook
+ "staged malformed required WALLET decoder input; retained original backup" // test hook
+ } // test hook
+ "qa1686RepairRequired" -> { // test hook
+ File(context.filesDir, "qa1686-corrupt-wallet").delete()
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ if (d.keychain().exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)) { // test hook
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ } // test hook
+ "repaired original required backup" // test hook
+ } // test hook
+ "qa1686SpendingGate" -> { // test hook
+ check(d.backupRepo().hasPendingWalletRestore()) // test hook
+ val result = d.lightning().payInvoice("qa1686-invalid-invoice") // test hook
+ check(result.exceptionOrNull()?.javaClass?.simpleName == "WalletBackupRestorePendingError") // test hook
+ "payment admission blocked by required restore before invoice parsing" // test hook
+ } // test hook
+ "qa1686AddressGate" -> { // test hook
+ val result = d.wallet().newAddress() // test hook
+ "new address success=${result.isSuccess}; error=${result.exceptionOrNull()?.javaClass?.simpleName}" // test hook
+ } // test hook
+ "qa1686SignOut" -> { // test hook
+ d.pubkyRepo().signOut().getOrThrow() // test hook
+ "identity removed" // test hook
+ } // test hook
+ else -> "source=${d.keychain().exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)}, savedSecret=${!d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()}, savedSession=${!d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrBlank()}, sdk=${d.pubkyService().currentPublicKey() != null}, identity=${d.pubkyRepo().publicKey.value != null}, profile=${d.pubkyRepo().profile.value?.name}, contacts=${d.pubkyRepo().contacts.value.size}, pendingRestore=${d.backupRepo().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}" // test hook
+ } // test hook
+ }.fold({ it }, { "ERROR: ${it.javaClass.simpleName}: ${it.message}" }) // test hook
+ Logger.info("QA1686 $method: $result", context="TestHook") // test hook
+ return Bundle().apply { putString("result", result) } // test hook
+ } // test hook
+} // test hookTerminal: Result: Bundle[{result=terminal=true; ordinary watch-only index=1 reserved; failed=true; remote unchanged=true; pending=true; error=AppError}]
RepairIdentity: Result: Bundle[{result=recovered original identity and complete WALLET upload}]
AddressGate: Result: Bundle[{result=new address success=false; error=NodeNotRunningError}]
RestorePause: Result: Bundle[{result=persistedPause=true; visiblePause=null}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=true, authenticated=true, profile=QA restore, contacts=1, pendingRestore=false, node=Running}]
RestorePause: Result: Bundle[{result=persistedPause=true; visiblePause=null}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=false, authenticated=false, profile=null, contacts=0, pendingRestore=true, node=Running}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=true, authenticated=true, profile=QA restore, contacts=1, pendingRestore=false, node=Running}]
Backup: Result: Bundle[{result=backup synchronized; sdk=true; metadataBytes=20220}]
RestorePause: Result: Bundle[{result=persistedPause=true; visiblePause=null}]
AddressGate: Result: Bundle[{result=new address success=true; error=null}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=true, authenticated=true, profile=QA restore, contacts=1, pendingRestore=false, node=Running}]
Backup: Result: Bundle[{result=backup synchronized; sdk=true; metadataBytes=21148}]
Backup: Result: Bundle[{result=backup synchronized; sdk=true; metadataBytes=20727}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=true, authenticated=true, profile=QA restore, contacts=1, pendingRestore=false, node=Running}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=false, authenticated=false, profile=null, contacts=0, pendingRestore=true, node=Running}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=false, authenticated=false, profile=null, contacts=0, pendingRestore=false, node=Running}]
RestorePause: Result: Bundle[{result=persistedPause=true; visiblePause=null}]
CheckRestore: Result: Bundle[{result=required markers clear; new ordinary address succeeds; old metadata address excluded=true}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=false, authenticated=false, profile=null, contacts=0, pendingRestore=false, node=Running}]
BackupPending: Result: Bundle[{result=failed=true; remote bytes/version unchanged=true; pending=true}]
Backup: Result: Bundle[{result=backup synchronized; sdk=true; metadataBytes=20720}]
RestorePause: Result: Bundle[{result=persistedPause=true; visiblePause=RESTORED}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=true, authenticated=true, profile=QA restore, contacts=1, pendingRestore=false, node=Running}]
RestorePause: Result: Bundle[{result=persistedPause=true; visiblePause=RESTORED}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=false, authenticated=false, profile=null, contacts=0, pendingRestore=true, node=Stopped}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=true, authenticated=true, profile=QA restore, contacts=1, pendingRestore=true, node=Running}]
SpendingGate: Result: Bundle[{result=payment admission blocked by required restore before invoice parsing}]
AddressGate: Result: Bundle[{result=new address success=false; error=WalletBackupRestorePendingError}]
RestorePause: Result: Bundle[{result=persistedPause=true; visiblePause=RESTORED}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=false, authenticated=false, profile=null, contacts=0, pendingRestore=true, node=Stopped}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=false, authenticated=false, profile=null, contacts=0, pendingRestore=true, node=Stopped}]
Reviewed by gpt-6.1-sol-high
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: full reassessment of the complete PR at b5d5858 against 7f49f216, after the comparison base changed; no coverage inherited. Shared restore/profile/backup contracts were compared with iOS 0ad55417, without a full iOS review.
1 actionable finding — resolve or provide an evidence-backed rebuttal.
The existing terminal-identity WALLET backup concern remains unresolved: no exportable identity state prevents complete uploads of later ordinary wallet changes. Source was rechecked; the earlier exact-head runtime report remains attributed evidence. The resolved profile, allowance and scheduled-status fixes remain present.
Validation: eight pinned coordinator tests plus one stalled-poll cancellation probe passed in an isolated Kotlin/JVM harness with native/app stubs. The app unit-test task could not execute because native Maven dependencies returned HTTP 401. CI reports success for this head; it is separate hosted evidence.
Suggested additional test cases
- Android: on a disposable wallet, hold the real standalone LDK LNURL-auth response during a VSS Debug read, leave Debug, then confirm Reset and Restore. Expect bounded completion or a retryable failure, preserved credentials on failure, and no late operation crossing wallet ownership.
Device testing: not performed in this review.
Findings
- [MEDIUM] Bound native authentication before draining VSS during reset — inline at
app/src/main/java/to/bitkit/data/backup/VssClientCoordinator.kt:138.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
| try { | ||
| withContext(NonCancellable) { | ||
| pending.forEach { it.cancel(CancellationException("VSS wallet retired")) } | ||
| pending.joinAll() |
There was a problem hiding this comment.
[MEDIUM] Bound native authentication before draining VSS during reset
When a standalone VSS request needs a fresh JWT and the LNURL-auth server holds its response, starting Reset and Restore would wait indefinitely here. The pinned VSS execute_async! bridge runs Runtime::block_on inside the synchronous UniFFI poll. Its vss-client-ng 0.4.0 authentication client has no timeout; the VSS request deadline starts only afterward. Cancelling the Kotlin job cannot finish that blocked poll, so this NonCancellable join never reaches shutdown or credential deletion. ResetAndRestoreScreen keeps Reset loading and disables Back while isWiping remains true; admission also stays closed. Bound native authentication while retaining drain-before-retirement, for example by adopting a release containing VSS #36, and add a stalled-auth reset regression. Evidence: pinned source analysis, checksum-verified dependency sources, and a focused Kotlin test using the real coordinator with a held synchronous-poll surrogate. No actual-native or device reproduction was performed. This adds Android-specific cancellation evidence to the shared iOS concern.
There was a problem hiding this comment.
Confirmed at b5d5858: the app still pins VSS 0.5.26-rc1, and the non-cancellable drain must wait for the admitted native poll before retiring either slot. I’m keeping that ownership protection.
The native authentication deadline fix is already prepared in VSS #36 at 1a5b788c. Its dedicated host-FFI lookup returned AuthError after ten held attempts in 110.199 seconds, and a fresh explicit read then succeeded with the same client. That is bounded native lookup evidence; it does not establish Android cancellation or Reset and Restore behavior.
The separate decision to merge/release #36 and adopt it in the apps is still pending. This thread remains open for that integration and the real native stalled-auth reset check. The Kotlin surrogate result stays distinct from an installed-app pass.
There was a problem hiding this comment.
I repeated the requested installed-app check at 3e1fdb7 with the real standalone LDK client, using a response hold on its LNURL-auth endpoint. The response stayed held for 193.8 seconds. After UI Reset closed admission, it remained wiping with credentials present, Back disabled and no retryable error for at least 162.6 seconds. Releasing the response let the old lookup end with CancellationException; reset then reached initial Terms, removed credentials and reopened admission. The native libraries are byte-identical to the pristine head build.
This reproduces the existing finding on VSS 0.5.26-rc1. synonymdev/vss-rust-client-ffi#36 fix: bound LNURL authentication before VSS requests is still open and not adopted here. Keeping this thread open for integration. The review body includes the recordings, unpublished control patch and deciding output.
There was a problem hiding this comment.
The installed-app result confirms this finding on the current 3e1fdb7d head. Your trace now covers the real LDK authentication response and Reset UI, with admission closed and credentials retained for at least 162.6 seconds. I’m keeping this thread open. Current reproduction
The private Android integration used app b5d5858d with VSS #36 at 1a5b788c. Its installed coordinator test drained the old native read in 110.336 seconds across ten held challenges, sent no original callback or storage POST, and then completed fresh authenticated reads through both client domains. That test covered coordinator retirement and reopening. Full Settings reset and running-LDK quiescence remain unverified with the fix. VSS #36
Public #36 release and app adoption still need the separate pending authorization. Once authorized, the next acceptance check is the installed Settings reset on the adopted SDK with the real auth response held throughout. It must finish or expose a retryable failure while preserving closed admission and drain-before-retirement. The terminal-identity case also remains open, including the newly reproduced stale ordinary WALLET state while credentials cannot recover. Terminal-identity disposition
There was a problem hiding this comment.
I repeated the installed-app boundary at 710292a with VSS 0.5.26-rc2 and the real standalone LDK client. Its genuine LNURL-auth response stayed held for 263.2 seconds. After UI Reset closed admission, it remained wiping with credentials present, Back disabled and no retryable error for at least 198.4 seconds. Releasing the response let the old lookup end with CancellationException; Reset reached Terms, removed credentials and reopened admission 40.7 seconds later. All eleven native libraries matched the pristine head APK.
This still reproduces the existing finding on the current native pin. The native deadline change in vss-rust-client-ffi#36 fix: bound LNURL authentication before VSS requests remains open. I am keeping this thread open; the review includes the recordings and deciding state/timing output. The following actual recovery-phrase restore completed and retained the automatic-payment journal and persistent restore pause.
There was a problem hiding this comment.
The 710292aa result confirms the existing native-drain finding on VSS 0.5.26-rc2: your controlled run held the native auth response for 263.2 seconds, observed Reset waiting for 198.4 seconds with admission closed and credentials retained, then finished 40.7 seconds after releasing the response. I am keeping this thread open and carrying that installed-app evidence forward with its stated native-library provenance.
The private #36 coordinator control remains a separate result on its recorded inputs: its approximately 110.336-second deadline/drain check did not establish a full installed Settings reset. The public #36 release/adoption decision and full reset acceptance remain pending, as recorded in the earlier disposition. The Spark restore scheduling repair keeps the existing VSS dependency and leaves this finding open.
There was a problem hiding this comment.
At acdd99c I checked that the coordinator still drains pending jobs inside NonCancellable and VSS remains 0.5.26-rc2. This Spark restore-wakeup delta does not include the native auth deadline fix. The 710292aa installed-app failure therefore remains applicable. I did not repeat held-auth Reset in this run before cd0befc superseded the pin; this thread remains open.
There was a problem hiding this comment.
Repeated at cd0befc with the real standalone LDK VSS client on 0.5.26-rc2. The real native auth response stayed held for about 230 seconds. After UI Reset closed admission, it remained wiping with credentials present and no retryable error for at least 204.5 seconds. After response release, the old lookup cancelled and Reset removed credentials and reopened admission 7.2 seconds later. Timing/state checked in logs/readback; consecutive recordings joined in order. Native libraries match the pristine head APK. This thread remains open.
|
@ben-kaufman this conflicts with |
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — I reproduced both existing findings at 3e1fdb7. I found no new defect in the delta.
Findings:
- Existing HIGH — Terminal identity failure blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:948. With an invalid retained session and no recovery secret, the real Paykit SDK refused capture before the complete-WALLET guard. A new ordinary watch-only reservation remained pending and remote WALLET bytes/version stayed unchanged. Ordinary address creation worked; restoring the original credentials allowed complete upload. Later ordinary wallet state therefore remains unavailable to remote recovery until identity recovers. - Existing MEDIUM — Native authentication can prevent reset from finishing.
app/src/main/java/to/bitkit/data/backup/VssClientCoordinator.kt:138. A real standalone LDK LNURL-auth response held Reset in its non-cancellable drain: admission stayed closed, credentials remained present, Back was disabled and no retryable error appeared for at least 162.6 seconds after the first observed admission closure. Releasing the response let the old lookup cancel and reset finish. The app still uses VSS0.5.26-rc1; the prepared native deadline change is not adopted.
QA for 3e1fdb7: Android 16 / API 36, two disposable staging/regtest wallets with real Pubky, Paykit, VSS and Lightning channels. I built the pristine head and ran focused regressions, then used unpublished Debug controls for faults, timing holds and readback. All ten native libraries matched the pristine APK; installed primary APK bytes matched the final controlled build. Videos play at 2×. Credential, journal, remote-backup and timing assertions below were checked in Debug output and app/proxy logs; the videos show the corresponding UI.
Back up ordinary changes after terminal identity failure ❌ A new ordinary reservation stayed pending; real remote bytes/version did not advance.
The invalid-session/no-secret case reached the actual Paykit SDK. Watch-only index 1 was reserved locally, capture failed and the previous complete remote WALLET remained unchanged. Ordinary receiving still worked. Original-credential recovery permitted complete upload. Debug readback and logs establish the reservation, refusal and remote comparison.
terminal-wallet-2x.2x.mp4
Ordinary receiving during the identity failure and subsequent recovery.
Skip unchanged observed activity and honor a full backup ✅ Actual VSS readback confirmed skipping, a full-request upload and cache invalidation after reset.
I stopped LDK to keep the real native activity rows stable. After an acknowledged upload, an identical observed payload made no PUT and retained identical remote bytes. A full request overlapping a held capture still uploaded. Reset detached the digest; the next unchanged observation uploaded again. Required/Running flags cleared after acknowledgement. These assertions use Debug readback and actual VSS logs. Running-node diagnostics changed native updatedAt on sync, so those diagnostics are not unchanged-payload passes.
activity-stable-2x.2x.mp4
Transaction Log status while the held capture and full request execute.
Disable allowances and restore the active row after enabling ✅ A covered request stayed manual while off; enabling restored Active and automatic payments.
With allowances off, the tab disappeared and a 2500-sat peer request opened with Swipe To Pay; the automatic journal stayed empty. Enabling restored the Active grant. A fresh covered request paid once, confirmed by a matching native payment event and succeeded journal entry. The earlier request had only been closed, so it also later paid once after enabling: retained WALLET readback showed two distinct succeeded request IDs before any restore. USD requests remain manual by design; the covered checks used BTC amounts.
| Allowances off | Allowances enabled |
|---|---|
allowance-btc-off-2x.2x.mp4 |
allowance-btc-on-2x.2x.mp4 |
Payer left, requester right; recordings aligned by their start timestamps. Journal and native payment assertions were checked separately.
Retain recovery gates across restart and complete Retry ✅ A real WALLET decoder failure retained payment/address gates until repair and Try Again.
Malformed WALLET decoder input retained the original valid envelope. With the node running, payment and address calls returned WalletBackupRestorePendingError, including after process restart. Repairing the original retained input and tapping Try Again completed recovery with no failed optional categories, cleared required markers and generated a fresh address different from the restored public address. Debug readback and app logs establish the gates and address comparison.
| Decoder failure and restart | Repaired input and Try Again |
|---|---|
required-wallet-decode-2x.2x.mp4 |
required-wallet-repair-2x.2x.mp4 |
Required recovery error, retained gates and successful retry.
Keep required USDT recovery gated through repair ✅ The USDT-only retained marker kept spending/address gates across restart; repairing it completed recovery.
The actual USDT envelope decoder rejected malformed retained input. With a running node, payments and address creation remained gated across restart. Repair changed only USDT_PENDING_BACKUP_RESTORE, introducing no WALLET marker. Try Again completed recovery, cleared the required gate and allowed address creation. Logs and Debug output establish these assertions. This is a decoder/gate check; funded nonempty USDT recovery uses the source-matched reports below.
required-usdt-2x.2x.mp4
USDT-only required recovery failure, restart and successful repair.
Keep restored allowances paused until Resume ✅ Restore pause survived restart; UI Resume enabled one fresh covered payment.
The same two pre-restore succeeded entries remained after restore and relaunch. The RESTORED banner persisted; a new covered 2500-sat request stayed manual without adding a journal entry. UI Resume cleared the persistent pause. After dismissing that pending request, a fresh covered request produced one native Lightning payment and changed the journal from two to three succeeded entries. State readback and native logs establish pause persistence and payment uniqueness.
| Restart with restore pause | Covered request and Resume | Fresh request after Resume |
|---|---|---|
restored-allowance-restart-2x.2x.mp4 |
restored-paused-2x.2x.mp4 |
resumed-fresh-2x.2x.mp4 |
The latter two videos align payer left and requester right. No duplicate replay was inferred from the two distinct payments already present before restore.
Restore wallet access before optional identity activation ✅ Required imports and receiving completed while identity activation waited, including after restart.
After required imports, a Debug gate removed in-memory authentication and held optional final activation while retaining credentials. Required markers cleared, the node ran and an ordinary address succeeded. A complete WALLET backup refused the unavailable identity export, preserving remote bytes and pending status. Recovery could be exited; a restart with initial activation held retained wallet access and credentials. Release recovered the original profile, two contacts, complete SDK-bearing WALLET upload and the same three succeeded allowance entries. Logs/readback establish these assertions. Optional METADATA reported incomplete during the injected outage; required recovery completed.
| Required recovery with final activation held | Restart, release and identity recovery |
|---|---|
final-activation-2x.2x.mp4 |
activation-restart-2x.2x.mp4 |
Optional identity activation does not keep ordinary receiving behind the required-restore gate.
Recover a missing profile and reject stale enrichment ✅ Reconnect retained the sign-in; a held response could not republish identity after sign-out.
A real offline profile fetch failed with transport_error. Three reconnect calls returned immediately with the same sign-in. After reconnect, a held successful response left identity active and profile absent while ordinary address creation completed. Release recovered the profile without opening Profile or reimporting identity. A second held response released after normal sign-out logged current=false and Skipped stale profile load; identity/profile stayed absent. The original disposable identity was recovered afterward. App logs and Debug readback establish the identity, maintenance and stale-response assertions.
| Offline fetch and reconnect | Held response, sign-out and stale rejection |
|---|---|
profile-reconnect-2x.2x.mp4 |
profile-stale-2x.2x.mp4 |
The first clip includes pending manual-request sheets; the continuation shows Home recovering and the stale response being rejected.
Finish reset while native backup authentication is stalled ❌ Reset stayed blocked until the real native auth response was released.
The proxy received a genuine standalone LDK LNURL-auth request and withheld its response for 193.8 seconds. Actual UI Reset stayed wiping without Retry, with Back disabled, credentials retained and admission closed for at least 162.6 seconds after first observed closure. Release delivered the response; the old lookup ended with CancellationException. Reset then reached initial Terms with credentials absent, wiping false and admission reopened. Proxy events and app readback/logs establish timing and state. Cleanup retained a private Paykit endpoint-removal warning; local reset completed.
| Held native authentication | Response release | Completed local reset |
|---|---|---|
native-auth-reset-2x.2x.mp4 |
native-auth-release-2x.2x.mp4 |
native-auth-cleanup-2x.2x.mp4 |
Actual Reset UI during the native hold, release and cleanup; no Kotlin held-poll substitute.
Check: earlier reports with matching source — retained only for the behavior those reports drove.
- USDT restore review at 5511ebb: real funded legacy-snapshot and seed-only recovery, each with a 100-second hold, announced only the new receipt and did not repeat it. The relevant USDT files and required-recovery region match this head.
- USDT receipt review at 629ce86: first-sync cold launch, receipt on Home/Receive Dollars, and relaunch suppression. The final upstream review retains these unaffected passes.
- Lightning receive-to-send review at b5ff811: the complete journey passed with real regtest liquidity, the single-digit exceeded toast and valid confirmation. The capacity helper and reached UI/service code match; this does not certify every restore/startup path.
- Dedicated Pubky queue review at 2fd4796: retained controlled cold-launch evidence and cancellation checks. The Pubky service/dispatcher/SDK construction code matches. Restore and held-profile composition were driven above.
- Earlier allowance admission review at b6751e2: same-identity acceptance, delayed activation and revocation of old queued offers. The admission/token mechanisms remain unchanged; ordinary payment and restore pause were driven above.
- Earlier status and recovery review at a2c58f2: retained only unchanged queued Settings, brief-background and observer stop/restart behavior. Changed activity and Pubky recovery paths were driven above.
These are attributed earlier executions, separate from the nine cases in this run.
Coverage: contiguous delta b5d5858..3e1fdb7 plus both unresolved findings. Fixed-amount hardware-selection routing was source-reviewed only. No new funded payment replay after process death, native shutdown-failure injection or Ring-app unlink was driven.
Twin: synonymdev/bitkit-ios#1121 fix: decouple wallet restore from profile refresh at 0ad55417 matches the deferred identity/profile intent and retains both existing limitations; I compared the relevant source without a new iOS device run.
Unpublished Debug controls and deciding output
Not pushed. These controls stage disposable state, hold real work, and read back results. They do not replace native return values. The complete app patch used for the controlled build follows; some provisioning helpers are unused by the reported cases. The primary installed controlled APK SHA256 is d956949a13ccb35869f630c547cc2cbf223db0ea25ae6da045c038156fe4d794.
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 6532aa144..4a1853cb0 100644
--- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
+++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
@@ -41,6 +41,9 @@ class DevToolsProvider : ContentProvider() {
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
check(Binder.getCallingUid() == SHELL_UID) { "Only ADB shell callers are allowed" }
+ if (method.startsWith("qa1686")) {
+ return kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) { Qa1686.run(requireNotNull(context), method, arg) }
+ }
return runCatching {
val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
ServiceQueue.LDK.blocking { command.execute(deps) }
diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt
index bd5fe8766..c931f4e5f 100644
--- a/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt
+++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt
@@ -57,7 +57,7 @@ class VssBackupClientLdk @Inject constructor(
storeId = vssDeriveStoreId(Env.vssStoreIdPrefix, mnemonic, passphrase),
mnemonic = mnemonic,
passphrase = passphrase,
- lnurlAuthServerUrl = Env.lnurlAuthServerUrl,
+ lnurlAuthServerUrl = if (to.bitkit.BuildConfig.DEBUG && java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-auth-override").exists()) "http://127.0.0.1:23469/lnurl_auth/auth" else Env.lnurlAuthServerUrl,
)
Logger.info("VSS LDK client setup for wallet[$walletIndex]", context = TAG)
}
diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
index 02c8b793d..705c59065 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -863,6 +863,11 @@ class BackupRepo @Inject constructor(
private suspend fun uploadActivityBackupLocked(skipIfUnchanged: Boolean): Boolean {
val cache = activityUploadCache
val ownerGeneration = vssClients.captureOwner().generation
+ if (to.bitkit.BuildConfig.DEBUG) {
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-activity")
+ if (gate.exists()) Logger.info("QA1686 activity capture held", context = TAG)
+ while (gate.exists()) delay(100)
+ }
val payload = getActivityBackupPayload()
val digest = MessageDigest.getInstance("SHA-256")
.digest(json.encodeToString(payload.copy(createdAt = 0)).toByteArray())
@@ -1255,7 +1260,11 @@ class BackupRepo @Inject constructor(
// Automatic allowance payments wait for the user to resume them before any restored state is written.
paykitAllowanceStore.pauseAfterRestore()
keychain.upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, String(dataBytes))
- val parsed = json.decodeFromString<WalletBackupV1>(String(dataBytes))
+ val walletInput = if (to.bitkit.BuildConfig.DEBUG && java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-corrupt-wallet").exists()) {
+ Logger.info("QA1686 staging malformed required WALLET input", context = TAG)
+ "invalid-json"
+ } else String(dataBytes)
+ val parsed = json.decodeFromString<WalletBackupV1>(walletInput)
var restoredAttempt: OnchainSendAttempt? = null
parsed.paykitPaymentState?.let {
// Validate every proof before writing a guard or replacing existing payment state.
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index 72303897d..d0728b1b5 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -380,6 +380,38 @@ class PubkyRepo @Inject constructor(
private suspend fun initializeForRevision(
expectedRevision: Long? = null,
): Result<Unit> = withContext(ioDispatcher) {
+ if (to.bitkit.BuildConfig.DEBUG) {
+ val root = java.io.File("/data/user/0/to.bitkit.dev/files")
+ val drop = java.io.File(root, "qa1686-drop-final-activation")
+ if (expectedRevision != null && drop.exists()) {
+ initializeMutex.withLock {
+ val savedSecret = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
+ val savedSession = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)
+ check(!savedSecret.isNullOrBlank() || !savedSession.isNullOrBlank())
+ pubkyService.forgetSessionAccess()
+ savedSecret?.let { keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, it) }
+ savedSession?.let { keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, it) }
+ clearAuthenticatedState(clearCachedProfile = false, preserveStoredIdentity = true)
+ drop.delete()
+ val sdkField = pubkyService.javaClass.getDeclaredField("paykitSdkService").apply { isAccessible = true }
+ val sdkService = sdkField.get(pubkyService) as to.bitkit.services.PaykitSdkService
+ val providerField = sdkService.javaClass.getDeclaredField("sessionProvider").apply { isAccessible = true }
+ val provider = providerField.get(sdkService) as to.bitkit.services.PaykitSdkSessionProvider
+ provider.suspendStoredSessionAccess()
+ Logger.info("QA1686 held final activation; required imports complete; saved credentials retained", context = TAG)
+ val held = java.io.File(root, "qa1686-hold-final")
+ try {
+ while (held.exists()) delay(200)
+ } finally {
+ provider.resumeStoredSessionAccess()
+ }
+ }
+ }
+ val gate = java.io.File(root, if (expectedRevision == null) "qa1686-hold-initial" else "qa1686-hold-final")
+ if (gate.exists()) Logger.info("QA1686 optional initialization held", context = TAG)
+ while (gate.exists()) delay(200)
+ Logger.info("QA1686 optional initialization released", context = TAG)
+ }
val (restored, revision) = initializeMutex.withLock {
if (expectedRevision != null && expectedRevision != storedIdentityRevision.get()) {
return@withContext Result.success(Unit)
@@ -490,6 +522,7 @@ class PubkyRepo @Inject constructor(
}
private suspend fun initializeSession(notifyFailure: Boolean = true): Boolean {
+ if (to.bitkit.BuildConfig.DEBUG) Logger.info("QA1686 session initialization entered", context = TAG) // test hook
_isRestoringSession.update { true }
try {
val savedSession = runSuspendCatching { keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) }
@@ -853,6 +886,7 @@ class PubkyRepo @Inject constructor(
return
}
val isCurrentLoad = { isCurrent(signIn) && profileWriteGeneration.get() == writeGeneration }
+ if (to.bitkit.BuildConfig.DEBUG) Logger.info("QA1686 own profile read started", context = TAG) // test hook
_isLoadingProfile.update { true }
try {
@@ -862,6 +896,13 @@ class PubkyRepo @Inject constructor(
?: throw AppError("Profile not found")
}
}.onSuccess { loadedProfile ->
+ if (to.bitkit.BuildConfig.DEBUG) {
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-profile")
+ if (gate.exists()) Logger.info("QA1686 profile response held", context = TAG)
+ while (gate.exists()) delay(200)
+ Logger.info("QA1686 profile released; current=${isCurrentLoad()}", context = TAG)
+ }
+
var isCurrent = false
_profile.update {
isCurrent = isCurrentLoad()
diff --git a/app/src/debug/java/to/bitkit/dev/Qa1686.kt b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
new file mode 100644
index 000000000..b40bb7211
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,470 @@
+package to.bitkit.dev // test hook
+
+import android.content.Context // test hook
+import android.content.ClipData // test hook
+import android.content.ClipboardManager // test hook
+import android.os.Bundle // test hook
+import dagger.hilt.EntryPoint // test hook
+import dagger.hilt.InstallIn // test hook
+import dagger.hilt.android.EntryPointAccessors // test hook
+import dagger.hilt.components.SingletonComponent // test hook
+import kotlinx.coroutines.Dispatchers // test hook
+import kotlinx.coroutines.flow.first // test hook
+import kotlinx.coroutines.withContext // test hook
+import to.bitkit.data.CacheStore // test hook
+import to.bitkit.data.backup.VssBackupClient // test hook
+import to.bitkit.data.keychain.Keychain // test hook
+import to.bitkit.models.BackupCategory // test hook
+import to.bitkit.repositories.BackupRepo // test hook
+import to.bitkit.repositories.PubkyRepo // test hook
+import to.bitkit.repositories.WalletRepo // test hook
+import to.bitkit.repositories.LightningRepo // test hook
+import to.bitkit.services.PubkyService // test hook
+import to.bitkit.di.json // test hook
+import to.bitkit.models.WalletBackupV1 // test hook
+import to.bitkit.utils.Logger // test hook
+import java.io.File // test hook
+import kotlinx.coroutines.*
+import to.bitkit.models.*
+import to.bitkit.repositories.*
+import to.bitkit.services.*
+import kotlin.time.Clock
+
+object Qa1686 { // test hook
+ private val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + Dispatchers.IO)
+ @EntryPoint @InstallIn(SingletonComponent::class) // test hook
+ interface Dependencies { // test hook
+ fun vssClients(): to.bitkit.data.backup.VssClientCoordinator
+ fun vssLdk(): to.bitkit.data.backup.VssBackupClientLdk
+ fun sdk(): to.bitkit.services.PaykitSdkService
+ fun allowanceExecutor(): to.bitkit.repositories.PaykitAllowanceExecutor
+ fun allowances(): to.bitkit.repositories.PaykitAllowanceRepo
+ fun allowanceStore(): to.bitkit.repositories.PaykitAllowanceStore
+ fun keychain(): Keychain // test hook
+ fun backupRepo(): BackupRepo // test hook
+ fun pubkyRepo(): PubkyRepo // test hook
+ fun pubkyService(): PubkyService // test hook
+ fun vss(): VssBackupClient // test hook
+ fun cache(): CacheStore // test hook
+ fun wallet(): WalletRepo // test hook
+ fun accounts(): to.bitkit.data.WatchOnlyAccountStore
+ fun settings(): to.bitkit.data.SettingsStore
+ fun lightning(): LightningRepo // test hook
+ } // test hook
+ suspend fun run(context: Context, method: String, arg: String?): Bundle { // test hook
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java) // test hook
+ fun file(name: String) = File(context.filesDir, "qa1686-$name")
+ val result = kotlin.runCatching { // test hook
+ when (method) { // test hook
+ "qa1686StopNode" -> { d.lightning().stop().getOrThrow(); "stopped native node to keep activity payload stable" }
+ "qa1686StartNode" -> { d.lightning().start().getOrThrow(); "started original native wallet" }
+ "qa1686Dev" -> { d.settings().update { it.copy(isDevModeEnabled = true) }; "enabled developer mode" }
+ "qa1686ActivitySeed" -> {
+ d.backupRepo().stopObservingBackups()
+ delay(500)
+ d.backupRepo().triggerBackup(BackupCategory.ACTIVITY).getOrThrow()
+ val item = requireNotNull(d.vss().getObject("ACTIVITY").getOrThrow())
+ file("activity-version").writeText(item.version.toString())
+ file("activity-bytes").writeBytes(item.value)
+ "seeded acknowledged ACTIVITY version=${item.version}"
+ }
+ "qa1686ActivitySchedule" -> {
+ d.backupRepo().startObservingBackups()
+ delay(500)
+ d.cache().updateBackupStatus(BackupCategory.ACTIVITY) {
+ it.copy(required = maxOf(System.currentTimeMillis(), it.required + 1, it.synced + 1))
+ }
+ "marked ACTIVITY required through real observer"
+ }
+ "qa1686Full" -> { d.backupRepo().scheduleFullBackup(); "requested full backup" }
+ "qa1686ActivityCheck" -> {
+ val old = file("activity-version").readText().toLong()
+ val item = requireNotNull(d.vss().getObject("ACTIVITY").getOrThrow())
+ val status = d.cache().backupStatuses.first()[BackupCategory.ACTIVITY]
+ val same = item.value.contentEquals(file("activity-bytes").readBytes())
+ if (arg == "same") check(same) else check(!same)
+ check(status != null)
+ "ACTIVITY sameBytes=$same; oldVersion=$old; currentVersion=${item.version}; required=${status.isRequired}; running=${status.running}"
+ }
+ "qa1686ActivityReset" -> { d.backupRepo().reset(); "reset detached activity digest" }
+ "qa1686NativeAuth" -> {
+ d.backupRepo().stopObservingBackups()
+ d.vssClients().withSuspendedClients {}
+ file("auth-override").writeText("hold")
+ d.vssLdk().setup().getOrThrow()
+ file("native-result").delete()
+ scope.launch {
+ try {
+ val result = d.vssLdk().getObject("qa1686-held-auth")
+ file("native-result").writeText("returned success=${result.isSuccess}; error=${result.exceptionOrNull()?.javaClass?.simpleName}")
+ } catch(e: Exception) { file("native-result").writeText("cancelled=${e.javaClass.simpleName}") }
+ }
+ "scheduled actual standalone LDK lookup against held auth response"
+ }
+ "qa1686NativeState" -> "credentialsPresent=${d.keychain().exists(Keychain.Key.BIP39_MNEMONIC.name)}; wiping=${d.backupRepo().isWiping.value}; admissionOpen=${runCatching { d.vssClients().captureOwner() }.isSuccess}; result=${file("native-result").takeIf { it.exists() }?.readText()}"
+ "qa1686RestorePause" -> "persistedPause=${d.allowanceStore().isPausedAfterRestore()}; visiblePause=${d.allowances().automaticPaymentPause.value}"
+ "qa1686ResumeAutomatic" -> { d.allowances().resumeAutomaticPayments().getOrThrow(); "resumed automatic payments; pause=${d.allowanceStore().isPausedAfterRestore()}" }
+ "qa1686StageProfile" -> {
+ d.settings().setPubkyProfileSetupPending(arg == "pending")
+ val field = d.pubkyRepo().javaClass.getDeclaredField("_profile").apply { isAccessible = true }
+ @Suppress("UNCHECKED_CAST")
+ (field.get(d.pubkyRepo()) as kotlinx.coroutines.flow.MutableStateFlow<PubkyProfile?>).value = null
+ file("sign-in").writeText(requireNotNull(d.pubkyRepo().currentSignIn()).generation.toString())
+ "cleared local profile; pending=${d.settings().isPubkyProfileSetupPending.first()}"
+ }
+ "qa1686RetryProfile" -> {
+ val started = kotlin.time.TimeSource.Monotonic.markNow()
+ repeat(3) { d.pubkyRepo().restoreSessionIfNeeded() }
+ "three reconnect calls returned in ${started.elapsedNow()}; current=${d.pubkyRepo().currentSignIn()?.generation.toString() == file("sign-in").readText()}"
+ }
+ "qa1686CompleteSetup" -> {
+ d.settings().setPubkyProfileSetupPending(false)
+ "cleared local setup pending flag"
+ }
+ "qa1686RestoreSaved" -> { d.pubkyRepo().initialize().getOrThrow(); "restored saved identity" }
+ "qa1686RejectedBackup" -> {
+ d.sdk().processPendingPrivateMessages()
+ val field = d.sdk().javaClass.getDeclaredField("cachedBackupState").apply { isAccessible = true }
+ val beforeCache = field.get(d.sdk())
+ check(beforeCache != null)
+ val before = d.sdk().backupStateVersion.value
+ var refused = false
+ try {
+ d.sdk().acceptAllowance(requireNotNull(d.pubkyRepo().publicKey.value).removePrefix("pubky"), "qa-no-native-call", 1uL) {
+ throw PaykitAllowanceError.Unavailable
+ }
+ } catch (error: PaykitAllowanceError.Unavailable) { refused = true }
+ check(refused)
+ check(d.sdk().backupStateVersion.value == before)
+ check(field.get(d.sdk()) === beforeCache)
+ "real SDK service refused admission; version unchanged=true; cached snapshot same=true"
+ }
+ "qa1686OpenChannel" -> {
+ d.lightning().sync().getOrThrow()
+ val peer = org.lightningdevkit.ldknode.PeerDetails(
+ nodeId = "028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc",
+ address = "34.65.86.104:9400", isConnected = false, isPersisted = false,
+ )
+ d.lightning().connectPeer(peer).getOrThrow()
+ val result = d.lightning().openChannel(peer, 200_000uL, 50_000uL).getOrThrow()
+ "opened real disposable regtest channel id=${result.userChannelId}; local funding=200000 sats; push=50000 sats"
+ }
+ "qa1686ChannelState" -> {
+ d.lightning().sync().getOrThrow()
+ "channels=${d.lightning().getChannels()?.map { "ready=${it.isChannelReady} usable=${it.isUsable} outMsat=${it.outboundCapacityMsat} inMsat=${it.inboundCapacityMsat}" }}"
+ }
+ "qa1686AllowanceJournal" -> {
+ val identity = requireNotNull(d.pubkyRepo().publicKey.value).removePrefix("pubky")
+ val state = d.allowanceExecutor().localState(identity)
+ "journal=${state.journal.size}; automatic=${state.journal.count { it.isAutomatic }}; succeeded=${d.allowanceExecutor().succeededAutomaticPayments(identity).size}; stages=${state.journal.map { it.stage }}"
+ }
+ "qa1686Identity" -> requireNotNull(d.pubkyRepo().publicKey.value)
+ "qa1686AddPeer" -> {
+ val peer = requireNotNull(arg)
+ file("peer").writeText(peer)
+ d.pubkyRepo().addContact(peer, PubkyProfile.placeholder(peer).copy(name = "QA peer")).getOrThrow()
+ "saved real peer contact"
+ }
+ "qa1686Link" -> {
+ val peer = file("peer").readText().removePrefix("pubky")
+ d.sdk().syncPaykitApp(true)
+ val link = d.sdk().ensureLinkWithPeer(peer, maxAdvanceSteps = 4u)
+ d.sdk().processOutboundPrivateMessages(peer)
+ "link=$link"
+ }
+ "qa1686Propose" -> {
+ val peer = file("peer").readText().removePrefix("pubky")
+ val role = if (arg == "ask") com.synonym.paykit.AllowanceLocalRole.ALLOWEE else com.synonym.paykit.AllowanceLocalRole.ALLOWER
+ val terms = PaykitAllowanceLimits(5, 50, 10_000uL, 100_000uL).terms(
+ PaykitAllowanceTime.monthStart(Clock.System.now()), PaykitAllowanceRepo.allowedPaymentEndpointIdentifiers())
+ val record = d.sdk().proposeAllowance(peer, role, terms)
+ d.sdk().processOutboundPrivateMessages(peer)
+ "proposed real allowance id=${record.allowanceId}; role=$role"
+ }
+ "qa1686Intake" -> {
+ val peer = file("peer").readText().removePrefix("pubky")
+ d.sdk().receivePrivateMessages(peer)
+ d.allowances().refresh().getOrThrow()
+ "received real messages; entries=${d.allowances().entries.value.size}"
+ }
+ "qa1686Activate" -> {
+ scope.launch {
+ try { d.allowances().activate(requireNotNull(d.pubkyRepo().publicKey.value)) }
+ catch (e: Exception) { Logger.info("QA1686 activate failed=${e.javaClass.simpleName}", context = "TestHook") }
+ }
+ "scheduled same-identity activation"
+ }
+ "qa1686Enable" -> { d.settings().setAreAllowancesEnabled(true); "enabled allowances" }
+ "qa1686Disable" -> { d.settings().setAreAllowancesEnabled(false); "disabled allowances" }
+ "qa1686AllowanceQuick" -> {
+ val f = d.allowances().javaClass.getDeclaredField("activation").apply { isAccessible = true }
+ val activation = f.get(d.allowances())
+ "activation=${activation?.let { System.identityHashCode(it) }}; enabled=${d.settings().areAllowancesEnabled.first()}; entries=${d.allowances().entries.value.size}; acceptance=${file("accept-result").takeIf { it.exists() }?.readText()}"
+ }
+ "qa1686Pause" -> {
+ d.allowances().pause()
+ "paused allowance repository"
+ }
+ "qa1686HoldLock" -> {
+ file("lock").writeText("hold")
+ scope.launch {
+ val f = d.sdk().javaClass.getDeclaredField("operationLock").apply { isAccessible = true }
+ (f.get(d.sdk()) as PaykitSdkOperationLock).withLock {
+ Logger.info("QA1686 SDK operation lock held", context = "TestHook")
+ withTimeout(90_000) { while (file("lock").exists()) delay(100) }
+ Logger.info("QA1686 SDK operation lock released", context = "TestHook")
+ }
+ }
+ "scheduled SDK lock hold (90 second bound)"
+ }
+ "qa1686ReleaseLock" -> { file("lock").delete(); "released lock gate" }
+ "qa1686Accept" -> {
+ val entry = requireNotNull(d.allowances().entries.value.firstOrNull { it.allowances.any { a -> a.isAnswerable } })
+ file("accept-result").delete()
+ scope.launch {
+ val result = if (arg == "auto") {
+ runCatching { check(d.allowances().acceptOffersFromAllowers().isNotEmpty()) }
+ } else d.allowances().accept(entry.id)
+ val value = "success=${result.isSuccess}; error=${result.exceptionOrNull()?.javaClass?.simpleName}"
+ file("accept-result").writeText(value)
+ Logger.info("QA1686 queued accept $value", context = "TestHook")
+ }
+ "queued real allowance acceptance"
+ }
+ "qa1686AllowanceState" -> {
+ val records = d.sdk().listAllowances(com.synonym.paykit.AllowanceFilter(null, null, emptyList()))
+ "native=${records.map { it.allowanceId + ":" + it.state }}; acceptance=${file("accept-result").takeIf { it.exists() }?.readText()}"
+ }
+
+ "qa1686Schedule" -> {
+ d.settings().update { it.copy(selectedCurrency = if (it.selectedCurrency == "EUR") "GBP" else "EUR") }
+ d.backupRepo().scheduleFullBackup()
+ "scheduled all categories"
+ }
+ "qa1686Stop" -> {
+ d.backupRepo().stopObservingBackups()
+ kotlinx.coroutines.delay(300)
+ val statuses = d.cache().backupStatuses.first()
+ "stopped; settings=${statuses[BackupCategory.SETTINGS]}"
+ }
+ "qa1686Start" -> {
+ d.backupRepo().startObservingBackups()
+ "started observers"
+ }
+ "qa1686Status" -> {
+ "settings=${d.cache().backupStatuses.first()[BackupCategory.SETTINGS]}; pending=${d.backupRepo().hasPendingWalletRestore()}; electrum=${d.settings().data.first().electrumServer}"
+ }
+ "qa1686ArmRestore" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value)
+ val old = d.cache().data.first().onchainAddress
+ File(context.filesDir, "qa1686-old-address").writeText(old)
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString())
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString())
+ "armed real required restore with original complete remote WALLET; saved old address=${old.isNotBlank()}"
+ }
+ "qa1686CheckRestore" -> {
+ check(!d.backupRepo().hasPendingWalletRestore())
+ val old = File(context.filesDir, "qa1686-old-address").readText()
+ val next = d.wallet().newAddress().getOrThrow()
+ check(old.isBlank() || next != old)
+ "required markers clear; new ordinary address succeeds; old metadata address excluded=${old.isNotBlank()}"
+ }
+ "qa1686PrepareProvider" -> {
+ File(context.filesDir, "qa1686-provider-secret").writeText(requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name)))
+ File(context.filesDir, "qa1686-provider-pubky").writeText(requireNotNull(d.pubkyRepo().publicKey.value).removePrefix("pubky"))
+ "staged disposable provider credential files"
+ }
+ "qa1686InitCore" -> { // test hook
+ to.bitkit.async.ServiceQueue.CORE.background { // test hook
+ com.synonym.bitkitcore.initDb(to.bitkit.env.Env.bitkitCoreStoragePath(0)) // test hook
+ com.synonym.bitkitcore.updateBlocktankUrl(to.bitkit.env.Env.blocktankApiUrl) // test hook
+ } // test hook
+ "initialized real native database after app storage path became available" // test hook
+ } // test hook
+ "qa1686RestageProfile" -> {
+ check(d.pubkyRepo().publicKey.value != null) // test hook
+ val setter = d.pubkyRepo().javaClass.getDeclaredMethod("setProfile", to.bitkit.models.PubkyProfile::class.java).apply { isAccessible = true } // test hook
+ setter.invoke(d.pubkyRepo(), null) // test hook
+ val store = d.pubkyRepo().javaClass.getDeclaredField("pubkyStore").apply { isAccessible = true }.get(d.pubkyRepo()) as to.bitkit.data.PubkyStore // test hook
+ store.reset() // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ "staged missing own profile and empty display cache for active saved identity; optional enrichment scheduled" // test hook
+ }
+ "qa1686Retry" -> {
+ val original = requireNotNull(d.pubkyRepo().currentSignIn())
+ val started = android.os.SystemClock.elapsedRealtime()
+ repeat(3) { check(!d.pubkyRepo().restoreSessionIfNeeded()) }
+ val elapsed = android.os.SystemClock.elapsedRealtime() - started
+ check(d.pubkyRepo().isCurrent(original))
+ "three retry calls returned in ${elapsed}ms; original sign-in retained=true; profile=${d.pubkyRepo().profile.value?.name}"
+ }
+ "qa1686ConnectionFault" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value)
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString())
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString())
+ d.settings().update { it.copy(electrumServer = "tcp://127.0.0.1:1") }
+ "set isolated unreachable Electrum and retained required WALLET/USDT payloads"
+ }
+ "qa1686AddContact" -> {
+ val secret = d.pubkyService().deriveSecretKey("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
+ val key = "pubky" + d.pubkyService().publicKeyFromSecret(secret).removePrefix("pubky")
+ d.pubkyRepo().addContact(key, to.bitkit.models.PubkyProfile.placeholder(key).copy(name = "QA contact")).getOrThrow()
+ "saved one disposable contact"
+ }
+ "qa1686QuickState" -> {
+ "source=${d.keychain().exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)}, savedSecret=${!d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()}, savedSession=${!d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrBlank()}, identity=${d.pubkyRepo().publicKey.value != null}, authenticated=${d.pubkyRepo().isAuthenticated.value}, profile=${d.pubkyRepo().profile.value?.name}, contacts=${d.pubkyRepo().contacts.value.size}, pendingRestore=${d.backupRepo().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}"
+ }
+ "qa1686SourceMarker" -> {
+ val key = requireNotNull(d.pubkyRepo().publicKey.value).removePrefix("pubky")
+ d.keychain().upsertString(Keychain.Key.SHARED_PUBKY_SOURCE.name, to.bitkit.data.sharedpubky.SharedPubkyContract.RING_SOURCE_PREFIX + key)
+ "staged adopted-source marker for the same active disposable identity"
+ }
+ "qa1686BackupPending" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET)
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true
+ check(failed.isFailure && before.value.contentEquals(after.value) && before.version == after.version && pending)
+ "failed=true; remote bytes/version unchanged=true; pending=true"
+ }
+ "qa1686CreateProfile" -> {
+ d.pubkyRepo().createIdentity("QA restore", "", emptyList(), emptyList(), null).getOrThrow()
+ "created disposable profile"
+ }
+ "qa1686Backup" -> {
+ for (c in listOf(BackupCategory.WALLET, BackupCategory.METADATA, BackupCategory.SETTINGS)) d.backupRepo().triggerBackup(c).getOrThrow()
+ val wallet = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val metadata = requireNotNull(d.vss().getObject("METADATA").getOrThrow())
+ val pending = d.cache().backupStatuses.first().filterKeys { it in listOf(BackupCategory.WALLET, BackupCategory.METADATA) }.any { it.value.isRequired }
+ check(!pending)
+ "backup synchronized; sdk=${json.decodeFromString<WalletBackupV1>(wallet.value.decodeToString()).paykitSdkBackupState != null}; metadataBytes=${metadata.value.size}"
+ }
+ "qa1686UsdtFault" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value)
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, "invalid-json")
+ "staged malformed retained USDT recovery payload"
+ }
+ "qa1686Terminal" -> {
+ d.backupRepo().stopObservingBackups()
+ val secret = requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name))
+ File(context.filesDir, "qa1686-original-secret").writeText(secret)
+ val session = requireNotNull(d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name))
+ File(context.filesDir, "qa1686-original-session").writeText(session)
+ d.pubkyService().forgetSessionAccess()
+ d.keychain().delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ d.keychain().upsertString(Keychain.Key.PAYKIT_SESSION.name, "qa1686-invalid-session")
+ d.pubkyRepo().initialize().getOrThrow()
+ check(d.pubkyRepo().publicKey.value == null)
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val reserved = d.accounts().reserveAccountIndex(0, "qa1686-terminal-ordinary")
+ check(d.accounts().backupSnapshot().allocationState.highestAccountIndexByWallet["0"] == reserved)
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET)
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val same = before.value.contentEquals(after.value) && before.version == after.version
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true
+ check(failed.isFailure && same && pending)
+ "terminal=true; ordinary watch-only index=$reserved reserved; failed=true; remote unchanged=true; pending=true; error=${failed.exceptionOrNull()?.javaClass?.simpleName}"
+ }
+ "qa1686RepairIdentity" -> {
+ d.keychain().upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, File(context.filesDir, "qa1686-original-secret").readText())
+ d.keychain().upsertString(Keychain.Key.PAYKIT_SESSION.name, File(context.filesDir, "qa1686-original-session").readText())
+ d.pubkyRepo().initialize().getOrThrow()
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow()
+ "recovered original identity and complete WALLET upload"
+ }
+ "qa1686Capture" -> { // test hook
+ File(context.filesDir, "qa1686-mnemonic").writeText(requireNotNull(d.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name))) // test hook
+ for (c in listOf(BackupCategory.WALLET, BackupCategory.METADATA, BackupCategory.SETTINGS)) d.backupRepo().triggerBackup(c).getOrThrow() // test hook
+ check(d.vss().getObject("WALLET").getOrThrow() != null) // test hook
+ "captured complete backup and test credentials" // test hook
+ } // test hook
+ "qa1686Clipboard" -> { // test hook
+ withContext(Dispatchers.Main) { // test hook
+ context.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("test seed", File(context.filesDir, "qa1686-mnemonic").readText())) // test hook
+ } // test hook
+ "copied test credentials" // test hook
+ } // test hook
+ "qa1686BackupFault" -> { // test hook
+ d.backupRepo().stopObservingBackups() // test hook
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val secret = requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) // test hook
+ d.pubkyService().forgetSessionAccess() // test hook
+ check(d.pubkyService().currentPublicKey() == null) // test hook
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET) // test hook
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true // test hook
+ val same = before.value.contentEquals(after.value) && before.version == after.version // test hook
+ Logger.info("QA1686 unavailable identity: failed=${failed.isFailure}, unchanged=$same, pending=$pending", context="TestHook") // test hook
+ check(failed.isFailure && same && pending) // test hook
+ d.pubkyService().signIn(secret) // test hook
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ val fresh = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val complete = json.decodeFromString<WalletBackupV1>(fresh.value.decodeToString()).paykitSdkBackupState != null // test hook
+ val synced = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == false // test hook
+ check(complete && synced) // test hook
+ d.backupRepo().startObservingBackups() // test hook
+ "backup protection and recovered export passed" // test hook
+ } // test hook
+ "qa1686Recover" -> { // test hook
+ val secret = d.pubkyService().deriveSecretKey(requireNotNull(d.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name))) // test hook
+ d.keychain().upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secret) // test hook
+ d.pubkyService().initialize() // test hook
+ d.pubkyRepo().restoreSessionBackupState(to.bitkit.models.PubkySessionBackupV1(kind = to.bitkit.models.PubkySessionBackupKind.LocalSeed)).getOrThrow() // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ d.backupRepo().startObservingBackups() // test hook
+ "recovered identity and fresh complete backup" // test hook
+ } // test hook
+ "qa1686Refresh" -> { // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ "scheduled optional enrichment" // test hook
+ } // test hook
+ "qa1686RequiredFault" -> { // test hook
+ d.backupRepo().stopObservingBackups() // test hook
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value) // test hook
+ File(context.filesDir, "qa1686-corrupt-wallet").writeText("hold")
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString()) // test hook
+ "staged malformed required WALLET decoder input; retained original backup" // test hook
+ } // test hook
+ "qa1686RepairUsdt" -> {
+ check(!d.keychain().exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name))
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, file("valid-wallet").readText())
+ "repaired only retained USDT envelope; no WALLET marker introduced"
+ }
+ "qa1686RepairRequired" -> { // test hook
+ File(context.filesDir, "qa1686-corrupt-wallet").delete()
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ if (d.keychain().exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)) { // test hook
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ } // test hook
+ "repaired original required backup" // test hook
+ } // test hook
+ "qa1686SpendingGate" -> { // test hook
+ check(d.backupRepo().hasPendingWalletRestore()) // test hook
+ val result = d.lightning().payInvoice("qa1686-invalid-invoice") // test hook
+ check(result.exceptionOrNull()?.javaClass?.simpleName == "WalletBackupRestorePendingError") // test hook
+ "payment admission blocked by required restore before invoice parsing" // test hook
+ } // test hook
+ "qa1686AddressGate" -> { // test hook
+ val result = d.wallet().newAddress() // test hook
+ "new address success=${result.isSuccess}; error=${result.exceptionOrNull()?.javaClass?.simpleName}" // test hook
+ } // test hook
+ "qa1686SignOut" -> { // test hook
+ d.pubkyRepo().signOut().getOrThrow() // test hook
+ "identity removed" // test hook
+ } // test hook
+ else -> "source=${d.keychain().exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)}, savedSecret=${!d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()}, savedSession=${!d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrBlank()}, sdk=${d.pubkyService().currentPublicKey() != null}, identity=${d.pubkyRepo().publicKey.value != null}, profile=${d.pubkyRepo().profile.value?.name}, contacts=${d.pubkyRepo().contacts.value.size}, pendingRestore=${d.backupRepo().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}" // test hook
+ } // test hook
+ }.fold({ it }, { "ERROR: ${it.javaClass.simpleName}: ${it.message}" }) // test hook
+ Logger.info("QA1686 $method: $result", context="TestHook") // test hook
+ return Bundle().apply { putString("result", result) } // test hook
+ } // test hook
+} // test hook
Native authentication response hold; forwards the genuine response after explicit release:
from http.server import ThreadingHTTPServer,BaseHTTPRequestHandler
import urllib.request,time,pathlib,threading
root=pathlib.Path(__file__).parent
end=time.monotonic()+900
class Handler(BaseHTTPRequestHandler):
def log_message(self,*args): pass
def do_GET(self):
with open(root/'auth-events.log','a') as f:f.write(f'{time.time():.3f} native auth request received\n')
while (root/'hold-auth').exists() and time.monotonic()<end: time.sleep(.1)
try:
with urllib.request.urlopen('https://bitkit.stag0.blocktank.to'+self.path, timeout=30) as r:
data=r.read();self.send_response(r.status);self.send_header('Content-Type',r.headers.get('Content-Type','application/json'));self.end_headers();self.wfile.write(data)
with open(root/'auth-events.log','a') as f:f.write(f'{time.time():.3f} forwarded response delivered\n')
except Exception as e:
self.send_response(502);self.end_headers()
with open(root/'auth-events.log','a') as f:f.write(f'{time.time():.3f} forward error {type(e).__name__}\n')
server=ThreadingHTTPServer(('127.0.0.1',23469),Handler)
threading.Timer(900,server.shutdown).start()
server.serve_forever()Selected deciding output, with repeated polling omitted:
Terminal: ordinary watch-only index=1 reserved; failed=true; remote unchanged=true; pending=true; error=AppError
RepairIdentity: recovered original identity and complete WALLET upload
ActivityCheck: ACTIVITY sameBytes=true; oldVersion=1; currentVersion=1; required=false; running=false
Full: requested full backup
ActivityCheck: ACTIVITY sameBytes=false; oldVersion=1; currentVersion=1; required=false; running=false
ActivityReset: reset detached activity digest
ActivityCheck: ACTIVITY sameBytes=false; oldVersion=1; currentVersion=1; required=false; running=false
SpendingGate: payment admission blocked by required restore before invoice parsing
AddressGate: new address success=false; error=WalletBackupRestorePendingError
CheckRestore: required markers clear; new ordinary address succeeds; old metadata address excluded=true
RepairUsdt: repaired only retained USDT envelope; no WALLET marker introduced
RestorePause: persistedPause=true; visiblePause=RESTORED
AllowanceJournal: journal=2; automatic=2; succeeded=2; stages=[SUCCEEDED, SUCCEEDED]
RestorePause: persistedPause=false; visiblePause=null
AllowanceJournal: journal=3; automatic=3; succeeded=3; stages=[SUCCEEDED, SUCCEEDED, SUCCEEDED]
BackupPending: failed=true; remote bytes/version unchanged=true; pending=true
Backup: backup synchronized; sdk=true; metadataBytes=22685
RetryProfile: three reconnect calls returned in 208.542us; current=true
PubkyRepo: QA1686 profile released; current=false
PubkyRepo: Skipped stale profile load
Native auth: response received at 1794267492.874; forwarded response delivered at 1794267686.704
00:38:42.555 NativeState: credentialsPresent=true; wiping=true; admissionOpen=false; result=null
00:41:25.170 NativeState: credentialsPresent=true; wiping=true; admissionOpen=false; result=null
00:41:56.425 NativeState: credentialsPresent=false; wiping=false; admissionOpen=true; result=cancelled=CancellationException
Reviewed by gpt-6.1-sol-high
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Re-reviewed the full pull request diff against merge base 596f46cd at 3e1fdb7d. The comparison base changed and no inherited coverage was supplied, so this pass reassessed the whole pinned diff.
No new actionable code findings.
Required wallet and USDT restore still finish before optional Pubky profile refresh. Scheduled initialization leaves an identity already activated by metadata restore in place and loads profile data without a second session import. Reconnect retries a missing own profile unless local profile setup is still pending, and a profile result that arrives after that sign-in ends is dropped. Standalone VSS setup, backup IO, retries, and Lightning routing resets keep the captured wallet generation; identity replacement cancels that work and shuts the standalone clients down before credentials change. An activity backup skips an unchanged payload only after a successful write for the same owner, and a full backup requested while that read is in progress still uploads. Turning allowances off and back on advances a pause revision so a contact sync that already started cannot accept the revoked activation.
Compared scheduled restore with bitkit-ios 0ad55417. That revision clears in-memory authentication and runs session initialization again. Android keeps the activated identity, which is the behavior covered by PubkyRepoTest.
When a saved identity exists but its Paykit backup state cannot be read, the wallet backup fails and the previous complete remote record stays in place. Newer ordinary wallet changes remain pending until that state can be written. That is the backup guard exercised by J1. J1's complete-app fault controls are still not provisioned, so that journey cannot be run from the standard fixture.
Validation: the production diff, unit tests, and restore, backup-status, and allowance journeys were inspected at this revision. The unit suite and device journeys were not executed in this review. This correction checked pinned source, current thread resolution and author replies, and the open documentation issue; it did not rerun the original review or execute tests. Separate published device evidence remains attributed to its original review.
Device testing: not performed in this review.
Existing concerns remain tracked below; no duplicate inline findings are proposed. The open terminal-identity backup and stalled-auth reset concerns still prevent treating this as a clean review. The LOW allowance-journey documentation gap is deferred to its open issue.
Existing concerns
- [MEDIUM] Bound native authentication before draining VSS during reset — Still unresolved. At 3e1fdb7 the coordinator still cancels and joins admitted work inside NonCancellable before shutdown and credential replacement. The existing thread now includes a separate installed-app reproduction: Reset remained wiping with admission closed, credentials present and Back disabled for at least 162.6 seconds while native authentication was held. The author confirms this finding and keeps integration open; private coordinator testing with the proposed deadline fix does not establish a fixed Settings reset. Those runtime results belong to the linked discussion, not this review.
- [HIGH] Terminal identity failure blocks newer ordinary WALLET backups — Still open at 3e1fdb7. The existing Android report records a later ordinary reservation remaining pending with unchanged remote WALLET bytes/version while retained identity credentials cannot recover. The author confirms the terminal-identity concern remains open in the latest reset-thread reply. Pinned BackupRepo.kt:948-951 still requires readable Paykit state when stored identity exists, and upload at :766-772 requires the snapshot first. The published device result belongs to the linked review; this correction adds no new execution or independent discovery.
- [LOW] Allowance journey describes a delayed-contact check its actions do not perform — Deferred LOW documentation gap, still tracked in open issue #1766. The original PR thread is resolved following the explicit deferral, rather than a code fix. At 3e1fdb7 the description still promises a delayed-contact off/on check, while the seven actions perform the ordinary toggle flow. This does not reopen the repaired allowance activation code or establish that the delayed-contact device case passed.
Reviewer model: grok-4.7
Reasoning effort: high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — I reproduced both existing findings at 710292a. I found no new actionable defect in the delta.
Findings:
- Existing HIGH — Terminal identity failure blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:1049. With an invalid retained session and no recovery secret, Paykit capture failed and a new ordinary watch-only reservation stayed pending. Remote WALLET bytes/version remained unchanged. Ordinary address creation worked; restoring the original credentials allowed a complete upload. The newer ordinary state remains outside the remote backup until identity recovers. - Existing MEDIUM — Native authentication can prevent Reset from finishing.
app/src/main/java/to/bitkit/data/backup/VssClientCoordinator.kt:138. On VSS0.5.26-rc2, a genuine native LNURL-auth response held Reset in its non-cancellable drain. Admission stayed closed, credentials remained present, Back was disabled and no retryable error appeared for at least 198.4 seconds. Releasing the response let the lookup cancel and Reset finish. The native deadline change remains open in vss-rust-client-ffi#36 fix: bound LNURL authentication before VSS requests.
QA for 710292a: Android 16 / API 36, two disposable staging/regtest wallets, real Pubky, Paykit, VSS, Spark and LDK libraries. I built the pristine head, then used unpublished Debug controls for fault injection, timing holds and readback. The installed APKs matched their recorded builds; all eleven native libraries in the final controlled build were byte-identical to the pristine APK. Videos play at 2×. Nonvisual assertions below were checked in Debug readback and app/proxy logs.
Back up ordinary changes after terminal identity failure ❌ A new ordinary reservation stayed pending with unchanged remote WALLET bytes/version.
The invalid-session/no-secret fault reached the real Paykit client. Capture failed after allocating a new ordinary watch-only reservation; the last complete remote backup stayed intact. Address creation still succeeded. Original-credential recovery allowed complete SDK-bearing WALLET upload. The reservation, capture failure and remote comparison were checked through readback/logs.
terminal-wallet-2x.2x.mp4
Backup status during the terminal identity failure.
Finish Reset while native authentication is held ❌ Reset stayed blocked until the genuine auth response was released.
The native standalone LDK lookup reached a loopback response hold on its actual LNURL-auth endpoint. The response remained held for 263.2 seconds. After UI Reset closed admission, the observed blocked interval was 198.4 seconds. Hardware Back left the wiping screen in place. Release cancelled the old lookup; Reset reached initial Terms, removed credentials and reopened admission 40.7 seconds later. These durations and credential/admission assertions come from proxy logs and Debug readback.
held: credentialsPresent=true; wiping=true; admissionOpen=false; result=null
released: credentialsPresent=false; wiping=false; admissionOpen=true; result=cancelled=CancellationException
| Held native auth and UI Reset | Continued hold, release and completed Reset |
|---|---|
native-reset-held-2x.2x.mp4 |
native-reset-held-continuation-2x.2x.mp4 |
Consecutive recordings of the same Reset; the response hold is in native HTTP IO, not a replacement Kotlin operation.
Replay Spark readiness after a cancelled status write ✅ Readback/logs confirmed replay and one acknowledged SPARK_EXIT upload.
Manual QA3: a real Spark disconnect/reconnect reached a held backup-required write before persistence. A failed owner transition cancelled that write without replacing credentials. Reopened admission replayed readiness, persisted the requirement and completed one real Spark export/VSS PUT; pending/running flags cleared. The fixture was unfunded.
spark-ready-2x.2x.mp4
Spark backup status while the held write is cancelled and replayed.
Replay Spark exit changes after a cancelled status write ✅ A cancelled notification replayed; observer restart added no duplicate upload.
Manual QA3: a Debug-injected exit-generation notification reached the real status write. Failed owner transition cancelled it; unchanged credentials reopened and produced one acknowledged upload. A separate fresh generation retained identical acknowledged required/synced status across observer stop/start, with no additional SPARK_EXIT PUT. Readback/logs establish these assertions. Export and VSS IO were real; the notification was injected.
| Cancellation and replay | Acknowledged generation through observer restart |
|---|---|
spark-generation-2x.2x.mp4 |
spark-observer-restart-verified-2x.2x.mp4 |
The restart recording accompanies the unchanged status and PUT-count readback.
Keep Spark observation alive after a storage error ✅ A later generation uploaded without restarting observation.
Manual QA3: one injected IOException occurred before the actual required-status persistence. A later injected generation was persisted and acknowledged through the same collector; required/running flags cleared. Real Spark export, VSS acknowledgement and collector continuity were checked in readback/logs.
spark-storage-2x.2x.mp4
Backup status after the one-shot failure and later notification.
Keep required recovery gated through repair ✅ Actual WALLET and USDT decoder failures retained gates through restart and cleared only after repair.
J1: explicit Try Again reached the real malformed WALLET decoder and, separately, the retained USDT decoder. Spending/address admission stayed blocked with the node running, including after restart. Repairing input and retrying cleared required markers and allowed a fresh address. USDT repair changed only its retained envelope and introduced no WALLET marker. Decoder exceptions and gates were checked in readback/logs; this covers decoder/admission behavior, not funded nonempty USDT import.
| WALLET failure, restart and repaired retry | USDT-only failure, restart and repaired retry |
|---|---|
required-wallet-complete-2x.2x.mp4 |
required-usdt-complete-2x.2x.mp4 |
Each video joins the recorded failure and recovery legs in order.
Restore wallet access before optional identity activation ✅ Required imports and receiving completed while optional activation waited, including after restart.
J1: after real required imports, a Debug gate removed in-memory identity access and held final optional activation while retaining saved credentials. Required markers cleared, the node ran, a new address succeeded and recovery could be exited. Complete WALLET capture refused the unavailable identity and preserved the remote backup. Restart retained wallet access during the hold. Release recovered the profile, contacts and a complete SDK-bearing WALLET/METADATA backup. Readback/logs establish the identity, gates and remote-backup assertions.
activation-complete-2x.2x.mp4
Required recovery, screen exit, restart and release of optional activation, joined in order.
Recover a missing profile and reject stale enrichment ✅ Reconnect retained the sign-in, Home recovered its name and a stale response could not republish identity.
J1: device-scoped offline networking caused a real own-profile transport failure. Repeated reconnect calls returned promptly with the same sign-in. During a held successful response, identity stayed authenticated and a fresh address succeeded. On Home, release visibly changed Your Name to QA restore without opening Profile or reimporting the session. A separate response released after normal sign-out logged stale rejection and left identity/profile absent. Transport, sign-in generation and absence of session initialization were checked in readback/logs.
| Real offline fetch failure | Held response and visible Home recovery | Response released after sign-out |
|---|---|---|
profile-offline-2x.2x.mp4 |
profile-home-2x.2x.mp4 |
profile-stale-2x.2x.mp4 |
The offline address-readback collection timed out and completed after reconnect; the online held-response address checks passed.
Accept allowances across contact refresh and revoke old queued attempts ✅ Same-identity contact refresh kept the queued Accept valid; off/on revoked the old attempt.
Manual QA1: a UI Accept held before the native lock survived an actual contact-key change with its original activation token and one native acceptance. For another real proposal, disable/re-enable rotated activation. Releasing the old queued UI action left that proposal PROPOSED; a fresh UI Accept changed it to ACCEPTED. Tokens, admission and native states were checked in readback/logs. The controlled toggle wrote through the real SettingsStore.
| Actual contact change during held UI Accept | Revoked old action, then fresh UI Accept |
|---|---|
manual-contact-2x.2x.mp4 |
manual-revoke-2x.2x.mp4 |
Real contacts and native allowance proposals were used on both wallets.
Resume allowance activation after toggling during contact synchronization ✅ Release installed a current activation and restored visible Active rows without restart.
Manual QA2: an actual contact-key change reached the held AppViewModel synchronization boundary. Real SettingsStore off/on writes revoked activation while it waited. Release installed a new token and reloaded the native grants; visible Active rows returned without restarting. The hold, token and records were checked in readback/logs.
| Held contact synchronization and off/on | Visible Active rows after release |
|---|---|
manual-toggle-2x.2x.mp4 |
manual-toggle-active-2x.2x.mp4 |
The persistent restore pause shown in the second clip was cleared before the ordinary off/on payment case.
Keep covered requests manual while allowances are off ✅ UI disable kept a covered request manual; enabling restored Active and a fresh request paid once.
J2: with allowances off in Dev Settings, a covered 2,500-sat request opened manual confirmation, the Allowances tab disappeared and the automatic journal stayed empty. UI enable restored Active. Two subsequent automatic attempts failed to route to the payee's Spark invoice from the staging LDK network; their separate FAILED entries were retained. After adding real LDK inbound liquidity and rotating the real private endpoint, a fresh 2,500-sat request paid once, reached SUCCEEDED for one request ID and appeared as Auto-paid. Native events, per-request journal and endpoint state were checked through readback/logs.
| Allowances off | UI enable and Active | Fresh request on the shared LDK route | Completed Auto-paid history |
|---|---|---|---|
allowance-off-pair-2x.2x.mp4 |
allowance-enable-2x.2x.mp4 |
allowance-ldk-on-pair-2x.2x.mp4 |
allowance-final-readback-2x.2x.mp4 |
Paired recordings align payer left and requester right by recorder start time. This is an LDK payment pass; Spark payment routing was not certified.
Keep restored automatic payments paused until Resume ✅ Actual wipe/phrase restore retained history and pause; manual approval worked and UI Resume enabled one fresh automatic payment.
Actual Settings Reset and the matching recovery phrase restored the same successful automatic-payment entry. RESTORED pause and Active grants survived relaunch. A new covered 2,500-sat request stayed manual without adding an automatic attempt, then Swipe To Pay completed it as a manual success. Another relaunch kept that success and the pause. UI Resume cleared the persistent flag; a fresh request added exactly one automatic SUCCEEDED entry. Native events, per-request journal and persistent flags were checked through readback/logs. The earlier failed routing attempts stayed distinct FAILED entries.
| Actual restore completion | Pause and history after restart |
|---|---|
actual-restore-completion-2x.2x.mp4 |
restored-pause-restart-2x.2x.mp4 |
Phrase paste matched the saved disposable wallet privately; phrase entry is omitted from the public clip.
| Manual approval while paused | Restart and UI Resume | Fresh automatic request after Resume |
|---|---|---|
restored-manual-pair-2x.2x.mp4 |
restored-resume-2x.2x.mp4 |
resumed-fresh-pair-2x.2x.mp4 |
Paired recordings align payer left and requester right. Readback distinguishes the manual success from the two distinct automatic successes.
Show queued backup status and acknowledgement ✅ The pristine APK showed Settings Running during debounce, then Latest Backup after acknowledgement.
J3: changing USD to GBP showed Settings Running, followed by Latest Backup: now, without Settings Retry. App logs confirmed the actual SETTINGS PUT acknowledgement.
j3-pristine-2x.2x.mp4
Currency change and Settings backup status on the pristine head.
Check: pristine build and focused regressions ✅ The pinned app built and the focused backup, Lightning, identity and coordinator tests passed.
assembleDevDebug and filtered testDevDebugUnitTest passed on pristine source. The selected classes were:
BackupRepoTestLightningRepoTestPubkyRepoTestVssClientCoordinatorTest
Installed APK hashes and native-library equality were checked separately from device outcomes. This was focused validation; the full compile/test/lint gate was not repeated.
Coverage: contiguous delta 3e1fdb7d..710292aa, merge integration and the inherited open findings from the prior review. All planned device cases were driven; both failures above remain open. The merged upstream Spark/UI/payment features were source-reviewed, but the named cases do not certify every merged flow. No funded Spark payment, unilateral exit, funded nonempty USDT import or real Ring-app interaction was driven.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh at 0ad55417 shares the deferred optional-restore intent. Android retains an identity already activated by metadata; that iOS pin clears in-memory authentication and initializes again. Spark notification replay is Android-only at these pins. This was a source comparison, not an iOS device pass.
Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the existing complete-WALLET availability finding remains open. No new defect found in this delta.
The PR moved to cd0befc after this run; these results are for acdd99c.
Findings:
- HIGH — Terminal identity failure prevents newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:1060still requires the saved identity's SDK snapshot before uploading ordinary wallet changes. The previous installed-app reproduction left a newer ordinary account reservation pending with remote WALLET bytes/version unchanged until credentials recovered. This delta leaves that path unchanged; this run did not repeat the failure. - MEDIUM — Reset still waits for unbounded native authentication.
app/src/main/java/to/bitkit/data/backup/VssClientCoordinator.kt:138still drains cancelled native work insideNonCancellableon VSS0.5.26-rc2. The previous held-auth Reset reproduction and author acknowledgement remain applicable; neither coordinator nor native pin changed. This run did not repeat it.
QA for acdd99c:
Disposable unfunded regtest wallet, real Spark SDK and staging VSS. The device checks use unpublished Debug controls to stage restore state and inspect persistence; the native libraries match the pristine head APK. The PR advanced to cd0befc while the expiry case was running. Results below are for acdd99c; remaining cases were not started on the superseded head.
Back up pending Spark changes when restore finishes ✅ Pending Spark readiness uploaded once after restore cleared; an unchanged later gate toggle added no PUT, checked in readback/logs.
A real Spark disconnect/reconnect became ready while restore was pending. No required write or remote change occurred until the gate cleared; then one real SPARK_EXIT upload acknowledged and cleared required/running. An unchanged hold/release followed by another full debounce caused no further PUT, checked in readback/logs.
Unpublished restore-state control; real Spark SDK, CacheStore and VSS. Remote version alone is not treated as an upload counter; app PUT/ack logs establish the count.
spark-restore-clear-2x.2x.mp4
Data Backups during the controlled case.
Back up pending Spark changes when restore expires ✅ The real ten-minute restore gate expired and the pending Spark generation uploaded without another event, checked in timed readback/logs.
A controlled exit-generation notification stayed unrecorded while the real pending-restore gate remained closed. The production ten-minute timeout elapsed without clock changes or another Spark event; the first open readback showed the queued backup, followed by one real VSS PUT acknowledgement and cleared required/running status. Generation stayed constant throughout; timing and persistence were checked in readback/logs.
Real Spark export, CacheStore and VSS on an unfunded regtest wallet. Consecutive recordings are joined in order; readback/logs establish elapsed time across recording boundaries.
spark-restore-expiry-2x.2x.mp4
Home during the real timeout wait; gate timing, unchanged generation and upload acknowledgement are established by readback/logs. Consecutive recordings are joined in order.
Resume a queued Spark backup after another restore ⏭️ Not driven on this head before it was superseded.
Not driven at acdd99c before the PR advanced to cd0befc during the expiry case.
Replay Spark readiness after a cancelled status write ⏭️ Not driven on this head before it was superseded.
The earlier 710292a pass exists, but this run did not repeat the affected notification path before the PR advanced.
Replay Spark exit changes after a cancelled status write ⏭️ Not driven on this head before it was superseded.
The earlier 710292a pass exists, but this run did not repeat the affected notification path before the PR advanced.
Keep Spark observation alive after a storage error ⏭️ Not driven on this head before it was superseded.
The earlier 710292a pass exists, but this run did not repeat the affected notification path before the PR advanced.
Back up ordinary changes after terminal identity failure ⏭️ Not driven on this head before it was superseded.
Existing HIGH retained from the previous review and unchanged capture path; no new runtime reproduction at acdd99c.
Finish Reset while native authentication is held ⏭️ Not driven on this head before it was superseded.
Existing MEDIUM retained from the previous installed-app reproduction and unchanged VSS pin/coordinator; no new reproduction at acdd99c.
Keep an in-flight backup with its original wallet ⏭️ Not driven on this head before it was superseded.
A real in-flight PUT during second-phrase replacement remains untested at acdd99c. Existing queued-backup and pre-persistence cancellation passes do not cover a native PUT awaiting its response.
Check: pristine build and restore regressions ✅ The pristine APK built and the focused backup suite passed.
assembleDevDebug and testDevDebugUnitTest --tests to.bitkit.repositories.BackupRepoTest passed on unmodified head source. Each added restore-gate regression failed with only BackupRepo production source reverted to 710292aa, then passed at acdd99c. This is focused validation; the full compile/test/lint gate was not repeated. Installed APK bytes and equality of all eleven native libraries were checked separately from device outcomes.
Coverage: contiguous delta 710292aa..acdd99c, both Spark listeners and scheduling/restore gates, plus the inherited open findings. The new interrupted-debounce case and affected notification replay cases remain unrun here. The previous review proves the unchanged required-recovery, optional identity/reconnect, allowance, restored-payment pause and queued Settings cases at 710292aa; those are retained evidence, not fresh passes at this head. No funded Spark payment, unilateral exit, funded nonempty USDT import or real Ring-app interaction was driven.
The historical request to hold a real in-flight PUT during second-phrase replacement remains unproven. The requesting review is included in the unrun plan above; queued-backup and pre-persistence cancellation passes do not establish that boundary.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh at 0ad55417 shares deferred optional identity recovery. This Spark restore-gate delta is Android-only; that pin's BackupService has no Spark exit-backup path. Source comparison only.
Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the terminal-WALLET failure remains open, and deferred profile migration can republish an ended identity.
Findings:
-
HIGH — Terminal identity failure prevents newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:1060still requires the identity snapshot before ordinary wallet changes can reach VSS. This run reproduced the existing finding: a new watch-only account reservation remained pending with remote WALLET bytes/version unchanged until identity repair. -
MEDIUM — Deferred profile migration can republish an identity after removal.
app/src/main/java/to/bitkit/repositories/PubkyRepo.kt:436starts enrichment after restore completes. Migration can retry an Identity error throughrefreshSessionIfPossible, whose delayed completion callscontinueSignInafter a normal wipe has cleared the identity and saved credentials. The profile-result guard and revision check after loading run too late to prevent that state change. A focused repository regression fails at this head; its service resources are mocked. Preserve the original sign-in/revision throughout the refresh. -
MEDIUM — Reset still waits for unbounded native authentication.
app/src/main/java/to/bitkit/data/backup/VssClientCoordinator.kt:138drains cancelled native work inNonCancellableon VSS0.5.26-rc2. The existing thread remains open; the installed-app result below repeats the held-auth boundary.
QA for cd0befc:
Built from cd0befc, on a disposable unfunded API 36 ARM64 regtest wallet: Paykit 0.1.1-rc7, Core 0.8.0-rc14, Spark 0.28.0, VSS 0.5.26-rc2 and staging VSS. Unpublished Debug controls stage persistence, credential and response faults and inspect readback. All eleven native libraries match the pristine head APK.
Recover the saved profile after an optional read fails ✅ A failed real profile read recovered on Home without delaying reconnect or receiving, checked in video and readback.
After successful required recovery (checked in readback/logs), a real optional profile read failed offline. Reconnect calls returned promptly, receiving succeeded while its successful response was held, and releasing it restored the saved name on Home without opening Profile. Sign-in generation stayed current, checked in readback/logs.
profile-reconnect-2x.2x.mp4
Home during optional profile recovery; timing and state checked in logs/readback.
Discard an ordinary profile response after sign-out ✅ Sign-out rejected the held ordinary response and kept identity/profile absent, checked in logs/readback.
The ordinary profile response was held after its real SDK read. Normal sign-out removed the identity and credentials; releasing the response was rejected by the current-sign-in guard and left identity/profile absent, checked in logs/readback.
profile-stale-2x.2x.mp4
Home during sign-out and release of the held ordinary profile response.
Resume a queued Spark backup after another restore ✅ Queued Spark work resumed after the second restore gate reopened, checked in logs/readback.
Closing restore during the queued Spark backup retained required work and cleared Running. Reopening restore rescheduled a real SPARK_EXIT PUT and acknowledgement without another generation event; remote payload digest changed and required/running cleared. Persistence and PUT count checked in logs/readback.
spark-restore-debounce-2x.2x.mp4
Data Backups during controlled Spark backup scheduling; persistence checked in logs/readback.
Replay Spark readiness after a cancelled status write ✅ Cancelled readiness replayed and acknowledged one upload after admission reopened, checked in logs/readback.
A real Spark reconnect reached the held required-status write. Failed identity replacement cancelled it without changing credentials; reopened admission replayed readiness, acknowledged one real SPARK_EXIT PUT and cleared required/running. Logs and CacheStore readback establish persistence.
spark-ready-2x.2x.mp4
Data Backups during controlled Spark backup scheduling; persistence checked in logs/readback.
Replay Spark exit changes after a cancelled status write ✅ The cancelled exit generation replayed and acknowledged one upload, checked in logs/readback.
A generation cancelled before status persistence replayed after the failed identity transition reopened the original owner. One real SPARK_EXIT PUT acknowledged and required/running cleared, checked in logs and CacheStore readback.
spark-generation-2x.2x.mp4
Data Backups during controlled Spark backup scheduling; persistence checked in logs/readback.
Keep Spark observation alive after a storage error ✅ A later generation uploaded after the injected storage error without restarting observation, checked in logs/readback.
An injected storage error failed one required-status write. A later generation was observed without restarting the collector, acknowledged one real SPARK_EXIT PUT and cleared required/running; checked in logs/readback.
spark-storage-2x.2x.mp4
Data Backups during controlled Spark backup scheduling; persistence checked in logs/readback.
Back up ordinary changes after terminal identity failure ❌ A newer ordinary account reservation stayed out of the remote WALLET until identity repair, checked in logs/readback.
With saved identity credentials made unusable, a new ordinary watch-only account reservation stayed pending and the real remote WALLET bytes/version remained unchanged. Restoring the original identity then permitted a complete upload. This reproduces the inherited HIGH; checked through real SDK/VSS and readback.
terminal-wallet-2x.2x.mp4
Home during the identity fault; backup contents checked by VSS readback.
Finish Reset while native authentication is held ❌ Reset remained blocked during the held native authentication response, checked in timed readback and video.
The real native auth response stayed held for about 230 seconds. After UI Reset closed admission, it remained wiping with credentials present and no retryable error for at least 204.5 seconds. After response release, the old lookup cancelled and Reset removed credentials and reopened admission 7.2 seconds later. Timing/state checked in logs/readback; consecutive recordings joined in order.
stalled-native-auth-2x.2x.mp4
Reset And Restore during the hold, then completion after release; consecutive recordings joined in order.
Keep an in-flight backup with its original wallet ✅ Held native upload retirement preserved the original credentials and isolated the replacement store, checked in logs/readback.
A real SETTINGS PUT was acknowledged upstream and its native response held while production wipe and second-phrase restore began. Admission closed and original credentials remained during drain. After release, only the old operation cancelled; the replacement uploaded and read GBP Settings in a distinct VSS namespace. Verified by proxy requests and credential/store readback.
inflight-owner-2x.2x.mp4
App during wallet replacement; request namespaces and credentials checked by readback.
Focused regression: deferred profile refresh after identity removal ❌ The ended public key was republished.
The repository test schedules enrichment, injects an Identity error at migration publication, and holds the refresh continuation after sign-in. wipeLocalState() completes with publicKey == null and both stored credentials absent. After release, the retry-publication barrier completes and the final null assertion fails with the old public key. This proves stale local identity publication; native credential resurrection and payment impact were not demonstrated. Fresh LocalSeed restore already requests current scopes, so a missing-scope failure is not claimed inevitable after phrase restore.
Pristine build and affected regression suites ✅ APK assembly and the affected unit suites passed.
assembleDevDebug and the complete BackupRepoTest, PubkyRepoTest and PaykitSdkServiceTest suites passed on unmodified head source. The separate added race regression above fails on that same production source. The full compile/test/lint gate was not repeated.
Coverage: contiguous delta acdd99c..cd0befc, its restore/profile/VSS integration, and inherited open findings. Device QA does not cover the imported move-to-Spark or BIP21/clipboard journeys, profile-editor UI or navigation transitions. No funded nonempty USDT import, unilateral Spark exit or real Ring-app interaction was driven.
Retained passing evidence: the acdd99c review proves unchanged Spark gate-clear and real ten-minute expiry wakeups. The 710292aa review proves the unchanged allowance admission, restored-payment pause and queued Settings cases. Paykit rc6→rc7 adds shared-profile APIs; its private-payment/allowance runtime and wire formats are unchanged in the release delta. Those are retained results at their recorded heads, rather than fresh executions here.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh at 0ad55417 shares deferred optional identity recovery and the terminal-WALLET finding. This imported Android shared-profile migration path has no matching implementation at that iOS pin. Source comparison only.
Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the terminal-WALLET failure remains open. The stale migration-refresh finding is fixed at this head.
Findings:
- HIGH — Terminal identity failure prevents newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:1060still requires a usable identity snapshot before capturing ordinary reservation state. The real SDK/VSS recheck reproduced the existing finding: a new watch-only account reservation stayed pending and remote WALLET bytes/version remained unchanged until identity repair. - MEDIUM — Reset still waits for unbounded native authentication.
app/src/main/java/to/bitkit/data/backup/VssClientCoordinator.kt:138still drains cancelled native work inNonCancellable, on VSS0.5.26-rc2. The existing thread remains open; the current installed-app result is below.
No new finding in this delta. The original PubkySignIn now follows migration/avatar/copy writes, and those writes serialize with identity replacement. The installed legacy-session refresh and same-key avatar cases below verify the repaired boundaries.
QA for cd1d129:
Built from cd1d129, on a disposable unfunded API 36 ARM64 regtest wallet with staging services. Paykit 0.1.1-rc8, bitkit-core 0.8.0-rc14, LDK 0.7.0-rc.80, Spark 0.28.0, VSS 0.5.26-rc2. Unpublished Debug controls stage legacy scopes, credential faults and response holds; service results are real. All eleven native libraries match the pristine head APK. Timing and hidden state are checked in logs/readback; the recordings show the app during each case.
Recover the saved profile after an optional read fails ✅ Home recovered the profile while reconnect and address creation continued, checked in video and logs/readback.
A real optional profile read failed offline. Reconnect calls returned under 1 ms while the real successful response was held, newAddress succeeded, and release restored QA restore on Home; sign-in generation stayed current. Timing and state checked in logs/readback.
profile-reconnect-2x.2x.mp4
App during the controlled profile reconnect case; hidden state checked in logs/readback.
Discard an ordinary profile response after sign-out ✅ The held response was rejected after sign-out, checked in logs/readback.
Normal sign-out completed while a real ordinary profile response was held. Release was rejected as stale; identity/profile and saved credentials remained absent, checked in logs/readback.
profile-stale-2x.2x.mp4
App during the controlled profile stale case; hidden state checked in logs/readback.
Drain profile migration refresh before identity removal ✅ Wipe drained the held real refresh and left identity/credentials absent, checked in logs/readback.
A real Paykit-only legacy session hit the migration Identity error and refreshed through real native signIn. Wipe started while that refresh was held, waited for release, then completed with identity/profile and saved credentials absent. Verified in logs/readback.
migration-refresh-2x.2x.mp4
App during the controlled migration refresh case; hidden state checked in logs/readback.
Reject an avatar migration after the sign-in changes ✅ The old avatar continuation was rejected after same-key replacement, checked in logs/readback.
A real Paykit-storage avatar download was held before publication. Replacing the sign-in with the same public key completed before release. The old avatar continuation was rejected by SignInChanged and did not publish stale state; checked in logs/readback.
avatar-replacement-2x.2x.mp4
App during the controlled avatar replacement case; hidden state checked in logs/readback.
Back up ordinary changes after terminal identity failure ❌ A newer ordinary reservation stayed out of WALLET until identity repair, checked by real VSS readback.
With saved identity credentials made unusable, a newer ordinary watch-only account reservation stayed pending and remote WALLET bytes/version remained unchanged. Repairing the original identity then allowed a complete upload. Real SDK/VSS; persistence checked in logs/readback.
terminal-wallet-2x.2x.mp4
App during the controlled terminal wallet case; hidden state checked in logs/readback.
Finish Reset while native authentication is held ❌ Reset stayed blocked while real authentication was held, checked in timed readback and video.
The real standalone LDK authentication response stayed held for 228.1 seconds. UI Reset remained wiping with credentials present, admission closed and no retryable error for at least 202.5 seconds. After release the old lookup cancelled, and Reset removed credentials and reopened admission 26.8 seconds later. Timing/state checked in logs/readback; consecutive recordings joined in order.
stalled-native-auth-2x.2x.mp4
App during the controlled stalled native auth case; hidden state checked in logs/readback.
Focused repository regressions and pristine build ✅ The affected suites and APK assembly passed.
assembleDevDebug and the complete PubkyRepoTest, PaykitSdkOperationLockTest and PaykitSdkServiceTest suites passed on unmodified head source. This includes refresh/wipe, same-key avatar, missing-profile setup/wipe and ordinary held-read controls. Repository tests use mocked service resources; the installed cases above use real native services. Full unit-suite and lint gates were not repeated.
Check: delta, dependency and twin scope ✅ Source comparison found no new defect.
Contiguous delta cd0befc..cd1d129, all six changed files and their reached restore/profile/wipe/SDK-admission paths were reviewed. The SDK lock now rechecks its admitted generation under the queue lock before acquisition/enqueue. The affected lock/service suites passed. That narrow pre-acquire interleaving was source-reviewed, without a dedicated interleaving test or device reproduction.
The Paykit release changes runtime TLS dependencies and release versions; private-payment/allowance implementation and wire formats are unchanged in the rc7→rc8 comparison. The LDK rc79→rc80 comparison updates upstream Lightning dependencies; funded LDK routing was not re-certified here.
Retained evidence at its recorded heads:
- The cd0befc report proves unchanged Spark interrupted-debounce, readiness/generation replay and storage-error recovery, plus a real held SETTINGS PUT during wipe and second-phrase replacement.
- The acdd99c report proves unchanged explicit restore-gate clear and real ten-minute expiry wakeups.
- The 710292aa report proves required wallet/USDT failure gates, post-restore payment pause, allowance admission/off-on behavior and queued Settings status. Those are retained results, not fresh executions in this round.
Coverage: this delta and inherited open findings. Current device cases exercise profile lifecycle, complete-backup refusal and native Reset; they do not repeat a full recovery-phrase restore or drive funded payments, nonempty USDT import, unilateral Spark exit, real Ring-app interaction, profile-editor navigation or imported clipboard journeys. The explicitly deferred allowance journey-documentation issue remains in bitkit-android#1766 docs: clarify delayed-contact coverage in the allowance toggle journey.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh at 0ad55417 shares deferred optional identity recovery and the terminal-WALLET finding. Its profile loader has no matching shared-profile migration implementation at that pin; this Android migration guard is platform-specific. Source comparison only.
Unpublished Debug controls
The controls hold actual responses, stage a Paykit-only legacy session and inspect state; they do not replace native results.
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 6532aa144..0c551c48e 100644
--- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
+++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
@@ -41,6 +41,7 @@ class DevToolsProvider : ContentProvider() {
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
check(Binder.getCallingUid() == SHELL_UID) { "Only ADB shell callers are allowed" }
+ if (method.startsWith("qa1686")) return kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) { Qa1686.run(requireNotNull(context), method, arg) } // test hook
return runCatching {
val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
ServiceQueue.LDK.blocking { command.execute(deps) }
diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt
index bd5fe8766..7a8a30f08 100644
--- a/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt
+++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt
@@ -57,7 +57,7 @@ class VssBackupClientLdk @Inject constructor(
storeId = vssDeriveStoreId(Env.vssStoreIdPrefix, mnemonic, passphrase),
mnemonic = mnemonic,
passphrase = passphrase,
- lnurlAuthServerUrl = Env.lnurlAuthServerUrl,
+ lnurlAuthServerUrl = if (to.bitkit.BuildConfig.DEBUG && java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-auth-override").exists()) "http://127.0.0.1:23469/lnurl_auth/auth" else Env.lnurlAuthServerUrl, // test hook
)
Logger.info("VSS LDK client setup for wallet[$walletIndex]", context = TAG)
}
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index e9f94c2cb..0c9ba9b67 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -930,6 +930,12 @@ class PubkyRepo @Inject constructor(
loadOwnProfile(signIn) ?: throw PubkyProfileNotFoundError()
}
}.onSuccess { (loadedProfile, revision) ->
+ if (to.bitkit.BuildConfig.DEBUG) { // test hook
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-profile") // test hook
+ if (gate.exists()) Logger.info("QA1686 profile response held", context = TAG) // test hook
+ while (gate.exists()) delay(200) // test hook
+ Logger.info("QA1686 profile released; current=${isCurrentLoad()}", context = TAG) // test hook
+ } // test hook
var isCurrent = false
initializeMutex.withLock {
_profile.update {
@@ -1554,6 +1560,12 @@ class PubkyRepo @Inject constructor(
): PubkyProfileSnapshot? = runSuspendCatching {
val publicKey = signIn.publicKey
val bytes = pubkyService.fetchFile(uri, PUBKY_APP_AVATAR_MAX_BYTES)
+ if (to.bitkit.BuildConfig.DEBUG) { // test hook
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-avatar") // test hook
+ if (gate.exists()) Logger.info("QA1686 avatar held after real download", context = TAG) // test hook
+ while (gate.exists()) delay(100) // test hook
+ Logger.info("QA1686 avatar released", context = TAG) // test hook
+ } // test hook
val contentType = when (uri.substringAfterLast('.').lowercase()) {
"png" -> "image/png"
"webp" -> "image/webp"
@@ -2324,6 +2336,12 @@ class PubkyRepo @Inject constructor(
val storedSecretKeyHex = activeSecretKeyHex() ?: return@withContext false
pubkyService.signIn(storedSecretKeyHex)
+ if (to.bitkit.BuildConfig.DEBUG) { // test hook
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-refresh") // test hook
+ if (gate.exists()) Logger.info("QA1686 refresh held after real signIn", context = TAG) // test hook
+ while (gate.exists()) delay(100) // test hook
+ Logger.info("QA1686 refresh released", context = TAG) // test hook
+ } // test hook
val publicKey = pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix()
notifyBackupStateChanged()
diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
index de8c204e7..c50c4ded0 100644
--- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
+++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
@@ -934,7 +934,7 @@ class PaykitSdkService @Inject constructor(
* Restoring a saved session still checks only the Paykit scope, so sessions from older builds keep working.
*/
fun requiredCapabilities(): String =
- "${paykitAuthorizerSessionCapabilities()},${pubkyAppProfileSessionCapabilities()}"
+ if (to.bitkit.BuildConfig.DEBUG && java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-legacy-scope").exists()) paykitAuthorizerSessionCapabilities() else "${paykitAuthorizerSessionCapabilities()},${pubkyAppProfileSessionCapabilities()}" // test hook
suspend fun syncPrivatePaymentListsWithReservations(
updates: List<PrivatePaymentListReservationUpdateInput>,
diff --git a/app/src/debug/java/to/bitkit/dev/Qa1686.kt b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
new file mode 100644
index 0000000..ee8c5ec
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,345 @@
+package to.bitkit.dev // test hook
+
+import android.content.Context // test hook
+import android.content.ClipData // test hook
+import android.content.ClipboardManager // test hook
+import android.os.Bundle // test hook
+import dagger.hilt.EntryPoint // test hook
+import dagger.hilt.InstallIn // test hook
+import dagger.hilt.android.EntryPointAccessors // test hook
+import dagger.hilt.components.SingletonComponent // test hook
+import kotlinx.coroutines.Dispatchers // test hook
+import kotlinx.coroutines.flow.first // test hook
+import kotlinx.coroutines.withContext // test hook
+import to.bitkit.data.CacheStore // test hook
+import to.bitkit.data.backup.VssBackupClient // test hook
+import to.bitkit.data.keychain.Keychain // test hook
+import to.bitkit.models.BackupCategory // test hook
+import to.bitkit.repositories.BackupRepo // test hook
+import to.bitkit.repositories.PubkyRepo // test hook
+import to.bitkit.repositories.WalletRepo // test hook
+import to.bitkit.repositories.LightningRepo // test hook
+import to.bitkit.services.PubkyService // test hook
+import to.bitkit.di.json // test hook
+import to.bitkit.models.WalletBackupV1 // test hook
+import to.bitkit.utils.Logger // test hook
+import java.io.File // test hook
+import kotlinx.coroutines.*
+import to.bitkit.models.*
+import to.bitkit.repositories.*
+import to.bitkit.services.*
+import kotlin.time.Clock
+
+object Qa1686 { // test hook
+ private var replacementPhrase: String? = null
+ private val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + Dispatchers.IO)
+ @EntryPoint @InstallIn(SingletonComponent::class) // test hook
+ interface Dependencies { // test hook
+ fun spark(): SparkRepo
+ fun sparkService(): SparkService
+ fun recovery(): to.bitkit.data.WalletConnectionRecovery
+ fun vssClients(): to.bitkit.data.backup.VssClientCoordinator
+ fun vssLdk(): to.bitkit.data.backup.VssBackupClientLdk
+ fun sdk(): to.bitkit.services.PaykitSdkService
+ fun allowanceExecutor(): to.bitkit.repositories.PaykitAllowanceExecutor
+ fun privatePaykit(): to.bitkit.repositories.PrivatePaykitRepo
+ fun allowances(): to.bitkit.repositories.PaykitAllowanceRepo
+ fun allowanceStore(): to.bitkit.repositories.PaykitAllowanceStore
+ fun keychain(): Keychain // test hook
+ fun backupRepo(): BackupRepo // test hook
+ fun pubkyRepo(): PubkyRepo // test hook
+ fun pubkyService(): PubkyService // test hook
+ fun vss(): VssBackupClient // test hook
+ fun cache(): CacheStore // test hook
+ fun wallet(): WalletRepo // test hook
+ fun accounts(): to.bitkit.data.WatchOnlyAccountStore
+ fun settings(): to.bitkit.data.SettingsStore
+ fun lightning(): LightningRepo // test hook
+ } // test hook
+ suspend fun run(context: Context, method: String, arg: String?): Bundle { // test hook
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java) // test hook
+ fun file(name: String) = File(context.filesDir, "qa1686-$name")
+ val result = kotlin.runCatching { // test hook
+ when (method) { "qa1686PrepareLocal" -> {
+ d.backupRepo().stopObservingBackups()
+ d.settings().update { it.copy(isDevModeEnabled = true) }
+ d.spark().start(0).getOrThrow()
+ "prepared disposable regtest wallet and real Spark service"
+ }
+ "qa1686ProfileStatus" -> {
+ "identity=${d.pubkyRepo().publicKey.value != null}; authenticated=${d.pubkyRepo().isAuthenticated.value}; profile=${d.pubkyRepo().profile.value?.name}; loading=${d.pubkyRepo().isLoadingProfile.value}"
+ }
+ "qa1686Dev" -> { d.settings().update { it.copy(isDevModeEnabled = true) }; "enabled developer mode" }
+ "qa1686NativeAuth" -> {
+ d.backupRepo().stopObservingBackups()
+ d.vssClients().withSuspendedClients {}
+ file("auth-override").writeText("hold")
+ d.vssLdk().setup().getOrThrow()
+ file("native-result").delete()
+ scope.launch {
+ try {
+ val result = d.vssLdk().getObject("qa1686-held-auth")
+ file("native-result").writeText("returned success=${result.isSuccess}; error=${result.exceptionOrNull()?.javaClass?.simpleName}")
+ } catch(e: Exception) { file("native-result").writeText("cancelled=${e.javaClass.simpleName}") }
+ }
+ "scheduled actual standalone LDK lookup against held auth response"
+ }
+ "qa1686NativeState" -> "credentialsPresent=${d.keychain().exists(Keychain.Key.BIP39_MNEMONIC.name)}; wiping=${d.backupRepo().isWiping.value}; admissionOpen=${runCatching { d.vssClients().captureOwner() }.isSuccess}; result=${file("native-result").takeIf { it.exists() }?.readText()}"
+ "qa1686StageProfile" -> {
+ d.settings().setPubkyProfileSetupPending(arg == "pending")
+ val field = d.pubkyRepo().javaClass.getDeclaredField("_profile").apply { isAccessible = true }
+ @Suppress("UNCHECKED_CAST")
+ (field.get(d.pubkyRepo()) as kotlinx.coroutines.flow.MutableStateFlow<PubkyProfile?>).value = null
+ file("sign-in").writeText(requireNotNull(d.pubkyRepo().currentSignIn()).generation.toString())
+ "cleared local profile; pending=${d.settings().isPubkyProfileSetupPending.first()}"
+ }
+ "qa1686RetryProfile" -> {
+ val started = kotlin.time.TimeSource.Monotonic.markNow()
+ repeat(3) { d.pubkyRepo().restoreSessionIfNeeded() }
+ "three reconnect calls returned in ${started.elapsedNow()}; current=${d.pubkyRepo().currentSignIn()?.generation.toString() == file("sign-in").readText()}"
+ }
+ "qa1686CompleteSetup" -> {
+ d.settings().setPubkyProfileSetupPending(false)
+ "cleared local setup pending flag"
+ }
+ "qa1686RestoreSaved" -> { d.pubkyRepo().initialize().getOrThrow(); "restored saved identity" }
+ "qa1686RejectedBackup" -> {
+ d.sdk().processPendingPrivateMessages()
+ val field = d.sdk().javaClass.getDeclaredField("cachedBackupState").apply { isAccessible = true }
+ val beforeCache = field.get(d.sdk())
+ check(beforeCache != null)
+ val before = d.sdk().backupStateVersion.value
+ var refused = false
+ try {
+ d.sdk().acceptAllowance(requireNotNull(d.pubkyRepo().publicKey.value).removePrefix("pubky"), "qa-no-native-call", 1uL) {
+ throw PaykitAllowanceError.Unavailable
+ }
+ } catch (error: PaykitAllowanceError.Unavailable) { refused = true }
+ check(refused)
+ check(d.sdk().backupStateVersion.value == before)
+ check(field.get(d.sdk()) === beforeCache)
+ "real SDK service refused admission; version unchanged=true; cached snapshot same=true"
+ }
+ "qa1686Stop" -> {
+ d.backupRepo().stopObservingBackups()
+ kotlinx.coroutines.delay(300)
+ val statuses = d.cache().backupStatuses.first()
+ "stopped; settings=${statuses[BackupCategory.SETTINGS]}"
+ }
+ "qa1686Start" -> {
+ d.backupRepo().startObservingBackups()
+ "started observers"
+ }
+ "qa1686Status" -> {
+ "settings=${d.cache().backupStatuses.first()[BackupCategory.SETTINGS]}; pending=${d.backupRepo().hasPendingWalletRestore()}; electrum=${d.settings().data.first().electrumServer}"
+ }
+ "qa1686ArmRestore" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value)
+ val old = d.cache().data.first().onchainAddress
+ File(context.filesDir, "qa1686-old-address").writeText(old)
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString())
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString())
+ "armed real required restore with original complete remote WALLET; saved old address=${old.isNotBlank()}"
+ }
+ "qa1686CheckRestore" -> {
+ check(!d.backupRepo().hasPendingWalletRestore())
+ val old = File(context.filesDir, "qa1686-old-address").readText()
+ val next = d.wallet().newAddress().getOrThrow()
+ check(old.isBlank() || next != old)
+ "required markers clear; new ordinary address succeeds; old metadata address excluded=${old.isNotBlank()}"
+ }
+ "qa1686PrepareProvider" -> {
+ File(context.filesDir, "qa1686-provider-secret").writeText(requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name)))
+ File(context.filesDir, "qa1686-provider-pubky").writeText(requireNotNull(d.pubkyRepo().publicKey.value).removePrefix("pubky"))
+ "staged disposable provider credential files"
+ }
+ "qa1686InitCore" -> { // test hook
+ to.bitkit.async.ServiceQueue.CORE.background { // test hook
+ com.synonym.bitkitcore.initDb(to.bitkit.env.Env.bitkitCoreStoragePath(0)) // test hook
+ com.synonym.bitkitcore.updateBlocktankUrl(to.bitkit.env.Env.blocktankApiUrl) // test hook
+ } // test hook
+ "initialized real native database after app storage path became available" // test hook
+ } // test hook
+ "qa1686QuickState" -> {
+ "source=${d.keychain().exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)}, savedSecret=${!d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()}, savedSession=${!d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrBlank()}, identity=${d.pubkyRepo().publicKey.value != null}, authenticated=${d.pubkyRepo().isAuthenticated.value}, profile=${d.pubkyRepo().profile.value?.name}, contacts=${d.pubkyRepo().contacts.value.size}, pendingRestore=${d.backupRepo().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}"
+ }
+ "qa1686CreateProfile" -> {
+ d.pubkyRepo().createIdentity("QA restore", "", emptyList(), emptyList(), null).getOrThrow()
+ "created disposable profile"
+ }
+ "qa1686Backup" -> {
+ for (c in listOf(BackupCategory.WALLET, BackupCategory.METADATA, BackupCategory.SETTINGS)) d.backupRepo().triggerBackup(c).getOrThrow()
+ val wallet = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val metadata = requireNotNull(d.vss().getObject("METADATA").getOrThrow())
+ val pending = d.cache().backupStatuses.first().filterKeys { it in listOf(BackupCategory.WALLET, BackupCategory.METADATA) }.any { it.value.isRequired }
+ check(!pending)
+ "backup synchronized; sdk=${json.decodeFromString<WalletBackupV1>(wallet.value.decodeToString()).paykitSdkBackupState != null}; metadataBytes=${metadata.value.size}"
+ }
+ "qa1686UsdtFault" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value)
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, "invalid-json")
+ "staged malformed retained USDT recovery payload"
+ }
+ "qa1686Terminal" -> {
+ d.backupRepo().stopObservingBackups()
+ val secret = requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name))
+ File(context.filesDir, "qa1686-original-secret").writeText(secret)
+ val session = requireNotNull(d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name))
+ File(context.filesDir, "qa1686-original-session").writeText(session)
+ d.pubkyService().forgetSessionAccess()
+ d.keychain().delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ d.keychain().upsertString(Keychain.Key.PAYKIT_SESSION.name, "qa1686-invalid-session")
+ d.pubkyRepo().initialize().getOrThrow()
+ check(d.pubkyRepo().publicKey.value == null)
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val reserved = d.accounts().reserveAccountIndex(0, "qa1686-terminal-ordinary")
+ check(d.accounts().backupSnapshot().allocationState.highestAccountIndexByWallet["0"] == reserved)
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET)
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val same = before.value.contentEquals(after.value) && before.version == after.version
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true
+ check(failed.isFailure && same && pending)
+ "terminal=true; ordinary watch-only index=$reserved reserved; failed=true; remote unchanged=true; pending=true; error=${failed.exceptionOrNull()?.javaClass?.simpleName}"
+ }
+ "qa1686RepairIdentity" -> {
+ d.keychain().upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, File(context.filesDir, "qa1686-original-secret").readText())
+ d.keychain().upsertString(Keychain.Key.PAYKIT_SESSION.name, File(context.filesDir, "qa1686-original-session").readText())
+ d.pubkyRepo().initialize().getOrThrow()
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow()
+ "recovered original identity and complete WALLET upload"
+ }
+ "qa1686Capture" -> { // test hook
+ File(context.filesDir, "qa1686-mnemonic").writeText(requireNotNull(d.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name))) // test hook
+ for (c in listOf(BackupCategory.WALLET, BackupCategory.METADATA, BackupCategory.SETTINGS)) d.backupRepo().triggerBackup(c).getOrThrow() // test hook
+ check(d.vss().getObject("WALLET").getOrThrow() != null) // test hook
+ "captured complete backup and test credentials" // test hook
+ } // test hook
+ "qa1686Clipboard" -> { // test hook
+ withContext(Dispatchers.Main) { // test hook
+ context.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("test seed", File(context.filesDir, "qa1686-mnemonic").readText())) // test hook
+ } // test hook
+ "copied test credentials" // test hook
+ } // test hook
+ "qa1686BackupFault" -> { // test hook
+ d.backupRepo().stopObservingBackups() // test hook
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val secret = requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) // test hook
+ d.pubkyService().forgetSessionAccess() // test hook
+ check(d.pubkyService().currentPublicKey() == null) // test hook
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET) // test hook
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true // test hook
+ val same = before.value.contentEquals(after.value) && before.version == after.version // test hook
+ Logger.info("QA1686 unavailable identity: failed=${failed.isFailure}, unchanged=$same, pending=$pending", context="TestHook") // test hook
+ check(failed.isFailure && same && pending) // test hook
+ d.pubkyService().signIn(secret) // test hook
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ val fresh = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val complete = json.decodeFromString<WalletBackupV1>(fresh.value.decodeToString()).paykitSdkBackupState != null // test hook
+ val synced = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == false // test hook
+ check(complete && synced) // test hook
+ d.backupRepo().startObservingBackups() // test hook
+ "backup protection and recovered export passed" // test hook
+ } // test hook
+ "qa1686Recover" -> { // test hook
+ val secret = d.pubkyService().deriveSecretKey(requireNotNull(d.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name))) // test hook
+ d.keychain().upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secret) // test hook
+ d.pubkyService().initialize() // test hook
+ d.pubkyRepo().restoreSessionBackupState(to.bitkit.models.PubkySessionBackupV1(kind = to.bitkit.models.PubkySessionBackupKind.LocalSeed)).getOrThrow() // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ d.backupRepo().startObservingBackups() // test hook
+ "recovered identity and fresh complete backup" // test hook
+ } // test hook
+ "qa1686Refresh" -> { // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ "scheduled optional enrichment" // test hook
+ } // test hook
+ "qa1686RequiredFault" -> { // test hook
+ d.backupRepo().stopObservingBackups() // test hook
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value) // test hook
+ File(context.filesDir, "qa1686-corrupt-wallet").writeText("hold")
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString()) // test hook
+ "staged malformed required WALLET decoder input; retained original backup" // test hook
+ } // test hook
+ "qa1686RepairUsdt" -> {
+ check(!d.keychain().exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name))
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, file("valid-wallet").readText())
+ "repaired only retained USDT envelope; no WALLET marker introduced"
+ }
+ "qa1686RepairRequired" -> { // test hook
+ File(context.filesDir, "qa1686-corrupt-wallet").delete()
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ if (d.keychain().exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)) { // test hook
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ } // test hook
+ "repaired original required backup" // test hook
+ } // test hook
+ "qa1686SpendingGate" -> { // test hook
+ check(d.backupRepo().hasPendingWalletRestore()) // test hook
+ val result = d.lightning().payInvoice("qa1686-invalid-invoice") // test hook
+ check(result.exceptionOrNull()?.javaClass?.simpleName == "WalletBackupRestorePendingError") // test hook
+ "payment admission blocked by required restore before invoice parsing" // test hook
+ } // test hook
+ "qa1686AddressGate" -> { // test hook
+ val result = d.wallet().newAddress() // test hook
+ "new address success=${result.isSuccess}; error=${result.exceptionOrNull()?.javaClass?.simpleName}" // test hook
+ } // test hook
+ "qa1686SignOut" -> { // test hook
+ d.pubkyRepo().signOut().getOrThrow() // test hook
+ "identity removed" // test hook
+ } // test hook
+ "qa1686StageMigration" -> {
+ d.backupRepo().stopObservingBackups()
+ val pub = requireNotNull(d.pubkyRepo().publicKey.value)
+ val remote = d.pubkyService().fetchPubkyProfileWithRevision(pub)
+ remote?.let { d.pubkyService().deletePubkyProfile(it.revision, pub) }
+ val record = requireNotNull(d.pubkyService().fetchPaykitProfile(pub))
+ val avatar = if (arg == "avatar") {
+ val png = android.util.Base64.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=",0)
+ d.pubkyService().uploadProfileAvatar(png, "image/png")
+ } else null
+ d.pubkyService().publishPaykitProfile(record.profile.copy(imageUri = avatar), pub)
+ val storeField = d.pubkyRepo().javaClass.getDeclaredField("pubkyStore").apply { isAccessible = true }
+ val store = storeField.get(d.pubkyRepo()) as to.bitkit.data.PubkyStore
+ store.update { it.copy(pubkyAppProfileOwners = it.pubkyAppProfileOwners - pub, pubkyAppAvatarPending = it.pubkyAppAvatarPending - pub) }
+ if (arg != "avatar") {
+ file("legacy-scope").writeText("on")
+ try { d.pubkyService().signIn(requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name))) }
+ finally { file("legacy-scope").delete() }
+ }
+ val field = d.pubkyRepo().javaClass.getDeclaredField("_profile").apply { isAccessible = true }
+ @Suppress("UNCHECKED_CAST")
+ (field.get(d.pubkyRepo()) as kotlinx.coroutines.flow.MutableStateFlow<PubkyProfile?>).value = null
+ file("sign-in").writeText(requireNotNull(d.pubkyRepo().currentSignIn()).generation.toString())
+ d.settings().setPubkyProfileSetupPending(false)
+ "prepared real legacy Paykit profile; avatar=${avatar != null}; current=true"
+ }
+ "qa1686LoadAsync" -> {
+ scope.launch { d.pubkyRepo().scheduleInitializationAfterRestore() }
+ "scheduled actual deferred enrichment"
+ }
+ "qa1686WipeAsync" -> {
+ file("wipe-result").delete()
+ scope.launch { d.pubkyRepo().wipeLocalState(); file("wipe-result").writeText("complete") }
+ "started normal identity wipe"
+ }
+ "qa1686LifecycleState" -> {
+ "identity=${d.pubkyRepo().publicKey.value != null}; profile=${d.pubkyRepo().profile.value?.name}; savedSecret=${d.keychain().exists(Keychain.Key.PUBKY_SECRET_KEY.name)}; savedSession=${d.keychain().exists(Keychain.Key.PAYKIT_SESSION.name)}; wipe=${file("wipe-result").takeIf { it.exists() }?.readText()}; current=${d.pubkyRepo().currentSignIn()?.generation.toString() == file("sign-in").takeIf { it.exists() }?.readText()}"
+ }
+ "qa1686ReplaceSameKey" -> {
+ d.pubkyRepo().restoreSessionBackupState(PubkySessionBackupV1(kind = PubkySessionBackupKind.LocalSeed)).getOrThrow()
+ "restored same key; original current=${d.pubkyRepo().currentSignIn()?.generation.toString() == file("sign-in").readText()}"
+ }
+ else -> "source=${d.keychain().exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)}, savedSecret=${!d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()}, savedSession=${!d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrBlank()}, sdk=${d.pubkyService().currentPublicKey() != null}, identity=${d.pubkyRepo().publicKey.value != null}, profile=${d.pubkyRepo().profile.value?.name}, contacts=${d.pubkyRepo().contacts.value.size}, pendingRestore=${d.backupRepo().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}" // test hook
+ } // test hook
+ }.fold({ it }, { "ERROR: ${it.javaClass.simpleName}: ${it.message}" }) // test hook
+ Logger.info("QA1686 $method: $result", context="TestHook") // test hook
+ return Bundle().apply { putString("result", result) } // test hook
+ } // test hook
+} // test hook
Reviewed by gpt-6.1-sol-high
November 11 · keep deferred profile work with its original sign-in
At
cd1d1290, deferred profile refresh, migration and avatar writes retain the original sign-in. Wipe or identity replacement can invalidate that work before it mutates storage or publishes profile state. SDK admission also rechecks the original generation before granting or queuing a call. Three controls first reproduced the stale-write paths; the four affected lifecycle controls now pass. Ordinary remote reads remain outside the identity-write lock.Final checks on tree
91ce475c, composed with master14e6d3dd, passed: formatting, compilation, full lint, APK assembly, 389 focused tests and 5,399 full-suite tests. Two independent source reviews cover the correction. All 27 commits against that live base verify locally and on GitHub.The first original CI, Lint and E2E runs passed on synthetic
dc9fb411, whose tree exactly matches local validation: 5,399 unit tests, 256 Compose cases and 39 E2E passes.settings_08remains intentionally disabled; six migration jobs were skipped. All workflows and seven E2E shard ledgers stayed on attempt one. Complete available diagnostics were inspected, including the 43 helper/stale-element recovery notices and inherited warnings.The hosted
transfer_2run recorded a peer closing-fee disagreement followed by force-close and commitment broadcast. Its UI-success and connection-removal assertions passed; they do not prove cooperative closure or confirmed returned funds. The close path and LDK pin equal base, but this event has no base-runtime reproduction. A Legacy-account sync timeout was followed by primary-wallet sync completion without an explicit Legacy acknowledgement. Three metadata-exclusion refusals had later success acknowledgements in the same app process. These limits remain recorded; generic CI does not certify authenticated wallet restore/reset or the held SDK work.The reproduced deferred-refresh finding has a verified fix reply. Existing human requests remain pending. Earlier failures and their exact input revisions follow below.
November 11 · resume Spark backups when restore ends
At
cd0befc6, pending Spark backup state is reconsidered when the existing restore gate clears or expires. A second restore that interrupts the debounce also retains the pending work. Three repository controls reproduced these gaps before the correction; acknowledged generations still do not upload twice.The first
acdd99c3hosted composition with master831c4d2aexposed five obsolete Pubky test fixtures. They now use the upstream own-profile revision API; no production behavior or assertions were removed for that correction. The original five failures remain recorded. Final local format, compile, lint and APK assembly passed, with 5,396 full-suite and 255 focused tests on tree79ef817eusing Paykit rc7. Two independent affected reviews are clean; all 26 live-base commits were verified locally and on GitHub.The first
cd0befc6hosted checks used syntheticd5d49a33, tree2332eb13, against master8e86d30dwith the separate upstream rc8 pin. All 5,396 JVM tests, including the five former failures, and 256 Compose cases passed. Lint reported zero findings. Seven E2E shards passed 39 cases on internal attempt one using suite53e8b45b;@settings_08was disabled and six migration jobs were skipped by their branch rule. Complete diagnostics retain 45 stale-element/helper recovery lines, a caught optional permission-button timeout, two native sync timeouts followed by same-process successes, and a legacy-listing timeout before a successful full VSS restore. These are qualified results, not a claim of clean runtime or full recovery acceptance. CI · Lint · E2EThe external reviewer reported two controlled installed-app passes on the earlier
acdd99c3: one upload after explicit restore completion and one after real ten-minute expiry. Those results belong to that tested revision and reviewer; their recordings/readbacks have not been independently qualified here. Interrupted debounce, storage-error timing and replacement during a native PUT remain distinct fixture gaps. The existing complete-WALLET and unbounded native-authentication findings remain open, including the recorded disposition. No SDK release/adoption or reset acceptance is implied.The required child compositions are published separately at #1688
dc9a9813and #1689e0d25982; their first hosted checks are in progress.November 11 · Spark backup notifications through identity retirement
At
710292aa, the additive composition with master4f401bd3preserves Spark startup, payment history and backup behavior alongside this PR’s original-wallet admission. A Spark notification is acknowledged only after its required status is stored. Cancellation before that write can replay after the original owner reopens; a storage error no longer stops later notifications. Three controls reproduced the two cancelled-write cases and the stopped collector before the fixes.Final format, compile, full unit tests, lint and APK assembly passed on one frozen tree: 5,334 unit tests, 371 focused tests and zero static findings. Two independent scoped reviews found no unresolved issue. All 24 live-base commits verify locally and on GitHub.
First hosted checks passed 5,334 JVM tests, 256 Compose cases and 39 E2E cases. One Settings case and five migration jobs were explicitly skipped. All seven E2E shards passed their first internal attempt on suite
53e8b45b; 36 stale-element messages remain recorded. Four metadata guards each had a later acknowledgement in the same process. Full logs and ten diagnostic archives were inspected, including fixture database/startup, network, fee, channel-closing and dependency advisories. CI · Lint · E2EThese controls do not establish a new authenticated Spark recovery journey, terminal complete-WALLET recovery or native VSS reset acceptance. Existing open findings, SDK decisions and fixture limits remain. #1688’s direct-child projection is clean with the same feature patch; #1689’s conflicting composition is being validated separately.
November 9 · activity backups on
3e1fdb7dAn observed ACTIVITY backup skips an unchanged payload only after a successful write for the same wallet owner. A full-backup request received while that read is in progress still sends the activity data. An uncertain PUT clears the cached digest. Restore and reset detach the old cache. VSS admission and shutdown stay with the original coordinator.
The regression test paused an unchanged read and requested a full backup before releasing it. The initial composition sent only the seed upload. The corrected tree sent the requested second PUT. Local checks passed all 125 focused tests and 4,993 full-suite tests, with no skips or failures. The six lint and Detekt reports contained no findings. All 23 commits verified against master
596f46cd. The published VSS dependency stays at 0.5.26-rc1.The first hosted run passed 4,993 JVM tests and 256 Compose tests. All seven E2E shards passed on their first workflow test attempt, with 39 cases passing and one explicitly disabled. Six migration jobs were skipped. Hosted lint and Detekt reported zero findings. The backup/wipe logs retained missing restored entries and a missing Pubky session during endpoint cleanup. Authenticated cleanup and complete WALLET recovery remain open. CI · E2E · Lint
November 6 validation at
b5d5858dThis branch now includes the upstream send-flow tests and keeps the allowance-admission regression. All 600 cases from the two parents ran locally. The full local suite passed 4,967 tests, lint reported zero findings, and all 22 commits in the live-master range verified locally and on GitHub.
GitHub tested merge tree
b1ad9c4eagainst masterebe9bc09. All 4,969 unit tests and 256 Compose cases passed. Six lint reports contained zero findings. The seven regtest E2E shards passed 39 cases on their first attempt. Settings address-type switching was disabled in the suite, and the release-branch migration jobs skipped. Hosted logs retain helper and runtime warnings. This run adds no authenticated Pubky/Paykit journey or terminal wallet-recovery result. CI · Lint · E2ECurrent backup-status correction — November 4
Signed fix
a2c58f2427635719b63263b9441b8d22a7b6c858prevents cancelled backup cleanup from inheriting Running from a replacement reservation that never entered its original wallet. Each pending reservation now records actual native admission and is removed by exact identity. Retirement drains admitted cleanup; rejected queued work cannot leave a stale Running flag. Startup status cleanup uses the same distinction. This is a two-file correction with no new public API or persisted state.Final formatting, compilation, 4,890 unit tests across 264 suites, lint and APK assembly passed with zero failures/errors/skips and zero static findings. All 1,797 frozen inputs match, complete diagnostics were inspected, and 1,947 sealed evidence files were verified. The single added regression covers retirement before and after the cancelled writer's status cleanup; it fails on the published predecessor and passes on this fix. Two affected independent source reviews are clean. All 21 live-master range commits verify locally and on GitHub.
Author J3: the frozen final ARM64 APK (
317136cc1b1385d9776f5edba2c22d4a25a5dd56109045595d1ddaf19b70609e) was installed in place on the preserved unfunded API36 ARM emulator, and installed bytes were verified. Changing EUR to GBP in the actual app showed Settings Running during its five-second queue, then Latest Backup after the VSS acknowledgement. No Settings Retry was observed. The original wallet and fixtures were preserved. This runtime case is distinct from the controlled unit retirement race; no wallet reset or complete-WALLET acceptance is claimed.The separate brief Transaction Log Failed Backup/Retry flash remains documented. Capture limitations and native/runtime diagnostics remain in the audit. The first CLI emulator launch exited after reporting boot; the single foreground SDK recovery ran the case. The emulator later accepted shutdown but exited134 after saving its snapshot. These observations are preserved and are not described as warning-free execution.
Current-head hosted CI, Lint and E2E all passed on attempt 1: 4,890 units, 256 Compose cases and 39 E2E passes, zero failures, one explicitly disabled Settings skip, and zero findings in six static reports. Six release-gated migration jobs were skipped. The synthetic checkout tree exactly matches
a2c58f24; complete logs, nested diagnostics and all 1,531 sealed evidence files were inspected and verified. APK identity is bound by producer/consumer artifact ID and digest; hosted APK bytes were not independently downloaded. Three RNBackupClient list-file timeouts and one ActivityRepo cancellation are newly observed runtime categories with unchanged base emitters; their cause remains unproven. Earlier cursor/native/bootstrap warnings and the temporary nonterminal GitHub status discrepancy are retained. This does not establish terminal complete-WALLET or new authenticated J1/J2 acceptance. Evidence:work/validation/hosted-android1686-a2c5-20261104/inspection/report.md. On predecessorb93444c3, the sole authorized Settings infrastructure retry completed with 13 passes and one explicit skip; the final unique E2E result was 39 passes/one skip, alongside 4,889 units, 256 Compose passes and zero lint findings. The original Docker port collision, runtime diagnostics and six skipped migration jobs remain preserved; that result is not transferred to this new head.Evidence:
work/validation/android1686-admitted-status-final-audit-20261104/,work/validation/android1686-admitted-status-app-audit-20261104/andwork/validation/android1686-admitted-status-20261104/publication.json. Existing QA IDs, remaining authenticated cases and terminal complete-WALLET limitations below remain unchanged.Additional external review: 5773366561 reports 10 passing cases and one failure at this exact
a2c58f2head on an unfunded API35 fixture. It confirms queued/cancelled Settings status and recovery gates, while reproducing the existing terminal-identity complete-WALLET failure:identity_errorleaves new ordinary-wallet work pending and preserves remote bytes/version. The reviewer distinguishes pristine app runs from Debug fault controls and decoder checks from nonempty USDT import. Raw install/proxy/readback artifacts were not independently inspected by the author. This is attributed external evidence, not another author-run campaign or human approval; the existing HIGH remains open.Queued backup status — November 4
b93444c35fixes 4467512070: a scheduled backup now shows Running during debounce and payment wait instead of falsely displaying Failed/Retry. Status cleanup stays inside the original wallet admission, preserves another active/pending backup, and checks startup cleanup against current state inside the persisted update. This does not change backup contents or relax complete-WALLET refusal.Final required compile, full tests, lint, formatting and DevDebug APK assembly passed on 1,797 matching inputs: 4,889 tests /264 suites, zero failures/errors/skips, and eight fresh static reports with zero findings. Two independent affected concurrency reviews are clean. The negative control retained three expected failures on the original production code; the intermediate compile/format failures remain recorded. Complete logs and structured diagnostics retain the verified existing cursor/JVM/deprecation/native-strip notices. All 20 commits against live master
44bf50ecverify locally and on GitHub (verified=true,reason=valid); new-head hosted CI is pending complete inspection.Author-run J3 used a new unfunded API36 ARM64 emulator, the installed APK SHA256
6646ff3bfcc7bc27dbb59e4e5c71d94b439c6c0a753f8594be520a481f7196e6, and ordinary staging VSS/Electrum. Changing local currency showed Settings Running without Retry, then Latest Backup after the real SETTINGS put acknowledgement. Continuous video and complete diagnostics are retained; the first delayed still capture missed the interval and is not counted as a pass. Media offsets are not wall-clock measurements. No profile, recovery phrase, wallet reset or funds were used. This finite result does not complete J1/J2 or the terminal complete-WALLET finding. The complete recording also retains two brief Transaction Log Failed/Retry frames between ordinary updates and scheduling, with no ACTIVITY upload failure logged. The required-before-running state is representable in live master44bf50ectoo; the exact runtime interleaving is inferred, not reproduced on that base. This separate pre-scheduling presentation gap remains, so no all-category/no-flash claim is made.Evidence:
work/validation/android1686-backup-status-final-audit-20261104/report.mdandwork/validation/android1686-backup-status-app-audit-20261104/report.md. Earlier results below remain tied to their stated revisions.VSS operation ownership — November 4
Signed
4056d2c00keeps app and standalone LDK backup work with its original wallet generation. Identity replacement closes both client admissions, cancels and drains setup/native calls and their caller tails, shuts down the old slots, then holds admission closed through credential changes. Each client derives its store ID from the same captured mnemonic/passphrase as its signer. Setup and ordinary calls retain their existing concurrency and readiness behavior.Backup payload capture, retries, status callbacks, routing repairs and detached node restarts retain the original owner. A canceled identity transition can rearm observer setup and pending backups; stale scheduling cannot replace a newer generation's debounce token. Explicit first-WALLET scheduling still works before node observation starts. Shutdown failure leaves admission closed. The released SDK and server protocol are unchanged.
Required final
just compile, fulljust testandjust lintpassed on the same 1,796 frozen inputs: 4,887 tests /264 suites, zero failures/errors/skips, with fresh Detekt and Android Lint SARIF/XML showing zero findings. Final formatting is clean; a subsequent local flag rename has exact reverse-hash equivalence and was checked by final lint. Complete logs, test reports, embedded Gradle diagnostics and all earlier failures were inspected. Four existing cursor-close traces and three JVM notices remain recorded against actual-base sources; no introduced compiler/static finding remains. Two independent affected concurrency/security source reviews are clean. All 19 commits against live master44bf50ecverify locally and on GitHub. Evidence:work/validation/android-vss-owner-diagnostics-20261104/report.md.At
4056d2c0036e5a4e51698e26b3d1615825dd77f5, hosted CI attempt 2 passed after the one authorized Compose retry. Compose executed 256 tests across 60 classes, zero failures and zero skips; XML, all HTML case identities, raw instrumentation and both binary UTP result files agree. The required build aggregate passed. Existing exact-tree evidence remains 4,887 unit passes, zero Detekt/Android Lint findings, and 39 general E2E passes, zero failures, one source-declared skip. Migration jobs were release-gated skips. The checkout was synthetic merge3c8fc2745652d6eb1f624ff537ec35cbe0702faawith the same tree32b8d4633b0f12fc1b9ee7d94007824c766addbdas the published head, against master44bf50ecabefe4e91d41fedbcbbb14fb132bbebc.The authorized Compose retry is now fully audited: Compose job, aggregate, and test artifact. All 256 cases passed with no skips. The original attempt's
No service published for: inputstartup failure remains preserved as a before-tests infrastructure failure. Complete logs and all 356 artifact members were inspected; check annotations contain only runner-image notices. Repeated fixture Core/database/Paykit bootstrap errors and Android resource warnings remain visible, and the non-uploaded Gradle problems HTML limits deprecation attribution. This does not establish running-node persistence quiescence, deployed migration or complete-WALLET recovery. No retry authority remains. Owner verification matched all 447 new evidence hashes and reused the sealed 475-file original audit. Evidence:work/validation/android1686-authorized-compose-retry-20261104/inspection/report.md. Existing authenticated QA and terminal complete-WALLET gaps remain open.Current composition — November 3
Signed additive merge
43e0f3fe04705d397a81f493cf8e57239a0be73eincorporates live master44bf50ecabefe4e91d41fedbcbbb14fb132bbebc. Both restore branches preserve deferred identity initialization while forwarding master’s failed optional backup categories to the completion screen. Existing required-restore, correction and cancellation gates remain in place. No published history was rewritten.Final
just format,just compile,just testandjust lintpassed on the same 1,794 inputs: 4,873 unit tests across 263 suites, zero failures or skips; Detekt and Android Lint report zero findings. Complete logs, XML, SARIF, HTML and embedded Gradle reports were audited, and the affected independent source review is clean. All 18 live-base commits are locally and GitHub signature-verified.Two test-fixture repairs were necessary after composition: supply master’s new ledger-state Boolean in the mocked allowance executor, and use a plain Application in the mocked view-model suite. An earlier run stalled with 64 IO workers blocked by production auth registrars started by that test fixture. Its complete thread dump, 695 passing results, one interrupted case and stopped-worker failure are retained. The final run used a different test order; it is not a same-order starvation reproduction or a production dispatcher fix. The initial formatting finding and pre-execution sandbox failure are also retained. Existing Gradle deprecation and JVM/cursor diagnostics remain disclosed in the audit.
All current-head hosted runs are complete and inspected: 4,873 unit tests, 256 Compose tests and 39 external E2E cases passed; Detekt and Android Lint have zero findings. The seven E2E shards passed their first whole-shard attempts; one Settings case, six migration jobs and all 14 retry steps were explicitly skipped. Synthetic merge
79371a24has exactly the published tree. Complete logs, nested XML/HTML/protobuf/logcats and 10 digest/CRC-verified report archives were audited; the owner verified all 520 evidence hashes.Compose retains Core storage-initialization diagnostics in its Hilt fixture. General E2E retains RN backup-list/restore timeouts and a METADATA safety refusal; relevant source blocks match actual master, without a base runtime or historical-cause claim. Two multi-address tap-helper retries are distinct from whole-shard attempts. These passing suites do not establish full authenticated backup/recovery. Hosted evidence:
work/validation/hosted-android1686-43e-20261103/inspection/report.md. The older hosted and runtime results below remain tied to their original revisions. No new authenticated J1, funded-payment or terminal complete-WALLET acceptance is claimed. Evidence:work/validation/android1686-master44bf-final-diagnostics-20261103/inspection/report.md; publication and exact signature receipts:work/validation/android1686-master44bf-publication-20261103.Previous-head hosted results — November 2
All hosted jobs for
b6751e22956653c4f2a80112143880b47298e30dare now inspected: 4,825 unit tests pass, all six static reports have zero findings, and 39 distinct E2E cases eventually pass with one deliberately disabled Settings case. The exact synthetic checkout tree matches this published head; the external suite is611509ee.The LNURL/transfer shard first failed two activity-card assertions and then passed its one automatic shard retry. Both failed executions and the exit-1 annotation remain recorded. Failure screenshots show the expected values in the second card; the guest clock differs from the host/deposit dates on the failed attempt. This supports a clock/order investigation, but the cause is not established. No manual CI rerun occurred. Six skipped migration jobs are not passes.
Complete logs, structured results, nested runtime streams, checksum/CRC-verified archives and failure images were inspected; the owner verified all 438 terminal evidence payloads. Runtime backup, TLS, sync and cancellation diagnostics remain disclosed. The earlier
181d664crouting failure is not waived or claimed repaired. Existing authenticated acceptance and terminal complete-WALLET gaps below remain open. Evidence:work/validation/hosted-android1686-b675-20261102/E2E-TERMINAL-REPORT.md.Routing-test diagnostics — November 1
Signed
b6751e22956653c4f2a80112143880b47298e30dadds failure-only resolved-activity and component-state diagnostics to the existing routing test. Assertions, query flags, deadlines and production behavior are preserved. It reuses the exact independently reviewed diagnostic file already present in #1688. The earlier181d664chosted failure below remains unresolved; this is not a causal repair or a waived check.The final composed candidate passed required compile, 4,825 tests / 260 suites, formatting and static checks with zero findings. All 1,778 frozen files match. Complete logs, XML/HTML/SARIF and lint partial/model outputs were inspected: Detekt reports are fresh; aggregate Android lint reports were UP-TO-DATE, with fresh changed-unit analysis also inspected. Four retained CloseGuard events are not claimed fixed. The exact-file/API applicability review is clean. All 17 live-base commits verify locally and on GitHub. Current-head hosted results are recorded above. Evidence:
work/validation/android1686-manifest-diagnostics-20261101/final-diagnostic-audit/REPORT.md.Previous-head hosted unit failure — November 1, 23:24 UTC
CI run 41360316580 failed on exact head
181d664c38a40df156cad4388168b5ab2df132c4: 4,824 tests passed and one failed, with zero skips.PubkyAuthManifestTest.ktfailed its first default-disabled route assertion forpubkyring://signup?hs=homeserver; both structural manifest checks and the new allowance off/on regression passed. The failure output omits the resolved activity and component override, so the cause is unresolved. Source equality with the actual parent does not establish a pre-existing runtime failure or infrastructure cause.The completed lint job has zero findings across all six reports. Five complete logs, all unit HTML and both digest/CRC-verified report archives were inspected; 400 sealed payloads were independently hash-verified. Seven E2E shards were pending at this snapshot, and six migration jobs skipped. No unchanged CI rerun was made. The diagnostic descendant is being inspected before selecting a repair; this failed required check is not waived. Evidence:
work/validation/hosted-android1686-181d-20261101-current-a/REPORT.md.Allowance off/on recovery — November 1
Signed
181d664c38a40df156cad4388168b5ab2df132c4fixes the lost activation reported in 4437596764. Turning allowances off still revokes queued work immediately. A pause revision now makes the latest enabled contact/settings state distinct, so turning them back on during held admission schedules a fresh activation for the current identity. Old queued acceptance remains revoked; identity, deletion and live-settings checks are retained.The actual AppViewModel collector and real allowance repository reproduced the failure before the fix and pass afterward, using mocked SDK/executor resources. Final required compile, 4,825 tests /260 suites, formatting and six complete static reports passed with zero failures/skips/findings. All 1,778 frozen files match. Full logs and structured diagnostics were inspected; four CloseGuard warnings and earlier failed attempts remain recorded. Two independent concurrency reviews are clean. All 16 live-master range commits verify locally and on GitHub (
verified=true,reason=valid).The existing allowance-off journey now describes the Android delayed-contact-loading variant. The author has not run that held-boundary authenticated app case; component evidence is not a device pass. Current-head hosted CI is pending complete inspection. The separate terminal complete-WALLET finding remains open. Evidence:
work/validation/android1686-allowance-resume-final-audit/REPORT.mdandwork/validation/android1686-allowance-resume-publication/.Hosted validation update — November 1, 22:16 UTC
Exact-head hosted checks are complete: 4,824 unit tests pass, six static reports have zero findings, and 39 distinct end-to-end cases pass on their first executions.
settings_08remains an explicit disabled skip; skipped migration jobs are not passes. Complete logs and seven checksum/CRC-verified end-to-end archives were inspected. Nonfatal runtime diagnostics remain recorded, without a blanket clean-runtime claim. Terminal complete-WALLET availability and the existing authenticated acceptance gaps remain open.Setup-pending and activation follow-up — November 1
Signed
1b1426fbebeccd616c29311fa5bf652089727a84stops reconnect recovery from repeatedly fetching a deliberately absent profile while local profile setup is still pending. Once setup completes, missing restored profiles remain eligible for recovery. The actual repository regression failed before repair (six unexpected resolver calls over three reconnects) and passes afterward. This head also includes the reviewed shared allowance fixes: revocable contact/dispatcher activation and rejection before SDK backup mutation tracking.Final required compile, full unit tests, lint and formatting passed: 4,824 tests, zero failures/skips and six static reports with zero findings. All 1,778 frozen inputs match; the combined source review preserves the independent shared concurrency reviews. Complete diagnostics retain four structured CloseGuard warnings and all earlier test-fixture failures/sandbox refusal. The complete 15-commit actual-live-base range verifies locally and on GitHub. New-head hosted CI is pending; no new authenticated app acceptance is claimed. The existing terminal identity/complete-WALLET HIGH remains unresolved and is not repaired by these changes.
Description
Ordinary wallet restore finishes after required wallet and USDT recovery; optional Pubky activation and profile enrichment continue afterward. Scheduled setup preserves an identity already activated by metadata restoration, coalesces requests for the same revision, and rejects obsolete results.
The reconnect follow-up in
9ac9746aeretries a missing own profile from existing reconnect, resume and maintenance calls. It captures the original sign-in, returns without waiting for profile networking, avoids re-importing an active session, and reuses the existing profile-write and overlapping-load guards. A successful load stops these retries. The previous “Your Name” placeholder finding is addressed without adding contact refresh or payment work.Complete WALLET and METADATA capture still rejects unavailable or unreadable identity data instead of replacing a complete backup with an incomplete snapshot. Terminal identity failure can still leave newer ordinary WALLET state pending; the existing HIGH availability finding remains open.
Twin: synonymdev/bitkit-ios#1121. Parent #1685 and fee follow-up #1701 merged externally; this PR now targets
master.Out of Scope
journeys/allowances/allowances-turned-off.xml: clarify the delayed-contact variant's executable or manual steps in #1766, milestone 3.1.0. This nonblocking LOW review finding is deferred under the repository review policy. The existing seven actions do not exercise that extra boundary.Current validation — November 1
Head
78269d8a1cae1ac127be93a96fba16f7d68d1019, tree325f57e2e6f20211b805816026fa98463d1f74ad, targets live master1337f121cd2694718fb58e1c4f022d73104cccc1directly. The SDK admission callback still rejects work queued before allowance deactivation. Review 4433972339 additionally found ordinary contact sync replacing the activation for the same enabled identity. The correction now retains that activation; pause, deactivation and identity changes still invalidate it. Restore, spending and complete-backup guards are unchanged.Final required format, compile, full tests and lint pass: 4,817 tests / 260 suites, zero failures/errors/skips/retries and zero fresh Detekt/Android Lint findings. All 1,778 frozen inputs match. Complete logs, nested output, XML/HTML/SARIF were audited; the owner verified 1,651 final artifacts. Three JVM notices and four cursor-close warnings remain, with retained actual-base/earlier-run comparisons. Two shared independent concurrency reviews and one affected integration review are clean; source/test blobs exactly match the validated #1684 correction. Controlled manual and automatic regressions failed before the correction and passed afterward; these are component tests, not a new authenticated app pass.
All 14 live-base commits are locally and GitHub
true/valid. The first immediate post-push REST snapshot was stale; live-head reconciliation followed by one read-only verification completed successfully, without repeating the push. New-head hosted checks await complete inspection. Evidence:work/validation/android1686-stable-activation-20261101/andwork/validation/android1686-stable-activation-final-a/inspection/structured-report.json.The previous
7b795fdchosted CI, lint and E2E are fully audited on the exact published tree: 4,815 units, zero static findings, 39 first-attempt E2E passes and one disabled Settings case. Passing multi-address logs still contain native sync deadlines and WalletOperationTimeout; their causes remain unproven. All 462 sealed evidence files verified. These results certify only that prior head.Retained evidence and unresolved findings
The terminal complete-WALLET availability defect remains open. Review5724858087 reproduces it at predecessor
71fa609c; the complete report and unpushed Debug-control appendix were read. Its raw runtime artifacts were not independently audited and its controlled APK is not claimed identical to the published APK. No nil/stale SDK fallback, partial backup or closure is implied by this allowance repair.At predecessor
71fa609c, CI passed 4,814 units, lint had zero findings, and E2E reached 39 unique eventual passes with one disabled settings case. Multi-address attempt1 failed a 30-second ReceivedTransaction wait; attempt2 passed and skipped two already-completed cases. Complete logs, all attempts, artifacts and the exact synthetic tree were audited. The failure cause remains unproven; migration skips are not passes. Earlier missing-invoice, UI-timeout and setup failures retain their original receipts; no unchanged CI retry or causal-fix claim is made.Design / Preview
Existing layout is unchanged. The status correction uses the mapped
Back up wallet › Data Backupsscreen indocs/screens-map.md; an exact Figma frame URL is not recorded, so no node link is invented. Own-profile recovery still uses existing Home/profile presentation.QA Notes
Setup
For J3, use a separate unfunded dev wallet in a DevDebug build (
./gradlew assembleDevDebug, then install the matching ABI APK). Keep default staging VSS/Electrum available, English selected, and Debug developer mode enabled foradb shell am start -a android.intent.action.VIEW -d bitkit://screen/backup-settings to.bitkit.dev. Start at Home. A device-local recording captures the brief debounce interval when host command latency is too high; it changes no product timing. No reset, profile, seed access or funding is needed.Use the committed Debug dependencies: Core 0.8.0-rc11, Paykit 0.1.1-rc6, VSS 0.5.26-rc2, LDK 0.7.0-rc.79 and Breez Spark 0.28.0. These published pins come from master; they do not include the private #267/#36 corrections or LDK #132. J1 needs a disposable test-network wallet with a complete VSS backup and saved identity. Keep metadata credential replacement and required imports available; delay only final activation or optional enrichment after required recovery. Preserve existing wallets and use no live funds.
Manual Tests
1. With two disposable linked identities and a pending allowance proposal, hold the receiver immediately before SDK acceptance admission. Tap Accept, synchronize a changed contact-key set while the same identity remains enabled, then release: the proposal accepts once. Repeat after disabling/re-enabling before release: the old queued attempt must be rejected. The standard journey environment lacks the controlled SDK-admission hold; deterministic repository coverage passes, but a fresh author-run authenticated app execution remains unperformed. The earlier before-fix runtime reproduction is attributed to its reviewed revision.
2. Hold an allowance activation during contact synchronization, switch Enable Allowances off and back on, then release: the current enabled identity activates once without restarting the app; the old queued acceptance stays revoked. The author’s controlled collector/repository test passes, but the authenticated app timing gate is not available in the standard fixture.
3. In an isolated unfunded Spark fixture, hold the backup-required status write before persistence, then fail an identity change without replacing credentials. After admission reopens, the pending readiness or exit-generation signal must still upload once. Also fail one status write with a storage error and verify that a later generation uploads without restarting observation. This requires controlled status-persistence and owner-transition faults unavailable in the standard journey environment; the three component regressions pass, but an authenticated app execution of these boundaries has not been performed.
4. With an isolated unfunded Spark/VSS fixture, retain a backup-required generation during restore, then clear the restore gate or allow its real timeout to expire. Verify one acknowledged upload without another Spark event. Repeat with a second restore interrupting the debounce and verify the pending upload resumes once. Three local repository controls pass; the external reviewer reported the clear/expiry portions on
acdd99c3. The interrupted-debounce installed-app case still needs the controlled fixture; no author-run authenticated pass is claimed.Journeys
J3 — new
scheduled-backup-status.xml: change a local currency and observe queued Running, followed by Latest Backup after acknowledgement. Android-only scheduling regression; author-run result is recorded above.J2 —
allowances/allowances-turned-off.xml: the seven executable actions cover ordinary off/on behavior and restoration of the Active row. The description-only delayed contact-loading variant is not exercised by those actions; its executable/manual documentation is deferred to #1766. That held-boundary case remains unexecuted as an author-run app journey.J1 —
restore-during-identity-outage.xml: optional activation outage after required restore, complete-backup protection, screen exit, missing-profile reconnect, stale enrichment after identity removal/replacement, restart, and required-recovery failures retaining spending/address gates.The new reconnect action expects wallet maintenance to continue while a real profile read is held, then Home to recover after release without manual Profile navigation. The final candidate has repository-level tests with mocked native/service resources; this is not a complete authenticated app journey. The author's isolated complete-app fault controls for J1 are not yet provisioned.
External review 5688838161 reports real SDK/VSS cases and reproduces both findings at predecessor
89e2702e. Its raw nonvisual artifacts have not been independently verified by the author, and it is not acceptance evidence for the new head. The matching iOS reconnect follow-up is published in #1121 at0b77f099; its iOS evidence does not establish Android acceptance.Automated Checks
BackupRepoTest.kt— retains Spark readiness and generation notifications across owner retirement, and keeps observing after a status-write failure.LightningRepoTest.kt— supplies the existing coordinator to the upstream startup and cross-rail fixtures.Evidence and remaining gaps
PubkyRepoTestcovers the new retry and retained coalescing, cancellation, sign-in and profile-write guards. ExistingWalletViewModelTestandBackupRepoTestcover required recovery admission and complete-backup refusal. Full-suite success does not establish process-kill, authenticated service faults or funded payments.The terminal full-WALLET availability finding remains unresolved on both platforms. No stale SDK fallback, partial backup, independent-only writer, legacy migration activation, payment replay or authorization bypass is introduced. Full-wallet redesign and contact CAS are separate work, not substitutes for this PR's acceptance.
Models used
gpt-6-astra(reasoning:xhigh).gpt-6-astra(reasoning:xhigh).gpt-6-astra(reasoning:xhigh),gpt-6.1-sol(reasoning:high).Actual execution metadata and historical review scopes are retained.